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
80 changes: 80 additions & 0 deletions problemtools/formatversion.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this be implemented as return get_format_data_by_name(detect_problem_version(path)) to avoid code duplication?

"""
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

1 change: 1 addition & 0 deletions problemtools/problem2html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions problemtools/problem2pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions problemtools/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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('')
Expand Down Expand Up @@ -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

7 changes: 6 additions & 1 deletion problemtools/templates/latex/problemset.cls
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 18 additions & 17 deletions problemtools/verifyproblem.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

from . import problem2pdf
from . import problem2html
from . import formatversion

from . import config
from . import languages
Expand Down Expand Up @@ -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()

Expand All @@ -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():
Expand Down Expand Up @@ -767,19 +772,15 @@ 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:
problem_name = hit.group(1).strip()
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'
Expand Down Expand Up @@ -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],
}
}

Expand Down