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 f0010f6c9..f88f7e513 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', 'default_solution', 'depth', 'directory', 'error', 'force', 'force_build', 'generic', 'input', 'interaction', 'interactive', 'invalid', 'kattis', 'language', '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', 'default_solution', 'depth', 'directory', 'error', 'force', 'force_build', 'generic', 'input', 'interaction', 'interactive', 'invalid', 'kattis', 'lang', '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 e816a5540..0f57bd091 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 @@ -140,7 +139,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 = { @@ -288,11 +287,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 0a9b47e22..c17743f1e 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 @@ -808,12 +806,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.", + ) subparsers.add_parser( "gitlabci", @@ -831,6 +839,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", @@ -846,6 +859,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( @@ -1009,8 +1027,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": @@ -1142,10 +1160,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={}) @@ -1159,10 +1183,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) @@ -1190,48 +1212,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 2f8a2c768..cda4cf1f8 100644 --- a/doc/commands.md +++ b/doc/commands.md @@ -55,7 +55,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):