Skip to content
Open
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
5 changes: 5 additions & 0 deletions problemtools/checks/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .includes import check_includes

__all__ = [
'check_includes',
]
69 changes: 69 additions & 0 deletions problemtools/checks/includes.py
Original file line number Diff line number Diff line change
@@ -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}')
13 changes: 13 additions & 0 deletions problemtools/languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions problemtools/model/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from .includes import DEFAULT_LANGUAGE, IncludeFile, LanguageIncludes, Includes, load_includes

__all__ = [
'DEFAULT_LANGUAGE',
'IncludeFile',
'LanguageIncludes',
'Includes',
'load_includes',
]
63 changes: 63 additions & 0 deletions problemtools/model/includes.py
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 0 additions & 6 deletions problemtools/run/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 2 additions & 4 deletions problemtools/run/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
Implementation of programs provided by source code.
"""

import re
import os
import shlex
import tempfile
Expand Down Expand Up @@ -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:]
Expand Down
29 changes: 28 additions & 1 deletion problemtools/verifyproblem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]] = [
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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'

Expand Down