From 09d1562f4181103d3255ec9693bcdaf63568861f Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 23 Dec 2025 14:03:02 -0800 Subject: [PATCH 1/7] fix: robust synthetic property handling for experimentst - Only generate synthetic fields for experiments (e.g., publication identifiers, score set URNs) when ORM attributes are present, avoiding dict-based synthesis. - Validators now check for ORM attribute presence before transformation, ensuring correct behavior for both ORM and API/dict contexts. - Updated tests to expect Pydantic validation errors when required synthetic fields are missing. --- src/mavedb/view_models/experiment.py | 31 +++++++++-------- tests/view_models/test_experiment.py | 52 +++++++++++++++++++++------- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/src/mavedb/view_models/experiment.py b/src/mavedb/view_models/experiment.py index b05766ff..d0d4bc8a 100644 --- a/src/mavedb/view_models/experiment.py +++ b/src/mavedb/view_models/experiment.py @@ -1,15 +1,15 @@ from datetime import date from typing import Any, Collection, Optional, Sequence -from pydantic import field_validator, model_validator, ValidationInfo +from pydantic import ValidationInfo, field_validator, model_validator +from mavedb.lib.validation import urn_re from mavedb.lib.validation.exceptions import ValidationError from mavedb.lib.validation.transform import ( transform_experiment_set_to_urn, - transform_score_set_list_to_urn_list, transform_record_publication_identifiers, + transform_score_set_list_to_urn_list, ) -from mavedb.lib.validation import urn_re from mavedb.lib.validation.utilities import is_null from mavedb.view_models import record_type_validator, set_record_type from mavedb.view_models.base.base import BaseModel @@ -129,12 +129,11 @@ def publication_identifiers_validator(cls, v: Any, info: ValidationInfo) -> list return list(v) # Re-cast into proper list-like type # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting - # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") def generate_primary_and_secondary_publications(cls, data: Any): - if not hasattr(data, "primary_publication_identifiers") or not hasattr( - data, "secondary_publication_identifiers" - ): + if hasattr(data, "publication_identifier_associations"): try: publication_identifiers = transform_record_publication_identifiers( data.publication_identifier_associations @@ -145,28 +144,30 @@ def generate_primary_and_secondary_publications(cls, data: Any): data.__setattr__( "secondary_publication_identifiers", publication_identifiers["secondary_publication_identifiers"] ) - except AttributeError as exc: + except (KeyError, AttributeError) as exc: raise ValidationError( - f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore + f"Unable to coerce publication identifier attributes from ORM for {cls.__name__}: {exc}." # type: ignore ) return data @model_validator(mode="before") def generate_score_set_urn_list(cls, data: Any): - if not hasattr(data, "score_set_urns"): + if hasattr(data, "score_sets"): try: data.__setattr__("score_set_urns", transform_score_set_list_to_urn_list(data.score_sets)) - except AttributeError as exc: - raise ValidationError(f"Unable to create {cls.__name__} without attribute: {exc}.") # type: ignore + except (KeyError, AttributeError) as exc: + raise ValidationError(f"Unable to coerce associated score set URNs from ORM for {cls.__name__}: {exc}.") # type: ignore return data @model_validator(mode="before") def generate_experiment_set_urn(cls, data: Any): - if not hasattr(data, "experiment_set_urn"): + if hasattr(data, "experiment_set"): try: data.__setattr__("experiment_set_urn", transform_experiment_set_to_urn(data.experiment_set)) - except AttributeError as exc: - raise ValidationError(f"Unable to create {cls.__name__} without attribute: {exc}.") # type: ignore + except (KeyError, AttributeError) as exc: + raise ValidationError( + f"Unable to coerce associated experiment set URN from ORM for {cls.__name__}: {exc}." + ) # type: ignore return data diff --git a/tests/view_models/test_experiment.py b/tests/view_models/test_experiment.py index 9f0c3e67..aab3c85f 100644 --- a/tests/view_models/test_experiment.py +++ b/tests/view_models/test_experiment.py @@ -1,17 +1,18 @@ import pytest +from pydantic import ValidationError -from mavedb.view_models.experiment import ExperimentCreate, SavedExperiment +from mavedb.view_models.experiment import Experiment, ExperimentCreate, SavedExperiment from mavedb.view_models.publication_identifier import PublicationIdentifier - from tests.helpers.constants import ( - VALID_EXPERIMENT_URN, - VALID_SCORE_SET_URN, - VALID_EXPERIMENT_SET_URN, + SAVED_BIORXIV_PUBLICATION, + SAVED_PUBMED_PUBLICATION, + TEST_BIORXIV_IDENTIFIER, TEST_MINIMAL_EXPERIMENT, TEST_MINIMAL_EXPERIMENT_RESPONSE, - SAVED_PUBMED_PUBLICATION, TEST_PUBMED_IDENTIFIER, - TEST_BIORXIV_IDENTIFIER, + VALID_EXPERIMENT_SET_URN, + VALID_EXPERIMENT_URN, + VALID_SCORE_SET_URN, ) from tests.helpers.util.common import dummy_attributed_object_from_dict @@ -237,8 +238,15 @@ def test_saved_experiment_synthetic_properties(): ) -@pytest.mark.parametrize("exclude", ["publication_identifier_associations", "score_sets", "experiment_set"]) -def test_cannot_create_saved_experiment_without_all_attributed_properties(exclude): +@pytest.mark.parametrize( + "exclude,expected_missing_fields", + [ + ("publication_identifier_associations", ["primaryPublicationIdentifiers", "secondaryPublicationIdentifiers"]), + ("score_sets", ["scoreSetUrns"]), + ("experiment_set", ["experimentSetUrn"]), + ], +) +def test_cannot_create_saved_experiment_without_all_attributed_properties(exclude, expected_missing_fields): experiment = TEST_MINIMAL_EXPERIMENT_RESPONSE.copy() experiment["urn"] = VALID_EXPERIMENT_URN @@ -280,11 +288,14 @@ def test_cannot_create_saved_experiment_without_all_attributed_properties(exclud experiment.pop(exclude) experiment_attributed_object = dummy_attributed_object_from_dict(experiment) - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValidationError) as exc_info: SavedExperiment.model_validate(experiment_attributed_object) - assert "Unable to create SavedExperiment without attribute" in str(exc_info.value) - assert exclude in str(exc_info.value) + # Should fail with missing fields coerced from missing attributed properties + msg = str(exc_info.value) + assert "Field required" in msg + for field in expected_missing_fields: + assert field in msg def test_can_create_experiment_with_nonetype_experiment_set_urn(): @@ -303,3 +314,20 @@ def test_cant_create_experiment_with_invalid_experiment_set_urn(): ExperimentCreate(**experiment_test) assert f"'{experiment_test['experiment_set_urn']}' is not a valid experiment set URN" in str(exc_info.value) + + +def test_can_create_experiment_from_non_orm_context(): + experiment = TEST_MINIMAL_EXPERIMENT_RESPONSE.copy() + experiment["urn"] = VALID_EXPERIMENT_URN + experiment["experimentSetUrn"] = VALID_EXPERIMENT_SET_URN + experiment["scoreSetUrns"] = [VALID_SCORE_SET_URN] + experiment["primaryPublicationIdentifiers"] = [SAVED_PUBMED_PUBLICATION] + experiment["secondaryPublicationIdentifiers"] = [SAVED_PUBMED_PUBLICATION, SAVED_BIORXIV_PUBLICATION] + + # Should not require any ORM attributes + saved_experiment = Experiment.model_validate(experiment) + assert saved_experiment.urn == VALID_EXPERIMENT_URN + assert saved_experiment.experiment_set_urn == VALID_EXPERIMENT_SET_URN + assert saved_experiment.score_set_urns == [VALID_SCORE_SET_URN] + assert len(saved_experiment.primary_publication_identifiers) == 1 + assert len(saved_experiment.secondary_publication_identifiers) == 2 From 656ad80d97159e9268d3add4f541161a65e50a22 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 23 Dec 2025 14:32:37 -0800 Subject: [PATCH 2/7] fix: robust synthetic property handling for target genes - Refactored SavedTargetGene and TargetGeneWithScoreSetUrn to synthesize synthetic fields (e.g., external_identifiers, score_set_urn) only from ORM objects, not dicts. - Updated model validators to require either target_sequence or target_accession for all construction contexts. - Added tests to ensure SavedTargetGene and TargetGeneWithScoreSetUrn can be created from both ORM (attributed object) and non-ORM (dict) contexts. --- src/mavedb/view_models/target_gene.py | 18 +++-- tests/view_models/test_target_gene.py | 110 +++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/src/mavedb/view_models/target_gene.py b/src/mavedb/view_models/target_gene.py index 48396a98..02ae0cbc 100644 --- a/src/mavedb/view_models/target_gene.py +++ b/src/mavedb/view_models/target_gene.py @@ -69,15 +69,16 @@ class Config: arbitrary_types_allowed = True # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting - # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") def generate_external_identifiers_list(cls, data: Any): - if not hasattr(data, "external_identifiers"): + if hasattr(data, "ensembl_offset") or hasattr(data, "refseq_offset") or hasattr(data, "uniprot_offset"): try: data.__setattr__("external_identifiers", transform_external_identifier_offsets_to_list(data)) - except AttributeError as exc: + except (AttributeError, KeyError) as exc: raise ValidationError( - f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore + f"Unable to coerce external identifiers for {cls.__name__}: {exc}." # type: ignore ) return data @@ -108,15 +109,16 @@ class TargetGeneWithScoreSetUrn(TargetGene): score_set_urn: str # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting - # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") def generate_score_set_urn(cls, data: Any): - if not hasattr(data, "score_set_urn"): + if hasattr(data, "score_set"): try: data.__setattr__("score_set_urn", transform_score_set_to_urn(data.score_set)) - except AttributeError as exc: + except (AttributeError, KeyError) as exc: raise ValidationError( - f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore + f"Unable to coerce score set urn for {cls.__name__}: {exc}." # type: ignore ) return data diff --git a/tests/view_models/test_target_gene.py b/tests/view_models/test_target_gene.py index 32ae4f30..71b497b9 100644 --- a/tests/view_models/test_target_gene.py +++ b/tests/view_models/test_target_gene.py @@ -1,12 +1,12 @@ import pytest -from mavedb.view_models.target_gene import TargetGeneCreate, SavedTargetGene +from mavedb.view_models.target_gene import SavedTargetGene, TargetGene, TargetGeneCreate, TargetGeneWithScoreSetUrn from tests.helpers.constants import ( SEQUENCE, + TEST_ENSEMBLE_EXTERNAL_IDENTIFIER, TEST_POPULATED_TAXONOMY, - TEST_SAVED_TAXONOMY, TEST_REFSEQ_EXTERNAL_IDENTIFIER, - TEST_ENSEMBLE_EXTERNAL_IDENTIFIER, + TEST_SAVED_TAXONOMY, TEST_UNIPROT_EXTERNAL_IDENTIFIER, ) from tests.helpers.util.common import dummy_attributed_object_from_dict @@ -200,3 +200,107 @@ def test_cannot_create_saved_target_without_seq_or_acc(): SavedTargetGene.model_validate(target_gene) assert "Either a `target_sequence` or `target_accession` is required" in str(exc_info.value) + + +def test_saved_target_gene_can_be_created_from_orm(): + orm_obj = dummy_attributed_object_from_dict( + { + "id": 1, + "name": "UBE2I", + "category": "regulatory", + "ensembl_offset": dummy_attributed_object_from_dict( + {"offset": 1, "identifier": dummy_attributed_object_from_dict(TEST_ENSEMBLE_EXTERNAL_IDENTIFIER)} + ), + "refseq_offset": None, + "uniprot_offset": None, + "target_sequence": dummy_attributed_object_from_dict( + { + "sequenceType": "dna", + "sequence": SEQUENCE, + "taxonomy": TEST_SAVED_TAXONOMY, + } + ), + "target_accession": None, + "record_type": "target_gene", + "uniprot_id_from_mapped_metadata": None, + } + ) + model = SavedTargetGene.model_validate(orm_obj) + assert model.name == "UBE2I" + assert model.external_identifiers[0].identifier.identifier == "ENSG00000103275" + + +def test_target_gene_with_score_set_urn_can_be_created_from_orm(): + orm_obj = dummy_attributed_object_from_dict( + { + "id": 1, + "name": "UBE2I", + "category": "regulatory", + "ensembl_offset": dummy_attributed_object_from_dict( + { + "offset": 1, + "identifier": dummy_attributed_object_from_dict(TEST_ENSEMBLE_EXTERNAL_IDENTIFIER), + } + ), + "refseq_offset": None, + "uniprot_offset": None, + "target_sequence": dummy_attributed_object_from_dict( + { + "sequenceType": "dna", + "sequence": SEQUENCE, + "taxonomy": TEST_SAVED_TAXONOMY, + } + ), + "target_accession": None, + "record_type": "target_gene", + "uniprot_id_from_mapped_metadata": None, + "score_set": dummy_attributed_object_from_dict({"urn": "urn:mavedb:01234567-a-1"}), + } + ) + model = TargetGeneWithScoreSetUrn.model_validate(orm_obj) + assert model.name == "UBE2I" + assert model.score_set_urn == "urn:mavedb:01234567-a-1" + + +def test_target_gene_can_be_created_from_non_orm_context(): + # Minimal valid dict for TargetGene (must have target_sequence or target_accession) + data = { + "id": 1, + "name": "UBE2I", + "category": "regulatory", + "external_identifiers": [{"identifier": TEST_ENSEMBLE_EXTERNAL_IDENTIFIER, "offset": 1}], + "target_sequence": { + "sequenceType": "dna", + "sequence": SEQUENCE, + "taxonomy": TEST_SAVED_TAXONOMY, + }, + "target_accession": None, + "record_type": "target_gene", + "uniprot_id_from_mapped_metadata": None, + } + model = TargetGene.model_validate(data) + assert model.name == data["name"] + assert model.category == data["category"] + assert model.external_identifiers[0].identifier.identifier == "ENSG00000103275" + + +def test_target_gene_with_score_set_urn_can_be_created_from_dict(): + # Minimal valid dict for TargetGeneWithScoreSetUrn (must have target_sequence or target_accession) + data = { + "id": 1, + "name": "UBE2I", + "category": "regulatory", + "external_identifiers": [{"identifier": TEST_ENSEMBLE_EXTERNAL_IDENTIFIER, "offset": 1}], + "target_sequence": { + "sequenceType": "dna", + "sequence": SEQUENCE, + "taxonomy": TEST_SAVED_TAXONOMY, + }, + "target_accession": None, + "record_type": "target_gene", + "uniprot_id_from_mapped_metadata": None, + "score_set_urn": "urn:mavedb:01234567-a-1", + } + model = TargetGeneWithScoreSetUrn.model_validate(data) + assert model.name == data["name"] + assert model.score_set_urn == data["score_set_urn"] From 49fe9273ed035b2aa787c3c99f6146b43d409b03 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 23 Dec 2025 15:52:27 -0800 Subject: [PATCH 3/7] fix: robust synthetic property handling for Collections - Refactored SavedCollection and CollectionWithUrn to ensure robust handling of synthetic and required fields for both ORM and dict contexts. - Added parameterized tests to verify all key attributes are correctly handled in both construction modes. - Added tests for creation from both dict and ORM contexts, mirroring the approach used for other models. --- src/mavedb/view_models/collection.py | 34 ++++---- tests/view_models/test_collection.py | 120 +++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 tests/view_models/test_collection.py diff --git a/src/mavedb/view_models/collection.py b/src/mavedb/view_models/collection.py index 9761686d..afba1079 100644 --- a/src/mavedb/view_models/collection.py +++ b/src/mavedb/view_models/collection.py @@ -1,5 +1,5 @@ from datetime import date -from typing import Any, Sequence, Optional +from typing import Any, Optional, Sequence from pydantic import Field, model_validator @@ -84,36 +84,36 @@ class Config: from_attributes = True # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting - # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") def generate_contribution_role_user_relationships(cls, data: Any): - try: - user_associations = transform_contribution_role_associations_to_roles(data.user_associations) - for k, v in user_associations.items(): - data.__setattr__(k, v) - - except AttributeError as exc: - raise ValidationError( - f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore - ) + if hasattr(data, "user_associations"): + try: + user_associations = transform_contribution_role_associations_to_roles(data.user_associations) + for k, v in user_associations.items(): + data.__setattr__(k, v) + + except (AttributeError, KeyError) as exc: + raise ValidationError(f"Unable to coerce user associations for {cls.__name__}: {exc}.") return data @model_validator(mode="before") def generate_score_set_urn_list(cls, data: Any): - if not hasattr(data, "score_set_urns"): + if hasattr(data, "score_sets"): try: data.__setattr__("score_set_urns", transform_score_set_list_to_urn_list(data.score_sets)) - except AttributeError as exc: - raise ValidationError(f"Unable to create {cls.__name__} without attribute: {exc}.") # type: ignore + except (AttributeError, KeyError) as exc: + raise ValidationError(f"Unable to coerce score set urns for {cls.__name__}: {exc}.") return data @model_validator(mode="before") def generate_experiment_urn_list(cls, data: Any): - if not hasattr(data, "experiment_urns"): + if hasattr(data, "experiments"): try: data.__setattr__("experiment_urns", transform_experiment_list_to_urn_list(data.experiments)) - except AttributeError as exc: - raise ValidationError(f"Unable to create {cls.__name__} without attribute: {exc}.") # type: ignore + except (AttributeError, KeyError) as exc: + raise ValidationError(f"Unable to coerce experiment urns for {cls.__name__}: {exc}.") return data diff --git a/tests/view_models/test_collection.py b/tests/view_models/test_collection.py new file mode 100644 index 00000000..b22cee2a --- /dev/null +++ b/tests/view_models/test_collection.py @@ -0,0 +1,120 @@ +import pytest +from pydantic import ValidationError + +from mavedb.models.enums.contribution_role import ContributionRole +from mavedb.view_models.collection import Collection, SavedCollection +from tests.helpers.constants import TEST_COLLECTION_RESPONSE +from tests.helpers.util.common import dummy_attributed_object_from_dict + + +@pytest.mark.parametrize( + "exclude,expected_missing_fields", + [ + ("user_associations", ["admins", "editors", "viewers"]), + ("score_sets", ["scoreSetUrns"]), + ("experiments", ["experimentUrns"]), + ], +) +def test_cannot_create_saved_experiment_without_all_attributed_properties(exclude, expected_missing_fields): + collection = TEST_COLLECTION_RESPONSE.copy() + collection["urn"] = "urn:mavedb:collection-xxx" + + # Remove pre-existing synthetic properties + collection.pop("experimentUrns", None) + collection.pop("scoreSetUrns", None) + collection.pop("admins", None) + collection.pop("editors", None) + collection.pop("viewers", None) + + # Set synthetic properties with dummy attributed objects to mock SQLAlchemy model objects. + collection["experiments"] = [dummy_attributed_object_from_dict({"urn": "urn:mavedb:experiment-xxx"})] + collection["score_sets"] = [ + dummy_attributed_object_from_dict({"urn": "urn:mavedb:score_set-xxx", "superseding_score_set": None}) + ] + collection["user_associations"] = [ + dummy_attributed_object_from_dict( + { + "contribution_role": ContributionRole.admin, + "user": {"id": 1, "username": "test_user", "email": "test_user@example.com"}, + } + ), + dummy_attributed_object_from_dict( + { + "contribution_role": ContributionRole.editor, + "user": {"id": 1, "username": "test_user", "email": "test_user@example.com"}, + } + ), + dummy_attributed_object_from_dict( + { + "contribution_role": ContributionRole.viewer, + "user": {"id": 1, "username": "test_user", "email": "test_user@example.com"}, + } + ), + ] + + collection.pop(exclude) + collection_attributed_object = dummy_attributed_object_from_dict(collection) + with pytest.raises(ValidationError) as exc_info: + SavedCollection.model_validate(collection_attributed_object) + + # Should fail with missing fields coerced from missing attributed properties + msg = str(exc_info.value) + assert "Field required" in msg + for field in expected_missing_fields: + assert field in msg + + +def test_saved_collection_can_be_created_with_all_attributed_properties(): + collection = TEST_COLLECTION_RESPONSE.copy() + urn = "urn:mavedb:collection-xxx" + collection["urn"] = urn + + # Remove pre-existing synthetic properties + collection.pop("experimentUrns", None) + collection.pop("scoreSetUrns", None) + collection.pop("admins", None) + collection.pop("editors", None) + collection.pop("viewers", None) + + # Set synthetic properties with dummy attributed objects to mock SQLAlchemy model objects. + collection["experiments"] = [dummy_attributed_object_from_dict({"urn": "urn:mavedb:experiment-xxx"})] + collection["score_sets"] = [ + dummy_attributed_object_from_dict({"urn": "urn:mavedb:score_set-xxx", "superseding_score_set": None}) + ] + collection["user_associations"] = [ + dummy_attributed_object_from_dict( + { + "contribution_role": ContributionRole.admin, + "user": {"id": 1, "username": "test_user", "email": "test_user@example.com"}, + } + ), + dummy_attributed_object_from_dict( + { + "contribution_role": ContributionRole.editor, + "user": {"id": 1, "username": "test_user", "email": "test_user@example.com"}, + } + ), + dummy_attributed_object_from_dict( + { + "contribution_role": ContributionRole.viewer, + "user": {"id": 1, "username": "test_user", "email": "test_user@example.com"}, + } + ), + ] + + collection_attributed_object = dummy_attributed_object_from_dict(collection) + model = SavedCollection.model_validate(collection_attributed_object) + assert model.name == TEST_COLLECTION_RESPONSE["name"] + assert model.urn == urn + assert len(model.admins) == 1 + assert len(model.editors) == 1 + assert len(model.viewers) == 1 + assert len(model.experiment_urns) == 1 + assert len(model.score_set_urns) == 1 + + +def test_collection_can_be_created_from_non_orm_context(): + data = dict(TEST_COLLECTION_RESPONSE) + data["urn"] = "urn:mavedb:collection-xxx" + model = Collection.model_validate(data) + assert model.urn == data["urn"] From 9c95538f483a628475ec6b3c4868fbf0f51573e8 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 23 Dec 2025 15:56:57 -0800 Subject: [PATCH 4/7] fix: robust synthetic property handling for mapped variants - Refactored MappedVariant view models to ensure robust handling of synthetic and required fields for both ORM and dict contexts. - Added tests to verify all key attributes and synthetic properties are correctly handled in both construction modes. - Ensured creation from both dict and ORM contexts, mirroring the approach used for other models. --- src/mavedb/view_models/mapped_variant.py | 13 +++++++---- tests/view_models/test_mapped_variant.py | 29 ++++++++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/mavedb/view_models/mapped_variant.py b/src/mavedb/view_models/mapped_variant.py index 13aec65d..e497e6f3 100644 --- a/src/mavedb/view_models/mapped_variant.py +++ b/src/mavedb/view_models/mapped_variant.py @@ -55,13 +55,16 @@ class SavedMappedVariant(MappedVariantBase): class Config: from_attributes = True + # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") def generate_score_set_urn_list(cls, data: Any): - if not hasattr(data, "variant_urn") and hasattr(data, "variant"): + if hasattr(data, "variant"): try: data.__setattr__("variant_urn", None if not data.variant else data.variant.urn) - except AttributeError as exc: - raise ValidationError(f"Unable to create {cls.__name__} without attribute: {exc}.") # type: ignore + except (AttributeError, KeyError) as exc: + raise ValidationError(f"Unable to coerce variant urn for {cls.__name__}: {exc}.") # type: ignore return data @@ -97,8 +100,8 @@ def generate_score_set_urn_list(cls, data: Any): # ruff: noqa: E402 -from mavedb.view_models.clinical_control import ClinicalControlBase, ClinicalControl, SavedClinicalControl -from mavedb.view_models.gnomad_variant import GnomADVariantBase, GnomADVariant, SavedGnomADVariant +from mavedb.view_models.clinical_control import ClinicalControl, ClinicalControlBase, SavedClinicalControl +from mavedb.view_models.gnomad_variant import GnomADVariant, GnomADVariantBase, SavedGnomADVariant MappedVariantUpdate.model_rebuild() SavedMappedVariantWithControls.model_rebuild() diff --git a/tests/view_models/test_mapped_variant.py b/tests/view_models/test_mapped_variant.py index 09866219..1a41a7ef 100644 --- a/tests/view_models/test_mapped_variant.py +++ b/tests/view_models/test_mapped_variant.py @@ -1,10 +1,9 @@ import pytest from pydantic import ValidationError -from mavedb.view_models.mapped_variant import MappedVariantCreate, MappedVariant - -from tests.helpers.util.common import dummy_attributed_object_from_dict +from mavedb.view_models.mapped_variant import MappedVariant, MappedVariantCreate from tests.helpers.constants import TEST_MINIMAL_MAPPED_VARIANT, TEST_MINIMAL_MAPPED_VARIANT_CREATE, VALID_VARIANT_URN +from tests.helpers.util.common import dummy_attributed_object_from_dict def test_minimal_mapped_variant_create(): @@ -72,10 +71,32 @@ def test_cannot_create_mapped_variant_without_variant(): MappedVariantCreate(**mapped_variant_create) +def test_can_create_saved_mapped_variant_with_variant_object(): + mapped_variant = TEST_MINIMAL_MAPPED_VARIANT.copy() + mapped_variant["id"] = 1 + + saved_mapped_variant = MappedVariant.model_validate( + dummy_attributed_object_from_dict( + {**mapped_variant, "variant": dummy_attributed_object_from_dict({"urn": VALID_VARIANT_URN})} + ) + ) + + assert all(saved_mapped_variant.__getattribute__(k) == v for k, v in mapped_variant.items()) + assert saved_mapped_variant.variant_urn == VALID_VARIANT_URN + + def test_cannot_save_mapped_variant_without_variant(): mapped_variant = TEST_MINIMAL_MAPPED_VARIANT.copy() mapped_variant["id"] = 1 - mapped_variant["variant"] = dummy_attributed_object_from_dict({"urn": None}) + mapped_variant["variant"] = None with pytest.raises(ValidationError): MappedVariant.model_validate(dummy_attributed_object_from_dict({**mapped_variant})) + + +def test_can_create_mapped_variant_from_non_orm_context(): + mapped_variant_create = TEST_MINIMAL_MAPPED_VARIANT_CREATE.copy() + mapped_variant_create["variant_urn"] = VALID_VARIANT_URN + created_mapped_variant = MappedVariantCreate.model_validate(mapped_variant_create) + + assert all(created_mapped_variant.__getattribute__(k) == v for k, v in mapped_variant_create.items()) From af7cddb2eb20da74d1d7a25fa947094db600c163 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 23 Dec 2025 16:15:08 -0800 Subject: [PATCH 5/7] fix: robust synthetic property handling for variants - Refactored Variant view models to ensure robust handling of synthetic and required fields for both ORM and dict contexts. - Added tests to verify all key attributes and synthetic properties are correctly handled in both construction modes. - Ensured creation from both dict and ORM contexts, mirroring the approach used for other models. --- src/mavedb/view_models/variant.py | 21 ++++++----- tests/view_models/test_variant.py | 62 +++++++++++++++++++++++++++++-- 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/mavedb/view_models/variant.py b/src/mavedb/view_models/variant.py index 2fc62d7f..eeb0b7a8 100644 --- a/src/mavedb/view_models/variant.py +++ b/src/mavedb/view_models/variant.py @@ -4,9 +4,9 @@ from pydantic import model_validator from mavedb.lib.validation.exceptions import ValidationError -from mavedb.view_models.mapped_variant import MappedVariant, SavedMappedVariant from mavedb.view_models import record_type_validator, set_record_type from mavedb.view_models.base.base import BaseModel +from mavedb.view_models.mapped_variant import MappedVariant, SavedMappedVariant class VariantEffectMeasurementBase(BaseModel): @@ -51,18 +51,19 @@ class SavedVariantEffectMeasurementWithMappedVariant(SavedVariantEffectMeasureme mapped_variant: Optional[SavedMappedVariant] = None + # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") - def generate_score_set_urn_list(cls, data: Any): - if not hasattr(data, "mapped_variant"): + def generate_associated_mapped_variant(cls, data: Any): + if hasattr(data, "mapped_variants"): try: - mapped_variant = None - if data.mapped_variants: - mapped_variant = next( - mapped_variant for mapped_variant in data.mapped_variants if mapped_variant.current - ) + mapped_variant = next( + (mapped_variant for mapped_variant in data.mapped_variants if mapped_variant.current), None + ) data.__setattr__("mapped_variant", mapped_variant) - except AttributeError as exc: - raise ValidationError(f"Unable to create {cls.__name__} without attribute: {exc}.") # type: ignore + except (AttributeError, KeyError) as exc: + raise ValidationError(f"Unable to coerce mapped variant for {cls.__name__}: {exc}.") # type: ignore return data diff --git a/tests/view_models/test_variant.py b/tests/view_models/test_variant.py index 9ec2d2f3..200eca9f 100644 --- a/tests/view_models/test_variant.py +++ b/tests/view_models/test_variant.py @@ -1,7 +1,15 @@ -from mavedb.view_models.variant import VariantEffectMeasurementCreate, VariantEffectMeasurement - +from mavedb.view_models.variant import ( + SavedVariantEffectMeasurementWithMappedVariant, + VariantEffectMeasurement, + VariantEffectMeasurementCreate, +) +from tests.helpers.constants import ( + TEST_MINIMAL_MAPPED_VARIANT, + TEST_MINIMAL_VARIANT, + TEST_POPULATED_VARIANT, + TEST_SAVED_VARIANT, +) from tests.helpers.util.common import dummy_attributed_object_from_dict -from tests.helpers.constants import TEST_MINIMAL_VARIANT, TEST_POPULATED_VARIANT, TEST_SAVED_VARIANT def test_minimal_variant_create(): @@ -19,3 +27,51 @@ def test_saved_variant(): dummy_attributed_object_from_dict({**TEST_SAVED_VARIANT, "score_set_id": 1}) ) assert all(variant.__getattribute__(k) == v for k, v in TEST_SAVED_VARIANT.items()) + + +def test_can_create_saved_variant_with_mapping_with_all_attributed_properties(): + variant = TEST_SAVED_VARIANT.copy() + variant["score_set_id"] = 1 + variant["mapped_variants"] = [ + dummy_attributed_object_from_dict( + { + **TEST_MINIMAL_MAPPED_VARIANT, + "id": 1, + "variant": dummy_attributed_object_from_dict({"urn": "urn:mavedb:variant-xxx"}), + } + ) + ] + variant_attributed_object = dummy_attributed_object_from_dict(variant) + saved_variant = SavedVariantEffectMeasurementWithMappedVariant.model_validate(variant_attributed_object) + assert saved_variant.mapped_variant is not None + assert saved_variant.mapped_variant.variant_urn == "urn:mavedb:variant-xxx" + for k, v in TEST_SAVED_VARIANT.items(): + assert saved_variant.__getattribute__(k) == v + + +# Missing attributed properties here are unproblematic, as they are optional on the view model. +def test_can_create_saved_variant_with_mapping_with_missing_attributed_properties(): + variant = TEST_SAVED_VARIANT.copy() + variant.pop("mapped_variants", None) + variant["score_set_id"] = 1 + + variant_attributed_object = dummy_attributed_object_from_dict(variant) + saved_variant = SavedVariantEffectMeasurementWithMappedVariant.model_validate(variant_attributed_object) + for k, v in TEST_SAVED_VARIANT.items(): + assert saved_variant.__getattribute__(k) == v + + +def test_can_create_saved_variant_with_mapping_from_non_orm_context(): + variant = TEST_SAVED_VARIANT.copy() + variant["score_set_id"] = 1 + variant["mapped_variant"] = { + **TEST_MINIMAL_MAPPED_VARIANT, + "id": 1, + "variant_urn": "urn:mavedb:variant-xxx", + } + + saved_variant = SavedVariantEffectMeasurementWithMappedVariant.model_validate(variant) + assert saved_variant.mapped_variant is not None + assert saved_variant.mapped_variant.variant_urn == "urn:mavedb:variant-xxx" + for k, v in TEST_SAVED_VARIANT.items(): + assert saved_variant.__getattribute__(k) == v From aa074561c905d5ea9ffd56dbcafa4e19f8438f43 Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 23 Dec 2025 17:12:46 -0800 Subject: [PATCH 6/7] fix: robust synthetic property handling for score calibrations - Refactored ScoreCalibration view models to ensure robust handling of synthetic and required fields for both ORM and dict contexts. - Made the source fields non-optional to enforce required data integrity. - Added tests to verify all key attributes and synthetic properties are correctly handled in both construction modes. - Ensured creation from both dict and ORM contexts, mirroring the approach used for other models. --- .../scripts/load_pp_style_calibration.py | 2 + src/mavedb/view_models/score_calibration.py | 57 ++++++++----------- tests/helpers/constants.py | 8 +-- tests/view_models/test_score_calibration.py | 39 ++++++++++++- 4 files changed, 67 insertions(+), 39 deletions(-) diff --git a/src/mavedb/scripts/load_pp_style_calibration.py b/src/mavedb/scripts/load_pp_style_calibration.py index 3d5015e4..fd8f0831 100644 --- a/src/mavedb/scripts/load_pp_style_calibration.py +++ b/src/mavedb/scripts/load_pp_style_calibration.py @@ -231,6 +231,8 @@ def main(db: Session, archive_path: str, dataset_map: str, overwrite: bool) -> N score_set_urn=score_set.urn, calibration_metadata={"prior_probability_pathogenicity": calibration_data.get("prior", None)}, method_sources=[ZEIBERG_CALIBRATION_CITATION], + threshold_sources=[], + classification_sources=[], ) new_calibration_object = asyncio.run( diff --git a/src/mavedb/view_models/score_calibration.py b/src/mavedb/view_models/score_calibration.py index 00d5d692..2ee4317b 100644 --- a/src/mavedb/view_models/score_calibration.py +++ b/src/mavedb/view_models/score_calibration.py @@ -184,9 +184,9 @@ class ScoreCalibrationBase(BaseModel): notes: Optional[str] = None functional_ranges: Optional[Sequence[FunctionalRangeBase]] = None - threshold_sources: Optional[Sequence[PublicationIdentifierBase]] = None - classification_sources: Optional[Sequence[PublicationIdentifierBase]] = None - method_sources: Optional[Sequence[PublicationIdentifierBase]] = None + threshold_sources: Sequence[PublicationIdentifierBase] + classification_sources: Sequence[PublicationIdentifierBase] + method_sources: Sequence[PublicationIdentifierBase] calibration_metadata: Optional[dict] = None @field_validator("functional_ranges") @@ -278,18 +278,18 @@ class ScoreCalibrationModify(ScoreCalibrationBase): score_set_urn: Optional[str] = None functional_ranges: Optional[Sequence[FunctionalRangeModify]] = None - threshold_sources: Optional[Sequence[PublicationIdentifierCreate]] = None - classification_sources: Optional[Sequence[PublicationIdentifierCreate]] = None - method_sources: Optional[Sequence[PublicationIdentifierCreate]] = None + threshold_sources: Sequence[PublicationIdentifierCreate] + classification_sources: Sequence[PublicationIdentifierCreate] + method_sources: Sequence[PublicationIdentifierCreate] class ScoreCalibrationCreate(ScoreCalibrationModify): """Model used to create a new score calibration.""" functional_ranges: Optional[Sequence[FunctionalRangeCreate]] = None - threshold_sources: Optional[Sequence[PublicationIdentifierCreate]] = None - classification_sources: Optional[Sequence[PublicationIdentifierCreate]] = None - method_sources: Optional[Sequence[PublicationIdentifierCreate]] = None + threshold_sources: Sequence[PublicationIdentifierCreate] + classification_sources: Sequence[PublicationIdentifierCreate] + method_sources: Sequence[PublicationIdentifierCreate] class SavedScoreCalibration(ScoreCalibrationBase): @@ -307,9 +307,9 @@ class SavedScoreCalibration(ScoreCalibrationBase): private: bool = True functional_ranges: Optional[Sequence[SavedFunctionalRange]] = None - threshold_sources: Optional[Sequence[SavedPublicationIdentifier]] = None - classification_sources: Optional[Sequence[SavedPublicationIdentifier]] = None - method_sources: Optional[Sequence[SavedPublicationIdentifier]] = None + threshold_sources: Sequence[SavedPublicationIdentifier] + classification_sources: Sequence[SavedPublicationIdentifier] + method_sources: Sequence[SavedPublicationIdentifier] created_by: Optional[SavedUser] = None modified_by: Optional[SavedUser] = None @@ -327,9 +327,6 @@ class Config: @field_validator("threshold_sources", "classification_sources", "method_sources", mode="before") def publication_identifiers_validator(cls, value: Any) -> Optional[list[PublicationIdentifier]]: """Coerce association proxy collections to plain lists.""" - if value is None: - return None - assert isinstance(value, Collection), "Publication identifier lists must be a collection" return list(value) @@ -354,19 +351,13 @@ def primary_calibrations_may_not_be_private(self: "SavedScoreCalibration") -> "S return self + # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") def generate_threshold_classification_and_method_sources(cls, data: Any): # type: ignore[override] """Populate threshold/classification/method source fields from association objects if missing.""" - association_keys = { - "threshold_sources", - "thresholdSources", - "classification_sources", - "classificationSources", - "method_sources", - "methodSources", - } - - if not any(hasattr(data, key) for key in association_keys): + if hasattr(data, "publication_identifier_associations"): try: publication_identifiers = transform_score_calibration_publication_identifiers( data.publication_identifier_associations @@ -374,9 +365,9 @@ def generate_threshold_classification_and_method_sources(cls, data: Any): # typ data.__setattr__("threshold_sources", publication_identifiers["threshold_sources"]) data.__setattr__("classification_sources", publication_identifiers["classification_sources"]) data.__setattr__("method_sources", publication_identifiers["method_sources"]) - except AttributeError as exc: + except (AttributeError, KeyError) as exc: raise ValidationError( - f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore + f"Unable to coerce publication associations for {cls.__name__}: {exc}." # type: ignore ) return data @@ -385,9 +376,9 @@ class ScoreCalibration(SavedScoreCalibration): """Complete score calibration model returned by the API.""" functional_ranges: Optional[Sequence[FunctionalRange]] = None - threshold_sources: Optional[Sequence[PublicationIdentifier]] = None - classification_sources: Optional[Sequence[PublicationIdentifier]] = None - method_sources: Optional[Sequence[PublicationIdentifier]] = None + threshold_sources: Sequence[PublicationIdentifier] + classification_sources: Sequence[PublicationIdentifier] + method_sources: Sequence[PublicationIdentifier] created_by: Optional[User] = None modified_by: Optional[User] = None @@ -399,11 +390,11 @@ class ScoreCalibrationWithScoreSetUrn(SavedScoreCalibration): @model_validator(mode="before") def generate_score_set_urn(cls, data: Any): - if not hasattr(data, "score_set_urn"): + if hasattr(data, "score_set"): try: data.__setattr__("score_set_urn", transform_score_set_to_urn(data.score_set)) - except AttributeError as exc: + except (AttributeError, KeyError) as exc: raise ValidationError( - f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore + f"Unable to coerce score set urn for {cls.__name__}: {exc}." # type: ignore ) return data diff --git a/tests/helpers/constants.py b/tests/helpers/constants.py index 1a219f17..2a4b255c 100644 --- a/tests/helpers/constants.py +++ b/tests/helpers/constants.py @@ -1561,8 +1561,8 @@ TEST_FUNCTIONAL_RANGE_ABNORMAL, ], "threshold_sources": [{"identifier": TEST_PUBMED_IDENTIFIER, "db_name": "PubMed"}], - "classification_sources": None, - "method_sources": None, + "classification_sources": [], + "method_sources": [], "calibration_metadata": {}, } @@ -1578,8 +1578,8 @@ TEST_SAVED_FUNCTIONAL_RANGE_ABNORMAL, ], "thresholdSources": [SAVED_PUBMED_PUBLICATION], - "classificationSources": None, - "methodSources": None, + "classificationSources": [], + "methodSources": [], "id": 2, "investigatorProvided": True, "primary": False, diff --git a/tests/view_models/test_score_calibration.py b/tests/view_models/test_score_calibration.py index bf89aec4..7df316f2 100644 --- a/tests/view_models/test_score_calibration.py +++ b/tests/view_models/test_score_calibration.py @@ -392,13 +392,20 @@ def test_can_create_valid_score_calibration_from_attributed_object(valid_calibra def test_cannot_create_score_calibration_when_publication_information_is_missing(): invalid_data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + # Add publication identifiers with missing information invalid_data.pop("thresholdSources", None) invalid_data.pop("classificationSources", None) invalid_data.pop("methodSources", None) - with pytest.raises(ValidationError, match="Unable to create ScoreCalibration without attribute"): + + with pytest.raises(ValidationError) as exc_info: ScoreCalibration.model_validate(dummy_attributed_object_from_dict(invalid_data)) + assert "Field required" in str(exc_info.value) + assert "thresholdSources" in str(exc_info.value) + assert "classificationSources" in str(exc_info.value) + assert "methodSources" in str(exc_info.value) + def test_can_create_score_calibration_from_association_style_publication_identifiers_against_attributed_object(): orig_data = TEST_SAVED_BRNICH_SCORE_CALIBRATION @@ -480,6 +487,24 @@ def test_primary_score_calibration_cannot_be_private(): ScoreCalibration.model_validate(dummy_attributed_object_from_dict(invalid_data)) +def test_can_create_score_calibration_from_non_orm_context(): + data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + + sc = ScoreCalibration.model_validate(data) + + assert sc.title == data["title"] + assert sc.research_use_only == data.get("researchUseOnly", False) + assert sc.primary == data.get("primary", False) + assert sc.investigator_provided == data.get("investigatorProvided", False) + assert sc.baseline_score == data.get("baselineScore") + assert sc.baseline_score_description == data.get("baselineScoreDescription") + assert len(sc.functional_ranges) == len(data["functionalRanges"]) + assert len(sc.threshold_sources) == len(data["thresholdSources"]) + assert len(sc.classification_sources) == len(data["classificationSources"]) + assert len(sc.method_sources) == len(data["methodSources"]) + assert sc.calibration_metadata == data.get("calibrationMetadata") + + def test_score_calibration_with_score_set_urn_can_be_created_from_attributed_object(): data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) data["score_set"] = dummy_attributed_object_from_dict({"urn": "urn:mavedb:00000000-0000-0000-0000-000000000001"}) @@ -493,5 +518,15 @@ def test_score_calibration_with_score_set_urn_can_be_created_from_attributed_obj def test_score_calibration_with_score_set_urn_cannot_be_created_without_score_set_urn(): invalid_data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) invalid_data["score_set"] = dummy_attributed_object_from_dict({}) - with pytest.raises(ValidationError, match="Unable to create ScoreCalibrationWithScoreSetUrn without attribute"): + with pytest.raises(ValidationError, match="Unable to coerce score set urn for ScoreCalibrationWithScoreSetUrn"): ScoreCalibrationWithScoreSetUrn.model_validate(dummy_attributed_object_from_dict(invalid_data)) + + +def test_score_calibration_with_score_set_urn_can_be_created_from_non_orm_context(): + data = deepcopy(TEST_SAVED_BRNICH_SCORE_CALIBRATION) + data["score_set_urn"] = "urn:mavedb:00000000-0000-0000-0000-000000000001" + + sc = ScoreCalibrationWithScoreSetUrn.model_validate(data) + + assert sc.title == data["title"] + assert sc.score_set_urn == data["score_set_urn"] From 4186f63e81370ace58f626f54fd57e7e9efedf0b Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Tue, 23 Dec 2025 17:30:19 -0800 Subject: [PATCH 7/7] fix: robust synthetic property handling for score sets - Refactored ScoreSet view models to ensure robust handling of synthetic and required fields for both ORM and dict contexts. - Added tests to verify all key attributes and synthetic properties are correctly handled in both construction modes. - Ensured creation from both dict and ORM contexts, mirroring the approach used for other models. --- src/mavedb/view_models/score_set.py | 40 ++++++++++++++--------------- tests/view_models/test_score_set.py | 35 ++++++++++++++++++++----- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/mavedb/view_models/score_set.py b/src/mavedb/view_models/score_set.py index 9f53cf64..176ab3d0 100644 --- a/src/mavedb/view_models/score_set.py +++ b/src/mavedb/view_models/score_set.py @@ -311,12 +311,11 @@ class Config: arbitrary_types_allowed = True # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting - # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") def generate_primary_and_secondary_publications(cls, data: Any): - if not hasattr(data, "primary_publication_identifiers") or not hasattr( - data, "secondary_publication_identifiers" - ): + if hasattr(data, "publication_identifier_associations"): try: publication_identifiers = transform_record_publication_identifiers( data.publication_identifier_associations @@ -327,9 +326,9 @@ def generate_primary_and_secondary_publications(cls, data: Any): data.__setattr__( "secondary_publication_identifiers", publication_identifiers["secondary_publication_identifiers"] ) - except AttributeError as exc: + except (AttributeError, KeyError) as exc: raise ValidationError( - f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore + f"Unable to coerce publication identifier attributes for {cls.__name__}: {exc}." # type: ignore ) return data @@ -384,12 +383,11 @@ def publication_identifiers_validator(cls, value: Any) -> list[PublicationIdenti return list(value) # Re-cast into proper list-like type # These 'synthetic' fields are generated from other model properties. Transform data from other properties as needed, setting - # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. + # the appropriate field on the model itself. Then, proceed with Pydantic ingestion once fields are created. Only perform these + # transformations if the relevant attributes are present on the input data (i.e., when creating from an ORM object). @model_validator(mode="before") def generate_primary_and_secondary_publications(cls, data: Any): - if not hasattr(data, "primary_publication_identifiers") or not hasattr( - data, "secondary_publication_identifiers" - ): + if hasattr(data, "publication_identifier_associations"): try: publication_identifiers = transform_record_publication_identifiers( data.publication_identifier_associations @@ -400,33 +398,35 @@ def generate_primary_and_secondary_publications(cls, data: Any): data.__setattr__( "secondary_publication_identifiers", publication_identifiers["secondary_publication_identifiers"] ) - except AttributeError as exc: - raise ValidationError( - f"Unable to create {cls.__name__} without attribute: {exc}." # type: ignore - ) + except (AttributeError, KeyError) as exc: + raise ValidationError(f"Unable to coerce publication identifier attributes for {cls.__name__}: {exc}.") return data @model_validator(mode="before") def transform_meta_analysis_objects_to_urns(cls, data: Any): - if not hasattr(data, "meta_analyzes_score_set_urns"): + if hasattr(data, "meta_analyzes_score_sets"): try: data.__setattr__( "meta_analyzes_score_set_urns", transform_score_set_list_to_urn_list(data.meta_analyzes_score_sets) ) - except AttributeError as exc: - raise ValidationError(f"Unable to create {cls.__name__} without attribute: {exc}.") # type: ignore + except (AttributeError, KeyError) as exc: + raise ValidationError( + f"Unable to coerce meta analyzes score set urn attribute for {cls.__name__}: {exc}." + ) return data @model_validator(mode="before") def transform_meta_analyzed_objects_to_urns(cls, data: Any): - if not hasattr(data, "meta_analyzed_by_score_set_urns"): + if hasattr(data, "meta_analyzed_by_score_sets"): try: data.__setattr__( "meta_analyzed_by_score_set_urns", transform_score_set_list_to_urn_list(data.meta_analyzed_by_score_sets), ) - except AttributeError as exc: - raise ValidationError(f"Unable to create {cls.__name__} without attribute: {exc}.") # type: ignore + except (AttributeError, KeyError) as exc: + raise ValidationError( + f"Unable to coerce meta analyzed by score set urn attribute for {cls.__name__}: {exc}." + ) return data diff --git a/tests/view_models/test_score_set.py b/tests/view_models/test_score_set.py index 754b8657..983c399b 100644 --- a/tests/view_models/test_score_set.py +++ b/tests/view_models/test_score_set.py @@ -3,7 +3,13 @@ import pytest from mavedb.view_models.publication_identifier import PublicationIdentifier, PublicationIdentifierCreate -from mavedb.view_models.score_set import SavedScoreSet, ScoreSetCreate, ScoreSetModify, ScoreSetUpdateAllOptional +from mavedb.view_models.score_set import ( + SavedScoreSet, + ScoreSet, + ScoreSetCreate, + ScoreSetModify, + ScoreSetUpdateAllOptional, +) from mavedb.view_models.target_gene import SavedTargetGene, TargetGeneCreate from tests.helpers.constants import ( EXTRA_LICENSE, @@ -17,6 +23,7 @@ TEST_MINIMAL_SEQ_SCORESET_RESPONSE, TEST_PATHOGENICITY_SCORE_CALIBRATION, TEST_PUBMED_IDENTIFIER, + VALID_EXPERIMENT_SET_URN, VALID_EXPERIMENT_URN, VALID_SCORE_SET_URN, VALID_TMP_URN, @@ -372,10 +379,14 @@ def test_score_set_update_all_optional(attribute, updated_data): @pytest.mark.parametrize( - "exclude", - ["publication_identifier_associations", "meta_analyzes_score_sets", "meta_analyzed_by_score_sets"], + "exclude,expected_missing_fields", + [ + ("publication_identifier_associations", ["primaryPublicationIdentifiers", "secondaryPublicationIdentifiers"]), + ("meta_analyzes_score_sets", ["metaAnalyzesScoreSetUrns"]), + ("meta_analyzed_by_score_sets", ["metaAnalyzedByScoreSetUrns"]), + ], ) -def test_cannot_create_saved_score_set_without_all_attributed_properties(exclude): +def test_cannot_create_saved_score_set_without_all_attributed_properties(exclude, expected_missing_fields): score_set = TEST_MINIMAL_SEQ_SCORESET_RESPONSE.copy() score_set["urn"] = "urn:score-set-xxx" @@ -429,8 +440,9 @@ def test_cannot_create_saved_score_set_without_all_attributed_properties(exclude with pytest.raises(ValueError) as exc_info: SavedScoreSet.model_validate(score_set_attributed_object) - assert "Unable to create SavedScoreSet without attribute" in str(exc_info.value) - assert exclude in str(exc_info.value) + assert "Field required" in str(exc_info.value) + for exclude_field in expected_missing_fields: + assert exclude_field in str(exc_info.value) def test_can_create_score_set_with_none_type_superseded_score_set_urn(): @@ -543,3 +555,14 @@ def test_cant_create_score_set_without_experiment_urn_if_not_meta_analysis(): ScoreSetCreate(**score_set_test) assert "experiment URN is required unless your score set is a meta-analysis" in str(exc_info.value) + + +def test_can_create_score_set_from_non_orm_context(): + score_set_test = TEST_MINIMAL_SEQ_SCORESET_RESPONSE.copy() + score_set_test["urn"] = "urn:score-set-xxx" + score_set_test["experiment"]["urn"] = VALID_EXPERIMENT_URN + score_set_test["experiment"]["experimentSetUrn"] = VALID_EXPERIMENT_SET_URN + + saved_score_set = ScoreSet.model_validate(score_set_test) + + assert saved_score_set.urn == "urn:score-set-xxx"