diff --git a/.gitmodules b/.gitmodules index 433d259..b1735c4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/va_spec"] path = submodules/va_spec url = https://github.com/ga4gh/va-spec - branch = 1.0 + branch = v1 diff --git a/pyproject.toml b/pyproject.toml index bb9f2eb..18a1e88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dynamic = ["version"] dependencies = [ "ga4gh.vrs>=2.2.0,<3.0", "ga4gh.cat_vrs~=0.7.1", - "pydantic>=2.0,<3.0" + "pydantic>=2.0,<3.0", + "typing_extensions", ] [project.optional-dependencies] diff --git a/src/ga4gh/va_spec/aac_2017/__init__.py b/src/ga4gh/va_spec/aac_2017/__init__.py index d26bc8e..0f0f8f2 100644 --- a/src/ga4gh/va_spec/aac_2017/__init__.py +++ b/src/ga4gh/va_spec/aac_2017/__init__.py @@ -1,21 +1,31 @@ """Module to load and init namespace at package level.""" from .models import ( - AMP_ASCO_CAP_LEVELS, - AMP_ASCO_CAP_TIERS, - Classification, - Strength, - VariantDiagnosticStudyStatement, - VariantPrognosticStudyStatement, - VariantTherapeuticResponseStudyStatement, + AMP_ASCO_CAP_CLASSIFICATION_CODES, + AMP_ASCO_CAP_CLASSIFICATION_MAP, + AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS, + AmpAscoCapClassificationCode, + AmpAscoCapClassificationName, + AmpAscoCapEvidenceLine, + AmpAscoCapEvidenceLineStrength, + AsmpAscoCapStrengthCode, + DiagnosticEvidenceLine, + PrognosticEvidenceLine, + TherapeuticEvidenceLine, + VariantClinicalSignificanceStatement, ) __all__ = [ - "AMP_ASCO_CAP_LEVELS", - "AMP_ASCO_CAP_TIERS", - "Classification", - "Strength", - "VariantDiagnosticStudyStatement", - "VariantPrognosticStudyStatement", - "VariantTherapeuticResponseStudyStatement", + "AMP_ASCO_CAP_CLASSIFICATION_CODES", + "AMP_ASCO_CAP_CLASSIFICATION_MAP", + "AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS", + "AmpAscoCapClassificationCode", + "AmpAscoCapClassificationName", + "AmpAscoCapEvidenceLine", + "AmpAscoCapEvidenceLineStrength", + "AsmpAscoCapStrengthCode", + "DiagnosticEvidenceLine", + "PrognosticEvidenceLine", + "TherapeuticEvidenceLine", + "VariantClinicalSignificanceStatement", ] diff --git a/src/ga4gh/va_spec/aac_2017/models.py b/src/ga4gh/va_spec/aac_2017/models.py index 98028c4..f9cf6a2 100644 --- a/src/ga4gh/va_spec/aac_2017/models.py +++ b/src/ga4gh/va_spec/aac_2017/models.py @@ -5,16 +5,19 @@ """ from enum import Enum +from types import MappingProxyType -from pydantic import ( - Field, - field_validator, -) +from pydantic import Field, field_validator, model_validator +from pydantic.dataclasses import dataclass +from typing_extensions import Self from ga4gh.core.models import MappableConcept, iriReference from ga4gh.va_spec.base.core import ( + Direction, + EvidenceLine, Method, Statement, + VariantClinicalSignificanceProposition, VariantDiagnosticProposition, VariantPrognosticProposition, VariantTherapeuticResponseProposition, @@ -23,134 +26,227 @@ from ga4gh.va_spec.base.validators import validate_mappable_concept -class Strength(str, Enum): - """Define constraints for AMP/ASCO/CAP strength coding""" +class AmpAscoCapEvidenceLineStrength(str, Enum): + """Define constraints for AMP/ASCO/CAP `EvidenceLine.strengthOfEvidenceProvided`""" - LEVEL_A = "Level A" - LEVEL_B = "Level B" - LEVEL_C = "Level C" - LEVEL_D = "Level D" + A = "A" + B = "B" + C = "C" + D = "D" -AMP_ASCO_CAP_LEVELS = [v.value for v in Strength.__members__.values()] +class AmpAscoCapEvidenceLine(EvidenceLine): + """Evidence line for AMP/ASCO/CAP""" + targetProposition: ( + VariantDiagnosticProposition + | VariantPrognosticProposition + | VariantTherapeuticResponseProposition + ) -class Classification(str, Enum): - """Define constraints for AMP/ASCO/CAP classification coding""" + @field_validator("strengthOfEvidenceProvided", mode="after") + @classmethod + def validate_strength_of_evidence_provided( + cls, v: MappableConcept | None + ) -> MappableConcept | None: + """Validate strengthOfEvidenceProvided""" + validate_mappable_concept( + v, + System.AMP_ASCO_CAP, + valid_codes=AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS, + mc_is_required=False, + ) + return v - TIER_I = "Tier I" - TIER_II = "Tier II" - TIER_III = "Tier III" - TIER_IV = "Tier IV" +class DiagnosticEvidenceLine(AmpAscoCapEvidenceLine): + """Diagnostic evidence line for AMP/ASCO/CAP""" -AMP_ASCO_CAP_TIERS = [v.value for v in Classification.__members__.values()] + targetProposition: VariantDiagnosticProposition -class AmpAscoCapValidatorMixin: - """Mixin class for reusable AMP/ASCO/CAP field validators +class PrognosticEvidenceLine(AmpAscoCapEvidenceLine): + """Prognostic evidence line for AMP/ASCO/CAP""" - Should be used with classes that inherit from Statement - """ + targetProposition: VariantPrognosticProposition - @field_validator("strength") - @classmethod - def validate_strength(cls, v: MappableConcept | None) -> MappableConcept | None: - """Validate strength - - :param v: Strength - :raises ValueError: If invalid strength values are provided - :return: Validated strength value - """ - return validate_mappable_concept( - v, - System.AMP_ASCO_CAP, - valid_codes=AMP_ASCO_CAP_LEVELS, - mc_is_required=False, - ) - @field_validator("classification") - @classmethod - def validate_classification(cls, v: MappableConcept) -> MappableConcept: - """Validate classification - - :param v: Classification - :raises ValueError: If invalid classification values are provided - :return: Validated classification value - """ - return validate_mappable_concept( - v, System.AMP_ASCO_CAP, valid_codes=AMP_ASCO_CAP_TIERS - ) +class TherapeuticEvidenceLine(AmpAscoCapEvidenceLine): + """Therapeutic evidence line for AMP/ASCO/CAP""" + targetProposition: VariantTherapeuticResponseProposition -class VariantDiagnosticStudyStatement(Statement, 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 - results. - """ - proposition: VariantDiagnosticProposition = Field( - ..., - description="A proposition about a diagnostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", - ) - strength: MappableConcept | None = Field( - default=None, - description="The strength of support that the Statement is determined to provide for or against the Diagnostic Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - classification: MappableConcept = Field( - ..., - description="A single term or phrase classifying the subject variant based on the outcome of direction and strength assessments of the Statement's Proposition - reported here using terms from the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - specifiedBy: Method | iriReference = Field( - ..., - description="A method that specifies how the diagnostic classification was ultimately assigned to the variant, based on assessment of evidence.", - ) +class AsmpAscoCapStrengthCode(str, Enum): + """Define constraints for AMP/ASCO/CAP strength coding""" + STRONG = "strong" + POTENTIAL = "potential" -class VariantPrognosticStudyStatement(Statement, 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. - """ - proposition: VariantPrognosticProposition = Field( - ..., - description="A proposition about a prognostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", - ) - strength: MappableConcept | None = Field( - default=None, - description="The strength of support that the Statement is determined to provide for or against the Prognostic Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - classification: MappableConcept = Field( - ..., - description="A single term or phrase classifying the subject variant based on the outcome of direction and strength assessments of the Statement's Proposition - reported here using terms from the AMP/ASCO/CAP (AAC) 2017 Guidelines. Note that the enumerated value set here is bound to the `code` field of the Coding object that is nested inside a MappableConcept's primary coding.", - ) - specifiedBy: Method | iriReference = Field( - ..., - description="A method that specifies how the prognostic classification was ultimately assigned to the variant, based on assessment of evidence.", - ) +class AmpAscoCapClassificationCode(str, Enum): + """Define constraints for AMP/ASCO/CAP classification coding""" + + TIER_1 = "tier i" + TIER_2 = "tier ii" + TIER_3 = "tier iii" + TIER_4 = "tier iv" + + +AMP_ASCO_CAP_CLASSIFICATION_CODES = [ + v.value for v in AmpAscoCapClassificationCode.__members__.values() +] + + +class AmpAscoCapClassificationName(str, Enum): + """Define constraints for AMP/ASCO/CAP classification name""" + + TIER_1 = "Tier I" + TIER_2 = "Tier II" + TIER_3 = "Tier III" + TIER_4 = "Tier IV" + + +AMP_ASCO_CAP_EVIDENCE_LINE_STRENGTHS = [ + v.value for v in AmpAscoCapEvidenceLineStrength.__members__.values() +] + + +@dataclass +class AmpAscoCapConfig: + """AMP/ASCO/CAP config for expected values""" + + name: AmpAscoCapClassificationName + direction: Direction + strength: AsmpAscoCapStrengthCode | None + + +AMP_ASCO_CAP_CLASSIFICATION_MAP = MappingProxyType( + { + AmpAscoCapClassificationCode.TIER_1: AmpAscoCapConfig( + name=AmpAscoCapClassificationName.TIER_1, + direction=Direction.SUPPORTS, + strength=AsmpAscoCapStrengthCode.STRONG, + ), + AmpAscoCapClassificationCode.TIER_2: AmpAscoCapConfig( + name=AmpAscoCapClassificationName.TIER_2, + direction=Direction.SUPPORTS, + strength=AsmpAscoCapStrengthCode.POTENTIAL, + ), + AmpAscoCapClassificationCode.TIER_3: AmpAscoCapConfig( + name=AmpAscoCapClassificationName.TIER_3, + direction=Direction.NEUTRAL, + strength=None, + ), + AmpAscoCapClassificationCode.TIER_4: AmpAscoCapConfig( + name=AmpAscoCapClassificationName.TIER_4, + direction=Direction.DISPUTES, + strength=None, + ), + } +) -class VariantTherapeuticResponseStudyStatement(Statement, AmpAscoCapValidatorMixin): +class VariantClinicalSignificanceStatement(Statement): """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. + associated with a disease (a diagnostic inclusion criterion), or absence of a + disease (diagnostic exclusion criterion) - based on interpretation of the study's + results. """ - proposition: VariantTherapeuticResponseProposition = Field( + proposition: VariantClinicalSignificanceProposition = Field( ..., - description="A proposition about the therapeutic response associated with a variant, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", + description="A proposition about a diagnostic association between a variant and condition, for which the study provides evidence. The validity of this proposition, and the level of confidence/evidence supporting it, may be assessed and reported by the Statement.", ) strength: MappableConcept | None = Field( default=None, - description="The strength of support that the Statement is determined to provide for or against the Therapeutic Response Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP (AAC) 2017 Guidelines.", + description="The strength of support that the Statement is determined to provide for or against the Diagnostic Proposition for the assessed variant, based on the curation and reporting conventions of the AMP/ASCO/CAP 2017 Guidelines.", ) classification: MappableConcept = Field( ..., - description="A single term or phrase classifying the subject variant based on the outcome of direction and strength assessments of the Statement's Proposition - reported here using terms from the AMP/ASCO/CAP (AAC) 2017 Guidelines.", - ) - specifiedBy: Method | iriReference = Field( - ..., - description="A method that specifies how the therapeutic response classification was ultimately assigned to the variant, based on assessment of evidence.", + description="A single term or phrase classifying the subject variant based on the outcome of direction and strength assessments of the Statement's Proposition, using terms from the AMP/ASCO/CAP 2017 Guidelines.", ) + specifiedBy: Method | iriReference + + @model_validator(mode="after") + def validate_aac_statement(self) -> Self: + """Validate VariantClinicalSignificanceStatement""" + + def _validate_classification_config( + classification_code: AmpAscoCapClassificationCode, + classification_name: str, + direction: str, + strength_code: MappableConcept | None, + has_evidence_lines: list, + ) -> None: + """Validate that classification config is correct""" + expected_config = AMP_ASCO_CAP_CLASSIFICATION_MAP[classification_code] + actual_strength = ( + strength_code.primaryCoding.code.root + if strength_code + else strength_code + ) + if actual_strength != expected_config.strength: + expected_strength = ( + expected_config.strength.value + if expected_config.strength + else expected_config.strength + ) + msg = f"`strength` must be: {expected_strength}" + raise ValueError(msg) + + if classification_name != expected_config.name: + msg = f"`classification.name` must be: {expected_config.name.value}" + raise ValueError(msg) + + if direction != expected_config.direction: + msg = f"`direction` must be: {expected_config.direction.value}" + raise ValueError(msg) + + if classification_code in { + AmpAscoCapClassificationCode.TIER_1, + AmpAscoCapClassificationCode.TIER_2, + }: + for evidence_line in has_evidence_lines: + found_approved_el_clas = False + for approved_el_cls in [ + DiagnosticEvidenceLine, + PrognosticEvidenceLine, + TherapeuticEvidenceLine, + iriReference, + ]: + try: + approved_el_cls(**evidence_line.model_dump()) + except Exception: # noqa: S110 + pass + else: + found_approved_el_clas = True + break + + if not found_approved_el_clas: + msg = "`hasEvidenceLines` must be one of: `DiagnosticEvidenceLine`, `PrognosticEvidenceLine`, `TherapeuticEvidenceLine`, or `iriReference`" + raise ValueError(msg) + + # Validate strength + validate_mappable_concept( + self.strength, + System.AMP_ASCO_CAP, + mc_is_required=False, + ) + + # Validate classification + validate_mappable_concept( + self.classification, + System.AMP_ASCO_CAP, + valid_codes=AMP_ASCO_CAP_CLASSIFICATION_CODES, + mc_is_required=True, + ) + _validate_classification_config( + AmpAscoCapClassificationCode(self.classification.primaryCoding.code.root), + self.classification.name, + self.direction, + self.strength, + self.hasEvidenceLines or [], + ) + + return self diff --git a/src/ga4gh/va_spec/acmg_2015/models.py b/src/ga4gh/va_spec/acmg_2015/models.py index a3e0afa..362645d 100644 --- a/src/ga4gh/va_spec/acmg_2015/models.py +++ b/src/ga4gh/va_spec/acmg_2015/models.py @@ -16,8 +16,8 @@ ) from ga4gh.va_spec.base.enums import ( CLIN_GEN_CLASSIFICATIONS, + STRENGTH_CODES, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, - STRENGTHS, System, ) from ga4gh.va_spec.base.validators import ( @@ -157,7 +157,7 @@ def validate_strength(cls, v: MappableConcept | None) -> MappableConcept | None: :return: Validated strength value """ return validate_mappable_concept( - v, System.ACMG, valid_codes=STRENGTHS, mc_is_required=False + v, System.ACMG, valid_codes=STRENGTH_CODES, mc_is_required=False ) @field_validator("classification") diff --git a/src/ga4gh/va_spec/base/__init__.py b/src/ga4gh/va_spec/base/__init__.py index 79d710f..187260d 100644 --- a/src/ga4gh/va_spec/base/__init__.py +++ b/src/ga4gh/va_spec/base/__init__.py @@ -20,6 +20,7 @@ StudyResult, SubjectVariantProposition, TumorVariantFrequencyStudyResult, + VariantClinicalSignificanceProposition, VariantDiagnosticProposition, VariantOncogenicityProposition, VariantPathogenicityProposition, @@ -30,14 +31,14 @@ from .enums import ( CCV_CLASSIFICATIONS, CLIN_GEN_CLASSIFICATIONS, + STRENGTH_CODES, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, - STRENGTHS, CcvClassification, ClinGenClassification, DiagnosticPredicate, MembershipOperator, PrognosticPredicate, - Strength, + StrengthCode, StrengthOfEvidenceProvided, System, TherapeuticResponsePredicate, @@ -69,10 +70,10 @@ "Method", "PrognosticPredicate", "Proposition", - "STRENGTHS", + "STRENGTH_CODES", "STRENGTH_OF_EVIDENCE_PROVIDED_VALUES", "Statement", - "Strength", + "StrengthCode", "StrengthOfEvidenceProvided", "StudyGroup", "StudyResult", @@ -81,6 +82,7 @@ "Therapeutic", "TherapeuticResponsePredicate", "TherapyGroup", + "VariantClinicalSignificanceProposition", "VariantDiagnosticProposition", "VariantOncogenicityProposition", "VariantPathogenicityProposition", diff --git a/src/ga4gh/va_spec/base/core.py b/src/ga4gh/va_spec/base/core.py index 8fbf3be..3a4b402 100644 --- a/src/ga4gh/va_spec/base/core.py +++ b/src/ga4gh/va_spec/base/core.py @@ -2,19 +2,16 @@ from __future__ import annotations -import importlib -import inspect from abc import ABC from datetime import date, datetime from enum import Enum -from typing import Annotated, Literal, TypeVar +from typing import Annotated, Literal from pydantic import ( ConfigDict, Field, RootModel, StringConstraints, - ValidationError, field_validator, ) @@ -35,9 +32,6 @@ from ga4gh.va_spec.base.validators import validate_mappable_concept from ga4gh.vrs.models import Allele, MolecularVariation -StatementType = TypeVar("StatementType") -EvidenceLineType = TypeVar("EvidenceLineType") - class CoreType(str, Enum): """Define VA Spec Base Core Types""" @@ -52,6 +46,22 @@ class CoreType(str, Enum): STUDY_GROUP = "StudyGroup" +class Agent(Entity, BaseModelForbidExtra): + """An autonomous actor (person, organization, or software agent) that bears some + form of responsibility for an activity taking place, for the existence of an entity, + or for another agent's activity. + """ + + type: Literal["Agent"] = Field( + default=CoreType.AGENT.value, description=f"MUST be '{CoreType.AGENT.value}'." + ) + name: str | None = Field(default=None, description="The given name of the Agent.") + agentType: str | None = Field( + default=None, + description="A specific type of agent the Agent object represents. Recommended subtypes include codes for `person`, `organization`, or `software`.", + ) + + class Contribution(Entity, BaseModelForbidExtra): """An action taken by an agent in contributing to the creation, modification, assessment, or deprecation of a particular entity (e.g. a Statement, EvidenceLine, @@ -363,6 +373,7 @@ class SubjectVariantProposition(RootModel): | VariantPrognosticProposition | VariantOncogenicityProposition | VariantTherapeuticResponseProposition + | VariantClinicalSignificanceProposition ) = Field(discriminator="type") @@ -381,7 +392,7 @@ class ClinicalVariantProposition(_SubjectVariantPropositionBase): ) alleleOriginQualifier: MappableConcept | iriReference | None = Field( default=None, - description="Reports whether the Proposition should be interpreted in the context of a heritable 'germline' variant, an acquired 'somatic' variant in a tumor, post-zygotic 'mosaic' variant. While these are the most commonly reported allele origins, other more nuanced concepts can be captured (e.g. 'maternal' vs 'paternal' allele origin'). In practice, populating this field may be complicated by the fact that some sources report allele origin based on the type of tissue that was sequenced to identify the variant, and others use it more generally to specify a category of variant for which the proposition holds. The stated intent of this attribute is the latter. However, if an implementer is not sure about which is reported in their data, it may be safer to create an Extension to hold this information, where they can explicitly acknowledge this ambiguity.", + description='Reports whether the Proposition should be interpreted in the context of a heritable "germline" variant, an acquired "somatic" variant in a tumor, or a post-zygotic "mosaic" variant. While these are the most commonly reported allele origins, other more nuanced concepts can be captured (e.g. "maternal" vs "paternal" allele origin). In practice, populating this field may be complicated by the fact that some sources report allele origin based on the type of tissue that was sequenced to identify the variant, and others use it more generally to specify a category of variant for which the proposition holds. The stated intent of this attribute is the latter. However, if an implementer is not sure about which is reported in their data, it may be safer to create an Extension to hold this information, where they can explicitly acknowledge this ambiguity.', ) @@ -410,6 +421,27 @@ class ExperimentalVariantFunctionalImpactProposition( ) +class VariantClinicalSignificanceProposition( + ClinicalVariantProposition, BaseModelForbidExtra +): + """A Proposition describing the clinical significance of a variant with respect to a + condition. + """ + + model_config = ConfigDict(use_enum_values=True) + type: Literal["VariantClinicalSignificanceProposition"] = Field( + default="VariantClinicalSignificanceProposition", + description="MUST be 'VariantClinicalSignificanceProposition'.", + ) + predicate: Literal["hasClinicalSignificanceFor"] = Field( + default="hasClinicalSignificanceFor", + description="The predicate associating the subject variant to clinical significance for the object Condition. MUST be 'hasClinicalSignificanceFor'.", + ) + objectCondition: Condition | iriReference = Field( + ..., description="The condition that is evaluated." + ) + + class VariantDiagnosticProposition(ClinicalVariantProposition, BaseModelForbidExtra): """A Proposition about whether a variant is associated with a disease (a diagnostic inclusion criterion), or absence of a disease (diagnostic exclusion criterion). @@ -515,22 +547,6 @@ class VariantTherapeuticResponseProposition( ) -class Agent(Entity, BaseModelForbidExtra): - """An autonomous actor (person, organization, or software agent) that bears some - form of responsibility for an activity taking place, for the existence of an entity, - or for another agent's activity. - """ - - type: Literal["Agent"] = Field( - default=CoreType.AGENT.value, description=f"MUST be '{CoreType.AGENT.value}'." - ) - name: str | None = Field(default=None, description="The given name of the Agent.") - agentType: str | None = Field( - default=None, - description="A specific type of agent the Agent object represents. Recommended subtypes include codes for `person`, `organization`, or `software`.", - ) - - class Direction(str, Enum): """A term indicating whether the Statement supports, disputes, or remains neutral w.r.t. the validity of the Proposition it evaluates. @@ -554,12 +570,21 @@ class EvidenceLine(InformationEntity, BaseModelForbidExtra): default=CoreType.EVIDENCE_LINE.value, description=f"MUST be '{CoreType.EVIDENCE_LINE.value}'.", ) - targetProposition: Proposition | SubjectVariantProposition | None = Field( + targetProposition: ( + ExperimentalVariantFunctionalImpactProposition + | VariantClinicalSignificanceProposition + | VariantDiagnosticProposition + | VariantOncogenicityProposition + | VariantPathogenicityProposition + | VariantPrognosticProposition + | VariantTherapeuticResponseProposition + | None + ) = Field( default=None, 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 | EvidenceLineType | iriReference] | None + list[StudyResult | Statement | EvidenceLine | iriReference] | None ) = Field( default=None, description="An individual piece of information that was evaluated as evidence in building the argument represented by an Evidence Line.", @@ -581,73 +606,6 @@ class EvidenceLine(InformationEntity, BaseModelForbidExtra): description="A term summarizing the overall outcome of the evidence assessment represented by the Evidence Line, in terms of the direction and strength of support it provides for or against the target Proposition.", ) - @field_validator("hasEvidenceItems", mode="before") - def validate_has_evidence_items( - cls, # noqa: N805 - v: list | None, - ) -> list | None: - """Ensure hasEvidenceItems is correct type - - This is needed since Pydantic was unable to determine which model to use - - This only handles cases defined in the VA-Spec. - - :param v: hasEvidenceItems value - :raises ValueError: If unable to find valid model for evidence items - :return: Evidence items - """ - if not v: - return v - - evidence_items = [] - - # Avoid circular imports - has_evidence_items_models = [] - for module in [ - "ga4gh.va_spec.aac_2017.models", - "ga4gh.va_spec.acmg_2015.models", - "ga4gh.va_spec.ccv_2022.models", - ]: - imported_module = importlib.import_module(module) - has_evidence_items_models.extend( - [ - obj_ - for _, obj_ in vars(imported_module).items() - if inspect.isclass(obj_) - and issubclass(obj_, Statement) - and obj_.__name__.endswith(("Statement", "EvidenceLine")) - and obj_ not in (Statement, EvidenceLine) - ] - ) - - has_evidence_items_models.extend( - [Statement, StudyResult, EvidenceLine, iriReference] - ) - - for evidence_item in v: - if isinstance(evidence_item, dict): - found_model = False - for evidence_item_model in has_evidence_items_models: - try: - evidence_item = evidence_item_model(**evidence_item) - except ValidationError: - pass - else: - evidence_items.append(evidence_item) - found_model = True - break - if not found_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)) - elif isinstance(evidence_item, tuple(has_evidence_items_models)): - evidence_items.append(evidence_item) - else: - err_msg = "Unable to find valid model for `hasEvidenceItems`" - raise ValueError(err_msg) - return evidence_items - @staticmethod def _validate_evidence_outcome( values: dict, system: System, code_pattern: str @@ -761,3 +719,7 @@ class Statement(InformationEntity, BaseModelForbidExtra): default=None, description="An evidence-based argument that supports or disputes the validity of the proposition that a Statement assesses or puts forth as true. The strength and direction of this argument (whether it supports or disputes the proposition, and how strongly) is based on an interpretation of one or more pieces of information as evidence (i.e. 'Evidence Items).", ) + + +Statement.model_rebuild() +EvidenceLine.model_rebuild() diff --git a/src/ga4gh/va_spec/base/enums.py b/src/ga4gh/va_spec/base/enums.py index 7615066..5f3f684 100644 --- a/src/ga4gh/va_spec/base/enums.py +++ b/src/ga4gh/va_spec/base/enums.py @@ -52,14 +52,17 @@ class StrengthOfEvidenceProvided(str, Enum): ] -class Strength(str, Enum): - """Define constrains for strength""" +class StrengthCode(str, Enum): + """Define constrains for strength + + Used in ACMG 2015 and CCV 2022 + """ DEFINITIVE = "definitive" LIKELY = "likely" -STRENGTHS = [v.value for v in Strength.__members__.values()] +STRENGTH_CODES = [v.value for v in StrengthCode.__members__.values()] class ClinGenClassification(str, Enum): @@ -92,6 +95,6 @@ class System(str, Enum): """Define constraints for systems""" ACMG = "ACMG Guidelines, 2015" - AMP_ASCO_CAP = "AMP/ASCO/CAP (AAC) Guidelines, 2017" + AMP_ASCO_CAP = "AMP/ASCO/CAP Guidelines, 2017" CLIN_GEN = "ClinGen Low Penetrance and Risk Allele Recommendations, 2024" CCV = "ClinGen/CGC/VICC Guidelines for Oncogenicity, 2022" diff --git a/src/ga4gh/va_spec/ccv_2022/__init__.py b/src/ga4gh/va_spec/ccv_2022/__init__.py index 8c6c7a9..a84a82c 100644 --- a/src/ga4gh/va_spec/ccv_2022/__init__.py +++ b/src/ga4gh/va_spec/ccv_2022/__init__.py @@ -2,10 +2,10 @@ from .models import ( VariantOncogenicityEvidenceLine, - VariantOncogenicityStudyStatement, + VariantOncogenicityStatement, ) __all__ = [ "VariantOncogenicityEvidenceLine", - "VariantOncogenicityStudyStatement", + "VariantOncogenicityStatement", ] diff --git a/src/ga4gh/va_spec/ccv_2022/models.py b/src/ga4gh/va_spec/ccv_2022/models.py index 3ba4c88..a0c20bd 100644 --- a/src/ga4gh/va_spec/ccv_2022/models.py +++ b/src/ga4gh/va_spec/ccv_2022/models.py @@ -16,8 +16,8 @@ ) from ga4gh.va_spec.base.enums import ( CCV_CLASSIFICATIONS, + STRENGTH_CODES, STRENGTH_OF_EVIDENCE_PROVIDED_VALUES, - STRENGTHS, System, ) from ga4gh.va_spec.base.validators import validate_mappable_concept @@ -98,7 +98,7 @@ def validate_model(cls, values: dict) -> dict: # noqa: N805 return cls._validate_evidence_outcome(values, System.CCV, ccv_code_pattern) -class VariantOncogenicityStudyStatement(Statement): +class VariantOncogenicityStatement(Statement): """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. @@ -130,7 +130,7 @@ def validate_strength(cls, v: MappableConcept | None) -> MappableConcept | None: :return: Validated strength value """ return validate_mappable_concept( - v, System.CCV, valid_codes=STRENGTHS, mc_is_required=False + v, System.CCV, valid_codes=STRENGTH_CODES, mc_is_required=False ) @field_validator("classification") diff --git a/submodules/va_spec b/submodules/va_spec index da35aa0..7caecd4 160000 --- a/submodules/va_spec +++ b/submodules/va_spec @@ -1 +1 @@ -Subproject commit da35aa0286aa209b24e8d3a827ddd5a491ab5350 +Subproject commit 7caecd474864b0e4bcb7764b545c0fa352ee8798 diff --git a/tests/validation/test_va_spec_models.py b/tests/validation/test_va_spec_models.py index 8a302c4..4fa7e85 100644 --- a/tests/validation/test_va_spec_models.py +++ b/tests/validation/test_va_spec_models.py @@ -10,7 +10,7 @@ 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.aac_2017.models import VariantClinicalSignificanceStatement from ga4gh.va_spec.acmg_2015.models import ( VariantPathogenicityEvidenceLine, VariantPathogenicityStatement, @@ -20,11 +20,17 @@ CohortAlleleFrequencyStudyResult, ExperimentalVariantFunctionalImpactStudyResult, ) -from ga4gh.va_spec.base.core import EvidenceLine, Method, StudyGroup, StudyResult +from ga4gh.va_spec.base.core import ( + EvidenceLine, + Method, + Statement, + StudyGroup, + StudyResult, +) from ga4gh.va_spec.base.domain_entities import ConditionSet from ga4gh.va_spec.ccv_2022.models import ( VariantOncogenicityEvidenceLine, - VariantOncogenicityStudyStatement, + VariantOncogenicityStatement, ) VA_SPEC_TESTS_DIR = SUBMODULES_DIR / "tests" @@ -233,14 +239,14 @@ def test_evidence_line(caf): }, "strength": { "primaryCoding": { - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", - "code": "Level A", + "system": "AMP/ASCO/CAP Guidelines, 2017", + "code": "strong", } }, "classification": { "primaryCoding": { - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", - "code": "Tier I", + "system": "AMP/ASCO/CAP Guidelines, 2017", + "code": "tier i", } }, "specifiedBy": { @@ -262,7 +268,7 @@ def test_evidence_line(caf): } el = EvidenceLine(**el_dict) assert isinstance(el.hasEvidenceItems[0], iriReference) - assert isinstance(el.hasEvidenceItems[1], VariantTherapeuticResponseStudyStatement) + assert isinstance(el.hasEvidenceItems[1], Statement) el_dict = { "type": "EvidenceLine", @@ -303,9 +309,7 @@ def test_evidence_line(caf): "hasEvidenceItems": [Agent(name="Joe")], "directionOfEvidenceProvided": "supports", } - with pytest.raises( - ValueError, match="Unable to find valid model for `hasEvidenceItems`" - ): + with pytest.raises(ValueError, match="validation errors for EvidenceLine"): EvidenceLine(**invalid_params) invalid_params = { @@ -313,9 +317,7 @@ def test_evidence_line(caf): "hasEvidenceItems": [{"type": "Statement"}], "directionOfEvidenceProvided": "supports", } - with pytest.raises( - ValueError, match="Unable to find valid model for `hasEvidenceItems`" - ): + with pytest.raises(ValueError, match="validation errors for EvidenceLine"): EvidenceLine(**invalid_params) @@ -350,7 +352,7 @@ def test_variant_pathogenicity_stmt(): invalid_params = deepcopy(params) invalid_params["classification"]["primaryCoding"]["system"] = ( - "AMP/ASCO/CAP (AAC) Guidelines, 2017" + "AMP/ASCO/CAP Guidelines, 2017" ) with pytest.raises(ValueError, match="`primaryCoding.system` must be one of"): VariantPathogenicityStatement(**invalid_params) @@ -457,7 +459,7 @@ def test_variant_pathogenicity_el(): invalid_params = deepcopy(params) invalid_params["strengthOfEvidenceProvided"] = { "primaryCoding": { - "system": "AMP/ASCO/CAP (AAC) Guidelines, 2017", + "system": "AMP/ASCO/CAP Guidelines, 2017", "code": "strong", } } @@ -489,7 +491,7 @@ def test_variant_pathogenicity_el(): def test_variant_onco_stmt(): - """Ensure VariantOncogenicityStudyStatement model works as expected""" + """Ensure VariantOncogenicityStatement model works as expected""" params = { "direction": "neutral", "proposition": { @@ -512,33 +514,33 @@ def test_variant_onco_stmt(): } }, } - assert VariantOncogenicityStudyStatement(**params) + assert VariantOncogenicityStatement(**params) valid_params = deepcopy(params) valid_params["strength"] = None - assert VariantOncogenicityStudyStatement(**valid_params) + assert VariantOncogenicityStatement(**valid_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) + VariantOncogenicityStatement(**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) + VariantOncogenicityStatement(**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) + VariantOncogenicityStatement(**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) + VariantOncogenicityStatement(**invalid_params) def test_variant_onco_el(): @@ -609,6 +611,110 @@ def test_variant_onco_el(): VariantOncogenicityEvidenceLine(**invalid_params) +def test_aac_statement(): + """Test that AMP/ASCO/CAP statement model validators work correctly""" + prop = { + "type": "VariantDiagnosticProposition", + "predicate": "isDiagnosticExclusionCriterionFor", + "objectCondition": "conditions.json#/1", + "subjectVariant": "alleles.json#/1", + } + params = { + "direction": "supports", + "proposition": { + "type": "VariantClinicalSignificanceProposition", + "predicate": "hasClinicalSignificanceFor", + "objectCondition": "conditions.json#/1", + "subjectVariant": "alleles.json#/1", + }, + "strength": { + "primaryCoding": { + "code": "strong", + "system": "AMP/ASCO/CAP Guidelines, 2017", + } + }, + "specifiedBy": "documents.json#/1", + "classification": { + "name": "Tier I", + "primaryCoding": { + "code": "tier i", + "system": "AMP/ASCO/CAP Guidelines, 2017", + }, + }, + "hasEvidenceLines": [ + { + "targetProposition": prop, + "directionOfEvidenceProvided": "supports", + "strengthOfEvidenceProvided": { + "primaryCoding": { + "code": "A", + "system": "AMP/ASCO/CAP Guidelines, 2017", + } + }, + "hasEvidenceItems": [ + "evidence_lines.json#/1", + { + "type": "Statement", + "direction": "supports", + "proposition": prop, + "strength": { + "primaryCoding": { + "code": "A", + "system": "System", + } + }, + "specifiedBy": "documents.json#/1", + }, + ], + }, + ], + } + assert VariantClinicalSignificanceStatement(**params) + + # No strengthOfEvidenceProvided + no_evidence_line_strength_params = deepcopy(params) + no_evidence_line_strength_params["hasEvidenceLines"][0].pop( + "strengthOfEvidenceProvided" + ) + assert VariantClinicalSignificanceStatement(**no_evidence_line_strength_params) + + # Invalid strength + invalid_params = deepcopy(params) + invalid_params["strength"]["primaryCoding"]["code"] = "Strong" + with pytest.raises(ValidationError, match="`strength` must be: strong"): + VariantClinicalSignificanceStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["strength"]["primaryCoding"]["code"] = "potential" + with pytest.raises(ValidationError, match="`strength` must be: strong"): + VariantClinicalSignificanceStatement(**invalid_params) + + # Invalid classification + invalid_params = deepcopy(params) + invalid_params["classification"]["primaryCoding"]["code"] = "Tier I" + with pytest.raises(ValidationError, match="`primaryCoding.code` must be one of"): + VariantClinicalSignificanceStatement(**invalid_params) + + invalid_params = deepcopy(params) + invalid_params["classification"]["name"] = "tier i" + with pytest.raises(ValidationError, match="`classification.name` must be: Tier I"): + VariantClinicalSignificanceStatement(**invalid_params) + + # Invalid direction + invalid_params = deepcopy(params) + invalid_params["direction"] = "disputes" + with pytest.raises(ValidationError, match="`direction` must be: supports"): + VariantClinicalSignificanceStatement(**invalid_params) + + # Invalid targetProposition + invalid_params = deepcopy(params) + invalid_params["hasEvidenceLines"][0]["targetProposition"] = invalid_params[ + "proposition" + ] + with pytest.raises(ValidationError, match="`hasEvidenceLines` must be one of"): + VariantClinicalSignificanceStatement(**invalid_params) + + def test_examples(test_definitions): """Test VA Spec examples""" va_spec_schema_mapping = {