Skip to content

Commit

Permalink
Infering test cases from a loaded zip file or an existing yml file, a…
Browse files Browse the repository at this point in the history
…lso able to hide test-cases from the problem statement respecting the judge order (pretest firsts).

fixes
  • Loading branch information
FherStk committed Aug 16, 2024
1 parent 8cebced commit 8fec955
Show file tree
Hide file tree
Showing 9 changed files with 396 additions and 15 deletions.
2 changes: 1 addition & 1 deletion judge/admin/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ class ProblemAdmin(NoBatchDeleteMixin, VersionAdmin):
'fields': (
'code', 'name', 'is_public', 'is_manually_managed', 'date', 'authors', 'curators', 'testers',
'organizations', 'submission_source_visibility_mode', 'is_full_markup',
'description', 'license',
'description', 'include_test_cases', 'license',
),
}),
(_('Social Media'), {'classes': ('collapse',), 'fields': ('og_image', 'summary')}),
Expand Down
85 changes: 85 additions & 0 deletions judge/migrations/0147_infer_test_cases_from_zip.py

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion judge/models/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
from django.db.models import CASCADE, Exists, F, FilteredRelation, OuterRef, Q, SET_NULL
Expand Down Expand Up @@ -120,6 +120,9 @@ class Problem(models.Model):
name = models.CharField(max_length=100, verbose_name=_('problem name'), db_index=True,
help_text=_('The full name of the problem, as shown in the problem list.'),
validators=[disallowed_characters_validator])
include_test_cases = models.BooleanField(verbose_name=_('include test cases'),
help_text=_('If true, the inputs and otuputs of every test case will '
'be automatically added after the body.'), default=False)
description = models.TextField(verbose_name=_('problem body'), validators=[disallowed_characters_validator])
authors = models.ManyToManyField(Profile, verbose_name=_('creators'), blank=True, related_name='authored_problems',
help_text=_('These users will be able to edit the problem, '
Expand Down Expand Up @@ -454,6 +457,7 @@ def markdown_style(self):

def save(self, *args, **kwargs):
super(Problem, self).save(*args, **kwargs)

if self.code != self.__original_code:
try:
problem_data = self.data_files
Expand All @@ -462,6 +466,13 @@ def save(self, *args, **kwargs):
else:
problem_data._update_code(self.__original_code, self.code)

if self.include_test_cases:
try:
self.data_files.setup_test_cases_content()
self.data_files.save()
except ObjectDoesNotExist:
pass

save.alters_data = True

def is_solved_by(self, user):
Expand Down
206 changes: 204 additions & 2 deletions judge/models/problem_data.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import errno
import os
from zipfile import ZipFile

import yaml
from django.db import models
from django.utils.translation import gettext_lazy as _

from judge.utils.problem_data import ProblemDataStorage


__all__ = ['problem_data_storage', 'problem_directory_file', 'ProblemData', 'ProblemTestCase', 'CHECKERS']

problem_data_storage = ProblemDataStorage()
Expand Down Expand Up @@ -38,6 +41,8 @@ class ProblemData(models.Model):
upload_to=problem_directory_file)
generator = models.FileField(verbose_name=_('generator file'), storage=problem_data_storage, null=True, blank=True,
upload_to=problem_directory_file)
infer_from_zip = models.BooleanField(verbose_name=_('infer test cases from zip'), null=True, blank=True)
test_cases_content = models.TextField(verbose_name=_('test cases content'), blank=True)
output_prefix = models.IntegerField(verbose_name=_('output prefix length'), blank=True, null=True)
output_limit = models.IntegerField(verbose_name=_('output limit length'), blank=True, null=True)
feedback = models.TextField(verbose_name=_('init.yml generation feedback'), blank=True)
Expand All @@ -53,9 +58,22 @@ def __init__(self, *args, **kwargs):
super(ProblemData, self).__init__(*args, **kwargs)
self.__original_zipfile = self.zipfile

if not self.zipfile:
# Test cases not loaded through the site, but some data has been found within the problem folder
if self.has_yml():
self.feedback = 'Warning: problem data found within the file system, but none has been setup '\
'using this site. No actions are needed if the problem is working as intended; '\
"otherwise, you can <a id='perform_infer_test_cases' href='javascript:void(0);'>"\
'infer the testcases using the existing zip file (one entry per file within the '\
"zip)</a> or <a id='perform_rebuild_test_cases' href='javascript:void(0);'>"\
'rebuild the test cases using the existing yml file as a template (only works'\
'with simple problems)</a>.'

def save(self, *args, **kwargs):
zipfile = self.zipfile
if self.zipfile != self.__original_zipfile:
self.__original_zipfile.delete(save=False)
self.__original_zipfile.delete(save=False) # This clears both zip fields (original and current)
self.zipfile = zipfile # Needed to restore the newly uploaded zip file when replacing an old one
return super(ProblemData, self).save(*args, **kwargs)

def has_yml(self):
Expand All @@ -74,6 +92,189 @@ def _update_code(self, original, new):
self.save()
_update_code.alters_data = True

def setup_test_cases_content(self):
self.test_cases_content = ''

if self.zipfile:
zip = ZipFile(self.zipfile)

last = 0
content = []
test_cases = ProblemTestCase.objects.filter(dataset_id=self.problem.pk)

for i, tc in enumerate([x for x in test_cases if x.is_pretest]):
self.append_tescase_to_statement(zip, content, tc, i)
last = i

if last > 0:
last += 1

for i, tc in enumerate([x for x in test_cases if not x.is_pretest]):
self.append_tescase_to_statement(zip, content, tc, i + last)

self.test_cases_content = '\n'.join(content)

def append_tescase_to_statement(self, zip, content, tc, i):
content.append(f'## Sample Input {i+1}')
content.append('')

if tc.is_private:
content.append('*Hidden: this is a private test case!* ')

else:
content.append('```')
content.append(zip.read(tc.input_file).decode('utf-8'))
content.append('```')

content.append('')
content.append(f'## Sample Output {i+1}')
content.append('')

if tc.is_private:
content.append('*Hidden: this is a private test case!* ')

else:
content.append('```')
content.append(zip.read(tc.output_file).decode('utf-8'))
content.append('```')

content.append('')

def infer_test_cases_from_zip(self):
# Just infers the zip data into ProblemTestCase objects, without changes in the database.
# It will try to mantain existing test cases data if the input and output entries are the same.
if not self.zipfile:
# The zip file will be loaded from the file system if not provided
files = problem_data_storage.listdir(self.problem.code)[1]
zipfiles = [x for x in files if '.zip' in x]

if len(zipfiles) > 0:
self.zipfile = _problem_directory_file(self.problem.code, zipfiles[0])
else:
raise FileNotFoundError

files = sorted(ZipFile(self.zipfile).namelist())
input = [x for x in files if '.in' in x or ('input' in x and '.' in x)]
output = [x for x in files if '.out' in x or ('output' in x and '.' in x)]

cases = []
for i in range(len(input)):
list = ProblemTestCase.objects.filter(dataset_id=self.problem.pk, input_file=input[i],
output_file=output[i])
if len(list) >= 1:
# Multiple test-cases for the same data is allowed, but strange. Using object.get() produces an
# exception.
ptc = list[0]
else:
ptc = ProblemTestCase()
ptc.dataset = self.problem
ptc.is_pretest = False
ptc.is_private = False
ptc.order = i
ptc.input_file = input[i]
ptc.output_file = output[i]
ptc.points = 0

cases.append(ptc)

return cases

def reload_test_cases_from_yml(self):
cases = []
if self.has_yml():
yml = problem_data_storage.open('%s/init.yml' % self.problem.code)
doc = yaml.safe_load(yml)

# Load same YML data as in site/judge/utils/problem_data.py -> ProblemDataCompiler()
if doc.get('archive'):
self.zipfile = _problem_directory_file(self.problem.code, doc['archive'])

if doc.get('generator'):
self.generator = _problem_directory_file(self.problem.code, doc['generator'])

if doc.get('pretest_test_cases'):
self.pretest_test_cases = doc['pretest_test_cases']

if doc.get('output_limit_length'):
self.output_limit = doc['output_limit_length']

if doc.get('output_prefix_length'):
self.output_prefix = doc['output_prefix_length']

if doc.get('unicode'):
self.unicode = doc['unicode']

if doc.get('nobigmath'):
self.nobigmath = doc['nobigmath']

if doc.get('checker'):
self.checker = doc['checker']

if doc.get('hints'):
for h in doc['hints']:
if h == 'unicode':
self.unicode = True
if h == 'nobigmath':
self.nobigmath = True

if doc.get('pretest_test_cases'):
cases += self._load_test_case_from_doc(doc, 'pretest_test_cases', True)

if doc.get('test_cases'):
cases += self._load_test_case_from_doc(doc, 'test_cases', False)

return cases

def _load_test_case_from_doc(self, doc, field, is_pretest):
cases = []
for i, test in enumerate(doc[field]):
ptc = ProblemTestCase()
ptc.dataset = self.problem
ptc.is_pretest = is_pretest
ptc.order = i

if test.get('type'):
ptc.type = test['type']

if test.get('in'):
ptc.input_file = test['in']

if test.get('out'):
ptc.output_file = test['out']

if test.get('points'):
ptc.points = test['points']
else:
ptc.points = 0

if test.get('is_private'):
ptc.is_private = test['is_private']

if test.get('generator_args'):
args = []
for arg in test['generator_args']:
args.append(arg)

ptc.generator_args = '\n'.join(args)

if test.get('output_prefix_length'):
ptc.output_prefix = doc['output_prefix_length']

if test.get('output_limit_length'):
ptc.output_limit = doc['output_limit_length']

if test.get('checker'):
chk = test['checker']
if isinstance(chk, str):
ptc.checker = chk
else:
ptc.checker = chk['name']
ptc.checker_args = chk['args']

cases.append(ptc)

return cases


class ProblemTestCase(models.Model):
dataset = models.ForeignKey('Problem', verbose_name=_('problem data set'), related_name='cases',
Expand All @@ -88,7 +289,8 @@ class ProblemTestCase(models.Model):
output_file = models.CharField(max_length=100, verbose_name=_('output file name'), blank=True)
generator_args = models.TextField(verbose_name=_('generator arguments'), blank=True)
points = models.IntegerField(verbose_name=_('point value'), blank=True, null=True)
is_pretest = models.BooleanField(verbose_name=_('case is pretest?'))
is_pretest = models.BooleanField(verbose_name=_('case is pretest?'), default=False)
is_private = models.BooleanField(verbose_name=_('case is private?'), default=False)
output_prefix = models.IntegerField(verbose_name=_('output prefix length'), blank=True, null=True)
output_limit = models.IntegerField(verbose_name=_('output limit length'), blank=True, null=True)
checker = models.CharField(max_length=10, verbose_name=_('checker'), choices=CHECKERS, blank=True)
Expand Down
6 changes: 5 additions & 1 deletion judge/utils/problem_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,12 @@ def make_checker(case):
if batch:
case.points = None
case.is_pretest = batch['is_pretest']
case.is_private = batch['is_private']
else:
if case.points is None:
raise ProblemDataError(_('Points must be defined for non-batch case #%d.') % i)
data['is_pretest'] = case.is_pretest
data['is_private'] = case.is_private

if not self.generator:
if case.input_file not in self.files:
Expand All @@ -107,7 +109,7 @@ def make_checker(case):
data['checker'] = make_checker(case)
else:
case.checker_args = ''
case.save(update_fields=('checker_args', 'is_pretest'))
case.save(update_fields=('checker_args', 'is_pretest', 'is_private'))
(batch['batched'] if batch else cases).append(data)
elif case.type == 'S':
batch_count += 1
Expand All @@ -134,6 +136,7 @@ def make_checker(case):
'points': case.points,
'batched': [],
'is_pretest': case.is_pretest,
'is_private': case.is_private,
'dependencies': dependencies,
}
if case.generator_args:
Expand All @@ -153,6 +156,7 @@ def make_checker(case):
if not batch:
raise ProblemDataError(_('Attempt to end batch outside of one in case #%d.') % i)
case.is_pretest = batch['is_pretest']
case.is_private = batch['is_private']
case.input_file = ''
case.output_file = ''
case.generator_args = ''
Expand Down
8 changes: 8 additions & 0 deletions judge/views/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ def get_context_data(self, **kwargs):
context['og_image'] = self.object.og_image or metadata[1]
context['enable_comments'] = settings.DMOJ_ENABLE_COMMENTS

if self.object.include_test_cases:
try:
context['test_cases'] = self.object.data_files.test_cases_content
except ObjectDoesNotExist:
context['test_cases'] = ''
else:
context['test_cases'] = ''

context['vote_perm'] = self.object.vote_permission_for_user(user)
if context['vote_perm'].can_vote():
try:
Expand Down
Loading

0 comments on commit 8fec955

Please sign in to comment.