From 786cacdb6248da9bc33c1f09f3fc1fb8b385aa1a Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Thu, 16 Jul 2026 09:42:46 +0200 Subject: [PATCH 1/4] Do not override build even if include files contain a build script --- problemtools/run/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/problemtools/run/__init__.py b/problemtools/run/__init__.py index 6f5791dc..fe74dac7 100644 --- a/problemtools/run/__init__.py +++ b/problemtools/run/__init__.py @@ -111,11 +111,5 @@ def get_program(path, language_config=None, work_dir=None, include_dir=None, all if language_config is not None: lang = language_config.detect_language(files) if lang is not None: - if include_dir is not None: - lang_dir = os.path.join(include_dir, lang.lang_id) - build = os.path.join(lang_dir, 'build') - if os.path.isfile(build) and os.access(build, os.X_OK): - return BuildRun(path, work_dir=work_dir, include_dir=lang_dir) - return SourceCode(path, lang, work_dir=work_dir, include_dir=include_dir) return None From 6d5d4e425f49cfeffb1ffea651ddbbf4ef3151dc Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Thu, 16 Jul 2026 09:57:37 +0200 Subject: [PATCH 2/4] Extract mainfile selection logic to Language (so it can become language-dependent) --- problemtools/languages.py | 13 +++++++++++++ problemtools/run/source.py | 6 ++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/problemtools/languages.py b/problemtools/languages.py index 93941205..000f0d15 100644 --- a/problemtools/languages.py +++ b/problemtools/languages.py @@ -24,6 +24,7 @@ class Language(object): __KEYS = ['name', 'priority', 'files', 'shebang', 'compile', 'run'] __VARIABLES = ['path', 'files', 'binary', 'mainfile', 'mainclass', 'Mainclass', 'memlim'] + __MAINFILE_RE = re.compile(r'^main\.', re.IGNORECASE) def __init__(self, lang_id, lang_spec): """Construct language object @@ -60,6 +61,18 @@ def get_source_files(self, file_list): ) ] + def mainfile_candidates(self, files: list[str | Path]) -> list[str | Path]: + """Given a list of files, determine which ones would be considered + plausible mainfiles for the language, i.e. an entrypoint override. + + Only the basename of each file is inspected, so callers may pass + either full or relative paths. + + Args: + files: list of file paths (str or Path) + """ + return [f for f in files if Language.__MAINFILE_RE.match(Path(f).name)] + def update(self, values): """Update a language specification with new values. diff --git a/problemtools/run/source.py b/problemtools/run/source.py index c646c60e..6b619428 100644 --- a/problemtools/run/source.py +++ b/problemtools/run/source.py @@ -2,7 +2,6 @@ Implementation of programs provided by source code. """ -import re import os import shlex import tempfile @@ -68,9 +67,8 @@ def __init__(self, path, language, work_dir=None, include_dir=None): if len(self.src) == 0: raise ProgramError('No source files found for language %s in %s' % (self.language.lang_id, self.name)) - self.mainfile = next((x for x in self.src if re.match(r'^main\.', os.path.basename(x), re.IGNORECASE)), None) - if self.mainfile is None: - self.mainfile = self.src[0] + candidates = self.language.mainfile_candidates(self.src) + self.mainfile = candidates[0] if candidates else self.src[0] self.mainclass = os.path.splitext(os.path.basename(self.mainfile))[0] self.Mainclass = self.mainclass[0].upper() + self.mainclass[1:] From 71d7896dd91ac982275384d0776c30efba2bfe52 Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Thu, 16 Jul 2026 10:29:40 +0200 Subject: [PATCH 3/4] Add model and checks for include files in new modules --- problemtools/checks/__init__.py | 5 +++ problemtools/checks/includes.py | 69 +++++++++++++++++++++++++++++++++ problemtools/model/__init__.py | 9 +++++ problemtools/model/includes.py | 63 ++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 problemtools/checks/__init__.py create mode 100644 problemtools/checks/includes.py create mode 100644 problemtools/model/__init__.py create mode 100644 problemtools/model/includes.py diff --git a/problemtools/checks/__init__.py b/problemtools/checks/__init__.py new file mode 100644 index 00000000..abbb1c2e --- /dev/null +++ b/problemtools/checks/__init__.py @@ -0,0 +1,5 @@ +from .includes import check_includes + +__all__ = [ + 'check_includes', +] diff --git a/problemtools/checks/includes.py b/problemtools/checks/includes.py new file mode 100644 index 00000000..ff1ac4c7 --- /dev/null +++ b/problemtools/checks/includes.py @@ -0,0 +1,69 @@ +"""Checks for a problem package's include files.""" + +from pathlib import Path + +from ..diagnostics import Diagnostics +from ..formatversion import FormatVersion +from ..languages import Languages +from ..model import DEFAULT_LANGUAGE, Includes + + +def check_includes(includes: Includes, language_config: Languages, format_version: FormatVersion, diag: Diagnostics) -> None: + """Run all checks on a problem's include files.""" + _check_default_and_unknown_languages(includes, language_config, format_version, diag) + _check_ambiguous_mainfile(includes, language_config, diag) + _check_default_sets_mainfile(includes, language_config, diag) + _check_default_path_collision(includes, diag) + + +def _check_default_and_unknown_languages( + includes: Includes, language_config: Languages, format_version: FormatVersion, diag: Diagnostics +) -> None: + for lang_id in includes.languages: + if lang_id == DEFAULT_LANGUAGE: + if format_version is FormatVersion.LEGACY: + diag.error(f'Include files for language "{DEFAULT_LANGUAGE}" are not supported in the legacy problem format') + elif language_config.get(lang_id) is None: + diag.warning(f'Include files found for unknown language "{lang_id}"') + + +def _default_include_paths(includes: Includes) -> list[Path]: + default_includes = includes.languages.get(DEFAULT_LANGUAGE) + return [f.path for f in default_includes.files] if default_includes else [] + + +def _check_default_path_collision(includes: Includes, diag: Diagnostics) -> None: + """Flag file name collisions between the default language and other languages""" + if default_paths := set(_default_include_paths(includes)): + for lang_id, lang_includes in includes.languages.items(): + if lang_id == DEFAULT_LANGUAGE: + continue + colliding = sorted(str(f.path) for f in lang_includes.files if f.path in default_paths) + if colliding: + names = ', '.join(colliding) + diag.error(f'Include files for language "{lang_id}" collide with "{DEFAULT_LANGUAGE}" include files: {names}') + + +def _check_ambiguous_mainfile(includes: Includes, language_config: Languages, diag: Diagnostics) -> None: + """Flag languages whose own include files have more than one plausible mainfile.""" + for lang_id, lang_includes in includes.languages.items(): + if lang_id == DEFAULT_LANGUAGE: + continue + language = language_config.get(lang_id) + if language is None: + continue + + candidates = language.mainfile_candidates([f.path for f in lang_includes.files]) + if len(candidates) > 1: + names = ', '.join(str(candidate) for candidate in candidates) + diag.error(f'Include files for language "{lang_id}" have multiple possible mainfiles: {names}') + + +def _check_default_sets_mainfile(includes: Includes, language_config: Languages, diag: Diagnostics) -> None: + """Flag "default" include files that look like a mainfile for some language.""" + if default_paths := _default_include_paths(includes): + for lang_id, language in language_config.languages.items(): + candidates = language.mainfile_candidates(default_paths) + if candidates: + names = ', '.join(str(candidate) for candidate in candidates) + diag.error(f'Include files for language "{DEFAULT_LANGUAGE}" set a mainfile for language "{lang_id}": {names}') diff --git a/problemtools/model/__init__.py b/problemtools/model/__init__.py new file mode 100644 index 00000000..80be73af --- /dev/null +++ b/problemtools/model/__init__.py @@ -0,0 +1,9 @@ +from .includes import DEFAULT_LANGUAGE, IncludeFile, LanguageIncludes, Includes, load_includes + +__all__ = [ + 'DEFAULT_LANGUAGE', + 'IncludeFile', + 'LanguageIncludes', + 'Includes', + 'load_includes', +] diff --git a/problemtools/model/includes.py b/problemtools/model/includes.py new file mode 100644 index 00000000..5f2c94dd --- /dev/null +++ b/problemtools/model/includes.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass, field +from pathlib import Path + +from ..languages import Language, Languages + +#: Pseudo-language whose include files are added for every language. +DEFAULT_LANGUAGE = 'default' + + +@dataclass +class IncludeFile: + """A single include file. + + `path` is relative to the include directory for its language, e.g. for + include/cpp/Vector/Vector.h, path is Vector/Vector.h. + """ + + path: Path + data: bytes + + +@dataclass +class LanguageIncludes: + mainfile: str | None = None + files: list[IncludeFile] = field(default_factory=list) + + +@dataclass +class Includes: + """All include files for a problem, keyed by language ID. + + The key DEFAULT_LANGUAGE holds files that are added to every language. + """ + + languages: dict[str, LanguageIncludes] = field(default_factory=dict) + + +def load_includes(probdir: Path, language_config: Languages) -> Includes: + include_dir = probdir / 'include' + 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) + return includes + + +def _load_language_includes(lang_dir: Path, language: Language | None) -> LanguageIncludes: + files = [ + IncludeFile(path=path.relative_to(lang_dir), data=path.read_bytes()) + for path in sorted(p for p in lang_dir.rglob('*') if p.is_file()) + ] + + mainfile = None + if language is not None: + candidates = language.mainfile_candidates([f.path for f in files]) + if candidates: + mainfile = str(candidates[0]) + + return LanguageIncludes(mainfile=mainfile, files=files) From b99ac59abec3cb751d989623159821d7d810b365 Mon Sep 17 00:00:00 2001 From: Gunnar Kreitz Date: Thu, 16 Jul 2026 10:41:28 +0200 Subject: [PATCH 4/4] Run new checks for include files in verifyproblem --- problemtools/verifyproblem.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 6b8dcc90..7347dc1b 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -22,9 +22,11 @@ import yaml +from . import checks from . import config from . import languages from . import metadata +from . import model from . import problem2html from . import problem2pdf from . import run @@ -1052,6 +1054,30 @@ def run_junk_case(case_desc: str, junk_content: bytes, testcases: list[TestCase] return self._check_res +class Includes(ProblemPart): + """Seam to integrate a model + checks setup into verifyproblem in a somewhat clean way""" + + PART_NAME = 'includes' + + def setup(self): + self.includes = model.load_includes(Path(self.problem.probdir), self.problem.language_config) + + def check(self, context: Context) -> bool: + if self._check_res is not None: + return self._check_res + self._check_res = True + + errors_before = self.errors + checks.check_includes(self.includes, self.problem.language_config, self.problem.format, self._diag) + if self.errors > errors_before: + self._check_res = False + + return self._check_res + + def __str__(self) -> str: + return 'includes' + + class Submissions(ProblemPart): # (verdict, directory, required) _VERDICTS: list[tuple[Verdict, str, bool]] = [ @@ -1421,6 +1447,7 @@ def load(self) -> None: self.output_validators = OutputValidators(self) self.graders = Graders(self) self.testdata = TestCaseGroup(self, os.path.join(self.probdir, 'data')) + self.includes = Includes(self) self.submissions = Submissions(self) self.loaded = True @@ -1459,7 +1486,7 @@ def check(self, context: Context) -> tuple[int, int]: 'validators': [self.input_validators, self.output_validators], 'graders': [self.graders], 'data': [self.testdata], - 'submissions': [self.submissions], + 'submissions': [self.includes, self.submissions], } assert sorted(part_mapping.keys()) == sorted(PROBLEM_PARTS), 'part_mapping and PROBLEM_PARTS must be kept in sync'