From 95f27417989ec7cf7a9b1f9518a8d3b5c83493b4 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 27 Mar 2025 21:29:32 -0400 Subject: [PATCH 1/9] fix!: community models should not inherit from `Statement` / `EvidenceLine` close #21 * Use model validator instead of inheriting from `Statement` or `EvidenceLine` * `VariantOncogenicityFunctionalImpactEvidenceLine` and `VariantPathogenicityFunctionalImpactEvidenceLine` should NOT inherit from `EvidenceLine` * `VariantOncogenicityStudyStatement`, `VariantPathogenicityStatement`, `VariantDiagnosticStudyStatement`, `VariantPrognosticStudyStatement`, and `VariantTherapeuticResponseStudyStatement` should NOT inherit from `Statement` --- pyproject.toml | 2 +- src/ga4gh/va_spec/aac_2017/models.py | 16 ++-- src/ga4gh/va_spec/acmg_2015/models.py | 36 ++++----- src/ga4gh/va_spec/base/core.py | 99 ++++++++++++++++++++++--- src/ga4gh/va_spec/ccv_2022/models.py | 32 ++++---- tests/validation/test_va_spec_models.py | 68 ++++++++++++++++- 6 files changed, 202 insertions(+), 51 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a212da6..622ba28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ requires-python = ">=3.10" dynamic = ["version"] dependencies = [ "ga4gh.vrs==2.*", - "ga4gh.cat_vrs~=0.4.0", + "ga4gh.cat_vrs~-0.5.0", "pydantic==2.*" ] diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index f4c92b3..320edcd 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -9,7 +9,7 @@ from ga4gh.core.models import MappableConcept, iriReference from ga4gh.va_spec.base.core import ( Method, - Statement, + StatementValidatorMixin, VariantDiagnosticProposition, VariantPrognosticProposition, VariantTherapeuticResponseProposition, @@ -17,6 +17,7 @@ from ga4gh.va_spec.base.enums import System from ga4gh.va_spec.base.validators import validate_mappable_concept from pydantic import ( + BaseModel, Field, field_validator, ) @@ -46,8 +47,11 @@ class Classification(str, Enum): AMP_ASCO_CAP_TIERS = [v.value for v in Classification.__members__.values()] -class _ValidatorMixin: - """Mixin class for reusable AMP/ASCO/CAP field validators""" +class AmpAscoCapValidatorMixin(StatementValidatorMixin): + """Mixin class for reusable AMP/ASCO/CAP field validators + + Should be used with classes that inherit from Pydantic BaseModel + """ @field_validator("strength") @classmethod @@ -74,7 +78,7 @@ def validate_classification(cls, v: MappableConcept) -> MappableConcept: return validate_mappable_concept(v, System.AMP_ASCO_CAP, AMP_ASCO_CAP_TIERS) -class VariantDiagnosticStudyStatement(Statement, _ValidatorMixin): +class VariantDiagnosticStudyStatement(BaseModel, AmpAscoCapValidatorMixin): """A statement reporting a conclusion from a single study about whether a variant is associated with a disease (a diagnostic inclusion criterion), or absence of a disease (diagnostic exclusion criterion) - based on interpretation of the study's @@ -99,7 +103,7 @@ class VariantDiagnosticStudyStatement(Statement, _ValidatorMixin): ) -class VariantPrognosticStudyStatement(Statement, _ValidatorMixin): +class VariantPrognosticStudyStatement(BaseModel, AmpAscoCapValidatorMixin): """A statement reporting a conclusion from a single study about whether a variant is associated with a disease prognosis - based on interpretation of the study's results. @@ -123,7 +127,7 @@ class VariantPrognosticStudyStatement(Statement, _ValidatorMixin): ) -class VariantTherapeuticResponseStudyStatement(Statement, _ValidatorMixin): +class VariantTherapeuticResponseStudyStatement(BaseModel, AmpAscoCapValidatorMixin): """A statement reporting a conclusion from a single study about whether a variant is associated with a therapeutic response (positive or negative) - based on interpretation of the study's results. diff --git a/src/ga4gh/va_spec/acmg_2015/models.py b/src/ga4gh/va_spec/acmg_2015/models.py index 18dd24c..377f2fe 100644 --- a/src/ga4gh/va_spec/acmg_2015/models.py +++ b/src/ga4gh/va_spec/acmg_2015/models.py @@ -7,9 +7,9 @@ from ga4gh.core.models import MappableConcept, iriReference from ga4gh.va_spec.base.core import ( - EvidenceLine, + EvidenceLineValidatorMixin, Method, - Statement, + StatementValidatorMixin, VariantPathogenicityProposition, ) from ga4gh.va_spec.base.enums import ( @@ -18,8 +18,10 @@ STRENGTHS, System, ) -from ga4gh.va_spec.base.validators import validate_mappable_concept -from pydantic import Field, field_validator +from ga4gh.va_spec.base.validators import ( + validate_mappable_concept, +) +from pydantic import BaseModel, Field, field_validator, model_validator class EvidenceOutcome(str, Enum): @@ -51,7 +53,9 @@ class AcmgClassification(str, Enum): ACMG_CLASSIFICATIONS = [v.value for v in AcmgClassification.__members__.values()] -class VariantPathogenicityFunctionalImpactEvidenceLine(EvidenceLine): +class VariantPathogenicityFunctionalImpactEvidenceLine( + BaseModel, EvidenceLineValidatorMixin +): """An Evidence Line that describes how information about the functional impact of a variant on a gene or gene product was interpreted as evidence for or against the variant's pathogenicity. @@ -100,23 +104,21 @@ def validate_specified_by(cls, v: Method | iriReference) -> Method | iriReferenc return v - @field_validator("evidenceOutcome") - @classmethod - def validate_evidence_outcome( - cls, v: MappableConcept | None - ) -> MappableConcept | None: - """Validate evidenceOutcome + @model_validator(mode="before") + def validate_evidence_outcome(cls, values: dict) -> dict: # noqa: N805 + """Validate ``evidenceOutcome`` property if it exists - :param v: evidenceOutcome - :raises ValueError: If invalid evidenceOutcome values are provided - :return: Validated evidenceOutcome value + :param values: Input values + :raises ValueError: If ``evidenceOutcome`` exists and is invalid + :return: Validated input values. If ``evidenceOutcome`` exists, then it will be + validated and converted to a ``MappableConcept`` """ - return validate_mappable_concept( - v, System.ACMG, EVIDENCE_OUTCOME_VALUES, mc_is_required=False + return cls._validate_evidence_outcome( + values, System.ACMG, EVIDENCE_OUTCOME_VALUES ) -class VariantPathogenicityStatement(Statement): +class VariantPathogenicityStatement(BaseModel, StatementValidatorMixin): """A Statement describing the role of a variant in causing an inherited condition.""" proposition: VariantPathogenicityProposition | None = Field( diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index 6512ada..f64c1e8 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -20,19 +20,24 @@ from ga4gh.va_spec.base.enums import ( DiagnosticPredicate, PrognosticPredicate, + System, TherapeuticResponsePredicate, ) +from ga4gh.va_spec.base.validators import validate_mappable_concept from ga4gh.vrs.models import Allele, MolecularVariation from pydantic import ( + BaseModel, ConfigDict, Field, RootModel, StringConstraints, ValidationError, field_validator, + model_validator, ) -StatementType = TypeVar("StatementType", bound="Statement") +StatementType = TypeVar("StatementType") +EvidenceLineType = TypeVar("EvidenceLineType") ######################################### # Abstract Core Classes @@ -484,7 +489,7 @@ class EvidenceLine(InformationEntity, BaseModelForbidExtra): description="The possible fact against which evidence items contained in an Evidence Line were collectively evaluated, in determining the overall strength and direction of support they provide. For example, in an ACMG Guideline-based assessment of variant pathogenicity, the support provided by distinct lines of evidence are assessed against a target proposition that the variant is pathogenic for a specific disease.", ) hasEvidenceItems: ( - list[StudyResult | StatementType | EvidenceLine | iriReference] | None + list[StudyResult | StatementType | EvidenceLineType | iriReference] | None ) = Field( None, description="An individual piece of information that was evaluated as evidence in building the argument represented by an Evidence Line.", @@ -509,7 +514,7 @@ class EvidenceLine(InformationEntity, BaseModelForbidExtra): @field_validator("hasEvidenceItems", mode="before") def validate_has_evidence_items( cls, # noqa: N805 - v: list[StudyResult, StatementType, EvidenceLine, iriReference] | None, + v: list | None, ) -> list | None: """Ensure hasEvidenceItems is correct type @@ -539,15 +544,12 @@ def validate_has_evidence_items( obj_ for _, obj_ in vars(imported_module).items() if inspect.isclass(obj_) - and issubclass(obj_, Statement) - and obj_ is not Statement + and issubclass(obj_, BaseModel) + and obj_.__name__.endswith(("Statement", "EvidenceLine")) ] ) - has_evidence_items_models.extend( - [Statement, StudyResult, EvidenceLine, iriReference] - ) - + has_evidence_items_models.extend([Statement, StudyResult, EvidenceLine]) for evidence_item in v: if isinstance(evidence_item, dict): found_model = False @@ -563,6 +565,8 @@ def validate_has_evidence_items( if not found_model: err_msg = "Unable to find valid model" raise ValueError(err_msg) + elif isinstance(evidence_item, str): + evidence_items.append(iriReference(root=evidence_item)) else: evidence_items.append(evidence_item) return evidence_items @@ -580,9 +584,17 @@ class Statement(InformationEntity, BaseModelForbidExtra): type: Literal["Statement"] = Field( CoreType.STATEMENT.value, description=f"MUST be '{CoreType.STATEMENT.value}'." ) - proposition: Proposition = Field( + proposition: ( + ExperimentalVariantFunctionalImpactProposition + | VariantDiagnosticProposition + | VariantOncogenicityProposition + | VariantPathogenicityProposition + | VariantPrognosticProposition + | VariantTherapeuticResponseProposition + ) = Field( ..., description="A possible fact, the validity of which is assessed and reported by the Statement. A Statement can put forth the proposition as being true, false, or uncertain, and may provide an assessment of the level of confidence/evidence supporting this claim.", + discriminator="type", ) direction: Direction = Field( ..., @@ -624,3 +636,70 @@ class StudyGroup(Entity, BaseModelForbidExtra): None, description="A feature or role shared by all members of the StudyGroup, representing a criterion for membership in the group.", ) + + +class StatementValidatorMixin: + """Mixin class for reusable Statement model validators + + Should be used with classes that inherit from Pydantic BaseModel + """ + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="after") + def statement_validator(cls, model: BaseModel) -> BaseModel: # noqa: N805 + """Validate that the model is a ``Statement``. + + :param model: Pydantic BaseModel to validate + :raises ValueError: If ``model`` does not validate against a ``Statement`` + :return: Validated model + """ + try: + Statement(**model.model_dump()) + except ValidationError as e: + err_msg = f"Must be a `Statement`: {e}" + raise ValueError(err_msg) from e + return model + + +class EvidenceLineValidatorMixin: + """Mixin class for reusable EvidenceLine model validators + + Should be used with classes that inherit from Pydantic BaseModel + """ + + model_config = ConfigDict(extra="allow") + + @staticmethod + def _validate_evidence_outcome( + values: dict, system: System, codes: list[str] + ) -> dict: + """Validate ``evidenceOutcome`` property if it exists + + :param values: Input values + :param system: System that should be used in ``MappableConcept`` + :param codes: Codes that should be used in ``MappableConcept`` + :raises ValueError: If ``evidenceOutcome`` exists and is invalid + :return: Validated input values. If ``evidenceOutcome`` exists, then it will be + validated and converted to a ``MappableConcept`` + """ + if "evidenceOutcome" in values: + mc = MappableConcept(**values["evidenceOutcome"]) + values["evidenceOutcome"] = mc + validate_mappable_concept(mc, system, codes, mc_is_required=False) + return values + + @model_validator(mode="after") + def evidence_line_validator(cls, model: BaseModel) -> BaseModel: # noqa: N805 + """Validate that the model is a ``EvidenceLine``. + + :param model: Pydantic BaseModel to validate + :raises ValueError: If ``model`` does not validate against a ``EvidenceLine`` + :return: Validated model + """ + try: + EvidenceLine(**model.model_dump()) + except ValidationError as e: + err_msg = f"Must be a `EvidenceLine`: {e}" + raise ValueError(err_msg) from e + return model diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index 682c442..bfcdef1 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -7,9 +7,9 @@ from ga4gh.core.models import MappableConcept, iriReference from ga4gh.va_spec.base.core import ( - EvidenceLine, + EvidenceLineValidatorMixin, Method, - Statement, + StatementValidatorMixin, VariantOncogenicityProposition, ) from ga4gh.va_spec.base.enums import ( @@ -19,7 +19,7 @@ System, ) from ga4gh.va_spec.base.validators import validate_mappable_concept -from pydantic import Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator class EvidenceOutcome(str, Enum): @@ -38,7 +38,9 @@ class EvidenceOutcome(str, Enum): EVIDENCE_OUTCOME_VALUES = [v.value for v in EvidenceOutcome.__members__.values()] -class VariantOncogenicityFunctionalImpactEvidenceLine(EvidenceLine): +class VariantOncogenicityFunctionalImpactEvidenceLine( + BaseModel, EvidenceLineValidatorMixin +): """An Evidence Line that describes how information about the functional impact of a variant on a gene or gene product was interpreted as evidence for or against the variant's oncogenicity. @@ -72,23 +74,21 @@ def validate_strength_of_evidence_provided( v, System.CCV, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, mc_is_required=False ) - @field_validator("evidenceOutcome") - @classmethod - def validate_evidence_outcome( - cls, v: MappableConcept | None - ) -> MappableConcept | None: - """Validate evidenceOutcome + @model_validator(mode="before") + def validate_evidence_outcome(cls, values: dict) -> dict: # noqa: N805 + """Validate ``evidenceOutcome`` property if it exists - :param v: evidenceOutcome - :raises ValueError: If invalid evidenceOutcome values are provided - :return: Validated evidenceOutcome value + :param values: Input values + :raises ValueError: If ``evidenceOutcome`` exists and is invalid + :return: Validated input values. If ``evidenceOutcome`` exists, then it will be + validated and converted to a ``MappableConcept`` """ - return validate_mappable_concept( - v, System.CCV, EVIDENCE_OUTCOME_VALUES, mc_is_required=False + return cls._validate_evidence_outcome( + values, System.CCV, EVIDENCE_OUTCOME_VALUES ) -class VariantOncogenicityStudyStatement(Statement): +class VariantOncogenicityStudyStatement(BaseModel, StatementValidatorMixin): """A statement reporting a conclusion from a single study about whether a variant is associated with oncogenicity (positive or negative) - based on interpretation of the study's results. diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 058fc5e..1cf3043 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -4,15 +4,21 @@ import pytest import yaml -from ga4gh.core.models import iriReference +from ga4gh.core.models import Coding, MappableConcept, code, iriReference from ga4gh.va_spec import acmg_2015, base, ccv_2022 from ga4gh.va_spec.aac_2017.models import VariantTherapeuticResponseStudyStatement +from ga4gh.va_spec.acmg_2015.models import ( + VariantPathogenicityFunctionalImpactEvidenceLine, +) from ga4gh.va_spec.base import ( Agent, CohortAlleleFrequencyStudyResult, ExperimentalVariantFunctionalImpactStudyResult, ) from ga4gh.va_spec.base.core import EvidenceLine, StudyGroup, StudyResult +from ga4gh.va_spec.ccv_2022.models import ( + VariantOncogenicityFunctionalImpactEvidenceLine, +) from pydantic import ValidationError from tests.conftest import SUBMODULES_DIR @@ -200,6 +206,66 @@ def test_evidence_line(caf): assert isinstance(el.hasEvidenceItems[0], iriReference) +def test_variant_pathogenicity_el(): + """Ensure VariantPathogenicityFunctionalImpactEvidenceLine model works as expected""" + vp = VariantPathogenicityFunctionalImpactEvidenceLine( + type="EvidenceLine", + specifiedBy={ + "type": "Method", + "id": "PS3", + "name": "ACMG 2015 PS3 Criterion", + "reportedIn": { + "type": "Document", + "pmid": 25741868, + "name": "ACMG Guidelines, 2015", + }, + }, + directionOfEvidenceProvided="supports", + evidenceOutcome={ + "primaryCoding": { + "code": "PS3_supporting", + "system": "ACMG Guidelines, 2015", + }, + "name": "ACMG 2015 PS3 Supporting Criterion Met", + }, + ) + assert vp.evidenceOutcome == MappableConcept( + primaryCoding=Coding( + code=code(root="PS3_supporting"), system="ACMG Guidelines, 2015" + ), + name="ACMG 2015 PS3 Supporting Criterion Met", + ) + + +def test_variant_onco_el(): + """Ensure VariantOncogenicityFunctionalImpactEvidenceLine model works as expected""" + vp = VariantOncogenicityFunctionalImpactEvidenceLine( + type="EvidenceLine", + specifiedBy={ + "type": "Method", + "reportedIn": { + "type": "Document", + "pmid": 35101336, + "name": "ClinGen/CGC/VICC Guidelines for Oncogenicity, 2022", + }, + }, + directionOfEvidenceProvided="supports", + scoreOfEvidenceProvided=1, + evidenceOutcome={ + "primaryCoding": { + "code": "OS2_supporting", + "system": "ClinGen/CGC/VICC Guidelines for Oncogenicity, 2022", + }, + }, + ) + assert vp.evidenceOutcome == MappableConcept( + primaryCoding=Coding( + code=code(root="OS2_supporting"), + system="ClinGen/CGC/VICC Guidelines for Oncogenicity, 2022", + ), + ) + + def test_examples(test_definitions): """Test VA Spec examples""" va_spec_schema_mapping = { From 950f3dcb794f3619ea387f79f2aee40a89a9301d Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 27 Mar 2025 21:35:10 -0400 Subject: [PATCH 2/9] update err msg --- src/ga4gh/va_spec/base/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index f64c1e8..0843255 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -700,6 +700,6 @@ def evidence_line_validator(cls, model: BaseModel) -> BaseModel: # noqa: N805 try: EvidenceLine(**model.model_dump()) except ValidationError as e: - err_msg = f"Must be a `EvidenceLine`: {e}" + err_msg = f"Must be an `EvidenceLine`: {e}" raise ValueError(err_msg) from e return model From 7004f425a21109a223be5ffe9a71649ee74ca8ae Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 27 Mar 2025 21:35:41 -0400 Subject: [PATCH 3/9] fix copy/paste --- tests/validation/test_va_spec_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 1cf3043..4c78973 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -239,7 +239,7 @@ def test_variant_pathogenicity_el(): def test_variant_onco_el(): """Ensure VariantOncogenicityFunctionalImpactEvidenceLine model works as expected""" - vp = VariantOncogenicityFunctionalImpactEvidenceLine( + vo = VariantOncogenicityFunctionalImpactEvidenceLine( type="EvidenceLine", specifiedBy={ "type": "Method", @@ -258,7 +258,7 @@ def test_variant_onco_el(): }, }, ) - assert vp.evidenceOutcome == MappableConcept( + assert vo.evidenceOutcome == MappableConcept( primaryCoding=Coding( code=code(root="OS2_supporting"), system="ClinGen/CGC/VICC Guidelines for Oncogenicity, 2022", From 6f63ab8be013f13d7cf88037274fc04071fab6ac Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 27 Mar 2025 21:37:17 -0400 Subject: [PATCH 4/9] add check --- tests/validation/test_va_spec_models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 4c78973..7a93c55 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -15,7 +15,7 @@ CohortAlleleFrequencyStudyResult, ExperimentalVariantFunctionalImpactStudyResult, ) -from ga4gh.va_spec.base.core import EvidenceLine, StudyGroup, StudyResult +from ga4gh.va_spec.base.core import EvidenceLine, Method, StudyGroup, StudyResult from ga4gh.va_spec.ccv_2022.models import ( VariantOncogenicityFunctionalImpactEvidenceLine, ) @@ -229,6 +229,8 @@ def test_variant_pathogenicity_el(): "name": "ACMG 2015 PS3 Supporting Criterion Met", }, ) + + assert isinstance(vp.specifiedBy, Method) assert vp.evidenceOutcome == MappableConcept( primaryCoding=Coding( code=code(root="PS3_supporting"), system="ACMG Guidelines, 2015" @@ -258,6 +260,7 @@ def test_variant_onco_el(): }, }, ) + assert isinstance(vo.specifiedBy, Method) assert vo.evidenceOutcome == MappableConcept( primaryCoding=Coding( code=code(root="OS2_supporting"), From b31c8068a2414778f1a776a6fa77c7f35a90eda3 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 27 Mar 2025 21:38:42 -0400 Subject: [PATCH 5/9] fix typo: --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 622ba28..97bc739 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ requires-python = ">=3.10" dynamic = ["version"] dependencies = [ "ga4gh.vrs==2.*", - "ga4gh.cat_vrs~-0.5.0", + "ga4gh.cat_vrs~=0.5.0", "pydantic==2.*" ] From 2edcb6bef86b933b57ffc0171cb1900343c7a455 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 27 Mar 2025 21:59:00 -0400 Subject: [PATCH 6/9] add more tests for acmg --- src/ga4gh/va_spec/acmg_2015/models.py | 3 +- tests/validation/test_va_spec_models.py | 72 ++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/ga4gh/va_spec/acmg_2015/models.py b/src/ga4gh/va_spec/acmg_2015/models.py index 377f2fe..e923bb1 100644 --- a/src/ga4gh/va_spec/acmg_2015/models.py +++ b/src/ga4gh/va_spec/acmg_2015/models.py @@ -164,9 +164,10 @@ def validate_classification(cls, v: MappableConcept) -> MappableConcept: err_msg = "`primaryCoding` is required." raise ValueError(err_msg) - supported_systems = [System.ACMG.value, System.ACMG.value] + supported_systems = [System.ACMG.value, System.CLIN_GEN.value] if v.primaryCoding.system not in supported_systems: err_msg = f"`primaryCoding.system` must be one of: {supported_systems}." + raise ValueError(err_msg) if v.primaryCoding.system == System.ACMG: if v.primaryCoding.code.root not in ACMG_CLASSIFICATIONS: diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 7a93c55..0df0998 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -1,6 +1,7 @@ """Test VA Spec Pydantic model""" import json +from copy import deepcopy import pytest import yaml @@ -9,6 +10,7 @@ from ga4gh.va_spec.aac_2017.models import VariantTherapeuticResponseStudyStatement from ga4gh.va_spec.acmg_2015.models import ( VariantPathogenicityFunctionalImpactEvidenceLine, + VariantPathogenicityStatement, ) from ga4gh.va_spec.base import ( Agent, @@ -206,11 +208,63 @@ def test_evidence_line(caf): assert isinstance(el.hasEvidenceItems[0], iriReference) +def test_variant_pathogenicity_stmt(): + """Ensure VariantPathogenicityStatement model works as expected""" + params = { + "direction": "supports", + "proposition": { + "type": "VariantPathogenicityProposition", + "predicate": "isCausalFor", + "objectCondition": "conditions.json#/1", + "subjectVariant": "alleles.json#/1", + }, + "classification": { + "primaryCoding": {"code": "pathogenic", "system": "ACMG Guidelines, 2015"} + }, + "specifiedBy": { + "reportedIn": { + "type": "Document", + "pmid": 25741868, + "name": "ACMG Guidelines, 2015", + } + }, + } + assert VariantPathogenicityStatement(**params) + + invalid_params = deepcopy(params) + del invalid_params["classification"]["primaryCoding"] + invalid_params["classification"]["name"] = "test" + with pytest.raises(ValueError, match="`primaryCoding` is required."): + VariantPathogenicityStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["classification"]["primaryCoding"]["system"] = ( + "AMP/ASCO/CAP (AAC) Guidelines, 2017" + ) + with pytest.raises(ValueError, match="`primaryCoding.system` must be one of"): + VariantPathogenicityStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["classification"]["primaryCoding"]["code"] = ( + "pathogenic, low penetrance" + ) + with pytest.raises(ValueError, match="`primaryCoding.code` must be one of"): + VariantPathogenicityStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["classification"]["primaryCoding"]["system"] = ( + "ClinGen Low Penetrance and Risk Allele Recommendations, 2024" + ) + invalid_params["classification"]["primaryCoding"]["code"] = "pathogenic" + with pytest.raises(ValueError, match="`primaryCoding.code` must be one of"): + VariantPathogenicityStatement(**invalid_params) + + def test_variant_pathogenicity_el(): """Ensure VariantPathogenicityFunctionalImpactEvidenceLine model works as expected""" - vp = VariantPathogenicityFunctionalImpactEvidenceLine( - type="EvidenceLine", - specifiedBy={ + params = { + "type": "EvidenceLine", + "specifiedBy": { "type": "Method", "id": "PS3", "name": "ACMG 2015 PS3 Criterion", @@ -220,15 +274,16 @@ def test_variant_pathogenicity_el(): "name": "ACMG Guidelines, 2015", }, }, - directionOfEvidenceProvided="supports", - evidenceOutcome={ + "directionOfEvidenceProvided": "supports", + "evidenceOutcome": { "primaryCoding": { "code": "PS3_supporting", "system": "ACMG Guidelines, 2015", }, "name": "ACMG 2015 PS3 Supporting Criterion Met", }, - ) + } + vp = VariantPathogenicityFunctionalImpactEvidenceLine(**params) assert isinstance(vp.specifiedBy, Method) assert vp.evidenceOutcome == MappableConcept( @@ -238,6 +293,11 @@ def test_variant_pathogenicity_el(): name="ACMG 2015 PS3 Supporting Criterion Met", ) + invalid_params = deepcopy(params) + del invalid_params["specifiedBy"]["reportedIn"] + with pytest.raises(ValueError, match="`reportedIn` is required"): + VariantPathogenicityFunctionalImpactEvidenceLine(**invalid_params) + def test_variant_onco_el(): """Ensure VariantOncogenicityFunctionalImpactEvidenceLine model works as expected""" From 748b91c309f0ed991428123f92bae3736aecce7b Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 27 Mar 2025 22:18:23 -0400 Subject: [PATCH 7/9] add more tests for ccv --- src/ga4gh/va_spec/base/__init__.py | 4 ++ src/ga4gh/va_spec/base/enums.py | 13 +++++++ src/ga4gh/va_spec/ccv_2022/models.py | 4 +- tests/validation/test_va_spec_models.py | 50 +++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/ga4gh/va_spec/base/__init__.py b/src/ga4gh/va_spec/base/__init__.py index 8e2e57b..cf9183f 100644 --- a/src/ga4gh/va_spec/base/__init__.py +++ b/src/ga4gh/va_spec/base/__init__.py @@ -27,9 +27,11 @@ ) from .domain_entities import Condition, ConditionSet, Therapeutic, TherapyGroup from .enums import ( + CCV_CLASSIFICATIONS, CLIN_GEN_CLASSIFICATIONS, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, STRENGTHS, + CcvClassification, ClinGenClassification, DiagnosticPredicate, MembershipOperator, @@ -42,7 +44,9 @@ __all__ = [ "Agent", + "CCV_CLASSIFICATIONS", "CLIN_GEN_CLASSIFICATIONS", + "CcvClassification", "ClinGenClassification", "ClinGenClassification", "ClinicalVariantProposition", diff --git a/src/ga4gh/va_spec/base/enums.py b/src/ga4gh/va_spec/base/enums.py index d890884..7615066 100644 --- a/src/ga4gh/va_spec/base/enums.py +++ b/src/ga4gh/va_spec/base/enums.py @@ -75,6 +75,19 @@ class ClinGenClassification(str, Enum): CLIN_GEN_CLASSIFICATIONS = [v.value for v in ClinGenClassification.__members__.values()] +class CcvClassification(str, Enum): + """Define constraints for CCV classifications""" + + ONCOGENIC = "oncogenic" + LIKELY_ONCOGENIC = "likely oncogenic" + UNCERTAIN_SIGNIFICANCE = "uncertain significance" + LIKELY_BENIGN = "likely benign" + BENIGN = "benign" + + +CCV_CLASSIFICATIONS = [v.value for v in CcvClassification.__members__.values()] + + class System(str, Enum): """Define constraints for systems""" diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index bfcdef1..260fb48 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -13,7 +13,7 @@ VariantOncogenicityProposition, ) from ga4gh.va_spec.base.enums import ( - CLIN_GEN_CLASSIFICATIONS, + CCV_CLASSIFICATIONS, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, STRENGTHS, System, @@ -133,5 +133,5 @@ def validate_classification(cls, v: MappableConcept) -> MappableConcept: :return: Validated classification value """ return validate_mappable_concept( - v, System.CLIN_GEN, CLIN_GEN_CLASSIFICATIONS, mc_is_required=True + v, System.CLIN_GEN, CCV_CLASSIFICATIONS, mc_is_required=True ) diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 0df0998..f99ed58 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -20,6 +20,7 @@ from ga4gh.va_spec.base.core import EvidenceLine, Method, StudyGroup, StudyResult from ga4gh.va_spec.ccv_2022.models import ( VariantOncogenicityFunctionalImpactEvidenceLine, + VariantOncogenicityStudyStatement, ) from pydantic import ValidationError @@ -299,6 +300,55 @@ def test_variant_pathogenicity_el(): VariantPathogenicityFunctionalImpactEvidenceLine(**invalid_params) +def test_variant_onco_stmt(): + """Ensure VariantOncogenicityStudyStatement model works as expected""" + params = { + "direction": "neutral", + "proposition": { + "type": "VariantOncogenicityProposition", + "predicate": "isCausalFor", + "objectTumorType": "conditions.json#/1", + "subjectVariant": "alleles.json#/1", + }, + "classification": { + "primaryCoding": { + "code": "oncogenic", + "system": "ClinGen Low Penetrance and Risk Allele Recommendations, 2024", + } + }, + "specifiedBy": "documents.json#/1", + "strength": { + "primaryCoding": { + "code": "definitive", + "system": "ClinGen Low Penetrance and Risk Allele Recommendations, 2024", + } + }, + } + assert VariantOncogenicityStudyStatement(**params) + + invalid_params = deepcopy(params) + invalid_params["strength"]["primaryCoding"]["code"] = "oncogenic" + with pytest.raises(ValueError, match="`primaryCoding.code` must be one of"): + VariantOncogenicityStudyStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["strength"]["primaryCoding"]["system"] = "ACMG Guidelines, 2015" + with pytest.raises(ValueError, match="`primaryCoding.system` must be"): + VariantOncogenicityStudyStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["classification"]["primaryCoding"]["code"] = "pathogenic" + with pytest.raises(ValueError, match="`primaryCoding.code` must be one of"): + VariantOncogenicityStudyStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["classification"]["primaryCoding"]["system"] = ( + "ACMG Guidelines, 2015" + ) + with pytest.raises(ValueError, match="`primaryCoding.system` must be"): + VariantOncogenicityStudyStatement(**invalid_params) + + def test_variant_onco_el(): """Ensure VariantOncogenicityFunctionalImpactEvidenceLine model works as expected""" vo = VariantOncogenicityFunctionalImpactEvidenceLine( From ba735ff0032b6dc086199d55f048481d093ae42c Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 27 Mar 2025 22:36:26 -0400 Subject: [PATCH 8/9] add more tests --- src/ga4gh/va_spec/base/core.py | 5 +- tests/validation/test_va_spec_models.py | 65 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index 0843255..27c59e6 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -563,12 +563,13 @@ def validate_has_evidence_items( found_model = True break if not found_model: - err_msg = "Unable to find valid model" + err_msg = "Unable to find valid model for `hasEvidenceItems`" raise ValueError(err_msg) elif isinstance(evidence_item, str): evidence_items.append(iriReference(root=evidence_item)) else: - evidence_items.append(evidence_item) + err_msg = "Unable to find valid model for `hasEvidenceItems`" + raise ValueError(err_msg) return evidence_items diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index f99ed58..b5a24bb 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -208,6 +208,33 @@ def test_evidence_line(caf): el = EvidenceLine(**el_dict) assert isinstance(el.hasEvidenceItems[0], iriReference) + el_dict = { + "type": "EvidenceLine", + "hasEvidenceItems": None, + "directionOfEvidenceProvided": "supports", + } + assert EvidenceLine(**el_dict) + + invalid_params = { + "type": "EvidenceLine", + "hasEvidenceItems": [Agent(name="Joe")], + "directionOfEvidenceProvided": "supports", + } + with pytest.raises( + ValueError, match="Unable to find valid model for `hasEvidenceItems`" + ): + EvidenceLine(**invalid_params) + + invalid_params = { + "type": "EvidenceLine", + "hasEvidenceItems": [{"type": "Statement"}], + "directionOfEvidenceProvided": "supports", + } + with pytest.raises( + ValueError, match="Unable to find valid model for `hasEvidenceItems`" + ): + EvidenceLine(**invalid_params) + def test_variant_pathogenicity_stmt(): """Ensure VariantPathogenicityStatement model works as expected""" @@ -260,6 +287,11 @@ def test_variant_pathogenicity_stmt(): with pytest.raises(ValueError, match="`primaryCoding.code` must be one of"): VariantPathogenicityStatement(**invalid_params) + invalid_params = deepcopy(params) + del invalid_params["proposition"] # proposition is required for statement + with pytest.raises(ValueError, match="Must be a `Statement`"): + VariantPathogenicityStatement(**invalid_params) + def test_variant_pathogenicity_el(): """Ensure VariantPathogenicityFunctionalImpactEvidenceLine model works as expected""" @@ -294,11 +326,44 @@ def test_variant_pathogenicity_el(): name="ACMG 2015 PS3 Supporting Criterion Met", ) + valid_params = deepcopy(params) + valid_params["strengthOfEvidenceProvided"] = None + assert VariantPathogenicityFunctionalImpactEvidenceLine(**valid_params) + invalid_params = deepcopy(params) del invalid_params["specifiedBy"]["reportedIn"] with pytest.raises(ValueError, match="`reportedIn` is required"): VariantPathogenicityFunctionalImpactEvidenceLine(**invalid_params) + invalid_params = deepcopy(params) + del invalid_params[ + "directionOfEvidenceProvided" + ] # directionOfEvidenceProvided is required for statement + with pytest.raises(ValueError, match="Must be an `EvidenceLine`"): + VariantPathogenicityFunctionalImpactEvidenceLine(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["strengthOfEvidenceProvided"] = {"name": "test"} + with pytest.raises(ValueError, match="`primaryCoding` is required."): + VariantPathogenicityFunctionalImpactEvidenceLine(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["strengthOfEvidenceProvided"] = { + "primaryCoding": { + "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + "code": "strong", + } + } + with pytest.raises(ValueError, match="`primaryCoding.system` must be"): + VariantPathogenicityFunctionalImpactEvidenceLine(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["strengthOfEvidenceProvided"] = { + "primaryCoding": {"system": "ACMG Guidelines, 2015", "code": "PS3"} + } + with pytest.raises(ValueError, match="`primaryCoding.code` must be"): + VariantPathogenicityFunctionalImpactEvidenceLine(**invalid_params) + def test_variant_onco_stmt(): """Ensure VariantOncogenicityStudyStatement model works as expected""" From 7a3fdd123c245c8847a910926a965b97e671ea7b Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 28 Mar 2025 09:22:36 -0400 Subject: [PATCH 9/9] fix system for ccv --- src/ga4gh/va_spec/ccv_2022/models.py | 6 ++---- tests/validation/test_va_spec_models.py | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index 260fb48..21ee9fd 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -119,9 +119,7 @@ def validate_strength(cls, v: MappableConcept | None) -> MappableConcept | None: :raises ValueError: If invalid strength values are provided :return: Validated strength value """ - return validate_mappable_concept( - v, System.CLIN_GEN, STRENGTHS, mc_is_required=False - ) + return validate_mappable_concept(v, System.CCV, STRENGTHS, mc_is_required=False) @field_validator("classification") @classmethod @@ -133,5 +131,5 @@ def validate_classification(cls, v: MappableConcept) -> MappableConcept: :return: Validated classification value """ return validate_mappable_concept( - v, System.CLIN_GEN, CCV_CLASSIFICATIONS, mc_is_required=True + v, System.CCV, CCV_CLASSIFICATIONS, mc_is_required=True ) diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index b5a24bb..93eae0e 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -378,14 +378,14 @@ def test_variant_onco_stmt(): "classification": { "primaryCoding": { "code": "oncogenic", - "system": "ClinGen Low Penetrance and Risk Allele Recommendations, 2024", + "system": "ClinGen/CGC/VICC Guidelines for Oncogenicity, 2022", } }, "specifiedBy": "documents.json#/1", "strength": { "primaryCoding": { "code": "definitive", - "system": "ClinGen Low Penetrance and Risk Allele Recommendations, 2024", + "system": "ClinGen/CGC/VICC Guidelines for Oncogenicity, 2022", } }, }