From 50acce8bb7f1bea68d69d2b98c6145b7be76ba1c Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 1 Sep 2026 11:07:58 -0400 Subject: [PATCH] feat: update models to cat-vrs 1.1.0 close #49 --- .gitmodules | 2 +- pyproject.toml | 2 +- src/ga4gh/cat_vrs/models.py | 77 ++++++++++++++++++++++++- src/ga4gh/cat_vrs/recipes.py | 43 +++++++++++++- src/ga4gh/cat_vrs/version.py | 2 +- submodules/cat_vrs | 2 +- tests/validation/test_cat_vrs_models.py | 45 +++++++++++++++ 7 files changed, 165 insertions(+), 8 deletions(-) diff --git a/.gitmodules b/.gitmodules index f51b824..6367eae 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/cat_vrs"] path = submodules/cat_vrs url = https://github.com/ga4gh/cat-vrs - branch = 1.1.0-snapshot.2026-02 + branch = 1.1 diff --git a/pyproject.toml b/pyproject.toml index 39b7eff..5d31cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ keywords = ["bioinformatics", "ga4gh", "genomics", "variation"] requires-python = ">=3.10" dynamic = ["version"] dependencies = [ - "ga4gh.vrs~=2.4.0-a3", + "ga4gh.vrs~=2.4.0-a4", "pydantic>=2.0,<3.0", ] diff --git a/src/ga4gh/cat_vrs/models.py b/src/ga4gh/cat_vrs/models.py index 03e6ac8..8d4ffc2 100644 --- a/src/ga4gh/cat_vrs/models.py +++ b/src/ga4gh/cat_vrs/models.py @@ -4,9 +4,10 @@ the GA4GH website for more information. """ +from enum import Enum from typing import ClassVar, Literal -from pydantic import Field, RootModel +from pydantic import ConfigDict, Field, RootModel from ga4gh.cat_vrs.metadata import CatVRSMetadataMixin from ga4gh.core.metadata import Maturity @@ -17,7 +18,16 @@ MappableConcept, iriReference, ) -from ga4gh.vrs.models import Allele, CopyChange, Range, SequenceLocation, Variation +from ga4gh.vrs.models import ( + Allele, + CopyChange, + Location, + Range, + SequenceExpression, + SequenceLocation, + Terminus, + Variation, +) class DefiningAlleleConstraint(CatVRSMetadataMixin, BaseModelForbidExtra): @@ -60,6 +70,66 @@ class DefiningLocationConstraint(CatVRSMetadataMixin, BaseModelForbidExtra): ) +class FunctionalDomainStatus(str, Enum): + """Define whether a functional domain is preserved or lost.""" + + LOST = "lost" + PRESERVED = "preserved" + + +class FunctionalDomain(CatVRSMetadataMixin, Entity, BaseModelForbidExtra): + """A sequence location whose presence or absence is used to define an adjacency.""" + + model_config = ConfigDict(use_enum_values=True) + + _maturity: ClassVar[Maturity] = Maturity.DRAFT + + type: Literal["FunctionalDomain"] = Field( + default="FunctionalDomain", description='Must be "FunctionalDomain"' + ) + location: Location = Field(..., description="A Sequence Location for the domain.") + status: FunctionalDomainStatus = Field( + ..., description='MUST be either "preserved" or "lost"' + ) + + +class UnspecifiedElement(CatVRSMetadataMixin, Entity, BaseModelForbidExtra): + """Represents an unspecified element that satisfies the described adjacency.""" + + _maturity: ClassVar[Maturity] = Maturity.DRAFT + + type: Literal["UnspecifiedElement"] = Field( + default="UnspecifiedElement", description='Must be "UnspecifiedElement"' + ) + + +class AdjacencyConstraint(CatVRSMetadataMixin, BaseModelForbidExtra): + """Components that define a molecular adjacency of congruent elements.""" + + _maturity: ClassVar[Maturity] = Maturity.DRAFT + + type: Literal["AdjacencyConstraint"] = Field( + default="AdjacencyConstraint", + description="MUST be 'AdjacencyConstraint'", + ) + adjoinedElements: list[ + UnspecifiedElement | MappableConcept | iriReference | Location | Terminus + ] = Field( + ..., min_length=2, max_length=2, description="The elements of the adjacency." + ) + functionalDomains: list[FunctionalDomain] | None = Field( + default=None, + description="Functional domains whose presence or absence is required to satisfy the adjacency.", + ) + linker: SequenceExpression | None = Field( + default=None, description="The sequence found between the adjoined elements." + ) + orderKnown: bool = Field( + ..., + description="When orderKnown is true, the order of adjoinedElements is assumed to denote the 5' partner first and the 3' partner second. If orderKnown is false, then the order of adjoinedElements assumed not in fact to be known, as in the case of a fusion where only one or both partners are known, but not their relative order. This field is redundant and may be set to true when using Sequence Locations and following the VRS 2 Adjacency model, as the order is implied by the usage of start and end on respective adjoinedElements.", + ) + + class CopyCountConstraint(CatVRSMetadataMixin, BaseModelForbidExtra): """The exact or range of copies that members of this categorical variant must satisfy. @@ -130,9 +200,10 @@ class Constraint(CatVRSMetadataMixin, RootModel): root: ( DefiningAlleleConstraint | DefiningLocationConstraint + | AdjacencyConstraint + | FeatureContextConstraint | CopyCountConstraint | CopyChangeConstraint - | FeatureContextConstraint | FunctionConstraint ) = Field(..., discriminator="type") diff --git a/src/ga4gh/cat_vrs/recipes.py b/src/ga4gh/cat_vrs/recipes.py index 4f8a083..833c418 100644 --- a/src/ga4gh/cat_vrs/recipes.py +++ b/src/ga4gh/cat_vrs/recipes.py @@ -9,6 +9,7 @@ from pydantic import Field, field_validator from ga4gh.cat_vrs.models import ( + AdjacencyConstraint, CategoricalVariant, Constraint, CopyChangeConstraint, @@ -17,6 +18,7 @@ DefiningLocationConstraint, FeatureContextConstraint, FunctionConstraint, + UnspecifiedElement, ) from ga4gh.cat_vrs.relations import ( LIFTOVER_TO_RELATION, @@ -24,7 +26,8 @@ TRANSLATION_OF_RELATION, ) from ga4gh.core.metadata import Maturity -from ga4gh.core.models import MappableConcept +from ga4gh.core.models import MappableConcept, iriReference +from ga4gh.vrs.models import Location class ProteinSequenceConsequence(CategoricalVariant): @@ -246,3 +249,41 @@ def validate_constraints(cls, v: list[Constraint]) -> list[Constraint]: raise ValueError(msg) return v + + +class GeneFusion(CategoricalVariant): + """A representation of the joining of two genes resulting in a chimeric transcript + and/or novel interaction between a rearranged regulatory elements with the + expression of a partner gene product (a regulatory fusion). + """ + + _maturity: ClassVar[Maturity] = Maturity.DRAFT + + constraints: list[Constraint] = Field( + ..., + min_length=1, + description="The constraints must contain at least one item of an Adjacency Constraint.", + ) + + @field_validator("constraints") + @classmethod + def validate_constraints(cls, v: list[Constraint]) -> list[Constraint]: + """Require an adjacency with at least one gene fusion element.""" + if not any( + isinstance(constraint.root, AdjacencyConstraint) + and any( + isinstance( + element, + iriReference | MappableConcept | Location | UnspecifiedElement, + ) + for element in constraint.root.adjoinedElements + ) + for constraint in v + ): + msg = ( + "Must contain at least one `AdjacencyConstraint` whose " + "`adjoinedElements` contains an `iriReference`, `MappableConcept`, " + "`Location`, or `UnspecifiedElement`." + ) + raise ValueError(msg) + return v diff --git a/src/ga4gh/cat_vrs/version.py b/src/ga4gh/cat_vrs/version.py index 67f23f1..bf3961f 100644 --- a/src/ga4gh/cat_vrs/version.py +++ b/src/ga4gh/cat_vrs/version.py @@ -1,3 +1,3 @@ """Define Cat-VRS version""" -CATVRS_VERSION = "1.1.0-snapshot.2026-02.3" +CATVRS_VERSION = "1.1.0" diff --git a/submodules/cat_vrs b/submodules/cat_vrs index df210a6..fef0876 160000 --- a/submodules/cat_vrs +++ b/submodules/cat_vrs @@ -1 +1 @@ -Subproject commit df210a62aa3dcbf5cf827869f13660435b99fc72 +Subproject commit fef0876ba9689f8983e1fba1f656f86f3d7b98e4 diff --git a/tests/validation/test_cat_vrs_models.py b/tests/validation/test_cat_vrs_models.py index 13fe76d..da40408 100644 --- a/tests/validation/test_cat_vrs_models.py +++ b/tests/validation/test_cat_vrs_models.py @@ -15,6 +15,7 @@ MappableConcept, code, ) +from ga4gh.vrs.models import SequenceLocation, Terminus DUMMY_ALLELE_IRI = "allele.json#/1" # Valid IRI but does not reference anything @@ -441,3 +442,47 @@ def test_function_variant( ] with pytest.raises(ValueError, match="List should have at least 2 items"): recipes.FunctionVariant(**invalid_params) + + +def test_gene_fusion(members_and_name: dict): + """Test the GeneFusion adjacency requirements.""" + location = SequenceLocation(sequenceReference="ga4gh:SQ.test", start=1, end=2) + terminus = Terminus(location=location) + valid_elements = [ + "gene:partner", + MappableConcept(name="partner gene"), + location, + models.UnspecifiedElement(), + ] + for element in valid_elements: + valid_adjacency = models.AdjacencyConstraint( + adjoinedElements=[element, terminus], orderKnown=False + ) + valid_params = deepcopy(members_and_name) + valid_params["constraints"] = [models.Constraint(root=valid_adjacency)] + assert recipes.GeneFusion(**valid_params) + + invalid_adjacency = models.AdjacencyConstraint( + adjoinedElements=[terminus, terminus], orderKnown=True + ) + invalid_params = deepcopy(members_and_name) + invalid_params["constraints"] = [models.Constraint(root=invalid_adjacency)] + with pytest.raises( + ValueError, + match=( + "Must contain at least one `AdjacencyConstraint` whose " + "`adjoinedElements` contains an `iriReference`, `MappableConcept`, " + "`Location`, or `UnspecifiedElement`." + ), + ): + recipes.GeneFusion(**invalid_params) + + +def test_functional_domain_status(): + """Test functional domain status string-enum coercion.""" + functional_domain = models.FunctionalDomain( + location=SequenceLocation(sequenceReference="ga4gh:SQ.test", start=1, end=2), + status="preserved", + ) + assert functional_domain.status == "preserved" + assert functional_domain.model_dump(mode="json")["status"] == "preserved"