diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab21e96..371f0e3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,10 +34,16 @@ `pi`/`e`, and prints floats without loss of precision. PEtab v2 condition and measurement tables now write expressions in PEtab syntax (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/545) +* `petab.v2.ProblemConfig.to_yaml` no longer writes empty optional entries + (`condition_files`, `experiment_files`, `mapping_files`, `extensions`) + (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/547) **Features** * `petab1to2` and `petab_files_1to2` can skip validation via `validate=False` (by @FFroehlich in https://github.com/PEtab-dev/libpetab-python/pull/534) +* Added `petab.v2.C.PROBLEM_CONFIG_REQUIRED_KEYS` and + `petab.v2.C.PROBLEM_CONFIG_OPTIONAL_KEYS` + (by @dweindl in https://github.com/PEtab-dev/libpetab-python/pull/547) ## 0.9 series diff --git a/petab/v2/C.py b/petab/v2/C.py index d738804f..625f128d 100644 --- a/petab/v2/C.py +++ b/petab/v2/C.py @@ -234,6 +234,8 @@ # YAML #: PEtab version key in the YAML file FORMAT_VERSION = "format_version" +#: Problem ID key in the YAML file +PROBLEM_ID = "id" #: Parameter files key in the YAML file PARAMETER_FILES = "parameter_files" #: Problems key in the YAML file @@ -261,6 +263,23 @@ #: PEtab SciML extension EXT_ID_SCIML = "sciml" +#: Required top-level keys in the problem YAML file +PROBLEM_CONFIG_REQUIRED_KEYS = [ + FORMAT_VERSION, + PARAMETER_FILES, + MODEL_FILES, + OBSERVABLE_FILES, + MEASUREMENT_FILES, +] +#: Optional top-level keys in the problem YAML file +PROBLEM_CONFIG_OPTIONAL_KEYS = [ + PROBLEM_ID, + CONDITION_FILES, + EXPERIMENT_FILES, + MAPPING_FILES, + EXTENSIONS, +] + # MAPPING diff --git a/petab/v2/core.py b/petab/v2/core.py index c36076ab..d7a36a3d 100644 --- a/petab/v2/core.py +++ b/petab/v2/core.py @@ -2812,14 +2812,20 @@ def to_yaml(self, filename: str | Path): data["model_files"][model_id][C.MODEL_LOCATION] = str( data["model_files"][model_id][C.MODEL_LOCATION] ) - if data["id"] is None: + if data[C.PROBLEM_ID] is None: # The schema requires a valid id or no id field at all. - del data["id"] + del data[C.PROBLEM_ID] for ext_id in list(data[C.EXTENSIONS]): if ext_id == C.EXT_ID_SCIML: data[C.EXTENSIONS][ext_id] = self.extensions[ext_id].to_yaml() + # Omit empty optional entries. Required ones are kept even if empty. + for key in C.PROBLEM_CONFIG_OPTIONAL_KEYS: + # an empty `id` is invalid and must not be dropped silently + if key != C.PROBLEM_ID and not data[key]: + del data[key] + write_yaml(data, filename) @property diff --git a/tests/v2/test_core.py b/tests/v2/test_core.py index 4dee46fa..1f13e411 100644 --- a/tests/v2/test_core.py +++ b/tests/v2/test_core.py @@ -29,7 +29,7 @@ UPPER_BOUND, ) from petab.v2.core import * -from petab.v2.core import ExtensionConfig +from petab.v2.core import ExtensionConfig, ModelFile from petab.v2.lint import CheckUnusedConditions, ValidationIssueSeverity from petab.v2.models.sbml_model import SbmlModel from petab.v2.petab1to2 import petab1to2 @@ -780,6 +780,62 @@ def test_problem_config_extensions_rejects_non_dict(): ) +def test_problem_config_keys_match_schema(): + """The required/optional YAML keys in `C` match the PEtab v2 schema.""" + from petab.v1.yaml import SCHEMAS, load_yaml + + schema = load_yaml(SCHEMAS[(2, 0)]) + assert set(C.PROBLEM_CONFIG_REQUIRED_KEYS) == set(schema["required"]) + assert set(C.PROBLEM_CONFIG_OPTIONAL_KEYS) == set( + schema["properties"] + ) - set(schema["required"]) + + +def test_problem_config_to_yaml_omits_empty_optional(tmp_path): + """Empty optional entries are not written; empty required ones are.""" + from petab.v1.yaml import load_yaml, validate_yaml_syntax + + pc = ProblemConfig( + parameter_files=["parameters.tsv"], + model_files={ + "model": ModelFile(location="model.xml", language="sbml") + }, + measurement_files=["measurements.tsv"], + ) + yaml_path = tmp_path / "problem.yaml" + pc.to_yaml(yaml_path) + + yaml_config = load_yaml(yaml_path) + assert set(yaml_config) == { + C.FORMAT_VERSION, + C.PARAMETER_FILES, + C.MODEL_FILES, + C.MEASUREMENT_FILES, + C.OBSERVABLE_FILES, + } + assert yaml_config[C.OBSERVABLE_FILES] == [] + validate_yaml_syntax(yaml_config) + reloaded = ProblemConfig(**yaml_config, base_path=tmp_path) + assert reloaded.model_dump() == pc.model_dump() + + # non-empty optional entries are kept + pc.condition_files = ["conditions.tsv"] + pc.experiment_files = ["experiments.tsv"] + pc.mapping_files = ["mapping.tsv"] + pc.extensions = {"my_ext": {"version": "1.0.0", "required": False}} + pc.to_yaml(yaml_path) + + yaml_config = load_yaml(yaml_path) + assert yaml_config[C.CONDITION_FILES] == ["conditions.tsv"] + assert yaml_config[C.EXPERIMENT_FILES] == ["experiments.tsv"] + assert yaml_config[C.MAPPING_FILES] == ["mapping.tsv"] + assert yaml_config[C.EXTENSIONS] == { + "my_ext": {"version": "1.0.0", "required": False} + } + reloaded = ProblemConfig(**yaml_config, base_path=tmp_path) + assert reloaded.model_dump() == pc.model_dump() + + def test_validate_unsupported_extension_severity(): """libpetab-python doesn't mathematically interpret extensions, so an unsupported extension only ever produces a WARNING (that the problem