Repository: Kattis/problemtools
Pull request #289: Allow verifying statements according to the new problem format
Author: login: ElliotRipa
Created: 2025-03-18T21:02:44Z | Merged: 2025-03-28T17:20:35Z

## Overview
* verifyproblem.py now checks the appropriate location for the problem statements based on the version of problem format.
* problem2html.py and problem2pdf.py now both take a flag "-F" which specifies the format used, which defaults to legacy. This flag is added because both files take the problem directory as an argument and therefore need to know where to expect the statements.
* Also allows the constructor for template.py to specify a version for the same reasons.
* The cls templates now also allow for using either version.


Diff:
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],
     }
 }
 


Reviews:
user: login: gkreitz
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T09:56:37Z
user: login: ElliotRipa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T12:56:08Z
user: login: gkreitz
body: Overall, this looks good. I added some minor comments, somewhat on the picky side.

This will need to be rebased, as there are merge conflicts. When rebasing, please also consider squishing a bit to get a cleaner commit history for this PR.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T18:29:18Z
user: login: gkreitz
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T17:20:29Z

Review comments:
diff_hunk: @@ -710,27 +711,33 @@ def all_datasets(self) -> list:
 
 class ProblemStatement(ProblemPart):
     PART_NAME = 'statement'
-
-    EXTENSIONS = []
+    STATEMENT_DATA = None
 
     def setup(self):
-        if not self.EXTENSIONS:
-            raise NotImplementedError('Need to override class and set EXTENSIONS class-variable')
+        if not self.STATEMENT_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.STATEMENT_DATA.get_statement_extensions())))
+        dir = os.path.join(self.problem.probdir, self.STATEMENT_DATA.get_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.STATEMENT_DATA.get_statement_directory()} found")
+            self.statements = []
 
         return self.get_config()
 
     def check(self, context: Context) -> bool:
+        STATEMENT_DATA = self.STATEMENT_DATA
+        if not STATEMENT_DATA:
+            raise NotImplementedError('No version selected.')
         if self._check_res is not None:
             return self._check_res
         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 STATEMENT_DATA.get_statement_extensions)
path: problemtools/verifyproblem.py
user: login: gkreitz
body: You probably meant to call `STATEMENT_DATA.get_statement_extensions` here.
created_at: 2025-03-24T09:56:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -710,27 +711,33 @@ def all_datasets(self) -> list:
 
 class ProblemStatement(ProblemPart):
     PART_NAME = 'statement'
-
-    EXTENSIONS = []
+    STATEMENT_DATA = None
 
     def setup(self):
-        if not self.EXTENSIONS:
-            raise NotImplementedError('Need to override class and set EXTENSIONS class-variable')
+        if not self.STATEMENT_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.STATEMENT_DATA.get_statement_extensions())))
+        dir = os.path.join(self.problem.probdir, self.STATEMENT_DATA.get_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.STATEMENT_DATA.get_statement_directory()} found")
+            self.statements = []
 
         return self.get_config()
 
     def check(self, context: Context) -> bool:
+        STATEMENT_DATA = self.STATEMENT_DATA
+        if not STATEMENT_DATA:
+            raise NotImplementedError('No version selected.')
         if self._check_res is not None:
             return self._check_res
         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 STATEMENT_DATA.get_statement_extensions)
