Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion problemtools/judge/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 5 additions & 3 deletions problemtools/model/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
10 changes: 6 additions & 4 deletions problemtools/model/graders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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
Expand All @@ -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)
7 changes: 4 additions & 3 deletions problemtools/model/includes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -15,7 +16,7 @@ class IncludeFile:
include/cpp/Vector/Vector.h, path is Vector/Vector.h.
"""

path: Path
path: RelativePath
data: bytes


Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions problemtools/model/paths.py
Original file line number Diff line number Diff line change
@@ -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())
4 changes: 3 additions & 1 deletion problemtools/model/problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions problemtools/model/statements.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .. import statement_util
from ..formatversion import FormatVersion
from .paths import AbsolutePath, abspath, resolve


@dataclass(frozen=True)
Expand All @@ -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()})
7 changes: 4 additions & 3 deletions problemtools/model/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -74,13 +75,13 @@ 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()

submissions = []
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)
28 changes: 16 additions & 12 deletions problemtools/model/testdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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]

Expand All @@ -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)
Expand Down Expand Up @@ -87,18 +88,21 @@ def get_score_range(self) -> tuple[float, float]:

def load_testdata(probdir: Path, metadata: Metadata) -> TestDataGroup:
"""Load the full testdata tree rooted at <probdir>/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 {}
loaded = yaml.safe_load(configfile.read_text())
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)
Expand Down Expand Up @@ -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()),
)
11 changes: 8 additions & 3 deletions problemtools/model/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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 = [
Expand All @@ -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."""
Expand All @@ -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)
14 changes: 14 additions & 0 deletions problemtools/run/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion problemtools/statement_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions tests/test_score_range.py
Original file line number Diff line number Diff line change
@@ -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=[],
)
Expand All @@ -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,
Expand Down