Repository: PEtab-dev/libpetab-python

Issue:
v2: version check 
`pteab.v2.Problem.from_yaml` will only accept "2.0.0" as version string, even though it sounds like "2" would also be valid according to spec.

see https://github.com/PEtab-dev/libpetab-python/blob/99373c4340512def5a23bf2a75805c3f88251251/petab/v2/problem.py#L139

Discussion:

Patch:
diff --git a/petab/v1/problem.py b/petab/v1/problem.py
index 5c0dcf6..373b6b4 100644
--- a/petab/v1/problem.py
+++ b/petab/v1/problem.py
@@ -13,10 +13,10 @@ from warnings import warn
 import pandas as pd
 from pydantic import AnyUrl, BaseModel, Field, RootModel
 
+from ..versions import get_major_version
 from . import (
     conditions,
     core,
-    format_version,
     mapping,
     measurements,
     observables,
@@ -290,13 +290,13 @@ class Problem:
                 "petab.CompositeProblem.from_yaml() instead."
             )
 
-        if yaml_config[FORMAT_VERSION] not in {"1", 1, "1.0.0", "2.0.0"}:
+        major_version = get_major_version(yaml_config)
+        if major_version not in {1, 2}:
             raise ValueError(
                 "Provided PEtab files are of unsupported version "
-                f"{yaml_config[FORMAT_VERSION]}. Expected "
-                f"{format_version.__format_version__}."
+                f"{yaml_config[FORMAT_VERSION]}."
             )
-        if yaml_config[FORMAT_VERSION] == "2.0.0":
+        if major_version == 2:
             warn("Support for PEtab2.0 is experimental!", stacklevel=2)
             warn(
                 "Using petab.v1.Problem with PEtab2.0 is deprecated. "
@@ -321,7 +321,7 @@ class Problem:
                 if config.parameter_file
                 else None
             )
