Skip to content

Commit 73b680c

Browse files
dweindlclaudedilpath
authored
Fix PEtab v2 extension config model (#506)
* Fix PEtab v2 extension config model (#474) ProblemConfig.extensions was typed as `list[ExtensionConfig] | dict`, but only the dict branch was ever used or supported downstream. On top of that, the extension config models didn't match the v2 schema: neither ExtensionConfig nor SciMLConfig had the schema-mandated `required` field, and the generic ExtensionConfig wrongly nested extra keys under a `config` field instead of allowing them directly. Writing a problem with a `sciml` extension to YAML and reading it back therefore failed schema validation. - Add a shared `ExtensionConfig` base (version, required, extra fields allowed) in petab/v2/extensions/__init__.py, and make SciMLConfig subclass it (required defaults to True, since a SciML hybrid model is virtually always load-bearing). - Type `ProblemConfig.extensions` as `dict[str, ExtensionConfig]` with `SerializeAsAny` so subclass fields survive serialization. - Fix a broken import in SciMLConfig.to_yaml() that made it crash unconditionally. - Problem.validate() still only warns about unsupported extensions (rejecting based on `required` is left to consumers like simulators that actually interpret the extension mathematically). Closes #474 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * less verbose * Apply suggestions from code review Co-authored-by: Dilan Pathirana <59329744+dilpath@users.noreply.github.com> * fixup * Fix CI regression and decouple extension parsing from core.py - Revert extensions field_validator to raise ValueError instead of TypeError: pydantic only converts ValueError/AssertionError raised in a validator into a ValidationError, not TypeError, so the TypeError variant broke test_problem_config_extensions_rejects_non_dict in CI. - Move the per-extension-ID config dispatch (sciml vs. generic) out of ProblemConfig._parse_extensions into petab.v2.extensions.parse_extension_config, so core.py no longer needs to import SciMLConfig or check C.EXT_ID_SCIML directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Dilan Pathirana <59329744+dilpath@users.noreply.github.com>
1 parent 2fd38a7 commit 73b680c

5 files changed

Lines changed: 163 additions & 33 deletions

File tree

petab/v2/core.py

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
BeforeValidator,
3636
ConfigDict,
3737
Field,
38+
SerializeAsAny,
3839
ValidationInfo,
3940
field_serializer,
4041
field_validator,
@@ -53,6 +54,7 @@
5354
from ..v1.yaml import get_path_prefix
5455
from ..versions import parse_version
5556
from . import C, get_observable_df
57+
from .extensions import ExtensionConfig, parse_extension_config
5658

5759
if TYPE_CHECKING:
5860
from ..v2.lint import ValidationResultList, ValidationTask
@@ -311,10 +313,7 @@ def __iadd__(self, other: T) -> BaseTable[T]:
311313

312314
# SciML extension classes — imported after BaseTable is defined to avoid
313315
# circular imports (sciml.py does not import from core.py).
314-
from .extensions.sciml import ( # noqa: E402
315-
SciMLConfig,
316-
SciMLExt,
317-
)
316+
from .extensions.sciml import SciMLExt # noqa: E402
318317

319318

320319
class ProblemExtensions:
@@ -2492,13 +2491,6 @@ class ModelFile(BaseModel):
24922491
)
24932492

24942493

2495-
class ExtensionConfig(BaseModel):
2496-
"""The configuration of a PEtab extension."""
2497-
2498-
version: str
2499-
config: dict
2500-
2501-
25022494
class ProblemConfig(BaseModel):
25032495
"""The PEtab problem configuration."""
25042496

@@ -2541,8 +2533,8 @@ class ProblemConfig(BaseModel):
25412533
# Absolute or relative to `base_path`.
25422534
mapping_files: list[AnyUrl | Path] = []
25432535

2544-
#: Extensions used by the problem.
2545-
extensions: list[ExtensionConfig] | dict = {}
2536+
#: Extensions used by the problem, keyed by extension ID.
2537+
extensions: dict[str, SerializeAsAny[ExtensionConfig]] = {}
25462538

25472539
model_config = ConfigDict(
25482540
validate_assignment=True,
@@ -2553,23 +2545,15 @@ class ProblemConfig(BaseModel):
25532545
def _parse_extensions(cls, v):
25542546
"""Parse extensions dict and convert known extensions to their specific
25552547
config classes."""
2556-
if isinstance(v, dict):
2557-
parsed_extensions = {}
2558-
for ext_name, ext_config in v.items():
2559-
if ext_name == C.EXT_ID_SCIML:
2560-
parsed_extensions[ext_name] = (
2561-
ext_config
2562-
if isinstance(ext_config, SciMLConfig)
2563-
else SciMLConfig(**ext_config)
2564-
)
2565-
else:
2566-
parsed_extensions[ext_name] = (
2567-
ext_config
2568-
if isinstance(ext_config, ExtensionConfig)
2569-
else ExtensionConfig(**ext_config)
2570-
)
2571-
return parsed_extensions
2572-
return v
2548+
if not isinstance(v, dict):
2549+
raise ValueError(
2550+
"extensions must be a dict of extension ID to extension "
2551+
f"config, got {type(v)}."
2552+
)
2553+
return {
2554+
ext_id: parse_extension_config(ext_id, ext_config)
2555+
for ext_id, ext_config in v.items()
2556+
}
25732557

25742558
# convert parameter_file to list
25752559
@field_validator(

petab/v2/extensions/__init__.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from pydantic import BaseModel, ConfigDict
2+
3+
__all__ = ["ExtensionConfig", "parse_extension_config"]
4+
5+
6+
class ExtensionConfig(BaseModel):
7+
"""The configuration of a PEtab extension."""
8+
9+
#: The extension's semantic version.
10+
version: str
11+
#: Whether the extension is required for the mathematical
12+
#: interpretation of the problem.
13+
required: bool
14+
15+
model_config = ConfigDict(extra="allow", validate_assignment=True)
16+
17+
18+
def _extension_config_classes() -> dict[str, type[ExtensionConfig]]:
19+
"""Registry of extension ID to its specific :class:`ExtensionConfig`
20+
subclass, if any.
21+
22+
Imported lazily (rather than built at module level) to avoid a
23+
circular import: extension submodules (e.g. ``sciml``) import
24+
:class:`ExtensionConfig` from this package.
25+
"""
26+
from .. import C
27+
from .sciml import SciMLConfig
28+
29+
return {C.EXT_ID_SCIML: SciMLConfig}
30+
31+
32+
def parse_extension_config(
33+
ext_id: str, config: dict | ExtensionConfig
34+
) -> ExtensionConfig:
35+
"""Parse a single extension's configuration.
36+
37+
Converts ``config`` to the extension-specific :class:`ExtensionConfig`
38+
subclass registered for ``ext_id``, or to the generic
39+
:class:`ExtensionConfig` if no specific subclass is registered.
40+
41+
:param ext_id: The extension ID.
42+
:param config: The extension's configuration, as a dict or an already
43+
parsed :class:`ExtensionConfig` (sub)instance.
44+
"""
45+
cls = _extension_config_classes().get(ext_id, ExtensionConfig)
46+
return config if isinstance(config, cls) else cls(**config)

petab/v2/extensions/sciml.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
pass
2626

2727
from .. import C
28+
from . import ExtensionConfig
2829

2930
__all__ = [
3031
"Hybridization",
@@ -136,11 +137,11 @@ class NeuralNetConfig(BaseModel):
136137
)
137138

138139

139-
class SciMLConfig(BaseModel):
140+
class SciMLConfig(ExtensionConfig):
140141
"""The extended configuration of a PEtab SciML problem."""
141142

142-
#: The PEtab SciML format version.
143143
version: str = "0.1.0"
144+
required: bool = True
144145
#: The paths to the array data files.
145146
array_files: list[AnyUrl | Path] = []
146147
#: The paths to the hybridization tables.
@@ -155,7 +156,7 @@ class SciMLConfig(BaseModel):
155156

156157
def to_yaml(self) -> dict:
157158
"""Return a YAML-serializable dict with Paths converted to strings."""
158-
from . import C
159+
from .. import C
159160

160161
d = self.model_dump(by_alias=True)
161162
for key in ("array_files", "hybridization_files"):

tests/v2/test_core.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
UPPER_BOUND,
3030
)
3131
from petab.v2.core import *
32+
from petab.v2.core import ExtensionConfig
33+
from petab.v2.lint import ValidationIssueSeverity
3234
from petab.v2.models.sbml_model import SbmlModel
3335
from petab.v2.petab1to2 import petab1to2
3436

@@ -595,6 +597,83 @@ def test_problem_config_paths():
595597
# see also https://github.com/pydantic/pydantic/issues/8575
596598

597599

600+
def test_problem_config_generic_extension():
601+
"""A generic (non-sciml) extension is parsed per the PEtab v2 schema:
602+
`version` and `required` at the top level, plus arbitrary
603+
extension-specific keys alongside them."""
604+
pc = ProblemConfig(
605+
parameter_files=["parameters.tsv"],
606+
measurement_files=["measurements.tsv"],
607+
observable_files=["observables.tsv"],
608+
extensions={
609+
"my_ext": {
610+
"version": "1.0.0",
611+
"required": False,
612+
"some_key": "some_value",
613+
}
614+
},
615+
)
616+
ext = pc.extensions["my_ext"]
617+
assert isinstance(ext, ExtensionConfig)
618+
assert ext.version == "1.0.0"
619+
assert ext.required is False
620+
assert ext.some_key == "some_value"
621+
622+
dumped = pc.model_dump(by_alias=True)["extensions"]["my_ext"]
623+
assert dumped == {
624+
"version": "1.0.0",
625+
"required": False,
626+
"some_key": "some_value",
627+
}
628+
629+
630+
def test_problem_config_extensions_rejects_non_dict():
631+
"""`extensions` must be a dict keyed by extension ID (see #474) -- a
632+
list is not a valid PEtab v2 problem configuration."""
633+
with pytest.raises(ValidationError):
634+
ProblemConfig(
635+
parameter_files=["parameters.tsv"],
636+
measurement_files=["measurements.tsv"],
637+
observable_files=["observables.tsv"],
638+
extensions=[{"version": "1.0.0", "required": False}],
639+
)
640+
641+
642+
def test_validate_unsupported_extension_severity():
643+
"""libpetab-python doesn't mathematically interpret extensions, so an
644+
unsupported extension only ever produces a WARNING (that the problem
645+
can't be fully linted) -- regardless of `required`. Rejecting a problem
646+
that uses an unsupported `required` extension is up to the consumer
647+
(e.g. a simulator) that actually interprets it."""
648+
problem = Problem()
649+
problem.model = SbmlModel.from_antimony("""
650+
model m
651+
species A;
652+
A = 1;
653+
k1 = 1;
654+
R1: A -> ; k1 * A;
655+
end
656+
""")
657+
problem.add_observable("obs_A", "A", noise_formula="1")
658+
problem.add_parameter(
659+
"k1", estimate=True, lb=1e-5, ub=1e5, nominal_value=1
660+
)
661+
problem.add_measurement("obs_A", time=1, measurement=1, experiment_id="")
662+
assert problem.validate() == []
663+
664+
for required in (False, True):
665+
problem.config = ProblemConfig(
666+
extensions={"my_ext": {"version": "1.0.0", "required": required}}
667+
)
668+
results = problem.validate()
669+
assert not results.has_errors()
670+
assert any(
671+
r.level == ValidationIssueSeverity.WARNING
672+
and "my_ext" in r.message
673+
for r in results
674+
)
675+
676+
598677
def test_get_changes_for_period():
599678
"""Test getting changes for a specific period."""
600679
problem = Problem()

tests/v2/test_sciml.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,26 @@ def test_lint():
157157
assert problem.validate() == []
158158

159159

160+
def test_sciml_config_yaml_round_trip(tmp_path):
161+
"""The `sciml` extension config, once written to YAML via
162+
`ProblemConfig.to_yaml()`, is schema-valid and can be read back.
163+
"""
164+
from petab.v1.yaml import load_yaml, validate_yaml_syntax
165+
166+
problem = _get_test_problem()
167+
yaml_path = tmp_path / "problem.yaml"
168+
problem.config.to_yaml(yaml_path)
169+
170+
yaml_config = load_yaml(yaml_path)
171+
validate_yaml_syntax(yaml_config)
172+
assert yaml_config["extensions"]["sciml"]["required"] is True
173+
174+
reloaded_config = ProblemConfig(**yaml_config, base_path=tmp_path)
175+
sciml_config = reloaded_config.extensions["sciml"]
176+
assert isinstance(sciml_config, SciMLConfig)
177+
assert sciml_config.required is True
178+
179+
160180
def test_lint_equinox_network_format():
161181
"""Linter accepts non-YAML formats without reading the network file."""
162182
problem = _get_test_problem()

0 commit comments

Comments
 (0)