diff --git a/.gitignore b/.gitignore index cfd8d14e7..8f9a1687f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ latex/solution*.pdf skel/problem*.zip __pycache__ *.swp +/venv +/dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c53218981..109dc18af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: - id: ruff args: [ --fix ] - id: ruff - files: ^bin/.*\.py$ + files: ^bapctools/.*\.py$ args: ["--select=I", "--fix"] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy @@ -36,4 +36,4 @@ repos: - --python-version=3.10 - --scripts-are-modules - --strict - exclude: ^(test|skel|scripts|bin/misc)/ + exclude: ^(test|scripts|bin/misc|bapctools/resources)/ diff --git a/skel/problem/submissions/accepted/.gitkeep b/bapctools/__init__.py similarity index 100% rename from skel/problem/submissions/accepted/.gitkeep rename to bapctools/__init__.py diff --git a/bapctools/__main__.py b/bapctools/__main__.py new file mode 100644 index 000000000..b49cd3768 --- /dev/null +++ b/bapctools/__main__.py @@ -0,0 +1,3 @@ +from bapctools.cli import main + +main() diff --git a/bin/check_testing_tool.py b/bapctools/check_testing_tool.py similarity index 97% rename from bin/check_testing_tool.py rename to bapctools/check_testing_tool.py index 64bebd591..1a69d3c2b 100644 --- a/bin/check_testing_tool.py +++ b/bapctools/check_testing_tool.py @@ -4,11 +4,10 @@ from pathlib import Path from typing import Optional, TYPE_CHECKING -import config -import parallel -from program import Program -from run import Submission -from util import ( +from bapctools import config, parallel +from bapctools.program import Program +from bapctools.run import Submission +from bapctools.util import ( command_supports_memory_limit, default_exec_code_map, ensure_symlink, @@ -19,7 +18,7 @@ ) if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 - from problem import Problem + from bapctools.problem import Problem """DISCLAIMER: diff --git a/bapctools/cli.py b/bapctools/cli.py new file mode 100755 index 000000000..f2d4dccef --- /dev/null +++ b/bapctools/cli.py @@ -0,0 +1,1487 @@ +#!/usr/bin/env python3 +# PYTHON_ARGCOMPLETE_OK +"""Can be run on multiple levels: + + - from the root of the git repository + - from a contest directory + - from a problem directory +the tool will know where it is (by looking for the .git directory) and run on +everything inside it + +- Ragnar Groot Koerkamp + +Parts of this are copied from/based on run_program.py, written by Raymond van +Bommel. +""" + +import argparse +import colorama +import hashlib +import os +import re +import shutil +import signal +import sys +import tempfile +from collections import Counter +from colorama import Style +from pathlib import Path +from typing import Any, Optional + +# Local imports +from bapctools import ( + config, + constraints, + contest, + download_submissions, + export, + fuzz, + generate, + latex, + skel, + slack, + solve_stats, + stats, + upgrade, + validate, +) +from bapctools.contest import call_api_get_json, contest_yaml, get_contest_id, problems_yaml +from bapctools.problem import Problem +from bapctools.util import ( + AbortException, + ask_variable_bool, + eprint, + error, + fatal, + glob, + has_ryaml, + inc_label, + is_problem_directory, + is_relative_to, + is_windows, + log, + ProgressBar, + read_yaml, + resolve_path_argument, + verbose, + warn, + write_yaml, +) + +if not is_windows(): + import argcomplete # For automatic shell completions + +# Initialize colorama for printing coloured output. On Windows, this captures +# stdout and replaces ANSI colour codes by calls to change the terminal colour. +# +# This initialization is disabled on GITLAB CI, since Colorama detects that +# the terminal is not a TTY and will strip all colour codes. Instead, we just +# disable this call since capturing of stdout/stderr isn't needed on Linux +# anyway. +# See: +# - https://github.com/conan-io/conan/issues/4718#issuecomment-473102953 +# - https://docs.gitlab.com/runner/faq/#how-can-i-get-colored-output-on-the-web-terminal +if not os.getenv("GITLAB_CI", False) and not os.getenv("CI", False): + colorama.init() + +# List of high level todos: +# TODO: Do more things in parallel (running testcases, building submissions) +# TODO: Get rid of old problem.path and settings objects in tools.py. +# This mostly needs changes in the less frequently used subcommands. + +if sys.version_info < (3, 10): + fatal("BAPCtools requires at least Python 3.10.") + + +# Changes the working directory to the root of the contest. +# sets the "level" of the current command (either 'problem' or 'problemset') +# and, if `level == 'problem'` returns the directory of the problem. +def change_directory() -> Optional[Path]: + problem_dir: Optional[Path] = None + config.level = "problemset" + if config.args.contest: + contest_dir = config.args.contest.absolute() + os.chdir(contest_dir) + if config.args.problem: + problem_dir = config.args.problem.absolute() + elif is_problem_directory(Path.cwd()): + problem_dir = Path.cwd().absolute() + if problem_dir is not None: + config.level = "problem" + os.chdir(problem_dir.parent) + return problem_dir + + +# Get the list of relevant problems. +# Either use the problems.yaml, +# or check the existence of problem.yaml and sort by shortname. +def get_problems(problem_dir: Optional[Path]) -> tuple[list[Problem], Path]: + # We create one tmpdir per contest. + h = hashlib.sha256(bytes(Path.cwd())).hexdigest()[-6:] + tmpdir = Path(tempfile.gettempdir()) / ("bapctools_" + h) + tmpdir.mkdir(parents=True, exist_ok=True) + + def fallback_problems() -> list[tuple[Path, str]]: + problem_paths = list(filter(is_problem_directory, glob(Path("."), "*/"))) + label = chr(ord("Z") - len(problem_paths) + 1) if contest_yaml().test_session else "A" + problems = [] + for path in problem_paths: + problems.append((path, label)) + label = inc_label(label) + return problems + + problems = [] + if config.level == "problem": + assert problem_dir + # If the problem is mentioned in problems.yaml, use that ID. + for p in problems_yaml(): + if p.id == problem_dir.name: + problems = [Problem(Path(problem_dir.name), tmpdir, p.label)] + break + + if not problems: + for path, label in fallback_problems(): + if path.name == problem_dir.name: + problems = [Problem(Path(problem_dir.name), tmpdir, label)] + break + else: + assert config.level == "problemset" + # If problems.yaml is available, use it. + if problems_yaml(): + problems = [Problem(Path(p.id), tmpdir, p.label) for p in problems_yaml()] + else: + # Otherwise, fallback to all directories with a problem.yaml and sort by shortname. + problems = [Problem(path, tmpdir, label) for patj, label in fallback_problems()] + if len(problems) == 0: + fatal("Did not find problem.yaml. Are you running this from a problem directory?") + + if config.args.action == "solutions": + order = config.args.order or contest_yaml().order + if order is not None: + labels = {p.label for p in problems} + counts = Counter(order) + for id, count in counts.items(): + if id not in labels: + append_s = "s" if count != 1 else "" + warn(f"Unknown {id} appears {count} time{append_s} in 'order'") + elif count > 1: + warn(f"{id} appears {count} times in 'order'") + for problem in problems: + if problem.label not in counts: + warn(f"{problem.label} does not appear in 'order'") + + # Sort by position of id in order + def get_pos(id: Optional[str]) -> int: + if id and id in order: + return order.index(id) + else: + return len(order) + + problems.sort(key=lambda p: (get_pos(p.label), p.label, p.name)) + + if config.args.order_from_ccs: + # Sort by increasing difficulty, extracted from the CCS api. + class ProblemStat: + def __init__(self) -> None: + 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]) -> None: + 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() + + # Read set of problems + contest_problems = call_api_get_json(f"/contests/{cid}/problems?public=true") + assert isinstance(problems, list) + + 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 team_stats in team["problems"]: + problem_stats[team_stats["problem_id"]].update(team_stats) + + # Sort the problems + 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"): + if has_ryaml: + contest_yaml_path = Path("contest.yaml") + data = read_yaml(contest_yaml_path) or {} + if not isinstance(data, dict): + error("could not parse contest.yaml.") + else: + data["order"] = "".join(p.label or p.name for p in problems) + write_yaml(data, contest_yaml_path) + log("Updated order") + else: + error("ruamel.yaml library not found. Update the order manually.") + + # Filter problems by submissions/testcases, if given. + if config.level == "problemset" and (config.args.submissions or config.args.testcases): + submissions = config.args.submissions or [] + testcases = config.args.testcases or [] + + def keep_problem(problem: Problem) -> bool: + for s in submissions: + x = resolve_path_argument(problem, s, "submissions") + if x: + if is_relative_to(problem.path, x): + return True + for t in testcases: + x = resolve_path_argument(problem, t, "data", suffixes=[".in"]) + if x: + if is_relative_to(problem.path, x): + return True + return False + + problems = [p for p in problems if keep_problem(p)] + + return problems, tmpdir + + +# NOTE: This is one of the few places that prints to stdout instead of stderr. +def print_sorted(problems: list[Problem]) -> None: + for problem in problems: + print(f"{problem.label:<2}: {problem.path}") + + +def split_submissions_and_testcases(s: list[Path]) -> tuple[list[Path], list[Path]]: + # We try to identify testcases by common directory names and common suffixes + submissions = [] + testcases = [] + for p in s: + testcase_dirs = ["data", "sample", "secret", "fuzz", "testing_tool_cases"] + if ( + any(part in testcase_dirs for part in p.parts) + or p.suffix in config.KNOWN_DATA_EXTENSIONS + ): + # Strip potential suffix + if p.suffix in config.KNOWN_DATA_EXTENSIONS: + p = p.with_suffix("") + testcases.append(p) + else: + submissions.append(p) + return (submissions, testcases) + + +# We set argument_default=SUPPRESS in all parsers, +# to make sure no default values (like `False` or `0`) end up in the parsed arguments object. +# If we would not do this, it would not be possible to check which keys are explicitly set from the command line. +# This check is necessary when loading the personal config file in `read_personal_config`. +class SuppressingParser(argparse.ArgumentParser): + def __init__(self, **kwargs: Any) -> None: + super(SuppressingParser, self).__init__(**kwargs, argument_default=argparse.SUPPRESS) + + +def build_parser() -> SuppressingParser: + parser = SuppressingParser( + description=""" +Tools for ICPC style problem sets. +Run this from one of: + - the repository root, and supply `contest` + - a contest directory + - a problem directory +""", + formatter_class=argparse.RawTextHelpFormatter, + ) + + # Global options + global_parser = SuppressingParser(add_help=False) + global_parser.add_argument( + "--verbose", + "-v", + action="count", + help="Verbose output; once for what's going on, twice for all intermediate output.", + ) + group = global_parser.add_mutually_exclusive_group() + group.add_argument("--contest", type=Path, help="Path to the contest to use.") + group.add_argument( + "--problem", + type=Path, + help="Path to the problem to use. Can be relative to contest if given.", + ) + + global_parser.add_argument( + "--no-bar", + action="store_true", + help="Do not show progress bars in non-interactive environments.", + ) + global_parser.add_argument( + "--error", + "-e", + action="store_true", + help="Print full error of failing commands and some succeeding commands.", + ) + global_parser.add_argument( + "--force-build", + action="store_true", + help="Force rebuild instead of only on changed files.", + ) + global_parser.add_argument( + "--jobs", + "-j", + type=int, + help="The number of jobs to use. Default: cpu_count()/2.", + ) + global_parser.add_argument( + "--memory", + "-m", + type=int, + help="The maximum amount of memory in MB a subprocess may use.", + ) + global_parser.add_argument( + "--api", + help="CCS API endpoint to use, e.g. https://www.domjudge.org/demoweb. Defaults to the value in contest.yaml.", + ) + global_parser.add_argument("--username", "-u", help="The username to login to the CCS.") + global_parser.add_argument("--password", "-p", help="The password to login to the CCS.") + global_parser.add_argument( + "--cp", + action="store_true", + help="Copy the output pdf instead of symlinking it.", + ) + global_parser.add_argument("--lang", nargs="+", help="Languages to include.") + + subparsers = parser.add_subparsers( + title="actions", dest="action", parser_class=SuppressingParser, required=True + ) + + # upgrade + subparsers.add_parser( + "upgrade", + parents=[global_parser], + help="Upgrade a problem or contest.", + ) + + # New contest + contestparser = subparsers.add_parser( + "new_contest", + parents=[global_parser], + help="Add a new contest to the current directory.", + ) + contestparser.add_argument("contestname", nargs="?", help="The name of the contest") + + # New problem + problemparser = subparsers.add_parser( + "new_problem", + parents=[global_parser], + help="Add a new problem to the current directory.", + ) + problemparser.add_argument("problemname", nargs="?", help="The name of the problem,") + problemparser.add_argument("--author", help="The author of the problem,") + problemparser.add_argument( + "--type", + help="The type of the problem.", + choices=[ + "pass-fail", + "float", + "custom", + "interactive", + "multi-pass", + "interactive multi-pass", + ], + ) + problemparser.add_argument("--skel", help="Skeleton problem directory to copy from.") + problemparser.add_argument( + "--defaults", + action="store_true", + help="Assume the defaults for fields not passed as arguments." + + " This skips input-prompts but fails when defaults cannot be assumed.", + ) + + # Copy directory from skel. + skelparser = subparsers.add_parser( + "skel", + parents=[global_parser], + help="Copy the given directories from skel to the current problem directory.", + ) + skelparser.add_argument( + "directory", + nargs="+", + type=Path, + help="Directories to copy from skel/problem/, relative to the problem directory.", + ) + skelparser.add_argument("--skel", help="Skeleton problem directory to copy from.") + + # Rename problem + renameproblemparser = subparsers.add_parser( + "rename_problem", + parents=[global_parser], + help="Rename a problem, including its directory.", + ) + renameproblemparser.add_argument("problemname", nargs="?", help="The new name of the problem,") + + # Problem statements + pdfparser = subparsers.add_parser( + "pdf", parents=[global_parser], help="Build the problem statement pdf." + ) + pdfparser.add_argument( + "--all", + "-a", + action="store_true", + help="Create problem statements for individual problems as well.", + ) + pdfparser.add_argument("--no-time-limit", action="store_true", help="Do not print timelimits.") + pdfparser.add_argument( + "--watch", + "-w", + action="store_true", + help="Continuously compile the pdf whenever a `problem.*.tex` changes. Note that this does not pick up changes to `*.yaml` configuration files. Further Note that this implies `--cp`.", + ) + pdfparser.add_argument( + "--open", + "-o", + nargs="?", + const=True, + type=Path, + help="Open the continuously compiled pdf (with a specified program).", + ) + pdfparser.add_argument("--web", action="store_true", help="Create a web version of the pdf.") + pdfparser.add_argument("-1", action="store_true", help="Only run the LaTeX compiler once.") + + # Problem slides + slidesparser = subparsers.add_parser( + "problem_slides", parents=[global_parser], help="Build the problem slides pdf." + ) + slidesparser.add_argument( + "--no-time-limit", action="store_true", help="Do not print timelimits." + ) + slidesparser.add_argument( + "--watch", + "-w", + action="store_true", + help="Continuously compile the pdf whenever a `problem-slide.*.tex` changes. Note that this does not pick up changes to `*.yaml` configuration files.", + ) + slidesparser.add_argument( + "--open", + "-o", + nargs="?", + const=True, + type=Path, + help="Open the continuously compiled pdf (with a specified program).", + ) + slidesparser.add_argument("-1", action="store_true", help="Only run the LaTeX compiler once.") + + # Solution slides + solparser = subparsers.add_parser( + "solutions", parents=[global_parser], help="Build the solution slides pdf." + ) + orderparser = solparser.add_mutually_exclusive_group() + orderparser.add_argument( + "--order", action="store", help='The order of the problems, e.g.: "CAB"' + ) + orderparser.add_argument( + "--order-from-ccs", + action="store_true", + help="Order the problems by increasing difficulty, extracted from the CCS.", + ) + solparser.add_argument( + "--contest-id", + action="store", + help="Contest ID to use when reading from the API. Only useful with --order-from-ccs. Defaults to value of contest_id in contest.yaml.", + ) + solparser.add_argument( + "--watch", + "-w", + action="store_true", + help="Continuously compile the pdf whenever a `solution.*.tex` changes. Note that this does not pick up changes to `*.yaml` configuration files. Further Note that this implies `--cp`.", + ) + solparser.add_argument( + "--open", + "-o", + nargs="?", + const=True, + type=Path, + help="Open the continuously compiled pdf (with a specified program).", + ) + solparser.add_argument("--web", action="store_true", help="Create a web version of the pdf.") + solparser.add_argument("-1", action="store_true", help="Only run the LaTeX compiler once.") + + # Validation + validate_parser = subparsers.add_parser( + "validate", parents=[global_parser], help="validate all grammar" + ) + validate_parser.add_argument("testcases", nargs="*", type=Path, help="The testcases to run on.") + validation_group = validate_parser.add_mutually_exclusive_group() + validation_group.add_argument("--input", "-i", action="store_true", help="Only validate input.") + validation_group.add_argument("--answer", action="store_true", help="Only validate answer.") + validation_group.add_argument( + "--invalid", action="store_true", help="Only check invalid files for validity." + ) + validation_group.add_argument( + "--generic", + choices=["invalid_input", "invalid_answer", "invalid_output", "valid_output"], + nargs="*", + help="Generate generic (in)valid files based on the first three samples and validate them.", + ) + validation_group.add_argument( + "--valid-output", + action="store_true", + help="Only check files in 'data/valid_output' for validity.", + ) + + move_or_remove_group = validate_parser.add_mutually_exclusive_group() + move_or_remove_group.add_argument( + "--remove", action="store_true", help="Remove failing testcases." + ) + move_or_remove_group.add_argument("--move-to", help="Move failing testcases to this directory.") + + validate_parser.add_argument( + "--no-testcase-sanity-checks", + action="store_true", + help="Skip sanity checks on testcases.", + ) + validate_parser.add_argument( + "--timeout", "-t", type=int, help="Override the default timeout. Default: 30." + ) + + # constraints validation + constraintsparser = subparsers.add_parser( + "constraints", + parents=[global_parser], + help="prints all the constraints found in problemset and validators", + ) + constraintsparser.add_argument( + "--no-generate", "-G", action="store_true", help="Do not run `generate`." + ) + + # Stats + statsparser = subparsers.add_parser( + "stats", parents=[global_parser], help="show statistics for contest/problem" + ) + all_stats_group = statsparser.add_mutually_exclusive_group() + all_stats_group.add_argument("--more", action="store_true", help="DEPRECATED! Use --all.") + all_stats_group.add_argument( + "--all", + "-a", + action="store_true", + help="Print all stats", + ) + + # Generate Testcases + genparser = subparsers.add_parser( + "generate", + parents=[global_parser], + help="Generate testcases according to .gen files.", + ) + genparser.add_argument( + "--check-deterministic", + action="store_true", + help="Rerun all generators to make sure generators are deterministic.", + ) + genparser.add_argument( + "--timeout", "-t", type=int, help="Override the default timeout. Default: 30." + ) + + genparser_group = genparser.add_mutually_exclusive_group() + genparser_group.add_argument( + "--add", + nargs="*", + type=Path, + help="Add case(s) to generators.yaml.", + metavar="TARGET_DIRECTORY=generators/manual", + ) + genparser_group.add_argument( + "--clean", "-C", action="store_true", help="Delete all cached files." + ) + genparser_group.add_argument( + "--reorder", + action="store_true", + help="Reorder cases by difficulty inside the given directories.", + ) + + genparser.add_argument( + "--interaction", + "-i", + action="store_true", + help="Use the solution to generate .interaction files.", + ) + genparser.add_argument( + "testcases", + nargs="*", + type=Path, + help="The testcases to generate, given as directory, .in/.ans file, or base name.", + ) + genparser.add_argument( + "--default-solution", + "-s", + type=Path, + help="The default solution to use for generating .ans files. Not compatible with generator.yaml.", + ) + 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.", + ) + + # Fuzzer + fuzzparser = subparsers.add_parser( + "fuzz", + parents=[global_parser], + help="Generate random testcases and search for inconsistencies in AC submissions.", + ) + fuzzparser.add_argument("--time", type=int, help="Number of seconds to run for. Default: 600") + fuzzparser.add_argument("--time-limit", "-t", type=float, help="Time limit for submissions.") + fuzzparser.add_argument( + "submissions", + nargs="*", + type=Path, + help="The generator.yaml rules to use, given as directory, .in/.ans file, or base name, and submissions to run.", + ) + fuzzparser.add_argument( + "--timeout", type=int, help="Override the default timeout. Default: 30." + ) + + # Run + runparser = subparsers.add_parser( + "run", + parents=[global_parser], + help="Run multiple programs against some or all input.", + ) + runparser.add_argument( + "submissions", + nargs="*", + type=Path, + help="optionally supply a list of programs and testcases to run", + ) + runparser.add_argument("--samples", action="store_true", help="Only run on the samples.") + runparser.add_argument( + "--no-generate", + "-G", + 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", + action="count", + default=0, + help="Run all testcases. Use twice to continue even after timeouts.", + ) + runparser.add_argument( + "--default-solution", + "-s", + type=Path, + help="The default solution to use for generating .ans files. Not compatible with generators.yaml.", + ) + runparser.add_argument( + "--table", + action="store_true", + help="Print a submissions x testcases table for analysis.", + ) + runparser.add_argument( + "--overview", + "-o", + action="store_true", + help="Print a live overview for the judgings.", + ) + runparser.add_argument("--tree", action="store_true", help="Show a tree of verdicts.") + + runparser.add_argument("--depth", type=int, help="Depth of verdict tree.") + runparser.add_argument( + "--timeout", + type=int, + help="Override the default timeout. Default: 1.5 * time_limit + 1.", + ) + runparser.add_argument( + "--time-limit", "-t", type=float, help="Override the default time-limit." + ) + runparser.add_argument( + "--no-testcase-sanity-checks", + action="store_true", + help="Skip sanity checks on testcases.", + ) + runparser.add_argument( + "--sanitizer", + action="store_true", + help="Run submissions with additional sanitizer flags (currently only C++). Note that this removes all memory limits for submissions.", + ) + + timelimitparser = subparsers.add_parser( + "time_limit", + parents=[global_parser], + help="Determine the time limit for a problem.", + ) + timelimitparser.add_argument( + "submissions", + nargs="*", + type=Path, + help="optionally supply a list of programs and testcases on which the time limit should be based.", + ) + timelimitparser.add_argument( + "--all", + "-a", + action="store_true", + help="Run all submissions, not only AC and TLE.", + ) + timelimitparser.add_argument( + "--write", + "-w", + action="store_true", + help="Write .timelimit file.", + ) + timelimitparser.add_argument( + "--timeout", "-t", type=int, help="Override the default timeout. Default: 60." + ) + timelimitparser.add_argument( + "--no-generate", "-G", action="store_true", help="Do not run `generate`." + ) + + # Test + testparser = subparsers.add_parser( + "test", + parents=[global_parser], + help="Run a single program and print the output.", + ) + testparser.add_argument("submissions", nargs=1, type=Path, help="A single submission to run") + testcasesgroup = testparser.add_mutually_exclusive_group() + testcasesgroup.add_argument( + "testcases", + nargs="*", + default=[], + type=Path, + help="Optionally a list of testcases to run on.", + ) + testcasesgroup.add_argument("--samples", action="store_true", help="Only run on the samples.") + testcasesgroup.add_argument( + "--interactive", + "-i", + action="store_true", + help="Run submission in interactive mode: stdin is from the command line.", + ) + testparser.add_argument( + "--timeout", + type=int, + help="Override the default timeout. Default: 1.5 * time_limit + 1.", + ) + + checktestingtool = subparsers.add_parser( + "check_testing_tool", + parents=[global_parser], + help="Run testing_tool against some or all accepted submissions.", + ) + checktestingtool.add_argument( + "submissions", + nargs="*", + type=Path, + help="optionally supply a list of programs and testcases to run", + ) + checktestingtool.add_argument( + "--no-generate", + "-G", + action="store_true", + help="Do not run `generate` before running submissions.", + ) + checktestingtool.add_argument( + "--timeout", + type=int, + help="Override the default timeout. Default: 1.5 * time_limit + 1.", + ) + checktestingtool.add_argument( + "--all", + "-a", + action="store_true", + help="Run all testcases and don't stop on error.", + ) + + # Sort + subparsers.add_parser( + "sort", parents=[global_parser], help="sort the problems for a contest by name" + ) + + # All + allparser = subparsers.add_parser( + "all", + parents=[global_parser], + help="validate input, validate answers, and run programs", + ) + allparser.add_argument("--no-time-limit", action="store_true", help="Do not print time limits.") + allparser.add_argument( + "--no-testcase-sanity-checks", + action="store_true", + help="Skip sanity checks on testcases.", + ) + allparser.add_argument( + "--check-deterministic", + action="store_true", + help="Rerun all generators to make sure generators are deterministic.", + ) + allparser.add_argument( + "--timeout", "-t", type=int, help="Override the default timeout. Default: 30." + ) + allparser.add_argument( + "--overview", + "-o", + action="store_true", + help="Print a live overview for the judgings.", + ) + + # Build DOMjudge zip + zipparser = subparsers.add_parser( + "zip", + parents=[global_parser], + help="Create zip file that can be imported into DOMjudge", + ) + zipparser.add_argument("--skip", action="store_true", help="Skip recreation of problem zips.") + zipparser.add_argument( + "--force", + "-f", + action="store_true", + help="Skip validation of input and answers.", + ) + zipparser.add_argument( + "--no-generate", "-G", action="store_true", help="Skip generation of test cases." + ) + zipparser.add_argument( + "--kattis", + 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. + 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." + ) + gitlab_parser.add_argument( + "--latest-bt", action="store_true", help="Cache the latest version of BAPCtools." + ) + + forgejo_parser = subparsers.add_parser( + "forgejo_actions", + parents=[global_parser], + help="Setup Forgejo Actions workflows in .forgejo.", + ) + forgejo_parser.add_argument( + "--latest-bt", action="store_true", help="Cache the latest version of BAPCtools." + ) + + github_parser = subparsers.add_parser( + "github_actions", + parents=[global_parser], + help="Setup Github Actions workflows in .github.", + ) + github_parser.add_argument( + "--latest-bt", action="store_true", help="Cache the latest version of BAPCtools." + ) + + exportparser = subparsers.add_parser( + "export", + parents=[global_parser], + help="Export the problem or contest to DOMjudge.", + ) + exportparser.add_argument( + "--contest-id", + 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", + parents=[global_parser], + help="Update the problems.yaml with current names and time limits.", + ) + updateproblemsyamlparser.add_argument( + "--colors", + help="Set the colors of the problems. Comma-separated list of hex-codes.", + ) + updateproblemsyamlparser.add_argument( + "--sort", + action="store_true", + help="Sort the problems by id.", + ) + updateproblemsyamlparser.add_argument( + "--number", + action="store_true", + help="Use Sxx as problem labels.", + ) + 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( + "tmp", + parents=[global_parser], + help="Print the tmpdir corresponding to the current problem.", + ) + tmpparser.add_argument( + "--clean", + "-C", + action="store_true", + help="Delete the temporary cache directory for the current problem/contest.", + ) + + solvestatsparser = subparsers.add_parser( + "solve_stats", + parents=[global_parser], + help="Make solve stats plots using Matplotlib. All teams on the public scoreboard are included (including spectator/company teams).", + ) + solvestatsparser.add_argument( + "--contest-id", + action="store", + help="Contest ID to use when reading from the API. Defaults to value of contest_id in contest.yaml.", + ) + solvestatsparser.add_argument( + "--post-freeze", + action="store_true", + help="When given, the solve stats will include submissions from after the scoreboard freeze.", + ) + + download_submissions_parser = subparsers.add_parser( + "download_submissions", + parents=[global_parser], + help="Download all submissions for a contest and write them to submissions/.", + ) + download_submissions_parser.add_argument( + "--contest-id", + action="store", + help="Contest ID to use when reading from the API. Defaults to value of contest_id in contest.yaml.", + ) + + create_slack_channel_parser = subparsers.add_parser( + "create_slack_channels", + parents=[global_parser], + help="Create a slack channel for each problem", + ) + create_slack_channel_parser.add_argument("--token", help="A user token is of the form xoxp-...") + + join_slack_channel_parser = subparsers.add_parser( + "join_slack_channels", + parents=[global_parser], + help="Join a slack channel for each problem", + ) + join_slack_channel_parser.add_argument("--token", help="A bot/user token is of the form xox...") + join_slack_channel_parser.add_argument("username", help="Slack username") + + if not is_windows(): + argcomplete.autocomplete(parser) + + return parser + + +def find_home_config_dir() -> Optional[Path]: + if is_windows(): + app_data = os.getenv("AppData") + return Path(app_data) if app_data else None + else: + home = os.getenv("HOME") + xdg_config_home = os.getenv("XDG_CONFIG_HOME") + return ( + Path(xdg_config_home) if xdg_config_home else Path(home) / ".config" if home else None + ) + + +def read_personal_config(problem_dir: Optional[Path]) -> None: + home_config_dir = find_home_config_dir() + # possible config files, sorted by priority + config_files = [] + if problem_dir: + config_files.append(problem_dir / ".bapctools.yaml") + config_files.append(Path.cwd() / ".bapctools.yaml") + if home_config_dir: + config_files.append(home_config_dir / "bapctools" / "config.yaml") + + for config_file in config_files: + if not config_file.is_file(): + continue + + config_data = read_yaml(config_file) + if not config_data: + continue + if not isinstance(config_data, dict): + warn(f"invalid data in {config_data}. SKIPPED.") + continue + + config.args.add_if_not_set(config.ARGS(config_file, **config_data)) + + +def run_parsed_arguments(args: argparse.Namespace, personal_config: bool = True) -> None: + # Don't zero newly allocated memory for this and any subprocess + # Will likely only have an effect on linux + os.environ["MALLOC_PERTURB_"] = str(0b01011001) + + # Process arguments + config.args = config.ARGS("args", **vars(args)) + + # cd to contest directory + call_cwd = Path.cwd().absolute() + problem_dir = change_directory() + level = config.level + contest_name = Path.cwd().name + + if personal_config: + read_personal_config(problem_dir) + + action = config.args.action + + # upgrade commands. + if action == "upgrade": + upgrade.upgrade(problem_dir) + return + + # Skel commands. + if action == "new_contest": + os.chdir(call_cwd) + skel.new_contest() + return + + if action == "new_problem": + os.chdir(call_cwd) + skel.new_problem() + return + + # get problems list + problems, tmpdir = get_problems(problem_dir) + + # Split submissions and testcases when needed. + if action in ["run", "fuzz", "time_limit", "check_testing_tool"]: + if config.args.submissions: + config.args.submissions, config.args.testcases = split_submissions_and_testcases( + config.args.submissions + ) + else: + config.args.testcases = [] + + # Check non unique uuid + # TODO: check this even more globally? + uuids: dict[str, Problem] = {} + for p in problems: + if p.settings.uuid in uuids: + warn(f"{p.name} has the same uuid as {uuids[p.settings.uuid].name}") + else: + uuids[p.settings.uuid] = p + + # Check for incompatible actions at the problem/problemset level. + if level != "problem": + if action == "test": + fatal("Testing a submission only works for a single problem.") + if action == "skel": + fatal("Copying skel directories only works for a single problem.") + + if action != "generate" and config.args.testcases and config.args.samples: + fatal("--samples can not go together with an explicit list of testcases.") + + if config.args.add is not None: + # default to 'generators/manual' + if len(config.args.add) == 0: + config.args.add = [Path("generators/manual")] + + # Paths *must* be inside generators/. + checked_paths = [] + for path in config.args.add: + if path.parts[0] != "generators": + warn(f'Path {path} does not match "generators/*". Skipping.') + else: + checked_paths.append(path) + config.args.add = checked_paths + + if config.args.reorder: + # default to 'data/secret' + if not config.args.testcases: + config.args.testcases = [Path("data/secret")] + + # Paths *must* be inside data/. + checked_paths = [] + for path in config.args.testcases: + if path.parts[0] != "data": + warn(f'Path {path} does not match "data/*". Skipping.') + else: + checked_paths.append(path) + config.args.testcases = checked_paths + + # Handle one-off subcommands. + if action == "tmp": + if level == "problem": + level_tmpdir = tmpdir / problems[0].name + else: + level_tmpdir = tmpdir + + if config.args.clean: + log(f"Deleting {tmpdir}!") + if level_tmpdir.is_dir(): + shutil.rmtree(level_tmpdir) + if level_tmpdir.is_file(): + level_tmpdir.unlink() + else: + eprint(level_tmpdir) + + return + + if action == "stats": + stats.stats(problems) + return + + if action == "sort": + print_sorted(problems) + return + + if action == "samplezip": + sampleout = Path("samples.zip") + if level == "problem": + sampleout = problems[0].path / sampleout + languages = export.select_languages(problems) + export.build_samples_zip(problems, sampleout, languages) + return + + if action == "rename_problem": + if level == "problemset": + fatal("rename_problem only works for a problem") + skel.rename_problem(problems[0]) + return + + if action == "gitlabci": + skel.create_gitlab_jobs(contest_name, problems) + return + + if action == "forgejo_actions": + skel.create_forgejo_actions(contest_name, problems) + return + + if action == "github_actions": + skel.create_github_actions(contest_name, problems) + return + + if action == "skel": + skel.copy_skel_dir(problems) + return + + if action == "solve_stats": + if level == "problem": + fatal("solve_stats only works for a contest") + config.args.jobs = (os.cpu_count() or 1) // 2 + solve_stats.generate_solve_stats(config.args.post_freeze) + return + + if action == "download_submissions": + if level == "problem": + fatal("download_submissions only works for a contest") + download_submissions.download_submissions() + return + + if action == "create_slack_channels": + slack.create_slack_channels(problems) + return + + if action == "join_slack_channels": + assert config.args.username is not None + slack.join_slack_channels(problems, config.args.username) + return + + problem_zips = [] + + success = True + + for problem in problems: + if ( + level == "problemset" + and action in ["pdf", "export", "update_problems_yaml"] + and not config.args.all + ): + continue + eprint(Style.BRIGHT, "PROBLEM ", problem.name, Style.RESET_ALL, sep="") + + if action in ["generate"]: + success &= generate.generate(problem) + if ( + action in ["all", "constraints", "run", "time_limit", "check_testing_tool"] + and not config.args.no_generate + ): + # Call `generate` with modified arguments. + old_args = config.args.copy() + config.args.jobs = (os.cpu_count() or 1) // 2 + config.args.add = None + config.args.verbose = 0 + config.args.no_visualizer = True + success &= generate.generate(problem) + config.args = old_args + if action in ["fuzz"]: + success &= fuzz.Fuzz(problem).run() + if action in ["pdf", "all"]: + # only build the pdf on the problem level, or on the contest level when + # --all is passed. + if level == "problem" or (level == "problemset" and config.args.all): + success &= latex.build_problem_pdfs(problem) + if level == "problem": + if action in ["solutions"]: + success &= latex.build_problem_pdfs( + problem, build_type=latex.PdfType.SOLUTION, web=config.args.web + ) + if action in ["problem_slides"]: + success &= latex.build_problem_pdfs( + problem, build_type=latex.PdfType.PROBLEM_SLIDE, web=config.args.web + ) + if action in ["validate", "all"]: + # if nothing is specified run all + specified = any( + [ + config.args.invalid, + config.args.generic is not None, + config.args.input, + config.args.answer, + config.args.valid_output, + ] + ) + if action == "all" or not specified or config.args.invalid: + success &= problem.validate_data(validate.Mode.INVALID) + if action == "all" or not specified or config.args.generic is not None: + if config.args.generic is None: + config.args.generic = [ + "invalid_input", + "invalid_answer", + "invalid_output", + "valid_output", + ] + success &= problem.validate_invalid_extra_data() + success &= problem.validate_valid_extra_data() + if action == "all" or not specified or config.args.input: + success &= problem.validate_data(validate.Mode.INPUT) + if action == "all" or not specified or config.args.answer: + success &= problem.validate_data(validate.Mode.ANSWER) + if action == "all" or not specified or config.args.valid_output: + success &= problem.validate_data(validate.Mode.VALID_OUTPUT) + if action in ["run", "all"]: + success &= problem.run_submissions() + if action in ["test"]: + config.args.no_bar = True + success &= problem.test_submissions() + if action in ["constraints"]: + success &= constraints.check_constraints(problem) + if action in ["check_testing_tool"]: + problem.check_testing_tool() + if action in ["time_limit"]: + success &= problem.determine_time_limit() + if action in ["zip"]: + output = problem.path / f"{problem.name}.zip" + + problem_zips.append(output) + if not config.args.skip: + if not config.args.no_generate: + # Set up arguments for generate. + old_args = config.args.copy() + config.args.check_deterministic = not config.args.force + config.args.add = None + config.args.verbose = 0 + config.args.testcases = None + config.args.force = False + success &= generate.generate(problem) + config.args = old_args + + if not config.args.kattis: + success &= latex.build_problem_pdfs(problem) + if not config.args.no_solutions: + success &= latex.build_problem_pdfs( + problem, build_type=latex.PdfType.SOLUTION + ) + + if any(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={}) + success &= problem.validate_data(validate.Mode.ANSWER, constraints={}) + + # Write to problemname.zip, where we strip all non-alphanumeric from the + # problem directory name. + success &= export.build_problem_zip(problem, output) + + if len(problems) > 1: + eprint() + + if action in ["export"]: + languages = export.select_languages(problems) + export.export_contest_and_problems(problems, languages) + + if level == "problemset": + eprint(f"{Style.BRIGHT}CONTEST {contest_name}{Style.RESET_ALL}") + + # build pdf for the entire contest + if action in ["pdf"]: + success &= latex.build_contest_pdfs(contest_name, problems, tmpdir, web=config.args.web) + + if action in ["solutions"]: + success &= latex.build_contest_pdfs( + contest_name, + problems, + tmpdir, + build_type=latex.PdfType.SOLUTION, + web=config.args.web, + ) + + if action in ["problem_slides"]: + success &= latex.build_contest_pdfs( + contest_name, + problems, + tmpdir, + build_type=latex.PdfType.PROBLEM_SLIDE, + web=config.args.web, + ) + + if action in ["zip"]: + languages = [] + if not config.args.kattis: + languages = export.select_languages(problems) + + # Only build the problem slides if at least one problem has the TeX for it + slideglob = latex.PdfType.PROBLEM_SLIDE.path("*") + build_problem_slides = any( + any(problem.path.glob(str(slideglob))) for problem in problems + ) + + for language in languages: + success &= latex.build_contest_pdfs(contest_name, problems, tmpdir, language) + success &= latex.build_contest_pdfs( + contest_name, problems, tmpdir, language, web=True + ) + if not config.args.no_solutions: + success &= latex.build_contest_pdf( + contest_name, + problems, + tmpdir, + language, + build_type=latex.PdfType.SOLUTION, + ) + success &= latex.build_contest_pdf( + contest_name, + problems, + tmpdir, + language, + build_type=latex.PdfType.SOLUTION, + web=True, + ) + if build_problem_slides: + success &= latex.build_contest_pdf( + contest_name, + 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_name + ".zip" + if config.args.kattis: + outfile = contest_name + "-kattis.zip" + export.build_contest_zip(problems, problem_zips, outfile, languages) + + if action in ["update_problems_yaml"]: + export.update_problems_yaml( + problems, + ( + re.split("[^#0-9A-Za-z]", config.args.colors.strip()) + if config.args.colors + else None + ), + ) + + if not success or config.n_error > 0 or config.n_warn > 0: + sys.exit(1) + + +# Takes command line arguments +def main() -> None: + def interrupt_handler(sig: Any, frame: Any) -> None: + fatal("Running interrupted") + + signal.signal(signal.SIGINT, interrupt_handler) + + try: + parser = build_parser() + run_parsed_arguments(parser.parse_args()) + except AbortException: + fatal("Running interrupted") + + +if __name__ == "__main__": + main() + + +def test(args: list[str]) -> None: + config.RUNNING_TEST = True + + # Make sure to cd back to the original directory before returning. + # Needed to stay in the same directory in tests. + original_directory = Path.cwd() + config.n_warn = 0 + config.n_error = 0 + contest._contest_yaml = None + contest._problems_yaml = None + try: + parser = build_parser() + run_parsed_arguments(parser.parse_args(args), personal_config=False) + finally: + os.chdir(original_directory) + ProgressBar.current_bar = None diff --git a/bin/config.py b/bapctools/config.py similarity index 98% rename from bin/config.py rename to bapctools/config.py index 20149e24a..cfa4dcf0b 100644 --- a/bin/config.py +++ b/bapctools/config.py @@ -9,6 +9,8 @@ from pathlib import Path from typing import Any, Final, Literal, Optional, TypeVar +import bapctools + # Randomly generated uuid4 for BAPCtools BAPC_UUID: Final[str] = "8ee7605a-d1ce-47b3-be37-15de5acd757e" BAPC_UUID_PREFIX: Final[int] = 8 @@ -98,14 +100,14 @@ SEED_DEPENDENCY_RETRIES: Final[int] = 10 -# The root directory of the BAPCtools repository. -TOOLS_ROOT: Final[Path] = Path(__file__).absolute().parent.parent +# The directory containing all non-python resources +RESOURCES_ROOT: Final[Path] = Path(bapctools.__file__).parent / "resources" # The directory from which BAPCtools is invoked. current_working_directory: Final[Path] = Path.cwd().absolute() # Add third_party/ to the $PATH for checktestdata. -os.environ["PATH"] += os.pathsep + str(TOOLS_ROOT / "third_party") +os.environ["PATH"] += os.pathsep + str(RESOURCES_ROOT / "third_party") # Below here is some global state that will be filled in main(). diff --git a/bin/conftest.py b/bapctools/conftest.py similarity index 100% rename from bin/conftest.py rename to bapctools/conftest.py diff --git a/bin/constraints.py b/bapctools/constraints.py similarity index 98% rename from bin/constraints.py rename to bapctools/constraints.py index 5dc9c41ca..d5ac9dc35 100644 --- a/bin/constraints.py +++ b/bapctools/constraints.py @@ -3,10 +3,9 @@ from colorama import Fore, Style from typing import Optional -import latex -import validate -from problem import Problem -from util import eprint, error, log, warn +from bapctools import latex, validate +from bapctools.problem import Problem +from bapctools.util import eprint, error, log, warn """DISCLAIMER: diff --git a/bin/contest.py b/bapctools/contest.py similarity index 99% rename from bin/contest.py rename to bapctools/contest.py index 39aa7dac4..44d527cb5 100644 --- a/bin/contest.py +++ b/bapctools/contest.py @@ -4,8 +4,8 @@ from pathlib import Path from typing import Any, Optional, TYPE_CHECKING -import config -from util import ( +from bapctools import config +from bapctools.util import ( error, fatal, has_ryaml, diff --git a/bin/download_submissions.py b/bapctools/download_submissions.py similarity index 94% rename from bin/download_submissions.py rename to bapctools/download_submissions.py index 41271e45a..ecb607140 100644 --- a/bin/download_submissions.py +++ b/bapctools/download_submissions.py @@ -5,11 +5,10 @@ from pathlib import Path from typing import Any -import config -import parallel -from contest import call_api_get_json, get_contest_id -from util import fatal, ProgressBar -from verdicts import from_string, Verdict +from bapctools import config, parallel +from bapctools.contest import call_api_get_json, get_contest_id +from bapctools.util import fatal, ProgressBar +from bapctools.verdicts import from_string, Verdict # Example usage: # bt download_submissions [--user ] [--password ] [--contest ] [--api ] diff --git a/bin/export.py b/bapctools/export.py similarity index 98% rename from bin/export.py rename to bapctools/export.py index 3d7bb03d5..df29ea119 100644 --- a/bin/export.py +++ b/bapctools/export.py @@ -5,11 +5,11 @@ from pathlib import Path from typing import Any, Optional -import config -from contest import call_api, call_api_get_json, contest_yaml, get_contests, problems_yaml -from latex import PdfType -from problem import Problem -from util import ( +from bapctools import config +from bapctools.contest import call_api, call_api_get_json, contest_yaml, get_contests, problems_yaml +from bapctools.latex import PdfType +from bapctools.problem import Problem +from bapctools.util import ( ask_variable_bool, drop_suffix, ensure_symlink, @@ -31,8 +31,8 @@ warn, write_yaml, ) -from validate import AnswerValidator, InputValidator, OutputValidator -from visualize import InputVisualizer, OutputVisualizer +from bapctools.validate import AnswerValidator, InputValidator, OutputValidator +from bapctools.visualize import InputVisualizer, OutputVisualizer def select_languages(problems: list[Problem]) -> list[str]: diff --git a/bin/fuzz.py b/bapctools/fuzz.py similarity index 98% rename from bin/fuzz.py rename to bapctools/fuzz.py index d9b8d32ac..d8e7fea77 100644 --- a/bin/fuzz.py +++ b/bapctools/fuzz.py @@ -7,13 +7,10 @@ from pathlib import Path from typing import Any, Optional -import config -import generate -import parallel -import problem -from run import Run, Submission -from testcase import Testcase -from util import ( +from bapctools import config, generate, parallel, problem +from bapctools.run import Run, Submission +from bapctools.testcase import Testcase +from bapctools.util import ( eprint, error, fatal, @@ -25,8 +22,8 @@ ryaml_get_or_add, write_yaml, ) -from validate import Mode, OutputValidator -from verdicts import Verdict +from bapctools.validate import Mode, OutputValidator +from bapctools.verdicts import Verdict if has_ryaml: from ruamel.yaml.comments import CommentedMap, CommentedSeq diff --git a/bin/generate.py b/bapctools/generate.py similarity index 99% rename from bin/generate.py rename to bapctools/generate.py index 39cfda183..78a5bb27e 100644 --- a/bin/generate.py +++ b/bapctools/generate.py @@ -11,15 +11,10 @@ from pathlib import Path, PurePosixPath from typing import cast, Final, Literal, Optional, overload, TypeVar -import config -import parallel -import program -import run -import validate -import visualize -from problem import Problem -from testcase import Testcase -from util import ( +from bapctools import config, parallel, program, run, validate, visualize +from bapctools.problem import Problem +from bapctools.testcase import Testcase +from bapctools.util import ( combine_hashes, combine_hashes_dict, ensure_symlink, @@ -46,7 +41,7 @@ warn, write_yaml, ) -from verdicts import Verdict +from bapctools.verdicts import Verdict if has_ryaml: import ruamel.yaml diff --git a/bin/interactive.py b/bapctools/interactive.py similarity index 99% rename from bin/interactive.py rename to bapctools/interactive.py index cd1753c71..4ef9aacce 100644 --- a/bin/interactive.py +++ b/bapctools/interactive.py @@ -9,9 +9,8 @@ from pathlib import Path from typing import Any, Final, IO, Literal, Optional, TYPE_CHECKING -import config -import validate -from util import ( +from bapctools import config, validate +from bapctools.util import ( eprint, error, exec_command, @@ -23,10 +22,10 @@ PrintBar, ProgressBar, ) -from verdicts import Verdict +from bapctools.verdicts import Verdict if TYPE_CHECKING: - from run import Run + from bapctools.run import Run BUFFER_SIZE: Final[int] = 2**20 diff --git a/bin/latex.py b/bapctools/latex.py similarity index 97% rename from bin/latex.py rename to bapctools/latex.py index d21cf6800..be46c3b7a 100644 --- a/bin/latex.py +++ b/bapctools/latex.py @@ -8,9 +8,9 @@ from pathlib import Path from typing import Optional, TextIO, TYPE_CHECKING -import config -from contest import contest_yaml, problems_yaml -from util import ( +from bapctools import config +from bapctools.contest import contest_yaml, problems_yaml +from bapctools.util import ( copy_and_substitute, ensure_symlink, eprint, @@ -24,7 +24,7 @@ ) if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 - from problem import Problem + from bapctools.problem import Problem class PdfType(Enum): @@ -230,7 +230,7 @@ def make_environment(builddir: Path) -> dict[str, str]: cwd / "solve_stats", cwd / "solve_stats" / "activity", cwd / "latex", - config.TOOLS_ROOT / "latex", + config.RESOURCES_ROOT / "latex", # The default empty element at the end makes sure that the new TEXINPUTS ends with a path separator. # This is required to make LaTeX look in the default global paths: https://tex.stackexchange.com/a/410353 env.get("TEXINPUTS", ""), @@ -375,7 +375,7 @@ def build_problem_pdf( local_data = Path(main_file) copy_and_substitute( - local_data if local_data.is_file() else config.TOOLS_ROOT / "latex" / main_file, + local_data if local_data.is_file() else config.RESOURCES_ROOT / "latex" / main_file, builddir / main_file, problem_data(problem, language), bar=bar, @@ -419,7 +419,7 @@ def find_logo() -> Path: logo = Path(directory + "logo." + extension) if logo.exists(): return logo - return config.TOOLS_ROOT / "latex" / "images" / "logo-not-found.pdf" + return config.RESOURCES_ROOT / "latex" / "images" / "logo-not-found.pdf" def build_contest_pdf( @@ -458,7 +458,7 @@ def build_contest_pdf( ( local_contest_data if local_contest_data.is_file() - else config.TOOLS_ROOT / "latex" / "contest_data.tex" + else config.RESOURCES_ROOT / "latex" / "contest_data.tex" ), builddir / "contest_data.tex", config_data, @@ -480,7 +480,7 @@ def build_contest_pdf( per_problem_data_tex = ( local_per_problem_data if local_per_problem_data.is_file() - else config.TOOLS_ROOT / "latex" / local_per_problem_data.name + else config.RESOURCES_ROOT / "latex" / local_per_problem_data.name ).read_text() for prob in problems: diff --git a/bin/parallel.py b/bapctools/parallel.py similarity index 99% rename from bin/parallel.py rename to bapctools/parallel.py index 4121f003e..bd23a385f 100644 --- a/bin/parallel.py +++ b/bapctools/parallel.py @@ -5,8 +5,7 @@ from collections.abc import Callable, Sequence from typing import Any, Generic, Literal, Optional, TypeVar -import config -import util +from bapctools import config, util T = TypeVar("T") diff --git a/bin/problem.py b/bapctools/problem.py similarity index 99% rename from bin/problem.py rename to bapctools/problem.py index 07995c27c..874651f74 100644 --- a/bin/problem.py +++ b/bapctools/problem.py @@ -8,21 +8,23 @@ from typing import Final, Literal, Optional, overload, TYPE_CHECKING if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 - from program import Program + from bapctools.program import Program import math -import check_testing_tool -import config -import latex -import parallel -import run -import testcase -import validate -import validator_tests -import verdicts -import visualize -from util import ( +from bapctools import ( + check_testing_tool, + config, + latex, + parallel, + run, + testcase, + validate, + validator_tests, + verdicts, + visualize, +) +from bapctools.util import ( BAR_TYPE, combine_hashes_dict, drop_suffix, @@ -1047,7 +1049,7 @@ def _validators( if problem.custom_output: paths = [problem.path / validate.OutputValidator.source_dir] else: - paths = [config.TOOLS_ROOT / "support" / "default_output_validator.cpp"] + paths = [config.RESOURCES_ROOT / "support" / "default_output_validator.cpp"] else: paths = list(glob(problem.path / cls.source_dir, "*")) diff --git a/bin/program.py b/bapctools/program.py similarity index 98% rename from bin/program.py rename to bapctools/program.py index cb92cd270..e5887f868 100644 --- a/bin/program.py +++ b/bapctools/program.py @@ -9,8 +9,8 @@ from pathlib import Path from typing import Any, Final, Optional, TYPE_CHECKING -import config -from util import ( +from bapctools import config +from bapctools.util import ( combine_hashes, copy_and_substitute, ensure_symlink, @@ -31,7 +31,7 @@ ) if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 - from problem import Problem + from bapctools.problem import Problem class Language: @@ -128,7 +128,7 @@ def languages() -> Sequence[Language]: if languages_path.is_file(): raw_languages = read_yaml(languages_path) else: - raw_languages = read_yaml(config.TOOLS_ROOT / "config/languages.yaml") + raw_languages = read_yaml(config.RESOURCES_ROOT / "config/languages.yaml") if not isinstance(raw_languages, dict): fatal("could not parse languages.yaml.") @@ -359,7 +359,7 @@ def _get_language(self, bar: ProgressBar) -> bool: self.tmpdir / "build" if (self.tmpdir / "build") in self.input_files else "" ), "run": self.tmpdir / "run", - "viva_jar": config.TOOLS_ROOT / "third_party/viva/viva.jar", + "viva_jar": config.RESOURCES_ROOT / "third_party/viva/viva.jar", } return True @@ -390,7 +390,7 @@ def _checks(self, bar: ProgressBar) -> None: pass # Warn for known bad (non-deterministic) patterns in generators - from validate import Validator + from bapctools.validate import Validator if isinstance(self, Generator) or isinstance(self, Validator): if "c++" in self.language.name.lower(): diff --git a/config/languages.yaml b/bapctools/resources/config/languages.yaml similarity index 100% rename from config/languages.yaml rename to bapctools/resources/config/languages.yaml diff --git a/headers/compile_flags.txt b/bapctools/resources/headers/compile_flags.txt similarity index 100% rename from headers/compile_flags.txt rename to bapctools/resources/headers/compile_flags.txt diff --git a/headers/validation.h b/bapctools/resources/headers/validation.h similarity index 100% rename from headers/validation.h rename to bapctools/resources/headers/validation.h diff --git a/latex/bapc.cls b/bapctools/resources/latex/bapc.cls similarity index 100% rename from latex/bapc.cls rename to bapctools/resources/latex/bapc.cls diff --git a/latex/contest-problem-slide.tex b/bapctools/resources/latex/contest-problem-slide.tex similarity index 100% rename from latex/contest-problem-slide.tex rename to bapctools/resources/latex/contest-problem-slide.tex diff --git a/latex/contest-problem.tex b/bapctools/resources/latex/contest-problem.tex similarity index 100% rename from latex/contest-problem.tex rename to bapctools/resources/latex/contest-problem.tex diff --git a/latex/contest-solution.tex b/bapctools/resources/latex/contest-solution.tex similarity index 100% rename from latex/contest-solution.tex rename to bapctools/resources/latex/contest-solution.tex diff --git a/latex/contest-web.tex b/bapctools/resources/latex/contest-web.tex similarity index 100% rename from latex/contest-web.tex rename to bapctools/resources/latex/contest-web.tex diff --git a/latex/contest.tex b/bapctools/resources/latex/contest.tex similarity index 100% rename from latex/contest.tex rename to bapctools/resources/latex/contest.tex diff --git a/latex/contest_data.tex b/bapctools/resources/latex/contest_data.tex similarity index 100% rename from latex/contest_data.tex rename to bapctools/resources/latex/contest_data.tex diff --git a/latex/images/blank-page-fr.png b/bapctools/resources/latex/images/blank-page-fr.png similarity index 100% rename from latex/images/blank-page-fr.png rename to bapctools/resources/latex/images/blank-page-fr.png diff --git a/latex/images/blank-page-fr.tex b/bapctools/resources/latex/images/blank-page-fr.tex similarity index 100% rename from latex/images/blank-page-fr.tex rename to bapctools/resources/latex/images/blank-page-fr.tex diff --git a/latex/images/cc-by-sa.pdf b/bapctools/resources/latex/images/cc-by-sa.pdf similarity index 100% rename from latex/images/cc-by-sa.pdf rename to bapctools/resources/latex/images/cc-by-sa.pdf diff --git a/latex/images/logo-not-found.pdf b/bapctools/resources/latex/images/logo-not-found.pdf similarity index 100% rename from latex/images/logo-not-found.pdf rename to bapctools/resources/latex/images/logo-not-found.pdf diff --git a/latex/lang/da.tex b/bapctools/resources/latex/lang/da.tex similarity index 100% rename from latex/lang/da.tex rename to bapctools/resources/latex/lang/da.tex diff --git a/latex/lang/de.tex b/bapctools/resources/latex/lang/de.tex similarity index 100% rename from latex/lang/de.tex rename to bapctools/resources/latex/lang/de.tex diff --git a/latex/lang/en.tex b/bapctools/resources/latex/lang/en.tex similarity index 100% rename from latex/lang/en.tex rename to bapctools/resources/latex/lang/en.tex diff --git a/latex/lang/es.tex b/bapctools/resources/latex/lang/es.tex similarity index 100% rename from latex/lang/es.tex rename to bapctools/resources/latex/lang/es.tex diff --git a/latex/lang/fr.tex b/bapctools/resources/latex/lang/fr.tex similarity index 100% rename from latex/lang/fr.tex rename to bapctools/resources/latex/lang/fr.tex diff --git a/latex/lang/is.tex b/bapctools/resources/latex/lang/is.tex similarity index 100% rename from latex/lang/is.tex rename to bapctools/resources/latex/lang/is.tex diff --git a/latex/lang/nl.tex b/bapctools/resources/latex/lang/nl.tex similarity index 100% rename from latex/lang/nl.tex rename to bapctools/resources/latex/lang/nl.tex diff --git a/latex/problem-slide.tex b/bapctools/resources/latex/problem-slide.tex similarity index 100% rename from latex/problem-slide.tex rename to bapctools/resources/latex/problem-slide.tex diff --git a/latex/problem-slides-base.tex b/bapctools/resources/latex/problem-slides-base.tex similarity index 100% rename from latex/problem-slides-base.tex rename to bapctools/resources/latex/problem-slides-base.tex diff --git a/latex/problem-slides.tex b/bapctools/resources/latex/problem-slides.tex similarity index 100% rename from latex/problem-slides.tex rename to bapctools/resources/latex/problem-slides.tex diff --git a/latex/problem.tex b/bapctools/resources/latex/problem.tex similarity index 100% rename from latex/problem.tex rename to bapctools/resources/latex/problem.tex diff --git a/latex/solution-web.tex b/bapctools/resources/latex/solution-web.tex similarity index 100% rename from latex/solution-web.tex rename to bapctools/resources/latex/solution-web.tex diff --git a/latex/solution.tex b/bapctools/resources/latex/solution.tex similarity index 100% rename from latex/solution.tex rename to bapctools/resources/latex/solution.tex diff --git a/latex/solutions-base.tex b/bapctools/resources/latex/solutions-base.tex similarity index 100% rename from latex/solutions-base.tex rename to bapctools/resources/latex/solutions-base.tex diff --git a/latex/solutions-web.tex b/bapctools/resources/latex/solutions-web.tex similarity index 100% rename from latex/solutions-web.tex rename to bapctools/resources/latex/solutions-web.tex diff --git a/latex/solutions.tex b/bapctools/resources/latex/solutions.tex similarity index 100% rename from latex/solutions.tex rename to bapctools/resources/latex/solutions.tex diff --git a/skel/contest/.gitignore b/bapctools/resources/skel/contest/.gitignore similarity index 100% rename from skel/contest/.gitignore rename to bapctools/resources/skel/contest/.gitignore diff --git a/skel/contest/contest.yaml b/bapctools/resources/skel/contest/contest.yaml similarity index 100% rename from skel/contest/contest.yaml rename to bapctools/resources/skel/contest/contest.yaml diff --git a/skel/contest/languages.yaml b/bapctools/resources/skel/contest/languages.yaml similarity index 100% rename from skel/contest/languages.yaml rename to bapctools/resources/skel/contest/languages.yaml diff --git a/skel/contest/logo.pdf b/bapctools/resources/skel/contest/logo.pdf similarity index 100% rename from skel/contest/logo.pdf rename to bapctools/resources/skel/contest/logo.pdf diff --git a/skel/contest/problems.yaml b/bapctools/resources/skel/contest/problems.yaml similarity index 100% rename from skel/contest/problems.yaml rename to bapctools/resources/skel/contest/problems.yaml diff --git a/skel/contest/solution_footer.tex b/bapctools/resources/skel/contest/solution_footer.tex similarity index 100% rename from skel/contest/solution_footer.tex rename to bapctools/resources/skel/contest/solution_footer.tex diff --git a/skel/contest/solution_header.tex b/bapctools/resources/skel/contest/solution_header.tex similarity index 100% rename from skel/contest/solution_header.tex rename to bapctools/resources/skel/contest/solution_header.tex diff --git a/skel/forgejo_actions_docker_bt/contest.yaml b/bapctools/resources/skel/forgejo_actions_docker_bt/contest.yaml similarity index 100% rename from skel/forgejo_actions_docker_bt/contest.yaml rename to bapctools/resources/skel/forgejo_actions_docker_bt/contest.yaml diff --git a/skel/forgejo_actions_docker_bt/problem.yaml b/bapctools/resources/skel/forgejo_actions_docker_bt/problem.yaml similarity index 100% rename from skel/forgejo_actions_docker_bt/problem.yaml rename to bapctools/resources/skel/forgejo_actions_docker_bt/problem.yaml diff --git a/skel/forgejo_actions_latest_bt/contest.yaml b/bapctools/resources/skel/forgejo_actions_latest_bt/contest.yaml similarity index 100% rename from skel/forgejo_actions_latest_bt/contest.yaml rename to bapctools/resources/skel/forgejo_actions_latest_bt/contest.yaml diff --git a/skel/forgejo_actions_latest_bt/problem.yaml b/bapctools/resources/skel/forgejo_actions_latest_bt/problem.yaml similarity index 100% rename from skel/forgejo_actions_latest_bt/problem.yaml rename to bapctools/resources/skel/forgejo_actions_latest_bt/problem.yaml diff --git a/skel/forgejo_actions_latest_bt/setup.yaml b/bapctools/resources/skel/forgejo_actions_latest_bt/setup.yaml similarity index 100% rename from skel/forgejo_actions_latest_bt/setup.yaml rename to bapctools/resources/skel/forgejo_actions_latest_bt/setup.yaml diff --git a/skel/gitlab_ci/contest.yaml b/bapctools/resources/skel/gitlab_ci/contest.yaml similarity index 100% rename from skel/gitlab_ci/contest.yaml rename to bapctools/resources/skel/gitlab_ci/contest.yaml diff --git a/skel/gitlab_ci/header_docker_bt.yaml b/bapctools/resources/skel/gitlab_ci/header_docker_bt.yaml similarity index 100% rename from skel/gitlab_ci/header_docker_bt.yaml rename to bapctools/resources/skel/gitlab_ci/header_docker_bt.yaml diff --git a/skel/gitlab_ci/header_latest_bt.yaml b/bapctools/resources/skel/gitlab_ci/header_latest_bt.yaml similarity index 100% rename from skel/gitlab_ci/header_latest_bt.yaml rename to bapctools/resources/skel/gitlab_ci/header_latest_bt.yaml diff --git a/skel/gitlab_ci/problem.yaml b/bapctools/resources/skel/gitlab_ci/problem.yaml similarity index 100% rename from skel/gitlab_ci/problem.yaml rename to bapctools/resources/skel/gitlab_ci/problem.yaml diff --git a/skel/multiple_validators/build b/bapctools/resources/skel/multiple_validators/build similarity index 100% rename from skel/multiple_validators/build rename to bapctools/resources/skel/multiple_validators/build diff --git a/skel/multiple_validators/run b/bapctools/resources/skel/multiple_validators/run similarity index 100% rename from skel/multiple_validators/run rename to bapctools/resources/skel/multiple_validators/run diff --git a/skel/problem/answer_validators/answer_validator/answer_validator.cpp b/bapctools/resources/skel/problem/answer_validators/answer_validator/answer_validator.cpp similarity index 100% rename from skel/problem/answer_validators/answer_validator/answer_validator.cpp rename to bapctools/resources/skel/problem/answer_validators/answer_validator/answer_validator.cpp diff --git a/skel/problem/answer_validators/answer_validator/validation.h b/bapctools/resources/skel/problem/answer_validators/answer_validator/validation.h similarity index 100% rename from skel/problem/answer_validators/answer_validator/validation.h rename to bapctools/resources/skel/problem/answer_validators/answer_validator/validation.h diff --git a/skel/problem/generators/example_generator.py b/bapctools/resources/skel/problem/generators/example_generator.py similarity index 100% rename from skel/problem/generators/example_generator.py rename to bapctools/resources/skel/problem/generators/example_generator.py diff --git a/skel/problem/generators/generators.yaml b/bapctools/resources/skel/problem/generators/generators.yaml similarity index 100% rename from skel/problem/generators/generators.yaml rename to bapctools/resources/skel/problem/generators/generators.yaml diff --git a/skel/problem/input_validators/input_validator/input_validator.cpp b/bapctools/resources/skel/problem/input_validators/input_validator/input_validator.cpp similarity index 100% rename from skel/problem/input_validators/input_validator/input_validator.cpp rename to bapctools/resources/skel/problem/input_validators/input_validator/input_validator.cpp diff --git a/skel/problem/input_validators/input_validator/validation.h b/bapctools/resources/skel/problem/input_validators/input_validator/validation.h similarity index 100% rename from skel/problem/input_validators/input_validator/validation.h rename to bapctools/resources/skel/problem/input_validators/input_validator/validation.h diff --git a/skel/problem/input_visualizer/example_input_visualizer.py b/bapctools/resources/skel/problem/input_visualizer/example_input_visualizer.py similarity index 100% rename from skel/problem/input_visualizer/example_input_visualizer.py rename to bapctools/resources/skel/problem/input_visualizer/example_input_visualizer.py diff --git a/skel/problem/input_visualizer/readme.md b/bapctools/resources/skel/problem/input_visualizer/readme.md similarity index 100% rename from skel/problem/input_visualizer/readme.md rename to bapctools/resources/skel/problem/input_visualizer/readme.md diff --git a/skel/problem/output_validator/output_validator.cpp b/bapctools/resources/skel/problem/output_validator/output_validator.cpp similarity index 100% rename from skel/problem/output_validator/output_validator.cpp rename to bapctools/resources/skel/problem/output_validator/output_validator.cpp diff --git a/skel/problem/output_validator/validation.h b/bapctools/resources/skel/problem/output_validator/validation.h similarity index 100% rename from skel/problem/output_validator/validation.h rename to bapctools/resources/skel/problem/output_validator/validation.h diff --git a/skel/problem/output_visualizer/example_output_visualizer.py b/bapctools/resources/skel/problem/output_visualizer/example_output_visualizer.py similarity index 100% rename from skel/problem/output_visualizer/example_output_visualizer.py rename to bapctools/resources/skel/problem/output_visualizer/example_output_visualizer.py diff --git a/skel/problem/problem.yaml b/bapctools/resources/skel/problem/problem.yaml similarity index 100% rename from skel/problem/problem.yaml rename to bapctools/resources/skel/problem/problem.yaml diff --git a/skel/problem/problem_slide/problem-slide.en.tex b/bapctools/resources/skel/problem/problem_slide/problem-slide.en.tex similarity index 100% rename from skel/problem/problem_slide/problem-slide.en.tex rename to bapctools/resources/skel/problem/problem_slide/problem-slide.en.tex diff --git a/skel/problem/solution/solution.en.tex b/bapctools/resources/skel/problem/solution/solution.en.tex similarity index 100% rename from skel/problem/solution/solution.en.tex rename to bapctools/resources/skel/problem/solution/solution.en.tex diff --git a/skel/problem/statement/problem.en.tex b/bapctools/resources/skel/problem/statement/problem.en.tex similarity index 100% rename from skel/problem/statement/problem.en.tex rename to bapctools/resources/skel/problem/statement/problem.en.tex diff --git a/skel/problem/submissions/run_time_error/.gitkeep b/bapctools/resources/skel/problem/submissions/accepted/.gitkeep similarity index 100% rename from skel/problem/submissions/run_time_error/.gitkeep rename to bapctools/resources/skel/problem/submissions/accepted/.gitkeep diff --git a/skel/problem/submissions/time_limit_exceeded/.gitkeep b/bapctools/resources/skel/problem/submissions/run_time_error/.gitkeep similarity index 100% rename from skel/problem/submissions/time_limit_exceeded/.gitkeep rename to bapctools/resources/skel/problem/submissions/run_time_error/.gitkeep diff --git a/skel/problem/submissions/wrong_answer/.gitkeep b/bapctools/resources/skel/problem/submissions/time_limit_exceeded/.gitkeep similarity index 100% rename from skel/problem/submissions/wrong_answer/.gitkeep rename to bapctools/resources/skel/problem/submissions/time_limit_exceeded/.gitkeep diff --git a/bapctools/resources/skel/problem/submissions/wrong_answer/.gitkeep b/bapctools/resources/skel/problem/submissions/wrong_answer/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/skel/problem_cfp/AUTHOR.md b/bapctools/resources/skel/problem_cfp/AUTHOR.md similarity index 100% rename from skel/problem_cfp/AUTHOR.md rename to bapctools/resources/skel/problem_cfp/AUTHOR.md diff --git a/skel/problem_cfp/README.md b/bapctools/resources/skel/problem_cfp/README.md similarity index 100% rename from skel/problem_cfp/README.md rename to bapctools/resources/skel/problem_cfp/README.md diff --git a/skel/problem_cfp/SOLUTION.md b/bapctools/resources/skel/problem_cfp/SOLUTION.md similarity index 100% rename from skel/problem_cfp/SOLUTION.md rename to bapctools/resources/skel/problem_cfp/SOLUTION.md diff --git a/skel/problem_cfp/data/sample/1.ans b/bapctools/resources/skel/problem_cfp/data/sample/1.ans similarity index 100% rename from skel/problem_cfp/data/sample/1.ans rename to bapctools/resources/skel/problem_cfp/data/sample/1.ans diff --git a/skel/problem_cfp/data/sample/1.in b/bapctools/resources/skel/problem_cfp/data/sample/1.in similarity index 100% rename from skel/problem_cfp/data/sample/1.in rename to bapctools/resources/skel/problem_cfp/data/sample/1.in diff --git a/skel/problem_cfp/data/test_group.yaml b/bapctools/resources/skel/problem_cfp/data/test_group.yaml similarity index 100% rename from skel/problem_cfp/data/test_group.yaml rename to bapctools/resources/skel/problem_cfp/data/test_group.yaml diff --git a/skel/problem_cfp/problem.yaml b/bapctools/resources/skel/problem_cfp/problem.yaml similarity index 100% rename from skel/problem_cfp/problem.yaml rename to bapctools/resources/skel/problem_cfp/problem.yaml diff --git a/skel/problem_cfp/solution/solution.en.tex b/bapctools/resources/skel/problem_cfp/solution/solution.en.tex similarity index 100% rename from skel/problem_cfp/solution/solution.en.tex rename to bapctools/resources/skel/problem_cfp/solution/solution.en.tex diff --git a/skel/problem_cfp/statement/problem.en.tex b/bapctools/resources/skel/problem_cfp/statement/problem.en.tex similarity index 100% rename from skel/problem_cfp/statement/problem.en.tex rename to bapctools/resources/skel/problem_cfp/statement/problem.en.tex diff --git a/skel/problem_cfp/submissions/accepted/author.cpp b/bapctools/resources/skel/problem_cfp/submissions/accepted/author.cpp similarity index 100% rename from skel/problem_cfp/submissions/accepted/author.cpp rename to bapctools/resources/skel/problem_cfp/submissions/accepted/author.cpp diff --git a/skel/problem_cfp/submissions/accepted/author.py b/bapctools/resources/skel/problem_cfp/submissions/accepted/author.py similarity index 100% rename from skel/problem_cfp/submissions/accepted/author.py rename to bapctools/resources/skel/problem_cfp/submissions/accepted/author.py diff --git a/skel/testing_tool.py b/bapctools/resources/skel/testing_tool.py similarity index 100% rename from skel/testing_tool.py rename to bapctools/resources/skel/testing_tool.py diff --git a/skel/testing_tool_multi_pass.py b/bapctools/resources/skel/testing_tool_multi_pass.py similarity index 100% rename from skel/testing_tool_multi_pass.py rename to bapctools/resources/skel/testing_tool_multi_pass.py diff --git a/support/default_output_validator.cpp b/bapctools/resources/support/default_output_validator.cpp similarity index 100% rename from support/default_output_validator.cpp rename to bapctools/resources/support/default_output_validator.cpp diff --git a/support/schemas/generators.cue b/bapctools/resources/support/schemas/generators.cue similarity index 100% rename from support/schemas/generators.cue rename to bapctools/resources/support/schemas/generators.cue diff --git a/support/schemas/generators_yaml_schema.json b/bapctools/resources/support/schemas/generators_yaml_schema.json similarity index 100% rename from support/schemas/generators_yaml_schema.json rename to bapctools/resources/support/schemas/generators_yaml_schema.json diff --git a/support/schemas/problemformat.cue b/bapctools/resources/support/schemas/problemformat.cue similarity index 100% rename from support/schemas/problemformat.cue rename to bapctools/resources/support/schemas/problemformat.cue diff --git a/third_party/checktestdata b/bapctools/resources/third_party/checktestdata similarity index 100% rename from third_party/checktestdata rename to bapctools/resources/third_party/checktestdata diff --git a/third_party/readme.md b/bapctools/resources/third_party/readme.md similarity index 100% rename from third_party/readme.md rename to bapctools/resources/third_party/readme.md diff --git a/third_party/viva/VIVA User's Guide.pdf b/bapctools/resources/third_party/viva/VIVA User's Guide.pdf similarity index 100% rename from third_party/viva/VIVA User's Guide.pdf rename to bapctools/resources/third_party/viva/VIVA User's Guide.pdf diff --git a/third_party/viva/viva.jar b/bapctools/resources/third_party/viva/viva.jar similarity index 100% rename from third_party/viva/viva.jar rename to bapctools/resources/third_party/viva/viva.jar diff --git a/third_party/viva/vivagui.jar b/bapctools/resources/third_party/viva/vivagui.jar similarity index 100% rename from third_party/viva/vivagui.jar rename to bapctools/resources/third_party/viva/vivagui.jar diff --git a/bin/run.py b/bapctools/run.py similarity index 98% rename from bin/run.py rename to bapctools/run.py index eb4f29564..c5b5718d1 100644 --- a/bin/run.py +++ b/bapctools/run.py @@ -8,15 +8,9 @@ from pathlib import Path from typing import Optional -import config -import interactive -import parallel -import problem -import program -import validate -import visualize -from testcase import Testcase -from util import ( +from bapctools import config, interactive, parallel, problem, program, validate, visualize +from bapctools.testcase import Testcase +from bapctools.util import ( BAR_TYPE, crop_output, ensure_symlink, @@ -30,7 +24,14 @@ shorten_path, warn, ) -from verdicts import from_string, from_string_domjudge, RunUntil, Verdict, Verdicts, VerdictTable +from bapctools.verdicts import ( + from_string, + from_string_domjudge, + RunUntil, + Verdict, + Verdicts, + VerdictTable, +) class Run: diff --git a/bin/skel.py b/bapctools/skel.py similarity index 94% rename from bin/skel.py rename to bapctools/skel.py index a5c18d4c7..f65f12254 100644 --- a/bin/skel.py +++ b/bapctools/skel.py @@ -5,11 +5,9 @@ from pathlib import Path # Local imports -import config -import contest -import latex -from problem import Problem -from util import ( +from bapctools import config, contest, latex +from bapctools.problem import Problem +from bapctools.util import ( ask_variable_bool, ask_variable_choice, ask_variable_string, @@ -27,7 +25,7 @@ warn, write_yaml, ) -from validate import OutputValidator +from bapctools.validate import OutputValidator # Returns the alphanumeric version of a string: @@ -63,7 +61,7 @@ def new_contest() -> None: rights_owner = f"rights_owner: {rights_owner}\n" if rights_owner else "" title = title.replace("_", "-") - skeldir = config.TOOLS_ROOT / "skel/contest" + skeldir = config.RESOURCES_ROOT / "skel/contest" log(f"Copying {skeldir} to {dirname}.") copytree_and_substitute( skeldir, Path(dirname), locals(), exist_ok=False, preserve_symlinks=False @@ -71,7 +69,7 @@ def new_contest() -> None: def get_skel_dir(target_dir: Path) -> tuple[Path, bool]: - skeldir = config.TOOLS_ROOT / "skel/problem" + skeldir = config.RESOURCES_ROOT / "skel/problem" preserve_symlinks = False if (target_dir / "skel/problem").is_dir(): skeldir = target_dir / "skel/problem" @@ -311,12 +309,12 @@ def problem_source_dir(problem: Problem) -> Path: return problem.path.absolute().relative_to(git_root_path) if config.args.latest_bt: - header_yml = (config.TOOLS_ROOT / "skel/gitlab_ci/header_latest_bt.yaml").read_text() + header_yml = (config.RESOURCES_ROOT / "skel/gitlab_ci/header_latest_bt.yaml").read_text() else: - header_yml = (config.TOOLS_ROOT / "skel/gitlab_ci/header_docker_bt.yaml").read_text() + header_yml = (config.RESOURCES_ROOT / "skel/gitlab_ci/header_docker_bt.yaml").read_text() print(header_yml) - contest_yml = (config.TOOLS_ROOT / "skel/gitlab_ci/contest.yaml").read_text() + contest_yml = (config.RESOURCES_ROOT / "skel/gitlab_ci/contest.yaml").read_text() contest_path = Path(".").absolute().relative_to(git_root_path) changes = "".join( f" - {problem_source_dir(problem)}/{pdf_type.path().parent}/**/*\n" @@ -329,7 +327,7 @@ def problem_source_dir(problem: Problem) -> Path: ) ) - problem_yml = (config.TOOLS_ROOT / "skel/gitlab_ci/problem.yaml").read_text() + problem_yml = (config.RESOURCES_ROOT / "skel/gitlab_ci/problem.yaml").read_text() for problem_obj in problems: problem_path = problem_source_dir(problem_obj) problem = problem_obj.name @@ -351,9 +349,9 @@ def create_forgejo_actions(contest: str, problems: list[Problem]) -> None: fatal(".git and ../.git not found after changing to contest directory.") if config.args.latest_bt: - src = config.TOOLS_ROOT / "skel/forgejo_actions_latest_bt" + src = config.RESOURCES_ROOT / "skel/forgejo_actions_latest_bt" else: - src = config.TOOLS_ROOT / "skel/forgejo_actions_docker_bt" + src = config.RESOURCES_ROOT / "skel/forgejo_actions_docker_bt" if config.args.latest_bt: # Copy the 'setup' action: @@ -403,7 +401,7 @@ def create_github_actions(contest: str, problems: list[Problem]) -> None: # Copy the contest-level workflow. contest_workflow_source = ( - config.TOOLS_ROOT / "skel/forgejo_actions_docker_bt/contest.yaml" + config.RESOURCES_ROOT / "skel/forgejo_actions_docker_bt/contest.yaml" ).read_text() contest_workflow = substitute( contest_workflow_source, {"contest": contest, "contest_path": str(contest_path)} @@ -417,7 +415,7 @@ def create_github_actions(contest: str, problems: list[Problem]) -> None: # Copy the problem-level workflows. problem_workflow_source = ( - config.TOOLS_ROOT / "skel/forgejo_actions_docker_bt/problem.yaml" + config.RESOURCES_ROOT / "skel/forgejo_actions_docker_bt/problem.yaml" ).read_text() for problem_obj in problems: problem = problem_obj.name diff --git a/bin/slack.py b/bapctools/slack.py similarity index 95% rename from bin/slack.py rename to bapctools/slack.py index e6bad1e86..b231f2c60 100644 --- a/bin/slack.py +++ b/bapctools/slack.py @@ -1,8 +1,8 @@ from typing import Any, TYPE_CHECKING -import config -from problem import Problem -from util import error, fatal, log, verbose +from bapctools import config +from bapctools.problem import Problem +from bapctools.util import error, fatal, log, verbose if TYPE_CHECKING: import requests diff --git a/bin/solve_stats.py b/bapctools/solve_stats.py similarity index 98% rename from bin/solve_stats.py rename to bapctools/solve_stats.py index c84ce064a..437148a1f 100644 --- a/bin/solve_stats.py +++ b/bapctools/solve_stats.py @@ -3,10 +3,9 @@ from pathlib import Path from typing import Any, Optional -import config -import parallel -from contest import call_api_get_json, get_contest_id -from util import ProgressBar +from bapctools import config, parallel +from bapctools.contest import call_api_get_json, get_contest_id +from bapctools.util import ProgressBar # Note on multiprocessing: # Our custom parallel module uses light-weight threads, which all compete for the global interpreter lock: diff --git a/bin/stats.py b/bapctools/stats.py similarity index 99% rename from bin/stats.py rename to bapctools/stats.py index f339e2f2d..50f467635 100644 --- a/bin/stats.py +++ b/bapctools/stats.py @@ -6,13 +6,9 @@ from pathlib import Path from typing import Any, cast, Literal, Optional -import config -import generate -import latex -import program -import validate -from problem import Problem -from util import eprint, error, glob, log, ShellCommand, warn +from bapctools import config, generate, latex, program, validate +from bapctools.problem import Problem +from bapctools.util import eprint, error, glob, log, ShellCommand, warn Selector = ( str | Callable[[Problem], int | float] | list[str] | list[Callable[[set[Path]], set[str]]] diff --git a/bin/testcase.py b/bapctools/testcase.py similarity index 99% rename from bin/testcase.py rename to bapctools/testcase.py index 294a9f54c..08db17a94 100644 --- a/bin/testcase.py +++ b/bapctools/testcase.py @@ -5,9 +5,8 @@ from pathlib import Path from typing import Optional, TYPE_CHECKING -import config -import validate -from util import ( +from bapctools import config, validate +from bapctools.util import ( BAR_TYPE, combine_hashes_dict, ExecStatus, @@ -18,8 +17,7 @@ ) if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 - import problem - import visualize + from bapctools import problem, visualize # TODO #102: Consistently separate the compound noun "test case", e.g. "TestCase" or "test_case" diff --git a/bin/upgrade.py b/bapctools/upgrade.py similarity index 99% rename from bin/upgrade.py rename to bapctools/upgrade.py index 00bc1ecd5..53055c8bb 100644 --- a/bin/upgrade.py +++ b/bapctools/upgrade.py @@ -6,9 +6,8 @@ from pathlib import Path from typing import Any, cast, Optional -import config -import generate -from util import ( +from bapctools import config, generate +from bapctools.util import ( fatal, has_ryaml, is_problem_directory, @@ -20,7 +19,7 @@ ryaml_replace, write_yaml, ) -from validate import AnswerValidator, InputValidator, OutputValidator +from bapctools.validate import AnswerValidator, InputValidator, OutputValidator if has_ryaml: from ruamel.yaml.comments import CommentedMap, CommentedSeq diff --git a/bin/util.py b/bapctools/util.py similarity index 99% rename from bin/util.py rename to bapctools/util.py index 2add520c4..5598f1179 100644 --- a/bin/util.py +++ b/bapctools/util.py @@ -36,7 +36,7 @@ ) from uuid import UUID -import config +from bapctools import config try: import ruamel.yaml @@ -51,8 +51,8 @@ has_ryaml = False if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 - from problem import Problem - from verdicts import Verdict + from bapctools.problem import Problem + from bapctools.verdicts import Verdict # For some reason ryaml.load doesn't work well in parallel. diff --git a/bin/validate.py b/bapctools/validate.py similarity index 98% rename from bin/validate.py rename to bapctools/validate.py index 86bacbd54..241158765 100644 --- a/bin/validate.py +++ b/bapctools/validate.py @@ -4,14 +4,12 @@ from pathlib import Path from typing import Any, Final, Optional, TYPE_CHECKING -import config -import program -from util import ExecResult, ExecStatus, fatal, ProgressBar, validator_exec_code_map +from bapctools import config, program +from bapctools.util import ExecResult, ExecStatus, fatal, ProgressBar, validator_exec_code_map if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 - import run - import testcase - from problem import Problem + from bapctools import run, testcase + from bapctools.problem import Problem class Mode(Enum): diff --git a/bin/validator_tests.py b/bapctools/validator_tests.py similarity index 98% rename from bin/validator_tests.py rename to bapctools/validator_tests.py index e1d6b8b56..91ab888d5 100644 --- a/bin/validator_tests.py +++ b/bapctools/validator_tests.py @@ -1,7 +1,7 @@ from collections.abc import Callable, Sequence from typing import Final, Optional, TypeVar -from validate import AnswerValidator, AnyValidator, InputValidator, OutputValidator +from bapctools.validate import AnswerValidator, AnyValidator, InputValidator, OutputValidator # helper function diff --git a/bin/verdicts.py b/bapctools/verdicts.py similarity index 99% rename from bin/verdicts.py rename to bapctools/verdicts.py index 8833742da..150c411c1 100644 --- a/bin/verdicts.py +++ b/bapctools/verdicts.py @@ -8,12 +8,11 @@ from pathlib import Path from typing import Any, Literal, Optional, TYPE_CHECKING -import config -import testcase -from util import eprint, ITEM_TYPE, ProgressBar +from bapctools import config, testcase +from bapctools.util import eprint, ITEM_TYPE, ProgressBar if TYPE_CHECKING: - import run + from bapctools import run class Verdict(Enum): @@ -695,7 +694,7 @@ def _print(self, *args: Any, **kwargs: Any) -> None: eprint(*args, **kwargs) def start(self, item: ITEM_TYPE = "") -> "TableProgressBar": - from run import Run + from bapctools.run import Run assert isinstance(item, Run) self.table.add_test_case(item.testcase.name) diff --git a/bin/visualize.py b/bapctools/visualize.py similarity index 96% rename from bin/visualize.py rename to bapctools/visualize.py index 6275c1830..9a58d7b09 100644 --- a/bin/visualize.py +++ b/bapctools/visualize.py @@ -2,11 +2,11 @@ from pathlib import Path from typing import Any, Final, Optional, TYPE_CHECKING -import program -from util import ExecResult +from bapctools import program +from bapctools.util import ExecResult if TYPE_CHECKING: # Prevent circular import: https://stackoverflow.com/a/39757388 - from problem import Problem + from bapctools.problem import Problem class InputVisualizer(program.Program): diff --git a/bin/tools.py b/bin/tools.py index afa5c32fa..06deda4ce 100755 --- a/bin/tools.py +++ b/bin/tools.py @@ -1,1485 +1,15 @@ #!/usr/bin/env python3 # PYTHON_ARGCOMPLETE_OK -"""Can be run on multiple levels: - - from the root of the git repository - - from a contest directory - - from a problem directory -the tool will know where it is (by looking for the .git directory) and run on -everything inside it - -- Ragnar Groot Koerkamp - -Parts of this are copied from/based on run_program.py, written by Raymond van -Bommel. -""" - -import argparse -import colorama -import hashlib -import os -import re -import shutil -import signal import sys -import tempfile -from collections import Counter -from colorama import Style from pathlib import Path -from typing import Any, Optional - -# Local imports -import config -import constraints -import contest -import download_submissions -import export -import fuzz -import generate -import latex -import skel -import slack -import solve_stats -import stats -import upgrade -import validate -from contest import call_api_get_json, contest_yaml, get_contest_id, problems_yaml -from problem import Problem -from util import ( - AbortException, - ask_variable_bool, - eprint, - error, - fatal, - glob, - has_ryaml, - inc_label, - is_problem_directory, - is_relative_to, - is_windows, - log, - ProgressBar, - read_yaml, - resolve_path_argument, - verbose, - warn, - write_yaml, -) - -if not is_windows(): - import argcomplete # For automatic shell completions - -# Initialize colorama for printing coloured output. On Windows, this captures -# stdout and replaces ANSI colour codes by calls to change the terminal colour. -# -# This initialization is disabled on GITLAB CI, since Colorama detects that -# the terminal is not a TTY and will strip all colour codes. Instead, we just -# disable this call since capturing of stdout/stderr isn't needed on Linux -# anyway. -# See: -# - https://github.com/conan-io/conan/issues/4718#issuecomment-473102953 -# - https://docs.gitlab.com/runner/faq/#how-can-i-get-colored-output-on-the-web-terminal -if not os.getenv("GITLAB_CI", False) and not os.getenv("CI", False): - colorama.init() - -# List of high level todos: -# TODO: Do more things in parallel (running testcases, building submissions) -# TODO: Get rid of old problem.path and settings objects in tools.py. -# This mostly needs changes in the less frequently used subcommands. - -if sys.version_info < (3, 10): - fatal("BAPCtools requires at least Python 3.10.") - - -# Changes the working directory to the root of the contest. -# sets the "level" of the current command (either 'problem' or 'problemset') -# and, if `level == 'problem'` returns the directory of the problem. -def change_directory() -> Optional[Path]: - problem_dir: Optional[Path] = None - config.level = "problemset" - if config.args.contest: - contest_dir = config.args.contest.absolute() - os.chdir(contest_dir) - if config.args.problem: - problem_dir = config.args.problem.absolute() - elif is_problem_directory(Path.cwd()): - problem_dir = Path.cwd().absolute() - if problem_dir is not None: - config.level = "problem" - os.chdir(problem_dir.parent) - return problem_dir - - -# Get the list of relevant problems. -# Either use the problems.yaml, -# or check the existence of problem.yaml and sort by shortname. -def get_problems(problem_dir: Optional[Path]) -> tuple[list[Problem], Path]: - # We create one tmpdir per contest. - h = hashlib.sha256(bytes(Path.cwd())).hexdigest()[-6:] - tmpdir = Path(tempfile.gettempdir()) / ("bapctools_" + h) - tmpdir.mkdir(parents=True, exist_ok=True) - - def fallback_problems() -> list[tuple[Path, str]]: - problem_paths = list(filter(is_problem_directory, glob(Path("."), "*/"))) - label = chr(ord("Z") - len(problem_paths) + 1) if contest_yaml().test_session else "A" - problems = [] - for path in problem_paths: - problems.append((path, label)) - label = inc_label(label) - return problems - - problems = [] - if config.level == "problem": - assert problem_dir - # If the problem is mentioned in problems.yaml, use that ID. - for p in problems_yaml(): - if p.id == problem_dir.name: - problems = [Problem(Path(problem_dir.name), tmpdir, p.label)] - break - - if not problems: - for path, label in fallback_problems(): - if path.name == problem_dir.name: - problems = [Problem(Path(problem_dir.name), tmpdir, label)] - break - else: - assert config.level == "problemset" - # If problems.yaml is available, use it. - if problems_yaml(): - problems = [Problem(Path(p.id), tmpdir, p.label) for p in problems_yaml()] - else: - # Otherwise, fallback to all directories with a problem.yaml and sort by shortname. - problems = [Problem(path, tmpdir, label) for patj, label in fallback_problems()] - if len(problems) == 0: - fatal("Did not find problem.yaml. Are you running this from a problem directory?") - - if config.args.action == "solutions": - order = config.args.order or contest_yaml().order - if order is not None: - labels = {p.label for p in problems} - counts = Counter(order) - for id, count in counts.items(): - if id not in labels: - append_s = "s" if count != 1 else "" - warn(f"Unknown {id} appears {count} time{append_s} in 'order'") - elif count > 1: - warn(f"{id} appears {count} times in 'order'") - for problem in problems: - if problem.label not in counts: - warn(f"{problem.label} does not appear in 'order'") - - # Sort by position of id in order - def get_pos(id: Optional[str]) -> int: - if id and id in order: - return order.index(id) - else: - return len(order) - - problems.sort(key=lambda p: (get_pos(p.label), p.label, p.name)) - - if config.args.order_from_ccs: - # Sort by increasing difficulty, extracted from the CCS api. - class ProblemStat: - def __init__(self) -> None: - 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]) -> None: - 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() - - # Read set of problems - contest_problems = call_api_get_json(f"/contests/{cid}/problems?public=true") - assert isinstance(problems, list) - - 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 team_stats in team["problems"]: - problem_stats[team_stats["problem_id"]].update(team_stats) - - # Sort the problems - 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"): - if has_ryaml: - contest_yaml_path = Path("contest.yaml") - data = read_yaml(contest_yaml_path) or {} - if not isinstance(data, dict): - error("could not parse contest.yaml.") - else: - data["order"] = "".join(p.label or p.name for p in problems) - write_yaml(data, contest_yaml_path) - log("Updated order") - else: - error("ruamel.yaml library not found. Update the order manually.") - - # Filter problems by submissions/testcases, if given. - if config.level == "problemset" and (config.args.submissions or config.args.testcases): - submissions = config.args.submissions or [] - testcases = config.args.testcases or [] - - def keep_problem(problem: Problem) -> bool: - for s in submissions: - x = resolve_path_argument(problem, s, "submissions") - if x: - if is_relative_to(problem.path, x): - return True - for t in testcases: - x = resolve_path_argument(problem, t, "data", suffixes=[".in"]) - if x: - if is_relative_to(problem.path, x): - return True - return False - - problems = [p for p in problems if keep_problem(p)] - - return problems, tmpdir - - -# NOTE: This is one of the few places that prints to stdout instead of stderr. -def print_sorted(problems: list[Problem]) -> None: - for problem in problems: - print(f"{problem.label:<2}: {problem.path}") - - -def split_submissions_and_testcases(s: list[Path]) -> tuple[list[Path], list[Path]]: - # We try to identify testcases by common directory names and common suffixes - submissions = [] - testcases = [] - for p in s: - testcase_dirs = ["data", "sample", "secret", "fuzz", "testing_tool_cases"] - if ( - any(part in testcase_dirs for part in p.parts) - or p.suffix in config.KNOWN_DATA_EXTENSIONS - ): - # Strip potential suffix - if p.suffix in config.KNOWN_DATA_EXTENSIONS: - p = p.with_suffix("") - testcases.append(p) - else: - submissions.append(p) - return (submissions, testcases) - - -# We set argument_default=SUPPRESS in all parsers, -# to make sure no default values (like `False` or `0`) end up in the parsed arguments object. -# If we would not do this, it would not be possible to check which keys are explicitly set from the command line. -# This check is necessary when loading the personal config file in `read_personal_config`. -class SuppressingParser(argparse.ArgumentParser): - def __init__(self, **kwargs: Any) -> None: - super(SuppressingParser, self).__init__(**kwargs, argument_default=argparse.SUPPRESS) - - -def build_parser() -> SuppressingParser: - parser = SuppressingParser( - description=""" -Tools for ICPC style problem sets. -Run this from one of: - - the repository root, and supply `contest` - - a contest directory - - a problem directory -""", - formatter_class=argparse.RawTextHelpFormatter, - ) - - # Global options - global_parser = SuppressingParser(add_help=False) - global_parser.add_argument( - "--verbose", - "-v", - action="count", - help="Verbose output; once for what's going on, twice for all intermediate output.", - ) - group = global_parser.add_mutually_exclusive_group() - group.add_argument("--contest", type=Path, help="Path to the contest to use.") - group.add_argument( - "--problem", - type=Path, - help="Path to the problem to use. Can be relative to contest if given.", - ) - - global_parser.add_argument( - "--no-bar", - action="store_true", - help="Do not show progress bars in non-interactive environments.", - ) - global_parser.add_argument( - "--error", - "-e", - action="store_true", - help="Print full error of failing commands and some succeeding commands.", - ) - global_parser.add_argument( - "--force-build", - action="store_true", - help="Force rebuild instead of only on changed files.", - ) - global_parser.add_argument( - "--jobs", - "-j", - type=int, - help="The number of jobs to use. Default: cpu_count()/2.", - ) - global_parser.add_argument( - "--memory", - "-m", - type=int, - help="The maximum amount of memory in MB a subprocess may use.", - ) - global_parser.add_argument( - "--api", - help="CCS API endpoint to use, e.g. https://www.domjudge.org/demoweb. Defaults to the value in contest.yaml.", - ) - global_parser.add_argument("--username", "-u", help="The username to login to the CCS.") - global_parser.add_argument("--password", "-p", help="The password to login to the CCS.") - global_parser.add_argument( - "--cp", - action="store_true", - help="Copy the output pdf instead of symlinking it.", - ) - global_parser.add_argument("--lang", nargs="+", help="Languages to include.") - - subparsers = parser.add_subparsers( - title="actions", dest="action", parser_class=SuppressingParser, required=True - ) - - # upgrade - subparsers.add_parser( - "upgrade", - parents=[global_parser], - help="Upgrade a problem or contest.", - ) - - # New contest - contestparser = subparsers.add_parser( - "new_contest", - parents=[global_parser], - help="Add a new contest to the current directory.", - ) - contestparser.add_argument("contestname", nargs="?", help="The name of the contest") - - # New problem - problemparser = subparsers.add_parser( - "new_problem", - parents=[global_parser], - help="Add a new problem to the current directory.", - ) - problemparser.add_argument("problemname", nargs="?", help="The name of the problem,") - problemparser.add_argument("--author", help="The author of the problem,") - problemparser.add_argument( - "--type", - help="The type of the problem.", - choices=[ - "pass-fail", - "float", - "custom", - "interactive", - "multi-pass", - "interactive multi-pass", - ], - ) - problemparser.add_argument("--skel", help="Skeleton problem directory to copy from.") - problemparser.add_argument( - "--defaults", - action="store_true", - help="Assume the defaults for fields not passed as arguments." - + " This skips input-prompts but fails when defaults cannot be assumed.", - ) - - # Copy directory from skel. - skelparser = subparsers.add_parser( - "skel", - parents=[global_parser], - help="Copy the given directories from skel to the current problem directory.", - ) - skelparser.add_argument( - "directory", - nargs="+", - type=Path, - help="Directories to copy from skel/problem/, relative to the problem directory.", - ) - skelparser.add_argument("--skel", help="Skeleton problem directory to copy from.") - - # Rename problem - renameproblemparser = subparsers.add_parser( - "rename_problem", - parents=[global_parser], - help="Rename a problem, including its directory.", - ) - renameproblemparser.add_argument("problemname", nargs="?", help="The new name of the problem,") - - # Problem statements - pdfparser = subparsers.add_parser( - "pdf", parents=[global_parser], help="Build the problem statement pdf." - ) - pdfparser.add_argument( - "--all", - "-a", - action="store_true", - help="Create problem statements for individual problems as well.", - ) - pdfparser.add_argument("--no-time-limit", action="store_true", help="Do not print timelimits.") - pdfparser.add_argument( - "--watch", - "-w", - action="store_true", - help="Continuously compile the pdf whenever a `problem.*.tex` changes. Note that this does not pick up changes to `*.yaml` configuration files. Further Note that this implies `--cp`.", - ) - pdfparser.add_argument( - "--open", - "-o", - nargs="?", - const=True, - type=Path, - help="Open the continuously compiled pdf (with a specified program).", - ) - pdfparser.add_argument("--web", action="store_true", help="Create a web version of the pdf.") - pdfparser.add_argument("-1", action="store_true", help="Only run the LaTeX compiler once.") - - # Problem slides - slidesparser = subparsers.add_parser( - "problem_slides", parents=[global_parser], help="Build the problem slides pdf." - ) - slidesparser.add_argument( - "--no-time-limit", action="store_true", help="Do not print timelimits." - ) - slidesparser.add_argument( - "--watch", - "-w", - action="store_true", - help="Continuously compile the pdf whenever a `problem-slide.*.tex` changes. Note that this does not pick up changes to `*.yaml` configuration files.", - ) - slidesparser.add_argument( - "--open", - "-o", - nargs="?", - const=True, - type=Path, - help="Open the continuously compiled pdf (with a specified program).", - ) - slidesparser.add_argument("-1", action="store_true", help="Only run the LaTeX compiler once.") - - # Solution slides - solparser = subparsers.add_parser( - "solutions", parents=[global_parser], help="Build the solution slides pdf." - ) - orderparser = solparser.add_mutually_exclusive_group() - orderparser.add_argument( - "--order", action="store", help='The order of the problems, e.g.: "CAB"' - ) - orderparser.add_argument( - "--order-from-ccs", - action="store_true", - help="Order the problems by increasing difficulty, extracted from the CCS.", - ) - solparser.add_argument( - "--contest-id", - action="store", - help="Contest ID to use when reading from the API. Only useful with --order-from-ccs. Defaults to value of contest_id in contest.yaml.", - ) - solparser.add_argument( - "--watch", - "-w", - action="store_true", - help="Continuously compile the pdf whenever a `solution.*.tex` changes. Note that this does not pick up changes to `*.yaml` configuration files. Further Note that this implies `--cp`.", - ) - solparser.add_argument( - "--open", - "-o", - nargs="?", - const=True, - type=Path, - help="Open the continuously compiled pdf (with a specified program).", - ) - solparser.add_argument("--web", action="store_true", help="Create a web version of the pdf.") - solparser.add_argument("-1", action="store_true", help="Only run the LaTeX compiler once.") - - # Validation - validate_parser = subparsers.add_parser( - "validate", parents=[global_parser], help="validate all grammar" - ) - validate_parser.add_argument("testcases", nargs="*", type=Path, help="The testcases to run on.") - validation_group = validate_parser.add_mutually_exclusive_group() - validation_group.add_argument("--input", "-i", action="store_true", help="Only validate input.") - validation_group.add_argument("--answer", action="store_true", help="Only validate answer.") - validation_group.add_argument( - "--invalid", action="store_true", help="Only check invalid files for validity." - ) - validation_group.add_argument( - "--generic", - choices=["invalid_input", "invalid_answer", "invalid_output", "valid_output"], - nargs="*", - help="Generate generic (in)valid files based on the first three samples and validate them.", - ) - validation_group.add_argument( - "--valid-output", - action="store_true", - help="Only check files in 'data/valid_output' for validity.", - ) - - move_or_remove_group = validate_parser.add_mutually_exclusive_group() - move_or_remove_group.add_argument( - "--remove", action="store_true", help="Remove failing testcases." - ) - move_or_remove_group.add_argument("--move-to", help="Move failing testcases to this directory.") - - validate_parser.add_argument( - "--no-testcase-sanity-checks", - action="store_true", - help="Skip sanity checks on testcases.", - ) - validate_parser.add_argument( - "--timeout", "-t", type=int, help="Override the default timeout. Default: 30." - ) - - # constraints validation - constraintsparser = subparsers.add_parser( - "constraints", - parents=[global_parser], - help="prints all the constraints found in problemset and validators", - ) - constraintsparser.add_argument( - "--no-generate", "-G", action="store_true", help="Do not run `generate`." - ) - - # Stats - statsparser = subparsers.add_parser( - "stats", parents=[global_parser], help="show statistics for contest/problem" - ) - all_stats_group = statsparser.add_mutually_exclusive_group() - all_stats_group.add_argument("--more", action="store_true", help="DEPRECATED! Use --all.") - all_stats_group.add_argument( - "--all", - "-a", - action="store_true", - help="Print all stats", - ) - - # Generate Testcases - genparser = subparsers.add_parser( - "generate", - parents=[global_parser], - help="Generate testcases according to .gen files.", - ) - genparser.add_argument( - "--check-deterministic", - action="store_true", - help="Rerun all generators to make sure generators are deterministic.", - ) - genparser.add_argument( - "--timeout", "-t", type=int, help="Override the default timeout. Default: 30." - ) - - genparser_group = genparser.add_mutually_exclusive_group() - genparser_group.add_argument( - "--add", - nargs="*", - type=Path, - help="Add case(s) to generators.yaml.", - metavar="TARGET_DIRECTORY=generators/manual", - ) - genparser_group.add_argument( - "--clean", "-C", action="store_true", help="Delete all cached files." - ) - genparser_group.add_argument( - "--reorder", - action="store_true", - help="Reorder cases by difficulty inside the given directories.", - ) - - genparser.add_argument( - "--interaction", - "-i", - action="store_true", - help="Use the solution to generate .interaction files.", - ) - genparser.add_argument( - "testcases", - nargs="*", - type=Path, - help="The testcases to generate, given as directory, .in/.ans file, or base name.", - ) - genparser.add_argument( - "--default-solution", - "-s", - type=Path, - help="The default solution to use for generating .ans files. Not compatible with generator.yaml.", - ) - 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.", - ) - - # Fuzzer - fuzzparser = subparsers.add_parser( - "fuzz", - parents=[global_parser], - help="Generate random testcases and search for inconsistencies in AC submissions.", - ) - fuzzparser.add_argument("--time", type=int, help="Number of seconds to run for. Default: 600") - fuzzparser.add_argument("--time-limit", "-t", type=float, help="Time limit for submissions.") - fuzzparser.add_argument( - "submissions", - nargs="*", - type=Path, - help="The generator.yaml rules to use, given as directory, .in/.ans file, or base name, and submissions to run.", - ) - fuzzparser.add_argument( - "--timeout", type=int, help="Override the default timeout. Default: 30." - ) - - # Run - runparser = subparsers.add_parser( - "run", - parents=[global_parser], - help="Run multiple programs against some or all input.", - ) - runparser.add_argument( - "submissions", - nargs="*", - type=Path, - help="optionally supply a list of programs and testcases to run", - ) - runparser.add_argument("--samples", action="store_true", help="Only run on the samples.") - runparser.add_argument( - "--no-generate", - "-G", - 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", - action="count", - default=0, - help="Run all testcases. Use twice to continue even after timeouts.", - ) - runparser.add_argument( - "--default-solution", - "-s", - type=Path, - help="The default solution to use for generating .ans files. Not compatible with generators.yaml.", - ) - runparser.add_argument( - "--table", - action="store_true", - help="Print a submissions x testcases table for analysis.", - ) - runparser.add_argument( - "--overview", - "-o", - action="store_true", - help="Print a live overview for the judgings.", - ) - runparser.add_argument("--tree", action="store_true", help="Show a tree of verdicts.") - - runparser.add_argument("--depth", type=int, help="Depth of verdict tree.") - runparser.add_argument( - "--timeout", - type=int, - help="Override the default timeout. Default: 1.5 * time_limit + 1.", - ) - runparser.add_argument( - "--time-limit", "-t", type=float, help="Override the default time-limit." - ) - runparser.add_argument( - "--no-testcase-sanity-checks", - action="store_true", - help="Skip sanity checks on testcases.", - ) - runparser.add_argument( - "--sanitizer", - action="store_true", - help="Run submissions with additional sanitizer flags (currently only C++). Note that this removes all memory limits for submissions.", - ) - - timelimitparser = subparsers.add_parser( - "time_limit", - parents=[global_parser], - help="Determine the time limit for a problem.", - ) - timelimitparser.add_argument( - "submissions", - nargs="*", - type=Path, - help="optionally supply a list of programs and testcases on which the time limit should be based.", - ) - timelimitparser.add_argument( - "--all", - "-a", - action="store_true", - help="Run all submissions, not only AC and TLE.", - ) - timelimitparser.add_argument( - "--write", - "-w", - action="store_true", - help="Write .timelimit file.", - ) - timelimitparser.add_argument( - "--timeout", "-t", type=int, help="Override the default timeout. Default: 60." - ) - timelimitparser.add_argument( - "--no-generate", "-G", action="store_true", help="Do not run `generate`." - ) - - # Test - testparser = subparsers.add_parser( - "test", - parents=[global_parser], - help="Run a single program and print the output.", - ) - testparser.add_argument("submissions", nargs=1, type=Path, help="A single submission to run") - testcasesgroup = testparser.add_mutually_exclusive_group() - testcasesgroup.add_argument( - "testcases", - nargs="*", - default=[], - type=Path, - help="Optionally a list of testcases to run on.", - ) - testcasesgroup.add_argument("--samples", action="store_true", help="Only run on the samples.") - testcasesgroup.add_argument( - "--interactive", - "-i", - action="store_true", - help="Run submission in interactive mode: stdin is from the command line.", - ) - testparser.add_argument( - "--timeout", - type=int, - help="Override the default timeout. Default: 1.5 * time_limit + 1.", - ) - - checktestingtool = subparsers.add_parser( - "check_testing_tool", - parents=[global_parser], - help="Run testing_tool against some or all accepted submissions.", - ) - checktestingtool.add_argument( - "submissions", - nargs="*", - type=Path, - help="optionally supply a list of programs and testcases to run", - ) - checktestingtool.add_argument( - "--no-generate", - "-G", - action="store_true", - help="Do not run `generate` before running submissions.", - ) - checktestingtool.add_argument( - "--timeout", - type=int, - help="Override the default timeout. Default: 1.5 * time_limit + 1.", - ) - checktestingtool.add_argument( - "--all", - "-a", - action="store_true", - help="Run all testcases and don't stop on error.", - ) - - # Sort - subparsers.add_parser( - "sort", parents=[global_parser], help="sort the problems for a contest by name" - ) - - # All - allparser = subparsers.add_parser( - "all", - parents=[global_parser], - help="validate input, validate answers, and run programs", - ) - allparser.add_argument("--no-time-limit", action="store_true", help="Do not print time limits.") - allparser.add_argument( - "--no-testcase-sanity-checks", - action="store_true", - help="Skip sanity checks on testcases.", - ) - allparser.add_argument( - "--check-deterministic", - action="store_true", - help="Rerun all generators to make sure generators are deterministic.", - ) - allparser.add_argument( - "--timeout", "-t", type=int, help="Override the default timeout. Default: 30." - ) - allparser.add_argument( - "--overview", - "-o", - action="store_true", - help="Print a live overview for the judgings.", - ) - - # Build DOMjudge zip - zipparser = subparsers.add_parser( - "zip", - parents=[global_parser], - help="Create zip file that can be imported into DOMjudge", - ) - zipparser.add_argument("--skip", action="store_true", help="Skip recreation of problem zips.") - zipparser.add_argument( - "--force", - "-f", - action="store_true", - help="Skip validation of input and answers.", - ) - zipparser.add_argument( - "--no-generate", "-G", action="store_true", help="Skip generation of test cases." - ) - zipparser.add_argument( - "--kattis", - 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. - 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." - ) - gitlab_parser.add_argument( - "--latest-bt", action="store_true", help="Cache the latest version of BAPCtools." - ) - - forgejo_parser = subparsers.add_parser( - "forgejo_actions", - parents=[global_parser], - help="Setup Forgejo Actions workflows in .forgejo.", - ) - forgejo_parser.add_argument( - "--latest-bt", action="store_true", help="Cache the latest version of BAPCtools." - ) - - github_parser = subparsers.add_parser( - "github_actions", - parents=[global_parser], - help="Setup Github Actions workflows in .github.", - ) - github_parser.add_argument( - "--latest-bt", action="store_true", help="Cache the latest version of BAPCtools." - ) - - exportparser = subparsers.add_parser( - "export", - parents=[global_parser], - help="Export the problem or contest to DOMjudge.", - ) - exportparser.add_argument( - "--contest-id", - 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", - parents=[global_parser], - help="Update the problems.yaml with current names and time limits.", - ) - updateproblemsyamlparser.add_argument( - "--colors", - help="Set the colors of the problems. Comma-separated list of hex-codes.", - ) - updateproblemsyamlparser.add_argument( - "--sort", - action="store_true", - help="Sort the problems by id.", - ) - updateproblemsyamlparser.add_argument( - "--number", - action="store_true", - help="Use Sxx as problem labels.", - ) - 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( - "tmp", - parents=[global_parser], - help="Print the tmpdir corresponding to the current problem.", - ) - tmpparser.add_argument( - "--clean", - "-C", - action="store_true", - help="Delete the temporary cache directory for the current problem/contest.", - ) - - solvestatsparser = subparsers.add_parser( - "solve_stats", - parents=[global_parser], - help="Make solve stats plots using Matplotlib. All teams on the public scoreboard are included (including spectator/company teams).", - ) - solvestatsparser.add_argument( - "--contest-id", - action="store", - help="Contest ID to use when reading from the API. Defaults to value of contest_id in contest.yaml.", - ) - solvestatsparser.add_argument( - "--post-freeze", - action="store_true", - help="When given, the solve stats will include submissions from after the scoreboard freeze.", - ) - - download_submissions_parser = subparsers.add_parser( - "download_submissions", - parents=[global_parser], - help="Download all submissions for a contest and write them to submissions/.", - ) - download_submissions_parser.add_argument( - "--contest-id", - action="store", - help="Contest ID to use when reading from the API. Defaults to value of contest_id in contest.yaml.", - ) - - create_slack_channel_parser = subparsers.add_parser( - "create_slack_channels", - parents=[global_parser], - help="Create a slack channel for each problem", - ) - create_slack_channel_parser.add_argument("--token", help="A user token is of the form xoxp-...") - - join_slack_channel_parser = subparsers.add_parser( - "join_slack_channels", - parents=[global_parser], - help="Join a slack channel for each problem", - ) - join_slack_channel_parser.add_argument("--token", help="A bot/user token is of the form xox...") - join_slack_channel_parser.add_argument("username", help="Slack username") - - if not is_windows(): - argcomplete.autocomplete(parser) - - return parser - - -def find_home_config_dir() -> Optional[Path]: - if is_windows(): - app_data = os.getenv("AppData") - return Path(app_data) if app_data else None - else: - home = os.getenv("HOME") - xdg_config_home = os.getenv("XDG_CONFIG_HOME") - return ( - Path(xdg_config_home) if xdg_config_home else Path(home) / ".config" if home else None - ) - - -def read_personal_config(problem_dir: Optional[Path]) -> None: - home_config_dir = find_home_config_dir() - # possible config files, sorted by priority - config_files = [] - if problem_dir: - config_files.append(problem_dir / ".bapctools.yaml") - config_files.append(Path.cwd() / ".bapctools.yaml") - if home_config_dir: - config_files.append(home_config_dir / "bapctools" / "config.yaml") - - for config_file in config_files: - if not config_file.is_file(): - continue - - config_data = read_yaml(config_file) - if not config_data: - continue - if not isinstance(config_data, dict): - warn(f"invalid data in {config_data}. SKIPPED.") - continue - - config.args.add_if_not_set(config.ARGS(config_file, **config_data)) - - -def run_parsed_arguments(args: argparse.Namespace, personal_config: bool = True) -> None: - # Don't zero newly allocated memory for this and any subprocess - # Will likely only have an effect on linux - os.environ["MALLOC_PERTURB_"] = str(0b01011001) - - # Process arguments - config.args = config.ARGS("args", **vars(args)) - - # cd to contest directory - call_cwd = Path.cwd().absolute() - problem_dir = change_directory() - level = config.level - contest_name = Path.cwd().name - - if personal_config: - read_personal_config(problem_dir) - - action = config.args.action - - # upgrade commands. - if action == "upgrade": - upgrade.upgrade(problem_dir) - return - - # Skel commands. - if action == "new_contest": - os.chdir(call_cwd) - skel.new_contest() - return - - if action == "new_problem": - os.chdir(call_cwd) - skel.new_problem() - return - - # get problems list - problems, tmpdir = get_problems(problem_dir) - - # Split submissions and testcases when needed. - if action in ["run", "fuzz", "time_limit", "check_testing_tool"]: - if config.args.submissions: - config.args.submissions, config.args.testcases = split_submissions_and_testcases( - config.args.submissions - ) - else: - config.args.testcases = [] - - # Check non unique uuid - # TODO: check this even more globally? - uuids: dict[str, Problem] = {} - for p in problems: - if p.settings.uuid in uuids: - warn(f"{p.name} has the same uuid as {uuids[p.settings.uuid].name}") - else: - uuids[p.settings.uuid] = p - - # Check for incompatible actions at the problem/problemset level. - if level != "problem": - if action == "test": - fatal("Testing a submission only works for a single problem.") - if action == "skel": - fatal("Copying skel directories only works for a single problem.") - - if action != "generate" and config.args.testcases and config.args.samples: - fatal("--samples can not go together with an explicit list of testcases.") - - if config.args.add is not None: - # default to 'generators/manual' - if len(config.args.add) == 0: - config.args.add = [Path("generators/manual")] - - # Paths *must* be inside generators/. - checked_paths = [] - for path in config.args.add: - if path.parts[0] != "generators": - warn(f'Path {path} does not match "generators/*". Skipping.') - else: - checked_paths.append(path) - config.args.add = checked_paths - - if config.args.reorder: - # default to 'data/secret' - if not config.args.testcases: - config.args.testcases = [Path("data/secret")] - - # Paths *must* be inside data/. - checked_paths = [] - for path in config.args.testcases: - if path.parts[0] != "data": - warn(f'Path {path} does not match "data/*". Skipping.') - else: - checked_paths.append(path) - config.args.testcases = checked_paths - - # Handle one-off subcommands. - if action == "tmp": - if level == "problem": - level_tmpdir = tmpdir / problems[0].name - else: - level_tmpdir = tmpdir - - if config.args.clean: - log(f"Deleting {tmpdir}!") - if level_tmpdir.is_dir(): - shutil.rmtree(level_tmpdir) - if level_tmpdir.is_file(): - level_tmpdir.unlink() - else: - eprint(level_tmpdir) - - return - - if action == "stats": - stats.stats(problems) - return - - if action == "sort": - print_sorted(problems) - return - - if action == "samplezip": - sampleout = Path("samples.zip") - if level == "problem": - sampleout = problems[0].path / sampleout - languages = export.select_languages(problems) - export.build_samples_zip(problems, sampleout, languages) - return - - if action == "rename_problem": - if level == "problemset": - fatal("rename_problem only works for a problem") - skel.rename_problem(problems[0]) - return - - if action == "gitlabci": - skel.create_gitlab_jobs(contest_name, problems) - return - - if action == "forgejo_actions": - skel.create_forgejo_actions(contest_name, problems) - return - - if action == "github_actions": - skel.create_github_actions(contest_name, problems) - return - - if action == "skel": - skel.copy_skel_dir(problems) - return - - if action == "solve_stats": - if level == "problem": - fatal("solve_stats only works for a contest") - config.args.jobs = (os.cpu_count() or 1) // 2 - solve_stats.generate_solve_stats(config.args.post_freeze) - return - - if action == "download_submissions": - if level == "problem": - fatal("download_submissions only works for a contest") - download_submissions.download_submissions() - return - - if action == "create_slack_channels": - slack.create_slack_channels(problems) - return - - if action == "join_slack_channels": - assert config.args.username is not None - slack.join_slack_channels(problems, config.args.username) - return - - problem_zips = [] - - success = True - - for problem in problems: - if ( - level == "problemset" - and action in ["pdf", "export", "update_problems_yaml"] - and not config.args.all - ): - continue - eprint(Style.BRIGHT, "PROBLEM ", problem.name, Style.RESET_ALL, sep="") - - if action in ["generate"]: - success &= generate.generate(problem) - if ( - action in ["all", "constraints", "run", "time_limit", "check_testing_tool"] - and not config.args.no_generate - ): - # Call `generate` with modified arguments. - old_args = config.args.copy() - config.args.jobs = (os.cpu_count() or 1) // 2 - config.args.add = None - config.args.verbose = 0 - config.args.no_visualizer = True - success &= generate.generate(problem) - config.args = old_args - if action in ["fuzz"]: - success &= fuzz.Fuzz(problem).run() - if action in ["pdf", "all"]: - # only build the pdf on the problem level, or on the contest level when - # --all is passed. - if level == "problem" or (level == "problemset" and config.args.all): - success &= latex.build_problem_pdfs(problem) - if level == "problem": - if action in ["solutions"]: - success &= latex.build_problem_pdfs( - problem, build_type=latex.PdfType.SOLUTION, web=config.args.web - ) - if action in ["problem_slides"]: - success &= latex.build_problem_pdfs( - problem, build_type=latex.PdfType.PROBLEM_SLIDE, web=config.args.web - ) - if action in ["validate", "all"]: - # if nothing is specified run all - specified = any( - [ - config.args.invalid, - config.args.generic is not None, - config.args.input, - config.args.answer, - config.args.valid_output, - ] - ) - if action == "all" or not specified or config.args.invalid: - success &= problem.validate_data(validate.Mode.INVALID) - if action == "all" or not specified or config.args.generic is not None: - if config.args.generic is None: - config.args.generic = [ - "invalid_input", - "invalid_answer", - "invalid_output", - "valid_output", - ] - success &= problem.validate_invalid_extra_data() - success &= problem.validate_valid_extra_data() - if action == "all" or not specified or config.args.input: - success &= problem.validate_data(validate.Mode.INPUT) - if action == "all" or not specified or config.args.answer: - success &= problem.validate_data(validate.Mode.ANSWER) - if action == "all" or not specified or config.args.valid_output: - success &= problem.validate_data(validate.Mode.VALID_OUTPUT) - if action in ["run", "all"]: - success &= problem.run_submissions() - if action in ["test"]: - config.args.no_bar = True - success &= problem.test_submissions() - if action in ["constraints"]: - success &= constraints.check_constraints(problem) - if action in ["check_testing_tool"]: - problem.check_testing_tool() - if action in ["time_limit"]: - success &= problem.determine_time_limit() - if action in ["zip"]: - output = problem.path / f"{problem.name}.zip" - - problem_zips.append(output) - if not config.args.skip: - if not config.args.no_generate: - # Set up arguments for generate. - old_args = config.args.copy() - config.args.check_deterministic = not config.args.force - config.args.add = None - config.args.verbose = 0 - config.args.testcases = None - config.args.force = False - success &= generate.generate(problem) - config.args = old_args - - if not config.args.kattis: - success &= latex.build_problem_pdfs(problem) - if not config.args.no_solutions: - success &= latex.build_problem_pdfs( - problem, build_type=latex.PdfType.SOLUTION - ) - - if any(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={}) - success &= problem.validate_data(validate.Mode.ANSWER, constraints={}) - - # Write to problemname.zip, where we strip all non-alphanumeric from the - # problem directory name. - success &= export.build_problem_zip(problem, output) - - if len(problems) > 1: - eprint() - - if action in ["export"]: - languages = export.select_languages(problems) - export.export_contest_and_problems(problems, languages) - - if level == "problemset": - eprint(f"{Style.BRIGHT}CONTEST {contest_name}{Style.RESET_ALL}") - - # build pdf for the entire contest - if action in ["pdf"]: - success &= latex.build_contest_pdfs(contest_name, problems, tmpdir, web=config.args.web) - - if action in ["solutions"]: - success &= latex.build_contest_pdfs( - contest_name, - problems, - tmpdir, - build_type=latex.PdfType.SOLUTION, - web=config.args.web, - ) - - if action in ["problem_slides"]: - success &= latex.build_contest_pdfs( - contest_name, - problems, - tmpdir, - build_type=latex.PdfType.PROBLEM_SLIDE, - web=config.args.web, - ) - - if action in ["zip"]: - languages = [] - if not config.args.kattis: - languages = export.select_languages(problems) - - # Only build the problem slides if at least one problem has the TeX for it - slideglob = latex.PdfType.PROBLEM_SLIDE.path("*") - build_problem_slides = any( - any(problem.path.glob(str(slideglob))) for problem in problems - ) - - for language in languages: - success &= latex.build_contest_pdfs(contest_name, problems, tmpdir, language) - success &= latex.build_contest_pdfs( - contest_name, problems, tmpdir, language, web=True - ) - if not config.args.no_solutions: - success &= latex.build_contest_pdf( - contest_name, - problems, - tmpdir, - language, - build_type=latex.PdfType.SOLUTION, - ) - success &= latex.build_contest_pdf( - contest_name, - problems, - tmpdir, - language, - build_type=latex.PdfType.SOLUTION, - web=True, - ) - if build_problem_slides: - success &= latex.build_contest_pdf( - contest_name, - 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_name + ".zip" - if config.args.kattis: - outfile = contest_name + "-kattis.zip" - export.build_contest_zip(problems, problem_zips, outfile, languages) - - if action in ["update_problems_yaml"]: - export.update_problems_yaml( - problems, - ( - re.split("[^#0-9A-Za-z]", config.args.colors.strip()) - if config.args.colors - else None - ), - ) - - if not success or config.n_error > 0 or config.n_warn > 0: - sys.exit(1) - - -# Takes command line arguments -def main() -> None: - def interrupt_handler(sig: Any, frame: Any) -> None: - fatal("Running interrupted") - - signal.signal(signal.SIGINT, interrupt_handler) - - try: - parser = build_parser() - run_parsed_arguments(parser.parse_args()) - except AbortException: - fatal("Running interrupted") - if __name__ == "__main__": - main() + # Add repository root to python path so that bapctools is importable. Notably, we need to + # resolve __file__ as it would otherwise refer to the location of the symlink used to invoke + # this script. + sys.path.append(str(Path(__file__).resolve().parents[1])) + from bapctools.cli import main -def test(args: list[str]) -> None: - config.RUNNING_TEST = True - - # Make sure to cd back to the original directory before returning. - # Needed to stay in the same directory in tests. - original_directory = Path.cwd() - config.n_warn = 0 - config.n_error = 0 - contest._contest_yaml = None - contest._problems_yaml = None - try: - parser = build_parser() - run_parsed_arguments(parser.parse_args(args), personal_config=False) - finally: - os.chdir(original_directory) - ProgressBar.current_bar = None + main() diff --git a/pyproject.toml b/pyproject.toml index 08f1bd9e7..b8a1c5a90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,32 @@ +[project] +name = "bapctools" +version = "0.0.0" +dependencies = [ + "pyyaml~=6.0.3", + "colorama~=0.4.6", + "argcomplete~=3.6.3; platform_system != 'Windows'", + "python-dateutil~=2.9.0", + "ruamel.yaml~=0.18.16", + "matplotlib~=3.10.7", + "requests~=2.32.5", + "questionary~=2.1.1", +] +requires-python = ">=3.10" + +[project.optional-dependencies] +dev = [ + "pytest~=9.0.1", +] + +[project.scripts] +bt = "bapctools.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + [tool.ruff] -# Line length 100 line-length = 100 -# Assume Python 3.10 -target-version = "py310" -# Source -src = ["bin"] [tool.ruff.lint.isort] # organize imports in two blocks, non local and local @@ -14,8 +36,5 @@ default-section = "standard-library" order-by-type = false [tool.pyright] -include = ["bin"] -exclude = ["bin/misc"] -executionEnvironments = [ - { root = "bin" }, -] +include = ["bapctools"] +exclude = ["bapctools/resources"] diff --git a/test/problems/alternativeencryption/input_validators/input_validator/validation.h b/test/problems/alternativeencryption/input_validators/input_validator/validation.h index 2b74c5d6a..09e9d67ee 120000 --- a/test/problems/alternativeencryption/input_validators/input_validator/validation.h +++ b/test/problems/alternativeencryption/input_validators/input_validator/validation.h @@ -1 +1 @@ -../../../../../headers/validation.h \ No newline at end of file +../../../../../bapctools/resources/headers/validation.h \ No newline at end of file diff --git a/test/problems/constants/input_validators/input_validator/validation.h b/test/problems/constants/input_validators/input_validator/validation.h index 2b74c5d6a..09e9d67ee 120000 --- a/test/problems/constants/input_validators/input_validator/validation.h +++ b/test/problems/constants/input_validators/input_validator/validation.h @@ -1 +1 @@ -../../../../../headers/validation.h \ No newline at end of file +../../../../../bapctools/resources/headers/validation.h \ No newline at end of file diff --git a/test/problems/constants/output_validator/validation.h b/test/problems/constants/output_validator/validation.h index 9a928c978..c24a808b4 120000 --- a/test/problems/constants/output_validator/validation.h +++ b/test/problems/constants/output_validator/validation.h @@ -1 +1 @@ -../../../../headers/validation.h \ No newline at end of file +../../../../bapctools/resources/headers/validation.h \ No newline at end of file diff --git a/test/problems/identity/answer_validators/answer_validator/validation.h b/test/problems/identity/answer_validators/answer_validator/validation.h index 2b74c5d6a..09e9d67ee 120000 --- a/test/problems/identity/answer_validators/answer_validator/validation.h +++ b/test/problems/identity/answer_validators/answer_validator/validation.h @@ -1 +1 @@ -../../../../../headers/validation.h \ No newline at end of file +../../../../../bapctools/resources/headers/validation.h \ No newline at end of file diff --git a/test/problems/identity/input_validators/input_validator/validation.h b/test/problems/identity/input_validators/input_validator/validation.h index 2b74c5d6a..09e9d67ee 120000 --- a/test/problems/identity/input_validators/input_validator/validation.h +++ b/test/problems/identity/input_validators/input_validator/validation.h @@ -1 +1 @@ -../../../../../headers/validation.h \ No newline at end of file +../../../../../bapctools/resources/headers/validation.h \ No newline at end of file diff --git a/test/test_default_output_validator.py b/test/test_default_output_validator.py index ed47bdbea..fb9bba51b 100644 --- a/test/test_default_output_validator.py +++ b/test/test_default_output_validator.py @@ -5,11 +5,7 @@ import tempfile from pathlib import Path -import problem -import testcase -import validate -import util -import config +from bapctools import problem, testcase, validate, util, config RUN_DIR = Path.cwd().absolute() # Note: the python version isn't tested by default, because it's quite slow. @@ -49,7 +45,7 @@ def validator(request): tmpdir = Path(tempfile.gettempdir()) / ("bapctools_" + h) tmpdir.mkdir(exist_ok=True) p = problem.Problem(Path("."), tmpdir) - validator = validate.OutputValidator(p, RUN_DIR / "support" / request.param) + validator = validate.OutputValidator(p, config.RESOURCES_ROOT / "support" / request.param) print(util.ProgressBar.current_bar) bar = util.ProgressBar("build", max_len=1) validator.build(bar) diff --git a/test/test_generators_yaml.py b/test/test_generators_yaml.py index 6b7be3a62..ef97b3f0e 100644 --- a/test/test_generators_yaml.py +++ b/test/test_generators_yaml.py @@ -3,8 +3,7 @@ import yaml from pathlib import Path -import generate -import config +from bapctools import generate, config config.RUNNING_TEST = True diff --git a/test/test_problem_yaml.py b/test/test_problem_yaml.py index 31da03e99..bf955a3ca 100644 --- a/test/test_problem_yaml.py +++ b/test/test_problem_yaml.py @@ -4,8 +4,7 @@ from typing import cast, Any from unittest.mock import call, MagicMock -import config -import problem +from bapctools import config, problem RUN_DIR = Path.cwd().absolute() @@ -72,7 +71,7 @@ def test_invalid(self, monkeypatch, test_data): fatal = MagicMock(name="fatal", side_effect=SystemExit(-42)) error = MagicMock(name="error") warn = MagicMock(name="warn") - for module in ["problem", "util"]: + for module in ["bapctools.problem", "bapctools.util"]: monkeypatch.setattr(f"{module}.fatal", fatal) monkeypatch.setattr(f"{module}.error", error) monkeypatch.setattr(f"{module}.warn", warn) diff --git a/test/test_problems.py b/test/test_problems.py index c7108fc34..ac35d9b73 100644 --- a/test/test_problems.py +++ b/test/test_problems.py @@ -4,10 +4,7 @@ from pathlib import Path from zipfile import ZipFile -import tools -import problem -import config -import util +from bapctools import cli as tools, problem, config, util # Run `bt run` on these problems. PROBLEMS = [ @@ -195,7 +192,11 @@ def test_zip(self): 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 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" @@ -206,7 +207,11 @@ def test_zip(self): 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"] + for path in [ + "problem_slide/problem-slide", + "solution/solution", + "statement/problem", + ] ], "Zip contents for PDFs with `--lang en` are not correct" zip_path.unlink() diff --git a/test/test_verdicts.py b/test/test_verdicts.py index f81aff3c0..5f4eccf3a 100644 --- a/test/test_verdicts.py +++ b/test/test_verdicts.py @@ -1,4 +1,4 @@ -import verdicts +from bapctools import verdicts class MockTestcase: