diff --git a/problemtools/checks/graders.py b/problemtools/checks/graders.py index 6cd1085b..087205ea 100644 --- a/problemtools/checks/graders.py +++ b/problemtools/checks/graders.py @@ -2,12 +2,14 @@ from __future__ import annotations +from pathlib import Path + from ..diagnostics import Diagnostics from ..metadata import Metadata from ..model import Graders -def check_graders(graders: Graders, metadata: Metadata, diag: Diagnostics) -> None: +def check_graders(graders: Graders, metadata: Metadata, work_dir: str, diag: Diagnostics) -> None: """Run all checks on a problem's custom graders.""" if len(graders.graders) > 1: diag.fatal('There is more than one custom grader') @@ -19,6 +21,6 @@ def check_graders(graders: Graders, metadata: Metadata, diag: Diagnostics) -> No if metadata.is_pass_fail(): diag.fatal('There is a grader but the problem is pass-fail') - success, msg = grader.compile() - if not success: - diag.fatal(f'Compile error for {grader}', msg) + result = grader.compile(Path(work_dir)) + if not result.success: + diag.fatal(f'Compile error for {grader}', result.errmsg) diff --git a/problemtools/checks/submissions.py b/problemtools/checks/submissions.py index 120c131a..7971d6d3 100644 --- a/problemtools/checks/submissions.py +++ b/problemtools/checks/submissions.py @@ -79,9 +79,9 @@ def check_submissions( ) continue - success, msg = sub.program.compile() - if not success: - diag.error(f'Compile error for {label} submission {sub.program}', additional_info=msg) + result = sub.program.compile(Path(tmpdir)) + if not result.success: + diag.error(f'Compile error for {label} submission {sub.program}', additional_info=result.errmsg) continue if has_testcases: diff --git a/problemtools/checks/validators.py b/problemtools/checks/validators.py index 9b645424..c862d4f9 100644 --- a/problemtools/checks/validators.py +++ b/problemtools/checks/validators.py @@ -104,9 +104,9 @@ def check_input_validators(validators: InputValidators, testdata: TestDataGroup, for val in validators.validators: try: - success, msg = val.compile() - if not success: - diag.error(f'Compile error for {val}', msg) + result = val.compile(Path(work_dir)) + if not result.success: + diag.error(f'Compile error for {val}', result.errmsg) except ProgramError as e: diag.error(str(e)) @@ -132,7 +132,7 @@ def collect_flags(group: TestDataGroup, flags: set[str]) -> None: for flags_str in all_flags: flags = flags_str.split() for val in validators.validators: - status, _ = val.run(file_name, args=flags, work_dir=work_dir) + status, _ = val.run(file_name, args=flags, work_dir=Path(work_dir)) if os.WEXITSTATUS(status) != 42: break else: @@ -154,7 +154,7 @@ def modified_input_validates(applicable: Callable[[str], bool], modifier: Callab for flags_str in all_flags: flags = flags_str.split() for val in validators.validators: - status, _ = val.run(file_name, args=flags, work_dir=work_dir) + status, _ = val.run(file_name, args=flags, work_dir=Path(work_dir)) if os.WEXITSTATUS(status) != 42: # expected behavior; validator rejects modified input return False @@ -179,12 +179,11 @@ def check_testcase_input(validators: InputValidators, testcase: TestCase, work_d for val in validators.validators: # A validator that failed to compile was already reported by check_input_validators; skip it. - success, _ = val.compile() - if not success: + if not val.compile(Path(work_dir)).success: continue with tempfile.NamedTemporaryFile() as outfile, tempfile.NamedTemporaryFile() as errfile: - status, _ = val.run(str(testcase.infile), outfile.name, errfile.name, args=flags, work_dir=work_dir) + status, _ = val.run(str(testcase.infile), outfile.name, errfile.name, args=flags, work_dir=Path(work_dir)) if not os.WIFEXITED(status): emsg = f'Input format validator {val} crashed on input {testcase.infile}' elif os.WEXITSTATUS(status) != 42: @@ -234,9 +233,9 @@ def check_output_validators( diag.fatal('problem.yaml specifies custom validator but no validator programs found') try: - success, msg = selected.compile() - if not success: - diag.fatal(f'Compile error for output validator {selected}', msg) + result = selected.compile(Path(work_dir)) + if not result.success: + diag.fatal(f'Compile error for output validator {selected}', result.errmsg) except ProgramError as e: diag.fatal(f'Compile error for output validator {selected}', str(e)) diff --git a/problemtools/judge/execute.py b/problemtools/judge/execute.py index 23a326ae..0e22caf3 100644 --- a/problemtools/judge/execute.py +++ b/problemtools/judge/execute.py @@ -69,25 +69,27 @@ def _run_normal( metadata: Metadata, timelim: float, execution_dir: Path, + base_dir: Path, diag: Diagnostics, ) -> SubmissionResult: """Run a submission once (non-interactive)""" outfile = execution_dir / 'submission_stdout' errfile = execution_dir / 'submission_stderr' + sub_path = sub.compile(base_dir).path status, runtime = sub.run( infile=str(infile), outfile=str(outfile), errfile=str(errfile), timelim=math.ceil(timelim) + 1, memlim=metadata.limits.memory, - work_dir=sub.path, + work_dir=sub_path, ) if _is_TLE(status) or runtime > timelim: result = SubmissionResult('TLE') elif _is_RTE(status): result = SubmissionResult('RTE', reason=_rte_reason(status), additional_info=_read_safe(errfile)) else: - result = _validate_output(testcase, outfile, output_validator, metadata, execution_dir, diag, infile=infile) + result = _validate_output(testcase, outfile, output_validator, metadata, execution_dir, base_dir, diag, infile=infile) result.runtime = runtime return result @@ -100,6 +102,7 @@ def _run_interactive( metadata: Metadata, timelim: float, execution_dir: Path, + base_dir: Path, diag: Diagnostics, ) -> SubmissionResult: """Run a submission once (interactive)""" @@ -108,9 +111,11 @@ def _run_interactive( diag.error('Could not locate interactive runner') return SubmissionResult('JE', reason='Could not locate interactive runner') - if not output_validator.compile()[0]: + if not output_validator.compile(base_dir).success: return SubmissionResult('JE', reason=f'output validator {output_validator} failed to compile') + sub_path = sub.compile(base_dir).path + feedback_dir = execution_dir / 'feedback' interactive_out = execution_dir / 'interactive_output' @@ -124,7 +129,7 @@ def _run_interactive( + [';'] + sub.get_runcmd(memlim=metadata.limits.memory) ), - work_dir=sub.path, + work_dir=sub_path, ) if _is_RTE(i_status): @@ -172,12 +177,13 @@ def _run_pass( metadata: Metadata, timelim: float, execution_dir: Path, + base_dir: Path, diag: Diagnostics, ) -> SubmissionResult: """Run a submission once (the common case, or one pass for a multi-pass problem)""" if metadata.is_interactive(): - return _run_interactive(infile, testcase, sub, output_validator, metadata, timelim, execution_dir, diag) - return _run_normal(infile, testcase, sub, output_validator, metadata, timelim, execution_dir, diag) + return _run_interactive(infile, testcase, sub, output_validator, metadata, timelim, execution_dir, base_dir, diag) + return _run_normal(infile, testcase, sub, output_validator, metadata, timelim, execution_dir, base_dir, diag) def _run_multipass( @@ -187,13 +193,14 @@ def _run_multipass( metadata: Metadata, timelim: float, execution_dir: Path, + base_dir: Path, diag: Diagnostics, ) -> SubmissionResult: infile = testcase.infile slowest = 0.0 feedback_dir = execution_dir / 'feedback' for _ in range(metadata.limits.validation_passes): - result = _run_pass(infile, testcase, sub, output_validator, metadata, timelim, execution_dir, diag) + result = _run_pass(infile, testcase, sub, output_validator, metadata, timelim, execution_dir, base_dir, diag) slowest = max(slowest, result.runtime) result.runtime = slowest nextpass = feedback_dir / 'nextpass.in' @@ -222,9 +229,9 @@ def execute_testcase( execution_dir = Path(exec_dir) (execution_dir / 'feedback').mkdir() if metadata.is_multi_pass(): - result = _run_multipass(testcase, sub, output_validator, metadata, timelim, execution_dir, diag) + result = _run_multipass(testcase, sub, output_validator, metadata, timelim, execution_dir, base_dir, diag) else: - result = _run_pass(testcase.infile, testcase, sub, output_validator, metadata, timelim, execution_dir, diag) + result = _run_pass(testcase.infile, testcase, sub, output_validator, metadata, timelim, execution_dir, base_dir, diag) result.test_node = testcase result.runtime_testcase = testcase return result diff --git a/problemtools/judge/grade.py b/problemtools/judge/grade.py index ac8795b6..5b18a693 100644 --- a/problemtools/judge/grade.py +++ b/problemtools/judge/grade.py @@ -29,7 +29,7 @@ def grade_group( if not sub_results: return ('AC', 0.0) - if not grader.compile()[0]: + if not grader.compile(base_dir).success: diag.error(f'Failed to compile grader {grader}') return ('JE', None) diff --git a/problemtools/judge/validate.py b/problemtools/judge/validate.py index 12787e75..c1520466 100644 --- a/problemtools/judge/validate.py +++ b/problemtools/judge/validate.py @@ -78,6 +78,7 @@ def _validate_output( output_validator: Program, metadata: Metadata, execution_dir: Path, + base_dir: Path, diag: Diagnostics, infile: Path | None = None, ) -> SubmissionResult: @@ -93,7 +94,7 @@ def _validate_output( 'OLE', reason=f'output ({output_size:.1f} MiB) exceeds output limit ({metadata.limits.output} MiB)' ) - if not output_validator.compile()[0]: + if not output_validator.compile(base_dir).success: return SubmissionResult('JE', reason=f'output validator {output_validator} failed to compile') val_stdout = execution_dir / 'val_stdout' val_stderr = execution_dir / 'val_stderr' @@ -125,4 +126,4 @@ def validate_output( with tempfile.TemporaryDirectory(dir=base_dir) as exec_dir: execution_dir = Path(exec_dir) (execution_dir / 'feedback').mkdir() - return _validate_output(testcase, submission_output, output_validator, metadata, execution_dir, diag) + return _validate_output(testcase, submission_output, output_validator, metadata, execution_dir, base_dir, diag) diff --git a/problemtools/model/graders.py b/problemtools/model/graders.py index 8e0b6370..d818afbc 100644 --- a/problemtools/model/graders.py +++ b/problemtools/model/graders.py @@ -24,6 +24,6 @@ def grader(self) -> Program | None: return self.graders[0] if len(self.graders) == 1 else None -def load_graders(probdir: Path, language_config: Languages, work_dir: str) -> Graders: - graders = find_programs(str(probdir / 'graders'), language_config=language_config, work_dir=work_dir) +def load_graders(probdir: Path, language_config: Languages) -> Graders: + graders = find_programs(str(probdir / 'graders'), language_config=language_config) return Graders(graders=graders) diff --git a/problemtools/model/submissions.py b/problemtools/model/submissions.py index 690f4604..6d82e7af 100644 --- a/problemtools/model/submissions.py +++ b/problemtools/model/submissions.py @@ -73,7 +73,7 @@ class Submissions: policy: LegacyPolicy = field(default_factory=LegacyPolicy) -def load_submissions(probdir: Path, language_config: Languages, work_dir: str, includes: Includes) -> Submissions: +def load_submissions(probdir: Path, language_config: Languages, includes: Includes) -> Submissions: subs_root = probdir / 'submissions' if not subs_root.is_dir(): return Submissions() @@ -81,6 +81,6 @@ def load_submissions(probdir: Path, language_config: Languages, work_dir: str, i submissions = [] for entry in sorted(subs_root.iterdir()): if entry.is_dir(): - for program in find_programs(str(entry), language_config=language_config, work_dir=work_dir, includes=includes): + for program in find_programs(str(entry), language_config=language_config, includes=includes): submissions.append(Submission(program=program, path=Path(entry.name) / program.name)) return Submissions(submissions=submissions) diff --git a/problemtools/model/validators.py b/problemtools/model/validators.py index 460b7019..978cc2dc 100644 --- a/problemtools/model/validators.py +++ b/problemtools/model/validators.py @@ -20,13 +20,11 @@ class InputValidators: uses_old_path: bool = False -def load_input_validators(probdir: Path, language_config: Languages, work_dir: str) -> InputValidators: +def load_input_validators(probdir: Path, language_config: Languages) -> InputValidators: old_path = probdir / 'input_format_validators' uses_old_path = old_path.is_dir() validators_path = old_path if uses_old_path else probdir / 'input_validators' - validators = find_programs( - str(validators_path), language_config=language_config, allow_validation_script=True, work_dir=work_dir - ) + validators = find_programs(str(validators_path), language_config=language_config, allow_validation_script=True) return InputValidators(validators=validators, uses_old_path=uses_old_path) @@ -50,8 +48,6 @@ def select(self, format: FormatVersion, metadata: Metadata) -> Program | None: return self.validators[0] -def load_output_validators(probdir: Path, format: FormatVersion, language_config: Languages, work_dir: str) -> OutputValidators: - validators = find_programs( - str(probdir / format.output_validator_directory), language_config=language_config, work_dir=work_dir - ) +def load_output_validators(probdir: Path, format: FormatVersion, language_config: Languages) -> OutputValidators: + validators = find_programs(str(probdir / format.output_validator_directory), language_config=language_config) return OutputValidators(validators=validators) diff --git a/problemtools/run/__init__.py b/problemtools/run/__init__.py index 4c066c54..6391c546 100644 --- a/problemtools/run/__init__.py +++ b/problemtools/run/__init__.py @@ -23,7 +23,6 @@ def find_programs( path: str, language_config: Languages, - work_dir: str, includes: 'Includes | None' = None, allow_validation_script: bool = False, ) -> list[Program]: @@ -36,8 +35,6 @@ def find_programs( programming language of source code and providing info on how to compile and run the source code. - work_dir: temp directory in which to compile programs etc - includes: include files to add to programs found, resolved per-program based on its detected language (see Includes.get_includes_for_language). @@ -57,7 +54,6 @@ def find_programs( run = get_program( fullpath, language_config=language_config, - work_dir=work_dir, includes=includes, allow_validation_script=allow_validation_script, ) @@ -69,7 +65,6 @@ def find_programs( def get_program( path: str, language_config: Languages, - work_dir: str, includes: 'Includes | None' = None, allow_validation_script: bool = False, ) -> Program | None: @@ -84,8 +79,6 @@ def get_program( programming language of source code and providing info on how to compile and run the source code. - work_dir: temp directory in which to compile programs etc - includes: include files to add to the program, resolved per the program's detected language (see Includes.get_includes_for_language). Defaults to no includes. @@ -115,10 +108,10 @@ def get_program( else: build = os.path.join(path, 'build') if os.path.isfile(build) and os.access(build, os.X_OK): - return BuildRun(path, work_dir) + return BuildRun(path) files = rutil.list_files_recursive(path) lang = language_config.detect_language(files) if lang is not None: - return SourceCode(path, lang, work_dir=work_dir, includes=includes.get_includes_for_language(lang.lang_id)) + return SourceCode(path, lang, includes=includes.get_includes_for_language(lang.lang_id)) return None diff --git a/problemtools/run/buildrun.py b/problemtools/run/buildrun.py index b2765a66..9e8fff72 100644 --- a/problemtools/run/buildrun.py +++ b/problemtools/run/buildrun.py @@ -5,21 +5,21 @@ import os import subprocess import tempfile +from pathlib import Path from . import rutil from .errors import ProgramError -from .program import Program +from .program import CompileResult, Program class BuildRun(Program): """Class for build/run-script program.""" - def __init__(self, path: str, work_dir: str) -> None: + def __init__(self, path: str) -> None: """Instantiate BuildRun object. Args: path: directory containing the build script. - work_dir: name of temp directory in which to run the scripts. """ if not os.path.isdir(path): raise ProgramError(f'{path} is not a directory') @@ -27,36 +27,43 @@ def __init__(self, path: str, work_dir: str) -> None: if path[-1] == '/': path = path[:-1] name = os.path.basename(path) - run_path = os.path.join(work_dir, name) + super().__init__(name=name) + self._source_path = path + + def do_compile(self, work_dir: Path) -> CompileResult: + """Set up the compile work-space (copying the build script and friends into + work_dir) and run the build script.""" + name = self.name + run_path = work_dir / name if os.path.exists(run_path): - run_path = tempfile.mkdtemp(prefix=f'{name}-', dir=work_dir) + run_path = Path(tempfile.mkdtemp(prefix=f'{name}-', dir=work_dir)) else: os.makedirs(run_path) - super().__init__(path=run_path, name=name) + self._path = run_path - rutil.add_files(path, self.path) + rutil.add_files(self._source_path, self.path) build = os.path.join(self.path, 'build') if not os.path.isfile(build): - raise ProgramError(f'{path} does not have a build script') + raise ProgramError(f'{self._source_path} does not have a build script') if not os.access(build, os.X_OK): - raise ProgramError(f'{path}/build is not executable') + raise ProgramError(f'{self._source_path}/build is not executable') - def do_compile(self) -> tuple[bool, str | None]: - """Run the build script.""" try: subprocess.check_output(['./build'], stderr=subprocess.STDOUT, cwd=self.path) except subprocess.CalledProcessError as err: - return (False, err.output.decode('utf8', 'replace')) + return CompileResult(False, err.output.decode('utf8', 'replace'), self.path) run = os.path.join(self.path, 'run') if not os.path.isfile(run) or not os.access(run, os.X_OK): - return (False, 'build script did not produce an executable called "run"') - return (True, None) + return CompileResult(False, 'build script did not produce an executable called "run"', self.path) + return CompileResult(True, None, self.path) def get_runcmd(self, cwd: str | None = None, memlim: int = 1024) -> list[str]: """Run command for the program. + Must not be called until compile() has been called. + Args: cwd: if not None, the run command is provided relative to cwd (otherwise absolute paths are given). diff --git a/problemtools/run/checktestdata.py b/problemtools/run/checktestdata.py index abe06de5..6dcc3383 100644 --- a/problemtools/run/checktestdata.py +++ b/problemtools/run/checktestdata.py @@ -4,8 +4,10 @@ import os import sys +from pathlib import Path from .executable import Executable +from .program import CompileResult class Checktestdata(Executable): @@ -19,15 +21,11 @@ def __init__(self, path: str) -> None: """ super().__init__(sys.executable, args=['-m', 'checktestdata', path], name=os.path.basename(path)) - 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 - """ + def do_compile(self, work_dir: Path) -> CompileResult: + """Syntax-check the Checktestdata script""" (status, _) = super().run() - return ((os.WIFEXITED(status) and os.WEXITSTATUS(status) in [0, 1]), None) + success = os.WIFEXITED(status) and os.WEXITSTATUS(status) in [0, 1] + return CompileResult(success, None, self.path) def run( self, @@ -37,7 +35,7 @@ def run( args: list[str] | None = None, timelim: int = 1000, memlim: int = 1024, - work_dir: str | None = None, + work_dir: Path | None = None, ) -> tuple[int, float]: """Run the Checktestdata script to validate an input file. diff --git a/problemtools/run/executable.py b/problemtools/run/executable.py index 90cb7f4e..eda047a8 100644 --- a/problemtools/run/executable.py +++ b/problemtools/run/executable.py @@ -3,6 +3,7 @@ """ import os +from pathlib import Path from .errors import ProgramError from .program import Program @@ -23,12 +24,12 @@ def __init__(self, path: str, args: list[str] | None = None, name: str | None = """ if not os.path.isfile(path) or not os.access(path, os.X_OK): raise ProgramError(f'{path} is not an executable program') - super().__init__(path=path, name=name if name is not None else os.path.basename(path)) + super().__init__(name=name if name is not None else os.path.basename(path), path=Path(path)) self.args = args if args is not None else [] def get_runcmd(self, cwd: str | None = None, memlim: int = 1024) -> list[str]: """Command to run the program.""" - return [self.path] + self.args + return [str(self.path)] + self.args def should_skip_memory_rlimit(self) -> bool: """Ugly hack (see program.py for details).""" diff --git a/problemtools/run/program.py b/problemtools/run/program.py index 2cc5105c..4ac9a1cd 100644 --- a/problemtools/run/program.py +++ b/problemtools/run/program.py @@ -1,11 +1,13 @@ """Abstract base class for programs.""" +import dataclasses import logging import os import resource import signal import threading from abc import ABC, abstractmethod +from pathlib import Path from . import limit from .errors import ProgramError @@ -13,20 +15,44 @@ log = logging.getLogger(__name__) +@dataclasses.dataclass(frozen=True) +class CompileResult: + """Result of compiling a Program. + + `path` is always set (even on a failed compile): it's the resolved path to + the program, established as a side effect of compiling. It's the only + reliable way to learn a program's path -- `Program.path` raises if read + before compile() has been called.""" + + success: bool + errmsg: str | None + path: Path + + class Program(ABC): """Abstract base class for programs.""" - def __init__(self, path: str, name: str) -> None: + def __init__(self, name: str, path: Path | None = None) -> None: """Instantiate program object. Args: - path: full path to the program (possibly in a temporary directory). name: human-readable name of the program. + path: full path to the program, if already known (possibly in a + temporary directory). Subclasses for which the path isn't known + until compile time (e.g. source code that gets copied into a + work directory) should leave this unset and assign `self._path` + as part of `do_compile()`. """ - self.path = path + self._path = path self.name = name self._compile_lock = threading.Lock() - self._compile_result: tuple[bool, str | None] | None = None + self._compile_result: CompileResult | None = None + + @property + def path(self) -> Path: + if self._path is None: + raise ProgramError(f'{self} has not been compiled yet') + return self._path def __str__(self) -> str: return self.name @@ -43,7 +69,7 @@ def run( args: list[str] | None = None, timelim: int = 1000, memlim: int = 1024, - work_dir: str | None = None, + work_dir: Path | None = None, ) -> tuple[int, float]: """Run the program. @@ -70,16 +96,22 @@ def run( return status, runtime - def compile(self) -> tuple[bool, str | None]: + def compile(self, work_dir: Path) -> CompileResult: + """Compile the program, if needed, and return the result. + + Only the first call actually compiles; later calls (even with a different + work_dir) return the cached result. work_dir is only used by subclasses that + need a place to set up a compile workspace (i.e. source code); others ignore it. + """ with self._compile_lock: if self._compile_result is None: - self._compile_result = self.do_compile() + self._compile_result = self.do_compile(work_dir) return self._compile_result - def do_compile(self) -> tuple[bool, str | None]: + def do_compile(self, work_dir: Path) -> CompileResult: """Actually compile the program, if needed. Subclasses should override this method. Do not call this manually -- use compile() instead.""" - return (True, None) + return CompileResult(True, None, self.path) def code_size(self) -> int: """Subclasses should override this method with the total size of the @@ -109,7 +141,7 @@ def __run_wait( errfile: str, timelim: int, memlim: int, - work_dir: str | None, + work_dir: Path | None, ) -> tuple[int, float]: log.debug('run "%s < %s > %s 2> %s"', ' '.join(argv), infile, outfile, errfile) pid = os.fork() diff --git a/problemtools/run/rutil.py b/problemtools/run/rutil.py index 62a48a1d..0a1631fe 100644 --- a/problemtools/run/rutil.py +++ b/problemtools/run/rutil.py @@ -3,11 +3,12 @@ import errno import os import shutil +from pathlib import Path from .errors import ProgramError -def add_files(src: str, dstdir: str) -> None: +def add_files(src: str | Path, dstdir: str | Path) -> None: """Copy src to dstdir. Args: @@ -38,7 +39,7 @@ def add_files(src: str, dstdir: str) -> None: raise -def list_files_recursive(root: str) -> list[str]: +def list_files_recursive(root: str | Path) -> list[str]: """List files in a directory with subdirectories. Returns: diff --git a/problemtools/run/source.py b/problemtools/run/source.py index d9f112c9..284bbbce 100644 --- a/problemtools/run/source.py +++ b/problemtools/run/source.py @@ -6,12 +6,13 @@ import os import subprocess import tempfile +from pathlib import Path from typing import TYPE_CHECKING from ..languages import CommandSubstitution, Language from . import rutil from .errors import ProgramError -from .program import Program +from .program import CompileResult, Program if TYPE_CHECKING: from ..model import LanguageIncludes @@ -22,7 +23,7 @@ class SourceCode(Program): """Class representing a program provided by source code.""" - def __init__(self, path: str, language: Language, work_dir: str, includes: 'LanguageIncludes') -> None: + def __init__(self, path: str, language: Language, includes: 'LanguageIncludes') -> None: """Instantiate SourceCode object Args: @@ -34,8 +35,6 @@ def __init__(self, path: str, language: Language, work_dir: str, includes: 'Lang language: language definition for the programming language of the code. - work_dir: temp directory in which to compile programs etc - includes: include files to add alongside the source file(s), already resolved for this program's language (see Includes.get_includes_for_language). If it specifies @@ -45,20 +44,34 @@ def __init__(self, path: str, language: Language, work_dir: str, includes: 'Lang if path[-1] == '/': path = path[:-1] name = os.path.basename(path) + super().__init__(name=name) + self.language = language + self._source_path = path + self._includes = includes + if os.path.isfile(path): + self._code_size = os.path.getsize(path) + else: + self._code_size = sum(os.path.getsize(f) for f in rutil.list_files_recursive(path)) + + def code_size(self) -> int: + return self._code_size + + def do_compile(self, work_dir: Path) -> CompileResult: + """Set up the compile work-space (copying source and includes into work_dir) and + compile the source code.""" + name = self.name # Set up work-space - run_path = os.path.join(work_dir, name) + run_path = work_dir / name if os.path.exists(run_path): - run_path = tempfile.mkdtemp(prefix=f'{name}-', dir=work_dir) + run_path = Path(tempfile.mkdtemp(prefix=f'{name}-', dir=work_dir)) else: os.makedirs(run_path) - super().__init__(path=run_path, name=name) - self.language = language + self._path = run_path # Copy all files - rutil.add_files(path, self.path) - self._code_size = sum(os.path.getsize(f) for f in rutil.list_files_recursive(self.path)) - for include_file in includes.files: + rutil.add_files(self._source_path, self.path) + for include_file in self._includes.files: dest = os.path.join(self.path, include_file.path) os.makedirs(os.path.dirname(dest), exist_ok=True) with open(dest, 'wb') as f: @@ -68,8 +81,8 @@ def __init__(self, path: str, language: Language, work_dir: str, includes: 'Lang if len(self.src) == 0: raise ProgramError(f'No source files found for language {self.language.lang_id} in {self.name}') - if includes.mainfile is not None: - self.mainfile = os.path.join(self.path, includes.mainfile) + if self._includes.mainfile is not None: + self.mainfile = os.path.join(self.path, self._includes.mainfile) else: candidates = self.language.mainfile_candidates(self.src) self.mainfile = str(candidates[0]) if candidates else self.src[0] @@ -79,42 +92,33 @@ def __init__(self, path: str, language: Language, work_dir: str, includes: 'Lang self.binary = os.path.join(self.path, 'run') - def code_size(self) -> int: - return self._code_size - - def do_compile(self) -> tuple[bool, str | None]: - """Compile the source code. - - Returns tuple: - (True, None) if compilation succeeded - (False, errmsg) otherwise - """ not_installed = self.language.check_installed() if not_installed is not None: - return (False, not_installed) + return CompileResult(False, not_installed, self.path) command = self.language.get_compile_command(self.__get_substitution()) if command is None: - return (True, None) + return CompileResult(True, None, self.path) log.debug('compile command: %s', command) try: subprocess.check_output(command, stderr=subprocess.STDOUT) - return (True, None) + return CompileResult(True, None, self.path) except subprocess.CalledProcessError as err: - return (False, err.output.decode('utf8', 'replace')) + return CompileResult(False, err.output.decode('utf8', 'replace'), self.path) def get_runcmd(self, cwd: str | None = None, memlim: int = 1024) -> list[str]: """Run command for the program. + Must not be called until compile() has been called. + Args: cwd: if not None, the run command is provided relative to cwd (otherwise absolute paths are given). memlim: memory limit in MiB (only relevant for languages where memory limit is passed on command line) """ - self.compile() subs = self.__get_substitution(memlim) if cwd is not None: subs.path = os.path.relpath(subs.path, cwd) @@ -132,7 +136,7 @@ def __str__(self) -> str: def __get_substitution(self, memlim: int = 1024) -> CommandSubstitution: return CommandSubstitution( - path=self.path, + path=str(self.path), files=' '.join(self.src), memlim=memlim, mainfile=self.mainfile, diff --git a/problemtools/run/viva.py b/problemtools/run/viva.py index 82b93037..53036307 100644 --- a/problemtools/run/viva.py +++ b/problemtools/run/viva.py @@ -3,9 +3,11 @@ """ import os +from pathlib import Path from .errors import ProgramError from .executable import Executable +from .program import CompileResult from .tools import get_tool_path @@ -24,14 +26,11 @@ def __init__(self, path: str) -> None: raise ProgramError(f'Could not locate the VIVA program to run {path}') super().__init__(Viva._VIVA_PATH, args=[path], name=os.path.basename(path)) - 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 - """ + def do_compile(self, work_dir: Path) -> CompileResult: + """Syntax-check the VIVA script""" (status, _) = super().run() - return ((os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0), None) + success = os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 + return CompileResult(success, None, self.path) def run( self, @@ -41,7 +40,7 @@ def run( args: list[str] | None = None, timelim: int = 1000, memlim: int = 1024, - work_dir: str | None = None, + work_dir: Path | None = None, ) -> tuple[int, float]: """Run the VIVA script to validate an input file. diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 3b4247b9..b3575fa3 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -191,16 +191,14 @@ class InputValidators(ProblemPart): PART_NAME = 'input_validator' def setup(self) -> None: - self.input_validators = model.load_input_validators( - Path(self.problem.probdir), self.problem.language_config, self.problem.tmpdir - ) + self.input_validators = model.load_input_validators(Path(self.problem.probdir), self.problem.language_config) def __str__(self) -> str: return 'input format validators' def start_background_work(self, context: Context) -> None: for val in self.input_validators.validators: - context.submit_background_work(lambda v: v.compile(), val) + context.submit_background_work(val.compile, Path(self.problem.tmpdir)) def check(self, context: Context) -> bool: if self._check_res is not None: @@ -221,7 +219,7 @@ class Graders(ProblemPart): PART_NAME = 'grader' def setup(self) -> None: - self.graders = model.load_graders(Path(self.problem.probdir), self.problem.language_config, self.problem.tmpdir) + self.graders = model.load_graders(Path(self.problem.probdir), self.problem.language_config) def __str__(self) -> str: return 'graders' @@ -232,7 +230,7 @@ def check(self, context: Context) -> bool: self._check_res = True errors_before = self.errors - checks.check_graders(self.graders, self.problem.metadata, self._diag) + checks.check_graders(self.graders, self.problem.metadata, self.problem.tmpdir, self._diag) if self.errors > errors_before: self._check_res = False @@ -246,7 +244,7 @@ class OutputValidators(ProblemPart): def setup(self) -> None: self.output_validators = model.load_output_validators( - Path(self.problem.probdir), self.problem.format, self.problem.language_config, self.problem.tmpdir + Path(self.problem.probdir), self.problem.format, self.problem.language_config ) self._has_precompiled = False @@ -262,7 +260,7 @@ def __str__(self) -> str: def start_background_work(self, context: Context) -> None: if not self._has_precompiled: - context.submit_background_work(lambda v: v.compile(), self.output_validator) + context.submit_background_work(self.output_validator.compile, Path(self.problem.tmpdir)) self._has_precompiled = True def check(self, context: Context) -> bool: @@ -317,7 +315,7 @@ class Submissions(ProblemPart): def setup(self) -> None: self.submissions = model.load_submissions( - Path(self.problem.probdir), self.problem.language_config, self.problem.tmpdir, self.problem.includes.includes + Path(self.problem.probdir), self.problem.language_config, self.problem.includes.includes ) def __str__(self) -> str: @@ -330,7 +328,7 @@ def start_background_work(self, context: Context) -> None: policy = self.submissions.policy for sub in self.submissions.submissions: if policy.matches(sub) and context.submission_filter.search(str(sub.path)): - context.submit_background_work(lambda s: s.compile(), sub.program) + context.submit_background_work(sub.program.compile, Path(self.problem.tmpdir)) def check(self, context: Context) -> bool: if self._check_res is not None: