From 29c0eec9b2dde761b9eb550f60a84b6b18256c12 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 24 Feb 2026 08:29:25 -0500 Subject: [PATCH 01/18] feat: update models to vrs 2.1.0-snapshot.2026-02.2 (#615) GKS-Core Release: https://github.com/ga4gh/gks-core/releases/tag/1.1.0 VRS Release: https://github.com/ga4gh/vrs/releases/tag/2.1.0-snapshot.2026-02.2 (changes can easily be seen https://github.com/ga4gh/vrs/compare/2.0.1...2.1.0-snapshot.2026-02.2) --- .gitmodules | 2 +- src/ga4gh/core/models.py | 41 ++++++++++++- src/ga4gh/vrs/models.py | 126 ++++++++++++++++++++++++++++++++++++++- submodules/vrs | 2 +- tests/test_vrs.py | 2 + 5 files changed, 167 insertions(+), 6 deletions(-) diff --git a/.gitmodules b/.gitmodules index 6ee1c44a..a7b8c08e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/vrs"] path = submodules/vrs url = https://github.com/ga4gh/vrs.git - branch = 2.0 + branch = 2.1.0-snapshot.2026-02 diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index aabfb45c..a8149a3d 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -4,7 +4,7 @@ from abc import ABC from enum import Enum -from typing import Annotated, Any +from typing import Annotated, Any, Literal from pydantic import ( BaseModel, @@ -37,6 +37,20 @@ class Relation(str, Enum): RELATED_MATCH = "relatedMatch" +class MembershipOperator(str, Enum): + """The logical relationship between concepts in the set, in the context of some + knowledge reported about them. The value 'AND' indicates that the concepts are + dependent and occur together in this context - i.e. the reported assertion is not + necessarily true for each concept on its own - only in combination with the + other(s). The value 'OR' indicates that each concept applies independently in this + context - i.e. the reported assertion is necessarily true for each concept on its + own, independent of the presence of the other(s). + """ + + AND = "AND" + OR = "OR" + + ######################################### # Primitive data types ######################################### @@ -185,6 +199,31 @@ class ConceptMapping(Element, BaseModelForbidExtra): ) +class ConceptSet(Element, BaseModelForbidExtra): + """A set of concepts that may be considered as dependent (occurring together), or + independent (existing separately) in the context of some knowledge reported about + them, as indicated by a set membership operator. e.g. a set of independent molecular + consequences that both result from the presence of a particular genetic variant + (membership operator = OR). + """ + + model_config = ConfigDict(use_enum_values=True) + + type: Literal["ConceptSet"] = Field( + default="ConceptSet", + description='MUST be "ConceptSet".', + ) + concepts: list[MappableConcept] | list[ConceptSet] = Field( + ..., + description="A list of concepts that are dependent (occurring together), or independent (existing separately), depending on the membership operator.", + min_length=2, + ) + membershipOperator: MembershipOperator = Field( # noqa: N815 + ..., + description="The logical relationship between concepts in the set, in the context of some knowledge reported about them. The value 'AND' indicates that the concepts are dependent and occur together in this context - i.e. the reported assertion is not necessarily true for each concept on its own - only in combination with the other(s). The value 'OR' indicates that each concept applies independently in this context - i.e. the reported assertion is necessarily true for each concept on its own, independent of the presence of the other(s).", + ) + + class Extension(Element, BaseModelForbidExtra): """The Extension class provides entities with a means to include additional attributes that are outside of the specified standard but needed by a given content diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index ad2978d5..e8029b01 100644 --- a/src/ga4gh/vrs/models.py +++ b/src/ga4gh/vrs/models.py @@ -161,7 +161,10 @@ class VrsType(str, Enum): LIT_SEQ_EXPR = "LiteralSequenceExpression" SEQ_REF = "SequenceReference" SEQ_LOC = "SequenceLocation" + SEQ_OFFSET_LOCATION = "SequenceOffsetLocation" + RELATIVE_SEQ_LOC = "RelativeSequenceLocation" ALLELE = "Allele" + RELATIVE_ALLELE = "RelativeAllele" CIS_PHASED_BLOCK = "CisPhasedBlock" ADJACENCY = "Adjacency" TERMINUS = "Terminus" @@ -229,6 +232,21 @@ class Syntax(str, Enum): SPDI = "spdi" +class AnchorOrientation(str, Enum): + """Indicates which side of a discontinuous anchor on the sequenceReference is used + as the reference point for interpreting offsetStart/offsetEnd. The anchor is an + inter-residue coordinate on the sequenceReference. When that anchor corresponds to a + boundary whose realization on a base sequence yields two distinct locations (e.g., + an exon junction), this property disambiguates which anchor side on the + sequenceReference is intended. `left` denotes the side immediately preceding the + anchor in sequenceReference coordinate order; `right` denotes the side immediately + following the anchor in sequenceReference coordinate order. + """ + + LEFT = "left" + RIGHT = "right" + + def _recurse_ga4gh_serialize(obj): if isinstance(obj, Ga4ghIdentifiableObject): return obj.get_or_create_digest() @@ -503,7 +521,7 @@ class ReferenceLengthExpression(_ValueObject, BaseModelForbidExtra): ) sequence: sequenceString | None = Field( default=None, - description="the literal Sequence encoded by the Reference Length Expression.", + description="the literal sequence encoded by the Reference Length Expression.", ) repeatSubunitLength: int = Field( ..., description="The number of residues in the repeat subunit." @@ -656,6 +674,72 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["end", "sequenceReference", "start", "type"] +class SequenceOffsetLocation(_ValueObject, BaseModelForbidExtra): + """A location defined by an offset relative to an anchor on a mapped sequence + reference. + """ + + model_config = ConfigDict(use_enum_values=True) + + type: Literal["SequenceOffsetLocation"] = Field( + default=VrsType.SEQ_OFFSET_LOCATION.value, + description=f'MUST be "{VrsType.SEQ_OFFSET_LOCATION.value}"', + ) + sequenceReference: SequenceReference | iriReference = Field( + ..., + description="A sequence reference that has been mapped from which a relative location is defined.", + ) + anchor: int = Field( + ..., + description="The inter-residue position on the sequence reference from which the relative location offset is calculated.", + ) + anchorOrientation: AnchorOrientation = Field( + ..., + description="Indicates which side of a discontinuous anchor on the sequenceReference is used as the reference point for interpreting offsetStart/offsetEnd. The anchor is an inter-residue coordinate on the sequenceReference. When that anchor corresponds to a boundary whose realization on a base sequence yields two distinct locations (e.g., an exon junction), this property disambiguates which anchor side on the sequenceReference is intended. `left` denotes the side immediately preceding the anchor in sequenceReference coordinate order; `right` denotes the side immediately following the anchor in sequenceReference coordinate order.", + ) + offsetStart: int | Range | None = Field( + default=None, + description="The start offset, in inter-residue coordinates, from the anchor realization selected by anchorOrientation on the sequenceReference.", + ) + offsetEnd: int | Range | None = Field( + default=None, + description="The end offset, in inter-residue coordinates, from the anchor realization selected by anchorOrientation on the sequenceReference.", + ) + + class ga4gh(_ValueObject.ga4gh): + inherent = [ + "sequenceReference", + "anchor", + "anchorOrientation", + "offsetStart", + "offsetEnd", + "type", + ] + + +class RelativeSequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): + """A location on a base sequence and its position relative to a boundary offset on a + mapped sequence gap. Typically used to describe intronic locations that exist with + respect to a mapped RNA transcript sequence. + """ + + type: Literal["RelativeSequenceLocation"] = Field( + default=VrsType.RELATIVE_SEQ_LOC.value, + description=f'MUST be "{VrsType.RELATIVE_SEQ_LOC.value}"', + ) + baseSequenceLocation: SequenceLocation | iriReference = Field( + ..., description="An absolute location on a sequence." + ) + mappedSequenceLocation: SequenceOffsetLocation | iriReference = Field( + ..., + description="A location relative to an offset on a mapped sequence.", + ) + + class ga4gh(Ga4ghIdentifiableObject.ga4gh): + prefix = "RSL" + inherent = ["baseSequenceLocation", "mappedSequenceLocation", "type"] + + ######################################### # base variation ######################################### @@ -716,6 +800,35 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["location", "state", "type"] +class RelativeAllele(_VariationBase, BaseModelForbidExtra): + """An Allele defined on a mapped location relative to a base location. Often used to describe intronic variants.""" + + type: Literal["RelativeAllele"] = Field( + default=VrsType.RELATIVE_ALLELE.value, + description=f'MUST be "{VrsType.RELATIVE_ALLELE.value}"', + ) + mappedState: ( + LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression + ) = Field( + ..., + description='The state of the RelativeAllele as expressed on the mapped sequence. This will differ from the base state when mapping to a reverse complement sequence, commonly observed when representing the state on transcripts mapped to the "negative strand" of a chromosome.', + ) + baseState: ( + LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression + ) = Field( + ..., + description="The state of the RelativeAllele as expressed on the base sequence.", + ) + relativeLocation: RelativeSequenceLocation | iriReference = Field( + ..., + description="The relative location at which the baseState and mappedState are expressed.", + ) + + class ga4gh(Ga4ghIdentifiableObject.ga4gh): + prefix = "RA" + inherent = ["mappedState", "baseState", "relativeLocation", "type"] + + class CisPhasedBlock(_VariationBase, BaseModelForbidExtra): """An ordered set of co-occurring `Variation` on the same molecule.""" @@ -921,7 +1034,14 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): class MolecularVariation(RootModel): """A `variation` on a contiguous molecule.""" - root: Allele | CisPhasedBlock | Adjacency | Terminus | DerivativeMolecule = Field( + root: ( + Allele + | RelativeAllele + | CisPhasedBlock + | Adjacency + | Terminus + | DerivativeMolecule + ) = Field( ..., json_schema_extra={"description": "A `variation` on a contiguous molecule."}, discriminator="type", @@ -943,7 +1063,7 @@ class SequenceExpression(RootModel): class Location(RootModel): """A contiguous segment of a biological sequence.""" - root: SequenceLocation = Field( + root: SequenceLocation | RelativeSequenceLocation = Field( ..., json_schema_extra={ "description": "A contiguous segment of a biological sequence." diff --git a/submodules/vrs b/submodules/vrs index d2e87dd8..83791ac4 160000 --- a/submodules/vrs +++ b/submodules/vrs @@ -1 +1 @@ -Subproject commit d2e87dd878a9a344c1910105c5acf5bdf8087af4 +Subproject commit 83791ac4294a59ce48400eb66c34af86a12a3704 diff --git a/tests/test_vrs.py b/tests/test_vrs.py index 975854ab..47e372e0 100644 --- a/tests/test_vrs.py +++ b/tests/test_vrs.py @@ -284,6 +284,8 @@ def test_enref2(): def test_class_refatt_map(): class_refatt_map_expected = { "Allele": ["location"], + "RelativeAllele": ["relativeLocation"], + "RelativeSequenceLocation": ["baseSequenceLocation"], "CisPhasedBlock": ["members"], "CopyNumberCount": ["location"], "CopyNumberChange": ["location"], From e1d7962221be34c244f6424aa92d20add24d40cc Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 24 Feb 2026 13:17:59 -0500 Subject: [PATCH 02/18] ci: remove get_branch job for pypi release (#616) (#617) * This is from over 4 years ago and I don't think we actually need this. There are times when we want to create releases off other branches (and its easy to forget to update). I think we should just remove Cherry-picked b573565877bc56467f0afe3cd903cedd8bde1532 --- .github/workflows/release.yml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f09a00f6..8880ce39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,25 +4,9 @@ on: tags: - "*.*.**" jobs: - get_branch: - runs-on: ubuntu-latest - outputs: - branch_name: ${{ steps.get_branch_name.outputs.name }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Get branch name - id: get_branch_name - run: | - raw=$(git branch -r --contains ${{ github.ref }}) - branch=$(echo "$raw" | grep "origin/main" | grep -v "HEAD" | sed "s|origin/||" | xargs) - echo "name=$branch" >> "$GITHUB_OUTPUT" build: name: Build distribution runs-on: ubuntu-latest - needs: get_branch - if: needs.get_branch.outputs.branch_name == 'main' steps: - uses: actions/checkout@v4 - name: Set up Python From 5f0a810773adea55985d45a64b8887766655d9a8 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 6 May 2026 10:34:57 -0400 Subject: [PATCH 03/18] fix: change VRS_VERSION to 2.1.0-snapshot.2026-02.2 (#634) --- src/ga4gh/vrs/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ga4gh/vrs/__init__.py b/src/ga4gh/vrs/__init__.py index 16d36647..24889aa6 100644 --- a/src/ga4gh/vrs/__init__.py +++ b/src/ga4gh/vrs/__init__.py @@ -15,7 +15,7 @@ del version, PackageNotFoundError -VRS_VERSION = "2.0.1" +VRS_VERSION = "2.1.0-snapshot.2026-02.2" __all__ = [ "VRS_VERSION", From 27d0ec43b846e06b6cd3ca7db07caa624a3d9a12 Mon Sep 17 00:00:00 2001 From: James Stevenson Date: Sat, 23 May 2026 14:51:21 -0400 Subject: [PATCH 04/18] fix: _from_vrs handle kwargs without error --- src/ga4gh/vrs/extras/translator.py | 2 +- tests/extras/test_allele_translator.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 00da9bb1..81f38306 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -154,7 +154,7 @@ def hgvs_tools(self) -> HgvsTools: """Instantiate and return an HgvsTools instance""" return HgvsTools(self.data_proxy) - def _from_vrs(self, var: dict) -> models._VariationBase | None: + def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noqa: ARG002 """Convert from dict representation of VRS JSON to VRS object""" if not isinstance(var, Mapping): return None diff --git a/tests/extras/test_allele_translator.py b/tests/extras/test_allele_translator.py index 24c461e8..59b68862 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -331,6 +331,11 @@ def test_from_invalid(tlr): ): tlr.translate_from("BRAF amplication") + with pytest.raises( + ValueError, match="Unable to parse data as beacon, gnomad, hgvs, spdi, vrs" + ): + tlr.translate_from("BRAF amplication", assembly_name="GRCh37") + @pytest.mark.vcr def test_from_beacon(tlr): From a50b9fb189e777241aefd789abbd480ec9df4351 Mon Sep 17 00:00:00 2001 From: James Stevenson Date: Sun, 24 May 2026 13:28:38 -0400 Subject: [PATCH 05/18] fix: _from_vrs handle kwargs without error (#635) the root cause of https://github.com/biocommons/anyvar/issues/467 If you have a valid variant expression, you can attach some extra kwargs like "assembly_name" to help ground certain kinds of translation. However, if you happen to have an invalid expression, the translator will attempt each different format of translation, including `_from_vrs`. If you have attached kwargs, `_from_vrs` will raise an unexpected TypeError because it, unlike the other `_from_` methods, doesn't include kwargs in its type signature. I can't think of a reason why this might be the case. I also added a test to show this. --- src/ga4gh/vrs/extras/translator.py | 2 +- tests/extras/test_allele_translator.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 00da9bb1..81f38306 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -154,7 +154,7 @@ def hgvs_tools(self) -> HgvsTools: """Instantiate and return an HgvsTools instance""" return HgvsTools(self.data_proxy) - def _from_vrs(self, var: dict) -> models._VariationBase | None: + def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noqa: ARG002 """Convert from dict representation of VRS JSON to VRS object""" if not isinstance(var, Mapping): return None diff --git a/tests/extras/test_allele_translator.py b/tests/extras/test_allele_translator.py index 24c461e8..59b68862 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -331,6 +331,11 @@ def test_from_invalid(tlr): ): tlr.translate_from("BRAF amplication") + with pytest.raises( + ValueError, match="Unable to parse data as beacon, gnomad, hgvs, spdi, vrs" + ): + tlr.translate_from("BRAF amplication", assembly_name="GRCh37") + @pytest.mark.vcr def test_from_beacon(tlr): From a9a4897d41e3a7e802fb374c84a574f1effa9c64 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 8 Jun 2026 14:12:49 -0400 Subject: [PATCH 06/18] fix: handle key error in translate_to (#639) close #638 * Unsupported `fmt` in `translate_to` should raise `NotImplementedError` --- src/ga4gh/vrs/extras/translator.py | 8 +++++++- tests/extras/test_allele_translator.py | 21 ++++----------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 81f38306..91c45532 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -142,8 +142,14 @@ def translate_to(self, vo: models._VariationBase, fmt: str, **kwargs) -> list[st kwargs: ref_seq_limit Optional(int): If vo.state is a ReferenceLengthExpression, and `ref_seq_limit` is specified, and `fmt` is `spdi`, the reference sequence is included in the SPDI expression if it is below the limit Otherwise only the length of the reference sequence is included. If the limit is None, the reference sequence is always included. In all cases, the alt sequence is included. Default is 0 (never include reference sequence). + :raise NotImplementedError: If `fmt` is not supported """ - t = self.to_translators[fmt] + try: + t = self.to_translators[fmt] + except KeyError as e: + msg = f"{fmt} is not supported" + raise NotImplementedError(msg) from e + return t(vo, **kwargs) ############################################################################ diff --git a/tests/extras/test_allele_translator.py b/tests/extras/test_allele_translator.py index 59b68862..15f87a89 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -978,20 +978,7 @@ def test_normalize_microsatellite_counts(tlr, case): ) -# TODO: Readd these tests -# @pytest.mark.vcr -# def test_errors(tlr): -# with pytest.raises(ValueError): -# tlr._from_beacon("bogus") -# -# with pytest.raises(ValueError): -# tlr._from_gnomad("NM_182763.2:c.688+403C>T") -# -# with pytest.raises(ValueError): -# tlr._from_hgvs("NM_182763.2:c.688+403C>T") -# -# with pytest.raises(ValueError): -# tlr._from_hgvs("NM_182763.2:c.688_690inv") -# -# with pytest.raises(ValueError): -# tlr._from_spdi("NM_182763.2:c.688+403C>T") +@pytest.mark.vcr +def test_translate_to_invalid_fmt(tlr): + with pytest.raises(NotImplementedError, match="gnomad is not supported"): + tlr.translate_to(models.Allele.model_validate(snv_output), fmt="gnomad") From 372e293d02610b871166a1af6895aa4134c02b8b Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 8 Jun 2026 14:12:49 -0400 Subject: [PATCH 07/18] fix: handle key error in translate_to (#639) close #638 * Unsupported `fmt` in `translate_to` should raise `NotImplementedError` --- src/ga4gh/vrs/extras/translator.py | 8 +++++++- tests/extras/test_allele_translator.py | 21 ++++----------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 81f38306..91c45532 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -142,8 +142,14 @@ def translate_to(self, vo: models._VariationBase, fmt: str, **kwargs) -> list[st kwargs: ref_seq_limit Optional(int): If vo.state is a ReferenceLengthExpression, and `ref_seq_limit` is specified, and `fmt` is `spdi`, the reference sequence is included in the SPDI expression if it is below the limit Otherwise only the length of the reference sequence is included. If the limit is None, the reference sequence is always included. In all cases, the alt sequence is included. Default is 0 (never include reference sequence). + :raise NotImplementedError: If `fmt` is not supported """ - t = self.to_translators[fmt] + try: + t = self.to_translators[fmt] + except KeyError as e: + msg = f"{fmt} is not supported" + raise NotImplementedError(msg) from e + return t(vo, **kwargs) ############################################################################ diff --git a/tests/extras/test_allele_translator.py b/tests/extras/test_allele_translator.py index 59b68862..15f87a89 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -978,20 +978,7 @@ def test_normalize_microsatellite_counts(tlr, case): ) -# TODO: Readd these tests -# @pytest.mark.vcr -# def test_errors(tlr): -# with pytest.raises(ValueError): -# tlr._from_beacon("bogus") -# -# with pytest.raises(ValueError): -# tlr._from_gnomad("NM_182763.2:c.688+403C>T") -# -# with pytest.raises(ValueError): -# tlr._from_hgvs("NM_182763.2:c.688+403C>T") -# -# with pytest.raises(ValueError): -# tlr._from_hgvs("NM_182763.2:c.688_690inv") -# -# with pytest.raises(ValueError): -# tlr._from_spdi("NM_182763.2:c.688+403C>T") +@pytest.mark.vcr +def test_translate_to_invalid_fmt(tlr): + with pytest.raises(NotImplementedError, match="gnomad is not supported"): + tlr.translate_to(models.Allele.model_validate(snv_output), fmt="gnomad") From 79c212d8c9509c1d627640087acf599ebd5d47fb Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 12 Aug 2026 09:35:50 -0400 Subject: [PATCH 08/18] fix: JSON Schema number type should also include int (#645) close #643 --- src/ga4gh/core/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index aabfb45c..89e9ab94 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -197,7 +197,7 @@ class Extension(Element, BaseModelForbidExtra): ..., description="A name for the Extension. Should be indicative of its meaning and/or the type of information it value represents.", ) - value: float | str | bool | dict[str, Any] | list[Any] | None = Field( + value: int | float | str | bool | dict[str, Any] | list[Any] | None = Field( ..., description="The value of the Extension - can be any primitive or structured object", ) From 053fa2e04216f460969e85257d1a023f098fc992 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 12 Aug 2026 09:36:20 -0400 Subject: [PATCH 09/18] fix: JSON Schema number type should also include int (#644) close #643 --- src/ga4gh/core/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index a8149a3d..7708ac7a 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -236,7 +236,7 @@ class Extension(Element, BaseModelForbidExtra): ..., description="A name for the Extension. Should be indicative of its meaning and/or the type of information it value represents.", ) - value: float | str | bool | dict[str, Any] | list[Any] | None = Field( + value: int | float | str | bool | dict[str, Any] | list[Any] | None = Field( ..., description="The value of the Extension - can be any primitive or structured object", ) From f4c7758ef7ddf7d9ab9ef42f57a471bba385bfce Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 14 Aug 2026 11:44:35 -0400 Subject: [PATCH 10/18] feat: add CORE_VERSION (gks-core) (#647) close #646 --- src/ga4gh/core/__init__.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ga4gh/core/__init__.py b/src/ga4gh/core/__init__.py index 1b219d1a..5b09f6cd 100644 --- a/src/ga4gh/core/__init__.py +++ b/src/ga4gh/core/__init__.py @@ -21,7 +21,17 @@ ) from ga4gh.core.pydantic import is_curie_type, is_pydantic_instance, pydantic_copy +try: + __version__ = version(__name__) +except PackageNotFoundError: # pragma: nocover + __version__ = "unknown" +finally: + del version, PackageNotFoundError + +CORE_VERSION = "1.1.0" + __all__ = [ + "CORE_VERSION", "CURIE_NAMESPACE", "CURIE_SEP", "GA4GH_DIGEST_REGEXP", @@ -42,10 +52,3 @@ "sha512t24u", "use_ga4gh_compute_identifier_when", ] - -try: - __version__ = version(__name__) -except PackageNotFoundError: # pragma: nocover - __version__ = "unknown" -finally: - del version, PackageNotFoundError From 859e77a132b8f097d52e1f0023cfa3d9225aa208 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 14 Aug 2026 11:44:48 -0400 Subject: [PATCH 11/18] feat: add CORE_VERSION (gks-core) (#648) close #646 --- src/ga4gh/core/__init__.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ga4gh/core/__init__.py b/src/ga4gh/core/__init__.py index 1b219d1a..541c56ff 100644 --- a/src/ga4gh/core/__init__.py +++ b/src/ga4gh/core/__init__.py @@ -21,7 +21,17 @@ ) from ga4gh.core.pydantic import is_curie_type, is_pydantic_instance, pydantic_copy +try: + __version__ = version(__name__) +except PackageNotFoundError: # pragma: nocover + __version__ = "unknown" +finally: + del version, PackageNotFoundError + +CORE_VERSION = "1.0.0" + __all__ = [ + "CORE_VERSION", "CURIE_NAMESPACE", "CURIE_SEP", "GA4GH_DIGEST_REGEXP", @@ -42,10 +52,3 @@ "sha512t24u", "use_ga4gh_compute_identifier_when", ] - -try: - __version__ = version(__name__) -except PackageNotFoundError: # pragma: nocover - __version__ = "unknown" -finally: - del version, PackageNotFoundError From f945295ff8c5439933fd80174dac174472b8cff4 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 19 Aug 2026 13:43:57 -0400 Subject: [PATCH 12/18] feat: expose json schema metadata to core and vrs models (#650) close #649, #466 * add `schema_id` and `maturity` class methods to core and vrs models * include `$id`, `maturity`, and `ga4gh` metadata in `model_json_schema` --- src/ga4gh/core/__init__.py | 21 ++++- src/ga4gh/core/metadata.py | 79 ++++++++++++++++ src/ga4gh/core/models.py | 47 +++++++--- src/ga4gh/core/version.py | 3 + src/ga4gh/vrs/__init__.py | 10 +-- src/ga4gh/vrs/models.py | 115 ++++++++++++++++++------ src/ga4gh/vrs/version.py | 3 + tests/validation/test_model_metadata.py | 90 +++++++++++++++++++ 8 files changed, 323 insertions(+), 45 deletions(-) create mode 100644 src/ga4gh/core/metadata.py create mode 100644 src/ga4gh/core/version.py create mode 100644 src/ga4gh/vrs/version.py create mode 100644 tests/validation/test_model_metadata.py diff --git a/src/ga4gh/core/__init__.py b/src/ga4gh/core/__init__.py index 5b09f6cd..b6da1c78 100644 --- a/src/ga4gh/core/__init__.py +++ b/src/ga4gh/core/__init__.py @@ -1,6 +1,7 @@ """Python support used across GA4GH projects""" -from importlib.metadata import PackageNotFoundError, version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version import ga4gh.core.models as core_models from ga4gh.core.digests import sha512t24u @@ -19,16 +20,23 @@ is_ga4gh_identifier, use_ga4gh_compute_identifier_when, ) +from ga4gh.core.metadata import ( + GKSMaturityMixin, + GKSMetadataMixin, + GKSSchemaMixin, + Maturity, +) +from ga4gh.core.models import GKSCoreMetadataMixin from ga4gh.core.pydantic import is_curie_type, is_pydantic_instance, pydantic_copy +from ga4gh.core.version import CORE_VERSION try: - __version__ = version(__name__) + __version__ = package_version(__name__) except PackageNotFoundError: # pragma: nocover __version__ = "unknown" finally: - del version, PackageNotFoundError + del package_version, PackageNotFoundError -CORE_VERSION = "1.1.0" __all__ = [ "CORE_VERSION", @@ -37,6 +45,11 @@ "GA4GH_DIGEST_REGEXP", "GA4GH_IR_REGEXP", "GA4GH_PREFIX_SEP", + "GKSCoreMetadataMixin", + "GKSMaturityMixin", + "GKSMetadataMixin", + "GKSSchemaMixin", + "Maturity", "PrevVrsVersion", "VrsObjectIdentifierIs", "core_models", diff --git a/src/ga4gh/core/metadata.py b/src/ga4gh/core/metadata.py new file mode 100644 index 00000000..1945fe44 --- /dev/null +++ b/src/ga4gh/core/metadata.py @@ -0,0 +1,79 @@ +"""Provide shared metadata types for GA4GH GKS models.""" + +from enum import Enum +from typing import Any, ClassVar + +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode + + +class Maturity(str, Enum): + """Maturity levels for GA4GH product features.""" + + DRAFT = "draft" + TRIAL_USE = "trial use" + NORMATIVE = "normative" + DEPRECATED = "deprecated" + + +class GKSMaturityMixin: + """Provide maturity metadata for a GA4GH GKS model.""" + + _maturity: ClassVar[Maturity] + + @classmethod + def maturity(cls) -> Maturity: + """Return the GKS maturity level for the model.""" + return cls._maturity + + +class GKSSchemaMixin: + """Provide a canonical JSON Schema identifier for a GA4GH GKS model.""" + + _schema_base_uri: ClassVar[str] = "https://w3id.org/ga4gh/schema" + _product_name: ClassVar[str] + _product_version: ClassVar[str] + + @classmethod + def schema_id(cls) -> str: + """Return the canonical JSON Schema identifier for the model.""" + return f"{cls._schema_base_uri}/{cls._product_name}/{cls._product_version}/json/{cls.__name__}" + + +class GKSMetadataMixin(GKSMaturityMixin, GKSSchemaMixin): + """Provide maturity and schema metadata for a concrete GKS model.""" + + @classmethod + def model_json_schema( + cls, + by_alias: bool = True, + ref_template: str = "#/$defs/{model}", + schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, + mode: JsonSchemaMode = "validation", + ) -> dict[str, Any]: + """Generate JSON Schema with GKS metadata.""" + schema = super().model_json_schema( + by_alias=by_alias, + ref_template=ref_template, + schema_generator=schema_generator, + mode=mode, + ) + + schema["$id"] = cls.schema_id() + schema["maturity"] = cls.maturity().value + + ga4gh_class = getattr(cls, "ga4gh", None) + if not ga4gh_class: + return schema + + ga4gh_metadata = {} + + if prefix := getattr(ga4gh_class, "prefix", None): + ga4gh_metadata["prefix"] = prefix + + if inherent := getattr(ga4gh_class, "inherent", None): + ga4gh_metadata["inherent"] = list(inherent) + + if ga4gh_metadata: + schema["ga4gh"] = ga4gh_metadata + + return schema diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index 7708ac7a..7492194b 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -4,7 +4,7 @@ from abc import ABC from enum import Enum -from typing import Annotated, Any, Literal +from typing import Annotated, Any, ClassVar, Literal from pydantic import ( BaseModel, @@ -17,6 +17,15 @@ from typing_extensions import Self from ga4gh.core.identifiers import GA4GH_IR_REGEXP +from ga4gh.core.metadata import GKSMaturityMixin, GKSMetadataMixin, Maturity +from ga4gh.core.version import CORE_VERSION + + +class GKSCoreMetadataMixin(GKSMetadataMixin): + """Provide GKS-Core model metadata.""" + + _product_name = "gks-core" + _product_version = CORE_VERSION class BaseModelForbidExtra(BaseModel): @@ -56,13 +65,15 @@ class MembershipOperator(str, Enum): ######################################### -class code(RootModel): # noqa: N801 +class code(GKSCoreMetadataMixin, RootModel): # noqa: N801 """Indicates that the value is taken from a set of controlled strings defined elsewhere. Technically, a code is restricted to a string which has at least one character and no leading or trailing whitespace, and where there is no whitespace other than single spaces in the contents. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: Annotated[str, StringConstraints(pattern=r"\S+( \S+)*")] = Field( ..., json_schema_extra={ @@ -72,7 +83,7 @@ class code(RootModel): # noqa: N801 ) -class iriReference(RootModel): # noqa: N801 +class iriReference(GKSCoreMetadataMixin, RootModel): # noqa: N801 """An IRI Reference (either an IRI or a relative-reference), according to `RFC3986 section 4.1 `_ and `RFC3987 section 2.1 `_. @@ -80,6 +91,8 @@ class iriReference(RootModel): # noqa: N801 `_. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + def __hash__(self) -> int: # noqa: D105 return self.root.__hash__() @@ -102,12 +115,14 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Entity(BaseModel, ABC): +class Entity(GKSMaturityMixin, BaseModel, ABC): """Anything that exists, has existed, or will exist. Abstract base class to be extended by other classes. Do NOT instantiate directly. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + id: str | None = Field( default=None, description="The 'logical' identifier of the Entity in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", @@ -129,12 +144,14 @@ class Entity(BaseModel, ABC): ) -class Element(BaseModel, ABC): +class Element(GKSMaturityMixin, BaseModel, ABC): """The base definition for all identifiable data objects. Abstract base class to be extended by other classes. Do NOT instantiate directly. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + id: str | None = Field( default=None, description="The 'logical' identifier of the data element in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", @@ -160,11 +177,13 @@ def get_extensions_by_name(self, name: str) -> list[Extension]: ######################################### -class Coding(Element, BaseModelForbidExtra): +class Coding(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """A structured representation of a code for a defined concept in a terminology or code system. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + name: str | None = Field( default=None, description="The human-readable name for the coded concept, as defined by the code system.", @@ -184,11 +203,13 @@ class Coding(Element, BaseModelForbidExtra): ) -class ConceptMapping(Element, BaseModelForbidExtra): +class ConceptMapping(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """A mapping to a concept in a terminology or code system.""" model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + coding: Coding = Field( ..., description="A structured representation of a code for a defined concept in a terminology or code system.", @@ -199,7 +220,7 @@ class ConceptMapping(Element, BaseModelForbidExtra): ) -class ConceptSet(Element, BaseModelForbidExtra): +class ConceptSet(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """A set of concepts that may be considered as dependent (occurring together), or independent (existing separately) in the context of some knowledge reported about them, as indicated by a set membership operator. e.g. a set of independent molecular @@ -209,6 +230,8 @@ class ConceptSet(Element, BaseModelForbidExtra): model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["ConceptSet"] = Field( default="ConceptSet", description='MUST be "ConceptSet".', @@ -224,7 +247,7 @@ class ConceptSet(Element, BaseModelForbidExtra): ) -class Extension(Element, BaseModelForbidExtra): +class Extension(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """The Extension class provides entities with a means to include additional attributes that are outside of the specified standard but needed by a given content provider or system implementer. These extensions are not expected to be natively @@ -232,6 +255,8 @@ class Extension(Element, BaseModelForbidExtra): between systems. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + name: str = Field( ..., description="A name for the Extension. Should be indicative of its meaning and/or the type of information it value represents.", @@ -246,9 +271,11 @@ class Extension(Element, BaseModelForbidExtra): ) -class MappableConcept(Element, BaseModelForbidExtra): +class MappableConcept(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """A concept based on a primaryCoding and/or name that may be mapped to one or more other `Codings`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + conceptType: str | None = Field( # noqa: N815 default=None, description="A term indicating the type of concept being represented by the MappableConcept.", diff --git a/src/ga4gh/core/version.py b/src/ga4gh/core/version.py new file mode 100644 index 00000000..74c4d4f1 --- /dev/null +++ b/src/ga4gh/core/version.py @@ -0,0 +1,3 @@ +"""Define GKS-Core version""" + +CORE_VERSION = "1.1.0" diff --git a/src/ga4gh/vrs/__init__.py b/src/ga4gh/vrs/__init__.py index 24889aa6..f6f9eada 100644 --- a/src/ga4gh/vrs/__init__.py +++ b/src/ga4gh/vrs/__init__.py @@ -1,22 +1,22 @@ """Public interface to the GA4GH Variation Representation reference implementation""" -from importlib.metadata import PackageNotFoundError, version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version from ga4gh.vrs import models from ga4gh.vrs.enderef import vrs_deref, vrs_enref from ga4gh.vrs.models import VrsType from ga4gh.vrs.normalize import normalize +from ga4gh.vrs.version import VRS_VERSION try: - __version__ = version(__name__) + __version__ = package_version(__name__) except PackageNotFoundError: # pragma: nocover __version__ = "unknown" finally: - del version, PackageNotFoundError + del package_version, PackageNotFoundError -VRS_VERSION = "2.1.0-snapshot.2026-02.2" - __all__ = [ "VRS_VERSION", "VrsType", diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index e8029b01..b836d9c1 100644 --- a/src/ga4gh/vrs/models.py +++ b/src/ga4gh/vrs/models.py @@ -17,7 +17,7 @@ from collections import OrderedDict from enum import Enum from types import UnionType -from typing import Annotated, Literal, get_args, get_origin +from typing import Annotated, ClassVar, Literal, get_args, get_origin from canonicaljson import encode_canonical_json from pydantic import ( @@ -44,7 +44,9 @@ Entity, iriReference, ) +from ga4gh.core.metadata import GKSMetadataMixin, Maturity from ga4gh.core.pydantic import get_pydantic_root, getattr_in +from ga4gh.vrs.version import VRS_VERSION def flatten(vals): @@ -261,6 +263,13 @@ def _recurse_ga4gh_serialize(obj): return obj +class VRSMetadataMixin(GKSMetadataMixin): + """Provide metadata for a concrete VRS model.""" + + _product_name = "vrs" + _product_version = VRS_VERSION + + class _ValueObject(Entity, ABC): """A contextual value whose equality is based on value, not identity. See https://en.wikipedia.org/wiki/Value_object for more on Value Objects. @@ -290,6 +299,8 @@ class Ga4ghIdentifiableObject(_ValueObject, ABC): Identifier algorithm. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: str digest: ( Annotated[str, StringConstraints(pattern=r"^[0-9A-Za-z_\-]{32}$")] | None @@ -398,12 +409,14 @@ class ga4gh(_ValueObject.ga4gh): # noqa: N801 prefix: str -class Expression(Element, BaseModelForbidExtra): +class Expression(VRSMetadataMixin, Element, BaseModelForbidExtra): """Representation of a variation by a specified nomenclature or syntax for a Variation object. Common examples of expressions for the description of molecular variation include the HGVS and ISCN nomenclatures. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + model_config = ConfigDict(use_enum_values=True) syntax: Syntax = Field( @@ -425,9 +438,11 @@ class Expression(Element, BaseModelForbidExtra): ######################################### -class Range(RootModel): +class Range(VRSMetadataMixin, RootModel): """An inclusive range of values bounded by one or more integers.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: list[int | None] = Field( ..., json_schema_extra={ @@ -459,13 +474,15 @@ def validate_range(cls, v: list[int | None]) -> list[int | None]: # noqa: N805 return v -class residue(RootModel): +class residue(VRSMetadataMixin, RootModel): """A character representing a specific residue (i.e., molecular species) or groupings of these ("ambiguity codes"), using `one-letter IUPAC abbreviations `_ for nucleic acids and amino acids. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: Annotated[str, StringConstraints(pattern=r"[A-Z*\-]")] = Field( ..., json_schema_extra={ @@ -474,13 +491,15 @@ class residue(RootModel): ) -class sequenceString(RootModel): +class sequenceString(VRSMetadataMixin, RootModel): """A character string of `Residues` that represents a biological sequence using the conventional sequence order (5'-to-3' for nucleic acid sequences, and amino-to-carboxyl for amino acid sequences). IUPAC ambiguity codes are permitted in Sequence Strings. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: Annotated[str, StringConstraints(pattern=r"^[A-Z*\-]*$")] = Field( ..., json_schema_extra={ @@ -494,9 +513,11 @@ class sequenceString(RootModel): ######################################### -class LengthExpression(_ValueObject, BaseModelForbidExtra): +class LengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A sequence expressed only by its length.""" + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["LengthExpression"] = Field( default=VrsType.LEN_EXPR.value, description=f'MUST be "{VrsType.LEN_EXPR.value}"', @@ -509,9 +530,11 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["length", "type"] -class ReferenceLengthExpression(_ValueObject, BaseModelForbidExtra): +class ReferenceLengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """An expression of a length of a sequence from a repeating reference.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["ReferenceLengthExpression"] = Field( default=VrsType.REF_LEN_EXPR.value, description=f'MUST be "{VrsType.REF_LEN_EXPR.value}"', @@ -531,9 +554,11 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["length", "repeatSubunitLength", "type"] -class LiteralSequenceExpression(_ValueObject, BaseModelForbidExtra): +class LiteralSequenceExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """An explicit expression of a Sequence.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["LiteralSequenceExpression"] = Field( default=VrsType.LIT_SEQ_EXPR.value, description=f'MUST be "{VrsType.LIT_SEQ_EXPR.value}"', @@ -549,9 +574,11 @@ class ga4gh(_ValueObject.ga4gh): ######################################### -class SequenceReference(_ValueObject, BaseModelForbidExtra): +class SequenceReference(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A sequence of nucleic or amino acid character codes.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + model_config = ConfigDict(use_enum_values=True) type: Literal["SequenceReference"] = Field( @@ -584,9 +611,11 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["refgetAccession", "type"] -class SequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): +class SequenceLocation(VRSMetadataMixin, Ga4ghIdentifiableObject, BaseModelForbidExtra): """A `Location` defined by an interval on a `Sequence`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["SequenceLocation"] = Field( default=VrsType.SEQ_LOC.value, description=f'MUST be "{VrsType.SEQ_LOC.value}"' ) @@ -674,13 +703,15 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["end", "sequenceReference", "start", "type"] -class SequenceOffsetLocation(_ValueObject, BaseModelForbidExtra): +class SequenceOffsetLocation(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A location defined by an offset relative to an anchor on a mapped sequence reference. """ model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["SequenceOffsetLocation"] = Field( default=VrsType.SEQ_OFFSET_LOCATION.value, description=f'MUST be "{VrsType.SEQ_OFFSET_LOCATION.value}"', @@ -717,12 +748,16 @@ class ga4gh(_ValueObject.ga4gh): ] -class RelativeSequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): +class RelativeSequenceLocation( + VRSMetadataMixin, Ga4ghIdentifiableObject, BaseModelForbidExtra +): """A location on a base sequence and its position relative to a boundary offset on a mapped sequence gap. Typically used to describe intronic locations that exist with respect to a mapped RNA transcript sequence. """ + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["RelativeSequenceLocation"] = Field( default=VrsType.RELATIVE_SEQ_LOC.value, description=f'MUST be "{VrsType.RELATIVE_SEQ_LOC.value}"', @@ -756,9 +791,11 @@ class _VariationBase(Ga4ghIdentifiableObject, ABC): ######################################### -class Allele(_VariationBase, BaseModelForbidExtra): +class Allele(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The state of a molecule at a `Location`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["Allele"] = Field( default=VrsType.ALLELE.value, description=f'MUST be "{VrsType.ALLELE.value}"' ) @@ -800,9 +837,11 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["location", "state", "type"] -class RelativeAllele(_VariationBase, BaseModelForbidExtra): +class RelativeAllele(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """An Allele defined on a mapped location relative to a base location. Often used to describe intronic variants.""" + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["RelativeAllele"] = Field( default=VrsType.RELATIVE_ALLELE.value, description=f'MUST be "{VrsType.RELATIVE_ALLELE.value}"', @@ -829,9 +868,11 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["mappedState", "baseState", "relativeLocation", "type"] -class CisPhasedBlock(_VariationBase, BaseModelForbidExtra): +class CisPhasedBlock(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """An ordered set of co-occurring `Variation` on the same molecule.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["CisPhasedBlock"] = Field( default=VrsType.CIS_PHASED_BLOCK.value, description=f'MUST be "{VrsType.CIS_PHASED_BLOCK.value}"', @@ -861,11 +902,13 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -class Adjacency(_VariationBase, BaseModelForbidExtra): +class Adjacency(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The `Adjacency` class represents the adjoining of the end of a sequence with the beginning of an adjacent sequence, potentially with an intervening linker sequence. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["Adjacency"] = Field( default=VrsType.ADJACENCY.value, description=f'MUST be "{VrsType.ADJACENCY.value}".', @@ -905,12 +948,14 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["adjoinedSequences", "linker", "type"] -class Terminus(_VariationBase, BaseModelForbidExtra): +class Terminus(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The `Terminus` data class provides a structure for describing the end (terminus) of a sequence. Structurally similar to Adjacency but the linker sequence is not allowed and it removes the unnecessary array structure. """ + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["Terminus"] = Field( default=VrsType.TERMINUS.value, description=f'MUST be "{VrsType.TERMINUS.value}".', @@ -924,13 +969,15 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 inherent = ["location", "type"] -class TraversalBlock(_ValueObject, BaseModelForbidExtra): +class TraversalBlock(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A component used to describe the orientation of applicable molecular variation within a DerivativeMolecule. """ model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["TraversalBlock"] = Field( default=VrsType.TRAVERSAL_BLOCK.value, description=f'MUST be "{VrsType.TRAVERSAL_BLOCK.value}".', @@ -948,11 +995,13 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["component", "orientation", "type"] -class DerivativeMolecule(_VariationBase, BaseModelForbidExtra): +class DerivativeMolecule(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The "Derivative Molecule" data class is a structure for describing a derivate molecule composed from multiple sequence components. """ + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["DerivativeMolecule"] = Field( default=VrsType.DERIVATIVE_MOL.value, description=f'MUST be "{VrsType.DERIVATIVE_MOL.value}".', @@ -979,11 +1028,13 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 ######################################### -class CopyNumberCount(_VariationBase, BaseModelForbidExtra): +class CopyNumberCount(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The absolute count of discrete copies of a `Location`, within a system (e.g. genome, cell, etc.). """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["CopyNumberCount"] = Field( default=VrsType.CN_COUNT.value, description=f'MUST be "{VrsType.CN_COUNT.value}"', @@ -1001,13 +1052,15 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 inherent = ["copies", "location", "type"] -class CopyNumberChange(_VariationBase, BaseModelForbidExtra): +class CopyNumberChange(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """An assessment of the copy number of a `Location` within a system (e.g. genome, cell, etc.) relative to a baseline ploidy. """ model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["CopyNumberChange"] = Field( default=VrsType.CN_CHANGE.value, description=f'MUST be "{VrsType.CN_CHANGE.value}"', @@ -1031,9 +1084,11 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -class MolecularVariation(RootModel): +class MolecularVariation(VRSMetadataMixin, RootModel): """A `variation` on a contiguous molecule.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: ( Allele | RelativeAllele @@ -1048,9 +1103,11 @@ class MolecularVariation(RootModel): ) -class SequenceExpression(RootModel): +class SequenceExpression(VRSMetadataMixin, RootModel): """An expression describing a `Sequence`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression = ( Field( ..., @@ -1060,9 +1117,11 @@ class SequenceExpression(RootModel): ) -class Location(RootModel): +class Location(VRSMetadataMixin, RootModel): """A contiguous segment of a biological sequence.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: SequenceLocation | RelativeSequenceLocation = Field( ..., json_schema_extra={ @@ -1072,9 +1131,11 @@ class Location(RootModel): ) -class Variation(RootModel): +class Variation(VRSMetadataMixin, RootModel): """A representation of the state of one or more biomolecules.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: ( Allele | CisPhasedBlock @@ -1092,11 +1153,13 @@ class Variation(RootModel): ) -class SystemicVariation(RootModel): +class SystemicVariation(VRSMetadataMixin, RootModel): """A Variation of multiple molecules in the context of a system, e.g. a genome, sample, or homologous chromosomes. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: CopyNumberChange | CopyNumberCount = Field( ..., json_schema_extra={ diff --git a/src/ga4gh/vrs/version.py b/src/ga4gh/vrs/version.py new file mode 100644 index 00000000..281da683 --- /dev/null +++ b/src/ga4gh/vrs/version.py @@ -0,0 +1,3 @@ +"""Define VRS version""" + +VRS_VERSION = "2.1.0-snapshot.2026-02.2" diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py new file mode 100644 index 00000000..77260795 --- /dev/null +++ b/tests/validation/test_model_metadata.py @@ -0,0 +1,90 @@ +"""Test model metadata against the GKS source and JSON schemas.""" + +import json +from pathlib import Path + +import pytest +import yaml + +from ga4gh.core import core_models +from ga4gh.core.metadata import Maturity +from ga4gh.vrs import models as vrs_models + +SUBMODULES_DIR = Path(__file__).parents[2] / "submodules" / "vrs" +SCHEMAS = ( + ( + core_models, + SUBMODULES_DIR + / "submodules" + / "gks-core" + / "schema" + / "gks-core" + / "gks-core-source.yaml", + SUBMODULES_DIR / "submodules" / "gks-core" / "schema" / "gks-core" / "json", + ), + ( + vrs_models, + SUBMODULES_DIR / "schema" / "vrs" / "vrs-source.yaml", + SUBMODULES_DIR / "schema" / "vrs" / "json", + ), +) + + +def _concrete_model_params(): + """Return concrete model metadata discovered from JSON Schema files.""" + params = [] + for model_module, _, json_dir in SCHEMAS: + schema_params = [] + for schema_path in sorted(json_dir.iterdir()): + model = getattr(model_module, schema_path.name, None) + if model is None: + continue # date and datetime use standard-library classes + with schema_path.open() as schema_file: + schema = json.load(schema_file) + schema_params.append(pytest.param(model, schema, id=schema["title"])) + assert schema_params, f"No concrete models discovered in {json_dir}" + params.extend(schema_params) + return params + + +def _abstract_model_params(): + """Return abstract model metadata found only in source schemas.""" + params = [] + for model_module, source_path, json_dir in SCHEMAS: + schema_params = [] + with source_path.open() as source_file: + definitions = yaml.safe_load(source_file)["$defs"] + concrete_names = {path.name for path in json_dir.iterdir()} + for name, definition in definitions.items(): + if name not in concrete_names and "heritableProperties" in definition: + schema_params.append( + pytest.param(getattr(model_module, name), definition, id=name) + ) + assert schema_params, f"No abstract models discovered in {source_path}" + params.extend(schema_params) + return params + + +@pytest.mark.parametrize(("model", "schema"), _concrete_model_params()) +def test_concrete_model_metadata(model, schema): + """Concrete model metadata matches its generated JSON Schema.""" + assert model.schema_id() == schema["$id"] + assert model.maturity() == Maturity(schema["maturity"]) + generated_schema = model.model_json_schema() + assert generated_schema["$id"] == schema["$id"] + assert generated_schema["maturity"] == schema["maturity"] + if ga4gh_metadata := schema.get("ga4gh"): + assert generated_schema["ga4gh"].get("prefix") == ga4gh_metadata.get("prefix") + assert set(generated_schema["ga4gh"]["inherent"]) == set( + ga4gh_metadata["inherent"] + ) + else: + assert "ga4gh" not in generated_schema + + +@pytest.mark.parametrize(("model", "definition"), _abstract_model_params()) +def test_abstract_model_metadata(model, definition): + """Abstract models expose source-defined maturity but no schema identifier.""" + assert "_maturity" in model.__dict__ + assert model.maturity() == Maturity(definition["maturity"]) + assert not hasattr(model, "schema_id") From 87ffb9d4c088fe31324cb15ed7104dc3e9d0552a Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 1 Sep 2026 10:36:57 -0400 Subject: [PATCH 13/18] feat: update models to gkm-core 1.2.0 and vrs 2.1.0 (#652) close #651 ## Diffs https://github.com/ga4gh/gkm-core/compare/1.1.0..1.2.0 https://github.com/ga4gh/vrs/compare/2.1.0-snapshot.2026-02.2..2.1.0 --- .gitmodules | 2 +- src/ga4gh/core/models.py | 12 ++++++++---- src/ga4gh/core/version.py | 4 ++-- src/ga4gh/vrs/version.py | 2 +- submodules/vrs | 2 +- tests/validation/test_model_metadata.py | 8 ++++---- tests/validation/test_schemas.py | 2 +- 7 files changed, 18 insertions(+), 14 deletions(-) diff --git a/.gitmodules b/.gitmodules index a7b8c08e..2981416e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/vrs"] path = submodules/vrs url = https://github.com/ga4gh/vrs.git - branch = 2.1.0-snapshot.2026-02 + branch = 2.1 diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index 7492194b..571cb22d 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -22,9 +22,9 @@ class GKSCoreMetadataMixin(GKSMetadataMixin): - """Provide GKS-Core model metadata.""" + """Provide gkm-core model metadata.""" - _product_name = "gks-core" + _product_name = "gkm-core" _product_version = CORE_VERSION @@ -220,7 +220,7 @@ class ConceptMapping(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): ) -class ConceptSet(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): +class ConceptSet(GKSCoreMetadataMixin, Entity, BaseModelForbidExtra): """A set of concepts that may be considered as dependent (occurring together), or independent (existing separately) in the context of some knowledge reported about them, as indicated by a set membership operator. e.g. a set of independent molecular @@ -271,11 +271,15 @@ class Extension(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): ) -class MappableConcept(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): +class MappableConcept(GKSCoreMetadataMixin, Entity, BaseModelForbidExtra): """A concept based on a primaryCoding and/or name that may be mapped to one or more other `Codings`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["MappableConcept"] = Field( + default="MappableConcept", + description='MUST be "MappableConcept".', + ) conceptType: str | None = Field( # noqa: N815 default=None, description="A term indicating the type of concept being represented by the MappableConcept.", diff --git a/src/ga4gh/core/version.py b/src/ga4gh/core/version.py index 74c4d4f1..982aa6c9 100644 --- a/src/ga4gh/core/version.py +++ b/src/ga4gh/core/version.py @@ -1,3 +1,3 @@ -"""Define GKS-Core version""" +"""Define GKM-Core version""" -CORE_VERSION = "1.1.0" +CORE_VERSION = "1.2.0" diff --git a/src/ga4gh/vrs/version.py b/src/ga4gh/vrs/version.py index 281da683..00b1afa9 100644 --- a/src/ga4gh/vrs/version.py +++ b/src/ga4gh/vrs/version.py @@ -1,3 +1,3 @@ """Define VRS version""" -VRS_VERSION = "2.1.0-snapshot.2026-02.2" +VRS_VERSION = "2.1.0" diff --git a/submodules/vrs b/submodules/vrs index 83791ac4..cf33bfa7 160000 --- a/submodules/vrs +++ b/submodules/vrs @@ -1 +1 @@ -Subproject commit 83791ac4294a59ce48400eb66c34af86a12a3704 +Subproject commit cf33bfa7618011087655d5a5898e518c9d96bcdb diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py index 77260795..5088531d 100644 --- a/tests/validation/test_model_metadata.py +++ b/tests/validation/test_model_metadata.py @@ -16,11 +16,11 @@ core_models, SUBMODULES_DIR / "submodules" - / "gks-core" + / "gkm-core" / "schema" - / "gks-core" - / "gks-core-source.yaml", - SUBMODULES_DIR / "submodules" / "gks-core" / "schema" / "gks-core" / "json", + / "gkm-core" + / "gkm-core-source.yaml", + SUBMODULES_DIR / "submodules" / "gkm-core" / "schema" / "gkm-core" / "json", ), ( vrs_models, diff --git a/tests/validation/test_schemas.py b/tests/validation/test_schemas.py index 88ad9591..322a30dc 100644 --- a/tests/validation/test_schemas.py +++ b/tests/validation/test_schemas.py @@ -61,7 +61,7 @@ def _update_gks_schema_mapping( # Get core classes core_mapping = GKS_SCHEMA_MAPPING[GKSSchema.CORE] for f in ( - SUBMODULES_DIR / "submodules" / "gks-core" / "schema" / "gks-core" / "json" + SUBMODULES_DIR / "submodules" / "gkm-core" / "schema" / "gkm-core" / "json" ).glob("*"): _update_gks_schema_mapping(f, core_mapping) From 0476ec6abbedd5f3ec9cd77c5a7d64c5badbaddb Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Tue, 29 Sep 2026 08:16:55 +0800 Subject: [PATCH 14/18] feat: update models vrs 2.1.1-ballot.2026-09.1 (#658) ## Changes * update models vrs 2.1.1-ballot.2026-09.1 and gkm-core 1.3.0-ballot.2026-09.1 * Added `_abstract` classvar * GKS -> GKM (deprecating classes that start with GKS) * Adds missing `RelativeAllele` to Variation root ## Schema changes _note: Use /json rather than *-source.yaml_ * Core changes: https://github.com/ga4gh/gkm-core/compare/1.2.0..1.3.0-ballot.2026-09.1 * VRS changes: https://github.com/ga4gh/vrs/compare/2.1.0..2.1.1-ballot.2026-09.1 --------- Co-authored-by: Larry Babb --- .gitmodules | 2 +- README.md | 2 +- src/ga4gh/core/__init__.py | 9 +- src/ga4gh/core/metadata.py | 101 +++++++++++---- src/ga4gh/core/models.py | 132 ++++++++++--------- src/ga4gh/core/version.py | 2 +- src/ga4gh/vrs/models.py | 48 +++---- src/ga4gh/vrs/version.py | 2 +- submodules/vrs | 2 +- tests/validation/test_model_metadata.py | 162 ++++++++++++++++++++---- tests/validation/test_models.py | 4 +- tests/validation/test_schemas.py | 68 +++++----- 12 files changed, 361 insertions(+), 173 deletions(-) diff --git a/.gitmodules b/.gitmodules index 2981416e..00bca507 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/vrs"] path = submodules/vrs url = https://github.com/ga4gh/vrs.git - branch = 2.1 + branch = 2.1.1-ballot.2026-09 diff --git a/README.md b/README.md index 3e5686de..9f86a581 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ ## Features -- Pydantic implementation of GKS core models and VRS models +- Pydantic implementation of GKM-Core models and VRS models - Algorithm for generating consistent, globally unique identifiers for variation without a central authority - Algorithm for performing fully justified allele normalization - Translating from and to other variant formats diff --git a/src/ga4gh/core/__init__.py b/src/ga4gh/core/__init__.py index b6da1c78..7170324d 100644 --- a/src/ga4gh/core/__init__.py +++ b/src/ga4gh/core/__init__.py @@ -21,12 +21,15 @@ use_ga4gh_compute_identifier_when, ) from ga4gh.core.metadata import ( + GKMMaturityMixin, + GKMMetadataMixin, + GKMSchemaMixin, GKSMaturityMixin, GKSMetadataMixin, GKSSchemaMixin, Maturity, ) -from ga4gh.core.models import GKSCoreMetadataMixin +from ga4gh.core.models import GKMCoreMetadataMixin, GKSCoreMetadataMixin from ga4gh.core.pydantic import is_curie_type, is_pydantic_instance, pydantic_copy from ga4gh.core.version import CORE_VERSION @@ -45,6 +48,10 @@ "GA4GH_DIGEST_REGEXP", "GA4GH_IR_REGEXP", "GA4GH_PREFIX_SEP", + "GKMCoreMetadataMixin", + "GKMMaturityMixin", + "GKMMetadataMixin", + "GKMSchemaMixin", "GKSCoreMetadataMixin", "GKSMaturityMixin", "GKSMetadataMixin", diff --git a/src/ga4gh/core/metadata.py b/src/ga4gh/core/metadata.py index 1945fe44..a428572f 100644 --- a/src/ga4gh/core/metadata.py +++ b/src/ga4gh/core/metadata.py @@ -1,9 +1,17 @@ -"""Provide shared metadata types for GA4GH GKS models.""" +"""Provide shared metadata types for GA4GH GKM models.""" from enum import Enum -from typing import Any, ClassVar +from typing import Any, ClassVar, Literal +import pydantic from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode +from typing_extensions import deprecated + +# union_format was introduced in pydantic 2.12 +# (https://pydantic.dev/docs/validation/latest/get-started/changelog/#v2120-2025-10-07) +PYDANTIC_HAS_UNION_FORMAT = tuple( + int(p) for p in pydantic.__version__.split(".")[:2] +) >= (2, 12) class Maturity(str, Enum): @@ -15,19 +23,19 @@ class Maturity(str, Enum): DEPRECATED = "deprecated" -class GKSMaturityMixin: - """Provide maturity metadata for a GA4GH GKS model.""" +class GKMMaturityMixin: + """Provide maturity metadata for a GA4GH GKM model.""" _maturity: ClassVar[Maturity] @classmethod def maturity(cls) -> Maturity: - """Return the GKS maturity level for the model.""" + """Return the GKM maturity level for the model.""" return cls._maturity -class GKSSchemaMixin: - """Provide a canonical JSON Schema identifier for a GA4GH GKS model.""" +class GKMSchemaMixin: + """Provide a canonical JSON Schema identifier for a GA4GH GKM model.""" _schema_base_uri: ClassVar[str] = "https://w3id.org/ga4gh/schema" _product_name: ClassVar[str] @@ -39,28 +47,25 @@ def schema_id(cls) -> str: return f"{cls._schema_base_uri}/{cls._product_name}/{cls._product_version}/json/{cls.__name__}" -class GKSMetadataMixin(GKSMaturityMixin, GKSSchemaMixin): - """Provide maturity and schema metadata for a concrete GKS model.""" +class GKMMetadataMixin(GKMMaturityMixin, GKMSchemaMixin): + """Provide maturity and schema metadata for a GKM model.""" + + _abstract: ClassVar[bool] = False @classmethod - def model_json_schema( - cls, - by_alias: bool = True, - ref_template: str = "#/$defs/{model}", - schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, - mode: JsonSchemaMode = "validation", - ) -> dict[str, Any]: - """Generate JSON Schema with GKS metadata.""" - schema = super().model_json_schema( - by_alias=by_alias, - ref_template=ref_template, - schema_generator=schema_generator, - mode=mode, - ) + def apply_schema_metadata(cls, schema: dict[str, Any]) -> dict[str, Any]: + """Add GKM metadata to a generated JSON Schema. + :param schema: Generated JSON Schema to annotate. + :returns: The annotated JSON Schema. + """ schema["$id"] = cls.schema_id() schema["maturity"] = cls.maturity().value + if cls.__dict__.get("_abstract", False): + schema["abstract"] = True + + # GA4GH identifier metadata is optional and applies only when declared. ga4gh_class = getattr(cls, "ga4gh", None) if not ga4gh_class: return schema @@ -77,3 +82,53 @@ def model_json_schema( schema["ga4gh"] = ga4gh_metadata return schema + + @classmethod + def model_json_schema( + cls, + by_alias: bool = True, + ref_template: str = "#/$defs/{model}", + schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, + mode: JsonSchemaMode = "validation", + *, + union_format: Literal["any_of", "primitive_type_array"] = "any_of", + ) -> dict[str, Any]: + """Generate JSON Schema with GKM metadata. + + Default values follow Pydantic defaults. + + :param by_alias: Whether to use field aliases. + :param ref_template: Template for schema references. + :param schema_generator: Pydantic schema generator class. + :param mode: Pydantic schema generation mode. + :param union_format: How to represent unions in the generated schema. + :returns: JSON Schema annotated with GKM metadata. + """ + schema_kwargs: dict[str, Any] = { + "by_alias": by_alias, + "ref_template": ref_template, + "schema_generator": schema_generator, + "mode": mode, + } + + if PYDANTIC_HAS_UNION_FORMAT: + schema_kwargs["union_format"] = union_format + + schema = super().model_json_schema(**schema_kwargs) # pyright: ignore[reportAttributeAccessIssue] + + return cls.apply_schema_metadata(schema) + + +@deprecated("GKSMaturityMixin is deprecated; use GKMMaturityMixin instead.") +class GKSMaturityMixin(GKMMaturityMixin): + """Deprecated alias for :class:`GKMMaturityMixin`.""" + + +@deprecated("GKSSchemaMixin is deprecated; use GKMSchemaMixin instead.") +class GKSSchemaMixin(GKMSchemaMixin): + """Deprecated alias for :class:`GKMSchemaMixin`.""" + + +@deprecated("GKSMetadataMixin is deprecated; use GKMMetadataMixin instead.") +class GKSMetadataMixin(GKMMetadataMixin): + """Deprecated alias for :class:`GKMMetadataMixin`.""" diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index 571cb22d..c71b294b 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -1,4 +1,4 @@ -"""GKS Core Class Definitions""" +"""GKM Core Class Definitions""" from __future__ import annotations @@ -14,20 +14,25 @@ StringConstraints, model_validator, ) -from typing_extensions import Self +from typing_extensions import Self, deprecated from ga4gh.core.identifiers import GA4GH_IR_REGEXP -from ga4gh.core.metadata import GKSMaturityMixin, GKSMetadataMixin, Maturity +from ga4gh.core.metadata import GKMMetadataMixin, Maturity from ga4gh.core.version import CORE_VERSION -class GKSCoreMetadataMixin(GKSMetadataMixin): +class GKMCoreMetadataMixin(GKMMetadataMixin): """Provide gkm-core model metadata.""" _product_name = "gkm-core" _product_version = CORE_VERSION +@deprecated("GKSCoreMetadataMixin is deprecated; use GKMCoreMetadataMixin instead.") +class GKSCoreMetadataMixin(GKMCoreMetadataMixin): + """Deprecated alias for :class:`GKMCoreMetadataMixin`.""" + + class BaseModelForbidExtra(BaseModel): """Base Pydantic model class with extra attributes forbidden.""" @@ -65,7 +70,7 @@ class MembershipOperator(str, Enum): ######################################### -class code(GKSCoreMetadataMixin, RootModel): # noqa: N801 +class code(GKMCoreMetadataMixin, RootModel): # noqa: N801 """Indicates that the value is taken from a set of controlled strings defined elsewhere. Technically, a code is restricted to a string which has at least one character and no leading or trailing whitespace, and where there is no whitespace @@ -83,7 +88,7 @@ class code(GKSCoreMetadataMixin, RootModel): # noqa: N801 ) -class iriReference(GKSCoreMetadataMixin, RootModel): # noqa: N801 +class iriReference(GKMCoreMetadataMixin, RootModel): # noqa: N801 """An IRI Reference (either an IRI or a relative-reference), according to `RFC3986 section 4.1 `_ and `RFC3987 section 2.1 `_. @@ -115,69 +120,96 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Entity(GKSMaturityMixin, BaseModel, ABC): - """Anything that exists, has existed, or will exist. +class Element(GKMCoreMetadataMixin, BaseModel, ABC): + """The base definition for all identifiable data objects. Abstract base class to be extended by other classes. Do NOT instantiate directly. """ _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True id: str | None = Field( default=None, - description="The 'logical' identifier of the Entity in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", + description="The 'logical' identifier of the data element in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", ) - type: str = Field( - ..., - description="The name of the class that is instantiated by a data object representing the Entity.", + extensions: list[Extension] | None = Field( + default=None, + description="A list of extensions to the Entity, that allow for capture of information not directly supported by elements defined in the model.", ) - name: str | None = Field(default=None, description="A primary name for the entity.") - description: str | None = Field( - default=None, description="A free-text description of the Entity." + + def get_extensions_by_name(self, name: str) -> list[Extension]: + """Fetch all contained extension exactly matching the provided name + + :param name: name of extension to fetch + :return: a list of all matching extensions, empty if no matches found (or if instance contains no extensions) + """ + if not self.extensions: + return [] + return [e for e in self.extensions if e.name == name] + + +# Not an ABC, but needs to go here so there's no circular imports +class Extension(Element, BaseModelForbidExtra): + """The Extension class provides entities with a means to include additional + attributes that are outside of the specified standard but needed by a given content + provider or system implementer. These extensions are not expected to be natively + understood, but may be used for pre-negotiated exchange of message attributes + between systems. + """ + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + + name: str = Field( + ..., + description="A name for the Extension. Should be indicative of its meaning and/or the type of information it value represents.", ) - aliases: list[str] | None = Field( - default=None, description="Alternative name(s) for the Entity." + value: int | float | str | bool | dict[str, Any] | list[Any] | None = Field( + ..., + description="The value of the Extension - can be any primitive or structured object", ) - extensions: list[Extension] | None = Field( + description: str | None = Field( default=None, - description="A list of extensions to the Entity, that allow for capture of information not directly supported by elements defined in the model.", + description="A description of the meaning or utility of the Extension, to explain the type of information it is meant to hold.", ) -class Element(GKSMaturityMixin, BaseModel, ABC): - """The base definition for all identifiable data objects. +class Entity(GKMCoreMetadataMixin, BaseModel, ABC): + """Anything that exists, has existed, or will exist. Abstract base class to be extended by other classes. Do NOT instantiate directly. """ _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True id: str | None = Field( default=None, - description="The 'logical' identifier of the data element in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", + description="The 'logical' identifier of the Entity in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", + ) + type: str = Field( + ..., + description="The name of the class that is instantiated by a data object representing the Entity.", + ) + name: str | None = Field(default=None, description="A primary name for the entity.") + description: str | None = Field( + default=None, description="A free-text description of the Entity." + ) + aliases: list[str] | None = Field( + default=None, description="Alternative name(s) for the Entity." ) extensions: list[Extension] | None = Field( default=None, description="A list of extensions to the Entity, that allow for capture of information not directly supported by elements defined in the model.", ) - def get_extensions_by_name(self, name: str) -> list[Extension]: - """Fetch all contained extension exactly matching the provided name - - :param name: name of extension to fetch - :return: a list of all matching extensions, empty if no matches found (or if instance contains no extensions) - """ - if not self.extensions: - return [] - return [e for e in self.extensions if e.name == name] - ######################################### # General-purpose data classes ######################################### -class Coding(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): +class Coding(Element, BaseModelForbidExtra): """A structured representation of a code for a defined concept in a terminology or code system. """ @@ -203,7 +235,7 @@ class Coding(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): ) -class ConceptMapping(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): +class ConceptMapping(Element, BaseModelForbidExtra): """A mapping to a concept in a terminology or code system.""" model_config = ConfigDict(use_enum_values=True) @@ -220,7 +252,7 @@ class ConceptMapping(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): ) -class ConceptSet(GKSCoreMetadataMixin, Entity, BaseModelForbidExtra): +class ConceptSet(Entity, BaseModelForbidExtra): """A set of concepts that may be considered as dependent (occurring together), or independent (existing separately) in the context of some knowledge reported about them, as indicated by a set membership operator. e.g. a set of independent molecular @@ -236,6 +268,10 @@ class ConceptSet(GKSCoreMetadataMixin, Entity, BaseModelForbidExtra): default="ConceptSet", description='MUST be "ConceptSet".', ) + conceptSetType: str | None = Field( # noqa: N815 + default=None, + description="A term indicating the type of concept being represented by the ConceptSet.", + ) concepts: list[MappableConcept] | list[ConceptSet] = Field( ..., description="A list of concepts that are dependent (occurring together), or independent (existing separately), depending on the membership operator.", @@ -247,31 +283,7 @@ class ConceptSet(GKSCoreMetadataMixin, Entity, BaseModelForbidExtra): ) -class Extension(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): - """The Extension class provides entities with a means to include additional - attributes that are outside of the specified standard but needed by a given content - provider or system implementer. These extensions are not expected to be natively - understood, but may be used for pre-negotiated exchange of message attributes - between systems. - """ - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - - name: str = Field( - ..., - description="A name for the Extension. Should be indicative of its meaning and/or the type of information it value represents.", - ) - value: int | float | str | bool | dict[str, Any] | list[Any] | None = Field( - ..., - description="The value of the Extension - can be any primitive or structured object", - ) - description: str | None = Field( - default=None, - description="A description of the meaning or utility of the Extension, to explain the type of information it is meant to hold.", - ) - - -class MappableConcept(GKSCoreMetadataMixin, Entity, BaseModelForbidExtra): +class MappableConcept(Entity, BaseModelForbidExtra): """A concept based on a primaryCoding and/or name that may be mapped to one or more other `Codings`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE diff --git a/src/ga4gh/core/version.py b/src/ga4gh/core/version.py index 982aa6c9..40dad489 100644 --- a/src/ga4gh/core/version.py +++ b/src/ga4gh/core/version.py @@ -1,3 +1,3 @@ """Define GKM-Core version""" -CORE_VERSION = "1.2.0" +CORE_VERSION = "1.3.0-ballot.2026-09.1" diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index b836d9c1..2b1ed69b 100644 --- a/src/ga4gh/vrs/models.py +++ b/src/ga4gh/vrs/models.py @@ -44,7 +44,7 @@ Entity, iriReference, ) -from ga4gh.core.metadata import GKSMetadataMixin, Maturity +from ga4gh.core.metadata import GKMMetadataMixin, Maturity from ga4gh.core.pydantic import get_pydantic_root, getattr_in from ga4gh.vrs.version import VRS_VERSION @@ -263,7 +263,7 @@ def _recurse_ga4gh_serialize(obj): return obj -class VRSMetadataMixin(GKSMetadataMixin): +class VRSMetadataMixin(GKMMetadataMixin): """Provide metadata for a concrete VRS model.""" _product_name = "vrs" @@ -293,13 +293,14 @@ def is_ga4gh_identifiable() -> bool: return False -class Ga4ghIdentifiableObject(_ValueObject, ABC): +class Ga4ghIdentifiableObject(VRSMetadataMixin, _ValueObject, ABC): """A contextual value object for which a GA4GH computed identifier can be created. All GA4GH Identifiable Objects may have computed digests from the VRS Computed Identifier algorithm. """ _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True type: str digest: ( @@ -611,7 +612,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["refgetAccession", "type"] -class SequenceLocation(VRSMetadataMixin, Ga4ghIdentifiableObject, BaseModelForbidExtra): +class SequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): """A `Location` defined by an interval on a `Sequence`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -748,9 +749,7 @@ class ga4gh(_ValueObject.ga4gh): ] -class RelativeSequenceLocation( - VRSMetadataMixin, Ga4ghIdentifiableObject, BaseModelForbidExtra -): +class RelativeSequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): """A location on a base sequence and its position relative to a boundary offset on a mapped sequence gap. Typically used to describe intronic locations that exist with respect to a mapped RNA transcript sequence. @@ -776,22 +775,17 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -# base variation +# vrs molecular variation ######################################### class _VariationBase(Ga4ghIdentifiableObject, ABC): - """Base class for variation""" + """Base class for variation.""" expressions: list[Expression] | None = None -######################################### -# vrs molecular variation -######################################### - - -class Allele(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class Allele(_VariationBase, BaseModelForbidExtra): """The state of a molecule at a `Location`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -837,7 +831,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["location", "state", "type"] -class RelativeAllele(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class RelativeAllele(_VariationBase, BaseModelForbidExtra): """An Allele defined on a mapped location relative to a base location. Often used to describe intronic variants.""" _maturity: ClassVar[Maturity] = Maturity.DRAFT @@ -868,7 +862,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["mappedState", "baseState", "relativeLocation", "type"] -class CisPhasedBlock(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class CisPhasedBlock(_VariationBase, BaseModelForbidExtra): """An ordered set of co-occurring `Variation` on the same molecule.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -902,7 +896,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -class Adjacency(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class Adjacency(_VariationBase, BaseModelForbidExtra): """The `Adjacency` class represents the adjoining of the end of a sequence with the beginning of an adjacent sequence, potentially with an intervening linker sequence. """ @@ -948,7 +942,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["adjoinedSequences", "linker", "type"] -class Terminus(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class Terminus(_VariationBase, BaseModelForbidExtra): """The `Terminus` data class provides a structure for describing the end (terminus) of a sequence. Structurally similar to Adjacency but the linker sequence is not allowed and it removes the unnecessary array structure. @@ -995,7 +989,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["component", "orientation", "type"] -class DerivativeMolecule(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class DerivativeMolecule(_VariationBase, BaseModelForbidExtra): """The "Derivative Molecule" data class is a structure for describing a derivate molecule composed from multiple sequence components. """ @@ -1028,7 +1022,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 ######################################### -class CopyNumberCount(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class CopyNumberCount(_VariationBase, BaseModelForbidExtra): """The absolute count of discrete copies of a `Location`, within a system (e.g. genome, cell, etc.). """ @@ -1052,7 +1046,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 inherent = ["copies", "location", "type"] -class CopyNumberChange(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class CopyNumberChange(_VariationBase, BaseModelForbidExtra): """An assessment of the copy number of a `Location` within a system (e.g. genome, cell, etc.) relative to a baseline ploidy. """ @@ -1080,14 +1074,15 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -# vrs kinds of variation, expression, and location +# Sealed-union adapters ######################################### class MolecularVariation(VRSMetadataMixin, RootModel): - """A `variation` on a contiguous molecule.""" + """A `Variation` on a contiguous molecule.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True root: ( Allele @@ -1107,6 +1102,7 @@ class SequenceExpression(VRSMetadataMixin, RootModel): """An expression describing a `Sequence`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True root: LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression = ( Field( @@ -1121,6 +1117,7 @@ class Location(VRSMetadataMixin, RootModel): """A contiguous segment of a biological sequence.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True root: SequenceLocation | RelativeSequenceLocation = Field( ..., @@ -1135,9 +1132,11 @@ class Variation(VRSMetadataMixin, RootModel): """A representation of the state of one or more biomolecules.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True root: ( Allele + | RelativeAllele | CisPhasedBlock | Adjacency | Terminus @@ -1159,6 +1158,7 @@ class SystemicVariation(VRSMetadataMixin, RootModel): """ _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True root: CopyNumberChange | CopyNumberCount = Field( ..., diff --git a/src/ga4gh/vrs/version.py b/src/ga4gh/vrs/version.py index 00b1afa9..46324e73 100644 --- a/src/ga4gh/vrs/version.py +++ b/src/ga4gh/vrs/version.py @@ -1,3 +1,3 @@ """Define VRS version""" -VRS_VERSION = "2.1.0" +VRS_VERSION = "2.1.1-ballot.2026-09.1" diff --git a/submodules/vrs b/submodules/vrs index cf33bfa7..220a16e8 160000 --- a/submodules/vrs +++ b/submodules/vrs @@ -1 +1 @@ -Subproject commit cf33bfa7618011087655d5a5898e518c9d96bcdb +Subproject commit 220a16e843ae2be345c10f67ade66a9aa7292223 diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py index 5088531d..e868af64 100644 --- a/tests/validation/test_model_metadata.py +++ b/tests/validation/test_model_metadata.py @@ -1,46 +1,71 @@ -"""Test model metadata against the GKS source and JSON schemas.""" +"""Test model metadata against the GKM JSON schemas.""" import json from pathlib import Path import pytest -import yaml +from pydantic import RootModel from ga4gh.core import core_models -from ga4gh.core.metadata import Maturity +from ga4gh.core.metadata import ( + GKMMaturityMixin, + GKMMetadataMixin, + GKMSchemaMixin, + GKSMaturityMixin, + GKSMetadataMixin, + GKSSchemaMixin, + Maturity, +) from ga4gh.vrs import models as vrs_models SUBMODULES_DIR = Path(__file__).parents[2] / "submodules" / "vrs" SCHEMAS = ( ( core_models, - SUBMODULES_DIR - / "submodules" - / "gkm-core" - / "schema" - / "gkm-core" - / "gkm-core-source.yaml", SUBMODULES_DIR / "submodules" / "gkm-core" / "schema" / "gkm-core" / "json", ), ( vrs_models, - SUBMODULES_DIR / "schema" / "vrs" / "vrs-source.yaml", SUBMODULES_DIR / "schema" / "vrs" / "json", ), ) +@pytest.mark.parametrize( + ("deprecated_model", "canonical_model"), + [ + (GKSMaturityMixin, GKMMaturityMixin), + (GKSSchemaMixin, GKMSchemaMixin), + (GKSMetadataMixin, GKMMetadataMixin), + (core_models.GKSCoreMetadataMixin, core_models.GKMCoreMetadataMixin), + ], +) +def test_gks_models_are_deprecated(deprecated_model, canonical_model): + """GKS model names remain available as deprecated aliases.""" + with pytest.deprecated_call(): + deprecated_model() + assert issubclass(deprecated_model, canonical_model) + + def _concrete_model_params(): - """Return concrete model metadata discovered from JSON Schema files.""" + """Return concrete model metadata discovered from JSON Schema files. + + :returns: Pytest parameters for concrete GKM models. + """ params = [] - for model_module, _, json_dir in SCHEMAS: + for model_module, json_dir in SCHEMAS: schema_params = [] for schema_path in sorted(json_dir.iterdir()): model = getattr(model_module, schema_path.name, None) if model is None: continue # date and datetime use standard-library classes + with schema_path.open() as schema_file: schema = json.load(schema_file) + + if schema.get("abstract") is True: + continue + schema_params.append(pytest.param(model, schema, id=schema["title"])) assert schema_params, f"No concrete models discovered in {json_dir}" params.extend(schema_params) @@ -48,26 +73,42 @@ def _concrete_model_params(): def _abstract_model_params(): - """Return abstract model metadata found only in source schemas.""" + """Return abstract model metadata from JSON Schema files. + + :returns: Pytest parameters for abstract GKM models and JSON definitions. + """ params = [] - for model_module, source_path, json_dir in SCHEMAS: + for model_module, json_dir in SCHEMAS: schema_params = [] - with source_path.open() as source_file: - definitions = yaml.safe_load(source_file)["$defs"] - concrete_names = {path.name for path in json_dir.iterdir()} - for name, definition in definitions.items(): - if name not in concrete_names and "heritableProperties" in definition: + for schema_path in sorted(json_dir.iterdir()): + with schema_path.open() as schema_file: + definition = json.load(schema_file) + if definition.get("abstract") is True: + model = getattr(model_module, schema_path.name, None) + if model is None: + continue # abstract schema with no corresponding Pydantic model + schema_params.append( - pytest.param(getattr(model_module, name), definition, id=name) + pytest.param( + model, + definition, + id=schema_path.name, + ) ) - assert schema_params, f"No abstract models discovered in {source_path}" + + assert schema_params, f"No abstract models discovered in {json_dir}" + params.extend(schema_params) return params @pytest.mark.parametrize(("model", "schema"), _concrete_model_params()) def test_concrete_model_metadata(model, schema): - """Concrete model metadata matches its generated JSON Schema.""" + """Verify concrete model metadata matches generated JSON Schema. + + :param model: Concrete Pydantic model. + :param schema: Corresponding generated JSON Schema. + """ assert model.schema_id() == schema["$id"] assert model.maturity() == Maturity(schema["maturity"]) generated_schema = model.model_json_schema() @@ -84,7 +125,80 @@ def test_concrete_model_metadata(model, schema): @pytest.mark.parametrize(("model", "definition"), _abstract_model_params()) def test_abstract_model_metadata(model, definition): - """Abstract models expose source-defined maturity but no schema identifier.""" + """Verify abstract models expose JSON Schema metadata. + + :param model: Abstract Pydantic model. + :param definition: Corresponding JSON Schema definition. + """ assert "_maturity" in model.__dict__ assert model.maturity() == Maturity(definition["maturity"]) - assert not hasattr(model, "schema_id") + generated_schema = model.model_json_schema() + assert generated_schema["$id"] == definition["$id"] + assert generated_schema["maturity"] == definition["maturity"] + assert generated_schema["abstract"] is True + if issubclass(model, RootModel): + # These are public compatibility adapters for the former sealed unions. + # Pydantic adds a discriminator mapping and local $defs references, whereas + # the published abstract schemas use portable references. + assert generated_schema["discriminator"]["propertyName"] == "type" + + # Compare union membership, not just cardinality, so a wrong or missing + # member (e.g. RelativeAllele) is caught. Both schemas use $ref entries + # whose trailing path segment is the member class name. + def _member_names(schema_obj): + return {entry["$ref"].rsplit("/", 1)[-1] for entry in schema_obj["oneOf"]} + + assert _member_names(generated_schema) == _member_names(definition) + else: + assert generated_schema.get("discriminator") == definition.get("discriminator") + assert generated_schema.get("oneOf") == definition.get("oneOf") + + +@pytest.mark.parametrize( + ("model", "member", "payload"), + [ + ( + vrs_models.Variation, + vrs_models.CopyNumberChange, + { + "type": "CopyNumberChange", + "location": "ga4gh:VSL.test", + "copyChange": "loss", + }, + ), + ( + vrs_models.MolecularVariation, + vrs_models.Allele, + { + "type": "Allele", + "location": "ga4gh:VSL.test", + "state": {"type": "LiteralSequenceExpression", "sequence": "A"}, + }, + ), + ( + vrs_models.SystemicVariation, + vrs_models.CopyNumberCount, + {"type": "CopyNumberCount", "location": "ga4gh:VSL.test", "copies": 2}, + ), + ( + vrs_models.SequenceExpression, + vrs_models.LiteralSequenceExpression, + {"type": "LiteralSequenceExpression", "sequence": "A"}, + ), + ( + vrs_models.Location, + vrs_models.SequenceLocation, + { + "type": "SequenceLocation", + "sequenceReference": "SQ.test", + "start": 1, + "end": 2, + }, + ), + ], +) +def test_abstract_vrs_models_dispatch_typed_payloads(model, member, payload): + """Abstract VRS models dispatch typed payloads to their concrete members.""" + result = model.model_validate(payload) + assert isinstance(result.root, member) + assert isinstance(model(root=payload).root, member) diff --git a/tests/validation/test_models.py b/tests/validation/test_models.py index e126a5b5..7371bf4a 100644 --- a/tests/validation/test_models.py +++ b/tests/validation/test_models.py @@ -134,9 +134,9 @@ def test_valid_types(): for enum_val in VrsType.__members__.values(): enum_val = enum_val.value if hasattr(models, enum_val): - gks_class = getattr(models, enum_val) + gkm_class = getattr(models, enum_val) try: - assert gks_class(type=enum_val) + assert gkm_class(type=enum_val) except ValidationError as e: found_type_mismatch = False for error in e.errors(): diff --git a/tests/validation/test_schemas.py b/tests/validation/test_schemas.py index 322a30dc..6908be70 100644 --- a/tests/validation/test_schemas.py +++ b/tests/validation/test_schemas.py @@ -1,4 +1,4 @@ -"""Test that VRS-Python Pydantic models match VRS and GKS-Common schemas""" +"""Test that VRS-Python Pydantic models match VRS and GKM-Core schemas""" import json from enum import Enum @@ -11,15 +11,15 @@ from ga4gh.vrs import models as vrs_models -class GKSSchema(str, Enum): - """Enum for GKS schema""" +class GKMSchema(str, Enum): + """Enum for GKM schema""" VRS = "vrs" CORE = "core" -class GKSSchemaMapping(BaseModel): - """Model for representing GKS Schema concrete classes, primitives, and schema""" +class GKMSchemaMapping(BaseModel): + """Model for representing GKM Schema concrete classes, primitives, and schema""" base_classes: set = set() concrete_classes: set = set() @@ -27,55 +27,55 @@ class GKSSchemaMapping(BaseModel): schema_name: dict = {} -def _update_gks_schema_mapping( - f_path: Path, gks_schema_mapping: GKSSchemaMapping +def _update_gkm_schema_mapping( + f_path: Path, gkm_schema_mapping: GKMSchemaMapping ) -> None: - """Update ``gks_schema_mapping`` properties + """Update ``gkm_schema_mapping`` properties :param f_path: Path to JSON Schema file - :param gks_schema_mapping: GKS schema mapping to update + :param gkm_schema_mapping: GKM schema mapping to update """ with f_path.open() as rf: cls_def = json.load(rf) spec_class = cls_def["title"] - gks_schema_mapping.schema_name[spec_class] = cls_def + gkm_schema_mapping.schema_name[spec_class] = cls_def - if "properties" in cls_def: - gks_schema_mapping.concrete_classes.add(spec_class) + if "properties" in cls_def and not cls_def.get("abstract"): + gkm_schema_mapping.concrete_classes.add(spec_class) elif cls_def.get("type") in {"array", "integer", "string"}: - gks_schema_mapping.primitives.add(spec_class) + gkm_schema_mapping.primitives.add(spec_class) else: - gks_schema_mapping.base_classes.add(spec_class) + gkm_schema_mapping.base_classes.add(spec_class) -GKS_SCHEMA_MAPPING = {gks: GKSSchemaMapping() for gks in GKSSchema} +GKM_SCHEMA_MAPPING = {gkm: GKMSchemaMapping() for gkm in GKMSchema} SUBMODULES_DIR = Path(__file__).parents[2] / "submodules" / "vrs" # Get vrs classes -vrs_mapping = GKS_SCHEMA_MAPPING[GKSSchema.VRS] +vrs_mapping = GKM_SCHEMA_MAPPING[GKMSchema.VRS] for f in (SUBMODULES_DIR / "schema" / "vrs" / "json").glob("*"): - _update_gks_schema_mapping(f, vrs_mapping) + _update_gkm_schema_mapping(f, vrs_mapping) # Get core classes -core_mapping = GKS_SCHEMA_MAPPING[GKSSchema.CORE] +core_mapping = GKM_SCHEMA_MAPPING[GKMSchema.CORE] for f in ( SUBMODULES_DIR / "submodules" / "gkm-core" / "schema" / "gkm-core" / "json" ).glob("*"): - _update_gks_schema_mapping(f, core_mapping) + _update_gkm_schema_mapping(f, core_mapping) @pytest.mark.parametrize( - ("gks_schema", "pydantic_models"), + ("gkm_schema", "pydantic_models"), [ - (GKSSchema.VRS, vrs_models), - (GKSSchema.CORE, core_models), + (GKMSchema.VRS, vrs_models), + (GKMSchema.CORE, core_models), ], ) -def test_schema_models_in_pydantic(gks_schema, pydantic_models): +def test_schema_models_in_pydantic(gkm_schema, pydantic_models): """Ensure that each schema model has corresponding Pydantic model""" - mapping = GKS_SCHEMA_MAPPING[gks_schema] + mapping = GKM_SCHEMA_MAPPING[gkm_schema] for schema_model in ( mapping.base_classes | mapping.concrete_classes | mapping.primitives ): @@ -87,17 +87,17 @@ def test_schema_models_in_pydantic(gks_schema, pydantic_models): @pytest.mark.parametrize( - ("gks_schema", "pydantic_models"), + ("gkm_schema", "pydantic_models"), [ - (GKSSchema.VRS, vrs_models), - (GKSSchema.CORE, core_models), + (GKMSchema.VRS, vrs_models), + (GKMSchema.CORE, core_models), ], ) -def test_schema_class_fields(gks_schema, pydantic_models): +def test_schema_class_fields(gkm_schema, pydantic_models): """Check that each schema model properties exist and are required in corresponding Pydantic model, and validate required properties """ - mapping = GKS_SCHEMA_MAPPING[gks_schema] + mapping = GKM_SCHEMA_MAPPING[gkm_schema] for schema_model in mapping.concrete_classes: schema_properties = mapping.schema_name[schema_model]["properties"] pydantic_model = getattr(pydantic_models, schema_model) @@ -134,15 +134,15 @@ def test_schema_class_fields(gks_schema, pydantic_models): @pytest.mark.parametrize( - ("gks_schema", "pydantic_models"), + ("gkm_schema", "pydantic_models"), [ - (GKSSchema.VRS, vrs_models), - (GKSSchema.CORE, core_models), + (GKMSchema.VRS, vrs_models), + (GKMSchema.CORE, core_models), ], ) -def test_ga4gh_keys(gks_schema, pydantic_models): +def test_ga4gh_keys(gkm_schema, pydantic_models): """Ensure ga4gh inherent defined in schema model exist in corresponding Pydantic model""" - mapping = GKS_SCHEMA_MAPPING[gks_schema] + mapping = GKM_SCHEMA_MAPPING[gkm_schema] for schema_model in mapping.concrete_classes: if ( mapping.schema_name[schema_model].get("ga4gh", {}).get("inherent", None) From d4baa1858cee01c6cc2907fe476d4fdb95b64f63 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Wed, 30 Sep 2026 18:52:15 +0800 Subject: [PATCH 15/18] fix: remove misleading ABC markers (#661) _abstract is metadata for MSP (to allow additional properties) --- src/ga4gh/core/models.py | 15 ++++----------- src/ga4gh/vrs/models.py | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index c71b294b..b96fac13 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC from enum import Enum from typing import Annotated, Any, ClassVar, Literal @@ -120,11 +119,8 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Element(GKMCoreMetadataMixin, BaseModel, ABC): - """The base definition for all identifiable data objects. - - Abstract base class to be extended by other classes. Do NOT instantiate directly. - """ +class Element(GKMCoreMetadataMixin, BaseModel): + """The base definition for all identifiable data objects.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE _abstract: ClassVar[bool] = True @@ -174,11 +170,8 @@ class Extension(Element, BaseModelForbidExtra): ) -class Entity(GKMCoreMetadataMixin, BaseModel, ABC): - """Anything that exists, has existed, or will exist. - - Abstract base class to be extended by other classes. Do NOT instantiate directly. - """ +class Entity(GKMCoreMetadataMixin, BaseModel): + """Anything that exists, has existed, or will exist.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE _abstract: ClassVar[bool] = True diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index 2b1ed69b..8e990dc2 100644 --- a/src/ga4gh/vrs/models.py +++ b/src/ga4gh/vrs/models.py @@ -293,7 +293,7 @@ def is_ga4gh_identifiable() -> bool: return False -class Ga4ghIdentifiableObject(VRSMetadataMixin, _ValueObject, ABC): +class Ga4ghIdentifiableObject(VRSMetadataMixin, _ValueObject): """A contextual value object for which a GA4GH computed identifier can be created. All GA4GH Identifiable Objects may have computed digests from the VRS Computed Identifier algorithm. From ca12a4fd60ce52f23f627c21c072bc7527519abf Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 1 Oct 2026 12:31:31 +0800 Subject: [PATCH 16/18] feat: update vrs version to 2.1.1 (#662) * GKM-Core version 1.3.0 * Also fixes constraints for `ConceptSet.concepts` --- .gitmodules | 2 +- src/ga4gh/core/models.py | 2 +- src/ga4gh/core/version.py | 2 +- src/ga4gh/vrs/version.py | 2 +- submodules/vrs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitmodules b/.gitmodules index 00bca507..2981416e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/vrs"] path = submodules/vrs url = https://github.com/ga4gh/vrs.git - branch = 2.1.1-ballot.2026-09 + branch = 2.1 diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index b96fac13..6cb0cf3b 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -265,7 +265,7 @@ class ConceptSet(Entity, BaseModelForbidExtra): default=None, description="A term indicating the type of concept being represented by the ConceptSet.", ) - concepts: list[MappableConcept] | list[ConceptSet] = Field( + concepts: list[MappableConcept | ConceptSet | iriReference] = Field( ..., description="A list of concepts that are dependent (occurring together), or independent (existing separately), depending on the membership operator.", min_length=2, diff --git a/src/ga4gh/core/version.py b/src/ga4gh/core/version.py index 40dad489..979d401a 100644 --- a/src/ga4gh/core/version.py +++ b/src/ga4gh/core/version.py @@ -1,3 +1,3 @@ """Define GKM-Core version""" -CORE_VERSION = "1.3.0-ballot.2026-09.1" +CORE_VERSION = "1.3.0" diff --git a/src/ga4gh/vrs/version.py b/src/ga4gh/vrs/version.py index 46324e73..c9499152 100644 --- a/src/ga4gh/vrs/version.py +++ b/src/ga4gh/vrs/version.py @@ -1,3 +1,3 @@ """Define VRS version""" -VRS_VERSION = "2.1.1-ballot.2026-09.1" +VRS_VERSION = "2.1.1" diff --git a/submodules/vrs b/submodules/vrs index 220a16e8..8e2bcd3f 160000 --- a/submodules/vrs +++ b/submodules/vrs @@ -1 +1 @@ -Subproject commit 220a16e843ae2be345c10f67ade66a9aa7292223 +Subproject commit 8e2bcd3fab401e0f55518d5590fa7c757868ae78 From 21709c530792510d0979ad18bf09613dc4c89438 Mon Sep 17 00:00:00 2001 From: Novikov Kirill <75641500+knQzx@users.noreply.github.com> Date: Fri, 2 Oct 2026 05:44:53 +0200 Subject: [PATCH 17/18] fix: detect intronic RNA (r.) variants in is_intronic (#642) `is_intronic` only recognized introns on coding (c.) coordinates because it gated on the interval being a `BaseOffsetInterval`. RNA (r.) coordinates parse to a plain `Interval` whose endpoints are still `BaseOffsetPosition`, so intronic r. variants slipped past the "Intronic HGVS variants are not supported" guard inspect the interval endpoints instead of the interval type. g./p. coordinates have no base-offset positions and stay non-intronic closes #628 --------- Co-authored-by: Kyle Ferriter --- src/ga4gh/vrs/utils/hgvs_tools.py | 15 ++++++++--- tests/extras/test_hgvs_tools.py | 42 +++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 tests/extras/test_hgvs_tools.py diff --git a/src/ga4gh/vrs/utils/hgvs_tools.py b/src/ga4gh/vrs/utils/hgvs_tools.py index 503d4022..42d81f86 100644 --- a/src/ga4gh/vrs/utils/hgvs_tools.py +++ b/src/ga4gh/vrs/utils/hgvs_tools.py @@ -5,6 +5,7 @@ import hgvs import hgvs.dataproviders.uta +import hgvs.location import hgvs.normalizer import hgvs.parser import hgvs.variantmapper @@ -77,9 +78,17 @@ def is_intronic(self, sv: HgvsSequenceVariant) -> bool: bool: True if the SequenceVariant is intronic, False otherwise. """ - if isinstance(sv.posedit.pos, hgvs.location.BaseOffsetInterval): - return sv.posedit.pos.start.is_intronic or sv.posedit.pos.end.is_intronic - return False + # Coding (c.) intervals are BaseOffsetInterval, but RNA (r.) intervals + # are plain Interval even when their endpoints carry a base-offset + # position, so inspect the endpoints directly rather than the interval + # type. Non-offset coordinates (g./p.) use position types without + # is_intronic and are correctly reported as not intronic. + pos = sv.posedit.pos + start = getattr(pos, "start", None) + end = getattr(pos, "end", None) + return ( + isinstance(start, hgvs.location.BaseOffsetPosition) and start.is_intronic + ) or (isinstance(end, hgvs.location.BaseOffsetPosition) and end.is_intronic) def get_edit_type(self, sv: HgvsSequenceVariant) -> str | None: """Safely extract ``type`` property from SequenceVariant""" diff --git a/tests/extras/test_hgvs_tools.py b/tests/extras/test_hgvs_tools.py new file mode 100644 index 00000000..fde53e60 --- /dev/null +++ b/tests/extras/test_hgvs_tools.py @@ -0,0 +1,42 @@ +"""Tests for ga4gh.vrs.utils.hgvs_tools.""" + +import hgvs.parser +import pytest + +from ga4gh.vrs.utils.hgvs_tools import HgvsTools + + +@pytest.fixture(scope="module") +def hgvs_tools(): + # is_intronic only parses expressions, so build HgvsTools without the + # network-dependent __init__ (which opens a UTA connection). + tools = HgvsTools.__new__(HgvsTools) + tools.parser = hgvs.parser.Parser() + return tools + + +@pytest.mark.parametrize( + ("hgvs_expr", "expected"), + [ + # coding (c.) + ("NM_000000.1:c.76-1G>A", True), # intronic (acceptor side) + ("NM_000000.1:c.87+1A>T", True), # intronic (donor side) + ("NM_000000.1:c.100A>T", False), # exonic + # RNA (r.) - previously missed: the interval is a plain Interval, not a + # BaseOffsetInterval, so the old isinstance check returned False + ("NR_000000.1:r.76-1g>a", True), # intronic (acceptor side) + ("NR_000000.1:r.77+1g>a", True), # intronic (donor side) + ("NR_000000.1:r.100a>u", False), # exonic + # RNA (r.) exonic - real-world ClinVar pins from issue #628 (offset 0) + ("NR_001566.1:r.398_399del", False), + ("NR_001566.1:r.245del", False), + ("NM_001374385.1:r.2843_2931del", False), + ("NM_001323289.2:r.2632c>a", False), + # genomic (g.) - no base-offset positions, never intronic + ("NC_000001.11:g.100A>T", False), + ], +) +def test_is_intronic(hgvs_tools, hgvs_expr, expected): + sv = hgvs_tools.parse(hgvs_expr) + assert sv is not None + assert hgvs_tools.is_intronic(sv) is expected From 9bcf024ffcdb0bd41b07fca816f349c1ceadbede Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Mon, 19 Oct 2026 14:25:58 -0400 Subject: [PATCH 18/18] test: remove generated files --- tests/extras/data/test_rle_output.pkl | Bin 1301 -> 0 bytes tests/extras/data/test_rle_output.vcf | 17 ----------------- 2 files changed, 17 deletions(-) delete mode 100644 tests/extras/data/test_rle_output.pkl delete mode 100644 tests/extras/data/test_rle_output.vcf diff --git a/tests/extras/data/test_rle_output.pkl b/tests/extras/data/test_rle_output.pkl deleted file mode 100644 index 5fc8dcc522748831d25f1417027680ab6f39f54f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1301 zcmdVY-*3`T6bEo&g%FL|<`zYLS+bbu!;l}{7`|O#v$i0VGNAZCTDbI7OUv3@KoS%G zfctn&{8xRm1*%(oVA-2}=}qss-*fW0{m%V(n0T;yzOmQzjiK=dyz8KhH5dG{iJE#X zn_s+#xm)*p{FRIZGO<2%=ZJ;nadX5VMmh3ziWyBJ%feQ;7?}q42%|?^Q)tR^h8i&) zI}C)KXac2{;r;r|X{ma_Yn8H6Wzqi9lg3L~kY|_9Ia!`C;OL%$uYGI}aV<)LBt6=C zlJ0w(b1gi^ZWyp?&(ZxT@b3oQb@AB11PzDS^TJ8#u_9+w?X2Y$s{&N9QB<{tV&pEI zk7nU&V9O#u1l_^*h>4$q<$8leEua5sRl~MH;9@a#qee+<^@&Mw znu#CX2QHpsjVQkE+a_5b4eES9A`C9k&0cp;Mf+^R=R1N`)pJ_ovI6aLSyu+d zCFIuT@Deykgm7EyA$5;if=73{6nGqFbFd40cLatfD_S^Mv%=vz;3+(lVvwdW#NdS# zyCs5}wmR-lWHMFy<*a1(Rrs8r8zZeKv>mr=ZA4y0B3tYK70K~@~ diff --git a/tests/extras/data/test_rle_output.vcf b/tests/extras/data/test_rle_output.vcf deleted file mode 100644 index bd4df902..00000000 --- a/tests/extras/data/test_rle_output.vcf +++ /dev/null @@ -1,17 +0,0 @@ -##fileformat=VCFv4.2 -##FILTER= -##reference=GRCh38 -##contig= -##INFO= -##INFO= -##INFO= -##INFO= -##INFO= -##INFO= -##INFO= -##INFO= -##INFO= -##INFO= -#CHROM POS ID REF ALT QUAL FILTER INFO -1 100210778 . AA A . . VARID=1663061;VARNAME=NM_001918.5(DBT):c.940-7del;VARTYPE=Deletion;VRS_Allele_IDs=ga4gh:VA.lwHuBiAPbL02xGhwrMuX4UQAHtmlIWwA,ga4gh:VA.r0qW5Jn6m42VHlDb1aOFChTBBbS_7zh1;VRS_Starts=100210777,100210777;VRS_Ends=100210779,100210779;VRS_States=AA,A;VRS_Lengths=2,1;VRS_RepeatSubunitLengths=2,1 -1 102995989 . CTTT CTTTCTTT . . VARID=3727769;VARNAME=NM_001854.4(COL11A1):c.2292_2295dup;VARTYPE=Duplication;VRS_Allele_IDs=ga4gh:VA.APnYfZmQtpSZB0KiZYhj2wgda7HTorWl,ga4gh:VA.WXxaJ2ZsP_XfDma8a-nBi_VxMe9V20zJ;VRS_Starts=102995988,102995988;VRS_Ends=102995992,102995992;VRS_States=CTTT,CTTTCTTT;VRS_Lengths=4,8;VRS_RepeatSubunitLengths=4,4