From 8c77822ff6b9c3953ccb35ee798469ce6b3dad9a Mon Sep 17 00:00:00 2001 From: MZuenni Date: Wed, 26 Feb 2025 12:57:52 +0100 Subject: [PATCH 01/23] Implement constants (#431) * implemented constants * expose constants in more latex files * rewrote zip export * small changes * remove print * fix typos * substitute in testdata.yaml * add test problem * add test * fix submission dir * update tests * add tests * fix? * fix * allow substitution in generator commands * update file name regex * fix * restric problem name * mock constants * small changes * fix --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- bin/config.py | 13 +- bin/export.py | 199 +++++++++--------- bin/generate.py | 14 +- bin/latex.py | 17 +- bin/problem.py | 23 +- bin/program.py | 28 ++- bin/skel.py | 4 +- bin/util.py | 84 ++++++-- bin/validate.py | 1 + latex/bapc.cls | 12 ++ latex/contest-problem-slide.tex | 1 + latex/contest-problem.tex | 1 + latex/contest-solution.tex | 1 + latex/problem-slide.tex | 1 + latex/problem-slides-base.tex | 8 + latex/problem.tex | 1 + latex/solution-web.tex | 1 + latex/solution.tex | 1 + latex/solutions-base.tex | 8 + test/problems/constants/.gitignore | 3 + test/problems/constants/generators/example.py | 6 + .../constants/generators/generators.yaml | 27 +++ .../input_validator/input_validator.cpp | 7 + .../input_validator/validation.h | 1 + .../constants/input_validators/validate.ctd | 2 + .../output_validator/output_validator.cpp | 12 ++ .../output_validator/validation.h | 1 + test/problems/constants/problem.yaml | 18 ++ .../problem_statement/problem-slide.en.tex | 7 + .../problem_statement/problem.en.tex | 14 ++ .../problem_statement/solution.en.tex | 4 + .../submissions/accepted/submission.py | 2 + .../wrong_answer/constant_in_submission.py | 2 + test/problems/solve_stats/activity/B.pdf | Bin 0 -> 5939 bytes test/test_generators_yaml.py | 6 + test/test_problems.py | 38 +++- 36 files changed, 429 insertions(+), 139 deletions(-) create mode 100644 test/problems/constants/.gitignore create mode 100644 test/problems/constants/generators/example.py create mode 100644 test/problems/constants/generators/generators.yaml create mode 100644 test/problems/constants/input_validators/input_validator/input_validator.cpp create mode 120000 test/problems/constants/input_validators/input_validator/validation.h create mode 100644 test/problems/constants/input_validators/validate.ctd create mode 100644 test/problems/constants/output_validators/output_validator/output_validator.cpp create mode 120000 test/problems/constants/output_validators/output_validator/validation.h create mode 100644 test/problems/constants/problem.yaml create mode 100644 test/problems/constants/problem_statement/problem-slide.en.tex create mode 100644 test/problems/constants/problem_statement/problem.en.tex create mode 100644 test/problems/constants/problem_statement/solution.en.tex create mode 100644 test/problems/constants/submissions/accepted/submission.py create mode 100644 test/problems/constants/submissions/wrong_answer/constant_in_submission.py create mode 100644 test/problems/solve_stats/activity/B.pdf diff --git a/bin/config.py b/bin/config.py index ab1ce2347..40aad5f46 100644 --- a/bin/config.py +++ b/bin/config.py @@ -32,9 +32,20 @@ # When --table is set, this threshold determines the number of identical profiles needed to get flagged. TABLE_THRESHOLD: Final[int] = 4 -FILE_NAME_REGEX: Final[str] = "[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9]" +FILE_NAME_REGEX: Final[str] = "[a-zA-Z0-9][a-zA-Z0-9_.-]{0,253}[a-zA-Z0-9]" COMPILED_FILE_NAME_REGEX: Final[re.Pattern[str]] = re.compile(FILE_NAME_REGEX) +CONSTANT_NAME_REGEX = "[a-zA-Z_][a-zA-Z0-9_]*" +COMPILED_CONSTANT_NAME_REGEX: Final[re.Pattern[str]] = re.compile(CONSTANT_NAME_REGEX) +CONSTANT_SUBSTITUTE_REGEX: Final[re.Pattern[str]] = re.compile( + f"\\{{\\{{({CONSTANT_NAME_REGEX})\\}}\\}}" +) + +BAPCTOOLS_SUBSTITUTE_REGEX: Final[re.Pattern[str]] = re.compile( + f"\\{{%({CONSTANT_NAME_REGEX})%\\}}" +) + + KNOWN_TESTCASE_EXTENSIONS: Final[Sequence[str]] = [ ".in", ".ans", diff --git a/bin/export.py b/bin/export.py index 433acaf7a..61de017d0 100644 --- a/bin/export.py +++ b/bin/export.py @@ -12,31 +12,6 @@ from problem import Problem -# Replace \problemname{...} by the value of `name:` in problems.yaml in all .tex files. -# This is needed because Kattis is currently still running the legacy version of the problem spec, -# rather than 2023-07-draft. -def fix_problem_name_cmd(problem): - reverts = [] - for f in (problem.path / "problem_statement").iterdir(): - if f.is_file() and f.suffix == ".tex" and len(f.suffixes) >= 2: - lang = f.suffixes[-2][1:] - t = f.read_text() - match = re.search(r"\\problemname\{\s*(\\problemyamlname)?\s*\}", t) - if match: - if lang in problem.settings.name: - reverts.append((f, t)) - t = t.replace(match[0], r"\problemname{" + problem.settings.name[lang] + "}") - f.write_text(t) - else: - util.error(f"{f}: no name set for language {lang}.") - - def revert(): - for f, t in reverts: - f.write_text(t) - - return revert - - def force_single_language(problems): if config.args.languages and len(config.args.languages) == 1: statement_language = config.args.languages[0] @@ -115,18 +90,13 @@ def build_samples_zip(problems, output, statement_language): def build_problem_zip(problem: Problem, output: Path): - """Make DOMjudge ZIP file for specified problem.""" + """Make DOMjudge/Kattis ZIP file for specified problem.""" # Add problem PDF for only one language to the zip file (note that Kattis export does not include PDF) statement_language = None if config.args.kattis else force_single_language([problem]) - deprecated = [ # may be removed at some point. - "domjudge-problem.ini", - ] - - write_file_strs: list[tuple[str, str]] = [] - files = [ + ("problem.yaml", True), ("problem_statement/*", True), ("submissions/accepted/**/*", True), ("submissions/*/**/*", False), @@ -156,56 +126,21 @@ def build_problem_zip(problem: Problem, output: Path): if config.args.kattis: files.append(("input_validators/**/*", True)) - print("Preparing to make ZIP file for problem dir %s" % problem.path, file=sys.stderr) - - # DOMjudge does not support 'type' in problem.yaml nor 'output_validator_args' in testdata.yaml yet. - # TODO: Remove this once it does. - problem_yaml_str = (problem.path / "problem.yaml").read_text() - if not config.args.kattis and not problem.settings.is_legacy(): - validator_flags = " ".join( - problem.get_testdata_yaml( - problem.path / "data", - "output_validator_args", - PrintBar("Getting validator_flags for legacy DOMjudge export"), - ) - ) - if validator_flags: - validator_flags = "validator_flags: " + validator_flags + "\n" - write_file_strs.append( - ( - "problem.yaml", - f"""{problem_yaml_str}\nvalidation: { - "custom interactive" - if problem.interactive - else "custom multi-pass" - if problem.multi_pass - else "custom" - if problem.custom_output - else "default" - }\n{validator_flags}""", - ) - ) - else: - write_file_strs.append(("problem.yaml", problem_yaml_str)) - - # DOMjudge does not support 'limits.time_limit' in problem.yaml yet. - # TODO: Remove this once it does. - if not config.args.kattis: - write_file_strs.append((".timelimit", str(problem.limits.time_limit))) + message("preparing zip file content", "Zip", problem.path, color_type=MessageType.LOG) - # Warn for all deprecated files but still add them to the files list - for pattern in deprecated: - files.append((pattern, False)) - # Only include hidden files if the pattern starts with a '.'. - paths = list(util.glob(problem.path, pattern, include_hidden=pattern[0] == ".")) - if len(paths) > 0: - addition = "" - if len(paths) > 1: - addition = f" and {len(paths) - 1} more" - util.warn(f'Found deprecated file "{paths[0]}"{addition}.') + # prepare files inside dir + export_dir = problem.tmpdir / "export" + if export_dir.exists(): + shutil.rmtree(export_dir) + # For Kattis, prepend the problem shortname to all files. + if config.args.kattis: + export_dir /= problem.name + export_dir.mkdir(parents=True, exist_ok=True) - # Build list of files to store in ZIP file. - copyfiles = set() + def add_file(path, source): + path = export_dir / path + path.parent.mkdir(parents=True, exist_ok=True) + ensure_symlink(path, source) # Include all files beside testcases for pattern, required in files: @@ -214,14 +149,10 @@ def build_problem_zip(problem: Problem, output: Path): if required and len(paths) == 0: util.error(f"No matches for required path {pattern}.") for f in paths: - # NOTE: Directories are skipped because ZIP only supports files. if f.is_file(): out = f.relative_to(problem.path) out = remove_language_suffix(out, statement_language) - # For Kattis, prepend the problem shortname to all files. - if config.args.kattis: - out = problem.name / out - copyfiles.add((f, out)) + add_file(out, f) # Include all testcases (specified by a .in file) and copy all related files for pattern, required in testcases: @@ -229,7 +160,6 @@ def build_problem_zip(problem: Problem, output: Path): if required and len(paths) == 0: util.error(f"No matches for required path {pattern}.") for f in paths: - # NOTE: Directories are skipped because ZIP only supports files. if f.is_file(): if not f.with_suffix(".ans").is_file(): util.warn(f"No answer file found for {f}, skipping.") @@ -238,31 +168,98 @@ def build_problem_zip(problem: Problem, output: Path): f2 = f.with_suffix(ext) if f2.is_file(): out = f2.relative_to(problem.path) - # For Kattis, prepend the problem shortname to all files. - if config.args.kattis: - out = problem.name / out - copyfiles.add((f2, out)) + add_file(out, f2) - # Build .ZIP file. - print("writing ZIP file:", output, file=sys.stderr) + # DOMjudge does not support 'type' in problem.yaml nor 'output_validator_args' in testdata.yaml yet. + # TODO: Remove this once it does. + if not config.args.kattis and not problem.settings.is_legacy(): + yaml_path = export_dir / "problem.yaml" + yaml_data = [yaml_path.read_text(), "\nvalidation:"] + if problem.custom_output: + yaml_data.append(" custom") + if problem.interactive: + yaml_data.append(" interactive") + if problem.multi_pass: + yaml_data.append(" multi-pass") + else: + yaml_data.append(" default") + yaml_data.append("\n") + + validator_flags = " ".join( + problem.get_testdata_yaml( + problem.path / "data", + "output_validator_args", + PrintBar("Getting validator_flags for legacy DOMjudge export"), + ) + ) + if validator_flags: + yaml_data.append(f"validator_flags: {validator_flags}\n") + + yaml_path.unlink() + yaml_path.write_text("".join(yaml_data)) + + # DOMjudge does not support 'limits.time_limit' in problem.yaml yet. + # TODO: Remove this once it does. + if not config.args.kattis: + (export_dir / ".timelimit").write_text(str(problem.limits.time_limit)) - revert_problem_name_cmd = fix_problem_name_cmd(problem) + # Replace \problemname{...} by the value of `name:` in problems.yaml in all .tex files. + # This is needed because Kattis is currently still running the legacy version of the problem spec, + # rather than 2023-07-draft. + for f in (export_dir / "problem_statement").iterdir(): + if f.is_file() and f.suffix == ".tex" and len(f.suffixes) >= 2: + lang = f.suffixes[-2][1:] + t = f.read_text() + match = re.search(r"\\problemname\{\s*(\\problemyamlname)?\s*\}", t) + if match: + if lang in problem.settings.name: + t = t.replace(match[0], r"\problemname{" + problem.settings.name[lang] + "}") + f.unlink() + f.write_text(t) + else: + util.error(f"{f}: no name set for language {lang}.") + + # DOMjudge does not support constants. + # TODO: Remove this if it ever does. + if problem.settings.constants: + constants_supported = [ + "data/**/testdata.yaml", + "output_validators/**/*", + "input_validators/**/*", + # "problem_statement/*", uses \constants + # "submissions/*/**/*", removed support? + ] + for pattern in constants_supported: + for f in export_dir.glob(pattern): + if f.is_file() and util.has_substitute(f, config.CONSTANT_SUBSTITUTE_REGEX): + text = f.read_text() + text = util.substitute( + text, + problem.settings.constants, + pattern=config.CONSTANT_SUBSTITUTE_REGEX, + bar=util.PrintBar("Zip"), + ) + f.unlink() + f.write_text(text) + # Build .ZIP file. + message("writing zip file", "Zip", output, color_type=MessageType.LOG) try: zf = zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_DEFLATED, allowZip64=False) - for source, target in sorted(copyfiles): - zf.write(source, target, compress_type=zipfile.ZIP_DEFLATED) - for target_file, content in sorted(write_file_strs): - zf.writestr(target_file, content, compress_type=zipfile.ZIP_DEFLATED) + export_dir = problem.tmpdir / "export" + for f in sorted(export_dir.rglob("*")): + # NOTE: Directories are skipped because ZIP only supports files. + if f.is_file(): + name = f.relative_to(export_dir) + zf.write(f, name, compress_type=zipfile.ZIP_DEFLATED) # Done. zf.close() - print("done", file=sys.stderr) + message("done", "Zip", color_type=MessageType.LOG) print(file=sys.stderr) - - finally: - revert_problem_name_cmd() + except Exception: + return False return True diff --git a/bin/generate.py b/bin/generate.py index 06dd81d00..a472c1d69 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -268,7 +268,7 @@ def default_solution_path(generator_config): if config.args.default_solution: if generator_config.has_yaml: message( - f"""--default-solution Ignored. Set the default solution in the generator.yaml! + f"""--default-solution Ignored. Set the default solution in the generators.yaml! solution: /{config.args.default_solution}""", "generators.yaml", color_type=MessageType.WARN, @@ -297,7 +297,7 @@ def default_solution_path(generator_config): raw = f"solution: /{solution.relative_to(problem.path)}\n" + raw yaml_path.write_text(raw) message( - f"No solution specified. {solution_short_path} added as default solution in the generator.yaml", + f"No solution specified. {solution_short_path} added as default solution in the generators.yaml", "generators.yaml", color_type=MessageType.LOG, ) @@ -522,8 +522,15 @@ def __init__(self, problem, generator_config, key, name: str, yaml, parent, coun if len(yaml["generate"]) == 0: raise ParseException("`generate` must not be empty.") - # replace count + # first replace {{constants}} command_string = yaml["generate"] + command_string = substitute( + command_string, + problem.settings.constants, + pattern=config.CONSTANT_SUBSTITUTE_REGEX, + ) + + # then replace {count} and {seed} if "{count}" in command_string: if "count" in yaml: command_string = command_string.replace( @@ -907,6 +914,7 @@ def generate_from_rule(): bar.error(f"Hardcoded {ext} data must not be empty!") return False else: + # substitute in contents? -> No! infile.with_suffix(ext).write_text(contents) # Step 4: Error if infile was not generated. diff --git a/bin/latex.py b/bin/latex.py index ab3539571..637b64235 100644 --- a/bin/latex.py +++ b/bin/latex.py @@ -164,9 +164,20 @@ def flush(): samples_file_path.write_text("".join(samples_data)) +def create_constants_file(problem: "problem.Problem", language: str) -> None: + constant_data: list[str] = [] + for key, item in problem.settings.constants.items(): + constant_data.append(f"\\expandafter\\def\\csname constants_{key}\\endcsname{{{item}}}\n") + + builddir = latex_builddir(problem, language) + constants_file_path = builddir / "constants.tex" + constants_file_path.write_text("".join(constant_data)) + + # Steps needed for both problem and contest compilation. def prepare_problem(problem: "problem.Problem", language: str): create_samples_file(problem, language) + create_constants_file(problem, language) def get_tl(problem: "problem.Problem"): @@ -350,7 +361,7 @@ def run_latexmk(stdout, stderr): # 1. Copy the latex/problem.tex file to tmpdir//latex//problem.tex, # substituting variables. -# 2. Create tmpdir//latex//samples.tex. +# 2. Create tmpdir//latex//{samples,constants}.tex. # 3. Run latexmk and link the resulting ..pdf into the problem directory. def build_problem_pdf( problem: "problem.Problem", language: str, build_type=PdfType.PROBLEM, web=False @@ -374,6 +385,7 @@ def build_problem_pdf( local_data if local_data.is_file() else config.TOOLS_ROOT / "latex" / main_file, builddir / main_file, problem_data(problem, language), + bar=bar, ) return build_latex_pdf(builddir, builddir / main_file, language, bar, problem.path) @@ -465,6 +477,7 @@ def build_contest_pdf( ), builddir / "contest_data.tex", config_data, + bar=bar, ) problems_data = "" @@ -489,6 +502,7 @@ def build_contest_pdf( if build_type == PdfType.PROBLEM: prepare_problem(prob, language) else: # i.e. for SOLUTION and PROBLEM_SLIDE + create_constants_file(prob, language) tex_no_lang = prob.path / "problem_statement" / f"{build_type.value}.tex" tex_with_lang = prob.path / "problem_statement" / f"{build_type.value}.{language}.tex" if tex_with_lang.is_file(): @@ -507,6 +521,7 @@ def build_contest_pdf( problems_data += substitute( per_problem_data_tex, problem_data(prob, language), + bar=bar, ) if solutions: diff --git a/bin/problem.py b/bin/problem.py index bd4a453a3..fe96c4ad2 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -343,8 +343,16 @@ def __init__( self.keywords: str = parse_setting(yaml_data, "keywords", "") # Not implemented in BAPCtools. We always test all languges in langauges.yaml. self.languages: list[str] = parse_optional_list_setting(yaml_data, "languages", str) - # Not yet implemented, pending https://github.com/Kattis/problem-package-format/issues/344 - self.constants: dict[str, Any] = parse_setting(yaml_data, "constants", {}) + + constants: dict[str, Any] = parse_setting(yaml_data, "constants", {}) + self.constants: dict[str, str] = {} + for key, value in constants.items(): + if not isinstance(key, str) or not config.COMPILED_CONSTANT_NAME_REGEX.fullmatch(key): + warn(f"invalid constant name: {key}. SKIPPED.") + elif not isinstance(value, (str, int, float)): + warn(f"invalid constant type for: {key}. SKIPPED.") + else: + self.constants[key] = str(value) # BAPCtools extensions: self.verified: Optional[str] = parse_optional_setting(yaml_data, "verified", str) @@ -372,7 +380,7 @@ def is_legacy(self): # A problem. class Problem: - _SHORTNAME_REGEX_STRING: Final[str] = "^[a-z0-9]+$" + _SHORTNAME_REGEX_STRING: Final[str] = "[a-z0-9]{2,255}" _SHORTNAME_REGEX: Final[re.Pattern[str]] = re.compile(_SHORTNAME_REGEX_STRING) def __init__(self, path: Path, tmpdir: Path, label: Optional[str] = None): @@ -406,7 +414,7 @@ def __init__(self, path: Path, tmpdir: Path, label: Optional[str] = None): # TODO: transform this into nice warnings assert path.is_dir() - if not Problem._SHORTNAME_REGEX.match(self.name): + if not Problem._SHORTNAME_REGEX.fullmatch(self.name): warn( f"Problem has a bad shortname: {self.name} does not match {self._SHORTNAME_REGEX_STRING}" ) @@ -504,7 +512,12 @@ def _parse_testdata_yaml(p, path, bar): continue with p._testdata_lock: if f not in p._testdata_yamls: - p._testdata_yamls[f] = flags = read_yaml(f, plain=True) + raw = substitute( + f.read_text(), + p.settings.constants, + pattern=config.CONSTANT_SUBSTITUTE_REGEX, + ) + p._testdata_yamls[f] = flags = parse_yaml(raw, path=f, plain=True) if p.settings.is_legacy(): # For legacy problems, support both _flags and _args, but move to _args. diff --git a/bin/program.py b/bin/program.py index 2dc633ed7..12d32a9c0 100644 --- a/bin/program.py +++ b/bin/program.py @@ -91,7 +91,7 @@ def sanitizer(): # Member variables are: # - short_path: the path relative to problem/subdir/, or None # - tmpdir: the build directory in tmpfs. This is only created when build() is called. -# - input_files: list of source files linked into tmpdir +# - input_files: list of source files linked/copied into tmpdir # - language: the detected language # - env: the environment variables used for compile/run command substitution # - hash: a hash of all of the program including all source files @@ -116,8 +116,9 @@ def __init__( subdir: str, deps: Optional[list[Path]] = None, *, - skip_double_build_warning=False, + skip_double_build_warning: bool = False, limits: dict[str, int] = {}, + substitute_constants: bool = False, ): if deps is not None: assert isinstance(self, Generator) @@ -157,6 +158,7 @@ def __init__( self.hash: Optional[str] = None self.env: dict[str, int | str | Path] = {} self.limits: dict[str, int] = limits + self.substitute_constants: bool = substitute_constants self.ok = True self.built = False @@ -438,13 +440,27 @@ def build(self, bar: ProgressBar): self.input_files = [] hashes = [] for f in self.source_files: - ensure_symlink(self.tmpdir / f.name, f) - self.input_files.append(self.tmpdir / f.name) if not f.is_file(): self.ok = False bar.error(f"{str(f)} is not a file") return False - hashes.append(hash_file(f)) + tmpf = self.tmpdir / f.name + if ( + not self.substitute_constants + or not self.problem.settings.constants + or not has_substitute(f, config.CONSTANT_SUBSTITUTE_REGEX) + ): + ensure_symlink(tmpf, f) + else: + copy_and_substitute( + f, + tmpf, + self.problem.settings.constants, + pattern=config.CONSTANT_SUBSTITUTE_REGEX, + bar=bar, + ) + self.input_files.append(tmpf) + hashes.append(hash_file(tmpf)) self.hash = combine_hashes(hashes) if not self._get_language(bar): @@ -520,6 +536,7 @@ def __init__(self, problem: "Problem", path: Path, **kwargs): path, "generators", limits={"timeout": problem.limits.generator_time}, + substitute_constants=True, **kwargs, ) @@ -583,6 +600,7 @@ def __init__(self, problem: "Problem", path: Path, **kwargs): path, "visualizers", limits={"timeout": problem.limits.visualizer_time}, + substitute_constants=True, **kwargs, ) diff --git a/bin/skel.py b/bin/skel.py index 316302b68..35fa57bf7 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -86,9 +86,9 @@ def _ask_variable_choice(name, choices, default=None): # [a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9] def _alpha_num(string): s = re.sub(r"[^a-zA-Z0-9_.-]", "", string.lower().replace(" ", "").replace("-", "")) - while s.startswith("_.-"): + while len(s) and s[0] in "_.-": s = s[1:] - while s.endswith("_.-"): + while len(s) and s[-1] in "_.-": s = s[:-1] return s diff --git a/bin/util.py b/bin/util.py index 06e146b4d..c658adc00 100644 --- a/bin/util.py +++ b/bin/util.py @@ -4,6 +4,7 @@ import errno import hashlib import os +import re import secrets import shutil import signal @@ -13,8 +14,7 @@ import threading import time from enum import Enum -from collections.abc import Sequence -from collections.abc import Callable +from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import ( Any, @@ -168,7 +168,7 @@ def message( # A simple bar that only holds a task prefix class PrintBar: - def __init__(self, task: str | Path): + def __init__(self, task: Optional[str | Path] = None): self.task = task def log(self, msg: Any, item: Optional[ITEM_TYPE] = None) -> None: @@ -584,6 +584,9 @@ def finalize( return self.global_logged and not suppress_newline +BAR_TYPE = PrintBar | ProgressBar + + # Given a command line argument, return the first match: # - absolute # - relative to the 'type' directory for the current problem @@ -884,37 +887,78 @@ def ensure_symlink(link: Path, target: Path, output: bool = False, relative: boo link.symlink_to(target.resolve(), target.is_dir()) -def substitute(data: str, variables: Optional[dict[str, Optional[str]]]) -> str: +def has_substitute( + inpath: Path, pattern: re.Pattern[str] = config.BAPCTOOLS_SUBSTITUTE_REGEX +) -> bool: + try: + data = inpath.read_text() + except UnicodeDecodeError: + return False + return pattern.search(data) is not None + + +def substitute( + data: str, + variables: Optional[Mapping[str, Optional[str]]], + *, + pattern: re.Pattern[str] = config.BAPCTOOLS_SUBSTITUTE_REGEX, + bar: BAR_TYPE = PrintBar(), +) -> str: if variables is None: - return data - for key, value in variables.items(): - data = data.replace("{%" + key + "%}", str(value or "")) - return data + variables = {} + + def substitute_function(match): + name = match.group(1) + if name in variables: + return str(variables[name]) if variables[name] is not None else "" + else: + variable = match.group() + bar.warn(f"Found pattern '{variable}' but no substitution was provided. Skipped.") + return variable + + return pattern.sub(substitute_function, data) def copy_and_substitute( - inpath: Path, outpath: Path, variables: Optional[dict[str, Optional[str]]] + inpath: Path, + outpath: Path, + variables: Optional[Mapping[str, Optional[str]]], + *, + pattern: re.Pattern[str] = config.BAPCTOOLS_SUBSTITUTE_REGEX, + bar: BAR_TYPE = PrintBar(), ) -> None: try: data = inpath.read_text() except UnicodeDecodeError: # skip this file - log(f'File "{inpath}" is not a text file.') + bar.log(f'File "{inpath}" is not a text file.') return - data = substitute(data, variables) + data = substitute(data, variables, pattern=pattern, bar=bar) if outpath.is_symlink(): outpath.unlink() outpath.write_text(data) -def substitute_file_variables(path: Path, variables: Optional[dict[str, Optional[str]]]) -> None: - copy_and_substitute(path, path, variables) +def substitute_file_variables( + path: Path, + variables: Optional[Mapping[str, Optional[str]]], + *, + pattern: re.Pattern[str] = config.BAPCTOOLS_SUBSTITUTE_REGEX, + bar: BAR_TYPE = PrintBar(), +) -> None: + copy_and_substitute(path, path, variables, pattern=pattern, bar=bar) -def substitute_dir_variables(dirname: Path, variables: Optional[dict[str, Optional[str]]]) -> None: +def substitute_dir_variables( + dirname: Path, + variables: Optional[Mapping[str, Optional[str]]], + *, + pattern: re.Pattern[str] = config.BAPCTOOLS_SUBSTITUTE_REGEX, + bar: BAR_TYPE = PrintBar(), +) -> None: for path in dirname.rglob("*"): if path.is_file(): - substitute_file_variables(path, variables) + substitute_file_variables(path, variables, pattern=pattern, bar=bar) # copies a directory recursively and substitutes {%key%} by their value in text files @@ -922,12 +966,14 @@ def substitute_dir_variables(dirname: Path, variables: Optional[dict[str, Option def copytree_and_substitute( src: Path, dst: Path, - variables: Optional[dict[str, Optional[str]]], + variables: Optional[Mapping[str, Optional[str]]], exist_ok: bool = True, *, preserve_symlinks: bool = True, base: Optional[Path] = None, skip: Optional[Iterable[Path]] = None, + pattern: re.Pattern[str] = config.BAPCTOOLS_SUBSTITUTE_REGEX, + bar: BAR_TYPE = PrintBar(), ) -> None: if base is None: base = src @@ -955,6 +1001,8 @@ def copytree_and_substitute( preserve_symlinks=preserve_symlinks, base=base, skip=skip, + pattern=pattern, + bar=bar, ) except OSError as why: errors.append((srcFile, dstFile, str(why))) @@ -966,11 +1014,11 @@ def copytree_and_substitute( raise Exception(errors) elif dst.exists(): - warn(f'File "{dst}" already exists, skipping...') + bar.warn(f'File "{dst}" already exists, skipping...') else: try: data = src.read_text() - data = substitute(data, variables) + data = substitute(data, variables, pattern=pattern, bar=bar) dst.write_text(data) except UnicodeDecodeError: # Do not substitute for binary files. diff --git a/bin/validate.py b/bin/validate.py index 15f18cf62..527fc5ad6 100644 --- a/bin/validate.py +++ b/bin/validate.py @@ -105,6 +105,7 @@ def __init__( "memory": problem.limits.validation_memory, }, skip_double_build_warning=skip_double_build_warning, + substitute_constants=True, ) assert self.__class__ is not Validator # Validator is abstract and may not be instantiated diff --git a/latex/bapc.cls b/latex/bapc.cls index 785c02653..7001e6a2a 100644 --- a/latex/bapc.cls +++ b/latex/bapc.cls @@ -427,6 +427,18 @@ \fi% } +%------------------------------------------------------------------------------- +% Command to include consatnts. +% The tooling has to define the commands \constants_{} +%------------------------------------------------------------------------------- +\newcommand{\constant}[1]{% + \ifcsname constants_#1\endcsname% + \csname constants_#1\endcsname% + \else% + \PackageError{constants}{constant{#1} is not defined}{}% + \fi% +} + %------------------------------------------------------------------------------- % The following are required for the overall layout: %------------------------------------------------------------------------------- diff --git a/latex/contest-problem-slide.tex b/latex/contest-problem-slide.tex index 235bb0784..ca385519b 100644 --- a/latex/contest-problem-slide.tex +++ b/latex/contest-problem-slide.tex @@ -6,6 +6,7 @@ \renewcommand{\problemforeground}{{%problemforeground%}} \renewcommand{\problemborder}{{%problemborder%}} \renewcommand{\timelimit}{{%timelimit%}} + \input{{%builddir%}/constants.tex} \input{{%problemdir%}/problem_statement/problem-slide.\lang.tex} \renewcommand{\problemlabel}{} \renewcommand{\problemyamlname}{} diff --git a/latex/contest-problem.tex b/latex/contest-problem.tex index 50472bd37..8818a15cc 100644 --- a/latex/contest-problem.tex +++ b/latex/contest-problem.tex @@ -4,6 +4,7 @@ \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/samples.tex} + \input{{%builddir%}/constants.tex} \input{{%problemdir%}/problem_statement/problem.\lang.tex} \remainingsamples{} \renewcommand{\problemlabel}{} diff --git a/latex/contest-solution.tex b/latex/contest-solution.tex index fd11f9a8e..f307f9468 100644 --- a/latex/contest-solution.tex +++ b/latex/contest-solution.tex @@ -3,6 +3,7 @@ \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} + \input{{%builddir%}/constants.tex} \input{{%problemdir%}/problem_statement/solution.\lang.tex} \renewcommand{\problemlabel}{} \renewcommand{\problemyamlname}{} diff --git a/latex/problem-slide.tex b/latex/problem-slide.tex index 409114b60..8bf68ad48 100644 --- a/latex/problem-slide.tex +++ b/latex/problem-slide.tex @@ -9,6 +9,7 @@ \renewcommand{\problemforeground}{{%problemforeground%}} \renewcommand{\problemborder}{{%problemborder%}} \renewcommand{\timelimit}{{%timelimit%}} + \input{{%builddir%}/constants.tex} \input{{%problemdir%}/problem_statement/problem-slide.\lang.tex} \endgroup \end{document} diff --git a/latex/problem-slides-base.tex b/latex/problem-slides-base.tex index eeffc9ff2..4b17fe054 100644 --- a/latex/problem-slides-base.tex +++ b/latex/problem-slides-base.tex @@ -51,6 +51,14 @@ \newcommand{\fullproblemtitle}{\problemlabel: \problemyamlname} \newcommand{\problemtitle}{\problemyamlname} +\newcommand{\constant}[1]{% + \ifcsname constants_#1\endcsname% + \csname constants_#1\endcsname% + \else% + \PackageError{constants}{constant{#1} is not defined}{}% + \fi% +} + \usetheme[numbering=none,block=fill]{metropolis} \newcommand{\illustration}[3]{ diff --git a/latex/problem.tex b/latex/problem.tex index 413f4c42a..ff1852a02 100644 --- a/latex/problem.tex +++ b/latex/problem.tex @@ -6,6 +6,7 @@ \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/samples.tex} + \input{{%builddir%}/constants.tex} \input{{%problemdir%}/problem_statement/problem.\lang.tex} \remainingsamples{} \endgroup diff --git a/latex/solution-web.tex b/latex/solution-web.tex index 6877cb919..37ea59a69 100644 --- a/latex/solution-web.tex +++ b/latex/solution-web.tex @@ -6,6 +6,7 @@ \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} + \input{{%builddir%}/constants.tex} \input{{%problemdir%}/problem_statement/solution.\lang.tex} \renewcommand{\problemlabel}{} \endgroup diff --git a/latex/solution.tex b/latex/solution.tex index d92dcfba5..84667c4cc 100644 --- a/latex/solution.tex +++ b/latex/solution.tex @@ -6,6 +6,7 @@ \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} + \input{{%builddir%}/constants.tex} \input{{%problemdir%}/problem_statement/solution.\lang.tex} \renewcommand{\problemlabel}{} \endgroup diff --git a/latex/solutions-base.tex b/latex/solutions-base.tex index 9071d8b53..e34ba8c3d 100644 --- a/latex/solutions-base.tex +++ b/latex/solutions-base.tex @@ -55,6 +55,14 @@ \newcommand{\fullproblemtitle}{\problemlabel: \problemyamlname} \newcommand{\problemtitle}{\problemlabel: \problemyamlname} +\newcommand{\constant}[1]{% + \ifcsname constants_#1\endcsname% + \csname constants_#1\endcsname% + \else% + \PackageError{constants}{constant{#1} is not defined}{}% + \fi% +} + % If solve_stats/activity/A.pdf exists, define the \activitychart command \IfFileExists{solve_stats/activity/A.pdf}{ \newcommand{\activitychart}{ diff --git a/test/problems/constants/.gitignore b/test/problems/constants/.gitignore new file mode 100644 index 000000000..8a1fac419 --- /dev/null +++ b/test/problems/constants/.gitignore @@ -0,0 +1,3 @@ +#GENERATED BY BAPCtools +data/* +!data/sample/ diff --git a/test/problems/constants/generators/example.py b/test/problems/constants/generators/example.py new file mode 100644 index 000000000..392657933 --- /dev/null +++ b/test/problems/constants/generators/example.py @@ -0,0 +1,6 @@ +#!/usr/bin/python3 +import sys + +values = sys.argv[1:] + ["{{INT_FIVE}}", "{{STRING_FIVE}}", "5"] +assert len(set(values)) == 1 +print(values[0]) diff --git a/test/problems/constants/generators/generators.yaml b/test/problems/constants/generators/generators.yaml new file mode 100644 index 000000000..bafb42925 --- /dev/null +++ b/test/problems/constants/generators/generators.yaml @@ -0,0 +1,27 @@ +solution: /submissions/accepted/submission.py + +data: + sample: + data: + - '': example.py {{INT_FIVE}} {{STRING_FIVE}} 5 # substituted + secret: + include: + - sample + + invalid_input: + data: + dont_substitute: + in: "{{INT_FIVE}}" # not substituted + + invalid_answer: + data: + dont_substitute: + in: "5" + ans: "{{INT_FIVE}}" # not substituted + + invalid_output: + data: + dont_substitute: + in: "5" + ans: "5" + out: "{{INT_FIVE}}" # not substituted diff --git a/test/problems/constants/input_validators/input_validator/input_validator.cpp b/test/problems/constants/input_validators/input_validator/input_validator.cpp new file mode 100644 index 000000000..bd2af7b5c --- /dev/null +++ b/test/problems/constants/input_validators/input_validator/input_validator.cpp @@ -0,0 +1,7 @@ +#include "validation.h" + +int main(int argc, char** argv) { + InputValidator v(argc, argv); + int n = v.read_integer("n", {{INT_FIVE}}, {{STRING_FIVE}}); + v.newline(); +} diff --git a/test/problems/constants/input_validators/input_validator/validation.h b/test/problems/constants/input_validators/input_validator/validation.h new file mode 120000 index 000000000..2b74c5d6a --- /dev/null +++ b/test/problems/constants/input_validators/input_validator/validation.h @@ -0,0 +1 @@ +../../../../../headers/validation.h \ No newline at end of file diff --git a/test/problems/constants/input_validators/validate.ctd b/test/problems/constants/input_validators/validate.ctd new file mode 100644 index 000000000..ec68f8dec --- /dev/null +++ b/test/problems/constants/input_validators/validate.ctd @@ -0,0 +1,2 @@ +INT({{INT_FIVE}}, {{STRING_FIVE}}) NEWLINE +EOF diff --git a/test/problems/constants/output_validators/output_validator/output_validator.cpp b/test/problems/constants/output_validators/output_validator/output_validator.cpp new file mode 100644 index 000000000..3b8d4d53a --- /dev/null +++ b/test/problems/constants/output_validators/output_validator/output_validator.cpp @@ -0,0 +1,12 @@ +#include "validation.h" + +int main(int argc, char *argv[]) { + // Set up the input and answer streams. + std::ifstream in(argv[1]); + OutputValidator v(argc, argv); + + int input; + in >> input; + int answer = v.read_integer("answer", {{INT_FIVE}}, {{STRING_FIVE}}); + v.newline(); +} diff --git a/test/problems/constants/output_validators/output_validator/validation.h b/test/problems/constants/output_validators/output_validator/validation.h new file mode 120000 index 000000000..2b74c5d6a --- /dev/null +++ b/test/problems/constants/output_validators/output_validator/validation.h @@ -0,0 +1 @@ +../../../../../headers/validation.h \ No newline at end of file diff --git a/test/problems/constants/problem.yaml b/test/problems/constants/problem.yaml new file mode 100644 index 000000000..5b1678d6a --- /dev/null +++ b/test/problems/constants/problem.yaml @@ -0,0 +1,18 @@ +# Specification: https://icpc.io/problem-package-format/spec/2023-07-draft.html +problem_format_version: 2023-07-draft +# 'pass-fail', 'interactive', 'multi-pass', or 'interactive multi-pass' +type: pass-fail +name: + #lang: name + en: constants +uuid: 8ee7605a-26db-897d-15c8-b72d4e1bfcbb +credits: BAPCtools +license: cc by-sa +rights_owner: author + +# limits: +# time_limit: 1.0 + +constants: + INT_FIVE: 5 + STRING_FIVE: "5" diff --git a/test/problems/constants/problem_statement/problem-slide.en.tex b/test/problems/constants/problem_statement/problem-slide.en.tex new file mode 100644 index 000000000..a5bc2a282 --- /dev/null +++ b/test/problems/constants/problem_statement/problem-slide.en.tex @@ -0,0 +1,7 @@ +\newcommand{\maxn}{1000} + +\begin{frame} + \frametitle{\problemtitle} + + Output a single integer \constant{INT_FIVE}. +\end{frame} diff --git a/test/problems/constants/problem_statement/problem.en.tex b/test/problems/constants/problem_statement/problem.en.tex new file mode 100644 index 000000000..c80ed5ef9 --- /dev/null +++ b/test/problems/constants/problem_statement/problem.en.tex @@ -0,0 +1,14 @@ +\problemname{} + +Output a single integer \constant{STRING_FIVE}. + +\begin{Input} + The input consists of: + \begin{itemize} + \item One line with a single integer \constant{INT_FIVE}. + \end{itemize} +\end{Input} + +\begin{Output} + Output a single integer \constant{INT_FIVE}. +\end{Output} diff --git a/test/problems/constants/problem_statement/solution.en.tex b/test/problems/constants/problem_statement/solution.en.tex new file mode 100644 index 000000000..1216d6f3c --- /dev/null +++ b/test/problems/constants/problem_statement/solution.en.tex @@ -0,0 +1,4 @@ +\begin{frame} + \frametitle{\problemtitle} + Output a single integer \constant{INT_FIVE}. +\end{frame} diff --git a/test/problems/constants/submissions/accepted/submission.py b/test/problems/constants/submissions/accepted/submission.py new file mode 100644 index 000000000..8f3072e43 --- /dev/null +++ b/test/problems/constants/submissions/accepted/submission.py @@ -0,0 +1,2 @@ +#!/usr/bin/python3 +print(5) diff --git a/test/problems/constants/submissions/wrong_answer/constant_in_submission.py b/test/problems/constants/submissions/wrong_answer/constant_in_submission.py new file mode 100644 index 000000000..e6d6ed614 --- /dev/null +++ b/test/problems/constants/submissions/wrong_answer/constant_in_submission.py @@ -0,0 +1,2 @@ +#!/usr/bin/python3 +print("{{STRING_FIVE}}") diff --git a/test/problems/solve_stats/activity/B.pdf b/test/problems/solve_stats/activity/B.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8389db7e95fd08dadea7acac593bd2ef7b0cebfd GIT binary patch literal 5939 zcmai22{crF*e|6tSt?sfTw7*{F~(T36QVGT_oYyl856}Y*0K!75=ul1%Am3(A`%KI zvLuy+kSvkx%9ic>U+Hc8zVnT9W_j-OJiq0C?{Usg41YjZ3L`Ct5XgcG(zM;9_Azjl>Mu_ur`AWHyy zKo?LU`FKEReP=*HYwc5e?NcAJ{)GON6~BnvjAg~I&yI5(FCV^8vFJ7uT@j{E3sdhFY~JkI(=8qDd89kt_3QLfrn)nY z#y6@n6 zgYD^SQ6Fb$74*yXZ4G^k=s}<4xW2G^f_))PXO)skYvT<)DmC}~R0S3e`l>H`X`S1p z7E&r*o>|A#&dt!qyxFuq#PGJz)43&7@}$SBqHT6JD|c?1dtk)Fl9Te(V)sW@M)Q!} ziPOf~IU0OqpI3QNvx4azPqJwR(8SCFTfig3@$tb95vT5s3o%09vcj(@9XTvDh|+zx zQ}W^SA*MiFJom9L^Bd9b1sX(Vu^L1?DX>-L2viWf!=AbRooZE;P_G2b>_JPv%;;nu z>#ggD3$Q#6bxc9r@mL=6o`1=$)k~^A{ua}|+n6X^IrXn~EYH8yjFtb2C#xkt8x@*v zx^bb&E-(^h{Z*>THqg#_XR3n%uQuV)rhIwC8$;u$^h7p0=K777j+X9B`YeBz8<{aD zTM1)k%tc;Hi9U0*@S%V>vL}r1^ZkzAw^QmL^fJLh@12-_!D>e-#Lh_Npv%Pqa|4d?g7 zQvm)^b~|PRjv}j4p#N6*Q5XK+`C#p*|7;8M$<@uwcwPOHny#BUP_sQdD?^8X+nmot z3U-pPK4;j%{}d6=X=XNL=afAFz4e;>XhP`e$;wLfJ@BqqTXnkdMCJfwYO@bt*~K$1 zC|n%=4=qjOgT|56L<8PK%`Nh0i%e1i%K$mjsHIwukZZ~{`zhB4_pLpZ)sw5=^mr;* zt;_ALx7^&)`KE9BKJgXqANOr1Z)o7aVu1H=3M<~^R#bUbBHf~+8vlT2|J4VHw^Jv% z`BDRYVb4U&+?zMHSOHga>ojvTlr(YPmJcf?T{vzB@M?X~NJvdH;LS*vgmk`R1w_lE4Oj^3s7-o6&oW!soG*RN|aEB%y|tptu$1a|5>^TQVs5ggCboyN91 zMsYBAI=j(ys_37OU)U)xV}v)nwe_Ys-U#1#-2wUGbv5vlZn2wLB0EE;xk%}3QI~Lt za>%`i+a=mN8P$ksvr>zr$xk8`Oq+bfaYu5TXTcZj^f_;Qvrfm-8|-`h=tQ)H^bTUR zY72XINeS&5x5`Ljc8T6iDYZ91S6J%?Vdk23%vL{H5{j!A^jb!3TNswxe9F$VbN>S@ z1PY2w0&V-8STM_5V4GGS!>emHJ;ygTVt?Ai#NFcTw$h%MG4{gf~=`O3ZE4%xSP4N=Y=}*;0@ud6VwsG6bKDHGHStzn`GDB|O}p zfSWyASRH=mv*RmQXLdmNtTScx3EZAt^7CeVhs2wJjjLv7Tvg#WGHmG-F+HoUt$In? z2t0dXPA*3P4zD}BS|0I}F9Jxkfai*E1)72#HoZ>!+fz)WhvEE`v3`Pzcwt@%dtrFGhwW8Xt2_;0ah?@3xk81M4;1_fJLvBg~Q*X8%@7lu< zQvKGAZsR`QBFI=^s~e4N!f*7SQrb~ruBN@?YXkXUfATzQXr}_>hE3PAq3caCcJ_;6 z&Ix(rj3zUOV1aM^L$9hm^%Dmr7e2X9?<>9nK)yBukmPUt5#8ntp;+HxEEkpLA3b#< zxk9C=#eBAnd=SeO>3GuLli?r6)n}djZuqwEsC)O;lBF3XgT+q$cO#O%V^}WDZvTO@ z+>}{0z24bwz(ZgW@Caw5vYeT87Y$Gk2owEO*A!FhKVLGe)A@dppA=EC=k?{q&&!Lf z`Y&%VgsL|!Hn^V*aApKVKOJpp!q+Z+OSl~#oV)}s7c)dAeP^+v)RllS1iCm}U9V?$ zl6;WU?4095fG3&;b`OHxs!PDPJar&x21r_729o-Kq=iN2`hdKZN5f?&{Z=#z8{DI4 z&K0!i!tO5=TS@)k@)g#I&ILwlr{w~=Nou7o?ZJw=UeL*NVH<2TCH0v&AnhFw)+w33 z-~514QZm0wq+?4he&aEXFj3bHRMB!lo4B@UjWDg5ja063$rvFvJSII-qX-=%wB=e5 zYs3g!Khnw@(vEm_Vi_J`p^82bxt_hDML;v`fY%mTt^(tjRF-SO{1M~aZK9!;F+#CR z!K|SpJNuD%%taMCtNw>w#Dh%TkY4Zp&3uS`V874Zx0kG+yTQvl93-=@a1Rw83 zT?+yIV#I?)1tGnjZ(H~xD@|%+HYRIDKlSF86)nh%NsYN4sn+pMGP z_R^L$wtIU^1jTNMl1%sqKNInsVS}&zc$B}|sA905|SHeQy8BfvEU= z96=A!e({JX>C|$_#i}xStHG>mk_ir`HOn?N5kUK$1V2-tQXADzbtbo9Utx0>BM z;d(WI#1awhU+ZK6|Az^HKf(~KOiBkUEeKC}p68>0p&eXQF)XFNP4IA4{*Kgq+!r?> zIZG+jDwo6j`T5BNPOtj5#1iGuktIi!e}(1bX}ZRISJX=~&fQKJBmjr!QFiFWc6FfY zK#Ht1%?K~V>lE<*YYI5mbUc7_%#1N)sGHS%G!~QSb8_Iml#k;)2aG7u1tWzwz{n<3 zA#+*2tg}*ySIlbO9yXBxM<&TlEzz|HM;V?4+|EGxv%hjO5qvo+?YYB-_Kk)=(;0(&Y7X!|Gmjo3bDCYm0n;M|zHhw_aRml;Sx`!5@9Z=LZwn_i?S; z?=OgkKOtt@e<2Bv^{aKjl73;^N&b_C;N4#?!?NpCe@7ZffVb8y4e)Heqkz2gp0ym- zdYQU5_BRCSPl$!zg#t?%|G%XiZGVap_)iS=LWgrao0mFd8JjoRzT77ntY4$2~P1|K>CYs9y=FWh6XdeQ{3 zSgFy4@6PN)bT?sSJYU55C7@k>uv5Ko^$j zIEOI!aQ6sT?j47gL@RV7#KI~^7j|s~OC-P&+H)wz5EE7DUBwz%xh#uhYHIjqRD&zs z$JE*-%ffW1VJ-kQkq??5$|8x+>Y*5C2|6^94hk`#nYTVA=8pLAF4k5`0Smq{_V8Y? z`ZCyLRDiVNsDon8i}Za?s+0n)QMG9k{z7yud3ugyn$_l7;R$C!y70sq(EI^|B$_`H zcD=(7R#QNsFdylcQIirg$~XLeV{06Sg-N6)ZK8`8h__-zw39I`ME7c84V)g;40P*a zkWAxG(_Gm&=^9@8z>Zy@p#C&%!Z5}h(J?Q>LM(eCJfX@C%#a7|jWS5$)+fTQ{#)tW zL|#2d0LDll=Q1dxtDqP*7CPj*2NaxqZXp+$0by=nsQz78-pB^Pm=)|m-Ua`{Dxnyu z^?>nu*2t7E-DwlPi)@IJq;4o?VMdCnLVM*>+NWv-WN@x8U280_n5EO`3?2HE04ifX zQB~t~1!RA;H@$MqnWoG(m^FUo^wBE2(JJz*whIkAc?MI}8;4c)Wt_KKKA5?6XV+vf z>(J74F#ph=Z;|{7s>{KPnU@10^-?la8|xy0q!P>6gZZ=Whx8JXhAfd&_Pck3-WTDJ zUJx#Aj;(RVNL1-!Beza8Vpb92eh$`AK4=@5MgpI4& z*I!g&_GLv*5ywrTp;Myc?kTk<`GjCm5JYk@cp&#eYIn6*v+zo;*z@PX@u9d?fv@Q^aHUXzPz0J(ZT+BuG11; zrY8MaZBaV0;=T&J`qBMMHe5PJFD29d`NwMd=Gd9!uM52du%l44F7BauoUq=Ei!l(m zpaafnL{SXvAYT%0x^A5}#?WaE_ZuK@_wC-NAHASV>1z5oZOibH7W4H+O)c8Y=FJj( zeA~#$P1l#6j49bwG2;5(vlEw!ZjD@?T$&>kEZXqiTWEi%HiB(Rw-c1pPhL>Ynr=0n zOP=<->HD&+LHq3u*zhr!HWcU{@931{t zgu}mx^Lw(1KIBMr2l?M>glPIb_VWah;VQ`=-u$8&tqr0(4+<4R8rpie zo}zf1aWsALoZji*wGp7tOK{B_Ee=m6Q-+Mfg{ zYa2l4>Uu|t%KMn)XR)*m&DA_zDCsL9HJboGxK7Hs;fCi}ni$pPPgpOul3 z2S@ow4T}YL(C;-IM*c4h8Ce;S-2T~z1p(|IH96T|w0Tf%9Z!*{tLZku@eB!g1))tT o6!?(gBp0pk;y{5g@FB0py6zsfRF73#aX3X8gqWC)u`c3&07_M48vp Date: Mon, 3 Mar 2025 13:48:30 +0100 Subject: [PATCH 02/23] Add bt upgrade (#432) * add bt upgrade * implemented todos * introduce SPEC constant * remove legacy parsing * fix * fix * simplify code * upgrade test files * remove empty keys * updated test yamls * fix * fix name * use newer version of bt upgrade * improve bt upgrade * add missing dir * [upgrade] Remove comments from limits if both limits are the new default * [upgrade] Ad-hoc import for CommentedMap and CommentedSeq * try to insert testdata before data * try to preserve the right comments * try to preserve the right comments * [upgrade] Improve preserving of comments when removing keys * [upgrade] Improve replacing of keys at their original position * fix errors * rerun upgrade * readd output validator dir * [upgrade] Only write generators.yaml when it changed --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- bin/config.py | 2 + bin/export.py | 2 +- bin/problem.py | 298 ++++--------- bin/tools.py | 13 + bin/upgrade.py | 390 ++++++++++++++++++ bin/util.py | 10 + test/problems/boolfind/domjudge-problem.ini | 5 - test/problems/boolfind/problem.yaml | 27 +- test/problems/different/problem.yaml | 96 +---- .../divsort/generators/generators.yaml | 7 +- test/problems/divsort/problem.yaml | 1 + test/problems/fltcmp/data/testdata.yaml | 1 + test/problems/fltcmp/domjudge-problem.ini | 5 - test/problems/fltcmp/problem.yaml | 27 +- test/problems/generatorincludes/.timelimit | 1 - test/problems/generatorincludes/problem.yaml | 30 +- test/problems/guess/problem.yaml | 7 +- test/problems/guessnoeofcheck/problem.yaml | 6 +- test/problems/hello/domjudge-problem.ini | 5 - test/problems/hello/problem.yaml | 27 +- .../helloproblemtools/domjudge-problem.ini | 1 - test/problems/helloproblemtools/problem.yaml | 2 + test/problems/hellounix/.timelimit | 1 - test/problems/hellounix/problem.yaml | 23 +- test/problems/hellowholeworld/problem.yaml | 6 +- test/problems/identity/.timelimit | 1 - .../identity/generators/generators.yaml | 8 +- test/problems/identity/problem.yaml | 25 +- .../interactivemultipass/problem.yaml | 10 +- test/problems/multipass/problem.yaml | 8 +- .../test_problem_config/domjudge-problem.ini | 1 - .../problems/test_problem_config/problem.yaml | 3 - .../output_validators/.gitkeep | 0 test/problems/testproblemconfig/problem.yaml | 6 + .../problem_statement/problem.en.tex | 0 test/test_problems.py | 2 +- test/yaml/problem/invalid.yaml | 11 +- test/yaml/problem/valid.yaml | 30 +- 38 files changed, 600 insertions(+), 498 deletions(-) create mode 100644 bin/upgrade.py delete mode 100644 test/problems/boolfind/domjudge-problem.ini create mode 100644 test/problems/fltcmp/data/testdata.yaml delete mode 100644 test/problems/fltcmp/domjudge-problem.ini delete mode 100644 test/problems/generatorincludes/.timelimit delete mode 100644 test/problems/hello/domjudge-problem.ini delete mode 100644 test/problems/helloproblemtools/domjudge-problem.ini delete mode 100644 test/problems/hellounix/.timelimit delete mode 100644 test/problems/identity/.timelimit delete mode 100644 test/problems/test_problem_config/domjudge-problem.ini delete mode 100644 test/problems/test_problem_config/problem.yaml create mode 100644 test/problems/testproblemconfig/output_validators/.gitkeep create mode 100644 test/problems/testproblemconfig/problem.yaml rename test/problems/{test_problem_config => testproblemconfig}/problem_statement/problem.en.tex (100%) diff --git a/bin/config.py b/bin/config.py index 40aad5f46..8661c3c60 100644 --- a/bin/config.py +++ b/bin/config.py @@ -7,6 +7,8 @@ from collections.abc import Mapping, Sequence from typing import Final, Literal, Optional +SPEC_VERSION: Final[str] = "2023-07-draft" + # return values RTV_AC: Final[int] = 42 RTV_WA: Final[int] = 43 diff --git a/bin/export.py b/bin/export.py index 61de017d0..5b25b75a6 100644 --- a/bin/export.py +++ b/bin/export.py @@ -172,7 +172,7 @@ def add_file(path, source): # DOMjudge does not support 'type' in problem.yaml nor 'output_validator_args' in testdata.yaml yet. # TODO: Remove this once it does. - if not config.args.kattis and not problem.settings.is_legacy(): + if not config.args.kattis: yaml_path = export_dir / "problem.yaml" yaml_data = [yaml_path.read_text(), "\nvalidation:"] if problem.custom_output: diff --git a/bin/problem.py b/bin/problem.py index fe96c4ad2..26dd511c6 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -1,5 +1,4 @@ import re -import shlex import sys import threading @@ -23,23 +22,6 @@ from colorama import Fore, Style -# Parse validation mode (only for legacy problem format version) -def parse_legacy_validation(mode: str) -> set[str]: - if mode == "default": - return {mode} - else: - ok = True - parsed = set() - for part in mode.split(): - if part in ["custom", "interactive", "multi-pass"] and part not in parsed: - parsed.add(part) - else: - ok = False - if "custom" not in parsed or not ok: - fatal(f"problem.yaml: unrecognized validation mode {mode}.") - return parsed - - # The parse_* functions will remove (.pop()) keys from the yaml data during parsing. # We will warn for any unknown keys that remain after this process. def check_unknown_keys(yaml_data: dict[str, Any], sub_key: Optional[str] = None): @@ -68,42 +50,30 @@ def __init__( self.packagers: list[Person] = [] self.acknowledgements: list[Person] = [] - # If problem.yaml uses the legacy version, do not support the new `credits` key. - # If problem.yaml uses 2023-07-draft, prefer `credit`, but also support `author` and warn for it. - legacy_author = parse_optional_setting(yaml_data, "author", str) - if problem_settings.is_legacy(): - if legacy_author: - self.authors = [Person(a) for a in legacy_author.replace("and", ",").split(",")] - else: - if legacy_author is not None: - warn( - "problem.yaml: author is removed in 2023-07-draft, please use credits.authors. SKIPPED." - ) - if "credits" not in yaml_data: - return - if isinstance(yaml_data["credits"], str): - self.authors = [Person(parse_setting(yaml_data, "credits", ""))] - return - - credits = parse_setting(yaml_data, "credits", dict[str, Any]()) - self.authors = [Person(s) for s in parse_optional_list_setting(credits, "authors", str)] - self.contributors = [ - Person(s) for s in parse_optional_list_setting(credits, "contributors", str) - ] - self.translators = parse_setting(credits, "translators", {}) - for lang in list(self.translators.keys()): - self.translators[lang] = [ - Person(s) for s in parse_optional_list_setting(self.translators, lang, str) - ] - self.testers = [Person(s) for s in parse_optional_list_setting(credits, "testers", str)] - self.packagers = [ - Person(s) for s in parse_optional_list_setting(credits, "packagers", str) - ] - self.acknowledgements = [ - Person(s) for s in parse_optional_list_setting(credits, "acknowledgements", str) + parse_deprecated_setting(yaml_data, "author", "credits.authors") + if "credits" not in yaml_data: + return + if isinstance(yaml_data["credits"], str): + self.authors = [Person(parse_setting(yaml_data, "credits", ""))] + return + + credits = parse_setting(yaml_data, "credits", dict[str, Any]()) + self.authors = [Person(s) for s in parse_optional_list_setting(credits, "authors", str)] + self.contributors = [ + Person(s) for s in parse_optional_list_setting(credits, "contributors", str) + ] + self.translators = parse_setting(credits, "translators", {}) + for lang in list(self.translators.keys()): + self.translators[lang] = [ + Person(s) for s in parse_optional_list_setting(self.translators, lang, str) ] + self.testers = [Person(s) for s in parse_optional_list_setting(credits, "testers", str)] + self.packagers = [Person(s) for s in parse_optional_list_setting(credits, "packagers", str)] + self.acknowledgements = [ + Person(s) for s in parse_optional_list_setting(credits, "acknowledgements", str) + ] - check_unknown_keys(credits, "credits") + check_unknown_keys(credits, "credits") class ProblemSource: @@ -121,44 +91,33 @@ def __init__( yaml_data: dict[str, Any], problem_settings: "ProblemSettings", ): - # If problem.yaml uses the legacy version, do not support the new type of the `source` key. - # If problem.yaml uses 2023-07-draft, prefer `source`, but also support `source_url` and warn for it. - legacy_source_url = parse_optional_setting(yaml_data, "source_url", str) - if problem_settings.is_legacy(): - source_name = parse_setting(yaml_data, "source", "") - if legacy_source_url: - self.append(ProblemSource(source_name, legacy_source_url)) - else: - if legacy_source_url is not None: - warn( - "problem.yaml: source_url is removed in 2023-07-draft, please use source.url. SKIPPED." + parse_deprecated_setting(yaml_data, "source_url", "source.url") + if "source" not in yaml_data: + return + if isinstance(yaml_data["source"], str): + self.append(ProblemSource(parse_setting(yaml_data, "source", ""))) + return + if isinstance(yaml_data["source"], dict): + source = parse_setting(yaml_data, "source", dict[str, str]()) + self.append( + ProblemSource( + parse_setting(source, "name", ""), + parse_optional_setting(source, "url", str), ) - if "source" not in yaml_data: - return - if isinstance(yaml_data["source"], str): - self.append(ProblemSource(parse_setting(yaml_data, "source", ""))) - return - if isinstance(yaml_data["source"], dict): - source = parse_setting(yaml_data, "source", dict[str, str]()) + ) + return + if isinstance(yaml_data["source"], list): + sources = parse_setting(yaml_data, "source", list[dict[str, str]]()) + for raw_source in sources: + source = parse_setting(raw_source, "source", dict[str, str]()) self.append( ProblemSource( parse_setting(source, "name", ""), parse_optional_setting(source, "url", str), ) ) - return - if isinstance(yaml_data["source"], list): - sources = parse_setting(yaml_data, "source", list[dict[str, str]]()) - for raw_source in sources: - source = parse_setting(raw_source, "source", dict[str, str]()) - self.append( - ProblemSource( - parse_setting(source, "name", ""), - parse_optional_setting(source, "url", str), - ) - ) - return - warn("problem.yaml key 'source' does not have the correct type") + return + warn("problem.yaml key 'source' does not have the correct type") class ProblemLimits: @@ -174,35 +133,13 @@ def __init__( # (defaults from https://icpc.io/problem-package-format/spec/2023-07-draft.html#limits) time_multipliers = parse_setting(yaml_data, "time_multipliers", dict[str, Any]()) - # If problem.yaml uses the legacy version, do not support the new keys. - # If problem.yaml uses 2023-07-draft, prefer the new keys, but also support and warn for the old keys. - legacy_ac_to_time_limit = parse_optional_setting(yaml_data, "time_multiplier", float) - if problem_settings.is_legacy(): - self.ac_to_time_limit = legacy_ac_to_time_limit or 5.0 - else: - if legacy_ac_to_time_limit is not None: - warn( - "problem.yaml: limits.time_multiplier is removed in 2023-07-draft, please use limits.time_multipliers.ac_to_time_limit" - ) - self.ac_to_time_limit = parse_setting( - time_multipliers, "ac_to_time_limit", legacy_ac_to_time_limit or 2.0 - ) - - legacy_time_limit_to_tle = parse_optional_setting(yaml_data, "time_safety_margin", float) - if problem_settings.is_legacy(): - self.time_limit_to_tle = legacy_time_limit_to_tle or 2.0 - else: - if legacy_time_limit_to_tle is not None: - warn( - "problem.yaml: limits.time_safety_margin is removed in 2023-07-draft, please use limits.time_multipliers.time_limit_to_tle" - ) - self.time_limit_to_tle = parse_setting( - time_multipliers, "time_limit_to_tle", legacy_time_limit_to_tle or 1.5 - ) + parse_deprecated_setting(yaml_data, "time_multiplier", "ac_to_time_limit") + self.ac_to_time_limit = parse_setting(time_multipliers, "ac_to_time_limit", 2.0) + parse_deprecated_setting(yaml_data, "time_safety_margin", "time_limit_to_tle") + self.time_limit_to_tle = parse_setting(time_multipliers, "time_limit_to_tle", 1.5) check_unknown_keys(time_multipliers, "limits.time_multipliers") - # time_limit is required, but parse as optional to more easily handle the legacy_time_limit. time_limit = parse_optional_setting(yaml_data, "time_limit", float) # in seconds self.time_resolution: float = parse_setting(yaml_data, "time_resolution", 1.0) self.memory: int = parse_setting(yaml_data, "memory", 2048) # in MiB @@ -223,34 +160,22 @@ def __init__( self.generator_time: int = parse_setting(yaml_data, "generator_time", 60) # in seconds self.visualizer_time: int = parse_setting(yaml_data, "visualizer_time", 60) # in seconds - # Try to read deprecated ways of setting the time limit. - def _get_legacy_time_limit(): - timelimit_path = problem.path / ".timelimit" - if timelimit_path.is_file(): - if not problem_settings.is_legacy(): - log("A .timelimit file is DEPRECATED. Use limits.time_limit instead.") - return float(timelimit_path.read_text()) - - domjudge_path = problem.path / "domjudge-problem.ini" - if domjudge_path.is_file(): - log("domjudge-problem.ini is DEPRECATED. Use limits.time_limit instead.") - for line in domjudge_path.read_text().splitlines(): - key, var = map(str.strip, line.strip().split("=")) - if (var[0] == '"' or var[0] == "'") and (var[-1] == '"' or var[-1] == "'"): - var = var[1:-1] - if key == "timelimit": - return float(var) - - # If limits.time_limit does not exist, attempt to use legacy_time_limit instead. - legacy_time_limit = _get_legacy_time_limit() - self.time_limit: float = time_limit or legacy_time_limit or 1.0 - self.time_limit_is_default: bool = time_limit is None and legacy_time_limit is None + # warn for deprecated timelimit files + if (problem.path / ".timelimit").is_file(): + warn("A .timelimit file is DEPRECATED. Use limits.time_limit instead.") + if (problem.path / "domjudge-problem.ini").is_file(): + warn( + "domjudge-problem.ini is DEPRECATED. Use limits.time_limit if you want to set a timelimit." + ) + + self.time_limit: float = time_limit or 1.0 + self.time_limit_is_default: bool = time_limit is None check_unknown_keys(yaml_data, "limits") # Override limmits by command line arguments. self.time_limit = config.args.time_limit or self.time_limit - self.timeout = int(config.args.timeout or self.time_limit_to_tle * self.time_limit + 1) + self.timeout: int = int(config.args.timeout or self.time_limit_to_tle * self.time_limit + 1) if config.args.timeout: self.validation_time = self.generator_time = self.visualizer_time = config.args.timeout if config.args.memory: @@ -273,44 +198,33 @@ def __init__( self.problem_format_version: str = parse_setting( yaml_data, "problem_format_version", "legacy-icpc" ) - if not self.is_legacy() and self.problem_format_version != "2023-07-draft": - fatal(f"problem_format_version {self.problem_format_version} not supported") - if self.is_legacy(): - mode = parse_legacy_validation(parse_setting(yaml_data, "validation", "default")) - else: - if "validation" in yaml_data: - warn( - "problem.yaml: 'validation' is removed in 2023-07-draft, please use 'type' instead. SKIPPED." - ) - yaml_data.pop("validation") - mode = set( - ["pass-fail"] - if "type" not in yaml_data - else parse_setting(yaml_data, "type", "pass-fail").split() - if isinstance(yaml_data["type"], str) - else parse_optional_list_setting(yaml_data, "type", str) - if isinstance(yaml_data["type"], list) - else [fatal("problem.yaml: 'type' must be a string or a sequence")] + if self.problem_format_version.startswith("legacy"): + fatal("legacy is no longer supported, try running 'bt upgrade'") + elif self.problem_format_version != config.SPEC_VERSION: + fatal(f"unrecognized problem_format_version: {self.problem_format_version}") + + parse_deprecated_setting(yaml_data, "validation", "type") + mode = set( + ["pass-fail"] + if "type" not in yaml_data + else parse_setting(yaml_data, "type", "pass-fail").split() + if isinstance(yaml_data["type"], str) + else parse_optional_list_setting(yaml_data, "type", str) + if isinstance(yaml_data["type"], list) + else [fatal("problem.yaml: 'type' must be a string or a sequence")] + ) + unrecognized_type = mode - {"pass-fail", "interactive", "multi-pass"} + if unrecognized_type: + fatal( + f"""problem.yaml: unrecognized value{ + "" if len(unrecognized_type) == 1 else "s" + } for 'type': {" ".join(sorted(unrecognized_type))}""" ) - unrecognized_type = mode - {"pass-fail", "interactive", "multi-pass"} - if unrecognized_type: - fatal( - f"""problem.yaml: unrecognized value{ - "" if len(unrecognized_type) == 1 else "s" - } for 'type': {" ".join(sorted(unrecognized_type))}""" - ) self.interactive: bool = "interactive" in mode self.multi_pass: bool = "multi-pass" in mode self.custom_output: bool = ( - self.interactive - or self.multi_pass - or ( - "custom" in mode - if self.is_legacy() - # TODO #424: output_validator should be singular, but DOMjudge does not support this yet, so this should be fixed during export. - else (problem.path / "output_validators").exists() - ) + self.interactive or self.multi_pass or (problem.path / "output_validators").exists() ) self.name: dict[str, str] = parse_setting(yaml_data, "name", {"en": ""}) @@ -326,19 +240,9 @@ def __init__( self.embargo_until: str = parse_setting(yaml_data, "embargo-until", "") self.limits = ProblemLimits(parse_setting(yaml_data, "limits", {}), problem, self) - # If problem.yaml uses 2023-07-draft, disallow `validator_flags`. - if self.is_legacy(): - if "validator_flags" in yaml_data and isinstance(yaml_data["validator_flags"], str): - yaml_data["validator_flags"] = shlex.split(yaml_data["validator_flags"]) - # This field should not be used anywhere except the default result of Problem.get_testdata_yaml(). - self._validator_flags: list[str] = parse_setting(yaml_data, "validator_flags", []) - else: - self._validator_flags = [] - if "validator_flags" in yaml_data: - warn( - "problem.yaml: 'validator_flags' is removed in 2023-07-draft, please use 'output_validator_args' in 'testdata.yaml' instead. SKIPPED." - ) - yaml_data.pop("validator_flags") + parse_deprecated_setting( + yaml_data, "validator_flags", "output_validator_args' in 'testdata.yaml" + ) self.keywords: str = parse_setting(yaml_data, "keywords", "") # Not implemented in BAPCtools. We always test all languges in langauges.yaml. @@ -374,9 +278,6 @@ def __init__( if not self.multi_pass and has_validation_passes: warn("limit: validation_passes is only used for multi_pass problems. SKIPPED.") - def is_legacy(self): - return self.problem_format_version.startswith("legacy") - # A problem. class Problem: @@ -519,25 +420,10 @@ def _parse_testdata_yaml(p, path, bar): ) p._testdata_yamls[f] = flags = parse_yaml(raw, path=f, plain=True) - if p.settings.is_legacy(): - # For legacy problems, support both _flags and _args, but move to _args. - if ( - "output_validator_flags" in flags - and "output_validator_args" not in flags - ): - flags["output_validator_args"] = flags.pop("output_validator_flags") - if "input_validator_flags" in flags and "input_validator_args" not in flags: - flags["input_validator_args"] = flags.pop("input_validator_flags") - else: - # For 2023-07-draft problems, skip the old name and warn to use the new one. - if "input_validator_flags" in flags: - bar.warn( - "input_validator_flags is removed in 2023-07-draft, use ..._args instead. SKIPPED." - ) - if "output_validator_flags" in flags: - bar.warn( - "output_validator_flags is removed in 2023-07-draft, use ..._args instead. SKIPPED." - ) + parse_deprecated_setting( + flags, "output_validator_flags", "output_validator_args" + ) + parse_deprecated_setting(flags, "input_validator_flags", "input_validator_args") # Verify testdata.yaml for k in flags: @@ -615,16 +501,11 @@ def get_testdata_yaml( # parse and cache testdata.yaml p._parse_testdata_yaml(path, bar) - # For legacy problems, default to validator_flags from problem.yaml - default_result = [] - if p.settings.is_legacy() and p.settings._validator_flags: - default_result = p.settings._validator_flags - # extract the flags for dir in [path] + list(path.parents): # Do not go above the data directory. if dir == p.path: - return default_result + return [] f = dir / "testdata.yaml" if f not in p._testdata_yamls: @@ -644,7 +525,7 @@ def get_testdata_yaml( elif name in flags[key]: return flags[key][name].split() - return default_result + return [] def testcases( p, @@ -939,9 +820,6 @@ def _validators( # Handle default output validation if cls == validate.OutputValidator: - if problem.settings.is_legacy() and not problem.custom_output and paths: - error("Validation is default but custom output validator exists (ignoring it)") - paths = [] if not paths: if problem.custom_output: fatal("Problem validation type requires output_validators/") diff --git a/bin/tools.py b/bin/tools.py index b6b9a792a..f31087d81 100755 --- a/bin/tools.py +++ b/bin/tools.py @@ -39,6 +39,7 @@ import solve_stats import download_submissions import stats +import upgrade import validate import signal @@ -351,6 +352,13 @@ def build_parser(): ) subparsers.required = True + # upgrade + subparsers.add_parser( + "upgrade", + parents=[global_parser], + help="Upgrade a problem or contest.", + ) + # New contest contestparser = subparsers.add_parser( "new_contest", @@ -941,6 +949,11 @@ def run_parsed_arguments(args): else: config.args.testcases = [] + # upgrade commands. + if action == "upgrade": + upgrade.upgrade() + return + # Skel commands. if action == "new_contest": skel.new_contest() diff --git a/bin/upgrade.py b/bin/upgrade.py new file mode 100644 index 000000000..15c3b72ba --- /dev/null +++ b/bin/upgrade.py @@ -0,0 +1,390 @@ +import config +import generate +from util import * + +import shutil +from typing import Any + +if has_ryaml: + # TODO #102 The conditional import in util.py isn't picked up properly + from ruamel.yaml.comments import CommentedMap, CommentedSeq + + +# This tries to preserve the correct comments. +def _filter(data: Any, remove: str) -> Any: + assert isinstance(data, CommentedMap) + + remove_index = list(data.keys()).index(remove) + if remove_index == 0: + return data.pop(remove) + + curr = data + prev_key = list(data.keys())[remove_index - 1] + while isinstance(curr[prev_key], list | dict): + # Try to remove the comment from the last element in the preceding list/dict + curr = curr[prev_key] + if isinstance(curr, list): + prev_key = len(curr) - 1 + else: + prev_key = list(curr.keys())[-1] + + if remove in data.ca.items: + # Move the comment that belongs to the removed key (which comes _after_ the removed key) + # to the preceding key + curr.ca.items[prev_key] = data.ca.items.pop(remove) + elif prev_key in data.ca.items: + # If the removed key does not have a comment, + # the comment after the previous key should be removed + curr.ca.items.pop(prev_key) + + return data.pop(remove) + + +# Insert a new key before an old key, then remove the old key. +# If new_value is not given, the default is to simply rename the old key to the new key. +def _replace(data: Any, old_key: str, new_key: str, new_value: Any = None) -> None: + if new_value is None: + new_value = data[old_key] + data.insert(list(data.keys()).index(old_key), new_key, new_value) + _filter(data, old_key) + + +def upgrade_data(problem_path: Path, bar: ProgressBar) -> None: + rename = [ + ("data/invalid_inputs", "data/invalid_input"), + ("data/invalid_answers", "data/invalid_answer"), + ("data/invalid_outputs", "data/invalid_output"), + ("data/valid_outputs", "data/valid_output"), + ] + for old_name, new_name in rename: + old_path = problem_path / old_name + new_path = problem_path / new_name + if old_path.is_dir(): + if new_path.exists(): + bar.error(f"can't rename '{old_name}', '{new_name}' already exists", resume=True) + continue + bar.log(f"renaming '{old_name}' to '{new_name}'") + old_path.rename(new_path) + + +def upgrade_testdata_yaml(problem_path: Path, bar: ProgressBar) -> None: + rename = [ + ("output_validator_flags", "output_validator_args"), + ("input_validator_flags", "input_validator_args"), + ] + + for f in (problem_path / "data").rglob("testdata.yaml"): + data = read_yaml(f) + assert data is not None + + for old, new in rename: + if old in data: + if new in data: + bar.error( + f"can't change '{old}', '{new}' already exists in {f.relative_to(problem_path)}", + resume=True, + ) + continue + _replace(data, old, new) + + write_yaml(data, f) + + +def upgrade_generators_yaml(problem_path: Path, bar: ProgressBar) -> None: + generators_yaml = problem_path / "generators" / "generators.yaml" + if not generators_yaml.is_file(): + return + data = read_yaml(generators_yaml) + if data is None or not isinstance(data, dict): + return + + changed = False + + rename = [ + ("invalid_inputs", "invalid_input"), + ("invalid_answers", "invalid_answer"), + ("invalid_outputs", "invalid_output"), + ("valid_outputs", "valid_output"), + ] + for old_name, new_name in rename: + if old_name in data: + if new_name in data: + bar.error( + f"can't rename 'data.{old_name}', 'data.{new_name}' already exists in generators.yaml", + resume=True, + ) + continue + bar.log(f"renaming 'data.{old_name}' to 'data.{new_name}' in generators.yaml") + _replace(data, old_name, new_name) + changed = True + + def upgrade_generated_testdata_yaml(data: dict[str, Any], path: str) -> bool: + changed = False + if "testdata.yaml" in data: + testdata = data["testdata.yaml"] + assert isinstance(testdata, dict) + print_path = f" ({path[1:]})" if len(path) > 1 else "" + + rename = [ + ("output_validator_flags", "output_validator_args"), + ("input_validator_flags", "input_validator_args"), + ] + for old, new in rename: + if old in testdata: + if new in testdata: + bar.error( + f"can't change '{old}', '{new}' already exists in generators.yaml{print_path}", + resume=True, + ) + continue + bar.log(f"change '{old}' to '{new}' in generators.yaml{print_path}") + _replace(testdata, old, new) + changed = True + if "data" in data and data["data"]: + children = data["data"] if isinstance(data["data"], list) else [data["data"]] + for dictionary in children: + for child_name, child_data in sorted(dictionary.items()): + if generate.is_directory(child_data): + changed |= upgrade_generated_testdata_yaml( + child_data, path + "." + child_name + ) + return changed + + changed |= upgrade_generated_testdata_yaml(data, "") + + if changed: + write_yaml(data, generators_yaml) + + +def upgrade_statement(problem_path: Path, bar: ProgressBar) -> None: + if (problem_path / "problem_statement").is_dir(): + if (problem_path / "statement").exists(): + bar.error("can't rename 'problem_statement/', 'statement/' already exists", resume=True) + return + bar.log("renaming 'problem_statement/' to 'statement/'") + (problem_path / "problem_statement").rename(problem_path / "statement") + + origin = problem_path / "statement" + move = [ + ("solution*", "solution"), + ("problem-slide*", "problem_slide"), + ] + for glob, dest_name in move: + dest_path = problem_path / dest_name + if dest_path.exists() and not dest_path.is_dir(): + bar.error(f"'{dest_name}' is not a directory", resume=True) + continue + + for f in origin.glob(glob): + dest = dest_path / f.relative_to(origin) + if dest.exists(): + bar.error( + f"can't move '{f.relative_to(problem_path)}', '{dest.relative_to(problem_path)}' already exists", + resume=True, + ) + continue + bar.log(f"moving '{f.relative_to(problem_path)}' to '{dest.relative_to(problem_path)}'") + dest_path.mkdir(parents=True, exist_ok=True) + shutil.move(f, dest) + + +def upgrade_problem_yaml(problem_path: Path, bar: ProgressBar) -> None: + assert (problem_path / "problem.yaml").exists() + data = cast(CommentedMap, read_yaml(problem_path / "problem.yaml")) + assert data is not None + assert isinstance(data, dict) + + if ( + "problem_format_version" not in data + or data["problem_format_version"] != config.SPEC_VERSION + ): + bar.log("set 'problem_format_version' in problem.yaml") + data.insert(0, "problem_format_version", config.SPEC_VERSION) + + if "validation" in data: + if "type" in data: + bar.error( + "can't change 'validation', 'type' already exists in problem.yaml", resume=True + ) + else: + bar.log("change 'validation' to 'type' in problem.yaml") + type = CommentedSeq() + if "interactive" in data["validation"]: + type.append("interactive") + if "multi-pass" in data["validation"]: + type.append("multi-pass") + if not type: + type.append("pass-fail") + # "type" comes before "name" in the spec + pos = list(data.keys()).index("name") if "name" in data else 0 + data.insert(pos, "type", type if len(type) > 1 else type[0]) + _filter(data, "validation") + + if "author" in data: + if "credits" in data: + bar.error( + "can't change 'author', 'credits' already exists in problem.yaml", resume=True + ) + else: + bar.log("change 'author' to 'credits.authors' in problem.yaml") + authors = CommentedSeq( + name.strip() for name in data["author"].replace("and", ",").split(",") + ) + credits = CommentedMap({"authors": authors if len(authors) > 1 else authors[0]}) + _replace(data, "author", "credits", credits) + + if "source_url" in data: + if "source" not in data: + _replace(data, "source_url", "source") + elif data["source"]: + bar.log("change 'source_url' to 'source.url' in problem.yaml") + old_pos = list(data.keys()).index("source") + old_source = _filter(data, "source") + old_source_url = _filter(data, "source_url") + data.insert( + old_pos, "source", CommentedMap({"name": old_source, "url": old_source_url}) + ) + else: + bar.log("remove empty 'source(_url)' in problem.yaml") + _filter(data, "source") + _filter(data, "source_url") + + if "limits" in data: + limits = data["limits"] + if "time_multiplier" in limits or "time_safety_margin" in limits: + if "time_multipliers" in limits: + bar.error( + "can't change 'limits.time_multiplier/limits.time_safety_margin', 'limits.time_multipliers' already exists in problem.yaml", + resume=True, + ) + else: + bar.log( + "change 'limits.time_multiplier/limits.time_safety_margin' to 'limits.time_multipliers'" + ) + time_multipliers = CommentedMap() + + if "time_multiplier" in limits: + if limits["time_multiplier"] != 2: # Skip if it's equal to the new default + time_multipliers["ac_to_time_limit"] = limits["time_multiplier"] + _filter(limits, "time_multiplier") + + if "time_safety_margin" in limits: + if limits["time_safety_margin"] != 1.5: # Skip if it's equal to the new default + time_multipliers["time_limit_to_tle"] = limits["time_safety_margin"] + _filter(limits, "time_safety_margin") + + if time_multipliers: + limits["time_multipliers"] = time_multipliers + # If both time multipliers are default, remove the comments (this only works if + # there are no other limits configured, but that's the most common case anyway) + if not limits: + _filter(data, "limits") + + def add_args(new_data: dict[str, Any]) -> bool: + if "output_validator_args" in new_data: + bar.error( + "can't change 'validator_flags', 'output_validator_args' already exists in testdata.yaml", + resume=True, + ) + return False + bar.log("change 'validator_flags' to 'output_validator_args' in testdata.yaml") + new_data["output_validator_args"] = data["validator_flags"] + _filter(data, "validator_flags") + return True + + if "validator_flags" in data: + generators_path = problem_path / "generators" / "generators.yaml" + if generators_path.exists(): + generators_data = read_yaml(generators_path) + assert generators_data is not None + assert isinstance(generators_data, CommentedMap) + + if "testdata.yaml" not in generators_data: + if "data" in generators_data: + # insert before data + pos = list(generators_data.keys()).index("data") + generators_data.insert(pos, "testdata.yaml", CommentedMap()) + else: + # insert at end + generators_data["testdata.yaml"] = CommentedMap() + if add_args(generators_data["testdata.yaml"]): + write_yaml(generators_data, generators_path) + else: + testdata_path = problem_path / "data" / "testdata.yaml" + testdata_data = read_yaml(testdata_path) if testdata_path.exists() else CommentedMap() + assert testdata_data is not None + assert isinstance(testdata_data, dict) + + if add_args(testdata_data): + write_yaml(testdata_data, testdata_path) + + timelimit_path = problem_path / ".timelimit" + if timelimit_path.is_file(): + if "limits" not in data: + data["limits"] = CommentedMap() + if "time_limit" in data["limits"]: + bar.error( + "can't change '.timelimit' file, 'limits.time_limit' already exists in problem.yaml", + resume=True, + ) + else: + bar.log("change '.timelimit' file to 'limits.time_limit' in problem.yaml") + data["limits"]["time_limit"] = float(timelimit_path.read_text()) + timelimit_path.unlink() + + domjudge_path = problem_path / "domjudge-problem.ini" + if domjudge_path.is_file(): + time_limit = None + for line in domjudge_path.read_text().splitlines(): + key, var = map(str.strip, line.strip().split("=")) + if (var[0] == '"' or var[0] == "'") and (var[-1] == '"' or var[-1] == "'"): + var = var[1:-1] + if key == "timelimit": + time_limit = float(var) + if time_limit is not None: + if "limits" not in data: + data["limits"] = CommentedMap() + if "time_limit" in data["limits"]: + bar.error( + "can't change '.timelimit' file, 'limits.time_limit' already exists in problem.yaml", + resume=True, + ) + else: + bar.log("change 'domjudge-problem.ini' file to 'limits.time_limit' in problem.yaml") + data["limits"]["time_limit"] = time_limit + domjudge_path.unlink() + + write_yaml(data, problem_path / "problem.yaml") + + +def _upgrade(problem_path: Path, bar: ProgressBar) -> None: + bar.start(problem_path) + + upgrade_data(problem_path, bar) + upgrade_testdata_yaml(problem_path, bar) + upgrade_generators_yaml(problem_path, bar) + # upgrade_statement(problem_path, bar) TODO: activate this when we support the new statement dirs + # TODO: output_validators -> output_validator + upgrade_problem_yaml(problem_path, bar) + + bar.done() + + +def upgrade() -> None: + if not has_ryaml: + error("upgrade needs the ruamel.yaml python3 library. Install python[3]-ruamel.yaml.") + return + cwd = Path().cwd() + + def is_problem_directory(path): + return (path / "problem.yaml").is_file() + + if is_problem_directory(cwd): + paths = [cwd] + else: + paths = [p for p in cwd.iterdir() if is_problem_directory(p)] + + bar = ProgressBar("upgrade", items=paths) + for path in paths: + _upgrade(path, bar) + bar.finalize() diff --git a/bin/util.py b/bin/util.py index c658adc00..0ff476f2b 100644 --- a/bin/util.py +++ b/bin/util.py @@ -46,6 +46,7 @@ ryaml.default_flow_style = False ryaml.indent(mapping=2, sequence=4, offset=2) ryaml.width = sys.maxsize + ryaml.preserve_quotes = True except Exception: has_ryaml = False @@ -796,6 +797,15 @@ def parse_optional_list_setting(yaml_data: dict[str, Any], key: str, t: type[T]) return [] +def parse_deprecated_setting( + yaml_data: dict[str, Any], key: str, new: Optional[str] = None +) -> None: + if key in yaml_data: + use = f", use '{new}' instead" if new else "" + warn(f"key '{key}' is deprecated{use}. SKIPPED.") + yaml_data.pop(key) + + # glob, but without hidden files def glob(path: Path, expression: str, include_hidden: bool = False) -> list[Path]: def keep(p: Path) -> bool: diff --git a/test/problems/boolfind/domjudge-problem.ini b/test/problems/boolfind/domjudge-problem.ini deleted file mode 100644 index e90ee3538..000000000 --- a/test/problems/boolfind/domjudge-problem.ini +++ /dev/null @@ -1,5 +0,0 @@ -probid='A' -allow_submit='1' -allow_judge='1' -timelimit='1' -color='#FFFFFF' diff --git a/test/problems/boolfind/problem.yaml b/test/problems/boolfind/problem.yaml index 8f103f71c..400e10cd2 100644 --- a/test/problems/boolfind/problem.yaml +++ b/test/problems/boolfind/problem.yaml @@ -1,26 +1,11 @@ +problem_format_version: 2023-07-draft +type: interactive name: boolfind -author: DOMjudge -# BAPC 2020 -source: -# 2020.bapc.eu -source_url: +credits: + authors: DOMjudge uuid: 8f7ed1ba-43f5-424e-9af4-8a5f2e428ce3 license: unknown rights_owner: -# 'default', 'custom', or 'interactive' -validation: custom interactive -# One or more of: -# case_sensitive -# space_change_sensitive -# float_absolute_tolerance eps -# float_relative_tolerance eps -# float_tolerance eps -#validator_flags: - -# To change the time limit factors for Kattis, use: -# limits: -# Time limit is 2*slowest accepted submission: -# time_multiplier: 2 -# Warning for submissions within 1 second of limit: -# time_safety_margin: 1 +limits: + time_limit: 1.0 diff --git a/test/problems/different/problem.yaml b/test/problems/different/problem.yaml index 48ed7e265..dcaa03958 100644 --- a/test/problems/different/problem.yaml +++ b/test/problems/different/problem.yaml @@ -1,16 +1,14 @@ # problem.yaml +problem_format_version: 2023-07-draft +type: pass-fail name: en: A Different Problem ## At least one of author, source, or rights_owner must be provided. -## -## Author of the problem (default: null) -# author: ## Where the problem was first used (default: null) source: Kattis -# source_url: # Unique problem uuid uuid: FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF @@ -19,97 +17,11 @@ uuid: FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF ## value of source if no author given). # rights_owner: -## License (see below for list of possible values) license: cc by-sa -## Some keywords describing the problem (default: empty) -# keywords: - -# Indicate that we use a custom output validator instead of the -# default token-based diff. -validation: custom -# validator_flags: float_tolerance 1e-4 - # Override standard limits: say that the TLE solutions provided should # be at least 4 times above the time limit in order for us to be # happy. limits: -# time_multiplier: 5 - time_safety_margin: 4 # (default is 2) -# memory: 1024 # MB -# output: 8 # MB -# compilation_time: 60 # seconds -# validation_time: 60 # seconds -# validation_memory: 1024 # MB -# validation_output: 8 # MB - - -############################################################################ -# POSSIBLE VALUES FOR LICENSE: -# -# "unknown" The default value. In practice means that the -# problem can not be used. -# "public domain" There are no known copyrights on the problem, -# anywhere in the world. -# http://creativecommons.org/about/pdm -# "cc0" CC0, "no rights reserved" -# http://creativecommons.org/about/cc0 -# "cc by" CC attribution -# http://creativecommons.org/licenses/by/3.0/ -# "cc by-sa" CC attribution, share alike -# http://creativecommons.org/licenses/by-sa/3.0/ -# "educational" May be freely used for educational purposes -# "permission" Used with permission. The author must be contacted -# for every additional use. -############################################################################ - - -############################################################################ -# OUTPUT VALIDATOR OPTIONS -# -# There is a relatively versatile default validator available that is -# sufficient for most problems. If the problem needs a custom output -# validator, the validation field should be set to "custom". The -# validator_flags field is just a list of command line arguments that -# are passed on to the validator program used (whether it be the -# default validator or a custom validator). -############################################################################ - - -############################################################################ -# DESCRIPTION OF DEFAULT VALIDATOR OPTIONS -# -# The default validator is essentially a beefed-up diff. In its default -# mode, it tokenizes the two files and compares token by token. It -# supports the following command-line arguments to control how tokens -# are compared. -# -# o case_sensitive -# indicates that comparisons should be case-sensitive -# o space_change_sensitive -# indicates that changes in the amount of whitespace should -# be rejected (the de- fault is that any sequence of 1 or more -# whitespace characters are equivalent). -# o float_relative_tolerance eps -# indicates that floating-point tokens should be accepted if -# they are within relative error <= eps -# o float_absolute_tolerance eps -# indicates that floating-point tokens should be accepted if -# they are within absolute error <= eps -# o float_tolerance eps -# short-hand for applying eps as both relative and absolute -# tolerance. -# -# Note that when supplying both a relative and an absolute tolerance, -# the semantics are that a token is accepted if it is within either of -# the two tolerances. -# -# When a floating-point tolerance has been set, any valid formatting -# of floating point numbers is accepted for floating point tokens. So -# for instance if a token in the answer file says 0.0314, a token of -# 3.14000000e-2 in the output file would be accepted (but note that -# this applies *only* to floating point tokens, so "2.0e2" would *not* -# be a correct output if the answer file says "200"). If no floating -# point tolerance has been set, floating point tokens are treated just -# like any other token and has to match exactly. -############################################################################ + time_multipliers: + time_limit_to_tle: 4 diff --git a/test/problems/divsort/generators/generators.yaml b/test/problems/divsort/generators/generators.yaml index 6775d7ebf..5f44912f6 100644 --- a/test/problems/divsort/generators/generators.yaml +++ b/test/problems/divsort/generators/generators.yaml @@ -12,8 +12,7 @@ data: data: integers: testdata.yaml: - input_validator_args: - --integer + input_validator_args: --integer #grading: foo data: - unsorted-integer: @@ -60,12 +59,12 @@ data: data: always_invalid: data: - too_many_tokens: { in: 10.0 2.5 ab cd ef } + too_many_tokens: {in: 10.0 2.5 ab cd ef} integers: testdata.yaml: input_validator_args: --integer data: - ints_expected: { in: 10.0 2.5 ab cd } + ints_expected: {in: 10.0 2.5 ab cd} include: - small_floats sorted: diff --git a/test/problems/divsort/problem.yaml b/test/problems/divsort/problem.yaml index bb6bfedaf..6a0c0092e 100644 --- a/test/problems/divsort/problem.yaml +++ b/test/problems/divsort/problem.yaml @@ -1,3 +1,4 @@ +problem_format_version: 2023-07-draft name: Division and sorting uuid: 8ee7605a-a0ba-8ce8-2a91-a6192b70141f license: unknown diff --git a/test/problems/fltcmp/data/testdata.yaml b/test/problems/fltcmp/data/testdata.yaml new file mode 100644 index 000000000..af7ce1f19 --- /dev/null +++ b/test/problems/fltcmp/data/testdata.yaml @@ -0,0 +1 @@ +output_validator_args: float_tolerance 1E-6 diff --git a/test/problems/fltcmp/domjudge-problem.ini b/test/problems/fltcmp/domjudge-problem.ini deleted file mode 100644 index e90ee3538..000000000 --- a/test/problems/fltcmp/domjudge-problem.ini +++ /dev/null @@ -1,5 +0,0 @@ -probid='A' -allow_submit='1' -allow_judge='1' -timelimit='1' -color='#FFFFFF' diff --git a/test/problems/fltcmp/problem.yaml b/test/problems/fltcmp/problem.yaml index b474cc94e..416dc982a 100644 --- a/test/problems/fltcmp/problem.yaml +++ b/test/problems/fltcmp/problem.yaml @@ -1,26 +1,11 @@ +problem_format_version: 2023-07-draft +type: pass-fail name: fltcmp -author: DOMjudge -# BAPC 2020 -source: -# 2020.bapc.eu -source_url: +credits: + authors: DOMjudge uuid: 407efad0-da0d-49a4-b925-329e929bc990 license: unknown rights_owner: -# 'default', 'custom', or 'interactive' -validation: default -# One or more of: -# case_sensitive -# space_change_sensitive -# float_absolute_tolerance eps -# float_relative_tolerance eps -# float_tolerance eps -validator_flags: float_tolerance 1E-6 - -# To change the time limit factors for Kattis, use: -# limits: -# Time limit is 2*slowest accepted submission: -# time_multiplier: 2 -# Warning for submissions within 1 second of limit: -# time_safety_margin: 1 +limits: + time_limit: 1.0 diff --git a/test/problems/generatorincludes/.timelimit b/test/problems/generatorincludes/.timelimit deleted file mode 100644 index d00491fd7..000000000 --- a/test/problems/generatorincludes/.timelimit +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/test/problems/generatorincludes/problem.yaml b/test/problems/generatorincludes/problem.yaml index ffbda47d4..a07282843 100644 --- a/test/problems/generatorincludes/problem.yaml +++ b/test/problems/generatorincludes/problem.yaml @@ -1,26 +1,14 @@ +problem_format_version: 2023-07-draft +type: pass-fail name: generatorincludes -author: Thore Husfeldt -# Contest name and year -source: Problems -# contest.region.eu -source_url: +credits: + authors: Thore Husfeldt +source: + name: Problems + url: uuid: 745cc994-4c3d-40cf-97c2-cc2a72af1884 license: cc by-sa rights_owner: author -# 'default', 'custom', or 'custom interactive' -validation: default -# One or more of: -# case_sensitive -# space_change_sensitive -# float_absolute_tolerance eps -# float_relative_tolerance eps -# float_tolerance eps -#validator_flags: - -# To change the time limit factors for problemtools/Kattis, use: -# limits: -# Time limit is 2*slowest accepted submission: (default: 5) -# time_multiplier: 2 -# Warning for submissions within 50% of time limit -# time_safety_margin: 1.5 +limits: + time_limit: 1.0 diff --git a/test/problems/guess/problem.yaml b/test/problems/guess/problem.yaml index fad6f7ef0..a176263f2 100644 --- a/test/problems/guess/problem.yaml +++ b/test/problems/guess/problem.yaml @@ -1,13 +1,14 @@ +problem_format_version: 2023-07-draft +type: interactive name: en: Guess the Number source: Kattis uuid: 4c1ca09b-af36-4cb6-82ec-2cd029c02a6a license: cc by-sa -validation: custom interactive - # Override standard limits: say that the TLE solutions provided should # be at least 4 times above the time limit in order for us to be # happy. limits: - time_safety_margin: 4 + time_multipliers: + time_limit_to_tle: 4 diff --git a/test/problems/guessnoeofcheck/problem.yaml b/test/problems/guessnoeofcheck/problem.yaml index cf3967e9e..4c20d8142 100644 --- a/test/problems/guessnoeofcheck/problem.yaml +++ b/test/problems/guessnoeofcheck/problem.yaml @@ -1,10 +1,12 @@ +type: interactive +problem_format_version: 2023-07-draft source: Kattis uuid: 1bf54011-8f0f-44fb-8030-15d9c1583979 license: cc by-sa -validation: custom interactive # Override standard limits: say that the TLE solutions provided should # be at least 4 times above the time limit in order for us to be # happy. limits: - time_safety_margin: 4 + time_multipliers: + time_limit_to_tle: 4 diff --git a/test/problems/hello/domjudge-problem.ini b/test/problems/hello/domjudge-problem.ini deleted file mode 100644 index d7ce01539..000000000 --- a/test/problems/hello/domjudge-problem.ini +++ /dev/null @@ -1,5 +0,0 @@ -probid='A' -allow_submit='1' -allow_judge='1' -timelimit='3' -color='#FFFFFF' diff --git a/test/problems/hello/problem.yaml b/test/problems/hello/problem.yaml index 50bad5079..a0c9b836c 100644 --- a/test/problems/hello/problem.yaml +++ b/test/problems/hello/problem.yaml @@ -1,26 +1,11 @@ +problem_format_version: 2023-07-draft +type: pass-fail name: hello -author: DOMjudge -# BAPC 2020 -source: -# 2020.bapc.eu -source_url: +credits: + authors: DOMjudge uuid: 323a5d9c-b38a-4110-8483-2846c920c1ee license: unknown rights_owner: -# 'default', 'custom', or 'interactive' -validation: default -# One or more of: -# case_sensitive -# space_change_sensitive -# float_absolute_tolerance eps -# float_relative_tolerance eps -# float_tolerance eps -#validator_flags: - -# To change the time limit factors for Kattis, use: -# limits: -# Time limit is 2*slowest accepted submission: -# time_multiplier: 2 -# Warning for submissions within 1 second of limit: -# time_safety_margin: 1 +limits: + time_limit: 3.0 diff --git a/test/problems/helloproblemtools/domjudge-problem.ini b/test/problems/helloproblemtools/domjudge-problem.ini deleted file mode 100644 index 7f11bbc4f..000000000 --- a/test/problems/helloproblemtools/domjudge-problem.ini +++ /dev/null @@ -1 +0,0 @@ -timelimit='2' diff --git a/test/problems/helloproblemtools/problem.yaml b/test/problems/helloproblemtools/problem.yaml index f3792eab2..89ccbed44 100644 --- a/test/problems/helloproblemtools/problem.yaml +++ b/test/problems/helloproblemtools/problem.yaml @@ -1,3 +1,4 @@ +problem_format_version: 2023-07-draft name: en: Hello World! sv: Hej Världen! @@ -11,3 +12,4 @@ license: public domain # a test submission that goes over this limit.) limits: memory: 512 + time_limit: 2.0 diff --git a/test/problems/hellounix/.timelimit b/test/problems/hellounix/.timelimit deleted file mode 100644 index 00750edc0..000000000 --- a/test/problems/hellounix/.timelimit +++ /dev/null @@ -1 +0,0 @@ -3 diff --git a/test/problems/hellounix/problem.yaml b/test/problems/hellounix/problem.yaml index eb9a5dfec..8b4cecc05 100644 --- a/test/problems/hellounix/problem.yaml +++ b/test/problems/hellounix/problem.yaml @@ -1,27 +1,14 @@ +problem_format_version: 2023-07-draft +type: pass-fail name: hellounix -author: various +credits: + authors: various # Various tests whose behaviour is only consistent on # unix-y operating systems -source: -source_url: uuid: 20798d22-3227-4e48-9877-7f73d3d3236e license: unknown rights_owner: -validation: default -# One or more of: -# case_sensitive -# space_change_sensitive -# float_absolute_tolerance eps -# float_relative_tolerance eps -# float_tolerance eps -#validator_flags: - -# To change the time limit factors for Kattis, use: -# limits: -# Time limit is 2*slowest accepted submission: -# time_multiplier: 2 -# Warning for submissions within 1 second of limit: -# time_safety_margin: 1 limits: memory: 512 + time_limit: 3.0 diff --git a/test/problems/hellowholeworld/problem.yaml b/test/problems/hellowholeworld/problem.yaml index ea8b4d13c..c847c1cce 100644 --- a/test/problems/hellowholeworld/problem.yaml +++ b/test/problems/hellowholeworld/problem.yaml @@ -1,7 +1,9 @@ +problem_format_version: 2023-07-draft +type: pass-fail name: en: Hello, Whole World! de: Hallo, ganze Welt! da: Hej, hele verden! -author: Thore Husfeldt +credits: + authors: Thore Husfeldt uuid: c7c28c31-809a-400c-84ae-f6a3b29a217a -validation: default diff --git a/test/problems/identity/.timelimit b/test/problems/identity/.timelimit deleted file mode 100644 index d00491fd7..000000000 --- a/test/problems/identity/.timelimit +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/test/problems/identity/generators/generators.yaml b/test/problems/identity/generators/generators.yaml index 5b454ee07..0ee583e2d 100644 --- a/test/problems/identity/generators/generators.yaml +++ b/test/problems/identity/generators/generators.yaml @@ -199,10 +199,10 @@ data: count_group: data: generate: - generate: stdout.py 704 - count: 3 + generate: stdout.py 704 + count: 3 seed: - generate: random_gen.py {seed:10} - count: 3 + generate: random_gen.py {seed:10} + count: 3 unknown_key: diff --git a/test/problems/identity/problem.yaml b/test/problems/identity/problem.yaml index a5170d17a..40a4aea69 100644 --- a/test/problems/identity/problem.yaml +++ b/test/problems/identity/problem.yaml @@ -1,24 +1,11 @@ +problem_format_version: 2023-07-draft +type: pass-fail name: Identity -author: Ragnar Groot Koerkamp -source: -source_url: +credits: + authors: Ragnar Groot Koerkamp uuid: a7d29d67-9b0b-4fd4-ae56-ab2cad5919ab license: unknown rights_owner: -# 'default', 'custom', or 'interactive' -validation: default -# One or more of: -# case_sensitive -# space_change_sensitive -# float_absolute_tolerance eps -# float_relative_tolerance eps -# float_tolerance eps -#validator_flags: - -# To change the time limit factors for Kattis, use: -# limits: -# Time limit is 2*slowest accepted submission: -# time_multiplier: 2 -# Warning for submissions within 1 second of limit: -# time_safety_margin: 1 +limits: + time_limit: 1.0 diff --git a/test/problems/interactivemultipass/problem.yaml b/test/problems/interactivemultipass/problem.yaml index 184568f32..c71be23ed 100644 --- a/test/problems/interactivemultipass/problem.yaml +++ b/test/problems/interactivemultipass/problem.yaml @@ -1,8 +1,10 @@ +problem_format_version: 2023-07-draft +type: + - interactive + - multi-pass name: interactive multi-pass -author: Michael Zündorf -source: -source_url: +credits: + authors: Michael Zündorf uuid: 42c9ed2e-f579-46ac-ae2c-191069f3df70 license: unknown rights_owner: -validation: custom interactive multi-pass diff --git a/test/problems/multipass/problem.yaml b/test/problems/multipass/problem.yaml index 0cf04635e..500eb82bc 100644 --- a/test/problems/multipass/problem.yaml +++ b/test/problems/multipass/problem.yaml @@ -1,8 +1,8 @@ +problem_format_version: 2023-07-draft +type: multi-pass name: multi-pass -author: Michael Zündorf -source: -source_url: +credits: + authors: Michael Zündorf uuid: 71076b69-e9c2-4227-ba54-ec3d4e277c78 license: unknown rights_owner: -validation: custom multi-pass diff --git a/test/problems/test_problem_config/domjudge-problem.ini b/test/problems/test_problem_config/domjudge-problem.ini deleted file mode 100644 index 04ad55730..000000000 --- a/test/problems/test_problem_config/domjudge-problem.ini +++ /dev/null @@ -1 +0,0 @@ -timelimit = '3' diff --git a/test/problems/test_problem_config/problem.yaml b/test/problems/test_problem_config/problem.yaml deleted file mode 100644 index 537c2d800..000000000 --- a/test/problems/test_problem_config/problem.yaml +++ /dev/null @@ -1,3 +0,0 @@ -name: 'ABC XYZ' -validation: 'custom' -uuid: 58c89b2d-616c-4291-ab8a-710b4e6cb978 diff --git a/test/problems/testproblemconfig/output_validators/.gitkeep b/test/problems/testproblemconfig/output_validators/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/test/problems/testproblemconfig/problem.yaml b/test/problems/testproblemconfig/problem.yaml new file mode 100644 index 000000000..e5342f032 --- /dev/null +++ b/test/problems/testproblemconfig/problem.yaml @@ -0,0 +1,6 @@ +problem_format_version: 2023-07-draft +type: pass-fail +name: 'ABC XYZ' +uuid: 58c89b2d-616c-4291-ab8a-710b4e6cb978 +limits: + time_limit: 3.0 diff --git a/test/problems/test_problem_config/problem_statement/problem.en.tex b/test/problems/testproblemconfig/problem_statement/problem.en.tex similarity index 100% rename from test/problems/test_problem_config/problem_statement/problem.en.tex rename to test/problems/testproblemconfig/problem_statement/problem.en.tex diff --git a/test/test_problems.py b/test/test_problems.py index dd79da11b..e8d12f36d 100644 --- a/test/test_problems.py +++ b/test/test_problems.py @@ -247,7 +247,7 @@ def test_new_contest_problem(self, monkeypatch): class TestReadProblemConfig: def test_read_problem_config(self): - p = problem.Problem(RUN_DIR / "test/problems/test_problem_config", Path("/tmp/xyz")) + p = problem.Problem(RUN_DIR / "test/problems/testproblemconfig", Path("/tmp/xyz")) assert p.settings.name["en"] == "ABC XYZ" assert p.custom_output and not p.interactive and not p.multi_pass assert p.limits.time_limit == 3.0 diff --git a/test/yaml/problem/invalid.yaml b/test/yaml/problem/invalid.yaml index 630e52b05..70ce6f805 100644 --- a/test/yaml/problem/invalid.yaml +++ b/test/yaml/problem/invalid.yaml @@ -1,6 +1,7 @@ --- # Unknown keys yaml: + problem_format_version: 2023-07-draft mumbo: jumbo warn: "found unknown problem.yaml key: mumbo in root" --- @@ -11,11 +12,13 @@ yaml: warn: "found unknown problem.yaml key: mumbo in `credits`" --- yaml: + problem_format_version: 2023-07-draft limits: mumbo: jumbo warn: "found unknown problem.yaml key: mumbo in `limits`" --- yaml: + problem_format_version: 2023-07-draft limits: time_multipliers: mumbo: jumbo @@ -24,10 +27,12 @@ warn: "found unknown problem.yaml key: mumbo in `limits.time_multipliers`" --- # Name yaml: + problem_format_version: 2023-07-draft name: 42 warn: incompatible value for key 'name' in problem.yaml. SKIPPED. --- yaml: + problem_format_version: 2023-07-draft name: en: 42 warn: incompatible value for key 'en' in problem.yaml. SKIPPED. @@ -35,9 +40,11 @@ warn: incompatible value for key 'en' in problem.yaml. SKIPPED. --- # Validation/type yaml: + problem_format_version: 2023-07-draft name: Incorrect validation validation: mumbo-jumbo -fatal: "problem.yaml: unrecognized validation mode mumbo-jumbo." +warn: + - "key 'validation' is deprecated, use 'type' instead. SKIPPED." --- yaml: problem_format_version: 2023-07-draft @@ -50,7 +57,7 @@ yaml: name: Deprecated validation validation: interactive warn: - - "problem.yaml: 'validation' is removed in 2023-07-draft, please use 'type' instead. SKIPPED." + - "key 'validation' is deprecated, use 'type' instead. SKIPPED." --- yaml: problem_format_version: 2023-07-draft diff --git a/test/yaml/problem/valid.yaml b/test/yaml/problem/valid.yaml index bdcea9de7..96a303261 100644 --- a/test/yaml/problem/valid.yaml +++ b/test/yaml/problem/valid.yaml @@ -1,30 +1,26 @@ --- # Problem name tests yaml: + problem_format_version: 2023-07-draft name: Minimal eq: + problem_format_version: 2023-07-draft name: en: Minimal --- yaml: + problem_format_version: 2023-07-draft name: en: Minimal --- yaml: + problem_format_version: 2023-07-draft name: en: Minimal nl: Minimaal --- -# Problem validation/type tests -yaml: - name: custom validation - validation: custom -eq: - custom_output: True - interactive: False - multi_pass: False ---- +# Problem type tests yaml: problem_format_version: 2023-07-draft name: pass-fail type @@ -89,22 +85,6 @@ eq: --- # Credits tests -yaml: - author: A. U. Thor -eq: - credits: - authors: - - name: A. U. Thor - email: ~ ---- -yaml: - author: A. U. Thor -eq: - credits: - authors: - - name: A. U. Thor - email: author@example.com ---- yaml: problem_format_version: 2023-07-draft credits: A. U. Thor From 824fc5b318e85192de3694bf0290f69cba1706a7 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Thu, 6 Mar 2025 14:41:48 +0100 Subject: [PATCH 03/23] Split up `problem_statement/` into `statement/`, `solution/`, and `problem_slide/` (#434) * use new paths * update latex template files * update skel * ran bt upgrade * remove stem call * implemented suggestions * export more stuff * Rewrite problem_statement to statement/solution/problem_slide in documentation * [export] Move files in solution/ or problem_slide/ to problem_statement/, not statement/ * use pdfType everywhere * dont create empty keys * return empty list on error * update glob patterns --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- bin/constraints.py | 5 +- bin/export.py | 23 ++++++- bin/latex.py | 61 ++++++++++--------- bin/problem.py | 19 +++--- bin/skel.py | 11 ++-- bin/stats.py | 5 +- bin/tools.py | 8 +-- bin/upgrade.py | 59 ++++++++++-------- doc/commands.md | 6 +- doc/implementation_notes.md | 10 +-- doc/multiple_languages.md | 8 +-- latex/contest-problem-slide.tex | 4 +- latex/contest-problem.tex | 4 +- latex/contest-solution.tex | 4 +- latex/problem-slide.tex | 4 +- latex/problem.tex | 4 +- latex/solution-web.tex | 4 +- latex/solution.tex | 4 +- readme.md | 2 +- skel/gitlab_ci/problem.yaml | 4 +- .../problem-slide.en.tex | 0 .../solution.en.tex | 0 .../problem.en.tex | 0 .../solution.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem-slide.en.tex | 0 .../solution.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.sv.tex | 0 .../problem.en.tex | 0 .../solution.en.tex | 0 .../problem.da.tex | 0 .../problem.en.tex | 0 .../problem.sv.tex | 0 .../problem-slide.en.tex | 0 .../solution.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 .../problem.en.tex | 0 49 files changed, 141 insertions(+), 108 deletions(-) rename skel/problem/{problem_statement => problem_slide}/problem-slide.en.tex (100%) rename skel/problem/{problem_statement => solution}/solution.en.tex (100%) rename skel/problem/{problem_statement => statement}/problem.en.tex (100%) rename skel/problem_cfp/{problem_statement => solution}/solution.en.tex (100%) rename skel/problem_cfp/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/boolfind/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/constants/{problem_statement => problem_slide}/problem-slide.en.tex (100%) rename test/problems/constants/{problem_statement => solution}/solution.en.tex (100%) rename test/problems/constants/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/different/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/divsort/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/fltcmp/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/generatorincludes/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/guess/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/guessnoeofcheck/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/hello/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/helloproblemtools/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/helloproblemtools/{problem_statement => statement}/problem.sv.tex (100%) rename test/problems/hellounix/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/hellowholeworld/{problem_statement => solution}/solution.en.tex (100%) rename test/problems/hellowholeworld/{problem_statement => statement}/problem.da.tex (100%) rename test/problems/hellowholeworld/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/hellowholeworld/{problem_statement => statement}/problem.sv.tex (100%) rename test/problems/identity/{problem_statement => problem_slide}/problem-slide.en.tex (100%) rename test/problems/identity/{problem_statement => solution}/solution.en.tex (100%) rename test/problems/identity/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/interactivemultipass/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/multipass/{problem_statement => statement}/problem.en.tex (100%) rename test/problems/testproblemconfig/{problem_statement => statement}/problem.en.tex (100%) diff --git a/bin/constraints.py b/bin/constraints.py index a8bdfa751..06ae5e1cb 100644 --- a/bin/constraints.py +++ b/bin/constraints.py @@ -1,6 +1,7 @@ import re from collections import defaultdict +import latex import validate from colorama import Fore, Style @@ -45,7 +46,7 @@ def f(cs): def check_statement(problem, language): - statement_file = problem.path / f"problem_statement/problem.{language}.tex" + statement_file = problem.path / latex.PdfType.PROBLEM.path(language) statement = statement_file.read_text() statement_values = set() @@ -170,7 +171,7 @@ def parse_command(): # 3) if a section starts parse that (and ensure that no environment is active) # 4) if an environment begins parse that (and ensure that no other environment is active) # 5) if a new define starts parse that - # 6) if inline math starts in an input/ouput part parse it as constraint + # 6) if inline math starts in an input/output part parse it as constraint while pos < len(statement): if statement[pos] == "%": next = statement.find("\n", pos) diff --git a/bin/export.py b/bin/export.py index 5b25b75a6..97a79e3e4 100644 --- a/bin/export.py +++ b/bin/export.py @@ -97,7 +97,7 @@ def build_problem_zip(problem: Problem, output: Path): files = [ ("problem.yaml", True), - ("problem_statement/*", True), + ("statement/*", True), ("submissions/accepted/**/*", True), ("submissions/*/**/*", False), ("attachments/**/*", problem.interactive or problem.multi_pass), @@ -206,7 +206,7 @@ def add_file(path, source): # Replace \problemname{...} by the value of `name:` in problems.yaml in all .tex files. # This is needed because Kattis is currently still running the legacy version of the problem spec, # rather than 2023-07-draft. - for f in (export_dir / "problem_statement").iterdir(): + for f in (export_dir / "statement").iterdir(): if f.is_file() and f.suffix == ".tex" and len(f.suffixes) >= 2: lang = f.suffixes[-2][1:] t = f.read_text() @@ -226,7 +226,7 @@ def add_file(path, source): "data/**/testdata.yaml", "output_validators/**/*", "input_validators/**/*", - # "problem_statement/*", uses \constants + # "statement/*", uses \constants # "submissions/*/**/*", removed support? ] for pattern in constants_supported: @@ -242,6 +242,23 @@ def add_file(path, source): f.unlink() f.write_text(text) + # TODO: Remove this if we know others import the statement folder + if (export_dir / "statement").exists(): + (export_dir / "statement").rename(export_dir / "problem_statement") + for d in ["solution", "problem_slide"]: + for f in list(util.glob(problem.path, f"{d}/*")): + if f.is_file(): + out = Path("problem_statement") / f.relative_to(problem.path / d) + if out.exists(): + message( + f"Can not export {f.relative_to(problem.path)} as {out}", + "Zip", + output, + color_type=MessageType.WARN, + ) + else: + add_file(out, f) + # Build .ZIP file. message("writing zip file", "Zip", output, color_type=MessageType.LOG) try: diff --git a/bin/latex.py b/bin/latex.py index 637b64235..30bdac4c1 100644 --- a/bin/latex.py +++ b/bin/latex.py @@ -6,13 +6,12 @@ import sys from enum import Enum from pathlib import Path -from typing import Optional +from typing import Optional, TYPE_CHECKING from colorama import Fore, Style import config from contest import contest_yaml, problems_yaml -import problem from util import ( copy_and_substitute, ensure_symlink, @@ -26,20 +25,27 @@ warn, ) +if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 + from problem import Problem -class PdfType(str, Enum): - PROBLEM = "problem" - PROBLEM_SLIDE = "problem-slide" - SOLUTION = "solution" +class PdfType(Enum): + PROBLEM = Path("statement") / "problem" + PROBLEM_SLIDE = Path("problem_slide") / "problem-slide" + SOLUTION = Path("solution") / "solution" -def latex_builddir(problem: "problem.Problem", language: str) -> Path: + def path(self, lang: Optional[str] = None, ext: str = ".tex") -> Path: + lang = f".{lang}" if lang is not None else "" + return self.value.with_name(f"{self.value.name}{lang}{ext}") + + +def latex_builddir(problem: "Problem", language: str) -> Path: builddir = problem.tmpdir / "latex" / language builddir.mkdir(parents=True, exist_ok=True) return builddir -def create_samples_file(problem: "problem.Problem", language: str) -> None: +def create_samples_file(problem: "Problem", language: str) -> None: builddir = latex_builddir(problem, language) # create the samples.tex file @@ -164,7 +170,7 @@ def flush(): samples_file_path.write_text("".join(samples_data)) -def create_constants_file(problem: "problem.Problem", language: str) -> None: +def create_constants_file(problem: "Problem", language: str) -> None: constant_data: list[str] = [] for key, item in problem.settings.constants.items(): constant_data.append(f"\\expandafter\\def\\csname constants_{key}\\endcsname{{{item}}}\n") @@ -175,12 +181,12 @@ def create_constants_file(problem: "problem.Problem", language: str) -> None: # Steps needed for both problem and contest compilation. -def prepare_problem(problem: "problem.Problem", language: str): +def prepare_problem(problem: "Problem", language: str): create_samples_file(problem, language) create_constants_file(problem, language) -def get_tl(problem: "problem.Problem"): +def get_tl(problem: "Problem"): tl = problem.limits.time_limit tl = int(tl) if abs(tl - int(tl)) < 0.0001 else tl @@ -194,7 +200,7 @@ def get_tl(problem: "problem.Problem"): return tl if print_tl else "" -def problem_data(problem: "problem.Problem", language: str): +def problem_data(problem: "Problem", language: str): background = next( ( p["rgb"][1:] @@ -363,17 +369,14 @@ def run_latexmk(stdout, stderr): # substituting variables. # 2. Create tmpdir//latex//{samples,constants}.tex. # 3. Run latexmk and link the resulting ..pdf into the problem directory. -def build_problem_pdf( - problem: "problem.Problem", language: str, build_type=PdfType.PROBLEM, web=False -): +def build_problem_pdf(problem: "Problem", language: str, build_type=PdfType.PROBLEM, web=False): """ Arguments: -- language: str, the two-letter language code appearing the file name, such as problem.en.tex """ - main_file = build_type.value - main_file += "-web.tex" if web else ".tex" + main_file = build_type.path(ext="-web.tex" if web else ".tex").name - bar = PrintBar(f"{main_file[:-3]}{language}.pdf") + bar = PrintBar(f"{main_file[:-4]}.{language}.pdf") bar.log(f"Building PDF for language {language}") prepare_problem(problem, language) @@ -391,7 +394,7 @@ def build_problem_pdf( return build_latex_pdf(builddir, builddir / main_file, language, bar, problem.path) -def build_problem_pdfs(problem: "problem.Problem", build_type=PdfType.PROBLEM, web=False): +def build_problem_pdfs(problem: "Problem", build_type=PdfType.PROBLEM, web=False): """Build PDFs for various languages. If list of languages is specified, (either via config files or --language arguments), build those. Otherwise build all languages for which there is a statement latex source. @@ -411,11 +414,11 @@ def build_problem_pdfs(problem: "problem.Problem", build_type=PdfType.PROBLEM, w if build_type != PdfType.PROBLEM: filtered_languages = [] for lang in languages: - if (problem.path / "problem_statement" / f"{build_type.value}.{lang}.tex").exists(): + if (problem.path / build_type.path(lang)).exists(): filtered_languages.append(lang) else: message( - f"{build_type.value}.{lang}.tex not found", + f"{build_type.path(lang)} not found", problem.name, color_type=MessageType.WARN, ) @@ -436,7 +439,7 @@ def find_logo() -> Path: def build_contest_pdf( contest: str, - problems: list["problem.Problem"], + problems: list["Problem"], tmpdir: Path, language: str, build_type=PdfType.PROBLEM, @@ -491,11 +494,11 @@ def build_contest_pdf( elif headertex.exists(): problems_data += f"\\input{{{headertex}}}\n" - local_per_problem_data = Path(f"contest-{build_type.value}.tex") + local_per_problem_data = Path(f"contest-{build_type.path().name}") per_problem_data_tex = ( local_per_problem_data if local_per_problem_data.is_file() - else config.TOOLS_ROOT / "latex" / f"contest-{build_type.value}.tex" + else config.TOOLS_ROOT / "latex" / local_per_problem_data.name ).read_text() for prob in problems: @@ -503,19 +506,19 @@ def build_contest_pdf( prepare_problem(prob, language) else: # i.e. for SOLUTION and PROBLEM_SLIDE create_constants_file(prob, language) - tex_no_lang = prob.path / "problem_statement" / f"{build_type.value}.tex" - tex_with_lang = prob.path / "problem_statement" / f"{build_type.value}.{language}.tex" + tex_no_lang = prob.path / build_type.path() + tex_with_lang = prob.path / build_type.path(language) if tex_with_lang.is_file(): # All is good pass elif tex_no_lang.is_file(): bar.warn( - f"Rename {build_type.value}.tex to {build_type.value}.{language}.tex", + f"Rename {tex_no_lang.name} to {tex_with_lang.name}", prob.name, ) continue else: - bar.warn(f"{build_type.value}.{language}.tex not found", prob.name) + bar.warn(f"{tex_with_lang.name} not found", prob.name) continue problems_data += substitute( @@ -533,7 +536,7 @@ def build_contest_pdf( elif footertex.exists(): problems_data += f"\\input{{{footertex}}}\n" - (builddir / f"contest-{build_type.value}s.tex").write_text(problems_data) + (builddir / f"contest-{build_type.path(ext='s.tex').name}").write_text(problems_data) return build_latex_pdf(builddir, Path(main_file), language, bar) diff --git a/bin/problem.py b/bin/problem.py index 26dd511c6..43aa69a53 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -334,30 +334,31 @@ def _determine_statement_languages(self): """ yamllangs = set(self.settings.name) texlangs = set( - path.suffixes[0][1:] for path in glob(self.path, "problem_statement/problem.*.tex") + path.suffixes[0][1:] for path in glob(self.path, str(latex.PdfType.PROBLEM.path("*"))) ) for lang in texlangs - yamllangs: error( - f"{self.name}: Found problem.{lang}.tex, but no corresponding name in problem.yaml." + f"{self.name}: Found {latex.PdfType.PROBLEM.path(lang).name}, but no corresponding name in problem.yaml." ) for lang in yamllangs - texlangs: error( - f"{self.name}: Found name for language {lang} in problem.yaml, but not problem_statement/problem.{lang}.tex." + f"{self.name}: Found name for language {lang} in problem.yaml, but not {latex.PdfType.PROBLEM.path(lang)}." ) # Check that names in problem.yaml and \problemname{} in problem.*.tex agree: for lang in texlangs & yamllangs: unnormalised_yamlname = self.settings.name[lang] yamlname = " ".join(unnormalised_yamlname.split()) - with open(self.path / "problem_statement" / f"problem.{lang}.tex") as texfile: + texpath = self.path / latex.PdfType.PROBLEM.path(lang) + with open(texpath) as texfile: match texname := latex.get_argument_for_command(texfile, "problemname"): case None: - error(rf"No \problemname found in problem.{lang}.tex") + error(rf"No \problemname found in {texpath.name}") continue case "": continue case r"\problemyamlname": warn( - rf"Prefer using \problemname{{}} instead of \problemname{{\problemyamlname}} in problem.{lang}.tex" + rf"Prefer using \problemname{{}} instead of \problemname{{\problemyamlname}} in {texpath.name}" ) continue case s if "\\" in s or "_" in s or "^" in s: @@ -366,7 +367,7 @@ def _determine_statement_languages(self): continue case s if s != yamlname: warn( - f"Problem titles in problem.{lang}.tex ({texname})" + f"Problem titles in {texpath.name} ({texname})" + f" and problem.yaml ({yamlname}) differ;" + r" consider using \problemname{}." ) @@ -514,12 +515,14 @@ def get_testdata_yaml( if key in flags: if key == "output_validator_args": if not isinstance(flags[key], str): - bar.error("ouput_validator_args must be string") + bar.error("output_validator_args must be string") + return [] return flags[key].split() if key == "input_validator_args": if not isinstance(flags[key], (str, dict)): bar.error("input_validator_args must be string or map") + return [] if isinstance(flags[key], str): return flags[key].split() elif name in flags[key]: diff --git a/bin/skel.py b/bin/skel.py index 35fa57bf7..3e1d19853 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -4,6 +4,7 @@ # Local imports import config +import latex from export import force_single_language from problem import Problem from util import * @@ -259,10 +260,11 @@ def new_problem(): # Warn about missing problem statement skeletons for non-en languages for lang in statement_languages: - filename = f"problem.{lang}.tex" - statement_path = target_dir / dirname / "problem_statement" / filename + statement_path = target_dir / dirname / latex.PdfType.PROBLEM.path(lang) if not statement_path.is_file(): - warn(f"No skeleton for {filename} found. Create it manually or update skel/problem.") + warn( + f"No skeleton for {statement_path.name} found. Create it manually or update skel/problem." + ) def rename_problem(problem): @@ -352,8 +354,9 @@ def problem_source_dir(problem: Problem): contest_yml = (config.TOOLS_ROOT / "skel/gitlab_ci/contest.yaml").read_text() contest_path = Path(".").resolve().relative_to(git_root_path) changes = "".join( - " - " + str(problem_source_dir(problem)) + "/problem_statement/**/*\n" + f" - {problem_source_dir(problem)}/{pdf_type.path().parent}/**/*\n" for problem in problems + for pdf_type in latex.PdfType ) print( substitute( diff --git a/bin/stats.py b/bin/stats.py index 69eb8a638..60253b77f 100644 --- a/bin/stats.py +++ b/bin/stats.py @@ -11,6 +11,7 @@ import config import generate +import latex import program from util import error, exec_command, glob, warn @@ -48,8 +49,8 @@ def problem_stats(problems): # Roughly in order of importance (" time", lambda p: p.limits.time_limit, 0), ("yaml", "problem.yaml"), - ("tex", "problem_statement/problem*.tex", 1), - ("sol", "problem_statement/solution*.tex", 1), + ("tex", str(latex.PdfType.PROBLEM.path("*")), 1), + ("sol", str(latex.PdfType.SOLUTION.path("*")), 1), (" val: I", ["input_validators/*", "input_format_validators/*"]), ("A", ["answer_validators/*"]), ("O", ["output_validators/*"]), diff --git a/bin/tools.py b/bin/tools.py index f31087d81..d67f6cbf1 100755 --- a/bin/tools.py +++ b/bin/tools.py @@ -1249,10 +1249,8 @@ def run_parsed_arguments(args): web=True, ) # Only build the problem slides if at least one problem has the TeX for it - if any( - glob(problem.path / "problem_statement", "problem-slide.*.tex") - for problem in problems - ): + slideglob = latex.PdfType.PROBLEM_SLIDE.path("*") + if any(problem.path.glob(str(slideglob)) for problem in problems): success &= latex.build_contest_pdf( contest, problems, @@ -1261,7 +1259,7 @@ def run_parsed_arguments(args): build_type=latex.PdfType.PROBLEM_SLIDE, ) else: - log("No problem has problem-slide.*.tex, skipping problem slides") + log(f"No problem has {slideglob.name}, skipping problem slides") outfile = contest + ".zip" if config.args.kattis: diff --git a/bin/upgrade.py b/bin/upgrade.py index 15c3b72ba..4cd1f4138 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -160,9 +160,9 @@ def upgrade_statement(problem_path: Path, bar: ProgressBar) -> None: if (problem_path / "problem_statement").is_dir(): if (problem_path / "statement").exists(): bar.error("can't rename 'problem_statement/', 'statement/' already exists", resume=True) - return - bar.log("renaming 'problem_statement/' to 'statement/'") - (problem_path / "problem_statement").rename(problem_path / "statement") + else: + bar.log("renaming 'problem_statement/' to 'statement/'") + (problem_path / "problem_statement").rename(problem_path / "statement") origin = problem_path / "statement" move = [ @@ -293,30 +293,35 @@ def add_args(new_data: dict[str, Any]) -> bool: return True if "validator_flags" in data: - generators_path = problem_path / "generators" / "generators.yaml" - if generators_path.exists(): - generators_data = read_yaml(generators_path) - assert generators_data is not None - assert isinstance(generators_data, CommentedMap) - - if "testdata.yaml" not in generators_data: - if "data" in generators_data: - # insert before data - pos = list(generators_data.keys()).index("data") - generators_data.insert(pos, "testdata.yaml", CommentedMap()) - else: - # insert at end - generators_data["testdata.yaml"] = CommentedMap() - if add_args(generators_data["testdata.yaml"]): - write_yaml(generators_data, generators_path) - else: - testdata_path = problem_path / "data" / "testdata.yaml" - testdata_data = read_yaml(testdata_path) if testdata_path.exists() else CommentedMap() - assert testdata_data is not None - assert isinstance(testdata_data, dict) + if data["validator_flags"]: + generators_path = problem_path / "generators" / "generators.yaml" + if generators_path.exists(): + generators_data = read_yaml(generators_path) + assert generators_data is not None + assert isinstance(generators_data, CommentedMap) + + if "testdata.yaml" not in generators_data: + if "data" in generators_data: + # insert before data + pos = list(generators_data.keys()).index("data") + generators_data.insert(pos, "testdata.yaml", CommentedMap()) + else: + # insert at end + generators_data["testdata.yaml"] = CommentedMap() + if add_args(generators_data["testdata.yaml"]): + write_yaml(generators_data, generators_path) + else: + testdata_path = problem_path / "data" / "testdata.yaml" + testdata_data = ( + read_yaml(testdata_path) if testdata_path.exists() else CommentedMap() + ) + assert testdata_data is not None + assert isinstance(testdata_data, dict) - if add_args(testdata_data): - write_yaml(testdata_data, testdata_path) + if add_args(testdata_data): + write_yaml(testdata_data, testdata_path) + else: + _filter(data, "validator_flags") timelimit_path = problem_path / ".timelimit" if timelimit_path.is_file(): @@ -363,7 +368,7 @@ def _upgrade(problem_path: Path, bar: ProgressBar) -> None: upgrade_data(problem_path, bar) upgrade_testdata_yaml(problem_path, bar) upgrade_generators_yaml(problem_path, bar) - # upgrade_statement(problem_path, bar) TODO: activate this when we support the new statement dirs + upgrade_statement(problem_path, bar) # TODO: output_validators -> output_validator upgrade_problem_yaml(problem_path, bar) diff --git a/doc/commands.md b/doc/commands.md index 9065e6e77..a8af59246 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -239,7 +239,7 @@ This table contains: - The problem label and shortname. - Whether `problem.yaml` and `domjudge.ini` are found. -- Whether `problem_statement/problem.en.tex` and `problem_statement/solution.tex` are found. +- Whether `statement/problem.en.tex` and `solution/solution.en.tex` are found. - Whether the problem has any `input_validators` and `output_validators`. - The number of `sample` and `secret` testcases. - The number of `accepted`, `wrong_answer`, and `time_limit_exceeded` solutions. @@ -427,7 +427,7 @@ contest_pdf_nwerc2020: - ./bt solutions --cp --no-bar --contest nwerc2020 only: changes: - - nwerc2020/testproblem/problem_statement/**/* + - nwerc2020/testproblem/statement/**/* artifacts: expire_in: 1 week @@ -570,7 +570,7 @@ When run for a contest: - Kattis needs the `input_validators` directory, while DOMjudge doesn't use this. - Kattis problem zips get an additional top level directory named after the problem shortname. - _Statements_: Kattis’s problemtools builds statement HTML (and PDF) using `problem2html` (and `problem2pdf`) rather than `bt pdf`. Problem authors should check the resulting statements after exporting to Kattis; pay attention to: - - The command `bt zip --kattis` exports `problem_statement/*` but not its subdirectories, so make sure illustrations and `\input`-ed tex sources are included. + - The command `bt zip --kattis` exports `{statement,solution}/*` but not its subdirectories, so make sure illustrations and `\input`-ed tex sources are included. - Proper images scaling in the HTML output requires explict widths, such as `\includegraphics[width=.5\textwidth]{foo.png}`. ## `export` diff --git a/doc/implementation_notes.md b/doc/implementation_notes.md index 500970674..57e3a6d7e 100644 --- a/doc/implementation_notes.md +++ b/doc/implementation_notes.md @@ -163,15 +163,15 @@ The following placeholders are automatically substituted in the `contest_data.te ## Solution slides Solutions are rendered in a similar way to the contest pdf. It uses the -`problem_statement/solution.tex` files as inputs. The main difference is that +`solution/solution..tex` files as inputs. The main difference is that you can provide additional files in `/`: -- `solutions_header.xy.tex`: slides prepended to the first problem, for the +- `solutions_header..tex`: slides prepended to the first problem, for the current language. -- `solutions_footer.xy.tex`: slides appended after the last problem, for the +- `solutions_footer..tex`: slides appended after the last problem, for the current language. -The following placeholders are automatically substituted in the `solution.tex`: +The following placeholders are automatically substituted in the `solution..tex`: ``` {%problemlabel%} {%problemyamlname%} @@ -190,7 +190,7 @@ There is some special support for handling _solve stats_: post-contest data on h ``` \newcommand{\solvestatsA}{\printsolvestats{}{}{}} ``` - When this file is present, each `problem_statement/solution.tex` may use `\solvestats` to print a line like: + When this file is present, each `solution/solution..tex` may use `\solvestats` to print a line like: ``` Statistics: 15 submissions, 3 accepted, 8 unknown ``` diff --git a/doc/multiple_languages.md b/doc/multiple_languages.md index 0c6177355..27fa90ed1 100644 --- a/doc/multiple_languages.md +++ b/doc/multiple_languages.md @@ -22,7 +22,7 @@ The default language for BAPCtools is English, but multiple languages can be spe In short, 1. configure `languages` in `.bapctools.yaml`. -2. add a skeleton for `problem.LANG.tex` in `skel/problem/problem_statement`. +2. add a skeleton for `problem.LANG.tex` in `skel/problem/statement`. ### Configure `language` @@ -44,9 +44,9 @@ languages: ### Add skeleton statements -The skeleton directory for a new problem statement (see `bt skel` and `bt new_problem`) by default only supports English and will populate `/problem_statement/problem.en.tex` with a default statement. +The skeleton directory for a new problem statement (see `bt skel` and `bt new_problem`) by default only supports English and will populate `/statement/problem.en.tex` with a default statement. To support, _e.g._, German, you need to add `problem.de.tex`. -To do this automatically for each `bt new_problem`, create a problem skeleton in `/skel/problem`, and add `problem_statement/problem.de.tex`, for instance like this: +To do this automatically for each `bt new_problem`, create a problem skeleton in `/skel/problem`, and add `statement/problem.de.tex`, for instance like this: ```tex \problemname{\problemyamlname} % replaced by name['de'] from problem.yaml @@ -129,6 +129,6 @@ a warning that they should be renamed to include the language suffix in their fi At the contest level things work similarly, and `contest.xy.pdf` and `solutions.xy.pdf` are created using `bt pdf` and `bt solutions` respectively. By default, only those languages `xy` are used for which -`/problem_statement/problem.xy.tex` is available for all problems in the +`/statement/problem.xy.tex` is available for all problems in the contest. Solution slides are skipped for problems without a corresponding `/problemstatement/solution.xy.tex` file. diff --git a/latex/contest-problem-slide.tex b/latex/contest-problem-slide.tex index ca385519b..350c05a6e 100644 --- a/latex/contest-problem-slide.tex +++ b/latex/contest-problem-slide.tex @@ -1,4 +1,4 @@ -\begingroup\graphicspath{{{%problemdir%}/problem_statement/}} +\begingroup\graphicspath{{{%problemdir%}/problem_slide/}{{%problemdir%}/statement/}} \renewcommand{\problemlabel}{{%problemlabel%}} \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} @@ -7,7 +7,7 @@ \renewcommand{\problemborder}{{%problemborder%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/constants.tex} - \input{{%problemdir%}/problem_statement/problem-slide.\lang.tex} + \input{{%problemdir%}/problem_slide/problem-slide.\lang.tex} \renewcommand{\problemlabel}{} \renewcommand{\problemyamlname}{} \renewcommand{\problemauthor}{} diff --git a/latex/contest-problem.tex b/latex/contest-problem.tex index 8818a15cc..384ede999 100644 --- a/latex/contest-problem.tex +++ b/latex/contest-problem.tex @@ -1,11 +1,11 @@ -\begingroup\graphicspath{{{%problemdir%}/problem_statement/}} +\begingroup\graphicspath{{{%problemdir%}/statement/}} \renewcommand{\problemlabel}{{%problemlabel%}} \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/samples.tex} \input{{%builddir%}/constants.tex} - \input{{%problemdir%}/problem_statement/problem.\lang.tex} + \input{{%problemdir%}/statement/problem.\lang.tex} \remainingsamples{} \renewcommand{\problemlabel}{} \renewcommand{\problemyamlname}{} diff --git a/latex/contest-solution.tex b/latex/contest-solution.tex index f307f9468..4b51c9f2b 100644 --- a/latex/contest-solution.tex +++ b/latex/contest-solution.tex @@ -1,10 +1,10 @@ -\begingroup\graphicspath{{{%problemdir%}/problem_statement/}} +\begingroup\graphicspath{{{%problemdir%}/solution/}{{%problemdir%}/statement/}} \renewcommand{\problemlabel}{{%problemlabel%}} \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/constants.tex} - \input{{%problemdir%}/problem_statement/solution.\lang.tex} + \input{{%problemdir%}/solution/solution.\lang.tex} \renewcommand{\problemlabel}{} \renewcommand{\problemyamlname}{} \renewcommand{\problemauthor}{} diff --git a/latex/problem-slide.tex b/latex/problem-slide.tex index 8bf68ad48..642e65363 100644 --- a/latex/problem-slide.tex +++ b/latex/problem-slide.tex @@ -1,7 +1,7 @@ \documentclass[rgb,dvipsnames,aspectratio=169,9pt,t]{beamer} \input{problem-slides-base.tex} \begin{document} -\begingroup\graphicspath{{{%problemdir%}/problem_statement/}} +\begingroup\graphicspath{{{%problemdir%}/problem_slide/}{{%problemdir%}/statement/}} \renewcommand{\problemlabel}{{%problemlabel%}} \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} @@ -10,6 +10,6 @@ \renewcommand{\problemborder}{{%problemborder%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/constants.tex} - \input{{%problemdir%}/problem_statement/problem-slide.\lang.tex} + \input{{%problemdir%}/problem_slide/problem-slide.\lang.tex} \endgroup \end{document} diff --git a/latex/problem.tex b/latex/problem.tex index ff1852a02..e288a1946 100644 --- a/latex/problem.tex +++ b/latex/problem.tex @@ -1,13 +1,13 @@ \documentclass{bapc} \begin{document} -\begingroup\graphicspath{{{%problemdir%}/problem_statement/}} +\begingroup\graphicspath{{{%problemdir%}/statement/}} \renewcommand{\problemlabel}{{%problemlabel%}} \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/samples.tex} \input{{%builddir%}/constants.tex} - \input{{%problemdir%}/problem_statement/problem.\lang.tex} + \input{{%problemdir%}/statement/problem.\lang.tex} \remainingsamples{} \endgroup \end{document} diff --git a/latex/solution-web.tex b/latex/solution-web.tex index 37ea59a69..8aa40aec2 100644 --- a/latex/solution-web.tex +++ b/latex/solution-web.tex @@ -1,13 +1,13 @@ \documentclass[rgb,dvipsnames,aspectratio=169,9pt,t,handout]{beamer} \input{solutions-base.tex} \begin{document} -\begingroup\graphicspath{{{%problemdir%}/problem_statement/}} +\begingroup\graphicspath{{{%problemdir%}/solution/}{{%problemdir%}/statement/}} \renewcommand{\problemlabel}{{%problemlabel%}} \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/constants.tex} - \input{{%problemdir%}/problem_statement/solution.\lang.tex} + \input{{%problemdir%}/solution/solution.\lang.tex} \renewcommand{\problemlabel}{} \endgroup \end{document} diff --git a/latex/solution.tex b/latex/solution.tex index 84667c4cc..d1186250e 100644 --- a/latex/solution.tex +++ b/latex/solution.tex @@ -1,13 +1,13 @@ \documentclass[rgb,dvipsnames,aspectratio=169,9pt,t]{beamer} \input{solutions-base.tex} \begin{document} -\begingroup\graphicspath{{{%problemdir%}/problem_statement/}} +\begingroup\graphicspath{{{%problemdir%}/solution/}{{%problemdir%}/statement/}} \renewcommand{\problemlabel}{{%problemlabel%}} \renewcommand{\problemyamlname}{{%problemyamlname%}} \renewcommand{\problemauthor}{{%problemauthor%}} \renewcommand{\timelimit}{{%timelimit%}} \input{{%builddir%}/constants.tex} - \input{{%problemdir%}/problem_statement/solution.\lang.tex} + \input{{%problemdir%}/solution/solution.\lang.tex} \renewcommand{\problemlabel}{} \endgroup \end{document} diff --git a/readme.md b/readme.md index c2bdc61c4..bc24dd7d9 100644 --- a/readme.md +++ b/readme.md @@ -179,7 +179,7 @@ them to a separate directory. - `bt pdf [-v]` -Use this command to compile the `problem.en.pdf` from the `problem_statement/problem.en.tex` LaTeX statement. +Use this command to compile the `problem.en.pdf` from the `statement/problem.en.tex` LaTeX statement. `problem.en.pdf` is written to the problem directory itself. This can also be used to create the contest pdf by running it from the contest directory. diff --git a/skel/gitlab_ci/problem.yaml b/skel/gitlab_ci/problem.yaml index 5bc7cd19d..c58ddaab3 100644 --- a/skel/gitlab_ci/problem.yaml +++ b/skel/gitlab_ci/problem.yaml @@ -3,7 +3,9 @@ verify_{%problem%}: - ./bt all --cp --error --no-bar --force --jobs 0 --problem {%problem_path%} only: changes: - #- {%problem_path%}/problem_statement/**/* + #- {%problem_path%}/statement/**/* + #- {%problem_path%}/solution/**/* + #- {%problem_path%}/problem_slide/**/* - {%problem_path%}/problem.yaml - {%problem_path%}/.timelimit - {%problem_path%}/data/**/* diff --git a/skel/problem/problem_statement/problem-slide.en.tex b/skel/problem/problem_slide/problem-slide.en.tex similarity index 100% rename from skel/problem/problem_statement/problem-slide.en.tex rename to skel/problem/problem_slide/problem-slide.en.tex diff --git a/skel/problem/problem_statement/solution.en.tex b/skel/problem/solution/solution.en.tex similarity index 100% rename from skel/problem/problem_statement/solution.en.tex rename to skel/problem/solution/solution.en.tex diff --git a/skel/problem/problem_statement/problem.en.tex b/skel/problem/statement/problem.en.tex similarity index 100% rename from skel/problem/problem_statement/problem.en.tex rename to skel/problem/statement/problem.en.tex diff --git a/skel/problem_cfp/problem_statement/solution.en.tex b/skel/problem_cfp/solution/solution.en.tex similarity index 100% rename from skel/problem_cfp/problem_statement/solution.en.tex rename to skel/problem_cfp/solution/solution.en.tex diff --git a/skel/problem_cfp/problem_statement/problem.en.tex b/skel/problem_cfp/statement/problem.en.tex similarity index 100% rename from skel/problem_cfp/problem_statement/problem.en.tex rename to skel/problem_cfp/statement/problem.en.tex diff --git a/test/problems/boolfind/problem_statement/problem.en.tex b/test/problems/boolfind/statement/problem.en.tex similarity index 100% rename from test/problems/boolfind/problem_statement/problem.en.tex rename to test/problems/boolfind/statement/problem.en.tex diff --git a/test/problems/constants/problem_statement/problem-slide.en.tex b/test/problems/constants/problem_slide/problem-slide.en.tex similarity index 100% rename from test/problems/constants/problem_statement/problem-slide.en.tex rename to test/problems/constants/problem_slide/problem-slide.en.tex diff --git a/test/problems/constants/problem_statement/solution.en.tex b/test/problems/constants/solution/solution.en.tex similarity index 100% rename from test/problems/constants/problem_statement/solution.en.tex rename to test/problems/constants/solution/solution.en.tex diff --git a/test/problems/constants/problem_statement/problem.en.tex b/test/problems/constants/statement/problem.en.tex similarity index 100% rename from test/problems/constants/problem_statement/problem.en.tex rename to test/problems/constants/statement/problem.en.tex diff --git a/test/problems/different/problem_statement/problem.en.tex b/test/problems/different/statement/problem.en.tex similarity index 100% rename from test/problems/different/problem_statement/problem.en.tex rename to test/problems/different/statement/problem.en.tex diff --git a/test/problems/divsort/problem_statement/problem.en.tex b/test/problems/divsort/statement/problem.en.tex similarity index 100% rename from test/problems/divsort/problem_statement/problem.en.tex rename to test/problems/divsort/statement/problem.en.tex diff --git a/test/problems/fltcmp/problem_statement/problem.en.tex b/test/problems/fltcmp/statement/problem.en.tex similarity index 100% rename from test/problems/fltcmp/problem_statement/problem.en.tex rename to test/problems/fltcmp/statement/problem.en.tex diff --git a/test/problems/generatorincludes/problem_statement/problem.en.tex b/test/problems/generatorincludes/statement/problem.en.tex similarity index 100% rename from test/problems/generatorincludes/problem_statement/problem.en.tex rename to test/problems/generatorincludes/statement/problem.en.tex diff --git a/test/problems/guess/problem_statement/problem.en.tex b/test/problems/guess/statement/problem.en.tex similarity index 100% rename from test/problems/guess/problem_statement/problem.en.tex rename to test/problems/guess/statement/problem.en.tex diff --git a/test/problems/guessnoeofcheck/problem_statement/problem.en.tex b/test/problems/guessnoeofcheck/statement/problem.en.tex similarity index 100% rename from test/problems/guessnoeofcheck/problem_statement/problem.en.tex rename to test/problems/guessnoeofcheck/statement/problem.en.tex diff --git a/test/problems/hello/problem_statement/problem.en.tex b/test/problems/hello/statement/problem.en.tex similarity index 100% rename from test/problems/hello/problem_statement/problem.en.tex rename to test/problems/hello/statement/problem.en.tex diff --git a/test/problems/helloproblemtools/problem_statement/problem.en.tex b/test/problems/helloproblemtools/statement/problem.en.tex similarity index 100% rename from test/problems/helloproblemtools/problem_statement/problem.en.tex rename to test/problems/helloproblemtools/statement/problem.en.tex diff --git a/test/problems/helloproblemtools/problem_statement/problem.sv.tex b/test/problems/helloproblemtools/statement/problem.sv.tex similarity index 100% rename from test/problems/helloproblemtools/problem_statement/problem.sv.tex rename to test/problems/helloproblemtools/statement/problem.sv.tex diff --git a/test/problems/hellounix/problem_statement/problem.en.tex b/test/problems/hellounix/statement/problem.en.tex similarity index 100% rename from test/problems/hellounix/problem_statement/problem.en.tex rename to test/problems/hellounix/statement/problem.en.tex diff --git a/test/problems/hellowholeworld/problem_statement/solution.en.tex b/test/problems/hellowholeworld/solution/solution.en.tex similarity index 100% rename from test/problems/hellowholeworld/problem_statement/solution.en.tex rename to test/problems/hellowholeworld/solution/solution.en.tex diff --git a/test/problems/hellowholeworld/problem_statement/problem.da.tex b/test/problems/hellowholeworld/statement/problem.da.tex similarity index 100% rename from test/problems/hellowholeworld/problem_statement/problem.da.tex rename to test/problems/hellowholeworld/statement/problem.da.tex diff --git a/test/problems/hellowholeworld/problem_statement/problem.en.tex b/test/problems/hellowholeworld/statement/problem.en.tex similarity index 100% rename from test/problems/hellowholeworld/problem_statement/problem.en.tex rename to test/problems/hellowholeworld/statement/problem.en.tex diff --git a/test/problems/hellowholeworld/problem_statement/problem.sv.tex b/test/problems/hellowholeworld/statement/problem.sv.tex similarity index 100% rename from test/problems/hellowholeworld/problem_statement/problem.sv.tex rename to test/problems/hellowholeworld/statement/problem.sv.tex diff --git a/test/problems/identity/problem_statement/problem-slide.en.tex b/test/problems/identity/problem_slide/problem-slide.en.tex similarity index 100% rename from test/problems/identity/problem_statement/problem-slide.en.tex rename to test/problems/identity/problem_slide/problem-slide.en.tex diff --git a/test/problems/identity/problem_statement/solution.en.tex b/test/problems/identity/solution/solution.en.tex similarity index 100% rename from test/problems/identity/problem_statement/solution.en.tex rename to test/problems/identity/solution/solution.en.tex diff --git a/test/problems/identity/problem_statement/problem.en.tex b/test/problems/identity/statement/problem.en.tex similarity index 100% rename from test/problems/identity/problem_statement/problem.en.tex rename to test/problems/identity/statement/problem.en.tex diff --git a/test/problems/interactivemultipass/problem_statement/problem.en.tex b/test/problems/interactivemultipass/statement/problem.en.tex similarity index 100% rename from test/problems/interactivemultipass/problem_statement/problem.en.tex rename to test/problems/interactivemultipass/statement/problem.en.tex diff --git a/test/problems/multipass/problem_statement/problem.en.tex b/test/problems/multipass/statement/problem.en.tex similarity index 100% rename from test/problems/multipass/problem_statement/problem.en.tex rename to test/problems/multipass/statement/problem.en.tex diff --git a/test/problems/testproblemconfig/problem_statement/problem.en.tex b/test/problems/testproblemconfig/statement/problem.en.tex similarity index 100% rename from test/problems/testproblemconfig/problem_statement/problem.en.tex rename to test/problems/testproblemconfig/statement/problem.en.tex From 3198d6155704509c6fe40926b9d1c2cf7066ccaa Mon Sep 17 00:00:00 2001 From: mzuenni Date: Sun, 9 Mar 2025 14:13:35 +0100 Subject: [PATCH 04/23] Output validator (#435) * use output_validator * upgraded tests * updated skel * fix symlink * create output_validators dir * changed class constants * fix source_dir(s) * copy symlinks * export legacy * [export] Fix typo in comment --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- bin/export.py | 102 +++++++++++----- bin/generate.py | 5 +- bin/interactive.py | 2 +- bin/problem.py | 27 ++--- bin/run.py | 22 ++-- bin/skel.py | 2 +- bin/stats.py | 2 +- bin/testcase.py | 2 +- bin/upgrade.py | 110 +++++++++--------- bin/util.py | 37 ++++++ bin/validate.py | 15 ++- .../output_validator/output_validator.cpp | 0 skel/problem/output_validator/validation.h | 1 + .../boolfind_run => output_validator}/build | 0 .../boolfind_run => output_validator}/run | 0 .../runjury_boolfind.c | 0 .../output_validator/output_validator.cpp | 0 .../constants}/output_validator/validation.h | 0 .../output_validator/validation.h | 1 - .../validate.cc | 0 .../validate.h | 0 .../validate.cc | 0 .../validate.h | 0 .../validate.cc | 0 .../validate.h | 0 .../interctive_multipass_validator.py | 0 .../multipass_validator.py | 0 .../.gitkeep | 0 test/test_default_output_validator.py | 10 +- 29 files changed, 203 insertions(+), 135 deletions(-) rename skel/problem/{output_validators => }/output_validator/output_validator.cpp (100%) create mode 120000 skel/problem/output_validator/validation.h rename test/problems/boolfind/{output_validators/boolfind_run => output_validator}/build (100%) rename test/problems/boolfind/{output_validators/boolfind_run => output_validator}/run (100%) rename test/problems/boolfind/{output_validators/boolfind_run => output_validator}/runjury_boolfind.c (100%) rename test/problems/constants/{output_validators => }/output_validator/output_validator.cpp (100%) rename {skel/problem/output_validators => test/problems/constants}/output_validator/validation.h (100%) delete mode 120000 test/problems/constants/output_validators/output_validator/validation.h rename test/problems/different/{output_validators/different_validator => output_validator}/validate.cc (100%) rename test/problems/different/{output_validators/different_validator => output_validator}/validate.h (100%) rename test/problems/guess/{output_validators/guess_validator => output_validator}/validate.cc (100%) rename test/problems/guess/{output_validators/guess_validator => output_validator}/validate.h (100%) rename test/problems/guessnoeofcheck/{output_validators/guess_validator => output_validator}/validate.cc (100%) rename test/problems/guessnoeofcheck/{output_validators/guess_validator => output_validator}/validate.h (100%) rename test/problems/interactivemultipass/{output_validators/interactive_multipass_validator => output_validator}/interctive_multipass_validator.py (100%) rename test/problems/multipass/{output_validators/multipass_validator => output_validator}/multipass_validator.py (100%) rename test/problems/testproblemconfig/{output_validators => output_validator}/.gitkeep (100%) diff --git a/bin/export.py b/bin/export.py index 97a79e3e4..f5d5e6028 100644 --- a/bin/export.py +++ b/bin/export.py @@ -92,6 +92,10 @@ def build_samples_zip(problems, output, statement_language): def build_problem_zip(problem: Problem, output: Path): """Make DOMjudge/Kattis ZIP file for specified problem.""" + if not has_ryaml: + error("zip needs the ruamel.yaml python3 library. Install python[3]-ruamel.yaml.") + return + # Add problem PDF for only one language to the zip file (note that Kattis export does not include PDF) statement_language = None if config.args.kattis else force_single_language([problem]) @@ -121,7 +125,7 @@ def build_problem_zip(problem: Problem, output: Path): ] if problem.custom_output: - files.append(("output_validators/**/*", True)) + files.append(("output_validator/**/*", True)) if config.args.kattis: files.append(("input_validators/**/*", True)) @@ -170,33 +174,64 @@ def add_file(path, source): out = f2.relative_to(problem.path) add_file(out, f2) - # DOMjudge does not support 'type' in problem.yaml nor 'output_validator_args' in testdata.yaml yet. - # TODO: Remove this once it does. - if not config.args.kattis: - yaml_path = export_dir / "problem.yaml" - yaml_data = [yaml_path.read_text(), "\nvalidation:"] - if problem.custom_output: - yaml_data.append(" custom") - if problem.interactive: - yaml_data.append(" interactive") - if problem.multi_pass: - yaml_data.append(" multi-pass") - else: - yaml_data.append(" default") - yaml_data.append("\n") - - validator_flags = " ".join( - problem.get_testdata_yaml( - problem.path / "data", - "output_validator_args", - PrintBar("Getting validator_flags for legacy DOMjudge export"), - ) + # DOMjudge and Kattis do not support 2023-07-draft yet. + # TODO: Remove once they do. + from ruamel.yaml.comments import CommentedMap + + yaml_path = export_dir / "problem.yaml" + yaml_data = read_yaml(yaml_path) + # drop format version -> legacy + if "problem_format_version" in yaml_data: + ryaml_filter(yaml_data, "problem_format_version") + # type -> validation + if "type" in yaml_data: + ryaml_filter(yaml_data, "type") + validation = [] + if problem.custom_output: + validation.append("custom") + if problem.interactive: + validation.append("interactive") + if problem.multi_pass: + validation.append("multi-pass") + else: + validation.append("default") + yaml_data["validation"] = " ".join(validation) + # credits -> author + if "credits" in yaml_data: + ryaml_filter(yaml_data, "credits") + if problem.settings.credits.authors: + yaml_data["author"] = ", ".join(p.name for p in problem.settings.credits.authors) + # change source: + if problem.settings.source: + if len(problem.settings.source) > 1: + util.warn(f"Found multiple sources, using '{problem.settings.source[0].name}'.") + yaml_data["source"] = problem.settings.source[0].name + yaml_data["source_url"] = problem.settings.source[0].url + # limits.time_multipliers -> time_multiplier / time_safety_margin + if "limits" not in yaml_data or not yaml_data["limits"]: + yaml_data["limits"] = CommentedMap() + limits = yaml_data["limits"] + if "time_multipliers" in limits: + ryaml_filter(limits, "time_multipliers") + limits["time_multiplier"] = problem.limits.ac_to_time_limit + limits["time_safety_margin"] = problem.limits.time_limit_to_tle + # drop explicit timelimit for kattis: + if "time_limit" in limits: + # keep this for kattis even when "time_limit" is supported + ryaml_filter(limits, "time_limit") + # validator_flags + validator_flags = " ".join( + problem.get_testdata_yaml( + problem.path / "data", + "output_validator_args", + PrintBar("Getting validator_flags for legacy export"), ) - if validator_flags: - yaml_data.append(f"validator_flags: {validator_flags}\n") - - yaml_path.unlink() - yaml_path.write_text("".join(yaml_data)) + ) + if validator_flags: + yaml_data["validator_flags"] = validator_flags + # write legacy style yaml + yaml_path.unlink() + write_yaml(yaml_data, yaml_path) # DOMjudge does not support 'limits.time_limit' in problem.yaml yet. # TODO: Remove this once it does. @@ -224,7 +259,7 @@ def add_file(path, source): if problem.settings.constants: constants_supported = [ "data/**/testdata.yaml", - "output_validators/**/*", + "output_validator/**/*", "input_validators/**/*", # "statement/*", uses \constants # "submissions/*/**/*", removed support? @@ -242,6 +277,13 @@ def add_file(path, source): f.unlink() f.write_text(text) + # TODO: Remove this if we know others use the output_validator dir + if (export_dir / "output_validator").exists(): + (export_dir / "output_validators").mkdir(parents=True) + (export_dir / "output_validator").rename( + export_dir / "output_validators" / "output_validator" + ) + # TODO: Remove this if we know others import the statement folder if (export_dir / "statement").exists(): (export_dir / "statement").rename(export_dir / "problem_statement") @@ -286,6 +328,10 @@ def add_file(path, source): # solutions*.{lang}.pdf # Output is def build_contest_zip(problems, zipfiles, outfile, statement_language): + if not has_ryaml: + error("zip needs the ruamel.yaml python3 library. Install python[3]-ruamel.yaml.") + return + print(f"writing ZIP file {outfile}", file=sys.stderr) if not config.args.kattis: # Kattis does not use problems.yaml. diff --git a/bin/generate.py b/bin/generate.py index a472c1d69..42ffa1e03 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -729,10 +729,7 @@ def validate_ans(t, problem: Problem, testcase: Testcase, meta_yaml: dict, bar: f".ans file is {size / 1024 / 1024:.3f}MiB, which is close to output limit (set limits.output to at least {(2 * size + 1024 * 1024 - 1) // 1024 // 1024}MiB in problem.yaml)" ) - answer_validator_hashes = { - **testcase.validator_hashes(validate.AnswerValidator, bar), - **testcase.validator_hashes(validate.OutputValidator, bar), - } + answer_validator_hashes = {**testcase.validator_hashes(validate.AnswerValidator, bar)} if all(h in meta_yaml["answer_validator_hashes"] for h in answer_validator_hashes): return True diff --git a/bin/interactive.py b/bin/interactive.py index 83390c365..1180a1c2c 100644 --- a/bin/interactive.py +++ b/bin/interactive.py @@ -32,7 +32,7 @@ def run_interactive_testcase( bar: Optional[ProgressBar] = None, ): output_validators = run.problem.validators(validate.OutputValidator) - if len(output_validators) != 1: + if not output_validators: return None output_validator = output_validators[0] diff --git a/bin/problem.py b/bin/problem.py index 43aa69a53..a652e18cf 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -224,7 +224,7 @@ def __init__( self.interactive: bool = "interactive" in mode self.multi_pass: bool = "multi-pass" in mode self.custom_output: bool = ( - self.interactive or self.multi_pass or (problem.path / "output_validators").exists() + self.interactive or self.multi_pass or (problem.path / "output_validator").is_dir() ) self.name: dict[str, str] = parse_setting(yaml_data, "name", {"en": ""}) @@ -801,8 +801,6 @@ def validators( warn("No input validators found.") case validate.AnswerValidator, 0: warn("No answer validators found") - case validate.OutputValidator, l if l != 1: - error(f"Found {len(validators)} output validators, expected exactly one.") build_ok = all(v.ok for v in validators) @@ -817,16 +815,16 @@ def _validators( if key in problem._validators_cache: return problem._validators_cache[key] - assert hasattr(cls, "source_dirs") - # TODO #424: We should not support multiple output validators inside output_validator/. - paths = [p for source_dir in cls.source_dirs for p in glob(problem.path / source_dir, "*")] - - # Handle default output validation if cls == validate.OutputValidator: - if not paths: - if problem.custom_output: - fatal("Problem validation type requires output_validators/") + if problem.custom_output: + paths = [problem.path / validate.OutputValidator.source_dir] + else: paths = [config.TOOLS_ROOT / "support" / "default_output_validator.cpp"] + else: + assert hasattr(cls, "source_dirs") + paths = [ + p for source_dir in cls.source_dirs for p in glob(problem.path / source_dir, "*") + ] # TODO: Instead of checking file contents, maybe specify this in generators.yaml? def has_constraints_checking(f): @@ -878,13 +876,8 @@ def prepare_run(problem): if not testcases: return False - if problem.interactive or problem.multi_pass: - validators = problem.validators(validate.OutputValidator) - if not validators: - return False - # Pre build the output validator to prevent nested ProgressBars. - if problem.validators(validate.OutputValidator) is False: + if not problem.validators(validate.OutputValidator): return False submissions = problem.submissions() diff --git a/bin/run.py b/bin/run.py index 87d29b88d..308b6d670 100644 --- a/bin/run.py +++ b/bin/run.py @@ -217,12 +217,12 @@ def _prepare_nextpass(self, nextpass): def _validate_output(self, bar): output_validators = self.problem.validators(validate.OutputValidator) - if len(output_validators) != 1: + if not output_validators: return None - validator = output_validators[0] - - return validator.run( - self.testcase, self, args=self.testcase.testdata_yaml_validator_args(validator, bar) + return output_validators[0].run( + self.testcase, + self, + args=self.testcase.testdata_yaml_validator_args(output_validators[0], bar), ) @@ -522,10 +522,8 @@ def test(self): testcases = self.problem.testcases(needans=False) - if self.problem.interactive: - output_validators = self.problem.validators(validate.OutputValidator) - if output_validators is False: - return + if not self.problem.validators(validate.OutputValidator): + return for testcase in testcases: header = ProgressBar.action("Running " + str(self.name), testcase.name) @@ -589,10 +587,8 @@ def test(self): # Run the submission using stdin as input. def test_interactive(self): - if self.problem.interactive: - output_validators = self.problem.validators(validate.OutputValidator) - if output_validators is False: - return + if not self.problem.validators(validate.OutputValidator): + return bar = ProgressBar("Running " + str(self.name), max_len=1, count=1) bar.start() diff --git a/bin/skel.py b/bin/skel.py index 3e1d19853..1b2588e55 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -255,7 +255,7 @@ def new_problem(): variables, exist_ok=True, preserve_symlinks=preserve_symlinks, - skip=[skeldir / "output_validators"] if not custom_output else None, + skip=[skeldir / "output_validator"] if not custom_output else None, ) # Warn about missing problem statement skeletons for non-en languages diff --git a/bin/stats.py b/bin/stats.py index 60253b77f..6123dc9c3 100644 --- a/bin/stats.py +++ b/bin/stats.py @@ -53,7 +53,7 @@ def problem_stats(problems): ("sol", str(latex.PdfType.SOLUTION.path("*")), 1), (" val: I", ["input_validators/*", "input_format_validators/*"]), ("A", ["answer_validators/*"]), - ("O", ["output_validators/*"]), + ("O", ["output_validator/"]), ( " sample", [lambda s: {x.stem for x in s if x.parts[2] == "sample"}], diff --git a/bin/testcase.py b/bin/testcase.py index d4bdb6255..d580bfe76 100644 --- a/bin/testcase.py +++ b/bin/testcase.py @@ -150,7 +150,7 @@ def validator_hashes(self, cls: type["validate.AnyValidator"], bar): indicating which validators will be run for this testcase. """ assert cls in [validate.InputValidator, validate.AnswerValidator, validate.OutputValidator] - validators = self.problem.validators(cls) or [] + validators = self.problem.validators(cls) d = dict() diff --git a/bin/upgrade.py b/bin/upgrade.py index 4cd1f4138..ee959e7bc 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -10,45 +10,6 @@ from ruamel.yaml.comments import CommentedMap, CommentedSeq -# This tries to preserve the correct comments. -def _filter(data: Any, remove: str) -> Any: - assert isinstance(data, CommentedMap) - - remove_index = list(data.keys()).index(remove) - if remove_index == 0: - return data.pop(remove) - - curr = data - prev_key = list(data.keys())[remove_index - 1] - while isinstance(curr[prev_key], list | dict): - # Try to remove the comment from the last element in the preceding list/dict - curr = curr[prev_key] - if isinstance(curr, list): - prev_key = len(curr) - 1 - else: - prev_key = list(curr.keys())[-1] - - if remove in data.ca.items: - # Move the comment that belongs to the removed key (which comes _after_ the removed key) - # to the preceding key - curr.ca.items[prev_key] = data.ca.items.pop(remove) - elif prev_key in data.ca.items: - # If the removed key does not have a comment, - # the comment after the previous key should be removed - curr.ca.items.pop(prev_key) - - return data.pop(remove) - - -# Insert a new key before an old key, then remove the old key. -# If new_value is not given, the default is to simply rename the old key to the new key. -def _replace(data: Any, old_key: str, new_key: str, new_value: Any = None) -> None: - if new_value is None: - new_value = data[old_key] - data.insert(list(data.keys()).index(old_key), new_key, new_value) - _filter(data, old_key) - - def upgrade_data(problem_path: Path, bar: ProgressBar) -> None: rename = [ ("data/invalid_inputs", "data/invalid_input"), @@ -85,7 +46,7 @@ def upgrade_testdata_yaml(problem_path: Path, bar: ProgressBar) -> None: resume=True, ) continue - _replace(data, old, new) + ryaml_replace(data, old, new) write_yaml(data, f) @@ -115,7 +76,7 @@ def upgrade_generators_yaml(problem_path: Path, bar: ProgressBar) -> None: ) continue bar.log(f"renaming 'data.{old_name}' to 'data.{new_name}' in generators.yaml") - _replace(data, old_name, new_name) + ryaml_replace(data, old_name, new_name) changed = True def upgrade_generated_testdata_yaml(data: dict[str, Any], path: str) -> bool: @@ -138,7 +99,7 @@ def upgrade_generated_testdata_yaml(data: dict[str, Any], path: str) -> bool: ) continue bar.log(f"change '{old}' to '{new}' in generators.yaml{print_path}") - _replace(testdata, old, new) + ryaml_replace(testdata, old, new) changed = True if "data" in data and data["data"]: children = data["data"] if isinstance(data["data"], list) else [data["data"]] @@ -188,6 +149,44 @@ def upgrade_statement(problem_path: Path, bar: ProgressBar) -> None: shutil.move(f, dest) +def upgrade_output_validators(problem_path: Path, bar: ProgressBar) -> None: + if (problem_path / "output_validators").is_dir(): + if (problem_path / "output_validator").exists(): + bar.error( + "can't rename 'output_validators/', 'output_validator/' already exists", resume=True + ) + return + content = [*(problem_path / "output_validators").iterdir()] + if len(content) == 1 and content[0].is_dir(): + bar.log(f"renaming 'output_validators/{content[0].name}' to 'output_validator/'") + + def move(src: str, dst: str) -> None: + if Path(src).is_symlink(): + src_dst = Path(src).resolve() + if src_dst.is_relative_to(content[0]): # local symlink + Path(src).rename(dst) + else: # link outside output_validators/ + dst_pos = Path(dst).resolve() + common = [ + a + for a, b in zip(reversed(src_dst.parents), reversed(dst_pos.parents)) + if a == b + ][-1] + link = Path( + "../" * (len(dst_pos.parents) - len(common.parts)) + ) / src_dst.relative_to(common) + Path(dst).symlink_to(link) + Path(src).unlink() + else: + Path(src).rename(dst) + + shutil.copytree(content[0], problem_path / "output_validator", copy_function=move) + shutil.rmtree(problem_path / "output_validators") + else: + bar.log("renaming 'output_validators/' to 'output_validator/'") + (problem_path / "output_validators").rename(problem_path / "output_validator") + + def upgrade_problem_yaml(problem_path: Path, bar: ProgressBar) -> None: assert (problem_path / "problem.yaml").exists() data = cast(CommentedMap, read_yaml(problem_path / "problem.yaml")) @@ -218,7 +217,7 @@ def upgrade_problem_yaml(problem_path: Path, bar: ProgressBar) -> None: # "type" comes before "name" in the spec pos = list(data.keys()).index("name") if "name" in data else 0 data.insert(pos, "type", type if len(type) > 1 else type[0]) - _filter(data, "validation") + ryaml_filter(data, "validation") if "author" in data: if "credits" in data: @@ -231,23 +230,23 @@ def upgrade_problem_yaml(problem_path: Path, bar: ProgressBar) -> None: name.strip() for name in data["author"].replace("and", ",").split(",") ) credits = CommentedMap({"authors": authors if len(authors) > 1 else authors[0]}) - _replace(data, "author", "credits", credits) + ryaml_replace(data, "author", "credits", credits) if "source_url" in data: if "source" not in data: - _replace(data, "source_url", "source") + ryaml_replace(data, "source_url", "source") elif data["source"]: bar.log("change 'source_url' to 'source.url' in problem.yaml") old_pos = list(data.keys()).index("source") - old_source = _filter(data, "source") - old_source_url = _filter(data, "source_url") + old_source = ryaml_filter(data, "source") + old_source_url = ryaml_filter(data, "source_url") data.insert( old_pos, "source", CommentedMap({"name": old_source, "url": old_source_url}) ) else: bar.log("remove empty 'source(_url)' in problem.yaml") - _filter(data, "source") - _filter(data, "source_url") + ryaml_filter(data, "source") + ryaml_filter(data, "source_url") if "limits" in data: limits = data["limits"] @@ -266,19 +265,19 @@ def upgrade_problem_yaml(problem_path: Path, bar: ProgressBar) -> None: if "time_multiplier" in limits: if limits["time_multiplier"] != 2: # Skip if it's equal to the new default time_multipliers["ac_to_time_limit"] = limits["time_multiplier"] - _filter(limits, "time_multiplier") + ryaml_filter(limits, "time_multiplier") if "time_safety_margin" in limits: if limits["time_safety_margin"] != 1.5: # Skip if it's equal to the new default time_multipliers["time_limit_to_tle"] = limits["time_safety_margin"] - _filter(limits, "time_safety_margin") + ryaml_filter(limits, "time_safety_margin") if time_multipliers: limits["time_multipliers"] = time_multipliers # If both time multipliers are default, remove the comments (this only works if # there are no other limits configured, but that's the most common case anyway) if not limits: - _filter(data, "limits") + ryaml_filter(data, "limits") def add_args(new_data: dict[str, Any]) -> bool: if "output_validator_args" in new_data: @@ -289,7 +288,7 @@ def add_args(new_data: dict[str, Any]) -> bool: return False bar.log("change 'validator_flags' to 'output_validator_args' in testdata.yaml") new_data["output_validator_args"] = data["validator_flags"] - _filter(data, "validator_flags") + ryaml_filter(data, "validator_flags") return True if "validator_flags" in data: @@ -321,7 +320,7 @@ def add_args(new_data: dict[str, Any]) -> bool: if add_args(testdata_data): write_yaml(testdata_data, testdata_path) else: - _filter(data, "validator_flags") + ryaml_filter(data, "validator_flags") timelimit_path = problem_path / ".timelimit" if timelimit_path.is_file(): @@ -369,7 +368,8 @@ def _upgrade(problem_path: Path, bar: ProgressBar) -> None: upgrade_testdata_yaml(problem_path, bar) upgrade_generators_yaml(problem_path, bar) upgrade_statement(problem_path, bar) - # TODO: output_validators -> output_validator + upgrade_output_validators(problem_path, bar) + # update .in.statement? upgrade_problem_yaml(problem_path, bar) bar.done() diff --git a/bin/util.py b/bin/util.py index 0ff476f2b..b4b570ddf 100644 --- a/bin/util.py +++ b/bin/util.py @@ -713,6 +713,43 @@ def ryaml_get_or_add( assert isinstance(value, t) return value # type: ignore + # This tries to preserve the correct comments. + def ryaml_filter(data: Any, remove: str) -> Any: + assert isinstance(data, ruamel.yaml.comments.CommentedMap) + remove_index = list(data.keys()).index(remove) + if remove_index == 0: + return data.pop(remove) + + curr = data + prev_key = list(data.keys())[remove_index - 1] + while isinstance(curr[prev_key], list | dict): + # Try to remove the comment from the last element in the preceding list/dict + curr = curr[prev_key] + if isinstance(curr, list): + prev_key = len(curr) - 1 + else: + prev_key = list(curr.keys())[-1] + + if remove in data.ca.items: + # Move the comment that belongs to the removed key (which comes _after_ the removed key) + # to the preceding key + curr.ca.items[prev_key] = data.ca.items.pop(remove) + elif prev_key in data.ca.items: + # If the removed key does not have a comment, + # the comment after the previous key should be removed + curr.ca.items.pop(prev_key) + + return data.pop(remove) + + # Insert a new key before an old key, then remove the old key. + # If new_value is not given, the default is to simply rename the old key to the new key. + def ryaml_replace(data: Any, old_key: str, new_key: str, new_value: Any = None) -> None: + assert isinstance(data, ruamel.yaml.comments.CommentedMap) + if new_value is None: + new_value = data[old_key] + data.insert(list(data.keys()).index(old_key), new_key, new_value) + ryaml_filter(data, old_key) + # Only allow one thread to write at the same time. Else, e.g., generating test cases in parallel goes wrong. write_yaml_lock = threading.Lock() diff --git a/bin/validate.py b/bin/validate.py index 527fc5ad6..3c8d4867d 100644 --- a/bin/validate.py +++ b/bin/validate.py @@ -224,9 +224,9 @@ class InputValidator(Validator): def __init__(self, problem, path, **kwargs): super().__init__(problem, path, "input_validators", **kwargs) - validator_type = "input" + validator_type: Final[str] = "input" - source_dirs = ["input_validators", "input_format_validators"] + source_dirs: Final[list[str]] = ["input_validators", "input_format_validators"] def run( self, @@ -284,9 +284,9 @@ class AnswerValidator(Validator): def __init__(self, problem, path, **kwargs): super().__init__(problem, path, "answer_validators", **kwargs) - validator_type = "answer" + validator_type: Final[str] = "answer" - source_dirs = ["answer_validators", "answer_format_validators"] + source_dirs: Final[list[str]] = ["answer_validators", "answer_format_validators"] def run( self, @@ -333,12 +333,11 @@ class OutputValidator(Validator): """ def __init__(self, problem, path, **kwargs): - super().__init__(problem, path, "output_validators", **kwargs) + super().__init__(problem, path, "output_validator", **kwargs) - validator_type = "output" + validator_type: Final[str] = "output" - # TODO #424: We should not support multiple output validators inside output_validator/. - source_dirs = ["output_validator", "output_validators"] + source_dir: Final[str] = "output_validator" def run( self, diff --git a/skel/problem/output_validators/output_validator/output_validator.cpp b/skel/problem/output_validator/output_validator.cpp similarity index 100% rename from skel/problem/output_validators/output_validator/output_validator.cpp rename to skel/problem/output_validator/output_validator.cpp diff --git a/skel/problem/output_validator/validation.h b/skel/problem/output_validator/validation.h new file mode 120000 index 000000000..8394e5a44 --- /dev/null +++ b/skel/problem/output_validator/validation.h @@ -0,0 +1 @@ +../../../headers/validation.h \ No newline at end of file diff --git a/test/problems/boolfind/output_validators/boolfind_run/build b/test/problems/boolfind/output_validator/build similarity index 100% rename from test/problems/boolfind/output_validators/boolfind_run/build rename to test/problems/boolfind/output_validator/build diff --git a/test/problems/boolfind/output_validators/boolfind_run/run b/test/problems/boolfind/output_validator/run similarity index 100% rename from test/problems/boolfind/output_validators/boolfind_run/run rename to test/problems/boolfind/output_validator/run diff --git a/test/problems/boolfind/output_validators/boolfind_run/runjury_boolfind.c b/test/problems/boolfind/output_validator/runjury_boolfind.c similarity index 100% rename from test/problems/boolfind/output_validators/boolfind_run/runjury_boolfind.c rename to test/problems/boolfind/output_validator/runjury_boolfind.c diff --git a/test/problems/constants/output_validators/output_validator/output_validator.cpp b/test/problems/constants/output_validator/output_validator.cpp similarity index 100% rename from test/problems/constants/output_validators/output_validator/output_validator.cpp rename to test/problems/constants/output_validator/output_validator.cpp diff --git a/skel/problem/output_validators/output_validator/validation.h b/test/problems/constants/output_validator/validation.h similarity index 100% rename from skel/problem/output_validators/output_validator/validation.h rename to test/problems/constants/output_validator/validation.h diff --git a/test/problems/constants/output_validators/output_validator/validation.h b/test/problems/constants/output_validators/output_validator/validation.h deleted file mode 120000 index 2b74c5d6a..000000000 --- a/test/problems/constants/output_validators/output_validator/validation.h +++ /dev/null @@ -1 +0,0 @@ -../../../../../headers/validation.h \ No newline at end of file diff --git a/test/problems/different/output_validators/different_validator/validate.cc b/test/problems/different/output_validator/validate.cc similarity index 100% rename from test/problems/different/output_validators/different_validator/validate.cc rename to test/problems/different/output_validator/validate.cc diff --git a/test/problems/different/output_validators/different_validator/validate.h b/test/problems/different/output_validator/validate.h similarity index 100% rename from test/problems/different/output_validators/different_validator/validate.h rename to test/problems/different/output_validator/validate.h diff --git a/test/problems/guess/output_validators/guess_validator/validate.cc b/test/problems/guess/output_validator/validate.cc similarity index 100% rename from test/problems/guess/output_validators/guess_validator/validate.cc rename to test/problems/guess/output_validator/validate.cc diff --git a/test/problems/guess/output_validators/guess_validator/validate.h b/test/problems/guess/output_validator/validate.h similarity index 100% rename from test/problems/guess/output_validators/guess_validator/validate.h rename to test/problems/guess/output_validator/validate.h diff --git a/test/problems/guessnoeofcheck/output_validators/guess_validator/validate.cc b/test/problems/guessnoeofcheck/output_validator/validate.cc similarity index 100% rename from test/problems/guessnoeofcheck/output_validators/guess_validator/validate.cc rename to test/problems/guessnoeofcheck/output_validator/validate.cc diff --git a/test/problems/guessnoeofcheck/output_validators/guess_validator/validate.h b/test/problems/guessnoeofcheck/output_validator/validate.h similarity index 100% rename from test/problems/guessnoeofcheck/output_validators/guess_validator/validate.h rename to test/problems/guessnoeofcheck/output_validator/validate.h diff --git a/test/problems/interactivemultipass/output_validators/interactive_multipass_validator/interctive_multipass_validator.py b/test/problems/interactivemultipass/output_validator/interctive_multipass_validator.py similarity index 100% rename from test/problems/interactivemultipass/output_validators/interactive_multipass_validator/interctive_multipass_validator.py rename to test/problems/interactivemultipass/output_validator/interctive_multipass_validator.py diff --git a/test/problems/multipass/output_validators/multipass_validator/multipass_validator.py b/test/problems/multipass/output_validator/multipass_validator.py similarity index 100% rename from test/problems/multipass/output_validators/multipass_validator/multipass_validator.py rename to test/problems/multipass/output_validator/multipass_validator.py diff --git a/test/problems/testproblemconfig/output_validators/.gitkeep b/test/problems/testproblemconfig/output_validator/.gitkeep similarity index 100% rename from test/problems/testproblemconfig/output_validators/.gitkeep rename to test/problems/testproblemconfig/output_validator/.gitkeep diff --git a/test/test_default_output_validator.py b/test/test_default_output_validator.py index 9c7286726..220b12d9f 100644 --- a/test/test_default_output_validator.py +++ b/test/test_default_output_validator.py @@ -13,7 +13,7 @@ RUN_DIR = Path.cwd().resolve() # Note: the python version isn't tested by default, because it's quite slow. -DEFAULT_OUTPUT_VALIDATORS = ["default_output_validator.cpp"] +DEFAULT_OUTPUT_VALIDATOR = ["default_output_validator.cpp"] config.args.verbose = 2 config.args.error = True @@ -42,7 +42,7 @@ def read_tests(): return tests -@pytest.fixture(scope="class", params=DEFAULT_OUTPUT_VALIDATORS) +@pytest.fixture(scope="class", params=DEFAULT_OUTPUT_VALIDATOR) def validator(request): problem_dir = RUN_DIR / "test/problems/identity" os.chdir(problem_dir) @@ -65,9 +65,9 @@ class MockRun: @pytest.mark.usefixtures("validator") -class TestDefaultOutputValidators: +class TestDefaultOutputValidator: @pytest.mark.parametrize("testdata", read_tests()) - def test_default_output_validators(self, validator, testdata): + def test_default_output_validator(self, validator, testdata): problem, validator = validator flags, ans, out, exp = testdata flags = flags.split() @@ -87,7 +87,7 @@ def test_default_output_validators(self, validator, testdata): r.out_path = out_path r.feedbackdir = problem.tmpdir / "data" - # TODO: the validators should probably be able to figure the flags out from the Problem config + # TODO: the validator should probably be able to figure the flags out from the Problem config result = validator.run(t, r, args=flags) if result.status != exp: From d0c3d80aa2520c3a845b8e6be5fbe0092d6d33b1 Mon Sep 17 00:00:00 2001 From: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> Date: Sun, 16 Mar 2025 22:59:54 +0100 Subject: [PATCH 05/23] [problem] Update parsing of problem.yaml based on Kattis/problem-package-format#372 (#437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [problem] Update parsing of problem.yaml based on Kattis/problem-package-format#372 Changes: - **C1**: Keywords are now a list of strings (we used to still parse them as a single string, woops) - **C2**: No change needed, this was just a "bug" in the human-readable text of the specification - **C3**: Add constraints to float/int types in `limits` and warnings when any of the values are out of range - **C5**: Do not allow lists to be empty (if a list-field is optional, it should be either `None` or a non-empty list) The discussion for **C4** was moved to Kattis/problem-package-format#378 and is pending consensus, and the proposals for **C6**, **Q1**, and **Q2** were dropped. * [problem] Fix parsing of ProblemSource, thanks to Thore's extra tests * [test] Add some more tests for license/rights_owner Note that I haven't thoroughly tested the combination of `license` and `rights_owner`. Similar to embargo_until, BAPCtools doesn't really do much with this information anyway, so the parser there is currently quite lenient, and as such I'll consider it out-of-scope for this PR. If others feel like improving and adding tests for this, feel free to do so 🙂 --- bin/problem.py | 107 ++++++++++++++++------------ bin/util.py | 12 +++- test/yaml/problem/invalid.yaml | 102 +++++++++++++++++++++++++++ test/yaml/problem/valid.yaml | 124 ++++++++++++++++++++++++++++++--- 4 files changed, 288 insertions(+), 57 deletions(-) diff --git a/bin/problem.py b/bin/problem.py index a652e18cf..b1f820754 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -1,3 +1,4 @@ +import datetime import re import sys import threading @@ -89,8 +90,16 @@ class ProblemSources(list[ProblemSource]): def __init__( self, yaml_data: dict[str, Any], - problem_settings: "ProblemSettings", ): + def source_from_dict(source_dict: dict[str, str]) -> ProblemSource: + name = parse_setting(source_dict, "name", "") + if not name: + warn("problem.yaml: 'name' is required in source") + return ProblemSource( + name, + parse_optional_setting(source_dict, "url", str), + ) + parse_deprecated_setting(yaml_data, "source_url", "source.url") if "source" not in yaml_data: return @@ -99,23 +108,17 @@ def __init__( return if isinstance(yaml_data["source"], dict): source = parse_setting(yaml_data, "source", dict[str, str]()) - self.append( - ProblemSource( - parse_setting(source, "name", ""), - parse_optional_setting(source, "url", str), - ) - ) + self.append(source_from_dict(source)) return if isinstance(yaml_data["source"], list): sources = parse_setting(yaml_data, "source", list[dict[str, str]]()) - for raw_source in sources: - source = parse_setting(raw_source, "source", dict[str, str]()) - self.append( - ProblemSource( - parse_setting(source, "name", ""), - parse_optional_setting(source, "url", str), - ) - ) + for i, source in enumerate(sources): + if isinstance(source, str): + self.append(ProblemSource(source)) + elif isinstance(source, dict): + self.append(source_from_dict(source)) + else: + warn(f"problem.yaml key 'source[{i}]' does not have the correct type") return warn("problem.yaml key 'source' does not have the correct type") @@ -134,31 +137,48 @@ def __init__( time_multipliers = parse_setting(yaml_data, "time_multipliers", dict[str, Any]()) parse_deprecated_setting(yaml_data, "time_multiplier", "ac_to_time_limit") - self.ac_to_time_limit = parse_setting(time_multipliers, "ac_to_time_limit", 2.0) + self.ac_to_time_limit = parse_setting(time_multipliers, "ac_to_time_limit", 2.0, ">= 1") parse_deprecated_setting(yaml_data, "time_safety_margin", "time_limit_to_tle") - self.time_limit_to_tle = parse_setting(time_multipliers, "time_limit_to_tle", 1.5) + self.time_limit_to_tle = parse_setting(time_multipliers, "time_limit_to_tle", 1.5, ">= 1") check_unknown_keys(time_multipliers, "limits.time_multipliers") - time_limit = parse_optional_setting(yaml_data, "time_limit", float) # in seconds - self.time_resolution: float = parse_setting(yaml_data, "time_resolution", 1.0) - self.memory: int = parse_setting(yaml_data, "memory", 2048) # in MiB - self.output: int = parse_setting(yaml_data, "output", 8) # in MiB - self.code: int = parse_setting(yaml_data, "code", 128) # in KiB - self.compilation_time: int = parse_setting(yaml_data, "compilation_time", 60) # in seconds + self.time_limit_is_default: bool = "time_limit" not in yaml_data + self.time_limit: float = parse_setting(yaml_data, "time_limit", 1.0, "> 0") # in seconds + self.time_resolution: float = parse_setting(yaml_data, "time_resolution", 1.0, "> 0") + self.memory: int = parse_setting(yaml_data, "memory", 2048, "> 0") # in MiB + self.output: int = parse_setting(yaml_data, "output", 8, "> 0") # in MiB + self.code: int = parse_setting(yaml_data, "code", 128, "> 0") # in KiB + self.compilation_time: int = parse_setting( + yaml_data, "compilation_time", 60, "> 0" + ) # in seconds self.compilation_memory: int = parse_setting( - yaml_data, "compilation_memory", 2048 + yaml_data, "compilation_memory", 2048, "> 0" ) # in MiB - self.validation_time: int = parse_setting(yaml_data, "validation_time", 60) # in seconds - self.validation_memory: int = parse_setting(yaml_data, "validation_memory", 2048) # in MiB - self.validation_output: int = parse_setting(yaml_data, "validation_output", 8) # in MiB - self.validation_passes: Optional[int] = parse_optional_setting( - yaml_data, "validation_passes", int - ) + self.validation_time: int = parse_setting( + yaml_data, "validation_time", 60, "> 0" + ) # in seconds + self.validation_memory: int = parse_setting( + yaml_data, "validation_memory", 2048, "> 0" + ) # in MiB + self.validation_output: int = parse_setting( + yaml_data, "validation_output", 8, "> 0" + ) # in MiB + if problem_settings.multi_pass: + self.validation_passes: Optional[int] = parse_setting( + yaml_data, "validation_passes", 2, ">= 2" + ) + elif "validation_passes" in yaml_data: + yaml_data.pop("validation_passes") + warn("limit: validation_passes is only used for multi-pass problems. SKIPPED.") # BAPCtools extensions: - self.generator_time: int = parse_setting(yaml_data, "generator_time", 60) # in seconds - self.visualizer_time: int = parse_setting(yaml_data, "visualizer_time", 60) # in seconds + self.generator_time: int = parse_setting( + yaml_data, "generator_time", 60, "> 0" + ) # in seconds + self.visualizer_time: int = parse_setting( + yaml_data, "visualizer_time", 60, "> 0" + ) # in seconds # warn for deprecated timelimit files if (problem.path / ".timelimit").is_file(): @@ -168,9 +188,6 @@ def __init__( "domjudge-problem.ini is DEPRECATED. Use limits.time_limit if you want to set a timelimit." ) - self.time_limit: float = time_limit or 1.0 - self.time_limit_is_default: bool = time_limit is None - check_unknown_keys(yaml_data, "limits") # Override limmits by command line arguments. @@ -233,18 +250,23 @@ def __init__( self.uuid: str = parse_setting(yaml_data, "uuid", "") self.version: str = parse_setting(yaml_data, "version", "") self.credits: ProblemCredits = ProblemCredits(yaml_data, self) - self.source: ProblemSources = ProblemSources(yaml_data, self) + self.source: ProblemSources = ProblemSources(yaml_data) self.license: str = parse_setting(yaml_data, "license", "unknown") - self.rights_owner: str = parse_setting(yaml_data, "rights_owner", "") + self.rights_owner: Optional[str] = parse_optional_setting(yaml_data, "rights_owner", str) # Not implemented in BAPCtools. Should be a date, but we don't do anything with this anyway. - self.embargo_until: str = parse_setting(yaml_data, "embargo-until", "") + self.embargo_until: Optional[datetime.date] = parse_optional_setting( + yaml_data, + "embargo_until", + # Note that datetime.datetime is also valid, as subclass of datetime.date + datetime.date, + ) self.limits = ProblemLimits(parse_setting(yaml_data, "limits", {}), problem, self) parse_deprecated_setting( yaml_data, "validator_flags", "output_validator_args' in 'testdata.yaml" ) - self.keywords: str = parse_setting(yaml_data, "keywords", "") + self.keywords: list[str] = parse_optional_list_setting(yaml_data, "keywords", str) # Not implemented in BAPCtools. We always test all languges in langauges.yaml. self.languages: list[str] = parse_optional_list_setting(yaml_data, "languages", str) @@ -271,13 +293,6 @@ def __init__( warn(f"invalid license: {self.license}") self.license = "unknown" - # Check that limits.validation_passes exists if and only if the problem is multi-pass - has_validation_passes = self.limits.validation_passes is not None - if self.multi_pass and not has_validation_passes: - self.limits.validation_passes = 2 - if not self.multi_pass and has_validation_passes: - warn("limit: validation_passes is only used for multi_pass problems. SKIPPED.") - # A problem. class Problem: diff --git a/bin/util.py b/bin/util.py index b4b570ddf..b8709d8c7 100644 --- a/bin/util.py +++ b/bin/util.py @@ -813,9 +813,15 @@ def parse_optional_setting(yaml_data: dict[str, Any], key: str, t: type[T]) -> O return None -def parse_setting(yaml_data: dict[str, Any], key: str, default: T) -> T: +def parse_setting( + yaml_data: dict[str, Any], key: str, default: T, constraint: Optional[str] = None +) -> T: value = parse_optional_setting(yaml_data, key, type(default)) - return default if value is None else value + result = default if value is None else value + if constraint and not eval(f"{result} {constraint}"): + warn(f"value for '{key}' in problem.yaml should be {constraint} but is {value}. SKIPPED.") + return default + return result def parse_optional_list_setting(yaml_data: dict[str, Any], key: str, t: type[T]) -> list[T]: @@ -829,6 +835,8 @@ def parse_optional_list_setting(yaml_data: dict[str, Any], key: str, t: type[T]) f"some values for key '{key}' in problem.yaml do not have type {t.__name__}. SKIPPED." ) return [] + if not value: + warn(f"value for '{key}' in problem.yaml should not be an empty list.") return value warn(f"incompatible value for key '{key}' in problem.yaml. SKIPPED.") return [] diff --git a/test/yaml/problem/invalid.yaml b/test/yaml/problem/invalid.yaml index 70ce6f805..48f805d7c 100644 --- a/test/yaml/problem/invalid.yaml +++ b/test/yaml/problem/invalid.yaml @@ -24,6 +24,20 @@ yaml: mumbo: jumbo warn: "found unknown problem.yaml key: mumbo in `limits.time_multipliers`" +--- +# UUID +yaml: + problem_format_version: 2023-07-draft + name: Invalid UUID, too short + uuid: 12345678-abcd +warn: "invalid uuid: 12345678-abcd" +--- +yaml: + problem_format_version: 2023-07-draft + name: Invalid UUID, not hexadecimal + uuid: 12345678-abcd-efgh-ijkl-12345678 +warn: "invalid uuid: 12345678-abcd-efgh-ijkl-12345678" + --- # Name yaml: @@ -85,3 +99,91 @@ yaml: name: Incorrect type (dict) type: 42 fatal: "problem.yaml: 'type' must be a string or a sequence" + +--- +# Limits +yaml: + problem_format_version: 2023-07-draft + name: Negative time limit + limits: + time_limit: -1 +warn: "value for 'time_limit' in problem.yaml should be > 0 but is -1.0. SKIPPED." +--- +yaml: + problem_format_version: 2023-07-draft + name: Time multiplier < 1 + limits: + time_multipliers: + ac_to_time_limit: 0.9 +warn: "value for 'ac_to_time_limit' in problem.yaml should be >= 1 but is 0.9. SKIPPED." +--- +yaml: + problem_format_version: 2023-07-draft + name: Only one pass for multi-pass + type: multi-pass + limits: + validation_passes: 1 +warn: "value for 'validation_passes' in problem.yaml should be >= 2 but is 1. SKIPPED." +--- +yaml: + problem_format_version: 2023-07-draft + name: Fractional passes for multi-pass + type: multi-pass + limits: + validation_passes: 2.5 +warn: "incompatible value for key 'validation_passes' in problem.yaml. SKIPPED." +--- +yaml: + problem_format_version: 2023-07-draft + name: validation_passes for non-multi-pass problem + limits: + validation_passes: 3 +warn: "limit: validation_passes is only used for multi-pass problems. SKIPPED." + +--- +# Empty list +yaml: + problem_format_version: 2023-07-draft + name: pass-fail type from empty type + type: [] +warn: "value for 'type' in problem.yaml should not be an empty list." +--- +yaml: + problem_format_version: 2023-07-draft + name: Empty list + keywords: [] +warn: "value for 'keywords' in problem.yaml should not be an empty list." + +--- +# Credits +yaml: + problem_format_version: 2023-07-draft + name: Cannot specify multiple authors in credits + credits: + - name: Alice + - name: Audrey Authorson + email: bob@foo.bar +warn: "incompatible value for key 'credits' in problem.yaml. SKIPPED." + +--- +# Source +yaml: + problem_format_version: 2023-07-draft + name: Source must have a name + source: + - url: https://2024.nwerc.example/contest +warn: "problem.yaml: 'name' is required in source" + +--- +# Embargo +yaml: + problem_format_version: 2023-07-draft + name: Embargo is not a date + embargo_until: not a date +warn: "incompatible value for key 'embargo_until' in problem.yaml. SKIPPED." +#--- +#yaml: +# problem_format_version: 2023-07-draft +# name: Embargo date does not exist +# embargo_until: 2025-02-29 +# Note that this cannot be tested in this way, because the YAML parser already throws an error. diff --git a/test/yaml/problem/valid.yaml b/test/yaml/problem/valid.yaml index 96a303261..ddcac08f9 100644 --- a/test/yaml/problem/valid.yaml +++ b/test/yaml/problem/valid.yaml @@ -18,6 +18,15 @@ yaml: name: en: Minimal nl: Minimaal +--- +yaml: + problem_format_version: 2023-07-draft + name: + en: Hello World! + pt-BR: Olá mundo! + pt-PT: Oi mundo! + fil: Kumusta mundo! + gsw-u-sd-chzh: Sali Zämme, Wäut! --- # Problem type tests @@ -38,15 +47,6 @@ eq: interactive: False multi_pass: False --- -yaml: - problem_format_version: 2023-07-draft - name: pass-fail type from empty type - type: [] -eq: - custom_output: False - interactive: False - multi_pass: False ---- yaml: problem_format_version: 2023-07-draft name: interactive type @@ -131,3 +131,109 @@ eq: en: - name: T. R. Anslator email: translator@example.com + +--- +# Source tests +yaml: + problem_format_version: 2023-07-draft + name: Source can be just a string + source: NWERC 2024 +eq: + source: + - name: NWERC 2024 + url: ~ +--- +yaml: + problem_format_version: 2023-07-draft + name: Source can be map + source: + name: NWERC 2024 +eq: + source: + - name: NWERC 2024 + url: ~ +--- +yaml: + problem_format_version: 2023-07-draft + name: Source can be map with two keys (name, url) + source: + name: NWERC 2024 + url: https://2024.nwerc.example/contest +eq: + source: + - name: NWERC 2024 + url: https://2024.nwerc.example/contest +--- +yaml: + problem_format_version: 2023-07-draft + name: Many sources can be specified + source: + - name: NWERC 2024 + url: https://2024.nwerc.example/contest + - SWERC 2024 + - name: SEERC 2024 +eq: + source: + - name: NWERC 2024 + url: https://2024.nwerc.example/contest + - name: SWERC 2024 + url: ~ + - name: SEERC 2024 + url: ~ + +--- +# License tests +yaml: + problem_format_version: 2023-07-draft + name: Default license is unknown and rights-less +eq: + license: unknown + rights_owner: +--- +yaml: + problem_format_version: 2023-07-draft + name: Rights-less license + license: public domain +eq: + license: public domain + rights_owner: +--- +yaml: + problem_format_version: 2023-07-draft + name: Specify license and rights owner + license: cc0 + rights_owner: Bob +--- +yaml: + problem_format_version: 2023-07-draft + name: Don't need rights_owner if credits are given + license: cc0 + credits: Bob +eq: + license: cc0 + rights_owner: # Allowed to be empty +--- +yaml: + problem_format_version: 2023-07-draft + name: Don't need rights_owner if credits.authors are given + license: cc0 + credits: + authors: "Bob" +--- +yaml: + problem_format_version: 2023-07-draft + name: Don't need rights_owner if source is given + license: cc0 + source: NWERC 2024 + +--- +# Embargo tests +yaml: + problem_format_version: 2023-07-draft + name: Embargo date + embargo_until: 2025-12-31 +--- +yaml: + problem_format_version: 2023-07-draft + name: Embargo datetime + embargo_until: 2025-12-31T23:59:59 From 027ff31f2f70049d1dc5a65be0be48950664dfe8 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Sun, 23 Mar 2025 15:12:08 +0100 Subject: [PATCH 06/23] Add .download samples and ans_is_output flag (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * partially implement draft for samples * only set out path if necessary * fix missing key * only check necessary .out files * use outpath if possible * some types * use string type name... * fix union type? * fix typing * drop .out support * dont warn here * add .out support * fix tests * made ans=out assumption optional * fix code * allow ans validators for interactive and multipass problems * add missing validator * properly handle samples in export * properly handle samples in export * properly handle samples in export * allow more answer validators * properly find testcases * [doc] Improve grammar in documentation * [validate] Replace import of Union with string type hint * hide A stat for interactive problems * dont always create empty ans files * add comment * rename * fix samples * only drop known suffixes * simplify code * add more tests * allow standalone in.statement * removed outdated assert * removed outdated if * undo namechange * update files * remove wip file * [export] bt samplezip: check for duplicate files from attachments/ * [validate] Skip sanity checks for empty .ans files for interactive problems * [test] samplezip/zip: assert that the correct samples are in the zip files * [test] Add samples for constants problem * [export] build_problem_zip: Make sure that .*.download files also end up in the zip * improve warning * [problem] Problem._samples: split warning message for has_raw over multiple lines * [export] Simplify getting of all samples: .interaction is included in KNOWN_DATA_EXTENSIONS * [generate] For interactive and/or multi-pass samples, allow .in.download and .interaction when both .in and .in.statement are missing Also generate empty .ans.statement or .ans.download files if they don't exist yet. * simplify code * i hate python tuples * [generate] generate_empty_interactive_sample_ans: stop when .ans file exists * [generate] Move generate_empty_interactive_sample_ans to later step * generators.cue: Add '{in,ans}.{statement,download}' to #testcase * [test] Fix test_schemata.sh: run from correct directory, replace {%placeholders%} * [test] test_schemata.sh: Skip empty snippets for now * [generate] Allow writing empty hardcoded files Kinda ugly, but should be caught by validators and sanity checks anyway, so having the check here should™ be redundant. This does allow writing empty .{in,ans}.{statement,download} files, which are _not_ sanity-checked. --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- bin/config.py | 10 +- bin/constraints.py | 2 +- bin/export.py | 104 +++---- bin/generate.py | 259 +++++++++++------- bin/problem.py | 234 ++++++++++------ bin/run.py | 6 +- bin/stats.py | 2 +- bin/testcase.py | 48 ++-- bin/util.py | 7 + bin/validate.py | 48 ++-- bin/validator_tests.py | 1 - doc/commands.md | 2 +- doc/generators.md | 2 +- doc/validation.md | 4 +- support/schemas/generators.cue | 9 +- support/schemas/generators_yaml_schema.json | 38 ++- test/problems/constants/data/sample/1.ans | 1 + test/problems/constants/data/sample/1.in | 1 + test/problems/identity/data/sample/5.ans | 1 + test/problems/identity/data/sample/5.in | 1 + test/problems/identity/data/sample/5.out | 1 + .../identity/data/sample/6.ans.statement | 1 + .../identity/data/sample/6.in.statement | 1 + .../identity/generators/generators.yaml | 7 + .../multipass/answer_validators/validate.ctd | 1 + test/test_generators_yaml.py | 2 + test/test_problems.py | 35 ++- test/yaml/generators/test_schemata.sh | 12 +- 28 files changed, 543 insertions(+), 297 deletions(-) create mode 100644 test/problems/constants/data/sample/1.ans create mode 100644 test/problems/constants/data/sample/1.in create mode 100644 test/problems/identity/data/sample/5.ans create mode 100644 test/problems/identity/data/sample/5.in create mode 100644 test/problems/identity/data/sample/5.out create mode 100644 test/problems/identity/data/sample/6.ans.statement create mode 100644 test/problems/identity/data/sample/6.in.statement create mode 100644 test/problems/multipass/answer_validators/validate.ctd diff --git a/bin/config.py b/bin/config.py index 8661c3c60..1327a2f6f 100644 --- a/bin/config.py +++ b/bin/config.py @@ -61,13 +61,19 @@ ".pdf", ] +KNOWN_SAMPLE_TESTCASE_EXTENSIONS: Final[Sequence[str]] = [ + ".in.statement", + ".ans.statement", + ".in.download", + ".ans.download", +] + KNOWN_TEXT_DATA_EXTENSIONS: Final[Sequence[str]] = [ *KNOWN_TESTCASE_EXTENSIONS, + *KNOWN_SAMPLE_TESTCASE_EXTENSIONS, ".interaction", ".hint", ".desc", - ".in.statement", - ".ans.statement", #'.args', ] diff --git a/bin/constraints.py b/bin/constraints.py index 06ae5e1cb..bea1f0ea4 100644 --- a/bin/constraints.py +++ b/bin/constraints.py @@ -23,7 +23,7 @@ def check_validators(problem): if not in_constraints: warn("No constraint validation of input values found in input validators.") problem.validate_data(validate.Mode.ANSWER, constraints=ans_constraints) - if not problem.interactive and not problem.multi_pass and not ans_constraints: + if not problem.settings.ans_is_output and not ans_constraints: log("No constraint validation of answer values found in answer or output validators.") print() diff --git a/bin/export.py b/bin/export.py index f5d5e6028..de91d6785 100644 --- a/bin/export.py +++ b/bin/export.py @@ -33,7 +33,7 @@ def remove_language_suffix(fname, statement_language): return out -def build_samples_zip(problems, output, statement_language): +def build_samples_zip(problems: list[Problem], output: Path, statement_language: str): zf = zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_DEFLATED, allowZip64=False) # Do not include contest PDF for kattis. @@ -47,18 +47,27 @@ def build_samples_zip(problems, output, statement_language): ) for problem in problems: + if not problem.label: + fatal(f"Cannot create samples zip: Problem {problem.name} does not have a label!") + outputdir = Path(problem.label) attachments_dir = problem.path / "attachments" if (problem.interactive or problem.multi_pass) and not attachments_dir.is_dir(): - interactive = "interactive " if problem.interactive else "" - multi_pass = "multi-pass " if problem.multi_pass else "" util.error( - f"{interactive}{multi_pass}problem {problem.name} does not have an attachments/ directory." + f"{problem.settings.type_name()} problem {problem.name} does not have an attachments/ directory." ) continue - empty = True + contents: dict[Path, Path] = {} # Maps desination to source, to allow checking duplicates. + + # Add samples. + samples = problem.download_samples() + for i, (in_file, ans_file) in enumerate(samples): + base_name = outputdir / str(i + 1) + contents[base_name.with_suffix(".in")] = in_file + if ans_file.stat().st_size > 0: + contents[base_name.with_suffix(".ans")] = ans_file # Add attachments if they exist. if attachments_dir.is_dir(): @@ -66,23 +75,23 @@ def build_samples_zip(problems, output, statement_language): if f.is_dir(): util.error(f"{f} directory attachments are not yet supported.") elif f.is_file() and f.exists(): - zf.write(f, outputdir / f.name) - empty = False + destination = outputdir / f.name + if destination in contents: + util.error( + f"Cannot overwrite {destination} from attachments/" + + f" (sourced from {contents[destination]})." + + "\n\tDo not include samples in attachments/," + + " use .{in,ans}.statement or .{in,ans}.download instead." + ) + else: + contents[destination] = f else: util.error(f"Cannot include broken file {f}.") - # Add samples for non-interactive and non-multi-pass problems. - if not problem.interactive and not problem.multi_pass: - samples = problem.testcases(only_samples=True) - if samples: - for i in range(0, len(samples)): - sample = samples[i] - basename = outputdir / str(i + 1) - zf.write(sample.in_path, basename.with_suffix(".in")) - zf.write(sample.ans_path, basename.with_suffix(".ans")) - empty = False - - if empty: + if contents: + for destination, source in contents.items(): + zf.write(source, destination) + else: util.error(f"No attachments or samples found for problem {problem.name}.") zf.close() @@ -107,22 +116,8 @@ def build_problem_zip(problem: Problem, output: Path): ("attachments/**/*", problem.interactive or problem.multi_pass), ] - testcases = [ - ("data/secret/**/*.in", True), - ("data/sample/**/*.in", not problem.interactive and not problem.multi_pass), - ] - - if problem.interactive or problem.multi_pass: - # .interaction files don't need a corresponding .in - # therefore we can handle them like all other files - files += [("data/sample/**/*.interaction", False)] - if not config.args.kattis: - files += [ - (f"problem.{statement_language}.pdf", True), - ("data/sample/**/*.in.statement", False), - ("data/sample/**/*.ans.statement", False), - ] + files.append((f"problem.{statement_language}.pdf", True)) if problem.custom_output: files.append(("output_validator/**/*", True)) @@ -141,7 +136,7 @@ def build_problem_zip(problem: Problem, output: Path): export_dir /= problem.name export_dir.mkdir(parents=True, exist_ok=True) - def add_file(path, source): + def add_file(path: Path, source: Path): path = export_dir / path path.parent.mkdir(parents=True, exist_ok=True) ensure_symlink(path, source) @@ -158,21 +153,32 @@ def add_file(path, source): out = remove_language_suffix(out, statement_language) add_file(out, f) - # Include all testcases (specified by a .in file) and copy all related files - for pattern, required in testcases: - paths = list(util.glob(problem.path, pattern)) - if required and len(paths) == 0: - util.error(f"No matches for required path {pattern}.") - for f in paths: + def add_testcase(in_file: Path): + base_name = util.drop_suffix(in_file, [".in", ".in.statement", ".in.download"]) + for ext in config.KNOWN_DATA_EXTENSIONS: + f = base_name.with_suffix(ext) if f.is_file(): - if not f.with_suffix(".ans").is_file(): - util.warn(f"No answer file found for {f}, skipping.") - else: - for ext in config.KNOWN_DATA_EXTENSIONS: - f2 = f.with_suffix(ext) - if f2.is_file(): - out = f2.relative_to(problem.path) - add_file(out, f2) + out = f.relative_to(problem.path) + add_file(out, f) + + # Include all sample test cases and copy all related files. + samples = problem.download_samples() + if len(samples) == 0: + util.error("No samples found.") + for in_file, _ in samples: + add_testcase(in_file) + + # Include all secret test cases and copy all related files. + pattern = "data/secret/**/*.in" + paths = util.glob(problem.path, pattern) + if len(paths) == 0: + util.error(f"No secret test cases found in {pattern}.") + for f in paths: + if f.is_file(): + if f.with_suffix(".ans").is_file(): + add_testcase(f) + else: + util.warn(f"No answer file found for {f}, skipping.") # DOMjudge and Kattis do not support 2023-07-draft yet. # TODO: Remove once they do. diff --git a/bin/generate.py b/bin/generate.py index 42ffa1e03..bf923226a 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Sequence from colorama import Fore, Style from pathlib import Path, PurePosixPath -from typing import Final, overload +from typing import Final, Optional, overload import config import parallel @@ -428,14 +428,24 @@ def __init__(self, problem, key, name, yaml, parent): class TestcaseRule(Rule): - def __init__(self, problem, generator_config, key, name: str, yaml, parent, count_index): + def __init__( + self, problem: Problem, generator_config, key, name: str, yaml, parent, count_index + ): assert is_testcase(yaml) # if not None rule will be skipped during generation - self.parse_error = None + self.parse_error: Optional[str] = None # Whether this testcase is a sample. - self.sample = len(parent.path.parts) > 0 and parent.path.parts[0] == "sample" + self.sample: bool = len(parent.path.parts) > 0 and parent.path.parts[0] == "sample" + # each test case needs some kind of input + self.required_in: list[list[str]] = [[".in"]] + if self.sample: + # for samples a statement in file is also sufficient + self.required_in.append([".in.statement"]) + if problem.interactive or problem.multi_pass: + # if .interaction is supported that is also fine as long as input download is provided as well. + self.required_in.append([".interaction", ".in.download"]) # 1. Generator self.generator = None @@ -484,10 +494,9 @@ def __init__(self, problem, generator_config, key, name: str, yaml, parent, coun if not config.COMPILED_FILE_NAME_REGEX.fullmatch(name + ".in"): raise ParseException("Testcase does not have a valid name.") + # files to consider for hashing + hashes = {} try: - # files to consider for hashing - hashes = {} - if yaml is None: raise ParseException( "Empty yaml entry (Testcases must be generated not only mentioned)." @@ -506,21 +515,32 @@ def __init__(self, problem, generator_config, key, name: str, yaml, parent, coun yaml = {"copy": yaml["generate"][:-3]} # checks - if not any(x in yaml for x in ["generate", "copy", "in", "interaction"]): + satisfied = False + msg = [] + for required in [[".generate"], [".copy"]] + self.required_in: + satisfied = satisfied or all(x[1:] in yaml for x in required) + msg.append(" and ".join([x[1:] for x in required])) + if not satisfied: + raise ParseException(f"Testcase requires at least one of: {', '.join(msg)}.") + if not problem.interactive and not problem.multi_pass and "interaction" in yaml: raise ParseException( - 'Testcase requires at least one key in "generate", "copy", "in", "interaction".' + "Testcase cannot have 'interaction' key for non-interactive/non-multi-pass problem." ) + if not self.sample: + for ext in config.KNOWN_SAMPLE_TESTCASE_EXTENSIONS: + if ext[1:] in yaml: + raise ParseException(f"Non sample testcase cannot use '{ext[1:]}") if "submission" in yaml and "ans" in yaml: - raise ParseException('Testcase cannot specify both "submissions" and "ans".') + raise ParseException("Testcase cannot specify both 'submissions' and 'ans'.") if "count" in yaml and not isinstance(yaml["count"], int): value = yaml["count"] - raise ParseException(f'Testcase expected int for "count" but found {value}.') + raise ParseException(f"Testcase expected int for 'count' but found {value}.") # 1. generate if "generate" in yaml: assert_type("generate", yaml["generate"], str) if len(yaml["generate"]) == 0: - raise ParseException("`generate` must not be empty.") + raise ParseException("'generate' must not be empty.") # first replace {{constants}} command_string = yaml["generate"] @@ -591,9 +611,8 @@ def __init__(self, problem, generator_config, key, name: str, yaml, parent, coun if ".in" in self.hardcoded: self.in_is_generated = False self.rule["in"] = self.hardcoded[".in"] - for ext in config.KNOWN_TESTCASE_EXTENSIONS: - if ext in self.hardcoded: - hashes[ext] = hash_string(self.hardcoded[ext]) + for ext, value in self.hardcoded.items(): + hashes[ext] = hash_string(value) # Warn/Error for unknown keys. for key in yaml: @@ -608,13 +627,8 @@ def __init__(self, problem, generator_config, key, name: str, yaml, parent, coun color_type=MessageType.LOG, ) - if ".in" not in hashes: - generator_config.n_parse_error += 1 - # An error is shown during generate. - return - # build ordered list of hashes we want to consider - hs = [hashes[ext] for ext in config.KNOWN_TESTCASE_EXTENSIONS if ext in hashes] + hs = list(hashes.values()) # combine hashes if len(hs) == 1: @@ -626,11 +640,22 @@ def __init__(self, problem, generator_config, key, name: str, yaml, parent, coun self.copy_of = generator_config.rules_cache[self.hash] else: generator_config.rules_cache[self.hash] = self + except ParseException as e: # For testcases we can handle the parse error locally since this does not influence much else self.parse_error = e.message generator_config.n_parse_error += 1 + if not any(all(ext in hashes for ext in required) for required in self.required_in): + generator_config.n_parse_error += 1 + # An error is shown during generate. + + def _has_required_in(t, infile: Path) -> bool: + for required in t.required_in: + if all(infile.with_suffix(ext).is_file() for ext in required): + return True + return False + def link(t, problem, generator_config, bar, dst): src_dir = problem.path / "data" / t.path.parent src = src_dir / (t.name + ".in") @@ -704,55 +729,58 @@ def validate_in(t, problem: Problem, testcase: Testcase, meta_yaml: dict, bar: P ) return True - def validate_ans(t, problem: Problem, testcase: Testcase, meta_yaml: dict, bar: ProgressBar): + # we assume .ans is a valid output and validate it as such + def validate_ans_and_out( + t, problem: Problem, testcase: Testcase, meta_yaml: dict, bar: ProgressBar + ): infile = problem.tmpdir / "data" / t.hash / "testcase.in" assert infile.is_file() if testcase.root == "invalid_input": return True - ansfile = problem.tmpdir / "data" / t.hash / "testcase.ans" - assert ansfile.is_file() + ansfile = infile.with_suffix(".ans") + if not ansfile.is_file(): + bar.error("No .ans file was generated!") + return False - if problem.interactive or problem.multi_pass: - if ansfile.stat().st_size != 0: - interactive = "interaction " if problem.interactive else "" - multi_pass = "multi-pass " if problem.multi_pass else "" - bar.warn(f".ans file for {interactive}{multi_pass}problem is expected to be empty.") - else: - size = ansfile.stat().st_size - if ( - size <= problem.limits.output * 1024 * 1024 - and problem.limits.output * 1024 * 1024 < 2 * size - ): # we already warn if the limit is exceeded - bar.warn( - f".ans file is {size / 1024 / 1024:.3f}MiB, which is close to output limit (set limits.output to at least {(2 * size + 1024 * 1024 - 1) // 1024 // 1024}MiB in problem.yaml)" - ) + outfile = infile.with_suffix(".out") + if not outfile.is_file() and testcase.root in ["invalid_output", "valid_output"]: + bar.error("No .out file was generated!") + return False - answer_validator_hashes = {**testcase.validator_hashes(validate.AnswerValidator, bar)} - if all(h in meta_yaml["answer_validator_hashes"] for h in answer_validator_hashes): - return True + answer_validator_hashes = {**testcase.validator_hashes(validate.AnswerValidator, bar)} + if all(h in meta_yaml["answer_validator_hashes"] for h in answer_validator_hashes): + return True - if not testcase.validate_format( - validate.Mode.ANSWER, - bar=bar, - warn_instead_of_error=config.args.no_validators, - ): - if not config.args.no_validators: - bar.debug("Use generate --no-validators to ignore validation results.") - bar.done(False) - return False - else: - for h in answer_validator_hashes: - meta_yaml["answer_validator_hashes"][h] = answer_validator_hashes[h] - write_yaml( - meta_yaml, - problem.tmpdir / "data" / t.hash / "meta_.yaml", - allow_yamllib=True, - ) + mode = validate.Mode.ANSWER + if testcase.root in config.INVALID_CASE_DIRECTORIES: + mode = validate.Mode.INVALID + elif testcase.root == "valid_output": + mode = validate.Mode.VALID_OUTPUT + elif outfile.is_file(): + mode = validate.Mode.VALID_OUTPUT + + if not testcase.validate_format( + mode, + bar=bar, + warn_instead_of_error=config.args.no_validators, + ): + if not config.args.no_validators: + bar.debug("Use generate --no-validators to ignore validation results.") + bar.done(False) + return False + else: + for h in answer_validator_hashes: + meta_yaml["answer_validator_hashes"][h] = answer_validator_hashes[h] + write_yaml( + meta_yaml, + problem.tmpdir / "data" / t.hash / "meta_.yaml", + allow_yamllib=True, + ) return True - def generate(t, problem, generator_config, parent_bar): + def generate(t, problem: Problem, generator_config, parent_bar): bar = parent_bar.start(str(t.path)) t.generate_success = False @@ -907,16 +935,13 @@ def generate_from_rule(): # Step 3: Write hardcoded files. for ext, contents in t.hardcoded.items(): - if contents == "" and t.root not in ["bad", "invalid_input"]: - bar.error(f"Hardcoded {ext} data must not be empty!") - return False - else: - # substitute in contents? -> No! - infile.with_suffix(ext).write_text(contents) + # substitute in contents? -> No! + infile.with_suffix(ext).write_text(contents) # Step 4: Error if infile was not generated. - if not infile.is_file(): - bar.error("No .in file was generated!") + if not t._has_required_in(infile): + msg = ", ".join(" and ".join(required) for required in t.required_in) + bar.error(f"No {msg} file was generated!") return False # Step 5: save which files where generated @@ -933,7 +958,7 @@ def generate_from_rule(): else: check_deterministic(False) - assert infile.is_file(), f"Failed to generate in file: {infile}" + assert t._has_required_in(infile), f"Failed to generate in file: {infile.name}" return True def generate_from_solution(): @@ -964,17 +989,22 @@ def needed(ext): used_solution = False changed_ans = False - if problem.interactive or problem.multi_pass: - # Generate empty ans file for interactive/multi-pass problems + if not problem.settings.ans_is_output: + # Generate empty ans file if ".ans" not in meta_yaml["generated_extensions"]: - if not ansfile.is_file() or ansfile.stat().st_size != 0: + if not ansfile.is_file() and (problem.interactive or problem.multi_pass): ansfile.write_text("") changed_ans = True - # For interactive/multi-pass problems, run the solution and generate a .interaction. + # For interactive/multi-pass problems, run the solution and generate a .interaction if necessary. if ( - t.config.solution + (problem.interactive or problem.multi_pass) + and t.config.solution and (testcase.root == "sample" or config.args.interaction) and needed(".interaction") + and not any( + infile.with_suffix(ext).is_file() + for ext in [".out", ".in.statement", ".ans.statement"] + ) ): if not t.config.solution.run_interaction(bar, cwd, t): return False @@ -1004,9 +1034,25 @@ def needed(ext): assert ansfile.is_file(), f"Failed to generate ans file: {ansfile}" return True + def generate_empty_interactive_sample_ans(): + if not t.sample: + return True + if not problem.interactive and not problem.multi_pass: + return True + for ext in ["", ".statement", ".download"]: + ans_ext_file = infile.with_suffix(f".ans{ext}") + if ans_ext_file.exists(): + return True + if infile.with_suffix(f".in{ext}").exists(): + ans_ext_file.write_text("") + return True + return True + def generate_visualization(): nonlocal meta_yaml + if testcase.root in [*config.INVALID_CASE_DIRECTORIES, "valid_output"]: + return True if not t.config.visualizer: return True if config.args.no_visualizer: @@ -1076,19 +1122,21 @@ def add_testdata_to_cache(): # Store the generated testdata for deduplication test cases. hashes = {} - # remove files that should not be considered for this testcase - extensions = list(config.KNOWN_TESTCASE_EXTENSIONS) - if t.root not in [*config.INVALID_CASE_DIRECTORIES[1:], "valid_output"]: - extensions.remove(".ans") - if t.root not in [*config.INVALID_CASE_DIRECTORIES[2:], "valid_output"]: - extensions.remove(".out") + # consider specific files for the uniqueness of this testcase + relevant_files = { + "bad": [".in", ".ans"], + "invalid_answer": [".in", ".ans"], + "invalid_output": [".in", ".ans", ".out"], + "valid_output": [".in", ".ans", ".out"], + } + extensions = relevant_files.get(t.root, [".in"]) for ext in extensions: if target_infile.with_suffix(ext).is_file(): hashes[ext] = hash_file(target_infile.with_suffix(ext)) # build ordered list of hashes we want to consider - hs = [hashes[ext] for ext in extensions if ext in hashes] + hs = list(hashes.values()) # combine hashes if len(hs) == 1: @@ -1120,29 +1168,35 @@ def add_testdata_to_cache(): if not generate_from_rule(): return - # Step 3: check .in if needed - testcase = Testcase(problem, infile, short_path=t.path / t.name) - if not t.validate_in(problem, testcase, meta_yaml, bar): - return + if infile.is_file(): + # Step 3: check .in if needed + testcase = Testcase(problem, infile, short_path=t.path / t.name) + if not t.validate_in(problem, testcase, meta_yaml, bar): + return - # Step 4: generate .ans and .interaction if needed - if not generate_from_solution(): - return + # Step 4: generate .ans and .interaction if needed + if not generate_from_solution(): + return - # Step 5: validate .ans if needed - if not t.validate_ans(problem, testcase, meta_yaml, bar): - return + # Step 5: validate .ans (and .out if it exists) + if not t.validate_ans_and_out(problem, testcase, meta_yaml, bar): + return + + # Step 6: generate visualization if needed + if not generate_visualization(): + return - # Step 6: generate visualization if needed - if not generate_visualization(): + # Step 7: for interactive and/or multi-pass samples, generate empty .ans if it does not exist + if not generate_empty_interactive_sample_ans(): return - # Step 7: copy all generated files + # Step 8: copy all generated files copy_generated() # Note that we set this to true even if not all files were overwritten -- a different log/warning message will be displayed for that. t.generate_success = True - add_testdata_to_cache() + if infile.is_file(): + add_testdata_to_cache() if config.args.action != "generate": bar.logged = True # Disable redundant 'up to date' message in run mode. bar.done(message="SKIPPED: up to date") @@ -1368,12 +1422,12 @@ def generate_includes(d, problem, generator_config, bar): meta_yaml = read_yaml(meta_path) testcase = Testcase(problem, infile, short_path=new_case) - # Step 1: validate input + # Step 1: validate .in if not t.validate_in(problem, testcase, meta_yaml, bar): continue - # Step 2: validate answer - if not t.validate_ans(problem, testcase, meta_yaml, bar): + # Step 2: validate .ans (and .out if it exists) + if not t.validate_ans_and_out(problem, testcase, meta_yaml, bar): continue t.link(problem, generator_config, bar, new_infile) @@ -1381,13 +1435,13 @@ def generate_includes(d, problem, generator_config, bar): # Returns the numbered name -def numbered_testcase_name(basename, i, n): +def numbered_testcase_name(base_name, i, n): width = len(str(n)) number_prefix = f"{i:0{width}}" - if basename: - return number_prefix + "-" + basename + if base_name: + return number_prefix + "-" + base_name else: - assert basename is None or basename == "" + assert base_name is None or base_name == "" return number_prefix @@ -1847,8 +1901,7 @@ def build_program(p): build_programs(program.Visualizer, visualizers_used) self.problem.validators(validate.InputValidator) - if not self.problem.interactive and not self.problem.multi_pass: - self.problem.validators(validate.AnswerValidator) + self.problem.validators(validate.AnswerValidator) self.problem.validators(validate.OutputValidator) def cleanup_build_failures(t): diff --git a/bin/problem.py b/bin/problem.py index b1f820754..04690535b 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -283,6 +283,12 @@ def __init__( # BAPCtools extensions: self.verified: Optional[str] = parse_optional_setting(yaml_data, "verified", str) self.comment: Optional[str] = parse_optional_setting(yaml_data, "comment", str) + self.ans_is_output: bool = parse_setting( + yaml_data, "ans_is_output", not self.interactive and not self.multi_pass + ) + if (self.interactive or self.multi_pass) and self.ans_is_output: + warn(f"ans_is_output: True makes no sense for {self.type_name()} problem. IGNORED.") + self.ans_is_output = False check_unknown_keys(yaml_data) @@ -293,6 +299,16 @@ def __init__( warn(f"invalid license: {self.license}") self.license = "unknown" + def type_name(self) -> str: + parts: list[str] = [] + if self.interactive: + parts.append("interactive") + if self.multi_pass: + parts.append("multi_pass") + if not parts: + parts.append("pass-fail") + return " ".join(parts) + # A problem. class Problem: @@ -576,12 +592,13 @@ def testcases( in_paths = list(set(in_paths)) elif mode is not None: + assert needans in_paths = [] for prefix in { validate.Mode.INPUT: ["secret", "sample"], validate.Mode.ANSWER: ["secret", "sample"], validate.Mode.INVALID: config.INVALID_CASE_DIRECTORIES, - validate.Mode.VALID_OUTPUT: ["valid_output"], + validate.Mode.VALID_OUTPUT: ["secret", "sample", "valid_output"], }[mode]: in_paths += glob(p.path, f"data/{prefix}/**/*.in") else: @@ -595,22 +612,22 @@ def testcases( if ( (p.interactive or p.multi_pass) and mode in [validate.Mode.INVALID, validate.Mode.VALID_OUTPUT] - and t.root in ["invalid_answer", "invalid_output", "valid_output"] + and t.root in ["invalid_output", "valid_output"] ): - msg = "" - if p.interactive: - msg += " interactive" - if p.multi_pass: - msg += " multi-pass" - warn(f"Found file {f} for {mode} validation in{msg} problem. Skipping.") + warn( + f"Found file {f} for {mode} validation in {p.settings.type_name()} problem. Skipping." + ) continue if needans and not t.ans_path.is_file(): if t.root != "invalid_input": warn(f"Found input file {f} without a .ans file. Skipping.") continue - if t.out_path is not None and not t.out_path.is_file(): - warn(f"Found input file {f} without a .out file. Skipping.") - continue + if mode == validate.Mode.VALID_OUTPUT: + if t.out_path is None: + continue + if not t.out_path.is_file(): + warn(f"Found input file {f} without a .out file. Skipping.") + continue testcases.append(t) testcases.sort(key=lambda t: t.name) @@ -629,67 +646,125 @@ def testcases( p._testcases[key] = testcases return testcases - # Returns a list of: - # - (Path, Path): (.in, .ans) pair - # - (Path, Path): (.in.statement, .ans.statement) pair - # - Path : .interaction file - def statement_samples(p) -> list[Path | tuple[Path, Path]]: - statement_in_paths = list(glob(p.path, "data/sample/**/*.in.statement")) - interaction_paths = list(glob(p.path, "data/sample/**/*.interaction")) + def _samples( + p, in_extensions: list[str], ans_extensions: list[str], return_interaction_file: bool + ) -> list[Path | tuple[Path, Path]]: + """ + Find the samples of the problem - # Make sure that .in.statement files are not mixed with .interaction files. - for in_path in interaction_paths: - if in_path.with_suffix(".in.statement").is_file(): - warn( - f"Do not mix .in.statement files and .interaction files with the same basename in {p}." - ) + Arguments + --------- + in_extensions: possible extensions for an in file sorted by priority + ans_extensions: possible extensions for an ans file sorted by priority + return_interaction_file: If True allows to represent testcases by an .interaction file - # A .in may be shadowed by either .in.statement or .interaction, in which case the .in itself is not shown in the PDF. - in_paths = [] - for in_path in list(glob(p.path, "data/sample/**/*.in")): - if in_path.with_suffix(".in.statement").is_file(): - continue - if in_path.with_suffix(".interaction").is_file(): + Returns: + -------- + A list of testcases represented either by their .interaction file or an in and ans file + """ + + base_names: set[Path] = set() + for ext in [".in", ".in.statement", ".interaction"]: + files = list(p.path.glob(f"data/sample/**/*{ext}")) + base_names.update([drop_suffix(f, [ext]) for f in files if f.is_file()]) + testcases: list[Path | tuple[Path, Path]] = [] + has_raw = False + for name in base_names: + in_found = [ext for ext in in_extensions if name.with_suffix(ext).is_file()] + ans_found = [ext for ext in ans_extensions if name.with_suffix(ext).is_file()] + has_statement = ".in.statement" in in_found or ".ans.statement" in ans_found + + # check for inconsistencies + if ".in" in in_found and ".ans" not in ans_found: + warn(f"Found {name}.in but no {name}.ans. SKIPPING.") continue - in_paths.append(in_path) - # .interaction files cannot be mixed with .in/.ans pairs. - if len(interaction_paths) != 0 and len(in_paths) + len(statement_in_paths) != 0: - warn(f"Do not mix .interaction files with .in/.ans files in {p}.") + # resolve some inconsistencies + if ".in" not in in_found: + if ".ans" in ans_found: + warn(f"Found {name}.ans but no {name}.in. IGNORED.") + ans_found.remove(".ans") + if ".out" in ans_found: + warn(f"Found {name}.out but no {name}.in. IGNORED.") + ans_found.remove(".out") + if has_statement and ".out" in ans_found: + # we prefer .statement files + warn(f"Found {name}.out (but also .statement). IGNORED.") + ans_found.remove(".out") + + # .interaction files get highest priority + if return_interaction_file and name.with_suffix(".interaction").is_file(): + if not p.interactive and not p.multi_pass: + warn(f"Found {name}.interaction for non-interactive/non-multi-pass. IGNORED.") + else: + if has_statement: + warn( + f"Mixed .interaction and .statement file for {name}. (using .interaction)." + ) + if ".out" in ans_found: + warn(f"Mixed .interaction and .out file for {name}. (using .interaction).") + testcases.append(name.with_suffix(".interaction")) + continue - # Non-interactive and Non-multi-pass problems should not have .interaction files. - # On the other hand, interactive problems are allowed to have .{in,ans}.statement files, - # so that they can emulate a non-interactive problem with on-the-fly generated input. - if not p.interactive and not p.multi_pass: - if len(interaction_paths) != 0: + if not in_found or not ans_found: warn( - f"Non-interactive/Non-multi-pass problem {p.name} should not have data/sample/*.interaction files." + f"Could not find valid .in/.ans combination for test case {name}. SKIPPED." + + "\n\tNumbering for statement and download could be inconsistent!" ) - interaction_paths = [] - - testcases = list[Path | tuple[Path, Path]]() - for in_path in in_paths: - ans_path = in_path.with_suffix(".ans") - if not ans_path.is_file(): - warn(f"Found input file {in_path} without a .ans file. Skipping.") continue - testcases.append((in_path, ans_path)) - for in_path in statement_in_paths: - # first remove .statement, then replace .in with .ans.statement - ans_path = in_path.with_suffix("").with_suffix(".ans.statement") - if not ans_path.is_file(): - warn(f"Found input file {in_path} without a .ans.statement file. Skipping.") - continue - testcases.append((in_path, ans_path)) + if ( + not name.with_suffix(".interaction").is_file() + and ans_found[0] == ".ans" + and name.with_suffix(in_found[0]).stat().st_size > 0 + and name.with_suffix(ans_found[0]).stat().st_size > 0 + ): + has_raw = True - for interaction_path in interaction_paths: - testcases.append(interaction_path) + # fallback is pair of files + testcases.append((name.with_suffix(in_found[0]), name.with_suffix(ans_found[0]))) - testcases.sort() + if has_raw and not p.settings.ans_is_output: + warn( + "It is advised to overwrite .ans for samples if it does not represent a valid output." + + "\n\tUse .ans.statement or .out for this." + ) + testcases.sort() return testcases + # Returns a list of: + # - (Path, Path): with the first being one of [.in.statement, .in] and the second one of [.ans.statement, .out, .ans] + # - Path : .interaction file + def statement_samples(p) -> list[Path | tuple[Path, Path]]: + in_extensions = [ + ".in.statement", + ".in", + ] + ans_extensions = [ + ".ans.statement", + ".out", + ".ans", + ] + return p._samples(in_extensions, ans_extensions, True) + + # Returns a list of: + # - (Path, Path): with the first being one of [.in.download, .in.statement, .in] and the second one of [.ans.download, .ans.statement, .out, .ans] + def download_samples(p) -> list[tuple[Path, Path]]: + in_extensions = [ + ".in.download", + ".in.statement", + ".in", + ] + ans_extensions = [ + ".ans.download", + ".ans.statement", + ".out", + ".ans", + ] + testcases = p._samples(in_extensions, ans_extensions, False) + return [t for t in testcases if isinstance(t, tuple)] + # Returns the list of submissions passed as command-line arguments, or the list of accepted submissions by default. def selected_or_accepted_submissions(problem) -> list["run.Submission"]: submissions = problem.submissions() @@ -800,22 +875,22 @@ def validators( list(Validator) otherwise, maybe empty """ validators = problem._validators(cls, check_constraints) - if not strict and cls == validate.AnswerValidator: + if not strict and cls == validate.AnswerValidator and problem.settings.ans_is_output: validators = validators + problem._validators( validate.OutputValidator, check_constraints ) # Check that the proper number of validators is present - # do this after handling the strict flag but dont warn every time + # do this after handling the strict flag but do not warn every time if print_warn: key = (cls, check_constraints) if key not in problem._validators_warn_cache: problem._validators_warn_cache.add(key) - match cls, len(validators): - case validate.InputValidator, 0: - warn("No input validators found.") - case validate.AnswerValidator, 0: - warn("No answer validators found") + if cls == validate.InputValidator and not validators: + warn("No input validators found.") + if cls == validate.AnswerValidator and not validators and not problem.interactive: + # for interactive problems, the .ans file should be empty + warn("No answer validators found.") build_ok = all(v.ok for v in validators) @@ -1058,16 +1133,6 @@ def validate_data(problem, mode: validate.Mode, constraints: dict | bool | None True if all validation was successful. Successful validation includes, e.g., correctly rejecting invalid inputs. """ - if (problem.interactive or problem.multi_pass) and mode == validate.Mode.ANSWER: - if problem.validators(validate.AnswerValidator, strict=True, print_warn=False): - msg = "" - if problem.interactive: - msg += " interactive" - if problem.multi_pass: - msg += " multi-pass" - log(f"Not running answer_validators for{msg} problems.") - return True - action: str = "" if mode == validate.Mode.INVALID: action = "Invalidation" @@ -1091,7 +1156,13 @@ def validate_invalid_extra_data(p) -> bool: validators: list[tuple[type[validate.AnyValidator], str, str, str, list[str]]] = [ (validate.InputValidator, "invalid_input", ".in", ".in", []), (validate.AnswerValidator, "invalid_answer", ".ans", ".ans", [".in"]), - (validate.OutputValidator, "invalid_output", ".ans", ".out", [".in", ".ans"]), + ( + validate.OutputValidator, + "invalid_output", + ".ans" if p.settings.ans_is_output else ".out", + ".out", + [".in", ".ans"], + ), ] testcases: list[testcase.Testcase] = [] @@ -1100,7 +1171,9 @@ def validate_invalid_extra_data(p) -> bool: for cls, directory, read, write, copy in validators: if directory not in config.args.generic: continue - if (p.interactive or p.multi_pass) and cls != validate.InputValidator: + if p.interactive and cls != validate.InputValidator: + continue + if p.multi_pass and cls == validate.OutputValidator: continue if not p.validators(cls, strict=True, print_warn=False): continue @@ -1236,17 +1309,12 @@ def _validate_data( case validate.Mode.INPUT: problem.validators(validate.InputValidator, check_constraints=check_constraints) case validate.Mode.ANSWER: - assert not problem.interactive - assert not problem.multi_pass problem.validators(validate.AnswerValidator, check_constraints=check_constraints) case validate.Mode.INVALID: problem.validators(validate.InputValidator) - if not problem.interactive and not problem.multi_pass: - problem.validators(validate.AnswerValidator) + problem.validators(validate.AnswerValidator) problem.validators(validate.OutputValidator) case validate.Mode.VALID_OUTPUT: - assert not problem.interactive - assert not problem.multi_pass problem.validators(validate.InputValidator) problem.validators(validate.AnswerValidator) problem.validators(validate.OutputValidator) diff --git a/bin/run.py b/bin/run.py index 308b6d670..297cb1fc5 100644 --- a/bin/run.py +++ b/bin/run.py @@ -219,10 +219,12 @@ def _validate_output(self, bar): output_validators = self.problem.validators(validate.OutputValidator) if not output_validators: return None - return output_validators[0].run( + output_validator = output_validators[0] + assert isinstance(output_validator, validate.OutputValidator) + return output_validator.run( self.testcase, self, - args=self.testcase.testdata_yaml_validator_args(output_validators[0], bar), + args=self.testcase.testdata_yaml_validator_args(output_validator, bar), ) diff --git a/bin/stats.py b/bin/stats.py index 6123dc9c3..c4d77163a 100644 --- a/bin/stats.py +++ b/bin/stats.py @@ -173,7 +173,7 @@ def count(path): def value(x): if x[0] == " time" or x[0] == "subs": return x[1](problem) - if x[0] == "A" and (problem.interactive or problem.multi_pass): + if x[0] == "A" and problem.interactive: return None # Do not show an entry for the answer validator if it is not required if x[0] == "O" and not problem.custom_output: return None # Do not show an entry for the output validator if it is not required diff --git a/bin/testcase.py b/bin/testcase.py index d580bfe76..f6500bf1e 100644 --- a/bin/testcase.py +++ b/bin/testcase.py @@ -1,6 +1,8 @@ """Test case""" -from typing import cast, Literal +from colorama import Fore, Style +from pathlib import Path +from typing import cast, Literal, Optional from util import ( ExecStatus, @@ -10,7 +12,6 @@ shorten_path, warn, ) -from colorama import Fore, Style import config import validate @@ -69,7 +70,7 @@ def __init__(self, base_problem, path, *, short_path=None, print_warn=False): is the (absolute) path to the input file, and `short_path` is used as the equivalent of the testcase's path relative to `problem.path / 'data'`. """ - assert path.suffix == ".in" or path.suffixes == [".in", ".statement"] + assert path.suffix == ".in" self.problem = base_problem @@ -85,16 +86,14 @@ def __init__(self, base_problem, path, *, short_path=None, print_warn=False): self.root = self.short_path.parts[0] self.in_path = path - self.ans_path = ( - self.in_path.with_suffix(".ans") - if path.suffix == ".in" - else self.in_path.with_name(self.in_path.with_suffix("").stem + ".ans.statement") - ) + self.ans_path = self.in_path.with_suffix(".ans") self.out_path = ( - None - if self.root not in ["valid_output", "invalid_output"] - else self.in_path.with_suffix(".out") + self.in_path.with_suffix(".out") + if self.root in ["valid_output", "invalid_output"] + or self.in_path.with_suffix(".out").is_file() + else None ) + # Display name: everything after data/. self.name = str(self.short_path.with_suffix("")) @@ -237,7 +236,6 @@ def validate_format( warn_instead_of_error=warn_instead_of_error, ) case validate.Mode.VALID_OUTPUT: - assert self.root == "valid_output" assert not self.problem.interactive assert not self.problem.multi_pass @@ -284,7 +282,7 @@ def _run_validators( results = [] for validator in validators: name = validator.name - if type(validator) is validate.OutputValidator and mode == validate.Mode.ANSWER: + if isinstance(validator, validate.OutputValidator) and mode == validate.Mode.ANSWER: args += ["case_sensitive", "space_change_sensitive"] name = f"{name} (ans)" flags = self.testdata_yaml_validator_args(validator, bar) @@ -395,11 +393,23 @@ def _run_validators( bar.error(msg, resume=True) else: success = all(results) - if success and mode in [validate.Mode.INPUT, validate.Mode.ANSWER]: - validate.sanity_check( - self.problem, - self.in_path if mode == validate.Mode.INPUT else self.ans_path, - bar, - ) + if success: + main_path: Optional[Path] = None + if mode == validate.Mode.INPUT: + main_path = self.in_path + elif mode == validate.Mode.ANSWER: + main_path = self.ans_path + elif mode == validate.Mode.VALID_OUTPUT and self.root not in [ + "valid_output", + "invalid_output", + ]: + main_path = self.out_path + + if main_path is not None: + validate.sanity_check( + self.problem, + main_path, + bar, + ) return success diff --git a/bin/util.py b/bin/util.py index b8709d8c7..753027ac4 100644 --- a/bin/util.py +++ b/bin/util.py @@ -644,6 +644,13 @@ def path_size(path: Path) -> int: return sum(f.stat().st_size for f in path.rglob("*") if f.exists()) +def drop_suffix(path: Path, suffixes: Sequence[str]) -> Path: + for suffix in suffixes: + if path.name.endswith(suffix): + return path.with_name(path.name.removesuffix(suffix)) + return path + + # Drops the first two path components // def print_name(path: Path, keep_type: bool = False) -> str: return str(Path(*path.parts[1 if keep_type else 2 :])) diff --git a/bin/validate.py b/bin/validate.py index 3c8d4867d..de6e1cadc 100644 --- a/bin/validate.py +++ b/bin/validate.py @@ -2,11 +2,14 @@ from util import * from enum import Enum from collections.abc import Sequence -from typing import Final +from typing import Final, TYPE_CHECKING import program import testcase +if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 + import run + class Mode(Enum): """There are four validation modes for file validation""" @@ -205,9 +208,9 @@ def _exec_helper(self, *args, cwd, **kwargs): def run( self, testcase: testcase.Testcase, - mode, + mode: Mode, constraints: Optional[ConstraintsDict] = None, - args=None, + args: Optional[list[str]] = None, ) -> ExecResult: raise Exception("Abstract method") @@ -230,10 +233,10 @@ def __init__(self, problem, path, **kwargs): def run( self, - testcase, - mode=Mode.INPUT, + testcase: testcase.Testcase, + mode: Mode = Mode.INPUT, constraints: Optional[ConstraintsDict] = None, - args=None, + args: Optional[list[str]] = None, ) -> ExecResult: """ Arguments @@ -274,7 +277,7 @@ def run( class AnswerValidator(Validator): """ - Validate the default answer file (such as "testcase.ans"), called as: + Validate the default answer file "testcase.ans" (or "testcase.out" if it exists), called as: ./validator input < answer. @@ -290,10 +293,10 @@ def __init__(self, problem, path, **kwargs): def run( self, - testcase, - mode=Mode.ANSWER, + testcase: testcase.Testcase, + mode: Mode = Mode.ANSWER, constraints: Optional[ConstraintsDict] = None, - args=None, + args: Optional[list[str]] = None, ) -> ExecResult: assert self.run_command is not None, "Validator should be built before running it" @@ -341,10 +344,10 @@ def __init__(self, problem, path, **kwargs): def run( self, - testcase, # TODO #102: fix type errors after setting type to Testcase - mode, # TODO #102: fix type errors after setting type to Mode | run.Run + testcase: testcase.Testcase, + mode: "Mode | run.Run", constraints: Optional[ConstraintsDict] = None, - args=None, + args: Optional[list[str]] = None, ) -> ExecResult: """ Run this validator on the given testcase. @@ -374,12 +377,10 @@ def run( raise ValueError( "OutputValidator in Mode.INVALID should only be run for data/invalid_output" ) + assert testcase.out_path is not None path = testcase.out_path.resolve() elif mode == Mode.VALID_OUTPUT: - if testcase.root != "valid_output": - raise ValueError( - "OutputValidator in Mode.VALID_OUTPUT should only be run for data/valid_output" - ) + assert testcase.out_path is not None path = testcase.out_path.resolve() else: assert mode != Mode.INPUT @@ -456,7 +457,7 @@ def sanity_check(problem, path, bar, strict_whitespace=True): if not path.exists(): fatal(f"{path} not found during sanity check") - return + with open(path, "rb") as file: name = { ".in": "Input", @@ -464,6 +465,12 @@ def sanity_check(problem, path, bar, strict_whitespace=True): ".out": "Output", }[path.suffix] file_bytes = file.read() + + if problem.interactive and path.suffix == ".ans": + if len(file_bytes) != 0: + bar.warn(f"use empty .ans file for {problem.settings.type_name()} problem") + return # Since the .ans file MUST be empty, the other sanity checks can be skipped. + if _has_invalid_byte(file_bytes, other_whitespaces=not strict_whitespace): bar.warn(f"{name} contains unexpected characters but was accepted!") elif len(file_bytes) == 0: @@ -477,6 +484,11 @@ def sanity_check(problem, path, bar, strict_whitespace=True): bar.warn( f"{name} exceeds output limit (set limits->output to at least {(len(file_bytes) + 1024 * 1024 - 1) // 1024 // 1024}MiB in problem.yaml)" ) + elif ( + path.suffix in [".ans", ".out"] + and 2 * len(file_bytes) > problem.limits.output * 1024 * 1024 + ): + bar.warn(f"{name} is close to output limit") elif strict_whitespace: if file_bytes[0] in [ord(" "), ord("\n")]: bar.warn(f"{name} starts with whitespace but was accepted!") diff --git a/bin/validator_tests.py b/bin/validator_tests.py index 7e10fc945..362e1e670 100644 --- a/bin/validator_tests.py +++ b/bin/validator_tests.py @@ -49,7 +49,6 @@ def decorator(func: T) -> T: # constant testcases register("latin-1")("Naïve") - register("empty")("") register("newline")("\n") register("fixed_random")("YVRtr&*teTsRjs8ZC2%kN*T63V@jJq!d") register("not_printable_ascii")("\x7f") diff --git a/doc/commands.md b/doc/commands.md index a8af59246..c711fc44d 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -97,7 +97,7 @@ Use `bt run -v` to show results for all testcases. - The path of the `.in` file: `data/secret/1.in` - The path of the `.ans` file: `data/secret/1.ans` (any other extension also works, even if the file doesn't exist) - - The basename of the testcase: `data/secret/1` + - The base name of the testcase: `data/secret/1` - A directory: `data/secret`. In this case, all `.in` files that are (nested) in this directory will be used. Testcases must always be inside the `data` directory. Anything outside `data/` will raise an error. diff --git a/doc/generators.md b/doc/generators.md index 15db34295..edf424ecb 100644 --- a/doc/generators.md +++ b/doc/generators.md @@ -47,7 +47,7 @@ Or as a shorthand: The follwoing things should hold: - A `.in` file must be specified/generated by this -- If a `.ans` file is not specified/generated a `solution` must be provided that will be used to generate the `.ans`. For interactive Problems +- If a `.ans` file is not specified/generated, a `solution` must be provided that will be used to generate the `.ans`. For interactive or multi-pass problems, an empty `.ans` will be generated. **Root object** The root of the `generators.yaml` is a `directory` object with one optional additional key: diff --git a/doc/validation.md b/doc/validation.md index 387174e7f..0613252ac 100644 --- a/doc/validation.md +++ b/doc/validation.md @@ -66,7 +66,9 @@ Answer validation can be as simple as checking that standard input contains a si A more advanced use case would be to read an integer `n` from the testcase input file `testcase.in` file provided as the first argument, followed by verifying that the standard input contains `n` newline-separated integers. -All answer files are also checked with the output validator invoked as +BAPCtools assumes that all answer files are also valid outputs and therefore also checks that the `.ans` files pass output validation. +If this assumption is wrong, you can specify `ans_is_output: False` in `problem.yaml` (note that this option is always `False` for interactive or multi-pass problems, because these do not have a single output). +If enabled, the output validator is invoked as: ``` output_validator /path/to/testcase.in /path/to/testcase.ans /path/to/feedbackdir \ diff --git a/support/schemas/generators.cue b/support/schemas/generators.cue index 98d3821b3..fa5f20b32 100644 --- a/support/schemas/generators.cue +++ b/support/schemas/generators.cue @@ -41,9 +41,12 @@ import "strings" count?: int & >=1 & <=100 // The "copy" key uses a path relative to "/generators/" ending in a testcase name, // such as "manual/samples/3". - copy?: #dirpath - ["in" | "ans" | "out" | "desc" | "hint"]: string - interaction?: =~"^([<>][^\\n]*\\n)+$" + copy?: #dirpath + + ["in" | "in.statement" | "in.download" | + "ans" | "ans.statement" | "ans.download" | + "out" | "desc" | "hint"]: string + interaction?: =~"^([<>][^\\n]*\\n)+$" #config } diff --git a/support/schemas/generators_yaml_schema.json b/support/schemas/generators_yaml_schema.json index a784a6210..f75b294bb 100644 --- a/support/schemas/generators_yaml_schema.json +++ b/support/schemas/generators_yaml_schema.json @@ -174,25 +174,35 @@ "title": "Input", "description": "Explicit input given as a string" }, + "in.statement": { + "type": "string", + "title": "Input (statement)", + "description": "Explicit input given as a string, only shown in problem statement (defaults to 'in')" + }, + "in.download": { + "type": "string", + "title": "Input (download)", + "description": "Explicit input given as a string, only shown as sample download in the contest system (defaults to 'in.statement' or 'in')" + }, "ans": { "type": "string", "title": "Default Answer", "description": "Explicit default answer given as a string" }, - "out": { + "ans.statement": { "type": "string", - "title": "Invalid output", - "description": "Explicit (in)valid output given as a string; can only be given in (in)valid_output" + "title": "Default Answer (statement)", + "description": "Explicit default answer given as a strans, only shown in problem statement (defaults to 'in')" }, - "desc": { + "ans.download": { "type": "string", - "title": "Description", - "description": "Privileged information explaining the purpose of this test case given as a string" + "title": "Default Answer (download)", + "description": "Explicit default answer given as a string, only shown as sample download in the contest system (defaults to 'ans.statement' or 'ans')" }, - "hint": { + "out": { "type": "string", - "title": "Hint", - "description": "Feedback shown to the solver about this test case given as a string" + "title": "Output", + "description": "Explicit (in)valid output given as a string; can only be given in sample or (in)valid_output" }, "interaction": { "title": "Sample interaction", @@ -201,6 +211,16 @@ "type": "string", "pattern": "^([<>][^\\n]*\\n)+$" }, + "desc": { + "type": "string", + "title": "Description", + "description": "Privileged information explaining the purpose of this test case given as a string" + }, + "hint": { + "type": "string", + "title": "Hint", + "description": "Feedback shown to the solver about this test case given as a string" + }, "visualizer": { "$ref": "#/$defs/visualizer" }, diff --git a/test/problems/constants/data/sample/1.ans b/test/problems/constants/data/sample/1.ans new file mode 100644 index 000000000..7ed6ff82d --- /dev/null +++ b/test/problems/constants/data/sample/1.ans @@ -0,0 +1 @@ +5 diff --git a/test/problems/constants/data/sample/1.in b/test/problems/constants/data/sample/1.in new file mode 100644 index 000000000..7ed6ff82d --- /dev/null +++ b/test/problems/constants/data/sample/1.in @@ -0,0 +1 @@ +5 diff --git a/test/problems/identity/data/sample/5.ans b/test/problems/identity/data/sample/5.ans new file mode 100644 index 000000000..7ed6ff82d --- /dev/null +++ b/test/problems/identity/data/sample/5.ans @@ -0,0 +1 @@ +5 diff --git a/test/problems/identity/data/sample/5.in b/test/problems/identity/data/sample/5.in new file mode 100644 index 000000000..7ed6ff82d --- /dev/null +++ b/test/problems/identity/data/sample/5.in @@ -0,0 +1 @@ +5 diff --git a/test/problems/identity/data/sample/5.out b/test/problems/identity/data/sample/5.out new file mode 100644 index 000000000..7ed6ff82d --- /dev/null +++ b/test/problems/identity/data/sample/5.out @@ -0,0 +1 @@ +5 diff --git a/test/problems/identity/data/sample/6.ans.statement b/test/problems/identity/data/sample/6.ans.statement new file mode 100644 index 000000000..1e8b31496 --- /dev/null +++ b/test/problems/identity/data/sample/6.ans.statement @@ -0,0 +1 @@ +6 diff --git a/test/problems/identity/data/sample/6.in.statement b/test/problems/identity/data/sample/6.in.statement new file mode 100644 index 000000000..1e8b31496 --- /dev/null +++ b/test/problems/identity/data/sample/6.in.statement @@ -0,0 +1 @@ +6 diff --git a/test/problems/identity/generators/generators.yaml b/test/problems/identity/generators/generators.yaml index 0ee583e2d..d8e5c0f20 100644 --- a/test/problems/identity/generators/generators.yaml +++ b/test/problems/identity/generators/generators.yaml @@ -67,6 +67,13 @@ data: copy: manual/sample "4": copy: manual/inans + "5": + in: "5" + ans: "5" + out: "5" + "6": + in.statement: "6" + ans.statement: "6" secret: data: diff --git a/test/problems/multipass/answer_validators/validate.ctd b/test/problems/multipass/answer_validators/validate.ctd new file mode 100644 index 000000000..1a2b1dc14 --- /dev/null +++ b/test/problems/multipass/answer_validators/validate.ctd @@ -0,0 +1 @@ +EOF diff --git a/test/test_generators_yaml.py b/test/test_generators_yaml.py index 3f766c909..a35919c66 100644 --- a/test/test_generators_yaml.py +++ b/test/test_generators_yaml.py @@ -21,6 +21,8 @@ def __init__(self): self._program_callbacks = dict() self._rules_cache = dict() self.settings = MockSettings() + self.interactive = False + self.multi_pass = False class MockGeneratorConfig(generate.GeneratorConfig): diff --git a/test/test_problems.py b/test/test_problems.py index e8d12f36d..7756e74e7 100644 --- a/test/test_problems.py +++ b/test/test_problems.py @@ -2,6 +2,7 @@ import os import io from pathlib import Path +from zipfile import ZipFile import tools import problem @@ -143,11 +144,41 @@ def test_constraints(self): # Exporting def test_samplezip(self): tools.test(["samplezip"]) - Path("samples.zip").unlink() + zip_path = Path("samples.zip") + + # Sample zip should contain exactly one .in and .ans file. + assert sorted( + (info.filename, info.file_size) + for info in ZipFile(zip_path).infolist() + if info.filename.startswith("A/") + ) == [ + (f"A/{i}.{ext}", size) + for i, size in enumerate([2, 4, 2, 5, 2, 2], start=1) + for ext in ["ans", "in"] + ], "Sample zip contents are not correct" + + zip_path.unlink() def test_zip(self): tools.test(["zip", "--force"]) - Path("identity.zip").unlink() + zip_path = Path("identity.zip") + + # The full zip should contain the samples with the original file extensions. + assert sorted( + (info.filename, info.file_size) + for info in ZipFile(zip_path).infolist() + if info.filename.startswith("data/sample/") + ) == [ + *( + (f"data/sample/{i}.{ext}", size) + for i, size in enumerate([2, 4, 2, 5], start=1) + for ext in ["ans", "in"] + ), + *((f"data/sample/5.{ext}", 2) for ext in ["ans", "in", "out"]), + *((f"data/sample/6.{ext}.statement", 2) for ext in ["ans", "in"]), + ], "Zip contents for data/sample/ are not correct" + + zip_path.unlink() # Misc # def test_all(self): tools.test(['all']) diff --git a/test/yaml/generators/test_schemata.sh b/test/yaml/generators/test_schemata.sh index 93c3ee6ad..10922f680 100644 --- a/test/yaml/generators/test_schemata.sh +++ b/test/yaml/generators/test_schemata.sh @@ -1,5 +1,7 @@ # Validate all valid generator YAML found in the following dirs agains the CUE schema: +cd "$(dirname "$0")" + all_valid_yaml=(../../../doc ../../../skel/problem ../../problems valid_yaml) # Arguments @@ -20,7 +22,10 @@ trap "rm -rf $SNIPPETS_DIR" EXIT for dir in "${all_valid_yaml[@]}"; do for file in $(find "$dir" -type f -name '*generators.yaml'); do echo -n "cue vet "$file" $schemadir/*.cue -d \"#Generators\" " - output_cue=$(cue vet "$file" $schemadir/*.cue -d "#Generators" 2>&1) + tmp="$(mktemp --suffix .yaml)" + sed "s/{%testdata_yaml_comment%}/#/" "$file" | sed "s/{%output_validator_args%}//" > "$tmp" + output_cue=$(cue vet "$tmp" $schemadir/*.cue -d "#Generators" 2>&1) + rm "$tmp" exit_code_cue=$? if [ $exit_code_cue -eq 0 ]; then echo -n -e "\033[0;32mOK(cue)\033[0m" @@ -69,6 +74,11 @@ done # Run `cue vet` on each invalid yaml file and snippet for snippet in "$SNIPPETS_DIR"/*.yaml; do + if ! grep -q '^[^#]' "$snippet"; then + # TODO: empty generators.yaml files _should_ be invalid, but for some reason, the CI currently disagrees. + echo "Skipping empty $(basename $snippet)" + continue + fi echo -n "Invalidating $(basename $snippet) " snippet_failed=0 cue vet "$snippet" $schemadir/*.cue -d "#Generators" > /dev/null 2>&1 From 75d52645a2d7486e78db338e495eb547e23bbf3c Mon Sep 17 00:00:00 2001 From: mzuenni Date: Mon, 24 Mar 2025 17:00:10 +0100 Subject: [PATCH 07/23] fix yaml handling --- bin/upgrade.py | 32 +++++++++++++++++--------------- bin/util.py | 8 +++++--- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/bin/upgrade.py b/bin/upgrade.py index ee959e7bc..bd6caf2ba 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -55,8 +55,8 @@ def upgrade_generators_yaml(problem_path: Path, bar: ProgressBar) -> None: generators_yaml = problem_path / "generators" / "generators.yaml" if not generators_yaml.is_file(): return - data = read_yaml(generators_yaml) - if data is None or not isinstance(data, dict): + yaml_data = read_yaml(generators_yaml) + if yaml_data is None or not isinstance(yaml_data, dict): return changed = False @@ -67,17 +67,19 @@ def upgrade_generators_yaml(problem_path: Path, bar: ProgressBar) -> None: ("invalid_outputs", "invalid_output"), ("valid_outputs", "valid_output"), ] - for old_name, new_name in rename: - if old_name in data: - if new_name in data: - bar.error( - f"can't rename 'data.{old_name}', 'data.{new_name}' already exists in generators.yaml", - resume=True, - ) - continue - bar.log(f"renaming 'data.{old_name}' to 'data.{new_name}' in generators.yaml") - ryaml_replace(data, old_name, new_name) - changed = True + if "data" in yaml_data and isinstance(yaml_data["data"], dict): + data = yaml_data["data"] + for old_name, new_name in rename: + if old_name in data: + if new_name in data: + bar.error( + f"can't rename 'data.{old_name}', 'data.{new_name}' already exists in generators.yaml", + resume=True, + ) + continue + bar.log(f"renaming 'data.{old_name}' to 'data.{new_name}' in generators.yaml") + ryaml_replace(data, old_name, new_name) + changed = True def upgrade_generated_testdata_yaml(data: dict[str, Any], path: str) -> bool: changed = False @@ -111,10 +113,10 @@ def upgrade_generated_testdata_yaml(data: dict[str, Any], path: str) -> bool: ) return changed - changed |= upgrade_generated_testdata_yaml(data, "") + changed |= upgrade_generated_testdata_yaml(yaml_data, "") if changed: - write_yaml(data, generators_yaml) + write_yaml(yaml_data, generators_yaml) def upgrade_statement(problem_path: Path, bar: ProgressBar) -> None: diff --git a/bin/util.py b/bin/util.py index 753027ac4..45b0e6dce 100644 --- a/bin/util.py +++ b/bin/util.py @@ -729,7 +729,7 @@ def ryaml_filter(data: Any, remove: str) -> Any: curr = data prev_key = list(data.keys())[remove_index - 1] - while isinstance(curr[prev_key], list | dict): + while isinstance(curr[prev_key], list | dict) and len(curr[prev_key]): # Try to remove the comment from the last element in the preceding list/dict curr = curr[prev_key] if isinstance(curr, list): @@ -741,7 +741,7 @@ def ryaml_filter(data: Any, remove: str) -> Any: # Move the comment that belongs to the removed key (which comes _after_ the removed key) # to the preceding key curr.ca.items[prev_key] = data.ca.items.pop(remove) - elif prev_key in data.ca.items: + elif prev_key in curr.ca.items: # If the removed key does not have a comment, # the comment after the previous key should be removed curr.ca.items.pop(prev_key) @@ -755,7 +755,9 @@ def ryaml_replace(data: Any, old_key: str, new_key: str, new_value: Any = None) if new_value is None: new_value = data[old_key] data.insert(list(data.keys()).index(old_key), new_key, new_value) - ryaml_filter(data, old_key) + data.pop(old_key) + if old_key in data.ca.items: + data.ca.items[new_key] = data.ca.items.pop(old_key) # Only allow one thread to write at the same time. Else, e.g., generating test cases in parallel goes wrong. From 9f3bd6614fd35bcb01b82a2e4fe6ddf057290f5c Mon Sep 17 00:00:00 2001 From: mzuenni Date: Tue, 25 Mar 2025 02:46:33 +0100 Subject: [PATCH 08/23] readd empty test --- bin/validator_tests.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/validator_tests.py b/bin/validator_tests.py index 362e1e670..c2a724dac 100644 --- a/bin/validator_tests.py +++ b/bin/validator_tests.py @@ -49,6 +49,7 @@ def decorator(func: T) -> T: # constant testcases register("latin-1")("Naïve") + register("empty", [InputValidator, OutputValidator])("") register("newline")("\n") register("fixed_random")("YVRtr&*teTsRjs8ZC2%kN*T63V@jJq!d") register("not_printable_ascii")("\x7f") From 825c8cd2f894cdf5c54c86094225c1070de336f6 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Sun, 30 Mar 2025 12:32:27 +0200 Subject: [PATCH 09/23] [validate] Remove {input,answer}_format_validators (#443) * remove format_validators * rename folders * [problem] Problem._validators: remove `assert cls.source_dir` because all {Input,Answer,Output}Validator classes now only have source_dir, instead of source_dirs. --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- bin/problem.py | 5 +---- bin/stats.py | 2 +- bin/upgrade.py | 17 +++++++++++++++++ bin/validate.py | 4 ++-- .../validate.py | 0 .../validate.py | 0 6 files changed, 21 insertions(+), 7 deletions(-) rename test/problems/guess/{input_format_validators => input_validators}/validate.py (100%) rename test/problems/guessnoeofcheck/{input_format_validators => input_validators}/validate.py (100%) diff --git a/bin/problem.py b/bin/problem.py index 04690535b..3d08e414b 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -911,10 +911,7 @@ def _validators( else: paths = [config.TOOLS_ROOT / "support" / "default_output_validator.cpp"] else: - assert hasattr(cls, "source_dirs") - paths = [ - p for source_dir in cls.source_dirs for p in glob(problem.path / source_dir, "*") - ] + paths = list(glob(problem.path / cls.source_dir, "*")) # TODO: Instead of checking file contents, maybe specify this in generators.yaml? def has_constraints_checking(f): diff --git a/bin/stats.py b/bin/stats.py index c4d77163a..437fa996f 100644 --- a/bin/stats.py +++ b/bin/stats.py @@ -51,7 +51,7 @@ def problem_stats(problems): ("yaml", "problem.yaml"), ("tex", str(latex.PdfType.PROBLEM.path("*")), 1), ("sol", str(latex.PdfType.SOLUTION.path("*")), 1), - (" val: I", ["input_validators/*", "input_format_validators/*"]), + (" val: I", ["input_validators/*"]), ("A", ["answer_validators/*"]), ("O", ["output_validator/"]), ( diff --git a/bin/upgrade.py b/bin/upgrade.py index bd6caf2ba..b31552cbb 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -151,6 +151,22 @@ def upgrade_statement(problem_path: Path, bar: ProgressBar) -> None: shutil.move(f, dest) +def upgrade_format_validators(problem_path: Path, bar: ProgressBar) -> None: + rename = [ + ("input_format_validators", "input_validators"), + ("answer_format_validators", "answer_validators"), + ] + for old_name, new_name in rename: + old_path = problem_path / old_name + new_path = problem_path / new_name + if old_path.is_dir(): + if new_path.exists(): + bar.error(f"can't rename '{old_name}', '{new_name}' already exists", resume=True) + continue + bar.log(f"renaming '{old_name}' to '{new_name}'") + old_path.rename(new_path) + + def upgrade_output_validators(problem_path: Path, bar: ProgressBar) -> None: if (problem_path / "output_validators").is_dir(): if (problem_path / "output_validator").exists(): @@ -370,6 +386,7 @@ def _upgrade(problem_path: Path, bar: ProgressBar) -> None: upgrade_testdata_yaml(problem_path, bar) upgrade_generators_yaml(problem_path, bar) upgrade_statement(problem_path, bar) + upgrade_format_validators(problem_path, bar) upgrade_output_validators(problem_path, bar) # update .in.statement? upgrade_problem_yaml(problem_path, bar) diff --git a/bin/validate.py b/bin/validate.py index de6e1cadc..fdd950dcc 100644 --- a/bin/validate.py +++ b/bin/validate.py @@ -229,7 +229,7 @@ def __init__(self, problem, path, **kwargs): validator_type: Final[str] = "input" - source_dirs: Final[list[str]] = ["input_validators", "input_format_validators"] + source_dir: Final[str] = "input_validators" def run( self, @@ -289,7 +289,7 @@ def __init__(self, problem, path, **kwargs): validator_type: Final[str] = "answer" - source_dirs: Final[list[str]] = ["answer_validators", "answer_format_validators"] + source_dir: Final[str] = "answer_validators" def run( self, diff --git a/test/problems/guess/input_format_validators/validate.py b/test/problems/guess/input_validators/validate.py similarity index 100% rename from test/problems/guess/input_format_validators/validate.py rename to test/problems/guess/input_validators/validate.py diff --git a/test/problems/guessnoeofcheck/input_format_validators/validate.py b/test/problems/guessnoeofcheck/input_validators/validate.py similarity index 100% rename from test/problems/guessnoeofcheck/input_format_validators/validate.py rename to test/problems/guessnoeofcheck/input_validators/validate.py From b7215377eb247c9afa932900fb547696aeabfae5 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Sun, 30 Mar 2025 13:13:50 +0200 Subject: [PATCH 10/23] Legacy export (#441) * make legacy export an explicit command * [export] Add all the directories! * [export] Legacy: remove solution/ and problem_slide/ from export dir * [export] Make answer_validators/ not required Apparently, `bt validate` also doesn't require them? * prepend problem name * fix test * handle languages * use ngerman * add german to wsl * keep languages in sync * keep languages in sync * [export] Add comment explaining why name in problems.yaml can also be str * [doc] Improve singular/plural in explanation of `--languages` * [export][latex] Rename --languages flag to --lang * [test][export] Add assertions for which PDFs should be in the ZIPs * [test] TestContest.test_zip: also remove constituent zip files after test completes --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- .github/workflows/ci.yaml | 1 + bin/config.py | 3 +- bin/export.py | 388 ++++++++++-------- bin/latex.py | 18 +- bin/skel.py | 8 +- bin/tools.py | 117 ++++-- bin/upgrade.py | 1 - doc/commands.md | 2 +- doc/multiple_languages.md | 18 +- latex/lang/de.tex | 2 +- test/problems/identity/problem.yaml | 4 +- .../problem_slide/problem-slide.de.tex | 10 + .../identity/solution/solution.de.tex | 8 + .../identity/statement/problem.de.tex | 22 + test/problems/problems.yaml | 5 + test/test_problems.py | 58 ++- 16 files changed, 413 insertions(+), 252 deletions(-) create mode 100644 test/problems/identity/problem_slide/problem-slide.de.tex create mode 100644 test/problems/identity/solution/solution.de.tex create mode 100644 test/problems/identity/statement/problem.de.tex diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b734a1668..7e9ceca75 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -50,5 +50,6 @@ jobs: lmodern texlive-science latexmk + texlive-lang-german - shell: wsl-bash {0} run: pytest diff --git a/bin/config.py b/bin/config.py index 1327a2f6f..086a47427 100644 --- a/bin/config.py +++ b/bin/config.py @@ -109,7 +109,6 @@ "jobs": (os.cpu_count() or 1) // 2, "time": 600, # Used for `bt fuzz` "verbose": 0, - "languages": None, } @@ -120,7 +119,7 @@ grep -Ev '^(h|jobs|time|verbose)$' | sed 's/^/"/;s/$/",/' | tr '\n' ' ' | sed 's/^/ARGS_LIST: Final[Sequence[str]] = [/;s/, $/]\n/' """ # fmt: off -ARGS_LIST: Final[Sequence[str]] = ["1", "add", "all", "answer", "api", "author", "check_deterministic", "clean", "colors", "contest", "contest_id", "contestname", "cp", "defaults", "default_solution", "depth", "directory", "error", "force", "force_build", "generic", "input", "interaction", "interactive", "invalid", "kattis", "language", "latest_bt", "memory", "more", "move_to", "no_bar", "no_generate", "no_solution", "no_solutions", "no_testcase_sanity_checks", "no_time_limit", "no_validators", "no_visualizer", "open", "order", "order_from_ccs", "overview", "password", "post_freeze", "problem", "problemname", "remove", "reorder", "samples", "sanitizer", "skel", "skip", "sort", "submissions", "table", "testcases", "time_limit", "timeout", "token", "tree", "type", "username", "valid_output", "watch", "web", "write"] +ARGS_LIST: Final[Sequence[str]] = ["1", "add", "all", "answer", "api", "author", "check_deterministic", "clean", "colors", "contest", "contest_id", "contestname", "cp", "defaults", "default_solution", "depth", "directory", "error", "force", "force_build", "generic", "input", "interaction", "interactive", "invalid", "kattis", "lang", "latest_bt", "legacy", "memory", "more", "move_to", "no_bar", "no_generate", "no_solution", "no_solutions", "no_testcase_sanity_checks", "no_time_limit", "no_validators", "no_visualizer", "open", "order", "order_from_ccs", "overview", "password", "post_freeze", "problem", "problemname", "remove", "reorder", "samples", "sanitizer", "skel", "skip", "sort", "submissions", "table", "testcases", "time_limit", "timeout", "token", "tree", "type", "username", "valid_output", "watch", "web", "write"] # fmt: on diff --git a/bin/export.py b/bin/export.py index de91d6785..071b61016 100644 --- a/bin/export.py +++ b/bin/export.py @@ -9,42 +9,47 @@ from typing import Optional from contest import * +from latex import PdfType from problem import Problem -def force_single_language(problems): - if config.args.languages and len(config.args.languages) == 1: - statement_language = config.args.languages[0] +def select_languages(problems: list[Problem]) -> list[str]: + if config.args.lang: + languages = config.args.lang else: - all_languages = set.union(*(set(p.statement_languages) for p in problems)) - if len(all_languages) > 1: - fatal("Multiple languages found, please specify one with --language") - statement_language = all_languages.pop() - return statement_language + languages = list(set(sum((p.statement_languages for p in problems), []))) + languages.sort() + if config.args.legacy: + if len(languages) > 1: + # legacy can handle at most one language + fatal("Multiple languages found, please specify one with --lang") + if not languages: + fatal("No language found") + return languages # Write any .lang.pdf files to .pdf. -def remove_language_suffix(fname, statement_language): - if not statement_language: - return fname - out = Path(fname) - if out.suffixes == ["." + statement_language, ".pdf"]: - out = out.with_suffix("").with_suffix(".pdf") - return out +def remove_language_pdf_suffix(file: Path, lang: Optional[str]) -> Path: + if lang and file.name.endswith(f".{lang}.pdf"): + return file.with_name(file.name.removesuffix(f".{lang}.pdf") + ".pdf") + else: + return file -def build_samples_zip(problems: list[Problem], output: Path, statement_language: str): +def build_samples_zip(problems: list[Problem], output: Path, languages: list[str]) -> None: zf = zipfile.ZipFile(output, mode="w", compression=zipfile.ZIP_DEFLATED, allowZip64=False) # Do not include contest PDF for kattis. if not config.args.kattis: - for fname in glob(Path("."), f"contest*.{statement_language}.pdf"): - if Path(fname).is_file(): - zf.write( - fname, - remove_language_suffix(fname, statement_language), - compress_type=zipfile.ZIP_DEFLATED, - ) + for language in languages: + for file in glob(Path("."), f"contest*.{language}.pdf"): + out = remove_language_pdf_suffix(file, language) if config.args.legacy else file + if Path(file).is_file(): + zf.write( + file, + out, + compress_type=zipfile.ZIP_DEFLATED, + ) for problem in problems: if not problem.label: @@ -98,45 +103,50 @@ def build_samples_zip(problems: list[Problem], output: Path, statement_language: print("Wrote zip to samples.zip", file=sys.stderr) -def build_problem_zip(problem: Problem, output: Path): +def build_problem_zip(problem: Problem, output: Path) -> bool: """Make DOMjudge/Kattis ZIP file for specified problem.""" if not has_ryaml: error("zip needs the ruamel.yaml python3 library. Install python[3]-ruamel.yaml.") - return + return False - # Add problem PDF for only one language to the zip file (note that Kattis export does not include PDF) - statement_language = None if config.args.kattis else force_single_language([problem]) + languages = select_languages([problem]) files = [ ("problem.yaml", True), ("statement/*", True), + ("solution/*", False), + ("problem_slide/*", False), + ("generators/*", False), + ("input_validators/**/*", True), + ("answer_validators/**/*", False), # TODO make required when not problem.interactive? ("submissions/accepted/**/*", True), ("submissions/*/**/*", False), ("attachments/**/*", problem.interactive or problem.multi_pass), ] + # Do not include PDFs for kattis. if not config.args.kattis: - files.append((f"problem.{statement_language}.pdf", True)) + for language in languages: + files.append((PdfType.PROBLEM.path(language, ".pdf").name, True)) + files.append((PdfType.PROBLEM_SLIDE.path(language, ".pdf").name, False)) + files.append((PdfType.SOLUTION.path(language, ".pdf").name, False)) if problem.custom_output: files.append(("output_validator/**/*", True)) - if config.args.kattis: - files.append(("input_validators/**/*", True)) - message("preparing zip file content", "Zip", problem.path, color_type=MessageType.LOG) # prepare files inside dir export_dir = problem.tmpdir / "export" if export_dir.exists(): shutil.rmtree(export_dir) - # For Kattis, prepend the problem shortname to all files. - if config.args.kattis: + # For Kattis / draft spec, prepend the problem shortname to all files. + if config.args.kattis or not config.args.legacy: export_dir /= problem.name export_dir.mkdir(parents=True, exist_ok=True) - def add_file(path: Path, source: Path): + def add_file(path: Path, source: Path) -> None: path = export_dir / path path.parent.mkdir(parents=True, exist_ok=True) ensure_symlink(path, source) @@ -149,17 +159,14 @@ def add_file(path: Path, source: Path): util.error(f"No matches for required path {pattern}.") for f in paths: if f.is_file(): - out = f.relative_to(problem.path) - out = remove_language_suffix(out, statement_language) - add_file(out, f) + add_file(f.relative_to(problem.path), f) - def add_testcase(in_file: Path): + def add_testcase(in_file: Path) -> None: base_name = util.drop_suffix(in_file, [".in", ".in.statement", ".in.download"]) for ext in config.KNOWN_DATA_EXTENSIONS: f = base_name.with_suffix(ext) if f.is_file(): - out = f.relative_to(problem.path) - add_file(out, f) + add_file(f.relative_to(problem.path), f) # Include all sample test cases and copy all related files. samples = problem.download_samples() @@ -180,94 +187,28 @@ def add_testcase(in_file: Path): else: util.warn(f"No answer file found for {f}, skipping.") - # DOMjudge and Kattis do not support 2023-07-draft yet. - # TODO: Remove once they do. - from ruamel.yaml.comments import CommentedMap - + # handle languages (files and yaml have to be in sync) yaml_path = export_dir / "problem.yaml" yaml_data = read_yaml(yaml_path) - # drop format version -> legacy - if "problem_format_version" in yaml_data: - ryaml_filter(yaml_data, "problem_format_version") - # type -> validation - if "type" in yaml_data: - ryaml_filter(yaml_data, "type") - validation = [] - if problem.custom_output: - validation.append("custom") - if problem.interactive: - validation.append("interactive") - if problem.multi_pass: - validation.append("multi-pass") - else: - validation.append("default") - yaml_data["validation"] = " ".join(validation) - # credits -> author - if "credits" in yaml_data: - ryaml_filter(yaml_data, "credits") - if problem.settings.credits.authors: - yaml_data["author"] = ", ".join(p.name for p in problem.settings.credits.authors) - # change source: - if problem.settings.source: - if len(problem.settings.source) > 1: - util.warn(f"Found multiple sources, using '{problem.settings.source[0].name}'.") - yaml_data["source"] = problem.settings.source[0].name - yaml_data["source_url"] = problem.settings.source[0].url - # limits.time_multipliers -> time_multiplier / time_safety_margin - if "limits" not in yaml_data or not yaml_data["limits"]: - yaml_data["limits"] = CommentedMap() - limits = yaml_data["limits"] - if "time_multipliers" in limits: - ryaml_filter(limits, "time_multipliers") - limits["time_multiplier"] = problem.limits.ac_to_time_limit - limits["time_safety_margin"] = problem.limits.time_limit_to_tle - # drop explicit timelimit for kattis: - if "time_limit" in limits: - # keep this for kattis even when "time_limit" is supported - ryaml_filter(limits, "time_limit") - # validator_flags - validator_flags = " ".join( - problem.get_testdata_yaml( - problem.path / "data", - "output_validator_args", - PrintBar("Getting validator_flags for legacy export"), - ) - ) - if validator_flags: - yaml_data["validator_flags"] = validator_flags - # write legacy style yaml - yaml_path.unlink() - write_yaml(yaml_data, yaml_path) + yaml_data["name"] = {language: problem.settings.name[language] for language in languages} + for type in PdfType: + for file in export_dir.glob(str(type.path("*"))): + if file.suffixes[-2][1:] not in languages: + file.unlink() - # DOMjudge does not support 'limits.time_limit' in problem.yaml yet. - # TODO: Remove this once it does. - if not config.args.kattis: - (export_dir / ".timelimit").write_text(str(problem.limits.time_limit)) - - # Replace \problemname{...} by the value of `name:` in problems.yaml in all .tex files. - # This is needed because Kattis is currently still running the legacy version of the problem spec, - # rather than 2023-07-draft. - for f in (export_dir / "statement").iterdir(): - if f.is_file() and f.suffix == ".tex" and len(f.suffixes) >= 2: - lang = f.suffixes[-2][1:] - t = f.read_text() - match = re.search(r"\\problemname\{\s*(\\problemyamlname)?\s*\}", t) - if match: - if lang in problem.settings.name: - t = t.replace(match[0], r"\problemname{" + problem.settings.name[lang] + "}") - f.unlink() - f.write_text(t) - else: - util.error(f"{f}: no name set for language {lang}.") + # drop explicit timelimit for kattis + if config.args.kattis: + if "limits" in yaml_data and "time_limit" in yaml_data["limits"]: + ryaml_filter(yaml_data["limits"], "time_limit") - # DOMjudge does not support constants. - # TODO: Remove this if it ever does. + # substitute constants. if problem.settings.constants: constants_supported = [ "data/**/testdata.yaml", - "output_validator/**/*", "input_validators/**/*", - # "statement/*", uses \constants + "answer_validators/**/*", + "output_validator/**/*", + # "statement/*", "solution/*", "problem_slide/*", use \constant{} commands # "submissions/*/**/*", removed support? ] for pattern in constants_supported: @@ -283,29 +224,125 @@ def add_testcase(in_file: Path): f.unlink() f.write_text(text) - # TODO: Remove this if we know others use the output_validator dir - if (export_dir / "output_validator").exists(): - (export_dir / "output_validators").mkdir(parents=True) - (export_dir / "output_validator").rename( - export_dir / "output_validators" / "output_validator" + # move pdfs + if config.args.legacy and languages: + for type in PdfType: + file = export_dir / type.path(languages[0], ".pdf").name + file.rename(remove_language_pdf_suffix(file, languages[0])) + else: + for language in languages: + for type in PdfType: + path = type.path(language, ".pdf") + file = export_dir / path.name + out = export_dir / path + if not file.exists(): + continue + if out.exists(): + util.warn(f"can't add {path} (already exists).") + file.unlink() + continue + out.parent.mkdir(parents=True, exist_ok=True) + file.rename(out) + + # downgrade some parts of the problem to be more legacy like + if config.args.legacy: + from ruamel.yaml.comments import CommentedMap + + # drop format version -> legacy + if "problem_format_version" in yaml_data: + ryaml_filter(yaml_data, "problem_format_version") + # type -> validation + if "type" in yaml_data: + ryaml_filter(yaml_data, "type") + validation = [] + if problem.custom_output: + validation.append("custom") + if problem.interactive: + validation.append("interactive") + if problem.multi_pass: + validation.append("multi-pass") + else: + validation.append("default") + yaml_data["validation"] = " ".join(validation) + # credits -> author + if "credits" in yaml_data: + ryaml_filter(yaml_data, "credits") + if problem.settings.credits.authors: + yaml_data["author"] = ", ".join(p.name for p in problem.settings.credits.authors) + # change source: + if problem.settings.source: + if len(problem.settings.source) > 1: + util.warn(f"Found multiple sources, using '{problem.settings.source[0].name}'.") + yaml_data["source"] = problem.settings.source[0].name + yaml_data["source_url"] = problem.settings.source[0].url + # limits.time_multipliers -> time_multiplier / time_safety_margin + if "limits" not in yaml_data or not yaml_data["limits"]: + yaml_data["limits"] = CommentedMap() + limits = yaml_data["limits"] + if "time_multipliers" in limits: + ryaml_filter(limits, "time_multipliers") + limits["time_multiplier"] = problem.limits.ac_to_time_limit + limits["time_safety_margin"] = problem.limits.time_limit_to_tle + # drop explicit timelimit + if "time_limit" in limits: + ryaml_filter(limits, "time_limit") + # validator_flags + validator_flags = " ".join( + problem.get_testdata_yaml( + problem.path / "data", + "output_validator_args", + PrintBar("Getting validator_flags for legacy export"), + ) ) + if validator_flags: + yaml_data["validator_flags"] = validator_flags + + # handle time limit + if not config.args.kattis: + (export_dir / ".timelimit").write_text(str(problem.limits.time_limit)) + + # Replace \problemname{...} by the value of `name:` in problems.yaml in all .tex files. + for f in (export_dir / "statement").iterdir(): + if f.is_file() and f.suffix == ".tex" and len(f.suffixes) >= 2: + lang = f.suffixes[-2][1:] + t = f.read_text() + match = re.search(r"\\problemname\{\s*(\\problemyamlname)?\s*\}", t) + if match: + if lang in problem.settings.name: + t = t.replace(match[0], rf"\problemname{{{problem.settings.name[lang]}}}") + f.unlink() + f.write_text(t) + else: + util.error(f"{f}: no name set for language {lang}.") - # TODO: Remove this if we know others import the statement folder - if (export_dir / "statement").exists(): - (export_dir / "statement").rename(export_dir / "problem_statement") - for d in ["solution", "problem_slide"]: - for f in list(util.glob(problem.path, f"{d}/*")): - if f.is_file(): - out = Path("problem_statement") / f.relative_to(problem.path / d) - if out.exists(): - message( - f"Can not export {f.relative_to(problem.path)} as {out}", - "Zip", - output, - color_type=MessageType.WARN, - ) - else: - add_file(out, f) + # rename output_validator dir + if (export_dir / "output_validator").exists(): + (export_dir / "output_validators").mkdir(parents=True) + (export_dir / "output_validator").rename( + export_dir / "output_validators" / "output_validator" + ) + + # rename statement dirs + if (export_dir / "statement").exists(): + (export_dir / "statement").rename(export_dir / "problem_statement") + for d in ["solution", "problem_slide"]: + for f in list(util.glob(problem.path, f"{d}/*")): + if f.is_file(): + out = Path("problem_statement") / f.relative_to(problem.path / d) + if out.exists(): + message( + f"Can not export {f.relative_to(problem.path)} as {out}", + "Zip", + output, + color_type=MessageType.WARN, + ) + else: + add_file(out, f) + shutil.rmtree(export_dir / d) + + # handle yaml updates + yaml_path.unlink() + write_yaml(yaml_data, yaml_path) # Build .ZIP file. message("writing zip file", "Zip", output, color_type=MessageType.LOG) @@ -332,8 +369,11 @@ def add_testcase(in_file: Path): # Assumes the current working directory has: the zipfiles and # contest*.{lang}.pdf # solutions*.{lang}.pdf +# problem-slides*.{lang}.pdf # Output is -def build_contest_zip(problems, zipfiles, outfile, statement_language): +def build_contest_zip( + problems: list[Problem], zipfiles: list[Path], outfile: str, languages: list[str] +) -> None: if not has_ryaml: error("zip needs the ruamel.yaml python3 library. Install python[3]-ruamel.yaml.") return @@ -351,25 +391,28 @@ def build_contest_zip(problems, zipfiles, outfile, statement_language): # For general zip export, also create pdfs and a samples zip. if not config.args.kattis: sampleout = Path("samples.zip") - build_samples_zip(problems, sampleout, statement_language) + build_samples_zip(problems, sampleout, languages) - for fname in ( - [ - "problems.yaml", - "contest.yaml", - sampleout, - ] - + list(Path(".").glob(f"contest*.{statement_language}.pdf")) - + list(Path(".").glob(f"solutions*.{statement_language}.pdf")) - + list(Path(".").glob(f"problem-slides*.{statement_language}.pdf")) - ): - if Path(fname).is_file(): + def add_file(file: Path) -> None: + if file.is_file(): + out = remove_language_pdf_suffix(file, language) if config.args.legacy else file zf.write( - fname, - remove_language_suffix(fname, statement_language), + file, + out, compress_type=zipfile.ZIP_DEFLATED, ) + add_file(Path("problems.yaml")) + add_file(Path("contest.yaml")) + add_file(sampleout) + for language in languages: + for name in [ + *Path(".").glob(f"contest*.{language}.pdf"), + *Path(".").glob(f"solutions*.{language}.pdf"), + *Path(".").glob(f"problem-slides*.{language}.pdf"), + ]: + add_file(name) + # For Kattis export, delete the original zipfiles. if config.args.kattis: for fname in zipfiles: @@ -381,7 +424,7 @@ def build_contest_zip(problems, zipfiles, outfile, statement_language): zf.close() -def update_contest_id(cid): +def update_contest_id(cid: str) -> None: if has_ryaml: contest_yaml_path = Path("contest.yaml") data = read_yaml(contest_yaml_path) @@ -391,7 +434,7 @@ def update_contest_id(cid): error("ruamel.yaml library not found. Update the id manually.") -def export_contest(cid: Optional[str]): +def export_contest(cid: Optional[str]) -> str: data = contest_yaml() if not data: @@ -440,7 +483,7 @@ def export_contest(cid: Optional[str]): return new_cid -def update_problems_yaml(problems, colors=None): +def update_problems_yaml(problems: list[Problem], colors: Optional[list[str]] = None) -> None: # Update name and time limit values. if not has_ryaml: log( @@ -452,16 +495,14 @@ def update_problems_yaml(problems, colors=None): path = Path("problems.yaml") data = path.is_file() and read_yaml(path) or [] - # DOMjudge does not yet support multilingual problems.yaml files. - statement_language = force_single_language(problems) - change = False for problem in problems: found = False - problem_name = problem.settings.name - if isinstance(problem_name, dict): - problem_name = problem_name[statement_language] + # ProblemSettings always has `name: dict[str, str]`, but we revert to `str` when `--legacy` is used. + problem_name: str | dict[str, str] = problem.settings.name + if isinstance(problem_name, dict) and config.args.legacy: + problem_name = problem_name[select_languages(problems)[0]] for d in data: if d["id"] == problem.name: @@ -528,7 +569,7 @@ def update_problems_yaml(problems, colors=None): log("Already up to date") -def export_problems(problems, cid): +def export_problems(problems: list[Problem], cid: str) -> Any: if not contest_yaml(): fatal("Exporting a contest only works if contest.yaml is available and not empty.") @@ -560,7 +601,7 @@ def export_problems(problems, cid): # Export a single problem to the specified contest ID. -def export_problem(problem, cid, pid): +def export_problem(problem: Problem, cid: str, pid: Optional[str]) -> None: if pid: log(f"Export {problem.name} to id {pid}") else: @@ -590,7 +631,7 @@ def export_problem(problem, cid, pid): # Export the contest and individual problems to DOMjudge. # Mimicked from https://github.com/DOMjudge/domjudge/blob/main/misc-tools/import-contest.sh -def export_contest_and_problems(problems, statement_language): +def export_contest_and_problems(problems: list[Problem], languages: list[str]) -> None: if config.args.contest_id: cid = config.args.contest_id else: @@ -600,7 +641,11 @@ def export_contest_and_problems(problems, statement_language): if not any(contest["id"] == cid for contest in get_contests()): cid = export_contest(cid) - with open(f"contest.{statement_language}.pdf", "rb") as pdf_file: + if len(languages) != 1: + # TODO: fix this + fatal("DOMjudge does not yet support multiple languages") + + with open(f"contest.{languages[0]}.pdf", "rb") as pdf_file: r = call_api( "POST", f"/contests/{cid}/problemset", @@ -621,18 +666,19 @@ def export_contest_and_problems(problems, statement_language): check_if_user_has_team() - def get_problem_id(problem): + def get_problem_id(problem: Problem) -> Optional[str]: nonlocal ccs_problems for p in ccs_problems: if problem.name in [p.get("short_name"), p.get("id"), p.get("externalid")]: return p["id"] + return None for problem in problems: pid = get_problem_id(problem) export_problem(problem, cid, pid) -def check_if_user_has_team(): +def check_if_user_has_team() -> None: # Not using the /users/{uid} route, because {uid} is either numeric or a string depending on the DOMjudge config. users = call_api_get_json("/users") if not any(user["username"] == config.args.username and user["team"] for user in users): diff --git a/bin/latex.py b/bin/latex.py index 30bdac4c1..b2df45b33 100644 --- a/bin/latex.py +++ b/bin/latex.py @@ -396,21 +396,21 @@ def build_problem_pdf(problem: "Problem", language: str, build_type=PdfType.PROB def build_problem_pdfs(problem: "Problem", build_type=PdfType.PROBLEM, web=False): """Build PDFs for various languages. If list of languages is specified, - (either via config files or --language arguments), build those. Otherwise + (either via config files or --lang arguments), build those. Otherwise build all languages for which there is a statement latex source. """ - if config.args.languages is not None: - for lang in config.args.languages: + if config.args.lang is not None: + for lang in config.args.lang: if lang not in problem.statement_languages: message( f"No statement source for language {lang}", problem.name, color_type=MessageType.FATAL, ) - languages = config.args.languages + languages = config.args.lang else: languages = problem.statement_languages - # For solutions or problem slides, filter for `..tex` files that exist. + # For solutions or problem slides, filter for `..tex` files that exist. if build_type != PdfType.PROBLEM: filtered_languages = [] for lang in languages: @@ -424,7 +424,7 @@ def build_problem_pdfs(problem: "Problem", build_type=PdfType.PROBLEM, web=False ) languages = filtered_languages if config.args.watch and len(languages) > 1: - fatal("--watch does not work with multiple languages. Please use --language") + fatal("--watch does not work with multiple languages. Please use --lang") return all([build_problem_pdf(problem, lang, build_type, web) for lang in languages]) @@ -551,8 +551,8 @@ def build_contest_pdfs(contest, problems, tmpdir, lang=None, build_type=PdfType. message( "No statement language present in every problem.", contest, color_type=MessageType.FATAL ) - if config.args.languages is not None: - languages = config.args.languages + if config.args.lang is not None: + languages = config.args.lang for lang in set(languages) - statement_languages: message( f"Unable to build all statements for language {lang}", @@ -563,7 +563,7 @@ def build_contest_pdfs(contest, problems, tmpdir, lang=None, build_type=PdfType. languages = statement_languages if config.args.watch and len(languages) > 1: message( - "--watch does not work with multiple languages. Please use --language", + "--watch does not work with multiple languages. Please use --lang", contest, color_type=MessageType.FATAL, ) diff --git a/bin/skel.py b/bin/skel.py index 1b2588e55..1d445889e 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -5,7 +5,6 @@ # Local imports import config import latex -from export import force_single_language from problem import Problem from util import * import contest @@ -144,7 +143,7 @@ def new_problem(): if config.args.problem: fatal("--problem does not work for new_problem.") - statement_languages = config.args.languages if config.args.languages else ["en"] + statement_languages = config.args.lang if config.args.lang else ["en"] main_language = "en" if "en" in statement_languages else statement_languages[0] problemname = { @@ -292,11 +291,6 @@ def rename_problem(problem): data["name"] = newname write_yaml(data, problem_yaml) - # DOMjudge does not yet support multilingual problems.yaml files. - statement_language = force_single_language([problem]) - if isinstance(newname, dict): - newname = newname[statement_language] - problems_yaml = Path("problems.yaml") if problems_yaml.is_file(): data = read_yaml(problems_yaml) or [] diff --git a/bin/tools.py b/bin/tools.py index d67f6cbf1..7d4eb879e 100755 --- a/bin/tools.py +++ b/bin/tools.py @@ -343,9 +343,7 @@ def build_parser(): action="store_true", help="Copy the output pdf instead of symlinking it.", ) - global_parser.add_argument( - "--language", dest="languages", action="append", help="Set language." - ) + global_parser.add_argument("--lang", nargs="+", help="Languages to include.") subparsers = parser.add_subparsers( title="actions", dest="action", parser_class=SuppressingParser @@ -814,12 +812,22 @@ def build_parser(): action="store_true", help="Make a zip more following the kattis problemarchive.com format.", ) + zipparser.add_argument( + "--legacy", + action="store_true", + help="Make a zip more following the legacy format.", + ) zipparser.add_argument("--no-solutions", action="store_true", help="Do not compile solutions") # Build a zip with all samples. - subparsers.add_parser( + samplezipparser = subparsers.add_parser( "samplezip", parents=[global_parser], help="Create zip file of all samples." ) + samplezipparser.add_argument( + "--legacy", + action="store_true", + help="Make a zip more following the legacy format.", + ) gitlab_parser = subparsers.add_parser( "gitlabci", parents=[global_parser], help="Print a list of jobs for the given contest." @@ -856,6 +864,11 @@ def build_parser(): action="store", help="Contest ID to use when writing to the API. Defaults to value of contest_id in contest.yaml.", ) + exportparser.add_argument( + "--legacy", + action="store_true", + help="Make export more following the legacy format.", + ) updateproblemsyamlparser = subparsers.add_parser( "update_problems_yaml", @@ -871,6 +884,11 @@ def build_parser(): action="store_true", help="Sort the problems by id.", ) + updateproblemsyamlparser.add_argument( + "--legacy", + action="store_true", + help="Make problems.yaml more following the legacy format.", + ) # Print the corresponding temporary directory. tmpparser = subparsers.add_parser( @@ -1034,8 +1052,8 @@ def run_parsed_arguments(args): sampleout = Path("samples.zip") if level == "problem": sampleout = problems[0].path / sampleout - statement_language = export.force_single_language(problems) - export.build_samples_zip(problems, sampleout, statement_language) + languages = export.select_languages(problems) + export.build_samples_zip(problems, sampleout, languages) return if action == "rename_problem": @@ -1175,10 +1193,16 @@ def run_parsed_arguments(args): config.args = old_args if not config.args.kattis: - # Make sure that all problems use the same language for the PDFs - export.force_single_language(problems) - success &= latex.build_problem_pdfs(problem) + if not config.args.no_solutions: + success &= latex.build_problem_pdfs( + problem, build_type=latex.PdfType.SOLUTION + ) + + if problem.path.glob(str(latex.PdfType.PROBLEM_SLIDE.path("*"))): + success &= latex.build_problem_pdfs( + problem, build_type=latex.PdfType.PROBLEM_SLIDE + ) if not config.args.force: success &= problem.validate_data(validate.Mode.INPUT, constraints={}) @@ -1192,10 +1216,8 @@ def run_parsed_arguments(args): print(file=sys.stderr) if action in ["export"]: - # Add contest PDF for only one language to DOMjudge - statement_language = export.force_single_language(problems) - - export.export_contest_and_problems(problems, statement_language) + languages = export.select_languages(problems) + export.export_contest_and_problems(problems, languages) if level == "problemset": print(f"{Style.BRIGHT}CONTEST {contest}{Style.RESET_ALL}", file=sys.stderr) @@ -1223,48 +1245,53 @@ def run_parsed_arguments(args): ) if action in ["zip"]: - statement_language = None + languages = [] if not config.args.kattis: - # Add contest/solutions PDF for only one language to the zip file - statement_language = export.force_single_language(problems) + languages = export.select_languages(problems) - success &= latex.build_contest_pdfs(contest, problems, tmpdir, statement_language) - success &= latex.build_contest_pdfs( - contest, problems, tmpdir, statement_language, web=True - ) - if not config.args.no_solutions: - success &= latex.build_contest_pdf( - contest, - problems, - tmpdir, - statement_language, - build_type=latex.PdfType.SOLUTION, - ) - success &= latex.build_contest_pdf( - contest, - problems, - tmpdir, - statement_language, - build_type=latex.PdfType.SOLUTION, - web=True, - ) # Only build the problem slides if at least one problem has the TeX for it slideglob = latex.PdfType.PROBLEM_SLIDE.path("*") - if any(problem.path.glob(str(slideglob)) for problem in problems): - success &= latex.build_contest_pdf( - contest, - problems, - tmpdir, - statement_language, - build_type=latex.PdfType.PROBLEM_SLIDE, + build_problem_slides = any( + problem.path.glob(str(slideglob)) for problem in problems + ) + + for language in languages: + success &= latex.build_contest_pdfs(contest, problems, tmpdir, language) + success &= latex.build_contest_pdfs( + contest, problems, tmpdir, language, web=True ) - else: + if not config.args.no_solutions: + success &= latex.build_contest_pdf( + contest, + problems, + tmpdir, + language, + build_type=latex.PdfType.SOLUTION, + ) + success &= latex.build_contest_pdf( + contest, + problems, + tmpdir, + language, + build_type=latex.PdfType.SOLUTION, + web=True, + ) + if build_problem_slides: + success &= latex.build_contest_pdf( + contest, + problems, + tmpdir, + language, + build_type=latex.PdfType.PROBLEM_SLIDE, + ) + + if not build_problem_slides: log(f"No problem has {slideglob.name}, skipping problem slides") outfile = contest + ".zip" if config.args.kattis: outfile = contest + "-kattis.zip" - export.build_contest_zip(problems, problem_zips, outfile, statement_language) + export.build_contest_zip(problems, problem_zips, outfile, languages) if action in ["update_problems_yaml"]: export.update_problems_yaml( diff --git a/bin/upgrade.py b/bin/upgrade.py index b31552cbb..7b1c6f915 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -388,7 +388,6 @@ def _upgrade(problem_path: Path, bar: ProgressBar) -> None: upgrade_statement(problem_path, bar) upgrade_format_validators(problem_path, bar) upgrade_output_validators(problem_path, bar) - # update .in.statement? upgrade_problem_yaml(problem_path, bar) bar.done() diff --git a/doc/commands.md b/doc/commands.md index c711fc44d..8ae849ed4 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -57,7 +57,7 @@ The flags below work for any subcommand: - `--no-bar`: Disable showing progress bars. This is useful when running in non-interactive contexts (such as CI jobs) or on platforms/terminals that don't handle the progress bars well. - `--error`/`-e`: show full output of failing commands using `--error`. The default is to show a short snippet only. - `--force-build`: Force rebuilding binaries instead of reusing cached version. -- `--language `: select a single language to use. `` should be a language code like `en` or `nl`. +- `--lang`: select languages to use for LaTeX commands. The languages should be specified by language codes like `en` or `nl`. # Problem development diff --git a/doc/multiple_languages.md b/doc/multiple_languages.md index 27fa90ed1..3eeb2e661 100644 --- a/doc/multiple_languages.md +++ b/doc/multiple_languages.md @@ -15,18 +15,18 @@ Here, `LANG` is a two-letter language code, see It is expected that the languages keys in the metadata and statement files agree. -The default language for BAPCtools is English, but multiple languages can be specified at various points of the tool, typically using the `--language` flag or configuration files. +The default language for BAPCtools is English, but multiple languages can be specified at various points of the tool, typically using the `--lang` flag or configuration files. ## Creating a contest In short, -1. configure `languages` in `.bapctools.yaml`. +1. configure `lang` in `.bapctools.yaml`. 2. add a skeleton for `problem.LANG.tex` in `skel/problem/statement`. -### Configure `language` +### Configure `lang` -To create a contest supporting French, Dutch, and Luxembourgish, set the configurartion key `languages` to the list `['nl', 'fr', 'lt']`. +To create a contest supporting French, Dutch, and Luxembourgish, set the configurartion key `lang` to the list `['nl', 'fr', 'lt']`. Configuration keys can be set in many ways, see **Personal configuration file** in the BAPCtools documentation, but an easy way is to create a new contest: ```sh @@ -36,7 +36,7 @@ bt new_contest and then create or extend the file `/.bapctools.yaml` with ```yaml -languages: +lang: - nl - fr - lt @@ -82,13 +82,13 @@ To create a problem, bt new_problem ``` -will look for the `languages` configuration (for instance, at contest level) and use that by default. +will look for the `lang` configuration (for instance, at contest level) and use that by default. Thus, if the contest is set up as above, you need to do nothing extra. With arguments, or outside of a contest directory, ```sh -bt new_problem --language en --language fr +bt new_problem --lang en fr ``` creates a problem with two languages, English and French. @@ -108,7 +108,7 @@ creates PDFs for every problem language statement `problem.xy.tex`. With arguments, ```sh -bt pdf --language en --language fr +bt pdf --lang en fr ``` produces PDFs for English and French. @@ -117,7 +117,7 @@ The resulting PDFs are named `/problem.xy.pdf`. ## Solution PDF -Similarly, `bt solutions [--language en --language fr]` creates +Similarly, `bt solutions [--lang en fr]` creates `/solution.xy.pdf` for the given languages, defaulting to all available `solution.xy.tex` files. diff --git a/latex/lang/de.tex b/latex/lang/de.tex index 9670d6a39..07a1adbcd 100644 --- a/latex/lang/de.tex +++ b/latex/lang/de.tex @@ -1,4 +1,4 @@ -\newcommand{\langbabel}{german} +\newcommand{\langbabel}{ngerman} % bapc.cls \newcommand{\langblank}{Diese Seite wurde absichtlich leer gelassen.} diff --git a/test/problems/identity/problem.yaml b/test/problems/identity/problem.yaml index 40a4aea69..c1a013182 100644 --- a/test/problems/identity/problem.yaml +++ b/test/problems/identity/problem.yaml @@ -1,6 +1,8 @@ problem_format_version: 2023-07-draft type: pass-fail -name: Identity +name: + en: Identity + de: Identität credits: authors: Ragnar Groot Koerkamp uuid: a7d29d67-9b0b-4fd4-ae56-ab2cad5919ab diff --git a/test/problems/identity/problem_slide/problem-slide.de.tex b/test/problems/identity/problem_slide/problem-slide.de.tex new file mode 100644 index 000000000..164a1f0d3 --- /dev/null +++ b/test/problems/identity/problem_slide/problem-slide.de.tex @@ -0,0 +1,10 @@ +\newcommand{\maxn}{1000} + +\begin{frame} + \frametitle{\problemtitle} + + \begin{itemize} + \item Gegeben ein Integer $0\leq n\leq \maxn$. + \item Gebe eine Zeile mit $n$ aus. + \end{itemize} +\end{frame} diff --git a/test/problems/identity/solution/solution.de.tex b/test/problems/identity/solution/solution.de.tex new file mode 100644 index 000000000..201b90853 --- /dev/null +++ b/test/problems/identity/solution/solution.de.tex @@ -0,0 +1,8 @@ +% this file is intentionally missing +\begin{frame} + \frametitle{\problemtitle} + \begin{itemize} + \item Gebe $4$ aus. + \solvestats + \end{itemize} +\end{frame} diff --git a/test/problems/identity/statement/problem.de.tex b/test/problems/identity/statement/problem.de.tex new file mode 100644 index 000000000..03d9cbcea --- /dev/null +++ b/test/problems/identity/statement/problem.de.tex @@ -0,0 +1,22 @@ +\problemname{} + +\newcommand{\maxn}{1000} + +Gegeben $n$, gebe $n$ aus. + +\begin{Input} + Die Eingabe besteht aus: + \begin{itemize} + \item Einer Zeile mit einem Integer $0\leq n\leq \maxn$. + \end{itemize} +\end{Input} + +\begin{Output} + Gebe eine Zeile mit $n$ aus. +\end{Output} + +\nextsample{} +Dieser Text steht hinter dem ersten Beispiel. + +\remainingsamples{} +Dieser Text steht hinter allen Beispielen. diff --git a/test/problems/problems.yaml b/test/problems/problems.yaml index 02df538df..13a3d923a 100644 --- a/test/problems/problems.yaml +++ b/test/problems/problems.yaml @@ -1,2 +1,7 @@ - id: identity label: A + name: + en: Identity + de: Identität + rgb: '#000000' + time_limit: 1.0 diff --git a/test/test_problems.py b/test/test_problems.py index 7756e74e7..9be303627 100644 --- a/test/test_problems.py +++ b/test/test_problems.py @@ -160,24 +160,44 @@ def test_samplezip(self): zip_path.unlink() def test_zip(self): - tools.test(["zip", "--force"]) zip_path = Path("identity.zip") + tools.test(["zip", "--force"]) + # The full zip should contain the samples with the original file extensions. assert sorted( (info.filename, info.file_size) for info in ZipFile(zip_path).infolist() - if info.filename.startswith("data/sample/") + if info.filename.startswith("identity/data/sample/") ) == [ *( - (f"data/sample/{i}.{ext}", size) + (f"identity/data/sample/{i}.{ext}", size) for i, size in enumerate([2, 4, 2, 5], start=1) for ext in ["ans", "in"] ), - *((f"data/sample/5.{ext}", 2) for ext in ["ans", "in", "out"]), - *((f"data/sample/6.{ext}.statement", 2) for ext in ["ans", "in"]), + *((f"identity/data/sample/5.{ext}", 2) for ext in ["ans", "in", "out"]), + *((f"identity/data/sample/6.{ext}.statement", 2) for ext in ["ans", "in"]), ], "Zip contents for data/sample/ are not correct" + # The full zip should contain all PDFs in their corresponding directories. + assert sorted( + info.filename for info in ZipFile(zip_path).infolist() if info.filename.endswith(".pdf") + ) == [ + f"identity/{path}.{lang}.pdf" + for path in ["problem_slide/problem-slide", "solution/solution", "statement/problem"] + for lang in ["de", "en"] + ], "Zip contents for PDFs with both languages are not correct" + + tools.test(["zip", "--force", "--lang", "en"]) + + # The full zip should contain all PDFs in their corresponding directories. + assert sorted( + info.filename for info in ZipFile(zip_path).infolist() if info.filename.endswith(".pdf") + ) == [ + f"identity/{path}.en.pdf" + for path in ["problem_slide/problem-slide", "solution/solution", "statement/problem"] + ], "Zip contents for PDFs with `--lang en` are not correct" + zip_path.unlink() # Misc @@ -234,6 +254,34 @@ def test_problem_slides(self): def test_gitlabci(self): tools.test(["gitlabci"]) + def test_zip(self): + zip_path = Path("problems.zip") + + for languages in [["en", "de"], ["en"]]: + tools.test(["zip", "--force", "--lang", *languages]) + + # The full zip should contain all PDFs in their corresponding directories. + assert sorted(info.filename for info in ZipFile(zip_path).infolist()) == sorted( + [ + "contest.yaml", + "identity.zip", + "problems.yaml", + "samples.zip", + *( + f"{name}{suffix}.{lang}.pdf" + for name in ["contest", "solutions", "problem-slides"] + for lang in languages + for suffix in ["", "-web"] + # The problem slides do not have a -web version. + if (name, suffix) != ("problem-slides", "-web") + ), + ] + ), f"Zip contents for contest zip are not correct for languages {languages}" + + zip_path.unlink() + Path("identity/identity.zip").unlink() + Path("samples.zip").unlink() + @pytest.fixture(scope="function") def tmp_contest_dir(tmp_path): From a0736af2229ea9c964d43ef72bc31a6dcefe4a75 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Sun, 30 Mar 2025 17:22:44 +0200 Subject: [PATCH 11/23] Use Validator.source_dir at all places (#444) * use source_dir at all places * use source_dir even more * fix bits/stdc++ check --- bin/export.py | 19 ++++++++++--------- bin/problem.py | 4 +++- bin/program.py | 9 ++++----- bin/skel.py | 5 +++-- bin/stats.py | 7 ++++--- bin/upgrade.py | 22 ++++++++++++++-------- bin/validate.py | 18 +++++++++--------- 7 files changed, 47 insertions(+), 37 deletions(-) diff --git a/bin/export.py b/bin/export.py index 071b61016..8c6b9d17e 100644 --- a/bin/export.py +++ b/bin/export.py @@ -11,6 +11,7 @@ from contest import * from latex import PdfType from problem import Problem +from validate import InputValidator, AnswerValidator, OutputValidator def select_languages(problems: list[Problem]) -> list[str]: @@ -118,8 +119,8 @@ def build_problem_zip(problem: Problem, output: Path) -> bool: ("solution/*", False), ("problem_slide/*", False), ("generators/*", False), - ("input_validators/**/*", True), - ("answer_validators/**/*", False), # TODO make required when not problem.interactive? + (f"{InputValidator.source_dir}/**/*", True), + (f"{AnswerValidator.source_dir}/**/*", False), # TODO required when not interactive? ("submissions/accepted/**/*", True), ("submissions/*/**/*", False), ("attachments/**/*", problem.interactive or problem.multi_pass), @@ -133,7 +134,7 @@ def build_problem_zip(problem: Problem, output: Path) -> bool: files.append((PdfType.SOLUTION.path(language, ".pdf").name, False)) if problem.custom_output: - files.append(("output_validator/**/*", True)) + files.append((f"{OutputValidator.source_dir}/**/*", True)) message("preparing zip file content", "Zip", problem.path, color_type=MessageType.LOG) @@ -205,9 +206,9 @@ def add_testcase(in_file: Path) -> None: if problem.settings.constants: constants_supported = [ "data/**/testdata.yaml", - "input_validators/**/*", - "answer_validators/**/*", - "output_validator/**/*", + f"{InputValidator.source_dir}/**/*", + f"{AnswerValidator.source_dir}/**/*", + f"{OutputValidator.source_dir}/**/*", # "statement/*", "solution/*", "problem_slide/*", use \constant{} commands # "submissions/*/**/*", removed support? ] @@ -316,10 +317,10 @@ def add_testcase(in_file: Path) -> None: util.error(f"{f}: no name set for language {lang}.") # rename output_validator dir - if (export_dir / "output_validator").exists(): + if (export_dir / OutputValidator.source_dir).exists(): (export_dir / "output_validators").mkdir(parents=True) - (export_dir / "output_validator").rename( - export_dir / "output_validators" / "output_validator" + (export_dir / OutputValidator.source_dir).rename( + export_dir / "output_validators" / OutputValidator.source_dir ) # rename statement dirs diff --git a/bin/problem.py b/bin/problem.py index 3d08e414b..4c3006493 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -241,7 +241,9 @@ def __init__( self.interactive: bool = "interactive" in mode self.multi_pass: bool = "multi-pass" in mode self.custom_output: bool = ( - self.interactive or self.multi_pass or (problem.path / "output_validator").is_dir() + self.interactive + or self.multi_pass + or (problem.path / validate.OutputValidator.source_dir).is_dir() ) self.name: dict[str, str] = parse_setting(yaml_data, "name", {"en": ""}) diff --git a/bin/program.py b/bin/program.py index 12d32a9c0..db79b11e4 100644 --- a/bin/program.py +++ b/bin/program.py @@ -307,12 +307,11 @@ def _checks(self, bar: ProgressBar): for f in self.source_files: try: if f.read_text().find("bits/stdc++.h") != -1: - if "validators/" in str(f): - bar.error("Must not depend on bits/stdc++.h.", resume=True) - break - else: + if f.is_relative_to(self.problem.path / "submissions"): bar.log("Should not depend on bits/stdc++.h") - break + else: + bar.error("Must not depend on bits/stdc++.h.", resume=True) + break except UnicodeDecodeError: pass diff --git a/bin/skel.py b/bin/skel.py index 1d445889e..6201ca536 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -4,10 +4,11 @@ # Local imports import config +import contest import latex from problem import Problem from util import * -import contest +from validate import OutputValidator try: import questionary @@ -254,7 +255,7 @@ def new_problem(): variables, exist_ok=True, preserve_symlinks=preserve_symlinks, - skip=[skeldir / "output_validator"] if not custom_output else None, + skip=[skeldir / OutputValidator.source_dir] if not custom_output else None, ) # Warn about missing problem statement skeletons for non-en languages diff --git a/bin/stats.py b/bin/stats.py index 437fa996f..d3dc94dda 100644 --- a/bin/stats.py +++ b/bin/stats.py @@ -13,6 +13,7 @@ import generate import latex import program +import validate from util import error, exec_command, glob, warn Selector = str | Callable | list[str] | list[Callable] @@ -51,9 +52,9 @@ def problem_stats(problems): ("yaml", "problem.yaml"), ("tex", str(latex.PdfType.PROBLEM.path("*")), 1), ("sol", str(latex.PdfType.SOLUTION.path("*")), 1), - (" val: I", ["input_validators/*"]), - ("A", ["answer_validators/*"]), - ("O", ["output_validator/"]), + (" val: I", [f"{validate.InputValidator.source_dir}/*"]), + ("A", [f"{validate.AnswerValidator.source_dir}/*"]), + ("O", [f"{validate.OutputValidator.source_dir}/*"]), ( " sample", [lambda s: {x.stem for x in s if x.parts[2] == "sample"}], diff --git a/bin/upgrade.py b/bin/upgrade.py index 7b1c6f915..b79040d65 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -1,6 +1,7 @@ import config import generate from util import * +from validate import InputValidator, AnswerValidator, OutputValidator import shutil from typing import Any @@ -153,8 +154,8 @@ def upgrade_statement(problem_path: Path, bar: ProgressBar) -> None: def upgrade_format_validators(problem_path: Path, bar: ProgressBar) -> None: rename = [ - ("input_format_validators", "input_validators"), - ("answer_format_validators", "answer_validators"), + ("input_format_validators", InputValidator.source_dir), + ("answer_format_validators", AnswerValidator.source_dir), ] for old_name, new_name in rename: old_path = problem_path / old_name @@ -169,14 +170,17 @@ def upgrade_format_validators(problem_path: Path, bar: ProgressBar) -> None: def upgrade_output_validators(problem_path: Path, bar: ProgressBar) -> None: if (problem_path / "output_validators").is_dir(): - if (problem_path / "output_validator").exists(): + if (problem_path / OutputValidator.source_dir).exists(): bar.error( - "can't rename 'output_validators/', 'output_validator/' already exists", resume=True + f"can't rename 'output_validators/', '{OutputValidator.source_dir}/' already exists", + resume=True, ) return content = [*(problem_path / "output_validators").iterdir()] if len(content) == 1 and content[0].is_dir(): - bar.log(f"renaming 'output_validators/{content[0].name}' to 'output_validator/'") + bar.log( + f"renaming 'output_validators/{content[0].name}' to '{OutputValidator.source_dir}/'" + ) def move(src: str, dst: str) -> None: if Path(src).is_symlink(): @@ -198,11 +202,13 @@ def move(src: str, dst: str) -> None: else: Path(src).rename(dst) - shutil.copytree(content[0], problem_path / "output_validator", copy_function=move) + shutil.copytree( + content[0], problem_path / OutputValidator.source_dir, copy_function=move + ) shutil.rmtree(problem_path / "output_validators") else: - bar.log("renaming 'output_validators/' to 'output_validator/'") - (problem_path / "output_validators").rename(problem_path / "output_validator") + bar.log(f"renaming 'output_validators/' to '{OutputValidator.source_dir}/'") + (problem_path / "output_validators").rename(problem_path / OutputValidator.source_dir) def upgrade_problem_yaml(problem_path: Path, bar: ProgressBar) -> None: diff --git a/bin/validate.py b/bin/validate.py index fdd950dcc..90ba120b5 100644 --- a/bin/validate.py +++ b/bin/validate.py @@ -224,13 +224,13 @@ class InputValidator(Validator): Also supports checktestdata and viva files, with different invocation. """ - def __init__(self, problem, path, **kwargs): - super().__init__(problem, path, "input_validators", **kwargs) - validator_type: Final[str] = "input" source_dir: Final[str] = "input_validators" + def __init__(self, problem, path, **kwargs): + super().__init__(problem, path, InputValidator.source_dir, **kwargs) + def run( self, testcase: testcase.Testcase, @@ -284,13 +284,13 @@ class AnswerValidator(Validator): Also supports checktestdata and viva files, with different invocation. """ - def __init__(self, problem, path, **kwargs): - super().__init__(problem, path, "answer_validators", **kwargs) - validator_type: Final[str] = "answer" source_dir: Final[str] = "answer_validators" + def __init__(self, problem, path, **kwargs): + super().__init__(problem, path, AnswerValidator.source_dir, **kwargs) + def run( self, testcase: testcase.Testcase, @@ -335,13 +335,13 @@ class OutputValidator(Validator): ./validator input answer feedbackdir [arguments from problem.yaml] < output """ - def __init__(self, problem, path, **kwargs): - super().__init__(problem, path, "output_validator", **kwargs) - validator_type: Final[str] = "output" source_dir: Final[str] = "output_validator" + def __init__(self, problem, path, **kwargs): + super().__init__(problem, path, OutputValidator.source_dir, **kwargs) + def run( self, testcase: testcase.Testcase, From 208e7f49867a17ee426914c8e1261893eb4aa32c Mon Sep 17 00:00:00 2001 From: mzuenni Date: Sun, 30 Mar 2025 22:17:09 +0200 Subject: [PATCH 12/23] change order --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cfcb3694c..86847e8ba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,9 +7,9 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.9 hooks: - - id: ruff-format - id: ruff args: [ --fix ] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.15.0 hooks: From a560f795d53eb68d90c371e7b20a7d3ab23c60f7 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Mon, 31 Mar 2025 09:17:34 +0200 Subject: [PATCH 13/23] Drop support for `data/bad` (#445) * drop bad support * fix * Fix log messages when renaming data/bad/* * [stats] Rename "bad" to "inv" and "good" to "v_o" to stay closer to their actual names --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- bin/config.py | 1 - bin/generate.py | 7 ----- bin/stats.py | 4 +-- bin/testcase.py | 9 +----- bin/upgrade.py | 79 ++++++++++++++++++++++++++++++++++++++++++++----- 5 files changed, 75 insertions(+), 25 deletions(-) diff --git a/bin/config.py b/bin/config.py index 086a47427..e8e526a19 100644 --- a/bin/config.py +++ b/bin/config.py @@ -86,7 +86,6 @@ "invalid_input", "invalid_answer", "invalid_output", - "bad", ] diff --git a/bin/generate.py b/bin/generate.py index bf923226a..15c3462c6 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -484,12 +484,6 @@ def __init__( # root in /data self.root = self.path.parts[0] - if self.root == "bad": - message( - "bad is deprecated. Use {invalid_input,invalid_answer} instead.", - self.path, - color_type=MessageType.WARN, - ) if not config.COMPILED_FILE_NAME_REGEX.fullmatch(name + ".in"): raise ParseException("Testcase does not have a valid name.") @@ -1124,7 +1118,6 @@ def add_testdata_to_cache(): # consider specific files for the uniqueness of this testcase relevant_files = { - "bad": [".in", ".ans"], "invalid_answer": [".in", ".ans"], "invalid_output": [".in", ".ans", ".out"], "valid_output": [".in", ".ans", ".out"], diff --git a/bin/stats.py b/bin/stats.py index d3dc94dda..2407bb072 100644 --- a/bin/stats.py +++ b/bin/stats.py @@ -68,12 +68,12 @@ def problem_stats(problems): 100, ), ( - "bad", + "inv", [lambda s: {x.stem for x in s if x.parts[2] in config.INVALID_CASE_DIRECTORIES}], 0, ), ( - "good", + "v_o", [lambda s: {x.stem for x in s if x.parts[2] in ["valid_output"]}], 0, ), diff --git a/bin/testcase.py b/bin/testcase.py index f6500bf1e..d07f2af79 100644 --- a/bin/testcase.py +++ b/bin/testcase.py @@ -10,7 +10,6 @@ fatal, print_name, shorten_path, - warn, ) import config import validate @@ -97,12 +96,6 @@ def __init__(self, base_problem, path, *, short_path=None, print_warn=False): # Display name: everything after data/. self.name = str(self.short_path.with_suffix("")) - # Backwards compatibility support for `data/bad`. - if self.root == "bad": - if print_warn: - warn("data/bad is deprecated. Use data/{invalid_input,invalid_answer} instead.") - self.root = "invalid_answer" if self.ans_path.is_file() else "invalid_input" - def __repr__(self): return self.name @@ -204,7 +197,7 @@ def validate_format( warn_instead_of_error=warn_instead_of_error, ) case validate.Mode.INVALID: - assert self.root in config.INVALID_CASE_DIRECTORIES[:-1] + assert self.root in config.INVALID_CASE_DIRECTORIES ok = self.validate_format( validate.Mode.INPUT, diff --git a/bin/upgrade.py b/bin/upgrade.py index b79040d65..0dd2d969b 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -3,6 +3,7 @@ from util import * from validate import InputValidator, AnswerValidator, OutputValidator +import secrets import shutil from typing import Any @@ -28,6 +29,34 @@ def upgrade_data(problem_path: Path, bar: ProgressBar) -> None: bar.log(f"renaming '{old_name}' to '{new_name}'") old_path.rename(new_path) + def rename_testcase(old_base: Path, new_dir: Path) -> None: + new_dir.mkdir(parents=True, exist_ok=True) + new_base = new_dir / old_base.name + for ext in config.KNOWN_TEXT_DATA_EXTENSIONS: + old_path = old_base.with_suffix(ext) + new_path = new_base.with_suffix(ext) + if old_path.is_file(): + old_rel_path, new_rel_path = [ + p.relative_to(problem_path) for p in (old_path, new_path) + ] + if new_path.exists(): + bar.error( + f"can't rename '{old_rel_path}', '{new_rel_path}' already exists", + resume=True, + ) + continue + bar.log(f"renaming '{old_rel_path}' to '{new_rel_path}'") + old_path.rename(new_path) + + bad_dir = problem_path / "data" / "bad" + for file in bad_dir.glob("*.in"): + if file.with_suffix(".ans").is_file(): + rename_testcase(file, problem_path / "data" / "invalid_answer") + else: + rename_testcase(file, problem_path / "data" / "invalid_input") + if bad_dir.is_dir() and not any(bad_dir.iterdir()): + bad_dir.rmdir() + def upgrade_testdata_yaml(problem_path: Path, bar: ProgressBar) -> None: rename = [ @@ -62,14 +91,16 @@ def upgrade_generators_yaml(problem_path: Path, bar: ProgressBar) -> None: changed = False - rename = [ - ("invalid_inputs", "invalid_input"), - ("invalid_answers", "invalid_answer"), - ("invalid_outputs", "invalid_output"), - ("valid_outputs", "valid_output"), - ] if "data" in yaml_data and isinstance(yaml_data["data"], dict): data = yaml_data["data"] + assert isinstance(data, CommentedMap) + + rename = [ + ("invalid_inputs", "invalid_input"), + ("invalid_answers", "invalid_answer"), + ("invalid_outputs", "invalid_output"), + ("valid_outputs", "valid_output"), + ] for old_name, new_name in rename: if old_name in data: if new_name in data: @@ -82,6 +113,40 @@ def upgrade_generators_yaml(problem_path: Path, bar: ProgressBar) -> None: ryaml_replace(data, old_name, new_name) changed = True + # this breaks comments... but that is fine + if "bad" in data: + + def move_testcase(name: str, value: Any, new_parent: str) -> None: + parent = ryaml_get_or_add(data, new_parent) + if "data" not in parent: + parent[data] = CommentedSeq + parent = parent["data"] + new_name = name + if isinstance(parent, list): + parent.append(CommentedMap()) + parent[-1][new_name] = value + else: + if new_name in parent: + new_name = f"bad_{new_name}" + if new_name in parent: + new_name = f"{new_name}_{secrets.token_hex(6)}" + assert new_name not in parent + parent[new_name] = value + bar.log(f"renaming 'bad.{name}' to '{new_parent}.{new_name}' in generators.yaml") + + bad = data["bad"] + if "data" in bad and bad["data"]: + children = bad["data"] if isinstance(bad["data"], list) else [bad["data"]] + for dictionary in children: + for child_name, child_data in sorted(dictionary.items()): + if "ans" in child_data: + move_testcase(child_name, child_data, "invalid_answer") + else: + move_testcase(child_name, child_data, "invalid_input") + + ryaml_filter(data, "bad") + changed = True + def upgrade_generated_testdata_yaml(data: dict[str, Any], path: str) -> bool: changed = False if "testdata.yaml" in data: @@ -374,7 +439,7 @@ def add_args(new_data: dict[str, Any]) -> bool: data["limits"] = CommentedMap() if "time_limit" in data["limits"]: bar.error( - "can't change '.timelimit' file, 'limits.time_limit' already exists in problem.yaml", + "can't change 'domjudge-problem.ini' file, 'limits.time_limit' already exists in problem.yaml", resume=True, ) else: From 1044c30d04c99fda61359b909622795f281e5386 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Mon, 31 Mar 2025 20:54:46 +0200 Subject: [PATCH 14/23] fix a lot of typing issues --- bin/config.py | 4 ++-- bin/constraints.py | 43 ++++++++++++++++++++++++------------------- bin/export.py | 9 +++++---- bin/fuzz.py | 2 ++ bin/generate.py | 8 +++++--- bin/interactive.py | 6 ++++-- bin/problem.py | 1 + bin/program.py | 5 +++-- bin/skel.py | 4 +++- bin/slack.py | 1 + bin/tools.py | 3 ++- bin/upgrade.py | 26 ++++++++++---------------- bin/util.py | 5 ++--- bin/validate.py | 4 +++- 14 files changed, 67 insertions(+), 54 deletions(-) diff --git a/bin/config.py b/bin/config.py index e8e526a19..eba8a1341 100644 --- a/bin/config.py +++ b/bin/config.py @@ -5,7 +5,7 @@ import re from pathlib import Path from collections.abc import Mapping, Sequence -from typing import Final, Literal, Optional +from typing import Any, Final, Literal, Optional SPEC_VERSION: Final[str] = "2023-07-draft" @@ -104,7 +104,7 @@ args = argparse.Namespace() -DEFAULT_ARGS: Final[Mapping] = { +DEFAULT_ARGS: Final[Mapping[str, Any]] = { "jobs": (os.cpu_count() or 1) // 2, "time": 600, # Used for `bt fuzz` "verbose": 0, diff --git a/bin/constraints.py b/bin/constraints.py index bea1f0ea4..f2f33699a 100644 --- a/bin/constraints.py +++ b/bin/constraints.py @@ -1,9 +1,11 @@ import re from collections import defaultdict +from typing import Optional import latex import validate from colorama import Fore, Style +from problem import Problem # Local imports from util import * @@ -16,7 +18,9 @@ """ -def check_validators(problem): +def check_validators( + problem: Problem, +) -> tuple[set[int | float], list[str | tuple[int | float, str, int | float]]]: in_constraints: validate.ConstraintsDict = {} ans_constraints: validate.ConstraintsDict = {} problem.validate_data(validate.Mode.INPUT, constraints=in_constraints) @@ -27,10 +31,10 @@ def check_validators(problem): log("No constraint validation of answer values found in answer or output validators.") print() - validator_values = set() + validator_values: set[int | float] = set() validator_defs: list[str | tuple[int | float, str, int | float]] = [] - def f(cs): + def f(cs: validate.ConstraintsDict) -> None: for loc, value in sorted(cs.items()): name, has_low, has_high, vmin, vmax, low, high = value validator_defs.append((low, name, high)) @@ -45,12 +49,12 @@ def f(cs): return validator_values, validator_defs -def check_statement(problem, language): +def check_statement(problem: Problem, language: str) -> tuple[set[int | float], list[str]]: statement_file = problem.path / latex.PdfType.PROBLEM.path(language) statement = statement_file.read_text() - statement_values = set() - statement_defs = [] + statement_values: set[int | float] = set() + statement_defs: list[str] = [] defines = ["\\def", "\\newcommand"] sections = ["Input", "Output", "Interaction"] @@ -67,15 +71,16 @@ def check_statement(problem, language): } relations = re.compile(r"(<=|!=|>=|<|=|>)") - def math_eval(text): + def math_eval(text: str) -> Optional[int | float]: try: # eval is dangerous, but on the other hand we run submission code so this is fine text = text.replace("^", "**") - return eval(text, {"__builtin__": None}) + value = eval(text, {"__builtin__": None}) + return value if value is isinstance(value, (int, float)) else None except (SyntaxError, NameError, TypeError, ZeroDivisionError): return None - def constraint(text): + def constraint(text: str) -> None: # handles $$math$$ if len(text) == 0: return @@ -132,13 +137,13 @@ def constraint(text): in_io = False end = None - def matches(text): + def matches(text: str) -> bool: nonlocal pos if pos + len(text) > len(statement): return False return statement[pos : pos + len(text)] == text - def parse_group(): + def parse_group() -> str: nonlocal pos assert statement[pos] == "{" next = pos + 1 @@ -155,7 +160,7 @@ def parse_group(): pos = next return name - def parse_command(): + def parse_command() -> str: nonlocal pos assert statement[pos] == "\\" next = pos + 1 @@ -251,16 +256,16 @@ def parse_command(): return statement_values, statement_defs -def check_constraints(problem): +def check_constraints(problem: Problem) -> bool: validator_values, validator_defs = check_validators(problem) - statement_values = defaultdict(set) - statement_defs = defaultdict(set) + statement_values: dict[int | float, set[str]] = defaultdict(set) + statement_defs: dict[str, set[str]] = defaultdict(set) for lang in problem.statement_languages: values, defs = check_statement(problem, lang) - for entry in values: - statement_values[entry].add(lang) - for entry in defs: - statement_defs[entry].add(lang) + for value_entry in values: + statement_values[value_entry].add(lang) + for def_entry in defs: + statement_defs[def_entry].add(lang) # print all the definitions. value_len = 12 diff --git a/bin/export.py b/bin/export.py index 8c6b9d17e..28e49e613 100644 --- a/bin/export.py +++ b/bin/export.py @@ -1,12 +1,13 @@ +import config import datetime +import re +import shutil import sys +import util import yaml -import re import zipfile -import config -import util from pathlib import Path -from typing import Optional +from typing import Any, Optional from contest import * from latex import PdfType diff --git a/bin/fuzz.py b/bin/fuzz.py index 6faf18a7c..8b7c8edd8 100644 --- a/bin/fuzz.py +++ b/bin/fuzz.py @@ -3,8 +3,10 @@ import run import random import generate +import signal import time import threading +from pathlib import Path import parallel from util import * diff --git a/bin/generate.py b/bin/generate.py index 15c3462c6..321774bb9 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -1,13 +1,15 @@ +import collections import random import re -import shutil -import collections import secrets +import shutil +import sys +import time from collections.abc import Callable, Sequence from colorama import Fore, Style from pathlib import Path, PurePosixPath -from typing import Final, Optional, overload +from typing import Any, Final, Optional, overload import config import parallel diff --git a/bin/interactive.py b/bin/interactive.py index 1180a1c2c..8936afb1e 100644 --- a/bin/interactive.py +++ b/bin/interactive.py @@ -1,13 +1,15 @@ +import os import signal -import time import subprocess import sys import threading +import time +from pathlib import Path from typing import Final, Literal, Optional, TYPE_CHECKING import config -from util import * import validate +from util import * from verdicts import Verdict if TYPE_CHECKING: diff --git a/bin/problem.py b/bin/problem.py index 4c3006493..ac00579e7 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -1,5 +1,6 @@ import datetime import re +import shutil import sys import threading diff --git a/bin/program.py b/bin/program.py index db79b11e4..fd38bd3d8 100644 --- a/bin/program.py +++ b/bin/program.py @@ -3,10 +3,11 @@ import stat import subprocess import threading -from typing import Final, TYPE_CHECKING - from colorama import Fore +from pathlib import Path +from typing import Final, Optional, TYPE_CHECKING +import config from util import * if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 diff --git a/bin/skel.py b/bin/skel.py index 6201ca536..358a00f50 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -1,6 +1,8 @@ -import shutil +import os import datetime import re +import shutil +from pathlib import Path # Local imports import config diff --git a/bin/slack.py b/bin/slack.py index cda86e11e..0f16426da 100644 --- a/bin/slack.py +++ b/bin/slack.py @@ -1,3 +1,4 @@ +import config from util import * # Perform slack actions for the selected problems (all, or the selected/current one). diff --git a/bin/tools.py b/bin/tools.py index 7d4eb879e..b6d11ddba 100755 --- a/bin/tools.py +++ b/bin/tools.py @@ -24,8 +24,9 @@ import colorama import re +from colorama import Style from pathlib import Path -from typing import Literal, cast +from typing import cast, Literal, Optional # Local imports import config diff --git a/bin/upgrade.py b/bin/upgrade.py index 0dd2d969b..e9b6bff37 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -5,7 +5,8 @@ import secrets import shutil -from typing import Any +from pathlib import Path +from typing import Any, cast if has_ryaml: # TODO #102 The conditional import in util.py isn't picked up properly @@ -65,8 +66,7 @@ def upgrade_testdata_yaml(problem_path: Path, bar: ProgressBar) -> None: ] for f in (problem_path / "data").rglob("testdata.yaml"): - data = read_yaml(f) - assert data is not None + data = cast(CommentedMap, read_yaml(f)) for old, new in rename: if old in data: @@ -92,8 +92,7 @@ def upgrade_generators_yaml(problem_path: Path, bar: ProgressBar) -> None: changed = False if "data" in yaml_data and isinstance(yaml_data["data"], dict): - data = yaml_data["data"] - assert isinstance(data, CommentedMap) + data = cast(CommentedMap, yaml_data["data"]) rename = [ ("invalid_inputs", "invalid_input"), @@ -150,8 +149,7 @@ def move_testcase(name: str, value: Any, new_parent: str) -> None: def upgrade_generated_testdata_yaml(data: dict[str, Any], path: str) -> bool: changed = False if "testdata.yaml" in data: - testdata = data["testdata.yaml"] - assert isinstance(testdata, dict) + testdata = cast(CommentedMap, data["testdata.yaml"]) print_path = f" ({path[1:]})" if len(path) > 1 else "" rename = [ @@ -279,8 +277,6 @@ def move(src: str, dst: str) -> None: def upgrade_problem_yaml(problem_path: Path, bar: ProgressBar) -> None: assert (problem_path / "problem.yaml").exists() data = cast(CommentedMap, read_yaml(problem_path / "problem.yaml")) - assert data is not None - assert isinstance(data, dict) if ( "problem_format_version" not in data @@ -384,9 +380,7 @@ def add_args(new_data: dict[str, Any]) -> bool: if data["validator_flags"]: generators_path = problem_path / "generators" / "generators.yaml" if generators_path.exists(): - generators_data = read_yaml(generators_path) - assert generators_data is not None - assert isinstance(generators_data, CommentedMap) + generators_data = cast(CommentedMap, read_yaml(generators_path)) if "testdata.yaml" not in generators_data: if "data" in generators_data: @@ -401,10 +395,10 @@ def add_args(new_data: dict[str, Any]) -> bool: else: testdata_path = problem_path / "data" / "testdata.yaml" testdata_data = ( - read_yaml(testdata_path) if testdata_path.exists() else CommentedMap() + cast(CommentedMap, read_yaml(testdata_path)) + if testdata_path.exists() + else CommentedMap() ) - assert testdata_data is not None - assert isinstance(testdata_data, dict) if add_args(testdata_data): write_yaml(testdata_data, testdata_path) @@ -470,7 +464,7 @@ def upgrade() -> None: return cwd = Path().cwd() - def is_problem_directory(path): + def is_problem_directory(path: Path) -> bool: return (path / "problem.yaml").is_file() if is_problem_directory(cwd): diff --git a/bin/util.py b/bin/util.py index 45b0e6dce..c643ff6d3 100644 --- a/bin/util.py +++ b/bin/util.py @@ -18,7 +18,6 @@ from pathlib import Path from typing import ( Any, - cast, Iterable, Literal, NoReturn, @@ -814,7 +813,7 @@ def parse_optional_setting(yaml_data: dict[str, Any], key: str, t: type[T]) -> O if isinstance(value, int) and t is float: value = float(value) if isinstance(value, t): - return cast(T, value) + return value if value == "" and (t is list or t is dict): # handle empty yaml keys return t() @@ -971,7 +970,7 @@ def substitute( if variables is None: variables = {} - def substitute_function(match): + def substitute_function(match: re.Match[str]) -> str: name = match.group(1) if name in variables: return str(variables[name]) if variables[name] is not None else "" diff --git a/bin/validate.py b/bin/validate.py index 90ba120b5..0eda6c1f7 100644 --- a/bin/validate.py +++ b/bin/validate.py @@ -2,8 +2,10 @@ from util import * from enum import Enum from collections.abc import Sequence -from typing import Final, TYPE_CHECKING +from pathlib import Path +from typing import Final, Optional, TYPE_CHECKING +import config import program import testcase From 6da3bc566c267baaa2a95b5012e241ce9f2da673 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Mon, 31 Mar 2025 22:07:41 +0200 Subject: [PATCH 15/23] more typing --- bin/constraints.py | 2 +- bin/contest.py | 17 ++++++------- bin/fuzz.py | 46 +++++++++++++++++++++-------------- bin/parallel.py | 36 ++++++++++++++-------------- bin/problem.py | 4 ++-- bin/skel.py | 60 +++++++++++++++++++++++++++------------------- 6 files changed, 94 insertions(+), 71 deletions(-) diff --git a/bin/constraints.py b/bin/constraints.py index f2f33699a..731453584 100644 --- a/bin/constraints.py +++ b/bin/constraints.py @@ -76,7 +76,7 @@ def math_eval(text: str) -> Optional[int | float]: # eval is dangerous, but on the other hand we run submission code so this is fine text = text.replace("^", "**") value = eval(text, {"__builtin__": None}) - return value if value is isinstance(value, (int, float)) else None + return value if isinstance(value, (int, float)) else None except (SyntaxError, NameError, TypeError, ZeroDivisionError): return None diff --git a/bin/contest.py b/bin/contest.py index 0e07024f7..3ae856801 100644 --- a/bin/contest.py +++ b/bin/contest.py @@ -1,14 +1,15 @@ import config from pathlib import Path +from typing import cast, Any, Optional from util import * # Read the contest.yaml, if available -_contest_yaml = None +_contest_yaml: Optional[dict[str, Any]] = None -def contest_yaml(): +def contest_yaml() -> dict[str, Any]: global _contest_yaml if _contest_yaml is not None: return _contest_yaml @@ -25,22 +26,22 @@ def contest_yaml(): _problems_yaml = None -def problems_yaml(): +def problems_yaml() -> Optional[list[dict[str, Any]]]: global _problems_yaml - if _problems_yaml: - return _problems_yaml if _problems_yaml is False: return None + if _problems_yaml: + return _problems_yaml problemsyaml_path = Path("problems.yaml") if not problemsyaml_path.is_file(): _problems_yaml = False return None _problems_yaml = read_yaml(problemsyaml_path) - return _problems_yaml + return cast(list[dict[str, Any]], _problems_yaml) -def get_api(): +def get_api() -> str: api = config.args.api or contest_yaml().get("api") if not api: fatal( @@ -105,7 +106,7 @@ def call_api(method, endpoint, **kwargs): return r -def call_api_get_json(url): +def call_api_get_json(url: str): r = call_api("GET", url) r.raise_for_status() try: diff --git a/bin/fuzz.py b/bin/fuzz.py index 8b7c8edd8..fb5841a2b 100644 --- a/bin/fuzz.py +++ b/bin/fuzz.py @@ -7,6 +7,7 @@ import time import threading from pathlib import Path +from typing import Any, Optional import parallel from util import * @@ -24,9 +25,11 @@ class GeneratorTask: - def __init__(self, fuzz: "Fuzz", t, i, tmp_id): + def __init__(self, fuzz: "Fuzz", t: generate.TestcaseRule, i: int, tmp_id: int): self.fuzz = fuzz - self.generator = t.generator + generator = t.generator + assert generator is not None + self.generator = generator self.solution = t.config.solution self.i = i self.tmp_id = tmp_id @@ -39,13 +42,13 @@ def __init__(self, fuzz: "Fuzz", t, i, tmp_id): self.save_mutex = threading.Lock() self.saved = False - def run(self, bar): + def run(self, bar: ProgressBar) -> None: if self._run(bar): self.fuzz.finish_task(self.tmp_id) else: self.fuzz.finish_task(self.tmp_id, 1 + len(self.fuzz.submissions)) - def _run(self, bar): + def _run(self, bar: ProgressBar) -> bool: # GENERATE THE TEST DATA dir = Path("fuzz") / f"tmp_id_{str(self.tmp_id)}" cwd = self.fuzz.problem.tmpdir / "tool_runs" / dir @@ -104,7 +107,7 @@ def _run(self, bar): self.fuzz.queue.put(SubmissionTask(self, submission, testcase, self.tmp_id)) return True - def save_test(self, bar): + def save_test(self, bar: ProgressBar) -> None: if self.saved: return save = False @@ -122,17 +125,23 @@ def save_test(self, bar): class SubmissionTask: - def __init__(self, generator_task, submission, testcase, tmp_id): + def __init__( + self, + generator_task: GeneratorTask, + submission: run.Submission, + testcase: Testcase, + tmp_id: int, + ): self.generator_task = generator_task self.submission = submission self.testcase = testcase self.tmp_id = tmp_id - def run(self, bar): + def run(self, bar: ProgressBar) -> None: self._run(bar) self.generator_task.fuzz.finish_task(self.tmp_id) - def _run(self, bar): + def _run(self, bar: ProgressBar) -> None: r = run.Run(self.generator_task.fuzz.problem, self.submission, self.testcase) localbar = bar.start(f"{self.generator_task.i}: {self.submission.name}") result = r.run(localbar) @@ -155,10 +164,11 @@ def __init__(self, problem: problem.Problem): # Filter to only keep valid rules depending on seed without duplicates from count added_testcase_rules = set() - def add_testcase(t): + def add_testcase(t: generate.TestcaseRule) -> None: if ( t.in_is_generated and t.parse_error is None + and t.generator is not None and t.generator.uses_seed and t.generator.command_string.strip() not in added_testcase_rules ): @@ -177,7 +187,7 @@ def add_testcase(t): # SUBMISSIONS self.submissions = self.problem.selected_or_accepted_submissions() - def run(self): + def run(self) -> bool: if not has_ryaml: error("Fuzzing needs the ruamel.yaml python3 library. Install python[3]-ruamel.yaml.") return False @@ -192,7 +202,7 @@ def run(self): message("Press CTRL+C to stop\n", "Fuzz", color_type=MessageType.LOG) - def runner(task: GeneratorTask): + def runner(task: GeneratorTask | SubmissionTask) -> None: task.run(bar) # config.args.no_bar = True @@ -203,7 +213,7 @@ def runner(task: GeneratorTask): self.tasks = 0 self.queue = parallel.new_queue(runner, pin=True) - def soft_exit(sig, frame): + def soft_exit(sig: Any, frame: Any) -> None: if self.queue.aborted: fatal("Running interrupted", force=True) else: @@ -240,7 +250,7 @@ def soft_exit(sig, frame): # finish task from generator with tmp_id # also add new tasks if queue becomes too empty - def finish_task(self, tmp_id=None, count=1): + def finish_task(self, tmp_id: Optional[int] = None, count: int = 1) -> None: with self.queue: # return tmp_id (and reuse it if all submissions are finished) if tmp_id is not None: @@ -259,18 +269,18 @@ def finish_task(self, tmp_id=None, count=1): self.iteration += 1 # 1 new generator tasks which will also create one task per submission new_tasks = 1 + len(self.submissions) - tmp_id = min(self.free_tmp_id) - self.free_tmp_id.remove(tmp_id) - self.tmp_id_count[tmp_id] = new_tasks + new_tmp_id = min(self.free_tmp_id) + self.free_tmp_id.remove(new_tmp_id) + self.tmp_id_count[new_tmp_id] = new_tasks self.tasks += new_tasks self.queue.put( - GeneratorTask(self, testcase_rule, self.iteration, tmp_id), + GeneratorTask(self, testcase_rule, self.iteration, new_tmp_id), priority=1, ) # Write new rule to yaml # lock between read and write to ensure that no rule gets lost - def save_test(self, command): + def save_test(self, command: str) -> None: with self.generators_yaml_mutex: generators_yaml = self.problem.path / "generators/generators.yaml" data = None diff --git a/bin/parallel.py b/bin/parallel.py index 722045771..a8ea1da9e 100644 --- a/bin/parallel.py +++ b/bin/parallel.py @@ -19,7 +19,7 @@ def __init__(self, task: T, priority: int, index: int): self.index = index # Note: heapq uses a min heap, so higher priorities are 'smaller'. - def __lt__(self, other): + def __lt__(self, other: "QueueItem[T]") -> bool: if self.priority != other.priority: # python priority queue is a min heap but larger priority # items should come first => reverse compare @@ -45,24 +45,24 @@ def __init__(self, f: Callable[[T], Any], pin: bool): # mutex to lock parallel access self.mutex = threading.RLock() - def __enter__(self): + def __enter__(self) -> None: self.mutex.__enter__() - def __exit__(self, *args): + def __exit__(self, *args: Any) -> None: self.mutex.__exit__(*args) # Add one task. Higher priority => done first - def put(self, task: T, priority=0): + def put(self, task: T, priority: int = 0) -> None: raise Exception("Abstract method") # By default, do nothing on .join(). This is overridden in ParallelQueue. - def join(self): + def join(self) -> None: return - def done(self): + def done(self) -> None: raise Exception("Abstract method") - def abort(self): + def abort(self) -> None: self.aborted = True @@ -71,7 +71,7 @@ def __init__(self, f: Callable[[T], Any], pin: bool): super().__init__(f, pin) # Add one task. Higher priority => done first - def put(self, task: T, priority: int = 0): + def put(self, task: T, priority: int = 0) -> None: # no task will be handled after self.abort() so skip adding if self.aborted: return @@ -80,7 +80,7 @@ def put(self, task: T, priority: int = 0): heapq.heappush(self.tasks, QueueItem(task, priority, self.total_tasks)) # Execute all tasks. - def done(self): + def done(self) -> None: if self.pin: cores = list(os.sched_getaffinity(0)) os.sched_setaffinity(0, {cores[0]}) @@ -127,7 +127,7 @@ def __init__(self, f: Callable[[T], Any], pin: bool, num_threads: int): signal.signal(signal.SIGINT, self._interrupt_handler) - def _worker(self, cores: Literal[False] | list[int] = False): + def _worker(self, cores: Literal[False] | list[int] = False) -> None: if cores is not False: os.sched_setaffinity(0, cores) while True: @@ -164,10 +164,10 @@ def _worker(self, cores: Literal[False] | list[int] = False): if self.missing == 0: self.all_done.notify_all() - def _interrupt_handler(self, sig, frame): + def _interrupt_handler(self, sig: Any, frame: Any) -> None: util.fatal("Running interrupted", force=True) - def _handle_first_error(self): + def _handle_first_error(self) -> None: if self.first_error is not None: first_error = self.first_error self.first_error = None @@ -177,7 +177,7 @@ def _handle_first_error(self): raise first_error # Add one task. Higher priority => done first - def put(self, task: T, priority: int = 0): + def put(self, task: T, priority: int = 0) -> None: with self.mutex: # no task should be added after .done() was called assert not self.finish @@ -189,14 +189,14 @@ def put(self, task: T, priority: int = 0): heapq.heappush(self.tasks, QueueItem(task, priority, self.total_tasks)) self.todo.notify() - def join(self): + def join(self) -> None: # wait for all current task to be completed with self.all_done: self.all_done.wait_for(lambda: self.missing == 0) self._handle_first_error() # Wait for all tasks to be done and stop all threads - def done(self): + def done(self) -> None: self.finish = True # notify all workers with permission to leave main loop @@ -213,7 +213,7 @@ def done(self): # Discard all remaining work in the queue and stop all workers. # Call done() to join the threads. - def abort(self): + def abort(self) -> None: super().abort() with self.mutex: @@ -227,7 +227,7 @@ def abort(self): self.all_done.notify_all() -def new_queue(f: Callable[[T], Any], pin: bool = False): +def new_queue(f: Callable[[T], Any], pin: bool = False) -> AbstractQueue[T]: """ f(task): the function to run on each queue item. @@ -242,7 +242,7 @@ def new_queue(f: Callable[[T], Any], pin: bool = False): return SequentialQueue(f, pin) -def run_tasks(f: Callable[[T], Any], tasks: Sequence[T], pin: bool = False): +def run_tasks(f: Callable[[T], Any], tasks: Sequence[T], pin: bool = False) -> None: queue = new_queue(f, pin) for task in tasks: queue.put(task) diff --git a/bin/problem.py b/bin/problem.py index ac00579e7..0c7761f75 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -769,7 +769,7 @@ def download_samples(p) -> list[tuple[Path, Path]]: return [t for t in testcases if isinstance(t, tuple)] # Returns the list of submissions passed as command-line arguments, or the list of accepted submissions by default. - def selected_or_accepted_submissions(problem) -> list["run.Submission"]: + def selected_or_accepted_submissions(problem) -> list[run.Submission]: submissions = problem.submissions() if not submissions: return [] @@ -778,7 +778,7 @@ def selected_or_accepted_submissions(problem) -> list["run.Submission"]: else: return [s for s in submissions if s.expected_verdicts == [verdicts.Verdict.ACCEPTED]] - def submissions(problem) -> list["run.Submission"] | Literal[False]: + def submissions(problem) -> list[run.Submission] | Literal[False]: if problem._submissions is not None: if problem._submissions is False: return False diff --git a/bin/skel.py b/bin/skel.py index 358a00f50..7da01fbb6 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -2,7 +2,9 @@ import datetime import re import shutil +from collections.abc import Sequence from pathlib import Path +from typing import cast, Optional # Local imports import config @@ -14,12 +16,13 @@ try: import questionary + from prompt_toolkit.document import Document from questionary import Validator, ValidationError has_questionary = True class EmptyValidator(Validator): - def validate(self, document): + def validate(self, document: Document) -> None: if len(document.text) == 0: raise ValidationError(message="Please enter a value") @@ -27,25 +30,28 @@ def validate(self, document): has_questionary = False -def _ask_variable(name, default=None, allow_empty=False): +def _ask_variable(name: str, default: Optional[str] = None, allow_empty: bool = False) -> str: if config.args.defaults: if not default and not allow_empty: fatal(f"{name} has no default") - return default + return default or "" while True: val = input(f"{name}: ") - val = default if val == "" else val + val = val or default or "" if val != "" or allow_empty: return val -def _ask_variable_string(name, default=None, allow_empty=False): +def _ask_variable_string( + name: str, default: Optional[str] = None, allow_empty: bool = False +) -> str: if has_questionary: try: validate = None if allow_empty else EmptyValidator - return questionary.text( - name + ":", default=default or "", validate=validate - ).unsafe_ask() + return cast( + str, + questionary.text(name + ":", default=default or "", validate=validate).unsafe_ask(), + ) except KeyboardInterrupt: fatal("Running interrupted") else: @@ -53,10 +59,13 @@ def _ask_variable_string(name, default=None, allow_empty=False): return _ask_variable(name + text, default if default else "", allow_empty) -def _ask_variable_bool(name, default=True): +def _ask_variable_bool(name: str, default: bool = True) -> bool: if has_questionary: try: - return questionary.confirm(name + "?", default=default, auto_enter=False).unsafe_ask() + return cast( + bool, + questionary.confirm(name + "?", default=default, auto_enter=False).unsafe_ask(), + ) except KeyboardInterrupt: fatal("Running interrupted") else: @@ -64,13 +73,16 @@ def _ask_variable_bool(name, default=True): return _ask_variable(name + text, "Y" if default else "N").lower()[0] == "y" -def _ask_variable_choice(name, choices, default=None): +def _ask_variable_choice(name: str, choices: Sequence[str], default: Optional[str] = None) -> str: if has_questionary: try: plain = questionary.Style([("selected", "noreverse")]) - return questionary.select( - name + ":", choices=choices, default=default, style=plain - ).unsafe_ask() + return cast( + str, + questionary.select( + name + ":", choices=choices, default=default, style=plain + ).unsafe_ask(), + ) except KeyboardInterrupt: fatal("Running interrupted") else: @@ -87,7 +99,7 @@ def _ask_variable_choice(name, choices, default=None): # Returns the alphanumeric version of a string: # This reduces it to a string that follows the regex: # [a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9] -def _alpha_num(string): +def _alpha_num(string: str) -> str: s = re.sub(r"[^a-zA-Z0-9_.-]", "", string.lower().replace(" ", "").replace("-", "")) while len(s) and s[0] in "_.-": s = s[1:] @@ -96,7 +108,7 @@ def _alpha_num(string): return s -def new_contest(): +def new_contest() -> None: if config.args.contest: fatal("--contest does not work for new_contest.") if config.args.problem: @@ -124,7 +136,7 @@ def new_contest(): ) -def get_skel_dir(target_dir): +def get_skel_dir(target_dir: Path) -> tuple[Path, bool]: skeldir = config.TOOLS_ROOT / "skel/problem" preserve_symlinks = False if (target_dir / "skel/problem").is_dir(): @@ -139,7 +151,7 @@ def get_skel_dir(target_dir): return (skeldir, preserve_symlinks) -def new_problem(): +def new_problem() -> None: target_dir = Path(".") if config.args.contest: os.chdir(Path(config.args.contest)) @@ -269,7 +281,7 @@ def new_problem(): ) -def rename_problem(problem): +def rename_problem(problem: Problem) -> None: if not has_ryaml: fatal("ruamel.yaml library not found.") @@ -304,7 +316,7 @@ def rename_problem(problem): write_yaml(data, problems_yaml) -def copy_skel_dir(problems): +def copy_skel_dir(problems: list[Problem]) -> None: assert len(problems) == 1 problem = problems[0] @@ -336,10 +348,10 @@ def copy_skel_dir(problems): # NOTE: This is one of few places that prints to stdout instead of stderr. -def create_gitlab_jobs(contest: str, problems: list[Problem]): +def create_gitlab_jobs(contest: str, problems: list[Problem]) -> None: git_root_path = Path(os.popen("git rev-parse --show-toplevel").read().strip()).resolve() - def problem_source_dir(problem: Problem): + def problem_source_dir(problem: Problem) -> Path: return problem.path.resolve().relative_to(git_root_path) if config.args.latest_bt: @@ -372,7 +384,7 @@ def problem_source_dir(problem: Problem): ) -def create_forgejo_actions(contest: str, problems: list[Problem]): +def create_forgejo_actions(contest: str, problems: list[Problem]) -> None: if Path(".git").is_dir(): contest_path = Path(".") forgejo = Path(".forgejo") @@ -418,7 +430,7 @@ def create_forgejo_actions(contest: str, problems: list[Problem]): # Differences with forgejo: # - flat structure, with all workflows directly in `.github/workflows`. -def create_github_actions(contest: str, problems: list[Problem]): +def create_github_actions(contest: str, problems: list[Problem]) -> None: if config.args.latest_bt: fatal("Caching the latest BAPCtools is not supported for github actions.") From 467e2bd5ee911e23895fe78525a504841110eb28 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Mon, 31 Mar 2025 23:02:04 +0200 Subject: [PATCH 16/23] use config.SPEC_VERSION --- bin/skel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/skel.py b/bin/skel.py index 7da01fbb6..1eeac8716 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -233,9 +233,9 @@ def new_problem() -> None: skeldir, preserve_symlinks = get_skel_dir(target_dir) log(f"Copying {skeldir} to {target_dir / dirname}.") - if "2023-07-draft" not in (skeldir / "problem.yaml").read_text(): + if config.SPEC_VERSION not in (skeldir / "problem.yaml").read_text(): fatal( - "new_problem only supports `skel` directories where `problem.yaml` has `version: 2023-07-draft." + f"new_problem only supports `skel` directories where `problem.yaml` has `version: {config.SPEC_VERSION}." ) problems_yaml = target_dir / "problems.yaml" From a6eff1bed7a6e66155ec460abe56d6c1579fed97 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Tue, 1 Apr 2025 00:00:29 +0200 Subject: [PATCH 17/23] no need to select language --- bin/skel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/skel.py b/bin/skel.py index 1eeac8716..1ac7a942b 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -254,7 +254,7 @@ def new_problem() -> None: { "id": dirname, "label": next_label, - "name": problemname[main_language], + "name": problemname, "rgb": "#000000", "time_limit": 1.0, } From f4e23e2fe3aa973cde453830156ec63e2a363e32 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Wed, 2 Apr 2025 12:03:13 +0200 Subject: [PATCH 18/23] Order in contest yaml (#446) * add order key to contest.yaml --- bin/contest.py | 9 ++-- bin/export.py | 17 ++----- bin/skel.py | 121 ++++++++----------------------------------------- bin/tools.py | 25 +++++++--- bin/util.py | 80 ++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 126 deletions(-) diff --git a/bin/contest.py b/bin/contest.py index 3ae856801..db00d2884 100644 --- a/bin/contest.py +++ b/bin/contest.py @@ -14,11 +14,10 @@ def contest_yaml() -> dict[str, Any]: if _contest_yaml is not None: return _contest_yaml - # TODO: Do we need both here? - for p in [Path("contest.yaml"), Path("../contest.yaml")]: - if p.is_file(): - _contest_yaml = read_yaml_settings(p) - return _contest_yaml + contest_yaml_path = Path("contest.yaml") + if contest_yaml_path.is_file(): + _contest_yaml = read_yaml_settings(contest_yaml_path) + return _contest_yaml _contest_yaml = {} return _contest_yaml diff --git a/bin/export.py b/bin/export.py index 28e49e613..a336ed2b1 100644 --- a/bin/export.py +++ b/bin/export.py @@ -476,9 +476,7 @@ def export_contest(cid: Optional[str]) -> str: new_cid = yaml.load(r.text, Loader=yaml.SafeLoader) log(f"Uploaded the contest to contest_id {new_cid}.") if new_cid != cid: - log("Update contest_id in contest.yaml automatically? [Y/n]") - a = input().lower() - if a == "" or a[0] == "y": + if ask_variable_bool("Update contest_id in contest.yaml automatically"): update_contest_id(new_cid) log(f"Updated contest_id to {new_cid}") @@ -558,12 +556,9 @@ def update_problems_yaml(problems: list[Problem], colors: Optional[list[str]] = label = inc_label(label) if change: - if config.args.action in ["update_problems_yaml"]: - a = "y" - else: - log("Update problems.yaml with latest values? [Y/n]") - a = input().lower() - if a == "" or a[0] == "y": + if config.args.action in ["update_problems_yaml"] or ask_variable_bool( + "Update problems.yaml with latest values" + ): write_yaml(data, path) log("Updated problems.yaml") else: @@ -686,7 +681,5 @@ def check_if_user_has_team() -> None: if not any(user["username"] == config.args.username and user["team"] for user in users): warn(f'User "{config.args.username}" is not associated with a team.') warn("Therefore, the jury submissions will not be run by the judgehosts.") - log("Continue export to DOMjudge? [N/y]") - a = input().lower() - if not a or a[0] != "y": + if ask_variable_bool("Continue export to DOMjudge", False): fatal("Aborted.") diff --git a/bin/skel.py b/bin/skel.py index 1ac7a942b..4427a69c8 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -2,9 +2,7 @@ import datetime import re import shutil -from collections.abc import Sequence from pathlib import Path -from typing import cast, Optional # Local imports import config @@ -14,87 +12,6 @@ from util import * from validate import OutputValidator -try: - import questionary - from prompt_toolkit.document import Document - from questionary import Validator, ValidationError - - has_questionary = True - - class EmptyValidator(Validator): - def validate(self, document: Document) -> None: - if len(document.text) == 0: - raise ValidationError(message="Please enter a value") - -except Exception: - has_questionary = False - - -def _ask_variable(name: str, default: Optional[str] = None, allow_empty: bool = False) -> str: - if config.args.defaults: - if not default and not allow_empty: - fatal(f"{name} has no default") - return default or "" - while True: - val = input(f"{name}: ") - val = val or default or "" - if val != "" or allow_empty: - return val - - -def _ask_variable_string( - name: str, default: Optional[str] = None, allow_empty: bool = False -) -> str: - if has_questionary: - try: - validate = None if allow_empty else EmptyValidator - return cast( - str, - questionary.text(name + ":", default=default or "", validate=validate).unsafe_ask(), - ) - except KeyboardInterrupt: - fatal("Running interrupted") - else: - text = f" ({default})" if default else "" - return _ask_variable(name + text, default if default else "", allow_empty) - - -def _ask_variable_bool(name: str, default: bool = True) -> bool: - if has_questionary: - try: - return cast( - bool, - questionary.confirm(name + "?", default=default, auto_enter=False).unsafe_ask(), - ) - except KeyboardInterrupt: - fatal("Running interrupted") - else: - text = " (Y/n)" if default else " (y/N)" - return _ask_variable(name + text, "Y" if default else "N").lower()[0] == "y" - - -def _ask_variable_choice(name: str, choices: Sequence[str], default: Optional[str] = None) -> str: - if has_questionary: - try: - plain = questionary.Style([("selected", "noreverse")]) - return cast( - str, - questionary.select( - name + ":", choices=choices, default=default, style=plain - ).unsafe_ask(), - ) - except KeyboardInterrupt: - fatal("Running interrupted") - else: - default = default or choices[0] - text = f" ({default})" if default else "" - while True: - got = _ask_variable(name + text, default if default else "") - if got in choices: - return got - else: - warn(f"unknown option: {got}") - # Returns the alphanumeric version of a string: # This reduces it to a string that follows the regex: @@ -115,15 +32,15 @@ def new_contest() -> None: fatal("--problem does not work for new_contest.") # Ask for all required infos. - title = _ask_variable_string("name", config.args.contestname) - subtitle = _ask_variable_string("subtitle", "", True).replace("_", "-") - dirname = _ask_variable_string("dirname", _alpha_num(title)) - author = _ask_variable_string("author", f"The {title} Jury").replace("_", "-") - testsession = _ask_variable_bool("testsession", False) - year = _ask_variable_string("year", str(datetime.datetime.now().year)) - source_url = _ask_variable_string("source url", "", True) - license = _ask_variable_choice("license", config.KNOWN_LICENSES) - rights_owner = _ask_variable_string( + title = ask_variable_string("name", config.args.contestname) + subtitle = ask_variable_string("subtitle", "", True).replace("_", "-") + dirname = ask_variable_string("dirname", _alpha_num(title)) + author = ask_variable_string("author", f"The {title} Jury").replace("_", "-") + testsession = ask_variable_bool("testsession", False) + year = ask_variable_string("year", str(datetime.datetime.now().year)) + source_url = ask_variable_string("source url", "", True) + license = ask_variable_choice("license", config.KNOWN_LICENSES) + rights_owner = ask_variable_string( "rights owner (if left empty, defaults to problem author)", "", allow_empty=True ) rights_owner = f"rights_owner: {rights_owner}\n" if rights_owner else "" @@ -165,23 +82,23 @@ def new_problem() -> None: lang: ( config.args.problemname if config.args.problemname - else _ask_variable_string(f"problem name ({lang})") + else ask_variable_string(f"problem name ({lang})") ) for lang in statement_languages } dirname = ( _alpha_num(config.args.problemname) if config.args.problemname - else _ask_variable_string("dirname", _alpha_num(problemname[main_language])) + else ask_variable_string("dirname", _alpha_num(problemname[main_language])) ) - author = config.args.author if config.args.author else _ask_variable_string("author") + author = config.args.author if config.args.author else ask_variable_string("author") output_validator_args = "#output_validator_args:" custom_output = False if config.args.type: problem_type = config.args.type else: - problem_type = _ask_variable_choice( + problem_type = ask_variable_choice( "type", ["pass-fail", "float", "custom", "interactive", "multi-pass", "interactive multi-pass"], ) @@ -208,18 +125,18 @@ def new_problem() -> None: "testdata_yaml_comment": "#" if output_validator_args[0] == "#" else "", } - source_name = _ask_variable_string( + source_name = ask_variable_string( "source", variables.get("source", variables.get("name", "")), True ) - source_url = _ask_variable_string("source url", variables.get("source_url", ""), True) + source_url = ask_variable_string("source url", variables.get("source_url", ""), True) variables["source"] = ( f"source:\n name: {source_name}\n{f' url: {source_url}' if source_url else ' #url:'}" ) - variables["license"] = _ask_variable_choice( + variables["license"] = ask_variable_choice( "license", config.KNOWN_LICENSES, variables.get("license", None) ) - variables["rights_owner"] = _ask_variable_string( + variables["rights_owner"] = ask_variable_string( f"rights owner{'' if variables.get('rights_owner', '') else ' (if left empty, defaults to problem author)'}", variables.get("rights_owner", ""), allow_empty=True, @@ -289,14 +206,14 @@ def rename_problem(problem: Problem) -> None: lang: ( config.args.problemname if config.args.problemname - else _ask_variable_string(f"New problem name ({lang})", problem.settings.name[lang]) + else ask_variable_string(f"New problem name ({lang})", problem.settings.name[lang]) ) for lang in problem.statement_languages } dirname = ( _alpha_num(config.args.problemname) if config.args.problemname - else _ask_variable_string("dirname", _alpha_num(newname[problem.statement_languages[0]])) + else ask_variable_string("dirname", _alpha_num(newname[problem.statement_languages[0]])) ) shutil.move(problem.name, dirname) diff --git a/bin/tools.py b/bin/tools.py index b6d11ddba..1ddd00550 100755 --- a/bin/tools.py +++ b/bin/tools.py @@ -31,6 +31,7 @@ # Local imports import config import constraints +import contest import export import generate import fuzz @@ -45,7 +46,6 @@ import signal from problem import Problem -import contest from contest import * from util import * @@ -179,13 +179,15 @@ def fallback_problems(): if len(problems) == 0: fatal("Did not find problem.yaml. Are you running this from a problem directory?") - if config.args.order: + if config.args.order or contest_yaml().get("order"): + order = config.args.order or contest_yaml()["order"] + # Sort by position of id in order def get_pos(id): - if id in config.args.order: - return config.args.order.index(id) + if id in order: + return order.index(id) else: - return len(config.args.order) + 1 + return len(order) problems.sort(key=lambda p: (get_pos(p.label), p.label)) @@ -216,8 +218,17 @@ def get_pos(id): # Sort the problems # Use negative solves instead of reversed, to preserver stable order. problems.sort(key=lambda p: (-solves[p.name], p.label)) - order = ", ".join(map(lambda p: str(p.label), problems)) - verbose("order: " + order) + verbose(f"order: {', '.join(map(lambda p: str(p.label), problems))}") + + if ask_variable_bool("Update order in contest.yaml"): + if has_ryaml: + contest_yaml_path = Path("contest.yaml") + data = contest_yaml() + data["order"] = [p.label for p in problems] + write_yaml(data, contest_yaml_path) + log("Updated order") + else: + error("ruamel.yaml library not found. Update the order manually.") contest_name = Path().cwd().name diff --git a/bin/util.py b/bin/util.py index c643ff6d3..f86911338 100644 --- a/bin/util.py +++ b/bin/util.py @@ -17,6 +17,7 @@ from collections.abc import Callable, Mapping, Sequence from pathlib import Path from typing import ( + cast, Any, Iterable, Literal, @@ -58,6 +59,21 @@ ruamel_lock = threading.Lock() +try: + import questionary + from prompt_toolkit.document import Document + + has_questionary = True + + class EmptyValidator(questionary.Validator): + def validate(self, document: Document) -> None: + if len(document.text) == 0: + raise questionary.ValidationError(message="Please enter a value") + +except Exception: + has_questionary = False + + def is_windows() -> bool: return sys.platform in ["win32", "cygwin"] @@ -859,6 +875,70 @@ def parse_deprecated_setting( yaml_data.pop(key) +def _ask_variable(name: str, default: Optional[str] = None, allow_empty: bool = False) -> str: + if config.args.defaults: + if not default and not allow_empty: + fatal(f"{name} has no default") + return default or "" + while True: + val = input(f"{name}: ") + val = val or default or "" + if val != "" or allow_empty: + return val + + +def ask_variable_string(name: str, default: Optional[str] = None, allow_empty: bool = False) -> str: + if has_questionary: + try: + validate = None if allow_empty else EmptyValidator + return cast( + str, + questionary.text(name + ":", default=default or "", validate=validate).unsafe_ask(), + ) + except KeyboardInterrupt: + fatal("Running interrupted") + else: + text = f" ({default})" if default else "" + return _ask_variable(name + text, default if default else "", allow_empty) + + +def ask_variable_bool(name: str, default: bool = True) -> bool: + if has_questionary: + try: + return cast( + bool, + questionary.confirm(name + "?", default=default, auto_enter=False).unsafe_ask(), + ) + except KeyboardInterrupt: + fatal("Running interrupted") + else: + text = " (Y/n)" if default else " (y/N)" + return _ask_variable(name + text, "Y" if default else "N").lower()[0] == "y" + + +def ask_variable_choice(name: str, choices: Sequence[str], default: Optional[str] = None) -> str: + if has_questionary: + try: + plain = questionary.Style([("selected", "noreverse")]) + return cast( + str, + questionary.select( + name + ":", choices=choices, default=default, style=plain + ).unsafe_ask(), + ) + except KeyboardInterrupt: + fatal("Running interrupted") + else: + default = default or choices[0] + text = f" ({default})" if default else "" + while True: + got = _ask_variable(name + text, default if default else "") + if got in choices: + return got + else: + warn(f"unknown option: {got}") + + # glob, but without hidden files def glob(path: Path, expression: str, include_hidden: bool = False) -> list[Path]: def keep(p: Path) -> bool: From 9091a8d4ccc85d1616601c6deaf1c049d0ee9f2d Mon Sep 17 00:00:00 2001 From: mzuenni Date: Wed, 2 Apr 2025 15:03:08 +0200 Subject: [PATCH 19/23] change typing --- bin/problem.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/problem.py b/bin/problem.py index 0c7761f75..db8524dcf 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -1122,7 +1122,9 @@ def matches_existing_testcase(self, t): self._testcase_hashes[h] = t return None - def validate_data(problem, mode: validate.Mode, constraints: dict | bool | None = None) -> bool: + def validate_data( + problem, mode: validate.Mode, constraints: dict | Literal[True] | None = None + ) -> bool: """Validate aspects of the test data files. Arguments: @@ -1289,7 +1291,7 @@ def validate_valid_extra_data(p) -> bool: def _validate_data( problem, mode: validate.Mode, - constraints: dict | bool | None, + constraints: dict | Literal[True] | None, action: str, testcases: Sequence[testcase.Testcase], extra: bool = False, From dd7cb2d7b0d5fbcfe51854c4290bbfbb2955bdd4 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Sat, 5 Apr 2025 23:22:54 +0200 Subject: [PATCH 20/23] Add secondary sort-keys to `--order-from-ccs` (#447) * changed order from ccs * Update bin/tools.py Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> * fix call --------- Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- bin/tools.py | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/bin/tools.py b/bin/tools.py index 1ddd00550..50a6c1ea6 100755 --- a/bin/tools.py +++ b/bin/tools.py @@ -193,31 +193,49 @@ def get_pos(id): if config.args.order_from_ccs: # Sort by increasing difficulty, extracted from the CCS api. - # Get active contest. + class ProblemStat: + def __init__(self): + self.solved = 0 + self.submissions = 0 + self.pending = 0 + self.teams_submitted = 0 + self.teams_pending = 0 + + def update(self, team_stats: dict[str, Any]): + if team_stats["solved"]: + self.solved += 1 + if team_stats["num_judged"]: + self.submissions += team_stats["num_judged"] + self.teams_submitted += 1 + if team_stats["num_pending"]: + self.pending += team_stats["num_pending"] + self.teams_pending += 1 + + def key(self) -> tuple[int, int]: + # self.solved more AC => easier + # possible tie breakers: + # self.submissions more needed to get the same number of AC => Harder + # self.teams_pending more teams tried => appeared easier + # TODO: consider more stats? + return (-self.solved, self.submissions) + # Get active contest. cid = get_contest_id() - solves = dict() # Read set of problems contest_problems = call_api_get_json(f"/contests/{cid}/problems?public=true") assert isinstance(problems, list) - for path in contest_problems: - solves[path["id"]] = 0 + + problem_stats = {problem["id"]: ProblemStat() for problem in contest_problems} scoreboard = call_api_get_json(f"/contests/{cid}/scoreboard?public=true") for team in scoreboard["rows"]: - for path in team["problems"]: - if path["solved"]: - solves[path["problem_id"]] += 1 - - # Convert away from defaultdict, so any non matching keys below raise an error. - solves = dict(solves) - verbose("solves: " + str(solves)) + for team_stats in team["problems"]: + problem_stats[team_stats["problem_id"]].update(team_stats) # Sort the problems - # Use negative solves instead of reversed, to preserver stable order. - problems.sort(key=lambda p: (-solves[p.name], p.label)) + problems.sort(key=lambda p: (problem_stats[p.name].key(), p.label)) verbose(f"order: {', '.join(map(lambda p: str(p.label), problems))}") if ask_variable_bool("Update order in contest.yaml"): From baae8d8b820afa5ad30b4ce83495830540bbbafd Mon Sep 17 00:00:00 2001 From: mzuenni Date: Thu, 24 Apr 2025 21:34:35 +0200 Subject: [PATCH 21/23] Draft visualizer (#448) * copied from mpsijm * update identity * update schemas * update doc * default value for action * move visualizer * remove testcases * add warning for outdated visualizer * add output visualizer (and made open more consistent) * add comments * implemented output visualizer * change visualizer interface * handle args * dont use answer_validator_args * properly resolve problem paths * fix * typing * typing * typing * typing * allow empty .ans files for multipass * fix test * refactored movetree * allow moving sylinks * improve _move_dir * improved user experience * add extra assert * substitute more constants in export * implement visualizer for interactive problems * `identity` output-visualize also ans and in * add example arg * fix arg passing * fix wsl * disable output visualizer again * removed outdated warning * always copy output visualizer from skel * `guess` output visualizer for an interactive problem * update type * more feedback * Update skel/problem/input_visualizer/readme.md Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> * Update skel/problem/input_visualizer/readme.md Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> * Update bin/generate.py Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> * update version * format * format * added example * update visualizer logic * fix mode * fix mode * simplify code * add warning for deprecated root key * Problem._parse_testdata_yaml: enforce validator/visualizer args to be lists of strings * [visualize] Rename InputVisualizer to TestCaseVisualizer --------- Co-authored-by: Thore Husfeldt Co-authored-by: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> --- .github/workflows/ci.yaml | 1 + bin/config.py | 2 + bin/contest.py | 2 +- bin/export.py | 7 +- bin/generate.py | 225 ++++++++++-------- bin/interactive.py | 6 +- bin/problem.py | 137 ++++++++--- bin/program.py | 49 +--- bin/run.py | 39 ++- bin/skel.py | 12 +- bin/testcase.py | 103 ++++---- bin/tools.py | 10 + bin/upgrade.py | 108 ++++++--- bin/util.py | 73 +++--- bin/validate.py | 39 +-- bin/visualize.py | 97 ++++++++ doc/commands.md | 1 + doc/generators.md | 2 - doc/generators.yaml | 26 +- .../{example.py => example_generator.py} | 0 skel/problem/generators/generators.yaml | 3 +- .../example_output_visualizer.py | 10 + .../example_test_case_visualizer.py | 9 + skel/problem/test_case_visualizer/readme.md | 2 + support/schemas/generators.cue | 6 +- support/schemas/generators_yaml_schema.json | 68 ++---- support/schemas/problemformat.cue | 6 +- .../divsort/generators/generators.yaml | 20 +- test/problems/fltcmp/data/testdata.yaml | 2 +- .../generators/generators.yaml | 6 +- .../guess-visualizer.py | 32 +++ .../guess/output_visualizer_disabled/run | 41 ++++ .../identity/data/sample/testdata.yaml | 1 + .../identity/generators/generators.yaml | 5 +- .../identity/output_visualizer_disabled/run | 13 + .../output_visualizer_disabled/visualize.asy | 20 ++ .../test_case_visualizer_disabled/run | 5 + .../visualize.asy | 0 test/problems/identity/visualizers/run | 6 - .../invalid_yaml/bad_generators.yaml | 13 +- .../invalid_yaml/invalid.generators.yaml | 23 +- .../valid_yaml/rich-generators.yaml | 3 +- 42 files changed, 787 insertions(+), 446 deletions(-) create mode 100644 bin/visualize.py rename skel/problem/generators/{example.py => example_generator.py} (100%) create mode 100644 skel/problem/output_visualizer/example_output_visualizer.py create mode 100644 skel/problem/test_case_visualizer/example_test_case_visualizer.py create mode 100644 skel/problem/test_case_visualizer/readme.md create mode 100644 test/problems/guess/output_visualizer_disabled/guess-visualizer.py create mode 100755 test/problems/guess/output_visualizer_disabled/run create mode 100644 test/problems/identity/data/sample/testdata.yaml create mode 100755 test/problems/identity/output_visualizer_disabled/run create mode 100644 test/problems/identity/output_visualizer_disabled/visualize.asy create mode 100755 test/problems/identity/test_case_visualizer_disabled/run rename test/problems/identity/{visualizers => test_case_visualizer_disabled}/visualize.asy (100%) delete mode 100755 test/problems/identity/visualizers/run diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7e9ceca75..f1f0a1946 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -51,5 +51,6 @@ jobs: texlive-science latexmk texlive-lang-german + asymptote - shell: wsl-bash {0} run: pytest diff --git a/bin/config.py b/bin/config.py index eba8a1341..1d5660633 100644 --- a/bin/config.py +++ b/bin/config.py @@ -108,6 +108,8 @@ "jobs": (os.cpu_count() or 1) // 2, "time": 600, # Used for `bt fuzz` "verbose": 0, + "action": None, + "no_visualizer": True, } diff --git a/bin/contest.py b/bin/contest.py index db00d2884..241301dd8 100644 --- a/bin/contest.py +++ b/bin/contest.py @@ -41,7 +41,7 @@ def problems_yaml() -> Optional[list[dict[str, Any]]]: def get_api() -> str: - api = config.args.api or contest_yaml().get("api") + api = config.args.api or cast(str, contest_yaml().get("api")) if not api: fatal( "Could not find key `api` in contest.yaml and it was not specified on the command line." diff --git a/bin/export.py b/bin/export.py index a336ed2b1..219c01a36 100644 --- a/bin/export.py +++ b/bin/export.py @@ -13,6 +13,7 @@ from latex import PdfType from problem import Problem from validate import InputValidator, AnswerValidator, OutputValidator +from visualize import TestCaseVisualizer, OutputVisualizer def select_languages(problems: list[Problem]) -> list[str]: @@ -125,6 +126,8 @@ def build_problem_zip(problem: Problem, output: Path) -> bool: ("submissions/accepted/**/*", True), ("submissions/*/**/*", False), ("attachments/**/*", problem.interactive or problem.multi_pass), + (f"{TestCaseVisualizer.source_dir}/**/*", False), + (f"{OutputVisualizer.source_dir}/**/*", False), ] # Do not include PDFs for kattis. @@ -212,6 +215,8 @@ def add_testcase(in_file: Path) -> None: f"{OutputValidator.source_dir}/**/*", # "statement/*", "solution/*", "problem_slide/*", use \constant{} commands # "submissions/*/**/*", removed support? + f"{TestCaseVisualizer.source_dir}/**/*", + f"{OutputVisualizer.source_dir}/**/*", ] for pattern in constants_supported: for f in export_dir.glob(pattern): @@ -292,7 +297,7 @@ def add_testcase(in_file: Path) -> None: validator_flags = " ".join( problem.get_testdata_yaml( problem.path / "data", - "output_validator_args", + OutputValidator.args_key, PrintBar("Getting validator_flags for legacy export"), ) ) diff --git a/bin/generate.py b/bin/generate.py index 321774bb9..e1d5465d1 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -9,13 +9,14 @@ from collections.abc import Callable, Sequence from colorama import Fore, Style from pathlib import Path, PurePosixPath -from typing import Any, Final, Optional, overload +from typing import Any, Final, Iterable, Optional, overload import config import parallel import program import run import validate +import visualize from testcase import Testcase from verdicts import Verdict from problem import Problem @@ -88,7 +89,6 @@ def resolve_path(path, *, allow_absolute, allow_relative): # The following classes inherit from Invocation: # - GeneratorInvocation # - SolutionInvocation -# - VisualizerInvocation class Invocation: SEED_REGEX: Final[re.Pattern[str]] = re.compile(r"\{seed(:[0-9]+)?\}") NAME_REGEX: Final[re.Pattern[str]] = re.compile(r"\{name\}") @@ -121,7 +121,7 @@ def __init__(self, problem: Problem, string: str, *, allow_absolute: bool, allow raise ParseException("{seed(:[0-9]+)} may appear at most once.") # Automatically set self.program when that program has been built. - self.program: Optional[program.Generator | program.Visualizer | run.Submission] = None + self.program: Optional[program.Generator | run.Submission] = None def callback(program): self.program = program @@ -172,7 +172,7 @@ def run(self, bar, cwd, name, seed, retries=1): ) if result.status: break - if not result.retry: + if result.status == ExecStatus.TIMEOUT: break if not result.status: @@ -189,30 +189,6 @@ def run(self, bar, cwd, name, seed, retries=1): return result -class VisualizerInvocation(Invocation): - def __init__(self, problem, string): - super().__init__(problem, string, allow_absolute=True, allow_relative=False) - - # Run the visualizer, taking {name} as a command line argument. - # Stdin and stdout are not used. - # {name} is no longer used and hardcoded to `testcase` (see #273), and {seed} is also not used. - def run(self, bar, cwd): - assert isinstance(self.program, program.Visualizer), "Visualizer program must be built!" - - result = self.program.run(cwd, args=self._sub_args()) - - if result.status == ExecStatus.TIMEOUT: - bar.debug(f"{Style.RESET_ALL}-> {shorten_path(self.problem, cwd)}") - bar.error(f"Visualizer TIMEOUT after {result.duration}s") - elif not result.status: - bar.debug(f"{Style.RESET_ALL}-> {shorten_path(self.problem, cwd)}") - bar.error("Visualizer failed", result.err) - - if result.status and config.args.error and result.err: - bar.log("stderr", result.err) - return result - - class SolutionInvocation(Invocation): def __init__(self, problem, string): super().__init__(problem, string, allow_absolute=True, allow_relative=False) @@ -244,8 +220,7 @@ def run(self, bar, cwd): def run_interaction(self, bar, cwd, t): in_path = cwd / "testcase.in" interaction_path = cwd / "testcase.interaction" - if interaction_path.is_file(): - return True + interaction_path.unlink(missing_ok=True) testcase = Testcase(self.problem, in_path, short_path=(t.path.parent / (t.name + ".in"))) assert isinstance(self.program, run.Submission) @@ -327,7 +302,6 @@ def __init__(self, generator_config): "generate", "copy", "solution", - "visualizer", "random_salt", "retries", "count", @@ -339,18 +313,16 @@ def __init__(self, generator_config): "testdata.yaml", "include", "solution", - "visualizer", "random_salt", "retries", ] RESERVED_DIRECTORY_KEYS: Final[Sequence[str]] = ["command"] KNOWN_ROOT_KEYS: Final[Sequence[str]] = ["generators", "parallel", "version"] -DEPRECATED_ROOT_KEYS: Final[Sequence[str]] = ["gitignore_generated"] +DEPRECATED_ROOT_KEYS: Final[Sequence[str]] = ["gitignore_generated", "visualizer"] # Holds all inheritable configuration options. Currently: # - config.solution -# - config.visualizer # - config.random_salt class Config: # Used at each directory or testcase level. @@ -362,13 +334,6 @@ def parse_solution(p, x, path): return None return SolutionInvocation(p, x) - @staticmethod - def parse_visualizer(p, x, path): - assert_type("Visualizer", x, [type(None), str], path) - if x is None: - return None - return VisualizerInvocation(p, x) - @staticmethod def parse_random_salt(p, x, path): assert_type("Random_salt", x, [type(None), str], path) @@ -379,7 +344,6 @@ def parse_random_salt(p, x, path): INHERITABLE_KEYS: Final[Sequence] = [ # True: use an AC submission by default when the solution: key is not present. ("solution", True, parse_solution), - ("visualizer", None, parse_visualizer), ("random_salt", "", parse_random_salt), # Non-portable keys only used by BAPCtools: # The number of retries to run a generator when it fails, each time incrementing the {seed} @@ -388,7 +352,6 @@ def parse_random_salt(p, x, path): ] solution: SolutionInvocation - visualizer: Optional[VisualizerInvocation] random_salt: str retries: int @@ -725,7 +688,6 @@ def validate_in(t, problem: Problem, testcase: Testcase, meta_yaml: dict, bar: P ) return True - # we assume .ans is a valid output and validate it as such def validate_ans_and_out( t, problem: Problem, testcase: Testcase, meta_yaml: dict, bar: ProgressBar ): @@ -745,18 +707,22 @@ def validate_ans_and_out( bar.error("No .out file was generated!") return False - answer_validator_hashes = {**testcase.validator_hashes(validate.AnswerValidator, bar)} - if all(h in meta_yaml["answer_validator_hashes"] for h in answer_validator_hashes): - return True + ans_out_validator_hashes = testcase.validator_hashes(validate.AnswerValidator, bar).copy() + output_validator_hashes = testcase.validator_hashes(validate.OutputValidator, bar) mode = validate.Mode.ANSWER - if testcase.root in config.INVALID_CASE_DIRECTORIES: + if testcase.root == "invalid_answer": mode = validate.Mode.INVALID - elif testcase.root == "valid_output": - mode = validate.Mode.VALID_OUTPUT - elif outfile.is_file(): + elif testcase.root == "invalid_output": + ans_out_validator_hashes.update(output_validator_hashes) + mode = validate.Mode.INVALID + elif testcase.root == "valid_output" or outfile.is_file(): + ans_out_validator_hashes.update(output_validator_hashes) mode = validate.Mode.VALID_OUTPUT + if all(h in meta_yaml["ans_out_validator_hashes"] for h in ans_out_validator_hashes): + return True + if not testcase.validate_format( mode, bar=bar, @@ -767,8 +733,9 @@ def validate_ans_and_out( bar.done(False) return False else: - for h in answer_validator_hashes: - meta_yaml["answer_validator_hashes"][h] = answer_validator_hashes[h] + for h in ans_out_validator_hashes: + meta_yaml["ans_out_validator_hashes"][h] = ans_out_validator_hashes[h] + meta_yaml["visualizer_hash"] = dict() write_yaml( meta_yaml, problem.tmpdir / "data" / t.hash / "meta_.yaml", @@ -820,7 +787,7 @@ def init_meta(): "generated_extensions": [], "input_validator_hashes": dict(), "solution_hash": dict(), - "answer_validator_hashes": dict(), + "ans_out_validator_hashes": dict(), "visualizer_hash": dict(), } meta_yaml["rule"] = t.rule @@ -904,7 +871,7 @@ def generate_from_rule(): if not infile.is_file() or meta_yaml.get("rule_hashes") != rule_hashes: # clear all generated files - shutil.rmtree(cwd) + shutil.rmtree(cwd, ignore_errors=True) cwd.mkdir(parents=True, exist_ok=True) meta_yaml = init_meta() @@ -957,7 +924,7 @@ def generate_from_rule(): assert t._has_required_in(infile), f"Failed to generate in file: {infile.name}" return True - def generate_from_solution(): + def generate_from_solution(testcase: Testcase): nonlocal meta_yaml if testcase.root in [*config.INVALID_CASE_DIRECTORIES, "valid_output"]: @@ -1022,7 +989,7 @@ def needed(ext): if used_solution: meta_yaml["solution_hash"] = solution_hash if changed_ans: - meta_yaml["answer_validator_hashes"] = dict() + meta_yaml["ans_out_validator_hashes"] = dict() meta_yaml["visualizer_hash"] = dict() if changed_ans or used_solution: write_yaml(meta_yaml, meta_path, allow_yamllib=True) @@ -1030,43 +997,118 @@ def needed(ext): assert ansfile.is_file(), f"Failed to generate ans file: {ansfile}" return True - def generate_empty_interactive_sample_ans(): - if not t.sample: - return True - if not problem.interactive and not problem.multi_pass: - return True - for ext in ["", ".statement", ".download"]: - ans_ext_file = infile.with_suffix(f".ans{ext}") - if ans_ext_file.exists(): - return True - if infile.with_suffix(f".in{ext}").exists(): - ans_ext_file.write_text("") - return True - return True - - def generate_visualization(): + def generate_visualization(testcase: Testcase, bar: ProgressBar): nonlocal meta_yaml - if testcase.root in [*config.INVALID_CASE_DIRECTORIES, "valid_output"]: - return True - if not t.config.visualizer: + if testcase.root in config.INVALID_CASE_DIRECTORIES: return True if config.args.no_visualizer: return True + # Generate visualization + in_path = cwd / "testcase.in" + ans_path = cwd / "testcase.ans" + out_path = cwd / "testcase.out" + assert in_path.is_file() + assert ans_path.is_file() + + feedbackdir = in_path.with_suffix(".feedbackdir") + image_files = [f"judgeimage{ext}" for ext in config.KNOWN_VISUALIZER_EXTENSIONS] + [ + f"teamimage{ext}" for ext in config.KNOWN_VISUALIZER_EXTENSIONS + ] + + def use_feedback_image(feedbackdir: Path, source: str) -> None: + for name in image_files: + path = feedbackdir / name + if path.exists(): + ensure_symlink(in_path.with_suffix(path.suffix), path) + bar.log(f"Using {name} from {source} as visualization") + return + + visualizer: Optional[visualize.AnyVisualizer] = problem.visualizer( + visualize.TestCaseVisualizer + ) + output_visualizer = problem.visualizer(visualize.OutputVisualizer) + if output_visualizer is not None: + if out_path.is_file() or problem.settings.ans_is_output: + if visualizer is None or out_path.is_file(): + visualizer = output_visualizer + if not out_path.is_file(): + assert problem.settings.ans_is_output + out_path = ans_path + + if visualizer is None: + for ext in config.KNOWN_VISUALIZER_EXTENSIONS: + in_path.with_suffix(ext).unlink(True) + use_feedback_image(feedbackdir, "validator") + return True + + visualizer_args = testcase.testdata_yaml_args(visualizer, bar) visualizer_hash = { - "visualizer_hash": t.config.visualizer.hash(), - "visualizer": t.config.visualizer.cache_command(), + "visualizer_hash": visualizer.hash, + "visualizer_args": visualizer_args, } if meta_yaml.get("visualizer_hash") == visualizer_hash: return True - # Generate visualization - t.config.visualizer.run(bar, cwd) + for ext in config.KNOWN_VISUALIZER_EXTENSIONS: + in_path.with_suffix(ext).unlink(True) + + if isinstance(visualizer, visualize.TestCaseVisualizer): + result = visualizer.run(in_path, ans_path, cwd, visualizer_args) + else: + feedbackcopy = in_path.with_suffix(".feedbackcopy") + shutil.rmtree(feedbackcopy) + + def skip_images(src: str, content: list[str]) -> list[str]: + return [] if src != str(feedbackdir) else image_files + + shutil.copytree(feedbackdir, feedbackcopy, ignore=skip_images) + + result = visualizer.run( + in_path, + ans_path, + out_path if not problem.interactive else None, + feedbackcopy, + visualizer_args, + ) + if result.status: + use_feedback_image(feedbackdir, "output_visualizer") + + if result.status == ExecStatus.TIMEOUT: + bar.debug(f"{Style.RESET_ALL}-> {shorten_path(problem, cwd)}") + bar.error( + f"{type(visualizer).visualizer_type.capitalize()} Visualizer TIMEOUT after {result.duration}s" + ) + elif not result.status: + bar.debug(f"{Style.RESET_ALL}-> {shorten_path(problem, cwd)}") + bar.error( + f"{type(visualizer).visualizer_type.capitalize()} Visualizer failed", result.err + ) - meta_yaml["visualizer_hash"] = visualizer_hash - write_yaml(meta_yaml, meta_path, allow_yamllib=True) + if result.status and config.args.error and result.err: + bar.log("stderr", result.err) + + if result.status: + meta_yaml["visualizer_hash"] = visualizer_hash + write_yaml(meta_yaml, meta_path, allow_yamllib=True) + + # errors in the visualizer are not critical + return True + + def generate_empty_interactive_sample_ans(): + if not t.sample: + return True + if not problem.interactive and not problem.multi_pass: + return True + for ext in ["", ".statement", ".download"]: + ans_ext_file = infile.with_suffix(f".ans{ext}") + if ans_ext_file.exists(): + return True + if infile.with_suffix(f".in{ext}").exists(): + ans_ext_file.write_text("") + return True return True def copy_generated(): @@ -1170,7 +1212,7 @@ def add_testdata_to_cache(): return # Step 4: generate .ans and .interaction if needed - if not generate_from_solution(): + if not generate_from_solution(testcase): return # Step 5: validate .ans (and .out if it exists) @@ -1178,7 +1220,7 @@ def add_testdata_to_cache(): return # Step 6: generate visualization if needed - if not generate_visualization(): + if not generate_visualization(testcase, bar): return # Step 7: for interactive and/or multi-pass samples, generate empty .ans if it does not exist @@ -1829,7 +1871,6 @@ def add_included_case(t: TestcaseRule): def build(self, build_visualizers=True, skip_double_build_warning=False): generators_used: set[Path] = set() solutions_used: set[Path] = set() - visualizers_used: set[Path] = set() # Collect all programs that need building. # Also, convert the default submission into an actual Invocation. @@ -1850,16 +1891,14 @@ def collect_programs(t): default_solution = DefaultSolutionInvocation(self) t.config.solution = default_solution solutions_used.add(t.config.solution.program_path) - if build_visualizers and t.config.visualizer: - visualizers_used.add(t.config.visualizer.program_path) self.root_dir.walk(collect_programs, dir_f=None) def build_programs( - program_type: type[program.Generator | program.Visualizer | run.Submission], - program_paths: set[Path], + program_type: type[program.Generator | run.Submission], + program_paths: Iterable[Path], ): - programs = list[program.Generator | program.Visualizer | run.Submission]() + programs = list[program.Generator | run.Submission]() for program_path in program_paths: path = self.problem.path / program_path if program_type is program.Generator and program_path in self.generators: @@ -1893,7 +1932,9 @@ def build_program(p): # TODO: Consider building all types of programs in parallel as well. build_programs(program.Generator, generators_used) build_programs(run.Submission, solutions_used) - build_programs(program.Visualizer, visualizers_used) + if build_visualizers: + self.problem.visualizer(visualize.TestCaseVisualizer) + self.problem.visualizer(visualize.OutputVisualizer) self.problem.validators(validate.InputValidator) self.problem.validators(validate.AnswerValidator) @@ -1902,10 +1943,6 @@ def build_program(p): def cleanup_build_failures(t): if t.config.solution and t.config.solution.program is None: t.config.solution = None - if not build_visualizers or ( - t.config.visualizer and t.config.visualizer.program is None - ): - t.config.visualizer = None self.root_dir.walk(cleanup_build_failures, dir_f=None) diff --git a/bin/interactive.py b/bin/interactive.py index 8936afb1e..8f587b80f 100644 --- a/bin/interactive.py +++ b/bin/interactive.py @@ -56,7 +56,7 @@ def get_validator_command(): run.testcase.ans_path.resolve(), run.feedbackdir.resolve(), ] - + run.testcase.testdata_yaml_validator_args( + + run.testcase.testdata_yaml_args( output_validator, bar or PrintBar("Run interactive test case"), ) @@ -167,6 +167,8 @@ def get_validator_command(): verdict = Verdict.VALIDATOR_CRASH break + run._visualize_output(bar or PrintBar("Visualize interaction")) + if tle_result is None: # Set result.err to validator error and result.out to team error. return ExecResult( @@ -431,6 +433,8 @@ def kill_handler_function(): if interaction_file is not None: interaction_file.close() + run._visualize_output(bar or PrintBar("Visualize interaction")) + if tle_result is None: return ExecResult( None, diff --git a/bin/problem.py b/bin/problem.py index db8524dcf..1b98b0343 100644 --- a/bin/problem.py +++ b/bin/problem.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence from pathlib import Path -from typing import Any, Final, Literal, Optional, TYPE_CHECKING +from typing import Any, Final, Literal, Optional, overload, TYPE_CHECKING if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 from program import Program @@ -20,6 +20,7 @@ import validate import validator_tests import verdicts +import visualize from util import * from colorama import Fore, Style @@ -266,7 +267,7 @@ def __init__( self.limits = ProblemLimits(parse_setting(yaml_data, "limits", {}), problem, self) parse_deprecated_setting( - yaml_data, "validator_flags", "output_validator_args' in 'testdata.yaml" + yaml_data, "validator_flags", f"{validate.OutputValidator.args_key}' in 'testdata.yaml" ) self.keywords: list[str] = parse_optional_list_setting(yaml_data, "keywords", str) @@ -337,6 +338,9 @@ def __init__(self, path: Path, tmpdir: Path, label: Optional[str] = None): tuple[type[validate.AnyValidator], bool], list[validate.AnyValidator] ]() self._validators_warn_cache = set[tuple[type[validate.AnyValidator], bool]]() + self._visualizer_cache = dict[ + type[visualize.AnyVisualizer], Optional[visualize.AnyVisualizer] + ]() self._programs = dict[Path, "Program"]() self._program_callbacks = dict[Path, list[Callable[["Program"], None]]]() # Dictionary from path to parsed file contents. @@ -383,7 +387,7 @@ def _determine_statement_languages(self): unnormalised_yamlname = self.settings.name[lang] yamlname = " ".join(unnormalised_yamlname.split()) texpath = self.path / latex.PdfType.PROBLEM.path(lang) - with open(texpath) as texfile: + with texpath.open() as texfile: match texname := latex.get_argument_for_command(texfile, "problemname"): case None: error(rf"No \problemname found in {texpath.name}") @@ -456,20 +460,31 @@ def _parse_testdata_yaml(p, path, bar): p._testdata_yamls[f] = flags = parse_yaml(raw, path=f, plain=True) parse_deprecated_setting( - flags, "output_validator_flags", "output_validator_args" + flags, "output_validator_flags", validate.OutputValidator.args_key + ) + parse_deprecated_setting( + flags, "input_validator_flags", validate.InputValidator.args_key ) - parse_deprecated_setting(flags, "input_validator_flags", "input_validator_args") # Verify testdata.yaml for k in flags: match k: - case "output_validator_args": - if not isinstance(flags[k], str): - bar.error(f"{k} must be string", resume=True, print_item=False) - case "input_validator_args": - if not isinstance(flags[k], (str, dict)): + case ( + validate.OutputValidator.args_key + | validate.AnswerValidator.args_key + | visualize.TestCaseVisualizer.args_key + | visualize.OutputVisualizer.args_key + ): + if not isinstance(flags[k], list): + bar.error( + f"{k} must be a list of strings", + resume=True, + print_item=False, + ) + case validate.InputValidator.args_key: + if not isinstance(flags[k], (list, dict)): bar.error( - f"{k} must be string or map", + f"{k} must be list or map", resume=True, print_item=False, ) @@ -504,8 +519,8 @@ def _parse_testdata_yaml(p, path, bar): def get_testdata_yaml( p, path: Path, - key: Literal["input_validator_args"] | Literal["output_validator_args"], - bar: ProgressBar | PrintBar, + key: str, + bar: BAR_TYPE, name: Optional[str] = None, ) -> list[str]: """ @@ -517,8 +532,7 @@ def get_testdata_yaml( Arguments --------- path: absolute path (a file or a directory) - key: The testdata.yaml key to look for, either of 'input_validator_args', 'output_validator_args', or 'grading'. - TODO: 'grading' is not yet implemented. + key: The testdata.yaml key to look for (TODO: 'grading' is not yet implemented) name: If key == 'input_validator_args', optionally the name of the input validator. Returns: @@ -526,9 +540,16 @@ def get_testdata_yaml( A list of string arguments, which is empty if no testdata.yaml is found. TODO: when 'grading' is supported, it also can return dict """ - if key not in ["input_validator_args", "output_validator_args"]: + known_args_keys = [ + validate.InputValidator.args_key, + validate.OutputValidator.args_key, + validate.AnswerValidator.args_key, + visualize.TestCaseVisualizer.args_key, + visualize.OutputVisualizer.args_key, + ] + if key not in known_args_keys: raise NotImplementedError(key) - if key != "input_validator_args" and name is not None: + if key != validate.InputValidator.args_key and name is not None: raise ValueError( f"Only input validators support flags by validator name, got {key} and {name}" ) @@ -547,20 +568,28 @@ def get_testdata_yaml( continue flags = p._testdata_yamls[f] if key in flags: - if key == "output_validator_args": - if not isinstance(flags[key], str): - bar.error("output_validator_args must be string") + args = flags[key] + if key == validate.InputValidator.args_key: + if not isinstance(args, (list, dict)): + bar.error(f"{key} must be list of strings or map of lists") return [] - return flags[key].split() - - if key == "input_validator_args": - if not isinstance(flags[key], (str, dict)): - bar.error("input_validator_args must be string or map") + if isinstance(args, list): + if any(not isinstance(arg, str) for arg in args): + bar.error(f"{key} must be list of strings or map of lists") + return [] + return args + elif name in args: + if not isinstance(args[name], list) or any( + not isinstance(arg, str) for arg in args[name] + ): + bar.error(f"{key} must be list of strings or map of lists") + return [] + return args[name] + elif key in known_args_keys: + if not isinstance(args, list) or any(not isinstance(arg, str) for arg in args): + bar.error(f"{key} must be a list of strings") return [] - if isinstance(flags[key], str): - return flags[key].split() - elif name in flags[key]: - return flags[key][name].split() + return args return [] @@ -611,7 +640,7 @@ def testcases( testcases = [] for f in in_paths: - t = testcase.Testcase(p, f, print_warn=True) + t = testcase.Testcase(p, f) if ( (p.interactive or p.multi_pass) and mode in [validate.Mode.INVALID, validate.Mode.VALID_OUTPUT] @@ -857,6 +886,30 @@ def build_program(p): assert isinstance(problem._submissions, list) return problem._submissions.copy() + @overload + def visualizer( + problem, cls: type[visualize.TestCaseVisualizer] + ) -> Optional[visualize.TestCaseVisualizer]: ... + @overload + def visualizer( + problem, cls: type[visualize.OutputVisualizer] + ) -> Optional[visualize.OutputVisualizer]: ... + def visualizer( + problem, cls: type[visualize.AnyVisualizer] + ) -> Optional[visualize.AnyVisualizer]: + path = problem.path / cls.source_dir + if not path.is_dir(): + return None + if cls not in problem._visualizer_cache: + visualizer = cls(problem, path) + bar = ProgressBar(f"Building {cls.visualizer_type} visualizer", items=[visualizer]) + localbar = bar.start(visualizer) + visualizer.build(localbar) + localbar.done() + bar.finalize(print_done=False) + problem._visualizer_cache[cls] = visualizer if visualizer.ok else None + return problem._visualizer_cache[cls] + def validators( problem, cls: type[validate.AnyValidator], @@ -960,7 +1013,7 @@ def build_program(p): problem._validators_cache[key] = validators return validators - # get all testcses and submissions and prepare the output validator + # get all testcases and submissions and prepare the output validator and visualizer def prepare_run(problem): testcases = problem.testcases() if not testcases: @@ -970,6 +1023,10 @@ def prepare_run(problem): if not problem.validators(validate.OutputValidator): return False + # Pre build the output visualizer to prevent nested ProgressBars. + if not config.args.no_visualizer: + problem.visualizer(visualize.OutputVisualizer) + submissions = problem.submissions() if not submissions: return False @@ -1003,6 +1060,10 @@ def run_submissions(problem): testcases, submissions = ts_pair ok, verdict_table = Problem.run_some(testcases, submissions) + if len(testcases) * len(submissions) > 1: + if not config.args.verbose and not config.args.no_visualizer: + log("use -v with --visualize to see the paths to the generated images") + if config.args.table: Problem._print_table(verdict_table.results, testcases) elif config.args.overview and not config.args.tree: @@ -1291,7 +1352,7 @@ def validate_valid_extra_data(p) -> bool: def _validate_data( problem, mode: validate.Mode, - constraints: dict | Literal[True] | None, + constraints: validate.ConstraintsDict | Literal[True] | None, action: str, testcases: Sequence[testcase.Testcase], extra: bool = False, @@ -1300,13 +1361,11 @@ def _validate_data( if not testcases: return True - if constraints is True: - constraints = {} - assert constraints is None or isinstance(constraints, dict) + constraints_dict = {} if constraints is True else constraints + check_constraints = constraints_dict is not None # Pre-build the relevant Validators so as to avoid clash with ProgressBar bar below # Also, pick the relevant testcases - check_constraints = constraints is not None match mode: case validate.Mode.INPUT: problem.validators(validate.InputValidator, check_constraints=check_constraints) @@ -1352,7 +1411,7 @@ def process_testcase(testcase: testcase.Testcase): return ok = testcase.validate_format( - mode, bar=localbar, constraints=constraints, warn_instead_of_error=extra + mode, bar=localbar, constraints=constraints_dict, warn_instead_of_error=extra ) success &= ok localbar.done(ok) @@ -1362,8 +1421,8 @@ def process_testcase(testcase: testcase.Testcase): bar.finalize(print_done=True) # Make sure all constraints are satisfied. - if constraints: - for loc, value in sorted(constraints.items()): + if constraints_dict: + for loc, value in sorted(constraints_dict.items()): loc = Path(loc).name name, has_low, has_high, vmin, vmax, low, high = value if not has_low: diff --git a/bin/program.py b/bin/program.py index fd38bd3d8..40278acd7 100644 --- a/bin/program.py +++ b/bin/program.py @@ -141,18 +141,16 @@ def __init__( # Set self.name and self.tmpdir. # Ideally they are the same as the path inside the problem, but fallback to just the name. - try: - # Only resolve the parent of the program. This preserves programs that are symlinks to other directories. - relpath = (path.parent.resolve() / path.name).relative_to( - problem.path.resolve() / self.subdir - ) - self.short_path = relpath - self.name: str = str(relpath) - self.tmpdir = problem.tmpdir / self.subdir / relpath - except ValueError: - self.short_path = Path(path.name) - self.name = str(path.name) - self.tmpdir = problem.tmpdir / self.subdir / path.name + relpath = Path(path.name) + if path.absolute().parent != problem.path.absolute(): + try: + relpath = path.absolute().relative_to(problem.path.absolute() / subdir) + except ValueError: + pass + + self.short_path = relpath + self.name: str = str(relpath) + self.tmpdir = problem.tmpdir / self.subdir / self.name self.compile_command: Optional[list[str]] = None self.run_command: Optional[list[str]] = None @@ -515,7 +513,7 @@ def build(self, bar: ProgressBar): return True - def _exec_command(self, *args, **kwargs): + def _exec_command(self, *args, **kwargs) -> ExecResult: if "timeout" not in kwargs and "timeout" in self.limits: kwargs["timeout"] = self.limits["timeout"] if "memory" not in kwargs and "memory" in self.limits: @@ -567,16 +565,12 @@ def run(self, bar, cwd, name, args=[]): cwd=cwd, ) - result.retry = False - if result.status == ExecStatus.TIMEOUT: # Timeout -> stop retrying and fail. bar.log(f"TIMEOUT after {timeout}s", color=Fore.RED) return result if not result.status: - # Other error -> try again. - result.retry = True return result if stdout_path.read_text(): @@ -591,24 +585,3 @@ def run(self, bar, cwd, name, args=[]): return result return result - - -class Visualizer(Program): - def __init__(self, problem: "Problem", path: Path, **kwargs): - super().__init__( - problem, - path, - "visualizers", - limits={"timeout": problem.limits.visualizer_time}, - substitute_constants=True, - **kwargs, - ) - - # Run the visualizer. - # Stdin and stdout are not used. - def run(self, cwd, args=[]): - assert self.run_command is not None - return self._exec_command( - self.run_command + args, - cwd=cwd, - ) diff --git a/bin/run.py b/bin/run.py index 297cb1fc5..51c05fe6f 100644 --- a/bin/run.py +++ b/bin/run.py @@ -5,7 +5,7 @@ from colorama import Fore, Style from pathlib import Path -from typing import cast +from typing import Optional import config import interactive @@ -13,8 +13,10 @@ import problem import program import validate +import visualize from testcase import Testcase from util import ( + BAR_TYPE, crop_output, ensure_symlink, error, @@ -23,6 +25,7 @@ is_bsd, is_windows, ProgressBar, + shorten_path, warn, ) from verdicts import from_string, from_string_domjudge, RunUntil, Verdict, Verdicts @@ -40,7 +43,7 @@ def __init__(self, problem: "problem.Problem", submission: "Submission", testcas self.problem.tmpdir / "runs" / self.submission.short_path - / cast(Path, self.testcase.short_path).with_suffix("") + / self.testcase.short_path.with_suffix("") ) self.in_path: Path = self.tmpdir / "testcase.in" @@ -174,7 +177,9 @@ def run(self, bar, *, interaction=None, submission_args=None): result.duration = max_duration - # Delete .out files larger than 1MB. + self._visualize_output(bar) + + # Delete .out files larger than 1GB. if ( not config.args.error and self.out_path.is_file() @@ -215,7 +220,7 @@ def _prepare_nextpass(self, nextpass): shutil.move(nextpass, self.in_path) return True - def _validate_output(self, bar): + def _validate_output(self, bar: BAR_TYPE) -> Optional[ExecResult]: output_validators = self.problem.validators(validate.OutputValidator) if not output_validators: return None @@ -224,7 +229,21 @@ def _validate_output(self, bar): return output_validator.run( self.testcase, self, - args=self.testcase.testdata_yaml_validator_args(output_validator, bar), + args=self.testcase.testdata_yaml_args(output_validator, bar), + ) + + def _visualize_output(self, bar: BAR_TYPE) -> Optional[ExecResult]: + if config.args.no_visualizer: + return None + output_visualizer = self.problem.visualizer(visualize.OutputVisualizer) + if output_visualizer is None: + return None + return output_visualizer.run( + self.in_path, + self.testcase.ans_path.resolve(), + self.out_path if not self.problem.interactive else None, + self.feedbackdir, + args=self.testcase.testdata_yaml_args(output_visualizer, bar), ) @@ -420,14 +439,16 @@ def process_run(run: Run): if result.out: data = crop_output(result.out) - judgemessage = run.feedbackdir / "judgemessage.txt" - judgeerror = run.feedbackdir / "judgeerror.txt" # Add data from feedbackdir. for f in run.feedbackdir.iterdir(): - if f in [judgemessage, judgeerror]: - continue if f.name.startswith("."): continue # skip "hidden" files + if f.name in ["judgemessage.txt", "judgeerror.txt"]: + continue + if f.name.startswith("judgeimage.") or f.name.startswith("teamimage."): + data += f"{f.name}: {shorten_path(self.problem, f.parent) / f.name}\n" + ensure_symlink(run.problem.path / f.name, f, output=True, relative=False) + continue if not f.is_file(): localbar.warn(f"Validator wrote to {f} but it's not a file.") continue diff --git a/bin/skel.py b/bin/skel.py index 4427a69c8..ba0c2a6ff 100644 --- a/bin/skel.py +++ b/bin/skel.py @@ -93,7 +93,7 @@ def new_problem() -> None: ) author = config.args.author if config.args.author else ask_variable_string("author") - output_validator_args = "#output_validator_args:" + output_validator_args = f"#{OutputValidator.args_key}:" custom_output = False if config.args.type: problem_type = config.args.type @@ -105,7 +105,7 @@ def new_problem() -> None: # The validation type `float` is not official, it only helps setting the `output_validator_args`. if problem_type == "float": problem_type = "pass-fail" - output_validator_args = "output_validator_args: float_tolerance 1e-6" + output_validator_args = f"{OutputValidator.args_key}: float_tolerance 1e-6" log("Using default float tolerance of 1e-6") # Since version 2023-07-draft of the spec, the `custom` validation type is no longer explicit. # The mere existence of the output_validator(s)/ folder signals non-default output validation. @@ -121,7 +121,7 @@ def new_problem() -> None: "dirname": dirname, "author": author, "type": problem_type, - "output_validator_args": output_validator_args, + OutputValidator.args_key: output_validator_args, "testdata_yaml_comment": "#" if output_validator_args[0] == "#" else "", } @@ -180,13 +180,17 @@ def new_problem() -> None: else: error("ruamel.yaml library not found. Please update problems.yaml manually.") + skip = [] + if custom_output: + skip.append(skeldir / OutputValidator.source_dir) + copytree_and_substitute( skeldir, target_dir / dirname, variables, exist_ok=True, preserve_symlinks=preserve_symlinks, - skip=[skeldir / OutputValidator.source_dir] if not custom_output else None, + skip=skip, ) # Warn about missing problem statement skeletons for non-en languages diff --git a/bin/testcase.py b/bin/testcase.py index d07f2af79..26f061566 100644 --- a/bin/testcase.py +++ b/bin/testcase.py @@ -1,19 +1,26 @@ """Test case""" +from collections.abc import Sequence from colorama import Fore, Style from pathlib import Path -from typing import cast, Literal, Optional +from typing import Optional, TYPE_CHECKING from util import ( + BAR_TYPE, ExecStatus, combine_hashes_dict, fatal, print_name, + ProgressBar, shorten_path, ) import config import validate +if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 + import visualize + import problem + class Testcase: """ @@ -57,7 +64,13 @@ class Testcase: """ - def __init__(self, base_problem, path, *, short_path=None, print_warn=False): + def __init__( + self, + base_problem: "problem.Problem", + path: Path, + *, + short_path: Optional[Path] = None, + ): """ Arguments --------- @@ -76,17 +89,17 @@ def __init__(self, base_problem, path, *, short_path=None, print_warn=False): # TODO add self.out_path if short_path is None: try: - self.short_path = path.relative_to(self.problem.path / "data") + self.short_path: Path = path.relative_to(self.problem.path / "data") except ValueError: fatal(f"Testcase {path} is not inside {self.problem.path / 'data'}.") else: self.short_path = short_path - self.root = self.short_path.parts[0] + self.root: str = self.short_path.parts[0] - self.in_path = path - self.ans_path = self.in_path.with_suffix(".ans") - self.out_path = ( + self.in_path: Path = path + self.ans_path: Path = self.in_path.with_suffix(".ans") + self.out_path: Optional[Path] = ( self.in_path.with_suffix(".out") if self.root in ["valid_output", "invalid_output"] or self.in_path.with_suffix(".out").is_file() @@ -94,18 +107,18 @@ def __init__(self, base_problem, path, *, short_path=None, print_warn=False): ) # Display name: everything after data/. - self.name = str(self.short_path.with_suffix("")) + self.name: str = str(self.short_path.with_suffix("")) - def __repr__(self): + def __repr__(self) -> str: return self.name - def with_suffix(self, ext): + def with_suffix(self, ext: str) -> Path: return self.in_path.with_suffix(ext) - def testdata_yaml_validator_args( + def testdata_yaml_args( self, - validator, # TODO #102: Fix circular import when setting type to validate.AnyValidator - bar, # TODO #102: Type should probably be ProgressBar | PrintBar or something + program: "validate.AnyValidator | visualize.AnyVisualizer", + bar: BAR_TYPE, ) -> list[str]: """ The flags specified in testdata.yaml for the given validator applying to this testcase. @@ -116,21 +129,18 @@ def testdata_yaml_validator_args( A nonempty list of strings, such as ["space_change_sensitive", "case_sensitive"] or ["--max_N", "50"] or even [""]. """ - key, name = ( - ("input_validator_args", validator.name) - if isinstance(validator, validate.InputValidator) - else ("output_validator_args", None) - ) path = self.problem.path / "data" / self.short_path return self.problem.get_testdata_yaml( path, - cast(Literal["input_validator_args", "output_validator_args"], key), + type(program).args_key, bar, - name=name, + name=program.name if isinstance(program, validate.InputValidator) else None, ) - def validator_hashes(self, cls: type["validate.AnyValidator"], bar): + def validator_hashes( + self, cls: type[validate.AnyValidator], bar: BAR_TYPE + ) -> dict[str, dict[str, str]]: """ Returns ------- @@ -138,7 +148,6 @@ def validator_hashes(self, cls: type["validate.AnyValidator"], bar): hash => - name - flags - - hash indicating which validators will be run for this testcase. """ assert cls in [validate.InputValidator, validate.AnswerValidator, validate.OutputValidator] @@ -147,29 +156,29 @@ def validator_hashes(self, cls: type["validate.AnyValidator"], bar): d = dict() for validator in validators: - flags = self.testdata_yaml_validator_args(validator, bar) - if not flags: - continue - flags_string = " ".join(flags) if flags is not None else None - o = { + flags = self.testdata_yaml_args(validator, bar) + flags_string = " ".join(flags) + h = combine_hashes_dict( + { + "name": validator.name, + "flags": flags_string, + "hash": validator.hash, + } + ) + d[h] = { "name": validator.name, "flags": flags_string, - "hash": validator.hash, } - h = combine_hashes_dict(o) - # Don't actually store the somewhat useless validator hash. - del o["hash"] - d[h] = o return d def validate_format( self, - mode: "validate.Mode", + mode: validate.Mode, *, - bar, - constraints=None, - warn_instead_of_error=False, + bar: ProgressBar, + constraints: Optional[validate.ConstraintsDict] = None, + warn_instead_of_error: bool = False, ) -> bool: check_constraints = constraints is not None @@ -263,13 +272,13 @@ def validate_format( def _run_validators( self, - mode: "validate.Mode", - validators, - expect_rejection, + mode: validate.Mode, + validators: Sequence[validate.AnyValidator], + expect_rejection: bool, *, - bar, - constraints=None, - warn_instead_of_error=False, + bar: ProgressBar, + constraints: Optional[validate.ConstraintsDict] = None, + warn_instead_of_error: bool = False, ) -> bool: args = [] results = [] @@ -278,10 +287,8 @@ def _run_validators( if isinstance(validator, validate.OutputValidator) and mode == validate.Mode.ANSWER: args += ["case_sensitive", "space_change_sensitive"] name = f"{name} (ans)" - flags = self.testdata_yaml_validator_args(validator, bar) - if flags is False: - continue - flags = args if flags is None else flags + args + flags = self.testdata_yaml_args(validator, bar) + flags = flags + args ret = validator.run(self, mode=mode, constraints=constraints, args=flags) results.append(ret.status) @@ -325,7 +332,7 @@ def _run_validators( data += ( f"{Style.RESET_ALL}-> {shorten_path(self.problem, file.parent) / file.name}\n" ) - else: + elif ret.err: data = ret.err if expect_rejection: @@ -343,7 +350,7 @@ def _run_validators( ) else: bar.part_done( - ret.status, + bool(ret.status), message, data=data, warn_instead_of_error=warn_instead_of_error, diff --git a/bin/tools.py b/bin/tools.py index 50a6c1ea6..5edac5b23 100755 --- a/bin/tools.py +++ b/bin/tools.py @@ -637,21 +637,25 @@ def build_parser(): ) genparser.add_argument( "--no-validators", + default=False, action="store_true", help="Ignore results of input and answer validation. Validators are still run.", ) genparser.add_argument( "--no-solution", + default=False, action="store_true", help="Skip generating .ans/.interaction files with the solution.", ) genparser.add_argument( "--no-visualizer", + default=False, action="store_true", help="Skip generating graphics with the visualizer.", ) genparser.add_argument( "--no-testcase-sanity-checks", + default=False, action="store_true", help="Skip sanity checks on testcases.", ) @@ -693,6 +697,12 @@ def build_parser(): action="store_true", help="Do not run `generate` before running submissions.", ) + runparser.add_argument( + "--visualizer", + dest="no_visualizer", + action="store_false", + help="Also run the output visualizer.", + ) runparser.add_argument( "--all", "-a", diff --git a/bin/upgrade.py b/bin/upgrade.py index e9b6bff37..cbf2a5413 100644 --- a/bin/upgrade.py +++ b/bin/upgrade.py @@ -13,6 +13,64 @@ from ruamel.yaml.comments import CommentedMap, CommentedSeq +# src_base must be a dir (or symlink to dir) +# dst_base must not exists +# the parents of dst_base must exist +def _move_dir(src_base: Path, dst_base: Path) -> None: + assert src_base.is_dir() + assert not dst_base.exists() + + src_base = src_base.absolute() + dst_base = dst_base.absolute() + base = [a for a, b in zip(reversed(src_base.parents), reversed(dst_base.parents)) if a == b][-1] + + def resolve_up(parts: tuple[str, ...]) -> Path: + resolved: list[str] = [] + for part in parts: + if part == ".": + continue + if part == ".." and len(resolved) and resolved[-1] != "..": + resolved.pop() + else: + resolved.append(part) + return Path(*resolved) + + def movetree(src: Path, dst: Path) -> None: + if src.is_symlink(): + # create a new symlink and make sure that the destination is handled properly + destination = src.readlink() + if destination.is_absolute(): + # absolute links should stay absolute + # if their destination is inside the dir we move we have to change it + if destination.is_relative_to(src_base): + destination = dst_base / destination.relative_to(src_base) + dst.symlink_to(destination) + src.unlink() + else: + if resolve_up(src.parent.parts + destination.parts).is_relative_to(src_base): + # the link is relative and points to another file we move + src.rename(dst) + else: + # the link is relative but points to a fixed place + src_rel = src.parent.relative_to(base) + dst_rel = dst.parent.relative_to(base) + parts = (("..",) * len(dst_rel.parts)) + src_rel.parts + destination.parts + dst.symlink_to(resolve_up(parts)) + src.unlink() + elif src.is_dir(): + # recursively move stuff inside dirs + dst.mkdir() + for file in [*src.iterdir()]: + movetree(file, dst / file.name) + # delete now empty dir + src.rmdir() + else: + # move file + src.rename(dst) + + movetree(src_base, dst_base) + + def upgrade_data(problem_path: Path, bar: ProgressBar) -> None: rename = [ ("data/invalid_inputs", "data/invalid_input"), @@ -61,8 +119,8 @@ def rename_testcase(old_base: Path, new_dir: Path) -> None: def upgrade_testdata_yaml(problem_path: Path, bar: ProgressBar) -> None: rename = [ - ("output_validator_flags", "output_validator_args"), - ("input_validator_flags", "input_validator_args"), + ("output_validator_flags", OutputValidator.args_key), + ("input_validator_flags", InputValidator.args_key), ] for f in (problem_path / "data").rglob("testdata.yaml"): @@ -91,6 +149,11 @@ def upgrade_generators_yaml(problem_path: Path, bar: ProgressBar) -> None: changed = False + if "visualizer" in yaml_data: + warn( + "Cannot automatically upgrade 'visualizer'.\n - move visualizer to 'test_case_visualizer/'\n - first argument is the in_file\n - second argument is the ans_file" + ) + if "data" in yaml_data and isinstance(yaml_data["data"], dict): data = cast(CommentedMap, yaml_data["data"]) @@ -153,8 +216,8 @@ def upgrade_generated_testdata_yaml(data: dict[str, Any], path: str) -> bool: print_path = f" ({path[1:]})" if len(path) > 1 else "" rename = [ - ("output_validator_flags", "output_validator_args"), - ("input_validator_flags", "input_validator_args"), + ("output_validator_flags", OutputValidator.args_key), + ("input_validator_flags", InputValidator.args_key), ] for old, new in rename: if old in testdata: @@ -244,31 +307,7 @@ def upgrade_output_validators(problem_path: Path, bar: ProgressBar) -> None: bar.log( f"renaming 'output_validators/{content[0].name}' to '{OutputValidator.source_dir}/'" ) - - def move(src: str, dst: str) -> None: - if Path(src).is_symlink(): - src_dst = Path(src).resolve() - if src_dst.is_relative_to(content[0]): # local symlink - Path(src).rename(dst) - else: # link outside output_validators/ - dst_pos = Path(dst).resolve() - common = [ - a - for a, b in zip(reversed(src_dst.parents), reversed(dst_pos.parents)) - if a == b - ][-1] - link = Path( - "../" * (len(dst_pos.parents) - len(common.parts)) - ) / src_dst.relative_to(common) - Path(dst).symlink_to(link) - Path(src).unlink() - else: - Path(src).rename(dst) - - shutil.copytree( - content[0], problem_path / OutputValidator.source_dir, copy_function=move - ) - shutil.rmtree(problem_path / "output_validators") + _move_dir(content[0], problem_path / OutputValidator.source_dir) else: bar.log(f"renaming 'output_validators/' to '{OutputValidator.source_dir}/'") (problem_path / "output_validators").rename(problem_path / OutputValidator.source_dir) @@ -365,14 +404,17 @@ def upgrade_problem_yaml(problem_path: Path, bar: ProgressBar) -> None: ryaml_filter(data, "limits") def add_args(new_data: dict[str, Any]) -> bool: - if "output_validator_args" in new_data: + if OutputValidator.args_key in new_data: bar.error( - "can't change 'validator_flags', 'output_validator_args' already exists in testdata.yaml", + f"can't change 'validator_flags', '{OutputValidator.args_key}' already exists in testdata.yaml", resume=True, ) return False - bar.log("change 'validator_flags' to 'output_validator_args' in testdata.yaml") - new_data["output_validator_args"] = data["validator_flags"] + bar.log(f"change 'validator_flags' to '{OutputValidator.args_key}' in testdata.yaml") + validator_flags = data["validator_flags"] + new_data[OutputValidator.args_key] = ( + validator_flags.split() if isinstance(validator_flags, str) else validator_flags + ) ryaml_filter(data, "validator_flags") return True diff --git a/bin/util.py b/bin/util.py index f86911338..7475fd4e4 100644 --- a/bin/util.py +++ b/bin/util.py @@ -791,7 +791,7 @@ def write_yaml( exit(1) if path is None: return yamllib.dump(data) - with open(path, "w") as stream: + with path.open("w") as stream: yamllib.dump(data, stream) return None with write_yaml_lock: @@ -993,41 +993,46 @@ def strip_newline(s: str) -> str: # When output is True, copy the file when args.cp is true. -def ensure_symlink(link: Path, target: Path, output: bool = False, relative: bool = False) -> None: - # on windows copy if necessary - if is_windows() and not windows_can_symlink: - if link.exists() or link.is_symlink(): - link.unlink() - shutil.copyfile(target, link) - return +def ensure_symlink(link: Path, target: Path, output: bool = False, relative: bool = False) -> bool: + try: + # on windows copy if necessary + if is_windows() and not windows_can_symlink: + if link.exists() or link.is_symlink(): + link.unlink() + shutil.copyfile(target, link) + return True - # For output files: copy them on Windows, or when --cp is passed. - if output and config.args.cp: - if link.exists() or link.is_symlink(): - link.unlink() - shutil.copyfile(target, link) - return + # For output files: copy them on Windows, or when --cp is passed. + if output and config.args.cp: + if link.exists() or link.is_symlink(): + link.unlink() + shutil.copyfile(target, link) + return True - # Do nothing if link already points to the right target. - if link.is_symlink() and link.resolve() == target.resolve(): - is_absolute = os.readlink(link) - if not relative and is_absolute: - return - # if relative and not is_absolute: return + # Do nothing if link already points to the right target. + if link.is_symlink() and link.resolve() == target.resolve(): + is_absolute = os.readlink(link) + if not relative and is_absolute: + return True + # if relative and not is_absolute: return - if link.is_symlink() or link.exists(): - if link.is_dir() and not link.is_symlink(): - shutil.rmtree(link) - else: - link.unlink() + if link.is_symlink() or link.exists(): + if link.is_dir() and not link.is_symlink(): + shutil.rmtree(link) + else: + link.unlink() - # for windows the symlink needs to know if it points to a directory or file - if relative: - # Rewrite target to be relative to link. - # Use os.path.relpath instead of Path.relative_to for non-subdirectories. - link.symlink_to(os.path.relpath(target, link.parent), target.is_dir()) - else: - link.symlink_to(target.resolve(), target.is_dir()) + # for windows the symlink needs to know if it points to a directory or file + if relative: + # Rewrite target to be relative to link. + # Use os.path.relpath instead of Path.relative_to for non-subdirectories. + link.symlink_to(os.path.relpath(target, link.parent), target.is_dir()) + else: + link.symlink_to(target.resolve(), target.is_dir()) + return True + except (FileNotFoundError, FileExistsError): + # this must be a race condition + return False def has_substitute( @@ -1478,7 +1483,7 @@ def hash_file_content(file: Path, buffer_size: int = 65536) -> str: raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(file)) sha = hashlib.sha512(usedforsecurity=False) - with open(file, "rb") as f: + with file.open("rb") as f: while True: data = f.read(buffer_size) if not data: @@ -1496,7 +1501,7 @@ def hash_file(file: Path, buffer_size: int = 65536) -> str: sha.update(len(name).to_bytes(8, "big")) sha.update(name) - with open(file, "rb") as f: + with file.open("rb") as f: while True: data = f.read(buffer_size) if not data: diff --git a/bin/validate.py b/bin/validate.py index 0eda6c1f7..6ca13e3f8 100644 --- a/bin/validate.py +++ b/bin/validate.py @@ -7,10 +7,10 @@ import config import program -import testcase if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 import run + import testcase class Mode(Enum): @@ -174,7 +174,7 @@ def format_exec_code_map(returncode): return ExecStatus.ERROR if self.language == "checktestdata": - with main_path.open() as main_file: + with main_path.open("rb") as main_file: return self._exec_command( self.run_command, exec_code_map=format_exec_code_map, @@ -209,7 +209,7 @@ def _exec_helper(self, *args, cwd, **kwargs): def run( self, - testcase: testcase.Testcase, + testcase: "testcase.Testcase", mode: Mode, constraints: Optional[ConstraintsDict] = None, args: Optional[list[str]] = None, @@ -230,12 +230,14 @@ class InputValidator(Validator): source_dir: Final[str] = "input_validators" + args_key: Final[str] = "input_validator_args" + def __init__(self, problem, path, **kwargs): super().__init__(problem, path, InputValidator.source_dir, **kwargs) def run( self, - testcase: testcase.Testcase, + testcase: "testcase.Testcase", mode: Mode = Mode.INPUT, constraints: Optional[ConstraintsDict] = None, args: Optional[list[str]] = None, @@ -263,7 +265,7 @@ def run( invocation = self.run_command.copy() - with testcase.in_path.open() as in_file: + with testcase.in_path.open("rb") as in_file: ret = self._exec_helper( invocation + arglist, exec_code_map=validator_exec_code_map, @@ -290,12 +292,15 @@ class AnswerValidator(Validator): source_dir: Final[str] = "answer_validators" + # use output_validator_args as well + args_key: Final[str] = "output_validator_args" + def __init__(self, problem, path, **kwargs): super().__init__(problem, path, AnswerValidator.source_dir, **kwargs) def run( self, - testcase: testcase.Testcase, + testcase: "testcase.Testcase", mode: Mode = Mode.ANSWER, constraints: Optional[ConstraintsDict] = None, args: Optional[list[str]] = None, @@ -316,7 +321,7 @@ def run( invocation = self.run_command + [testcase.in_path.resolve()] - with testcase.ans_path.open() as ans_file: + with testcase.ans_path.open("rb") as ans_file: ret = self._exec_helper( invocation + arglist, exec_code_map=validator_exec_code_map, @@ -341,12 +346,14 @@ class OutputValidator(Validator): source_dir: Final[str] = "output_validator" + args_key: Final[str] = "output_validator_args" + def __init__(self, problem, path, **kwargs): super().__init__(problem, path, OutputValidator.source_dir, **kwargs) def run( self, - testcase: testcase.Testcase, + testcase: "testcase.Testcase", mode: "Mode | run.Run", constraints: Optional[ConstraintsDict] = None, args: Optional[list[str]] = None, @@ -358,7 +365,7 @@ def run( --------- mode: either a run.Run (namely, when validating submission output) or a Mode - (namely, when validation a testcase) + (namely, when validating a testcase) Returns ------- @@ -368,7 +375,7 @@ def run( assert self.run_command is not None, "Validator should be built before running it" if mode == Mode.INPUT: - raise ValueError("OutputValidator do not support Mode.INPUT") + raise ValueError("OutputValidator does not support Mode.INPUT") in_path = testcase.in_path.resolve() ans_path = testcase.ans_path.resolve() @@ -388,7 +395,7 @@ def run( assert mode != Mode.INPUT # mode is actually a Run path = mode.out_path - in_path = mode.in_path + in_path = mode.in_path # relevant for multipass if self.language in Validator.FORMAT_VALIDATOR_LANGUAGES: raise ValueError("Invalid output validator language") @@ -398,7 +405,7 @@ def run( cwd = mode.feedbackdir invocation = self.run_command + [in_path, ans_path, cwd] - with path.open() as file: + with path.open("rb") as file: ret = self._exec_helper( invocation + arglist, exec_code_map=validator_exec_code_map, @@ -460,7 +467,7 @@ def sanity_check(problem, path, bar, strict_whitespace=True): if not path.exists(): fatal(f"{path} not found during sanity check") - with open(path, "rb") as file: + with path.open("rb") as file: name = { ".in": "Input", ".ans": "Answer", @@ -475,7 +482,9 @@ def sanity_check(problem, path, bar, strict_whitespace=True): if _has_invalid_byte(file_bytes, other_whitespaces=not strict_whitespace): bar.warn(f"{name} contains unexpected characters but was accepted!") - elif len(file_bytes) == 0: + elif len(file_bytes) == 0 and not ( + path.suffix == ".ans" and problem.multi_pass + ): # explicitly allow empty .ans files for multipass bar.warn(f"{name} is empty but was accepted!") elif len(file_bytes) > 20_000_000: bar.warn(f"{name} is larger than 20MB!") @@ -491,7 +500,7 @@ def sanity_check(problem, path, bar, strict_whitespace=True): and 2 * len(file_bytes) > problem.limits.output * 1024 * 1024 ): bar.warn(f"{name} is close to output limit") - elif strict_whitespace: + elif strict_whitespace and len(file_bytes) > 0: if file_bytes[0] in [ord(" "), ord("\n")]: bar.warn(f"{name} starts with whitespace but was accepted!") elif file_bytes[-1] != ord("\n"): diff --git a/bin/visualize.py b/bin/visualize.py new file mode 100644 index 000000000..074248cfd --- /dev/null +++ b/bin/visualize.py @@ -0,0 +1,97 @@ +from pathlib import Path +from typing import Any, Final, Optional, TYPE_CHECKING + +import program + +from util import * + +if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 + from problem import Problem + + +class TestCaseVisualizer(program.Program): + """ + Visualizes a test case, called as: + + ./visualizer input answer [args] + + """ + + visualizer_type: Final[str] = "test case" + + source_dir: Final[str] = "test_case_visualizer" + + args_key: Final[str] = "test_case_visualizer_args" + + def __init__(self, problem: "Problem", path: Path, **kwargs: Any): + super().__init__( + problem, + path, + TestCaseVisualizer.source_dir, + limits={"timeout": problem.limits.visualizer_time}, + substitute_constants=True, + **kwargs, + ) + + # Run the visualizer (should create a testcase. file). + def run( + self, in_path: Path, ans_path: Path, cwd: Path, args: Optional[list[str]] = None + ) -> ExecResult: + assert self.run_command is not None, ( + "Test Case Visualizer should be built before running it" + ) + + return self._exec_command( + self.run_command + [in_path, ans_path] + (args or []), + cwd=cwd, + ) + + +class OutputVisualizer(program.Program): + """ + Visualizes the output of a submission + + ./visualizer input answer feedbackdir [args] < output + + """ + + visualizer_type: Final[str] = "output" + + source_dir: Final[str] = "output_visualizer" + + args_key: Final[str] = "output_visualizer_args" + + def __init__(self, problem: "Problem", path: Path, **kwargs: Any): + super().__init__( + problem, + path, + OutputVisualizer.source_dir, + limits={"timeout": problem.limits.visualizer_time}, + substitute_constants=True, + **kwargs, + ) + + # Run the visualizer. + # should write to feedbackdir/judgeimage. and/or feedbackdir/teamimage. + def run( + self, + in_path: Path, + ans_path: Path, + out_path: Optional[Path], + cwd: Path, + args: Optional[list[str]] = None, + ) -> ExecResult: + assert self.run_command is not None, "Output Visualizer should be built before running it" + assert (out_path is None) == self.problem.interactive, ( + "out_path should be None if and only if problem is interactive" + ) + + command = self.run_command + [in_path, ans_path, cwd] + (args or []) + if out_path is not None: + with out_path.open("rb") as out_file: + return self._exec_command(command, stdin=out_file, cwd=cwd) + else: + return self._exec_command(command, cwd=cwd) + + +AnyVisualizer = TestCaseVisualizer | OutputVisualizer diff --git a/doc/commands.md b/doc/commands.md index 8ae849ed4..102f03603 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -112,6 +112,7 @@ Use `bt run -v` to show results for all testcases. - `--overview`/`-o`: Print a live overview of the received verdicts for all submissions and testcases. If combined with `--no-bar` only the final table is printed. - `--no-testcase-sanity-checks`: when passed, all sanity checks on the testcases are skipped. You might want to set this in `.bapctools.yaml`. - `--sanitizer`: when passed, run submissions with additional sanitizer flags (currently only C++). Note that this removes all memory limits for submissions. +- `--visualizer`: when passed, run the output visualizer. ## `test` diff --git a/doc/generators.md b/doc/generators.md index edf424ecb..572ed2700 100644 --- a/doc/generators.md +++ b/doc/generators.md @@ -28,8 +28,6 @@ The two main object types are `directory` and `generator`. The root of `generato - `testdata.yaml`: Optional yaml configuration that will be copied to `testdata.yaml` in this directory. - `solution`: Optional invocation of a solution to be used to generate `.ans` files. Set to empty to disable generating `.ans`. (Useful for e.g. the `data/samples/` directory.) This must be an absolute path relative to the problem root. -- `visualizer`: Optional invocation of a visualizer to generate visualizations for each test case in this directory. - This must be an absolute path relative to the problem root. Set to empty to disable. - `random_salt`: Optional string that will be prepended to each command before computing its `{seed}`. May be used to regenerate all random cases and to prevent predictable seeds. - `data`: The test cases / test groups contained in this directory. This may take two forms: - A dictionary, each key is the name of a test case/test group, and each value must be a `directory` or `generator` object. diff --git a/doc/generators.yaml b/doc/generators.yaml index b6e13a7bd..fb78d06d0 100644 --- a/doc/generators.yaml +++ b/doc/generators.yaml @@ -11,24 +11,13 @@ # TOOLING: may pick a default if not specified, but should raise an error. solution: /submissions/accepted/sol.py -# The visualizer is used when no suitable image was generated already. -# This should read `testcase.in` and/or `testcase.ans` from the current working -# directory, and write `testcase.ext` for an extension in: -# .png, .jpg, .svg -# -# This must be the absolute path, starting in the problem root. -# -# TOOLING: may provide a flag to make running this optional, as it can be slow -# and usually isn't required. -visualizer: /visualizers/vis.py - # Optionally, a salt for generating the {seed} variables. Will be prepended to # the command being run. random_salt: abcd # The top level may contain a testdata.yaml that will be written to data/ as specified. testdata.yaml: - output_validator_args: "" + output_validator_args: [] # We support three types of generators: # - Standalone files, like generators/a.cpp, generators/b.py, ..., which will @@ -116,7 +105,7 @@ data: secret: include: # You can include other testcroups by there yaml name - - 'sample' + - "sample" # This will include "1", "2", "3", "4", and "5" from sample data: # Types of generator programs. @@ -147,8 +136,8 @@ data: 11-random-3: graph seed={seed:2} # Different seed, because command isn't the same. #11-random-4: graph {seed} {seed:2} # Not allowed because the regex matches twice. 12-counted: - generate: graph {seed:3} {count} - count: 2 # generate two testcases at once + generate: graph {seed:3} {count} + count: 2 # generate two testcases at once # No key (testcase or testgroup) may be a prefix of another key. #01-second: graph 6 # Collision with rule 01 above. @@ -169,12 +158,11 @@ data: "13": write_in_and_ans.py # To override the global/testgroup configuration on a per-testcase basis, - # a dictionary may be used. This allows the solution: and visualizer: keys, + # a dictionary may be used. This allows the solution: key, # as well as the generate: key which contains the command to execute. - 14_no_visualizer: + 14_override: generate: large_case_generator.py 1000000 solution: /generators/gnu_multi_precision.cpp - visualizer: # Empty to disable the visualizer here. random_salt: "123" # An entry must include *some* key that produces an in-file, @@ -189,7 +177,7 @@ data: hard_cases_group: # Directories may contain a testdata.yaml that will be written as specified. testdata.yaml: - output_validator_args: space_change_sensitive + output_validator_args: [space_change_sensitive] # To enable automatic numbering of testcases, data: may also contain a list of # single-element dictionaries instead of a single dictionary. In this case, diff --git a/skel/problem/generators/example.py b/skel/problem/generators/example_generator.py similarity index 100% rename from skel/problem/generators/example.py rename to skel/problem/generators/example_generator.py diff --git a/skel/problem/generators/generators.yaml b/skel/problem/generators/generators.yaml index 2c3a3fe72..fa2e7209f 100644 --- a/skel/problem/generators/generators.yaml +++ b/skel/problem/generators/generators.yaml @@ -1,6 +1,5 @@ #solution: /submissions/accepted/submission.py -#visualizer: /visualizers/asy.sh -version: 2025-02 # use this version of the generators framework +version: 2025-04 # use this version of the generators framework {%testdata_yaml_comment%}testdata.yaml: # One or more of: diff --git a/skel/problem/output_visualizer/example_output_visualizer.py b/skel/problem/output_visualizer/example_output_visualizer.py new file mode 100644 index 000000000..0d27bcb5e --- /dev/null +++ b/skel/problem/output_visualizer/example_output_visualizer.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 +import sys + +input_file = open(sys.argv[1]).read().strip() +answer_file = open(sys.argv[2]).read().strip() +# input yields the team output +args = sys.argv[4:] +with open(f"{sys.argv[3]}/judgeimage.svg", "w") as f: + # this is unsafe since args could contain svg tags + print(f"args: {args}", file=f) diff --git a/skel/problem/test_case_visualizer/example_test_case_visualizer.py b/skel/problem/test_case_visualizer/example_test_case_visualizer.py new file mode 100644 index 000000000..df1162d22 --- /dev/null +++ b/skel/problem/test_case_visualizer/example_test_case_visualizer.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +import sys + +input_file = open(sys.argv[1]).read().strip() +answer_file = open(sys.argv[2]).read().strip() +args = sys.argv[3:] +with open("testcase.svg", "w") as f: + # this is unsafe since args could contain svg tags + print(f"args: {args}", file=f) diff --git a/skel/problem/test_case_visualizer/readme.md b/skel/problem/test_case_visualizer/readme.md new file mode 100644 index 000000000..88516a9c0 --- /dev/null +++ b/skel/problem/test_case_visualizer/readme.md @@ -0,0 +1,2 @@ +This test case visualizer is intended for use with BAPCtools' `bt generate`. +The visualizer should be invoked as `./visualizer <...test_case_visualizer_args>` and should write a `testcase.` file. diff --git a/support/schemas/generators.cue b/support/schemas/generators.cue index fa5f20b32..5dce093ee 100644 --- a/support/schemas/generators.cue +++ b/support/schemas/generators.cue @@ -20,12 +20,10 @@ import "strings" _parts: [#path, ...#command_args] } -// Test cases and test groups allow configuration of solution, visualiser, and random salt. +// Test cases and test groups allow configuration of solution, and random salt. #config: { // Path to solution starts with slash, such as "/submissions/accepted/foo.py" solution?: #filepath & =~"^/" - // Visualiser can be omitted to disable visualisation, may not use {count} - visualizer?: #command & =~"^/" & !~"\\{count" | null random_salt?: string } @@ -73,7 +71,7 @@ import "strings" valid_output?: #testgroup }) #testgroup_config - version: =~"^[0-9]{4}-[0-9]{2}$" | *"2025-02" + version: =~"^[0-9]{4}-[0-9]{2}$" | *"2025-04" ... // Do allow unknown_key at top level for tooling } diff --git a/support/schemas/generators_yaml_schema.json b/support/schemas/generators_yaml_schema.json index f75b294bb..3377488a3 100644 --- a/support/schemas/generators_yaml_schema.json +++ b/support/schemas/generators_yaml_schema.json @@ -43,9 +43,6 @@ "solution": { "$ref": "#/$defs/solution" }, - "visualizer": { - "$ref": "#/$defs/visualizer" - } }, "additionalProperties": false }, @@ -67,9 +64,6 @@ }, "input_validator_args": { "oneOf": [ - { - "type": "string" - }, { "type": "array", "items": { @@ -80,7 +74,10 @@ "type": "object", "patternProperties": { "^([A-Za-z0-9][A-Za-z0-9_-]*[A-Za-z0-9]|[A-Za-z0-9])$":{ - "type": "string" + "type": "array", + "items": { + "type": "string" + } } } } @@ -88,19 +85,26 @@ "description": "Defines arguments passed to each input validator for the test case/group. If a sequence of strings, then those are the arguments that will be passed to each input validator for this the case/group. If a map, then each key is the name of the input validator and the value is the arguments to pass to that input validator for the test case/group. Validators not present in the map are run without any arguments." }, "output_validator_args": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ], + "type": "array", + "items": { + "type": "string" + }, "description": "Defines arguments passed to the output validator for the test case/group." }, + "test_case_visualizer_args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Defines arguments passed to the test case visualizer for the test case/group." + }, + "output_visualizer_args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Defines arguments passed to the output visualizer for the test case/group." + }, "input_validator_flags": { "type": "string", "deprecated": true, @@ -109,7 +113,7 @@ "output_validator_flags": { "type": "string", "deprecated": true, - "description": "With 'problem_format_version: 2023-07-draft' in problem.yaml, use input_validator_args instead." + "description": "With 'problem_format_version: 2023-07-draft' in problem.yaml, use output_validator_args instead." }, "accept_score": { "type": "string" @@ -221,9 +225,6 @@ "title": "Hint", "description": "Feedback shown to the solver about this test case given as a string" }, - "visualizer": { - "$ref": "#/$defs/visualizer" - }, "random_salt": { "$ref": "#/$defs/random_salt" }, @@ -235,24 +236,6 @@ } ] }, - "visualizer": { - "title": "Visualizer", - "description": "Absolute path to and arguments for a visualizer. Leave empty to disable visualizion.", - "examples": [ - "/visualizer", - "/visualizers/asy.py", - "/visualizers/vis --large" - ], - "oneOf": [ - { - "type": "string", - "pattern": "^\/([^{}]|\\{name\\})*(\\{seed(:[0-9]+)?\\})?([^{}]|\\{name\\})*$" - }, - { - "type": "null" - } - ] - }, "random_salt": { "title": "Random Salt", "type": "string", @@ -297,14 +280,11 @@ } }, "additionalProperties": true, - "description": "Generate test data for this problem. Version 2025-02.", + "description": "Generate test data for this problem. Version 2025-04.", "properties": { "solution": { "$ref": "#/$defs/solution" }, - "visualizer": { - "$ref": "#/$defs/visualizer" - }, "random_salt": { "$ref": "#/$defs/random_salt" }, diff --git a/support/schemas/problemformat.cue b/support/schemas/problemformat.cue index e063dd16a..8f5fca3d5 100644 --- a/support/schemas/problemformat.cue +++ b/support/schemas/problemformat.cue @@ -23,8 +23,10 @@ let filename = "[A-Za-z0-9][A-Za-z0-9_.-]{0,253}[A-Za-z0-9]" // Test data settings #testdata_settings: { - input_validator_args?: *"" | string | {[string]: string} - output_validator_args?: *"" | string + input_validator_args?: *[] | [string] | {[string]: [string]} + output_validator_args?: *[] | [string] + test_case_visualizer_args?: *[] | [string] + output_visualizer_args?: *[] | [string] grading?: { score?: >0 max_score?: >0 diff --git a/test/problems/divsort/generators/generators.yaml b/test/problems/divsort/generators/generators.yaml index 5f44912f6..dd1d0f5b1 100644 --- a/test/problems/divsort/generators/generators.yaml +++ b/test/problems/divsort/generators/generators.yaml @@ -8,11 +8,11 @@ data: secret: testdata.yaml: input_validator_args: - integers: small + integers: [small] data: integers: testdata.yaml: - input_validator_args: --integer + input_validator_args: [--integer] #grading: foo data: - unsorted-integer: @@ -21,7 +21,7 @@ data: sorted: testdata.yaml: input_validator_args: - strings: --sorted + strings: [--sorted] data: - sorted-integer: in: 10.0 1.0 ab cd @@ -30,14 +30,14 @@ data: data: nested_1: testdata.yaml: - input_validator_args: --small + input_validator_args: [--small] data: small_floats: in: 10 3.5 ab cd nested_2: testdata.yaml: input_validator_args: - integers: "" # hides the input_validator_args in secret/testdata.yaml + integers: [] # hides the input_validator_args in secret/testdata.yaml data: - tiny_floats: in: 10.0 3.5 ab dc @@ -46,7 +46,7 @@ data: desc: Must validate, because `secret/testdata.yaml` hidden by `secret/general/nested_2/testdata.yaml` tolerant: testdata.yaml: - output_validator_args: float_tolerance 1e-2 + output_validator_args: [float_tolerance, "1e-2"] data: - tiny_floats: in: 10.0 3.0 ab dc @@ -62,14 +62,14 @@ data: too_many_tokens: {in: 10.0 2.5 ab cd ef} integers: testdata.yaml: - input_validator_args: --integer + input_validator_args: [--integer] data: ints_expected: {in: 10.0 2.5 ab cd} include: - small_floats sorted: testdata.yaml: - input_validator_args: --sorted + input_validator_args: [--sorted] include: - unsorted # invalid here because of --sorted flag (valid input in invalid_answers/no_output_validator_args) invalid_answer: @@ -84,7 +84,7 @@ data: ans: 5.0 Abccd with_output_validator_args: testdata.yaml: - output_validator_args: --forbid_abcd + output_validator_args: [--forbid_abcd] include: - imprecise # must reject because its ans includes abcd invalid_output: @@ -94,6 +94,8 @@ data: ans: 3.333333333 abcd out: 3.33 abcd valid_output: + testdata.yaml: + output_validator_args: [float_tolerance, "1e-2"] data: valid: in: 10.0 3.0 ab cd diff --git a/test/problems/fltcmp/data/testdata.yaml b/test/problems/fltcmp/data/testdata.yaml index af7ce1f19..ded323389 100644 --- a/test/problems/fltcmp/data/testdata.yaml +++ b/test/problems/fltcmp/data/testdata.yaml @@ -1 +1 @@ -output_validator_args: float_tolerance 1E-6 +output_validator_args: [float_tolerance, "1E-6"] diff --git a/test/problems/generatorincludes/generators/generators.yaml b/test/problems/generatorincludes/generators/generators.yaml index c82e8b97e..139deb187 100644 --- a/test/problems/generatorincludes/generators/generators.yaml +++ b/test/problems/generatorincludes/generators/generators.yaml @@ -11,10 +11,10 @@ data: data: - small: testdata.yaml: - output_validator_args: space_change_sensitive + output_validator_args: [space_change_sensitive] input_validator_args: - connected: --small - strongly-connected: --small + connected: [--small] + strongly-connected: [--small] data: - positive: data: diff --git a/test/problems/guess/output_visualizer_disabled/guess-visualizer.py b/test/problems/guess/output_visualizer_disabled/guess-visualizer.py new file mode 100644 index 000000000..4f201edf2 --- /dev/null +++ b/test/problems/guess/output_visualizer_disabled/guess-visualizer.py @@ -0,0 +1,32 @@ +import sys +from pathlib import Path + + +with open(sys.argv[1]) as in_file, open(sys.argv[3] / Path("judgemessage.txt"), "r") as msg_file: + mode = in_file.read().split()[0] + assert mode in ("random", "fixed", "adaptive"), mode + judgemessages = iter(msg_file) + + print(r"""\documentclass[varwidth]{standalone} +\usepackage{tikz} +\usetikzlibrary{patterns} +\tikzset{every node/.style={font=\sffamily}} +\begin{document} +\begin{tikzpicture} + """) + if not mode == "adaptive": + secret = int(next(judgemessages).split()[-1]) + print(rf"\node at ({secret / 100},-1.5) {{ {secret} ({mode}) }};") + else: + next(judgemessages) + print(r"\node at (5,-.5) { adaptive };") + for line in judgemessages: + rnd, guess = int(line.split()[1]), int(line.split()[3]) + y = -1 - rnd + print(rf"\draw [very thick, blue!20] (0, {y}) -- (10, {y});") + print(rf"\node at ({guess / 100}, {y})[anchor=north]", r"{$\uparrow$};") + print(rf"\node at ({guess / 100}, {y - 0.5})[anchor=north] {{ {guess} }};") + if not mode == "adaptive": + print(rf"\draw [red] ({secret / 100}, {-rnd - 1}) -- ({secret / 100}, 0);") + + print(r"\end{tikzpicture}\end{document}") diff --git a/test/problems/guess/output_visualizer_disabled/run b/test/problems/guess/output_visualizer_disabled/run new file mode 100755 index 000000000..62229c730 --- /dev/null +++ b/test/problems/guess/output_visualizer_disabled/run @@ -0,0 +1,41 @@ +#!/bin/bash + +# Set script directory +SCRIPT_DIR="$(dirname "$0")" + +# Check if visualize.py exists +if [[ ! -f "$SCRIPT_DIR/guess-visualizer.py" ]]; then + echo "Error: guess-visualizer.py not found in $SCRIPT_DIR" >&2 + exit 1 +fi + +tmptexdir=$(mktemp -d) # Create a unique temporary directory +OUTPUT_FILE="$tmptexdir/judgeimage.tex" + +# Run visualize.py +python3 "$SCRIPT_DIR/guess-visualizer.py" $1 $2 $3 > "$OUTPUT_FILE" +if [[ $? -ne 0 ]]; then + echo "Error: guess-visualizer.py failed" >&2 + exit 1 +fi + +# Check if judgeimage.tex exists +if [[ ! -f "$OUTPUT_FILE" ]]; then + echo "Error: texfile not found in $SCRIPT_DIR" >&2 + exit 1 +fi + +# Run pdflatex +( + cd "$tmptexdir" && pdflatex judgeimage.tex +) +if [[ $? -ne 0 ]]; then + echo "Error: pdflatex failed" >&2 + exit 1 +fi + +mv "$tmptexdir/judgeimage.pdf" $3 +rm -r "$tmptexdir" + +echo "Script completed successfully." +exit 0 diff --git a/test/problems/identity/data/sample/testdata.yaml b/test/problems/identity/data/sample/testdata.yaml new file mode 100644 index 000000000..cb6f96a79 --- /dev/null +++ b/test/problems/identity/data/sample/testdata.yaml @@ -0,0 +1 @@ +output_visualizer_args: [--draw-please] diff --git a/test/problems/identity/generators/generators.yaml b/test/problems/identity/generators/generators.yaml index d8e5c0f20..e6090d53c 100644 --- a/test/problems/identity/generators/generators.yaml +++ b/test/problems/identity/generators/generators.yaml @@ -1,6 +1,4 @@ solution: /submissions/accepted/author.py -# The visualizer is disabled to speed up testing. -#visualizer: /visualizers random_salt: "abc" generators: @@ -74,6 +72,8 @@ data: "6": in.statement: "6" ans.statement: "6" + testdata.yaml: + output_visualizer_args: [--draw-please] secret: data: @@ -133,7 +133,6 @@ data: solution: /generators/solution.c generate: random_gen.py {seed:7} testcase_dict_3: - visualizer: generate: random_gen.py {seed:8} unused_args_1: > # Spread arguments over multiple lines. random_gen.py diff --git a/test/problems/identity/output_visualizer_disabled/run b/test/problems/identity/output_visualizer_disabled/run new file mode 100755 index 000000000..d8d0ead16 --- /dev/null +++ b/test/problems/identity/output_visualizer_disabled/run @@ -0,0 +1,13 @@ +#!/usr/bin/env sh + +set -e + +draw=false +for var in "$@" +do + [ "$var" = "--draw-please" ] && draw=true +done + +if [ "$draw" = true ]; then + asy -f png $(dirname $0)/visualize.asy -u infilename="'${1}'" -u ansfilename="'${2}'" -o $3/judgeimage.png +fi diff --git a/test/problems/identity/output_visualizer_disabled/visualize.asy b/test/problems/identity/output_visualizer_disabled/visualize.asy new file mode 100644 index 000000000..dc5bda3b0 --- /dev/null +++ b/test/problems/identity/output_visualizer_disabled/visualize.asy @@ -0,0 +1,20 @@ +defaultpen(1); + +string outvalue = stdin; + +string infilename; +string ansfilename; +usersetting(); +file fin=input(infilename); +file fans=input(infilename); +string invalue = fin; +string ansvalue = fans; + +string label = "\texttt{in}: " + invalue ; +label(scale(5)*label, (0,200)); +string label = "\texttt{ans}: " + ansvalue ; +label(scale(5)*label, (0,100)); +pen labelPen = (invalue == outvalue) ? green : red; +string label = "\texttt{out}: " + outvalue ; +label(scale(5)*label, (0,0), p=labelPen); +shipout(bbox(xmargin=5, white, Fill)); diff --git a/test/problems/identity/test_case_visualizer_disabled/run b/test/problems/identity/test_case_visualizer_disabled/run new file mode 100755 index 000000000..ecc84b0c4 --- /dev/null +++ b/test/problems/identity/test_case_visualizer_disabled/run @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +set -e + +cat $1 $2 | asy -f png $(dirname $0)/visualize.asy -o testcase.png diff --git a/test/problems/identity/visualizers/visualize.asy b/test/problems/identity/test_case_visualizer_disabled/visualize.asy similarity index 100% rename from test/problems/identity/visualizers/visualize.asy rename to test/problems/identity/test_case_visualizer_disabled/visualize.asy diff --git a/test/problems/identity/visualizers/run b/test/problems/identity/visualizers/run deleted file mode 100755 index b82da4c8d..000000000 --- a/test/problems/identity/visualizers/run +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env sh - -set -e - -name=$1 -cat $name.in $name.ans | asy -f png $(dirname $0)/visualize.asy -o $name.png diff --git a/test/yaml/generators/invalid_yaml/bad_generators.yaml b/test/yaml/generators/invalid_yaml/bad_generators.yaml index 7c1f24525..2ce4851af 100644 --- a/test/yaml/generators/invalid_yaml/bad_generators.yaml +++ b/test/yaml/generators/invalid_yaml/bad_generators.yaml @@ -23,9 +23,6 @@ solution: true --- solution: false --- -# visualizer must be null or string -visualizer: 0 ---- # random_salt must be null or string random_salt: 0 --- @@ -164,21 +161,13 @@ data: data: ab: /generators/dir/gen.py --- -# Solution ans visualizer must have an absolute path: +# Solution must have an absolute path: solution: a --- solution: a/b --- solution: a 1 2 --- -visualizer: a ---- -visualizer: a/b ---- -visualizer: a 1 2 ---- -visualizer: a {name} ---- # Directories may not have generate:. generate: xyz --- diff --git a/test/yaml/generators/invalid_yaml/invalid.generators.yaml b/test/yaml/generators/invalid_yaml/invalid.generators.yaml index b78f8e92a..d4618cc0c 100644 --- a/test/yaml/generators/invalid_yaml/invalid.generators.yaml +++ b/test/yaml/generators/invalid_yaml/invalid.generators.yaml @@ -29,10 +29,6 @@ data: {sample: {data: []}, secret: {data: []}} solution: false data: {sample: {data: []}, secret: {data: []}} --- -# visualizer must be null or string -visualizer: 0 -data: {sample: {data: []}, secret: {data: []}} ---- # random_salt must be null or string random_salt: 0 data: {sample: {data: []}, secret: {data: []}} @@ -266,7 +262,7 @@ data: a: generate: /generators/gen.py --- -# Solution and visualizer must have an absolute path: +# Solution must have an absolute path: solution: a data: {sample: {data: []}, secret: {data: []}} --- @@ -276,18 +272,6 @@ data: {sample: {data: []}, secret: {data: []}} solution: a 1 2 data: {sample: {data: []}, secret: {data: []}} --- -visualizer: a -data: {sample: {data: []}, secret: {data: []}} ---- -visualizer: a/b -data: {sample: {data: []}, secret: {data: []}} ---- -visualizer: a 1 2 -data: {sample: {data: []}, secret: {data: []}} ---- -visualizer: a {name} -data: {sample: {data: []}, secret: {data: []}} ---- ## No toplevel generate TODO #generate: xyz #data: {sample: {data: []}, secret: {data: []}} @@ -405,6 +389,5 @@ data: data: - '': in: '1 2' - visualizer: "/ab/c" # this is fine - testdata.yaml: # this is not - input_validator_args: "connected" + testdata.yaml: # this is not ok + input_validator_args: [connected] diff --git a/test/yaml/generators/valid_yaml/rich-generators.yaml b/test/yaml/generators/valid_yaml/rich-generators.yaml index b2f5d743f..4e32ed274 100644 --- a/test/yaml/generators/valid_yaml/rich-generators.yaml +++ b/test/yaml/generators/valid_yaml/rich-generators.yaml @@ -29,8 +29,7 @@ data: count: 5 'group_with_testdata': testdata.yaml: - input_validator_args: "--connected --max_n 2000" - visualizer: "/foo/bar/baz" + input_validator_args: [--connected, --max_n, "2000"] data: 'a': my_generator invalid_input: From 1f528b78f7b92984d848f4c735a64ce9c83e9ccb Mon Sep 17 00:00:00 2001 From: mzuenni Date: Sat, 3 May 2025 22:15:27 +0200 Subject: [PATCH 22/23] allow latex in subdirs --- bin/latex.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/bin/latex.py b/bin/latex.py index b2df45b33..38e7c94a6 100644 --- a/bin/latex.py +++ b/bin/latex.py @@ -17,6 +17,7 @@ ensure_symlink, exec_command, fatal, + is_windows, message, MessageType, PrintBar, @@ -232,24 +233,20 @@ def problem_data(problem: "Problem", language: str): def make_environment() -> dict[str, str]: + sep = ";" if is_windows() else ":" env = os.environ.copy() # Search the contest directory and the latex directory. latex_paths = [ - Path.cwd(), - Path.cwd() / "solve_stats", - Path.cwd() / "solve_stats" / "activity", - config.TOOLS_ROOT / "latex", + f"{Path.cwd()}", + f"{Path.cwd() / 'solve_stats'}//", + f"{Path.cwd() / 'latex'}//", + f"{config.TOOLS_ROOT / 'latex'}//", ] - texinputs = "" - for p in latex_paths: - texinputs += str(p) + ";" + texinputs = sep.join(latex_paths) + sep if config.args.verbose >= 2: print(f"export TEXINPUTS='{texinputs}'", file=sys.stderr) if "TEXINPUTS" in env: - prev = env["TEXINPUTS"] - if len(prev) > 0 and prev[-1] != ";": - prev += ";" - texinputs = prev + texinputs + texinputs = texinputs + env["TEXINPUTS"] env["TEXINPUTS"] = texinputs return env From d99d699b50b0176ef5c671436bf99fcf9e89b060 Mon Sep 17 00:00:00 2001 From: mzuenni Date: Tue, 6 May 2025 16:35:17 +0200 Subject: [PATCH 23/23] .interaction file is based on output_validator --- bin/generate.py | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/bin/generate.py b/bin/generate.py index e1d5465d1..946267095 100644 --- a/bin/generate.py +++ b/bin/generate.py @@ -217,7 +217,7 @@ def run(self, bar, cwd): bar.log("stderr", result.err) return result - def run_interaction(self, bar, cwd, t): + def generate_interaction(self, bar, cwd, t): in_path = cwd / "testcase.in" interaction_path = cwd / "testcase.interaction" interaction_path.unlink(missing_ok=True) @@ -787,6 +787,7 @@ def init_meta(): "generated_extensions": [], "input_validator_hashes": dict(), "solution_hash": dict(), + "interactor_hash": dict(), "ans_out_validator_hashes": dict(), "visualizer_hash": dict(), } @@ -924,7 +925,7 @@ def generate_from_rule(): assert t._has_required_in(infile), f"Failed to generate in file: {infile.name}" return True - def generate_from_solution(testcase: Testcase): + def generate_from_solution(testcase: Testcase, bar: ProgressBar): nonlocal meta_yaml if testcase.root in [*config.INVALID_CASE_DIRECTORIES, "valid_output"]: @@ -943,11 +944,16 @@ def generate_from_solution(testcase: Testcase): "solution": None, } - def needed(ext): + def needed(ext, interactor_hash=None): if ext in meta_yaml["generated_extensions"]: return False if not infile.with_suffix(ext).is_file(): return True + if ( + interactor_hash is not None + and meta_yaml.get("interactor_hash") != interactor_hash + ): + return True return meta_yaml.get("solution_hash") != solution_hash used_solution = False @@ -959,19 +965,21 @@ def needed(ext): ansfile.write_text("") changed_ans = True # For interactive/multi-pass problems, run the solution and generate a .interaction if necessary. - if ( - (problem.interactive or problem.multi_pass) - and t.config.solution - and (testcase.root == "sample" or config.args.interaction) - and needed(".interaction") - and not any( - infile.with_suffix(ext).is_file() - for ext in [".out", ".in.statement", ".ans.statement"] - ) - ): - if not t.config.solution.run_interaction(bar, cwd, t): - return False - used_solution = True + if problem.interactive or problem.multi_pass: + interactor_hash = testcase.validator_hashes(validate.OutputValidator, bar) + if ( + t.config.solution + and (testcase.root == "sample" or config.args.interaction) + and needed(".interaction", interactor_hash) + and not any( + infile.with_suffix(ext).is_file() + for ext in [".out", ".in.statement", ".ans.statement"] + ) + ): + if not t.config.solution.generate_interaction(bar, cwd, t): + return False + used_solution = True + meta_yaml["interactor_hash"] = interactor_hash else: # Generate a .ans if not already generated by earlier steps. if needed(".ans"): @@ -1212,7 +1220,7 @@ def add_testdata_to_cache(): return # Step 4: generate .ans and .interaction if needed - if not generate_from_solution(testcase): + if not generate_from_solution(testcase, bar): return # Step 5: validate .ans (and .out if it exists)