diff --git a/problemtools/formatversion.py b/problemtools/formatversion.py new file mode 100644 index 00000000..12af9169 --- /dev/null +++ b/problemtools/formatversion.py @@ -0,0 +1,80 @@ +import os +import yaml +from dataclasses import dataclass + + +VERSION_LEGACY = "legacy" +VERSION_2023_07 = "2023-07" + + +@dataclass(frozen=True) +class FormatData: + """ + A class containing data specific to the format version. + name: the version name. + statement_directory: the directory where the statements should be found. + statement_extensions: the allowed extensions for the statements. + """ + name: str + statement_directory: str + statement_extensions: list[str] + + +FORMAT_DATACLASSES = { + VERSION_LEGACY: FormatData(name=VERSION_LEGACY, statement_directory="problem_statement", statement_extensions=["tex"]), + VERSION_2023_07: FormatData(name=VERSION_2023_07, statement_directory="statement", statement_extensions=["md", "tex"]) +} + + +def detect_problem_version(path) -> str: + """ + Returns the problem version value of problem.yaml or throws an error if it is unable to read the file. + Args: + path: the problem path + + Returns: + the version name as a String + + """ + config_path = os.path.join(path, 'problem.yaml') + try: + with open(config_path) as f: + config: dict = yaml.safe_load(f) or {} + except Exception as e: + raise VersionError(f"Error reading problem.yaml: {e}") + return config.get('problem_format_version', VERSION_LEGACY) + + +def get_format_data(path): + """ + Gets the dataclass object containing the necessary data for a problem format. + Args: + path: the problem path + + Returns: + the dataclass object containing the necessary data for a problem format + + """ + return get_format_data_by_name(detect_problem_version(path)) + + +def get_format_data_by_name(name): + """ + Gets the dataclass object containing the necessary data for a problem format given the format name. + Args: + name: the format name + + Returns: + the dataclass object containing the necessary data for a problem format + + """ + data = FORMAT_DATACLASSES.get(name) + if not data: + raise VersionError(f"No version found with name {name}") + else: + return data + + +class VersionError(Exception): + pass + diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 6bf56192..86137a59 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -123,6 +123,7 @@ def get_parser() -> argparse.ArgumentParser: parser.add_argument('-L', '--log-level', dest='loglevel', help='set log level (debug, info, warning, error, critical)', default='warning') parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help="quiet", default=False) parser.add_argument('-i', '--imgbasedir', dest='imgbasedir', default='') + parser.add_argument('-v', '--format-version', dest='format_version', help='choose format version', default="automatic") parser.add_argument('problem', help='the problem to convert') return parser diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 0f6fc452..ec2b3ad1 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -50,6 +50,7 @@ def get_parser() -> argparse.ArgumentParser: parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help="quiet", default=False) parser.add_argument('-l', '--language', dest='language', help='choose alternate language (2-letter code)', default=None) parser.add_argument('-n', '--no-pdf', dest='nopdf', action='store_true', help='run pdflatex in -draftmode', default=False) + parser.add_argument('-v', '--format-version', dest='format_version', help='choose format version', default="automatic") parser.add_argument('problem', help='the problem to convert') return parser diff --git a/problemtools/template.py b/problemtools/template.py index 0d5951f7..f0c7bc4b 100644 --- a/problemtools/template.py +++ b/problemtools/template.py @@ -4,6 +4,8 @@ import tempfile import shutil +from . import formatversion + # For backwards compatibility, remove in bright and shiny future. def detect_version(problemdir, problemtex): @@ -14,14 +16,20 @@ def detect_version(problemdir, problemtex): class Template: - def __init__(self, problemdir, language=None, force_copy_cls=False): + def __init__(self, problemdir, language=None, force_copy_cls=False, version="automatic"): if not os.path.isdir(problemdir): raise Exception('%s is not a directory' % problemdir) if problemdir[-1] == '/': problemdir = problemdir[:-1] - stmtdir = os.path.join(problemdir, 'problem_statement') + if version == "automatic": + version_data = formatversion.get_format_data(problemdir) + + else: + version_data = formatversion.get_format_data_by_name(version) + + stmtdir = os.path.join(problemdir, version_data.statement_directory) langs = [] if glob.glob(os.path.join(stmtdir, 'problem.tex')): langs.append('') @@ -115,3 +123,4 @@ def __exit__(self, exc_type, exc_value, exc_traceback): def get_file_name(self): assert os.path.isfile(self.filename) return self.filename + diff --git a/problemtools/templates/latex/problemset.cls b/problemtools/templates/latex/problemset.cls index 8c198243..1700901e 100644 --- a/problemtools/templates/latex/problemset.cls +++ b/problemtools/templates/latex/problemset.cls @@ -163,7 +163,12 @@ %% Problem inclusion \newcommand{\includeproblem}[1]{ \startproblem{#1} - \import{#1/problem_statement/}{problem\@problemlanguage.tex} +\IfFileExists{#1/statement/problem\@problemlanguage.tex}{% + \import{#1/statement/}{problem\@problemlanguage.tex}% +}{% + \import{#1/problem_statement/}{problem\@problemlanguage.tex}% +} + %% Automatically include samples 1..9, if enabled \ifplastex\else diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 646e262f..3aa566d5 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -28,6 +28,7 @@ from . import problem2pdf from . import problem2html +from . import formatversion from . import config from . import languages @@ -702,24 +703,28 @@ def aggregate_results(self, sub, sub_results: list[SubmissionResult], shadow_res self.error(f'submission {sub} got {res} on group {groupname}, which is outside of expected score range [{min_score}, {max_score}]') return res - def all_datasets(self) -> list: res: list = [] for child in self._items: res += child.all_datasets() return res + class ProblemStatement(ProblemPart): PART_NAME = 'statement' - EXTENSIONS: list[str] = [] def setup(self): - if not self.EXTENSIONS: - raise NotImplementedError('Need to override class and set EXTENSIONS class-variable') + self.format_data = formatversion.get_format_data(self.problem.probdir) + if not self.format_data: + raise NotImplementedError('No version selected.') self.debug(' Loading problem statement') - self.statement_regex = re.compile(r"problem(\.([a-z]{2,3}|[a-z]{2}-[A-Z]{2}))?\.(%s)$" % ('|'.join(self.EXTENSIONS))) - dir = os.path.join(self.problem.probdir, 'problem_statement') - self.statements = [(m.group(0), m.group(2) or '') for file in os.listdir(dir) if (m := re.search(self.statement_regex, file))] + self.statement_regex = re.compile(r"problem(\.([a-z]{2,3}|[a-z]{2}-[A-Z]{2}))?\.(%s)$" % ('|'.join(self.format_data.statement_extensions))) + dir = os.path.join(self.problem.probdir, self.format_data.statement_directory) + if os.path.isdir(dir): + self.statements = [(m.group(0), m.group(2) or '') for file in os.listdir(dir) if (m := re.search(self.statement_regex, file))] + else: + self.error(f"No directory named {self.format_data.statement_directory} found") + self.statements = [] return self.get_config() @@ -729,8 +734,8 @@ def check(self, context: Context) -> bool: self._check_res = True if not self.statements: - allowed_statements = ', '.join(f'problem.{ext}, problem.[a-z][a-z].{ext}' for ext in self.EXTENSIONS) - self.error(f'No problem statements found (expected file of one of following forms in folder problem_statement/: {allowed_statements}') + allowed_statements = ', '.join(f'problem.{ext}, problem.[a-z][a-z].{ext}' for ext in self.format_data.statement_extensions) + self.error(f'No problem statements found (expected file of one of following forms in directory {self.format_data.statement_directory}/: {allowed_statements})') langs = [lang or 'en' for _, lang in self.statements] for lang, count in collections.Counter(langs).items(): @@ -767,7 +772,8 @@ def __str__(self) -> str: def get_config(self) -> dict[str, dict[str, str]]: ret: dict[str, dict[str, str]] = {'name':{}} for filename, lang in self.statements: - with open(os.path.join(self.problem.probdir, 'problem_statement', filename)) as f: + dir = os.path.join(self.problem.probdir, self.format_data.statement_directory) + with open(os.path.join(dir, filename)) as f: stmt = f.read() hit = re.search(r'\\problemname{(.*)}', stmt, re.MULTILINE) if hit: @@ -775,11 +781,6 @@ def get_config(self) -> dict[str, dict[str, str]]: ret['name'][lang] = problem_name return ret if ret['name'] else {} -class ProblemStatementLegacy(ProblemStatement): - EXTENSIONS = ['tex'] - -class ProblemStatement2023_07(ProblemStatement): - EXTENSIONS = ['md', 'tex'] class ProblemConfig(ProblemPart): PART_NAME = 'config' @@ -1730,14 +1731,14 @@ def check(self, context: Context) -> bool: PROBLEM_FORMATS: dict[str, dict[str, list[Type[ProblemPart]]]] = { 'legacy': { 'config': [ProblemConfig], - 'statement': [ProblemStatementLegacy, Attachments], + 'statement': [ProblemStatement, Attachments], 'validators': [InputValidators, OutputValidators], 'graders': [Graders], 'data': [ProblemTestCases], 'submissions': [Submissions], }, '2023-07': { # TODO: Add all the parts - 'statement': [ProblemStatement2023_07, Attachments], + 'statement': [ProblemStatement, Attachments], } }