From 0ce4640c35d6e3a9f05d5e107cd394592ff875c7 Mon Sep 17 00:00:00 2001 From: Tagl Date: Fri, 22 Sep 2023 22:30:47 +0000 Subject: [PATCH 001/272] Set working directory for submission --- problemtools/run/program.py | 9 +++++---- problemtools/verifyproblem.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/problemtools/run/program.py b/problemtools/run/program.py index b237a507..a86fb8e5 100644 --- a/problemtools/run/program.py +++ b/problemtools/run/program.py @@ -14,7 +14,7 @@ class Program(object): runtime = 0 def run(self, infile='/dev/null', outfile='/dev/null', errfile='/dev/null', - args=None, timelim=1000, memlim=1024): + args=None, timelim=1000, memlim=1024, set_work_dir=False): """Run the program. Args: @@ -41,7 +41,7 @@ def run(self, infile='/dev/null', outfile='/dev/null', errfile='/dev/null', status, runtime = self.__run_wait(runcmd + args, infile, outfile, errfile, - timelim, memlim) + timelim, memlim, self.path if set_work_dir else None) self.runtime = max(self.runtime, runtime) @@ -69,7 +69,7 @@ def should_skip_memory_rlimit(self): @staticmethod - def __run_wait(argv, infile, outfile, errfile, timelim, memlim): + def __run_wait(argv, infile, outfile, errfile, timelim, memlim, working_directory=None): logging.debug('run "%s < %s > %s 2> %s"', ' '.join(argv), infile, outfile, errfile) pid = os.fork() @@ -103,7 +103,8 @@ def __run_wait(argv, infile, outfile, errfile, timelim, memlim): os.O_WRONLY | os.O_CREAT | os.O_TRUNC) Program.__setfd(2, errfile, os.O_WRONLY | os.O_CREAT | os.O_TRUNC) - + if working_directory is not None: + os.chdir(working_directory) os.execvp(argv[0], argv) except Exception as exc: print("Oops. Fatal error in child process:") diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index a81986fc..6974bf97 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -246,7 +246,7 @@ def _run_submission_real(self, sub, args, timelim, timelim_low, timelim_high): else: status, runtime = sub.run(self.infile, outfile, timelim=timelim_high+1, - memlim=self._problem.config.get('limits')['memory']) + memlim=self._problem.config.get('limits')['memory'], set_work_dir=True) if is_TLE(status) or runtime > timelim_high: res_high = SubmissionResult('TLE') elif is_RTE(status): From 92dcbf3fab45ed4c13438511df98ec82fec512f9 Mon Sep 17 00:00:00 2001 From: Per Austrin Date: Mon, 16 Oct 2023 15:18:05 +0200 Subject: [PATCH 002/272] small fixes to README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 29140990..d6ce4c28 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ There are four supported ways of installing and running problemtools. Run ``` -pip3 install git+https://github.com/kattis/problemtools +sudo pip3 install git+https://github.com/kattis/problemtools ``` Or if you don't want a system-wide installation, @@ -60,7 +60,7 @@ We maintain three official problemtools Docker images on Docker Hub: - [`problemtools/full`](https://hub.docker.com/r/problemtools/full/): this image contains problemtools along with compilers/interpreters for all supported programming languages. -- [`problemtools/icpc`](https://hub.docker.com/r/problemtools/icpc/): this image contains problemtools along with compilers/interpreters for the programming languages allowed in the International Collegiate Programming Contest (ICPC): C, C++, Java, Python 2+3, and Kotlin. +- [`problemtools/icpc`](https://hub.docker.com/r/problemtools/icpc/): this image contains problemtools along with compilers/interpreters for the programming languages allowed in the International Collegiate Programming Contest (ICPC): C, C++, Java, Kotlin, and Python 3. Note that the compiler/interpreter versions used might not be exactly the same as those used in the current ICPC season. - [`problemtools/minimal`](https://hub.docker.com/r/problemtools/minimal/): this image only contains problemtools, no additional programming languages. As such as it is not particularly useful on its own, but if you are organizing a contest and want to set up a problemtools environment containing exactly the right set of compilers/interpreters for your contest, this is the recommended starting point. From 24598fb85bc47f12457c8824834628da693bf381 Mon Sep 17 00:00:00 2001 From: Tagl Date: Fri, 20 Oct 2023 11:24:35 +0000 Subject: [PATCH 003/272] Support overwriting directories within submissions with included directories --- problemtools/run/rutil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/run/rutil.py b/problemtools/run/rutil.py index 7b889e39..a3bafd9c 100644 --- a/problemtools/run/rutil.py +++ b/problemtools/run/rutil.py @@ -30,7 +30,7 @@ def add_files(src, dstdir): srcfile = os.path.join(src, name) destfile = os.path.join(dstdir, name) if os.path.isdir(srcfile): - shutil.copytree(srcfile, destfile) + shutil.copytree(srcfile, destfile, dirs_exist_ok=True) else: shutil.copy(srcfile, destfile) except IOError as exc: From 11cdc61a0804444f0f78ce8b373c90862bf3ed26 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Sun, 1 Oct 2023 17:26:03 -0500 Subject: [PATCH 004/272] Simplify problem2html argparser and add type annotations --- problemtools/problem2html.py | 59 ++++++++++++------------------------ 1 file changed, 19 insertions(+), 40 deletions(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index b3f10b1c..2bb79f5d 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -14,9 +14,10 @@ from .ProblemPlasTeX import ProblemsetMacros from . import template +from typing import Any -def convert(problem, options=None): - problem = os.path.realpath(problem) +def convert(options: argparse.Namespace) -> None: + problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] destdir = string.Template(options.destdir).safe_substitute(problem=problembase) @@ -92,7 +93,7 @@ def convert(problem, options=None): # identify any large generated files (especially images) if not options.quiet: - for path, dirs, files in os.walk('.'): + for path, _dirs, files in os.walk('.'): for f in files: file_size_kib = os.stat(os.path.join(path, f)).st_size // 1024 if file_size_kib > 1024: @@ -109,46 +110,24 @@ def convert(problem, options=None): # restore cwd os.chdir(origcwd) - return True - - -class ConvertOptions: - available = [ - ['bodyonly', 'store_true', '-b', '--body-only', - 'only generate HTML body, no HTML headers', False], - ['css', 'store_false', '-c', '--no-css', - "don't copy CSS file to output directory", True], - ['headers', 'store_false', '-H', '--headers', - "don't generate problem headers (title, problem id, time limit)", True], - ['tidy', 'store_false', '-m', '--messy', - "don't run tidy to postprocess the HTML", True], - ['destdir', 'store', '-d', '--dest-dir', - "output directory", '${problem}_html'], - ['destfile', 'store', '-f', '--dest-file', - "output file name", 'index.html'], - ['language', 'store', '-l', '--language', - 'choose alternate language (2-letter code)', None], - ['loglevel', 'store', '-L', '--log-level', - 'set log level (debug, info, warning, error, critical)', 'warning'], - ['quiet', 'store_true', '-q', '--quiet', - "quiet", False], - ] - - def __init__(self): - for (dest, _, _, _, _, default) in ConvertOptions.available: - setattr(self, dest, default) - self.imgbasedir = '' - - -def main(): - options = ConvertOptions() + +def main() -> None: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) - for (dest, action, short, _long, _help, default) in ConvertOptions.available: - parser.add_argument(short, _long, dest=dest, help=_help, action=action, default=default) + parser.add_argument('-b', '--body-only', dest='bodyonly', action='store_true', help='only generate HTML body, no HTML headers', default=False) + parser.add_argument('-c', '--no-css', dest='css', action='store_false', help="don't copy CSS file to output directory", default=True) + parser.add_argument('-H', '--headers', dest='headers', action='store_false', help="don't generate problem headers (title, problem id, time limit)", default=True) + parser.add_argument('-m', '--messy', dest='tidy', action='store_false', help="don't run tidy to postprocess the HTML", default=True) + parser.add_argument('-d', '--dest-dir', dest='destdir', help="output directory", default='${problem}_html') + parser.add_argument('-f', '--dest-file', dest='destfile', help="output file name", default='index.html') + parser.add_argument('-l', '--language', dest='language', help='choose alternate language (2-letter code)', default=None) + parser.add_argument('-L', '--log-level', dest='loglevel', help='set log level (debug, info, warning, error, critical)', default='warning') + parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help="quiet", default=False) parser.add_argument('problem', help='the problem to convert') - options = parser.parse_args(namespace=options) - convert(options.problem, options) + options = parser.parse_args() + options.imgbasedir = '' + + convert(options) if __name__ == '__main__': From 01baa3c04e37d2f0ac2d473e858724b103dbf7e8 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Sun, 1 Oct 2023 17:30:33 -0500 Subject: [PATCH 005/272] Simplify problem2pdf argparser and add type annotations --- problemtools/problem2pdf.py | 36 ++++++++---------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 1e039a19..fef8c25d 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -8,11 +8,8 @@ from . import template -def convert(problem, options=None): - if options is None: - options = ConvertOptions() - - problem = os.path.realpath(problem) +def convert(options: argparse.Namespace) -> None: + problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) @@ -45,35 +42,18 @@ def convert(problem, options=None): if status == 0 and not options.nopdf: shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) - return status == 0 - - -class ConvertOptions: - available = [ - ['destfile', 'store', '-o', '--output', - "output file name", '${problem}.pdf'], - ['quiet', 'store_true', '-q', '--quiet', - "quiet", False], - ['language', 'store', '-l', '--language', - 'choose alternate language (2-letter code)', None], - ['nopdf', 'store_true', '-n', '--no-pdf', - 'run pdflatex in -draftmode', False], - ] - - def __init__(self): - for (dest, _, _, _, _, default) in ConvertOptions.available: - setattr(self, dest, default) - -def main(): +def main() -> None: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) - for (dest, action, short, _long, _help, default) in ConvertOptions.available: - parser.add_argument(short, _long, dest=dest, help=_help, action=action, default=default) + parser.add_argument('-o', '--output', dest='destfile', help="output file name", default='${problem}.pdf') + parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help="quiet", default=False) + parser.add_argument('-l', '--language', dest='language', help='choose alternate language (2-letter code)', default=None) + parser.add_argument('-n', '--no-pdf', dest='nopdf', action='store_true', help='run pdflatex in -draftmode', default=False) parser.add_argument('problem', help='the problem to convert') options = parser.parse_args() - convert(options.problem, options) + convert(options) if __name__ == '__main__': From 8de3d1f2ce3c6f8aaaa9298150858d7c2e561843 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Sun, 1 Oct 2023 17:31:10 -0500 Subject: [PATCH 006/272] Add line break for readability --- problemtools/problem2html.py | 1 + 1 file changed, 1 insertion(+) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 2bb79f5d..f3b80049 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -113,6 +113,7 @@ def convert(options: argparse.Namespace) -> None: def main() -> None: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument('-b', '--body-only', dest='bodyonly', action='store_true', help='only generate HTML body, no HTML headers', default=False) parser.add_argument('-c', '--no-css', dest='css', action='store_false', help="don't copy CSS file to output directory", default=True) parser.add_argument('-H', '--headers', dest='headers', action='store_false', help="don't generate problem headers (title, problem id, time limit)", default=True) From cb904b6a162eb8d1ae9235864aae450680701020 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Mon, 9 Oct 2023 09:55:31 -0500 Subject: [PATCH 007/272] Add type annotations to verifyproblem --- problemtools/problem2html.py | 16 +- problemtools/problem2pdf.py | 17 +- problemtools/verifyproblem.py | 446 ++++++++++++++++------------------ 3 files changed, 235 insertions(+), 244 deletions(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index f3b80049..f4306d5d 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -14,9 +14,9 @@ from .ProblemPlasTeX import ProblemsetMacros from . import template -from typing import Any +def convert(args: list[str]|None = None) -> None: + options = parse_args(args) -def convert(options: argparse.Namespace) -> None: problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] @@ -111,7 +111,7 @@ def convert(options: argparse.Namespace) -> None: os.chdir(origcwd) -def main() -> None: +def parse_args(args: list[str]|None) -> argparse.Namespace: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('-b', '--body-only', dest='bodyonly', action='store_true', help='only generate HTML body, no HTML headers', default=False) @@ -125,10 +125,16 @@ def main() -> None: parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help="quiet", default=False) parser.add_argument('problem', help='the problem to convert') - options = parser.parse_args() + if args is not None: + options = parser.parse_args(args) + else: + options = parser.parse_args() + options.imgbasedir = '' + return options - convert(options) +def main() -> None: + convert() if __name__ == '__main__': diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index fef8c25d..4c7a221b 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -8,7 +8,9 @@ from . import template -def convert(options: argparse.Namespace) -> None: +def convert(args: list[str]|None = None) -> bool: + options = parse_args(args) + problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) @@ -42,8 +44,9 @@ def convert(options: argparse.Namespace) -> None: if status == 0 and not options.nopdf: shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) + return status == 0 -def main() -> None: +def parse_args(args: list[str]|None) -> argparse.Namespace: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('-o', '--output', dest='destfile', help="output file name", default='${problem}.pdf') @@ -52,8 +55,14 @@ def main() -> None: parser.add_argument('-n', '--no-pdf', dest='nopdf', action='store_true', help='run pdflatex in -draftmode', default=False) parser.add_argument('problem', help='the problem to convert') - options = parser.parse_args() - convert(options) + if args is not None: + return parser.parse_args(args) + + return parser.parse_args() + + +def main() -> None: + convert() if __name__ == '__main__': diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 32d28bee..2ae6c8f7 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1,5 +1,7 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- +from __future__ import annotations + import glob import string import hashlib @@ -25,36 +27,39 @@ from . import languages from . import run +from typing import Callable, Literal, Pattern, Match + +Verdict = Literal['AC', 'TLE', 'OLE', 'MLE', 'RTE', 'WA', 'PAC', 'JE'] -def is_TLE(status, may_signal_with_usr1=False): +def is_TLE(status: int, may_signal_with_usr1: bool=False) -> bool: return (os.WIFSIGNALED(status) and (os.WTERMSIG(status) == signal.SIGXCPU or (may_signal_with_usr1 and os.WTERMSIG(status) == signal.SIGUSR1))) -def is_RTE(status): - return not os.WIFEXITED(status) or os.WEXITSTATUS(status) +def is_RTE(status: int) -> bool: + return not os.WIFEXITED(status) or bool(os.WEXITSTATUS(status)) class SubmissionResult: - def __init__(self, verdict, score=None, testcase=None, reason=None, additional_info=None): + def __init__(self, verdict: str, score: float|None=None, reason: str|None=None, additional_info: str|None=None): self.verdict = verdict self.score = score - self.testcase = testcase self.reason = reason self.additional_info = additional_info + self.testcase: TestCase|None = None + self.runtime_testcase: TestCase|None = None self.runtime = -1.0 - self.runtime_testcase = None self.ac_runtime = -1.0 self.ac_runtime_testcase = None self.validator_first = False - self.sample_failures = [] + self.sample_failures: list[SubmissionResult] = [] - def set_ac_runtime(self): + def set_ac_runtime(self) -> None: if self.verdict == 'AC': self.ac_runtime = self.runtime self.ac_runtime_testcase = self.runtime_testcase - def __str__(self): + def __str__(self) -> str: verdict = self.verdict details = [] @@ -83,11 +88,12 @@ class ProblemAspect: errors = 0 warnings = 0 bail_on_error = False - _check_res = None + _check_res: bool|None = None basename_regex = re.compile('^[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9]$') + consider_warnings_errors: bool @staticmethod - def __append_additional_info(msg, additional_info): + def __append_additional_info(msg: str, additional_info: str|None) -> str: if additional_info is None or ProblemAspect.max_additional_info <= 0: return msg additional_info = additional_info.rstrip() @@ -102,46 +108,46 @@ def __append_additional_info(msg, additional_info): return f'{msg}:\n' + '\n'.join(' '*8 + line for line in lines) - def error(self, msg, additional_info=None): + def error(self, msg: str, additional_info: str|None=None) -> None: self._check_res = False ProblemAspect.errors += 1 logging.error('in %s: %s', self, ProblemAspect.__append_additional_info(msg, additional_info)) if ProblemAspect.bail_on_error: raise VerifyError(msg) - def warning(self, msg, additional_info=None): + def warning(self, msg: str, additional_info: str|None=None) -> None: if ProblemAspect.consider_warnings_errors: self.error(msg) return ProblemAspect.warnings += 1 logging.warning('in %s: %s', self, ProblemAspect.__append_additional_info(msg, additional_info)) - def msg(self, msg): + def msg(self, msg: str) -> None: print(msg) - def info(self, msg): + def info(self, msg: str) -> None: logging.info(': %s', msg) - def debug(self, msg): + def debug(self, msg: str) -> None: logging.debug(': %s', msg) - def check_basename(self, path): + def check_basename(self, path: str) -> None: basename = os.path.basename(path) if not self.basename_regex.match(basename): self.error(f"Invalid name '{basename}' (should match '{self.basename_regex.pattern}')") class TestCase(ProblemAspect): - def __init__(self, problem, base, testcasegroup): + def __init__(self, problem: Problem, base: str, testcasegroup: TestCaseGroup): self._base = base self.infile = f'{base}.in' self.ansfile = f'{base}.ans' self._problem = problem self.testcasegroup = testcasegroup - self.reuse_result_from = None - self._result_cache = (None, None) + self.reuse_result_from: TestCase|None = None + self._result_cache: tuple[tuple, tuple[SubmissionResult, SubmissionResult, SubmissionResult]]|tuple[None, None] = (None, None) problem.testcase_by_infile[self.infile] = self - def check_newlines(self, filename): + def check_newlines(self, filename: str) -> None: with open(filename, 'r') as f: data = f.read() if data.find('\r') != -1: @@ -149,13 +155,13 @@ def check_newlines(self, filename): if len(data) > 0 and data[-1] != '\n': self.warning(f"The file {filename} does not end with '\\n'.") - def strip_path_prefix(self, path): + def strip_path_prefix(self, path: str) -> str: return os.path.relpath(path, os.path.join(self._problem.probdir, 'data')) - def is_in_sample_group(self): + def is_in_sample_group(self) -> bool: return self.strip_path_prefix(self.infile).startswith('sample') - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -180,20 +186,20 @@ def check(self, args): self._check_symlinks() return self._check_res - def __str__(self): + def __str__(self) -> str: return f'test case {self.strip_path_prefix(self._base)}' - def matches_filter(self, filter_re): + def matches_filter(self, filter_re: Pattern[str]) -> bool: return filter_re.search(self.strip_path_prefix(self._base)) is not None - def set_symlinks(self): + def set_symlinks(self) -> None: if not os.path.islink(self.infile): return target = os.path.realpath(self.infile) if target in self._problem.testcase_by_infile: self.reuse_result_from = self._problem.testcase_by_infile[target] - def _check_symlinks(self): + def _check_symlinks(self) -> bool: if not os.path.islink(self.infile): return True nicepath = os.path.relpath(self.infile, self._problem.probdir) @@ -213,7 +219,7 @@ def _check_symlinks(self): return False return True - def run_submission(self, sub, args, timelim, timelim_low, timelim_high): + def run_submission(self, sub, args: argparse.Namespace, timelim: int, timelim_low: int, timelim_high: int) -> tuple[SubmissionResult, SubmissionResult, SubmissionResult]: res, res_low, res_high, reused = self._run_submission_real(sub, args, timelim, timelim_low, timelim_high) res = self._init_result_for_testcase(res) res_low = self._init_result_for_testcase(res_low) @@ -225,7 +231,7 @@ def run_submission(self, sub, args, timelim, timelim_low, timelim_high): return (res, res_low, res_high) - def _run_submission_real(self, sub, args, timelim, timelim_low, timelim_high): + def _run_submission_real(self, sub, args: argparse.Namespace, timelim: int, timelim_low: int, timelim_high: int) -> tuple[SubmissionResult, SubmissionResult, SubmissionResult, bool]: if self.reuse_result_from is not None: return self.reuse_result_from._run_submission_real(sub, args, timelim, timelim_low, timelim_high) @@ -279,7 +285,7 @@ def _run_submission_real(self, sub, args, timelim, timelim_low, timelim_high): self._result_cache = (cache_key, (res, res_low, res_high)) return (res, res_low, res_high, False) - def _init_result_for_testcase(self, res): + def _init_result_for_testcase(self, res: SubmissionResult) -> SubmissionResult: res = copy.copy(res) res.testcase = self res.runtime_testcase = self @@ -290,10 +296,10 @@ def _init_result_for_testcase(self, res): res.score = self.testcasegroup.config['reject_score'] return res - def get_all_testcases(self): + def get_all_testcases(self) -> list[TestCase]: return [self] - def all_datasets(self): + def all_datasets(self) -> list[str]: return [self._base] @@ -301,7 +307,7 @@ class TestCaseGroup(ProblemAspect): _DEFAULT_CONFIG = config.load_config('testdata.yaml') _SCORING_ONLY_KEYS = ['accept_score', 'reject_score', 'range'] - def __init__(self, problem, datadir, parent=None): + def __init__(self, problem: Problem, datadir: str, parent: TestCaseGroup|None=None): self._parent = parent self._problem = problem self._datadir = datadir @@ -314,7 +320,7 @@ def __init__(self, problem, datadir, parent=None): with open(configfile) as f: self.config = yaml.safe_load(f) except Exception as e: - self.error(e) + self.error(str(e)) if self.config is None: self.config = {} @@ -345,14 +351,14 @@ def __init__(self, problem, datadir, parent=None): if field not in self.config: self.config[field] = default - self._items = [] + self._items: list[TestCaseGroup|TestCase] = [] if os.path.isdir(datadir): - for f in sorted(os.listdir(datadir)): - f = os.path.join(datadir, f) - if os.path.isdir(f): - self._items.append(TestCaseGroup(problem, f, self)) + for filename in sorted(os.listdir(datadir)): + filename = os.path.join(datadir, filename) + if os.path.isdir(filename): + self._items.append(TestCaseGroup(problem, filename, self)) else: - base, ext = os.path.splitext(f) + base, ext = os.path.splitext(filename) if ext == '.ans' and os.path.isfile(f'{base}.in'): self._items.append(TestCase(problem, base, self)) @@ -360,51 +366,47 @@ def __init__(self, problem, datadir, parent=None): self.set_symlinks() - def __str__(self): + def __str__(self) -> str: return f'test case group {os.path.relpath(self._datadir, os.path.join(self._problem.probdir))}' - def set_symlinks(self): + def set_symlinks(self) -> None: for sub in self._items: sub.set_symlinks() - def matches_filter(self, filter_re): + def matches_filter(self, filter_re: Pattern[str]) -> bool: return True - def get_all_testcases(self): - res = [] + def get_all_testcases(self) -> list: + res: list = [] for child in self._items: res += child.get_all_testcases() return res - def get_testcases(self): + def get_testcases(self) -> list[TestCase]: return [child for child in self._items if isinstance(child, TestCase)] - def get_subgroups(self): + def get_subgroups(self) -> list[TestCaseGroup]: return [child for child in self._items if isinstance(child, TestCaseGroup)] - def get_subgroup(self, name): - return next((child for child in self._items if isinstance(child, TestCaseGroup) and os.path.basename(child._datadir) == name), None) - - - def has_custom_groups(self): + def has_custom_groups(self) -> bool: return any(group.get_subgroups() for group in self.get_subgroups()) - def get_score_range(self): + def get_score_range(self) -> tuple[float, float]: try: score_range = self.config['range'] min_score, max_score = list(map(float, score_range.split())) return (min_score, max_score) except: - return (-float('inf'), float('inf')) + return (float('-inf'), float('inf')) - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -463,7 +465,7 @@ def check(self, args): seen_sample = True else: self.error("Test data at top level can only have the groups sample and secret") - self.debug(self._items) + self.debug(str(self._items)) if not seen_secret: self.error("No secret data provided") if not seen_sample: @@ -487,25 +489,25 @@ def check(self, args): infiles = glob.glob(os.path.join(self._datadir, '*.in')) ansfiles = glob.glob(os.path.join(self._datadir, '*.ans')) - for f in infiles: - if os.path.isdir(f): continue - if not f'{f[:-3]}.ans' in ansfiles: - self.error(f"No matching answer file for input '{f}'") - for f in ansfiles: - if os.path.isdir(f): continue - if not f'{f[:-4]}.in' in infiles: - self.error(f"No matching input file for answer '{f}'") + for infile in infiles: + if os.path.isdir(infile): continue + if not f'{infile[:-3]}.ans' in ansfiles: + self.error(f"No matching answer file for input '{infile}'") + for ansfile in ansfiles: + if os.path.isdir(ansfile): continue + if not f'{ansfile[:-4]}.in' in infiles: + self.error(f"No matching input file for answer '{ansfile}'") if not self.get_subgroups() and not self.get_testcases(): self.error('Test case group is empty') # Check whether a <= b according to a natural sorting where numeric components # are compactified, so that e.g. "a" < "a1" < "a2" < "a10" = "a010" < "a10a". - def natural_sort_le(a, b): + def natural_sort_le(a: str, b: str) -> bool: a += '\0' b += '\0' i = j = 0 - def parse_num(s, i): + def parse_num(s: str, i: int) -> tuple[int, int]: ret = 0 while ord('0') <= ord(s[i]) <= ord('9'): ret = ret * 10 + ord(s[i]) - ord('0') @@ -539,11 +541,11 @@ def parse_num(s, i): return self._check_res - def run_submission(self, sub, args, timelim, timelim_low, timelim_high): + def run_submission(self, sub, args: argparse.Namespace, timelim: int, timelim_low: int, timelim_high: int) -> tuple[SubmissionResult, SubmissionResult, SubmissionResult]: self.info(f'Running on {self}') - subres = [] - subres_low = [] - subres_high = [] + subres: list[SubmissionResult] = [] + subres_low: list[SubmissionResult] = [] + subres_high: list[SubmissionResult] = [] active_low, active = True, True on_reject = self.config['on_reject'] for child in self._items: @@ -566,7 +568,7 @@ def run_submission(self, sub, args, timelim, timelim_low, timelim_high): self.aggregate_results(sub, subres_high, shadow_result=True)) - def aggregate_results(self, sub, sub_results, shadow_result=False): + def aggregate_results(self, sub, sub_results: list[SubmissionResult], shadow_result: bool=False) -> SubmissionResult: res = SubmissionResult(None) for r in sub_results: @@ -601,8 +603,8 @@ def aggregate_results(self, sub, sub_results, shadow_result=False): return res - def all_datasets(self): - res = [] + def all_datasets(self) -> list: + res: list = [] for child in self._items: res += child.all_datasets() return res @@ -613,7 +615,7 @@ class ProblemConfig(ProblemAspect): _OPTIONAL_CONFIG = config.load_config('problem.yaml') _VALID_LICENSES = ['unknown', 'public domain', 'cc0', 'cc by', 'cc by-sa', 'educational', 'permission'] - def __init__(self, problem): + def __init__(self, problem: Problem): self.debug(' Loading problem config') self._problem = problem self.configfile = os.path.join(problem.probdir, 'problem.yaml') @@ -627,7 +629,7 @@ def __init__(self, problem): if self._data is None: self._data = {} except Exception as e: - self.error(e) + self.error(str(e)) # Add config items from problem statement e.g. name self._data.update(problem.statement.get_config()) @@ -667,15 +669,15 @@ def __init__(self, problem): self._data['languages'] = self._data['languages'].split() - def __str__(self): + def __str__(self) -> str: return 'problem configuration' - def get(self, key=None): + def get(self, key: str|None=None): if key: return self._data[key] return self._data - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -773,14 +775,11 @@ class Generators(ProblemAspect): _DATA_DIRECTORIES = {'sample', 'secret'} _VISUALIZER_EXTENSIONS = ['png', 'jpg', 'jpeg', 'svg', 'interaction', 'desc', 'hint'] - def __init__(self, problem): + def __init__(self, problem: Problem): self.debug(' Loading generators') self._problem = problem self.configfile = os.path.join(problem.probdir, 'generators', 'generators.yaml') self._data = None - self._testcases = [] - self._testdata_yaml = {} - self._data_directories = set() if os.path.isfile(self.configfile): try: @@ -790,38 +789,41 @@ def __init__(self, problem): if self._data is None: self._data = {} except Exception as e: - self.error(e) + self.error(str(e)) if isinstance(self._data, dict): # The top-level dict always represents a directory, even if there # is no type key self._data['type'] = 'directory' - def __str__(self): + def __str__(self) -> str: return 'generators' - def _parse_command(self, key, state): + def _parse_command(self, key: str, state: dict) -> tuple[str, list[str]]|None: command = state[key] name = os.path.basename(state['path']) random_salt = str(state['random_salt']) - def err(): + def err() -> None: self.error('Invalid %s key for path %s in generators.yaml' % (key, state['path'])) if not isinstance(command, str): - return err() + err() + return None seed = str(int(hashlib.sha512((random_salt + command).encode('utf-8')).hexdigest(), 16) % (2**31)) parts = shlex.split(command) if not parts: - return err() + err() + return None for i, part in enumerate(parts): new = '' for j, group in enumerate(part.split('{')): if group.count('}') != (0 if j == 0 else 1): - return err() + err() + return None if j == 0: new += group else: @@ -831,7 +833,8 @@ def err(): elif group == 'name': new += name else: - return err() + err() + return None new += rest parts[i] = new @@ -841,23 +844,20 @@ def err(): return (program, arguments) - def _parse_testcase(self, data, state): + def _parse_testcase(self, data: dict, state: dict) -> None: if state['input'] is None: self.error('Path %s in generators.yaml must contain an input key' % state['path']) for key in ['input', 'solution', 'visualizer']: if state[key] is not None: state[key] = self._parse_command(key, state) - if state['input'] is not None: - self._testcases.append(state) - def _parse_directory(self, data, state): + def _parse_directory(self, data: dict, state: dict) -> None: # TODO: Process includes if 'testdata.yaml' in data: content = data['testdata.yaml'] if content is None: content = {} - self._testdata_yaml['%s/%s' % (state['path'], 'testdata.yaml')] = content cases = data.get('data', {}) ordered = True @@ -886,7 +886,7 @@ def _parse_directory(self, data, state): next_state['path'] = '%s/%s' % (state['path'], name) self._parse_element(value, next_state) - def _parse_element(self, data, state): + def _parse_element(self, data: dict, state: dict) -> None: if data is None: data = '/%s.in' % state['path'] state['manual'] = True @@ -909,7 +909,7 @@ def _parse_element(self, data, state): self.error("Type of %s in generators.yaml must be 'directory'" % state['path']) self._parse_directory(data, state) - def _resolve_path(self, path): + def _resolve_path(self, path: str) -> str: base_path = self._problem.probdir if path.startswith('/'): path = path[1:] @@ -917,7 +917,7 @@ def _resolve_path(self, path): base_path = os.path.join(base_path, 'generators') return os.path.join(*([base_path] + path.split('/'))) - def _compile_generators(self): + def _compile_generators(self) -> None: for gen, files in list(self._generators.items()): implicit = True manual = False @@ -972,7 +972,7 @@ def _compile_generators(self): else: shutil.copy2(fpath, dest) except Exception as e: - self.error(e) + self.error(str(e)) ok = False if ok: if manual: @@ -993,7 +993,7 @@ def _compile_generators(self): if not ok and gen in self._generators: del self._generators[gen] - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1026,7 +1026,6 @@ def check(self, args): self.warning("Type of %s in generators.yaml must be 'directory'" % key) else: valid = True - self._data_directories.add(key) if not valid: invalid.append(key) for key in invalid: @@ -1034,7 +1033,7 @@ def check(self, args): # Run a depth-first search through generators.yaml and generate a # flattened list of testcases - default_state = { key: None for key in Generators._TESTCASE_OPTIONS } + default_state: dict[str, str|bool|None] = { key: None for key in Generators._TESTCASE_OPTIONS } default_state.update({ 'path': 'data', 'manual': False, @@ -1050,7 +1049,7 @@ def check(self, args): class ProblemStatement(ProblemAspect): - def __init__(self, problem): + def __init__(self, problem: Problem): self.debug(' Loading problem statement') self._problem = problem self.languages = [] @@ -1060,7 +1059,7 @@ def __init__(self, problem): for f in glob.glob(glob_path + '[a-z][a-z].tex'): self.languages.append(re.search("problem.([a-z][a-z]).tex$", f).group(1)) - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1069,44 +1068,33 @@ def check(self, args): self.error('No problem statements found (expected problem.tex or problem.[a-z][a-z].tex in problem_statement directory)') if '' in self.languages and 'en' in self.languages: self.error("Can't supply both problem.tex and problem.en.tex") - pdfopt = problem2pdf.ConvertOptions() - pdfopt.nopdf = True - pdfopt.quiet = True - htmlopt = problem2html.ConvertOptions() - htmlopt.destdir = os.path.join(self._problem.tmpdir, 'html') - htmlopt.quiet = True for lang in self.languages: - pdfopt.language = lang - htmlopt.language = lang try: - if not problem2pdf.convert(self._problem.probdir, pdfopt): - langparam = '' - if lang != '': - langparam = '-l ' + lang - self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf {langparam} on the problem to diagnose.') + if not problem2pdf.convert([self._problem.probdir, '--language', lang, '--no-pdf', '--quiet']): + langparam = f' -l {lang}' if lang != '' else '' + self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') except Exception as e: self.error(f'Error raised when checking problem statement for language {lang}:\n{e}') try: - problem2html.convert(self._problem.probdir, htmlopt) - except Exception as e: - langparam = '' - if lang != '': - langparam = '-l ' + lang - self.error(f'Could not convert problem statement to html for language "{lang}". Run problem2html {langparam} on the problem to diagnose.') + problem2html.convert([self._problem.probdir, '--dest-dir', os.path.join(self._problem.tmpdir, 'html'), '--language', lang, '--quiet']) + except Exception: + langparam = f' -l {lang}' if lang != '' else '' + self.error(f'Could not convert problem statement to html for language "{lang}". Run problem2html{langparam} on the problem to diagnose.') return self._check_res - def __str__(self): + def __str__(self) -> str: return 'problem statement' - def get_config(self): - ret = {} + def get_config(self) -> dict[str, dict[str, str]]: + ret: dict[str, dict[str, str]] = {} for lang in self.languages: filename = f'problem.{lang}.tex' if lang != '' else 'problem.tex' stmt = open(os.path.join(self._problem.probdir, 'problem_statement', filename)).read() - patterns = [(r'\\problemname{(.*)}', 'name'), - (r'^%%\s*plainproblemname:(.*)$', 'name') - ] + patterns = [ + (r'\\problemname{(.*)}', 'name'), + (r'^%%\s*plainproblemname:(.*)$', 'name'), + ] for tup in patterns: pattern = tup[0] dest = tup[1] @@ -1126,15 +1114,15 @@ class Attachments(ProblemAspect): """ - def __init__(self, problem): + def __init__(self, problem: Problem): attachments_path = os.path.join(problem.probdir, 'attachments') + self.attachments: list[str] = [] if os.path.isdir(attachments_path): self.attachments = [os.path.join(attachments_path, attachment_name) for attachment_name in os.listdir(attachments_path)] - else: - self.attachments = [] + self.debug(f'Adding attachments {str(self.attachments)}') - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1145,10 +1133,7 @@ def check(self, args): return self._check_res - def get_attachment_paths(self): - return self.attachments - - def __str__(self): + def __str__(self) -> str: return 'attachments' @@ -1159,7 +1144,7 @@ def __str__(self): ('a random text file with printable ASCII characters', bytearray(random.choice(string.printable.encode('utf8')) for _ in range(200))), ] -def _build_junk_modifier(desc, pattern, repl): +def _build_junk_modifier(desc: str, pattern: str, repl: str|Callable[[Match], str]) -> tuple[str, Callable, Callable[[str], str]]: p = re.compile(pattern) return (desc, p.search, lambda text: p.sub(repl, text)) @@ -1173,7 +1158,7 @@ def _build_junk_modifier(desc, pattern, repl): class InputFormatValidators(ProblemAspect): - def __init__(self, problem): + def __init__(self, problem: Problem): self._problem = problem input_validators_path = os.path.join(problem.probdir, 'input_format_validators') if os.path.isdir(input_validators_path): @@ -1189,11 +1174,11 @@ def __init__(self, problem): work_dir=problem.tmpdir) - def __str__(self): + def __str__(self) -> str: return 'input format validators' - def check(self, args): + def check(self, args: argparse.Namespace|None) -> bool: if self._check_res is not None: return self._check_res if self._uses_old_path: @@ -1209,12 +1194,12 @@ def check(self, args): self.error(f'Compile error for {val}', msg) self._validators.remove(val) except run.ProgramError as e: - self.error(e) + self.error(str(e)) # Only sanity check input validators if they all actually compiled if self._check_res: - all_flags = set() - def collect_flags(group, flags): + all_flags: set = set() + def collect_flags(group: TestCaseGroup, flags: set) -> None: if len(group.get_testcases()) > 0: flags.add(group.config['input_validator_flags']) for subgroup in group.get_subgroups(): @@ -1239,12 +1224,12 @@ def collect_flags(group, flags): def modified_input_validates(applicable, modifier): for testcase in self._problem.testdata.get_all_testcases(): with open(testcase.infile) as infile: - infile = infile.read() - if not applicable(infile): + infile_data = infile.read() + if not applicable(infile_data): continue with open(file_name, "wb") as f: - f.write(modifier(infile).encode('utf8')) + f.write(modifier(infile_data).encode('utf8')) for flags in all_flags: flags = flags.split() @@ -1270,7 +1255,7 @@ def modified_input_validates(applicable, modifier): return self._check_res - def validate(self, testcase): + def validate(self, testcase: TestCase) -> None: flags = testcase.testcasegroup.config['input_validator_flags'].split() self.check(None) for val in self._validators: @@ -1292,16 +1277,16 @@ def validate(self, testcase): class Graders(ProblemAspect): _default_grader = run.get_tool('default_grader') - def __init__(self, problem): + def __init__(self, problem: Problem): self._problem = problem - self._graders = run.find_programs(os.path.join(problem.probdir, 'graders'), + self._graders: list = run.find_programs(os.path.join(problem.probdir, 'graders'), language_config=problem.language_config, work_dir=problem.tmpdir) - def __str__(self): + def __str__(self) -> str: return 'graders' - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1315,7 +1300,7 @@ def check(self, args): self.error(f'Compile error for {grader}', msg) return self._check_res - def grade(self, sub_results, testcasegroup, shadow_result=False): + def grade(self, sub_results: list[SubmissionResult], testcasegroup: TestCaseGroup, shadow_result: bool=False) -> tuple[Verdict, float|None]: if testcasegroup.config['grading'] == 'default': graders = [self._default_grader] @@ -1324,8 +1309,8 @@ def grade(self, sub_results, testcasegroup, shadow_result=False): grader_input = ''.join([f'{r.verdict} {0 if r.score is None else r.score}\n' for r in sub_results]) grader_output_re = r'^((AC)|(WA)|(TLE)|(RTE)|(JE))\s+-?[0-9.]+\s*$' - verdict = 'AC' - score = 0 + verdict: Verdict = 'AC' + score: float = 0 if not sub_results: self.info('No results on %s, so no graders ran' % (testcasegroup,)) @@ -1366,8 +1351,8 @@ def grade(self, sub_results, testcasegroup, shadow_result=False): self.debug(f'Output was: "{grader_output}"') return ('JE', None) - verdict, score = grader_output.split() - score = float(score) + verdict, score_str = grader_output.split() + score = float(score_str) # TODO: check that all graders give same result if not shadow_result: @@ -1380,7 +1365,7 @@ class OutputValidators(ProblemAspect): _default_validator = run.get_tool('default_validator') - def __init__(self, problem): + def __init__(self, problem: Problem): self._problem = problem self._validators = run.find_programs(os.path.join(problem.probdir, 'output_validators'), @@ -1388,11 +1373,11 @@ def __init__(self, problem): work_dir=problem.tmpdir) - def __str__(self): + def __str__(self) -> str: return 'output validators' - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1417,7 +1402,7 @@ def check(self, args): if not success: self.error(f'Compile error for output validator {val}', msg) except run.ProgramError as e: - self.error(e) + self.error(str(e)) # Only sanity check output validators if they all actually compiled if self._check_res: @@ -1444,7 +1429,7 @@ def check(self, args): return self._check_res @staticmethod - def __get_feedback(feedback_dir): + def __get_feedback(feedback_dir: str) -> str|None: all_feedback = [] for feedback_file in os.listdir(feedback_dir): feedback_path = os.path.join(feedback_dir, feedback_file) @@ -1462,7 +1447,7 @@ def __get_feedback(feedback_dir): return None - def _parse_validator_results(self, val, status, feedbackdir, testcase): + def _parse_validator_results(self, val, status: int, feedbackdir, testcase: TestCase) -> SubmissionResult: custom_score = self._problem.config.get('grading')['custom_scoring'] score = None # TODO: would be good to have some way of displaying the feedback for debugging uses @@ -1496,14 +1481,14 @@ def _parse_validator_results(self, val, status, feedbackdir, testcase): return SubmissionResult('AC', score=score) - def _actual_validators(self): + def _actual_validators(self) -> list: vals = self._validators if self._problem.config.get('validation') == 'default': vals = [self._default_validator] return vals - def validate_interactive(self, testcase, submission, timelim, errorhandler): + def validate_interactive(self, testcase: TestCase, submission, timelim: int, errorhandler: Submissions) -> SubmissionResult: interactive_output_re = r'\d+ \d+\.\d+ \d+ \d+\.\d+ (validator|submission)' res = SubmissionResult('JE') interactive = run.get_tool('interactive') @@ -1534,10 +1519,10 @@ def validate_interactive(self, testcase, submission, timelim, errorhandler): if not re.match(interactive_output_re, interactive_output): errorhandler.error(f'Output from interactive does not follow expected format, got output "{interactive_output}"') else: - val_status, _, sub_status, sub_runtime, first = interactive_output.split() - sub_status = int(sub_status) - sub_runtime = float(sub_runtime) - val_status = int(val_status) + val_status_str, _, sub_status_str, sub_runtime_str, first = interactive_output.split() + sub_status = int(sub_status_str) + sub_runtime = float(sub_runtime_str) + val_status = int(val_status_str) val_JE = not os.WIFEXITED(val_status) or os.WEXITSTATUS(val_status) not in [42, 43] val_WA = os.WIFEXITED(val_status) and os.WEXITSTATUS(val_status) == 43 if val_JE or (val_WA and first == 'validator'): @@ -1566,8 +1551,7 @@ def validate_interactive(self, testcase, submission, timelim, errorhandler): return res - def validate(self, testcase, submission_output): - res = SubmissionResult('JE') + def validate(self, testcase: TestCase, submission_output: str) -> SubmissionResult: val_timelim = self._problem.config.get('limits')['validation_time'] val_memlim = self._problem.config.get('limits')['validation_memory'] flags = self._problem.config.get('validator_flags').split() + testcase.testcasegroup.config['output_validator_flags'].split() @@ -1583,21 +1567,21 @@ def validate(self, testcase, submission_output): return res # TODO: check that all output validators give same result - return res + return SubmissionResult('JE') class Submissions(ProblemAspect): _SUB_REGEXP = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9](\.c\+\+)?$') # (verdict, directory, required) - _VERDICTS = [ - ['AC', 'accepted', True], - ['PAC', 'partially_accepted', False], - ['WA', 'wrong_answer', False], - ['RTE', 'run_time_error', False], - ['TLE', 'time_limit_exceeded', False], + _VERDICTS: list[tuple[Verdict, str, bool]] = [ + ('AC', 'accepted', True), + ('PAC', 'partially_accepted', False), + ('WA', 'wrong_answer', False), + ('RTE', 'run_time_error', False), + ('TLE', 'time_limit_exceeded', False), ] - def __init__(self, problem): + def __init__(self, problem: Problem): self._submissions = {} self._problem = problem srcdir = os.path.join(problem.probdir, 'submissions') @@ -1610,10 +1594,10 @@ def __init__(self, problem): include_dir=os.path.join(problem.probdir, 'include')) - def __str__(self): + def __str__(self) -> str: return 'submissions' - def check_submission(self, sub, args, expected_verdict, timelim, timelim_low, timelim_high): + def check_submission(self, sub, args: argparse.Namespace, expected_verdict: Verdict, timelim: int, timelim_low: int, timelim_high: int) -> SubmissionResult: desc = f'{expected_verdict} submission {sub}' partial = False if expected_verdict == 'PAC': @@ -1650,19 +1634,19 @@ def check_submission(self, sub, args, expected_verdict, timelim, timelim_low, ti return result - def full_score_finite(self): + def full_score_finite(self) -> bool: min_score, max_score = self._problem.testdata.get_score_range() if self._problem.config.get('grading')['objective'] == 'min': - return min_score != -float('inf') + return min_score != float('-inf') else: return max_score != float('inf') - def fully_accepted(self, result): + def fully_accepted(self, result: SubmissionResult) -> bool: min_score, max_score = self._problem.testdata.get_score_range() best_score = min_score if self._problem.config.get('grading')['objective'] == 'min' else max_score return result.verdict == 'AC' and (not self._problem.is_scoring or result.score == best_score) - def check(self, args): + def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1708,19 +1692,19 @@ def check(self, args): if len(runtimes) > 0: max_runtime = max(runtimes) exact_timelim = max_runtime * time_multiplier - max_runtime = f'{max_runtime:.3f}' + max_runtime_str = f'{max_runtime:.3f}' timelim = max(1, int(0.5 + exact_timelim)) timelim_margin_lo = max(1, min(int(0.5 + exact_timelim / safety_margin), timelim - 1)) timelim_margin = max(timelim + 1, int(0.5 + exact_timelim * safety_margin)) else: - max_runtime = None + max_runtime_str = None if args.fixed_timelim is not None and args.fixed_timelim != timelim: self.msg(f" Solutions give timelim of {timelim} seconds, but will use provided fixed limit of {args.fixed_timelim} seconds instead") timelim = args.fixed_timelim timelim_margin = timelim * safety_margin - self.msg(f" Slowest AC runtime: {max_runtime}, setting timelim to {timelim} secs, safety margin to {timelim_margin} secs") + self.msg(f" Slowest AC runtime: {max_runtime_str}, setting timelim to {timelim} secs, safety margin to {timelim_margin} secs") limits['time'] = timelim return self._check_res @@ -1728,12 +1712,12 @@ def check(self, args): PROBLEM_PARTS = ['config', 'statement', 'validators', 'graders', 'generators', 'data', 'submissions'] class Problem(ProblemAspect): - def __init__(self, probdir): + def __init__(self, probdir: str): self.probdir = os.path.realpath(probdir) - self.shortname = os.path.basename(self.probdir) + self.shortname: str|None = os.path.basename(self.probdir) self.language_config = languages.load_language_config() - def __enter__(self): + def __enter__(self) -> Problem: self.tmpdir = tempfile.mkdtemp(prefix=f'verify-{self.shortname}-') if not os.path.isdir(self.probdir): self.error(f"Problem directory '{self.probdir}' not found") @@ -1757,23 +1741,21 @@ def __enter__(self): self.input_format_validators = InputFormatValidators(self) self.output_validators = OutputValidators(self) self.graders = Graders(self) - self.testcase_by_infile = {} + self.testcase_by_infile: dict[str, TestCase] = {} self.testdata = TestCaseGroup(self, os.path.join(self.probdir, 'data')) self.submissions = Submissions(self) self.generators = Generators(self) return self - def __exit__(self, exc_type, exc_value, exc_traceback): + def __exit__(self, exc_type, exc_value, exc_traceback) -> None: shutil.rmtree(self.tmpdir) - def __str__(self): - return self.shortname + def __str__(self) -> str: + return str(self.shortname) - def check(self, args=None): + def check(self, args: argparse.Namespace) -> tuple[int, int]: if self.shortname is None: - return [1, 0] - if args is None: - args = default_args() + return 1, 0 ProblemAspect.errors = 0 ProblemAspect.warnings = 0 @@ -1781,13 +1763,15 @@ def check(self, args=None): ProblemAspect.consider_warnings_errors = args.werror try: - part_mapping = {'config': [self.config], - 'statement': [self.statement, self.attachments], - 'validators': [self.input_format_validators, self.output_validators], - 'graders': [self.graders], - 'data': [self.testdata], - 'submissions': [self.submissions], - 'generators': [self.generators]} + part_mapping: dict[str, list] = { + 'config': [self.config], + 'statement': [self.statement, self.attachments], + 'validators': [self.input_format_validators, self.output_validators], + 'graders': [self.graders], + 'generators': [self.generators], + 'data': [self.testdata], + 'submissions': [self.submissions], + } if not re.match('^[a-z0-9]+$', self.shortname): self.error(f"Invalid shortname '{self.shortname}' (must be [a-z0-9]+)") @@ -1800,10 +1784,10 @@ def check(self, args=None): item.check(args) except VerifyError: pass - return [ProblemAspect.errors, ProblemAspect.warnings] + return ProblemAspect.errors, ProblemAspect.warnings -def re_argument(s): +def re_argument(s: str) -> Pattern[str]: try: r = re.compile(s) return r @@ -1811,28 +1795,12 @@ def re_argument(s): raise argparse.ArgumentTypeError(f'{s} is not a valid regex') -def part_argument(s): +def part_argument(s: str) -> str: if s not in PROBLEM_PARTS: raise argparse.ArgumentTypeError(f"Invalid problem part specified: {s}") return s - -def argparser_basic_arguments(parser): - parser.add_argument('-b', '--bail_on_error', - action='store_true', - help='bail verification on first error') - parser.add_argument('-l', '--log_level', - default='warning', - help='set log level (debug, info, warning, error, critical)') - parser.add_argument('-e', '--werror', - action='store_true', - help='consider warnings as errors') - parser.add_argument('--max_additional_info', - type=int, default=15, - help='maximum number of lines of additional info (e.g. compiler output or validator feedback) to display about an error (set to 0 to disable additional info)') - - -def argparser(): +def argparser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description='Validate a problem package in the Kattis problem format.') parser.add_argument('-s', '--submission_filter', metavar='SUBMISSIONS', type=re_argument, default=re.compile('.*'), @@ -1847,17 +1815,24 @@ def argparser(): type=part_argument, nargs='+', default=PROBLEM_PARTS, help=f'only test the indicated parts of the problem. Each PROBLEM_PART can be one of {PROBLEM_PARTS}.') - argparser_basic_arguments(parser) + parser.add_argument('-b', '--bail_on_error', + action='store_true', + help='bail verification on first error') + parser.add_argument('-l', '--log_level', + default='warning', + help='set log level (debug, info, warning, error, critical)') + parser.add_argument('-e', '--werror', + action='store_true', + help='consider warnings as errors') + parser.add_argument('--max_additional_info', + type=int, default=15, + help='maximum number of lines of additional info (e.g. compiler output or validator feedback) to display about an error (set to 0 to disable additional info)') parser.add_argument('problemdir', nargs='+') return parser -def default_args(): - return argparser().parse_args([None]) - - -def initialize_logging(args): +def initialize_logging(args: argparse.Namespace) -> None: ProblemAspect.max_additional_info = args.max_additional_info fmt = "%(levelname)s %(message)s" @@ -1866,7 +1841,7 @@ def initialize_logging(args): level=eval(f"logging.{args.log_level.upper()}")) -def main(): +def main() -> None: args = argparser().parse_args() initialize_logging(args) @@ -1880,7 +1855,8 @@ def main(): print(f'{prob.shortname} tested: {errors} error{p(errors)}, {warnings} warning{p(warnings)}') total_errors += errors - sys.exit(1 if total_errors > 0 else 0) + if total_errors > 0: + sys.exit(1) if __name__ == '__main__': main() From d715eff8cafb198de8fbe5ad51332cbf6d0af141 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Mon, 9 Oct 2023 10:21:18 -0500 Subject: [PATCH 008/272] Use long name for langparam --- problemtools/verifyproblem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 2ae6c8f7..3f6a5f23 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1072,14 +1072,14 @@ def check(self, args: argparse.Namespace) -> bool: for lang in self.languages: try: if not problem2pdf.convert([self._problem.probdir, '--language', lang, '--no-pdf', '--quiet']): - langparam = f' -l {lang}' if lang != '' else '' + langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') except Exception as e: self.error(f'Error raised when checking problem statement for language {lang}:\n{e}') try: problem2html.convert([self._problem.probdir, '--dest-dir', os.path.join(self._problem.tmpdir, 'html'), '--language', lang, '--quiet']) except Exception: - langparam = f' -l {lang}' if lang != '' else '' + langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not convert problem statement to html for language "{lang}". Run problem2html{langparam} on the problem to diagnose.') return self._check_res From 4d71cfaeffdd876e2dbc4a82c1ef3e4fa71bed86 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Mon, 9 Oct 2023 10:24:19 -0500 Subject: [PATCH 009/272] Fix potential error --- problemtools/verifyproblem.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 3f6a5f23..e656ffc7 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1552,6 +1552,7 @@ def validate_interactive(self, testcase: TestCase, submission, timelim: int, err def validate(self, testcase: TestCase, submission_output: str) -> SubmissionResult: + res = SubmissionResult('JE') val_timelim = self._problem.config.get('limits')['validation_time'] val_memlim = self._problem.config.get('limits')['validation_memory'] flags = self._problem.config.get('validator_flags').split() + testcase.testcasegroup.config['output_validator_flags'].split() @@ -1567,7 +1568,7 @@ def validate(self, testcase: TestCase, submission_output: str) -> SubmissionResu return res # TODO: check that all output validators give same result - return SubmissionResult('JE') + return res class Submissions(ProblemAspect): From 209d1fd2074e31af1186121162394d7f1427f348 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Mon, 9 Oct 2023 10:27:14 -0500 Subject: [PATCH 010/272] Use tuple destructuring syntax --- problemtools/verifyproblem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index e656ffc7..48c376fc 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1851,7 +1851,7 @@ def main() -> None: for problemdir in args.problemdir: print(f'Loading problem {os.path.basename(os.path.realpath(problemdir))}') with Problem(problemdir) as prob: - [errors, warnings] = prob.check(args) + errors, warnings = prob.check(args) p = lambda x: '' if x == 1 else 's' print(f'{prob.shortname} tested: {errors} error{p(errors)}, {warnings} warning{p(warnings)}') total_errors += errors From 0b2e846a632804f6619ce62b5b036340df90db59 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Mon, 9 Oct 2023 10:35:08 -0500 Subject: [PATCH 011/272] Don't remove argparser_basic_arguments --- problemtools/generatedata.py | 10 ++++------ problemtools/verifyproblem.py | 29 +++++++++++++++++------------ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/problemtools/generatedata.py b/problemtools/generatedata.py index 3f8abaa5..33d0aebf 100644 --- a/problemtools/generatedata.py +++ b/problemtools/generatedata.py @@ -13,7 +13,7 @@ ALL_EXTENSIONS = ['in', 'ans'] + Generators._VISUALIZER_EXTENSIONS -def argparser(): +def argparser() -> ArgumentParser: parser = ArgumentParser(description='Generate test data for a problem package in the Kattis problem format.') parser.add_argument('-g', '--generate', action='store_true', @@ -31,7 +31,9 @@ def argparser(): type=int, default=None, help='level of parallelism') + argparser_basic_arguments(parser) + parser.add_argument('problemdir', nargs='+') return parser @@ -41,10 +43,7 @@ def clean(prob, args): ProblemAspect.warnings = 0 base_path = os.path.join(prob.probdir, 'data') - testcases = { - case['path']: case - for case in prob.generators._testcases - } + testcases = { case['path']: case for case in prob.generators._testcases } def walk(name, path): case_count = 0 @@ -191,7 +190,6 @@ def generate_case(case_idx): def generate(prob, args): - # Create directory structure created = set() for case in prob.generators._testcases: diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 48c376fc..9737240f 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1801,6 +1801,22 @@ def part_argument(s: str) -> str: raise argparse.ArgumentTypeError(f"Invalid problem part specified: {s}") return s + +def argparser_basic_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument('-b', '--bail_on_error', + action='store_true', + help='bail verification on first error') + parser.add_argument('-l', '--log_level', + default='warning', + help='set log level (debug, info, warning, error, critical)') + parser.add_argument('-e', '--werror', + action='store_true', + help='consider warnings as errors') + parser.add_argument('--max_additional_info', + type=int, default=15, + help='maximum number of lines of additional info (e.g. compiler output or validator feedback) to display about an error (set to 0 to disable additional info)') + + def argparser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description='Validate a problem package in the Kattis problem format.') parser.add_argument('-s', '--submission_filter', metavar='SUBMISSIONS', @@ -1816,18 +1832,7 @@ def argparser() -> argparse.ArgumentParser: type=part_argument, nargs='+', default=PROBLEM_PARTS, help=f'only test the indicated parts of the problem. Each PROBLEM_PART can be one of {PROBLEM_PARTS}.') - parser.add_argument('-b', '--bail_on_error', - action='store_true', - help='bail verification on first error') - parser.add_argument('-l', '--log_level', - default='warning', - help='set log level (debug, info, warning, error, critical)') - parser.add_argument('-e', '--werror', - action='store_true', - help='consider warnings as errors') - parser.add_argument('--max_additional_info', - type=int, default=15, - help='maximum number of lines of additional info (e.g. compiler output or validator feedback) to display about an error (set to 0 to disable additional info)') + argparser_basic_arguments(parser) parser.add_argument('problemdir', nargs='+') return parser From d5a3fa94b1c11599030e7517ac85137736eb9add Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Mon, 23 Oct 2023 17:10:18 -0500 Subject: [PATCH 012/272] Fix CodeFactor errors --- problemtools/verifyproblem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 9737240f..613913d5 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -543,8 +543,8 @@ def parse_num(s: str, i: int) -> tuple[int, int]: def run_submission(self, sub, args: argparse.Namespace, timelim: int, timelim_low: int, timelim_high: int) -> tuple[SubmissionResult, SubmissionResult, SubmissionResult]: self.info(f'Running on {self}') - subres: list[SubmissionResult] = [] - subres_low: list[SubmissionResult] = [] + subres: list[SubmissionResult] = [] + subres_low: list[SubmissionResult] = [] subres_high: list[SubmissionResult] = [] active_low, active = True, True on_reject = self.config['on_reject'] From a6ae7057692332062da6b24319da34e8000a28a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Konr=C3=A1=C3=B0=20El=C3=AD=20Sigurgeirsson?= <8724275+SvartaHjarta@users.noreply.github.com> Date: Mon, 6 Nov 2023 15:37:17 +0000 Subject: [PATCH 013/272] Update README.md Added section on installing on Arch --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index d6ce4c28..697c1513 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,9 @@ Followed by: pip3 install --user plastex +### Arch +Package is avalible on the AUR [kattis-problemtools-git](https://aur.archlinux.org/packages/kattis-problemtools-git), use your favorite AUR helper or follow the installation instructions found [here](https://wiki.archlinux.org/title/Arch_User_Repository#Installing_and_upgrading_packages). + ### Other platforms The problem tools have not been tested on other platforms. If you do From f27bd8b0ff8596ef0be86e7dcf5e2afbb58d9498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joel=20Niemel=C3=A4?= Date: Fri, 17 Nov 2023 12:08:15 -0600 Subject: [PATCH 014/272] Update README.md Fix some punctuation errors and typos. --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 697c1513..1bba9f93 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The problem tools provide the following three programs: - `problem2pdf`: convert a problem statement to pdf - `problem2html`: convert a problem statement to html -Running any of them with command-line option `-h` gives +Running any of them with the command-line option `-h` gives documentation on what arguments they accept. @@ -62,7 +62,7 @@ We maintain three official problemtools Docker images on Docker Hub: - [`problemtools/icpc`](https://hub.docker.com/r/problemtools/icpc/): this image contains problemtools along with compilers/interpreters for the programming languages allowed in the International Collegiate Programming Contest (ICPC): C, C++, Java, Kotlin, and Python 3. Note that the compiler/interpreter versions used might not be exactly the same as those used in the current ICPC season. -- [`problemtools/minimal`](https://hub.docker.com/r/problemtools/minimal/): this image only contains problemtools, no additional programming languages. As such as it is not particularly useful on its own, but if you are organizing a contest and want to set up a problemtools environment containing exactly the right set of compilers/interpreters for your contest, this is the recommended starting point. +- [`problemtools/minimal`](https://hub.docker.com/r/problemtools/minimal/): this image only contains problemtools, no additional programming languages. As such, it is not particularly useful on its own, but if you are organizing a contest and want to set up a problemtools environment containing exactly the right set of compilers/interpreters for your contest, this is the recommended starting point. For example, suppose you want to use the `problemtools/icpc` image. To get started, install the [Docker CLI](https://docs.docker.com/install), and then pull the image: @@ -78,16 +78,16 @@ By default, docker containers do _NOT_ persist storage between runs, so any file ``` docker run --rm -it -v ${FULL_PATH_TO_MOUNT}:/kattis_work_dir problemtools/icpc ``` -2) Persist any changes you want to keep to a remote file system/source control (e.g. a remote Git repository, note however that you would first need to install Git in the image). +2) Persist any changes you want to keep to a remote file system/source control (e.g., a remote Git repository; note, however, that you would first need to install Git in the image). #### Building your own images -If you want a more complete environment in the Docker images (e.g. if +If you want a more complete environment in the Docker images (e.g., if you want to install git or your favorite editor), feel free to extend them in whichever way you like. The `problemtools/{minimal,icpc,full}` images point to the latest -release versions of problemtools. If for some reason you want an +release versions of problemtools. If, for some reason, you want an image containing the latest development version, you have to build it yourself from scratch (while there are `problemtools/{minimal,icpc,full}:develop` Docker images on Docker @@ -101,7 +101,7 @@ If you intend to help develop problemtools, or if you just want a bare-bones way of running them, this is your option. For this method, you need to clone the repository (just downloading a -zip archive of it does not work, because the project has submodules +zip archive of it does not work because the project has submodules that are not included in that zip archive). In order for the tools to work, you first have to compile the various @@ -119,11 +119,11 @@ order for problemtools to work correctly. ### Method 4: Build and install the Debian package -This applies if you are running on Debian or a Debian derivative such +This applies if you are running on Debian or a Debian derivative, such as Ubuntu. As with method 3, you need to clone the repository (just downloading a -zip archive of it does not work, because the project has submodules +zip archive of it does not work because the project has submodules that are not included in that zip archive). Run `make builddeb` in the root of the problemtools repository to @@ -189,7 +189,7 @@ problemtools' configuration: memory: 2048 # (unit is MiB) ``` - (In principle it is possible to override the defaults of other values than the + (In principle, it is possible to override the defaults of other values than the system-dependent defaults in the problem.yaml metadata files this way, but such usage is very strongly discouraged.) @@ -220,7 +220,7 @@ Followed by: pip3 install --user plastex ### Arch -Package is avalible on the AUR [kattis-problemtools-git](https://aur.archlinux.org/packages/kattis-problemtools-git), use your favorite AUR helper or follow the installation instructions found [here](https://wiki.archlinux.org/title/Arch_User_Repository#Installing_and_upgrading_packages). +Package is available on the AUR [kattis-problemtools-git](https://aur.archlinux.org/packages/kattis-problemtools-git). Use your favorite AUR helper or follow the installation instructions found [here](https://wiki.archlinux.org/title/Arch_User_Repository#Installing_and_upgrading_packages). ### Other platforms From 180c7e32b1baf18cae674128afadf5efb6ea870a Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Fri, 17 Nov 2023 12:28:27 -0600 Subject: [PATCH 015/272] Update kotlin to version 1.8.10 --- admin/docker/Dockerfile.icpc | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/admin/docker/Dockerfile.icpc b/admin/docker/Dockerfile.icpc index 904529f8..55f2dff6 100644 --- a/admin/docker/Dockerfile.icpc +++ b/admin/docker/Dockerfile.icpc @@ -9,17 +9,28 @@ LABEL maintainer="austrin@kattis.com" ENV DEBIAN_FRONTEND=noninteractive -# Install C++, Java, Kotlin, and PyPy 3 via their ppa repository -RUN apt update && \ - apt install -y software-properties-common && \ +# Install C++, Java, and PyPy 3 via their ppa repository (Kotlin is installed below to get a more up-to-date version) +RUN apt-get update && \ + apt-get install -y software-properties-common && \ add-apt-repository ppa:pypy/ppa && \ - apt update && \ - apt install -y \ + apt-get update && \ + apt-get install -y \ gcc g++ \ openjdk-11-jdk openjdk-11-jre \ - kotlin \ pypy3 +RUN apt-get install -y curl + +ARG KOTLIN_VERSION=1.8.10 +RUN curl "https://github.com/JetBrains/kotlin/releases/download/v$KOTLIN_VERSION/kotlin-compiler-$KOTLIN_VERSION.zip" -L -o kotlin.zip +RUN unzip -q kotlin.zip +RUN rm kotlin.zip + +RUN mv kotlinc/bin/* /usr/bin +RUN mv kotlinc/lib/* /usr/lib + +RUN rm -r kotlinc + # Reconfigure problemtools: # - Use PyPy for Python 2 (not available in this image but in the full one) # - Use PyPy for Python 3 From 70e29866430a1421cefb4c1955fa3f8c650831df Mon Sep 17 00:00:00 2001 From: Harry Zhang <75111093+hairez@users.noreply.github.com> Date: Sat, 18 Nov 2023 01:02:34 +0100 Subject: [PATCH 016/272] Fix not showing WA test case verifyproblem.py Edited line 71 in verifyproblem.py. Now verifyproblem will also show what test case it is failing on, and not only the test case that took the longest time to run. --- problemtools/verifyproblem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 3fed8a5b..be67a1f6 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -68,7 +68,7 @@ def __str__(self) -> str: if self.reason is not None: details.append(self.reason) - if self.verdict != 'AC' and self.testcase is not None: + if self.testcase is not None: details.append(f'test case: {self.testcase}') if self.runtime != -1: details.append(f'CPU: {self.runtime:.2f}s @ {self.runtime_testcase}') From 7fb6089ace043a0d7058e571f46871631bd581ec Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Thu, 25 Jan 2024 11:37:28 +0100 Subject: [PATCH 017/272] Fix crash in verifyproblem when in or ans files are not utf-8 --- problemtools/verifyproblem.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index be67a1f6..61ab419d 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -148,8 +148,13 @@ def __init__(self, problem: Problem, base: str, testcasegroup: TestCaseGroup): problem.testcase_by_infile[self.infile] = self def check_newlines(self, filename: str) -> None: - with open(filename, 'r') as f: - data = f.read() + with open(filename, 'rb') as f: + rawdata = f.read() + try: + data = rawdata.decode('utf-8', 'strict') + except UnicodeDecodeError: + self.warning(f'The file {filename} could not be decoded as utf-8') + return if data.find('\r') != -1: self.warning(f'The file {filename} contains non-standard line breaks.') if len(data) > 0 and data[-1] != '\n': From 5cfcd1e1eff96561399d6b0c2cda980dbb8a5f22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Fri, 2 Feb 2024 16:06:20 +0100 Subject: [PATCH 018/272] Restructure problem2html Simplifies calling problem2html from other scripts by factoring out the command line parsing. --- problemtools/problem2html.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index f4306d5d..6d5c8dfc 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -14,9 +14,7 @@ from .ProblemPlasTeX import ProblemsetMacros from . import template -def convert(args: list[str]|None = None) -> None: - options = parse_args(args) - +def convert(options: argparse.Namespace) -> None: problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] @@ -111,7 +109,7 @@ def convert(args: list[str]|None = None) -> None: os.chdir(origcwd) -def parse_args(args: list[str]|None) -> argparse.Namespace: +def get_parser() -> argparse.Namespace: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('-b', '--body-only', dest='bodyonly', action='store_true', help='only generate HTML body, no HTML headers', default=False) @@ -123,18 +121,15 @@ def parse_args(args: list[str]|None) -> argparse.Namespace: parser.add_argument('-l', '--language', dest='language', help='choose alternate language (2-letter code)', default=None) parser.add_argument('-L', '--log-level', dest='loglevel', help='set log level (debug, info, warning, error, critical)', default='warning') parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help="quiet", default=False) + parser.add_argument('-i', '--imgbasedir', dest='imgbasedir', default='') parser.add_argument('problem', help='the problem to convert') - if args is not None: - options = parser.parse_args(args) - else: - options = parser.parse_args() - - options.imgbasedir = '' - return options + return parser def main() -> None: - convert() + parser = get_parser() + options = parser.parse_args() + convert(options) if __name__ == '__main__': From ec0f8f7f60af76b57b788dbd3ec28bbc59d10504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Fri, 2 Feb 2024 16:21:42 +0100 Subject: [PATCH 019/272] Restructure restrucure problem2pdf Simplifies calling problem2pdf from other scripts by factoring out the command line parsing. --- problemtools/problem2pdf.py | 14 ++++++-------- problemtools/verifyproblem.py | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 4c7a221b..3671bcfb 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -8,8 +8,7 @@ from . import template -def convert(args: list[str]|None = None) -> bool: - options = parse_args(args) +def convert(options: argparse.Namespace) -> bool: problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] @@ -46,7 +45,7 @@ def convert(args: list[str]|None = None) -> bool: return status == 0 -def parse_args(args: list[str]|None) -> argparse.Namespace: +def get_parser() -> argparse.Namespace: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('-o', '--output', dest='destfile', help="output file name", default='${problem}.pdf') @@ -55,14 +54,13 @@ def parse_args(args: list[str]|None) -> argparse.Namespace: parser.add_argument('-n', '--no-pdf', dest='nopdf', action='store_true', help='run pdflatex in -draftmode', default=False) parser.add_argument('problem', help='the problem to convert') - if args is not None: - return parser.parse_args(args) - - return parser.parse_args() + return parser def main() -> None: - convert() + parser = get_parser() + options = parser.parse_args() + convert(options) if __name__ == '__main__': diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 61ab419d..ca901c38 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1076,13 +1076,23 @@ def check(self, args: argparse.Namespace) -> bool: for lang in self.languages: try: - if not problem2pdf.convert([self._problem.probdir, '--language', lang, '--no-pdf', '--quiet']): + options = problem2pdf.get_parser() + options.problem = self._problem.probdir + options.language = lang + options.nopdf = True + options.quiet = True + if not problem2pdf.convert(options): langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') except Exception as e: self.error(f'Error raised when checking problem statement for language {lang}:\n{e}') try: - problem2html.convert([self._problem.probdir, '--dest-dir', os.path.join(self._problem.tmpdir, 'html'), '--language', lang, '--quiet']) + options = problem2html.get_parser() + options.problem = self._problem.probdir + options.destdir = os.path.join(self._problem.tmpdir, 'html') + options.language = lang + options.quiet = True + problem2html.convert(options) except Exception: langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not convert problem statement to html for language "{lang}". Run problem2html{langparam} on the problem to diagnose.') From 212ad3450bbf5cd7ce56a8d46500247248b280e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Fri, 2 Feb 2024 16:30:31 +0100 Subject: [PATCH 020/272] Improve debugging output --- problemtools/verifyproblem.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index ca901c38..f8f681fc 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -15,6 +15,8 @@ import sys import copy import random +import traceback + import argparse import shlex @@ -1085,7 +1087,7 @@ def check(self, args: argparse.Namespace) -> bool: langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') except Exception as e: - self.error(f'Error raised when checking problem statement for language {lang}:\n{e}') + self.error(f'Error raised when checking problem statement for language {lang}:\n{e}\n{traceback.format_exc()}') try: options = problem2html.get_parser() options.problem = self._problem.probdir @@ -1093,9 +1095,9 @@ def check(self, args: argparse.Namespace) -> bool: options.language = lang options.quiet = True problem2html.convert(options) - except Exception: + except Exception as e: langparam = f' --language {lang}' if lang != '' else '' - self.error(f'Could not convert problem statement to html for language "{lang}". Run problem2html{langparam} on the problem to diagnose.') + self.error(f'Could not convert problem statement to html for language "{lang}". Run problem2html{langparam} on the problem to diagnose.\n{e}\n{traceback.format_exc()}') return self._check_res def __str__(self) -> str: From 16dba673defd8d270f5db705a0298cd95071ede4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Fri, 2 Feb 2024 16:34:30 +0100 Subject: [PATCH 021/272] Parse args properly. --- problemtools/verifyproblem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index f8f681fc..155d3336 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1078,7 +1078,7 @@ def check(self, args: argparse.Namespace) -> bool: for lang in self.languages: try: - options = problem2pdf.get_parser() + options = problem2pdf.get_parser().parse_args([None]) options.problem = self._problem.probdir options.language = lang options.nopdf = True @@ -1089,7 +1089,7 @@ def check(self, args: argparse.Namespace) -> bool: except Exception as e: self.error(f'Error raised when checking problem statement for language {lang}:\n{e}\n{traceback.format_exc()}') try: - options = problem2html.get_parser() + options = problem2html.get_parser().parse_args([None]) options.problem = self._problem.probdir options.destdir = os.path.join(self._problem.tmpdir, 'html') options.language = lang From dfd7447ca9da18a832ae66a6c25eaa0ba30f269d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Fri, 2 Feb 2024 17:03:08 +0100 Subject: [PATCH 022/272] Reintroduce the get_subgroup, as it is needed for addproblem. --- problemtools/verifyproblem.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 155d3336..4253f5eb 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -400,6 +400,10 @@ def get_subgroups(self) -> list[TestCaseGroup]: return [child for child in self._items if isinstance(child, TestCaseGroup)] + def get_subgroup(self, name): + return next((child for child in self._items if isinstance(child, TestCaseGroup) and os.path.basename(child._datadir) == name), None) + + def has_custom_groups(self) -> bool: return any(group.get_subgroups() for group in self.get_subgroups()) From 102b72396e92083a1d3a3540344f9d63af983ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Fri, 2 Feb 2024 17:05:29 +0100 Subject: [PATCH 023/272] Reintroduce get_attachment_paths, as it is needed for addproblem. --- problemtools/verifyproblem.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 4253f5eb..2a0dd19d 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1154,6 +1154,11 @@ def check(self, args: argparse.Namespace) -> bool: return self._check_res + + def get_attachment_paths(self): + return self.attachments + + def __str__(self) -> str: return 'attachments' From b850e321794291abb4429eee124b20eac7e2ec58 Mon Sep 17 00:00:00 2001 From: Tobias Meggendorfer Date: Tue, 12 Jul 2022 23:25:14 +0200 Subject: [PATCH 024/272] Improved logging, fix unicode error, include_dir buildrun --- problemtools/problem2html.py | 13 +++--- problemtools/run/__init__.py | 11 +++-- problemtools/run/buildrun.py | 22 ++++++---- problemtools/run/program.py | 7 ++- problemtools/run/source.py | 5 ++- problemtools/verifyproblem.py | 81 +++++++++++++++++++++++++++-------- 6 files changed, 99 insertions(+), 40 deletions(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 6d5c8dfc..28ead925 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -7,14 +7,15 @@ import logging import subprocess -import plasTeX.TeX -import plasTeX.Logging - -from .ProblemPlasTeX import ProblemRenderer -from .ProblemPlasTeX import ProblemsetMacros -from . import template def convert(options: argparse.Namespace) -> None: + import plasTeX.TeX + import plasTeX.Logging + + from .ProblemPlasTeX import ProblemRenderer + from .ProblemPlasTeX import ProblemsetMacros + from . import template + problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] diff --git a/problemtools/run/__init__.py b/problemtools/run/__init__.py index 79de6047..6713e700 100644 --- a/problemtools/run/__init__.py +++ b/problemtools/run/__init__.py @@ -102,13 +102,18 @@ def get_program(path, language_config=None, work_dir=None, include_dir=None, files = [path] else: build = os.path.join(path, 'build') - if os.path.isfile(build) and os.access(path, os.X_OK): + if os.path.isfile(build) and os.access(build, os.X_OK): return BuildRun(path, work_dir) files = rutil.list_files_recursive(path) if language_config is not None: lang = language_config.detect_language(files) if lang is not None: - return SourceCode(path, lang, - work_dir=work_dir, include_dir=include_dir) + if include_dir is not None: + lang_dir = os.path.join(include_dir, lang.lang_id) + build = os.path.join(lang_dir, 'build') + if os.path.isfile(build) and os.access(build, os.X_OK): + return BuildRun(path, work_dir=work_dir, include_dir=lang_dir) + + return SourceCode(path, lang, work_dir=work_dir, include_dir=include_dir) return None diff --git a/problemtools/run/buildrun.py b/problemtools/run/buildrun.py index 208527c0..a86777b6 100644 --- a/problemtools/run/buildrun.py +++ b/problemtools/run/buildrun.py @@ -12,12 +12,14 @@ from .program import Program from . import rutil +log = logging.getLogger(__file__) + class BuildRun(Program): """Class for build/run-script program. """ - def __init__(self, path, work_dir=None): + def __init__(self, path, work_dir=None, include_dir=None): """Instantiate BuildRun object. Args: @@ -28,12 +30,6 @@ def __init__(self, path, work_dir=None): if not os.path.isdir(path): raise ProgramError('%s is not a directory' % path) - build = os.path.join(path, 'build') - if not os.path.isfile(build): - raise ProgramError('%s does not have a build script' % path) - if not os.access(build, os.X_OK): - raise ProgramError('%s/build is not executable' % path) - if work_dir is None: work_dir = tempfile.mkdtemp() @@ -47,7 +43,15 @@ def __init__(self, path, work_dir=None): os.makedirs(self.path) rutil.add_files(path, self.path) + if include_dir is not None and os.path.isdir(include_dir): + rutil.add_files(include_dir, self.path) + # Check for existence of build script after copying include_dir, since that could contain the script + build = os.path.join(self.path, 'build') + if not os.path.isfile(build): + raise ProgramError('%s does not have a build script' % path) + if not os.access(build, os.X_OK): + raise ProgramError('%s/build is not executable' % path) def __str__(self): """String representation""" @@ -65,8 +69,8 @@ def compile(self): run = os.path.join(self.path, 'run') if status: - logging.debug('Build script failed (status %d) when compiling %s\n', status, self.name) - self._compile_result = (False, 'build script failed with exit code %d' % (status)) + log.debug('Build script failed (status %d) when compiling %s', status, self.name) + self._compile_result = (False, f'build script failed with exit code {status:d}') elif not os.path.isfile(run) or not os.access(run, os.X_OK): self._compile_result = (False, 'build script did not produce an executable called "run"') else: diff --git a/problemtools/run/program.py b/problemtools/run/program.py index a86fb8e5..3cdad782 100644 --- a/problemtools/run/program.py +++ b/problemtools/run/program.py @@ -8,6 +8,9 @@ from .errors import ProgramError +log = logging.getLogger(__name__) + + class Program(object): """Abstract base class for programs. """ @@ -70,7 +73,7 @@ def should_skip_memory_rlimit(self): @staticmethod def __run_wait(argv, infile, outfile, errfile, timelim, memlim, working_directory=None): - logging.debug('run "%s < %s > %s 2> %s"', + log.debug('run "%s < %s > %s 2> %s"', ' '.join(argv), infile, outfile, errfile) pid = os.fork() if pid == 0: # child @@ -111,7 +114,7 @@ def __run_wait(argv, infile, outfile, errfile, timelim, memlim, working_director print(exc) os.kill(os.getpid(), signal.SIGTERM) # Unreachable - logging.error("Unreachable part of run_wait reached") + log.error("Unreachable part of run_wait reached") os.kill(os.getpid(), signal.SIGTERM) (pid, status, rusage) = os.wait4(pid, 0) return status, rusage.ru_utime + rusage.ru_stime diff --git a/problemtools/run/source.py b/problemtools/run/source.py index a7724bda..3fa5b8b6 100644 --- a/problemtools/run/source.py +++ b/problemtools/run/source.py @@ -12,6 +12,9 @@ from .program import Program from . import rutil +log = logging.getLogger(__name__) + + class SourceCode(Program): """Class representing a program provided by source code. """ @@ -103,7 +106,7 @@ def compile(self): if not os.path.isfile(compiler) or not os.access(compiler, os.X_OK): return (False, '%s does not seem to be installed, expected to find compiler at %s' % (self.language.name, compiler)) - logging.debug('compile command: %s', command) + log.debug('compile command: %s', command) try: subprocess.check_output(command, stderr=subprocess.STDOUT) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 2a0dd19d..146007a1 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -31,6 +31,8 @@ from typing import Callable, Literal, Pattern, Match +log = logging.getLogger(__name__) + Verdict = Literal['AC', 'TLE', 'OLE', 'MLE', 'RTE', 'WA', 'PAC', 'JE'] def is_TLE(status: int, may_signal_with_usr1: bool=False) -> bool: @@ -91,6 +93,7 @@ class ProblemAspect: warnings = 0 bail_on_error = False _check_res: bool|None = None + consider_warnings_errors = False basename_regex = re.compile('^[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9]$') consider_warnings_errors: bool @@ -110,28 +113,28 @@ def __append_additional_info(msg: str, additional_info: str|None) -> str: return f'{msg}:\n' + '\n'.join(' '*8 + line for line in lines) - def error(self, msg: str, additional_info: str|None=None) -> None: + def __init__(self, name): + self.log = log.getChild(name) + + def error(self, msg: str, additional_info: str|None=None, *args) -> None: self._check_res = False ProblemAspect.errors += 1 - logging.error('in %s: %s', self, ProblemAspect.__append_additional_info(msg, additional_info)) + self.log.error(ProblemAspect.__append_additional_info(msg, additional_info), *args) if ProblemAspect.bail_on_error: raise VerifyError(msg) - def warning(self, msg: str, additional_info: str|None=None) -> None: + def warning(self, msg: str, additional_info: str|None=None, *args) -> None: if ProblemAspect.consider_warnings_errors: - self.error(msg) + self.error(msg, additional_info, *args) return ProblemAspect.warnings += 1 - logging.warning('in %s: %s', self, ProblemAspect.__append_additional_info(msg, additional_info)) - - def msg(self, msg: str) -> None: - print(msg) + self.log.warning(ProblemAspect.__append_additional_info(msg, additional_info), *args) - def info(self, msg: str) -> None: - logging.info(': %s', msg) + def info(self, msg: str, *args) -> None: + self.log.info(msg, *args) - def debug(self, msg: str) -> None: - logging.debug(': %s', msg) + def debug(self, msg: str, *args) -> None: + self.log.debug(msg, *args) def check_basename(self, path: str) -> None: basename = os.path.basename(path) @@ -140,6 +143,7 @@ def check_basename(self, path: str) -> None: class TestCase(ProblemAspect): def __init__(self, problem: Problem, base: str, testcasegroup: TestCaseGroup): + super().__init__(f"{problem.shortname}.test.{testcasegroup.name}.{os.path.basename(base)}") self._base = base self.infile = f'{base}.in' self.ansfile = f'{base}.ans' @@ -248,6 +252,8 @@ def _run_submission_real(self, sub, args: argparse.Namespace, timelim: int, time return (res, res_low, res_high, True) outfile = os.path.join(self._problem.tmpdir, 'output') + errfile = os.path.join(self._problem.tmpdir, 'error') + if sys.stdout.isatty(): msg = f'Running {sub} on {self}...' sys.stdout.write(msg) @@ -256,16 +262,23 @@ def _run_submission_real(self, sub, args: argparse.Namespace, timelim: int, time if self._problem.is_interactive: res_high = self._problem.output_validators.validate_interactive(self, sub, timelim_high, self._problem.submissions) else: - status, runtime = sub.run(self.infile, outfile, + status, runtime = sub.run(infile=self.infile, outfile=outfile, errfile=errfile, timelim=timelim_high+1, memlim=self._problem.config.get('limits')['memory'], set_work_dir=True) if is_TLE(status) or runtime > timelim_high: res_high = SubmissionResult('TLE') elif is_RTE(status): - res_high = SubmissionResult('RTE') + try: + with open(errfile, mode="rt") as f: + info = f.read() + except IOError: + self.info("Failed to read error file %s", errfile) + info = None + res_high = SubmissionResult('RTE', additional_info=info) else: res_high = self._problem.output_validators.validate(self, outfile) res_high.runtime = runtime + if sys.stdout.isatty(): sys.stdout.write('\b \b' * (len(msg))) if res_high.runtime <= timelim_low: @@ -318,8 +331,13 @@ def __init__(self, problem: Problem, datadir: str, parent: TestCaseGroup|None=No self._parent = parent self._problem = problem self._datadir = datadir + self.name = os.path.relpath(os.path.abspath(self._datadir), + os.path.abspath(self._problem.probdir)).replace("/", ".") + + super().__init__(f"{problem.shortname}.test.{self.name}") + self._seen_oob_scores = False - self.debug(f' Loading test data group {datadir}') + self.debug('Loading test data group %s', datadir) configfile = os.path.join(self._datadir, 'testdata.yaml') self.config = {} if os.path.isfile(configfile): @@ -374,7 +392,7 @@ def __init__(self, problem: Problem, datadir: str, parent: TestCaseGroup|None=No def __str__(self) -> str: - return f'test case group {os.path.relpath(self._datadir, os.path.join(self._problem.probdir))}' + return f'test case group {self.name}' def set_symlinks(self) -> None: for sub in self._items: @@ -627,6 +645,7 @@ class ProblemConfig(ProblemAspect): _VALID_LICENSES = ['unknown', 'public domain', 'cc0', 'cc by', 'cc by-sa', 'educational', 'permission'] def __init__(self, problem: Problem): + super().__init__(f"{problem.shortname}.config") self.debug(' Loading problem config') self._problem = problem self.configfile = os.path.join(problem.probdir, 'problem.yaml') @@ -1061,6 +1080,7 @@ def check(self, args: argparse.Namespace) -> bool: class ProblemStatement(ProblemAspect): def __init__(self, problem: Problem): + super().__init__(f"{problem.shortname}.statement") self.debug(' Loading problem statement') self._problem = problem self.languages = [] @@ -1136,6 +1156,7 @@ class Attachments(ProblemAspect): """ def __init__(self, problem: Problem): + super().__init__(f"{problem.shortname}.attachments") attachments_path = os.path.join(problem.probdir, 'attachments') self.attachments: list[str] = [] if os.path.isdir(attachments_path): @@ -1165,7 +1186,7 @@ def __str__(self) -> str: _JUNK_CASES = [ ('an empty file', b''), - ('a binary file with byte values 0 up to 256', bytearray(x for x in range(256))), + ('a binary file with byte values 0 up to 127', bytearray(x for x in range(127))), ('a text file with the ASCII characters 32 up to 127', bytearray(x for x in range(32, 127))), ('a random text file with printable ASCII characters', bytearray(random.choice(string.printable.encode('utf8')) for _ in range(200))), ] @@ -1185,6 +1206,7 @@ def _build_junk_modifier(desc: str, pattern: str, repl: str|Callable[[Match], st class InputFormatValidators(ProblemAspect): def __init__(self, problem: Problem): + super().__init__(f"{problem.shortname}.input_validator") self._problem = problem input_validators_path = os.path.join(problem.probdir, 'input_format_validators') if os.path.isdir(input_validators_path): @@ -1304,6 +1326,7 @@ class Graders(ProblemAspect): _default_grader = run.get_tool('default_grader') def __init__(self, problem: Problem): + super().__init__(f"{problem.shortname}.grader") self._problem = problem self._graders: list = run.find_programs(os.path.join(problem.probdir, 'graders'), language_config=problem.language_config, @@ -1382,7 +1405,7 @@ def grade(self, sub_results: list[SubmissionResult], testcasegroup: TestCaseGrou # TODO: check that all graders give same result if not shadow_result: - self.info(f'Grade on {testcasegroup} is {verdict} ({score})') + self.debug(f'Grade on {testcasegroup} is {verdict} ({score})') return (verdict, score) @@ -1392,6 +1415,7 @@ class OutputValidators(ProblemAspect): def __init__(self, problem: Problem): + super().__init__(f"{problem.shortname}.output_validator") self._problem = problem self._validators = run.find_programs(os.path.join(problem.probdir, 'output_validators'), @@ -1585,11 +1609,28 @@ def validate(self, testcase: TestCase, submission_output: str) -> SubmissionResu for val in self._actual_validators(): if val is not None and val.compile()[0]: feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self._problem.tmpdir) + validator_output = tempfile.mkdtemp(prefix='checker_out', dir=self._problem.tmpdir) + outfile = validator_output + "/out.txt" + errfile = validator_output + "/err.txt" status, runtime = val.run(submission_output, args=[testcase.infile, testcase.ansfile, feedbackdir] + flags, - timelim=val_timelim, memlim=val_memlim) + timelim=val_timelim, memlim=val_memlim, + outfile=outfile, errfile=errfile) + if self.log.isEnabledFor(logging.DEBUG): + try: + with open(outfile, mode="rt") as f: + output = f.read() + if output: + self.log.debug("Validator output:\n%s", output) + with open(errfile, mode="rt") as f: + error = f.read() + if error: + self.log.debug("Validator stderr:\n%s", error) + except IOError as e: + self.info("Failed to read validator output: %s", e) res = self._parse_validator_results(val, status, feedbackdir, testcase) shutil.rmtree(feedbackdir) + shutil.rmtree(validator_output) if res.verdict != 'AC': return res @@ -1609,6 +1650,7 @@ class Submissions(ProblemAspect): ] def __init__(self, problem: Problem): + super().__init__(f"{problem.shortname}.submission") self._submissions = {} self._problem = problem srcdir = os.path.join(problem.probdir, 'submissions') @@ -1742,6 +1784,7 @@ class Problem(ProblemAspect): def __init__(self, probdir: str): self.probdir = os.path.realpath(probdir) self.shortname: str|None = os.path.basename(self.probdir) + super().__init__(self.shortname) self.language_config = languages.load_language_config() def __enter__(self) -> Problem: From a2d8a5875363fe1250658e28554f70ff10a81d1f Mon Sep 17 00:00:00 2001 From: Tobias Meggendorfer Date: Mon, 12 Feb 2024 21:00:02 +0100 Subject: [PATCH 025/272] Guard better agains non-unicode feedback --- problemtools/tests/test_output_validator.py | 25 +++++++++++++++++++++ problemtools/verifyproblem.py | 14 ++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 problemtools/tests/test_output_validator.py diff --git a/problemtools/tests/test_output_validator.py b/problemtools/tests/test_output_validator.py new file mode 100644 index 00000000..afd6f2d4 --- /dev/null +++ b/problemtools/tests/test_output_validator.py @@ -0,0 +1,25 @@ +import random +import pathlib +import string +import tempfile + +from problemtools.verifyproblem import OutputValidators + + +def test_output_validator_feedback(): + r = random.Random(0) + with tempfile.TemporaryDirectory() as directory: + feedback = pathlib.Path(directory) / "feedback.txt" + text = "".join(r.choices(string.printable)) + feedback.write_text(text) + data = OutputValidators._get_feedback(directory) + assert text in data + + +def test_output_validator_feedback_non_unicode(): + r = random.Random(0) + with tempfile.TemporaryDirectory() as directory: + feedback = pathlib.Path(directory) / "feedback.txt" + feedback.write_bytes(r.randbytes(1024)) + # Just test that this does not throw an error + OutputValidators._get_feedback(directory) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 146007a1..824e8e84 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1186,7 +1186,7 @@ def __str__(self) -> str: _JUNK_CASES = [ ('an empty file', b''), - ('a binary file with byte values 0 up to 127', bytearray(x for x in range(127))), + ('a binary file with random bytes', bytearray(random.Random(0).randbytes(1024))), ('a text file with the ASCII characters 32 up to 127', bytearray(x for x in range(32, 127))), ('a random text file with printable ASCII characters', bytearray(random.choice(string.printable.encode('utf8')) for _ in range(200))), ] @@ -1479,15 +1479,15 @@ def check(self, args: argparse.Namespace) -> bool: return self._check_res @staticmethod - def __get_feedback(feedback_dir: str) -> str|None: + def _get_feedback(feedback_dir: str) -> str|None: all_feedback = [] for feedback_file in os.listdir(feedback_dir): feedback_path = os.path.join(feedback_dir, feedback_file) if os.path.getsize(feedback_path) == 0: continue all_feedback.append(f'=== {feedback_file}: ===') - # FIXME handle feedback files containing non-text - with open(feedback_path, 'r') as feedback: + # Note: The file could contain non-unicode characters, "replace" to be on the safe side + with open(feedback_path, 'r', errors="replace") as feedback: # Cap amount of feedback per file at some high-ish # size, so that a buggy validator spewing out lots of # data doesn't kill us. @@ -1508,15 +1508,15 @@ def _parse_validator_results(self, val, status: int, feedbackdir, testcase: Test if not os.WIFEXITED(status): return SubmissionResult('JE', reason=f'output validator {val} crashed, status {status}', - additional_info=OutputValidators.__get_feedback(feedbackdir)) + additional_info=OutputValidators._get_feedback(feedbackdir)) ret = os.WEXITSTATUS(status) if ret not in [42, 43]: return SubmissionResult('JE', reason=f'output validator {val} exited with status {ret}', - additional_info=OutputValidators.__get_feedback(feedbackdir)) + additional_info=OutputValidators._get_feedback(feedbackdir)) if ret == 43: - return SubmissionResult('WA', additional_info=OutputValidators.__get_feedback(feedbackdir)) + return SubmissionResult('WA', additional_info=OutputValidators._get_feedback(feedbackdir)) if custom_score: if os.path.isfile(score_file): From ad310643fb1d03325374e7ecd94afdb809bf6d69 Mon Sep 17 00:00:00 2001 From: Tobias Meggendorfer Date: Mon, 12 Feb 2024 21:38:46 +0100 Subject: [PATCH 026/272] Revert delayed import --- problemtools/problem2html.py | 13 ++++++------- problemtools/problem2pdf.py | 2 -- requirements.txt | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 28ead925..6d5c8dfc 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -7,15 +7,14 @@ import logging import subprocess +import plasTeX.TeX +import plasTeX.Logging -def convert(options: argparse.Namespace) -> None: - import plasTeX.TeX - import plasTeX.Logging - - from .ProblemPlasTeX import ProblemRenderer - from .ProblemPlasTeX import ProblemsetMacros - from . import template +from .ProblemPlasTeX import ProblemRenderer +from .ProblemPlasTeX import ProblemsetMacros +from . import template +def convert(options: argparse.Namespace) -> None: problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 3671bcfb..583e8aca 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -9,12 +9,10 @@ def convert(options: argparse.Namespace) -> bool: - problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - texfile = problem # Set up template if necessary with template.Template(problem, language=options.language) as templ: texfile = templ.get_file_name() diff --git a/requirements.txt b/requirements.txt index d6e1198b..ecf975e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ --e . +-e . \ No newline at end of file From 069287c84ccf128d9e41686dac9edaf92f7d80e0 Mon Sep 17 00:00:00 2001 From: Tobias Meggendorfer Date: Sun, 18 Feb 2024 14:54:35 +0100 Subject: [PATCH 027/272] Fix missed init, add test case --- problemtools/tests/test_verify_hello.py | 17 +++++++++++++++++ problemtools/verifyproblem.py | 5 +++++ 2 files changed, 22 insertions(+) create mode 100644 problemtools/tests/test_verify_hello.py diff --git a/problemtools/tests/test_verify_hello.py b/problemtools/tests/test_verify_hello.py new file mode 100644 index 00000000..0e115ae2 --- /dev/null +++ b/problemtools/tests/test_verify_hello.py @@ -0,0 +1,17 @@ +import pathlib +import problemtools.verifyproblem as verify + + +def test_load_hello(): + directory = pathlib.Path(__file__).parent.parent.parent / "examples" / "hello" + string = str(directory.resolve()) + + args = verify.argparser().parse_args([string]) + verify.initialize_logging(args) + + with verify.Problem(string) as p: + assert p.shortname == "hello" + # pytest and fork don't go along very well, so just run aspects that work without run + assert p.config.check(args) + assert p.attachments.check(args) + assert p.generators.check(args) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 824e8e84..ae951656 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -136,6 +136,10 @@ def info(self, msg: str, *args) -> None: def debug(self, msg: str, *args) -> None: self.log.debug(msg, *args) + def msg(self, msg): + # TODO Should this be silent? + print(msg) + def check_basename(self, path: str) -> None: basename = os.path.basename(path) if not self.basename_regex.match(basename): @@ -806,6 +810,7 @@ class Generators(ProblemAspect): _VISUALIZER_EXTENSIONS = ['png', 'jpg', 'jpeg', 'svg', 'interaction', 'desc', 'hint'] def __init__(self, problem: Problem): + super().__init__(f"{problem.shortname}.generators") self.debug(' Loading generators') self._problem = problem self.configfile = os.path.join(problem.probdir, 'generators', 'generators.yaml') From 1a3d454490935546255cc2e9c031e994a4b696d1 Mon Sep 17 00:00:00 2001 From: Maarten Sijm <9739541+mpsijm@users.noreply.github.com> Date: Thu, 22 Feb 2024 10:04:42 +0100 Subject: [PATCH 028/272] Explicitly set language versions for Java and Kotlin in languages.yaml --- problemtools/config/languages.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/problemtools/config/languages.yaml b/problemtools/config/languages.yaml index caa6dbbc..4f0f7296 100644 --- a/problemtools/config/languages.yaml +++ b/problemtools/config/languages.yaml @@ -147,7 +147,7 @@ java: name: 'Java' priority: 800 files: '*.java' - compile: '/usr/bin/javac -encoding UTF-8 -sourcepath {path} -d {path} {files}' + compile: '/usr/bin/javac -source 11 -encoding UTF-8 -sourcepath {path} -d {path} {files}' run: '/usr/bin/java -Dfile.encoding=UTF-8 -XX:+UseSerialGC -Xss64m -Xms{memlim}m -Xmx{memlim}m -cp {path} {mainclass}' javascript: @@ -161,7 +161,7 @@ kotlin: name: 'Kotlin' priority: 250 files: '*.kt' - compile: '/usr/bin/kotlinc -d {path}/ -- {files}' + compile: '/usr/bin/kotlinc -language-version 1.3 -d {path}/ -- {files}' run: '/usr/bin/kotlin -Dfile.encoding=UTF-8 -J-XX:+UseSerialGC -J-Xss64m -J-Xms{memlim}m -J-Xmx{memlim}m -cp {path}/ {Mainclass}Kt' lisp: From cc02a135a464f2c7d01aa30ffb22b2d499280227 Mon Sep 17 00:00:00 2001 From: Tobias Meggendorfer Date: Tue, 2 Apr 2024 11:07:44 +0200 Subject: [PATCH 029/272] Restore old format --- problemtools/problem2html.py | 11 ++++++----- problemtools/verifyproblem.py | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 6d5c8dfc..5fdb9bde 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -7,14 +7,15 @@ import logging import subprocess -import plasTeX.TeX -import plasTeX.Logging - -from .ProblemPlasTeX import ProblemRenderer -from .ProblemPlasTeX import ProblemsetMacros from . import template def convert(options: argparse.Namespace) -> None: + # PlasTeX.Logging statically overwrites logging and formatting, so delay loading + import plasTeX.TeX + import plasTeX.Logging + from .ProblemPlasTeX import ProblemRenderer + from .ProblemPlasTeX import ProblemsetMacros + problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index ae951656..65d39fbb 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1915,7 +1915,8 @@ def argparser() -> argparse.ArgumentParser: def initialize_logging(args: argparse.Namespace) -> None: ProblemAspect.max_additional_info = args.max_additional_info - fmt = "%(levelname)s %(message)s" + # fmt = "%(levelname)s %(message)s" + fmt = "%(message)s" logging.basicConfig(stream=sys.stdout, format=fmt, level=eval(f"logging.{args.log_level.upper()}")) From b25ae8081de6fccd488ee61dfb27d114267a6c98 Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Sat, 25 May 2024 10:22:31 +0200 Subject: [PATCH 030/272] Add github action to run pytest and flake8 This is mostly the default github action generated by their config, with the following minor changes: * Also test with one pypy version * Exclude examples/ in flake8 due to python2 examples --- .github/workflows/python-app.yml | 43 ++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 00000000..4a7d4bfe --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,43 @@ +# This workflow will install Python dependencies, run tests and lint +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python tests + +on: + push: + branches: [ "master", "develop" ] + pull_request: + branches: [ "master", "develop" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["pypy3.10", "3.10"] # Arbitrary pick of one pypy and one Cpython version + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + # Exclude examples as there is some python2 code there + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude examples/ + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --exclude examples/ + - name: Test with pytest + run: | + pytest From e33cf0bd636f5239d9cb6f2d1931f04bb620f000 Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Sat, 25 May 2024 10:32:32 +0200 Subject: [PATCH 031/272] Remove travis config, point badges to github badge --- .travis.yml | 5 ----- README.md | 4 ++-- 2 files changed, 2 insertions(+), 7 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f81f3fcd..00000000 --- a/.travis.yml +++ /dev/null @@ -1,5 +0,0 @@ -language: python -python: - - 3.7 - -script: py.test diff --git a/README.md b/README.md index 1bba9f93..601de517 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Kattis Problem Tools master: -[![Master Build Status](https://travis-ci.org/Kattis/problemtools.svg?branch=master)](https://travis-ci.org/Kattis/problemtools). +![Master Build Status](https://github.com/kattis/problemtools/actions/workflows/python-app.yml/badge.svg?branch=master) develop: -[![Develop Build Status](https://travis-ci.org/Kattis/problemtools.svg?branch=develop)](https://travis-ci.org/Kattis/problemtools) +![Develop Build Status](https://github.com/kattis/problemtools/actions/workflows/python-app.yml/badge.svg?branch=develop) These are tools to manage problem packages using the Kattis problem package format. From 8b9522f284317c8f1c55ce8f3ac3ab67d26d80f8 Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Fri, 24 May 2024 20:56:40 +0200 Subject: [PATCH 032/272] Check that all symlinks point to something existing within the problem package --- problemtools/verifyproblem.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 65d39fbb..6b03c285 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1851,6 +1851,8 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: if not re.match('^[a-z0-9]+$', self.shortname): self.error(f"Invalid shortname '{self.shortname}' (must be [a-z0-9]+)") + self._check_symlinks() + run.limit.check_limit_capabilities(self) for part in args.parts: @@ -1861,6 +1863,27 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: pass return ProblemAspect.errors, ProblemAspect.warnings + def _check_symlinks(self): + """Check that all symlinks point to something existing within the problem package""" + probdir = os.path.realpath(self.probdir) + for root, dirs, files in os.walk(probdir): + for file in dirs + files: + filename = os.path.join(root, file) + if os.path.islink(filename): + target = os.path.realpath(filename) + # We only use these relative paths to give a nice error message. + # relfile is the filename of the symlink, relative to the problem root + relfile = os.path.relpath(filename, self.probdir) + # reltarget is what the symlink points to (absolute, or relative to where the symlink is) + reltarget = os.readlink(filename) + if not os.path.exists(target): + self.error( + f"Symlink {relfile} links to {reltarget} which does not exist" + ) + if os.path.commonpath([probdir, target]) != probdir: + self.error( + f"Symlink {relfile} links to {reltarget} which is outside of problem package" + ) def re_argument(s: str) -> Pattern[str]: try: From 7e8b7e3d495bc40a99aaf54c6ea7fcdd1c14acf3 Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Sat, 25 May 2024 06:56:09 +0200 Subject: [PATCH 033/272] Forbid all absolute symlinks --- problemtools/verifyproblem.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 6b03c285..e87f556c 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1871,8 +1871,7 @@ def _check_symlinks(self): filename = os.path.join(root, file) if os.path.islink(filename): target = os.path.realpath(filename) - # We only use these relative paths to give a nice error message. - # relfile is the filename of the symlink, relative to the problem root + # relfile is the filename of the symlink, relative to the problem root (only used for nicer error messages) relfile = os.path.relpath(filename, self.probdir) # reltarget is what the symlink points to (absolute, or relative to where the symlink is) reltarget = os.readlink(filename) @@ -1884,6 +1883,10 @@ def _check_symlinks(self): self.error( f"Symlink {relfile} links to {reltarget} which is outside of problem package" ) + if os.path.isabs(reltarget): + self.error( + f"Symlink {relfile} links to {reltarget} which is an absolute path. Symlinks must be relative." + ) def re_argument(s: str) -> Pattern[str]: try: From fae2a4e12fc410856833298fae531a8a4cad41fa Mon Sep 17 00:00:00 2001 From: Simon Lindholm Date: Tue, 4 Jun 2024 22:26:34 +0200 Subject: [PATCH 034/272] Type annotation fixes --- problemtools/problem2html.py | 2 +- problemtools/problem2pdf.py | 2 +- problemtools/verifyproblem.py | 41 ++++++++++++++++++++--------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 5fdb9bde..6bf56192 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -110,7 +110,7 @@ def convert(options: argparse.Namespace) -> None: os.chdir(origcwd) -def get_parser() -> argparse.Namespace: +def get_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('-b', '--body-only', dest='bodyonly', action='store_true', help='only generate HTML body, no HTML headers', default=False) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 583e8aca..0f6fc452 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -43,7 +43,7 @@ def convert(options: argparse.Namespace) -> bool: return status == 0 -def get_parser() -> argparse.Namespace: +def get_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument('-o', '--output', dest='destfile', help="output file name", default='${problem}.pdf') diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index e87f556c..01732448 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -29,7 +29,7 @@ from . import languages from . import run -from typing import Callable, Literal, Pattern, Match +from typing import Any, Callable, Literal, Pattern, Match log = logging.getLogger(__name__) @@ -54,7 +54,7 @@ def __init__(self, verdict: str, score: float|None=None, reason: str|None=None, self.runtime_testcase: TestCase|None = None self.runtime = -1.0 self.ac_runtime = -1.0 - self.ac_runtime_testcase = None + self.ac_runtime_testcase: TestCase|None = None self.validator_first = False self.sample_failures: list[SubmissionResult] = [] @@ -422,7 +422,7 @@ def get_subgroups(self) -> list[TestCaseGroup]: return [child for child in self._items if isinstance(child, TestCaseGroup)] - def get_subgroup(self, name): + def get_subgroup(self, name: str) -> TestCaseGroup|None: return next((child for child in self._items if isinstance(child, TestCaseGroup) and os.path.basename(child._datadir) == name), None) @@ -602,7 +602,7 @@ def run_submission(self, sub, args: argparse.Namespace, timelim: int, timelim_lo def aggregate_results(self, sub, sub_results: list[SubmissionResult], shadow_result: bool=False) -> SubmissionResult: - res = SubmissionResult(None) + res = SubmissionResult('JE') for r in sub_results: if r.runtime > res.runtime: @@ -706,7 +706,7 @@ def __init__(self, problem: Problem): def __str__(self) -> str: return 'problem configuration' - def get(self, key: str|None=None): + def get(self, key: str|None=None) -> Any: if key: return self._data[key] return self._data @@ -815,6 +815,7 @@ def __init__(self, problem: Problem): self._problem = problem self.configfile = os.path.join(problem.probdir, 'generators', 'generators.yaml') self._data = None + self._generators: dict[str, str|list[str]|run.Program] = {} if os.path.isfile(self.configfile): try: @@ -1093,7 +1094,9 @@ def __init__(self, problem: Problem): if glob.glob(glob_path + 'tex'): self.languages.append('') for f in glob.glob(glob_path + '[a-z][a-z].tex'): - self.languages.append(re.search("problem.([a-z][a-z]).tex$", f).group(1)) + m = re.search("problem.([a-z][a-z]).tex$", f) + assert m + self.languages.append(m.group(1)) def check(self, args: argparse.Namespace) -> bool: if self._check_res is not None: @@ -1107,7 +1110,7 @@ def check(self, args: argparse.Namespace) -> bool: for lang in self.languages: try: - options = problem2pdf.get_parser().parse_args([None]) + options = problem2pdf.get_parser().parse_args([""]) options.problem = self._problem.probdir options.language = lang options.nopdf = True @@ -1118,7 +1121,7 @@ def check(self, args: argparse.Namespace) -> bool: except Exception as e: self.error(f'Error raised when checking problem statement for language {lang}:\n{e}\n{traceback.format_exc()}') try: - options = problem2html.get_parser().parse_args([None]) + options = problem2html.get_parser().parse_args([""]) options.problem = self._problem.probdir options.destdir = os.path.join(self._problem.tmpdir, 'html') options.language = lang @@ -1196,7 +1199,7 @@ def __str__(self) -> str: ('a random text file with printable ASCII characters', bytearray(random.choice(string.printable.encode('utf8')) for _ in range(200))), ] -def _build_junk_modifier(desc: str, pattern: str, repl: str|Callable[[Match], str]) -> tuple[str, Callable, Callable[[str], str]]: +def _build_junk_modifier(desc: str, pattern: str, repl: str|Callable[[Match[str]], str]) -> tuple[str, Callable, Callable[[str], str]]: p = re.compile(pattern) return (desc, p.search, lambda text: p.sub(repl, text)) @@ -1251,8 +1254,8 @@ def check(self, args: argparse.Namespace|None) -> bool: # Only sanity check input validators if they all actually compiled if self._check_res: - all_flags: set = set() - def collect_flags(group: TestCaseGroup, flags: set) -> None: + all_flags: set[str] = set() + def collect_flags(group: TestCaseGroup, flags: set[str]) -> None: if len(group.get_testcases()) > 0: flags.add(group.config['input_validator_flags']) for subgroup in group.get_subgroups(): @@ -1265,8 +1268,8 @@ def collect_flags(group: TestCaseGroup, flags: set) -> None: f = open(file_name, "wb") f.write(case) f.close() - for flags in all_flags: - flags = flags.split() + for flags_str in all_flags: + flags = flags_str.split() for val in self._validators: status, _ = val.run(file_name, args=flags) if os.WEXITSTATUS(status) != 42: @@ -1284,8 +1287,8 @@ def modified_input_validates(applicable, modifier): with open(file_name, "wb") as f: f.write(modifier(infile_data).encode('utf8')) - for flags in all_flags: - flags = flags.split() + for flags_str in all_flags: + flags = flags_str.split() for val in self._validators: status, _ = val.run(file_name, args=flags) if os.WEXITSTATUS(status) != 42: @@ -1405,7 +1408,8 @@ def grade(self, sub_results: list[SubmissionResult], testcasegroup: TestCaseGrou self.debug(f'Output was: "{grader_output}"') return ('JE', None) - verdict, score_str = grader_output.split() + verdict_str, score_str = grader_output.split() + verdict = verdict_str # type: ignore score = float(score_str) # TODO: check that all graders give same result @@ -1440,7 +1444,7 @@ def check(self, args: argparse.Namespace) -> bool: recommended_output_validator_languages = {'c', 'cpp', 'python3'} for v in self._validators: - if not isinstance(v, run.BuildRun) and v.language.lang_id not in recommended_output_validator_languages: + if isinstance(v, run.SourceCode) and v.language.lang_id not in recommended_output_validator_languages: self.warning('output validator language %s is not recommended' % v.language.name) if self._problem.config.get('validation') == 'default' and self._validators: @@ -1747,7 +1751,8 @@ def check(self, args: argparse.Namespace) -> bool: runtimes = [] for sub in self._submissions[acr]: - if args.submission_filter.search(os.path.join(verdict[1], sub.name)): + sub_name = sub.name # type: ignore + if args.submission_filter.search(os.path.join(verdict[1], sub_name)): self.info(f'Check {acr} submission {sub}') if sub.code_size() > 1024*limits['code']: From 6ab200bd8f11078f8929efa020cdf0b4a7a9536e Mon Sep 17 00:00:00 2001 From: Simon Lindholm Date: Tue, 4 Jun 2024 22:26:34 +0200 Subject: [PATCH 035/272] Add -j flag for multi-threaded validation --- problemtools/run/__init__.py | 4 +- problemtools/run/buildrun.py | 23 ++- problemtools/run/checktestdata.py | 14 +- problemtools/run/executable.py | 7 +- problemtools/run/program.py | 22 ++- problemtools/run/source.py | 23 +-- problemtools/run/viva.py | 12 +- problemtools/verifyproblem.py | 279 +++++++++++++++++++++++------- 8 files changed, 266 insertions(+), 118 deletions(-) diff --git a/problemtools/run/__init__.py b/problemtools/run/__init__.py index 6713e700..e9c7e746 100644 --- a/problemtools/run/__init__.py +++ b/problemtools/run/__init__.py @@ -16,7 +16,7 @@ def find_programs(path, pattern='.*', language_config=None, work_dir=None, - include_dir=None, allow_validation_script=False): + include_dir=None, allow_validation_script=False) -> list[Program]: """Find all programs in a directory. Args: @@ -62,7 +62,7 @@ def find_programs(path, pattern='.*', language_config=None, work_dir=None, def get_program(path, language_config=None, work_dir=None, include_dir=None, - allow_validation_script=False): + allow_validation_script=False) -> Program|None: """Get a Program object for a program Args: diff --git a/problemtools/run/buildrun.py b/problemtools/run/buildrun.py index a86777b6..870c5446 100644 --- a/problemtools/run/buildrun.py +++ b/problemtools/run/buildrun.py @@ -27,6 +27,8 @@ def __init__(self, path, work_dir=None, include_dir=None): work_dir (str): name of temp directory in which to run the scripts (if None, will make new temp directory). """ + super().__init__() + if not os.path.isdir(path): raise ProgramError('%s is not a directory' % path) @@ -53,32 +55,27 @@ def __init__(self, path, work_dir=None, include_dir=None): if not os.access(build, os.X_OK): raise ProgramError('%s/build is not executable' % path) - def __str__(self): + def __str__(self) -> str: """String representation""" return '%s/' % (self.path) - _compile_result = None - def compile(self): + def do_compile(self) -> tuple[bool, str|None]: """Run the build script.""" - if self._compile_result is not None: - return self._compile_result - with open(os.devnull, 'w') as devnull: status = subprocess.call(['./build'], stdout=devnull, stderr=devnull, cwd=self.path) run = os.path.join(self.path, 'run') if status: - log.debug('Build script failed (status %d) when compiling %s', status, self.name) - self._compile_result = (False, f'build script failed with exit code {status:d}') + logging.debug('Build script failed (status %d) when compiling %s\n', status, self.name) + return (False, 'build script failed with exit code %d' % (status)) elif not os.path.isfile(run) or not os.access(run, os.X_OK): - self._compile_result = (False, 'build script did not produce an executable called "run"') + return (False, 'build script did not produce an executable called "run"') else: - self._compile_result = (True, None) - return self._compile_result + return (True, None) - def get_runcmd(self, cwd=None, memlim=None): + def get_runcmd(self, cwd=None, memlim=None) -> list[str]: """Run command for the program. Args: @@ -89,6 +86,6 @@ def get_runcmd(self, cwd=None, memlim=None): return [os.path.join(path, 'run')] - def should_skip_memory_rlimit(self): + def should_skip_memory_rlimit(self) -> bool: """Ugly hack (see program.py for details).""" return True diff --git a/problemtools/run/checktestdata.py b/problemtools/run/checktestdata.py index 0fd4a4d7..28abe779 100644 --- a/problemtools/run/checktestdata.py +++ b/problemtools/run/checktestdata.py @@ -22,27 +22,23 @@ def __init__(self, path): if Checktestdata._CTD_PATH is None: raise ProgramError( 'Could not locate the Checktestdata program to run %s' % path) - super(Checktestdata, self).__init__(Checktestdata._CTD_PATH, - args=[path]) + super().__init__(Checktestdata._CTD_PATH, args=[path]) - def __str__(self): + def __str__(self) -> str: """String representation""" return '%s' % (self.args[0]) - _compile_result = None - def compile(self): + def do_compile(self) -> tuple[bool, str|None]: """Syntax-check the Checktestdata script Returns: (False, None) if the Checktestdata script has syntax errors and (True, None) otherwise """ - if self._compile_result is None: - (status, _) = super(Checktestdata, self).run() - self._compile_result = ((os.WIFEXITED(status) and os.WEXITSTATUS(status) in [0, 1]), None) - return self._compile_result + (status, _) = super().run() + return ((os.WIFEXITED(status) and os.WEXITSTATUS(status) in [0, 1]), None) def run(self, infile='/dev/null', outfile='/dev/null', diff --git a/problemtools/run/executable.py b/problemtools/run/executable.py index b2538e22..1d9953d3 100644 --- a/problemtools/run/executable.py +++ b/problemtools/run/executable.py @@ -17,6 +17,8 @@ def __init__(self, path, args=None): args: list of additional command line arguments that should be passed to the program every time it is executed. """ + super().__init__() + if not os.path.isfile(path) or not os.access(path, os.X_OK): raise ProgramError('%s is not an executable program' % path) self.path = path @@ -26,11 +28,6 @@ def __str__(self): """String representation""" return '%s' % (self.path) - def compile(self): - """Dummy implementation of the compile method -- nothing to check! - """ - return (True, None) - def get_runcmd(self, cwd=None, memlim=None): """Command to run the program. """ diff --git a/problemtools/run/program.py b/problemtools/run/program.py index 3cdad782..895e1f35 100644 --- a/problemtools/run/program.py +++ b/problemtools/run/program.py @@ -5,6 +5,7 @@ import resource import signal import logging +import threading from .errors import ProgramError @@ -14,7 +15,11 @@ class Program(object): """Abstract base class for programs. """ - runtime = 0 + + def __init__(self) -> None: + self.runtime = 0 + self._compile_lock = threading.Lock() + self._compile_result: tuple[bool, str|None]|None = None def run(self, infile='/dev/null', outfile='/dev/null', errfile='/dev/null', args=None, timelim=1000, memlim=1024, set_work_dir=False): @@ -50,12 +55,23 @@ def run(self, infile='/dev/null', outfile='/dev/null', errfile='/dev/null', return status, runtime - def code_size(self): + def compile(self) -> tuple[bool, str|None]: + with self._compile_lock: + if self._compile_result is None: + self._compile_result = self.do_compile() + return self._compile_result + + def do_compile(self) -> tuple[bool, str|None]: + """Actually compile the program, if needed. Subclasses should override this method. + Do not call this manually -- use compile() instead.""" + return (True, None) + + def code_size(self) -> int: """Subclasses should override this method with the total size of the source code.""" return 0 - def should_skip_memory_rlimit(self): + def should_skip_memory_rlimit(self) -> bool: """Ugly workaround to accommodate Java -- the JVM will crash and burn if there is a memory rlimit applied and this will probably not change anytime soon [time of writing this: 2017-02-05], see diff --git a/problemtools/run/source.py b/problemtools/run/source.py index 3fa5b8b6..60d3ee4f 100644 --- a/problemtools/run/source.py +++ b/problemtools/run/source.py @@ -39,6 +39,7 @@ def __init__(self, path, language, work_dir=None, include_dir=None): then the files in include_dir// will be copied into the work_dir along with the source file(s). """ + super().__init__() if path[-1] == '/': path = path[:-1] @@ -80,24 +81,18 @@ def __init__(self, path, language, work_dir=None, include_dir=None): self.binary = os.path.join(self.path, 'run') - def code_size(self): + def code_size(self) -> int: return sum(os.path.getsize(x) for x in self.src) - _compile_result = None - - def compile(self): + def do_compile(self) -> tuple[bool, str|None]: """Compile the source code. Returns tuple: (True, None) if compilation succeeded (False, errmsg) otherwise """ - if self._compile_result is not None: - return self._compile_result - if self.language.compile is None: - self._compile_result = (True, None) return (True, None) command = self.get_compilecmd() @@ -110,14 +105,12 @@ def compile(self): try: subprocess.check_output(command, stderr=subprocess.STDOUT) - self._compile_result = (True, None) + return (True, None) except subprocess.CalledProcessError as err: - self._compile_result = (False, err.output.decode('utf8', 'replace')) - - return self._compile_result + return (False, err.output.decode('utf8', 'replace')) - def get_compilecmd(self): + def get_compilecmd(self) -> list[str]: return shlex.split(self.language.compile.format(**self.__get_substitution())) @@ -140,12 +133,12 @@ def get_runcmd(self, cwd=None, memlim=1024): return shlex.split(self.language.run.format(**subs)) - def should_skip_memory_rlimit(self): + def should_skip_memory_rlimit(self) -> bool: """Ugly hack (see program.py for details).""" return self.language.name in ['Java', 'Scala', 'Kotlin', 'Common Lisp'] - def __str__(self): + def __str__(self) -> str: """String representation""" return '%s (%s)' % (self.name, self.language.name) diff --git a/problemtools/run/viva.py b/problemtools/run/viva.py index b6129169..cddc0f3b 100644 --- a/problemtools/run/viva.py +++ b/problemtools/run/viva.py @@ -22,8 +22,7 @@ def __init__(self, path): if Viva._VIVA_PATH is None: raise ProgramError( 'Could not locate the VIVA program to run %s' % path) - super(Viva, self).__init__(Viva._VIVA_PATH, - args=[path]) + super().__init__(Viva._VIVA_PATH, args=[path]) def __str__(self): @@ -31,17 +30,14 @@ def __str__(self): return '%s' % (self.args[0]) - _compile_result = None - def compile(self): + def do_compile(self) -> tuple[bool, str|None]: """Syntax-check the VIVA script Returns: (False, None) if the VIVA script has syntax errors and (True, None) otherwise """ - if self._compile_result is None: - (status, _) = super(Viva, self).run() - self._compile_result = ((os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0), None) - return self._compile_result + (status, _) = super().run() + return ((os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0), None) def run(self, infile='/dev/null', outfile='/dev/null', diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 01732448..246256b1 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -2,6 +2,10 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import concurrent.futures +from concurrent.futures import ThreadPoolExecutor +import threading +import queue import glob import string import hashlib @@ -87,6 +91,15 @@ class VerifyError(Exception): pass +class Context: + def __init__(self, args: argparse.Namespace, executor: ThreadPoolExecutor|None) -> None: + self.data_filter: Pattern[str] = args.data_filter + self.submission_filter: Pattern[str] = args.submission_filter + self.fixed_timelim: int|None = args.fixed_timelim + self.compile_generators: bool = ('compile_generators' not in args or args.compile_generators) + self.executor = executor + + class ProblemAspect: max_additional_info = 15 errors = 0 @@ -95,7 +108,7 @@ class ProblemAspect: _check_res: bool|None = None consider_warnings_errors = False basename_regex = re.compile('^[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9]$') - consider_warnings_errors: bool + name: str @staticmethod def __append_additional_info(msg: str, additional_info: str|None) -> str: @@ -113,7 +126,7 @@ def __append_additional_info(msg: str, additional_info: str|None) -> str: return f'{msg}:\n' + '\n'.join(' '*8 + line for line in lines) - def __init__(self, name): + def __init__(self, name: str) -> None: self.log = log.getChild(name) def error(self, msg: str, additional_info: str|None=None, *args) -> None: @@ -145,8 +158,13 @@ def check_basename(self, path: str) -> None: if not self.basename_regex.match(basename): self.error(f"Invalid name '{basename}' (should match '{self.basename_regex.pattern}')") + def start_background_work(self, context: Context) -> None: + pass + class TestCase(ProblemAspect): - def __init__(self, problem: Problem, base: str, testcasegroup: TestCaseGroup): + Result = tuple[SubmissionResult, SubmissionResult, SubmissionResult] + + def __init__(self, problem: Problem, base: str, testcasegroup: TestCaseGroup) -> None: super().__init__(f"{problem.shortname}.test.{testcasegroup.name}.{os.path.basename(base)}") self._base = base self.infile = f'{base}.in' @@ -154,7 +172,7 @@ def __init__(self, problem: Problem, base: str, testcasegroup: TestCaseGroup): self._problem = problem self.testcasegroup = testcasegroup self.reuse_result_from: TestCase|None = None - self._result_cache: tuple[tuple, tuple[SubmissionResult, SubmissionResult, SubmissionResult]]|tuple[None, None] = (None, None) + self.counter = len(problem.testcase_by_infile) problem.testcase_by_infile[self.infile] = self def check_newlines(self, filename: str) -> None: @@ -176,7 +194,7 @@ def strip_path_prefix(self, path: str) -> str: def is_in_sample_group(self) -> bool: return self.strip_path_prefix(self.infile).startswith('sample') - def check(self, args: argparse.Namespace) -> bool: + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -184,7 +202,7 @@ def check(self, args: argparse.Namespace) -> bool: self.check_basename(self.ansfile) self.check_newlines(self.infile) self.check_newlines(self.ansfile) - self._problem.input_format_validators.validate(self) + self._problem.input_validators.validate(self) anssize = os.path.getsize(self.ansfile) / 1024.0 / 1024.0 outputlim = self._problem.config.get('limits')['output'] if anssize > outputlim: @@ -234,8 +252,8 @@ def _check_symlinks(self) -> bool: return False return True - def run_submission(self, sub, args: argparse.Namespace, timelim: int, timelim_low: int, timelim_high: int) -> tuple[SubmissionResult, SubmissionResult, SubmissionResult]: - res, res_low, res_high, reused = self._run_submission_real(sub, args, timelim, timelim_low, timelim_high) + def run_submission(self, sub, runner: Runner, context: Context) -> Result: + (res, res_low, res_high), reused = runner.run(self) res = self._init_result_for_testcase(res) res_low = self._init_result_for_testcase(res_low) res_high = self._init_result_for_testcase(res_high) @@ -246,26 +264,13 @@ def run_submission(self, sub, args: argparse.Namespace, timelim: int, timelim_lo return (res, res_low, res_high) - def _run_submission_real(self, sub, args: argparse.Namespace, timelim: int, timelim_low: int, timelim_high: int) -> tuple[SubmissionResult, SubmissionResult, SubmissionResult, bool]: - if self.reuse_result_from is not None: - return self.reuse_result_from._run_submission_real(sub, args, timelim, timelim_low, timelim_high) - - cache_key = (sub, args, timelim, timelim_low, timelim_high) - if self._result_cache[0] == cache_key: - res, res_low, res_high = self._result_cache[1] - return (res, res_low, res_high, True) - - outfile = os.path.join(self._problem.tmpdir, 'output') - errfile = os.path.join(self._problem.tmpdir, 'error') - - if sys.stdout.isatty(): - msg = f'Running {sub} on {self}...' - sys.stdout.write(msg) - sys.stdout.flush() - + def run_submission_real(self, sub, context: Context, timelim: int, timelim_low: int, timelim_high: int) -> Result: + # This may be called off-main thread. if self._problem.is_interactive: res_high = self._problem.output_validators.validate_interactive(self, sub, timelim_high, self._problem.submissions) else: + outfile = os.path.join(self._problem.tmpdir, f'output-{self.counter}') + errfile = os.path.join(self._problem.tmpdir, f'error-{self.counter}') status, runtime = sub.run(infile=self.infile, outfile=outfile, errfile=errfile, timelim=timelim_high+1, memlim=self._problem.config.get('limits')['memory'], set_work_dir=True) @@ -283,8 +288,6 @@ def _run_submission_real(self, sub, args: argparse.Namespace, timelim: int, time res_high = self._problem.output_validators.validate(self, outfile) res_high.runtime = runtime - if sys.stdout.isatty(): - sys.stdout.write('\b \b' * (len(msg))) if res_high.runtime <= timelim_low: res_low = res_high res = res_high @@ -306,8 +309,7 @@ def _run_submission_real(self, sub, args: argparse.Namespace, timelim: int, time res.set_ac_runtime() res_low.set_ac_runtime() res_high.set_ac_runtime() - self._result_cache = (cache_key, (res, res_low, res_high)) - return (res, res_low, res_high, False) + return (res, res_low, res_high) def _init_result_for_testcase(self, res: SubmissionResult) -> SubmissionResult: res = copy.copy(res) @@ -343,7 +345,7 @@ def __init__(self, problem: Problem, datadir: str, parent: TestCaseGroup|None=No self._seen_oob_scores = False self.debug('Loading test data group %s', datadir) configfile = os.path.join(self._datadir, 'testdata.yaml') - self.config = {} + self.config: dict[str, Any] = {} if os.path.isfile(configfile): try: with open(configfile) as f: @@ -439,7 +441,7 @@ def get_score_range(self) -> tuple[float, float]: return (float('-inf'), float('inf')) - def check(self, args: argparse.Namespace) -> bool: + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -568,23 +570,23 @@ def parse_num(s: str, i: int) -> tuple[int, int]: last_testgroup_name = name for child in self._items: - if child.matches_filter(args.data_filter): - child.check(args) + if child.matches_filter(context.data_filter): + child.check(context) return self._check_res - - def run_submission(self, sub, args: argparse.Namespace, timelim: int, timelim_low: int, timelim_high: int) -> tuple[SubmissionResult, SubmissionResult, SubmissionResult]: + def run_submission(self, sub, runner: Runner, context: Context) -> TestCase.Result: self.info(f'Running on {self}') subres: list[SubmissionResult] = [] subres_low: list[SubmissionResult] = [] subres_high: list[SubmissionResult] = [] active_low, active = True, True on_reject = self.config['on_reject'] + broken = False for child in self._items: - if not child.matches_filter(args.data_filter): + if not child.matches_filter(context.data_filter): continue - res, res_low, res_high = child.run_submission(sub, args, timelim, timelim_low, timelim_high) + res, res_low, res_high = child.run_submission(sub, runner, context) subres_high.append(res_high) if active: subres.append(res) @@ -594,8 +596,11 @@ def run_submission(self, sub, args: argparse.Namespace, timelim: int, timelim_lo active_low &= res_low.verdict == 'AC' active &= res.verdict == 'AC' if res_high.verdict != 'AC': + broken = True break + runner.mark_group_done(self, broken) + return (self.aggregate_results(sub, subres), self.aggregate_results(sub, subres_low, shadow_result=True), self.aggregate_results(sub, subres_high, shadow_result=True)) @@ -711,7 +716,7 @@ def get(self, key: str|None=None) -> Any: return self._data[key] return self._data - def check(self, args: argparse.Namespace) -> bool: + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1029,7 +1034,7 @@ def _compile_generators(self) -> None: if not ok and gen in self._generators: del self._generators[gen] - def check(self, args: argparse.Namespace) -> bool: + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1078,7 +1083,7 @@ def check(self, args: argparse.Namespace) -> bool: self._parse_element(self._data, default_state) - if 'compile_generators' not in args or args.compile_generators: + if context.compile_generators: self._compile_generators() return self._check_res @@ -1098,7 +1103,7 @@ def __init__(self, problem: Problem): assert m self.languages.append(m.group(1)) - def check(self, args: argparse.Namespace) -> bool: + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1172,7 +1177,7 @@ def __init__(self, problem: Problem): self.debug(f'Adding attachments {str(self.attachments)}') - def check(self, args: argparse.Namespace) -> bool: + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1211,7 +1216,7 @@ def _build_junk_modifier(desc: str, pattern: str, repl: str|Callable[[Match[str] ('random junk added to the end of the file', lambda f: True, lambda f: f + ''.join(random.choice(string.printable) for _ in range(200))), ] -class InputFormatValidators(ProblemAspect): +class InputValidators(ProblemAspect): def __init__(self, problem: Problem): super().__init__(f"{problem.shortname}.input_validator") @@ -1234,7 +1239,13 @@ def __str__(self) -> str: return 'input format validators' - def check(self, args: argparse.Namespace|None) -> bool: + def start_background_work(self, context: Context) -> None: + if context.executor: + for val in self._validators: + context.executor.submit(lambda v: v.compile(), val) + + + def check(self, context: Context|None) -> bool: if self._check_res is not None: return self._check_res if self._uses_old_path: @@ -1313,7 +1324,10 @@ def modified_input_validates(applicable, modifier): def validate(self, testcase: TestCase) -> None: flags = testcase.testcasegroup.config['input_validator_flags'].split() + + # Remove input validators that don't compile, even without -p validators self.check(None) + for val in self._validators: with tempfile.NamedTemporaryFile() as outfile, tempfile.NamedTemporaryFile() as errfile: status, _ = val.run(testcase.infile, outfile.name, errfile.name, args=flags) @@ -1343,7 +1357,7 @@ def __init__(self, problem: Problem): def __str__(self) -> str: return 'graders' - def check(self, args: argparse.Namespace) -> bool: + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1430,13 +1444,21 @@ def __init__(self, problem: Problem): 'output_validators'), language_config=problem.language_config, work_dir=problem.tmpdir) + self._has_precompiled = False def __str__(self) -> str: return 'output validators' - def check(self, args: argparse.Namespace) -> bool: + def start_background_work(self, context: Context) -> None: + if context.executor and not self._has_precompiled: + for val in self._actual_validators(): + context.executor.submit(lambda v: v.compile(), val) + self._has_precompiled = True + + + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1544,10 +1566,11 @@ def _actual_validators(self) -> list: vals = self._validators if self._problem.config.get('validation') == 'default': vals = [self._default_validator] - return vals + return [val for val in vals if val is not None] def validate_interactive(self, testcase: TestCase, submission, timelim: int, errorhandler: Submissions) -> SubmissionResult: + # This may be called off-main thread. interactive_output_re = r'\d+ \d+\.\d+ \d+ \d+\.\d+ (validator|submission)' res = SubmissionResult('JE') interactive = run.get_tool('interactive') @@ -1562,7 +1585,7 @@ def validate_interactive(self, testcase: TestCase, submission, timelim: int, err val_timelim = self._problem.config.get('limits')['validation_time'] val_memlim = self._problem.config.get('limits')['validation_memory'] for val in self._actual_validators(): - if val is not None and val.compile()[0]: + if val.compile()[0]: feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self._problem.tmpdir) validator_args[2] = feedbackdir + os.sep f = tempfile.NamedTemporaryFile(delete=False) @@ -1616,7 +1639,7 @@ def validate(self, testcase: TestCase, submission_output: str) -> SubmissionResu val_memlim = self._problem.config.get('limits')['validation_memory'] flags = self._problem.config.get('validator_flags').split() + testcase.testcasegroup.config['output_validator_flags'].split() for val in self._actual_validators(): - if val is not None and val.compile()[0]: + if val.compile()[0]: feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self._problem.tmpdir) validator_output = tempfile.mkdtemp(prefix='checker_out', dir=self._problem.tmpdir) outfile = validator_output + "/out.txt" @@ -1647,6 +1670,114 @@ def validate(self, testcase: TestCase, submission_output: str) -> SubmissionResu return res +class Runner: + def __init__(self, problem: Problem, sub, context: Context, timelim: int, timelim_low: int, timelim_high: int) -> None: + self._problem = problem + self._sub = sub + self._context = context + self._executor = context.executor + self._timelim = timelim + self._timelim_low = timelim_low + self._timelim_high = timelim_high + self._cache: dict[TestCase, TestCase.Result] = {} + if self._executor: + self._queues: dict[TestCase, queue.Queue[TestCase.Result]] = {} + self._lock = threading.Lock() + self._started_jobs: set[TestCase] = set() + self._done_groups: set[TestCaseGroup] = set() + self._remaining_jobs: list[TestCase] = [] + self._recompute_jobs() + + def __enter__(self) -> Runner: + if self._executor: + for i in range(len(self._remaining_jobs)): + future = self._executor.submit(self._work) + self._problem.background_jobs.append(future) + return self + + def __exit__(self, *exc) -> None: + if self._executor: + with self._lock: + self._remaining_jobs = [] + + def run(self, testcase: TestCase) -> tuple[TestCase.Result, bool]: + while testcase.reuse_result_from: + testcase = testcase.reuse_result_from + + if testcase in self._cache: + return (self._cache[testcase], True) + + if sys.stdout.isatty(): + msg = f'Running {self._sub} on {testcase}...' + sys.stdout.write(msg) + sys.stdout.flush() + + if self._executor: + result = self._queues[testcase].get() + else: + result = self._run_submission_real(testcase) + + if sys.stdout.isatty(): + sys.stdout.write('\b \b' * len(msg)) + + self._cache[testcase] = result + return (result, False) + + def mark_group_done(self, group: TestCaseGroup, broken: bool) -> None: + if self._executor: + self._done_groups.add(group) + if broken: + # Since a group was broken out of, some test cases may no + # longer be relevant to run. Recompute the work list. + self._recompute_jobs() + + def _run_submission_real(self, item: TestCase) -> TestCase.Result: + return item.run_submission_real(self._sub, self._context, self._timelim, self._timelim_low, self._timelim_high) + + def _work(self) -> None: + item = self._next_job() + if item: + res = self._run_submission_real(item) + self._queues[item].put(res) + + def _gather_testcases(self, item: TestCase|TestCaseGroup) -> list[TestCase]: + if not item.matches_filter(self._context.data_filter): + return [] + if isinstance(item, TestCase): + if item.reuse_result_from: + return self._gather_testcases(item.reuse_result_from) + else: + return [item] + elif item not in self._done_groups: + ret = [] + for child in item.get_testcases() + item.get_subgroups(): + ret.extend(self._gather_testcases(child)) + return ret + else: + return [] + + def _next_job(self) -> TestCase|None: + with self._lock: + if self._remaining_jobs: + job = self._remaining_jobs.pop() + self._started_jobs.add(job) + return job + else: + return None + + def _recompute_jobs(self) -> None: + with self._lock: + seen = set(self._started_jobs) + self._remaining_jobs = [] + for testcase in self._gather_testcases(self._problem.testdata): + if testcase not in seen: + seen.add(testcase) + self._remaining_jobs.append(testcase) + if testcase not in self._queues: + self._queues[testcase] = queue.Queue(maxsize=1) + self._remaining_jobs.reverse() + + class Submissions(ProblemAspect): _SUB_REGEXP = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9](\.c\+\+)?$') # (verdict, directory, required) @@ -1675,7 +1806,7 @@ def __init__(self, problem: Problem): def __str__(self) -> str: return 'submissions' - def check_submission(self, sub, args: argparse.Namespace, expected_verdict: Verdict, timelim: int, timelim_low: int, timelim_high: int) -> SubmissionResult: + def check_submission(self, sub, context: Context, expected_verdict: Verdict, timelim: int, timelim_low: int, timelim_high: int) -> SubmissionResult: desc = f'{expected_verdict} submission {sub}' partial = False if expected_verdict == 'PAC': @@ -1686,7 +1817,8 @@ def check_submission(self, sub, args: argparse.Namespace, expected_verdict: Verd else: timelim_low = timelim - result, result_low, result_high = self._problem.testdata.run_submission(sub, args, timelim, timelim_low, timelim_high) + with Runner(self._problem, sub, context, timelim, timelim_low, timelim_high) as runner: + result, result_low, result_high = self._problem.testdata.run_submission(sub, runner, context) if result.verdict == 'AC' and expected_verdict == 'AC' and not partial and result.sample_failures: res = result.sample_failures[0] @@ -1724,7 +1856,7 @@ def fully_accepted(self, result: SubmissionResult) -> bool: best_score = min_score if self._problem.config.get('grading')['objective'] == 'min' else max_score return result.verdict == 'AC' and (not self._problem.is_scoring or result.score == best_score) - def check(self, args: argparse.Namespace) -> bool: + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res self._check_res = True @@ -1739,10 +1871,18 @@ def check(self, args: argparse.Namespace) -> bool: if 'time_for_AC_submissions' in limits: timelim = timelim_margin = limits['time_for_AC_submissions'] - if args.fixed_timelim is not None: - timelim = args.fixed_timelim + if context.fixed_timelim is not None: + timelim = context.fixed_timelim timelim_margin = int(round(timelim * safety_margin)) + if context.executor: + # Send off an early background compile job for each submission and + # validator, to avoid a bottleneck step at the start of each test run. + self._problem.output_validators.start_background_work(context) + for acr in self._submissions: + for sub in self._submissions[acr]: + context.executor.submit(lambda s: s.compile(), sub) + for verdict in Submissions._VERDICTS: acr = verdict[0] if verdict[2] and not self._submissions[acr]: @@ -1752,7 +1892,7 @@ def check(self, args: argparse.Namespace) -> bool: for sub in self._submissions[acr]: sub_name = sub.name # type: ignore - if args.submission_filter.search(os.path.join(verdict[1], sub_name)): + if context.submission_filter.search(os.path.join(verdict[1], sub_name)): self.info(f'Check {acr} submission {sub}') if sub.code_size() > 1024*limits['code']: @@ -1764,7 +1904,7 @@ def check(self, args: argparse.Namespace) -> bool: self.error(f'Compile error for {acr} submission {sub}', additional_info=msg) continue - res = self.check_submission(sub, args, acr, timelim, timelim_margin_lo, timelim_margin) + res = self.check_submission(sub, context, acr, timelim, timelim_margin_lo, timelim_margin) runtimes.append(res.runtime) if acr == 'AC': @@ -1778,9 +1918,9 @@ def check(self, args: argparse.Namespace) -> bool: int(0.5 + exact_timelim * safety_margin)) else: max_runtime_str = None - if args.fixed_timelim is not None and args.fixed_timelim != timelim: - self.msg(f" Solutions give timelim of {timelim} seconds, but will use provided fixed limit of {args.fixed_timelim} seconds instead") - timelim = args.fixed_timelim + if context.fixed_timelim is not None and context.fixed_timelim != timelim: + self.msg(f" Solutions give timelim of {timelim} seconds, but will use provided fixed limit of {context.fixed_timelim} seconds instead") + timelim = context.fixed_timelim timelim_margin = timelim * safety_margin self.msg(f" Slowest AC runtime: {max_runtime_str}, setting timelim to {timelim} secs, safety margin to {timelim_margin} secs") @@ -1818,16 +1958,20 @@ def __enter__(self) -> Problem: self.is_interactive = 'interactive' in self.config.get('validation-params') self.is_scoring = (self.config.get('type') == 'scoring') - self.input_format_validators = InputFormatValidators(self) + self.input_validators = InputValidators(self) self.output_validators = OutputValidators(self) self.graders = Graders(self) self.testcase_by_infile: dict[str, TestCase] = {} self.testdata = TestCaseGroup(self, os.path.join(self.probdir, 'data')) self.submissions = Submissions(self) self.generators = Generators(self) + self.background_jobs: list[concurrent.futures.Future[None]] = [] return self def __exit__(self, exc_type, exc_value, exc_traceback) -> None: + # Wait for discarded speculative submission runs to finish before + # performing an rmtree on the directory tree they use. + concurrent.futures.wait(self.background_jobs) shutil.rmtree(self.tmpdir) def __str__(self) -> str: @@ -1842,11 +1986,14 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: ProblemAspect.bail_on_error = args.bail_on_error ProblemAspect.consider_warnings_errors = args.werror + executor = ThreadPoolExecutor(args.threads) if args.threads > 1 else None + context = Context(args, executor) + try: part_mapping: dict[str, list] = { 'config': [self.config], 'statement': [self.statement, self.attachments], - 'validators': [self.input_format_validators, self.output_validators], + 'validators': [self.input_validators, self.output_validators], 'graders': [self.graders], 'generators': [self.generators], 'data': [self.testdata], @@ -1860,10 +2007,14 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: run.limit.check_limit_capabilities(self) + for part in args.parts: + for item in part_mapping[part]: + item.start_background_work(context) + for part in args.parts: self.msg(f'Checking {part}') for item in part_mapping[part]: - item.check(args) + item.check(context) except VerifyError: pass return ProblemAspect.errors, ProblemAspect.warnings @@ -1936,6 +2087,8 @@ def argparser() -> argparse.ArgumentParser: parser.add_argument('-p', '--parts', metavar='PROBLEM_PART', type=part_argument, nargs='+', default=PROBLEM_PARTS, help=f'only test the indicated parts of the problem. Each PROBLEM_PART can be one of {PROBLEM_PARTS}.') + parser.add_argument('-j', '--threads', type=int, default=1, + help='run validation using multiple threads. This will make timings less reliable, but can be convenient during development') argparser_basic_arguments(parser) @@ -1950,7 +2103,7 @@ def initialize_logging(args: argparse.Namespace) -> None: fmt = "%(message)s" logging.basicConfig(stream=sys.stdout, format=fmt, - level=eval(f"logging.{args.log_level.upper()}")) + level=getattr(logging, args.log_level.upper())) def main() -> None: From fc70d52f4730fde96ecbe839f5ea6619d2452c68 Mon Sep 17 00:00:00 2001 From: Simon Lindholm Date: Fri, 7 Jun 2024 00:25:12 +0200 Subject: [PATCH 036/272] Compile submissions early, improve cleanup --- problemtools/verifyproblem.py | 68 ++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 246256b1..37f7d009 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -33,7 +33,7 @@ from . import languages from . import run -from typing import Any, Callable, Literal, Pattern, Match +from typing import Any, Callable, Literal, Pattern, Match, ParamSpec, TypeVar log = logging.getLogger(__name__) @@ -91,6 +91,9 @@ class VerifyError(Exception): pass +_T = TypeVar("_T") +_P = ParamSpec("_P") + class Context: def __init__(self, args: argparse.Namespace, executor: ThreadPoolExecutor|None) -> None: self.data_filter: Pattern[str] = args.data_filter @@ -98,6 +101,14 @@ def __init__(self, args: argparse.Namespace, executor: ThreadPoolExecutor|None) self.fixed_timelim: int|None = args.fixed_timelim self.compile_generators: bool = ('compile_generators' not in args or args.compile_generators) self.executor = executor + self._background_work: list[concurrent.futures.Future[object]] = [] + + def submit_background_work(self, job: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs) -> None: + assert self.executor + self._background_work.append(self.executor.submit(job, *args, **kwargs)) + + def wait_for_background_work(self) -> None: + concurrent.futures.wait(self._background_work) class ProblemAspect: @@ -1240,9 +1251,8 @@ def __str__(self) -> str: def start_background_work(self, context: Context) -> None: - if context.executor: - for val in self._validators: - context.executor.submit(lambda v: v.compile(), val) + for val in self._validators: + context.submit_background_work(lambda v: v.compile(), val) def check(self, context: Context|None) -> bool: @@ -1452,9 +1462,9 @@ def __str__(self) -> str: def start_background_work(self, context: Context) -> None: - if context.executor and not self._has_precompiled: + if not self._has_precompiled: for val in self._actual_validators(): - context.executor.submit(lambda v: v.compile(), val) + context.submit_background_work(lambda v: v.compile(), val) self._has_precompiled = True @@ -1675,12 +1685,12 @@ def __init__(self, problem: Problem, sub, context: Context, timelim: int, timeli self._problem = problem self._sub = sub self._context = context - self._executor = context.executor + self._multithreaded = (context.executor is not None) self._timelim = timelim self._timelim_low = timelim_low self._timelim_high = timelim_high self._cache: dict[TestCase, TestCase.Result] = {} - if self._executor: + if self._multithreaded: self._queues: dict[TestCase, queue.Queue[TestCase.Result]] = {} self._lock = threading.Lock() self._started_jobs: set[TestCase] = set() @@ -1689,14 +1699,13 @@ def __init__(self, problem: Problem, sub, context: Context, timelim: int, timeli self._recompute_jobs() def __enter__(self) -> Runner: - if self._executor: + if self._multithreaded: for i in range(len(self._remaining_jobs)): - future = self._executor.submit(self._work) - self._problem.background_jobs.append(future) + self._context.submit_background_work(self._work) return self def __exit__(self, *exc) -> None: - if self._executor: + if self._multithreaded: with self._lock: self._remaining_jobs = [] @@ -1712,7 +1721,7 @@ def run(self, testcase: TestCase) -> tuple[TestCase.Result, bool]: sys.stdout.write(msg) sys.stdout.flush() - if self._executor: + if self._multithreaded: result = self._queues[testcase].get() else: result = self._run_submission_real(testcase) @@ -1724,7 +1733,7 @@ def run(self, testcase: TestCase) -> tuple[TestCase.Result, bool]: return (result, False) def mark_group_done(self, group: TestCaseGroup, broken: bool) -> None: - if self._executor: + if self._multithreaded: self._done_groups.add(group) if broken: # Since a group was broken out of, some test cases may no @@ -1856,6 +1865,14 @@ def fully_accepted(self, result: SubmissionResult) -> bool: best_score = min_score if self._problem.config.get('grading')['objective'] == 'min' else max_score return result.verdict == 'AC' and (not self._problem.is_scoring or result.score == best_score) + def start_background_work(self, context: Context) -> None: + # Send off an early background compile job for each submission and + # validator, to avoid a bottleneck step at the start of each test run. + self._problem.output_validators.start_background_work(context) + for acr in self._submissions: + for sub in self._submissions[acr]: + context.submit_background_work(lambda s: s.compile(), sub) + def check(self, context: Context) -> bool: if self._check_res is not None: return self._check_res @@ -1875,14 +1892,6 @@ def check(self, context: Context) -> bool: timelim = context.fixed_timelim timelim_margin = int(round(timelim * safety_margin)) - if context.executor: - # Send off an early background compile job for each submission and - # validator, to avoid a bottleneck step at the start of each test run. - self._problem.output_validators.start_background_work(context) - for acr in self._submissions: - for sub in self._submissions[acr]: - context.executor.submit(lambda s: s.compile(), sub) - for verdict in Submissions._VERDICTS: acr = verdict[0] if verdict[2] and not self._submissions[acr]: @@ -1965,13 +1974,9 @@ def __enter__(self) -> Problem: self.testdata = TestCaseGroup(self, os.path.join(self.probdir, 'data')) self.submissions = Submissions(self) self.generators = Generators(self) - self.background_jobs: list[concurrent.futures.Future[None]] = [] return self def __exit__(self, exc_type, exc_value, exc_traceback) -> None: - # Wait for discarded speculative submission runs to finish before - # performing an rmtree on the directory tree they use. - concurrent.futures.wait(self.background_jobs) shutil.rmtree(self.tmpdir) def __str__(self) -> str: @@ -2007,9 +2012,10 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: run.limit.check_limit_capabilities(self) - for part in args.parts: - for item in part_mapping[part]: - item.start_background_work(context) + if executor: + for part in args.parts: + for item in part_mapping[part]: + item.start_background_work(context) for part in args.parts: self.msg(f'Checking {part}') @@ -2017,6 +2023,10 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: item.check(context) except VerifyError: pass + finally: + # Wait for background work to finish before performing an rmtree on + # the directory tree it uses. + context.wait_for_background_work() return ProblemAspect.errors, ProblemAspect.warnings def _check_symlinks(self): From 7994ed38893ecef9afa8b90c4e3600c8dc8e0a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Mon, 10 Jun 2024 17:16:49 +0200 Subject: [PATCH 037/272] Adding sanity checks for file sizes. --- problemtools/verifyproblem.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 37f7d009..2d5f868a 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -199,6 +199,13 @@ def check_newlines(self, filename: str) -> None: if len(data) > 0 and data[-1] != '\n': self.warning(f"The file {filename} does not end with '\\n'.") + def check_size_limits(self, filename: str) -> None: + filesize = os.path.getsize(filename) / 1024.0 / 1024.0 + if filesize > 1000: + self.error(f'The file {filename} ({filesize:.1f} Mb) is larger than 1000 Mb and can not be installed.') + elif filesize > 100: + self.warning(f'The file {filename} ({filesize:.1f} Mb) is larger than 100 Mb. This may cause performance issues and is not recommended.') + def strip_path_prefix(self, path: str) -> str: return os.path.relpath(path, os.path.join(self._problem.probdir, 'data')) @@ -213,6 +220,8 @@ def check(self, context: Context) -> bool: self.check_basename(self.ansfile) self.check_newlines(self.infile) self.check_newlines(self.ansfile) + self.check_size_limits(self.infile) + self.check_size_limits(self.ansfile) self._problem.input_validators.validate(self) anssize = os.path.getsize(self.ansfile) / 1024.0 / 1024.0 outputlim = self._problem.config.get('limits')['output'] From 7a04162e6bcd67e08eed4782c8541013b3c6c080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Mon, 10 Jun 2024 17:30:09 +0200 Subject: [PATCH 038/272] Add UUID as an optional field in problem.yaml --- problemtools/config/problem.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/problemtools/config/problem.yaml b/problemtools/config/problem.yaml index 79c9a1d5..bcc49b32 100644 --- a/problemtools/config/problem.yaml +++ b/problemtools/config/problem.yaml @@ -1,3 +1,4 @@ +uuid: '' type: pass-fail author: '' source: '' From 8fb20341f7e907c150dbbffd272133734ad57d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Thu, 13 Jun 2024 13:05:29 +0200 Subject: [PATCH 039/272] Include examples in the manifest --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 4d57d2ed..59adcd4a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ recursive-include problemtools/config * recursive-include problemtools/templates * recursive-include problemtools/tests * +recursive-include examples * recursive-include support * From 92cb0b267558625d45233770220ff21f9f387702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Tue, 18 Jun 2024 13:44:12 +0200 Subject: [PATCH 040/272] Fixing running of tests --- .../tests/hello/data/secret/hello.ans | 1 + problemtools/tests/hello/data/secret/hello.in | 0 .../tests/hello/input_validators/validate.py | 8 +++++ problemtools/tests/hello/problem.yaml | 8 +++++ .../hello/problem_statement/problem.en.tex | 9 +++++ .../hello/problem_statement/problem.sv.tex | 9 +++++ .../tests/hello/submissions/accepted/hello.cc | 6 ++++ .../hello/submissions/accepted/hello.java | 5 +++ .../tests/hello/submissions/accepted/hello.kt | 4 +++ .../tests/hello/submissions/accepted/hello.py | 3 ++ .../hello/submissions/accepted/hello_alarm.c | 35 +++++++++++++++++++ .../run_time_error/memory_limit.cc | 11 ++++++ .../hello/submissions/wrong_answer/hello.cc | 6 ++++ problemtools/tests/test_verify_hello.py | 2 +- 14 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 problemtools/tests/hello/data/secret/hello.ans create mode 100644 problemtools/tests/hello/data/secret/hello.in create mode 100755 problemtools/tests/hello/input_validators/validate.py create mode 100644 problemtools/tests/hello/problem.yaml create mode 100644 problemtools/tests/hello/problem_statement/problem.en.tex create mode 100644 problemtools/tests/hello/problem_statement/problem.sv.tex create mode 100644 problemtools/tests/hello/submissions/accepted/hello.cc create mode 100644 problemtools/tests/hello/submissions/accepted/hello.java create mode 100644 problemtools/tests/hello/submissions/accepted/hello.kt create mode 100644 problemtools/tests/hello/submissions/accepted/hello.py create mode 100644 problemtools/tests/hello/submissions/accepted/hello_alarm.c create mode 100644 problemtools/tests/hello/submissions/run_time_error/memory_limit.cc create mode 100644 problemtools/tests/hello/submissions/wrong_answer/hello.cc diff --git a/problemtools/tests/hello/data/secret/hello.ans b/problemtools/tests/hello/data/secret/hello.ans new file mode 100644 index 00000000..980a0d5f --- /dev/null +++ b/problemtools/tests/hello/data/secret/hello.ans @@ -0,0 +1 @@ +Hello World! diff --git a/problemtools/tests/hello/data/secret/hello.in b/problemtools/tests/hello/data/secret/hello.in new file mode 100644 index 00000000..e69de29b diff --git a/problemtools/tests/hello/input_validators/validate.py b/problemtools/tests/hello/input_validators/validate.py new file mode 100755 index 00000000..2f71d601 --- /dev/null +++ b/problemtools/tests/hello/input_validators/validate.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +from sys import stdin +import sys + +# There shouldn't be any input +assert len(stdin.readline()) == 0 + +sys.exit(42) diff --git a/problemtools/tests/hello/problem.yaml b/problemtools/tests/hello/problem.yaml new file mode 100644 index 00000000..194b060f --- /dev/null +++ b/problemtools/tests/hello/problem.yaml @@ -0,0 +1,8 @@ +source: Kattis +license: public domain + +# Fix memory limit at 512 MB. (Note that for most problems, this +# should not be done. It is only done in this case because we include +# a test submission that goes over this limit.) +limits: + memory: 512 diff --git a/problemtools/tests/hello/problem_statement/problem.en.tex b/problemtools/tests/hello/problem_statement/problem.en.tex new file mode 100644 index 00000000..3df9596c --- /dev/null +++ b/problemtools/tests/hello/problem_statement/problem.en.tex @@ -0,0 +1,9 @@ +\problemname{Hello World!} + +\section*{Input} + +There is no input for this problem. + +\section*{Output} + +Output should contain one line, containing the string ``Hello World!''. diff --git a/problemtools/tests/hello/problem_statement/problem.sv.tex b/problemtools/tests/hello/problem_statement/problem.sv.tex new file mode 100644 index 00000000..a87c7402 --- /dev/null +++ b/problemtools/tests/hello/problem_statement/problem.sv.tex @@ -0,0 +1,9 @@ +\problemname{Hej Världen!} + +\section*{Indata} + +Detta problem har inget indata. + +\section*{Output} + +Utdata ska bestå av en rad, innehållandes strängen ``Hello World!''. diff --git a/problemtools/tests/hello/submissions/accepted/hello.cc b/problemtools/tests/hello/submissions/accepted/hello.cc new file mode 100644 index 00000000..9feeee8e --- /dev/null +++ b/problemtools/tests/hello/submissions/accepted/hello.cc @@ -0,0 +1,6 @@ +#include + +int main(void) { + printf("Hello World!\n"); + return 0; +} diff --git a/problemtools/tests/hello/submissions/accepted/hello.java b/problemtools/tests/hello/submissions/accepted/hello.java new file mode 100644 index 00000000..49243411 --- /dev/null +++ b/problemtools/tests/hello/submissions/accepted/hello.java @@ -0,0 +1,5 @@ +public class hello { + public static void main(String args[]) { + System.out.println("Hello World!"); + } +} \ No newline at end of file diff --git a/problemtools/tests/hello/submissions/accepted/hello.kt b/problemtools/tests/hello/submissions/accepted/hello.kt new file mode 100644 index 00000000..3dcd543f --- /dev/null +++ b/problemtools/tests/hello/submissions/accepted/hello.kt @@ -0,0 +1,4 @@ +fun main(args: Array) { + val words = if (args.size == 0) arrayOf("Hello", "World!") else args + System.`out`.println(words.joinToString(separator = " ")) +} diff --git a/problemtools/tests/hello/submissions/accepted/hello.py b/problemtools/tests/hello/submissions/accepted/hello.py new file mode 100644 index 00000000..9016c9b6 --- /dev/null +++ b/problemtools/tests/hello/submissions/accepted/hello.py @@ -0,0 +1,3 @@ +#!/usr/bin/env python3 + +print('Hello World!') diff --git a/problemtools/tests/hello/submissions/accepted/hello_alarm.c b/problemtools/tests/hello/submissions/accepted/hello_alarm.c new file mode 100644 index 00000000..ba104baa --- /dev/null +++ b/problemtools/tests/hello/submissions/accepted/hello_alarm.c @@ -0,0 +1,35 @@ +#include +#include +#include + +/* Based on the libc manual*/ + +/* This flag controls termination of the main loop. */ +volatile sig_atomic_t keep_going = 1; + +/* The signal handler just clears the flag and re-enables itself. */ +void catch_alarm (int sig) +{ + keep_going = 0; + signal (sig, catch_alarm); +} + +void do_nothing (void) +{ + int i=0; + for (i=0;i<1000;i+=1); +} + +int main (void) +{ + /* Establish a handler for SIGALRM signals. */ + signal (SIGALRM, catch_alarm); + /* Set an alarm to go off in a little while. */ + alarm (1); + /* Check the flag once in a while to see when to quit. */ + while (keep_going) + do_nothing(); + + printf("Hello World!\n"); + return EXIT_SUCCESS; +} diff --git a/problemtools/tests/hello/submissions/run_time_error/memory_limit.cc b/problemtools/tests/hello/submissions/run_time_error/memory_limit.cc new file mode 100644 index 00000000..6d858755 --- /dev/null +++ b/problemtools/tests/hello/submissions/run_time_error/memory_limit.cc @@ -0,0 +1,11 @@ +#include +#include + +int main(void) { + char *buf = new char[512*1024*1024]; // 512MB + buf[0] = 0; + for (int i = 1; i < 512*1024*1024; ++i) + buf[i] = 23*buf[i-1]+42; + std::cout << "Hello World!\n" << std::endl; + return 0; +} diff --git a/problemtools/tests/hello/submissions/wrong_answer/hello.cc b/problemtools/tests/hello/submissions/wrong_answer/hello.cc new file mode 100644 index 00000000..80ab5c4f --- /dev/null +++ b/problemtools/tests/hello/submissions/wrong_answer/hello.cc @@ -0,0 +1,6 @@ +#include + +int main(void) { + printf("Hello!"); + return 0; +} diff --git a/problemtools/tests/test_verify_hello.py b/problemtools/tests/test_verify_hello.py index 0e115ae2..677f69cd 100644 --- a/problemtools/tests/test_verify_hello.py +++ b/problemtools/tests/test_verify_hello.py @@ -3,7 +3,7 @@ def test_load_hello(): - directory = pathlib.Path(__file__).parent.parent.parent / "examples" / "hello" + directory = pathlib.Path(__file__).parent / "hello" string = str(directory.resolve()) args = verify.argparser().parse_args([string]) From 54a77bb19f1c057fcfe9b35a55ec7f99d04643ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Tue, 18 Jun 2024 13:47:39 +0200 Subject: [PATCH 041/272] Build debian packages in ci --- .github/workflows/python-app.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 4a7d4bfe..ab52f815 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -38,6 +38,7 @@ jobs: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude examples/ # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --exclude examples/ + - name: Build debian packages + run: make builddeb - name: Test with pytest - run: | - pytest + run: pytest From b6dedd16e6426cf5b4de2d6b60ef50539d449847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Tue, 18 Jun 2024 13:54:27 +0200 Subject: [PATCH 042/272] Cache and add packages --- .github/workflows/python-app.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index ab52f815..d1336188 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -19,13 +19,17 @@ jobs: strategy: matrix: python-version: ["pypy3.10", "3.10"] # Arbitrary pick of one pypy and one Cpython version - steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Setup cache for apt packages (used by builddeb) + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: debhelper (>= 8.0.0) dh-python python3-pytest libboost-regex-dev + version: 1.0 - name: Install dependencies run: | python -m pip install --upgrade pip From ffdfbd0aabdd7c663fad07b83be2af1982aac7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Tue, 18 Jun 2024 14:55:13 +0200 Subject: [PATCH 043/272] Warn if sample is empty --- .github/workflows/python-app.yml | 8 +++----- problemtools/verifyproblem.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index d1336188..b7058d7a 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -25,11 +25,9 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Setup cache for apt packages (used by builddeb) - uses: awalsh128/cache-apt-pkgs-action@latest - with: - packages: debhelper (>= 8.0.0) dh-python python3-pytest libboost-regex-dev - version: 1.0 + - name: Install apt packages (for debbuild) + run: sudo apt-get install debhelper dh-python python3-pytest libboost-regex-dev + shell: bash - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 2d5f868a..a45cbf9d 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -77,7 +77,7 @@ def __str__(self) -> str: if self.reason is not None: details.append(self.reason) if self.testcase is not None: - details.append(f'test case: {self.testcase}') + details.append(f'testcase: {self.testcase}') if self.runtime != -1: details.append(f'CPU: {self.runtime:.2f}s @ {self.runtime_testcase}') @@ -240,7 +240,7 @@ def check(self, context: Context) -> bool: return self._check_res def __str__(self) -> str: - return f'test case {self.strip_path_prefix(self._base)}' + return f'testcase {self.strip_path_prefix(self._base)}' def matches_filter(self, filter_re: Pattern[str]) -> bool: return filter_re.search(self.strip_path_prefix(self._base)) is not None @@ -268,7 +268,7 @@ def _check_symlinks(self) -> bool: self.error(f"Symbolic link points outside data/ directory for file '{nicepath}'") return False if self.testcasegroup.config['output_validator_flags'] != self.reuse_result_from.testcasegroup.config['output_validator_flags']: - self.error(f"Symbolic link '{nicepath}' points to test case with different output validator flags") + self.error(f"Symbolic link '{nicepath}' points to testcase with different output validator flags") return False return True @@ -418,7 +418,7 @@ def __init__(self, problem: Problem, datadir: str, parent: TestCaseGroup|None=No def __str__(self) -> str: - return f'test case group {self.name}' + return f'testcase group {self.name}' def set_symlinks(self) -> None: for sub in self._items: @@ -554,7 +554,10 @@ def check(self, context: Context) -> bool: self.error(f"No matching input file for answer '{ansfile}'") if not self.get_subgroups() and not self.get_testcases(): - self.error('Test case group is empty') + if os.path.basename(self._datadir) != 'sample': + self.error(f'Testcase group {self._datadir} exists, but does not contain any testcases') + else: + self.warning(f'Sample testcase group {self._datadir} exists, but does not contain any testcases') # Check whether a <= b according to a natural sorting where numeric components # are compactified, so that e.g. "a" < "a1" < "a2" < "a10" = "a010" < "a10a". @@ -785,7 +788,7 @@ def check(self, context: Context) -> bool: elif self._data['grading']['show_test_data_groups'] and self._data['type'] == 'pass-fail': self.error("Showing test data groups is only supported for scoring problems, this is a pass-fail problem") if self._data['type'] != 'pass-fail' and self._problem.testdata.has_custom_groups() and 'show_test_data_groups' not in self._origdata.get('grading', {}): - self.warning("Problem has custom test case groups, but does not specify a value for grading.show_test_data_groups; defaulting to false") + self.warning("Problem has custom testcase groups, but does not specify a value for grading.show_test_data_groups; defaulting to false") if 'on_reject' in self._data['grading']: if self._data['type'] == 'pass-fail' and self._data['grading']['on_reject'] == 'grade': From 986006ba147660c23ccf852a3b07644f2c29a480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Niemel=C3=A4?= Date: Wed, 24 Jul 2024 16:12:10 -0500 Subject: [PATCH 044/272] Change team to user for default validator --- support/default_validator/default_validator.cc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/support/default_validator/default_validator.cc b/support/default_validator/default_validator.cc index fe1390c2..0ad1be7f 100644 --- a/support/default_validator/default_validator.cc +++ b/support/default_validator/default_validator.cc @@ -65,7 +65,7 @@ FILE *openfeedback(const char *feedbackdir, const char *feedback, const char *wh return res; } -const char *USAGE = "Usage: %s judge_in judge_ans feedback_file [options] < team_out"; +const char *USAGE = "Usage: %s judge_in judge_ans feedback_file [options] < user_out"; int main(int argc, char **argv) { if(argc < 4) { @@ -128,7 +128,7 @@ int main(int argc, char **argv) { while (isspace(std::cin.peek())) { char d = (char)std::cin.get(); if (space_change_sensitive) { - wrong_answer("Space change error: judge out of space, got %d from team", d); + wrong_answer("Space change error: judge out of space, got %d from user", d); } if (d == '\n') ++stdin_line; ++stdin_pos; @@ -148,16 +148,16 @@ int main(int argc, char **argv) { } if(!(fabs(jval - tval) <= float_abs_tol) && !(fabs(jval - tval) <= float_rel_tol*fabs(jval))) { - wrong_answer("Too large difference.\n Judge: %s\n Team: %s\n Difference: %le\n (abs tol %le rel tol %le)", + wrong_answer("Too large difference.\n Judge: %s\n User: %s\n Difference: %le\n (abs tol %le rel tol %le)", judge.c_str(), team.c_str(), jval-tval, float_abs_tol, float_rel_tol); } } else if (case_sensitive) { if (strcmp(judge.c_str(), team.c_str()) != 0) { - wrong_answer("String tokens mismatch\nJudge: \"%s\"\nTeam: \"%s\"", judge.c_str(), team.c_str()); + wrong_answer("String tokens mismatch\nJudge: \"%s\"\nUser: \"%s\"", judge.c_str(), team.c_str()); } } else { if(strcasecmp(judge.c_str(), team.c_str()) != 0) { - wrong_answer("String tokens mismatch\nJudge: \"%s\"\nTeam: \"%s\"", judge.c_str(), team.c_str()); + wrong_answer("String tokens mismatch\nJudge: \"%s\"\nUser: \"%s\"", judge.c_str(), team.c_str()); } } judgeans_pos += judge.length(); From ca99da78d90e58077756a56b776295a97ebc2ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Sun, 28 Jul 2024 08:29:54 -0500 Subject: [PATCH 045/272] Add a dependency on dvisvgm, which was missing --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 0a635887..42797c8b 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Homepage: https://github.com/Kattis/problemtools Package: kattis-problemtools Architecture: any -Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, python3-plastex, python3-pkg-resources, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript +Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, python3-plastex, python3-pkg-resources, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm Recommends: gcc, g++ Description: Kattis Problem Tools These are tools to manage and verify problem packages in the From cb3ea100392775314e263b3e9c89b91b4db6088d Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 00:54:41 +0200 Subject: [PATCH 046/272] Add markdown support --- examples/README.md | 5 + examples/bplusa/data/sample/1.ans | 1 + examples/bplusa/data/sample/1.in | 1 + examples/bplusa/data/secret/1.ans | 1 + examples/bplusa/data/secret/1.in | 1 + examples/bplusa/data/secret/2.ans | 1 + examples/bplusa/data/secret/2.in | 1 + examples/bplusa/data/secret/3.ans | 1 + examples/bplusa/data/secret/3.in | 1 + .../input_validators/validator/validator.cpp | 8 + .../input_validators}/validator/validator.h | 0 .../output_validators/validator/validate.cc | 64 ++++ .../output_validators/validator/validate.h | 153 ++++++++ examples/bplusa/problem.yaml | 4 + .../bplusa/problem_statement/problem.en.md | 8 + .../bplusa/submissions/accepted/cplus1.cpp | 10 + examples/bplusa/submissions/accepted/zero.cpp | 10 + .../validator/validator.cpp | 0 .../input_validators/validator/validator.h | 356 ++++++++++++++++++ problemtools/md2html.py | 162 ++++++++ problemtools/problem2html.py | 98 +++-- problemtools/problem2pdf.py | 6 +- .../templates/markdown/default-layout.html | 35 ++ problemtools/templates/markdown/problem.css | 90 +++++ problemtools/tex2html.py | 67 ++++ problemtools/verifyproblem.py | 53 +-- 26 files changed, 1056 insertions(+), 81 deletions(-) create mode 100644 examples/bplusa/data/sample/1.ans create mode 100644 examples/bplusa/data/sample/1.in create mode 100644 examples/bplusa/data/secret/1.ans create mode 100644 examples/bplusa/data/secret/1.in create mode 100644 examples/bplusa/data/secret/2.ans create mode 100644 examples/bplusa/data/secret/2.in create mode 100644 examples/bplusa/data/secret/3.ans create mode 100644 examples/bplusa/data/secret/3.in create mode 100644 examples/bplusa/input_validators/validator/validator.cpp rename examples/{oddecho/input_format_validators => bplusa/input_validators}/validator/validator.h (100%) create mode 100644 examples/bplusa/output_validators/validator/validate.cc create mode 100644 examples/bplusa/output_validators/validator/validate.h create mode 100644 examples/bplusa/problem.yaml create mode 100644 examples/bplusa/problem_statement/problem.en.md create mode 100644 examples/bplusa/submissions/accepted/cplus1.cpp create mode 100644 examples/bplusa/submissions/accepted/zero.cpp rename examples/oddecho/{input_format_validators => input_validators}/validator/validator.cpp (100%) create mode 100644 examples/oddecho/input_validators/validator/validator.h create mode 100644 problemtools/md2html.py create mode 100644 problemtools/templates/markdown/default-layout.html create mode 100644 problemtools/templates/markdown/problem.css create mode 100644 problemtools/tex2html.py diff --git a/examples/README.md b/examples/README.md index 2f6107a3..9665f8a7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,3 +25,8 @@ more than one language. This is an example of a *scoring* problem where submissions can get different scores depending on which test groups they solve. It also demonstrates how an input validator might check different constraints for different test groups. + +# bplusa + +This is an example of a problem using an output validator, showcasing different things to keep in mind +when using output validator. It also demonstrates using Markdown as a statement language. diff --git a/examples/bplusa/data/sample/1.ans b/examples/bplusa/data/sample/1.ans new file mode 100644 index 00000000..654d5269 --- /dev/null +++ b/examples/bplusa/data/sample/1.ans @@ -0,0 +1 @@ +2 3 diff --git a/examples/bplusa/data/sample/1.in b/examples/bplusa/data/sample/1.in new file mode 100644 index 00000000..7ed6ff82 --- /dev/null +++ b/examples/bplusa/data/sample/1.in @@ -0,0 +1 @@ +5 diff --git a/examples/bplusa/data/secret/1.ans b/examples/bplusa/data/secret/1.ans new file mode 100644 index 00000000..1790e253 --- /dev/null +++ b/examples/bplusa/data/secret/1.ans @@ -0,0 +1 @@ +123 0 diff --git a/examples/bplusa/data/secret/1.in b/examples/bplusa/data/secret/1.in new file mode 100644 index 00000000..190a1803 --- /dev/null +++ b/examples/bplusa/data/secret/1.in @@ -0,0 +1 @@ +123 diff --git a/examples/bplusa/data/secret/2.ans b/examples/bplusa/data/secret/2.ans new file mode 100644 index 00000000..93fd4034 --- /dev/null +++ b/examples/bplusa/data/secret/2.ans @@ -0,0 +1 @@ +992 0 diff --git a/examples/bplusa/data/secret/2.in b/examples/bplusa/data/secret/2.in new file mode 100644 index 00000000..7f9d7e97 --- /dev/null +++ b/examples/bplusa/data/secret/2.in @@ -0,0 +1 @@ +992 diff --git a/examples/bplusa/data/secret/3.ans b/examples/bplusa/data/secret/3.ans new file mode 100644 index 00000000..80c0cc79 --- /dev/null +++ b/examples/bplusa/data/secret/3.ans @@ -0,0 +1 @@ +1 0 diff --git a/examples/bplusa/data/secret/3.in b/examples/bplusa/data/secret/3.in new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/bplusa/data/secret/3.in @@ -0,0 +1 @@ +1 diff --git a/examples/bplusa/input_validators/validator/validator.cpp b/examples/bplusa/input_validators/validator/validator.cpp new file mode 100644 index 00000000..0ecff521 --- /dev/null +++ b/examples/bplusa/input_validators/validator/validator.cpp @@ -0,0 +1,8 @@ +#include "validator.h" + + +void run() { + Int(1, 1000); + Endl(); + Eof(); +} diff --git a/examples/oddecho/input_format_validators/validator/validator.h b/examples/bplusa/input_validators/validator/validator.h similarity index 100% rename from examples/oddecho/input_format_validators/validator/validator.h rename to examples/bplusa/input_validators/validator/validator.h diff --git a/examples/bplusa/output_validators/validator/validate.cc b/examples/bplusa/output_validators/validator/validate.cc new file mode 100644 index 00000000..61eabfc2 --- /dev/null +++ b/examples/bplusa/output_validators/validator/validate.cc @@ -0,0 +1,64 @@ +#include "validate.h" + +#include +using namespace std; + +#define rep(i, a, b) for(int i = a; i < (b); ++i) +#define all(x) begin(x), end(x) +#define sz(x) (int)(x).size() +typedef long long ll; +typedef pair pii; +typedef vector vi; +typedef vector vvi; +typedef long double ld; + +#define repe(i, container) for (auto& i : container) + +void check_isvalid(int a, int b, int c, feedback_function feedback) +{ + if (a==b) feedback("a is equal to b"); + if (a+b!=c) feedback("b+a!=c"); +} + +const int HUNDRED_THOUSAND = int(1e5); +int main(int argc, char **argv) { + init_io(argc, argv); + + // Read the testcase input + int c; + judge_in >> c; + + auto check = [&](istream& sol, feedback_function feedback) { + int a, b; + // Don't get stuck waiting for output from solution + if(!(sol >> a >> b)) feedback("Expected more output"); + // Validate constraints + if (a < -HUNDRED_THOUSAND || a > HUNDRED_THOUSAND) feedback("a is too big or large"); + if (b < -HUNDRED_THOUSAND || b > HUNDRED_THOUSAND) feedback("b is too big or large"); + + // Check that they actually solved the task + check_isvalid(a, b, c, feedback); + + // Disallow trailing output + string trailing; + if(sol >> trailing) feedback("Trailing output"); + return true; + }; + + // Check both the judge's and contestants' output + // It is good practice to not assume that the judge is correct/optimal + bool judge_found_sol = check(judge_ans, judge_error); + bool author_found_sol = check(author_out, wrong_answer); + + // In this problem, having a return value from check is unnecessary + // However, if there isn't always a solution, we will get a nice + // judge error if the judge solution claims no solution exists, while + // a contestant finds one + if(!judge_found_sol) + judge_error("NO! Judge did not find valid solution"); + + if(!author_found_sol) + wrong_answer("Contestant did not find valid solution"); + + accept(); +} diff --git a/examples/bplusa/output_validators/validator/validate.h b/examples/bplusa/output_validators/validator/validate.h new file mode 100644 index 00000000..c59c5fdb --- /dev/null +++ b/examples/bplusa/output_validators/validator/validate.h @@ -0,0 +1,153 @@ +/* Utility functions for writing output validators for the Kattis + * problem format. + * + * The primary functions and variables available are the following. + * In many cases, the only functions needed are "init_io", + * "wrong_answer", and "accept". + * + * - init_io(argc, argv): + * initialization + * + * - judge_in, judge_ans, author_out: + * std::istream objects for judge input file, judge answer + * file, and submission output file. + * + * - accept(): + * exit and give Accepted! + * + * - accept_with_score(double score): + * exit with Accepted and give a score (for scoring problems) + * + * - judge_message(std::string msg, ...): + * printf-style function for emitting a judge message (a + * message that gets displayed to a privileged user with access + * to secret data etc). + * + * - wrong_answer(std::string msg, ...): + * printf-style function for exitting and giving Wrong Answer, + * and emitting a judge message (which would typically explain + * the cause of the Wrong Answer) + * + * - judge_error(std::string msg, ...): + * printf-style function for exitting and giving Judge Error, + * and emitting a judge message (which would typically explain + * the cause of the Judge Error) + * + * - author_message(std::string msg, ...): + * printf-style function for emitting an author message (a + * message that gets displayed to the author of the + * submission). (Use with caution, and be careful not to let + * it leak information!) + * + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +typedef void (*feedback_function)(const char*, ...); + +const int EXITCODE_AC = 42; +const int EXITCODE_WA = 43; +const char* FILENAME_AUTHOR_MESSAGE = "teammessage.txt"; +const char* FILENAME_JUDGE_MESSAGE = "judgemessage.txt"; +const char* FILENAME_JUDGE_ERROR = "judgeerror.txt"; +const char* FILENAME_SCORE = "score.txt"; + +#define USAGE "%s: judge_in judge_ans feedback_dir < author_out\n" + +std::ifstream judge_in, judge_ans; +std::istream author_out(std::cin.rdbuf()); + +char *feedbackdir = NULL; + +void vreport_feedback(const char* category, + const char* msg, + va_list pvar) { + std::ostringstream fname; + if (feedbackdir) + fname << feedbackdir << '/'; + fname << category; + FILE *f = fopen(fname.str().c_str(), "a"); + assert(f); + vfprintf(f, msg, pvar); + fclose(f); +} + +void report_feedback(const char* category, const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(category, msg, pvar); +} + +void author_message(const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(FILENAME_AUTHOR_MESSAGE, msg, pvar); +} + +void judge_message(const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); +} + +void wrong_answer(const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); + exit(EXITCODE_WA); +} + +void judge_error(const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(FILENAME_JUDGE_ERROR, msg, pvar); + assert(0); +} + +void accept() { + exit(EXITCODE_AC); +} + +void accept_with_score(double scorevalue) { + report_feedback(FILENAME_SCORE, "%.9le", scorevalue); + exit(EXITCODE_AC); +} + + +bool is_directory(const char *path) { + struct stat entry; + return stat(path, &entry) == 0 && S_ISDIR(entry.st_mode); +} + +void init_io(int argc, char **argv) { + if(argc < 4) { + fprintf(stderr, USAGE, argv[0]); + judge_error("Usage: %s judgein judgeans feedbackdir [opts] < userout", argv[0]); + } + + // Set up feedbackdir first, as that allows us to produce feedback + // files for errors in the other parameters. + if (!is_directory(argv[3])) { + judge_error("%s: %s is not a directory\n", argv[0], argv[3]); + } + feedbackdir = argv[3]; + + judge_in.open(argv[1], std::ios_base::in); + if (judge_in.fail()) { + judge_error("%s: failed to open %s\n", argv[0], argv[1]); + } + + judge_ans.open(argv[2], std::ios_base::in); + if (judge_ans.fail()) { + judge_error("%s: failed to open %s\n", argv[0], argv[2]); + } + + author_out.rdbuf(std::cin.rdbuf()); +} diff --git a/examples/bplusa/problem.yaml b/examples/bplusa/problem.yaml new file mode 100644 index 00000000..d59b82ec --- /dev/null +++ b/examples/bplusa/problem.yaml @@ -0,0 +1,4 @@ +source: Kattis +license: public domain +name: B plus A +validation: custom diff --git a/examples/bplusa/problem_statement/problem.en.md b/examples/bplusa/problem_statement/problem.en.md new file mode 100644 index 00000000..d5060a86 --- /dev/null +++ b/examples/bplusa/problem_statement/problem.en.md @@ -0,0 +1,8 @@ +Given the integer $c$, find any pair of integers $b$ and $a$ satisfying $b+a=c$ and $a \neq b$. + +## Input +Input consists of the integer $C$ ($1 \le C \le 1000$). + +## Output +Output $b$ and $a$, separated by a space. Any $b$, $a$ satisfying above constraints and $-10^5 \leq a,b \leq 10^5$ +will be accepted. diff --git a/examples/bplusa/submissions/accepted/cplus1.cpp b/examples/bplusa/submissions/accepted/cplus1.cpp new file mode 100644 index 00000000..946facb7 --- /dev/null +++ b/examples/bplusa/submissions/accepted/cplus1.cpp @@ -0,0 +1,10 @@ +#include +using namespace std; + +int main() +{ + int c; + cin >> c; + cout << c+1 << " " << -1 << endl; + return 0; +} diff --git a/examples/bplusa/submissions/accepted/zero.cpp b/examples/bplusa/submissions/accepted/zero.cpp new file mode 100644 index 00000000..2f4c748a --- /dev/null +++ b/examples/bplusa/submissions/accepted/zero.cpp @@ -0,0 +1,10 @@ +#include +using namespace std; + +int main() +{ + int c; + cin >> c; + cout << c << " " << 0 << endl; + return 0; +} diff --git a/examples/oddecho/input_format_validators/validator/validator.cpp b/examples/oddecho/input_validators/validator/validator.cpp similarity index 100% rename from examples/oddecho/input_format_validators/validator/validator.cpp rename to examples/oddecho/input_validators/validator/validator.cpp diff --git a/examples/oddecho/input_validators/validator/validator.h b/examples/oddecho/input_validators/validator/validator.h new file mode 100644 index 00000000..f42bc2d7 --- /dev/null +++ b/examples/oddecho/input_validators/validator/validator.h @@ -0,0 +1,356 @@ +#ifdef NDEBUG +#error Asserts must be enabled! Do not set NDEBUG. +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace std; + +// Implemented by you! +void run(); + +// PUBLIC API +// (extend if you need to) + +[[noreturn]] +void die(const string& msg); +[[noreturn]] +void die_line(const string& msg); + +struct ArgType { + string _name, _x; + ArgType(const string& name, const string& x) : _name(name), _x(x) {} + operator string() const { return _x; } + operator long long() const; + operator bool() const; + operator int() const; +}; + +struct IntType { + long long _x; + IntType(long long x) : _x(x) {} + operator long long() const { return _x; } + operator int() const; + operator bool() const; +}; + +ArgType Arg(const string& name); + +ArgType Arg(const string& name, long long _default); + +string Arg(const string& name, const string& _default); + +template +void AssertUnique(const Vec& v); + +namespace IO { + IntType Int(long long lo, long long hi); + double Float(double lo, double hi, bool strict = true); + template + vector SpacedInts(long long count, T lo, T hi); + vector SpacedFloats(long long count, double lo, double hi); + void Char(char expected); + char Char(); + string Line(); + void Endl() { Char('\n'); } + void Space() { Char(' '); } + void Eof() { Char(-1); } +}; +using namespace IO; + +// INTERNALS + +bool _validator_initialized; +struct _validator { + map params; + set used_params; + + void construct(int argc, char** argv) { + _validator_initialized = true; + for (int i = 1; i < argc; i++) { + string s = argv[i]; + size_t ind = s.find('='); + if (ind == string::npos) continue; + auto before = s.substr(0, ind), after = s.substr(ind + 1); + if (params.count(before)) + die("Duplicate parameter " + before); + params[before] = after; + } + } + + void destroy() { + assert(_validator_initialized); + if (!params.empty()) { + string name = params.begin()->first; + die("Unused parameter " + name); + } + IO::Eof(); + _Exit(42); + } + + bool has_var(const string& name) { + if (!_validator_initialized) die("Must not read variables before main"); + return params.count(name) || used_params.count(name); + } + + string get_var(const string& name) { + if (!_validator_initialized) die("Must not read variables before main"); + if (used_params.count(name)) die("Must not read parameter " + name + " twice (either typo or slow)"); + if (!params.count(name)) die("No parameter " + name); + string res = params.at(name); + params.erase(name); + used_params.insert(name); + return res; + } +} _validator_inst; + +void die(const string& msg) { + cerr << msg << endl; + ofstream fout("/tmp/input_validator_msg", ios::app); + fout << msg << endl; + fout.close(); + _Exit(43); +} + +ArgType::operator long long() const { + string dummy; + { + long long num; + istringstream iss(_x); + iss >> num; + if (iss && !(iss >> dummy)) return num; + } + { + // We also allow scientific notation, for clarity + long double num; + istringstream iss(_x); + iss >> num; + if (iss && !(iss >> dummy)) return (long long)num; + } + die("Unable to parse value " + _x + " for parameter " + _name); +} + +ArgType::operator int() const { + long long val = (long long)*this; + if (val < INT_MIN || val > INT_MAX) + die("number " + to_string(val) + " is too large for an int for parameter " + _name); + return (int)val; +} + +ArgType::operator bool() const { + long long val = (long long)*this; + if (val < 0 || val > 1) + die("number " + to_string(val) + " is not boolean (0/1), for parameter " + _name); + return (bool)val; +} + +IntType::operator int() const { + long long val = (long long)*this; + if (val < INT_MIN || val > INT_MAX) + die_line("number " + to_string(val) + " is too large for an int"); + return (int)val; +} + +IntType::operator bool() const { + long long val = (long long)*this; + if (val < 0 || val > 1) + die_line("number " + to_string(val) + " is not boolean (0/1)"); + return (bool)val; +} + +ArgType Arg(const string& name) { + return {name, _validator_inst.get_var(name)}; +} + +ArgType Arg(const string& name, long long _default) { + if (!_validator_inst.has_var(name)) + return {name, to_string(_default)}; + ArgType ret = Arg(name); + (void)(long long)ret; + return ret; +} + +string Arg(const string& name, const string& _default) { + if (!_validator_inst.has_var(name)) + return _default; + return (string)Arg(name); +} + +static int _lineno = 1, _consumed_lineno = -1, _hit_char_error = 0; +char _peek1(); +void die_line(const string& msg) { + if (!_hit_char_error && _peek1() == -1) die(msg); + else if (_consumed_lineno == -1) die(msg + " (before reading any input)"); + else die(msg + " on line " + to_string(_consumed_lineno)); +} + +static char _buffer = -2; // -2 = none, -1 = eof, other = that char +char _peek1() { + if (_buffer != -2) return _buffer; + int val = getchar_unlocked(); + static_assert(EOF == -1, ""); + static_assert(CHAR_MIN == -128, ""); + if (val == -2 || val < CHAR_MIN || val >= CHAR_MAX) { + _hit_char_error = 1; + die_line("Unable to process byte " + to_string(val)); + } + _buffer = (char)val; + return _buffer; +} +void _use_peek(char ch) { + _buffer = -2; + if (ch == '\n') _lineno++; + else _consumed_lineno = _lineno; +} +char _read1() { + char ret = _peek1(); + _use_peek(ret); + return ret; +} +string _token() { + string ret; + for (;;) { + char ch = _peek1(); + if (ch == ' ' || ch == '\n' || ch == -1) { + break; + } + _use_peek(ch); + ret += ch; + } + return ret; +} +string _describe(char ch) { + assert(ch != -2); + if (ch == -1) return "EOF"; + if (ch == ' ') return "SPACE"; + if (ch == '\r') return "CARRIAGE RETURN"; + if (ch == '\n') return "NEWLINE"; + if (ch == '\t') return "TAB"; + if (ch == '\'') return "\"'\""; + return string("'") + ch + "'"; +} + +IntType IO::Int(long long lo, long long hi) { + string s = _token(); + if (s.empty()) die_line("Expected number, saw " + _describe(_peek1())); + try { + long long mul = 1; + int ind = 0; + if (s[0] == '-') { + mul = -1; + ind = 1; + } + if (ind == (int)s.size()) throw false; + char ch = s[ind++]; + if (ch < '0' || ch > '9') throw false; + if (ch == '0' && ind != (int)s.size()) throw false; + long long ret = ch - '0'; + while (ind < (int)s.size()) { + if (ret > LLONG_MAX / 10 - 20 || ret < LLONG_MIN / 10 + 20) + throw false; + ret *= 10; + ch = s[ind++]; + if (ch < '0' || ch > '9') throw false; + ret += ch - '0'; + } + ret *= mul; + if (ret < lo || ret > hi) die_line("Number " + s + " is out of range [" + to_string(lo) + ", " + to_string(hi) + "]"); + return {ret}; + } catch (bool) { + die_line("Unable to parse \"" + s + "\" as integer"); + } +} + +template +vector IO::SpacedInts(long long count, T lo, T hi) { + vector res; + res.reserve(count); + for (int i = 0; i < count; i++) { + if (i != 0) IO::Space(); + res.emplace_back((T)IO::Int(lo, hi)); + } + IO::Endl(); + return res; +} + +vector IO::SpacedFloats(long long count, double lo, double hi) { + vector res; + res.reserve(count); + for (int i = 0; i < count; i++) { + if (i != 0) IO::Space(); + res.emplace_back(IO::Float(lo, hi)); + } + IO::Endl(); + return res; +} + +double IO::Float(double lo, double hi, bool strict) { + string s = _token(); + if (s.empty()) die_line("Expected floating point number, saw " + _describe(_peek1())); + istringstream iss(s); + double res; + string dummy; + iss >> res; + if (!iss || iss >> dummy) die_line("Unable to parse " + s + " as a float"); + if (res < lo || res > hi) die_line("Floating-point number " + s + " is out of range [" + to_string(lo) + ", " + to_string(hi) + "]"); + if (res != res) die_line("Floating-point number " + s + " is NaN"); + if (strict) { + if (s.find('.') != string::npos && s.back() == '0' && s.substr(s.size() - 2) != ".0") + die_line("Number " + s + " has unnecessary trailing zeroes"); + if (s[0] == '0' && s.size() > 1 && s[1] == '0') + die_line("Number " + s + " has unnecessary leading zeroes"); + } + return res; +} + +char IO::Char() { + char ret = _read1(); + if (ret == -1) die_line("Expected character, saw EOF"); + return ret; +} + +void IO::Char(char expected) { + char ret = _peek1(); + if (ret != expected) die_line("Expected " + _describe(expected) + ", saw " + _describe(ret)); + _use_peek(ret); +} + +string IO::Line() { + string ret; + for (;;) { + char ch = IO::Char(); + if (ch == '\n') break; + ret += ch; + } + return ret; +} + +template +void AssertUnique(const Vec& v_) { + Vec v = v_; + auto beg = v.begin(), end = v.end(); + sort(beg, end); + int size = (int)(end - beg); + for (int i = 0; i < size - 1; i++) { + if (v[i] == v[i+1]) { + ostringstream oss; + oss << "Vector contains duplicate value " << v[i]; + die_line(oss.str()); + } + } +} + +int main(int argc, char** argv) { + _validator_inst.construct(argc, argv); + run(); + _validator_inst.destroy(); +} + diff --git a/problemtools/md2html.py b/problemtools/md2html.py new file mode 100644 index 00000000..63b1f6f4 --- /dev/null +++ b/problemtools/md2html.py @@ -0,0 +1,162 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +import html +import os.path +import string +import argparse +from typing import Optional + +import markdown +from markdown.inlinepatterns import InlineProcessor +from markdown.extensions import Extension +import xml.etree.ElementTree as etree + +from . import verifyproblem +from . import problem2html + +def _substitute_template(templatepath: str, templatefile: str, **params) -> str: + """Read the markdown template and substitute in things such as problem name, + statement etc using python's format syntax. + """ + with open(os.path.join(templatepath, templatefile), "r", encoding="utf-8") as template_file: + html_template = template_file.read() % params + return html_template + + +def _get_problem_name(problem: str, language: str = "en") -> Optional[str]: + """Load problem.yaml to get problem name""" + with verifyproblem.Problem(problem) as prob: + config = verifyproblem.ProblemConfig(prob) + if not config.check(None): + print("Please add problem name to problem.yaml when using markdown") + return None + names = config.get("name") + # If there is only one language, per the spec that is the one we want + if len(names) == 1: + return next(iter(names.values())) + + if language not in names: + raise Exception(f"No problem name defined for language {language}") + return names[language] + + +def _samples_to_html(problem: str) -> str: + """Read all samples from the problem directory and convert them to HTML""" + samples_html = "" + sample_path = os.path.join(problem, "data", "sample") + interactive_samples = [] + samples = [] + casenum = 1 + for sample in sorted(os.listdir(sample_path)): + if sample.endswith(".interaction"): + lines = [""" + + + + + +
ReadSample Interaction {}Write
""".format(casenum)] + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: + sample_interaction = infile.readlines() + for interaction in sample_interaction: + data = interaction[1:] + line_type = "" + if interaction[0] == '>': + line_type = "sampleinteractionwrite" + elif interaction[0] == '<': + line_type = "sampleinteractionread" + else: + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(f"""
{data}
""") + + interactive_samples.append(''.join(lines)) + casenum += 1 + continue + if not sample.endswith(".in"): + continue + sample_name = sample[:-3] + outpath = os.path.join(sample_path, sample_name + ".ans") + if not os.path.isfile(outpath): + continue + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: + sample_input = infile.read() + with open(outpath, "r", encoding="utf-8") as outfile: + sample_output = outfile.read() + + samples.append(f""" + + Sample Input %(case)d + Sample Output %(case)d + + +
%(input)s
+
%(output)s
+ """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + casenum += 1 + + if interactive_samples: + samples_html += ''.join(interactive_samples) + if samples: + samples_html += """ + + + %(samples)s + +
+ """ % {"samples": ''.join(samples)} + return samples_html + + +def convert(problem: str, options: argparse.Namespace) -> None: + problembase = os.path.splitext(os.path.basename(problem))[0] + destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + + statement_path = problem2html._find_statement(problem, extension="md", language=options.language) + + if statement_path is None: + raise Exception('No markdown statement found') + + with open(statement_path, "r", encoding="utf-8") as input_file: + text = input_file.read() + statement_html = markdown.markdown(text, extensions=[InlineMathExtension(), "tables"]) + + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), + os.path.join(os.path.dirname(__file__), '../templates/markdown'), + '/usr/lib/problemtools/templates/markdown'] + templatepath = next((p for p in templatepaths + if os.path.isdir(p) and os.path.isfile(os.path.join(p, "default-layout.html"))), + None) + + if templatepath is None: + raise Exception('Could not find directory with markdown templates') + + problem_name = _get_problem_name(problem) + + html_template = _substitute_template(templatepath, "default-layout.html", + statement_html=statement_html, + language=options.language, + title=problem_name or "Missing problem name", + problemid=problembase) + + html_template += _samples_to_html(problem) + + with open(destfile, "w", encoding="utf-8", errors="xmlcharrefreplace") as output_file: + output_file.write(html_template) + + if options.css: + with open("problem.css", "w") as output_file: + with open(os.path.join(templatepath, "problem.css"), "r") as input_file: + output_file.write(input_file.read()) + + +class InlineMathProcessor(InlineProcessor): + def handleMatch(self, m, data): + el = etree.Element('span') + el.attrib['class'] = 'tex2jax_process' + el.text = "$" + m.group(1) + "$" + return el, m.start(0), m.end(0) + +class InlineMathExtension(Extension): + def extendMarkdown(self, md): + MATH_PATTERN = r'\$(.*?)\$' # like $1 + 2$ + md.inlinePatterns.register(InlineMathProcessor(MATH_PATTERN, md), 'inline-math', 200) \ No newline at end of file diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 6bf56192..f0224e89 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -6,65 +6,50 @@ import argparse import logging import subprocess +from typing import Optional + +from . import tex2html +from . import md2html + +SUPPORTED_EXTENSIONS = ("tex", "md") + +def _find_statement(problem: str, extension: str, language: Optional[str]) -> Optional[str]: + """Finds the "best" statement for given language and extension""" + if language is None: + statement_path = os.path.join(problem, f"problem_statement/problem.en.{extension}") + if os.path.isfile(statement_path): + return statement_path + statement_path = os.path.join(problem, f"problem_statement/problem.{extension}") + if os.path.isfile(statement_path): + return statement_path + return None + statement_path = os.path.join(problem, f"problem_statement/problem.{language}.{extension}") + if os.path.isfile(statement_path): + return statement_path + return None + + +def _find_statement_extension(problem: str, language: Optional[str]) -> str: + """Given a language, find whether the extension is tex or md""" + extensions = [] + for ext in SUPPORTED_EXTENSIONS: + if _find_statement(problem, ext, language) is not None: + extensions.append(ext) + # At most one extension per language to avoid arbitrary/hidden priorities + if len(extensions) > 1: + raise Exception(f"""Found more than one type of statement ({' and '.join(extensions)}) + for language {language or 'en'}""") + if len(extensions) == 1: + return extensions[0] + raise Exception(f"No statement found for language {language or 'en'}") -from . import template def convert(options: argparse.Namespace) -> None: - # PlasTeX.Logging statically overwrites logging and formatting, so delay loading - import plasTeX.TeX - import plasTeX.Logging - from .ProblemPlasTeX import ProblemRenderer - from .ProblemPlasTeX import ProblemsetMacros - problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] destdir = string.Template(options.destdir).safe_substitute(problem=problembase) destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - imgbasedir = string.Template(options.imgbasedir).safe_substitute(problem=problembase) - - if options.quiet: - plasTeX.Logging.disableLogging() - else: - plasTeX.Logging.getLogger().setLevel(getattr(logging, options.loglevel.upper())) - plasTeX.Logging.getLogger('status').setLevel(getattr(logging, options.loglevel.upper())) - - texfile = problem - # Set up template if necessary - with template.Template(problem, language=options.language) as templ: - texfile = open(templ.get_file_name(), 'r') - - origcwd = os.getcwd() - - # Setup parser and renderer etc - - # plasTeX version 3 changed the name of this argument (and guarding against this - # by checking plasTeX.__version__ fails on plastex v3.0 which failed to update - # __version__) - try: - tex = plasTeX.TeX.TeX(myfile=texfile) - except Exception: - tex = plasTeX.TeX.TeX(file=texfile) - - ProblemsetMacros.init(tex) - - tex.ownerDocument.config['general']['copy-theme-extras'] = options.css - if not options.headers: - tex.ownerDocument.userdata['noheaders'] = True - tex.ownerDocument.config['files']['filename'] = destfile - tex.ownerDocument.config['images']['filenames'] = 'img-$num(4)' - tex.ownerDocument.config['images']['enabled'] = False - tex.ownerDocument.config['images']['imager'] = 'none' - tex.ownerDocument.config['images']['base-url'] = imgbasedir - # tell plasTeX where to search for problemtools' built-in packages - tex.ownerDocument.config['general']['packages-dirs'] = [os.path.join(os.path.dirname(__file__), 'ProblemPlasTeX')] - - renderer = ProblemRenderer() - - if not options.quiet: - print('Parsing TeX source...') - doc = tex.parse() - texfile.close() # Go to destdir if destdir: @@ -75,12 +60,13 @@ def convert(options: argparse.Namespace) -> None: try: if not options.quiet: print('Rendering!') - renderer.render(doc) - # Annoying: I have not figured out any way of stopping the plasTeX - # renderer from generating a .paux file - if os.path.isfile('.paux'): - os.remove('.paux') + origcwd = os.getcwd() + + if _find_statement_extension(problem, options.language) == "tex": + tex2html.convert(problem, options) + else: + md2html.convert(problem, options) if options.tidy: with open(os.devnull, 'w') as devnull: diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 0f6fc452..ac119d05 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -8,11 +8,15 @@ from . import template -def convert(options: argparse.Namespace) -> bool: +def convert(options: argparse.Namespace, ignore_markdown: bool = False) -> bool: problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + # We skip PDF check when verifying problems with markdown statements + if os.path.isfile(os.path.join(problem, "problem_statement", "problem.%s.md" % options.language)) and ignore_markdown: + return True + # Set up template if necessary with template.Template(problem, language=options.language) as templ: texfile = templ.get_file_name() diff --git a/problemtools/templates/markdown/default-layout.html b/problemtools/templates/markdown/default-layout.html new file mode 100644 index 00000000..a7177fc3 --- /dev/null +++ b/problemtools/templates/markdown/default-layout.html @@ -0,0 +1,35 @@ + + + + +%(title)s + + + + + + + + +
+

%(title)s

+

Problem ID: %(problemid)s

+
+
+ %(statement_html)s +
+ + + \ No newline at end of file diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css new file mode 100644 index 00000000..20448219 --- /dev/null +++ b/problemtools/templates/markdown/problem.css @@ -0,0 +1,90 @@ +.problemheader { + text-align: center; +} + +.problembody { + font-family: 'Times New Roman', Georgia, serif; + font-size: 1.1em; + text-align: justify; + padding-top: 1.5em; +} + +.problembody h2, .problembody h3, .problembody table.sample th { + font-family: Arial, Helvetica, sans-serif; +} + +div.minipage { + display: inline-block; +} + +div.illustration { + float: right; + padding-left: 20px; +} + +img.illustration { + width: 100%; +} + +div.figure { + display: block; + float: none; + margin-left: auto; + margin-right: auto; +} + +.illustration div.description { + font-size: 8pt; + text-align: right; +} + +.problembody p { + text-align: justify; +} + +td { + vertical-align:top; +} + +table, table td { + border: 0; +} + +table.tabular p { + margin: 0; +} + +table.sample { + width: 100%; +} + +table.sample th { + text-align: left; + width: 50%; +} + +table.sample td { + border: 1px solid black; +} + +div.sampleinteractionread { + border: 1px solid black; + width: 60%; + float: left; + margin: 3px 0px 3px 0px; +} + +.sampleinteractionread pre { + margin: 1px 5px 1px 5px; +} + +div.sampleinteractionwrite { + border: 1px solid black; + width: 60%; + float: right; + margin: 3px 0px 3px 0px; +} + +.sampleinteractionwrite pre { + margin: 1px 5px 1px 5px; +} \ No newline at end of file diff --git a/problemtools/tex2html.py b/problemtools/tex2html.py new file mode 100644 index 00000000..8281f804 --- /dev/null +++ b/problemtools/tex2html.py @@ -0,0 +1,67 @@ +import os +import logging +import string +import argparse + +from . import template + + +def convert(problem: str, options: argparse.Namespace) -> None: + # PlasTeX.Logging statically overwrites logging and formatting, so delay loading + import plasTeX.TeX + import plasTeX.Logging + from .ProblemPlasTeX import ProblemRenderer + from .ProblemPlasTeX import ProblemsetMacros + + problembase = os.path.splitext(os.path.basename(problem))[0] + if options.quiet: + plasTeX.Logging.disableLogging() + else: + plasTeX.Logging.getLogger().setLevel(getattr(logging, options.loglevel.upper())) + plasTeX.Logging.getLogger('status').setLevel(getattr(logging, options.loglevel.upper())) + + destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + imgbasedir = string.Template(options.imgbasedir).safe_substitute(problem=problembase) + + texfile = problem + # Set up template if necessary + with template.Template(problem, language=options.language) as templ: + texfile = open(templ.get_file_name(), 'r') + + # Setup parser and renderer etc + + # plasTeX version 3 changed the name of this argument (and guarding against this + # by checking plasTeX.__version__ fails on plastex v3.0 which failed to update + # __version__) + try: + tex = plasTeX.TeX.TeX(myfile=texfile) + except Exception: + tex = plasTeX.TeX.TeX(file=texfile) + + ProblemsetMacros.init(tex) + + tex.ownerDocument.config['general']['copy-theme-extras'] = options.css + if not options.headers: + tex.ownerDocument.userdata['noheaders'] = True + tex.ownerDocument.config['files']['filename'] = destfile + tex.ownerDocument.config['images']['filenames'] = 'img-$num(4)' + tex.ownerDocument.config['images']['enabled'] = False + tex.ownerDocument.config['images']['imager'] = 'none' + tex.ownerDocument.config['images']['base-url'] = imgbasedir + # tell plasTeX where to search for problemtools' built-in packages + tex.ownerDocument.config['general']['packages-dirs'] = [os.path.join(os.path.dirname(__file__), 'ProblemPlasTeX')] + + renderer = ProblemRenderer() + + if not options.quiet: + print('Parsing TeX source...') + doc = tex.parse() + texfile.close() + + + renderer.render(doc) + + # Annoying: I have not figured out any way of stopping the plasTeX + # renderer from generating a .paux file + if os.path.isfile('.paux'): + os.remove('.paux') diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index a45cbf9d..1db4a6e6 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1119,12 +1119,14 @@ def __init__(self, problem: Problem): self._problem = problem self.languages = [] glob_path = os.path.join(problem.probdir, 'problem_statement', 'problem.') - if glob.glob(glob_path + 'tex'): - self.languages.append('') - for f in glob.glob(glob_path + '[a-z][a-z].tex'): - m = re.search("problem.([a-z][a-z]).tex$", f) - assert m - self.languages.append(m.group(1)) + for extension in problem2html.SUPPORTED_EXTENSIONS: + if glob.glob(glob_path + extension): + self.languages.append('') + for f in glob.glob(glob_path + '[a-z][a-z].%s' % extension): + lang = re.search("problem.([a-z][a-z]).%s$" % extension, f).group(1) + if lang in self.languages: + self.error('Language %s has several statement formats' % lang) + self.languages.append(lang) def check(self, context: Context) -> bool: if self._check_res is not None: @@ -1132,9 +1134,9 @@ def check(self, context: Context) -> bool: self._check_res = True if not self.languages: - self.error('No problem statements found (expected problem.tex or problem.[a-z][a-z].tex in problem_statement directory)') + self.error('No problem statements found (expected problem.{tex,md} or problem.[a-z][a-z].{tex,md} in problem_statement directory)') if '' in self.languages and 'en' in self.languages: - self.error("Can't supply both problem.tex and problem.en.tex") + self.error("Can't supply both problem.{tex,md} and problem.en.{tex,md}") for lang in self.languages: try: @@ -1143,7 +1145,7 @@ def check(self, context: Context) -> bool: options.language = lang options.nopdf = True options.quiet = True - if not problem2pdf.convert(options): + if not problem2pdf.convert(options, ignore_markdown=True): langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') except Exception as e: @@ -1165,21 +1167,24 @@ def __str__(self) -> str: def get_config(self) -> dict[str, dict[str, str]]: ret: dict[str, dict[str, str]] = {} - for lang in self.languages: - filename = f'problem.{lang}.tex' if lang != '' else 'problem.tex' - stmt = open(os.path.join(self._problem.probdir, 'problem_statement', filename)).read() - patterns = [ - (r'\\problemname{(.*)}', 'name'), - (r'^%%\s*plainproblemname:(.*)$', 'name'), - ] - for tup in patterns: - pattern = tup[0] - dest = tup[1] - hit = re.search(pattern, stmt, re.MULTILINE) - if hit: - if not dest in ret: - ret[dest] = {} - ret[dest][lang] = hit.group(1).strip() + for extension in problem2html.SUPPORTED_EXTENSIONS: + for lang in self.languages: + filename = f'problem.{lang}.{extension}' if lang != '' else 'problem.{extension}' + if not os.path.isfile(filename): + continue + stmt = open(os.path.join(self._problem.probdir, 'problem_statement', filename)).read() + patterns = [ + (r'\\problemname{(.*)}', 'name'), + (r'^%%\s*plainproblemname:(.*)$', 'name'), + ] + for tup in patterns: + pattern = tup[0] + dest = tup[1] + hit = re.search(pattern, stmt, re.MULTILINE) + if hit: + if not dest in ret: + ret[dest] = {} + ret[dest][lang] = hit.group(1).strip() return ret From 868eb39d990a6ebeba8cebbfc22cd587a38ce978 Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 01:10:58 +0200 Subject: [PATCH 047/272] Added display math --- examples/README.md | 5 +++-- problemtools/md2html.py | 19 +++++++++++++++---- .../templates/markdown/default-layout.html | 3 ++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/examples/README.md b/examples/README.md index 9665f8a7..d646a86d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,5 +28,6 @@ different scores depending on which test groups they solve. It also demonstrates # bplusa -This is an example of a problem using an output validator, showcasing different things to keep in mind -when using output validator. It also demonstrates using Markdown as a statement language. +This is an example of a problem using an output validator, where there are multiple valid answers. +The output validator is written pretty generally, guarding against the most common mistakes when using +output validators. It also demonstrates using Markdown as a statement language. diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 63b1f6f4..1349b206 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -118,7 +118,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() - statement_html = markdown.markdown(text, extensions=[InlineMathExtension(), "tables"]) + statement_html = markdown.markdown(text, extensions=[MathExtension(), "tables"]) templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -156,7 +156,18 @@ def handleMatch(self, m, data): el.text = "$" + m.group(1) + "$" return el, m.start(0), m.end(0) -class InlineMathExtension(Extension): +class DisplayMathProcessor(InlineProcessor): + def handleMatch(self, m, data): + el = etree.Element('div') + el.attrib['class'] = 'tex2jax_process' + el.text = "$$" + m.group(1) + "$$" + return el, m.start(0), m.end(0) + +class MathExtension(Extension): def extendMarkdown(self, md): - MATH_PATTERN = r'\$(.*?)\$' # like $1 + 2$ - md.inlinePatterns.register(InlineMathProcessor(MATH_PATTERN, md), 'inline-math', 200) \ No newline at end of file + # Regex magic so that both $ $ and $$ $$ can coexist + INLINE_MATH_PATTERN = r'(? Date: Thu, 8 Aug 2024 01:32:01 +0200 Subject: [PATCH 048/272] Add dependencies for markdown --- Dockerfile | 1 + README.md | 4 ++-- admin/docker/Dockerfile.minimal | 1 + debian/control | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e9787418..daa50dde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ RUN apt-get update && \ python3-minimal \ python3-pip \ python3-plastex \ + python3-markdown \ python3-yaml \ sudo \ texlive-fonts-recommended \ diff --git a/README.md b/README.md index 601de517..499fe610 100644 --- a/README.md +++ b/README.md @@ -207,13 +207,13 @@ The dependencies needed to *build/install* problemtools can be installed with: And the dependencies needed to *run* problemtools can be installed with: - sudo apt install ghostscript libgmpxx4ldbl python3-minimal python-pkg-resources python3-plastex python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy + sudo apt install ghostscript libgmpxx4ldbl python3-minimal python-pkg-resources python3-plastex python3-markdown python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy ### Fedora On Fedora, these dependencies can be installed with: - sudo dnf install boost-regex gcc gmp-devel gmp-c++ python3 python3-pyyaml texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript + sudo dnf install boost-regex gcc gmp-devel gmp-c++ python3 python3-pyyaml python3-markdown texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript Followed by: diff --git a/admin/docker/Dockerfile.minimal b/admin/docker/Dockerfile.minimal index 534e661f..a44811f5 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -24,6 +24,7 @@ RUN apt update && \ python3-minimal \ python3-yaml \ python3-plastex \ + python3-markdown \ texlive-fonts-recommended \ texlive-lang-cyrillic \ texlive-latex-extra \ diff --git a/debian/control b/debian/control index 42797c8b..43410292 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Homepage: https://github.com/Kattis/problemtools Package: kattis-problemtools Architecture: any -Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, python3-plastex, python3-pkg-resources, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm +Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, python3-plastex, python3-markdown, python3-pkg-resources, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm Recommends: gcc, g++ Description: Kattis Problem Tools These are tools to manage and verify problem packages in the From 05f6372cf850ef128e52b746687e1cc98c2b8ae0 Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 02:12:18 +0200 Subject: [PATCH 049/272] Style markdown tables --- examples/guess/problem.yaml | 2 + .../guess/problem_statement/problem.sv.md | 20 ++ examples/oddecho/problem.yaml | 4 +- .../oddecho/problem_statement/problem.sv.md | 27 ++ examples/problemset.cls | 257 ++++++++++++++++++ examples/tmpe5kbz3qn.tex | 6 + oddecho_html/index.html | 136 +++++++++ oddecho_html/problem.css | 105 +++++++ problemtools/md2html.py | 19 +- .../templates/markdown/default-layout.html | 2 +- problemtools/templates/markdown/problem.css | 15 + 11 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 examples/guess/problem_statement/problem.sv.md create mode 100644 examples/oddecho/problem_statement/problem.sv.md create mode 100644 examples/problemset.cls create mode 100644 examples/tmpe5kbz3qn.tex create mode 100644 oddecho_html/index.html create mode 100644 oddecho_html/problem.css diff --git a/examples/guess/problem.yaml b/examples/guess/problem.yaml index fcb51934..c1e29500 100644 --- a/examples/guess/problem.yaml +++ b/examples/guess/problem.yaml @@ -2,6 +2,8 @@ source: Kattis license: cc by-sa validation: custom interactive +name: + sv: Gissa talet # Override standard limits: say that the TLE solutions provided should # be at least 4 times above the time limit in order for us to be diff --git a/examples/guess/problem_statement/problem.sv.md b/examples/guess/problem_statement/problem.sv.md new file mode 100644 index 00000000..c1edbd67 --- /dev/null +++ b/examples/guess/problem_statement/problem.sv.md @@ -0,0 +1,20 @@ +Jag tänker på ett hemligt tal mellan $1$ and $100$, kan du gissa vilket? +Givet en gissning kommer jag att berätta om din gissning +var för stor, för liten eller rätt. Du får bara $10$ gissningar, använd +dem klokt! + + +## Interaktion +Ditt program ska skriva ut gissningar om talet. +En gissning är en rad som enbart innehåller ett heltal mellan $1$ och $1000$. +Efter varje gissning måste du flusha standard out. + +Efter varje gissning kan du läs svaret på standard in. +Detta svar är ett av tre ord: + +- `lower` om talet jag tänker på är lägre än din gissning, +- `higher` om talet jag tänker på är högre än din gissning, eller +- `correct` om din gissning är korrekt. + +Efter att ha gissat rätt ska du avsluta ditt program. +Om du gissar fel $10$ gånger får du inga fler chanser och ditt program kommer avbrytas. diff --git a/examples/oddecho/problem.yaml b/examples/oddecho/problem.yaml index 1fcd5e21..f213fbd9 100644 --- a/examples/oddecho/problem.yaml +++ b/examples/oddecho/problem.yaml @@ -2,6 +2,8 @@ license: cc by-sa author: Johan Sannemo source: Principles of Algorithmic Problem Solving type: scoring -name: Echo +name: + en: Echo + sv: Eko grading: show_test_data_groups: true diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md new file mode 100644 index 00000000..e0af2eea --- /dev/null +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -0,0 +1,27 @@ +**ECHO! Echo! Ech...** + +Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, är du inte tillräckligt lycklig för att komma ut och skrika i grottor så ofta. Istället skulle du vilja implementera ett program som fungerar som en ersättning för en grotta. + +Ibland vill du mata in några ord i programmet och få dem ekade tillbaka till dig. Men, som det är välkänt, om du skriker för snabbt i en grotta kan ekot störa de nya ord du säger. Mer specifikt, varje annat ord du säger kommer att störa ekot av ditt tidigare ord. Därför kommer endast det första, tredje, femte och så vidare ordet faktiskt att producera ett eko. + +Din uppgift är att skriva ett program som simulerar detta beteende. + +## Inmatning + +Den första raden av inmatningen innehåller ett heltal $N$ ($1 \le N \le 10$). + +De följande $N$ raderna innehåller vardera ett ord. Varje ord är högst $100$ bokstäver långt och innehåller endast bokstäverna `a-z`. + +## Utmatning + +Skriv ut de ord som har udda index (dvs. första, tredje, femte och så vidare) i inmatningen. + +## Bedömning + +Din lösning kommer att testas på en uppsättning testgrupper, där varje grupp är värd ett antal poäng. För att få poängen för en testgrupp måste du lösa alla testfall i den testgruppen. + +| Grupp | Poäng | Begränsningar | +|-------|-------|--------------------------| +| 1 | 1 | $N$ är alltid $5$ | +| 2 | 1 | Inga ytterligare begränsningar | + diff --git a/examples/problemset.cls b/examples/problemset.cls new file mode 100644 index 00000000..8501dea1 --- /dev/null +++ b/examples/problemset.cls @@ -0,0 +1,257 @@ +\NeedsTeXFormat{LaTeX2e} +\ProvidesClass{problemset}[2011/08/26 Problem Set For ACM-Style Programming Contests] + + +% Options to add: +% noproblemnumbers +% nosamplenumbers +% nopagenumbers +% nofooter +% noheader + +\newif\ifplastex +\plastexfalse + +\newif\if@footer\@footertrue +\DeclareOption{nofooter}{\@footerfalse} + +\newif\if@problemnumbers\@problemnumberstrue +\DeclareOption{noproblemnumbers}{\@problemnumbersfalse} + +\newif\if@clearevenpages\@clearevenpagestrue + +\DeclareOption{plainproblems}{ + \@footerfalse + \@problemnumbersfalse + \@clearevenpagesfalse +} + +%\DeclareOption{noproblemnumbers}{...} + +\DeclareOption*{\PassOptionsToClass{\CurrentOption}{article}} +\ProcessOptions\relax + +\LoadClass{article} + +\RequirePackage{times} % Font choice +\RequirePackage{amsmath} % AMS +\RequirePackage{amssymb} % AMS +\RequirePackage[OT2,T1]{fontenc} % Cyrillic and standard % TODO: make alphabet options more general +\RequirePackage[utf8]{inputenc} % UTF-8 support +\RequirePackage{fancyhdr} % Headers +\RequirePackage{graphicx} % Graphics +\RequirePackage{subfigure} % Subfigures +\RequirePackage{wrapfig} % Illustrations +\RequirePackage{import} % Proper file inclusion +\RequirePackage{verbatim} % For samples +\RequirePackage{fullpage} % Set up margins for full page +\RequirePackage{url} % Urls +\RequirePackage[colorlinks=true]{hyperref} +\RequirePackage{ulem} % \sout + + +%% Commands used to set name, logo, etc of contest +\newcommand*{\contestname}[1]{\def\@contestname{#1}} +\newcommand*{\contestshortname}[1]{\def\@contestshortname{#1}} +\newcommand*{\contestlogo}[1]{\def\@contestlogo{#1}} +\newcommand*{\headerlogo}[1]{\def\@headerlogo{#1}} +\newcommand*{\location}[1]{\def\@location{#1}} +\newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} +\newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}} +\contestname{} +\contestshortname{} +\contestlogo{} +\headerlogo{} +\location{} +\licenseblurb{} +\problemlanguage{} + + + +% Typesetting sections in a problem + +\renewcommand\section{\@startsection{section}{1}{\z@}% + {-3.5ex \@plus -1ex \@minus -.2ex}% + {2.3ex \@plus.2ex}% + {\normalfont\large\sf\bfseries}} + +\renewcommand\subsection{\@startsection{subsection}{2}{\z@}% + {-3.25ex\@plus -1ex \@minus -.2ex}% + {1.5ex \@plus .2ex}% + {\normalfont\normalsize\sf\bfseries}} + +\renewcommand{\contentsname}{Problems} + + +% TODO: make last command of illustration/figure optional + +\newcommand{\illustration}[3]{ + \begin{wrapfigure}{r}{#1\textwidth} + \includegraphics[width=#1\textwidth]{#2} + \begin{flushright} + \vspace{-9pt} + \tiny #3 + \end{flushright} + \vspace{-15pt} + \end{wrapfigure} + \par + \noindent +} + + +%% Redefine cleardoublepage to put a text on even-numbered empty +%% pages. +\newcommand{\makeemptypage}{ + ~\thispagestyle{empty} + \vfill + \centerline{\Large \textsf{ This page is intentionally left (almost) blank.}} + \vfill + \clearpage +} +\renewcommand{\cleardoublepage}{ + \clearpage% + \ifodd\value{page}\else\makeemptypage\fi% +} + +\newcommand{\clearproblemsetpage}{ + \if@clearevenpages + \cleardoublepage + \else + \clearpage + \fi +} + + +%% Set up a problem counter and number problems A B C ... +\newcounter{problemcount} +\setcounter{problemcount}{0} +\newcommand{\problemnumber}{\Alph{problemcount}} + +%% Number figures as A.1 A.2... B.1 B.2... (except if we're converting to HTML) +\ifplastex\else +\renewcommand{\thefigure}{\problemnumber.\arabic{figure}} +\fi + + +%% Command for starting new problem + +%% Problem inclusion +\newcommand{\includeproblem}[3]{ + \startproblem{#1}{#2}{#3} + \import{#1/problem_statement/}{problem\@problemlanguage.tex} +} + +\newcommand{\startproblem}[3]{ + \clearproblemsetpage + \refstepcounter{problemcount} + \setcounter{samplenum}{0} + \setcounter{figure}{0}% + \def\@problemid{#1} + \def\@problemname{#2} + \def\@timelimit{#3} + \problemheader{\@problemname}{\@problemid} +} + +\newcommand{\problemheader}[2]{ + \begin{center} + \textsf{ + {\huge #1\\} + {\Large Problem ID: #2\\} + } + \end{center} +} + +%% Commands related to sample data + +%% Sample counter +\newcounter{samplenum} +\newcommand{\sampleid}{\arabic{samplenum}} + +%% Define the command used to give sample data +%% Takes filename as parameter + +\newcommand{\includesample}[1]{ + \displaysample{\@problemid/data/sample/#1} +} + +\newcommand{\displaysample}[1]{ + \IfFileExists{#1.in}{}{\ClassError{problemset}{Can't find file '#1.in'}{}} + \IfFileExists{#1.ans}{}{\ClassError{problemset}{Can't find file '#1.ans'}{}} + \refstepcounter{samplenum} + \par + \vspace{0.4cm} + \noindent + \sampletable + {Sample Input \sampleid}{#1.in} + {Sample Output \sampleid}{#1.ans} +} + +\newcommand{\sampletable}[4]{ + \begin{tabular}{|l|l|} + \multicolumn{1}{l}{\textsf{\textbf{#1}}} & + \multicolumn{1}{l}{\textsf{\textbf{#3}}} \\ + \hline + \parbox[t]{0.475\textwidth}{\vspace{-0.5cm}\verbatiminput{#2}} + & + \parbox[t]{0.475\textwidth}{\vspace{-0.5cm}\verbatiminput{#4}} + \\ + \hline + \end{tabular} +} + + +% Remaining part of file is headers and toc, not tested with plasTeX +% and should not be used in plastex mode +\ifplastex\else + + +%% Set up headers +\fancypagestyle{problem}{ + \fancyhf{} % Clear old junk +% \ifx \@headerlogo \@empty\relax \else +% \fancyhead[C]{ +% \includegraphics[scale=0.3]{\@headerlogo} +% } +% \fi + \if@footer + \fancyhead[L]{ + \emph{ + \@contestshortname{} + \if@problemnumbers Problem \problemnumber:{} \fi + \@problemname + } + } + \fancyhead[R]{\thepage} + \fancyfoot[L]{ + \emph{\@licenseblurb} + } +% \fancyfoot[R]{\includegraphics[scale=0.5]{cc-by-sa} } + \fi +} +\renewcommand{\headrulewidth}{0pt} +\pagestyle{problem} + +\AtBeginDocument{ + % FIXME: Figure out how to do this in a header-indep. way. +% \ifx \@headerlogo \@empty \relax\else + \addtolength{\headheight}{12pt} + \addtolength{\topmargin}{-30pt} + \addtolength{\textheight}{18pt} +% \fi + \setlength{\headsep}{25pt} +} + + +% Set up table of contents for cover page +\AtBeginDocument{ + \addtocontents{toc}{\protect\begin{tabular}{cl}} +} +\AtEndDocument{ + \clearproblemsetpage + % Annoyingly enough addtocontents won't work at end of doc + \immediate\write\@auxout{% + \string\@writefile{toc}{\string\end{tabular}}% + } +} + +\fi diff --git a/examples/tmpe5kbz3qn.tex b/examples/tmpe5kbz3qn.tex new file mode 100644 index 00000000..ba1c2e5b --- /dev/null +++ b/examples/tmpe5kbz3qn.tex @@ -0,0 +1,6 @@ +\documentclass[plainproblems]{problemset} + +\problemlanguage{.sv} + +\begin{document} + diff --git a/oddecho_html/index.html b/oddecho_html/index.html new file mode 100644 index 00000000..3e88327f --- /dev/null +++ b/oddecho_html/index.html @@ -0,0 +1,136 @@ + + + + + Echo + + + + + +
+

Echo

+

Problem ID: oddecho

+
+
+

ECHO! Echo! Ech...

+

Du älskar att skrika i grottor för att höra dina ord ekade + tillbaka till dig. Tyvärr, som en hårt arbetande + mjukvaruingenjör, är du inte tillräckligt lycklig för att komma + ut och skrika i grottor så ofta. Istället skulle du vilja + implementera ett program som fungerar som en ersättning för en + grotta.

+

Ibland vill du mata in några ord i programmet och få dem + ekade tillbaka till dig. Men, som det är välkänt, om du skriker + för snabbt i en grotta kan ekot störa de nya ord du säger. Mer + specifikt, varje annat ord du säger kommer att störa ekot av + ditt tidigare ord. Därför kommer endast det första, tredje, + femte och så vidare ordet faktiskt att producera ett eko.

+

Din uppgift är att skriva ett program som simulerar detta + beteende.

+

Inmatning

+

Den första raden av inmatningen innehåller ett heltal + $N$ ($1 \le N \le 10$).

+

De följande $N$ raderna + innehåller vardera ett ord. Varje ord är högst $100$ bokstäver långt och innehåller + endast bokstäverna a-z.

+

Utmatning

+

Skriv ut de ord som har udda index (dvs. första, tredje, + femte och så vidare) i inmatningen.

+

Bedömning

+

Din lösning kommer att testas på en uppsättning testgrupper, + där varje grupp är värd ett antal poäng. För att få poängen för + en testgrupp måste du lösa alla testfall i den testgruppen.

+ + + + + + + + + + + + + + + + + + + + +
GruppPoängBegränsningar
11$N$ är alltid + $5$
21Inga ytterligare begränsningar
+
+ + + + + + + + + + + + + + + + + + + +
Sample Input 1Sample Output 1
+
5
+hello
+i
+am
+an
+echo
+
+
+
hello
+am
+echo
+
+
Sample Input 2Sample Output 2
+
10
+only
+if
+these
+oddindexed
+words
+appear
+are
+you
+correct
+output
+
+
+
only
+these
+words
+are
+correct
+
+
+ + diff --git a/oddecho_html/problem.css b/oddecho_html/problem.css new file mode 100644 index 00000000..0b5be150 --- /dev/null +++ b/oddecho_html/problem.css @@ -0,0 +1,105 @@ +.problemheader { + text-align: center; +} + +.problembody { + font-family: 'Times New Roman', Georgia, serif; + font-size: 1.1em; + text-align: justify; + padding-top: 1.5em; +} + +.problembody h2, .problembody h3, .problembody table.sample th { + font-family: Arial, Helvetica, sans-serif; +} + +.markdown-table { + border-collapse: collapse; + width: 100%; +} + +.markdown-table th, .markdown-table td { + border: 1px solid black; + padding: 8px; + text-align: left; +} + +.markdown-table th { + background-color: #f2f2f2; +} + +div.minipage { + display: inline-block; +} + +div.illustration { + float: right; + padding-left: 20px; +} + +img.illustration { + width: 100%; +} + +div.figure { + display: block; + float: none; + margin-left: auto; + margin-right: auto; +} + +.illustration div.description { + font-size: 8pt; + text-align: right; +} + +.problembody p { + text-align: justify; +} + +td { + vertical-align:top; +} + +table, table td { + border: 0; +} + +table.tabular p { + margin: 0; +} + +table.sample { + width: 100%; +} + +table.sample th { + text-align: left; + width: 50%; +} + +table.sample td { + border: 1px solid black; +} + +div.sampleinteractionread { + border: 1px solid black; + width: 60%; + float: left; + margin: 3px 0px 3px 0px; +} + +.sampleinteractionread pre { + margin: 1px 5px 1px 5px; +} + +div.sampleinteractionwrite { + border: 1px solid black; + width: 60%; + float: right; + margin: 3px 0px 3px 0px; +} + +.sampleinteractionwrite pre { + margin: 1px 5px 1px 5px; +} \ No newline at end of file diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 1349b206..6140d607 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -7,6 +7,7 @@ from typing import Optional import markdown +from markdown.treeprocessors import Treeprocessor from markdown.inlinepatterns import InlineProcessor from markdown.extensions import Extension import xml.etree.ElementTree as etree @@ -118,7 +119,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), "tables"]) + statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables"]) templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -148,7 +149,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: with open(os.path.join(templatepath, "problem.css"), "r") as input_file: output_file.write(input_file.read()) - +# Parse inline math $a+b$ class InlineMathProcessor(InlineProcessor): def handleMatch(self, m, data): el = etree.Element('span') @@ -156,6 +157,7 @@ def handleMatch(self, m, data): el.text = "$" + m.group(1) + "$" return el, m.start(0), m.end(0) +# Parse display math $$a+b$$ class DisplayMathProcessor(InlineProcessor): def handleMatch(self, m, data): el = etree.Element('div') @@ -163,6 +165,7 @@ def handleMatch(self, m, data): el.text = "$$" + m.group(1) + "$$" return el, m.start(0), m.end(0) +# Add the display+inline math class MathExtension(Extension): def extendMarkdown(self, md): # Regex magic so that both $ $ and $$ $$ can coexist @@ -171,3 +174,15 @@ def extendMarkdown(self, md): md.inlinePatterns.register(DisplayMathProcessor(DISPLAY_MATH_PATTERN, md), 'display-math', 200) md.inlinePatterns.register(InlineMathProcessor(INLINE_MATH_PATTERN, md), 'inline-math', 201) + +# Add class markdown-table to all tables for easier styling +# (Otherwise, we will end up styling sample tables) +class AddClassTreeprocessor(Treeprocessor): + def run(self, root): + for table in root.findall(".//table"): + if 'class' not in table.attrib: + table.set('class', 'markdown-table') # Replace 'my-custom-class' with your desired class name + +class AddClassExtension(Extension): + def extendMarkdown(self, md): + md.treeprocessors.register(AddClassTreeprocessor(md), 'add_class', 15) diff --git a/problemtools/templates/markdown/default-layout.html b/problemtools/templates/markdown/default-layout.html index 0285e9d4..814324c1 100644 --- a/problemtools/templates/markdown/default-layout.html +++ b/problemtools/templates/markdown/default-layout.html @@ -33,4 +33,4 @@

Problem ID: %(problemid)s

- \ No newline at end of file + diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css index 20448219..0b5be150 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown/problem.css @@ -13,6 +13,21 @@ font-family: Arial, Helvetica, sans-serif; } +.markdown-table { + border-collapse: collapse; + width: 100%; +} + +.markdown-table th, .markdown-table td { + border: 1px solid black; + padding: 8px; + text-align: left; +} + +.markdown-table th { + background-color: #f2f2f2; +} + div.minipage { display: inline-block; } From 673773e4363d40b80bbf62feb149093d2ffaacfa Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 02:13:41 +0200 Subject: [PATCH 050/272] Remove temp files --- examples/problemset.cls | 257 --------------------------------------- examples/tmpe5kbz3qn.tex | 6 - oddecho_html/index.html | 136 --------------------- oddecho_html/problem.css | 105 ---------------- 4 files changed, 504 deletions(-) delete mode 100644 examples/problemset.cls delete mode 100644 examples/tmpe5kbz3qn.tex delete mode 100644 oddecho_html/index.html delete mode 100644 oddecho_html/problem.css diff --git a/examples/problemset.cls b/examples/problemset.cls deleted file mode 100644 index 8501dea1..00000000 --- a/examples/problemset.cls +++ /dev/null @@ -1,257 +0,0 @@ -\NeedsTeXFormat{LaTeX2e} -\ProvidesClass{problemset}[2011/08/26 Problem Set For ACM-Style Programming Contests] - - -% Options to add: -% noproblemnumbers -% nosamplenumbers -% nopagenumbers -% nofooter -% noheader - -\newif\ifplastex -\plastexfalse - -\newif\if@footer\@footertrue -\DeclareOption{nofooter}{\@footerfalse} - -\newif\if@problemnumbers\@problemnumberstrue -\DeclareOption{noproblemnumbers}{\@problemnumbersfalse} - -\newif\if@clearevenpages\@clearevenpagestrue - -\DeclareOption{plainproblems}{ - \@footerfalse - \@problemnumbersfalse - \@clearevenpagesfalse -} - -%\DeclareOption{noproblemnumbers}{...} - -\DeclareOption*{\PassOptionsToClass{\CurrentOption}{article}} -\ProcessOptions\relax - -\LoadClass{article} - -\RequirePackage{times} % Font choice -\RequirePackage{amsmath} % AMS -\RequirePackage{amssymb} % AMS -\RequirePackage[OT2,T1]{fontenc} % Cyrillic and standard % TODO: make alphabet options more general -\RequirePackage[utf8]{inputenc} % UTF-8 support -\RequirePackage{fancyhdr} % Headers -\RequirePackage{graphicx} % Graphics -\RequirePackage{subfigure} % Subfigures -\RequirePackage{wrapfig} % Illustrations -\RequirePackage{import} % Proper file inclusion -\RequirePackage{verbatim} % For samples -\RequirePackage{fullpage} % Set up margins for full page -\RequirePackage{url} % Urls -\RequirePackage[colorlinks=true]{hyperref} -\RequirePackage{ulem} % \sout - - -%% Commands used to set name, logo, etc of contest -\newcommand*{\contestname}[1]{\def\@contestname{#1}} -\newcommand*{\contestshortname}[1]{\def\@contestshortname{#1}} -\newcommand*{\contestlogo}[1]{\def\@contestlogo{#1}} -\newcommand*{\headerlogo}[1]{\def\@headerlogo{#1}} -\newcommand*{\location}[1]{\def\@location{#1}} -\newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} -\newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}} -\contestname{} -\contestshortname{} -\contestlogo{} -\headerlogo{} -\location{} -\licenseblurb{} -\problemlanguage{} - - - -% Typesetting sections in a problem - -\renewcommand\section{\@startsection{section}{1}{\z@}% - {-3.5ex \@plus -1ex \@minus -.2ex}% - {2.3ex \@plus.2ex}% - {\normalfont\large\sf\bfseries}} - -\renewcommand\subsection{\@startsection{subsection}{2}{\z@}% - {-3.25ex\@plus -1ex \@minus -.2ex}% - {1.5ex \@plus .2ex}% - {\normalfont\normalsize\sf\bfseries}} - -\renewcommand{\contentsname}{Problems} - - -% TODO: make last command of illustration/figure optional - -\newcommand{\illustration}[3]{ - \begin{wrapfigure}{r}{#1\textwidth} - \includegraphics[width=#1\textwidth]{#2} - \begin{flushright} - \vspace{-9pt} - \tiny #3 - \end{flushright} - \vspace{-15pt} - \end{wrapfigure} - \par - \noindent -} - - -%% Redefine cleardoublepage to put a text on even-numbered empty -%% pages. -\newcommand{\makeemptypage}{ - ~\thispagestyle{empty} - \vfill - \centerline{\Large \textsf{ This page is intentionally left (almost) blank.}} - \vfill - \clearpage -} -\renewcommand{\cleardoublepage}{ - \clearpage% - \ifodd\value{page}\else\makeemptypage\fi% -} - -\newcommand{\clearproblemsetpage}{ - \if@clearevenpages - \cleardoublepage - \else - \clearpage - \fi -} - - -%% Set up a problem counter and number problems A B C ... -\newcounter{problemcount} -\setcounter{problemcount}{0} -\newcommand{\problemnumber}{\Alph{problemcount}} - -%% Number figures as A.1 A.2... B.1 B.2... (except if we're converting to HTML) -\ifplastex\else -\renewcommand{\thefigure}{\problemnumber.\arabic{figure}} -\fi - - -%% Command for starting new problem - -%% Problem inclusion -\newcommand{\includeproblem}[3]{ - \startproblem{#1}{#2}{#3} - \import{#1/problem_statement/}{problem\@problemlanguage.tex} -} - -\newcommand{\startproblem}[3]{ - \clearproblemsetpage - \refstepcounter{problemcount} - \setcounter{samplenum}{0} - \setcounter{figure}{0}% - \def\@problemid{#1} - \def\@problemname{#2} - \def\@timelimit{#3} - \problemheader{\@problemname}{\@problemid} -} - -\newcommand{\problemheader}[2]{ - \begin{center} - \textsf{ - {\huge #1\\} - {\Large Problem ID: #2\\} - } - \end{center} -} - -%% Commands related to sample data - -%% Sample counter -\newcounter{samplenum} -\newcommand{\sampleid}{\arabic{samplenum}} - -%% Define the command used to give sample data -%% Takes filename as parameter - -\newcommand{\includesample}[1]{ - \displaysample{\@problemid/data/sample/#1} -} - -\newcommand{\displaysample}[1]{ - \IfFileExists{#1.in}{}{\ClassError{problemset}{Can't find file '#1.in'}{}} - \IfFileExists{#1.ans}{}{\ClassError{problemset}{Can't find file '#1.ans'}{}} - \refstepcounter{samplenum} - \par - \vspace{0.4cm} - \noindent - \sampletable - {Sample Input \sampleid}{#1.in} - {Sample Output \sampleid}{#1.ans} -} - -\newcommand{\sampletable}[4]{ - \begin{tabular}{|l|l|} - \multicolumn{1}{l}{\textsf{\textbf{#1}}} & - \multicolumn{1}{l}{\textsf{\textbf{#3}}} \\ - \hline - \parbox[t]{0.475\textwidth}{\vspace{-0.5cm}\verbatiminput{#2}} - & - \parbox[t]{0.475\textwidth}{\vspace{-0.5cm}\verbatiminput{#4}} - \\ - \hline - \end{tabular} -} - - -% Remaining part of file is headers and toc, not tested with plasTeX -% and should not be used in plastex mode -\ifplastex\else - - -%% Set up headers -\fancypagestyle{problem}{ - \fancyhf{} % Clear old junk -% \ifx \@headerlogo \@empty\relax \else -% \fancyhead[C]{ -% \includegraphics[scale=0.3]{\@headerlogo} -% } -% \fi - \if@footer - \fancyhead[L]{ - \emph{ - \@contestshortname{} - \if@problemnumbers Problem \problemnumber:{} \fi - \@problemname - } - } - \fancyhead[R]{\thepage} - \fancyfoot[L]{ - \emph{\@licenseblurb} - } -% \fancyfoot[R]{\includegraphics[scale=0.5]{cc-by-sa} } - \fi -} -\renewcommand{\headrulewidth}{0pt} -\pagestyle{problem} - -\AtBeginDocument{ - % FIXME: Figure out how to do this in a header-indep. way. -% \ifx \@headerlogo \@empty \relax\else - \addtolength{\headheight}{12pt} - \addtolength{\topmargin}{-30pt} - \addtolength{\textheight}{18pt} -% \fi - \setlength{\headsep}{25pt} -} - - -% Set up table of contents for cover page -\AtBeginDocument{ - \addtocontents{toc}{\protect\begin{tabular}{cl}} -} -\AtEndDocument{ - \clearproblemsetpage - % Annoyingly enough addtocontents won't work at end of doc - \immediate\write\@auxout{% - \string\@writefile{toc}{\string\end{tabular}}% - } -} - -\fi diff --git a/examples/tmpe5kbz3qn.tex b/examples/tmpe5kbz3qn.tex deleted file mode 100644 index ba1c2e5b..00000000 --- a/examples/tmpe5kbz3qn.tex +++ /dev/null @@ -1,6 +0,0 @@ -\documentclass[plainproblems]{problemset} - -\problemlanguage{.sv} - -\begin{document} - diff --git a/oddecho_html/index.html b/oddecho_html/index.html deleted file mode 100644 index 3e88327f..00000000 --- a/oddecho_html/index.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - Echo - - - - - -
-

Echo

-

Problem ID: oddecho

-
-
-

ECHO! Echo! Ech...

-

Du älskar att skrika i grottor för att höra dina ord ekade - tillbaka till dig. Tyvärr, som en hårt arbetande - mjukvaruingenjör, är du inte tillräckligt lycklig för att komma - ut och skrika i grottor så ofta. Istället skulle du vilja - implementera ett program som fungerar som en ersättning för en - grotta.

-

Ibland vill du mata in några ord i programmet och få dem - ekade tillbaka till dig. Men, som det är välkänt, om du skriker - för snabbt i en grotta kan ekot störa de nya ord du säger. Mer - specifikt, varje annat ord du säger kommer att störa ekot av - ditt tidigare ord. Därför kommer endast det första, tredje, - femte och så vidare ordet faktiskt att producera ett eko.

-

Din uppgift är att skriva ett program som simulerar detta - beteende.

-

Inmatning

-

Den första raden av inmatningen innehåller ett heltal - $N$ ($1 \le N \le 10$).

-

De följande $N$ raderna - innehåller vardera ett ord. Varje ord är högst $100$ bokstäver långt och innehåller - endast bokstäverna a-z.

-

Utmatning

-

Skriv ut de ord som har udda index (dvs. första, tredje, - femte och så vidare) i inmatningen.

-

Bedömning

-

Din lösning kommer att testas på en uppsättning testgrupper, - där varje grupp är värd ett antal poäng. För att få poängen för - en testgrupp måste du lösa alla testfall i den testgruppen.

- - - - - - - - - - - - - - - - - - - - -
GruppPoängBegränsningar
11$N$ är alltid - $5$
21Inga ytterligare begränsningar
-
- - - - - - - - - - - - - - - - - - - -
Sample Input 1Sample Output 1
-
5
-hello
-i
-am
-an
-echo
-
-
-
hello
-am
-echo
-
-
Sample Input 2Sample Output 2
-
10
-only
-if
-these
-oddindexed
-words
-appear
-are
-you
-correct
-output
-
-
-
only
-these
-words
-are
-correct
-
-
- - diff --git a/oddecho_html/problem.css b/oddecho_html/problem.css deleted file mode 100644 index 0b5be150..00000000 --- a/oddecho_html/problem.css +++ /dev/null @@ -1,105 +0,0 @@ -.problemheader { - text-align: center; -} - -.problembody { - font-family: 'Times New Roman', Georgia, serif; - font-size: 1.1em; - text-align: justify; - padding-top: 1.5em; -} - -.problembody h2, .problembody h3, .problembody table.sample th { - font-family: Arial, Helvetica, sans-serif; -} - -.markdown-table { - border-collapse: collapse; - width: 100%; -} - -.markdown-table th, .markdown-table td { - border: 1px solid black; - padding: 8px; - text-align: left; -} - -.markdown-table th { - background-color: #f2f2f2; -} - -div.minipage { - display: inline-block; -} - -div.illustration { - float: right; - padding-left: 20px; -} - -img.illustration { - width: 100%; -} - -div.figure { - display: block; - float: none; - margin-left: auto; - margin-right: auto; -} - -.illustration div.description { - font-size: 8pt; - text-align: right; -} - -.problembody p { - text-align: justify; -} - -td { - vertical-align:top; -} - -table, table td { - border: 0; -} - -table.tabular p { - margin: 0; -} - -table.sample { - width: 100%; -} - -table.sample th { - text-align: left; - width: 50%; -} - -table.sample td { - border: 1px solid black; -} - -div.sampleinteractionread { - border: 1px solid black; - width: 60%; - float: left; - margin: 3px 0px 3px 0px; -} - -.sampleinteractionread pre { - margin: 1px 5px 1px 5px; -} - -div.sampleinteractionwrite { - border: 1px solid black; - width: 60%; - float: right; - margin: 3px 0px 3px 0px; -} - -.sampleinteractionwrite pre { - margin: 1px 5px 1px 5px; -} \ No newline at end of file From 1c64085aafc4b5b35e1d24d4685e17ba15720917 Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 02:37:33 +0200 Subject: [PATCH 051/272] Statement fix --- .../oddecho/problem_statement/problem.sv.md | 17 ++++++++++------- problemtools/md2html.py | 4 ++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index e0af2eea..52ebedf7 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,24 +1,27 @@ **ECHO! Echo! Ech...** -Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, är du inte tillräckligt lycklig för att komma ut och skrika i grottor så ofta. Istället skulle du vilja implementera ett program som fungerar som en ersättning för en grotta. +Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, har du +inte tid för att komma ut och skrika i grottor så ofta. Istället skulle du vilja implementera ett program som fungerar som en ersättning för en grotta. -Ibland vill du mata in några ord i programmet och få dem ekade tillbaka till dig. Men, som det är välkänt, om du skriker för snabbt i en grotta kan ekot störa de nya ord du säger. Mer specifikt, varje annat ord du säger kommer att störa ekot av ditt tidigare ord. Därför kommer endast det första, tredje, femte och så vidare ordet faktiskt att producera ett eko. +Ibland vill du mata in några ord i programmet och få dem ekade tillbaka till dig. Men, som det är välkänt, om du skriker för snabbt i en grotta kan ekot störa de nya ord du säger. Mer specifikt, vartannat ord du säger kommer att störa ekot av ditt tidigare ord. Därför kommer endast det första, tredje, femte och så vidare ordet faktiskt att producera ett eko. Din uppgift är att skriva ett program som simulerar detta beteende. -## Inmatning +## Indata -Den första raden av inmatningen innehåller ett heltal $N$ ($1 \le N \le 10$). +Den första raden av indata innehåller ett heltal $N$ ($1 \le N \le 10$). De följande $N$ raderna innehåller vardera ett ord. Varje ord är högst $100$ bokstäver långt och innehåller endast bokstäverna `a-z`. -## Utmatning +## Utdata Skriv ut de ord som har udda index (dvs. första, tredje, femte och så vidare) i inmatningen. -## Bedömning -Din lösning kommer att testas på en uppsättning testgrupper, där varje grupp är värd ett antal poäng. För att få poängen för en testgrupp måste du lösa alla testfall i den testgruppen. +## Poängsättning + +Din lösning kommer att testas på en mängd testfallsgrupper. +För att få poäng för en grupp så måste du klara alla testfall i gruppen. | Grupp | Poäng | Begränsningar | |-------|-------|--------------------------| diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 6140d607..bc7ccc5c 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -168,7 +168,7 @@ def handleMatch(self, m, data): # Add the display+inline math class MathExtension(Extension): def extendMarkdown(self, md): - # Regex magic so that both $ $ and $$ $$ can coexist + # Regex magic so that both $ $ and $$ $$ can coexist. Written by a wizard (ChatGPT) INLINE_MATH_PATTERN = r'(? Date: Thu, 8 Aug 2024 02:59:22 +0200 Subject: [PATCH 052/272] Some refactoring --- problemtools/md2html.py | 125 +++++++++++++++++++---------------- problemtools/problem2html.py | 1 - problemtools/tex2html.py | 2 +- 3 files changed, 70 insertions(+), 58 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index bc7ccc5c..5db0d727 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -6,18 +6,67 @@ import argparse from typing import Optional +import xml.etree.ElementTree as etree import markdown from markdown.treeprocessors import Treeprocessor from markdown.inlinepatterns import InlineProcessor from markdown.extensions import Extension -import xml.etree.ElementTree as etree from . import verifyproblem from . import problem2html + +def convert(problem: str, options: argparse.Namespace) -> None: + """Convert a Markdown statement to HTML + + Args: + problem: path to problem directory + options: command-line arguments. See problem2html.py + """ + problembase = os.path.splitext(os.path.basename(problem))[0] + destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + + statement_path = problem2html._find_statement(problem, extension="md", language=options.language) + + if statement_path is None: + raise Exception('No markdown statement found') + + with open(statement_path, "r", encoding="utf-8") as input_file: + text = input_file.read() + statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables"]) + + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), + os.path.join(os.path.dirname(__file__), '../templates/markdown'), + '/usr/lib/problemtools/templates/markdown'] + templatepath = next((p for p in templatepaths + if os.path.isdir(p) and os.path.isfile(os.path.join(p, "default-layout.html"))), + None) + + if templatepath is None: + raise Exception('Could not find directory with markdown templates') + + problem_name = _get_problem_name(problem) + + html_template = _substitute_template(templatepath, "default-layout.html", + statement_html=statement_html, + language=options.language, + title=problem_name or "Missing problem name", + problemid=problembase) + + html_template += _samples_to_html(problem) + + with open(destfile, "w", encoding="utf-8", errors="xmlcharrefreplace") as output_file: + output_file.write(html_template) + + if options.css: + with open("problem.css", "w") as output_file: + with open(os.path.join(templatepath, "problem.css"), "r") as input_file: + output_file.write(input_file.read()) + + def _substitute_template(templatepath: str, templatefile: str, **params) -> str: """Read the markdown template and substitute in things such as problem name, - statement etc using python's format syntax. + statement etc using python's format syntax. """ with open(os.path.join(templatepath, templatefile), "r", encoding="utf-8") as template_file: html_template = template_file.read() % params @@ -35,7 +84,7 @@ def _get_problem_name(problem: str, language: str = "en") -> Optional[str]: # If there is only one language, per the spec that is the one we want if len(names) == 1: return next(iter(names.values())) - + if language not in names: raise Exception(f"No problem name defined for language {language}") return names[language] @@ -50,13 +99,13 @@ def _samples_to_html(problem: str) -> str: casenum = 1 for sample in sorted(os.listdir(sample_path)): if sample.endswith(".interaction"): - lines = [""" + lines = [f"""
- + -
ReadSample Interaction {}Sample Interaction {casenum} Write
""".format(casenum)] + """] with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: sample_interaction = infile.readlines() for interaction in sample_interaction: @@ -84,7 +133,7 @@ def _samples_to_html(problem: str) -> str: with open(outpath, "r", encoding="utf-8") as outfile: sample_output = outfile.read() - samples.append(f""" + samples.append(""" Sample Input %(case)d Sample Output %(case)d @@ -92,63 +141,23 @@ def _samples_to_html(problem: str) -> str:
%(input)s
%(output)s
- """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + """ + % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) casenum += 1 if interactive_samples: samples_html += ''.join(interactive_samples) if samples: - samples_html += """ + samples_html += f""" - %(samples)s + {''.join(samples)}
- """ % {"samples": ''.join(samples)} + """ return samples_html -def convert(problem: str, options: argparse.Namespace) -> None: - problembase = os.path.splitext(os.path.basename(problem))[0] - destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - - statement_path = problem2html._find_statement(problem, extension="md", language=options.language) - - if statement_path is None: - raise Exception('No markdown statement found') - - with open(statement_path, "r", encoding="utf-8") as input_file: - text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables"]) - - templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), - os.path.join(os.path.dirname(__file__), '../templates/markdown'), - '/usr/lib/problemtools/templates/markdown'] - templatepath = next((p for p in templatepaths - if os.path.isdir(p) and os.path.isfile(os.path.join(p, "default-layout.html"))), - None) - - if templatepath is None: - raise Exception('Could not find directory with markdown templates') - - problem_name = _get_problem_name(problem) - - html_template = _substitute_template(templatepath, "default-layout.html", - statement_html=statement_html, - language=options.language, - title=problem_name or "Missing problem name", - problemid=problembase) - - html_template += _samples_to_html(problem) - - with open(destfile, "w", encoding="utf-8", errors="xmlcharrefreplace") as output_file: - output_file.write(html_template) - - if options.css: - with open("problem.css", "w") as output_file: - with open(os.path.join(templatepath, "problem.css"), "r") as input_file: - output_file.write(input_file.read()) - # Parse inline math $a+b$ class InlineMathProcessor(InlineProcessor): def handleMatch(self, m, data): @@ -157,6 +166,7 @@ def handleMatch(self, m, data): el.text = "$" + m.group(1) + "$" return el, m.start(0), m.end(0) + # Parse display math $$a+b$$ class DisplayMathProcessor(InlineProcessor): def handleMatch(self, m, data): @@ -165,15 +175,17 @@ def handleMatch(self, m, data): el.text = "$$" + m.group(1) + "$$" return el, m.start(0), m.end(0) + # Add the display+inline math class MathExtension(Extension): def extendMarkdown(self, md): # Regex magic so that both $ $ and $$ $$ can coexist. Written by a wizard (ChatGPT) - INLINE_MATH_PATTERN = r'(? None: doc = tex.parse() texfile.close() - + renderer.render(doc) # Annoying: I have not figured out any way of stopping the plasTeX From 08645f58cacf8c80d9d994aa3b18d6863cc80230 Mon Sep 17 00:00:00 2001 From: matistjati Date: Fri, 9 Aug 2024 15:02:30 +0200 Subject: [PATCH 053/272] Added image support in markdown --- examples/README.md | 2 +- .../oddecho/problem_statement/echo_cave.webp | Bin 0 -> 19340 bytes .../oddecho/problem_statement/problem.en.tex | 2 + .../oddecho/problem_statement/problem.sv.md | 7 ++ problemtools/md2html.py | 89 ++++++++++++++++-- 5 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 examples/oddecho/problem_statement/echo_cave.webp diff --git a/examples/README.md b/examples/README.md index d646a86d..1aa6f8a2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,7 @@ more than one language. ## oddecho This is an example of a *scoring* problem where submissions can get -different scores depending on which test groups they solve. It also demonstrates how an input validator might check different constraints for different test groups. +different scores depending on which test groups they solve. It also demonstrates how an input validator might check different constraints for different test groups. The swedish statement showcses how to use images in Markdown. # bplusa diff --git a/examples/oddecho/problem_statement/echo_cave.webp b/examples/oddecho/problem_statement/echo_cave.webp new file mode 100644 index 0000000000000000000000000000000000000000..8e79d2bc1596bf2cfcb1c50cf1c8b731beb99aea GIT binary patch literal 19340 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 LA=f6700000Kr61t literal 0 HcmV?d00001 diff --git a/examples/oddecho/problem_statement/problem.en.tex b/examples/oddecho/problem_statement/problem.en.tex index e4b03ab8..2505bb0e 100644 --- a/examples/oddecho/problem_statement/problem.en.tex +++ b/examples/oddecho/problem_statement/problem.en.tex @@ -12,6 +12,8 @@ Your task is to write a program that simulates this behavior. +\includegraphics[]{image.jpg} + \section*{Input} The first line of the input contains an integer \(N\) (\(1 \le N \le 10\)). diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index 52ebedf7..d51c5e13 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,5 +1,12 @@ **ECHO! Echo! Ech...** + +A cave + + Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, har du inte tid för att komma ut och skrika i grottor så ofta. Istället skulle du vilja implementera ett program som fungerar som en ersättning för en grotta. diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 5db0d727..b73ff4d9 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -4,6 +4,7 @@ import os.path import string import argparse +import re from typing import Optional import xml.etree.ElementTree as etree @@ -31,9 +32,14 @@ def convert(problem: str, options: argparse.Namespace) -> None: if statement_path is None: raise Exception('No markdown statement found') + seen_images = set() + call_handle = lambda src: _handle_image(os.path.join(problem, "problem_statement", src), seen_images) with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables"]) + statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables", + FixImageLinksExtension(call_handle)]) + + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -64,6 +70,17 @@ def convert(problem: str, options: argparse.Namespace) -> None: output_file.write(input_file.read()) +def _handle_image(src, seen_images): + if src in seen_images: + return + if not os.path.isfile(src): + raise Exception(f"Could not find image {src} in problem_statement folder") + file_name = os.path.basename(src) + with open(src, "rb") as img: + with open(file_name, "wb") as out: + out.write(img.read()) + + def _substitute_template(templatepath: str, templatefile: str, **params) -> str: """Read the markdown template and substitute in things such as problem name, statement etc using python's format syntax. @@ -158,8 +175,8 @@ def _samples_to_html(problem: str) -> str: return samples_html -# Parse inline math $a+b$ class InlineMathProcessor(InlineProcessor): + """Tell mathjax to process all $a+b$""" def handleMatch(self, m, data): el = etree.Element('span') el.attrib['class'] = 'tex2jax_process' @@ -167,8 +184,8 @@ def handleMatch(self, m, data): return el, m.start(0), m.end(0) -# Parse display math $$a+b$$ class DisplayMathProcessor(InlineProcessor): + """Tell mathjax to process all $$a+b$$""" def handleMatch(self, m, data): el = etree.Element('div') el.attrib['class'] = 'tex2jax_process' @@ -176,8 +193,8 @@ def handleMatch(self, m, data): return el, m.start(0), m.end(0) -# Add the display+inline math class MathExtension(Extension): + """Add $a+b$ and $$a+b$$""" def extendMarkdown(self, md): # Regex magic so that both $ $ and $$ $$ can coexist. Written by a wizard (ChatGPT) inline_math_pattern = r'(? + + Implementation details: python-markdown seems to put both of these inside + html nodes' text, not as their own nodes. Therefore, we do a dfs and + use regex to extract them. + + """ + def __init__(self, md, callback): + super().__init__(md) + self.callback = callback + + def find_images(self, text: str) -> None: + """Find all images in a string and call the callback on each""" + if not text: + return + + # Find html-style images + html_img_pattern = re.compile(r']*?)>', re.IGNORECASE) + + html_src_pattern = re.compile(r'src\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) + for match in html_img_pattern.finditer(text): + img_attrs = match.group(1) + + src_match = html_src_pattern.search(img_attrs) + if src_match: + src_value = src_match.group(1) + self.callback(src_value) + + # Find markdown-style images + markdown_pattern = re.compile(r'!\[(.*?)\]\((.*?)\s*(?:"(.*?)")?\)') + + for match in markdown_pattern.finditer(text): + alt_text, src, title = match.groups() + self.callback(src) + + def dfs(self, element): + """Visit every html node and find any images contained in it""" + self.find_images(element.text) + for child in element: + self.dfs(child) + + def run(self, root): + self.dfs(root) + +class FixImageLinksExtension(Extension): + """Add FixImageLinks extension""" + def __init__(self, callback): + super().__init__() + self.callback = callback + + def extendMarkdown(self, md): + md.treeprocessors.register(FixImageLinks(md, self.callback), 'find_images', 200) From a6a19330156ca6ad100d71d55d841dfe02225957 Mon Sep 17 00:00:00 2001 From: matistjati Date: Fri, 9 Aug 2024 15:16:26 +0200 Subject: [PATCH 054/272] Added footnote support --- examples/README.md | 3 ++- examples/oddecho/problem_statement/problem.sv.md | 3 ++- problemtools/md2html.py | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/README.md b/examples/README.md index 1aa6f8a2..d1076a7e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,8 @@ more than one language. ## oddecho This is an example of a *scoring* problem where submissions can get -different scores depending on which test groups they solve. It also demonstrates how an input validator might check different constraints for different test groups. The swedish statement showcses how to use images in Markdown. +different scores depending on which test groups they solve. It also demonstrates how an input validator might check different constraints for different test groups. The swedish statement showcases how to use images, footnotes +and tables in Markdown. # bplusa diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index d51c5e13..09d4cea0 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -10,7 +10,7 @@ Alternatively, Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, har du inte tid för att komma ut och skrika i grottor så ofta. Istället skulle du vilja implementera ett program som fungerar som en ersättning för en grotta. -Ibland vill du mata in några ord i programmet och få dem ekade tillbaka till dig. Men, som det är välkänt, om du skriker för snabbt i en grotta kan ekot störa de nya ord du säger. Mer specifikt, vartannat ord du säger kommer att störa ekot av ditt tidigare ord. Därför kommer endast det första, tredje, femte och så vidare ordet faktiskt att producera ett eko. +Ibland vill du mata in några ord i programmet och få dem ekade tillbaka till dig. Men, som det är välkänt, om du skriker för snabbt i en grotta kan ekot störa de nya ord du säger. [^1] Mer specifikt, vartannat ord du säger kommer att störa ekot av ditt tidigare ord. Därför kommer endast det första, tredje, femte och så vidare ordet faktiskt att producera ett eko. Din uppgift är att skriva ett program som simulerar detta beteende. @@ -35,3 +35,4 @@ För att få poäng för en grupp så måste du klara alla testfall i gruppen. | 1 | 1 | $N$ är alltid $5$ | | 2 | 1 | Inga ytterligare begränsningar | +[^1]: [https://sv.wikipedia.org/wiki/Interferens_(v%C3%A5gr%C3%B6relse)](https://sv.wikipedia.org/wiki/Interferens_(v%C3%A5gr%C3%B6relse)) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index b73ff4d9..b7aad19a 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -36,8 +36,9 @@ def convert(problem: str, options: argparse.Namespace) -> None: call_handle = lambda src: _handle_image(os.path.join(problem, "problem_statement", src), seen_images) with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables", - FixImageLinksExtension(call_handle)]) + statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), + FixImageLinksExtension(call_handle), + 'footnotes', "tables"]) From 7627c58bc98f3e606065f9d30ba751718dc2b660 Mon Sep 17 00:00:00 2001 From: matistjati Date: Fri, 9 Aug 2024 15:26:52 +0200 Subject: [PATCH 055/272] Code cleanup --- problemtools/md2html.py | 35 +++++++++++++++++++++-------------- problemtools/problem2html.py | 4 ++-- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index b7aad19a..334bbea1 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -27,21 +27,20 @@ def convert(problem: str, options: argparse.Namespace) -> None: problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - statement_path = problem2html._find_statement(problem, extension="md", language=options.language) + statement_path = problem2html.find_statement(problem, extension="md", language=options.language) if statement_path is None: raise Exception('No markdown statement found') - seen_images = set() - call_handle = lambda src: _handle_image(os.path.join(problem, "problem_statement", src), seen_images) + # The extension will only call _handle_image with the image name. We also need the path + # to the statement folder. We capture that with this lambda + call_handle = lambda src: _copy_image(os.path.join(problem, "problem_statement", src)) with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), FixImageLinksExtension(call_handle), 'footnotes', "tables"]) - - templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), '/usr/lib/problemtools/templates/markdown'] @@ -71,12 +70,20 @@ def convert(problem: str, options: argparse.Namespace) -> None: output_file.write(input_file.read()) -def _handle_image(src, seen_images): - if src in seen_images: - return +def _copy_image(src: str) -> None: + """This is called for every image in the statement + Copies the image to the output directory from the statement + + Args: + src: full file path to the image + """ + if not os.path.isfile(src): raise Exception(f"Could not find image {src} in problem_statement folder") file_name = os.path.basename(src) + # No point in copying it twice + if os.path.isfile(file_name): + return with open(src, "rb") as img: with open(file_name, "wb") as out: out.write(img.read()) @@ -226,11 +233,11 @@ class FixImageLinks(Treeprocessor): If your image name is image.jpg, we consider the following to be reasonable ![Alt](image.jpg) - + Implementation details: python-markdown seems to put both of these inside html nodes' text, not as their own nodes. Therefore, we do a dfs and use regex to extract them. - + """ def __init__(self, md, callback): super().__init__(md) @@ -240,24 +247,24 @@ def find_images(self, text: str) -> None: """Find all images in a string and call the callback on each""" if not text: return - + # Find html-style images html_img_pattern = re.compile(r']*?)>', re.IGNORECASE) html_src_pattern = re.compile(r'src\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) for match in html_img_pattern.finditer(text): img_attrs = match.group(1) - + src_match = html_src_pattern.search(img_attrs) if src_match: src_value = src_match.group(1) self.callback(src_value) - + # Find markdown-style images markdown_pattern = re.compile(r'!\[(.*?)\]\((.*?)\s*(?:"(.*?)")?\)') for match in markdown_pattern.finditer(text): - alt_text, src, title = match.groups() + _, src, __ = match.groups() self.callback(src) def dfs(self, element): diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index e1380e64..4c084613 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -12,7 +12,7 @@ SUPPORTED_EXTENSIONS = ("tex", "md") -def _find_statement(problem: str, extension: str, language: Optional[str]) -> Optional[str]: +def find_statement(problem: str, extension: str, language: Optional[str]) -> Optional[str]: """Finds the "best" statement for given language and extension""" if language is None: statement_path = os.path.join(problem, f"problem_statement/problem.en.{extension}") @@ -32,7 +32,7 @@ def _find_statement_extension(problem: str, language: Optional[str]) -> str: """Given a language, find whether the extension is tex or md""" extensions = [] for ext in SUPPORTED_EXTENSIONS: - if _find_statement(problem, ext, language) is not None: + if find_statement(problem, ext, language) is not None: extensions.append(ext) # At most one extension per language to avoid arbitrary/hidden priorities if len(extensions) > 1: From 1b222ac332d1f4be06a6b2821a3dae950689808b Mon Sep 17 00:00:00 2001 From: matistjati Date: Tue, 13 Aug 2024 13:19:37 +0200 Subject: [PATCH 056/272] md -> html works --- .../oddecho/problem_statement/echo_cave.jpg | Bin 0 -> 35667 bytes .../oddecho/problem_statement/echo_cave.webp | Bin 19340 -> 0 bytes .../oddecho/problem_statement/problem.en.tex | 2 - .../oddecho/problem_statement/problem.sv.md | 11 +- problemtools/md2html.py | 187 ++++++------------ problemtools/templates/markdown/problem.css | 54 ++--- 6 files changed, 101 insertions(+), 153 deletions(-) create mode 100644 examples/oddecho/problem_statement/echo_cave.jpg delete mode 100644 examples/oddecho/problem_statement/echo_cave.webp diff --git a/examples/oddecho/problem_statement/echo_cave.jpg b/examples/oddecho/problem_statement/echo_cave.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e197bf1b4a2e9d2d2782e29680869536a27684b1 GIT binary patch literal 35667 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 literal 0 HcmV?d00001 diff --git a/examples/oddecho/problem_statement/echo_cave.webp b/examples/oddecho/problem_statement/echo_cave.webp deleted file mode 100644 index 8e79d2bc1596bf2cfcb1c50cf1c8b731beb99aea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19340 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 LA=f6700000Kr61t diff --git a/examples/oddecho/problem_statement/problem.en.tex b/examples/oddecho/problem_statement/problem.en.tex index 2505bb0e..e4b03ab8 100644 --- a/examples/oddecho/problem_statement/problem.en.tex +++ b/examples/oddecho/problem_statement/problem.en.tex @@ -12,8 +12,6 @@ Your task is to write a program that simulates this behavior. -\includegraphics[]{image.jpg} - \section*{Input} The first line of the input contains an integer \(N\) (\(1 \le N \le 10\)). diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index 09d4cea0..9d2e236c 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,10 +1,9 @@ **ECHO! Echo! Ech...** - -A cave - Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, har du @@ -35,4 +34,4 @@ För att få poäng för en grupp så måste du klara alla testfall i gruppen. | 1 | 1 | $N$ är alltid $5$ | | 2 | 1 | Inga ytterligare begränsningar | -[^1]: [https://sv.wikipedia.org/wiki/Interferens_(v%C3%A5gr%C3%B6relse)](https://sv.wikipedia.org/wiki/Interferens_(v%C3%A5gr%C3%B6relse)) +[^1]: [https://sv.wikipedia.org/wiki/Interferens](https://sv.wikipedia.org/wiki/Interferens) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 334bbea1..8505704c 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -5,18 +5,15 @@ import string import argparse import re +import json from typing import Optional -import xml.etree.ElementTree as etree -import markdown -from markdown.treeprocessors import Treeprocessor -from markdown.inlinepatterns import InlineProcessor -from markdown.extensions import Extension - from . import verifyproblem from . import problem2html +FOOTNOTES_STRING = '
' + def convert(problem: str, options: argparse.Namespace) -> None: """Convert a Markdown statement to HTML @@ -32,14 +29,13 @@ def convert(problem: str, options: argparse.Namespace) -> None: if statement_path is None: raise Exception('No markdown statement found') - # The extension will only call _handle_image with the image name. We also need the path - # to the statement folder. We capture that with this lambda - call_handle = lambda src: _copy_image(os.path.join(problem, "problem_statement", src)) - with open(statement_path, "r", encoding="utf-8") as input_file: - text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), - FixImageLinksExtension(call_handle), - 'footnotes', "tables"]) + if not os.path.isfile(statement_path): + raise Exception(f"Error! {statement_path} is not a file") + + + _copy_images(statement_path, + lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) + statement_html = os.popen(f"pandoc {statement_path} -t html").read() templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -59,7 +55,10 @@ def convert(problem: str, options: argparse.Namespace) -> None: title=problem_name or "Missing problem name", problemid=problembase) - html_template += _samples_to_html(problem) + samples = _samples_to_html(problem) + + html_template = inject_samples(html_template, samples) + html_template = replace_hr_in_footnotes(html_template) with open(destfile, "w", encoding="utf-8", errors="xmlcharrefreplace") as output_file: output_file.write(html_template) @@ -70,18 +69,17 @@ def convert(problem: str, options: argparse.Namespace) -> None: output_file.write(input_file.read()) -def _copy_image(src: str) -> None: +def handle_image(src: str) -> None: """This is called for every image in the statement - Copies the image to the output directory from the statement + Copies the image from the statement to the output directory Args: src: full file path to the image """ + file_name = os.path.basename(src) if not os.path.isfile(src): - raise Exception(f"Could not find image {src} in problem_statement folder") - file_name = os.path.basename(src) - # No point in copying it twice + raise Exception(f"File {file_name} not found in problem_statement") if os.path.isfile(file_name): return with open(src, "rb") as img: @@ -89,6 +87,53 @@ def _copy_image(src: str) -> None: out.write(img.read()) +def json_dfs(data, callback) -> None: + if isinstance(data, dict): + for key, value in data.items(): + # Markdown-style images + if key == 't' and value == 'Image': + callback(data['c'][2][0]) + else: + json_dfs(value, callback) + + # HTML-style images + if key == "t" and value == "RawInline": + image_string = data["c"][1] + src = re.search(r'src=["\'](.*?)["\']', image_string) + if src: + callback(src.group(1)) + + elif isinstance(data, list): + for item in data: + json_dfs(item, callback) + + +def _copy_images(statement_path, callback): + statement_json = os.popen(f"pandoc {statement_path} -t json").read() + json_dfs(json.loads(statement_json), callback) + + +def inject_samples(html, samples): + if FOOTNOTES_STRING in html: + pos = html.find(FOOTNOTES_STRING) + else: + pos = html.find("") + html = html[:pos] + samples + html[pos:] + return html + + +def replace_hr_in_footnotes(html_content): + if not FOOTNOTES_STRING in html_content: + return html_content + footnotes = html_content.find(FOOTNOTES_STRING) + hr_pos = html_content.find("
", footnotes) + return html_content[:hr_pos] + """ +

+ Footnotes +

+""" + html_content[6 + hr_pos:] + + def _substitute_template(templatepath: str, templatefile: str, **params) -> str: """Read the markdown template and substitute in things such as problem name, statement etc using python's format syntax. @@ -182,105 +227,3 @@ def _samples_to_html(problem: str) -> str: """ return samples_html - -class InlineMathProcessor(InlineProcessor): - """Tell mathjax to process all $a+b$""" - def handleMatch(self, m, data): - el = etree.Element('span') - el.attrib['class'] = 'tex2jax_process' - el.text = "$" + m.group(1) + "$" - return el, m.start(0), m.end(0) - - -class DisplayMathProcessor(InlineProcessor): - """Tell mathjax to process all $$a+b$$""" - def handleMatch(self, m, data): - el = etree.Element('div') - el.attrib['class'] = 'tex2jax_process' - el.text = "$$" + m.group(1) + "$$" - return el, m.start(0), m.end(0) - - -class MathExtension(Extension): - """Add $a+b$ and $$a+b$$""" - def extendMarkdown(self, md): - # Regex magic so that both $ $ and $$ $$ can coexist. Written by a wizard (ChatGPT) - inline_math_pattern = r'(? - - Implementation details: python-markdown seems to put both of these inside - html nodes' text, not as their own nodes. Therefore, we do a dfs and - use regex to extract them. - - """ - def __init__(self, md, callback): - super().__init__(md) - self.callback = callback - - def find_images(self, text: str) -> None: - """Find all images in a string and call the callback on each""" - if not text: - return - - # Find html-style images - html_img_pattern = re.compile(r']*?)>', re.IGNORECASE) - - html_src_pattern = re.compile(r'src\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) - for match in html_img_pattern.finditer(text): - img_attrs = match.group(1) - - src_match = html_src_pattern.search(img_attrs) - if src_match: - src_value = src_match.group(1) - self.callback(src_value) - - # Find markdown-style images - markdown_pattern = re.compile(r'!\[(.*?)\]\((.*?)\s*(?:"(.*?)")?\)') - - for match in markdown_pattern.finditer(text): - _, src, __ = match.groups() - self.callback(src) - - def dfs(self, element): - """Visit every html node and find any images contained in it""" - self.find_images(element.text) - for child in element: - self.dfs(child) - - def run(self, root): - self.dfs(root) - -class FixImageLinksExtension(Extension): - """Add FixImageLinks extension""" - def __init__(self, callback): - super().__init__() - self.callback = callback - - def extendMarkdown(self, md): - md.treeprocessors.register(FixImageLinks(md, self.callback), 'find_images', 200) diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css index 0b5be150..66c5dc95 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown/problem.css @@ -13,23 +13,50 @@ font-family: Arial, Helvetica, sans-serif; } -.markdown-table { +table { border-collapse: collapse; width: 100%; } -.markdown-table th, .markdown-table td { +table th, table td { border: 1px solid black; padding: 8px; text-align: left; } -.markdown-table th { +table th { background-color: #f2f2f2; } +.sample { + font-family: Arial, Helvetica, sans-serif; +} + +.sample td { + font-size: 13px; +} + +.sample { + border-collapse: separate; + width: 100%; +} + +.sample th { + padding: 0px; + border: 0px; + background-color: #ffffff; + text-align: left; + width: 50%; + font-size: 16px; + font-family: Arial, Helvetica, sans-serif; +} + +.sample td { + border: 1px solid black; +} + div.minipage { - display: inline-block; + display: inline-block; } div.illustration { @@ -61,26 +88,7 @@ td { vertical-align:top; } -table, table td { - border: 0; -} -table.tabular p { - margin: 0; -} - -table.sample { - width: 100%; -} - -table.sample th { - text-align: left; - width: 50%; -} - -table.sample td { - border: 1px solid black; -} div.sampleinteractionread { border: 1px solid black; From 880812128f9734677c1c780bbf8bd98263440dd2 Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Tue, 13 Aug 2024 15:24:34 +0200 Subject: [PATCH 057/272] Remove non-standard judgeerror.txt from example problems --- .../different/output_validators/different_validator/validate.h | 3 +-- examples/guess/output_validators/guess_validator/validate.h | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/different/output_validators/different_validator/validate.h b/examples/different/output_validators/different_validator/validate.h index 00c896a7..4f653ff1 100644 --- a/examples/different/output_validators/different_validator/validate.h +++ b/examples/different/output_validators/different_validator/validate.h @@ -56,7 +56,6 @@ const int EXITCODE_AC = 42; const int EXITCODE_WA = 43; const std::string FILENAME_AUTHOR_MESSAGE = "teammessage.txt"; const std::string FILENAME_JUDGE_MESSAGE = "judgemessage.txt"; -const std::string FILENAME_JUDGE_ERROR = "judgeerror.txt"; const std::string FILENAME_SCORE = "score.txt"; #define USAGE "%s: judge_in judge_ans feedback_dir < author_out\n" @@ -107,7 +106,7 @@ void wrong_answer(const std::string &msg, ...) { void judge_error(const std::string &msg, ...) { va_list pvar; va_start(pvar, msg); - vreport_feedback(FILENAME_JUDGE_ERROR, msg, pvar); + vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); assert(0); } diff --git a/examples/guess/output_validators/guess_validator/validate.h b/examples/guess/output_validators/guess_validator/validate.h index 00c896a7..4f653ff1 100644 --- a/examples/guess/output_validators/guess_validator/validate.h +++ b/examples/guess/output_validators/guess_validator/validate.h @@ -56,7 +56,6 @@ const int EXITCODE_AC = 42; const int EXITCODE_WA = 43; const std::string FILENAME_AUTHOR_MESSAGE = "teammessage.txt"; const std::string FILENAME_JUDGE_MESSAGE = "judgemessage.txt"; -const std::string FILENAME_JUDGE_ERROR = "judgeerror.txt"; const std::string FILENAME_SCORE = "score.txt"; #define USAGE "%s: judge_in judge_ans feedback_dir < author_out\n" @@ -107,7 +106,7 @@ void wrong_answer(const std::string &msg, ...) { void judge_error(const std::string &msg, ...) { va_list pvar; va_start(pvar, msg); - vreport_feedback(FILENAME_JUDGE_ERROR, msg, pvar); + vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); assert(0); } From 712ce3edec968a25ec3196f6f84ef19eea1626f4 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sat, 17 Aug 2024 01:18:41 +0200 Subject: [PATCH 058/272] Make md styling more constistent with latex --- problemtools/templates/markdown/problem.css | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css index 66c5dc95..8d354eca 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown/problem.css @@ -13,31 +13,31 @@ font-family: Arial, Helvetica, sans-serif; } -table { +/*Style all tables except sample*/ +table:not(.sample) { border-collapse: collapse; - width: 100%; } -table th, table td { - border: 1px solid black; - padding: 8px; +table:not(.sample) td, table:not(.sample) th { + border-top-style: solid; + border-top-color: black; + border-top-width: 1px; text-align: left; + border-right: 1px solid black; + border-left: 1px solid black; + border-bottom: 1px solid black; } -table th { - background-color: #f2f2f2; +table:not(.sample) td { + margin: 0px; } +/*Style sample in its own way*/ .sample { font-family: Arial, Helvetica, sans-serif; } -.sample td { - font-size: 13px; -} - .sample { - border-collapse: separate; width: 100%; } From 9d545c6a87ecb076f9d058570de56a7da3f8cfa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Sat, 17 Aug 2024 12:39:14 +0200 Subject: [PATCH 059/272] Bump the language versions for c and c++ --- problemtools/config/languages.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/problemtools/config/languages.yaml b/problemtools/config/languages.yaml index caa6dbbc..43f3ab44 100644 --- a/problemtools/config/languages.yaml +++ b/problemtools/config/languages.yaml @@ -98,14 +98,14 @@ c: name: 'C' priority: 950 files: '*.c' - compile: '/usr/bin/gcc -g -O2 -std=gnu99 -static -o {binary} {files} -lm' + compile: '/usr/bin/gcc -g -O2 -std=gnu23 -static -o {binary} {files} -lm' run: '{binary}' cpp: name: 'C++' priority: 1000 files: '*.cc *.C *.cpp *.cxx *.c++' - compile: '/usr/bin/g++ -g -O2 -std=gnu++17 -static -o {binary} {files}' + compile: '/usr/bin/g++ -g -O2 -std=gnu++23 -static -o {binary} {files}' run: '{binary}' csharp: From 2477d77d2b06fdd1d065d242b3fef0c286d3ecdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Sat, 17 Aug 2024 12:47:12 +0200 Subject: [PATCH 060/272] Bump the language versions for Java --- problemtools/config/languages.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/config/languages.yaml b/problemtools/config/languages.yaml index 1175a4b8..394a6064 100644 --- a/problemtools/config/languages.yaml +++ b/problemtools/config/languages.yaml @@ -147,7 +147,7 @@ java: name: 'Java' priority: 800 files: '*.java' - compile: '/usr/bin/javac -source 11 -encoding UTF-8 -sourcepath {path} -d {path} {files}' + compile: '/usr/bin/javac -source 21 -encoding UTF-8 -sourcepath {path} -d {path} {files}' run: '/usr/bin/java -Dfile.encoding=UTF-8 -XX:+UseSerialGC -Xss64m -Xms{memlim}m -Xmx{memlim}m -cp {path} {mainclass}' javascript: From 11a2e4c1cafe6911cffd9275e27a8decd41a1c79 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sat, 17 Aug 2024 17:12:02 +0200 Subject: [PATCH 061/272] md->pdf and Reorganize code --- .../oddecho/problem_statement/problem.sv.md | 6 +- problemtools/md2html.py | 102 +------------- problemtools/problem2html.py | 37 +---- problemtools/problem2pdf.py | 85 +++++++----- problemtools/statement_common.py | 130 ++++++++++++++++++ problemtools/verifyproblem.py | 7 +- 6 files changed, 196 insertions(+), 171 deletions(-) create mode 100644 problemtools/statement_common.py diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index 9d2e236c..4ffd89cf 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,10 +1,6 @@ -**ECHO! Echo! Ech...** +**EKO! Eko! Ek...** ![](echo_cave.jpg) - Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, har du inte tid för att komma ut och skrika i grottor så ofta. Istället skulle du vilja implementera ett program som fungerar som en ersättning för en grotta. diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 8505704c..68b967ae 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -8,8 +8,7 @@ import json from typing import Optional -from . import verifyproblem -from . import problem2html +from . import statement_common FOOTNOTES_STRING = '
' @@ -24,7 +23,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - statement_path = problem2html.find_statement(problem, extension="md", language=options.language) + statement_path = statement_common.find_statement(problem, extension="md", language=options.language) if statement_path is None: raise Exception('No markdown statement found') @@ -47,7 +46,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: if templatepath is None: raise Exception('Could not find directory with markdown templates') - problem_name = _get_problem_name(problem) + problem_name = statement_common.get_problem_name(problem, options.language) html_template = _substitute_template(templatepath, "default-layout.html", statement_html=statement_html, @@ -55,7 +54,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: title=problem_name or "Missing problem name", problemid=problembase) - samples = _samples_to_html(problem) + samples = statement_common.samples_to_html(problem) html_template = inject_samples(html_template, samples) html_template = replace_hr_in_footnotes(html_template) @@ -88,6 +87,7 @@ def handle_image(src: str) -> None: def json_dfs(data, callback) -> None: + """Traverse all items in a JSON tree, find all images, and call callback for each one""" if isinstance(data, dict): for key, value in data.items(): # Markdown-style images @@ -96,13 +96,6 @@ def json_dfs(data, callback) -> None: else: json_dfs(value, callback) - # HTML-style images - if key == "t" and value == "RawInline": - image_string = data["c"][1] - src = re.search(r'src=["\'](.*?)["\']', image_string) - if src: - callback(src.group(1)) - elif isinstance(data, list): for item in data: json_dfs(item, callback) @@ -142,88 +135,3 @@ def _substitute_template(templatepath: str, templatefile: str, **params) -> str: html_template = template_file.read() % params return html_template - -def _get_problem_name(problem: str, language: str = "en") -> Optional[str]: - """Load problem.yaml to get problem name""" - with verifyproblem.Problem(problem) as prob: - config = verifyproblem.ProblemConfig(prob) - if not config.check(None): - print("Please add problem name to problem.yaml when using markdown") - return None - names = config.get("name") - # If there is only one language, per the spec that is the one we want - if len(names) == 1: - return next(iter(names.values())) - - if language not in names: - raise Exception(f"No problem name defined for language {language}") - return names[language] - - -def _samples_to_html(problem: str) -> str: - """Read all samples from the problem directory and convert them to HTML""" - samples_html = "" - sample_path = os.path.join(problem, "data", "sample") - interactive_samples = [] - samples = [] - casenum = 1 - for sample in sorted(os.listdir(sample_path)): - if sample.endswith(".interaction"): - lines = [f""" - - - - - -
ReadSample Interaction {casenum}Write
"""] - with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: - sample_interaction = infile.readlines() - for interaction in sample_interaction: - data = interaction[1:] - line_type = "" - if interaction[0] == '>': - line_type = "sampleinteractionwrite" - elif interaction[0] == '<': - line_type = "sampleinteractionread" - else: - print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(f"""
{data}
""") - - interactive_samples.append(''.join(lines)) - casenum += 1 - continue - if not sample.endswith(".in"): - continue - sample_name = sample[:-3] - outpath = os.path.join(sample_path, sample_name + ".ans") - if not os.path.isfile(outpath): - continue - with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: - sample_input = infile.read() - with open(outpath, "r", encoding="utf-8") as outfile: - sample_output = outfile.read() - - samples.append(""" - - Sample Input %(case)d - Sample Output %(case)d - - -
%(input)s
-
%(output)s
- """ - % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) - casenum += 1 - - if interactive_samples: - samples_html += ''.join(interactive_samples) - if samples: - samples_html += f""" - - - {''.join(samples)} - -
- """ - return samples_html - diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 4c084613..9536da38 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -5,43 +5,10 @@ import string import argparse import subprocess -from typing import Optional from . import tex2html from . import md2html - -SUPPORTED_EXTENSIONS = ("tex", "md") - -def find_statement(problem: str, extension: str, language: Optional[str]) -> Optional[str]: - """Finds the "best" statement for given language and extension""" - if language is None: - statement_path = os.path.join(problem, f"problem_statement/problem.en.{extension}") - if os.path.isfile(statement_path): - return statement_path - statement_path = os.path.join(problem, f"problem_statement/problem.{extension}") - if os.path.isfile(statement_path): - return statement_path - return None - statement_path = os.path.join(problem, f"problem_statement/problem.{language}.{extension}") - if os.path.isfile(statement_path): - return statement_path - return None - - -def _find_statement_extension(problem: str, language: Optional[str]) -> str: - """Given a language, find whether the extension is tex or md""" - extensions = [] - for ext in SUPPORTED_EXTENSIONS: - if find_statement(problem, ext, language) is not None: - extensions.append(ext) - # At most one extension per language to avoid arbitrary/hidden priorities - if len(extensions) > 1: - raise Exception(f"""Found more than one type of statement ({' and '.join(extensions)}) - for language {language or 'en'}""") - if len(extensions) == 1: - return extensions[0] - raise Exception(f"No statement found for language {language or 'en'}") - +from . import statement_common def convert(options: argparse.Namespace) -> None: problem = os.path.realpath(options.problem) @@ -62,7 +29,7 @@ def convert(options: argparse.Namespace) -> None: origcwd = os.getcwd() - if _find_statement_extension(problem, options.language) == "tex": + if statement_common.find_statement_extension(problem, options.language) == "tex": tex2html.convert(problem, options) else: md2html.convert(problem, options) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index ac119d05..ef1784d4 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -5,47 +5,70 @@ import string import argparse import subprocess -from . import template +import tempfile +from . import template +from . import statement_common -def convert(options: argparse.Namespace, ignore_markdown: bool = False) -> bool: - problem = os.path.realpath(options.problem) - problembase = os.path.splitext(os.path.basename(problem))[0] +def convert(options: argparse.Namespace) -> bool: + problem_root = os.path.realpath(options.problem) + problembase = os.path.splitext(os.path.basename(problem_root))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - # We skip PDF check when verifying problems with markdown statements - if os.path.isfile(os.path.join(problem, "problem_statement", "problem.%s.md" % options.language)) and ignore_markdown: - return True - - # Set up template if necessary - with template.Template(problem, language=options.language) as templ: - texfile = templ.get_file_name() - - origcwd = os.getcwd() - - os.chdir(os.path.dirname(texfile)) - params = ['pdflatex', '-interaction=nonstopmode'] - output = None - if options.quiet: - output = open(os.devnull, 'w') - if options.nopdf: - params.append('-draftmode') - - params.append(texfile) + if statement_common.find_statement_extension(problem_root, language=options.language) == "md": + statement_path = statement_common.find_statement(problem_root, extension="md", language=options.language) + + if not os.path.isfile(statement_path): + raise Exception(f"Error! {statement_path} is not a file") + + statement_dir = os.path.join(problem_root, "problem_statement") + with open(statement_path, "r") as f: + statement_md = f.read() + + # Hacky: html samples -> md. Then we append to the markdown document + samples = statement_common._samples_to_html(problem_root) + with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: + temp_file.write(samples) + temp_file.flush() + samples_md = os.popen(f"pandoc {temp_file.name} -t markdown").read() + + statement_md += samples_md + with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: + temp_file.write(statement_md) + temp_file.flush() + # Do .read so that the file isn't deleted until pandoc is done + os.popen(f"pandoc {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() + + else: + # Set up template if necessary + with template.Template(problem_root, language=options.language) as templ: + texfile = templ.get_file_name() + + origcwd = os.getcwd() + + os.chdir(os.path.dirname(texfile)) + params = ['pdflatex', '-interaction=nonstopmode'] + output = None + if options.quiet: + output = open(os.devnull, 'w') + if options.nopdf: + params.append('-draftmode') + + params.append(texfile) - status = subprocess.call(params, stdout=output) - if status == 0: status = subprocess.call(params, stdout=output) + if status == 0: + status = subprocess.call(params, stdout=output) - if output is not None: - output.close() + if output is not None: + output.close() - os.chdir(origcwd) + os.chdir(origcwd) - if status == 0 and not options.nopdf: - shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) + if status == 0 and not options.nopdf: + shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) - return status == 0 + return status == 0 def get_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py new file mode 100644 index 00000000..e8130c93 --- /dev/null +++ b/problemtools/statement_common.py @@ -0,0 +1,130 @@ +import os +from typing import Optional +import html + +from . import verifyproblem + +SUPPORTED_EXTENSIONS = ("tex", "md") + +def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: + """Finds the "best" statement for given language and extension""" + if language is None: + statement_path = os.path.join(problem_root, f"problem_statement/problem.en.{extension}") + if os.path.isfile(statement_path): + return statement_path + statement_path = os.path.join(problem_root, f"problem_statement/problem.{extension}") + if os.path.isfile(statement_path): + return statement_path + return None + statement_path = os.path.join(problem_root, f"problem_statement/problem.{language}.{extension}") + if os.path.isfile(statement_path): + return statement_path + return None + + +def find_statement_extension(problem_root: str, language: Optional[str]) -> str: + """Given a language, find whether the extension is tex or md + + Args: + problem_root: path to problem root + """ + extensions = [] + for ext in SUPPORTED_EXTENSIONS: + if find_statement(problem_root, ext, language) is not None: + extensions.append(ext) + # At most one extension per language to avoid arbitrary/hidden priorities + if len(extensions) > 1: + raise Exception(f"""Found more than one type of statement ({' and '.join(extensions)}) + for language {language or 'en'}""") + if len(extensions) == 1: + return extensions[0] + raise Exception(f"No statement found for language {language or 'en'}") + + + +def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: + """Load problem.yaml to get problem name""" + if language is None: + language = "en" + with verifyproblem.Problem(problem) as prob: + config = verifyproblem.ProblemConfig(prob) + if not config.check(None): + print("Please add problem name to problem.yaml when using markdown") + return None + names = config.get("name") + # If there is only one language, per the spec that is the one we want + if len(names) == 1: + return next(iter(names.values())) + + if language not in names: + raise Exception(f"No problem name defined for language {language or 'en'}") + return names[language] + + +def _samples_to_html(problem: str) -> str: + """Read all samples from the problem directory and convert them to HTML""" + samples_html = "" + sample_path = os.path.join(problem, "data", "sample") + interactive_samples = [] + samples = [] + casenum = 1 + for sample in sorted(os.listdir(sample_path)): + if sample.endswith(".interaction"): + lines = [f""" + + + + + +
ReadSample Interaction {casenum}Write
"""] + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: + sample_interaction = infile.readlines() + for interaction in sample_interaction: + data = interaction[1:] + line_type = "" + if interaction[0] == '>': + line_type = "sampleinteractionwrite" + elif interaction[0] == '<': + line_type = "sampleinteractionread" + else: + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(f"""
{data}
""") + + interactive_samples.append(''.join(lines)) + casenum += 1 + continue + if not sample.endswith(".in"): + continue + sample_name = sample[:-3] + outpath = os.path.join(sample_path, sample_name + ".ans") + if not os.path.isfile(outpath): + continue + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: + sample_input = infile.read() + with open(outpath, "r", encoding="utf-8") as outfile: + sample_output = outfile.read() + + samples.append(""" + + Sample Input %(case)d + Sample Output %(case)d + + +
%(input)s
+
%(output)s
+ """ + % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + casenum += 1 + + if interactive_samples: + samples_html += ''.join(interactive_samples) + if samples: + samples_html += f""" + + + {''.join(samples)} + +
+ """ + return samples_html + diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 1db4a6e6..8be12f67 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -28,6 +28,7 @@ from . import problem2pdf from . import problem2html +from . import statement_common from . import config from . import languages @@ -1119,7 +1120,7 @@ def __init__(self, problem: Problem): self._problem = problem self.languages = [] glob_path = os.path.join(problem.probdir, 'problem_statement', 'problem.') - for extension in problem2html.SUPPORTED_EXTENSIONS: + for extension in statement_common.SUPPORTED_EXTENSIONS: if glob.glob(glob_path + extension): self.languages.append('') for f in glob.glob(glob_path + '[a-z][a-z].%s' % extension): @@ -1145,7 +1146,7 @@ def check(self, context: Context) -> bool: options.language = lang options.nopdf = True options.quiet = True - if not problem2pdf.convert(options, ignore_markdown=True): + if not problem2pdf.convert(options): langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') except Exception as e: @@ -1167,7 +1168,7 @@ def __str__(self) -> str: def get_config(self) -> dict[str, dict[str, str]]: ret: dict[str, dict[str, str]] = {} - for extension in problem2html.SUPPORTED_EXTENSIONS: + for extension in statement_common.SUPPORTED_EXTENSIONS: for lang in self.languages: filename = f'problem.{lang}.{extension}' if lang != '' else 'problem.{extension}' if not os.path.isfile(filename): From be8f8e05abfee4b1ccbbd0e8ff9767a06857887c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Sat, 17 Aug 2024 17:17:07 +0200 Subject: [PATCH 062/272] GCC should use gnu17 --- problemtools/config/languages.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/config/languages.yaml b/problemtools/config/languages.yaml index 394a6064..130937fe 100644 --- a/problemtools/config/languages.yaml +++ b/problemtools/config/languages.yaml @@ -98,7 +98,7 @@ c: name: 'C' priority: 950 files: '*.c' - compile: '/usr/bin/gcc -g -O2 -std=gnu23 -static -o {binary} {files} -lm' + compile: '/usr/bin/gcc -g -O2 -std=gnu17 -static -o {binary} {files} -lm' run: '{binary}' cpp: From 480e0ea9885b6e6d871656fa1b9ca51669fd7e5c Mon Sep 17 00:00:00 2001 From: matistjati Date: Sat, 17 Aug 2024 19:39:39 +0200 Subject: [PATCH 063/272] Better md->pdf tables --- problemtools/md2html.py | 2 +- problemtools/problem2pdf.py | 24 +++++++-- problemtools/statement_common.py | 52 +++++++++---------- .../templates/markdown_pdf/fix_tables.md | 12 +++++ 4 files changed, 59 insertions(+), 31 deletions(-) create mode 100644 problemtools/templates/markdown_pdf/fix_tables.md diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 68b967ae..f9190ffe 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -54,7 +54,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: title=problem_name or "Missing problem name", problemid=problembase) - samples = statement_common.samples_to_html(problem) + samples = "".join(statement_common.samples_to_html(problem)) html_template = inject_samples(html_template, samples) html_template = replace_hr_in_footnotes(html_template) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index ef1784d4..d65ea432 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -21,23 +21,39 @@ def convert(options: argparse.Namespace) -> bool: if not os.path.isfile(statement_path): raise Exception(f"Error! {statement_path} is not a file") + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), + os.path.join(os.path.dirname(__file__), '../templates/markdown_pdf'), + '/usr/lib/problemtools/templates/markdown_pdf'] + templatepath = next((p for p in templatepaths + if os.path.isdir(p) and os.path.isfile(os.path.join(p, "fix_tables.md"))), + None) + table_fix_path = os.path.join(templatepath, "fix_tables.md") + if not os.path.isfile(table_fix_path): + raise Exception("Could not find markdown pdf template") + + with open(table_fix_path, "r") as f: + table_fix = f.read() + statement_dir = os.path.join(problem_root, "problem_statement") with open(statement_path, "r") as f: statement_md = f.read() + statement_md = table_fix + statement_md + # Hacky: html samples -> md. Then we append to the markdown document - samples = statement_common._samples_to_html(problem_root) + samples = "".join(statement_common.samples_to_html(problem_root)) with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: temp_file.write(samples) temp_file.flush() - samples_md = os.popen(f"pandoc {temp_file.name} -t markdown").read() - + samples_md = os.popen(f"pandoc {temp_file.name} -t latex").read() statement_md += samples_md + + #statement_md += samples_md with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: temp_file.write(statement_md) temp_file.flush() # Do .read so that the file isn't deleted until pandoc is done - os.popen(f"pandoc {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() + os.popen(f"pandoc --verbose {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() else: # Set up template if necessary diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index e8130c93..0667e928 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -1,5 +1,5 @@ import os -from typing import Optional +from typing import Optional, List import html from . import verifyproblem @@ -61,11 +61,17 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: return names[language] -def _samples_to_html(problem: str) -> str: - """Read all samples from the problem directory and convert them to HTML""" - samples_html = "" - sample_path = os.path.join(problem, "data", "sample") - interactive_samples = [] +def samples_to_html(problem_root: str) -> List[str]: + """Read all samples from the problem directory and convert them to HTML + + Args: + problem_root: path to root of problem + + Returns: + List[str]: All samples, converted to html. Ordered lexicographically by file names + """ + + sample_path = os.path.join(problem_root, "data", "sample") samples = [] casenum = 1 for sample in sorted(os.listdir(sample_path)): @@ -90,7 +96,7 @@ def _samples_to_html(problem: str) -> str: print(f"Warning: Interaction had unknown prefix {interaction[0]}") lines.append(f"""
{data}
""") - interactive_samples.append(''.join(lines)) + samples.append(''.join(lines)) casenum += 1 continue if not sample.endswith(".in"): @@ -105,26 +111,20 @@ def _samples_to_html(problem: str) -> str: sample_output = outfile.read() samples.append(""" - - Sample Input %(case)d - Sample Output %(case)d - - -
%(input)s
-
%(output)s
- """ + + + + + + + + + + + +
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) casenum += 1 - if interactive_samples: - samples_html += ''.join(interactive_samples) - if samples: - samples_html += f""" - - - {''.join(samples)} - -
- """ - return samples_html + return samples diff --git a/problemtools/templates/markdown_pdf/fix_tables.md b/problemtools/templates/markdown_pdf/fix_tables.md new file mode 100644 index 00000000..fd597724 --- /dev/null +++ b/problemtools/templates/markdown_pdf/fix_tables.md @@ -0,0 +1,12 @@ +--- +header-includes: + - '\usepackage{xstring}' + - '\setlength{\aboverulesep}{0pt}' + - '\setlength{\belowrulesep}{0pt}' + - '\renewcommand{\arraystretch}{1.3}' + - '\makeatletter' + - '\patchcmd{\LT@array}{\@mkpream{#2}}{\StrGobbleLeft{#2}{2}[\pream]\StrGobbleRight{\pream}{2}[\pream]\StrSubstitute{\pream}{l}{|l}[\pream]\@mkpream{@{}\pream|@{}}}{}{}' + - '\def\midrule{}' + - '\apptocmd{\LT@tabularcr}{\hline}{}{}' + - '\makeatother' +--- From e9b3f8ed43faeed909cac3f95338232bb0bf30c7 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 02:00:47 +0200 Subject: [PATCH 064/272] Interactive samples for pdf --- problemtools/md2html.py | 2 +- problemtools/problem2pdf.py | 16 ++-- problemtools/statement_common.py | 74 +++++++++++++++---- .../templates/markdown_pdf/fix_tables.md | 2 + 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index f9190ffe..d764b4f4 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -54,7 +54,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: title=problem_name or "Missing problem name", problemid=problembase) - samples = "".join(statement_common.samples_to_html(problem)) + samples = "".join(statement_common.format_samples(problem, to_pdf=False)) html_template = inject_samples(html_template, samples) html_template = replace_hr_in_footnotes(html_template) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index d65ea432..77081f8a 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -38,22 +38,20 @@ def convert(options: argparse.Namespace) -> bool: with open(statement_path, "r") as f: statement_md = f.read() + # Add code that adds vertical and horizontal lines to all tables statement_md = table_fix + statement_md # Hacky: html samples -> md. Then we append to the markdown document - samples = "".join(statement_common.samples_to_html(problem_root)) - with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: - temp_file.write(samples) - temp_file.flush() - samples_md = os.popen(f"pandoc {temp_file.name} -t latex").read() - statement_md += samples_md + samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) + + # If we don't add newline, the table might get attached to a footnote + statement_md += "\n" + samples - #statement_md += samples_md with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: temp_file.write(statement_md) temp_file.flush() - # Do .read so that the file isn't deleted until pandoc is done - os.popen(f"pandoc --verbose {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() + # Do .read so that the temp file isn't deleted until pandoc is done + os.popen(f"pandoc {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() else: # Set up template if necessary diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 0667e928..cb6960f0 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -1,6 +1,7 @@ import os from typing import Optional, List import html +import tempfile from . import verifyproblem @@ -49,8 +50,7 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: with verifyproblem.Problem(problem) as prob: config = verifyproblem.ProblemConfig(prob) if not config.check(None): - print("Please add problem name to problem.yaml when using markdown") - return None + raise Exception(f"Invalid problem.yaml") names = config.get("name") # If there is only one language, per the spec that is the one we want if len(names) == 1: @@ -61,44 +61,78 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: return names[language] -def samples_to_html(problem_root: str) -> List[str]: - """Read all samples from the problem directory and convert them to HTML +def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: + """Read all samples from the problem directory and convert them to pandoc-valid markdown Args: problem_root: path to root of problem + to_pdf: whether the outputted samples should be valid for for html or pdf Returns: - List[str]: All samples, converted to html. Ordered lexicographically by file names + List[str]: All samples, converted to a format appropriate to be pasted into + a markdown file. Ordered lexicographically by file names """ sample_path = os.path.join(problem_root, "data", "sample") + if not os.path.isdir(sample_path): + print("WARNING!! no sample folder") + return [] samples = [] casenum = 1 for sample in sorted(os.listdir(sample_path)): if sample.endswith(".interaction"): - lines = [f""" + if to_pdf: + line = r"""\begin{tabular}{p{0.3\textwidth} p{0.5\textwidth} p{0.0\textwidth}} +\textbf{Read} & \textbf{Sample Interaction %i} & \textbf{Write} \\ +\end{tabular}""" % casenum + else: + line = f""" +
-
Read Sample Interaction {casenum} Write
"""] + """ + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: sample_interaction = infile.readlines() + lines = [] for interaction in sample_interaction: data = interaction[1:] - line_type = "" - if interaction[0] == '>': - line_type = "sampleinteractionwrite" - elif interaction[0] == '<': - line_type = "sampleinteractionread" + if to_pdf: + if interaction[0] == '>': + left = True + elif interaction[0] == '<': + left = False + else: + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(r""" + \begin{table}[H] + %(justify)s\begin{tabular}{|p{0.6\textwidth}|} + \hline + %(text)s \\ + \hline + \end{tabular} + \end{table}""" % {"justify": "" if left else "\\hspace*{\\fill}\n", + "text": data}) else: - print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(f"""
{data}
""") - - samples.append(''.join(lines)) + line_type = "" + if interaction[0] == '>': + line_type = "sampleinteractionwrite" + elif interaction[0] == '<': + line_type = "sampleinteractionread" + else: + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(f"""
{data}
""") + + if to_pdf: + samples.append(line + '\\vspace{-15pt}'.join(lines)) + else: + samples.append(line + ''.join(lines)) casenum += 1 continue + if not sample.endswith(".in"): continue sample_name = sample[:-3] @@ -124,6 +158,14 @@ def samples_to_html(problem_root: str) -> List[str]: """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + + if to_pdf: + # If pdf, convert to markdown + with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: + temp_file.write(samples[-1]) + temp_file.flush() + samples[-1] = os.popen(f"pandoc {temp_file.name} -t markdown").read() + casenum += 1 return samples diff --git a/problemtools/templates/markdown_pdf/fix_tables.md b/problemtools/templates/markdown_pdf/fix_tables.md index fd597724..1b04614f 100644 --- a/problemtools/templates/markdown_pdf/fix_tables.md +++ b/problemtools/templates/markdown_pdf/fix_tables.md @@ -1,5 +1,7 @@ --- header-includes: + - '\usepackage{float}' + - '\usepackage{booktabs}' - '\usepackage{xstring}' - '\setlength{\aboverulesep}{0pt}' - '\setlength{\belowrulesep}{0pt}' From ad3e801c453a19bd174750b4e3107f4f50d18f62 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 02:14:57 +0200 Subject: [PATCH 065/272] Remove bplusa --- examples/bplusa/data/sample/1.ans | 1 - examples/bplusa/data/sample/1.in | 1 - examples/bplusa/data/secret/1.ans | 1 - examples/bplusa/data/secret/1.in | 1 - examples/bplusa/data/secret/2.ans | 1 - examples/bplusa/data/secret/2.in | 1 - examples/bplusa/data/secret/3.ans | 1 - examples/bplusa/data/secret/3.in | 1 - .../input_validators/validator/validator.cpp | 8 - .../input_validators/validator/validator.h | 356 ------------------ .../output_validators/validator/validate.cc | 64 ---- .../output_validators/validator/validate.h | 153 -------- examples/bplusa/problem.yaml | 4 - .../bplusa/problem_statement/problem.en.md | 8 - .../bplusa/submissions/accepted/cplus1.cpp | 10 - examples/bplusa/submissions/accepted/zero.cpp | 10 - 16 files changed, 621 deletions(-) delete mode 100644 examples/bplusa/data/sample/1.ans delete mode 100644 examples/bplusa/data/sample/1.in delete mode 100644 examples/bplusa/data/secret/1.ans delete mode 100644 examples/bplusa/data/secret/1.in delete mode 100644 examples/bplusa/data/secret/2.ans delete mode 100644 examples/bplusa/data/secret/2.in delete mode 100644 examples/bplusa/data/secret/3.ans delete mode 100644 examples/bplusa/data/secret/3.in delete mode 100644 examples/bplusa/input_validators/validator/validator.cpp delete mode 100644 examples/bplusa/input_validators/validator/validator.h delete mode 100644 examples/bplusa/output_validators/validator/validate.cc delete mode 100644 examples/bplusa/output_validators/validator/validate.h delete mode 100644 examples/bplusa/problem.yaml delete mode 100644 examples/bplusa/problem_statement/problem.en.md delete mode 100644 examples/bplusa/submissions/accepted/cplus1.cpp delete mode 100644 examples/bplusa/submissions/accepted/zero.cpp diff --git a/examples/bplusa/data/sample/1.ans b/examples/bplusa/data/sample/1.ans deleted file mode 100644 index 654d5269..00000000 --- a/examples/bplusa/data/sample/1.ans +++ /dev/null @@ -1 +0,0 @@ -2 3 diff --git a/examples/bplusa/data/sample/1.in b/examples/bplusa/data/sample/1.in deleted file mode 100644 index 7ed6ff82..00000000 --- a/examples/bplusa/data/sample/1.in +++ /dev/null @@ -1 +0,0 @@ -5 diff --git a/examples/bplusa/data/secret/1.ans b/examples/bplusa/data/secret/1.ans deleted file mode 100644 index 1790e253..00000000 --- a/examples/bplusa/data/secret/1.ans +++ /dev/null @@ -1 +0,0 @@ -123 0 diff --git a/examples/bplusa/data/secret/1.in b/examples/bplusa/data/secret/1.in deleted file mode 100644 index 190a1803..00000000 --- a/examples/bplusa/data/secret/1.in +++ /dev/null @@ -1 +0,0 @@ -123 diff --git a/examples/bplusa/data/secret/2.ans b/examples/bplusa/data/secret/2.ans deleted file mode 100644 index 93fd4034..00000000 --- a/examples/bplusa/data/secret/2.ans +++ /dev/null @@ -1 +0,0 @@ -992 0 diff --git a/examples/bplusa/data/secret/2.in b/examples/bplusa/data/secret/2.in deleted file mode 100644 index 7f9d7e97..00000000 --- a/examples/bplusa/data/secret/2.in +++ /dev/null @@ -1 +0,0 @@ -992 diff --git a/examples/bplusa/data/secret/3.ans b/examples/bplusa/data/secret/3.ans deleted file mode 100644 index 80c0cc79..00000000 --- a/examples/bplusa/data/secret/3.ans +++ /dev/null @@ -1 +0,0 @@ -1 0 diff --git a/examples/bplusa/data/secret/3.in b/examples/bplusa/data/secret/3.in deleted file mode 100644 index d00491fd..00000000 --- a/examples/bplusa/data/secret/3.in +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/examples/bplusa/input_validators/validator/validator.cpp b/examples/bplusa/input_validators/validator/validator.cpp deleted file mode 100644 index 0ecff521..00000000 --- a/examples/bplusa/input_validators/validator/validator.cpp +++ /dev/null @@ -1,8 +0,0 @@ -#include "validator.h" - - -void run() { - Int(1, 1000); - Endl(); - Eof(); -} diff --git a/examples/bplusa/input_validators/validator/validator.h b/examples/bplusa/input_validators/validator/validator.h deleted file mode 100644 index f42bc2d7..00000000 --- a/examples/bplusa/input_validators/validator/validator.h +++ /dev/null @@ -1,356 +0,0 @@ -#ifdef NDEBUG -#error Asserts must be enabled! Do not set NDEBUG. -#endif -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -using namespace std; - -// Implemented by you! -void run(); - -// PUBLIC API -// (extend if you need to) - -[[noreturn]] -void die(const string& msg); -[[noreturn]] -void die_line(const string& msg); - -struct ArgType { - string _name, _x; - ArgType(const string& name, const string& x) : _name(name), _x(x) {} - operator string() const { return _x; } - operator long long() const; - operator bool() const; - operator int() const; -}; - -struct IntType { - long long _x; - IntType(long long x) : _x(x) {} - operator long long() const { return _x; } - operator int() const; - operator bool() const; -}; - -ArgType Arg(const string& name); - -ArgType Arg(const string& name, long long _default); - -string Arg(const string& name, const string& _default); - -template -void AssertUnique(const Vec& v); - -namespace IO { - IntType Int(long long lo, long long hi); - double Float(double lo, double hi, bool strict = true); - template - vector SpacedInts(long long count, T lo, T hi); - vector SpacedFloats(long long count, double lo, double hi); - void Char(char expected); - char Char(); - string Line(); - void Endl() { Char('\n'); } - void Space() { Char(' '); } - void Eof() { Char(-1); } -}; -using namespace IO; - -// INTERNALS - -bool _validator_initialized; -struct _validator { - map params; - set used_params; - - void construct(int argc, char** argv) { - _validator_initialized = true; - for (int i = 1; i < argc; i++) { - string s = argv[i]; - size_t ind = s.find('='); - if (ind == string::npos) continue; - auto before = s.substr(0, ind), after = s.substr(ind + 1); - if (params.count(before)) - die("Duplicate parameter " + before); - params[before] = after; - } - } - - void destroy() { - assert(_validator_initialized); - if (!params.empty()) { - string name = params.begin()->first; - die("Unused parameter " + name); - } - IO::Eof(); - _Exit(42); - } - - bool has_var(const string& name) { - if (!_validator_initialized) die("Must not read variables before main"); - return params.count(name) || used_params.count(name); - } - - string get_var(const string& name) { - if (!_validator_initialized) die("Must not read variables before main"); - if (used_params.count(name)) die("Must not read parameter " + name + " twice (either typo or slow)"); - if (!params.count(name)) die("No parameter " + name); - string res = params.at(name); - params.erase(name); - used_params.insert(name); - return res; - } -} _validator_inst; - -void die(const string& msg) { - cerr << msg << endl; - ofstream fout("/tmp/input_validator_msg", ios::app); - fout << msg << endl; - fout.close(); - _Exit(43); -} - -ArgType::operator long long() const { - string dummy; - { - long long num; - istringstream iss(_x); - iss >> num; - if (iss && !(iss >> dummy)) return num; - } - { - // We also allow scientific notation, for clarity - long double num; - istringstream iss(_x); - iss >> num; - if (iss && !(iss >> dummy)) return (long long)num; - } - die("Unable to parse value " + _x + " for parameter " + _name); -} - -ArgType::operator int() const { - long long val = (long long)*this; - if (val < INT_MIN || val > INT_MAX) - die("number " + to_string(val) + " is too large for an int for parameter " + _name); - return (int)val; -} - -ArgType::operator bool() const { - long long val = (long long)*this; - if (val < 0 || val > 1) - die("number " + to_string(val) + " is not boolean (0/1), for parameter " + _name); - return (bool)val; -} - -IntType::operator int() const { - long long val = (long long)*this; - if (val < INT_MIN || val > INT_MAX) - die_line("number " + to_string(val) + " is too large for an int"); - return (int)val; -} - -IntType::operator bool() const { - long long val = (long long)*this; - if (val < 0 || val > 1) - die_line("number " + to_string(val) + " is not boolean (0/1)"); - return (bool)val; -} - -ArgType Arg(const string& name) { - return {name, _validator_inst.get_var(name)}; -} - -ArgType Arg(const string& name, long long _default) { - if (!_validator_inst.has_var(name)) - return {name, to_string(_default)}; - ArgType ret = Arg(name); - (void)(long long)ret; - return ret; -} - -string Arg(const string& name, const string& _default) { - if (!_validator_inst.has_var(name)) - return _default; - return (string)Arg(name); -} - -static int _lineno = 1, _consumed_lineno = -1, _hit_char_error = 0; -char _peek1(); -void die_line(const string& msg) { - if (!_hit_char_error && _peek1() == -1) die(msg); - else if (_consumed_lineno == -1) die(msg + " (before reading any input)"); - else die(msg + " on line " + to_string(_consumed_lineno)); -} - -static char _buffer = -2; // -2 = none, -1 = eof, other = that char -char _peek1() { - if (_buffer != -2) return _buffer; - int val = getchar_unlocked(); - static_assert(EOF == -1, ""); - static_assert(CHAR_MIN == -128, ""); - if (val == -2 || val < CHAR_MIN || val >= CHAR_MAX) { - _hit_char_error = 1; - die_line("Unable to process byte " + to_string(val)); - } - _buffer = (char)val; - return _buffer; -} -void _use_peek(char ch) { - _buffer = -2; - if (ch == '\n') _lineno++; - else _consumed_lineno = _lineno; -} -char _read1() { - char ret = _peek1(); - _use_peek(ret); - return ret; -} -string _token() { - string ret; - for (;;) { - char ch = _peek1(); - if (ch == ' ' || ch == '\n' || ch == -1) { - break; - } - _use_peek(ch); - ret += ch; - } - return ret; -} -string _describe(char ch) { - assert(ch != -2); - if (ch == -1) return "EOF"; - if (ch == ' ') return "SPACE"; - if (ch == '\r') return "CARRIAGE RETURN"; - if (ch == '\n') return "NEWLINE"; - if (ch == '\t') return "TAB"; - if (ch == '\'') return "\"'\""; - return string("'") + ch + "'"; -} - -IntType IO::Int(long long lo, long long hi) { - string s = _token(); - if (s.empty()) die_line("Expected number, saw " + _describe(_peek1())); - try { - long long mul = 1; - int ind = 0; - if (s[0] == '-') { - mul = -1; - ind = 1; - } - if (ind == (int)s.size()) throw false; - char ch = s[ind++]; - if (ch < '0' || ch > '9') throw false; - if (ch == '0' && ind != (int)s.size()) throw false; - long long ret = ch - '0'; - while (ind < (int)s.size()) { - if (ret > LLONG_MAX / 10 - 20 || ret < LLONG_MIN / 10 + 20) - throw false; - ret *= 10; - ch = s[ind++]; - if (ch < '0' || ch > '9') throw false; - ret += ch - '0'; - } - ret *= mul; - if (ret < lo || ret > hi) die_line("Number " + s + " is out of range [" + to_string(lo) + ", " + to_string(hi) + "]"); - return {ret}; - } catch (bool) { - die_line("Unable to parse \"" + s + "\" as integer"); - } -} - -template -vector IO::SpacedInts(long long count, T lo, T hi) { - vector res; - res.reserve(count); - for (int i = 0; i < count; i++) { - if (i != 0) IO::Space(); - res.emplace_back((T)IO::Int(lo, hi)); - } - IO::Endl(); - return res; -} - -vector IO::SpacedFloats(long long count, double lo, double hi) { - vector res; - res.reserve(count); - for (int i = 0; i < count; i++) { - if (i != 0) IO::Space(); - res.emplace_back(IO::Float(lo, hi)); - } - IO::Endl(); - return res; -} - -double IO::Float(double lo, double hi, bool strict) { - string s = _token(); - if (s.empty()) die_line("Expected floating point number, saw " + _describe(_peek1())); - istringstream iss(s); - double res; - string dummy; - iss >> res; - if (!iss || iss >> dummy) die_line("Unable to parse " + s + " as a float"); - if (res < lo || res > hi) die_line("Floating-point number " + s + " is out of range [" + to_string(lo) + ", " + to_string(hi) + "]"); - if (res != res) die_line("Floating-point number " + s + " is NaN"); - if (strict) { - if (s.find('.') != string::npos && s.back() == '0' && s.substr(s.size() - 2) != ".0") - die_line("Number " + s + " has unnecessary trailing zeroes"); - if (s[0] == '0' && s.size() > 1 && s[1] == '0') - die_line("Number " + s + " has unnecessary leading zeroes"); - } - return res; -} - -char IO::Char() { - char ret = _read1(); - if (ret == -1) die_line("Expected character, saw EOF"); - return ret; -} - -void IO::Char(char expected) { - char ret = _peek1(); - if (ret != expected) die_line("Expected " + _describe(expected) + ", saw " + _describe(ret)); - _use_peek(ret); -} - -string IO::Line() { - string ret; - for (;;) { - char ch = IO::Char(); - if (ch == '\n') break; - ret += ch; - } - return ret; -} - -template -void AssertUnique(const Vec& v_) { - Vec v = v_; - auto beg = v.begin(), end = v.end(); - sort(beg, end); - int size = (int)(end - beg); - for (int i = 0; i < size - 1; i++) { - if (v[i] == v[i+1]) { - ostringstream oss; - oss << "Vector contains duplicate value " << v[i]; - die_line(oss.str()); - } - } -} - -int main(int argc, char** argv) { - _validator_inst.construct(argc, argv); - run(); - _validator_inst.destroy(); -} - diff --git a/examples/bplusa/output_validators/validator/validate.cc b/examples/bplusa/output_validators/validator/validate.cc deleted file mode 100644 index 61eabfc2..00000000 --- a/examples/bplusa/output_validators/validator/validate.cc +++ /dev/null @@ -1,64 +0,0 @@ -#include "validate.h" - -#include -using namespace std; - -#define rep(i, a, b) for(int i = a; i < (b); ++i) -#define all(x) begin(x), end(x) -#define sz(x) (int)(x).size() -typedef long long ll; -typedef pair pii; -typedef vector vi; -typedef vector vvi; -typedef long double ld; - -#define repe(i, container) for (auto& i : container) - -void check_isvalid(int a, int b, int c, feedback_function feedback) -{ - if (a==b) feedback("a is equal to b"); - if (a+b!=c) feedback("b+a!=c"); -} - -const int HUNDRED_THOUSAND = int(1e5); -int main(int argc, char **argv) { - init_io(argc, argv); - - // Read the testcase input - int c; - judge_in >> c; - - auto check = [&](istream& sol, feedback_function feedback) { - int a, b; - // Don't get stuck waiting for output from solution - if(!(sol >> a >> b)) feedback("Expected more output"); - // Validate constraints - if (a < -HUNDRED_THOUSAND || a > HUNDRED_THOUSAND) feedback("a is too big or large"); - if (b < -HUNDRED_THOUSAND || b > HUNDRED_THOUSAND) feedback("b is too big or large"); - - // Check that they actually solved the task - check_isvalid(a, b, c, feedback); - - // Disallow trailing output - string trailing; - if(sol >> trailing) feedback("Trailing output"); - return true; - }; - - // Check both the judge's and contestants' output - // It is good practice to not assume that the judge is correct/optimal - bool judge_found_sol = check(judge_ans, judge_error); - bool author_found_sol = check(author_out, wrong_answer); - - // In this problem, having a return value from check is unnecessary - // However, if there isn't always a solution, we will get a nice - // judge error if the judge solution claims no solution exists, while - // a contestant finds one - if(!judge_found_sol) - judge_error("NO! Judge did not find valid solution"); - - if(!author_found_sol) - wrong_answer("Contestant did not find valid solution"); - - accept(); -} diff --git a/examples/bplusa/output_validators/validator/validate.h b/examples/bplusa/output_validators/validator/validate.h deleted file mode 100644 index c59c5fdb..00000000 --- a/examples/bplusa/output_validators/validator/validate.h +++ /dev/null @@ -1,153 +0,0 @@ -/* Utility functions for writing output validators for the Kattis - * problem format. - * - * The primary functions and variables available are the following. - * In many cases, the only functions needed are "init_io", - * "wrong_answer", and "accept". - * - * - init_io(argc, argv): - * initialization - * - * - judge_in, judge_ans, author_out: - * std::istream objects for judge input file, judge answer - * file, and submission output file. - * - * - accept(): - * exit and give Accepted! - * - * - accept_with_score(double score): - * exit with Accepted and give a score (for scoring problems) - * - * - judge_message(std::string msg, ...): - * printf-style function for emitting a judge message (a - * message that gets displayed to a privileged user with access - * to secret data etc). - * - * - wrong_answer(std::string msg, ...): - * printf-style function for exitting and giving Wrong Answer, - * and emitting a judge message (which would typically explain - * the cause of the Wrong Answer) - * - * - judge_error(std::string msg, ...): - * printf-style function for exitting and giving Judge Error, - * and emitting a judge message (which would typically explain - * the cause of the Judge Error) - * - * - author_message(std::string msg, ...): - * printf-style function for emitting an author message (a - * message that gets displayed to the author of the - * submission). (Use with caution, and be careful not to let - * it leak information!) - * - */ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -typedef void (*feedback_function)(const char*, ...); - -const int EXITCODE_AC = 42; -const int EXITCODE_WA = 43; -const char* FILENAME_AUTHOR_MESSAGE = "teammessage.txt"; -const char* FILENAME_JUDGE_MESSAGE = "judgemessage.txt"; -const char* FILENAME_JUDGE_ERROR = "judgeerror.txt"; -const char* FILENAME_SCORE = "score.txt"; - -#define USAGE "%s: judge_in judge_ans feedback_dir < author_out\n" - -std::ifstream judge_in, judge_ans; -std::istream author_out(std::cin.rdbuf()); - -char *feedbackdir = NULL; - -void vreport_feedback(const char* category, - const char* msg, - va_list pvar) { - std::ostringstream fname; - if (feedbackdir) - fname << feedbackdir << '/'; - fname << category; - FILE *f = fopen(fname.str().c_str(), "a"); - assert(f); - vfprintf(f, msg, pvar); - fclose(f); -} - -void report_feedback(const char* category, const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(category, msg, pvar); -} - -void author_message(const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(FILENAME_AUTHOR_MESSAGE, msg, pvar); -} - -void judge_message(const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); -} - -void wrong_answer(const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); - exit(EXITCODE_WA); -} - -void judge_error(const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(FILENAME_JUDGE_ERROR, msg, pvar); - assert(0); -} - -void accept() { - exit(EXITCODE_AC); -} - -void accept_with_score(double scorevalue) { - report_feedback(FILENAME_SCORE, "%.9le", scorevalue); - exit(EXITCODE_AC); -} - - -bool is_directory(const char *path) { - struct stat entry; - return stat(path, &entry) == 0 && S_ISDIR(entry.st_mode); -} - -void init_io(int argc, char **argv) { - if(argc < 4) { - fprintf(stderr, USAGE, argv[0]); - judge_error("Usage: %s judgein judgeans feedbackdir [opts] < userout", argv[0]); - } - - // Set up feedbackdir first, as that allows us to produce feedback - // files for errors in the other parameters. - if (!is_directory(argv[3])) { - judge_error("%s: %s is not a directory\n", argv[0], argv[3]); - } - feedbackdir = argv[3]; - - judge_in.open(argv[1], std::ios_base::in); - if (judge_in.fail()) { - judge_error("%s: failed to open %s\n", argv[0], argv[1]); - } - - judge_ans.open(argv[2], std::ios_base::in); - if (judge_ans.fail()) { - judge_error("%s: failed to open %s\n", argv[0], argv[2]); - } - - author_out.rdbuf(std::cin.rdbuf()); -} diff --git a/examples/bplusa/problem.yaml b/examples/bplusa/problem.yaml deleted file mode 100644 index d59b82ec..00000000 --- a/examples/bplusa/problem.yaml +++ /dev/null @@ -1,4 +0,0 @@ -source: Kattis -license: public domain -name: B plus A -validation: custom diff --git a/examples/bplusa/problem_statement/problem.en.md b/examples/bplusa/problem_statement/problem.en.md deleted file mode 100644 index d5060a86..00000000 --- a/examples/bplusa/problem_statement/problem.en.md +++ /dev/null @@ -1,8 +0,0 @@ -Given the integer $c$, find any pair of integers $b$ and $a$ satisfying $b+a=c$ and $a \neq b$. - -## Input -Input consists of the integer $C$ ($1 \le C \le 1000$). - -## Output -Output $b$ and $a$, separated by a space. Any $b$, $a$ satisfying above constraints and $-10^5 \leq a,b \leq 10^5$ -will be accepted. diff --git a/examples/bplusa/submissions/accepted/cplus1.cpp b/examples/bplusa/submissions/accepted/cplus1.cpp deleted file mode 100644 index 946facb7..00000000 --- a/examples/bplusa/submissions/accepted/cplus1.cpp +++ /dev/null @@ -1,10 +0,0 @@ -#include -using namespace std; - -int main() -{ - int c; - cin >> c; - cout << c+1 << " " << -1 << endl; - return 0; -} diff --git a/examples/bplusa/submissions/accepted/zero.cpp b/examples/bplusa/submissions/accepted/zero.cpp deleted file mode 100644 index 2f4c748a..00000000 --- a/examples/bplusa/submissions/accepted/zero.cpp +++ /dev/null @@ -1,10 +0,0 @@ -#include -using namespace std; - -int main() -{ - int c; - cin >> c; - cout << c << " " << 0 << endl; - return 0; -} From 30d9603e1d7aa505cb8ebce05021ce4a5329235e Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 02:47:41 +0200 Subject: [PATCH 066/272] PDF problem name --- problemtools/problem2html.py | 3 +++ problemtools/problem2pdf.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 9536da38..c9ffe221 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -13,6 +13,9 @@ def convert(options: argparse.Namespace) -> None: problem = os.path.realpath(options.problem) + if not os.path.isdir(problem): + raise Exception(f"Problem does not exist: {problem}") + problembase = os.path.splitext(os.path.basename(problem))[0] destdir = string.Template(options.destdir).safe_substitute(problem=problembase) destfile = string.Template(options.destfile).safe_substitute(problem=problembase) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 77081f8a..63c1a1cd 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -38,9 +38,12 @@ def convert(options: argparse.Namespace) -> bool: with open(statement_path, "r") as f: statement_md = f.read() + problem_name = statement_common.get_problem_name(problem_root, options.language) + # Add code that adds vertical and horizontal lines to all tables + statement_md = r'\centerline{\huge %s}' % problem_name + statement_md statement_md = table_fix + statement_md - + # Hacky: html samples -> md. Then we append to the markdown document samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) From efc5c9e6b666839e9ec96502a079115dbf6446d9 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 10:46:43 +0200 Subject: [PATCH 067/272] Add dependencies --- Dockerfile | 3 ++- README.md | 4 ++-- admin/docker/Dockerfile.minimal | 3 ++- debian/control | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index daa50dde..cff647c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,11 +14,12 @@ RUN apt-get update && \ libgmp10 \ libgmpxx4ldbl \ openjdk-8-jdk \ + pandoc \ python3-minimal \ python3-pip \ python3-plastex \ - python3-markdown \ python3-yaml \ + rsvg-convert \ sudo \ texlive-fonts-recommended \ texlive-lang-cyrillic \ diff --git a/README.md b/README.md index 499fe610..4e708b1a 100644 --- a/README.md +++ b/README.md @@ -207,13 +207,13 @@ The dependencies needed to *build/install* problemtools can be installed with: And the dependencies needed to *run* problemtools can be installed with: - sudo apt install ghostscript libgmpxx4ldbl python3-minimal python-pkg-resources python3-plastex python3-markdown python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy + sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-markdown python3-yaml rsvg-convert texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy ### Fedora On Fedora, these dependencies can be installed with: - sudo dnf install boost-regex gcc gmp-devel gmp-c++ python3 python3-pyyaml python3-markdown texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript + sudo dnf install boost-regex gcc gmp-devel gmp-c++ pandoc python3 python3-pyyaml rsvg-convert texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript Followed by: diff --git a/admin/docker/Dockerfile.minimal b/admin/docker/Dockerfile.minimal index a44811f5..340f0b20 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -20,11 +20,12 @@ RUN apt update && \ apt install -y \ ghostscript \ libgmpxx4ldbl \ + pandoc \ python-pkg-resources \ python3-minimal \ python3-yaml \ python3-plastex \ - python3-markdown \ + rsvg-convert \ texlive-fonts-recommended \ texlive-lang-cyrillic \ texlive-latex-extra \ diff --git a/debian/control b/debian/control index 43410292..d1bf4179 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Homepage: https://github.com/Kattis/problemtools Package: kattis-problemtools Architecture: any -Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, python3-plastex, python3-markdown, python3-pkg-resources, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm +Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, pandoc, python3-plastex, python3-pkg-resources, rsvg-convert, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm Recommends: gcc, g++ Description: Kattis Problem Tools These are tools to manage and verify problem packages in the From 762599f9f70364787257b61825a19e3134da30ce Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 10:57:15 +0200 Subject: [PATCH 068/272] Add problem names --- examples/different/problem.yaml | 5 +++++ examples/guess/problem.yaml | 1 + examples/hello/problem.yaml | 3 +++ examples/oddecho/problem.yaml | 4 ++-- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/examples/different/problem.yaml b/examples/different/problem.yaml index 279a8acb..a7652c2e 100644 --- a/examples/different/problem.yaml +++ b/examples/different/problem.yaml @@ -5,6 +5,11 @@ ## Author of the problem (default: null) # author: +# The problem name +# En may be omitted, as there is only one language +name: + en: A Different Problem + ## Where the problem was first used (default: null) source: Kattis # source_url: diff --git a/examples/guess/problem.yaml b/examples/guess/problem.yaml index c1e29500..bf832bb2 100644 --- a/examples/guess/problem.yaml +++ b/examples/guess/problem.yaml @@ -4,6 +4,7 @@ license: cc by-sa validation: custom interactive name: sv: Gissa talet + en: Guess the Number # Override standard limits: say that the TLE solutions provided should # be at least 4 times above the time limit in order for us to be diff --git a/examples/hello/problem.yaml b/examples/hello/problem.yaml index 194b060f..6c6f791e 100644 --- a/examples/hello/problem.yaml +++ b/examples/hello/problem.yaml @@ -1,5 +1,8 @@ source: Kattis license: public domain +name: + sv: Hej Världen! + en: Hello World! # Fix memory limit at 512 MB. (Note that for most problems, this # should not be done. It is only done in this case because we include diff --git a/examples/oddecho/problem.yaml b/examples/oddecho/problem.yaml index f213fbd9..3a918455 100644 --- a/examples/oddecho/problem.yaml +++ b/examples/oddecho/problem.yaml @@ -3,7 +3,7 @@ author: Johan Sannemo source: Principles of Algorithmic Problem Solving type: scoring name: - en: Echo - sv: Eko + en: Odd Echo + sv: Udda Eko grading: show_test_data_groups: true From 2bba9d4c935c6a51690bcdf9fbff9f731a2f2002 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 11:04:22 +0200 Subject: [PATCH 069/272] Added problem name to test hello package --- problemtools/tests/hello/problem.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/problemtools/tests/hello/problem.yaml b/problemtools/tests/hello/problem.yaml index 194b060f..6c6f791e 100644 --- a/problemtools/tests/hello/problem.yaml +++ b/problemtools/tests/hello/problem.yaml @@ -1,5 +1,8 @@ source: Kattis license: public domain +name: + sv: Hej Världen! + en: Hello World! # Fix memory limit at 512 MB. (Note that for most problems, this # should not be done. It is only done in this case because we include From cdd1804a06605db2c823e288834c48742486127a Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 11:21:34 +0200 Subject: [PATCH 070/272] Improve security by running pandoc without shell capabilities --- problemtools/md2html.py | 9 +++++---- problemtools/problem2pdf.py | 4 ++-- problemtools/statement_common.py | 4 +++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index d764b4f4..76ccd4e4 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -4,9 +4,8 @@ import os.path import string import argparse -import re import json -from typing import Optional +import subprocess from . import statement_common @@ -34,7 +33,8 @@ def convert(problem: str, options: argparse.Namespace) -> None: _copy_images(statement_path, lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) - statement_html = os.popen(f"pandoc {statement_path} -t html").read() + command = ["pandoc", statement_path, "-t" , "html"] + statement_html = subprocess.run(command, capture_output=True, text=True, shell=False).stdout templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -102,7 +102,8 @@ def json_dfs(data, callback) -> None: def _copy_images(statement_path, callback): - statement_json = os.popen(f"pandoc {statement_path} -t json").read() + command = ["pandoc", statement_path, "-t" , "json"] + statement_json = subprocess.run(command, capture_output=True, text=True, shell=False).stdout json_dfs(json.loads(statement_json), callback) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 63c1a1cd..4eeeea0f 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -53,8 +53,8 @@ def convert(options: argparse.Namespace) -> bool: with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: temp_file.write(statement_md) temp_file.flush() - # Do .read so that the temp file isn't deleted until pandoc is done - os.popen(f"pandoc {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() + command = ["pandoc", temp_file.name, "-o", f"{problembase}.pdf", f"--resource-path={statement_dir}"] + subprocess.run(command, capture_output=True, text=True, shell=False) else: # Set up template if necessary diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index cb6960f0..5b2bd29f 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -2,6 +2,7 @@ from typing import Optional, List import html import tempfile +import subprocess from . import verifyproblem @@ -164,7 +165,8 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: temp_file.write(samples[-1]) temp_file.flush() - samples[-1] = os.popen(f"pandoc {temp_file.name} -t markdown").read() + command = ["pandoc", temp_file.name, "-t" , "markdown"] + samples[-1] = subprocess.run(command, capture_output=True, text=True, shell=False).stdout casenum += 1 From 194c7b1a11e1af2bbe16b95232c8b7c3c8ea6727 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 11:33:09 +0200 Subject: [PATCH 071/272] Refactoring --- problemtools/md2html.py | 16 +++++++++------- problemtools/problem2pdf.py | 18 +++++++++--------- problemtools/statement_common.py | 16 ++++++++-------- problemtools/templates/markdown/problem.css | 13 +++++-------- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 76ccd4e4..62a8e153 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -1,6 +1,5 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- -import html import os.path import string import argparse @@ -12,9 +11,9 @@ FOOTNOTES_STRING = '
' -def convert(problem: str, options: argparse.Namespace) -> None: +def convert(problem: str, options: argparse.Namespace) -> bool: """Convert a Markdown statement to HTML - + Args: problem: path to problem directory options: command-line arguments. See problem2html.py @@ -34,7 +33,8 @@ def convert(problem: str, options: argparse.Namespace) -> None: _copy_images(statement_path, lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) command = ["pandoc", statement_path, "-t" , "html"] - statement_html = subprocess.run(command, capture_output=True, text=True, shell=False).stdout + statement_html = subprocess.run(command, capture_output=True, text=True, + shell=False, check=True).stdout templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -66,11 +66,13 @@ def convert(problem: str, options: argparse.Namespace) -> None: with open("problem.css", "w") as output_file: with open(os.path.join(templatepath, "problem.css"), "r") as input_file: output_file.write(input_file.read()) + + return True def handle_image(src: str) -> None: """This is called for every image in the statement - Copies the image from the statement to the output directory + Copies the image from the statement to the output directory Args: src: full file path to the image @@ -103,7 +105,8 @@ def json_dfs(data, callback) -> None: def _copy_images(statement_path, callback): command = ["pandoc", statement_path, "-t" , "json"] - statement_json = subprocess.run(command, capture_output=True, text=True, shell=False).stdout + statement_json = subprocess.run(command, capture_output=True, + text=True, shell=False, check=True).stdout json_dfs(json.loads(statement_json), callback) @@ -135,4 +138,3 @@ def _substitute_template(templatepath: str, templatefile: str, **params) -> str: with open(os.path.join(templatepath, templatefile), "r", encoding="utf-8") as template_file: html_template = template_file.read() % params return html_template - diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 4eeeea0f..911c5cb6 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -20,7 +20,7 @@ def convert(options: argparse.Namespace) -> bool: if not os.path.isfile(statement_path): raise Exception(f"Error! {statement_path} is not a file") - + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), os.path.join(os.path.dirname(__file__), '../templates/markdown_pdf'), '/usr/lib/problemtools/templates/markdown_pdf'] @@ -30,20 +30,20 @@ def convert(options: argparse.Namespace) -> bool: table_fix_path = os.path.join(templatepath, "fix_tables.md") if not os.path.isfile(table_fix_path): raise Exception("Could not find markdown pdf template") - - with open(table_fix_path, "r") as f: - table_fix = f.read() + + with open(table_fix_path, "r") as file: + table_fix = file.read() statement_dir = os.path.join(problem_root, "problem_statement") - with open(statement_path, "r") as f: - statement_md = f.read() - + with open(statement_path, "r") as file: + statement_md = file.read() + problem_name = statement_common.get_problem_name(problem_root, options.language) # Add code that adds vertical and horizontal lines to all tables statement_md = r'\centerline{\huge %s}' % problem_name + statement_md statement_md = table_fix + statement_md - + # Hacky: html samples -> md. Then we append to the markdown document samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) @@ -54,7 +54,7 @@ def convert(options: argparse.Namespace) -> bool: temp_file.write(statement_md) temp_file.flush() command = ["pandoc", temp_file.name, "-o", f"{problembase}.pdf", f"--resource-path={statement_dir}"] - subprocess.run(command, capture_output=True, text=True, shell=False) + return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) else: # Set up template if necessary diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 5b2bd29f..97b71170 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -26,7 +26,7 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) - def find_statement_extension(problem_root: str, language: Optional[str]) -> str: """Given a language, find whether the extension is tex or md - + Args: problem_root: path to problem root """ @@ -51,7 +51,7 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: with verifyproblem.Problem(problem) as prob: config = verifyproblem.ProblemConfig(prob) if not config.check(None): - raise Exception(f"Invalid problem.yaml") + raise Exception("Invalid problem.yaml") names = config.get("name") # If there is only one language, per the spec that is the one we want if len(names) == 1: @@ -64,7 +64,7 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: """Read all samples from the problem directory and convert them to pandoc-valid markdown - + Args: problem_root: path to root of problem to_pdf: whether the outputted samples should be valid for for html or pdf @@ -73,7 +73,7 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: List[str]: All samples, converted to a format appropriate to be pasted into a markdown file. Ordered lexicographically by file names """ - + sample_path = os.path.join(problem_root, "data", "sample") if not os.path.isdir(sample_path): print("WARNING!! no sample folder") @@ -95,7 +95,7 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: Write """ - + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: sample_interaction = infile.readlines() lines = [] @@ -159,16 +159,16 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) - + if to_pdf: # If pdf, convert to markdown with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: temp_file.write(samples[-1]) temp_file.flush() command = ["pandoc", temp_file.name, "-t" , "markdown"] - samples[-1] = subprocess.run(command, capture_output=True, text=True, shell=False).stdout + samples[-1] = subprocess.run(command, capture_output=True, text=True, + shell=False, check=True).stdout casenum += 1 return samples - diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css index 8d354eca..ca6e72ed 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown/problem.css @@ -35,9 +35,6 @@ table:not(.sample) td { /*Style sample in its own way*/ .sample { font-family: Arial, Helvetica, sans-serif; -} - -.sample { width: 100%; } @@ -94,20 +91,20 @@ div.sampleinteractionread { border: 1px solid black; width: 60%; float: left; - margin: 3px 0px 3px 0px; + margin: 3px 0px; } .sampleinteractionread pre { - margin: 1px 5px 1px 5px; + margin: 1px 5px; } div.sampleinteractionwrite { border: 1px solid black; width: 60%; float: right; - margin: 3px 0px 3px 0px; + margin: 3px 0px; } .sampleinteractionwrite pre { - margin: 1px 5px 1px 5px; -} \ No newline at end of file + margin: 1px 5px; +} From 554892a122a7e84516486d78888d18a92f8f81ee Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 11:59:18 +0200 Subject: [PATCH 072/272] Even more refactoring --- problemtools/md2html.py | 2 +- problemtools/problem2pdf.py | 117 +++++++++++--------- problemtools/statement_common.py | 181 ++++++++++++++++++------------- 3 files changed, 174 insertions(+), 126 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 62a8e153..7b834d22 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -66,7 +66,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: with open("problem.css", "w") as output_file: with open(os.path.join(templatepath, "problem.css"), "r") as input_file: output_file.write(input_file.read()) - + return True diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 911c5cb6..09ed5962 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -12,80 +12,93 @@ def convert(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) + + if statement_common.find_statement_extension(problem_root, language=options.language) == "md": + return md2pdf(options) + else: + return latex2pdf(options) + + +def md2pdf(options: argparse.Namespace) -> bool: + problem_root = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem_root))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - if statement_common.find_statement_extension(problem_root, language=options.language) == "md": - statement_path = statement_common.find_statement(problem_root, extension="md", language=options.language) + statement_path = statement_common.find_statement(problem_root, extension="md", language=options.language) - if not os.path.isfile(statement_path): - raise Exception(f"Error! {statement_path} is not a file") + if not os.path.isfile(statement_path): + raise Exception(f"Error! {statement_path} is not a file") - templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), - os.path.join(os.path.dirname(__file__), '../templates/markdown_pdf'), - '/usr/lib/problemtools/templates/markdown_pdf'] - templatepath = next((p for p in templatepaths - if os.path.isdir(p) and os.path.isfile(os.path.join(p, "fix_tables.md"))), - None) - table_fix_path = os.path.join(templatepath, "fix_tables.md") - if not os.path.isfile(table_fix_path): - raise Exception("Could not find markdown pdf template") + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), + os.path.join(os.path.dirname(__file__), '../templates/markdown_pdf'), + '/usr/lib/problemtools/templates/markdown_pdf'] + templatepath = next((p for p in templatepaths + if os.path.isdir(p) and os.path.isfile(os.path.join(p, "fix_tables.md"))), + None) + table_fix_path = os.path.join(templatepath, "fix_tables.md") + if not os.path.isfile(table_fix_path): + raise Exception("Could not find markdown pdf template") - with open(table_fix_path, "r") as file: - table_fix = file.read() + with open(table_fix_path, "r") as file: + table_fix = file.read() - statement_dir = os.path.join(problem_root, "problem_statement") - with open(statement_path, "r") as file: - statement_md = file.read() + statement_dir = os.path.join(problem_root, "problem_statement") + with open(statement_path, "r") as file: + statement_md = file.read() - problem_name = statement_common.get_problem_name(problem_root, options.language) + problem_name = statement_common.get_problem_name(problem_root, options.language) - # Add code that adds vertical and horizontal lines to all tables - statement_md = r'\centerline{\huge %s}' % problem_name + statement_md - statement_md = table_fix + statement_md + # Add code that adds vertical and horizontal lines to all tables + statement_md = r'\centerline{\huge %s}' % problem_name + statement_md + statement_md = table_fix + statement_md - # Hacky: html samples -> md. Then we append to the markdown document - samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) + samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) - # If we don't add newline, the table might get attached to a footnote - statement_md += "\n" + samples + # If we don't add newline, the topmost table might get attached to a footnote + statement_md += "\n" + samples - with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: - temp_file.write(statement_md) - temp_file.flush() - command = ["pandoc", temp_file.name, "-o", f"{problembase}.pdf", f"--resource-path={statement_dir}"] - return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) + with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: + temp_file.write(statement_md) + temp_file.flush() + command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}"] + return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) - else: - # Set up template if necessary - with template.Template(problem_root, language=options.language) as templ: - texfile = templ.get_file_name() - origcwd = os.getcwd() +def latex2pdf(options: argparse.Namespace) -> bool: + problem_root = os.path.realpath(options.problem) + problembase = os.path.splitext(os.path.basename(problem_root))[0] + destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - os.chdir(os.path.dirname(texfile)) - params = ['pdflatex', '-interaction=nonstopmode'] - output = None - if options.quiet: - output = open(os.devnull, 'w') - if options.nopdf: - params.append('-draftmode') + # Set up template if necessary + with template.Template(problem_root, language=options.language) as templ: + texfile = templ.get_file_name() - params.append(texfile) + origcwd = os.getcwd() + os.chdir(os.path.dirname(texfile)) + params = ['pdflatex', '-interaction=nonstopmode'] + output = None + if options.quiet: + output = open(os.devnull, 'w') + if options.nopdf: + params.append('-draftmode') + + params.append(texfile) + + status = subprocess.call(params, stdout=output) + if status == 0: status = subprocess.call(params, stdout=output) - if status == 0: - status = subprocess.call(params, stdout=output) - if output is not None: - output.close() + if output is not None: + output.close() + + os.chdir(origcwd) - os.chdir(origcwd) + if status == 0 and not options.nopdf: + shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) - if status == 0 and not options.nopdf: - shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) + return status == 0 - return status == 0 def get_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 97b71170..66a6c673 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -82,55 +82,7 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: casenum = 1 for sample in sorted(os.listdir(sample_path)): if sample.endswith(".interaction"): - if to_pdf: - line = r"""\begin{tabular}{p{0.3\textwidth} p{0.5\textwidth} p{0.0\textwidth}} -\textbf{Read} & \textbf{Sample Interaction %i} & \textbf{Write} \\ -\end{tabular}""" % casenum - else: - line = f""" - - - - - - -
ReadSample Interaction {casenum}Write
""" - - with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: - sample_interaction = infile.readlines() - lines = [] - for interaction in sample_interaction: - data = interaction[1:] - if to_pdf: - if interaction[0] == '>': - left = True - elif interaction[0] == '<': - left = False - else: - print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(r""" - \begin{table}[H] - %(justify)s\begin{tabular}{|p{0.6\textwidth}|} - \hline - %(text)s \\ - \hline - \end{tabular} - \end{table}""" % {"justify": "" if left else "\\hspace*{\\fill}\n", - "text": data}) - else: - line_type = "" - if interaction[0] == '>': - line_type = "sampleinteractionwrite" - elif interaction[0] == '<': - line_type = "sampleinteractionread" - else: - print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(f"""
{data}
""") - - if to_pdf: - samples.append(line + '\\vspace{-15pt}'.join(lines)) - else: - samples.append(line + ''.join(lines)) + samples.append(format_interactive_sample(sample_path, sample, casenum, to_pdf)) casenum += 1 continue @@ -140,35 +92,118 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: outpath = os.path.join(sample_path, sample_name + ".ans") if not os.path.isfile(outpath): continue - with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: - sample_input = infile.read() - with open(outpath, "r", encoding="utf-8") as outfile: - sample_output = outfile.read() - samples.append(""" + samples.append(format_normal_sample(sample_path, sample, casenum, to_pdf)) + casenum += 1 + + return samples + + +def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bool) -> str: + """ + + Args: + sample_root: root of the sample folder + sample: file name of the sample + casenum: which sample is this? (1, 2, 3...) + to_pdf: do we target pdf or html output + + Returns: + str: the sample, ready to be pasted into a markdown doc and fed to pandoc + """ + + with open(os.path.join(sample_root, sample), "r", encoding="utf-8") as infile: + sample_input = infile.read() + sample_name = sample[:-3] + outpath = os.path.join(sample_root, sample_name + ".ans") + with open(outpath, "r", encoding="utf-8") as outfile: + sample_output = outfile.read() + + sample = """ + + + + + + + + + + + +
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), + "output": html.escape(sample_output)}) + + if to_pdf: + # If pdf, convert to markdown + with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: + temp_file.write(sample) + temp_file.flush() + command = ["pandoc", temp_file.name, "-t" , "markdown"] + return subprocess.run(command, capture_output=True, text=True, + shell=False, check=True).stdout + else: + return sample + + +def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pdf: bool) -> str: + """ + + Args: + sample_root: root of the sample folder + sample: file name of the sample + casenum: which sample is this? (1, 2, 3...) + to_pdf: do we target pdf or html output + + Returns: + str: the sample, ready to be pasted into a markdown doc and fed to pandoc + """ + if to_pdf: + line = r"""\begin{tabular}{p{0.3\textwidth} p{0.5\textwidth} p{0.0\textwidth}} +\textbf{Read} & \textbf{Sample Interaction %i} & \textbf{Write} \\ +\end{tabular}""" % casenum + else: + line = f""" - - - + + + - - - - -
Sample Input %(case)dSample Output %(case)dReadSample Interaction {casenum}Write
%(input)s
%(output)s
""" - % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + with open(os.path.join(sample_root, sample), "r", encoding="utf-8") as infile: + sample_interaction = infile.readlines() + lines = [] + for interaction in sample_interaction: + data = interaction[1:] if to_pdf: - # If pdf, convert to markdown - with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: - temp_file.write(samples[-1]) - temp_file.flush() - command = ["pandoc", temp_file.name, "-t" , "markdown"] - samples[-1] = subprocess.run(command, capture_output=True, text=True, - shell=False, check=True).stdout - - casenum += 1 + if interaction[0] == '>': + left = True + elif interaction[0] == '<': + left = False + else: + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(r""" + \begin{table}[H] + %(justify)s\begin{tabular}{|p{0.6\textwidth}|} + \hline + %(text)s \\ + \hline + \end{tabular} + \end{table}""" % {"justify": "" if left else "\\hspace*{\\fill}\n", + "text": data}) + else: + line_type = "" + if interaction[0] == '>': + line_type = "sampleinteractionwrite" + elif interaction[0] == '<': + line_type = "sampleinteractionread" + else: + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(f"""
{data}
""") - return samples + if to_pdf: + return line + '\\vspace{-15pt}'.join(lines) + else: + return line + ''.join(lines) From d8a4c3e79c91776c924edcee560271fcc94cd8f3 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 12:12:25 +0200 Subject: [PATCH 073/272] Remove python3-markdown dependency --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e708b1a..96758f52 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ The dependencies needed to *build/install* problemtools can be installed with: And the dependencies needed to *run* problemtools can be installed with: - sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-markdown python3-yaml rsvg-convert texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy + sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-yaml rsvg-convert texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy ### Fedora From 7390fb815d4cc72fe7a3a847389d37b1c3e31434 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 12:55:15 +0200 Subject: [PATCH 074/272] Add problem id to pdf and small fixes --- examples/README.md | 6 ------ problemtools/md2html.py | 6 +++--- problemtools/problem2pdf.py | 2 ++ .../{markdown => markdown_html}/default-layout.html | 0 .../templates/{markdown => markdown_html}/problem.css | 2 -- 5 files changed, 5 insertions(+), 11 deletions(-) rename problemtools/templates/{markdown => markdown_html}/default-layout.html (100%) rename problemtools/templates/{markdown => markdown_html}/problem.css (99%) diff --git a/examples/README.md b/examples/README.md index d1076a7e..9d7f9ee5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,9 +26,3 @@ more than one language. This is an example of a *scoring* problem where submissions can get different scores depending on which test groups they solve. It also demonstrates how an input validator might check different constraints for different test groups. The swedish statement showcases how to use images, footnotes and tables in Markdown. - -# bplusa - -This is an example of a problem using an output validator, where there are multiple valid answers. -The output validator is written pretty generally, guarding against the most common mistakes when using -output validators. It also demonstrates using Markdown as a statement language. diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 7b834d22..3d729e72 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -36,9 +36,9 @@ def convert(problem: str, options: argparse.Namespace) -> bool: statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout - templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), - os.path.join(os.path.dirname(__file__), '../templates/markdown'), - '/usr/lib/problemtools/templates/markdown'] + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_html'), + os.path.join(os.path.dirname(__file__), '../templates/markdown_html'), + '/usr/lib/problemtools/templates/markdown_html'] templatepath = next((p for p in templatepaths if os.path.isdir(p) and os.path.isfile(os.path.join(p, "default-layout.html"))), None) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 09ed5962..62d40dbe 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -48,7 +48,9 @@ def md2pdf(options: argparse.Namespace) -> bool: problem_name = statement_common.get_problem_name(problem_root, options.language) + problem_id = os.path.basename(problem_root) # Add code that adds vertical and horizontal lines to all tables + statement_md = r'\centerline{\large %s}' % f"Problem id: {problem_id}" + statement_md statement_md = r'\centerline{\huge %s}' % problem_name + statement_md statement_md = table_fix + statement_md diff --git a/problemtools/templates/markdown/default-layout.html b/problemtools/templates/markdown_html/default-layout.html similarity index 100% rename from problemtools/templates/markdown/default-layout.html rename to problemtools/templates/markdown_html/default-layout.html diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown_html/problem.css similarity index 99% rename from problemtools/templates/markdown/problem.css rename to problemtools/templates/markdown_html/problem.css index ca6e72ed..c38a4d97 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown_html/problem.css @@ -85,8 +85,6 @@ td { vertical-align:top; } - - div.sampleinteractionread { border: 1px solid black; width: 60%; From 3fada076f418df8797ab945b5b4f536668ecbef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pehr=20S=C3=B6derman?= Date: Sun, 18 Aug 2024 14:30:40 +0200 Subject: [PATCH 075/272] Update languages.yaml To match the setup in kattis. We switch to use PyPy, and also compile C++ with flags for rt, pthread and whole-archive. --- problemtools/config/languages.yaml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/problemtools/config/languages.yaml b/problemtools/config/languages.yaml index 130937fe..7a4b002d 100644 --- a/problemtools/config/languages.yaml +++ b/problemtools/config/languages.yaml @@ -105,7 +105,7 @@ cpp: name: 'C++' priority: 1000 files: '*.cc *.C *.cpp *.cxx *.c++' - compile: '/usr/bin/g++ -g -O2 -std=gnu++23 -static -o {binary} {files}' + compile: '/usr/bin/g++ -g -O2 -std=gnu++23 -static -o {binary} {files} -lrt -Wl,--whole-archive -lpthread -Wl,--no-whole-archive' run: '{binary}' csharp: @@ -209,27 +209,27 @@ prolog: # Python2 with shebang comes before default python3. python2_with_shebang: - name: 'Python 2' + name: 'Python 2 (w/PyPy)' priority: 860 files: '*.py *.py2' shebang: '^#!.*python2\b' - compile: '/usr/bin/python2 -m py_compile {files}' - run: '/usr/bin/python2 "{mainfile}"' + compile: '/usr/bin/pypy -m py_compile {files}' + run: '/usr/bin/pypy "{mainfile}"' python3: - name: 'Python 3' + name: 'Python 3 (w/PyPy3)' priority: 850 files: '*.py *.py3' - compile: '/usr/bin/python3 -m py_compile {files}' - run: '/usr/bin/python3 "{mainfile}"' + compile: '/usr/bin/pypy3 -m py_compile {files}' + run: '/usr/bin/pypy3 "{mainfile}"' # Python2 without shebang comes after python3. python2: - name: 'Python 2' + name: 'Python 2 (w/PyPy)' priority: 840 files: '*.py2' - compile: '/usr/bin/python2 -m py_compile {files}' - run: '/usr/bin/python2 "{mainfile}"' + compile: '/usr/bin/pypy -m py_compile {files}' + run: '/usr/bin/pypy "{mainfile}"' ruby: name: 'Ruby' From 650fa458911a4e00fc0d190f7f8df987cc7b8899 Mon Sep 17 00:00:00 2001 From: Tagl Date: Wed, 25 Sep 2024 16:03:29 +0000 Subject: [PATCH 076/272] Run interactive validation with submission's working directory --- problemtools/run/program.py | 4 ++-- problemtools/verifyproblem.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/problemtools/run/program.py b/problemtools/run/program.py index 895e1f35..023ff0e3 100644 --- a/problemtools/run/program.py +++ b/problemtools/run/program.py @@ -22,7 +22,7 @@ def __init__(self) -> None: self._compile_result: tuple[bool, str|None]|None = None def run(self, infile='/dev/null', outfile='/dev/null', errfile='/dev/null', - args=None, timelim=1000, memlim=1024, set_work_dir=False): + args=None, timelim=1000, memlim=1024, work_dir=None): """Run the program. Args: @@ -49,7 +49,7 @@ def run(self, infile='/dev/null', outfile='/dev/null', errfile='/dev/null', status, runtime = self.__run_wait(runcmd + args, infile, outfile, errfile, - timelim, memlim, self.path if set_work_dir else None) + timelim, memlim, work_dir) self.runtime = max(self.runtime, runtime) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index a45cbf9d..51d7a6c0 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -293,7 +293,7 @@ def run_submission_real(self, sub, context: Context, timelim: int, timelim_low: errfile = os.path.join(self._problem.tmpdir, f'error-{self.counter}') status, runtime = sub.run(infile=self.infile, outfile=outfile, errfile=errfile, timelim=timelim_high+1, - memlim=self._problem.config.get('limits')['memory'], set_work_dir=True) + memlim=self._problem.config.get('limits')['memory'], work_dir=sub.path) if is_TLE(status) or runtime > timelim_high: res_high = SubmissionResult('TLE') elif is_RTE(status): @@ -1614,7 +1614,7 @@ def validate_interactive(self, testcase: TestCase, submission, timelim: int, err interactive_out = f.name f.close() i_status, _ = interactive.run(outfile=interactive_out, - args=initargs + val.get_runcmd(memlim=val_memlim) + validator_args + [';'] + submission_args) + args=initargs + val.get_runcmd(memlim=val_memlim) + validator_args + [';'] + submission_args, work_dir=submission.path) if is_RTE(i_status): errorhandler.error(f'Interactive crashed, status {i_status}') else: From 05800557dddde9603e40306e53e9ec4952c55f89 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Mon, 11 Nov 2024 04:19:55 +0100 Subject: [PATCH 077/272] Change Rust compilation flags --- examples/hello/submissions/accepted/hello.rs | 3 +++ problemtools/config/languages.yaml | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 examples/hello/submissions/accepted/hello.rs diff --git a/examples/hello/submissions/accepted/hello.rs b/examples/hello/submissions/accepted/hello.rs new file mode 100644 index 00000000..47ad8c63 --- /dev/null +++ b/examples/hello/submissions/accepted/hello.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello World!"); +} diff --git a/problemtools/config/languages.yaml b/problemtools/config/languages.yaml index 7a4b002d..de1a6411 100644 --- a/problemtools/config/languages.yaml +++ b/problemtools/config/languages.yaml @@ -245,8 +245,8 @@ rust: name: 'Rust' priority: 575 files: '*.rs' - compile: '/usr/bin/rustc -o{binary} -O --crate-type bin --edition=2018 {files}' - run: '{binary}' + compile: '/usr/bin/rustc -C opt-level=3 -C target-cpu=native --crate-type bin --edition 2021 {mainfile} -o {mainfile}.out' + run: '{mainfile}.out' scala: name: 'Scala' From bd707012bc4dc11751744cf5718653b3fc423639 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Mon, 3 Mar 2025 15:10:51 +0100 Subject: [PATCH 078/272] Remove deprecated functionality Co-authored-by: Zazmuz --- problemtools/verifyproblem.py | 302 +--------------------------------- 1 file changed, 2 insertions(+), 300 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 51d7a6c0..9e696edd 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -99,7 +99,6 @@ def __init__(self, args: argparse.Namespace, executor: ThreadPoolExecutor|None) self.data_filter: Pattern[str] = args.data_filter self.submission_filter: Pattern[str] = args.submission_filter self.fixed_timelim: int|None = args.fixed_timelim - self.compile_generators: bool = ('compile_generators' not in args or args.compile_generators) self.executor = executor self._background_work: list[concurrent.futures.Future[object]] = [] @@ -729,7 +728,6 @@ def __init__(self, problem: Problem): elif param == 'interactive': pass - self._data['languages'] = self._data['languages'].split() def __str__(self) -> str: return 'problem configuration' @@ -819,299 +817,12 @@ def check(self, context: Context) -> bool: self.error('Limits key in problem.yaml must specify a dict') self._data['limits'] = ProblemConfig._OPTIONAL_CONFIG['limits'] - if self._data['languages'] != '': - for lang_id in self._data['languages']: - if lang_id != 'all' and self._problem.language_config.get(lang_id) is None: - self.error("Unrecognized language id '%s'" % lang_id) - # Some things not yet implemented if self._data['libraries'] != '': self.error("Libraries not yet supported") return self._check_res - -class Generators(ProblemAspect): - _TESTCASE_OPTIONS = ['input', 'solution', 'visualizer', 'random_salt'] - _NULLABLE_OPTIONS = ['input', 'solution', 'visualizer'] - _DATA_DIRECTORIES = {'sample', 'secret'} - _VISUALIZER_EXTENSIONS = ['png', 'jpg', 'jpeg', 'svg', 'interaction', 'desc', 'hint'] - - def __init__(self, problem: Problem): - super().__init__(f"{problem.shortname}.generators") - self.debug(' Loading generators') - self._problem = problem - self.configfile = os.path.join(problem.probdir, 'generators', 'generators.yaml') - self._data = None - self._generators: dict[str, str|list[str]|run.Program] = {} - - if os.path.isfile(self.configfile): - try: - with open(self.configfile) as f: - self._data = yaml.safe_load(f) - # Loading empty yaml yields None, for no apparent reason... - if self._data is None: - self._data = {} - except Exception as e: - self.error(str(e)) - - if isinstance(self._data, dict): - # The top-level dict always represents a directory, even if there - # is no type key - self._data['type'] = 'directory' - - def __str__(self) -> str: - return 'generators' - - def _parse_command(self, key: str, state: dict) -> tuple[str, list[str]]|None: - command = state[key] - name = os.path.basename(state['path']) - random_salt = str(state['random_salt']) - - def err() -> None: - self.error('Invalid %s key for path %s in generators.yaml' % (key, state['path'])) - - if not isinstance(command, str): - err() - return None - - seed = str(int(hashlib.sha512((random_salt + command).encode('utf-8')).hexdigest(), 16) % (2**31)) - - parts = shlex.split(command) - if not parts: - err() - return None - - for i, part in enumerate(parts): - new = '' - for j, group in enumerate(part.split('{')): - if group.count('}') != (0 if j == 0 else 1): - err() - return None - if j == 0: - new += group - else: - group, rest = group.split('}') - if group.startswith('seed'): - new += seed - elif group == 'name': - new += name - else: - err() - return None - new += rest - parts[i] = new - - program, arguments = parts[0], parts[1:] - if program not in self._generators: - self._generators[program] = program - - return (program, arguments) - - def _parse_testcase(self, data: dict, state: dict) -> None: - if state['input'] is None: - self.error('Path %s in generators.yaml must contain an input key' % state['path']) - for key in ['input', 'solution', 'visualizer']: - if state[key] is not None: - state[key] = self._parse_command(key, state) - - def _parse_directory(self, data: dict, state: dict) -> None: - # TODO: Process includes - - if 'testdata.yaml' in data: - content = data['testdata.yaml'] - if content is None: - content = {} - - cases = data.get('data', {}) - ordered = True - if not isinstance(cases, list): - ordered = False - cases = [cases] - - case_counter = 0 - case_format = '%%0%dd' % len(str(len(cases))) - for case in cases: - if not isinstance(case, dict): - self.error('Path %s/data in generators.yaml must contain a dict or a list of dicts' % state['path']) - continue - - if ordered: - case_counter += 1 - - for name, value in sorted(case.items(), key=lambda kv: str(kv[0])): - if ordered: - num = case_format % case_counter - name = num + ('' if name is None else '-' + str(name)) - else: - name = str(name) - - next_state = copy.deepcopy(state) - next_state['path'] = '%s/%s' % (state['path'], name) - self._parse_element(value, next_state) - - def _parse_element(self, data: dict, state: dict) -> None: - if data is None: - data = '/%s.in' % state['path'] - state['manual'] = True - if isinstance(data, str): - data = { 'input': data } - if not isinstance(data, dict): - self.error("Path %s in generators.yaml must specify a dict" % state['path']) - return - - state.update({ - key: data[key] - for key in Generators._TESTCASE_OPTIONS - if key in data - }) - - if data.get('type', 'testcase') == 'testcase': - self._parse_testcase(data, state) - else: - if data['type'] != 'directory': - self.error("Type of %s in generators.yaml must be 'directory'" % state['path']) - self._parse_directory(data, state) - - def _resolve_path(self, path: str) -> str: - base_path = self._problem.probdir - if path.startswith('/'): - path = path[1:] - else: - base_path = os.path.join(base_path, 'generators') - return os.path.join(*([base_path] + path.split('/'))) - - def _compile_generators(self) -> None: - for gen, files in list(self._generators.items()): - implicit = True - manual = False - if isinstance(files, str): - path = files - files = [] - implicit = False - if path.endswith('.in'): - manual = True - for ext in ['ans'] + Generators._VISUALIZER_EXTENSIONS: - other_path = path[:-2] + ext - if os.path.isfile(self._resolve_path(other_path)): - files.append(other_path) - # Always add original file last, to ensure it is chosen as - # the representative file - files.append(path) - if not isinstance(files, list) or not files: - self.error('Invalid generator %s in generators.yaml' % gen) - continue - tmpdir = tempfile.mkdtemp(prefix='generator', dir=self._problem.tmpdir) - ok = True - for opath in files: - if not isinstance(opath, str) or not opath: - self.error('Invalid generator %s in generators.yaml' % gen) - ok = False - break - - name = os.path.basename(opath) - if implicit and opath == files[0]: - # In implicit generators, the first listed file should - # be the entry point. problemtools usually picks the - # lexicographically smallest filename as the entry - # point, unless there exists a file that starts with - # "main.". Thus the following renames the file that - # should be the entry point to "main.old.extension". - # TODO: Make problemtools support passing a different - # entry point than "main.", and remove this hack. - name = 'main' + os.path.splitext(name)[1] - - fpath = self._resolve_path(opath) - dest = os.path.join(tmpdir, name) - if os.path.exists(dest): - self.error('Duplicate entry for filename %s in generator %s' % (name, gen)) - ok = False - elif not os.path.exists(fpath): - self.error('Generator %s does not exist' % opath) - ok = False - else: - try: - if os.path.isdir(fpath): - shutil.copytree(fpath, dest) - else: - shutil.copy2(fpath, dest) - except Exception as e: - self.error(str(e)) - ok = False - if ok: - if manual: - self._generators[gen] = dest - else: - prog = run.get_program(tmpdir if implicit else dest, - language_config=self._problem.language_config, - work_dir=self._problem.tmpdir) - if prog is None: - self.error('Could not load generator %s' % gen) - ok = False - else: - self._generators[gen] = prog - success, msg = prog.compile() - if not success: - self.error('Compile error for generator %s' % gen, msg) - ok = False - if not ok and gen in self._generators: - del self._generators[gen] - - def check(self, context: Context) -> bool: - if self._check_res is not None: - return self._check_res - self._check_res = True - - if self._data is None: - return self._check_res - if not isinstance(self._data, dict): - self.error('generators.yaml must specify a dict') - return self._check_res - - self._generators = self._data.get('generators') or {} - if not isinstance(self._generators, dict): - self.error('Generators key in generators.yaml must specify a dict') - self._generators = {} - - # Check the shape of the top-level data dict - if isinstance(self._data.get('data'), list): - self.error('Top-level data key in generators.yaml must specify a dict') - self._data['data'] = {} - - if isinstance(self._data.get('data'), dict): - invalid = [] - for key, value in self._data['data'].items(): - valid = False - if key not in Generators._DATA_DIRECTORIES: - self.warning("Invalid key '%s' in generators.yaml, expected one of %s" % (key, Generators._DATA_DIRECTORIES)) - elif not isinstance(value, dict): - self.warning("Key '%s' in generators.yaml must specify a dict" % key) - elif value.get('type') != 'directory': - self.warning("Type of %s in generators.yaml must be 'directory'" % key) - else: - valid = True - if not valid: - invalid.append(key) - for key in invalid: - del self._data['data'][key] - - # Run a depth-first search through generators.yaml and generate a - # flattened list of testcases - default_state: dict[str, str|bool|None] = { key: None for key in Generators._TESTCASE_OPTIONS } - default_state.update({ - 'path': 'data', - 'manual': False, - 'random_salt': '', - }) - - self._parse_element(self._data, default_state) - - if context.compile_generators: - self._compile_generators() - - return self._check_res - - class ProblemStatement(ProblemAspect): def __init__(self, problem: Problem): super().__init__(f"{problem.shortname}.statement") @@ -1949,7 +1660,7 @@ def check(self, context: Context) -> bool: return self._check_res -PROBLEM_PARTS = ['config', 'statement', 'validators', 'graders', 'generators', 'data', 'submissions'] +PROBLEM_PARTS = ['config', 'statement', 'validators', 'graders', 'data', 'submissions'] class Problem(ProblemAspect): def __init__(self, probdir: str): @@ -1968,14 +1679,7 @@ def __enter__(self) -> Problem: self.statement = ProblemStatement(self) self.attachments = Attachments(self) self.config = ProblemConfig(self) - available_languages = self.config.get('languages') - if 'all' not in available_languages: - language_config = languages.Languages() - for lang_id in available_languages: - lang_spec = self.language_config.get(lang_id) - if lang_spec is not None: - language_config.update({lang_id: self.language_config.get(lang_id)}) - self.language_config = language_config + self.available_languages = languages.load_language_config() self.is_interactive = 'interactive' in self.config.get('validation-params') self.is_scoring = (self.config.get('type') == 'scoring') @@ -1985,7 +1689,6 @@ def __enter__(self) -> Problem: self.testcase_by_infile: dict[str, TestCase] = {} self.testdata = TestCaseGroup(self, os.path.join(self.probdir, 'data')) self.submissions = Submissions(self) - self.generators = Generators(self) return self def __exit__(self, exc_type, exc_value, exc_traceback) -> None: @@ -2012,7 +1715,6 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: 'statement': [self.statement, self.attachments], 'validators': [self.input_validators, self.output_validators], 'graders': [self.graders], - 'generators': [self.generators], 'data': [self.testdata], 'submissions': [self.submissions], } From c7b03650a0a77c4421be8fadb292055414fa2b2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Mon, 3 Mar 2025 15:12:14 +0100 Subject: [PATCH 079/272] add build to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ddbe0378..2f372208 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /problemtools.egg-info/ /support/default_validator/default_validator /support/interactive/interactive +build/ From 150184d289eefa589305fbf76bfbe3fe5a80fe49 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Sun, 2 Mar 2025 16:13:49 -0600 Subject: [PATCH 080/272] Add special case error message when user output file is empty --- .../default_validator/default_validator.cc | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/support/default_validator/default_validator.cc b/support/default_validator/default_validator.cc index 0ad1be7f..b30be6e5 100644 --- a/support/default_validator/default_validator.cc +++ b/support/default_validator/default_validator.cc @@ -14,14 +14,13 @@ const int EXIT_WA = 43; std::ifstream judgein, judgeans; FILE *judgemessage = NULL; FILE *diffpos = NULL; -int judgeans_pos, stdin_pos; -int judgeans_line, stdin_line; +int judgeans_pos = 0, stdin_pos = 0; +int judgeans_line = 1, stdin_line = 1; void wrong_answer(const char *err, ...) { va_list pvar; va_start(pvar, err); - fprintf(judgemessage, "Wrong answer on line %d of output (corresponding to line %d in answer file)\n", - stdin_line, judgeans_line); + fprintf(judgemessage, "Wrong answer on line %d of output (corresponding to line %d in answer file)\n", stdin_line, judgeans_line); vfprintf(judgemessage, err, pvar); fprintf(judgemessage, "\n"); if (diffpos) { @@ -68,7 +67,7 @@ FILE *openfeedback(const char *feedbackdir, const char *feedback, const char *wh const char *USAGE = "Usage: %s judge_in judge_ans feedback_file [options] < user_out"; int main(int argc, char **argv) { - if(argc < 4) { + if (argc < 4) { judge_error(USAGE, argv[0]); } judgemessage = openfeedback(argv[3], "judgemessage.txt", argv[0]); @@ -88,16 +87,19 @@ int main(int argc, char **argv) { } else if (!strcmp(argv[a], "space_change_sensitive")) { space_change_sensitive = true; } else if (!strcmp(argv[a], "float_absolute_tolerance")) { - if (a+1 == argc || !isfloat(argv[a+1], float_abs_tol)) + if (a+1 == argc || !isfloat(argv[a+1], float_abs_tol)) { judge_error(USAGE, argv[0]); + } ++a; } else if (!strcmp(argv[a], "float_relative_tolerance")) { - if (a+1 == argc || !isfloat(argv[a+1], float_rel_tol)) + if (a+1 == argc || !isfloat(argv[a+1], float_rel_tol)) { judge_error(USAGE, argv[0]); + } ++a; } else if (!strcmp(argv[a], "float_tolerance")) { - if (a+1 == argc || !isfloat(argv[a+1], float_rel_tol)) + if (a+1 == argc || !isfloat(argv[a+1], float_rel_tol)) { judge_error(USAGE, argv[0]); + } float_abs_tol = float_rel_tol; ++a; } else { @@ -106,11 +108,8 @@ int main(int argc, char **argv) { } use_floats = float_abs_tol >= 0 || float_rel_tol >= 0; - judgeans_pos = stdin_pos; - judgeans_line = stdin_line = 1; - std::string judge, team; - while (true) { + for (int token = 0; true; token++) { // Space! Can't live with it, can't live without it... while (isspace(judgeans.peek())) { char c = (char)judgeans.get(); @@ -134,11 +133,26 @@ int main(int argc, char **argv) { ++stdin_pos; } - if (!(judgeans >> judge)) + if (!(judgeans >> judge)) { break; + } if (!(std::cin >> team)) { - wrong_answer("User EOF while judge had more output\n(Next judge token: %s)", judge.c_str()); + if (token == 0) { + if (stdin_pos == 0) { + wrong_answer( + "User EOF while judge had more output; user output was empty.\n(Next judge token: %s)", + judge.c_str() + ); + } else { + wrong_answer( + "User EOF while judge had more output; user output contained no tokens.\n(Next judge token: %s)", + judge.c_str() + ); + } + } else { + wrong_answer("User EOF while judge had more output\n(Next judge token: %s)", judge.c_str()); + } } double jval, tval; @@ -146,8 +160,8 @@ int main(int argc, char **argv) { if (!isfloat(team.c_str(), tval)) { wrong_answer("Expected float, got: %s", team.c_str()); } - if(!(fabs(jval - tval) <= float_abs_tol) && - !(fabs(jval - tval) <= float_rel_tol*fabs(jval))) { + if (!(fabs(jval - tval) <= float_abs_tol) && + !(fabs(jval - tval) <= float_rel_tol * fabs(jval))) { wrong_answer("Too large difference.\n Judge: %s\n User: %s\n Difference: %le\n (abs tol %le rel tol %le)", judge.c_str(), team.c_str(), jval-tval, float_abs_tol, float_rel_tol); } @@ -156,7 +170,7 @@ int main(int argc, char **argv) { wrong_answer("String tokens mismatch\nJudge: \"%s\"\nUser: \"%s\"", judge.c_str(), team.c_str()); } } else { - if(strcasecmp(judge.c_str(), team.c_str()) != 0) { + if (strcasecmp(judge.c_str(), team.c_str()) != 0) { wrong_answer("String tokens mismatch\nJudge: \"%s\"\nUser: \"%s\"", judge.c_str(), team.c_str()); } } From 71bdda45ab617282bb0806ad05ea3658ce8b6619 Mon Sep 17 00:00:00 2001 From: JoelNiemela Date: Mon, 3 Mar 2025 09:07:03 -0600 Subject: [PATCH 081/272] Modify error message according to github comment --- support/default_validator/default_validator.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/support/default_validator/default_validator.cc b/support/default_validator/default_validator.cc index b30be6e5..33416666 100644 --- a/support/default_validator/default_validator.cc +++ b/support/default_validator/default_validator.cc @@ -146,7 +146,7 @@ int main(int argc, char **argv) { ); } else { wrong_answer( - "User EOF while judge had more output; user output contained no tokens.\n(Next judge token: %s)", + "User EOF while judge had more output; user output contained only whitespace.\n(Next judge token: %s)", judge.c_str() ); } From 7524e0b5ef2c0e48a6638de0ebb1e987a829909c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Tue, 4 Mar 2025 00:02:25 +0100 Subject: [PATCH 082/272] add command-line argument, begin generalizing Problem class Co-authored-by: Zazmuz --- problemtools/verifyproblem.py | 159 +++++++++++++++++++++++----------- 1 file changed, 108 insertions(+), 51 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 9e696edd..a6744768 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -352,9 +352,10 @@ class TestCaseGroup(ProblemAspect): _DEFAULT_CONFIG = config.load_config('testdata.yaml') _SCORING_ONLY_KEYS = ['accept_score', 'reject_score', 'range'] - def __init__(self, problem: Problem, datadir: str, parent: TestCaseGroup|None=None): + def __init__(self, problem: Problem, datadir: str|None=None, parent: TestCaseGroup|None=None): self._parent = parent self._problem = problem + datadir = datadir or os.path.join(problem.probdir, 'data') self._datadir = datadir self.name = os.path.relpath(os.path.abspath(self._datadir), os.path.abspath(self._problem.probdir)).replace("/", ".") @@ -1663,6 +1664,7 @@ def check(self, context: Context) -> bool: PROBLEM_PARTS = ['config', 'statement', 'validators', 'graders', 'data', 'submissions'] class Problem(ProblemAspect): + def __init__(self, probdir: str): self.probdir = os.path.realpath(probdir) self.shortname: str|None = os.path.basename(self.probdir) @@ -1675,20 +1677,7 @@ def __enter__(self) -> Problem: self.error(f"Problem directory '{self.probdir}' not found") self.shortname = None return self - - self.statement = ProblemStatement(self) - self.attachments = Attachments(self) - self.config = ProblemConfig(self) - self.available_languages = languages.load_language_config() - - self.is_interactive = 'interactive' in self.config.get('validation-params') - self.is_scoring = (self.config.get('type') == 'scoring') - self.input_validators = InputValidators(self) - self.output_validators = OutputValidators(self) - self.graders = Graders(self) - self.testcase_by_infile: dict[str, TestCase] = {} - self.testdata = TestCaseGroup(self, os.path.join(self.probdir, 'data')) - self.submissions = Submissions(self) + return self def __exit__(self, exc_type, exc_value, exc_traceback) -> None: @@ -1697,6 +1686,9 @@ def __exit__(self, exc_type, exc_value, exc_traceback) -> None: def __str__(self) -> str: return str(self.shortname) + def do_check(self, args, executor, context): + pass + def check(self, args: argparse.Namespace) -> tuple[int, int]: if self.shortname is None: return 1, 0 @@ -1709,6 +1701,58 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: executor = ThreadPoolExecutor(args.threads) if args.threads > 1 else None context = Context(args, executor) + self.do_check(args, executor, context) + + return ProblemAspect.errors, ProblemAspect.warnings + + def _check_symlinks(self): + """Check that all symlinks point to something existing within the problem package""" + probdir = os.path.realpath(self.probdir) + for root, dirs, files in os.walk(probdir): + for file in dirs + files: + filename = os.path.join(root, file) + if os.path.islink(filename): + target = os.path.realpath(filename) + # relfile is the filename of the symlink, relative to the problem root (only used for nicer error messages) + relfile = os.path.relpath(filename, self.probdir) + # reltarget is what the symlink points to (absolute, or relative to where the symlink is) + reltarget = os.readlink(filename) + if not os.path.exists(target): + self.error( + f"Symlink {relfile} links to {reltarget} which does not exist" + ) + if os.path.commonpath([probdir, target]) != probdir: + self.error( + f"Symlink {relfile} links to {reltarget} which is outside of problem package" + ) + if os.path.isabs(reltarget): + self.error( + f"Symlink {relfile} links to {reltarget} which is an absolute path. Symlinks must be relative." + ) + +class ProblemLegacy(Problem): + def __enter__(self): + if super().__enter__() is None: + return None + + self.testcase_by_infile: dict[str, TestCase] = {} # Not part-mapping. Is only used in TestCase. Should maybe be moved? + + self.statement = ProblemStatement(self) + self.attachments = Attachments(self) + self.config = ProblemConfig(self) + + self.input_validators = InputValidators(self) + self.output_validators = OutputValidators(self) + self.graders = Graders(self) + self.testdata = TestCaseGroup(self) + self.submissions = Submissions(self) + + self.is_interactive = 'interactive' in self.config.get('validation-params') # Not part-mapping, only used in TestCase. Should maybe be moved? + self.is_scoring = (self.config.get('type') == 'scoring') # Not part-mapping, used in 2 places. Maybe can be moved? A little code-duplication + + return self + + def do_check(self, args, executor, context): try: part_mapping: dict[str, list] = { 'config': [self.config], @@ -1727,7 +1771,7 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: run.limit.check_limit_capabilities(self) if executor: - for part in args.parts: + for part in args.parts: # # BEWARE, args.parts being valid over problem format for item in part_mapping[part]: item.start_background_work(context) @@ -1741,32 +1785,13 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: # Wait for background work to finish before performing an rmtree on # the directory tree it uses. context.wait_for_background_work() - return ProblemAspect.errors, ProblemAspect.warnings + - def _check_symlinks(self): - """Check that all symlinks point to something existing within the problem package""" - probdir = os.path.realpath(self.probdir) - for root, dirs, files in os.walk(probdir): - for file in dirs + files: - filename = os.path.join(root, file) - if os.path.islink(filename): - target = os.path.realpath(filename) - # relfile is the filename of the symlink, relative to the problem root (only used for nicer error messages) - relfile = os.path.relpath(filename, self.probdir) - # reltarget is what the symlink points to (absolute, or relative to where the symlink is) - reltarget = os.readlink(filename) - if not os.path.exists(target): - self.error( - f"Symlink {relfile} links to {reltarget} which does not exist" - ) - if os.path.commonpath([probdir, target]) != probdir: - self.error( - f"Symlink {relfile} links to {reltarget} which is outside of problem package" - ) - if os.path.isabs(reltarget): - self.error( - f"Symlink {relfile} links to {reltarget} which is an absolute path. Symlinks must be relative." - ) +class Problem2023_07(Problem): + def __init__(self, probdir): + raise VerifyError("new format not implemented yet!") + super().__init__(probdir) + def re_argument(s: str) -> Pattern[str]: try: @@ -1781,6 +1806,7 @@ def part_argument(s: str) -> str: raise argparse.ArgumentTypeError(f"Invalid problem part specified: {s}") return s +PROBLEM_FORMATS = {'legacy':ProblemLegacy, '2023-07':Problem2023_07} def argparser_basic_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument('-b', '--bail_on_error', @@ -1795,6 +1821,9 @@ def argparser_basic_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument('--max_additional_info', type=int, default=15, help='maximum number of lines of additional info (e.g. compiler output or validator feedback) to display about an error (set to 0 to disable additional info)') + parser.add_argument('-v', '--problem_format', + default='automatic', choices=list(PROBLEM_FORMATS.keys()) + ['automatic'], + help='which problem format should the package be interpreted as, or "automatic" if it should be figured out from problem.yaml') def argparser() -> argparse.ArgumentParser: @@ -1829,6 +1858,21 @@ def initialize_logging(args: argparse.Namespace) -> None: format=fmt, level=getattr(logging, args.log_level.upper())) +def detect_problem_version(path) -> str: + config_path = os.path.join(path, 'problem.yaml') + + try: + with open(config_path) as f: + config: dict = yaml.safe_load(f) or {} + except FileExistsError: + raise VerifyError('problem.yaml does not exist') + except yaml.YAMLError: + raise VerifyError('problem.yaml could not be parsed') + except Exception as e: + raise VerifyError(str(e)) + return config.get('problem_format_version', 'legacy') + + def main() -> None: args = argparser().parse_args() @@ -1836,16 +1880,29 @@ def main() -> None: initialize_logging(args) total_errors = 0 - for problemdir in args.problemdir: - print(f'Loading problem {os.path.basename(os.path.realpath(problemdir))}') - with Problem(problemdir) as prob: - errors, warnings = prob.check(args) - p = lambda x: '' if x == 1 else 's' - print(f'{prob.shortname} tested: {errors} error{p(errors)}, {warnings} warning{p(warnings)}') - total_errors += errors - - if total_errors > 0: - sys.exit(1) + try: + for problemdir in args.problemdir: + problem_version = args.problem_format + if problem_version == 'automatic': + try: + problem_version = detect_problem_version(problemdir) + except VerifyError as e: + total_errors += 1 + print(f'ERROR: problem version could not be decided for {os.path.basename(os.path.realpath(problemdir))}: {e}') + continue + + print(f'Loading problem {os.path.basename(os.path.realpath(problemdir))}') + with PROBLEM_FORMATS[problem_version](problemdir) as prob: + errors, warnings = prob.check(args) + p = lambda x: '' if x == 1 else 's' + print(f'{prob.shortname} tested: {errors} error{p(errors)}, {warnings} warning{p(warnings)}') + total_errors += errors + + except KeyboardInterrupt: + print('\naborting...') + finally: + if total_errors > 0: + sys.exit(1) if __name__ == '__main__': main() From 38e1fd1ff2a7f2e02fd67174f176ebba9ae21528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Tue, 4 Mar 2025 15:37:24 +0100 Subject: [PATCH 083/272] small fix --- problemtools/verifyproblem.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index a6744768..a1934e48 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1731,9 +1731,10 @@ def _check_symlinks(self): ) class ProblemLegacy(Problem): - def __enter__(self): - if super().__enter__() is None: - return None + def __enter__(self) -> Problem: + super().__enter__() + if not self.shortname: + return self self.testcase_by_infile: dict[str, TestCase] = {} # Not part-mapping. Is only used in TestCase. Should maybe be moved? From 6dcc507b95ea47baf1fef08c7bd4e2f5bc7dc0a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Tue, 4 Mar 2025 16:48:45 +0100 Subject: [PATCH 084/272] abstract problems further --- problemtools/verifyproblem.py | 228 +++++++++++++++++++--------------- 1 file changed, 127 insertions(+), 101 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index a1934e48..8089fada 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -182,8 +182,8 @@ def __init__(self, problem: Problem, base: str, testcasegroup: TestCaseGroup) -> self._problem = problem self.testcasegroup = testcasegroup self.reuse_result_from: TestCase|None = None - self.counter = len(problem.testcase_by_infile) - problem.testcase_by_infile[self.infile] = self + self.counter = len(problem.data['testcase_by_infile']) + problem.data['testcase_by_infile'][self.infile] = self def check_newlines(self, filename: str) -> None: with open(filename, 'rb') as f: @@ -221,15 +221,15 @@ def check(self, context: Context) -> bool: self.check_newlines(self.ansfile) self.check_size_limits(self.infile) self.check_size_limits(self.ansfile) - self._problem.input_validators.validate(self) + self._problem.classes['input_validators'].validate(self) anssize = os.path.getsize(self.ansfile) / 1024.0 / 1024.0 - outputlim = self._problem.config.get('limits')['output'] + outputlim = self._problem.classes['config'].get('limits')['output'] if anssize > outputlim: self.error(f'Answer file ({anssize:.1f} Mb) is larger than output limit ({outputlim} Mb), you need to increase output limit') elif 2 * anssize > outputlim: self.warning(f'Answer file ({anssize:.1f} Mb) is within 50% of output limit ({outputlim} Mb), you might want to increase output limit') - if not self._problem.is_interactive: - val_res = self._problem.output_validators.validate(self, self.ansfile) + if not self._problem.data['is_interactive']: + val_res = self._problem.classes['output_validators'].validate(self, self.ansfile) if val_res.verdict != 'AC': if self.is_in_sample_group(): self.error(f'judge answer file got {val_res}') @@ -248,8 +248,8 @@ def set_symlinks(self) -> None: if not os.path.islink(self.infile): return target = os.path.realpath(self.infile) - if target in self._problem.testcase_by_infile: - self.reuse_result_from = self._problem.testcase_by_infile[target] + if target in self._problem.data['testcase_by_infile']: + self.reuse_result_from = self._problem.data['testcase_by_infile'][target] def _check_symlinks(self) -> bool: if not os.path.islink(self.infile): @@ -285,14 +285,14 @@ def run_submission(self, sub, runner: Runner, context: Context) -> Result: def run_submission_real(self, sub, context: Context, timelim: int, timelim_low: int, timelim_high: int) -> Result: # This may be called off-main thread. - if self._problem.is_interactive: - res_high = self._problem.output_validators.validate_interactive(self, sub, timelim_high, self._problem.submissions) + if self._problem.data['is_interactive']: + res_high = self._problem.classes['output_validators'].validate_interactive(self, sub, timelim_high, self._problem.classes['submissions']) else: outfile = os.path.join(self._problem.tmpdir, f'output-{self.counter}') errfile = os.path.join(self._problem.tmpdir, f'error-{self.counter}') status, runtime = sub.run(infile=self.infile, outfile=outfile, errfile=errfile, timelim=timelim_high+1, - memlim=self._problem.config.get('limits')['memory'], work_dir=sub.path) + memlim=self._problem.classes['config'].get('limits')['memory'], work_dir=sub.path) if is_TLE(status) or runtime > timelim_high: res_high = SubmissionResult('TLE') elif is_RTE(status): @@ -304,7 +304,7 @@ def run_submission_real(self, sub, context: Context, timelim: int, timelim_low: info = None res_high = SubmissionResult('RTE', additional_info=info) else: - res_high = self._problem.output_validators.validate(self, outfile) + res_high = self._problem.classes['output_validators'].validate(self, outfile) res_high.runtime = runtime if res_high.runtime <= timelim_low: @@ -382,9 +382,9 @@ def __init__(self, problem: Problem, datadir: str|None=None, parent: TestCaseGro self.config[field] = parent_value # Some deprecated properties are inherited from problem config during a transition period - problem_grading = problem.config.get('grading') + problem_grading = problem.classes['config'].get('grading') for key in ['accept_score', 'reject_score', 'range']: - if key in problem.config.get('grading'): + if key in problem.classes['config'].get('grading'): self.config[key] = problem_grading[key] problem_on_reject = problem_grading.get('on_reject') @@ -393,7 +393,7 @@ def __init__(self, problem: Problem, datadir: str|None=None, parent: TestCaseGro if problem_on_reject == 'grade': self.config['on_reject'] = 'continue' - if self._problem.config.get('type') == 'pass-fail': + if self._problem.classes['config'].get('type') == 'pass-fail': for key in TestCaseGroup._SCORING_ONLY_KEYS: if key not in self.config: self.config[key] = None @@ -471,10 +471,10 @@ def check(self, context: Context) -> bool: if self.config['grading'] not in ['default', 'custom']: self.error("Invalid grading policy in testdata.yaml") - if self.config['grading'] == 'custom' and len(self._problem.graders._graders) == 0: - self._problem.graders.error(f'{self} has custom grading but no custom graders provided') + if self.config['grading'] == 'custom' and len(self._problem.classes['graders']._graders) == 0: + self._problem.classes['graders'].error(f'{self} has custom grading but no custom graders provided') if self.config['grading'] == 'default' and Graders._default_grader is None: - self._problem.graders.error(f'{self} has default grading but I could not find default grader') + self._problem.classes['graders'].error(f'{self} has default grading but I could not find default grader') if self.config['grading'] == 'default' and 'ignore_sample' in self.config['grader_flags'].split(): if self._parent is not None: @@ -486,7 +486,7 @@ def check(self, context: Context) -> bool: if field not in TestCaseGroup._DEFAULT_CONFIG.keys(): self.warning(f"Unknown key '{field}' in '{os.path.join(self._datadir, 'testdata.yaml')}'") - if not self._problem.is_scoring: + if not self._problem.data['is_scoring']: for key in TestCaseGroup._SCORING_ONLY_KEYS: if self.config.get(key) is not None: self.error(f"Key '{key}' is only applicable for scoring problems, this is a pass-fail problem") @@ -494,7 +494,7 @@ def check(self, context: Context) -> bool: if self.config['on_reject'] not in ['break', 'continue']: self.error(f"Invalid value '{self.config['on_reject']}' for on_reject policy") - if self._problem.is_scoring: + if self._problem.data['is_scoring']: # Check grading try: score_range = self.config['range'] @@ -648,11 +648,11 @@ def aggregate_results(self, sub, sub_results: list[SubmissionResult], shadow_res res.additional_info = judge_error.additional_info res.testcase = judge_error.testcase else: - res.verdict, score = self._problem.graders.grade(sub_results, self, shadow_result) + res.verdict, score = self._problem.classes['graders'].grade(sub_results, self, shadow_result) if sub_results: res.testcase = sub_results[-1].testcase res.additional_info = sub_results[-1].additional_info - if self._problem.is_scoring: + if self._problem.data['is_scoring']: res.score = score min_score, max_score = self.get_score_range() if score is not None and not (min_score <= score <= max_score) and not self._seen_oob_scores: @@ -694,7 +694,7 @@ def __init__(self, problem: Problem): self.error(str(e)) # Add config items from problem statement e.g. name - self._data.update(problem.statement.get_config()) + self._data.update(problem.classes['statement'].get_config()) # Populate rights_owner unless license is public domain if 'rights_owner' not in self._data and self._data.get('license') != 'public domain': @@ -786,7 +786,7 @@ def check(self, context: Context) -> bool: self.error(f"Invalid value for grading.show_test_data_groups: {self._data['grading']['show_test_data_groups']}") elif self._data['grading']['show_test_data_groups'] and self._data['type'] == 'pass-fail': self.error("Showing test data groups is only supported for scoring problems, this is a pass-fail problem") - if self._data['type'] != 'pass-fail' and self._problem.testdata.has_custom_groups() and 'show_test_data_groups' not in self._origdata.get('grading', {}): + if self._data['type'] != 'pass-fail' and self._problem.classes['testdata'].has_custom_groups() and 'show_test_data_groups' not in self._origdata.get('grading', {}): self.warning("Problem has custom testcase groups, but does not specify a value for grading.show_test_data_groups; defaulting to false") if 'on_reject' in self._data['grading']: @@ -1005,7 +1005,7 @@ def collect_flags(group: TestCaseGroup, flags: set[str]) -> None: flags.add(group.config['input_validator_flags']) for subgroup in group.get_subgroups(): collect_flags(subgroup, flags) - collect_flags(self._problem.testdata, all_flags) + collect_flags(self._problem.classes['testdata'], all_flags) fd, file_name = tempfile.mkstemp() os.close(fd) @@ -1023,7 +1023,7 @@ def collect_flags(group: TestCaseGroup, flags: set[str]) -> None: self.warning(f'No validator rejects {desc} with flags "{" ".join(flags)}"') def modified_input_validates(applicable, modifier): - for testcase in self._problem.testdata.get_all_testcases(): + for testcase in self._problem.classes['testdata'].get_all_testcases(): with open(testcase.infile) as infile: infile_data = infile.read() if not applicable(infile_data): @@ -1096,7 +1096,7 @@ def check(self, context: Context) -> bool: return self._check_res self._check_res = True - if self._problem.config.get('type') == 'pass-fail' and len(self._graders) > 0: + if self._problem.classes['config'].get('type') == 'pass-fail' and len(self._graders) > 0: self.error('There are grader programs but the problem is pass-fail') for grader in self._graders: @@ -1203,12 +1203,12 @@ def check(self, context: Context) -> bool: if isinstance(v, run.SourceCode) and v.language.lang_id not in recommended_output_validator_languages: self.warning('output validator language %s is not recommended' % v.language.name) - if self._problem.config.get('validation') == 'default' and self._validators: + if self._problem.classes['config'].get('validation') == 'default' and self._validators: self.error('There are validator programs but problem.yaml has validation = "default"') - elif self._problem.config.get('validation') != 'default' and not self._validators: + elif self._problem.classes['config'].get('validation') != 'default' and not self._validators: self.error('problem.yaml specifies custom validator but no validator programs found') - if self._problem.config.get('validation') == 'default' and self._default_validator is None: + if self._problem.classes['config'].get('validation') == 'default' and self._default_validator is None: self.error('Unable to locate default validator') for val in self._validators[:]: @@ -1221,7 +1221,7 @@ def check(self, context: Context) -> bool: # Only sanity check output validators if they all actually compiled if self._check_res: - flags = self._problem.config.get('validator_flags') + flags = self._problem.classes['config'].get('validator_flags') fd, file_name = tempfile.mkstemp() os.close(fd) @@ -1230,7 +1230,7 @@ def check(self, context: Context) -> bool: f.write(case) f.close() rejected = False - for testcase in self._problem.testdata.get_all_testcases(): + for testcase in self._problem.classes['testdata'].get_all_testcases(): result = self.validate(testcase, file_name) if result.verdict != 'AC': rejected = True @@ -1263,7 +1263,7 @@ def _get_feedback(feedback_dir: str) -> str|None: def _parse_validator_results(self, val, status: int, feedbackdir, testcase: TestCase) -> SubmissionResult: - custom_score = self._problem.config.get('grading')['custom_scoring'] + custom_score = self._problem.classes['config'].get('grading')['custom_scoring'] score = None # TODO: would be good to have some way of displaying the feedback for debugging uses score_file = os.path.join(feedbackdir, 'score.txt') @@ -1298,7 +1298,7 @@ def _parse_validator_results(self, val, status: int, feedbackdir, testcase: Test def _actual_validators(self) -> list: vals = self._validators - if self._problem.config.get('validation') == 'default': + if self._problem.classes['config'].get('validation') == 'default': vals = [self._default_validator] return [val for val in vals if val is not None] @@ -1314,10 +1314,10 @@ def validate_interactive(self, testcase: TestCase, submission, timelim: int, err # file descriptor, wall time lim initargs = ['1', str(2 * timelim)] validator_args = [testcase.infile, testcase.ansfile, ''] - submission_args = submission.get_runcmd(memlim=self._problem.config.get('limits')['memory']) + submission_args = submission.get_runcmd(memlim=self._problem.classes['config'].get('limits')['memory']) - val_timelim = self._problem.config.get('limits')['validation_time'] - val_memlim = self._problem.config.get('limits')['validation_memory'] + val_timelim = self._problem.classes['config'].get('limits')['validation_time'] + val_memlim = self._problem.classes['config'].get('limits')['validation_memory'] for val in self._actual_validators(): if val.compile()[0]: feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self._problem.tmpdir) @@ -1369,9 +1369,9 @@ def validate_interactive(self, testcase: TestCase, submission, timelim: int, err def validate(self, testcase: TestCase, submission_output: str) -> SubmissionResult: res = SubmissionResult('JE') - val_timelim = self._problem.config.get('limits')['validation_time'] - val_memlim = self._problem.config.get('limits')['validation_memory'] - flags = self._problem.config.get('validator_flags').split() + testcase.testcasegroup.config['output_validator_flags'].split() + val_timelim = self._problem.classes['config'].get('limits')['validation_time'] + val_memlim = self._problem.classes['config'].get('limits')['validation_memory'] + flags = self._problem.classes['config'].get('validator_flags').split() + testcase.testcasegroup.config['output_validator_flags'].split() for val in self._actual_validators(): if val.compile()[0]: feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self._problem.tmpdir) @@ -1551,7 +1551,7 @@ def check_submission(self, sub, context: Context, expected_verdict: Verdict, tim timelim_low = timelim with Runner(self._problem, sub, context, timelim, timelim_low, timelim_high) as runner: - result, result_low, result_high = self._problem.testdata.run_submission(sub, runner, context) + result, result_low, result_high = self._problem.classes['testdata'].run_submission(sub, runner, context) if result.verdict == 'AC' and expected_verdict == 'AC' and not partial and result.sample_failures: res = result.sample_failures[0] @@ -1579,20 +1579,20 @@ def check_submission(self, sub, context: Context, expected_verdict: Verdict, tim def full_score_finite(self) -> bool: min_score, max_score = self._problem.testdata.get_score_range() - if self._problem.config.get('grading')['objective'] == 'min': + if self._problem.classes['config'].get('grading')['objective'] == 'min': return min_score != float('-inf') else: return max_score != float('inf') def fully_accepted(self, result: SubmissionResult) -> bool: - min_score, max_score = self._problem.testdata.get_score_range() - best_score = min_score if self._problem.config.get('grading')['objective'] == 'min' else max_score - return result.verdict == 'AC' and (not self._problem.is_scoring or result.score == best_score) + min_score, max_score = self._problem.classes['testdata'].get_score_range() + best_score = min_score if self._problem.classes['config'].get('grading')['objective'] == 'min' else max_score + return result.verdict == 'AC' and (not self._problem.data['is_scoring'] or result.score == best_score) def start_background_work(self, context: Context) -> None: # Send off an early background compile job for each submission and # validator, to avoid a bottleneck step at the start of each test run. - self._problem.output_validators.start_background_work(context) + self._problem.classes['output_validators'].start_background_work(context) for acr in self._submissions: for sub in self._submissions[acr]: context.submit_background_work(lambda s: s.compile(), sub) @@ -1602,7 +1602,7 @@ def check(self, context: Context) -> bool: return self._check_res self._check_res = True - limits = self._problem.config.get('limits') + limits = self._problem.classes['config'].get('limits') time_multiplier = limits['time_multiplier'] safety_margin = limits['time_safety_margin'] @@ -1661,15 +1661,29 @@ def check(self, context: Context) -> bool: return self._check_res +# TODO: This has to be thought over PROBLEM_PARTS = ['config', 'statement', 'validators', 'graders', 'data', 'submissions'] class Problem(ProblemAspect): + """ + Abstract baseclass for all problem-formats + """ + + """ + Holds all the classes that should be checked, and which classes they are dependent on, since one aspect + could depend on values in another class. Should be overridden in each problem-version. + + So the format is basically 'name' -> (ProblemAspectDerivate, ['dependency1', 'dependency2']) + """ + aspects: dict[str, tuple[type, set[str]]] = {} + part_mapping: dict[str, list[str]] = {} def __init__(self, probdir: str): self.probdir = os.path.realpath(probdir) self.shortname: str|None = os.path.basename(self.probdir) super().__init__(self.shortname) self.language_config = languages.load_language_config() + self.data = {} def __enter__(self) -> Problem: self.tmpdir = tempfile.mkdtemp(prefix=f'verify-{self.shortname}-') @@ -1677,6 +1691,24 @@ def __enter__(self) -> Problem: self.error(f"Problem directory '{self.probdir}' not found") self.shortname = None return self + + # Initialize the classes, making sure to resolve dependencies first + initialized = set() + self.classes = {} + + def init(name): + if name in initialized: + return + for d in self.aspects[name][1]: + init(d) + self.classes[name] = self.aspects[name][0](self) + initialized.add(name) + + for name in self.aspects.keys(): + if name in initialized: + continue + init(name) + return self @@ -1686,9 +1718,6 @@ def __exit__(self, exc_type, exc_value, exc_traceback) -> None: def __str__(self) -> str: return str(self.shortname) - def do_check(self, args, executor, context): - pass - def check(self, args: argparse.Namespace) -> tuple[int, int]: if self.shortname is None: return 1, 0 @@ -1701,7 +1730,29 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: executor = ThreadPoolExecutor(args.threads) if args.threads > 1 else None context = Context(args, executor) - self.do_check(args, executor, context) + try: + if not re.match('^[a-z0-9]+$', self.shortname): + self.error(f"Invalid shortname '{self.shortname}' (must be [a-z0-9]+)") + + self._check_symlinks() + + run.limit.check_limit_capabilities(self) + + if executor: + for part in args.parts: # # BEWARE, args.parts being valid over problem format + for item in self.part_mapping[part]: + self.classes[item].start_background_work(context) + + for part in args.parts: + self.msg(f'Checking {part}') + for item in self.part_mapping[part]: + self.classes[item].check(context) + except VerifyError: + pass + finally: + # Wait for background work to finish before performing an rmtree on + # the directory tree it uses. + context.wait_for_background_work() return ProblemAspect.errors, ProblemAspect.warnings @@ -1731,67 +1782,42 @@ def _check_symlinks(self): ) class ProblemLegacy(Problem): + aspects: dict[str, tuple[type, set[str]]] = { + 'config': (ProblemConfig, set(['statement'])), + 'statement': (ProblemStatement, set()), + 'attachments': (Attachments, set()), + 'input_validators': (InputValidators, set()), + 'output_validators': (OutputValidators, set()), + 'graders': (Graders, set()), + 'testdata': (TestCaseGroup, set(['config'])), + 'submissions': (Submissions, set()) + } + part_mapping: dict[str, list[str]] = { + 'config': ['config'], + 'statement': ['statement', 'attachments'], + 'validators': ['input_validators', 'output_validators'], + 'graders': ['graders'], + 'data': ['testdata'], + 'submissions': ['submissions'], + } def __enter__(self) -> Problem: + self.data['testcase_by_infile'] = {} # Not part-mapping. Is only used in TestCase. Should maybe be moved? + super().__enter__() if not self.shortname: return self - - self.testcase_by_infile: dict[str, TestCase] = {} # Not part-mapping. Is only used in TestCase. Should maybe be moved? - - self.statement = ProblemStatement(self) - self.attachments = Attachments(self) - self.config = ProblemConfig(self) - - self.input_validators = InputValidators(self) - self.output_validators = OutputValidators(self) - self.graders = Graders(self) - self.testdata = TestCaseGroup(self) - self.submissions = Submissions(self) - - self.is_interactive = 'interactive' in self.config.get('validation-params') # Not part-mapping, only used in TestCase. Should maybe be moved? - self.is_scoring = (self.config.get('type') == 'scoring') # Not part-mapping, used in 2 places. Maybe can be moved? A little code-duplication + + # Not part-mapping, only used in TestCase. Should maybe be moved? + self.data['is_interactive'] = 'interactive' in self.classes['config'].get('validation-params') + # Not part-mapping, used in 2 places. Maybe can be moved? A little code-duplication + self.data['is_scoring'] = (self.classes['config'].get('type') == 'scoring') return self - - def do_check(self, args, executor, context): - try: - part_mapping: dict[str, list] = { - 'config': [self.config], - 'statement': [self.statement, self.attachments], - 'validators': [self.input_validators, self.output_validators], - 'graders': [self.graders], - 'data': [self.testdata], - 'submissions': [self.submissions], - } - - if not re.match('^[a-z0-9]+$', self.shortname): - self.error(f"Invalid shortname '{self.shortname}' (must be [a-z0-9]+)") - - self._check_symlinks() - - run.limit.check_limit_capabilities(self) - - if executor: - for part in args.parts: # # BEWARE, args.parts being valid over problem format - for item in part_mapping[part]: - item.start_background_work(context) - - for part in args.parts: - self.msg(f'Checking {part}') - for item in part_mapping[part]: - item.check(context) - except VerifyError: - pass - finally: - # Wait for background work to finish before performing an rmtree on - # the directory tree it uses. - context.wait_for_background_work() class Problem2023_07(Problem): def __init__(self, probdir): raise VerifyError("new format not implemented yet!") - super().__init__(probdir) def re_argument(s: str) -> Pattern[str]: From d4c778b81233ed78fd164b0c7145554692e1574e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Wed, 5 Mar 2025 13:30:21 +0100 Subject: [PATCH 085/272] catch general exception for detecting problem-format --- problemtools/verifyproblem.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 8089fada..a3457cc7 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1887,14 +1887,9 @@ def initialize_logging(args: argparse.Namespace) -> None: def detect_problem_version(path) -> str: config_path = os.path.join(path, 'problem.yaml') - try: with open(config_path) as f: config: dict = yaml.safe_load(f) or {} - except FileExistsError: - raise VerifyError('problem.yaml does not exist') - except yaml.YAMLError: - raise VerifyError('problem.yaml could not be parsed') except Exception as e: raise VerifyError(str(e)) return config.get('problem_format_version', 'legacy') From 91b9b552988d673a50c34c6f773e387a1d3f4287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Wed, 5 Mar 2025 13:41:19 +0100 Subject: [PATCH 086/272] Add some documentation --- problemtools/verifyproblem.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index a3457cc7..82cf2948 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1676,6 +1676,18 @@ class Problem(ProblemAspect): So the format is basically 'name' -> (ProblemAspectDerivate, ['dependency1', 'dependency2']) """ aspects: dict[str, tuple[type, set[str]]] = {} + + """ + Holds the configurable mapping of parts to different problem-aspects. This means you can specify + each key as a commandline-argument if it should be checked and it will map to a list of checks + done for that part. + + You could for example have 'statement' -> ['statement', 'attachment']. This would indicate that + if you include the part 'statemen' in the verification, then the classes 'statement' and 'attachment' + would be verified during the checking-step. + + Note that all classes will be loaded regardless. + """ part_mapping: dict[str, list[str]] = {} def __init__(self, probdir: str): From 26bf20cde45a00f05429b1d4ed3f6b0e1c30bce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Wed, 5 Mar 2025 16:43:41 +0100 Subject: [PATCH 087/272] New abstraction, ProblemPart which makes it easier to implement parts of problems --- problemtools/verifyproblem.py | 399 ++++++++++++++++++---------------- 1 file changed, 213 insertions(+), 186 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 82cf2948..796650cd 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -168,14 +168,35 @@ def check_basename(self, path: str) -> None: if not self.basename_regex.match(basename): self.error(f"Invalid name '{basename}' (should match '{self.basename_regex.pattern}')") +class ProblemPart(ProblemAspect): + PART_NAME = None + DEPENDS_ON = set() + + def __init__(self, problem: Problem) -> None: + if self.PART_NAME is None: + raise NotImplementedError('Every problem-part must override PART_NAME') + super().__init__(f"{problem.shortname}.{self.PART_NAME}") + self.problem = problem + self.problem.data[self.PART_NAME] = {} + self.setup() + + def setup(self) -> None: + pass + + def set_prop(self, key: str, val: Any) -> None: + self.problem.data[self.PART_NAME][key] = val + def start_background_work(self, context: Context) -> None: pass + def check(self, context: Context) -> bool: + return True + class TestCase(ProblemAspect): Result = tuple[SubmissionResult, SubmissionResult, SubmissionResult] - def __init__(self, problem: Problem, base: str, testcasegroup: TestCaseGroup) -> None: - super().__init__(f"{problem.shortname}.test.{testcasegroup.name}.{os.path.basename(base)}") + def __init__(self, problem: Problem, aspect_name: str, base: str, testcasegroup: TestCaseGroup) -> None: + super().__init__(f"{problem.shortname}.{aspect_name}.{testcasegroup.name}.{os.path.basename(base)}") self._base = base self.infile = f'{base}.in' self.ansfile = f'{base}.ans' @@ -221,7 +242,7 @@ def check(self, context: Context) -> bool: self.check_newlines(self.ansfile) self.check_size_limits(self.infile) self.check_size_limits(self.ansfile) - self._problem.classes['input_validators'].validate(self) + self._problem.classes[InputValidators.PART_NAME].validate(self) anssize = os.path.getsize(self.ansfile) / 1024.0 / 1024.0 outputlim = self._problem.classes['config'].get('limits')['output'] if anssize > outputlim: @@ -229,7 +250,7 @@ def check(self, context: Context) -> bool: elif 2 * anssize > outputlim: self.warning(f'Answer file ({anssize:.1f} Mb) is within 50% of output limit ({outputlim} Mb), you might want to increase output limit') if not self._problem.data['is_interactive']: - val_res = self._problem.classes['output_validators'].validate(self, self.ansfile) + val_res = self._problem.classes[OutputValidators.PART_NAME].validate(self, self.ansfile) if val_res.verdict != 'AC': if self.is_in_sample_group(): self.error(f'judge answer file got {val_res}') @@ -286,7 +307,7 @@ def run_submission(self, sub, runner: Runner, context: Context) -> Result: def run_submission_real(self, sub, context: Context, timelim: int, timelim_low: int, timelim_high: int) -> Result: # This may be called off-main thread. if self._problem.data['is_interactive']: - res_high = self._problem.classes['output_validators'].validate_interactive(self, sub, timelim_high, self._problem.classes['submissions']) + res_high = self._problem.classes[OutputValidators.PART_NAME].validate_interactive(self, sub, timelim_high, self._problem.classes['submissions']) else: outfile = os.path.join(self._problem.tmpdir, f'output-{self.counter}') errfile = os.path.join(self._problem.tmpdir, f'error-{self.counter}') @@ -304,7 +325,7 @@ def run_submission_real(self, sub, context: Context, timelim: int, timelim_low: info = None res_high = SubmissionResult('RTE', additional_info=info) else: - res_high = self._problem.classes['output_validators'].validate(self, outfile) + res_high = self._problem.classes[OutputValidators.PART_NAME].validate(self, outfile) res_high.runtime = runtime if res_high.runtime <= timelim_low: @@ -352,7 +373,7 @@ class TestCaseGroup(ProblemAspect): _DEFAULT_CONFIG = config.load_config('testdata.yaml') _SCORING_ONLY_KEYS = ['accept_score', 'reject_score', 'range'] - def __init__(self, problem: Problem, datadir: str|None=None, parent: TestCaseGroup|None=None): + def __init__(self, problem: Problem, aspect_name: str, datadir: str|None=None, parent: TestCaseGroup|None=None): self._parent = parent self._problem = problem datadir = datadir or os.path.join(problem.probdir, 'data') @@ -360,7 +381,7 @@ def __init__(self, problem: Problem, datadir: str|None=None, parent: TestCaseGro self.name = os.path.relpath(os.path.abspath(self._datadir), os.path.abspath(self._problem.probdir)).replace("/", ".") - super().__init__(f"{problem.shortname}.test.{self.name}") + super().__init__(f"{problem.shortname}.{aspect_name}.{self.name}") self._seen_oob_scores = False self.debug('Loading test data group %s', datadir) @@ -381,6 +402,7 @@ def __init__(self, problem: Problem, datadir: str|None=None, parent: TestCaseGro if not field in self.config: self.config[field] = parent_value + # TODO: Decide if these should stay # Some deprecated properties are inherited from problem config during a transition period problem_grading = problem.classes['config'].get('grading') for key in ['accept_score', 'reject_score', 'range']: @@ -407,11 +429,11 @@ def __init__(self, problem: Problem, datadir: str|None=None, parent: TestCaseGro for filename in sorted(os.listdir(datadir)): filename = os.path.join(datadir, filename) if os.path.isdir(filename): - self._items.append(TestCaseGroup(problem, filename, self)) + self._items.append(TestCaseGroup(problem, aspect_name, filename, self)) else: base, ext = os.path.splitext(filename) if ext == '.ans' and os.path.isfile(f'{base}.in'): - self._items.append(TestCase(problem, base, self)) + self._items.append(TestCase(problem, aspect_name, base, self)) if not parent: self.set_symlinks() @@ -471,10 +493,10 @@ def check(self, context: Context) -> bool: if self.config['grading'] not in ['default', 'custom']: self.error("Invalid grading policy in testdata.yaml") - if self.config['grading'] == 'custom' and len(self._problem.classes['graders']._graders) == 0: - self._problem.classes['graders'].error(f'{self} has custom grading but no custom graders provided') + if self.config['grading'] == 'custom' and len(self._problem.classes[Graders.PART_NAME]._graders) == 0: + self._problem.classes[Graders.PART_NAME].error(f'{self} has custom grading but no custom graders provided') if self.config['grading'] == 'default' and Graders._default_grader is None: - self._problem.classes['graders'].error(f'{self} has default grading but I could not find default grader') + self._problem.classes[Graders.PART_NAME].error(f'{self} has default grading but I could not find default grader') if self.config['grading'] == 'default' and 'ignore_sample' in self.config['grader_flags'].split(): if self._parent is not None: @@ -648,7 +670,7 @@ def aggregate_results(self, sub, sub_results: list[SubmissionResult], shadow_res res.additional_info = judge_error.additional_info res.testcase = judge_error.testcase else: - res.verdict, score = self._problem.classes['graders'].grade(sub_results, self, shadow_result) + res.verdict, score = self._problem.classes[Graders.PART_NAME].grade(sub_results, self, shadow_result) if sub_results: res.testcase = sub_results[-1].testcase res.additional_info = sub_results[-1].additional_info @@ -670,17 +692,89 @@ def all_datasets(self) -> list: res += child.all_datasets() return res +class ProblemStatement(ProblemPart): + PART_NAME = 'statement' + + def setup(self): + self.debug(' Loading problem statement') + self.languages = [] + glob_path = os.path.join(self.problem.probdir, 'problem_statement', 'problem.') + if glob.glob(glob_path + 'tex'): + self.languages.append('') + for f in glob.glob(glob_path + '[a-z][a-z].tex'): + m = re.search("problem.([a-z][a-z]).tex$", f) + assert m + self.languages.append(m.group(1)) + + self.set_prop('config', self.get_config()) + + def check(self, context: Context) -> bool: + if self._check_res is not None: + return self._check_res + self._check_res = True + + if not self.languages: + self.error('No problem statements found (expected problem.tex or problem.[a-z][a-z].tex in problem_statement directory)') + if '' in self.languages and 'en' in self.languages: + self.error("Can't supply both problem.tex and problem.en.tex") + + for lang in self.languages: + try: + options = problem2pdf.get_parser().parse_args([""]) + options.problem = self.problem.probdir + options.language = lang + options.nopdf = True + options.quiet = True + if not problem2pdf.convert(options): + langparam = f' --language {lang}' if lang != '' else '' + self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') + except Exception as e: + self.error(f'Error raised when checking problem statement for language {lang}:\n{e}\n{traceback.format_exc()}') + try: + options = problem2html.get_parser().parse_args([""]) + options.problem = self.problem.probdir + options.destdir = os.path.join(self.problem.tmpdir, 'html') + options.language = lang + options.quiet = True + problem2html.convert(options) + except Exception as e: + langparam = f' --language {lang}' if lang != '' else '' + self.error(f'Could not convert problem statement to html for language "{lang}". Run problem2html{langparam} on the problem to diagnose.\n{e}\n{traceback.format_exc()}') + return self._check_res + + def __str__(self) -> str: + return 'problem statement' + + def get_config(self) -> dict[str, dict[str, str]]: + ret: dict[str, dict[str, str]] = {} + for lang in self.languages: + filename = f'problem.{lang}.tex' if lang != '' else 'problem.tex' + stmt = open(os.path.join(self.problem.probdir, 'problem_statement', filename)).read() + patterns = [ + (r'\\problemname{(.*)}', 'name'), + (r'^%%\s*plainproblemname:(.*)$', 'name'), + ] + for tup in patterns: + pattern = tup[0] + dest = tup[1] + hit = re.search(pattern, stmt, re.MULTILINE) + if hit: + if not dest in ret: + ret[dest] = {} + ret[dest][lang] = hit.group(1).strip() + return ret + +class ProblemConfig(ProblemPart): + PART_NAME = 'config' + DEPENDS_ON = {ProblemStatement} -class ProblemConfig(ProblemAspect): _MANDATORY_CONFIG = ['name'] _OPTIONAL_CONFIG = config.load_config('problem.yaml') _VALID_LICENSES = ['unknown', 'public domain', 'cc0', 'cc by', 'cc by-sa', 'educational', 'permission'] - def __init__(self, problem: Problem): - super().__init__(f"{problem.shortname}.config") + def setup(self): self.debug(' Loading problem config') - self._problem = problem - self.configfile = os.path.join(problem.probdir, 'problem.yaml') + self.configfile = os.path.join(self.problem.probdir, 'problem.yaml') self._data = {} if os.path.isfile(self.configfile): @@ -694,7 +788,7 @@ def __init__(self, problem: Problem): self.error(str(e)) # Add config items from problem statement e.g. name - self._data.update(problem.classes['statement'].get_config()) + self._data.update(self.problem.classes['statement'].get_config()) # Populate rights_owner unless license is public domain if 'rights_owner' not in self._data and self._data.get('license') != 'public domain': @@ -729,6 +823,8 @@ def __init__(self, problem: Problem): elif param == 'interactive': pass + self.set_prop('data', self._data) + def __str__(self) -> str: return 'problem configuration' @@ -786,7 +882,7 @@ def check(self, context: Context) -> bool: self.error(f"Invalid value for grading.show_test_data_groups: {self._data['grading']['show_test_data_groups']}") elif self._data['grading']['show_test_data_groups'] and self._data['type'] == 'pass-fail': self.error("Showing test data groups is only supported for scoring problems, this is a pass-fail problem") - if self._data['type'] != 'pass-fail' and self._problem.classes['testdata'].has_custom_groups() and 'show_test_data_groups' not in self._origdata.get('grading', {}): + if self._data['type'] != 'pass-fail' and self.problem.get(ProblemTestCases.PART_NAME, 'root_group').has_custom_groups() and 'show_test_data_groups' not in self._origdata.get('grading', {}): self.warning("Problem has custom testcase groups, but does not specify a value for grading.show_test_data_groups; defaulting to false") if 'on_reject' in self._data['grading']: @@ -824,78 +920,19 @@ def check(self, context: Context) -> bool: return self._check_res -class ProblemStatement(ProblemAspect): - def __init__(self, problem: Problem): - super().__init__(f"{problem.shortname}.statement") - self.debug(' Loading problem statement') - self._problem = problem - self.languages = [] - glob_path = os.path.join(problem.probdir, 'problem_statement', 'problem.') - if glob.glob(glob_path + 'tex'): - self.languages.append('') - for f in glob.glob(glob_path + '[a-z][a-z].tex'): - m = re.search("problem.([a-z][a-z]).tex$", f) - assert m - self.languages.append(m.group(1)) - - def check(self, context: Context) -> bool: - if self._check_res is not None: - return self._check_res - self._check_res = True +class ProblemTestCases(ProblemPart): + PART_NAME = 'testdata' + DEPENDS_ON = {ProblemConfig} - if not self.languages: - self.error('No problem statements found (expected problem.tex or problem.[a-z][a-z].tex in problem_statement directory)') - if '' in self.languages and 'en' in self.languages: - self.error("Can't supply both problem.tex and problem.en.tex") + def setup(self): + self.set_prop('root_group', TestCaseGroup(self.problem, self.PART_NAME)) - for lang in self.languages: - try: - options = problem2pdf.get_parser().parse_args([""]) - options.problem = self._problem.probdir - options.language = lang - options.nopdf = True - options.quiet = True - if not problem2pdf.convert(options): - langparam = f' --language {lang}' if lang != '' else '' - self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') - except Exception as e: - self.error(f'Error raised when checking problem statement for language {lang}:\n{e}\n{traceback.format_exc()}') - try: - options = problem2html.get_parser().parse_args([""]) - options.problem = self._problem.probdir - options.destdir = os.path.join(self._problem.tmpdir, 'html') - options.language = lang - options.quiet = True - problem2html.convert(options) - except Exception as e: - langparam = f' --language {lang}' if lang != '' else '' - self.error(f'Could not convert problem statement to html for language "{lang}". Run problem2html{langparam} on the problem to diagnose.\n{e}\n{traceback.format_exc()}') - return self._check_res + def check(self, context: Context) -> bool: + return self.problem.get(self.PART_NAME, 'root_group').check(context) - def __str__(self) -> str: - return 'problem statement' - - def get_config(self) -> dict[str, dict[str, str]]: - ret: dict[str, dict[str, str]] = {} - for lang in self.languages: - filename = f'problem.{lang}.tex' if lang != '' else 'problem.tex' - stmt = open(os.path.join(self._problem.probdir, 'problem_statement', filename)).read() - patterns = [ - (r'\\problemname{(.*)}', 'name'), - (r'^%%\s*plainproblemname:(.*)$', 'name'), - ] - for tup in patterns: - pattern = tup[0] - dest = tup[1] - hit = re.search(pattern, stmt, re.MULTILINE) - if hit: - if not dest in ret: - ret[dest] = {} - ret[dest][lang] = hit.group(1).strip() - return ret -class Attachments(ProblemAspect): +class Attachments(ProblemPart): """Represents the attachments of a problem. Attributes: @@ -903,9 +940,10 @@ class Attachments(ProblemAspect): """ - def __init__(self, problem: Problem): - super().__init__(f"{problem.shortname}.attachments") - attachments_path = os.path.join(problem.probdir, 'attachments') + PART_NAME = 'attachments' + + def setup(self): + attachments_path = os.path.join(self.problem.probdir, 'attachments') self.attachments: list[str] = [] if os.path.isdir(attachments_path): self.attachments = [os.path.join(attachments_path, attachment_name) for attachment_name in os.listdir(attachments_path)] @@ -951,23 +989,22 @@ def _build_junk_modifier(desc: str, pattern: str, repl: str|Callable[[Match[str] ('random junk added to the end of the file', lambda f: True, lambda f: f + ''.join(random.choice(string.printable) for _ in range(200))), ] -class InputValidators(ProblemAspect): +class InputValidators(ProblemPart): + PART_NAME = 'input_validator' - def __init__(self, problem: Problem): - super().__init__(f"{problem.shortname}.input_validator") - self._problem = problem - input_validators_path = os.path.join(problem.probdir, 'input_format_validators') + def setup(self): + input_validators_path = os.path.join(self.problem.probdir, 'input_format_validators') if os.path.isdir(input_validators_path): self._uses_old_path = True else: self._uses_old_path = False - new_input_validators_path = os.path.join(problem.probdir, 'input_validators') + new_input_validators_path = os.path.join(self.problem.probdir, 'input_validators') if os.path.isdir(new_input_validators_path): input_validators_path = new_input_validators_path self._validators = run.find_programs(input_validators_path, - language_config=problem.language_config, + language_config=self.problem.language_config, allow_validation_script=True, - work_dir=problem.tmpdir) + work_dir=self.problem.tmpdir) def __str__(self) -> str: @@ -1005,7 +1042,7 @@ def collect_flags(group: TestCaseGroup, flags: set[str]) -> None: flags.add(group.config['input_validator_flags']) for subgroup in group.get_subgroups(): collect_flags(subgroup, flags) - collect_flags(self._problem.classes['testdata'], all_flags) + collect_flags(self.problem.get(ProblemTestCases.PART_NAME, 'root_group'), all_flags) fd, file_name = tempfile.mkstemp() os.close(fd) @@ -1023,7 +1060,7 @@ def collect_flags(group: TestCaseGroup, flags: set[str]) -> None: self.warning(f'No validator rejects {desc} with flags "{" ".join(flags)}"') def modified_input_validates(applicable, modifier): - for testcase in self._problem.classes['testdata'].get_all_testcases(): + for testcase in self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_all_testcases(): with open(testcase.infile) as infile: infile_data = infile.read() if not applicable(infile_data): @@ -1078,15 +1115,15 @@ def validate(self, testcase: TestCase) -> None: testcase.error(emsg, validator_output) -class Graders(ProblemAspect): +class Graders(ProblemPart): _default_grader = run.get_tool('default_grader') - def __init__(self, problem: Problem): - super().__init__(f"{problem.shortname}.grader") - self._problem = problem - self._graders: list = run.find_programs(os.path.join(problem.probdir, 'graders'), - language_config=problem.language_config, - work_dir=problem.tmpdir) + PART_NAME = 'grader' + + def setup(self): + self._graders: list = run.find_programs(os.path.join(self.problem.probdir, 'graders'), + language_config=self.problem.language_config, + work_dir=self.problem.tmpdir) def __str__(self) -> str: return 'graders' @@ -1096,7 +1133,7 @@ def check(self, context: Context) -> bool: return self._check_res self._check_res = True - if self._problem.classes['config'].get('type') == 'pass-fail' and len(self._graders) > 0: + if self.problem.classes['config'].get('type') == 'pass-fail' and len(self._graders) > 0: self.error('There are grader programs but the problem is pass-fail') for grader in self._graders: @@ -1167,17 +1204,16 @@ def grade(self, sub_results: list[SubmissionResult], testcasegroup: TestCaseGrou return (verdict, score) -class OutputValidators(ProblemAspect): +class OutputValidators(ProblemPart): _default_validator = run.get_tool('default_validator') + + PART_NAME = 'output_validator' - - def __init__(self, problem: Problem): - super().__init__(f"{problem.shortname}.output_validator") - self._problem = problem - self._validators = run.find_programs(os.path.join(problem.probdir, + def setup(self): + self._validators = run.find_programs(os.path.join(self.problem.probdir, 'output_validators'), - language_config=problem.language_config, - work_dir=problem.tmpdir) + language_config=self.problem.language_config, + work_dir=self.problem.tmpdir) self._has_precompiled = False @@ -1203,12 +1239,12 @@ def check(self, context: Context) -> bool: if isinstance(v, run.SourceCode) and v.language.lang_id not in recommended_output_validator_languages: self.warning('output validator language %s is not recommended' % v.language.name) - if self._problem.classes['config'].get('validation') == 'default' and self._validators: + if self.problem.classes['config'].get('validation') == 'default' and self._validators: self.error('There are validator programs but problem.yaml has validation = "default"') - elif self._problem.classes['config'].get('validation') != 'default' and not self._validators: + elif self.problem.classes['config'].get('validation') != 'default' and not self._validators: self.error('problem.yaml specifies custom validator but no validator programs found') - if self._problem.classes['config'].get('validation') == 'default' and self._default_validator is None: + if self.problem.classes['config'].get('validation') == 'default' and self._default_validator is None: self.error('Unable to locate default validator') for val in self._validators[:]: @@ -1221,7 +1257,7 @@ def check(self, context: Context) -> bool: # Only sanity check output validators if they all actually compiled if self._check_res: - flags = self._problem.classes['config'].get('validator_flags') + flags = self.problem.classes['config'].get('validator_flags') fd, file_name = tempfile.mkstemp() os.close(fd) @@ -1230,7 +1266,7 @@ def check(self, context: Context) -> bool: f.write(case) f.close() rejected = False - for testcase in self._problem.classes['testdata'].get_all_testcases(): + for testcase in self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_all_testcases(): result = self.validate(testcase, file_name) if result.verdict != 'AC': rejected = True @@ -1263,7 +1299,7 @@ def _get_feedback(feedback_dir: str) -> str|None: def _parse_validator_results(self, val, status: int, feedbackdir, testcase: TestCase) -> SubmissionResult: - custom_score = self._problem.classes['config'].get('grading')['custom_scoring'] + custom_score = self.problem.classes['config'].get('grading')['custom_scoring'] score = None # TODO: would be good to have some way of displaying the feedback for debugging uses score_file = os.path.join(feedbackdir, 'score.txt') @@ -1298,7 +1334,7 @@ def _parse_validator_results(self, val, status: int, feedbackdir, testcase: Test def _actual_validators(self) -> list: vals = self._validators - if self._problem.classes['config'].get('validation') == 'default': + if self.problem.classes['config'].get('validation') == 'default': vals = [self._default_validator] return [val for val in vals if val is not None] @@ -1314,13 +1350,13 @@ def validate_interactive(self, testcase: TestCase, submission, timelim: int, err # file descriptor, wall time lim initargs = ['1', str(2 * timelim)] validator_args = [testcase.infile, testcase.ansfile, ''] - submission_args = submission.get_runcmd(memlim=self._problem.classes['config'].get('limits')['memory']) + submission_args = submission.get_runcmd(memlim=self.problem.classes['config'].get('limits')['memory']) - val_timelim = self._problem.classes['config'].get('limits')['validation_time'] - val_memlim = self._problem.classes['config'].get('limits')['validation_memory'] + val_timelim = self.problem.classes['config'].get('limits')['validation_time'] + val_memlim = self.problem.classes['config'].get('limits')['validation_memory'] for val in self._actual_validators(): if val.compile()[0]: - feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self._problem.tmpdir) + feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self.problem.tmpdir) validator_args[2] = feedbackdir + os.sep f = tempfile.NamedTemporaryFile(delete=False) interactive_out = f.name @@ -1369,13 +1405,13 @@ def validate_interactive(self, testcase: TestCase, submission, timelim: int, err def validate(self, testcase: TestCase, submission_output: str) -> SubmissionResult: res = SubmissionResult('JE') - val_timelim = self._problem.classes['config'].get('limits')['validation_time'] - val_memlim = self._problem.classes['config'].get('limits')['validation_memory'] - flags = self._problem.classes['config'].get('validator_flags').split() + testcase.testcasegroup.config['output_validator_flags'].split() + val_timelim = self.problem.classes['config'].get('limits')['validation_time'] + val_memlim = self.problem.classes['config'].get('limits')['validation_memory'] + flags = self.problem.classes['config'].get('validator_flags').split() + testcase.testcasegroup.config['output_validator_flags'].split() for val in self._actual_validators(): if val.compile()[0]: - feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self._problem.tmpdir) - validator_output = tempfile.mkdtemp(prefix='checker_out', dir=self._problem.tmpdir) + feedbackdir = tempfile.mkdtemp(prefix='feedback', dir=self.problem.tmpdir) + validator_output = tempfile.mkdtemp(prefix='checker_out', dir=self.problem.tmpdir) outfile = validator_output + "/out.txt" errfile = validator_output + "/err.txt" status, runtime = val.run(submission_output, @@ -1511,7 +1547,7 @@ def _recompute_jobs(self) -> None: self._remaining_jobs.reverse() -class Submissions(ProblemAspect): +class Submissions(ProblemPart): _SUB_REGEXP = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.-]*[a-zA-Z0-9](\.c\+\+)?$') # (verdict, directory, required) _VERDICTS: list[tuple[Verdict, str, bool]] = [ @@ -1522,18 +1558,18 @@ class Submissions(ProblemAspect): ('TLE', 'time_limit_exceeded', False), ] - def __init__(self, problem: Problem): - super().__init__(f"{problem.shortname}.submission") + PART_NAME = 'submission' + + def setup(self): self._submissions = {} - self._problem = problem - srcdir = os.path.join(problem.probdir, 'submissions') + srcdir = os.path.join(self.problem.probdir, 'submissions') for verdict in Submissions._VERDICTS: acr = verdict[0] self._submissions[acr] = run.find_programs(os.path.join(srcdir, verdict[1]), - language_config=problem.language_config, + language_config=self.problem.language_config, pattern=Submissions._SUB_REGEXP, - work_dir=problem.tmpdir, - include_dir=os.path.join(problem.probdir, + work_dir=self.problem.tmpdir, + include_dir=os.path.join(self.problem.probdir, 'include')) def __str__(self) -> str: @@ -1550,8 +1586,8 @@ def check_submission(self, sub, context: Context, expected_verdict: Verdict, tim else: timelim_low = timelim - with Runner(self._problem, sub, context, timelim, timelim_low, timelim_high) as runner: - result, result_low, result_high = self._problem.classes['testdata'].run_submission(sub, runner, context) + with Runner(self.problem, sub, context, timelim, timelim_low, timelim_high) as runner: + result, result_low, result_high = self.problem.get(ProblemTestCases.PART_NAME, 'root_group').run_submission(sub, runner, context) if result.verdict == 'AC' and expected_verdict == 'AC' and not partial and result.sample_failures: res = result.sample_failures[0] @@ -1578,21 +1614,21 @@ def check_submission(self, sub, context: Context, expected_verdict: Verdict, tim return result def full_score_finite(self) -> bool: - min_score, max_score = self._problem.testdata.get_score_range() - if self._problem.classes['config'].get('grading')['objective'] == 'min': + min_score, max_score = self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_score_range() + if self.problem.classes['config'].get('grading')['objective'] == 'min': return min_score != float('-inf') else: return max_score != float('inf') def fully_accepted(self, result: SubmissionResult) -> bool: - min_score, max_score = self._problem.classes['testdata'].get_score_range() - best_score = min_score if self._problem.classes['config'].get('grading')['objective'] == 'min' else max_score - return result.verdict == 'AC' and (not self._problem.data['is_scoring'] or result.score == best_score) + min_score, max_score = self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_score_range() + best_score = min_score if self.problem.classes['config'].get('grading')['objective'] == 'min' else max_score + return result.verdict == 'AC' and (not self.problem.data['is_scoring'] or result.score == best_score) def start_background_work(self, context: Context) -> None: # Send off an early background compile job for each submission and # validator, to avoid a bottleneck step at the start of each test run. - self._problem.classes['output_validators'].start_background_work(context) + self.problem.classes[OutputValidators.PART_NAME].start_background_work(context) for acr in self._submissions: for sub in self._submissions[acr]: context.submit_background_work(lambda s: s.compile(), sub) @@ -1602,7 +1638,7 @@ def check(self, context: Context) -> bool: return self._check_res self._check_res = True - limits = self._problem.classes['config'].get('limits') + limits = self.problem.classes['config'].get('limits') time_multiplier = limits['time_multiplier'] safety_margin = limits['time_safety_margin'] @@ -1670,14 +1706,12 @@ class Problem(ProblemAspect): """ """ - Holds all the classes that should be checked, and which classes they are dependent on, since one aspect - could depend on values in another class. Should be overridden in each problem-version. - - So the format is basically 'name' -> (ProblemAspectDerivate, ['dependency1', 'dependency2']) + TODO: write thorough documentation """ - aspects: dict[str, tuple[type, set[str]]] = {} + aspects: set[type] = set() """ + TODO: This is incorrect Holds the configurable mapping of parts to different problem-aspects. This means you can specify each key as a commandline-argument if it should be checked and it will map to a list of checks done for that part. @@ -1688,7 +1722,7 @@ class Problem(ProblemAspect): Note that all classes will be loaded regardless. """ - part_mapping: dict[str, list[str]] = {} + part_mapping: dict[str, list[type]] = {} def __init__(self, probdir: str): self.probdir = os.path.realpath(probdir) @@ -1697,6 +1731,11 @@ def __init__(self, probdir: str): self.language_config = languages.load_language_config() self.data = {} + def get(self, part, key, default=None) -> Any: + if part not in self.data.keys(): + return default + return self.data[part].get(key, default) + def __enter__(self) -> Problem: self.tmpdir = tempfile.mkdtemp(prefix=f'verify-{self.shortname}-') if not os.path.isdir(self.probdir): @@ -1708,19 +1747,16 @@ def __enter__(self) -> Problem: initialized = set() self.classes = {} - def init(name): - if name in initialized: + def init(c): + if c.PART_NAME in initialized: return - for d in self.aspects[name][1]: + for d in c.DEPENDS_ON: init(d) - self.classes[name] = self.aspects[name][0](self) - initialized.add(name) - - for name in self.aspects.keys(): - if name in initialized: - continue - init(name) + self.classes[c.PART_NAME] = c(self) + initialized.add(c.PART_NAME) + for c in self.aspects: + init(c) return self @@ -1753,12 +1789,12 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: if executor: for part in args.parts: # # BEWARE, args.parts being valid over problem format for item in self.part_mapping[part]: - self.classes[item].start_background_work(context) + self.classes[item.PART_NAME].start_background_work(context) for part in args.parts: self.msg(f'Checking {part}') for item in self.part_mapping[part]: - self.classes[item].check(context) + self.classes[item.PART_NAME].check(context) except VerifyError: pass finally: @@ -1794,23 +1830,14 @@ def _check_symlinks(self): ) class ProblemLegacy(Problem): - aspects: dict[str, tuple[type, set[str]]] = { - 'config': (ProblemConfig, set(['statement'])), - 'statement': (ProblemStatement, set()), - 'attachments': (Attachments, set()), - 'input_validators': (InputValidators, set()), - 'output_validators': (OutputValidators, set()), - 'graders': (Graders, set()), - 'testdata': (TestCaseGroup, set(['config'])), - 'submissions': (Submissions, set()) - } - part_mapping: dict[str, list[str]] = { - 'config': ['config'], - 'statement': ['statement', 'attachments'], - 'validators': ['input_validators', 'output_validators'], - 'graders': ['graders'], - 'data': ['testdata'], - 'submissions': ['submissions'], + aspects: set[type] = { ProblemConfig, ProblemStatement, Attachments, InputValidators, OutputValidators, Graders, ProblemTestCases, Submissions } + part_mapping: dict[str, list[type]] = { + 'config': [ProblemConfig], + 'statement': [ProblemStatement, Attachments], + 'validators': [InputValidators, OutputValidators], + 'graders': [Graders], + 'data': [ProblemTestCases], + 'submissions': [Submissions], } def __enter__(self) -> Problem: self.data['testcase_by_infile'] = {} # Not part-mapping. Is only used in TestCase. Should maybe be moved? @@ -1829,7 +1856,7 @@ def __enter__(self) -> Problem: class Problem2023_07(Problem): def __init__(self, probdir): - raise VerifyError("new format not implemented yet!") + raise NotImplementedError("new format not implemented yet!") def re_argument(s: str) -> Pattern[str]: From ce05160cc46e2b49b97b6e7ce0c1f399bb485e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Wed, 5 Mar 2025 21:02:18 +0100 Subject: [PATCH 088/272] Problem is no longer an abstract class --- problemtools/verifyproblem.py | 128 ++++++++++++++-------------------- 1 file changed, 52 insertions(+), 76 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 796650cd..6740719f 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -203,8 +203,8 @@ def __init__(self, problem: Problem, aspect_name: str, base: str, testcasegroup: self._problem = problem self.testcasegroup = testcasegroup self.reuse_result_from: TestCase|None = None - self.counter = len(problem.data['testcase_by_infile']) - problem.data['testcase_by_infile'][self.infile] = self + self.counter = len(problem.get('testdata', 'testcase_by_infile')) + problem.get('testdata', 'testcase_by_infile')[self.infile] = self def check_newlines(self, filename: str) -> None: with open(filename, 'rb') as f: @@ -249,7 +249,7 @@ def check(self, context: Context) -> bool: self.error(f'Answer file ({anssize:.1f} Mb) is larger than output limit ({outputlim} Mb), you need to increase output limit') elif 2 * anssize > outputlim: self.warning(f'Answer file ({anssize:.1f} Mb) is within 50% of output limit ({outputlim} Mb), you might want to increase output limit') - if not self._problem.data['is_interactive']: + if not self._problem.get('testdata', 'is_interactive'): val_res = self._problem.classes[OutputValidators.PART_NAME].validate(self, self.ansfile) if val_res.verdict != 'AC': if self.is_in_sample_group(): @@ -269,8 +269,8 @@ def set_symlinks(self) -> None: if not os.path.islink(self.infile): return target = os.path.realpath(self.infile) - if target in self._problem.data['testcase_by_infile']: - self.reuse_result_from = self._problem.data['testcase_by_infile'][target] + if target in self._problem.get('testdata', 'testcase_by_infile'): + self.reuse_result_from = self._problem.get('testdata', 'testcase_by_infile')[target] def _check_symlinks(self) -> bool: if not os.path.islink(self.infile): @@ -306,7 +306,7 @@ def run_submission(self, sub, runner: Runner, context: Context) -> Result: def run_submission_real(self, sub, context: Context, timelim: int, timelim_low: int, timelim_high: int) -> Result: # This may be called off-main thread. - if self._problem.data['is_interactive']: + if self._problem.get('testdata', 'is_interactive'): res_high = self._problem.classes[OutputValidators.PART_NAME].validate_interactive(self, sub, timelim_high, self._problem.classes['submissions']) else: outfile = os.path.join(self._problem.tmpdir, f'output-{self.counter}') @@ -508,7 +508,7 @@ def check(self, context: Context) -> bool: if field not in TestCaseGroup._DEFAULT_CONFIG.keys(): self.warning(f"Unknown key '{field}' in '{os.path.join(self._datadir, 'testdata.yaml')}'") - if not self._problem.data['is_scoring']: + if not self._problem.get('testdata', 'is_scoring'): for key in TestCaseGroup._SCORING_ONLY_KEYS: if self.config.get(key) is not None: self.error(f"Key '{key}' is only applicable for scoring problems, this is a pass-fail problem") @@ -516,7 +516,7 @@ def check(self, context: Context) -> bool: if self.config['on_reject'] not in ['break', 'continue']: self.error(f"Invalid value '{self.config['on_reject']}' for on_reject policy") - if self._problem.data['is_scoring']: + if self._problem.get('testdata', 'is_scoring'): # Check grading try: score_range = self.config['range'] @@ -674,7 +674,7 @@ def aggregate_results(self, sub, sub_results: list[SubmissionResult], shadow_res if sub_results: res.testcase = sub_results[-1].testcase res.additional_info = sub_results[-1].additional_info - if self._problem.data['is_scoring']: + if self._problem.get('testdata', 'is_scoring'): res.score = score min_score, max_score = self.get_score_range() if score is not None and not (min_score <= score <= max_score) and not self._seen_oob_scores: @@ -925,7 +925,10 @@ class ProblemTestCases(ProblemPart): DEPENDS_ON = {ProblemConfig} def setup(self): + self.set_prop('testcase_by_infile', {}) self.set_prop('root_group', TestCaseGroup(self.problem, self.PART_NAME)) + self.set_prop('is_interactive', 'interactive' in self.problem.classes['config'].get('validation-params')) + self.set_prop('is_scoring', self.problem.classes['config'].get('type') == 'scoring') def check(self, context: Context) -> bool: return self.problem.get(self.PART_NAME, 'root_group').check(context) @@ -1623,7 +1626,7 @@ def full_score_finite(self) -> bool: def fully_accepted(self, result: SubmissionResult) -> bool: min_score, max_score = self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_score_range() best_score = min_score if self.problem.classes['config'].get('grading')['objective'] == 'min' else max_score - return result.verdict == 'AC' and (not self.problem.data['is_scoring'] or result.score == best_score) + return result.verdict == 'AC' and (not self.problem.get('testdata', 'is_scoring') or result.score == best_score) def start_background_work(self, context: Context) -> None: # Send off an early background compile job for each submission and @@ -1697,34 +1700,18 @@ def check(self, context: Context) -> bool: return self._check_res -# TODO: This has to be thought over -PROBLEM_PARTS = ['config', 'statement', 'validators', 'graders', 'data', 'submissions'] class Problem(ProblemAspect): - """ - Abstract baseclass for all problem-formats - """ + """Represents a checkable problem""" """ - TODO: write thorough documentation - """ - aspects: set[type] = set() - - """ - TODO: This is incorrect - Holds the configurable mapping of parts to different problem-aspects. This means you can specify - each key as a commandline-argument if it should be checked and it will map to a list of checks - done for that part. - - You could for example have 'statement' -> ['statement', 'attachment']. This would indicate that - if you include the part 'statemen' in the verification, then the classes 'statement' and 'attachment' - would be verified during the checking-step. - - Note that all classes will be loaded regardless. + Needs a problem-format in the form of a parts-dictionary, where all classes that verify the + problem are listed. These should all be a subclass of ProblemPart. The dictionary is in the form + of category -> part-types. You could for example have 'validators' -> [InputValidators, OutputValidators]. """ - part_mapping: dict[str, list[type]] = {} - - def __init__(self, probdir: str): + def __init__(self, probdir: str, parts: dict[str, list[type]]): + self.part_mapping: dict[str, list[type]] = parts + self.aspects: set[type] = {v for s in parts.values() for v in s} self.probdir = os.path.realpath(probdir) self.shortname: str|None = os.path.basename(self.probdir) super().__init__(self.shortname) @@ -1747,13 +1734,15 @@ def __enter__(self) -> Problem: initialized = set() self.classes = {} - def init(c): - if c.PART_NAME in initialized: + def init(_class): + if _class.PART_NAME in initialized: return - for d in c.DEPENDS_ON: - init(d) - self.classes[c.PART_NAME] = c(self) - initialized.add(c.PART_NAME) + for dependency in _class.DEPENDS_ON: + if dependency not in self.aspects: + raise NotImplementedError(f'Part "{_class.PART_NAME}" depends on part "{dependency.PART_NAME}" which is not included in problem-format') + init(dependency) + self.classes[_class.PART_NAME] = _class(self) + initialized.add(_class.PART_NAME) for c in self.aspects: init(c) @@ -1785,13 +1774,16 @@ def check(self, args: argparse.Namespace) -> tuple[int, int]: self._check_symlinks() run.limit.check_limit_capabilities(self) + + # Skip any parts that do not belong to the format + parts = [part for part in args.parts if part in self.part_mapping] if executor: - for part in args.parts: # # BEWARE, args.parts being valid over problem format + for part in parts: for item in self.part_mapping[part]: self.classes[item.PART_NAME].start_background_work(context) - for part in args.parts: + for part in parts: self.msg(f'Checking {part}') for item in self.part_mapping[part]: self.classes[item.PART_NAME].check(context) @@ -1829,36 +1821,6 @@ def _check_symlinks(self): f"Symlink {relfile} links to {reltarget} which is an absolute path. Symlinks must be relative." ) -class ProblemLegacy(Problem): - aspects: set[type] = { ProblemConfig, ProblemStatement, Attachments, InputValidators, OutputValidators, Graders, ProblemTestCases, Submissions } - part_mapping: dict[str, list[type]] = { - 'config': [ProblemConfig], - 'statement': [ProblemStatement, Attachments], - 'validators': [InputValidators, OutputValidators], - 'graders': [Graders], - 'data': [ProblemTestCases], - 'submissions': [Submissions], - } - def __enter__(self) -> Problem: - self.data['testcase_by_infile'] = {} # Not part-mapping. Is only used in TestCase. Should maybe be moved? - - super().__enter__() - if not self.shortname: - return self - - # Not part-mapping, only used in TestCase. Should maybe be moved? - self.data['is_interactive'] = 'interactive' in self.classes['config'].get('validation-params') - # Not part-mapping, used in 2 places. Maybe can be moved? A little code-duplication - self.data['is_scoring'] = (self.classes['config'].get('type') == 'scoring') - - return self - - -class Problem2023_07(Problem): - def __init__(self, probdir): - raise NotImplementedError("new format not implemented yet!") - - def re_argument(s: str) -> Pattern[str]: try: r = re.compile(s) @@ -1872,7 +1834,22 @@ def part_argument(s: str) -> str: raise argparse.ArgumentTypeError(f"Invalid problem part specified: {s}") return s -PROBLEM_FORMATS = {'legacy':ProblemLegacy, '2023-07':Problem2023_07} +PROBLEM_FORMATS = { + 'legacy': { + 'config': [ProblemConfig], + 'statement': [ProblemStatement, Attachments], + 'validators': [InputValidators, OutputValidators], + 'graders': [Graders], + 'data': [ProblemTestCases], + 'submissions': [Submissions], + }, + '2023-07': { # TODO + + } +} + +# parts tested in alphabetical order +PROBLEM_PARTS = [*sorted({part for format in PROBLEM_FORMATS.values() for part in format})] def argparser_basic_arguments(parser: argparse.ArgumentParser) -> None: parser.add_argument('-b', '--bail_on_error', @@ -1933,8 +1910,6 @@ def detect_problem_version(path) -> str: raise VerifyError(str(e)) return config.get('problem_format_version', 'legacy') - - def main() -> None: args = argparser().parse_args() @@ -1952,8 +1927,9 @@ def main() -> None: print(f'ERROR: problem version could not be decided for {os.path.basename(os.path.realpath(problemdir))}: {e}') continue - print(f'Loading problem {os.path.basename(os.path.realpath(problemdir))}') - with PROBLEM_FORMATS[problem_version](problemdir) as prob: + print(f'Loading problem {os.path.basename(os.path.realpath(problemdir))} with format version {problem_version}') + format = PROBLEM_FORMATS[problem_version] + with Problem(problemdir, format) as prob: errors, warnings = prob.check(args) p = lambda x: '' if x == 1 else 's' print(f'{prob.shortname} tested: {errors} error{p(errors)}, {warnings} warning{p(warnings)}') From d8b9ff89d49fc761cb4fde0aa7af28e411821966 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Wed, 5 Mar 2025 22:16:05 +0100 Subject: [PATCH 089/272] ProblemStatement now exists for old and new format --- problemtools/verifyproblem.py | 70 ++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 6740719f..470ade59 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -525,7 +525,7 @@ def check(self, context: Context) -> bool: self.error(f"Invalid score range '{score_range}': minimum score cannot be greater than maximum score") except VerifyError: raise - except: + except Exception: self.error(f"Invalid format '{score_range}' for range: must be exactly two floats") if self._parent is None: @@ -697,14 +697,14 @@ class ProblemStatement(ProblemPart): def setup(self): self.debug(' Loading problem statement') - self.languages = [] - glob_path = os.path.join(self.problem.probdir, 'problem_statement', 'problem.') - if glob.glob(glob_path + 'tex'): - self.languages.append('') - for f in glob.glob(glob_path + '[a-z][a-z].tex'): - m = re.search("problem.([a-z][a-z]).tex$", f) - assert m - self.languages.append(m.group(1)) + self.languages = self.list_languages() + #glob_path = os.path.join(self.problem.probdir, 'problem_statement', 'problem.') + #if glob.glob(glob_path + 'tex'): + # self.languages.append('') + #for f in glob.glob(glob_path + '[a-z][a-z].tex'): + # m = re.search("problem.([a-z][a-z]).tex$", f) + # assert m + # self.languages.append(m.group(1)) self.set_prop('config', self.get_config()) @@ -715,8 +715,9 @@ def check(self, context: Context) -> bool: if not self.languages: self.error('No problem statements found (expected problem.tex or problem.[a-z][a-z].tex in problem_statement directory)') - if '' in self.languages and 'en' in self.languages: - self.error("Can't supply both problem.tex and problem.en.tex") + for lang, count in collections.Counter(self.languages).items(): + if count > 1: + self.error(f"Can't supply multiple statements of the same language ({lang}).") for lang in self.languages: try: @@ -764,6 +765,35 @@ def get_config(self) -> dict[str, dict[str, str]]: ret[dest][lang] = hit.group(1).strip() return ret + def list_languages(self) -> list[str]: + print(type(self)) + raise NotImplementedError('Need to specialize ProblemStatement to list languages') + +class ProblemStatementLegacy(ProblemStatement): + def list_languages(self) -> list[str]: + languages = [] + glob_path = os.path.join(self.problem.probdir, 'problem_statement', 'problem.') + if glob.glob(glob_path + 'tex'): + languages.append('en') + for f in glob.glob(glob_path + '[a-z][a-z].tex'): + m = re.search("problem.([a-z][a-z]).tex$", f) + assert m + languages.append(m.group(1)) + return languages + +class ProblemStatement2023_07(ProblemStatement): + def list_languages(self) -> list[str]: + languages = [] + for ext in ('tex', 'md'): + glob_path = os.path.join(self.problem.probdir, 'problem_statement', 'problem.') + if glob.glob(glob_path + ext): + languages.append('en') + for f in glob.glob(glob_path + '[a-z][a-z].' + ext): + m = re.search(f"problem.([a-z][a-z]).{ext}$", f) + assert m + languages.append(m.group(1)) + return languages + class ProblemConfig(ProblemPart): PART_NAME = 'config' DEPENDS_ON = {ProblemStatement} @@ -788,7 +818,7 @@ def setup(self): self.error(str(e)) # Add config items from problem statement e.g. name - self._data.update(self.problem.classes['statement'].get_config()) + self._data.update(self.problem.get(ProblemStatement.PART_NAME, 'config')) # Populate rights_owner unless license is public domain if 'rights_owner' not in self._data and self._data.get('license') != 'public domain': @@ -1717,6 +1747,7 @@ def __init__(self, probdir: str, parts: dict[str, list[type]]): super().__init__(self.shortname) self.language_config = languages.load_language_config() self.data = {} + self.debug(f'Problem-format: {parts}') def get(self, part, key, default=None) -> Any: if part not in self.data.keys(): @@ -1737,10 +1768,15 @@ def __enter__(self) -> Problem: def init(_class): if _class.PART_NAME in initialized: return + # A bit ugly but want to allow for subclasses for dependency in _class.DEPENDS_ON: - if dependency not in self.aspects: + for cl in self.aspects: + if issubclass(cl, dependency): + init(cl) + break + else: raise NotImplementedError(f'Part "{_class.PART_NAME}" depends on part "{dependency.PART_NAME}" which is not included in problem-format') - init(dependency) + self.debug(f'Initializing {_class.PART_NAME} ({_class})') self.classes[_class.PART_NAME] = _class(self) initialized.add(_class.PART_NAME) @@ -1837,14 +1873,14 @@ def part_argument(s: str) -> str: PROBLEM_FORMATS = { 'legacy': { 'config': [ProblemConfig], - 'statement': [ProblemStatement, Attachments], + 'statement': [ProblemStatementLegacy, Attachments], 'validators': [InputValidators, OutputValidators], 'graders': [Graders], 'data': [ProblemTestCases], 'submissions': [Submissions], }, - '2023-07': { # TODO - + '2023-07': { # TODO: Add all the parts + 'statement': [ProblemStatement2023_07, Attachments], } } From 4a4152ce592caf231b359f65b93eac397ced4657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Wed, 5 Mar 2025 22:32:15 +0100 Subject: [PATCH 090/272] Add TODO for ProblemStatement --- problemtools/verifyproblem.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 470ade59..7c8af110 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -693,18 +693,12 @@ def all_datasets(self) -> list: return res class ProblemStatement(ProblemPart): + # TODO: Not finished yet, get_config() is not correct, error-messages should be more specialized PART_NAME = 'statement' def setup(self): self.debug(' Loading problem statement') self.languages = self.list_languages() - #glob_path = os.path.join(self.problem.probdir, 'problem_statement', 'problem.') - #if glob.glob(glob_path + 'tex'): - # self.languages.append('') - #for f in glob.glob(glob_path + '[a-z][a-z].tex'): - # m = re.search("problem.([a-z][a-z]).tex$", f) - # assert m - # self.languages.append(m.group(1)) self.set_prop('config', self.get_config()) From a4b99201eb00ed03ce6c0c14946680b62158340c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Thu, 6 Mar 2025 22:35:02 +0100 Subject: [PATCH 091/272] Fix issues with ProblemStatement --- problemtools/verifyproblem.py | 73 ++++++++++++----------------------- 1 file changed, 25 insertions(+), 48 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 7c8af110..92afc03d 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -169,6 +169,7 @@ def check_basename(self, path: str) -> None: self.error(f"Invalid name '{basename}' (should match '{self.basename_regex.pattern}')") class ProblemPart(ProblemAspect): + # TODO: document these PART_NAME = None DEPENDS_ON = set() @@ -693,12 +694,17 @@ def all_datasets(self) -> list: return res class ProblemStatement(ProblemPart): - # TODO: Not finished yet, get_config() is not correct, error-messages should be more specialized PART_NAME = 'statement' + EXTENSIONS = [] + def setup(self): + if not self.EXTENSIONS: + raise NotImplementedError('Need to override class and set EXTENSIONS class-variable') self.debug(' Loading problem statement') - self.languages = self.list_languages() + self.statement_regex = re.compile(r"problem(\.([a-z][a-z]))?\.(%s)$" % ('|'.join(self.EXTENSIONS))) + dir = os.path.join(self.problem.probdir, 'problem_statement') + self.statements = [(m.group(0), m.group(2) or '') for file in os.listdir(dir) if (m := re.search(self.statement_regex, file))] self.set_prop('config', self.get_config()) @@ -707,13 +713,15 @@ def check(self, context: Context) -> bool: return self._check_res self._check_res = True - if not self.languages: - self.error('No problem statements found (expected problem.tex or problem.[a-z][a-z].tex in problem_statement directory)') - for lang, count in collections.Counter(self.languages).items(): + if not self.statements: + self.error(f'No problem statements found (expected file of one of following forms in folder problem_statement/: {', '.join(f'problem.{ext}, problem.[a-z][a-z].{ext}' for ext in self.EXTENSIONS)}') + + langs = [lang or 'en' for _, lang in self.statements] + for lang, count in collections.Counter(langs).items(): if count > 1: self.error(f"Can't supply multiple statements of the same language ({lang}).") - for lang in self.languages: + for _, lang in self.statements: try: options = problem2pdf.get_parser().parse_args([""]) options.problem = self.problem.probdir @@ -741,52 +749,21 @@ def __str__(self) -> str: return 'problem statement' def get_config(self) -> dict[str, dict[str, str]]: - ret: dict[str, dict[str, str]] = {} - for lang in self.languages: - filename = f'problem.{lang}.tex' if lang != '' else 'problem.tex' - stmt = open(os.path.join(self.problem.probdir, 'problem_statement', filename)).read() - patterns = [ - (r'\\problemname{(.*)}', 'name'), - (r'^%%\s*plainproblemname:(.*)$', 'name'), - ] - for tup in patterns: - pattern = tup[0] - dest = tup[1] - hit = re.search(pattern, stmt, re.MULTILINE) - if hit: - if not dest in ret: - ret[dest] = {} - ret[dest][lang] = hit.group(1).strip() - return ret - - def list_languages(self) -> list[str]: - print(type(self)) - raise NotImplementedError('Need to specialize ProblemStatement to list languages') + ret: dict[str, dict[str, str]] = {'name':{}} + for filename, lang in self.statements: + with open(os.path.join(self.problem.probdir, 'problem_statement', filename)) as f: + stmt = f.read() + hit = re.search(r'\\problemname{(.*)}', stmt, re.MULTILINE) + if hit: + problem_name = hit.group(1).strip() + ret['name'][lang] = problem_name + return ret if ret['name'] else {} class ProblemStatementLegacy(ProblemStatement): - def list_languages(self) -> list[str]: - languages = [] - glob_path = os.path.join(self.problem.probdir, 'problem_statement', 'problem.') - if glob.glob(glob_path + 'tex'): - languages.append('en') - for f in glob.glob(glob_path + '[a-z][a-z].tex'): - m = re.search("problem.([a-z][a-z]).tex$", f) - assert m - languages.append(m.group(1)) - return languages + EXTENSIONS = ['tex'] class ProblemStatement2023_07(ProblemStatement): - def list_languages(self) -> list[str]: - languages = [] - for ext in ('tex', 'md'): - glob_path = os.path.join(self.problem.probdir, 'problem_statement', 'problem.') - if glob.glob(glob_path + ext): - languages.append('en') - for f in glob.glob(glob_path + '[a-z][a-z].' + ext): - m = re.search(f"problem.([a-z][a-z]).{ext}$", f) - assert m - languages.append(m.group(1)) - return languages + EXTENSIONS = ['md', 'tex'] class ProblemConfig(ProblemPart): PART_NAME = 'config' From a0518b9ef12cbe0e7a804b4ce3fb0e9e4fbb9190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Thu, 6 Mar 2025 22:55:37 +0100 Subject: [PATCH 092/272] Add some documentation and some final fixes --- problemtools/verifyproblem.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 92afc03d..dfbf231c 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -169,10 +169,24 @@ def check_basename(self, path: str) -> None: self.error(f"Invalid name '{basename}' (should match '{self.basename_regex.pattern}')") class ProblemPart(ProblemAspect): - # TODO: document these + """Baseclass for all parts that can be included in a problem-format. + """ + + """Should always be overridden by the subclass. Specifies the name that will be used to refer + to the part e.g for logs. + """ PART_NAME = None - DEPENDS_ON = set() + """Should return all classes that need to be initialized before this one. It is sufficient to be + a subclass of the classes listed. There should be exactly one subclass of each dependency in the + format that the problem-part is included in. + + Note that this will only ensure that the specified classes are initialized before this one, but + they might be checked in a different order. + """ + def depends_on() -> set[type]: + return set() + def __init__(self, problem: Problem) -> None: if self.PART_NAME is None: raise NotImplementedError('Every problem-part must override PART_NAME') @@ -181,6 +195,9 @@ def __init__(self, problem: Problem) -> None: self.problem.data[self.PART_NAME] = {} self.setup() + """Override to setup data about this problem-part. The order in which problem-parts are setup + will be decided based on the dependencies that exist. + """ def setup(self) -> None: pass @@ -767,7 +784,8 @@ class ProblemStatement2023_07(ProblemStatement): class ProblemConfig(ProblemPart): PART_NAME = 'config' - DEPENDS_ON = {ProblemStatement} + def depends_on(): + return {ProblemStatement} _MANDATORY_CONFIG = ['name'] _OPTIONAL_CONFIG = config.load_config('problem.yaml') @@ -922,8 +940,11 @@ def check(self, context: Context) -> bool: return self._check_res class ProblemTestCases(ProblemPart): + PART_NAME = 'testdata' - DEPENDS_ON = {ProblemConfig} + + def depends_on(): + return {ProblemConfig} def setup(self): self.set_prop('testcase_by_infile', {}) @@ -1740,7 +1761,7 @@ def init(_class): if _class.PART_NAME in initialized: return # A bit ugly but want to allow for subclasses - for dependency in _class.DEPENDS_ON: + for dependency in _class.depends_on(): for cl in self.aspects: if issubclass(cl, dependency): init(cl) From bf0eaee9ed9cdbec3273b42726c3db93a149d1ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Fri, 7 Mar 2025 00:02:22 +0100 Subject: [PATCH 093/272] Small change --- problemtools/verifyproblem.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index dfbf231c..701f46d1 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -949,8 +949,8 @@ def depends_on(): def setup(self): self.set_prop('testcase_by_infile', {}) self.set_prop('root_group', TestCaseGroup(self.problem, self.PART_NAME)) - self.set_prop('is_interactive', 'interactive' in self.problem.classes['config'].get('validation-params')) - self.set_prop('is_scoring', self.problem.classes['config'].get('type') == 'scoring') + self.set_prop('is_interactive', 'interactive' in self.problem.get(ProblemConfig.PART_NAME, 'data')['validation-params']) + self.set_prop('is_scoring', self.problem.get(ProblemConfig.PART_NAME, 'data')['type'] == 'scoring') def check(self, context: Context) -> bool: return self.problem.get(self.PART_NAME, 'root_group').check(context) From 1d0763127eec292c799cd61e94de856e58fb5e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Fri, 7 Mar 2025 13:41:12 +0100 Subject: [PATCH 094/272] Allow to give class-type for part in Problem.get --- problemtools/verifyproblem.py | 44 ++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 701f46d1..207a79a2 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -221,8 +221,8 @@ def __init__(self, problem: Problem, aspect_name: str, base: str, testcasegroup: self._problem = problem self.testcasegroup = testcasegroup self.reuse_result_from: TestCase|None = None - self.counter = len(problem.get('testdata', 'testcase_by_infile')) - problem.get('testdata', 'testcase_by_infile')[self.infile] = self + self.counter = len(problem.get(ProblemTestCases, 'testcase_by_infile')) + problem.get(ProblemTestCases, 'testcase_by_infile')[self.infile] = self def check_newlines(self, filename: str) -> None: with open(filename, 'rb') as f: @@ -267,7 +267,7 @@ def check(self, context: Context) -> bool: self.error(f'Answer file ({anssize:.1f} Mb) is larger than output limit ({outputlim} Mb), you need to increase output limit') elif 2 * anssize > outputlim: self.warning(f'Answer file ({anssize:.1f} Mb) is within 50% of output limit ({outputlim} Mb), you might want to increase output limit') - if not self._problem.get('testdata', 'is_interactive'): + if not self._problem.get(ProblemTestCases, 'is_interactive'): val_res = self._problem.classes[OutputValidators.PART_NAME].validate(self, self.ansfile) if val_res.verdict != 'AC': if self.is_in_sample_group(): @@ -287,8 +287,8 @@ def set_symlinks(self) -> None: if not os.path.islink(self.infile): return target = os.path.realpath(self.infile) - if target in self._problem.get('testdata', 'testcase_by_infile'): - self.reuse_result_from = self._problem.get('testdata', 'testcase_by_infile')[target] + if target in self._problem.get(ProblemTestCases, 'testcase_by_infile'): + self.reuse_result_from = self._problem.get(ProblemTestCases, 'testcase_by_infile')[target] def _check_symlinks(self) -> bool: if not os.path.islink(self.infile): @@ -324,7 +324,7 @@ def run_submission(self, sub, runner: Runner, context: Context) -> Result: def run_submission_real(self, sub, context: Context, timelim: int, timelim_low: int, timelim_high: int) -> Result: # This may be called off-main thread. - if self._problem.get('testdata', 'is_interactive'): + if self._problem.get(ProblemTestCases, 'is_interactive'): res_high = self._problem.classes[OutputValidators.PART_NAME].validate_interactive(self, sub, timelim_high, self._problem.classes['submissions']) else: outfile = os.path.join(self._problem.tmpdir, f'output-{self.counter}') @@ -526,7 +526,7 @@ def check(self, context: Context) -> bool: if field not in TestCaseGroup._DEFAULT_CONFIG.keys(): self.warning(f"Unknown key '{field}' in '{os.path.join(self._datadir, 'testdata.yaml')}'") - if not self._problem.get('testdata', 'is_scoring'): + if not self._problem.get(ProblemTestCases, 'is_scoring'): for key in TestCaseGroup._SCORING_ONLY_KEYS: if self.config.get(key) is not None: self.error(f"Key '{key}' is only applicable for scoring problems, this is a pass-fail problem") @@ -534,7 +534,7 @@ def check(self, context: Context) -> bool: if self.config['on_reject'] not in ['break', 'continue']: self.error(f"Invalid value '{self.config['on_reject']}' for on_reject policy") - if self._problem.get('testdata', 'is_scoring'): + if self._problem.get(ProblemTestCases, 'is_scoring'): # Check grading try: score_range = self.config['range'] @@ -692,7 +692,7 @@ def aggregate_results(self, sub, sub_results: list[SubmissionResult], shadow_res if sub_results: res.testcase = sub_results[-1].testcase res.additional_info = sub_results[-1].additional_info - if self._problem.get('testdata', 'is_scoring'): + if self._problem.get(ProblemTestCases, 'is_scoring'): res.score = score min_score, max_score = self.get_score_range() if score is not None and not (min_score <= score <= max_score) and not self._seen_oob_scores: @@ -807,7 +807,7 @@ def setup(self): self.error(str(e)) # Add config items from problem statement e.g. name - self._data.update(self.problem.get(ProblemStatement.PART_NAME, 'config')) + self._data.update(self.problem.get(ProblemStatement, 'config')) # Populate rights_owner unless license is public domain if 'rights_owner' not in self._data and self._data.get('license') != 'public domain': @@ -901,7 +901,7 @@ def check(self, context: Context) -> bool: self.error(f"Invalid value for grading.show_test_data_groups: {self._data['grading']['show_test_data_groups']}") elif self._data['grading']['show_test_data_groups'] and self._data['type'] == 'pass-fail': self.error("Showing test data groups is only supported for scoring problems, this is a pass-fail problem") - if self._data['type'] != 'pass-fail' and self.problem.get(ProblemTestCases.PART_NAME, 'root_group').has_custom_groups() and 'show_test_data_groups' not in self._origdata.get('grading', {}): + if self._data['type'] != 'pass-fail' and self.problem.get(ProblemTestCases, 'root_group').has_custom_groups() and 'show_test_data_groups' not in self._origdata.get('grading', {}): self.warning("Problem has custom testcase groups, but does not specify a value for grading.show_test_data_groups; defaulting to false") if 'on_reject' in self._data['grading']: @@ -949,11 +949,11 @@ def depends_on(): def setup(self): self.set_prop('testcase_by_infile', {}) self.set_prop('root_group', TestCaseGroup(self.problem, self.PART_NAME)) - self.set_prop('is_interactive', 'interactive' in self.problem.get(ProblemConfig.PART_NAME, 'data')['validation-params']) - self.set_prop('is_scoring', self.problem.get(ProblemConfig.PART_NAME, 'data')['type'] == 'scoring') + self.set_prop('is_interactive', 'interactive' in self.problem.get(ProblemConfig, 'data')['validation-params']) + self.set_prop('is_scoring', self.problem.get(ProblemConfig, 'data')['type'] == 'scoring') def check(self, context: Context) -> bool: - return self.problem.get(self.PART_NAME, 'root_group').check(context) + return self.problem.get(self, 'root_group').check(context) @@ -1067,7 +1067,7 @@ def collect_flags(group: TestCaseGroup, flags: set[str]) -> None: flags.add(group.config['input_validator_flags']) for subgroup in group.get_subgroups(): collect_flags(subgroup, flags) - collect_flags(self.problem.get(ProblemTestCases.PART_NAME, 'root_group'), all_flags) + collect_flags(self.problem.get(ProblemTestCases, 'root_group'), all_flags) fd, file_name = tempfile.mkstemp() os.close(fd) @@ -1085,7 +1085,7 @@ def collect_flags(group: TestCaseGroup, flags: set[str]) -> None: self.warning(f'No validator rejects {desc} with flags "{" ".join(flags)}"') def modified_input_validates(applicable, modifier): - for testcase in self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_all_testcases(): + for testcase in self.problem.get(ProblemTestCases, 'root_group').get_all_testcases(): with open(testcase.infile) as infile: infile_data = infile.read() if not applicable(infile_data): @@ -1291,7 +1291,7 @@ def check(self, context: Context) -> bool: f.write(case) f.close() rejected = False - for testcase in self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_all_testcases(): + for testcase in self.problem.get(ProblemTestCases, 'root_group').get_all_testcases(): result = self.validate(testcase, file_name) if result.verdict != 'AC': rejected = True @@ -1612,7 +1612,7 @@ def check_submission(self, sub, context: Context, expected_verdict: Verdict, tim timelim_low = timelim with Runner(self.problem, sub, context, timelim, timelim_low, timelim_high) as runner: - result, result_low, result_high = self.problem.get(ProblemTestCases.PART_NAME, 'root_group').run_submission(sub, runner, context) + result, result_low, result_high = self.problem.get(ProblemTestCases, 'root_group').run_submission(sub, runner, context) if result.verdict == 'AC' and expected_verdict == 'AC' and not partial and result.sample_failures: res = result.sample_failures[0] @@ -1639,7 +1639,7 @@ def check_submission(self, sub, context: Context, expected_verdict: Verdict, tim return result def full_score_finite(self) -> bool: - min_score, max_score = self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_score_range() + min_score, max_score = self.problem.get(ProblemTestCases, 'root_group').get_score_range() if self.problem.classes['config'].get('grading')['objective'] == 'min': return min_score != float('-inf') else: @@ -1648,7 +1648,7 @@ def full_score_finite(self) -> bool: def fully_accepted(self, result: SubmissionResult) -> bool: min_score, max_score = self.problem.get(ProblemTestCases.PART_NAME, 'root_group').get_score_range() best_score = min_score if self.problem.classes['config'].get('grading')['objective'] == 'min' else max_score - return result.verdict == 'AC' and (not self.problem.get('testdata', 'is_scoring') or result.score == best_score) + return result.verdict == 'AC' and (not self.problem.get(ProblemTestCases, 'is_scoring') or result.score == best_score) def start_background_work(self, context: Context) -> None: # Send off an early background compile job for each submission and @@ -1742,7 +1742,9 @@ def __init__(self, probdir: str, parts: dict[str, list[type]]): self.debug(f'Problem-format: {parts}') def get(self, part, key, default=None) -> Any: - if part not in self.data.keys(): + if isinstance(part, type) and issubclass(part, ProblemPart): + part = part.PART_NAME + if part not in self.data: return default return self.data[part].get(key, default) From a672674d20bb8d4389df0ee1ef763f003b62d0ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Fri, 7 Mar 2025 13:46:47 +0100 Subject: [PATCH 095/272] Whoops small bug crashed code --- problemtools/verifyproblem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 207a79a2..efa84273 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -953,7 +953,7 @@ def setup(self): self.set_prop('is_scoring', self.problem.get(ProblemConfig, 'data')['type'] == 'scoring') def check(self, context: Context) -> bool: - return self.problem.get(self, 'root_group').check(context) + return self.problem.get(ProblemTestCases, 'root_group').check(context) From 848b19609a104ed8d11fb53fb622159ace09292b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Fri, 7 Mar 2025 13:50:15 +0100 Subject: [PATCH 096/272] Fix bug that crashed multithreading for testcase-validation --- problemtools/verifyproblem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index efa84273..4950349e 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1563,7 +1563,7 @@ def _recompute_jobs(self) -> None: with self._lock: seen = set(self._started_jobs) self._remaining_jobs = [] - for testcase in self._gather_testcases(self._problem.testdata): + for testcase in self._gather_testcases(self._problem.get(ProblemTestCases, 'root_group')): if testcase not in seen: seen.add(testcase) self._remaining_jobs.append(testcase) From 11a9b4816fa009a65cc0688b54d6f0979dcea806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=B6derbergh?= Date: Fri, 7 Mar 2025 13:57:04 +0100 Subject: [PATCH 097/272] Mark ProblemPart.depends_on() as staticmethod --- problemtools/verifyproblem.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 4950349e..312c207b 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -184,6 +184,7 @@ class ProblemPart(ProblemAspect): Note that this will only ensure that the specified classes are initialized before this one, but they might be checked in a different order. """ + @staticmethod def depends_on() -> set[type]: return set() @@ -784,6 +785,8 @@ class ProblemStatement2023_07(ProblemStatement): class ProblemConfig(ProblemPart): PART_NAME = 'config' + + @staticmethod def depends_on(): return {ProblemStatement} @@ -943,6 +946,7 @@ class ProblemTestCases(ProblemPart): PART_NAME = 'testdata' + @staticmethod def depends_on(): return {ProblemConfig} From 46a700352fd0f053bb352adc7b215850c89fee2c Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 00:24:10 +0100 Subject: [PATCH 098/272] Disable html --- problemtools/md2html.py | 4 ++-- problemtools/problem2pdf.py | 2 +- problemtools/statement_common.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 3d729e72..7b398369 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -32,7 +32,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: _copy_images(statement_path, lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) - command = ["pandoc", statement_path, "-t" , "html"] + command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html"] statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout @@ -104,7 +104,7 @@ def json_dfs(data, callback) -> None: def _copy_images(statement_path, callback): - command = ["pandoc", statement_path, "-t" , "json"] + command = ["pandoc", statement_path, "-t" , "json", "-f", "markdown-raw_html"] statement_json = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout json_dfs(json.loads(statement_json), callback) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 62d40dbe..2b686500 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -62,7 +62,7 @@ def md2pdf(options: argparse.Namespace) -> bool: with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: temp_file.write(statement_md) temp_file.flush() - command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}"] + command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}", "-f", "markdown-raw_html"] return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 66a6c673..2d6f75ad 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -139,7 +139,7 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bo with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: temp_file.write(sample) temp_file.flush() - command = ["pandoc", temp_file.name, "-t" , "markdown"] + command = ["pandoc", temp_file.name, "-t" , "markdown", "-f", "markdown-raw_html"] return subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout else: From 770d5da95adf61d49e90726a50920726c332b729 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 02:07:35 +0100 Subject: [PATCH 099/272] Change to wikimedia example image --- examples/different/problem.yaml | 2 +- examples/oddecho/problem_statement/cave.jpg | Bin 0 -> 104326 bytes .../oddecho/problem_statement/echo_cave.jpg | Bin 35667 -> 0 bytes .../oddecho/problem_statement/problem.sv.md | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 examples/oddecho/problem_statement/cave.jpg delete mode 100644 examples/oddecho/problem_statement/echo_cave.jpg diff --git a/examples/different/problem.yaml b/examples/different/problem.yaml index a7652c2e..64f5357a 100644 --- a/examples/different/problem.yaml +++ b/examples/different/problem.yaml @@ -6,7 +6,7 @@ # author: # The problem name -# En may be omitted, as there is only one language +# "en" may be omitted, as there is only one language name: en: A Different Problem diff --git a/examples/oddecho/problem_statement/cave.jpg b/examples/oddecho/problem_statement/cave.jpg new file mode 100644 index 0000000000000000000000000000000000000000..670bbedae2b38a7db351e96b7a751cb536a48d50 GIT binary patch literal 104326 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 literal 0 HcmV?d00001 diff --git a/examples/oddecho/problem_statement/echo_cave.jpg b/examples/oddecho/problem_statement/echo_cave.jpg deleted file mode 100644 index e197bf1b4a2e9d2d2782e29680869536a27684b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35667 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 diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index 4ffd89cf..55f9806f 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,6 +1,6 @@ **EKO! Eko! Ek...** -![](echo_cave.jpg) +![CC-BY-SA 2.0 By William Craig on wikimedia.org](cave.jpg) Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, har du inte tid för att komma ut och skrika i grottor så ofta. Istället skulle du vilja implementera ett program som fungerar som en ersättning för en grotta. From 11b6a13be45616c0c3c8464d7deb2f9f6c5d7802 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 02:07:58 +0100 Subject: [PATCH 100/272] Sanitize image sources --- problemtools/md2html.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 7b398369..d39dfb12 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -5,6 +5,7 @@ import argparse import json import subprocess +import re from . import statement_common @@ -31,7 +32,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: _copy_images(statement_path, - lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) + lambda img_name: handle_image(problem, img_name)) command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html"] statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout @@ -70,21 +71,29 @@ def convert(problem: str, options: argparse.Namespace) -> bool: return True -def handle_image(src: str) -> None: +def handle_image(problem_root: str, img_src: str) -> None: """This is called for every image in the statement - Copies the image from the statement to the output directory + First, check if we actually allow this image + Then, copies the image from the statement to the output directory Args: - src: full file path to the image + problem_root: the root of the problem directory + img_src: the image source as in the Markdown statement """ - file_name = os.path.basename(src) - if not os.path.isfile(src): - raise Exception(f"File {file_name} not found in problem_statement") - if os.path.isfile(file_name): + src_pattern = r'^[a-zA-Z0-9.]+\.(png|jpg|jpeg)$' + + if not re.match(src_pattern, img_src): + raise Exception(f"Image source must match regex {src_pattern}") + + source_name = os.path.join(problem_root, "problem_statement", img_src) + + if not os.path.isfile(source_name): + raise Exception(f"File {source_name} not found in problem_statement") + if os.path.isfile(img_src): # already copied return - with open(src, "rb") as img: - with open(file_name, "wb") as out: + with open(source_name, "rb") as img: + with open(img_src, "wb") as out: out.write(img.read()) From bfd4703dbb518024674a28789a82bec20eacf7a3 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 02:13:07 +0100 Subject: [PATCH 101/272] Remove SVG dependency --- Dockerfile | 1 - README.md | 4 ++-- admin/docker/Dockerfile.minimal | 1 - debian/control | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index cff647c5..2c3b4c57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ RUN apt-get update && \ python3-pip \ python3-plastex \ python3-yaml \ - rsvg-convert \ sudo \ texlive-fonts-recommended \ texlive-lang-cyrillic \ diff --git a/README.md b/README.md index 96758f52..84494e09 100644 --- a/README.md +++ b/README.md @@ -207,13 +207,13 @@ The dependencies needed to *build/install* problemtools can be installed with: And the dependencies needed to *run* problemtools can be installed with: - sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-yaml rsvg-convert texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy + sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy ### Fedora On Fedora, these dependencies can be installed with: - sudo dnf install boost-regex gcc gmp-devel gmp-c++ pandoc python3 python3-pyyaml rsvg-convert texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript + sudo dnf install boost-regex gcc gmp-devel gmp-c++ pandoc python3 python3-pyyaml texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript Followed by: diff --git a/admin/docker/Dockerfile.minimal b/admin/docker/Dockerfile.minimal index 340f0b20..886d1a2d 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -25,7 +25,6 @@ RUN apt update && \ python3-minimal \ python3-yaml \ python3-plastex \ - rsvg-convert \ texlive-fonts-recommended \ texlive-lang-cyrillic \ texlive-latex-extra \ diff --git a/debian/control b/debian/control index d1bf4179..717a9b53 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Homepage: https://github.com/Kattis/problemtools Package: kattis-problemtools Architecture: any -Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, pandoc, python3-plastex, python3-pkg-resources, rsvg-convert, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm +Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, pandoc, python3-plastex, python3-pkg-resources, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm Recommends: gcc, g++ Description: Kattis Problem Tools These are tools to manage and verify problem packages in the From d93577132a44bfd8b1eb578398c6f869923149cf Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 20:19:16 +0100 Subject: [PATCH 102/272] Better markdown styling --- problemtools/md2html.py | 4 +-- problemtools/statement_common.py | 1 - .../markdown_html/default-layout.html | 5 ++- .../templates/markdown_html/problem.css | 35 +++++++++++++------ 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index d39dfb12..d3fd100d 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -33,7 +33,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: _copy_images(statement_path, lambda img_name: handle_image(problem, img_name)) - command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html"] + command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html", "--mathjax"] statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout @@ -81,7 +81,7 @@ def handle_image(problem_root: str, img_src: str) -> None: img_src: the image source as in the Markdown statement """ - src_pattern = r'^[a-zA-Z0-9.]+\.(png|jpg|jpeg)$' + src_pattern = r'^[a-zA-Z0-9._]+\.(png|jpg|jpeg)$' if not re.match(src_pattern, img_src): raise Exception(f"Image source must match regex {src_pattern}") diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 2d6f75ad..0bbb0cac 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -76,7 +76,6 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: sample_path = os.path.join(problem_root, "data", "sample") if not os.path.isdir(sample_path): - print("WARNING!! no sample folder") return [] samples = [] casenum = 1 diff --git a/problemtools/templates/markdown_html/default-layout.html b/problemtools/templates/markdown_html/default-layout.html index 814324c1..93a84572 100644 --- a/problemtools/templates/markdown_html/default-layout.html +++ b/problemtools/templates/markdown_html/default-layout.html @@ -8,9 +8,8 @@ /problem.yaml" create mode 100644 "problemtools/tests/problems///problem_statement/problem.md" create mode 100644 problemtools/tests/problems/problemnamexss/problem.yaml create mode 100644 problemtools/tests/problems/problemnamexss/problem_statement/problem.md create mode 100644 problemtools/tests/problems/samplexss/data/sample/1.ans create mode 100644 problemtools/tests/problems/samplexss/data/sample/1.in create mode 100644 problemtools/tests/problems/samplexss/data/sample/testdata.yaml create mode 100644 problemtools/tests/problems/samplexss/data/testdata.yaml create mode 100644 problemtools/tests/problems/samplexss/problem.yaml create mode 100644 problemtools/tests/problems/samplexss/problem_statement/problem.md create mode 100644 problemtools/tests/problems/specialcharacterssample/data/sample/1.ans create mode 100644 problemtools/tests/problems/specialcharacterssample/data/sample/1.in create mode 100644 problemtools/tests/problems/specialcharacterssample/data/sample/testdata.yaml create mode 100644 problemtools/tests/problems/specialcharacterssample/data/testdata.yaml create mode 100644 problemtools/tests/problems/specialcharacterssample/problem.yaml create mode 100644 problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md create mode 100644 problemtools/tests/problems/statementxss/problem.yaml create mode 100644 problemtools/tests/problems/statementxss/problem_statement/problem.md create mode 100644 problemtools/tests/test_markdown.py create mode 100644 problemtools/tests/test_xss.py diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 776e3cf0..2e2db6a3 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -1,11 +1,12 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- -import os.path -import string import argparse +import html +import os +import string import subprocess -import re -import tempfile + +import nh3 from . import statement_common @@ -34,46 +35,67 @@ def convert(problem: str, options: argparse.Namespace) -> bool: statement_common.assert_images_are_valid_md(statement_path) statement_common.foreach_image(statement_path, lambda img_name: copy_image(problem, img_name)) - - command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html", "--mathjax"] + + command = ["pandoc", statement_path, "-t" , "html", "--mathjax"] statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_html'), - os.path.join(os.path.dirname(__file__), '../templates/markdown_html'), '/usr/lib/problemtools/templates/markdown_html'] templatepath = next((p for p in templatepaths - if os.path.isdir(p) and os.path.isfile(os.path.join(p, "default-layout.html"))), - None) + if os.path.isdir(p) and os.path.isfile(os.path.join(p, "default-layout.html"))), + None) if templatepath is None: raise Exception('Could not find directory with markdown templates') - problem_name = statement_common.get_problem_name(problem, options.language) + problem_name = statement_common.get_yaml_problem_name(problem, options.language) + + if problem_name: + problem_name = html.escape(problem_name) - html_template = _substitute_template(templatepath, "default-layout.html", + statement_html = _substitute_template(templatepath, "default-layout.html", statement_html=statement_html, language=options.language, title=problem_name or "Missing problem name", - problemid=problembase) + problemid=problembase) # No need to escape problem shortname, the spec has tight restrictions directory names samples = statement_common.format_samples(problem, to_pdf=False) # Insert samples at {{nextsample}} and {{remainingsamples}} - html_template, remaining_samples = statement_common.inject_samples(html_template, samples, "") + statement_html, remaining_samples = statement_common.inject_samples(statement_html, samples, "") # Insert the remaining samples at the bottom - if FOOTNOTES_STRING in html_template: - pos = html_template.find(FOOTNOTES_STRING) + if FOOTNOTES_STRING in statement_html: + pos = statement_html.find(FOOTNOTES_STRING) else: - pos = html_template.find("") - html_template = html_template[:pos] + "".join(remaining_samples) + html_template[pos:] - - html_template = replace_hr_in_footnotes(html_template) + pos = statement_html.find("") + statement_html = statement_html[:pos] + "".join(remaining_samples) + statement_html[pos:] + + statement_html = replace_hr_in_footnotes(statement_html) + html_body = statement_html[statement_html.find(""):] + statement_html = statement_html[:statement_html.find("")] + + allowed_classes = ("sample", "problemheader", "problembody", + "sampleinteractionwrite", "sampleinteractionread") + def attribute_filter(tag, attribute, value): + if attribute == "class" and value in allowed_classes: + return value + if tag == "img" and attribute == "src": + return value + return None + + html_body = nh3.clean(html_body, + link_rel="noopener nofollow noreferrer", + attribute_filter=attribute_filter, + tags=nh3.ALLOWED_TAGS | {"img"}, + attributes={"table": {"class"}, "div": {"class"}, "img": {"src"}}, + ) + statement_html += html_body with open(destfile, "w", encoding="utf-8", errors="xmlcharrefreplace") as output_file: - output_file.write(html_template) + output_file.write(statement_html) if options.css: with open("problem.css", "w") as output_file: diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 27fe2ffd..b7c6b995 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -32,7 +32,6 @@ def md2pdf(options: argparse.Namespace) -> bool: statement_common.assert_images_are_valid_md(statement_path) templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), - os.path.join(os.path.dirname(__file__), '../templates/markdown_pdf'), '/usr/lib/problemtools/templates/markdown_pdf'] templatepath = next((p for p in templatepaths if os.path.isdir(p) and os.path.isfile(os.path.join(p, "fix_tables.md"))), @@ -48,7 +47,7 @@ def md2pdf(options: argparse.Namespace) -> bool: with open(statement_path, "r") as file: statement_md = file.read() - problem_name = statement_common.get_problem_name(problem_root, options.language) + problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) # Add problem name and id to the top problem_id = os.path.basename(problem_root) @@ -66,7 +65,7 @@ def md2pdf(options: argparse.Namespace) -> bool: with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: temp_file.write(statement_md) temp_file.flush() - command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}", "-f", "markdown-raw_html"] + command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}"] try: return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) except subprocess.CalledProcessError as e: diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 945a116f..5ed6defa 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -7,7 +7,7 @@ import json from pathlib import Path -from . import verifyproblem +import yaml SUPPORTED_EXTENSIONS = ("tex", "md") @@ -43,7 +43,7 @@ def find_statement_extension(problem_root: str, language: Optional[str]) -> str: for language {language or 'en'}""") if len(extensions) == 1: return extensions[0] - raise Exception(f"No statement found for language {language or 'en'}") + raise FileNotFoundError(f"No statement found for language {language or 'en'}") def json_dfs(data, callback) -> None: @@ -63,7 +63,7 @@ def json_dfs(data, callback) -> None: def foreach_image(statement_path, callback): # Find all images in the statement and call callback for each one - command = ["pandoc", statement_path, "-t" , "json", "-f", "markdown-raw_html"] + command = ["pandoc", statement_path, "-t" , "json"] statement_json = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout json_dfs(json.loads(statement_json), callback) @@ -77,8 +77,7 @@ def assert_image_is_valid(problem_root: str, img_src: str) -> None: source_name = os.path.join(problem_root, img_src) if not os.path.isfile(source_name): - print(source_name) - raise Exception(f"File {img_src} not found in problem_statement") + raise FileNotFoundError(f"Resource file {img_src} not found in problem_statement") def assert_images_are_valid_md(statement_path: str) -> None: @@ -88,49 +87,63 @@ def assert_images_are_valid_md(statement_path: str) -> None: foreach_image(statement_path, lambda img_name: assert_image_is_valid(problem_root, img_name)) -def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: - """Load problem.yaml to get problem name""" - if language is None: - language = "en" - with verifyproblem.Problem(problem) as prob: - config = verifyproblem.ProblemConfig(prob) - if not config.check(None): - raise Exception("Invalid problem.yaml") +def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: + + # TODO: getting this should be done using verifyproblem + # Wait until new config parsing system is in place + config_file = Path(problem) / 'problem.yaml' + + if not config_file.is_file(): + raise FileNotFoundError("No problem.yaml found") + + try: + with open(config_file) as f: + config = yaml.safe_load(f) + if config is None: + config = {} + except Exception as e: + raise Exception(f"Invalid problem.yaml: {e}") + + if 'name' in config and not isinstance(config['name'], dict): + config['name'] = {'': config['name']} + names = config.get("name") # If there is only one language, per the spec that is the one we want if len(names) == 1: return next(iter(names.values())) + if language is None: + language = "en" if language not in names: raise Exception(f"No problem name defined for language {language or 'en'}") return names[language] -def inject_samples(html, samples, sample_separator): +def inject_samples(statement_html, samples, sample_separator): """Injects samples at occurences of {{nextsample}} and {{remainingsamples}} Non-destructive, returns the new html and all left-over samples Returns: """ - + while True: - match = re.search(r'\{\{(nextsample|remainingsamples)\}\}', html) + match = re.search(r'\{\{(nextsample|remainingsamples)\}\}', statement_html) if not match: break matched_text = match.group(1) if matched_text == "nextsample" and len(samples) == 0: raise Exception("Error: called {{nextsample}} without any samples left") - + num_inject = 1 if matched_text == "nextsample" else len(samples) to_inject = sample_separator.join(samples[:num_inject]) samples = samples[num_inject:] - + # Always inject, even if to_inject is empty # This will remove all occurences of {{nextsample}} and {{remainingsamples}} # (And also properly throw an error if {{nextsample}} is called with no samples left) - html = html[:match.start()] + to_inject + html[match.end():] + statement_html = statement_html[:match.start()] + to_inject + statement_html[match.end():] - return html, samples + return statement_html, samples def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: @@ -253,6 +266,7 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd elif interaction[0] == '<': left = False else: + left = True print(f"Warning: Interaction had unknown prefix {interaction[0]}") lines.append(r""" \begin{table}[H] @@ -275,5 +289,5 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd if to_pdf: return line + '\\vspace{-15pt}'.join(lines) - else: - return line + ''.join(lines) + + return line + ''.join(lines) diff --git "a/problemtools/tests/problems///problem.yaml" "b/problemtools/tests/problems///problem.yaml" new file mode 100644 index 00000000..97d008eb --- /dev/null +++ "b/problemtools/tests/problems///problem.yaml" @@ -0,0 +1 @@ +name: Problem ID xss diff --git "a/problemtools/tests/problems///problem_statement/problem.md" "b/problemtools/tests/problems///problem_statement/problem.md" new file mode 100644 index 00000000..8b137891 --- /dev/null +++ "b/problemtools/tests/problems///problem_statement/problem.md" @@ -0,0 +1 @@ + diff --git a/problemtools/tests/problems/problemnamexss/problem.yaml b/problemtools/tests/problems/problemnamexss/problem.yaml new file mode 100644 index 00000000..2f3393a7 --- /dev/null +++ b/problemtools/tests/problems/problemnamexss/problem.yaml @@ -0,0 +1 @@ +name: diff --git a/problemtools/tests/problems/problemnamexss/problem_statement/problem.md b/problemtools/tests/problems/problemnamexss/problem_statement/problem.md new file mode 100644 index 00000000..95c3b387 --- /dev/null +++ b/problemtools/tests/problems/problemnamexss/problem_statement/problem.md @@ -0,0 +1 @@ +XSS injection via problem name. diff --git a/problemtools/tests/problems/samplexss/data/sample/1.ans b/problemtools/tests/problems/samplexss/data/sample/1.ans new file mode 100644 index 00000000..0f61cbb0 --- /dev/null +++ b/problemtools/tests/problems/samplexss/data/sample/1.ans @@ -0,0 +1 @@ +PWNED diff --git a/problemtools/tests/problems/samplexss/data/sample/1.in b/problemtools/tests/problems/samplexss/data/sample/1.in new file mode 100644 index 00000000..9114f1c2 --- /dev/null +++ b/problemtools/tests/problems/samplexss/data/sample/1.in @@ -0,0 +1,3 @@ + diff --git a/problemtools/tests/problems/samplexss/data/sample/testdata.yaml b/problemtools/tests/problems/samplexss/data/sample/testdata.yaml new file mode 100644 index 00000000..8034585a --- /dev/null +++ b/problemtools/tests/problems/samplexss/data/sample/testdata.yaml @@ -0,0 +1,5 @@ +on_reject: continue +range: 0 0 +accept_score: 0 +grader_flags: first_error +input_validator_flags: nFive=0 diff --git a/problemtools/tests/problems/samplexss/data/testdata.yaml b/problemtools/tests/problems/samplexss/data/testdata.yaml new file mode 100644 index 00000000..6e832954 --- /dev/null +++ b/problemtools/tests/problems/samplexss/data/testdata.yaml @@ -0,0 +1,3 @@ +on_reject: continue +range: 0 2 +grader_flags: ignore_sample diff --git a/problemtools/tests/problems/samplexss/problem.yaml b/problemtools/tests/problems/samplexss/problem.yaml new file mode 100644 index 00000000..54e6792a --- /dev/null +++ b/problemtools/tests/problems/samplexss/problem.yaml @@ -0,0 +1 @@ +name: Sample XSS diff --git a/problemtools/tests/problems/samplexss/problem_statement/problem.md b/problemtools/tests/problems/samplexss/problem_statement/problem.md new file mode 100644 index 00000000..0405e8d6 --- /dev/null +++ b/problemtools/tests/problems/samplexss/problem_statement/problem.md @@ -0,0 +1,27 @@ +Various XSS methods. Hopefully the sanitizer doesn't let any of them through. + + + + + + +Click me + + + +Click me + + + + + + + + + + + +

+ diff --git a/problemtools/tests/problems/specialcharacterssample/data/sample/1.ans b/problemtools/tests/problems/specialcharacterssample/data/sample/1.ans new file mode 100644 index 00000000..e66448f5 --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/data/sample/1.ans @@ -0,0 +1 @@ +Nice! diff --git a/problemtools/tests/problems/specialcharacterssample/data/sample/1.in b/problemtools/tests/problems/specialcharacterssample/data/sample/1.in new file mode 100644 index 00000000..950eee18 --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/data/sample/1.in @@ -0,0 +1 @@ +0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ diff --git a/problemtools/tests/problems/specialcharacterssample/data/sample/testdata.yaml b/problemtools/tests/problems/specialcharacterssample/data/sample/testdata.yaml new file mode 100644 index 00000000..8034585a --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/data/sample/testdata.yaml @@ -0,0 +1,5 @@ +on_reject: continue +range: 0 0 +accept_score: 0 +grader_flags: first_error +input_validator_flags: nFive=0 diff --git a/problemtools/tests/problems/specialcharacterssample/data/testdata.yaml b/problemtools/tests/problems/specialcharacterssample/data/testdata.yaml new file mode 100644 index 00000000..6e832954 --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/data/testdata.yaml @@ -0,0 +1,3 @@ +on_reject: continue +range: 0 2 +grader_flags: ignore_sample diff --git a/problemtools/tests/problems/specialcharacterssample/problem.yaml b/problemtools/tests/problems/specialcharacterssample/problem.yaml new file mode 100644 index 00000000..10e3241d --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/problem.yaml @@ -0,0 +1 @@ +name: Special Characters Sample diff --git a/problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md b/problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md new file mode 100644 index 00000000..abf3e60b --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md @@ -0,0 +1 @@ +All printable ASCII characters in sample diff --git a/problemtools/tests/problems/statementxss/problem.yaml b/problemtools/tests/problems/statementxss/problem.yaml new file mode 100644 index 00000000..adbc951a --- /dev/null +++ b/problemtools/tests/problems/statementxss/problem.yaml @@ -0,0 +1 @@ +name: XSS diff --git a/problemtools/tests/problems/statementxss/problem_statement/problem.md b/problemtools/tests/problems/statementxss/problem_statement/problem.md new file mode 100644 index 00000000..0405e8d6 --- /dev/null +++ b/problemtools/tests/problems/statementxss/problem_statement/problem.md @@ -0,0 +1,27 @@ +Various XSS methods. Hopefully the sanitizer doesn't let any of them through. + + + + + + +Click me + + + +Click me + + + + + + + + + + + +
+ diff --git a/problemtools/tests/test_markdown.py b/problemtools/tests/test_markdown.py new file mode 100644 index 00000000..968012e1 --- /dev/null +++ b/problemtools/tests/test_markdown.py @@ -0,0 +1,8 @@ +from pathlib import Path +from problemtools.tests.test_xss import render + +def test_sample_escaping(): + problem_path = Path(__file__).parent / "problems" / "specialcharacterssample" + html = render(problem_path) + all_printable = r"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~" + assert all_printable in html diff --git a/problemtools/tests/test_xss.py b/problemtools/tests/test_xss.py new file mode 100644 index 00000000..f932aba7 --- /dev/null +++ b/problemtools/tests/test_xss.py @@ -0,0 +1,33 @@ +import os +from pathlib import Path +from problemtools.problem2html import convert, get_parser +import tempfile + +def render(problem_path): + with tempfile.TemporaryDirectory() as temp_dir: + args, _unknown = get_parser().parse_known_args(['--problem', str(problem_path.resolve()), '--dest-dir', str(temp_dir)]) + convert(args) + with open(f"{temp_dir}/index.html", "r") as f: + html = f.read() + return html + +def test_no_xss_statement(): + problem_path = Path(__file__).parent / "problems" / "statementxss" + html = render(problem_path) + assert "alert" not in html + +def test_no_xss_problemname(): + problem_path = Path(__file__).parent / "problems" / "problemnamexss" + html = render(problem_path) + assert " - - Click me diff --git a/problemtools/tests/problems/statementxss/problem_statement/problem.md b/problemtools/tests/problems/statementxss/problem_statement/problem.md index 0405e8d6..1a555545 100644 --- a/problemtools/tests/problems/statementxss/problem_statement/problem.md +++ b/problemtools/tests/problems/statementxss/problem_statement/problem.md @@ -5,7 +5,6 @@ Various XSS methods. Hopefully the sanitizer doesn't let any of them through. alert("Hello world!"); - Click me diff --git a/problemtools/tests/problems/twofootnotes/problem.yaml b/problemtools/tests/problems/twofootnotes/problem.yaml new file mode 100644 index 00000000..e936cbc6 --- /dev/null +++ b/problemtools/tests/problems/twofootnotes/problem.yaml @@ -0,0 +1 @@ +name: Footnote Test 2 diff --git a/problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md b/problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md new file mode 100644 index 00000000..d95657ad --- /dev/null +++ b/problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md @@ -0,0 +1,9 @@ +Footnote test 2 + +[^1] + +[^2] + +[^1]: [https://sv.wikipedia.org/wiki/Interferens](https://sv.wikipedia.org/wiki/Interferens) + +[^2]: [https://sv.wikipedia.org/wiki/Interferens](https://sv.wikipedia.org/wiki/Interferens) diff --git a/problemtools/tests/test_markdown.py b/problemtools/tests/test_markdown.py index 968012e1..2b9501bc 100644 --- a/problemtools/tests/test_markdown.py +++ b/problemtools/tests/test_markdown.py @@ -1,8 +1,34 @@ from pathlib import Path from problemtools.tests.test_xss import render +from problemtools.md2html import FOOTNOTES_STRING +import pytest def test_sample_escaping(): problem_path = Path(__file__).parent / "problems" / "specialcharacterssample" html = render(problem_path) all_printable = r"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~" assert all_printable in html + +def test_footnotes(): + # We always want footnotes to be at the bottom + # When we insert samples, we need to insert them right above the first footnote + # To do this, we search for a string (very fragile) + problem_path = Path(__file__).parent / "problems" / "footnote" + html = render(problem_path) + assert FOOTNOTES_STRING in html + + problem_path = Path(__file__).parent / "problems" / "twofootnotes" + html = render(problem_path) + assert FOOTNOTES_STRING in html + +def test_footnotes_href(): + # We use allowlist-based id values for footnotes. Ensure they have not changed + problem_path = Path(__file__).parent / "problems" / "footnote" + html = render(problem_path) + assert "fn1" in html and "fnref1" in html + +def test_invalid_image_throws(): + # If images can point to img that doesn't exist, it's arbitrary web request + problem_path = Path(__file__).parent / "problems" / "imgrequest" + with pytest.raises(Exception): + render(problem_path) diff --git a/problemtools/tests/test_xss.py b/problemtools/tests/test_xss.py index f932aba7..0e32775e 100644 --- a/problemtools/tests/test_xss.py +++ b/problemtools/tests/test_xss.py @@ -15,7 +15,7 @@ def test_no_xss_statement(): problem_path = Path(__file__).parent / "problems" / "statementxss" html = render(problem_path) assert "alert" not in html - + def test_no_xss_problemname(): problem_path = Path(__file__).parent / "problems" / "problemnamexss" html = render(problem_path) @@ -26,6 +26,7 @@ def test_no_xss_sample(): html = render(problem_path) assert "/problem_statement/problem.md" => "problemtools/tests/problems///statement/problem.md" (100%) rename problemtools/tests/problems/footnote/{problem_statement => statement}/problem.en.md (100%) rename problemtools/tests/problems/imgrequest/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/problemnamexss/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/samplexss/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/specialcharacterssample/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/statementxss/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/twofootnotes/{problem_statement => statement}/problem.en.md (100%) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 5d4f0f85..c46a43fb 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -32,7 +32,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: raise FileNotFoundError('No markdown statement found') if not os.path.isfile(statement_path): - raise FileNotFoundError(f"Error! {statement_path} is not a file") + raise FileNotFoundError(f"Error! {statement_path} does not exist") command = ["pandoc", statement_path, "-t" , "html", "--mathjax"] @@ -130,7 +130,7 @@ def copy_image(problem_root: str, img_src: str) -> None: img_src: the image source as in the Markdown statement """ - source_name = os.path.join(problem_root, "problem_statement", img_src) + source_name = os.path.join(problem_root, "statement", img_src) if os.path.isfile(img_src): # already copied return diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 59b01d16..249fff95 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -1,7 +1,6 @@ import os from typing import Optional, List import html -import tempfile import subprocess import re import json @@ -14,14 +13,14 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: """Finds the "best" statement for given language and extension""" if language is None: - statement_path = os.path.join(problem_root, f"problem_statement/problem.en.{extension}") + statement_path = os.path.join(problem_root, f"statement/problem.en.{extension}") if os.path.isfile(statement_path): return statement_path - statement_path = os.path.join(problem_root, f"problem_statement/problem.{extension}") + statement_path = os.path.join(problem_root, f"statement/problem.{extension}") if os.path.isfile(statement_path): return statement_path return None - statement_path = os.path.join(problem_root, f"problem_statement/problem.{language}.{extension}") + statement_path = os.path.join(problem_root, f"statement/problem.{language}.{extension}") if os.path.isfile(statement_path): return statement_path return None @@ -75,9 +74,9 @@ def is_image_valid(problem_root: str, img_src: str) -> str|None: if extension not in (".png", ".jpg", ".jpeg"): # ".svg" return f"Unsupported image extension {extension} for image {img_src}" - source_file = Path(problem_root) / "problem_statement" / img_src + source_file = Path(problem_root) / "statement" / img_src if not source_file.exists(): - return f"Resource file {img_src} not found in problem_statement" + return f"Resource file {img_src} not found in statement" return None @@ -182,6 +181,34 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: return samples +def escape_latex_char(char: str) -> str: + if len(char) != 1: + raise ValueError("Input must be a single character.") + + replacements = { + "\\": "\\textbackslash{}", + "^": "\\textasciicircum{}", + "~": "\\textasciitilde{}", + "#": "\\#", + "$": "\\$", + "%": "\\%", + "&": "\\&", + "_": "\\_", + "{": "\\{", + "}": "\\}", + "*": "\\*", + "<": "\\textless{}", + ">": "\\textgreater{}", + "|": "\\textbar{}", + "'": "\\textquotesingle{}", + "`": "\\textasciigrave{}", + "\"":"\\verb|\"|", + ",": "\\verb|,|", + "-": "\\verb|-|", + "[": "\\verb|[|", + "]": "\\verb|]|", + } + return replacements.get(char, char) # Default: return unmodified char def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bool) -> str: """ @@ -203,31 +230,91 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bo with open(outpath, "r", encoding="utf-8") as outfile: sample_output = outfile.read() - sample = """ - - - - - - - - - - - -
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), - "output": html.escape(sample_output)}) + if not to_pdf: + return """ + + + + + + + + + + + +
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), + "output": html.escape(sample_output)}) - if to_pdf: - # If pdf, convert to markdown - with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: - temp_file.write(sample) - temp_file.flush() - command = ["pandoc", temp_file.name, "-t" , "markdown"] - return subprocess.run(command, capture_output=True, text=True, - shell=False, check=True).stdout - else: - return sample + + # Try to pack input and output into a Markdown table like this + # Precompute characters widths in LaTeX, pack as much + # as possible without causing overflow in the LaTeX table + # Use an obscene number of columns so Markdown is not limiting + """ + +---------------------------------+---------------------------------+ + | Sample Input 1 | Sample Output 1 | + +=================================+=================================+ + |0123456789abcdefghijklmnopqrstuv-|Nice! | + |wxyzABCDEFGHIJKLMNOPQRS- | | + |TUVWXYZ!"#$%&'()*+,-./:;<=>?- | | + |@[\]^_`{|}~ | | + +---------------------------------+---------------------------------+ + """ + # Need to account for if we have >= 10 samples + casenum_len = len(str(casenum))-1 + + # If there are lots of ^, we use lots of \\textasciicircum{}, and they must all fit + # Lower if debugging (or zoom out terminal veery far) + table_cols = 1000 + row = f"|{' ' * (table_cols + 16)}|{' ' * (table_cols + 16)}|\n" + ascii_char_widths = {' ': 3.33333, '!': 3.2, '"': 6.2, '#': 9.6, '$': 5.9, '%': 9.6, '&': 9.0, "'": 3.2, '(': 4.5, ')': 4.5, '*': 5.8, '+': 9.0, ',': 6.2, '-': 6.5, '.': 5, '/': 5.8, '0': 5.8, '1': 5.8, '2': 5.8, '3': 5.8, '4': 5.8, '5': 5.8, '6': 5.8, '7': 5.8, '8': 5.8, '9': 5.8, ':': 3.2, ';': 3.2, '<': 8.9, '=': 8.9, '>': 8.9, '?': 5.4, '@': 8.9, 'A': 7.50002, 'B': 7.08336, 'C': 7.22223, 'D': 7.6389, 'E': 6.80557, 'F': 6.5278, 'G': 7.84723, 'H': 7.50002, 'I': 3.61111, 'J': 5.1389, 'K': 8.5, 'L': 6.25002, 'M': 9.16669, 'N': 7.50002, 'O': 8.5, 'P': 6.80557, 'Q': 8.5, 'R': 7.36111, 'S': 5.55557, 'T': 7.22223, 'U': 7.50002, 'V': 7.50002, 'W': 10.2778, 'X': 7.50002, 'Y': 7.50002, 'Z': 6.11111, '[': 6.2, '\\': 6.0, ']': 6.2, '^': 6.5, '_': 8.6, '`': 5.8, 'a': 5.8, 'b': 5.55557, 'c': 4.44444, 'd': 5.55557, 'e': 4.44444, 'f': 3.05557, 'g': 5.8, 'h': 5.55557, 'i': 3.2, 'j': 3.05557, 'k': 5.2778, 'l': 3.2, 'm': 9.6, 'n': 5.55557, 'o': 5.8, 'p': 5.55557, 'q': 5.27779, 'r': 3.91667, 's': 3.94444, 't': 4.5, 'u': 5.55557, 'v': 5.2778, 'w': 7.22223, 'x': 5.2778, 'y': 5.2778, 'z': 4.44444, '{': 5.8, '|': 3.3, '}': 5.8, '~': 6.5} + space_per_row = 160 # Number of LaTeX units of horizontal space available + chars_per_row = (table_cols + 16)-1 # Save one space for - + num_rows = 0 + table = list(f""" ++----------------{'-' * table_cols}+----------------{'-' * table_cols}+ +| Sample Input {casenum} {' ' * (table_cols-casenum_len)}| Sample Output {casenum}{' ' * (table_cols-casenum_len)}| ++================{'=' * table_cols}+================{'=' * table_cols}+ +""") + base_table_offset = len(table) + def insert_into_table(offset, text): + nonlocal num_rows, table + curr_row = -1 + for line in text.split("\n"): + while len(line): + curr_row += 1 + if curr_row >= num_rows: + num_rows+=1 + table += list(row) + table += list(row) + + # Add stuff to write to this line while it fits + curr_vspace = 0 + curr_line = "" + # Must fit in both Markdown table and LaTeX table + while len(line) and \ + len(curr_line)+1 str: @@ -286,7 +373,7 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd line_type = "sampleinteractionread" else: print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(f"""
{data}
""") + lines.append(f"""
{html.escape(data)}
""") if to_pdf: return line + '\\vspace{-15pt}'.join(lines) diff --git "a/problemtools/tests/problems///problem_statement/problem.md" "b/problemtools/tests/problems///statement/problem.md" similarity index 100% rename from "problemtools/tests/problems///problem_statement/problem.md" rename to "problemtools/tests/problems///statement/problem.md" diff --git a/problemtools/tests/problems/footnote/problem_statement/problem.en.md b/problemtools/tests/problems/footnote/statement/problem.en.md similarity index 100% rename from problemtools/tests/problems/footnote/problem_statement/problem.en.md rename to problemtools/tests/problems/footnote/statement/problem.en.md diff --git a/problemtools/tests/problems/imgrequest/problem_statement/problem.md b/problemtools/tests/problems/imgrequest/statement/problem.md similarity index 100% rename from problemtools/tests/problems/imgrequest/problem_statement/problem.md rename to problemtools/tests/problems/imgrequest/statement/problem.md diff --git a/problemtools/tests/problems/problemnamexss/problem_statement/problem.md b/problemtools/tests/problems/problemnamexss/statement/problem.md similarity index 100% rename from problemtools/tests/problems/problemnamexss/problem_statement/problem.md rename to problemtools/tests/problems/problemnamexss/statement/problem.md diff --git a/problemtools/tests/problems/samplexss/problem_statement/problem.md b/problemtools/tests/problems/samplexss/statement/problem.md similarity index 100% rename from problemtools/tests/problems/samplexss/problem_statement/problem.md rename to problemtools/tests/problems/samplexss/statement/problem.md diff --git a/problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md b/problemtools/tests/problems/specialcharacterssample/statement/problem.md similarity index 100% rename from problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md rename to problemtools/tests/problems/specialcharacterssample/statement/problem.md diff --git a/problemtools/tests/problems/statementxss/problem_statement/problem.md b/problemtools/tests/problems/statementxss/statement/problem.md similarity index 100% rename from problemtools/tests/problems/statementxss/problem_statement/problem.md rename to problemtools/tests/problems/statementxss/statement/problem.md diff --git a/problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md b/problemtools/tests/problems/twofootnotes/statement/problem.en.md similarity index 100% rename from problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md rename to problemtools/tests/problems/twofootnotes/statement/problem.en.md From 213f9aca65c559bbbcea61306f74a05a477b0c83 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Tue, 8 Apr 2025 02:19:57 +0200 Subject: [PATCH 145/272] Better md -> pdf sample rendering --- problemtools/problem2pdf.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index b7c6b995..078af4c4 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -27,7 +27,7 @@ def md2pdf(options: argparse.Namespace) -> bool: statement_path = statement_common.find_statement(problem_root, extension="md", language=options.language) if not os.path.isfile(statement_path): - raise Exception(f"Error! {statement_path} is not a file") + raise FileNotFoundError(f"Error! {statement_path} does not exist") statement_common.assert_images_are_valid_md(statement_path) @@ -38,12 +38,12 @@ def md2pdf(options: argparse.Namespace) -> bool: None) table_fix_path = os.path.join(templatepath, "fix_tables.md") if not os.path.isfile(table_fix_path): - raise Exception("Could not find markdown pdf template") + raise FileNotFoundError("Could not find markdown pdf template") with open(table_fix_path, "r") as file: table_fix = file.read() - statement_dir = os.path.join(problem_root, "problem_statement") + statement_dir = os.path.join(problem_root, "statement") with open(statement_path, "r") as file: statement_md = file.read() @@ -62,16 +62,15 @@ def md2pdf(options: argparse.Namespace) -> bool: # If we don't add newline, the topmost table might get attached to a footnote statement_md += "\n" + "\n".join(remaining_samples) - with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: - temp_file.write(statement_md) - temp_file.flush() - command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}"] - try: - return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) - except subprocess.CalledProcessError as e: - print(f"Error compiling Markdown to pdf: {e.stderr}") - return False - + print("Rendering!") + command = ["pandoc", "-f", "markdown", "-o", destfile, f"--resource-path={statement_dir}"] + try: + return subprocess.run(command, input=statement_md, capture_output=True, + text=True, shell=False, check=True + ) + except subprocess.CalledProcessError as e: + print(f"Error compiling Markdown to pdf: {e.stderr}") + return False def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) From d745f6e37b1d3a73e613ab45afc6ebc7c36629c7 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Tue, 8 Apr 2025 02:23:17 +0200 Subject: [PATCH 146/272] Another escape --- problemtools/statement_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 249fff95..1ae8a2b6 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -364,7 +364,7 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd \hline \end{tabular} \end{table}""" % {"justify": "" if left else "\\hspace*{\\fill}\n", - "text": data}) + "text": html.escape(data)}) else: line_type = "" if interaction[0] == '>': From d4e27a29b8e0902d2618e666c940b02d6c7f59ae Mon Sep 17 00:00:00 2001 From: Matistjati Date: Tue, 8 Apr 2025 06:34:23 +0200 Subject: [PATCH 147/272] More careful with images --- problemtools/md2html.py | 7 ++++-- problemtools/problem2pdf.py | 18 ++++++++++--- problemtools/statement_common.py | 25 +++++++++++++++---- .../problems/imgrequest/statement/problem.md | 2 +- .../tests/problems/imgrequest2/problem.yaml | 1 + .../problems/imgrequest2/statement/problem.md | 3 +++ problemtools/tests/test_markdown.py | 15 ++++++++--- problemtools/tests/test_xss.py | 13 +++++++--- 8 files changed, 65 insertions(+), 19 deletions(-) create mode 100644 problemtools/tests/problems/imgrequest2/problem.yaml create mode 100644 problemtools/tests/problems/imgrequest2/statement/problem.md diff --git a/problemtools/md2html.py b/problemtools/md2html.py index c46a43fb..42f9450a 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -91,7 +91,7 @@ def is_fn_id(s): "footnotes") # Annoying: nh3 will ignore exceptions in attribute_filter - image_fail_reason = None + image_fail_reason: str|None = None def attribute_filter(tag, attribute, value): if attribute == "class" and value in allowed_classes: return value @@ -118,7 +118,10 @@ def attribute_filter(tag, attribute, value): ) if image_fail_reason: - raise Exception(image_fail_reason) + assert isinstance(image_fail_reason, str) + if "Unsupported" in image_fail_reason: + raise ValueError(image_fail_reason) + raise FileNotFoundError(image_fail_reason) return statement_html diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 078af4c4..0973138d 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -40,13 +40,13 @@ def md2pdf(options: argparse.Namespace) -> bool: if not os.path.isfile(table_fix_path): raise FileNotFoundError("Could not find markdown pdf template") - with open(table_fix_path, "r") as file: + with open(table_fix_path, "r", encoding="utf-8") as file: table_fix = file.read() statement_dir = os.path.join(problem_root, "statement") - with open(statement_path, "r") as file: + with open(statement_path, "r", encoding="utf-8") as file: statement_md = file.read() - + problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) # Add problem name and id to the top @@ -65,13 +65,23 @@ def md2pdf(options: argparse.Namespace) -> bool: print("Rendering!") command = ["pandoc", "-f", "markdown", "-o", destfile, f"--resource-path={statement_dir}"] try: - return subprocess.run(command, input=statement_md, capture_output=True, + subprocess.run(command, input=statement_md, capture_output=True, text=True, shell=False, check=True ) except subprocess.CalledProcessError as e: print(f"Error compiling Markdown to pdf: {e.stderr}") return False + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: + command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", + "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] + subprocess.run(command, capture_output=True, + text=True, shell=False, check=True + ) + shutil.copy(f.name, destfile) + + return True + def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem_root))[0] diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 1ae8a2b6..f94af909 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -1,9 +1,10 @@ import os from typing import Optional, List import html -import subprocess -import re import json +import re +import subprocess +import tempfile from pathlib import Path import yaml @@ -63,8 +64,11 @@ def json_dfs(data, callback) -> None: def foreach_image(statement_path, callback): # Find all images in the statement and call callback for each one command = ["pandoc", statement_path, "-t" , "json"] - statement_json = subprocess.run(command, capture_output=True, - text=True, shell=False, check=True).stdout + # Must create a working directory for pytest to work + with tempfile.TemporaryDirectory() as dir: + statement_json = subprocess.run(command, capture_output=True, text=True, + shell=False, check=True, cwd=dir).stdout + json_dfs(json.loads(statement_json), callback) def is_image_valid(problem_root: str, img_src: str) -> str|None: @@ -79,13 +83,24 @@ def is_image_valid(problem_root: str, img_src: str) -> str|None: return f"Resource file {img_src} not found in statement" return None +def assert_image_is_valid(problem_root: str, img_src: str) -> str|None: + # Check that the image exists and uses an allowed extension + extension = Path(img_src).suffix + # TODO: fix svg sanitization and allow svg + if extension not in (".png", ".jpg", ".jpeg"): # ".svg" + raise ValueError(f"Unsupported image extension {extension} for image {img_src}") + + source_file = Path(problem_root) / "statement" / img_src + if not source_file.exists(): + raise FileNotFoundError(f"Resource file {img_src} not found in statement") + def assert_images_are_valid_md(statement_path: str) -> None: # Find all images in the statement and assert that they exist and # use valid image extensions problem_root = os.path.dirname(statement_path) foreach_image(statement_path, - lambda img_name: is_image_valid(problem_root, img_name)) + lambda img_name: assert_image_is_valid(problem_root, img_name)) def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: diff --git a/problemtools/tests/problems/imgrequest/statement/problem.md b/problemtools/tests/problems/imgrequest/statement/problem.md index a97e2f1a..53ac7554 100644 --- a/problemtools/tests/problems/imgrequest/statement/problem.md +++ b/problemtools/tests/problems/imgrequest/statement/problem.md @@ -1,3 +1,3 @@ Make web request via image - +![Alt text](http:picsum.photos/400) diff --git a/problemtools/tests/problems/imgrequest2/problem.yaml b/problemtools/tests/problems/imgrequest2/problem.yaml new file mode 100644 index 00000000..57f37816 --- /dev/null +++ b/problemtools/tests/problems/imgrequest2/problem.yaml @@ -0,0 +1 @@ +name: Make web request via image diff --git a/problemtools/tests/problems/imgrequest2/statement/problem.md b/problemtools/tests/problems/imgrequest2/statement/problem.md new file mode 100644 index 00000000..a97e2f1a --- /dev/null +++ b/problemtools/tests/problems/imgrequest2/statement/problem.md @@ -0,0 +1,3 @@ +Make web request via image + + diff --git a/problemtools/tests/test_markdown.py b/problemtools/tests/test_markdown.py index 2b9501bc..75535306 100644 --- a/problemtools/tests/test_markdown.py +++ b/problemtools/tests/test_markdown.py @@ -1,5 +1,5 @@ from pathlib import Path -from problemtools.tests.test_xss import render +from problemtools.tests.test_xss import render, renderpdf from problemtools.md2html import FOOTNOTES_STRING import pytest @@ -29,6 +29,13 @@ def test_footnotes_href(): def test_invalid_image_throws(): # If images can point to img that doesn't exist, it's arbitrary web request - problem_path = Path(__file__).parent / "problems" / "imgrequest" - with pytest.raises(Exception): - render(problem_path) + for problem in ("imgrequest", "imgrequest2"): + problem_path = Path(__file__).parent / "problems" / problem + with pytest.raises(ValueError): + render(problem_path) + + # Pandoc won't make a web request for imgrequest2 + with pytest.raises(ValueError): + renderpdf(Path(__file__).parent / "problems" / "imgrequest") + + diff --git a/problemtools/tests/test_xss.py b/problemtools/tests/test_xss.py index 0e32775e..aafc561b 100644 --- a/problemtools/tests/test_xss.py +++ b/problemtools/tests/test_xss.py @@ -1,16 +1,23 @@ import os from pathlib import Path -from problemtools.problem2html import convert, get_parser +from problemtools import problem2html +from problemtools import problem2pdf import tempfile def render(problem_path): with tempfile.TemporaryDirectory() as temp_dir: - args, _unknown = get_parser().parse_known_args(['--problem', str(problem_path.resolve()), '--dest-dir', str(temp_dir)]) - convert(args) + args, _unknown = problem2html.get_parser().parse_known_args(['--problem', str(problem_path.resolve()), '--dest-dir', str(temp_dir)]) + problem2html.convert(args) with open(f"{temp_dir}/index.html", "r") as f: html = f.read() return html +def renderpdf(problem_path): + with tempfile.TemporaryDirectory() as temp_dir: + outpath = os.path.join(temp_dir, "out.pdf") + args, _unknown = problem2pdf.get_parser().parse_known_args(['--problem', str(problem_path.resolve()), '--o', outpath]) + problem2pdf.convert(args) + def test_no_xss_statement(): problem_path = Path(__file__).parent / "problems" / "statementxss" html = render(problem_path) From fdde1a462345f236cbf84b1544362a7dcc7401d9 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Tue, 8 Apr 2025 06:37:49 +0200 Subject: [PATCH 148/272] Make samplexss more focused --- .../problems/samplexss/statement/problem.md | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/problemtools/tests/problems/samplexss/statement/problem.md b/problemtools/tests/problems/samplexss/statement/problem.md index eb13d8f0..eba8940f 100644 --- a/problemtools/tests/problems/samplexss/statement/problem.md +++ b/problemtools/tests/problems/samplexss/statement/problem.md @@ -1,25 +1 @@ -Various XSS methods. Hopefully the sanitizer doesn't let any of them through. - - - - -Click me - - - -Click me - - - - - - - - - - - -
- +XSS via sample? From 3ded4a44ac207e63d50b334e8e6848e3fe993c31 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 06:32:44 +0200 Subject: [PATCH 149/272] Experimentally reuse normal LaTeX rendering --- problemtools/problem2pdf.py | 26 +- .../templates/latex/problemset_md.cls | 436 ++++++++++++++++++ 2 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 problemtools/templates/latex/problemset_md.cls diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 0973138d..23d75f26 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -4,6 +4,7 @@ import shutil import string import argparse +from pathlib import Path import subprocess import tempfile @@ -29,7 +30,29 @@ def md2pdf(options: argparse.Namespace) -> bool: if not os.path.isfile(statement_path): raise FileNotFoundError(f"Error! {statement_path} does not exist") - statement_common.assert_images_are_valid_md(statement_path) + #statement_common.assert_images_are_valid_md(statement_path) + + fake_tex = Path(statement_path).parent / "problem.tex" + print(f"{fake_tex=} {statement_path=}") + command = ["pandoc", statement_path, "-o", fake_tex] + try: + subprocess.run(command, capture_output=True, + text=True, shell=False, check=True + ) + except subprocess.CalledProcessError as e: + print(f"Error compiling Markdown to pdf: {e.stderr}") + return False + + with open(fake_tex, "r") as f: + tex = f.read() + with open(fake_tex, "w") as f: + f.write('\\problemname{asd}\n'+tex) + + try: + latex2pdf(options) + finally: + fake_tex.unlink() + return False templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), '/usr/lib/problemtools/templates/markdown_pdf'] @@ -102,6 +125,7 @@ def latex2pdf(options: argparse.Namespace) -> bool: params.append('-draftmode') params.append(texfile) + print(texfile) status = subprocess.call(params, stdout=output) if status == 0: diff --git a/problemtools/templates/latex/problemset_md.cls b/problemtools/templates/latex/problemset_md.cls new file mode 100644 index 00000000..55d6e0fb --- /dev/null +++ b/problemtools/templates/latex/problemset_md.cls @@ -0,0 +1,436 @@ +\NeedsTeXFormat{LaTeX2e} +\ProvidesClass{problemset}[2012/01/19 Problem Set For ACM-Style Programming Contests] + + +\newif\ifplastex +\plastexfalse + +\newif\if@footer\@footertrue +\DeclareOption{nofooter}{\@footerfalse} + +\newif\if@problemnumbers\@problemnumberstrue +\DeclareOption{noproblemnumbers}{\@problemnumbersfalse} + +\newif\if@problemids\@problemidstrue +\DeclareOption{noproblemids}{\@problemidsfalse} + +\newif\if@samplenumbers\@samplenumberstrue +\DeclareOption{nosamplenumbers}{\@samplenumbersfalse} + +\newif\if@clearevenpages\@clearevenpagestrue + +\newif\if@autoincludesamples\@autoincludesamplestrue +\DeclareOption{noautoincludesamples}{\@autoincludesamplesfalse} + +\DeclareOption{plainproblems}{ + \@footerfalse + \@problemnumbersfalse + \@clearevenpagesfalse +} + +\DeclareOption*{\PassOptionsToClass{\CurrentOption}{article}} +\ProcessOptions\relax + +\LoadClass{article} + +\RequirePackage{times} % Font choice +\RequirePackage{amsmath} % AMS +\RequirePackage{amssymb} % AMS +\RequirePackage[OT2,T1]{fontenc} % Cyrillic and standard % TODO: make alphabet options more general +\RequirePackage[utf8]{inputenc} % UTF-8 support +\RequirePackage{fancyhdr} % Headers +\RequirePackage{graphicx} % Graphics +\RequirePackage{subfigure} % Subfigures +\RequirePackage{wrapfig} % Illustrations +\RequirePackage{import} % Proper file inclusion +\RequirePackage{fancyvrb} % +\RequirePackage{listingsutf8} % For samples +\RequirePackage[left=1in,right=1in,top=0.75in,bottom=0.75in]{geometry} +%\RequirePackage{fullpage} % Set up margins for full page +\RequirePackage{url} % Urls +\RequirePackage[normalem]{ulem} % \sout +\RequirePackage[colorlinks=true,implicit=false]{hyperref} +\ifplastex\else +\RequirePackage{xstring} +\RequirePackage{pgffor} +\fi + +\usepackage{graphicx} % Required for inserting images +\usepackage{hyperref} +\providecommand{\tightlist}{% + \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} +\usepackage{longtable} +\usepackage{booktabs} + +%% Commands used to set name, logo, etc of contest +\newcommand*{\contestname}[1]{\def\@contestname{#1}} +\newcommand*{\contestshortname}[1]{\def\@contestshortname{#1}} +\newcommand*{\contestlogo}[1]{\def\@contestlogo{#1}} +\newcommand*{\location}[1]{\def\@location{#1}} +\newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} +\newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}} +\contestname{} +\contestshortname{} +\contestlogo{} +\location{} +\licenseblurb{} +\problemlanguage{} + + +% Command to set a header logo +\newsavebox{\PS@headerbox} +\savebox{\PS@headerbox}{} +\addtolength{\headheight}{0.25in} +\addtolength{\textheight}{-0.25in} +\setlength{\headsep}{12pt} +\newcommand*{\headerlogo}[1]{ + \def\@headerlogo{#1} + \savebox{\PS@headerbox}{\includegraphics[width=\textwidth]{\@headerlogo}} + \addtolength{\textheight}{\headheight} + \settoheight{\headheight}{\usebox{\PS@headerbox}} + \addtolength{\headheight}{4.2pt} + \addtolength{\textheight}{-\headheight} +} + + + +% Typesetting sections in a problem + +\renewcommand\section{\@startsection{section}{1}{\z@}% + {-3ex}% + {1ex}% + {\normalfont\large\sf\bfseries}} +\newenvironment{Input}{\section*{Input}}{} +\newenvironment{Output}{\section*{Output}}{} +\newenvironment{Interaction}{\section*{Interaction}}{} + +\renewcommand\subsection{\@startsection{subsection}{2}{\z@}% + {-3.55ex}% + {1.7ex}% + {\normalfont\normalsize\sf\bfseries}} + +\renewcommand{\contentsname}{Problems} + + +% TODO: make last command of illustration optional +\newcommand{\illustration}[3]{ + \begin{wrapfigure}{r}{#1\textwidth} + \includegraphics[width=#1\textwidth]{#2} + \begin{flushright} + \vspace{-9pt} + \tiny #3 + \end{flushright} + \vspace{-15pt} + \end{wrapfigure} + \par + \noindent +} + + +%% Redefine cleardoublepage to put a text on even-numbered empty +%% pages. +\newcommand{\makeemptypage}{ + ~\thispagestyle{empty} + \vfill + \centerline{\Large \textsf{ This page is intentionally left blank.}} + \vfill + \clearpage +} +\renewcommand{\cleardoublepage}{ + \clearpage% + \ifodd\value{page}\else\makeemptypage\fi% +} + +\newcommand{\clearproblemsetpage}{ + \if@clearevenpages + \cleardoublepage + \else + \clearpage + \fi +} + + +%% Set up a problem counter and number problems A B C ... +\newcounter{problemcount} +\setcounter{problemcount}{0} +\newcommand{\problemnumber}{\Alph{problemcount}} + +%% Number figures as A.1 A.2... B.1 B.2... +%% (except if we're converting to HTML or if we're not using problem numbers) +\ifplastex\else +\if@problemnumbers +\renewcommand{\thefigure}{\problemnumber.\arabic{figure}} +\let\p@subfigure=\thefigure +\fi +\fi + + +%% Command for starting new problem + +%% Problem inclusion +\newcommand{\includeproblem}[1]{ + \startproblem{#1} + \import{#1/problem_statement/}{problem\@problemlanguage.tex} + + %% Automatically include samples 1..9, if enabled + \ifplastex\else + \if@autoincludesamples + \foreach \SampleNum in {1,...,9} { + \IfFileExists{\@problemid/data/sample/\SampleNum.interaction}{ + \displaysampleinteraction{\@problemid/data/sample/\SampleNum} + }{\IfFileExists{\@problemid/data/sample/\SampleNum.in}{ + \displaysample{\@problemid/data/sample/\SampleNum} + }{} + } + } + \fi + \fi +} + +\newcommand{\startproblem}[1]{ + \clearproblemsetpage + \refstepcounter{problemcount} + \setcounter{samplenum}{0} + \setcounter{figure}{0}% + \def\@problemid{#1} +} + +\newcommand{\problemname}[1]{ + \def\@problemname{#1} + \problemheader{\@problemname}{\@problemid} +} + +\newcommand{\ps@formattime}[1]{ + #1\ifdim#1in=1in second \else seconds \fi +} + +\newread\ps@timelimitfile +\newcommand{\problemheader}[2]{ + \begin{center} + \textsf{ + \if@problemnumbers {\huge Problem \problemnumber\\[3mm]} \fi + {\LARGE #1} + \if@problemids {\\[2mm]{\Large Problem ID: #2}} \fi + \IfFileExists{#2/.timelimit}{ + \openin\ps@timelimitfile=#2/.timelimit + \read\ps@timelimitfile to\ps@timelimit + \\[2mm]{\Large Time limit:\ps@formattime{\ps@timelimit}} + \closein\ps@timelimitfile + }{} + \\[5mm] + } + \end{center} + \addtocontents{toc}{ + \if@problemnumbers \problemnumber \fi + & \@problemname \\}% +} + +%% Commands related to sample data + +\newcommand{\sampleinputname}{Sample Input} +\newcommand{\sampleoutputname}{Sample Output} +\newcommand{\sampleinteractname}{Sample Interaction} +\newcommand{\sampleinteractreadname}{Read} +\newcommand{\sampleinteractwritename}{Write} + +\newcommand{\formatsampleheader}[1]{\textsf{\textbf{#1}}} + +%% Sample counter +\newcounter{samplenum} +\newcommand{\sampleid}{\arabic{samplenum}} + +%% Define the command used to give sample data +%% Takes filename as parameter +\newcommand{\includesample}[1]{ + \IfFileExists{\@problemid/data/sample/#1.interaction}{ + \displaysampleinteraction{\@problemid/data/sample/#1} + }{ + \IfFileExists{\@problemid/data/sample/#1.in}{ + \displaysample{\@problemid/data/sample/#1} + }{ + \ClassError{problemset}{Can't find any sample named #1}{} + } + } + +} + +\newcommand{\displaysample}[1]{ + \IfFileExists{#1.in}{}{\ClassError{problemset}{Can't find file '#1.in'}{}} + \IfFileExists{#1.ans}{}{\ClassError{problemset}{Can't find file '#1.ans'}{}} + \refstepcounter{samplenum} + \vspace{0.4cm} + \sampletable + {\sampleinputname{} \if@samplenumbers\sampleid\fi}{#1.in} + {\sampleoutputname{} \if@samplenumbers\sampleid\fi}{#1.ans} +} + +\newcommand{\displaysampleinteraction}[1]{ + \IfFileExists{#1.interaction}{}{\ClassError{problemset}{Can't find file '#1.interaction'}{}} + \refstepcounter{samplenum} + \vspace{0.4cm} + \sampletableinteractive{\sampleinteractname{} \if@samplenumbers\sampleid\fi} + {\sampleinteractreadname} + {\sampleinteractwritename} + {#1.interaction} +} + +\newlength{\PS@sampleidealwidth} +\setlength{\PS@sampleidealwidth}{0.473\textwidth} +\newsavebox{\PS@sampleinbox} +\newsavebox{\PS@sampleoutbox} +\newlength{\PS@sampleinwidth} +\newlength{\PS@sampleoutwidth} +\newlength{\PS@sampletotwidth} + +\newcommand{\sampletable}[4]{ + % First find widths of the two files + \savebox{\PS@sampleinbox}{\lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{#2}} + \savebox{\PS@sampleoutbox}{\lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{#4}} + \settowidth{\PS@sampleoutwidth}{\usebox{\PS@sampleoutbox}} + \settowidth{\PS@sampleinwidth}{\usebox{\PS@sampleinbox}} + \setlength{\PS@sampletotwidth}{\PS@sampleinwidth} + \addtolength{\PS@sampletotwidth}{\PS@sampleoutwidth} + % Check if too wide for side-by-side + \ifdim\PS@sampletotwidth>2\PS@sampleidealwidth + \par + \noindent + \begin{tabular}{|l|} + \multicolumn{1}{l}{\formatsampleheader{#1}}\\ + \hline + \parbox[t]{0.968\textwidth}{\vspace{-0.3cm}\usebox{\PS@sampleinbox}}\\ + \hline + \end{tabular} + \par + \vspace{0.25cm} + \noindent + \begin{tabular}{|l|} + \multicolumn{1}{l}{\formatsampleheader{#3}}\\ + \hline + \parbox[t]{0.968\textwidth}{\vspace{-0.3cm}\usebox{\PS@sampleoutbox}}\\ + \hline + \end{tabular} + \else + % Side by side possible, figure out if adjustments are needed. + \ifdim\PS@sampleoutwidth>\PS@sampleidealwidth% Sample out too large + \setlength{\PS@sampleinwidth}{2\PS@sampleidealwidth} + \addtolength{\PS@sampleinwidth}{-\PS@sampleoutwidth} + \else + \ifdim\PS@sampleinwidth>\PS@sampleidealwidth% Sample in too large + \setlength{\PS@sampleoutwidth}{2\PS@sampleidealwidth} + \addtolength{\PS@sampleoutwidth}{-\PS@sampleinwidth} + \else% Ideal case: neither sample in nor sammple out too large + \setlength{\PS@sampleinwidth}{\PS@sampleidealwidth} + \setlength{\PS@sampleoutwidth}{\PS@sampleidealwidth} + \fi + \fi + \par + \noindent + \begin{tabular}{|l|l|} + \multicolumn{1}{l}{\formatsampleheader{#1}} & + \multicolumn{1}{l}{\formatsampleheader{#3}} \\ + \hline + \parbox[t]{\PS@sampleinwidth}{\vspace{-0.3cm}\usebox{\PS@sampleinbox}} + & + \parbox[t]{\PS@sampleoutwidth}{\vspace{-0.3cm}\usebox{\PS@sampleoutbox}} + \\ + \hline + \end{tabular} + \fi + \par +} + +\newread\ps@sampleinteraction +\newwrite\ps@sampleinteractionmsg +\newcommand{\sampletableinteractive}[4]{ + \noindent + \begin{tabular}{p{0.306\textwidth}p{0.306\textwidth}p{0.306\textwidth}} + \formatsampleheader{#2} \hfill & + \centering\formatsampleheader{#1} & + \hfill \formatsampleheader{#3} \\ + \end{tabular} + \begingroup + \openin\ps@sampleinteraction=#4 + \def\curmode{x} + \def\showmessage{ + \if x\curmode\else + \immediate\closeout\ps@sampleinteractionmsg + \vspace{-\parskip} + \vspace{0.05cm} + \par\noindent + \if w\curmode\hfill\fi + \begin{tabular}{|l|} + \hline + \parbox[t]{0.55\textwidth}{ + \vspace{-0.49cm} + \lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{\jobname.pstmp} + \vspace{-0.21cm} + }\\ + \hline + \end{tabular} + \par + \fi + } + \@whilesw\unless\ifeof\ps@sampleinteraction\fi{ + \endlinechar=-1 + \readline\ps@sampleinteraction to\ps@interactionline + \endlinechar=13 + \def\mode{x} + \IfBeginWith{\ps@interactionline}{>}{\def\mode{w}}{} + \IfBeginWith{\ps@interactionline}{<}{\def\mode{r}}{} + \if x\mode\else + \if \mode\curmode\else + \showmessage + \immediate\openout\ps@sampleinteractionmsg=\jobname.pstmp + \edef\curmode{\mode} + \fi + \StrGobbleLeft{\ps@interactionline}{1}[\ps@interactionline] + \immediate\write\ps@sampleinteractionmsg{\ps@interactionline} + \fi + } + \showmessage + \closein\ps@sampleinteraction + \endgroup +} + + +% Remaining part of file is headers and toc, not tested with plasTeX +% and should not be used in plastex mode +\ifplastex\else + +\AtBeginDocument{ + %% Set up headers + \fancypagestyle{problem}{ + \fancyhf{} % Clear old junk + \fancyhead[C]{\usebox{\PS@headerbox}} + \if@footer + \fancyfoot[L]{ + \emph{ + \@contestshortname{} + \ifdefined\@problemname + \if@problemnumbers Problem \problemnumber:{} \fi + \@problemname + \fi + \ifx\@licenseblurb\@empty\relax\else + \\\@licenseblurb + \fi + } + } + \fancyfoot[R]{\thepage} + \fi + } + \renewcommand{\headrulewidth}{0pt} + \pagestyle{problem} + + % Set up table of contents for cover page + \addtocontents{toc}{\protect\begin{tabular}{cl}} +} + +\AtEndDocument{ + \clearproblemsetpage + % Annoyingly enough addtocontents won't work at end of doc + \immediate\write\@auxout{% + \string\@writefile{toc}{\string\end{tabular}}% + } +} + +\fi From 79b5a5d69fbe22c6f04edafe4334a62f32d71698 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 08:36:28 +0200 Subject: [PATCH 150/272] Use problemtools problem2pdf to handle md -> pdf --- problemtools/formatversion.py | 6 +- problemtools/md2html.py | 2 +- problemtools/problem2pdf.py | 108 +++-- problemtools/statement_common.py | 231 +++------- problemtools/template.py | 8 +- problemtools/templates/latex/problemset.cls | 5 + .../templates/latex/problemset_md.cls | 436 ------------------ .../templates/markdown_pdf/fix_tables.md | 14 - 8 files changed, 118 insertions(+), 692 deletions(-) delete mode 100644 problemtools/templates/latex/problemset_md.cls delete mode 100644 problemtools/templates/markdown_pdf/fix_tables.md diff --git a/problemtools/formatversion.py b/problemtools/formatversion.py index 12af9169..cf574ccf 100644 --- a/problemtools/formatversion.py +++ b/problemtools/formatversion.py @@ -13,16 +13,14 @@ class FormatData: A class containing data specific to the format version. name: the version name. statement_directory: the directory where the statements should be found. - statement_extensions: the allowed extensions for the statements. """ name: str statement_directory: str - statement_extensions: list[str] FORMAT_DATACLASSES = { - VERSION_LEGACY: FormatData(name=VERSION_LEGACY, statement_directory="problem_statement", statement_extensions=["tex"]), - VERSION_2023_07: FormatData(name=VERSION_2023_07, statement_directory="statement", statement_extensions=["md", "tex"]) + VERSION_LEGACY: FormatData(name=VERSION_LEGACY, statement_directory="problem_statement"), + VERSION_2023_07: FormatData(name=VERSION_2023_07, statement_directory="statement") } diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 42f9450a..1e41beff 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -58,7 +58,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: title=html.escape(problem_name) if problem_name else "Missing problem name", problemid=html.escape(problembase)) - samples = statement_common.format_samples(problem, to_pdf=False) + samples = statement_common.format_samples(problem) # Insert samples at {{nextsample}} and {{remainingsamples}} statement_html, remaining_samples = statement_common.inject_samples(statement_html, samples, "") diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 25f095d7..198e0f82 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -5,6 +5,7 @@ import string import argparse from pathlib import Path +import re import subprocess import tempfile @@ -32,7 +33,9 @@ def md2pdf(options: argparse.Namespace) -> bool: #statement_common.assert_images_are_valid_md(statement_path) - fake_tex = Path(statement_path).parent / "problem.tex" + # TODO: fix nextsample and remainingsamples + # TODO: better language code + fake_tex = Path(statement_path).parent / "problem.en.tex" print(f"{fake_tex=} {statement_path=}") command = ["pandoc", statement_path, "-o", fake_tex] try: @@ -43,67 +46,60 @@ def md2pdf(options: argparse.Namespace) -> bool: print(f"Error compiling Markdown to pdf: {e.stderr}") return False - with open(fake_tex, "r") as f: - tex = f.read() - with open(fake_tex, "w") as f: - f.write('\\problemname{asd}\n'+tex) - try: + with open(fake_tex, "r") as f: + tex = f.read() + + def format_latex_tables(latex_doc): + # Match table environments with column specs between @{...@{}} + pattern = r''' + (\\begin\{longtable\}\[\]\{@\{\}) + ([a-z]) + ([a-z]*) + (@\{\}\}) + ''' + + def replacer(match): + prefix = match.group(1)[:-3] + first_col = match.group(2) + other_cols = match.group(3) + suffix = match.group(4)[3:] + + # Combine columns with | separators + cols = [first_col] + list(other_cols) + return f'{prefix}|{"|".join(cols)}|{suffix} \hline' + + return re.sub(pattern, replacer, latex_doc, flags=re.VERBOSE) + + tex = format_latex_tables(tex) + tex = tex.replace(r"\toprule", "") + tex = tex.replace(r"\midrule", "") + tex = tex.replace(r"\endhead", "") + tex = tex.replace(r"\bottomrule", "") + tex = tex.replace(r"\tabularnewline", r"\\ \hline") + + problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) + tex = '\\problemname{' + problem_name + '}\n' + tex + with open(fake_tex, "w") as f: + f.write(tex) + with open("SOGS.tex", "w") as f: + f.write(tex) + print("RENDERING!!") latex2pdf(options) + except Exception as e: + print(f"{e}") finally: fake_tex.unlink() - return False - - templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), - '/usr/lib/problemtools/templates/markdown_pdf'] - templatepath = next((p for p in templatepaths - if os.path.isdir(p) and os.path.isfile(os.path.join(p, "fix_tables.md"))), - None) - table_fix_path = os.path.join(templatepath, "fix_tables.md") - if not os.path.isfile(table_fix_path): - raise FileNotFoundError("Could not find markdown pdf template") - - with open(table_fix_path, "r", encoding="utf-8") as file: - table_fix = file.read() - - statement_dir = os.path.join(problem_root, "statement") - with open(statement_path, "r", encoding="utf-8") as file: - statement_md = file.read() - - problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) - - # Add problem name and id to the top - problem_id = os.path.basename(problem_root) - statement_md = r'\centerline{\large %s}' % f"Problem id: {problem_id}" + statement_md - statement_md = r'\centerline{\huge %s}' % problem_name + statement_md - # Add code that adds vertical and horizontal lines to all tables - statement_md = table_fix + statement_md - - samples = statement_common.format_samples(problem_root, to_pdf=True) - - statement_md, remaining_samples = statement_common.inject_samples(statement_md, samples, "\n") - # If we don't add newline, the topmost table might get attached to a footnote - statement_md += "\n" + "\n".join(remaining_samples) - - print("Rendering!") - command = ["pandoc", "-f", "markdown", "-o", destfile, f"--resource-path={statement_dir}"] - try: - subprocess.run(command, input=statement_md, capture_output=True, - text=True, shell=False, check=True - ) - except subprocess.CalledProcessError as e: - print(f"Error compiling Markdown to pdf: {e.stderr}") - return False - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: - command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", - "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] - subprocess.run(command, capture_output=True, - text=True, shell=False, check=True - ) - shutil.copy(f.name, destfile) + # with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: + # command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", + # "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] + # subprocess.run(command, capture_output=True, + # text=True, shell=False, check=True + # ) + # shutil.copy(f.name, destfile) - return True + return False def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index f94af909..450ccd21 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -9,21 +9,28 @@ import yaml +from . import formatversion + SUPPORTED_EXTENSIONS = ("tex", "md") +ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg") # ".svg" def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: """Finds the "best" statement for given language and extension""" + statement_dir = Path(problem_root) / formatversion.get_format_data(problem_root).statement_directory + + candidates = [] if language is None: - statement_path = os.path.join(problem_root, f"statement/problem.en.{extension}") - if os.path.isfile(statement_path): - return statement_path - statement_path = os.path.join(problem_root, f"statement/problem.{extension}") - if os.path.isfile(statement_path): - return statement_path - return None - statement_path = os.path.join(problem_root, f"statement/problem.{language}.{extension}") - if os.path.isfile(statement_path): - return statement_path + candidates = [ + statement_dir / f"problem.en.{extension}", + statement_dir / f"problem.{extension}", + ] + else: + candidates = [statement_dir / f"problem.{language}.{extension}"] + + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + return None @@ -75,7 +82,7 @@ def is_image_valid(problem_root: str, img_src: str) -> str|None: # Check that the image exists and uses an allowed extension extension = Path(img_src).suffix # TODO: fix svg sanitization and allow svg - if extension not in (".png", ".jpg", ".jpeg"): # ".svg" + if extension not in ALLOWED_IMAGE_EXTENSIONS: return f"Unsupported image extension {extension} for image {img_src}" source_file = Path(problem_root) / "statement" / img_src @@ -87,7 +94,7 @@ def assert_image_is_valid(problem_root: str, img_src: str) -> str|None: # Check that the image exists and uses an allowed extension extension = Path(img_src).suffix # TODO: fix svg sanitization and allow svg - if extension not in (".png", ".jpg", ".jpeg"): # ".svg" + if extension not in ALLOWED_IMAGE_EXTENSIONS: # ".svg" raise ValueError(f"Unsupported image extension {extension} for image {img_src}") source_file = Path(problem_root) / "statement" / img_src @@ -161,7 +168,7 @@ def inject_samples(statement_html, samples, sample_separator): return statement_html, samples -def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: +def format_samples(problem_root: str) -> List[str]: """Read all samples from the problem directory and convert them to pandoc-valid markdown Args: @@ -180,7 +187,7 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: casenum = 1 for sample in sorted(os.listdir(sample_path)): if sample.endswith(".interaction"): - samples.append(format_interactive_sample(sample_path, sample, casenum, to_pdf)) + samples.append(format_interactive_sample(sample_path, sample, casenum)) casenum += 1 continue @@ -191,48 +198,18 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: if not os.path.isfile(outpath): continue - samples.append(format_normal_sample(sample_path, sample, casenum, to_pdf)) + samples.append(format_normal_sample(sample_path, sample, casenum)) casenum += 1 return samples -def escape_latex_char(char: str) -> str: - if len(char) != 1: - raise ValueError("Input must be a single character.") - - replacements = { - "\\": "\\textbackslash{}", - "^": "\\textasciicircum{}", - "~": "\\textasciitilde{}", - "#": "\\#", - "$": "\\$", - "%": "\\%", - "&": "\\&", - "_": "\\_", - "{": "\\{", - "}": "\\}", - "*": "\\*", - "<": "\\textless{}", - ">": "\\textgreater{}", - "|": "\\textbar{}", - "'": "\\textquotesingle{}", - "`": "\\textasciigrave{}", - "\"":"\\verb|\"|", - ",": "\\verb|,|", - "-": "\\verb|-|", - "[": "\\verb|[|", - "]": "\\verb|]|", - } - return replacements.get(char, char) # Default: return unmodified char - -def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bool) -> str: +def format_normal_sample(sample_root: str, sample: str, casenum: int) -> str: """ Args: sample_root: root of the sample folder sample: file name of the sample casenum: which sample is this? (1, 2, 3...) - to_pdf: do we target pdf or html output Returns: str: the sample, ready to be pasted into a markdown doc and fed to pandoc @@ -245,94 +222,23 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bo with open(outpath, "r", encoding="utf-8") as outfile: sample_output = outfile.read() - if not to_pdf: - return """ - - - - - - - - - - - -
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), - "output": html.escape(sample_output)}) - - - # Try to pack input and output into a Markdown table like this - # Precompute characters widths in LaTeX, pack as much - # as possible without causing overflow in the LaTeX table - # Use an obscene number of columns so Markdown is not limiting - """ - +---------------------------------+---------------------------------+ - | Sample Input 1 | Sample Output 1 | - +=================================+=================================+ - |0123456789abcdefghijklmnopqrstuv-|Nice! | - |wxyzABCDEFGHIJKLMNOPQRS- | | - |TUVWXYZ!"#$%&'()*+,-./:;<=>?- | | - |@[\]^_`{|}~ | | - +---------------------------------+---------------------------------+ - """ - # Need to account for if we have >= 10 samples - casenum_len = len(str(casenum))-1 - - # If there are lots of ^, we use lots of \\textasciicircum{}, and they must all fit - # Lower if debugging (or zoom out terminal veery far) - table_cols = 1000 - row = f"|{' ' * (table_cols + 16)}|{' ' * (table_cols + 16)}|\n" - ascii_char_widths = {' ': 3.33333, '!': 3.2, '"': 6.2, '#': 9.6, '$': 5.9, '%': 9.6, '&': 9.0, "'": 3.2, '(': 4.5, ')': 4.5, '*': 5.8, '+': 9.0, ',': 6.2, '-': 6.5, '.': 5, '/': 5.8, '0': 5.8, '1': 5.8, '2': 5.8, '3': 5.8, '4': 5.8, '5': 5.8, '6': 5.8, '7': 5.8, '8': 5.8, '9': 5.8, ':': 3.2, ';': 3.2, '<': 8.9, '=': 8.9, '>': 8.9, '?': 5.4, '@': 8.9, 'A': 7.50002, 'B': 7.08336, 'C': 7.22223, 'D': 7.6389, 'E': 6.80557, 'F': 6.5278, 'G': 7.84723, 'H': 7.50002, 'I': 3.61111, 'J': 5.1389, 'K': 8.5, 'L': 6.25002, 'M': 9.16669, 'N': 7.50002, 'O': 8.5, 'P': 6.80557, 'Q': 8.5, 'R': 7.36111, 'S': 5.55557, 'T': 7.22223, 'U': 7.50002, 'V': 7.50002, 'W': 10.2778, 'X': 7.50002, 'Y': 7.50002, 'Z': 6.11111, '[': 6.2, '\\': 6.0, ']': 6.2, '^': 6.5, '_': 8.6, '`': 5.8, 'a': 5.8, 'b': 5.55557, 'c': 4.44444, 'd': 5.55557, 'e': 4.44444, 'f': 3.05557, 'g': 5.8, 'h': 5.55557, 'i': 3.2, 'j': 3.05557, 'k': 5.2778, 'l': 3.2, 'm': 9.6, 'n': 5.55557, 'o': 5.8, 'p': 5.55557, 'q': 5.27779, 'r': 3.91667, 's': 3.94444, 't': 4.5, 'u': 5.55557, 'v': 5.2778, 'w': 7.22223, 'x': 5.2778, 'y': 5.2778, 'z': 4.44444, '{': 5.8, '|': 3.3, '}': 5.8, '~': 6.5} - space_per_row = 160 # Number of LaTeX units of horizontal space available - chars_per_row = (table_cols + 16)-1 # Save one space for - - num_rows = 0 - table = list(f""" -+----------------{'-' * table_cols}+----------------{'-' * table_cols}+ -| Sample Input {casenum} {' ' * (table_cols-casenum_len)}| Sample Output {casenum}{' ' * (table_cols-casenum_len)}| -+================{'=' * table_cols}+================{'=' * table_cols}+ -""") - base_table_offset = len(table) - def insert_into_table(offset, text): - nonlocal num_rows, table - curr_row = -1 - for line in text.split("\n"): - while len(line): - curr_row += 1 - if curr_row >= num_rows: - num_rows+=1 - table += list(row) - table += list(row) - - # Add stuff to write to this line while it fits - curr_vspace = 0 - curr_line = "" - # Must fit in both Markdown table and LaTeX table - while len(line) and \ - len(curr_line)+1 str: + return """ + + + + + + + + + + + +
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), + "output": html.escape(sample_output)}) + + +def format_interactive_sample(sample_root: str, sample: str, casenum: int) -> str: """ Args: @@ -344,53 +250,28 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd Returns: str: the sample, ready to be pasted into a markdown doc and fed to pandoc """ - if to_pdf: - line = r"""\begin{tabular}{p{0.3\textwidth} p{0.5\textwidth} p{0.0\textwidth}} -\textbf{Read} & \textbf{Sample Interaction %i} & \textbf{Write} \\ -\end{tabular}""" % casenum - else: - line = f""" - - - - - - -
ReadSample Interaction {casenum}Write
""" + + line = f""" + + + + + + +
ReadSample Interaction {casenum}Write
""" with open(os.path.join(sample_root, sample), "r", encoding="utf-8") as infile: sample_interaction = infile.readlines() lines = [] for interaction in sample_interaction: data = html.escape(interaction[1:]) - if to_pdf: - if interaction[0] == '>': - left = True - elif interaction[0] == '<': - left = False - else: - left = True - print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(r""" - \begin{table}[H] - %(justify)s\begin{tabular}{|p{0.6\textwidth}|} - \hline - %(text)s \\ - \hline - \end{tabular} - \end{table}""" % {"justify": "" if left else "\\hspace*{\\fill}\n", - "text": html.escape(data)}) + line_type = "" + if interaction[0] == '>': + line_type = "sampleinteractionwrite" + elif interaction[0] == '<': + line_type = "sampleinteractionread" else: - line_type = "" - if interaction[0] == '>': - line_type = "sampleinteractionwrite" - elif interaction[0] == '<': - line_type = "sampleinteractionread" - else: - print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(f"""
{html.escape(data)}
""") - - if to_pdf: - return line + '\\vspace{-15pt}'.join(lines) + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(f"""
{html.escape(data)}
""") return line + ''.join(lines) diff --git a/problemtools/template.py b/problemtools/template.py index f0c7bc4b..b1e73466 100644 --- a/problemtools/template.py +++ b/problemtools/template.py @@ -16,18 +16,14 @@ def detect_version(problemdir, problemtex): class Template: - def __init__(self, problemdir, language=None, force_copy_cls=False, version="automatic"): + def __init__(self, problemdir, language=None, force_copy_cls=False): if not os.path.isdir(problemdir): raise Exception('%s is not a directory' % problemdir) if problemdir[-1] == '/': problemdir = problemdir[:-1] - if version == "automatic": - version_data = formatversion.get_format_data(problemdir) - - else: - version_data = formatversion.get_format_data_by_name(version) + version_data = formatversion.get_format_data(problemdir) stmtdir = os.path.join(problemdir, version_data.statement_directory) langs = [] diff --git a/problemtools/templates/latex/problemset.cls b/problemtools/templates/latex/problemset.cls index 1700901e..f747551c 100644 --- a/problemtools/templates/latex/problemset.cls +++ b/problemtools/templates/latex/problemset.cls @@ -50,6 +50,8 @@ \RequirePackage{url} % Urls \RequirePackage[normalem]{ulem} % \sout \RequirePackage[colorlinks=true,implicit=false]{hyperref} +\RequirePackage{longtable} % TODO: needed by Pandoc, but what do they do? +\RequirePackage{booktabs} % -||- \ifplastex\else \RequirePackage{xstring} \RequirePackage{pgffor} @@ -85,6 +87,9 @@ \addtolength{\textheight}{-\headheight} } +% Pandoc outputs these +\newcommand{\tightlist}{% + \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} % Typesetting sections in a problem diff --git a/problemtools/templates/latex/problemset_md.cls b/problemtools/templates/latex/problemset_md.cls deleted file mode 100644 index 55d6e0fb..00000000 --- a/problemtools/templates/latex/problemset_md.cls +++ /dev/null @@ -1,436 +0,0 @@ -\NeedsTeXFormat{LaTeX2e} -\ProvidesClass{problemset}[2012/01/19 Problem Set For ACM-Style Programming Contests] - - -\newif\ifplastex -\plastexfalse - -\newif\if@footer\@footertrue -\DeclareOption{nofooter}{\@footerfalse} - -\newif\if@problemnumbers\@problemnumberstrue -\DeclareOption{noproblemnumbers}{\@problemnumbersfalse} - -\newif\if@problemids\@problemidstrue -\DeclareOption{noproblemids}{\@problemidsfalse} - -\newif\if@samplenumbers\@samplenumberstrue -\DeclareOption{nosamplenumbers}{\@samplenumbersfalse} - -\newif\if@clearevenpages\@clearevenpagestrue - -\newif\if@autoincludesamples\@autoincludesamplestrue -\DeclareOption{noautoincludesamples}{\@autoincludesamplesfalse} - -\DeclareOption{plainproblems}{ - \@footerfalse - \@problemnumbersfalse - \@clearevenpagesfalse -} - -\DeclareOption*{\PassOptionsToClass{\CurrentOption}{article}} -\ProcessOptions\relax - -\LoadClass{article} - -\RequirePackage{times} % Font choice -\RequirePackage{amsmath} % AMS -\RequirePackage{amssymb} % AMS -\RequirePackage[OT2,T1]{fontenc} % Cyrillic and standard % TODO: make alphabet options more general -\RequirePackage[utf8]{inputenc} % UTF-8 support -\RequirePackage{fancyhdr} % Headers -\RequirePackage{graphicx} % Graphics -\RequirePackage{subfigure} % Subfigures -\RequirePackage{wrapfig} % Illustrations -\RequirePackage{import} % Proper file inclusion -\RequirePackage{fancyvrb} % -\RequirePackage{listingsutf8} % For samples -\RequirePackage[left=1in,right=1in,top=0.75in,bottom=0.75in]{geometry} -%\RequirePackage{fullpage} % Set up margins for full page -\RequirePackage{url} % Urls -\RequirePackage[normalem]{ulem} % \sout -\RequirePackage[colorlinks=true,implicit=false]{hyperref} -\ifplastex\else -\RequirePackage{xstring} -\RequirePackage{pgffor} -\fi - -\usepackage{graphicx} % Required for inserting images -\usepackage{hyperref} -\providecommand{\tightlist}{% - \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} -\usepackage{longtable} -\usepackage{booktabs} - -%% Commands used to set name, logo, etc of contest -\newcommand*{\contestname}[1]{\def\@contestname{#1}} -\newcommand*{\contestshortname}[1]{\def\@contestshortname{#1}} -\newcommand*{\contestlogo}[1]{\def\@contestlogo{#1}} -\newcommand*{\location}[1]{\def\@location{#1}} -\newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} -\newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}} -\contestname{} -\contestshortname{} -\contestlogo{} -\location{} -\licenseblurb{} -\problemlanguage{} - - -% Command to set a header logo -\newsavebox{\PS@headerbox} -\savebox{\PS@headerbox}{} -\addtolength{\headheight}{0.25in} -\addtolength{\textheight}{-0.25in} -\setlength{\headsep}{12pt} -\newcommand*{\headerlogo}[1]{ - \def\@headerlogo{#1} - \savebox{\PS@headerbox}{\includegraphics[width=\textwidth]{\@headerlogo}} - \addtolength{\textheight}{\headheight} - \settoheight{\headheight}{\usebox{\PS@headerbox}} - \addtolength{\headheight}{4.2pt} - \addtolength{\textheight}{-\headheight} -} - - - -% Typesetting sections in a problem - -\renewcommand\section{\@startsection{section}{1}{\z@}% - {-3ex}% - {1ex}% - {\normalfont\large\sf\bfseries}} -\newenvironment{Input}{\section*{Input}}{} -\newenvironment{Output}{\section*{Output}}{} -\newenvironment{Interaction}{\section*{Interaction}}{} - -\renewcommand\subsection{\@startsection{subsection}{2}{\z@}% - {-3.55ex}% - {1.7ex}% - {\normalfont\normalsize\sf\bfseries}} - -\renewcommand{\contentsname}{Problems} - - -% TODO: make last command of illustration optional -\newcommand{\illustration}[3]{ - \begin{wrapfigure}{r}{#1\textwidth} - \includegraphics[width=#1\textwidth]{#2} - \begin{flushright} - \vspace{-9pt} - \tiny #3 - \end{flushright} - \vspace{-15pt} - \end{wrapfigure} - \par - \noindent -} - - -%% Redefine cleardoublepage to put a text on even-numbered empty -%% pages. -\newcommand{\makeemptypage}{ - ~\thispagestyle{empty} - \vfill - \centerline{\Large \textsf{ This page is intentionally left blank.}} - \vfill - \clearpage -} -\renewcommand{\cleardoublepage}{ - \clearpage% - \ifodd\value{page}\else\makeemptypage\fi% -} - -\newcommand{\clearproblemsetpage}{ - \if@clearevenpages - \cleardoublepage - \else - \clearpage - \fi -} - - -%% Set up a problem counter and number problems A B C ... -\newcounter{problemcount} -\setcounter{problemcount}{0} -\newcommand{\problemnumber}{\Alph{problemcount}} - -%% Number figures as A.1 A.2... B.1 B.2... -%% (except if we're converting to HTML or if we're not using problem numbers) -\ifplastex\else -\if@problemnumbers -\renewcommand{\thefigure}{\problemnumber.\arabic{figure}} -\let\p@subfigure=\thefigure -\fi -\fi - - -%% Command for starting new problem - -%% Problem inclusion -\newcommand{\includeproblem}[1]{ - \startproblem{#1} - \import{#1/problem_statement/}{problem\@problemlanguage.tex} - - %% Automatically include samples 1..9, if enabled - \ifplastex\else - \if@autoincludesamples - \foreach \SampleNum in {1,...,9} { - \IfFileExists{\@problemid/data/sample/\SampleNum.interaction}{ - \displaysampleinteraction{\@problemid/data/sample/\SampleNum} - }{\IfFileExists{\@problemid/data/sample/\SampleNum.in}{ - \displaysample{\@problemid/data/sample/\SampleNum} - }{} - } - } - \fi - \fi -} - -\newcommand{\startproblem}[1]{ - \clearproblemsetpage - \refstepcounter{problemcount} - \setcounter{samplenum}{0} - \setcounter{figure}{0}% - \def\@problemid{#1} -} - -\newcommand{\problemname}[1]{ - \def\@problemname{#1} - \problemheader{\@problemname}{\@problemid} -} - -\newcommand{\ps@formattime}[1]{ - #1\ifdim#1in=1in second \else seconds \fi -} - -\newread\ps@timelimitfile -\newcommand{\problemheader}[2]{ - \begin{center} - \textsf{ - \if@problemnumbers {\huge Problem \problemnumber\\[3mm]} \fi - {\LARGE #1} - \if@problemids {\\[2mm]{\Large Problem ID: #2}} \fi - \IfFileExists{#2/.timelimit}{ - \openin\ps@timelimitfile=#2/.timelimit - \read\ps@timelimitfile to\ps@timelimit - \\[2mm]{\Large Time limit:\ps@formattime{\ps@timelimit}} - \closein\ps@timelimitfile - }{} - \\[5mm] - } - \end{center} - \addtocontents{toc}{ - \if@problemnumbers \problemnumber \fi - & \@problemname \\}% -} - -%% Commands related to sample data - -\newcommand{\sampleinputname}{Sample Input} -\newcommand{\sampleoutputname}{Sample Output} -\newcommand{\sampleinteractname}{Sample Interaction} -\newcommand{\sampleinteractreadname}{Read} -\newcommand{\sampleinteractwritename}{Write} - -\newcommand{\formatsampleheader}[1]{\textsf{\textbf{#1}}} - -%% Sample counter -\newcounter{samplenum} -\newcommand{\sampleid}{\arabic{samplenum}} - -%% Define the command used to give sample data -%% Takes filename as parameter -\newcommand{\includesample}[1]{ - \IfFileExists{\@problemid/data/sample/#1.interaction}{ - \displaysampleinteraction{\@problemid/data/sample/#1} - }{ - \IfFileExists{\@problemid/data/sample/#1.in}{ - \displaysample{\@problemid/data/sample/#1} - }{ - \ClassError{problemset}{Can't find any sample named #1}{} - } - } - -} - -\newcommand{\displaysample}[1]{ - \IfFileExists{#1.in}{}{\ClassError{problemset}{Can't find file '#1.in'}{}} - \IfFileExists{#1.ans}{}{\ClassError{problemset}{Can't find file '#1.ans'}{}} - \refstepcounter{samplenum} - \vspace{0.4cm} - \sampletable - {\sampleinputname{} \if@samplenumbers\sampleid\fi}{#1.in} - {\sampleoutputname{} \if@samplenumbers\sampleid\fi}{#1.ans} -} - -\newcommand{\displaysampleinteraction}[1]{ - \IfFileExists{#1.interaction}{}{\ClassError{problemset}{Can't find file '#1.interaction'}{}} - \refstepcounter{samplenum} - \vspace{0.4cm} - \sampletableinteractive{\sampleinteractname{} \if@samplenumbers\sampleid\fi} - {\sampleinteractreadname} - {\sampleinteractwritename} - {#1.interaction} -} - -\newlength{\PS@sampleidealwidth} -\setlength{\PS@sampleidealwidth}{0.473\textwidth} -\newsavebox{\PS@sampleinbox} -\newsavebox{\PS@sampleoutbox} -\newlength{\PS@sampleinwidth} -\newlength{\PS@sampleoutwidth} -\newlength{\PS@sampletotwidth} - -\newcommand{\sampletable}[4]{ - % First find widths of the two files - \savebox{\PS@sampleinbox}{\lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{#2}} - \savebox{\PS@sampleoutbox}{\lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{#4}} - \settowidth{\PS@sampleoutwidth}{\usebox{\PS@sampleoutbox}} - \settowidth{\PS@sampleinwidth}{\usebox{\PS@sampleinbox}} - \setlength{\PS@sampletotwidth}{\PS@sampleinwidth} - \addtolength{\PS@sampletotwidth}{\PS@sampleoutwidth} - % Check if too wide for side-by-side - \ifdim\PS@sampletotwidth>2\PS@sampleidealwidth - \par - \noindent - \begin{tabular}{|l|} - \multicolumn{1}{l}{\formatsampleheader{#1}}\\ - \hline - \parbox[t]{0.968\textwidth}{\vspace{-0.3cm}\usebox{\PS@sampleinbox}}\\ - \hline - \end{tabular} - \par - \vspace{0.25cm} - \noindent - \begin{tabular}{|l|} - \multicolumn{1}{l}{\formatsampleheader{#3}}\\ - \hline - \parbox[t]{0.968\textwidth}{\vspace{-0.3cm}\usebox{\PS@sampleoutbox}}\\ - \hline - \end{tabular} - \else - % Side by side possible, figure out if adjustments are needed. - \ifdim\PS@sampleoutwidth>\PS@sampleidealwidth% Sample out too large - \setlength{\PS@sampleinwidth}{2\PS@sampleidealwidth} - \addtolength{\PS@sampleinwidth}{-\PS@sampleoutwidth} - \else - \ifdim\PS@sampleinwidth>\PS@sampleidealwidth% Sample in too large - \setlength{\PS@sampleoutwidth}{2\PS@sampleidealwidth} - \addtolength{\PS@sampleoutwidth}{-\PS@sampleinwidth} - \else% Ideal case: neither sample in nor sammple out too large - \setlength{\PS@sampleinwidth}{\PS@sampleidealwidth} - \setlength{\PS@sampleoutwidth}{\PS@sampleidealwidth} - \fi - \fi - \par - \noindent - \begin{tabular}{|l|l|} - \multicolumn{1}{l}{\formatsampleheader{#1}} & - \multicolumn{1}{l}{\formatsampleheader{#3}} \\ - \hline - \parbox[t]{\PS@sampleinwidth}{\vspace{-0.3cm}\usebox{\PS@sampleinbox}} - & - \parbox[t]{\PS@sampleoutwidth}{\vspace{-0.3cm}\usebox{\PS@sampleoutbox}} - \\ - \hline - \end{tabular} - \fi - \par -} - -\newread\ps@sampleinteraction -\newwrite\ps@sampleinteractionmsg -\newcommand{\sampletableinteractive}[4]{ - \noindent - \begin{tabular}{p{0.306\textwidth}p{0.306\textwidth}p{0.306\textwidth}} - \formatsampleheader{#2} \hfill & - \centering\formatsampleheader{#1} & - \hfill \formatsampleheader{#3} \\ - \end{tabular} - \begingroup - \openin\ps@sampleinteraction=#4 - \def\curmode{x} - \def\showmessage{ - \if x\curmode\else - \immediate\closeout\ps@sampleinteractionmsg - \vspace{-\parskip} - \vspace{0.05cm} - \par\noindent - \if w\curmode\hfill\fi - \begin{tabular}{|l|} - \hline - \parbox[t]{0.55\textwidth}{ - \vspace{-0.49cm} - \lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{\jobname.pstmp} - \vspace{-0.21cm} - }\\ - \hline - \end{tabular} - \par - \fi - } - \@whilesw\unless\ifeof\ps@sampleinteraction\fi{ - \endlinechar=-1 - \readline\ps@sampleinteraction to\ps@interactionline - \endlinechar=13 - \def\mode{x} - \IfBeginWith{\ps@interactionline}{>}{\def\mode{w}}{} - \IfBeginWith{\ps@interactionline}{<}{\def\mode{r}}{} - \if x\mode\else - \if \mode\curmode\else - \showmessage - \immediate\openout\ps@sampleinteractionmsg=\jobname.pstmp - \edef\curmode{\mode} - \fi - \StrGobbleLeft{\ps@interactionline}{1}[\ps@interactionline] - \immediate\write\ps@sampleinteractionmsg{\ps@interactionline} - \fi - } - \showmessage - \closein\ps@sampleinteraction - \endgroup -} - - -% Remaining part of file is headers and toc, not tested with plasTeX -% and should not be used in plastex mode -\ifplastex\else - -\AtBeginDocument{ - %% Set up headers - \fancypagestyle{problem}{ - \fancyhf{} % Clear old junk - \fancyhead[C]{\usebox{\PS@headerbox}} - \if@footer - \fancyfoot[L]{ - \emph{ - \@contestshortname{} - \ifdefined\@problemname - \if@problemnumbers Problem \problemnumber:{} \fi - \@problemname - \fi - \ifx\@licenseblurb\@empty\relax\else - \\\@licenseblurb - \fi - } - } - \fancyfoot[R]{\thepage} - \fi - } - \renewcommand{\headrulewidth}{0pt} - \pagestyle{problem} - - % Set up table of contents for cover page - \addtocontents{toc}{\protect\begin{tabular}{cl}} -} - -\AtEndDocument{ - \clearproblemsetpage - % Annoyingly enough addtocontents won't work at end of doc - \immediate\write\@auxout{% - \string\@writefile{toc}{\string\end{tabular}}% - } -} - -\fi diff --git a/problemtools/templates/markdown_pdf/fix_tables.md b/problemtools/templates/markdown_pdf/fix_tables.md deleted file mode 100644 index 1b04614f..00000000 --- a/problemtools/templates/markdown_pdf/fix_tables.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -header-includes: - - '\usepackage{float}' - - '\usepackage{booktabs}' - - '\usepackage{xstring}' - - '\setlength{\aboverulesep}{0pt}' - - '\setlength{\belowrulesep}{0pt}' - - '\renewcommand{\arraystretch}{1.3}' - - '\makeatletter' - - '\patchcmd{\LT@array}{\@mkpream{#2}}{\StrGobbleLeft{#2}{2}[\pream]\StrGobbleRight{\pream}{2}[\pream]\StrSubstitute{\pream}{l}{|l}[\pream]\@mkpream{@{}\pream|@{}}}{}{}' - - '\def\midrule{}' - - '\apptocmd{\LT@tabularcr}{\hline}{}{}' - - '\makeatother' ---- From fcda10648556520afa82393a4b80ab4bc2e5c4f5 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 09:17:10 +0200 Subject: [PATCH 151/272] Cleanup --- problemtools/md2html.py | 45 +++++---- problemtools/problem2html.py | 4 +- problemtools/problem2pdf.py | 80 ++++++++------- ...{statement_common.py => statement_util.py} | 99 ++++++++----------- .../script>/problem.yaml" | 1 + .../tests/problems/footnote/problem.yaml | 1 + .../tests/problems/imgrequest/problem.yaml | 1 + .../tests/problems/imgrequest2/problem.yaml | 1 + .../problems/problemnamexss/problem.yaml | 1 + .../tests/problems/samplexss/problem.yaml | 1 + .../specialcharacterssample/problem.yaml | 1 + .../tests/problems/statementxss/problem.yaml | 1 + .../tests/problems/twofootnotes/problem.yaml | 1 + problemtools/verifyproblem.py | 1 - 14 files changed, 121 insertions(+), 117 deletions(-) rename problemtools/{statement_common.py => statement_util.py} (89%) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 1e41beff..253bd3ef 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -3,6 +3,7 @@ import argparse import html import os +from pathlib import Path import re import shutil import string @@ -10,7 +11,7 @@ import nh3 -from . import statement_common +from . import statement_util FOOTNOTES_STRING = '
' @@ -25,7 +26,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - statement_path = statement_common.find_statement(problem, extension="md", + statement_path = statement_util.find_statement(problem, extension="md", language=options.language) if statement_path is None: @@ -50,18 +51,20 @@ def convert(problem: str, options: argparse.Namespace) -> bool: if templatepath is None: raise FileNotFoundError('Could not find directory with markdown templates') - problem_name = statement_common.get_yaml_problem_name(problem, options.language) + with open(Path(templatepath) / "default-layout.html", "r", encoding="utf-8") as template_file: + template = template_file.read() - statement_html = _substitute_template(templatepath, "default-layout.html", - statement_html=statement_html, - language=options.language, - title=html.escape(problem_name) if problem_name else "Missing problem name", - problemid=html.escape(problembase)) + problem_name = statement_util.get_yaml_problem_name(problem, options.language) + substitution_params = {"statement_html": statement_html, + "language": options.language, + "title": html.escape(problem_name) if problem_name else "Missing problem name", + "problemid": html.escape(problembase)} - samples = statement_common.format_samples(problem) + statement_html = template % substitution_params + samples = statement_util.format_samples(problem) # Insert samples at {{nextsample}} and {{remainingsamples}} - statement_html, remaining_samples = statement_common.inject_samples(statement_html, samples, "") + statement_html, remaining_samples = statement_util.inject_samples(statement_html, samples, "") # Insert the remaining samples at the bottom # However, footnotes should be below samples @@ -90,6 +93,18 @@ def is_fn_id(s): "sampleinteractionwrite", "sampleinteractionread", "footnotes") + def is_image_valid(problem_root: str, img_src: str) -> str|None: + # Check that the image exists and uses an allowed extension + extension = Path(img_src).suffix + # TODO: fix svg sanitization and allow svg + if extension not in statement_util.ALLOWED_IMAGE_EXTENSIONS: + return f"Unsupported image extension {extension} for image {img_src}" + + source_file = Path(problem_root) / "statement" / img_src + if not source_file.exists(): + return f"Resource file {img_src} not found in statement" + return None + # Annoying: nh3 will ignore exceptions in attribute_filter image_fail_reason: str|None = None def attribute_filter(tag, attribute, value): @@ -100,7 +115,7 @@ def attribute_filter(tag, attribute, value): if tag in ("li", "a") and attribute == "id" and is_fn_id(value): return value if tag == "img" and attribute == "src": - fail = statement_common.is_image_valid(problem, value) + fail = is_image_valid(problem, value) if fail: nonlocal image_fail_reason image_fail_reason = fail @@ -138,11 +153,3 @@ def copy_image(problem_root: str, img_src: str) -> None: if os.path.isfile(img_src): # already copied return shutil.copyfile(source_name, img_src) - -def _substitute_template(templatepath: str, templatefile: str, **params) -> str: - """Read the markdown template and substitute in things such as problem name, - statement etc using python's format syntax. - """ - with open(os.path.join(templatepath, templatefile), "r", encoding="utf-8") as template_file: - html_template = template_file.read() % params - return html_template diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index c396e4a0..1807c2ad 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -8,7 +8,7 @@ from . import tex2html from . import md2html -from . import statement_common +from . import statement_util def convert(options: argparse.Namespace) -> None: problem = os.path.realpath(options.problem) @@ -32,7 +32,7 @@ def convert(options: argparse.Namespace) -> None: origcwd = os.getcwd() - if statement_common.find_statement_extension(problem, options.language) == "tex": + if statement_util.find_statement_extension(problem, options.language) == "tex": tex2html.convert(problem, options) else: md2html.convert(problem, options) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 198e0f82..a268a73d 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -1,21 +1,21 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import os.path +import re import shutil import string -import argparse -from pathlib import Path -import re import subprocess import tempfile +from pathlib import Path from . import template -from . import statement_common +from . import statement_util def convert(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) - if statement_common.find_statement_extension(problem_root, language=options.language) == "md": + if statement_util.find_statement_extension(problem_root, language=options.language) == "md": return md2pdf(options) else: return latex2pdf(options) @@ -26,18 +26,18 @@ def md2pdf(options: argparse.Namespace) -> bool: problembase = os.path.splitext(os.path.basename(problem_root))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - statement_path = statement_common.find_statement(problem_root, extension="md", language=options.language) + statement_path = statement_util.find_statement(problem_root, extension="md", language=options.language) if not os.path.isfile(statement_path): raise FileNotFoundError(f"Error! {statement_path} does not exist") - #statement_common.assert_images_are_valid_md(statement_path) + statement_util.assert_images_are_valid_md(statement_path) - # TODO: fix nextsample and remainingsamples - # TODO: better language code - fake_tex = Path(statement_path).parent / "problem.en.tex" - print(f"{fake_tex=} {statement_path=}") - command = ["pandoc", statement_path, "-o", fake_tex] + language = options.language + if not language: + language = "en" + temp_tex_file = Path(statement_path).parent / f"problem.{language}.tex" + command = ["pandoc", statement_path, "-o", temp_tex_file] try: subprocess.run(command, capture_output=True, text=True, shell=False, check=True @@ -45,9 +45,9 @@ def md2pdf(options: argparse.Namespace) -> bool: except subprocess.CalledProcessError as e: print(f"Error compiling Markdown to pdf: {e.stderr}") return False - + try: - with open(fake_tex, "r") as f: + with open(temp_tex_file, "r", encoding="utf-8") as f: tex = f.read() def format_latex_tables(latex_doc): @@ -58,48 +58,56 @@ def format_latex_tables(latex_doc): ([a-z]*) (@\{\}\}) ''' - + def replacer(match): prefix = match.group(1)[:-3] first_col = match.group(2) other_cols = match.group(3) suffix = match.group(4)[3:] - + # Combine columns with | separators cols = [first_col] + list(other_cols) - return f'{prefix}|{"|".join(cols)}|{suffix} \hline' - + return f'{prefix}|{"|".join(cols)}|{suffix} \\hline' + return re.sub(pattern, replacer, latex_doc, flags=re.VERBOSE) + # Add solid outline to tables tex = format_latex_tables(tex) tex = tex.replace(r"\toprule", "") tex = tex.replace(r"\midrule", "") tex = tex.replace(r"\endhead", "") tex = tex.replace(r"\bottomrule", "") tex = tex.replace(r"\tabularnewline", r"\\ \hline") - - problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) + + # Fix sample inclusions commands + # Currently does not work, as normal problemtools tex -> pdf does not support it + tex = tex.replace(r"\{\{nextsample\}\}", r"\nextsample") + tex = tex.replace(r"\{\{remainingsamples\}\}", r"\remainingsamples") + + problem_name = statement_util.get_yaml_problem_name(problem_root, options.language) tex = '\\problemname{' + problem_name + '}\n' + tex - with open(fake_tex, "w") as f: - f.write(tex) - with open("SOGS.tex", "w") as f: + with open(temp_tex_file, "w", encoding="utf-8") as f: f.write(tex) - print("RENDERING!!") - latex2pdf(options) - except Exception as e: - print(f"{e}") + + status = latex2pdf(options) + if status != 0: + return status finally: - fake_tex.unlink() + temp_tex_file.unlink() - # with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: - # command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", - # "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] - # subprocess.run(command, capture_output=True, - # text=True, shell=False, check=True - # ) - # shutil.copy(f.name, destfile) + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: + command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", + "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] + status = subprocess.run(command, capture_output=True, + text=True, shell=False, check=True + ) + shutil.copy(f.name, destfile) + except subprocess.CalledProcessError as e: + print(f"Error sanitizing PDF: {e} {e.stderr}") + raise - return False + return status == 0 def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) diff --git a/problemtools/statement_common.py b/problemtools/statement_util.py similarity index 89% rename from problemtools/statement_common.py rename to problemtools/statement_util.py index 450ccd21..28e1b44a 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_util.py @@ -17,7 +17,7 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: """Finds the "best" statement for given language and extension""" statement_dir = Path(problem_root) / formatversion.get_format_data(problem_root).statement_directory - + candidates = [] if language is None: candidates = [ @@ -33,7 +33,6 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) - return None - def find_statement_extension(problem_root: str, language: Optional[str]) -> str: """Given a language, find whether the extension is tex or md @@ -52,6 +51,36 @@ def find_statement_extension(problem_root: str, language: Optional[str]) -> str: return extensions[0] raise FileNotFoundError(f"No statement found for language {language or 'en'}") +def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: + + # TODO: getting this should be done using verifyproblem + # Wait until new config parsing system is in place + config_file = Path(problem) / 'problem.yaml' + + if not config_file.is_file(): + raise FileNotFoundError("No problem.yaml found") + + try: + with open(config_file, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + if config is None: + config = {} + except Exception as e: + raise ValueError(f"Invalid problem.yaml: {e}") from e + + if 'name' in config and not isinstance(config['name'], dict): + config['name'] = {'': config['name']} + + names = config.get("name") + # If there is only one language, per the spec that is the one we want + if len(names) == 1: + return next(iter(names.values())) + + if language is None: + language = "en" + if language not in names: + raise ValueError(f"No problem name defined for language {language or 'en'}") + return names[language] def json_dfs(data, callback) -> None: """Traverse all items in a JSON tree, find all images, and call callback for each one""" @@ -67,80 +96,36 @@ def json_dfs(data, callback) -> None: for item in data: json_dfs(item, callback) - def foreach_image(statement_path, callback): - # Find all images in the statement and call callback for each one + """ Find all images in the statement and call callback for each one """ command = ["pandoc", statement_path, "-t" , "json"] # Must create a working directory for pytest to work - with tempfile.TemporaryDirectory() as dir: + with tempfile.TemporaryDirectory() as work_dir: statement_json = subprocess.run(command, capture_output=True, text=True, - shell=False, check=True, cwd=dir).stdout + shell=False, check=True, cwd=work_dir).stdout json_dfs(json.loads(statement_json), callback) -def is_image_valid(problem_root: str, img_src: str) -> str|None: - # Check that the image exists and uses an allowed extension - extension = Path(img_src).suffix - # TODO: fix svg sanitization and allow svg - if extension not in ALLOWED_IMAGE_EXTENSIONS: - return f"Unsupported image extension {extension} for image {img_src}" - - source_file = Path(problem_root) / "statement" / img_src - if not source_file.exists(): - return f"Resource file {img_src} not found in statement" - return None - def assert_image_is_valid(problem_root: str, img_src: str) -> str|None: - # Check that the image exists and uses an allowed extension + """ Check that the image exists and uses an allowed extension """ extension = Path(img_src).suffix # TODO: fix svg sanitization and allow svg if extension not in ALLOWED_IMAGE_EXTENSIONS: # ".svg" raise ValueError(f"Unsupported image extension {extension} for image {img_src}") - source_file = Path(problem_root) / "statement" / img_src + source_file = Path(problem_root) / img_src if not source_file.exists(): raise FileNotFoundError(f"Resource file {img_src} not found in statement") - def assert_images_are_valid_md(statement_path: str) -> None: - # Find all images in the statement and assert that they exist and - # use valid image extensions + """ Find all images in the statement and assert that they exist and + use valid image extensions + + """ problem_root = os.path.dirname(statement_path) foreach_image(statement_path, lambda img_name: assert_image_is_valid(problem_root, img_name)) -def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: - - # TODO: getting this should be done using verifyproblem - # Wait until new config parsing system is in place - config_file = Path(problem) / 'problem.yaml' - - if not config_file.is_file(): - raise FileNotFoundError("No problem.yaml found") - - try: - with open(config_file, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) - if config is None: - config = {} - except Exception as e: - raise ValueError(f"Invalid problem.yaml: {e}") from e - - if 'name' in config and not isinstance(config['name'], dict): - config['name'] = {'': config['name']} - - names = config.get("name") - # If there is only one language, per the spec that is the one we want - if len(names) == 1: - return next(iter(names.values())) - - if language is None: - language = "en" - if language not in names: - raise ValueError(f"No problem name defined for language {language or 'en'}") - return names[language] - - def inject_samples(statement_html, samples, sample_separator): """Injects samples at occurences of {{nextsample}} and {{remainingsamples}} Non-destructive, returns the new html and all left-over samples @@ -167,13 +152,11 @@ def inject_samples(statement_html, samples, sample_separator): return statement_html, samples - def format_samples(problem_root: str) -> List[str]: """Read all samples from the problem directory and convert them to pandoc-valid markdown Args: problem_root: path to root of problem - to_pdf: whether the outputted samples should be valid for for html or pdf Returns: List[str]: All samples, converted to a format appropriate to be pasted into @@ -237,7 +220,6 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int) -> str: """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)}) - def format_interactive_sample(sample_root: str, sample: str, casenum: int) -> str: """ @@ -245,7 +227,6 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int) -> st sample_root: root of the sample folder sample: file name of the sample casenum: which sample is this? (1, 2, 3...) - to_pdf: do we target pdf or html output Returns: str: the sample, ready to be pasted into a markdown doc and fed to pandoc diff --git "a/problemtools/tests/problems///problem.yaml" "b/problemtools/tests/problems///problem.yaml" index 97d008eb..81f4b166 100644 --- "a/problemtools/tests/problems///problem.yaml" +++ "b/problemtools/tests/problems///problem.yaml" @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Problem ID xss diff --git a/problemtools/tests/problems/footnote/problem.yaml b/problemtools/tests/problems/footnote/problem.yaml index e36e455a..90adb3d4 100644 --- a/problemtools/tests/problems/footnote/problem.yaml +++ b/problemtools/tests/problems/footnote/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Footnote Test diff --git a/problemtools/tests/problems/imgrequest/problem.yaml b/problemtools/tests/problems/imgrequest/problem.yaml index 57f37816..10ac351a 100644 --- a/problemtools/tests/problems/imgrequest/problem.yaml +++ b/problemtools/tests/problems/imgrequest/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Make web request via image diff --git a/problemtools/tests/problems/imgrequest2/problem.yaml b/problemtools/tests/problems/imgrequest2/problem.yaml index 57f37816..10ac351a 100644 --- a/problemtools/tests/problems/imgrequest2/problem.yaml +++ b/problemtools/tests/problems/imgrequest2/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Make web request via image diff --git a/problemtools/tests/problems/problemnamexss/problem.yaml b/problemtools/tests/problems/problemnamexss/problem.yaml index 2f3393a7..cad13b0b 100644 --- a/problemtools/tests/problems/problemnamexss/problem.yaml +++ b/problemtools/tests/problems/problemnamexss/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: diff --git a/problemtools/tests/problems/samplexss/problem.yaml b/problemtools/tests/problems/samplexss/problem.yaml index 54e6792a..5f9a55fb 100644 --- a/problemtools/tests/problems/samplexss/problem.yaml +++ b/problemtools/tests/problems/samplexss/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Sample XSS diff --git a/problemtools/tests/problems/specialcharacterssample/problem.yaml b/problemtools/tests/problems/specialcharacterssample/problem.yaml index 10e3241d..21125ad7 100644 --- a/problemtools/tests/problems/specialcharacterssample/problem.yaml +++ b/problemtools/tests/problems/specialcharacterssample/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Special Characters Sample diff --git a/problemtools/tests/problems/statementxss/problem.yaml b/problemtools/tests/problems/statementxss/problem.yaml index adbc951a..a728c041 100644 --- a/problemtools/tests/problems/statementxss/problem.yaml +++ b/problemtools/tests/problems/statementxss/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: XSS diff --git a/problemtools/tests/problems/twofootnotes/problem.yaml b/problemtools/tests/problems/twofootnotes/problem.yaml index e936cbc6..e8c5ca31 100644 --- a/problemtools/tests/problems/twofootnotes/problem.yaml +++ b/problemtools/tests/problems/twofootnotes/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Footnote Test 2 diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 3c5abbdb..8859ea20 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -28,7 +28,6 @@ from . import problem2pdf from . import problem2html -from . import statement_common from . import formatversion from . import config From 47bda29ee94ea46bf79d822abd9eff9be44653c2 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 09:22:54 +0200 Subject: [PATCH 152/272] librsvg out of focus for this PR --- Dockerfile | 1 - README.md | 4 ++-- admin/docker/Dockerfile.build | 1 - admin/docker/Dockerfile.full | 1 - admin/docker/Dockerfile.minimal | 1 - 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index ac91a281..2c3b4c57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,6 @@ RUN apt-get update && \ libgmp-dev \ libgmp10 \ libgmpxx4ldbl \ - librsvg2-bin \ openjdk-8-jdk \ pandoc \ python3-minimal \ diff --git a/README.md b/README.md index 4572f138..84494e09 100644 --- a/README.md +++ b/README.md @@ -207,13 +207,13 @@ The dependencies needed to *build/install* problemtools can be installed with: And the dependencies needed to *run* problemtools can be installed with: - sudo apt install ghostscript libgmpxx4ldbl librsvg2-bin pandoc python3-minimal python-pkg-resources python3-plastex python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy + sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy ### Fedora On Fedora, these dependencies can be installed with: - sudo dnf install boost-regex gcc gmp-devel gmp-c++ librsvg pandoc python3 python3-pyyaml texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript + sudo dnf install boost-regex gcc gmp-devel gmp-c++ pandoc python3 python3-pyyaml texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript Followed by: diff --git a/admin/docker/Dockerfile.build b/admin/docker/Dockerfile.build index a6c124fa..e7041fb9 100644 --- a/admin/docker/Dockerfile.build +++ b/admin/docker/Dockerfile.build @@ -25,7 +25,6 @@ RUN apt update && \ libgmp-dev \ libgmp10 \ libgmpxx4ldbl \ - librsvg2-bin \ pandoc \ python3 \ python3-pytest \ diff --git a/admin/docker/Dockerfile.full b/admin/docker/Dockerfile.full index 036a930a..9fb5196a 100644 --- a/admin/docker/Dockerfile.full +++ b/admin/docker/Dockerfile.full @@ -19,7 +19,6 @@ RUN apt-get update && \ gnustep-devel gnustep gnustep-make gnustep-common gobjc \ libgmp3-dev \ libmozjs-78-dev \ - librsvg2-bin \ lua5.4 \ mono-complete \ nodejs \ diff --git a/admin/docker/Dockerfile.minimal b/admin/docker/Dockerfile.minimal index 45881e5d..886d1a2d 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -20,7 +20,6 @@ RUN apt update && \ apt install -y \ ghostscript \ libgmpxx4ldbl \ - librsvg2-bin \ pandoc \ python-pkg-resources \ python3-minimal \ From 054448e3fedb452ab7514fe4fe34b45e09afd8cc Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 09:28:27 +0200 Subject: [PATCH 153/272] Ensure nh3 --- Dockerfile | 1 + README.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2c3b4c57..02a400a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update && \ openjdk-8-jdk \ pandoc \ python3-minimal \ + python-nh3 \ python3-pip \ python3-plastex \ python3-yaml \ diff --git a/README.md b/README.md index 84494e09..e490f67e 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ The dependencies needed to *build/install* problemtools can be installed with: And the dependencies needed to *run* problemtools can be installed with: - sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy + sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-nh3 python-pkg-resources python3-plastex python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy ### Fedora @@ -217,7 +217,7 @@ On Fedora, these dependencies can be installed with: Followed by: - pip3 install --user plastex + pip3 install --user plastex nh3 ### Arch Package is available on the AUR [kattis-problemtools-git](https://aur.archlinux.org/packages/kattis-problemtools-git). Use your favorite AUR helper or follow the installation instructions found [here](https://wiki.archlinux.org/title/Arch_User_Repository#Installing_and_upgrading_packages). From ecdb6c4e0995b50fa78bd42fbc053fabb6763250 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 09:40:30 +0200 Subject: [PATCH 154/272] Remove ghostscript sanitization. If it wasn't used before, it probably isn't needed --- problemtools/problem2pdf.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index a268a73d..b0a53cd0 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -95,18 +95,6 @@ def replacer(match): finally: temp_tex_file.unlink() - try: - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: - command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", - "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] - status = subprocess.run(command, capture_output=True, - text=True, shell=False, check=True - ) - shutil.copy(f.name, destfile) - except subprocess.CalledProcessError as e: - print(f"Error sanitizing PDF: {e} {e.stderr}") - raise - return status == 0 def latex2pdf(options: argparse.Namespace) -> bool: From 690215f1e81987b6cd756bd653e89baf18d187ce Mon Sep 17 00:00:00 2001 From: Matistjati Date: Sun, 13 Apr 2025 03:34:25 +0200 Subject: [PATCH 155/272] Add nh3 to deb build --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 717a9b53..1d39a4a9 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: kattis-problemtools Section: devel Priority: optional Maintainer: Per Austrin -Build-Depends: debhelper (>= 8.0.0), g++ (>= 4.8), dh-python, python3, python3-setuptools, python3-pytest, python3-yaml, python3-setuptools, python3-pytest, libboost-regex-dev, libgmp-dev, automake, autoconf +Build-Depends: debhelper (>= 8.0.0), g++ (>= 4.8), dh-python, python3, python3-setuptools, python3-pytest, python3-yaml, python3-setuptools, python3-pytest, python3-nh3, libboost-regex-dev, libgmp-dev, automake, autoconf Standards-Version: 3.9.4 Homepage: https://github.com/Kattis/problemtools From 77cb2c99214e217ba2873c908df56291e89044e7 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Sun, 13 Apr 2025 05:59:40 +0200 Subject: [PATCH 156/272] Linting --- problemtools/md2html.py | 29 ++++++++++++++++------------- problemtools/problem2pdf.py | 20 +++++++++----------- problemtools/statement_util.py | 34 +++++++++++++++++++++++----------- problemtools/tex2html.py | 1 - 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 253bd3ef..aff5e5fb 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -16,6 +16,7 @@ FOOTNOTES_STRING = '
' + def convert(problem: str, options: argparse.Namespace) -> bool: """Convert a Markdown statement to HTML @@ -27,7 +28,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: destfile = string.Template(options.destfile).safe_substitute(problem=problembase) statement_path = statement_util.find_statement(problem, extension="md", - language=options.language) + language=options.language) if statement_path is None: raise FileNotFoundError('No markdown statement found') @@ -35,10 +36,9 @@ def convert(problem: str, options: argparse.Namespace) -> bool: if not os.path.isfile(statement_path): raise FileNotFoundError(f"Error! {statement_path} does not exist") - - command = ["pandoc", statement_path, "-t" , "html", "--mathjax"] + command = ["pandoc", statement_path, "-t", "html", "--mathjax"] statement_html = subprocess.run(command, capture_output=True, text=True, - shell=False, check=True).stdout + shell=False, check=True).stdout statement_html = sanitize_html(problem, statement_html) @@ -56,15 +56,15 @@ def convert(problem: str, options: argparse.Namespace) -> bool: problem_name = statement_util.get_yaml_problem_name(problem, options.language) substitution_params = {"statement_html": statement_html, - "language": options.language, - "title": html.escape(problem_name) if problem_name else "Missing problem name", - "problemid": html.escape(problembase)} + "language": options.language, + "title": html.escape(problem_name) if problem_name else "Missing problem name", + "problemid": html.escape(problembase)} statement_html = template % substitution_params samples = statement_util.format_samples(problem) # Insert samples at {{nextsample}} and {{remainingsamples}} - statement_html, remaining_samples = statement_util.inject_samples(statement_html, samples, "") + statement_html, remaining_samples = statement_util.inject_samples(statement_html, samples) # Insert the remaining samples at the bottom # However, footnotes should be below samples @@ -82,6 +82,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: return True + def sanitize_html(problem: str, statement_html: str): # Allow footnote ids (the anchor points you jump to) def is_fn_id(s): @@ -90,10 +91,10 @@ def is_fn_id(s): return bool(re.fullmatch(pattern_id_top, s)) or bool(re.fullmatch(pattern_id_bottom, s)) allowed_classes = ("sample", "problemheader", "problembody", - "sampleinteractionwrite", "sampleinteractionread", - "footnotes") + "sampleinteractionwrite", "sampleinteractionread", + "footnotes") - def is_image_valid(problem_root: str, img_src: str) -> str|None: + def is_image_valid(problem_root: str, img_src: str) -> str | None: # Check that the image exists and uses an allowed extension extension = Path(img_src).suffix # TODO: fix svg sanitization and allow svg @@ -106,7 +107,8 @@ def is_image_valid(problem_root: str, img_src: str) -> str|None: return None # Annoying: nh3 will ignore exceptions in attribute_filter - image_fail_reason: str|None = None + image_fail_reason: str | None = None + def attribute_filter(tag, attribute, value): if attribute == "class" and value in allowed_classes: return value @@ -140,6 +142,7 @@ def attribute_filter(tag, attribute, value): return statement_html + def copy_image(problem_root: str, img_src: str) -> None: """Copy image to output directory @@ -150,6 +153,6 @@ def copy_image(problem_root: str, img_src: str) -> None: source_name = os.path.join(problem_root, "statement", img_src) - if os.path.isfile(img_src): # already copied + if os.path.isfile(img_src): # already copied return shutil.copyfile(source_name, img_src) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index b0a53cd0..85250fbb 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -12,6 +12,7 @@ from . import template from . import statement_util + def convert(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) @@ -23,12 +24,9 @@ def convert(options: argparse.Namespace) -> bool: def md2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) - problembase = os.path.splitext(os.path.basename(problem_root))[0] - destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - statement_path = statement_util.find_statement(problem_root, extension="md", language=options.language) - if not os.path.isfile(statement_path): + if not statement_path or not os.path.isfile(statement_path): raise FileNotFoundError(f"Error! {statement_path} does not exist") statement_util.assert_images_are_valid_md(statement_path) @@ -37,11 +35,11 @@ def md2pdf(options: argparse.Namespace) -> bool: if not language: language = "en" temp_tex_file = Path(statement_path).parent / f"problem.{language}.tex" - command = ["pandoc", statement_path, "-o", temp_tex_file] + command = ["pandoc", statement_path, "-o", str(temp_tex_file)] try: subprocess.run(command, capture_output=True, - text=True, shell=False, check=True - ) + text=True, shell=False, check=True + ) except subprocess.CalledProcessError as e: print(f"Error compiling Markdown to pdf: {e.stderr}") return False @@ -51,7 +49,7 @@ def md2pdf(options: argparse.Namespace) -> bool: tex = f.read() def format_latex_tables(latex_doc): - # Match table environments with column specs between @{...@{}} + # Match table environments produced by pandoc pattern = r''' (\\begin\{longtable\}\[\]\{@\{\}) ([a-z]) @@ -85,18 +83,19 @@ def replacer(match): tex = tex.replace(r"\{\{remainingsamples\}\}", r"\remainingsamples") problem_name = statement_util.get_yaml_problem_name(problem_root, options.language) - tex = '\\problemname{' + problem_name + '}\n' + tex + tex = r'\problemname{' + problem_name + '}\n' + tex with open(temp_tex_file, "w", encoding="utf-8") as f: f.write(tex) status = latex2pdf(options) if status != 0: - return status + return False finally: temp_tex_file.unlink() return status == 0 + def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem_root))[0] @@ -117,7 +116,6 @@ def latex2pdf(options: argparse.Namespace) -> bool: params.append('-draftmode') params.append(texfile) - print(texfile) status = subprocess.call(params, stdout=output) if status == 0: diff --git a/problemtools/statement_util.py b/problemtools/statement_util.py index 28e1b44a..3beab3d8 100644 --- a/problemtools/statement_util.py +++ b/problemtools/statement_util.py @@ -1,5 +1,5 @@ import os -from typing import Optional, List +from typing import Optional, List, Tuple import html import json import re @@ -12,7 +12,8 @@ from . import formatversion SUPPORTED_EXTENSIONS = ("tex", "md") -ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg") # ".svg" +ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg") # ".svg" + def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: """Finds the "best" statement for given language and extension""" @@ -33,6 +34,7 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) - return None + def find_statement_extension(problem_root: str, language: Optional[str]) -> str: """Given a language, find whether the extension is tex or md @@ -51,8 +53,9 @@ def find_statement_extension(problem_root: str, language: Optional[str]) -> str: return extensions[0] raise FileNotFoundError(f"No statement found for language {language or 'en'}") -def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: +def get_yaml_problem_name(problem: str, language: Optional[str]) -> str: + """Finds the problem name from the problem.yaml file""" # TODO: getting this should be done using verifyproblem # Wait until new config parsing system is in place config_file = Path(problem) / 'problem.yaml' @@ -82,6 +85,7 @@ def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str raise ValueError(f"No problem name defined for language {language or 'en'}") return names[language] + def json_dfs(data, callback) -> None: """Traverse all items in a JSON tree, find all images, and call callback for each one""" if isinstance(data, dict): @@ -96,9 +100,10 @@ def json_dfs(data, callback) -> None: for item in data: json_dfs(item, callback) + def foreach_image(statement_path, callback): """ Find all images in the statement and call callback for each one """ - command = ["pandoc", statement_path, "-t" , "json"] + command = ["pandoc", statement_path, "-t", "json"] # Must create a working directory for pytest to work with tempfile.TemporaryDirectory() as work_dir: statement_json = subprocess.run(command, capture_output=True, text=True, @@ -106,17 +111,19 @@ def foreach_image(statement_path, callback): json_dfs(json.loads(statement_json), callback) -def assert_image_is_valid(problem_root: str, img_src: str) -> str|None: + +def assert_image_is_valid(problem_root: str, img_src: str) -> None: """ Check that the image exists and uses an allowed extension """ extension = Path(img_src).suffix # TODO: fix svg sanitization and allow svg - if extension not in ALLOWED_IMAGE_EXTENSIONS: # ".svg" + if extension not in ALLOWED_IMAGE_EXTENSIONS: raise ValueError(f"Unsupported image extension {extension} for image {img_src}") source_file = Path(problem_root) / img_src if not source_file.exists(): raise FileNotFoundError(f"Resource file {img_src} not found in statement") + def assert_images_are_valid_md(statement_path: str) -> None: """ Find all images in the statement and assert that they exist and use valid image extensions @@ -124,14 +131,16 @@ def assert_images_are_valid_md(statement_path: str) -> None: """ problem_root = os.path.dirname(statement_path) foreach_image(statement_path, - lambda img_name: assert_image_is_valid(problem_root, img_name)) + lambda img_name: assert_image_is_valid(problem_root, img_name)) -def inject_samples(statement_html, samples, sample_separator): + +def inject_samples(statement_html: str, samples: List[str]) -> Tuple[str, List[str]]: """Injects samples at occurences of {{nextsample}} and {{remainingsamples}} - Non-destructive, returns the new html and all left-over samples + Non-destructive Returns: - """ + Statement with samples inject and left-over samples. + """ while True: match = re.search(r'\{\{(nextsample|remainingsamples)\}\}', statement_html) @@ -142,7 +151,7 @@ def inject_samples(statement_html, samples, sample_separator): raise ValueError("Error: called {{nextsample}} without any samples left") num_inject = 1 if matched_text == "nextsample" else len(samples) - to_inject = sample_separator.join(samples[:num_inject]) + to_inject = "".join(samples[:num_inject]) samples = samples[num_inject:] # Always inject, even if to_inject is empty @@ -152,6 +161,7 @@ def inject_samples(statement_html, samples, sample_separator): return statement_html, samples + def format_samples(problem_root: str) -> List[str]: """Read all samples from the problem directory and convert them to pandoc-valid markdown @@ -186,6 +196,7 @@ def format_samples(problem_root: str) -> List[str]: return samples + def format_normal_sample(sample_root: str, sample: str, casenum: int) -> str: """ @@ -220,6 +231,7 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int) -> str: """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)}) + def format_interactive_sample(sample_root: str, sample: str, casenum: int) -> str: """ diff --git a/problemtools/tex2html.py b/problemtools/tex2html.py index 49c88c78..598b9a89 100644 --- a/problemtools/tex2html.py +++ b/problemtools/tex2html.py @@ -23,7 +23,6 @@ def convert(problem: str, options: argparse.Namespace) -> None: destfile = string.Template(options.destfile).safe_substitute(problem=problembase) imgbasedir = string.Template(options.imgbasedir).safe_substitute(problem=problembase) - texfile = problem # Set up template if necessary with template.Template(problem, language=options.language) as templ: texfile = open(templ.get_file_name(), 'r') From 2e7653fcadf5b01b7b5236f6717d1275508c3132 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Sun, 13 Apr 2025 06:04:15 +0200 Subject: [PATCH 157/272] Add back ghostscript sanitization --- problemtools/problem2pdf.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 85250fbb..4e687fac 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -23,6 +23,9 @@ def convert(options: argparse.Namespace) -> bool: def md2pdf(options: argparse.Namespace) -> bool: + """Renders a Markdown document to pdf. Uses pandoc md -> tex, then + reuses the normal tex -> pdf pipeline + """ problem_root = os.path.realpath(options.problem) statement_path = statement_util.find_statement(problem_root, extension="md", language=options.language) @@ -129,7 +132,24 @@ def latex2pdf(options: argparse.Namespace) -> bool: if status == 0 and not options.nopdf: shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) - return status == 0 + if status: + return False + + try: + with tempfile.NamedTemporaryFile(suffix='.pdf') as f: + command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", + "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] + gs_status = subprocess.run(command, capture_output=True, + text=True, shell=False, check=True + ) + if gs_status: + return False + shutil.copy(f.name, destfile) + except subprocess.CalledProcessError as e: + print(f"Error sanitizing PDF: {e} {e.stderr}") + raise + + return True def get_parser() -> argparse.ArgumentParser: From 51f5539adf3c422cb3d66ecfa272f9b774134d0b Mon Sep 17 00:00:00 2001 From: Matistjati Date: Sun, 13 Apr 2025 06:19:02 +0200 Subject: [PATCH 158/272] Remove unnecessary test --- .../script>/problem.yaml" | 2 -- .../script>/statement/problem.md" | 1 - problemtools/tests/test_xss.py | 7 ------- 3 files changed, 10 deletions(-) delete mode 100644 "problemtools/tests/problems///problem.yaml" delete mode 100644 "problemtools/tests/problems///statement/problem.md" diff --git "a/problemtools/tests/problems///problem.yaml" "b/problemtools/tests/problems///problem.yaml" deleted file mode 100644 index 81f4b166..00000000 --- "a/problemtools/tests/problems///problem.yaml" +++ /dev/null @@ -1,2 +0,0 @@ -problem_format_version: 2023-07 -name: Problem ID xss diff --git "a/problemtools/tests/problems///statement/problem.md" "b/problemtools/tests/problems///statement/problem.md" deleted file mode 100644 index 8b137891..00000000 --- "a/problemtools/tests/problems///statement/problem.md" +++ /dev/null @@ -1 +0,0 @@ - diff --git a/problemtools/tests/test_xss.py b/problemtools/tests/test_xss.py index aafc561b..e45c6c1d 100644 --- a/problemtools/tests/test_xss.py +++ b/problemtools/tests/test_xss.py @@ -32,10 +32,3 @@ def test_no_xss_sample(): problem_path = Path(__file__).parent / "problems" / "samplexss" html = render(problem_path) assert "