diff --git a/problemtools/checks/problem_package.py b/problemtools/checks/problem_package.py index 28eb3346..e0c8cb7d 100644 --- a/problemtools/checks/problem_package.py +++ b/problemtools/checks/problem_package.py @@ -5,75 +5,68 @@ import difflib import os import re -import stat from pathlib import Path -from ..diagnostics import Diagnostics +from ..diagnostics import Diagnostics, pluralize from ..formatversion import FormatVersion +from ..model import BadReason, EntryKind, Files _NAME_REGEX = re.compile(r'^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,254}$') +_SYMLINK_PROBLEMS = { + BadReason.ABSOLUTE_SYMLINK: 'which is an absolute path. Symlinks must be relative.', + BadReason.OUTSIDE_PACKAGE: 'which is outside of problem package', + BadReason.BROKEN_SYMLINK: 'which does not exist', + BadReason.SYMLINK_LOOP: 'which creates a symlink loop', + BadReason.IGNORED_TARGET: "which is ignored (its name, or the name of a directory it is in, starts with '.')", +} -def check_problem_package(probdir: Path, format_version: FormatVersion, diag: Diagnostics) -> None: + +def check_problem_package(files: Files, format_version: FormatVersion, diag: Diagnostics) -> None: """Run all checks on the structure of a problem package.""" - _check_symlinks(probdir, diag) - _check_file_types(probdir, diag) - _check_file_and_directory_names(probdir, diag) - _check_root_directory_names(probdir, format_version, diag) - - -def _check_symlinks(probdir: Path, diag: Diagnostics) -> None: - """Check that all symlinks point to something existing within the problem package""" - real_probdir = os.path.realpath(probdir) - for root, dirs, files in os.walk(real_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, 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): - diag.error(f'Symlink {relfile} links to {reltarget} which does not exist') - if os.path.commonpath([real_probdir, target]) != real_probdir: - diag.error(f'Symlink {relfile} links to {reltarget} which is outside of problem package') - if os.path.isabs(reltarget): - diag.error(f'Symlink {relfile} links to {reltarget} which is an absolute path. Symlinks must be relative.') - - -def _check_file_types(probdir: Path, diag: Diagnostics) -> None: - """Check that the package only contains regular files, directories and symlinks - (symlinks are checked by _check_symlinks), and warn about empty directories.""" - for root, dirs, files in os.walk(probdir): - if root == str(probdir) and '.git' in dirs: - dirs.remove('.git') - # Path of the directory we're in, starting with problem shortname. Only used for nicer error messages. - reldir = os.path.relpath(root, probdir.parent) - if not dirs and not files: - diag.warning(f'Empty directory {reldir}') - for file in files: - mode = os.lstat(os.path.join(root, file)).st_mode - if not stat.S_ISREG(mode) and not stat.S_ISLNK(mode): - diag.error(f"'{file}' in {reldir} is not a regular file, directory or symlink") - - -def _check_file_and_directory_names(probdir: Path, diag: Diagnostics) -> None: - def _special_case_allowed_files(file: str, reldir: str) -> bool: - return file == '.gitignore' or (file == '.timelimit' and reldir == probdir.name) - - def _special_case_allowed_dirs(directory: str, reldir: str) -> bool: - return directory == '.git' and reldir == probdir.name - - for root, dirs, files in os.walk(probdir): - # Path of the directory we're in, starting with problem shortname. Only used for nicer error messages. - reldir = os.path.relpath(root, probdir.parent) - for file in files: - if not _NAME_REGEX.match(file) and not _special_case_allowed_files(file, reldir): - diag.error(f"Invalid file name '{file}' in {reldir}, should match {_NAME_REGEX.pattern}") - for directory in dirs: - if not _NAME_REGEX.match(directory) and not _special_case_allowed_dirs(directory, reldir): - diag.error(f"Invalid directory name '{directory}' in {reldir}, should match {_NAME_REGEX.pattern}") + num_files = len(files.walk(files.root, EntryKind.FILE)) + num_dirs = len(files.walk(files.root, EntryKind.DIR)) + msg = f'Checking package structure: {pluralize(num_files, "file")}, {pluralize(num_dirs, "directory", "directories")}' + extra = [f'{len(files.ignored)} ignored'] if files.ignored else [] + extra += [f'{len(files.bad)} invalid'] if files.bad else [] + if extra: + msg += f' ({", ".join(extra)})' + diag.msg(msg) + + _check_bad_entries(files, diag) + _check_empty_directories(files, diag) + _check_file_and_directory_names(files, diag) + _check_root_directory_names(files, format_version, diag) + + +def _display_path(files: Files, path: Path) -> Path: + """path, starting with the problem shortname. Only used for nicer error messages.""" + return path.relative_to(files.root.parent) + + +def _check_bad_entries(files: Files, diag: Diagnostics) -> None: + """Report entries which can not be part of a valid package: bad symlinks and special files.""" + for entry in files.bad: + if entry.reason is BadReason.SPECIAL_FILE: + diag.error(f'{_display_path(files, entry.path)} is not a regular file, directory or symlink') + else: + target = os.readlink(entry.path) + diag.error(f'Symlink {_display_path(files, entry.path)} links to {target} {_SYMLINK_PROBLEMS[entry.reason]}') + + +def _check_empty_directories(files: Files, diag: Diagnostics) -> None: + for directory in files.walk(files.root, EntryKind.DIR): + if not files.listdir(directory): + diag.warning(f'Empty directory {_display_path(files, directory)}') + + +def _check_file_and_directory_names(files: Files, diag: Diagnostics) -> None: + for path, kind in files.entries.items(): + if not _NAME_REGEX.match(path.name): + what = 'file' if kind is EntryKind.FILE else 'directory' + diag.error( + f"Invalid {what} name '{path.name}' in {_display_path(files, path.parent)}, should match {_NAME_REGEX.pattern}" + ) def _warn_renamed_directory(found_name: str, format_version: FormatVersion, diag: Diagnostics) -> bool: @@ -88,13 +81,13 @@ def _warn_renamed_directory(found_name: str, format_version: FormatVersion, diag return False -def _check_root_directory_names(probdir: Path, format_version: FormatVersion, diag: Diagnostics) -> None: +def _check_root_directory_names(files: Files, format_version: FormatVersion, diag: Diagnostics) -> None: """Warn about unrecognized directories at the problem root: deprecated names, directories renamed between format versions, directories belonging to other format versions, and likely typos.""" known = format_version.root_directories other_known = {directory for version in FormatVersion for directory in version.root_directories} - known - existing_dirs = {entry.name for entry in probdir.iterdir() if entry.is_dir()} + existing_dirs = {path.name for path in files.listdir(files.root, EntryKind.DIR)} typo_candidates = known - existing_dirs for name in sorted(existing_dirs - known): diff --git a/problemtools/checks/statements.py b/problemtools/checks/statements.py index 1940b527..72c123ef 100644 --- a/problemtools/checks/statements.py +++ b/problemtools/checks/statements.py @@ -2,7 +2,6 @@ from __future__ import annotations -import glob import os import traceback from pathlib import Path @@ -11,24 +10,27 @@ from ..diagnostics import Diagnostics, pluralize from ..formatversion import FormatVersion from ..metadata import Metadata -from ..model import Statements +from ..model import EntryKind, Files, Statements def check_statements( statements: Statements, metadata: Metadata, format_version: FormatVersion, - probdir: Path, + files: Files, work_dir: Path, diag: Diagnostics, ) -> None: """Run all checks on a problem's statements.""" diag.msg(f'Checking problem statements in {pluralize(len(statements.by_language), "language")}') - for ifilename in glob.glob(os.path.join(str(probdir), 'data/sample/*.interaction')): + for path in files.listdir(files.root / 'data' / 'sample', EntryKind.FILE): + if path.suffix != '.interaction': + continue + ifilename = path.relative_to(files.root) if not metadata.is_interactive() and not metadata.is_multi_pass(): diag.error(f'Problem is not interactive, but there is an interaction sample {ifilename}') - with open(ifilename, 'r') as interaction: + with open(path, 'r') as interaction: for i, line in enumerate(interaction): valid_new_pass = metadata.is_multi_pass() and line.strip() == '---' if len(line) == 0 or (line[0] != '<' and line[0] != '>' and not valid_new_pass): @@ -52,9 +54,11 @@ def check_statements( def _latex_heuristic(name: str) -> bool: return '\\' in name or '$' in name - for lang, files in statements.by_language.items(): - if len(files) > 1: - diag.error(f'Found multiple statements in the same language {lang}: {", ".join(file.name for file in files)}') + for lang, statement_files in statements.by_language.items(): + if len(statement_files) > 1: + diag.error( + f'Found multiple statements in the same language {lang}: {", ".join(file.name for file in statement_files)}' + ) if lang not in metadata.name: diag.error(f'No problem name given in language {lang}') @@ -65,10 +69,10 @@ def _latex_heuristic(name: str) -> bool: elif format_version is FormatVersion.LEGACY and _latex_heuristic(metadata.name[lang]): diag.warning(f'Problem name in language {lang} looks like LaTeX. Consider using plainproblemname.') - for file in files: + for file in statement_files: try: options = problem2pdf.get_parser().parse_args(['']) - options.problem = probdir + options.problem = files.root options.language = lang options.nopdf = True options.quiet = True @@ -82,7 +86,7 @@ def _latex_heuristic(name: str) -> bool: try: options = problem2html.get_parser().parse_args(['']) - options.problem = probdir + options.problem = files.root options.destdir = os.path.join(work_dir, 'html') options.language = lang options.quiet = True diff --git a/problemtools/checks/testdata.py b/problemtools/checks/testdata.py index ad4872ce..d5655c21 100644 --- a/problemtools/checks/testdata.py +++ b/problemtools/checks/testdata.py @@ -3,9 +3,7 @@ from __future__ import annotations import collections -import glob import hashlib -import os from collections.abc import Callable, Iterator from pathlib import Path @@ -18,6 +16,8 @@ DEFAULT_CONFIG, DEFAULT_GRADER, SCORING_ONLY_KEYS, + EntryKind, + Files, Graders, InputValidators, OutputValidators, @@ -32,7 +32,7 @@ def check_testdata( testdata: TestDataGroup, context: Context, metadata: Metadata, - probdir: Path, + files: Files, graders: Graders, input_validators: InputValidators, output_validators: OutputValidators, @@ -72,7 +72,7 @@ def check_testdata( testdata, context, metadata, - probdir, + files, has_custom_grader, has_default_grader, input_validation, @@ -221,7 +221,7 @@ def _check_group( group: TestDataGroup, context: Context, metadata: Metadata, - probdir: Path, + files: Files, has_custom_grader: bool, has_default_grader: bool, input_validation: InputValidationCache, @@ -278,48 +278,39 @@ def _check_group( if not seen_sample: diag.warning('No sample data provided') - hashes = collections.defaultdict(list) - for root, _dirs, files in os.walk(group.datadir): - for filename in files: - filepath = os.path.join(root, filename) - if filepath.endswith('.in') and not os.path.islink(filepath): - md5 = hashlib.md5(usedforsecurity=False) - with open(filepath, 'rb') as f: - for buf in iter(lambda: f.read(1024), b''): - md5.update(buf) - filehash = md5.digest() - hashes[filehash].append(os.path.relpath(filepath, probdir)) - for files in hashes.values(): - if len(files) > 1: - diag.warning(f"Identical input files: '{files!s}'") - - infiles = glob.glob(os.path.join(str(group.datadir), '*.in')) - ansfiles = glob.glob(os.path.join(str(group.datadir), '*.ans')) - - for infile in infiles: - if os.path.isdir(infile): - continue - if f'{infile[:-3]}.ans' not in ansfiles: - diag.error(f"No matching answer file for input '{infile}'") - for ansfile in ansfiles: - if os.path.isdir(ansfile): - continue - if f'{ansfile[:-4]}.in' not in infiles: - diag.error(f"No matching input file for answer '{ansfile}'") + hashes: dict[bytes, list[Path]] = collections.defaultdict(list) + for testcase in group.get_all_testcases(): + if testcase.infile.is_symlink(): + continue + with open(testcase.infile, 'rb') as f: + digest = hashlib.file_digest(f, lambda: hashlib.sha256(usedforsecurity=False)).digest() + hashes[digest].append(testcase.infile.relative_to(files.root)) + for identical in hashes.values(): + if len(identical) > 1: + diag.warning( + f'Identical input files: {", ".join(str(path) for path in identical)}. If this is intentional, use symlinks.' + ) + + for path in files.listdir(group.datadir, EntryKind.FILE): + if path.suffix == '.in' and not files.is_file(path.with_suffix('.ans')): + diag.error(f"No matching answer file for input '{path.relative_to(files.root)}'") + elif path.suffix == '.ans' and not files.is_file(path.with_suffix('.in')): + diag.error(f"No matching input file for answer '{path.relative_to(files.root)}'") if not group.get_subgroups() and not group.get_testcases(): + group_dir = group.datadir.relative_to(files.root) if group.datadir.name != 'sample': - diag.error(f'Testcase group {group.datadir} exists, but does not contain any testcases') + diag.error(f'Testcase group {group_dir} exists, but does not contain any testcases') else: if not ( (metadata.is_interactive() or metadata.is_multi_pass()) - and glob.glob(os.path.join(str(group.datadir), '*.interaction')) + and any(path.suffix == '.interaction' for path in files.listdir(group.datadir, EntryKind.FILE)) ): - diag.warning(f'Sample testcase group {group.datadir} exists, but does not contain any testcases') + diag.warning(f'Sample testcase group {group_dir} exists, but does not contain any testcases') last_testgroup_name = '' for subgroup in group.get_subgroups(): - name = os.path.relpath(subgroup.datadir, probdir) + name = str(subgroup.datadir.relative_to(files.root)) if _natural_sort_le(name, last_testgroup_name): diag.warning(f"Test data group '{last_testgroup_name}' will be ordered before '{name}'; consider zero-padding") last_testgroup_name = name @@ -332,7 +323,7 @@ def _check_group( child, context, metadata, - probdir, + files, has_custom_grader, has_default_grader, input_validation, diff --git a/problemtools/diagnostics.py b/problemtools/diagnostics.py index 9b0e924b..182f102d 100644 --- a/problemtools/diagnostics.py +++ b/problemtools/diagnostics.py @@ -14,9 +14,12 @@ class VerifyError(Exception): pass -def pluralize(n: int, word: str) -> str: - """Format a count together with a regular English noun, e.g. pluralize(3, 'submission') -> '3 submissions'.""" - return f'{n} {word}' if n == 1 else f'{n} {word}s' +def pluralize(n: int, word: str, plural: str | None = None) -> str: + """Format a count together with an English noun, e.g. pluralize(3, 'submission') -> '3 submissions'. + + plural is the plural form of word, for irregular nouns (defaults to word + 's'). + """ + return f'{n} {word}' if n == 1 else f'{n} {plural or word + "s"}' class Diagnostics(ABC): diff --git a/problemtools/model/__init__.py b/problemtools/model/__init__.py index 77aeda7e..271299a5 100644 --- a/problemtools/model/__init__.py +++ b/problemtools/model/__init__.py @@ -4,6 +4,7 @@ Verdict = Literal['AC', 'TLE', 'OLE', 'MLE', 'RTE', 'WA', 'PAC', 'JE'] from .attachments import Attachments, load_attachments +from .files import BadEntry, BadReason, EntryKind, Files, load_files from .graders import DEFAULT_GRADER, Graders, load_graders from .includes import DEFAULT_LANGUAGE, Includes, LanguageIncludes, load_includes from .problem import Problem, load_problem @@ -20,6 +21,10 @@ 'DEFAULT_VALIDATOR', 'SCORING_ONLY_KEYS', 'Attachments', + 'BadEntry', + 'BadReason', + 'EntryKind', + 'Files', 'Graders', 'Includes', 'InputValidators', @@ -36,6 +41,7 @@ 'TestDataGroup', 'Verdict', 'load_attachments', + 'load_files', 'load_graders', 'load_includes', 'load_input_validators', diff --git a/problemtools/model/attachments.py b/problemtools/model/attachments.py index ed0efea2..de7899c1 100644 --- a/problemtools/model/attachments.py +++ b/problemtools/model/attachments.py @@ -1,9 +1,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from pathlib import Path -from .paths import AbsolutePath, abspath, resolve +from .files import Files +from .paths import AbsolutePath @dataclass(frozen=True) @@ -13,7 +13,5 @@ class Attachments: paths: list[AbsolutePath] = field(default_factory=list) -def load_attachments(probdir: Path) -> Attachments: - attachments_dir = resolve(probdir) / 'attachments' - paths = [abspath(p) for p in attachments_dir.iterdir()] if attachments_dir.is_dir() else [] - return Attachments(paths=paths) +def load_attachments(files: Files) -> Attachments: + return Attachments(paths=files.listdir(files.root / 'attachments')) diff --git a/problemtools/model/files.py b/problemtools/model/files.py new file mode 100644 index 00000000..7d8802a1 --- /dev/null +++ b/problemtools/model/files.py @@ -0,0 +1,145 @@ +"""The files and directories making up a problem package. + +The package is represented as a logical tree: entries are keyed by the path at +which they appear in the package, with symlinks (to files or directories inside +the package) followed. E.g., if data/secret/group is a symlink to ../sample, +then data/secret/group/1.in is an entry. + +Entries whose name starts with '.' are ignored (and not descended into). +Entries which can not be part of a valid package (see BadReason) are recorded, +but are not part of the tree. +""" + +from __future__ import annotations + +import errno +import os +import stat +from dataclasses import dataclass, field +from enum import Enum, auto +from pathlib import Path + +from .paths import AbsolutePath, abspath, resolve + + +class EntryKind(Enum): + FILE = auto() + DIR = auto() + + +class BadReason(Enum): + ABSOLUTE_SYMLINK = auto() + OUTSIDE_PACKAGE = auto() + BROKEN_SYMLINK = auto() + SYMLINK_LOOP = auto() + IGNORED_TARGET = auto() # symlink pointing at, or into, an ignored entry + SPECIAL_FILE = auto() # fifo, socket, device, ... + + +@dataclass(frozen=True) +class BadEntry: + path: AbsolutePath + reason: BadReason + + +@dataclass(frozen=True) +class Files: + """All files and directories of a problem package. + + All paths are absolute logical paths (i.e., below root, with symlinks not + resolved). Paths passed to the query methods must be absolute (asserted, but + typed as Path for convenience), and are matched exactly. + """ + + root: AbsolutePath + entries: dict[AbsolutePath, EntryKind] = field(default_factory=dict) + ignored: list[AbsolutePath] = field(default_factory=list) + bad: list[BadEntry] = field(default_factory=list) + # Sorted children of root and of each directory in entries. + _children: dict[AbsolutePath, list[AbsolutePath]] = field(default_factory=dict, repr=False) + + def is_file(self, path: Path) -> bool: + return self.entries.get(abspath(path)) == EntryKind.FILE + + def is_dir(self, path: Path) -> bool: + return abspath(path) in self._children + + def listdir(self, dir: Path, kind: EntryKind | None = None) -> list[AbsolutePath]: + """Sorted entries directly in dir, optionally only those of the given kind. + + Empty if dir is not a directory in the package. + """ + return [path for path in self._children.get(abspath(dir), []) if kind is None or self.entries[path] == kind] + + def walk(self, dir: Path, kind: EntryKind | None = EntryKind.FILE) -> list[AbsolutePath]: + """Sorted entries anywhere below dir, optionally only those of the given kind. + + Empty if dir is not a directory in the package. + """ + found: list[AbsolutePath] = [] + stack = list(self._children.get(abspath(dir), [])) + while stack: + path = stack.pop() + if kind is None or self.entries[path] == kind: + found.append(path) + stack.extend(self._children.get(path, [])) + return sorted(found) + + +def load_files(probdir: Path) -> Files: + """Walk the problem package at probdir, following symlinks inside the package.""" + root = resolve(probdir) + files = Files(root=root) + _walk(files, root, root, frozenset([root])) + return files + + +def _walk(files: Files, dir: AbsolutePath, real_dir: Path, ancestors: frozenset[Path]) -> None: + """Add the contents of the directory at dir (which really is real_dir) to files. + + ancestors are the real paths of all directories from root down to (and including) real_dir. + """ + children = files._children.setdefault(dir, []) + for name in sorted(os.listdir(real_dir)): + path = abspath(dir / name) + if name.startswith('.'): + files.ignored.append(path) + continue + + real = real_dir / name + st = os.lstat(real) + if stat.S_ISLNK(st.st_mode): + reason = _check_symlink(files.root, real) + if reason is not None: + files.bad.append(BadEntry(path, reason)) + continue + real = Path(os.path.realpath(real)) + st = os.stat(real) + + if stat.S_ISDIR(st.st_mode): + if real in ancestors: + files.bad.append(BadEntry(path, BadReason.SYMLINK_LOOP)) + continue + files.entries[path] = EntryKind.DIR + children.append(path) + _walk(files, path, real, ancestors | {real}) + elif stat.S_ISREG(st.st_mode): + files.entries[path] = EntryKind.FILE + children.append(path) + else: + files.bad.append(BadEntry(path, BadReason.SPECIAL_FILE)) + + +def _check_symlink(root: Path, symlink: Path) -> BadReason | None: + """Check whether symlink is bad, returning the reason if so.""" + if os.path.isabs(os.readlink(symlink)): + return BadReason.ABSOLUTE_SYMLINK + try: + target = Path(os.path.realpath(symlink, strict=True)) + except OSError as e: + return BadReason.SYMLINK_LOOP if e.errno == errno.ELOOP else BadReason.BROKEN_SYMLINK + if not target.is_relative_to(root): + return BadReason.OUTSIDE_PACKAGE + if any(part.startswith('.') for part in target.relative_to(root).parts): + return BadReason.IGNORED_TARGET + return None diff --git a/problemtools/model/graders.py b/problemtools/model/graders.py index 526e90d4..1132511b 100644 --- a/problemtools/model/graders.py +++ b/problemtools/model/graders.py @@ -1,11 +1,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from pathlib import Path from ..languages import Languages -from ..run import BuildRun, SourceCode, as_source_or_buildrun, find_programs, get_tool -from .paths import resolve +from ..run import BuildRun, SourceCode, get_tool +from .files import Files +from .programs import as_source_or_buildrun, find_programs DEFAULT_GRADER = get_tool('default_grader') @@ -25,7 +25,6 @@ def grader(self) -> SourceCode | BuildRun | None: return self.graders[0] if len(self.graders) == 1 else None -def load_graders(probdir: Path, language_config: Languages) -> Graders: - probdir = resolve(probdir) - graders = as_source_or_buildrun(find_programs(probdir / 'graders', language_config=language_config)) +def load_graders(files: Files, language_config: Languages) -> Graders: + graders = as_source_or_buildrun(find_programs(files, files.root / 'graders', language_config=language_config)) return Graders(graders=graders) diff --git a/problemtools/model/includes.py b/problemtools/model/includes.py index 43e7635d..a17294c8 100644 --- a/problemtools/model/includes.py +++ b/problemtools/model/includes.py @@ -2,7 +2,8 @@ from pathlib import Path from ..languages import Language, Languages -from .paths import RelativePath, relpath, resolve +from .files import EntryKind, Files +from .paths import RelativePath, relpath from .program_files import ProgramFiles, load_program_files #: Pseudo-language whose include files are added for every language. @@ -41,27 +42,22 @@ def get_includes_for_language(self, language: str) -> LanguageIncludes: return LanguageIncludes(mainfile=lang_includes.mainfile, files=default_includes.files.merged(lang_includes.files)) -def load_includes(probdir: Path, language_config: Languages) -> Includes: - include_dir = resolve(probdir) / 'include' +def load_includes(files: Files, language_config: Languages) -> Includes: includes = Includes() - if not include_dir.is_dir(): - return includes - - for lang_dir in sorted(include_dir.iterdir()): - if lang_dir.is_dir(): - language = language_config.get(lang_dir.name) - includes.languages[lang_dir.name] = _load_language_includes(lang_dir, language) + for lang_dir in files.listdir(files.root / 'include', EntryKind.DIR): + language = language_config.get(lang_dir.name) + includes.languages[lang_dir.name] = _load_language_includes(files, lang_dir, language) return includes -def _load_language_includes(lang_dir: Path, language: Language | None) -> LanguageIncludes: - files = load_program_files(lang_dir) +def _load_language_includes(files: Files, lang_dir: Path, language: Language | None) -> LanguageIncludes: + program_files = load_program_files(files, lang_dir) mainfile: RelativePath | None = None if language is not None: - source_files = language.get_source_files([f.path for f in files.files]) + source_files = language.get_source_files([f.path for f in program_files.files]) candidates = language.mainfile_candidates(source_files) if candidates: mainfile = relpath(candidates[0]) - return LanguageIncludes(mainfile=mainfile, files=files) + return LanguageIncludes(mainfile=mainfile, files=program_files) diff --git a/problemtools/model/problem.py b/problemtools/model/problem.py index 3f9b31a4..27e30cda 100644 --- a/problemtools/model/problem.py +++ b/problemtools/model/problem.py @@ -10,6 +10,7 @@ from ..languages import Languages, load_language_config from ..metadata import Metadata, load_metadata from .attachments import Attachments, load_attachments +from .files import Files, load_files from .graders import Graders, load_graders from .includes import Includes, load_includes from .paths import AbsolutePath, resolve @@ -29,6 +30,7 @@ class Problem: """ probdir: AbsolutePath + files: Files language_config: Languages metadata: Metadata statements: Statements @@ -56,24 +58,26 @@ def load_problem(probdir: Path, diag: Diagnostics) -> Problem: """ try: probdir = resolve(probdir) + files = load_files(probdir) language_config = load_language_config(probdir.parent) problem_metadata = load_metadata(probdir) format_version = problem_metadata.problem_format_version - includes = load_includes(probdir, language_config) + includes = load_includes(files, language_config) return Problem( probdir=probdir, + files=files, language_config=language_config, metadata=problem_metadata, statements=load_statements(probdir, format_version), - attachments=load_attachments(probdir), - input_validators=load_input_validators(probdir, language_config), - output_validators=load_output_validators(probdir, format_version, language_config), - graders=load_graders(probdir, language_config), - testdata=load_testdata(probdir, problem_metadata), + attachments=load_attachments(files), + input_validators=load_input_validators(files, language_config), + output_validators=load_output_validators(files, format_version, language_config), + graders=load_graders(files, language_config), + testdata=load_testdata(files, problem_metadata), includes=includes, # Submissions need includes, so includes must be loaded first. - submissions=load_submissions(probdir, language_config, includes), + submissions=load_submissions(files, language_config, includes), ) except VerifyError: # A loader already reported its own diagnostic; don't double-report. diff --git a/problemtools/model/program_files.py b/problemtools/model/program_files.py index 106727cd..12ca257c 100644 --- a/problemtools/model/program_files.py +++ b/problemtools/model/program_files.py @@ -1,9 +1,9 @@ """In-memory snapshot of the files making up a program (e.g. a submission or validator).""" -import os from dataclasses import dataclass, field from pathlib import Path +from .files import Files from .paths import RelativePath, relpath @@ -48,22 +48,16 @@ def size(self) -> int: return sum(len(file.data) for file in self.files) -def load_program_files(path: Path) -> ProgramFiles: +def load_program_files(files: Files, path: Path) -> ProgramFiles: """Load the files of the program at path. path is either a single file (giving a program with that one file), or a - directory, which is read recursively, following symlinks. A file counts as + directory, giving all files below it in the package. A file counts as executable if any of its execute bits are set. """ - if path.is_file(): + if files.is_file(path): return ProgramFiles(files=[_load_file(path, relpath(Path(path.name)))]) - - files = [] - for root, _, filenames in os.walk(path, followlinks=True): - for filename in filenames: - file_path = Path(root) / filename - files.append(_load_file(file_path, relpath(file_path.relative_to(path)))) - return ProgramFiles(files=sorted(files, key=lambda file: file.path)) + return ProgramFiles(files=[_load_file(file, relpath(file.relative_to(path))) for file in files.walk(path)]) def _load_file(path: Path, relative: RelativePath) -> ProgramFile: diff --git a/problemtools/model/programs.py b/problemtools/model/programs.py new file mode 100644 index 00000000..2518cbe2 --- /dev/null +++ b/problemtools/model/programs.py @@ -0,0 +1,131 @@ +"""Discovery of the programs (submissions, validators, graders) in a problem package.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from ..languages import Languages +from ..run import BuildRun, Checktestdata, Program, SourceCode, Viva +from .files import Files +from .includes import Includes +from .program_files import load_program_files + + +def find_programs( + files: Files, + dir: Path, + language_config: Languages, + allow_validation_script: bool = False, +) -> list[Program]: + """Find all programs in a directory. + + Args: + files: the files of the problem package. + + dir: directory in which to search for programs + + language_config: language config, used for auto-detecting + programming language of source code and providing info + on how to compile and run the source code. + + allow_validation_script: if true, also looks for + validation scripts in the Checktestdata and VIVA formats. + + Returns: + list of Program instances, all programs found in dir. + """ + ret = [] + for path in files.listdir(dir): + program = get_program(files, path, language_config=language_config, allow_validation_script=allow_validation_script) + if program is not None: + ret.append(program) + return ret + + +def get_program( + files: Files, + path: Path, + language_config: Languages, + allow_validation_script: bool = False, +) -> Program | None: + """Get a Program object for a program + + Args: + files: the files of the problem package. + + path: path of program. Can be either a single file or a + directory (in which case the program is considered to + consist of all files and subdirectories in the path). + + language_config, allow_validation_script: see find_programs. + + Returns: + a Program instance, or None if no program was found at + the given path. + """ + if files.is_file(path): + if allow_validation_script: + if path.suffix == '.viva': + return Viva(path) + if path.suffix == '.ctd': + return Checktestdata(path) + else: + build = path / 'build' + if files.is_file(build) and os.access(build, os.X_OK): + return BuildRun(path.name, load_program_files(files, path)) + + return get_source_program(files, path, language_config) + + +def find_source_programs(files: Files, dir: Path, language_config: Languages, includes: Includes) -> list[SourceCode]: + """Find all programs provided as source code in a directory. + + Like find_programs, but never gives a BuildRun: a directory with a build + script is treated like any other source code directory. + """ + ret = [] + for path in files.listdir(dir): + program = get_source_program(files, path, language_config=language_config, includes=includes) + if program is not None: + ret.append(program) + return ret + + +def get_source_program( + files: Files, path: Path, language_config: Languages, includes: Includes | None = None +) -> SourceCode | None: + """Get a SourceCode object for a program. + + Args: + files, path, language_config: see get_program. + + 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. + + Returns: + a SourceCode instance, or None if no source code in a known + language was found at the given path. + """ + if includes is None: + includes = Includes() + + lang = language_config.detect_language([path] if files.is_file(path) else files.walk(path)) + if lang is None: + return None + return SourceCode(path.name, load_program_files(files, path), lang, includes=includes.get_includes_for_language(lang.lang_id)) + + +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/model/submissions.py b/problemtools/model/submissions.py index 4ffc3f2e..7ec76e5c 100644 --- a/problemtools/model/submissions.py +++ b/problemtools/model/submissions.py @@ -6,10 +6,12 @@ from ..languages import Languages from ..metadata import Metadata -from ..run import SourceCode, find_source_programs +from ..run import SourceCode from ..submission_rules import FinalVerdictRule, PermittedRule, Rule, ScoreRule +from .files import EntryKind, Files from .includes import Includes -from .paths import RelativePath, relpath, resolve +from .paths import RelativePath, relpath +from .programs import find_source_programs from .testdata import TestDataGroup @@ -102,16 +104,11 @@ class Submissions: policy: LegacyPolicy = field(default_factory=LegacyPolicy) -def load_submissions(probdir: Path, language_config: Languages, includes: Includes) -> Submissions: - subs_root = resolve(probdir) / 'submissions' - if not subs_root.is_dir(): - return Submissions() - +def load_submissions(files: Files, language_config: Languages, includes: Includes) -> Submissions: submissions = [] - for entry in sorted(subs_root.iterdir()): - if entry.is_dir(): - for program in find_source_programs(entry, language_config=language_config, includes=includes): - submissions.append(Submission(program=program, path=relpath(Path(entry.name) / program.name))) + for entry in files.listdir(files.root / 'submissions', EntryKind.DIR): + for program in find_source_programs(files, entry, language_config=language_config, includes=includes): + 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 1edc57e6..ed1fb974 100644 --- a/problemtools/model/testdata.py +++ b/problemtools/model/testdata.py @@ -2,7 +2,6 @@ import os from dataclasses import dataclass, field -from pathlib import Path from re import Pattern from typing import Any @@ -10,7 +9,8 @@ from .. import config from ..metadata import Metadata -from .paths import AbsolutePath, RelativePath, abspath, relpath, resolve +from .files import Files +from .paths import AbsolutePath, RelativePath, abspath, relpath DEFAULT_CONFIG = config.load_config('testdata.yaml') SCORING_ONLY_KEYS = ['accept_score', 'reject_score', 'range'] @@ -90,26 +90,26 @@ def get_score_range(self) -> tuple[float, float]: return (float('-inf'), float('inf')) -def load_testdata(probdir: Path, metadata: Metadata) -> TestDataGroup: +def load_testdata(files: Files, metadata: Metadata) -> TestDataGroup: """Load the full testdata tree rooted at /data.""" - root = resolve(probdir) - return _load_group(root, abspath(root / 'data'), {}, metadata, is_root=True) + return _load_group(files, abspath(files.root / 'data'), {}, metadata, is_root=True) -def _load_group_config(datadir: AbsolutePath) -> dict[str, Any]: +def _load_group_config(files: Files, datadir: AbsolutePath) -> dict[str, Any]: configfile = datadir / 'testdata.yaml' - if not configfile.is_file(): + if not files.is_file(configfile): return {} loaded = yaml.safe_load(configfile.read_text()) return loaded if loaded is not None else {} def _load_group( - probdir: AbsolutePath, datadir: AbsolutePath, parent_config: dict[str, Any], metadata: Metadata, is_root: bool + files: Files, datadir: AbsolutePath, parent_config: dict[str, Any], metadata: Metadata, is_root: bool ) -> TestDataGroup: + probdir = files.root name = os.path.relpath(datadir, probdir).replace(os.sep, '.') - merged_config = _load_group_config(datadir) + merged_config = _load_group_config(files, datadir) # For non-root groups, missing properties are inherited from the parent group for key, parent_value in parent_config.items(): @@ -135,12 +135,11 @@ def _load_group( merged_config.setdefault(key, default) items: list[TestCase | TestDataGroup] = [] - if datadir.is_dir(): - for entry in sorted(datadir.iterdir()): - if entry.is_dir(): - 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(abspath(entry), abspath(probdir / 'data'), merged_config, metadata)) + for entry in files.listdir(datadir): + if files.is_dir(entry): + items.append(_load_group(files, entry, merged_config, metadata, is_root=False)) + elif entry.suffix == '.ans' and files.is_file(entry.with_suffix('.in')): + items.append(_load_testcase(entry, abspath(probdir / 'data'), merged_config, metadata)) path = relpath(datadir.relative_to(probdir / 'data')) return TestDataGroup(name=name, datadir=datadir, path=path, config=merged_config, is_root=is_root, items=items) diff --git a/problemtools/model/validators.py b/problemtools/model/validators.py index d00b1f3c..4c030332 100644 --- a/problemtools/model/validators.py +++ b/problemtools/model/validators.py @@ -1,13 +1,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from pathlib import Path from ..formatversion import FormatVersion from ..languages import Languages from ..metadata import Metadata -from ..run import BuildRun, Program, SourceCode, as_source_or_buildrun, find_programs, get_tool -from .paths import resolve +from ..run import BuildRun, Program, SourceCode, get_tool +from .files import Files +from .programs import as_source_or_buildrun, find_programs #: The problemtools-provided validator used when a problem doesn't ship a custom output validator. DEFAULT_VALIDATOR = get_tool('default_validator') @@ -20,14 +20,13 @@ class InputValidators: validators: list[Program] = field(default_factory=list) -def load_input_validators(probdir: Path, language_config: Languages) -> InputValidators: - probdir = resolve(probdir) +def load_input_validators(files: Files, language_config: Languages) -> InputValidators: # 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 = [ program for directory in ('input_format_validators', 'input_validators') - for program in find_programs(probdir / directory, language_config=language_config, allow_validation_script=True) + for program in find_programs(files, files.root / directory, language_config=language_config, allow_validation_script=True) ] return InputValidators(validators=validators) @@ -51,9 +50,8 @@ def select(self, format_version: FormatVersion, metadata: Metadata) -> Program: return self.validators[0] -def load_output_validators(probdir: Path, format_version: FormatVersion, language_config: Languages) -> OutputValidators: - probdir = resolve(probdir) +def load_output_validators(files: Files, format_version: FormatVersion, language_config: Languages) -> OutputValidators: validators = as_source_or_buildrun( - find_programs(probdir / format_version.output_validator_directory, language_config=language_config) + find_programs(files, files.root / 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 afe66990..fdf92f0f 100644 --- a/problemtools/run/__init__.py +++ b/problemtools/run/__init__.py @@ -2,155 +2,13 @@ Problemtools. """ -import os -from pathlib import Path -from typing import TYPE_CHECKING - -from ..languages import Languages -from . import rutil -from .buildrun import BuildRun -from .checktestdata import Checktestdata +from .buildrun import BuildRun as BuildRun +from .checktestdata import Checktestdata as Checktestdata from .executable import Executable as Executable from .program import CompileResult as CompileResult -from .program import Program -from .source import SourceCode +from .program import Program as Program +from .source import SourceCode as SourceCode from .tools import Tool as Tool from .tools import get_tool as get_tool from .tools import get_tool_path as get_tool_path -from .viva import Viva - -if TYPE_CHECKING: - from ..model import Includes - - -def find_programs( - path: Path, - language_config: Languages, - allow_validation_script: bool = False, -) -> list[Program]: - """Find all programs in a directory. - - Args: - path: directory in which to search for programs - - language_config: language config, used for auto-detecting - programming language of source code and providing info - on how to compile and run the source code. - - allow_validation_script: if true, also looks for - validation scripts in the Checktestdata and VIVA formats. - - Returns: - list of Program instances, all programs found in path. - - """ - if not path.is_dir(): - return [] - ret = [] - for fullpath in sorted(path.iterdir()): - run = get_program( - fullpath, - language_config=language_config, - allow_validation_script=allow_validation_script, - ) - if run is not None: - ret.append(run) - return ret - - -def get_program( - path: Path, - language_config: Languages, - allow_validation_script: bool = False, -) -> Program | None: - """Get a Program object for a program - - Args: - path: path of program. Can be either a single file or a - directory (in which case the program is considered to - consist of all files and subdirectories in the path). - - language_config: language config, used for auto-detecting - programming language of source code and providing info - on how to compile and run the source code. - - allow_validation_script: if true, also looks for - validation scripts in the Checktestdata and VIVA formats. - - Returns: - a Program instance, or None if no program was found at - the given path. - """ - # Imported lazily (rather than at module scope) since `model` depends on `run` - # (e.g. for `run.find_programs`), so importing it here avoids a circular import. - from ..model import load_program_files - - if path.is_file(): - if allow_validation_script: - if path.suffix == '.viva': - return Viva(path) - if path.suffix == '.ctd': - return Checktestdata(path) - else: - build = path / 'build' - if build.is_file() and os.access(build, os.X_OK): - return BuildRun(path.name, load_program_files(path)) - - return get_source_program(path, language_config) - - -def find_source_programs(path: Path, language_config: Languages, includes: 'Includes') -> list[SourceCode]: - """Find all programs provided as source code in a directory. - - Like find_programs, but never gives a BuildRun: a directory with a build - script is treated like any other source code directory. - """ - if not path.is_dir(): - return [] - ret = [] - for fullpath in sorted(path.iterdir()): - program = get_source_program(fullpath, language_config=language_config, includes=includes) - if program is not None: - ret.append(program) - return ret - - -def get_source_program(path: Path, language_config: Languages, includes: 'Includes | None' = None) -> SourceCode | None: - """Get a SourceCode object for a program. - - Args: - path, language_config: see get_program. - - 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. - - Returns: - a SourceCode instance, or None if no source code in a known - language was found at the given path. - """ - # Imported lazily, see get_program - from ..model import Includes, load_program_files - - if includes is None: - includes = Includes() - - files = [path] if path.is_file() else rutil.list_files_recursive(path) - lang = language_config.detect_language(files) - if lang is None: - return None - return SourceCode(path.name, load_program_files(path), lang, includes=includes.get_includes_for_language(lang.lang_id)) - - -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 +from .viva import Viva as Viva diff --git a/problemtools/run/rutil.py b/problemtools/run/rutil.py index ce4a4a00..4ba79b3c 100644 --- a/problemtools/run/rutil.py +++ b/problemtools/run/rutil.py @@ -5,19 +5,6 @@ from pathlib import Path -def list_files_recursive(root: Path) -> list[Path]: - """List files in a directory with subdirectories. - - Returns: - paths of all files contained in a directory and its - subdirectories. - """ - ret: list[Path] = [] - for path, _, files in os.walk(root): - ret.extend(Path(path) / filename for filename in files) - return ret - - def check_build_dir(build_dir: Path) -> str | None: """Check that a build only produced regular files and directories. diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 22161939..ff3fb183 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -98,7 +98,7 @@ def get_output_validator() -> run.Program: f'Support for version {problem.format_version} is very incomplete. Verification may not work as expected.' ) - checks.check_problem_package(problem.probdir, problem.format_version, diag) + checks.check_problem_package(problem.files, problem.format_version, diag) run.limit.check_limit_capabilities(diag) steps = self._build_steps(context, get_output_validator, set_timelim) @@ -170,7 +170,7 @@ def start_submissions(context: Context) -> None: 'statement', 'statement', lambda diag: checks.check_statements( - problem.statements, problem.metadata, problem.format_version, problem.probdir, work_dir, diag + problem.statements, problem.metadata, problem.format_version, problem.files, work_dir, diag ), ), _CheckStep('statement', 'attachments', lambda diag: checks.check_attachments(problem.attachments, diag)), @@ -205,7 +205,7 @@ def start_submissions(context: Context) -> None: problem.testdata, context, problem.metadata, - problem.probdir, + problem.files, problem.graders, problem.input_validators, problem.output_validators,