-        if config.format_version.root in [1, "1", "1.0.0"]:
+        if major_version == 1:
             if len(problem0.sbml_files) > 1:
                 # TODO https://github.com/PEtab-dev/libpetab-python/issues/6
                 raise NotImplementedError(
diff --git a/petab/v1/yaml.py b/petab/v1/yaml.py
index ecffc48..5115945 100644
--- a/petab/v1/yaml.py
+++ b/petab/v1/yaml.py
@@ -12,15 +12,15 @@ import pandas as pd
 import yaml
 from pandas.io.common import get_handle
 
+from ..versions import parse_version
 from .C import *  # noqa: F403
 
 # directory with PEtab yaml schema files
 SCHEMA_DIR = Path(__file__).parent.parent / "schemas"
 # map of version number to validation schema
 SCHEMAS = {
-    "1": SCHEMA_DIR / "petab_schema.v1.0.0.yaml",
-    "1.0.0": SCHEMA_DIR / "petab_schema.v1.0.0.yaml",
-    "2.0.0": SCHEMA_DIR / "petab_schema.v2.0.0.yaml",
+    (1, 0): SCHEMA_DIR / "petab_schema.v1.0.0.yaml",
+    (2, 0): SCHEMA_DIR / "petab_schema.v2.0.0.yaml",
 }
 
 __all__ = [
@@ -71,14 +71,18 @@ def validate_yaml_syntax(
     yaml_config = load_yaml(yaml_config)
 
     if schema is None:
-        # try get PEtab version from yaml file
+        # try to get PEtab version from the yaml file
         #  if this is not the available, the file is not valid anyways,
         #  but let's still use the latest PEtab schema for full validation
+        version = yaml_config.get(FORMAT_VERSION, None)
         version = (
-            yaml_config.get(FORMAT_VERSION, None) or list(SCHEMAS.values())[-1]
+            parse_version(version)[:2]
+            if version
+            else list(SCHEMAS.values())[-1]
         )
+
         try:
-            schema = SCHEMAS[str(version)]
+            schema = SCHEMAS[version]
         except KeyError as e:
             raise ValueError(
                 "Unknown PEtab version given in problem "
diff --git a/petab/v2/problem.py b/petab/v2/problem.py
index 79bb619..bcf3876 100644
--- a/petab/v2/problem.py
+++ b/petab/v2/problem.py
@@ -28,6 +28,7 @@ from ..v1.models.model import Model, model_factory
 from ..v1.problem import ListOfFiles, VersionNumber
 from ..v1.yaml import get_path_prefix
 from ..v2.C import *  # noqa: F403
+from ..versions import parse_version
 from . import conditions, experiments
 
 if TYPE_CHECKING:
@@ -161,14 +162,15 @@ class Problem:
                 return filename
             return f"{base_path}/{filename}"
 
-        if yaml_config[FORMAT_VERSION] not in {"2.0.0"}:
+        if (format_version := parse_version(yaml_config[FORMAT_VERSION]))[
+            0
+        ] != 2:
             # If we got a path to a v1 yaml file, try to auto-upgrade
             from tempfile import TemporaryDirectory
 
-            from ..versions import get_major_version
             from .petab1to2 import petab1to2
 
-            if get_major_version(yaml_config) == 1 and yaml_file:
+            if format_version[0] == 1 and yaml_file:
                 logging.debug(
                     "Auto-upgrading problem from PEtab 1.0 to PEtab 2.0"
                 )
@@ -185,7 +187,7 @@ class Problem:
                     )
             raise ValueError(
                 "Provided PEtab files are of unsupported version "
-                f"{yaml_config[FORMAT_VERSION]}. Expected 2.0.0."
+                f"{yaml_config[FORMAT_VERSION]}."
             )
 
         if yaml.is_composite_problem(yaml_config):
diff --git a/petab/versions.py b/petab/versions.py
index 93f6a60..e19d0cd 100644
--- a/petab/versions.py
+++ b/petab/versions.py
@@ -1,35 +1,62 @@
 """Handling of PEtab version numbers."""
 from __future__ import annotations
 
+import re
 from pathlib import Path
 
 import petab
-from petab.v1 import Problem as V1Problem
-from petab.v1.C import FORMAT_VERSION
-from petab.v1.yaml import load_yaml
 
 __all__ = [
     "get_major_version",
+    "parse_version",
 ]
+from . import v1
+
+# version regex pattern
+_version_pattern = (
+    r"(?P<major>\d+)(?:\.(?P<minor>\d+))?"
+    r"(?:\.(?P<patch>\d+))?(?P<suffix>[\w.]+)?"
+)
+_version_re = re.compile(_version_pattern)
+
+
+def parse_version(version: str | int) -> tuple[int, int, int, str]:
+    """Parse a version string into a tuple of integers and suffix."""
+    if isinstance(version, int):
+        return version, 0, 0, ""
+
+    version = str(version)
+    match = _version_re.match(version)
+    if match is None:
+        raise ValueError(f"Invalid version string: {version}")
+
+    major = int(match.group("major"))
+    minor = int(match.group("minor") or 0)
+    patch = int(match.group("patch") or 0)
+    suffix = match.group("suffix") or ""
+
+    return major, minor, patch, suffix
 
 
 def get_major_version(
-    problem: str | dict | Path | V1Problem | petab.v2.Problem,
+    problem: str | dict | Path | petab.v1.Problem | petab.v2.Problem,
 ) -> int:
     """Get the major version number of the given problem."""
     version = None
 
     if isinstance(problem, str | Path):
+        from petab.v1.yaml import load_yaml
+
         yaml_config = load_yaml(problem)
-        version = yaml_config.get(FORMAT_VERSION)
+        version = yaml_config.get(v1.C.FORMAT_VERSION)
     elif isinstance(problem, dict):
-        version = problem.get(FORMAT_VERSION)
+        version = problem.get(v1.C.FORMAT_VERSION)
 
     if version is not None:
         version = str(version)
         return int(version.split(".")[0])
 
-    if isinstance(problem, V1Problem):
+    if isinstance(problem, petab.v1.Problem):
         return 1
 
     from . import v2


Test patch:
diff --git a/tests/test_version.py b/tests/test_version.py
new file mode 100644
index 0000000..757a9b5
--- /dev/null
+++ b/tests/test_version.py
@@ -0,0 +1,13 @@
+"""Tests related to petab.versions"""
+
+from petab.versions import *
+
+
+def test_parse_version():
+    assert parse_version("1.2.3") == (1, 2, 3, "")
+    assert parse_version("1.2.3a") == (1, 2, 3, "a")
+    assert parse_version("1.2") == (1, 2, 0, "")
+    assert parse_version("1") == (1, 0, 0, "")
+    assert parse_version(1) == (1, 0, 0, "")
+    assert parse_version("1.2.3.a") == (1, 2, 3, ".a")
+    assert parse_version("1.2.3.4") == (1, 2, 3, ".4")