From b6f7136e916639650a96c156d33cfb70b064c7d6 Mon Sep 17 00:00:00 2001 From: Daniel Weindl Date: Mon, 21 Jul 2025 16:08:38 +0200 Subject: [PATCH] v2: Paths as pathlib.Path & validate assignment For petab.v2 pydantic models, change path attributes to `pathlib.Path`, and validate assignments. --- petab/v1/yaml.py | 2 +- petab/v2/core.py | 29 ++++++++++++++++----- petab/v2/problem.py | 54 +++++++++++++++++++++++++++++++--------- tests/v2/test_problem.py | 27 ++++++++++++++++++++ 4 files changed, 93 insertions(+), 19 deletions(-) diff --git a/petab/v1/yaml.py b/petab/v1/yaml.py index 0c092049..cefc594c 100644 --- a/petab/v1/yaml.py +++ b/petab/v1/yaml.py @@ -242,7 +242,7 @@ def write_yaml(yaml_config: dict[str, Any], filename: str | Path) -> None: """ Path(filename).parent.mkdir(parents=True, exist_ok=True) with open(filename, "w") as outfile: - yaml.dump( + yaml.safe_dump( yaml_config, outfile, default_flow_style=False, sort_keys=False ) diff --git a/petab/v2/core.py b/petab/v2/core.py index 193be335..6532a52c 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -204,7 +204,10 @@ class Observable(BaseModel): #: :meta private: model_config = ConfigDict( - arbitrary_types_allowed=True, populate_by_name=True, extra="allow" + arbitrary_types_allowed=True, + populate_by_name=True, + extra="allow", + validate_assignment=True, ) @field_validator( @@ -344,6 +347,7 @@ class Change(BaseModel): populate_by_name=True, use_enum_values=True, extra="allow", + validate_assignment=True, ) @field_validator("target_value", mode="before") @@ -385,7 +389,9 @@ class Condition(BaseModel): changes: list[Change] #: :meta private: - model_config = ConfigDict(populate_by_name=True, extra="allow") + model_config = ConfigDict( + populate_by_name=True, extra="allow", validate_assignment=True + ) def __add__(self, other: Change) -> Condition: """Add a change to the set.""" @@ -503,7 +509,9 @@ class ExperimentPeriod(BaseModel): condition_ids: list[str] = Field(default_factory=list) #: :meta private: - model_config = ConfigDict(populate_by_name=True, extra="allow") + model_config = ConfigDict( + populate_by_name=True, extra="allow", validate_assignment=True + ) @field_validator("condition_ids", mode="before") @classmethod @@ -544,7 +552,10 @@ class Experiment(BaseModel): #: :meta private: model_config = ConfigDict( - arbitrary_types_allowed=True, populate_by_name=True, extra="allow" + arbitrary_types_allowed=True, + populate_by_name=True, + extra="allow", + validate_assignment=True, ) def __add__(self, other: ExperimentPeriod) -> Experiment: @@ -682,7 +693,10 @@ class Measurement(BaseModel): #: :meta private: model_config = ConfigDict( - arbitrary_types_allowed=True, populate_by_name=True, extra="allow" + arbitrary_types_allowed=True, + populate_by_name=True, + extra="allow", + validate_assignment=True, ) @field_validator( @@ -806,7 +820,9 @@ class Mapping(BaseModel): ) #: :meta private: - model_config = ConfigDict(populate_by_name=True, extra="allow") + model_config = ConfigDict( + populate_by_name=True, extra="allow", validate_assignment=True + ) class MappingTable(BaseModel): @@ -909,6 +925,7 @@ class Parameter(BaseModel): populate_by_name=True, use_enum_values=True, extra="allow", + validate_assignment=True, ) @field_validator("id") diff --git a/petab/v2/problem.py b/petab/v2/problem.py index 0b935818..93362f62 100644 --- a/petab/v2/problem.py +++ b/petab/v2/problem.py @@ -16,7 +16,13 @@ import numpy as np import pandas as pd import sympy as sp -from pydantic import AnyUrl, BaseModel, Field, field_validator +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + Field, + field_validator, +) from ..v1 import ( parameter_mapping, @@ -1124,9 +1130,13 @@ def model_dump(self, **kwargs) -> dict[str, Any]: class ModelFile(BaseModel): """A file in the PEtab problem configuration.""" - location: str | AnyUrl + location: AnyUrl | Path language: str + model_config = ConfigDict( + validate_assignment=True, + ) + class ExtensionConfig(BaseModel): """The configuration of a PEtab extension.""" @@ -1139,13 +1149,13 @@ class ProblemConfig(BaseModel): """The PEtab problem configuration.""" #: The path to the PEtab problem configuration. - filepath: str | AnyUrl | None = Field( + filepath: AnyUrl | Path | None = Field( None, description="The path to the PEtab problem configuration.", exclude=True, ) #: The base path to resolve relative paths. - base_path: str | AnyUrl | None = Field( + base_path: AnyUrl | Path | None = Field( None, description="The base path to resolve relative paths.", exclude=True, @@ -1156,21 +1166,24 @@ class ProblemConfig(BaseModel): # TODO https://github.com/PEtab-dev/PEtab/pull/641: # rename to parameter_files in yaml for consistency with other files? # always a list? - parameter_files: list[str | AnyUrl] = Field( + parameter_files: list[AnyUrl | Path] = Field( default=[], alias=PARAMETER_FILES ) - # TODO: consider changing str to Path model_files: dict[str, ModelFile] | None = {} - measurement_files: list[str | AnyUrl] = [] - condition_files: list[str | AnyUrl] = [] - experiment_files: list[str | AnyUrl] = [] - observable_files: list[str | AnyUrl] = [] - mapping_files: list[str | AnyUrl] = [] + measurement_files: list[AnyUrl | Path] = [] + condition_files: list[AnyUrl | Path] = [] + experiment_files: list[AnyUrl | Path] = [] + observable_files: list[AnyUrl | Path] = [] + mapping_files: list[AnyUrl | Path] = [] #: Extensions used by the problem. extensions: list[ExtensionConfig] | dict = {} + model_config = ConfigDict( + validate_assignment=True, + ) + # convert parameter_file to list @field_validator( "parameter_files", @@ -1194,7 +1207,24 @@ def to_yaml(self, filename: str | Path): """ from ..v1.yaml import write_yaml - write_yaml(self.model_dump(by_alias=True), filename) + data = self.model_dump(by_alias=True) + # convert Paths to strings for YAML serialization + for key in ( + "measurement_files", + "condition_files", + "experiment_files", + "observable_files", + "mapping_files", + "parameter_files", + ): + data[key] = list(map(str, data[key])) + + for model_id in data.get("model_files", {}): + data["model_files"][model_id][MODEL_LOCATION] = str( + data["model_files"][model_id]["location"] + ) + + write_yaml(data, filename) @property def format_version_tuple(self) -> tuple[int, int, int, str]: diff --git a/tests/v2/test_problem.py b/tests/v2/test_problem.py index 73cc3988..580c691a 100644 --- a/tests/v2/test_problem.py +++ b/tests/v2/test_problem.py @@ -4,6 +4,7 @@ import numpy as np import pandas as pd from pandas.testing import assert_frame_equal +from pydantic import AnyUrl import petab.v2 as petab from petab.v2 import Problem @@ -198,3 +199,29 @@ def test_sample_startpoint_shape(): n_starts = 10 sp = problem.sample_parameter_startpoints(n_starts=n_starts) assert sp.shape == (n_starts, 2) + + +def test_problem_config_paths(): + """Test handling of URLS and local paths in ProblemConfig.""" + + pc = petab.ProblemConfig( + parameter_files=["https://example.com/params.tsv"], + condition_files=["conditions.tsv"], + measurement_files=["measurements.tsv"], + observable_files=["observables.tsv"], + experiment_files=["experiments.tsv"], + ) + assert isinstance(pc.parameter_files[0], AnyUrl) + assert isinstance(pc.condition_files[0], Path) + assert isinstance(pc.measurement_files[0], Path) + assert isinstance(pc.observable_files[0], Path) + assert isinstance(pc.experiment_files[0], Path) + + # Auto-convert to Path on assignment + pc.parameter_files = ["foo.tsv"] + assert isinstance(pc.parameter_files[0], Path) + + # We can't easily intercept mutations to the list: + # pc.parameter_files[0] = "foo.tsv" + # assert isinstance(pc.parameter_files[0], Path) + # see also https://github.com/pydantic/pydantic/issues/8575