path: problemtools/verifyproblem.py
user: login: ElliotRipa
body: Yes, you're right. Thank you
created_at: 2025-03-24T12:56:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+import os
+import yaml
+
+
+"""
+The data specific to any given format version.
+"""
+FORMAT_DATA = {
+    "legacy": {
path: problemtools/formatversion.py
user: login: gkreitz
body: You probably want to use the `VERSION_*` constants in this dict rather than copy-pasting the values.
created_at: 2025-03-25T18:03:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+import os
+import yaml
+
+
+"""
+The data specific to any given format version.
+"""
+FORMAT_DATA = {
+    "legacy": {
+        "name": "legacy",
+        "statement_directory": "problem_statement",
+        "statement_extensions": ["tex"]
+    },
+    "2023-07": {
+        "name": "2023-07",
+        "statement_directory": "statement",
+        "statement_extensions": ["md", "tex"],
+    }
+}
+
+VERSION_LEGACY = "legacy"
+VERSION_2023_07 = "2023-07"
+
+
+"""
+Returns the problem version value of problem.yaml or throws an error if it is unable to read the file.
+"""
+def detect_problem_version(path) -> str:
+    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', 'legacy')
+
+
+"""
+Returns a dictionary containing the necessary data for a file format.
+"""
+def get_format_data(path):
path: problemtools/formatversion.py
user: login: gkreitz
body: Can't this be implemented as `return get_format_data_by_name(detect_problem_version(path))` to avoid code duplication?
created_at: 2025-03-25T18:06:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+import os
+import yaml
+
+
+"""
+The data specific to any given format version.
+"""
+FORMAT_DATA = {
+    "legacy": {
+        "name": "legacy",
+        "statement_directory": "problem_statement",
+        "statement_extensions": ["tex"]
+    },
+    "2023-07": {
+        "name": "2023-07",
+        "statement_directory": "statement",
+        "statement_extensions": ["md", "tex"],
+    }
+}
+
+VERSION_LEGACY = "legacy"
+VERSION_2023_07 = "2023-07"
+
+
+"""
+Returns the problem version value of problem.yaml or throws an error if it is unable to read the file.
+"""
+def detect_problem_version(path) -> str:
+    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', 'legacy')
+
+
+"""
+Returns a dictionary containing the necessary data for a file format.
path: problemtools/formatversion.py
user: login: gkreitz
body: These functions should document what keys the dict has. "The necessary data" is pretty vague. :)

(The best/easiest way to document what keys to expect would be to return a dataclass rather than a dict.)
created_at: 2025-03-25T18:09:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,62 @@
+import os
+import yaml
+
+
+"""
+The data specific to any given format version.
+"""
+FORMAT_DATA = {
+    "legacy": {
+        "name": "legacy",
+        "statement_directory": "problem_statement",
+        "statement_extensions": ["tex"]
+    },
+    "2023-07": {
+        "name": "2023-07",
+        "statement_directory": "statement",
+        "statement_extensions": ["md", "tex"],
+    }
+}
+
+VERSION_LEGACY = "legacy"
+VERSION_2023_07 = "2023-07"
+
+
+"""
path: problemtools/formatversion.py
user: login: gkreitz
body: AFAIK, docstrings should be the first thing *in* the function, not before?
created_at: 2025-03-25T18:12:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -701,25 +702,29 @@ 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 = []
+    FORMAT_DATA = None
path: problemtools/verifyproblem.py
user: login: gkreitz
body: Why is this in all caps when it seems to be an instance variable?

Also, why are you initializing it here?
created_at: 2025-03-25T18:14:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -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.get('statement_directory'))
path: problemtools/template.py
user: login: gkreitz
body: Is there a reason you index the version data dict with `get`? I *think* the vast majority of the code base indexes dicts using brackets. (This comment applies throughout the PR in a lot of places).
created_at: 2025-03-25T18:22:51Z
author_association: CONTRIBUTOR

PR comments:
user: login: simonlindholm
created_at: 2025-03-18T21:06:45Z
body: > problem2html.py and problem2pdf.py now both take a flag "-F" which specifies the format used, which defaults to legacy. This flag is added because both files take the problem directory as an argument and therefore need to know where to expect the statements.

Can we read problem.yaml for this? As a problemtools user, having to pass -F every time will be very annoying.
author_association: MEMBER
user: login: niemela
created_at: 2025-03-18T22:57:12Z
body: > Can we read problem.yaml for this? As a problemtools user, having to pass -F every time will be very annoying.

Agreed. If `problem_format_version` is not in `problem.yaml`, then the version is `legacy` otherwise it is what is given, so this is always well defined. It's fine to provide `-F` as a way to override the format version though (I.e. validate this as if it was this version instead).
author_association: MEMBER
user: login: Matistjati
created_at: 2025-03-20T17:42:37Z
body: I think it would be nice to change DIR_END to something more clear such as STATEMENT_DIR. Additionally, I dislike having a copy of `detect_problem_version` in template.py. It would probably be good for the future to extract `detect_problem_version` so it can be used by multiple files.

Also, it would be probably be wise to use constants instead of hard-coding strings such as "2023-07". Perhaps create a file version.py that has these constants, the `detect_problem_version` function and more? I'm not too familiar with the new code, opinions @Zazmuz @square-cylinder ?

Finally, I think version "default" is strange. It would probably be better to explicitly set a default problem version that exists, i.e., legacy.
author_association: CONTRIBUTOR
user: login: Zazmuz
created_at: 2025-03-20T20:31:28Z
body: @Matistjati I think your input is very valid and think it makes a lot of sense. I am a little unsure what DIR_END stands for or does but STATEMENT_DIR makes more sense. 

Having a copy of `detect_problem_version` should be avoided, either by splitting out the functionality to the config.py since it could be argued to be 'config esq' or as you suggested with a version.py. Personally I'm leaning towards version.py.
When splitting it out I think a good idea would be to have a mapping from version name eg. "2023-07" "legacy" to its corresponding statement directory as a dict in version.py or config.py whichever is decided, is DIR_END still needed then?

Should there be one latex template per format maybe? Instead of checking for the old folder then the new. It feels like it could build up to a mess over time.

Maybe also throwing a VersionError instead of a generic RuntimeError would be a good idea.


author_association: CONTRIBUTOR
user: login: Zazmuz
created_at: 2025-03-20T20:39:02Z
body: It could also be argued that when splitting it into version.py that the `EXTENSIONS = ['tex']` and `EXTENSIONS = ['md', 'tex']` should be moved out and have a similar mapping as I proposed in the last comment therefore allowing problem_statement to be fully generic between the two formats.
author_association: CONTRIBUTOR
user: login: ElliotRipa
created_at: 2025-03-21T16:11:01Z
body: Alright, this feedback seems reasonable, thanks. I'll try to implement these changes
author_association: CONTRIBUTOR
user: login: ElliotRipa
created_at: 2025-03-23T22:35:38Z
body: The code specific to certain format versions has now been moved to a separate file, and the feedback provided here has been implemented. Also, the format-version flag was changed to -v to stay consistent with similar functions in verifyproblem.py and made optional to use automatic detection
author_association: CONTRIBUTOR
user: login: Zazmuz
created_at: 2025-03-24T13:34:50Z
body: There were some good changes here! However:
* The flag for problem2html is still -F
* I am not a big fan of the abstract class that you use for formatversion.py. I still stand by that you should save the data in dictionaries to allow for easy generic behavior between different versions not having random if's in a function to instantiate a class that stores next to no data. Instead having the problem format names easily accessible in that module and using the name as a key too find the other information. Now you have basically just moved a class from verifyproblem.py and changed its name and made it inherent from an abstract. This is not necessarily wrong but its excessive and is more error prone. 
The STATEMENT_EXTENSIONS and STATEMENT_DIRECTORY being looked up be the easily accessible name. What is your opinion @square-cylinder, not sure how much @Matistjati looks at problemtools but usually has good feedback. 
* The change to 'automatic' is good
* Still think we should consider having two separate latex templates, what is everyone's opinion? 

Overall good improvements.
author_association: CONTRIBUTOR
user: login: ElliotRipa
created_at: 2025-03-24T17:42:27Z
body: The flag has been changed to be -v in both places now. Also the data objects in formatversion.py have been replaced with a dictionary instead.
author_association: CONTRIBUTOR
user: login: Matistjati
created_at: 2025-03-24T18:45:44Z
body: I don't see any constants? Concretely, my suggestion is to expose
`VERSION_LEGACY = "legacy"`
and
`VERSION_2023_07 = "2023_07"`

This way, you can use
`from problem_version import VERSION_LEGACY`
in other files. That way, there is no risk of misspelling problem versions, as it will lead to a "compile error". I suggested this earlier but don't see it in the PR, are you against it? 

Also, didn't we agree to not touch problemset_0.1.cls? Since any packages using that one are likely invalid in the 2023-07 format.
author_association: CONTRIBUTOR
user: login: Zazmuz
created_at: 2025-03-24T19:04:45Z
body: I don't fully see the point of get_format_data, when you could just index the dictionary.

As I see it, you would use `detect_problem_version` to get a name for the format. After which you can use the same string with the same functionality to index the dictionary and it will just work. The functions seem unnecessary. Also having `"name": "legacy"` seems weird, since you use that value as a key to get the dictionary in the first place.


More importantly, I think the code is suffering a little from all the changes and should be polished and cleaned up.
For example, `self.FORMAT_DATA = formatversion.get_format_data(self.problem.probdir)` should not be in the __init__, that is what setup() is for. @square-cylinder pointed out that your code should just crash since you check if `FORMAT_DATA` is set but you set it after checking.


author_association: CONTRIBUTOR
user: login: ElliotRipa
created_at: 2025-03-24T21:32:00Z
body: > I don't fully see the point of get_format_data, when you could just index the dictionary.
> 
> As I see it, you would use `detect_problem_version` to get a name for the format. After which you can use the same string with the same functionality to index the dictionary and it will just work. The functions seem unnecessary. Also having `"name": "legacy"` seems weird, since you use that value as a key to get the dictionary in the first place.

The reason for `get_format_data` was that, since users will rarely if ever need to handle data specific to multiple versions at once, having a method that simply gives the information relevant to the current format could be desired. This would further mean that the details of implementation aren't as necessary as you can simply make use of the parts of the dictionary currently relevant.  

Having `"name": "legacy"` was done mostly in the case that the dictionary in some later case might be passed to a function. Then this could be used to get the version it applies to. However this was mostly a minor afterthought and not one I feel very strongly about.


As for the second half of the comment, it seemed to work fine for me without crashing, but I see no real reason to keep it in `__init__` so I'll move it and check for anything similar in the code.
author_association: CONTRIBUTOR
user: login: ElliotRipa
created_at: 2025-03-24T21:35:58Z
body: > I don't see any constants? Concretely, my suggestion is to expose `VERSION_LEGACY = "legacy"` and `VERSION_2023_07 = "2023_07"`
> 
> This way, you can use `from problem_version import VERSION_LEGACY` in other files. That way, there is no risk of misspelling problem versions, as it will lead to a "compile error". I suggested this earlier but don't see it in the PR, are you against it?

I can add those as well. The main reason for not having that as a priority currently was since it likely won't be used in the code for the statements, nor specific to this PR in any greater extent than this provides some extra overhead for the format versioning itself.

> Also, didn't we agree to not touch problemset_0.1.cls? Since any packages using that one are likely invalid in the 2023-07 format.

Yes, I can revert that back to the previous version


author_association: CONTRIBUTOR
user: login: Matistjati
created_at: 2025-03-25T15:58:09Z
body: Nice! Given my (comparatively limited) knowledge, I think we're ready to merge. @Zazmuz @square-cylinder opinions? 
author_association: CONTRIBUTOR
user: login: Matistjati
created_at: 2025-03-25T15:59:16Z
body: Also @ElliotRipa, for the future, please rebase instead of merging. Currently, ~40% of the commits in this PR are merges, and it makes it harder to follow. 
author_association: CONTRIBUTOR
user: login: ElliotRipa
created_at: 2025-03-28T17:12:15Z
body: All right, the changes have been implemented now. I wound up changing to a dataclass instead of a dict, and I also rebased to get a cleaner commit history
author_association: CONTRIBUTOR