diff --git a/problemtools/judge/execute.py b/problemtools/judge/execute.py index 0e22caf3..fe366f0e 100644 --- a/problemtools/judge/execute.py +++ b/problemtools/judge/execute.py @@ -196,7 +196,7 @@ def _run_multipass( base_dir: Path, diag: Diagnostics, ) -> SubmissionResult: - infile = testcase.infile + infile: Path = testcase.infile slowest = 0.0 feedback_dir = execution_dir / 'feedback' for _ in range(metadata.limits.validation_passes): diff --git a/problemtools/model/attachments.py b/problemtools/model/attachments.py index d2d63ae2..ed0efea2 100644 --- a/problemtools/model/attachments.py +++ b/problemtools/model/attachments.py @@ -3,15 +3,17 @@ from dataclasses import dataclass, field from pathlib import Path +from .paths import AbsolutePath, abspath, resolve + @dataclass(frozen=True) class Attachments: """A problem's attachments: files found under the attachments/ directory.""" - paths: list[Path] = field(default_factory=list) + paths: list[AbsolutePath] = field(default_factory=list) def load_attachments(probdir: Path) -> Attachments: - attachments_dir = probdir / 'attachments' - paths = list(attachments_dir.iterdir()) if attachments_dir.is_dir() else [] + attachments_dir = resolve(probdir) / 'attachments' + paths = [abspath(p) for p in attachments_dir.iterdir()] if attachments_dir.is_dir() else [] return Attachments(paths=paths) diff --git a/problemtools/model/graders.py b/problemtools/model/graders.py index d818afbc..2480511c 100644 --- a/problemtools/model/graders.py +++ b/problemtools/model/graders.py @@ -4,7 +4,8 @@ from pathlib import Path from ..languages import Languages -from ..run import Program, find_programs, get_tool +from ..run import BuildRun, SourceCode, as_source_or_buildrun, find_programs, get_tool +from .paths import resolve DEFAULT_GRADER = get_tool('default_grader') @@ -13,10 +14,10 @@ class Graders: """A problem's graders: custom grader programs found on disk, if any.""" - graders: list[Program] = field(default_factory=list) + graders: list[SourceCode | BuildRun] = field(default_factory=list) @property - def grader(self) -> Program | None: + def grader(self) -> SourceCode | BuildRun | None: """The custom grader, if there's exactly one; None if there are zero (default grading is used). Does not validate that there's at most one grader; callers that care about the invalid @@ -25,5 +26,6 @@ def grader(self) -> Program | None: def load_graders(probdir: Path, language_config: Languages) -> Graders: - graders = find_programs(str(probdir / 'graders'), language_config=language_config) + probdir = resolve(probdir) + graders = as_source_or_buildrun(find_programs(str(probdir / 'graders'), language_config=language_config)) return Graders(graders=graders) diff --git a/problemtools/model/includes.py b/problemtools/model/includes.py index 3dff54ed..b1be33b5 100644 --- a/problemtools/model/includes.py +++ b/problemtools/model/includes.py @@ -2,6 +2,7 @@ from pathlib import Path from ..languages import Language, Languages +from .paths import RelativePath, relpath, resolve #: Pseudo-language whose include files are added for every language. DEFAULT_LANGUAGE = 'default' @@ -15,7 +16,7 @@ class IncludeFile: include/cpp/Vector/Vector.h, path is Vector/Vector.h. """ - path: Path + path: RelativePath data: bytes @@ -45,7 +46,7 @@ def get_includes_for_language(self, language: str) -> LanguageIncludes: def load_includes(probdir: Path, language_config: Languages) -> Includes: - include_dir = probdir / 'include' + include_dir = resolve(probdir) / 'include' includes = Includes() if not include_dir.is_dir(): return includes @@ -59,7 +60,7 @@ def load_includes(probdir: Path, language_config: Languages) -> Includes: def _load_language_includes(lang_dir: Path, language: Language | None) -> LanguageIncludes: paths = sorted(p for p in lang_dir.rglob('*') if p.is_file()) - files = [IncludeFile(path=path.relative_to(lang_dir), data=path.read_bytes()) for path in paths] + files = [IncludeFile(path=relpath(path.relative_to(lang_dir)), data=path.read_bytes()) for path in paths] mainfile = None if language is not None: diff --git a/problemtools/model/paths.py b/problemtools/model/paths.py new file mode 100644 index 00000000..9daf9261 --- /dev/null +++ b/problemtools/model/paths.py @@ -0,0 +1,29 @@ +"""Type-level distinction between absolute and relative paths. + +`AbsolutePath`/`RelativePath` are used to communicate which paths +in our models are absolute, and which are relative, with type +system enforcement. For relative paths, the model should document +what they are relative to.""" + +from pathlib import Path +from typing import NewType + +AbsolutePath = NewType('AbsolutePath', Path) +RelativePath = NewType('RelativePath', Path) + + +def abspath(path: Path) -> AbsolutePath: + """Assert that `path` is already absolute.""" + assert path.is_absolute(), f'expected an absolute path, got {path}' + return AbsolutePath(path) + + +def relpath(path: Path) -> RelativePath: + """Assert that `path` is relative.""" + assert not path.is_absolute(), f'expected a relative path, got {path}' + return RelativePath(path) + + +def resolve(path: Path) -> AbsolutePath: + """Canonicalize `path` to an `AbsolutePath`.""" + return AbsolutePath(path.resolve()) diff --git a/problemtools/model/problem.py b/problemtools/model/problem.py index 4933390e..3f9b31a4 100644 --- a/problemtools/model/problem.py +++ b/problemtools/model/problem.py @@ -12,6 +12,7 @@ from .attachments import Attachments, load_attachments from .graders import Graders, load_graders from .includes import Includes, load_includes +from .paths import AbsolutePath, resolve from .statements import Statements, load_statements from .submissions import Submissions, load_submissions from .testdata import TestDataGroup, load_testdata @@ -27,7 +28,7 @@ class Problem: checks that should be run on top of a loaded Problem. """ - probdir: Path + probdir: AbsolutePath language_config: Languages metadata: Metadata statements: Statements @@ -54,6 +55,7 @@ def load_problem(probdir: Path, diag: Diagnostics) -> Problem: On failure, reports errors via `diag` and raises VerifyError. """ try: + probdir = resolve(probdir) language_config = load_language_config(probdir.parent) problem_metadata = load_metadata(probdir) format_version = problem_metadata.problem_format_version diff --git a/problemtools/model/statements.py b/problemtools/model/statements.py index 18d0f9df..97a57328 100644 --- a/problemtools/model/statements.py +++ b/problemtools/model/statements.py @@ -5,6 +5,7 @@ from .. import statement_util from ..formatversion import FormatVersion +from .paths import AbsolutePath, abspath, resolve @dataclass(frozen=True) @@ -14,8 +15,10 @@ class Statements: Well-formed packages have exactly one statement per language; this keeps every file found (rather than just the first) so checks can report duplicates.""" - by_language: dict[str, list[Path]] = field(default_factory=dict) + by_language: dict[str, list[AbsolutePath]] = field(default_factory=dict) def load_statements(probdir: Path, format_version: FormatVersion) -> Statements: - return Statements(by_language=statement_util.find_statements(probdir, format_version)) + probdir = resolve(probdir) + found = statement_util.find_statements(probdir, format_version) + return Statements(by_language={lang: [abspath(p) for p in paths] for lang, paths in found.items()}) diff --git a/problemtools/model/submissions.py b/problemtools/model/submissions.py index 6d82e7af..b343b397 100644 --- a/problemtools/model/submissions.py +++ b/problemtools/model/submissions.py @@ -7,6 +7,7 @@ from ..run import Program, find_programs from . import Verdict from .includes import Includes +from .paths import RelativePath, relpath, resolve @dataclass(frozen=True) @@ -17,7 +18,7 @@ class Submission: submissions/accepted/hello.java, path is accepted/hello.java.""" program: Program - path: Path + path: RelativePath def __post_init__(self) -> None: if len(self.path.parts) != 2: @@ -74,7 +75,7 @@ class Submissions: def load_submissions(probdir: Path, language_config: Languages, includes: Includes) -> Submissions: - subs_root = probdir / 'submissions' + subs_root = resolve(probdir) / 'submissions' if not subs_root.is_dir(): return Submissions() @@ -82,5 +83,5 @@ def load_submissions(probdir: Path, language_config: Languages, includes: Includ for entry in sorted(subs_root.iterdir()): if entry.is_dir(): for program in find_programs(str(entry), language_config=language_config, includes=includes): - submissions.append(Submission(program=program, path=Path(entry.name) / program.name)) + submissions.append(Submission(program=program, path=relpath(Path(entry.name) / program.name))) return Submissions(submissions=submissions) diff --git a/problemtools/model/testdata.py b/problemtools/model/testdata.py index 5c1bb795..a1add264 100644 --- a/problemtools/model/testdata.py +++ b/problemtools/model/testdata.py @@ -10,6 +10,7 @@ from .. import config from ..metadata import Metadata +from .paths import AbsolutePath, RelativePath, abspath, relpath, resolve DEFAULT_CONFIG = config.load_config('testdata.yaml') SCORING_ONLY_KEYS = ['accept_score', 'reject_score', 'range'] @@ -22,9 +23,9 @@ class TestCase: `path` is relative to the data directory and has no extension, e.g. for data/secret/hello.in, path is secret/hello.""" - infile: Path - ansfile: Path - path: Path + infile: AbsolutePath + ansfile: AbsolutePath + path: RelativePath input_validator_flags: list[str] output_validator_flags: list[str] @@ -50,7 +51,7 @@ class TestDataGroup: package defaults.""" name: str - datadir: Path + datadir: AbsolutePath config: dict[str, Any] is_root: bool items: list[TestCase | TestDataGroup] = field(default_factory=list) @@ -87,10 +88,11 @@ def get_score_range(self) -> tuple[float, float]: def load_testdata(probdir: Path, metadata: Metadata) -> TestDataGroup: """Load the full testdata tree rooted at /data.""" - return _load_group(probdir, probdir / 'data', {}, metadata, is_root=True) + root = resolve(probdir) + return _load_group(root, abspath(root / 'data'), {}, metadata, is_root=True) -def _load_group_config(datadir: Path) -> dict[str, Any]: +def _load_group_config(datadir: AbsolutePath) -> dict[str, Any]: configfile = datadir / 'testdata.yaml' if not configfile.is_file(): return {} @@ -98,7 +100,9 @@ def _load_group_config(datadir: Path) -> dict[str, Any]: return loaded if loaded is not None else {} -def _load_group(probdir: Path, datadir: Path, parent_config: dict[str, Any], metadata: Metadata, is_root: bool) -> TestDataGroup: +def _load_group( + probdir: AbsolutePath, datadir: AbsolutePath, parent_config: dict[str, Any], metadata: Metadata, is_root: bool +) -> TestDataGroup: name = os.path.relpath(datadir, probdir).replace(os.sep, '.') merged_config = _load_group_config(datadir) @@ -130,18 +134,18 @@ def _load_group(probdir: Path, datadir: Path, parent_config: dict[str, Any], met if datadir.is_dir(): for entry in sorted(datadir.iterdir()): if entry.is_dir(): - items.append(_load_group(probdir, entry, merged_config, metadata, is_root=False)) + items.append(_load_group(probdir, abspath(entry), merged_config, metadata, is_root=False)) elif entry.suffix == '.ans' and entry.with_suffix('.in').is_file(): - items.append(_load_testcase(entry, probdir / 'data', merged_config, metadata)) + items.append(_load_testcase(abspath(entry), abspath(probdir / 'data'), merged_config, metadata)) return TestDataGroup(name=name, datadir=datadir, config=merged_config, is_root=is_root, items=items) -def _load_testcase(ansfile: Path, data_root: Path, group_config: dict[str, Any], metadata: Metadata) -> TestCase: - infile = ansfile.with_suffix('.in') +def _load_testcase(ansfile: AbsolutePath, data_root: AbsolutePath, group_config: dict[str, Any], metadata: Metadata) -> TestCase: + infile = abspath(ansfile.with_suffix('.in')) return TestCase( infile=infile, ansfile=ansfile, - path=infile.with_suffix('').relative_to(data_root), + path=relpath(infile.with_suffix('').relative_to(data_root)), input_validator_flags=group_config['input_validator_flags'].split(), output_validator_flags=(metadata.legacy_validator_flags.split() + group_config.get('output_validator_flags', '').split()), ) diff --git a/problemtools/model/validators.py b/problemtools/model/validators.py index 36034920..996e89b2 100644 --- a/problemtools/model/validators.py +++ b/problemtools/model/validators.py @@ -6,7 +6,8 @@ from ..formatversion import FormatVersion from ..languages import Languages from ..metadata import Metadata -from ..run import Program, find_programs, get_tool +from ..run import BuildRun, Program, SourceCode, as_source_or_buildrun, find_programs, get_tool +from .paths import resolve #: The problemtools-provided validator used when a problem doesn't ship a custom output validator. DEFAULT_VALIDATOR = get_tool('default_validator') @@ -20,6 +21,7 @@ class InputValidators: def load_input_validators(probdir: Path, language_config: Languages) -> InputValidators: + probdir = resolve(probdir) # input_format_validators is a deprecated name for input_validators. We just load # from both and let _check_root_directory_names warn about the deprecated directory validators = [ @@ -34,7 +36,7 @@ def load_input_validators(probdir: Path, language_config: Languages) -> InputVal class OutputValidators: """A problem's output validators: custom validator programs found on disk, if any.""" - validators: list[Program] = field(default_factory=list) + validators: list[SourceCode | BuildRun] = field(default_factory=list) def uses_default(self, format_version: FormatVersion, metadata: Metadata) -> bool: """Whether the default validator is used, rather than a custom one.""" @@ -51,5 +53,8 @@ def select(self, format_version: FormatVersion, metadata: Metadata) -> Program | def load_output_validators(probdir: Path, format_version: FormatVersion, language_config: Languages) -> OutputValidators: - validators = find_programs(str(probdir / format_version.output_validator_directory), language_config=language_config) + probdir = resolve(probdir) + validators = as_source_or_buildrun( + find_programs(str(probdir / format_version.output_validator_directory), language_config=language_config) + ) return OutputValidators(validators=validators) diff --git a/problemtools/run/__init__.py b/problemtools/run/__init__.py index 6391c546..0f95f69a 100644 --- a/problemtools/run/__init__.py +++ b/problemtools/run/__init__.py @@ -115,3 +115,17 @@ def get_program( if lang is not None: return SourceCode(path, lang, includes=includes.get_includes_for_language(lang.lang_id)) return None + + +def as_source_or_buildrun(programs: list[Program]) -> list[SourceCode | BuildRun]: + """Narrow a list of Programs to SourceCode | BuildRun. + + Use on the result of find_programs(allow_validation_script=False) (the default) if + you need to narrow the type.""" + result: list[SourceCode | BuildRun] = [] + for program in programs: + assert isinstance(program, SourceCode | BuildRun), ( + f'{program} is a {type(program).__name__}, expected SourceCode or BuildRun' + ) + result.append(program) + return result diff --git a/problemtools/statement_util.py b/problemtools/statement_util.py index 649e2987..b92c0bdf 100644 --- a/problemtools/statement_util.py +++ b/problemtools/statement_util.py @@ -21,7 +21,7 @@ def find_statements(problem_root: Path, version: FormatVersion) -> dict[str, list[Path]]: - """Returns a dict mapping language code to a list of paths to statements (relative to problem_root) + """Returns a dict mapping language code to a list of paths to statements, under problem_root. Note that in well-formed problem packages, there should only be a single statement for each language, but this function returns all found diff --git a/tests/test_score_range.py b/tests/test_score_range.py index 6127505a..a3e832e5 100644 --- a/tests/test_score_range.py +++ b/tests/test_score_range.py @@ -1,21 +1,22 @@ from pathlib import Path from problemtools.checks.testdata import _check_score_range +from problemtools.model.paths import abspath, relpath from problemtools.model.testdata import TestCase, TestDataGroup # Not test classes -- just named that way by the model. Tell pytest not to collect them. -TestCase.__test__ = False -TestDataGroup.__test__ = False +TestCase.__test__ = False # type: ignore[attr-defined] +TestDataGroup.__test__ = False # type: ignore[attr-defined] INF = float('inf') def make_testcase(name: str) -> TestCase: - path = Path(name) + path = Path('/' + name) return TestCase( - infile=path.with_suffix('.in'), - ansfile=path.with_suffix('.ans'), - path=path, + infile=abspath(path.with_suffix('.in')), + ansfile=abspath(path.with_suffix('.ans')), + path=relpath(Path(name)), input_validator_flags=[], output_validator_flags=[], ) @@ -34,7 +35,7 @@ def make_group( ) -> TestDataGroup: return TestDataGroup( name=name, - datadir=Path(name), + datadir=abspath(Path('/' + name)), config={ 'grading': grading, 'grader_flags': grader_flags,