From 1391dce861bd0a2d7d98e9e44cd86d77169ef572 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 24 Sep 2026 14:58:43 -0400 Subject: [PATCH 1/6] feat!: update models vrs 2.1.1-ballot.2026-09.1 * core version: 1.3.0-ballot.2026-09.1 --- .gitmodules | 2 +- src/ga4gh/core/metadata.py | 61 +++++--- src/ga4gh/core/models.py | 39 ++++- src/ga4gh/core/version.py | 2 +- src/ga4gh/vrs/extras/translator.py | 49 +++++-- src/ga4gh/vrs/models.py | 186 +++++++++--------------- src/ga4gh/vrs/version.py | 2 +- submodules/vrs | 2 +- tests/validation/test_model_metadata.py | 71 ++++++++- tests/validation/test_schemas.py | 2 +- 10 files changed, 250 insertions(+), 166 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/src/ga4gh/core/metadata.py b/src/ga4gh/core/metadata.py index 1945fe44..ee2708bf 100644 --- a/src/ga4gh/core/metadata.py +++ b/src/ga4gh/core/metadata.py @@ -40,28 +40,28 @@ def schema_id(cls) -> str: class GKSMetadataMixin(GKSMaturityMixin, GKSSchemaMixin): - """Provide maturity and schema metadata for a concrete GKS model.""" + """Provide maturity and schema metadata for a 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", + _abstract: ClassVar[bool] = False + + @staticmethod + def apply_schema_metadata( + model_class: type, schema: dict[str, Any] ) -> 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, - ) + """Add GKS metadata to a generated JSON Schema. - schema["$id"] = cls.schema_id() - schema["maturity"] = cls.maturity().value + :param model_class: Pydantic model class that produced the schema. + :param schema: Generated JSON Schema to annotate. + :returns: The annotated JSON Schema. + """ + schema["$id"] = model_class.schema_id() + schema["maturity"] = model_class.maturity().value - ga4gh_class = getattr(cls, "ga4gh", None) + if model_class.__dict__.get("_abstract", False): + schema["abstract"] = True + + # GA4GH identifier metadata is optional and applies only when declared. + ga4gh_class = getattr(model_class, "ga4gh", None) if not ga4gh_class: return schema @@ -77,3 +77,28 @@ 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", + ) -> dict[str, Any]: + """Generate JSON Schema with GKS metadata. + + :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. + :returns: JSON Schema annotated with GKS metadata. + """ + schema = super().model_json_schema( + by_alias=by_alias, + ref_template=ref_template, + schema_generator=schema_generator, + mode=mode, + ) + + return cls.apply_schema_metadata(cls, schema) diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index 571cb22d..e4644e96 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -17,7 +17,7 @@ from typing_extensions import Self from ga4gh.core.identifiers import GA4GH_IR_REGEXP -from ga4gh.core.metadata import GKSMaturityMixin, GKSMetadataMixin, Maturity +from ga4gh.core.metadata import GKSMetadataMixin, Maturity from ga4gh.core.version import CORE_VERSION @@ -34,6 +34,23 @@ class BaseModelForbidExtra(BaseModel): model_config = ConfigDict(extra="forbid") +class _AbstractGKSModel(GKSCoreMetadataMixin, BaseModel, ABC): + """Provide common runtime behavior for abstract GKS models.""" + + @model_validator(mode="after") + def require_concrete_model(self) -> Self: + """Reject direct construction of an abstract model. + + :raises ValueError: If an abstract model is instantiated directly. + :returns: The validated concrete model. + """ + if type(self).__dict__.get("_abstract", False): + msg = f"{type(self).__name__} is abstract and cannot be instantiated directly." + raise ValueError(msg) + + return self + + class Relation(str, Enum): """A mapping relation between concepts as defined by the Simple Knowledge Organization System (SKOS). @@ -115,13 +132,14 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Entity(GKSMaturityMixin, BaseModel, ABC): +class Entity(_AbstractGKSModel): """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, @@ -144,13 +162,14 @@ class Entity(GKSMaturityMixin, BaseModel, ABC): ) -class Element(GKSMaturityMixin, BaseModel, ABC): +class Element(_AbstractGKSModel): """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, @@ -177,7 +196,7 @@ def get_extensions_by_name(self, name: str) -> list[Extension]: ######################################### -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 +222,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 +239,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 +255,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,7 +270,7 @@ class ConceptSet(GKSCoreMetadataMixin, Entity, BaseModelForbidExtra): ) -class Extension(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): +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 @@ -271,7 +294,7 @@ class Extension(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): ) -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/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 91c45532..faa5b6b2 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -30,8 +30,13 @@ class VariationToStrProtocol(Protocol): into variation strings, with optional keyword arguments for customization. """ - def __call__(self, vo: models._VariationBase, **kwargs) -> list[str]: - """Translate vrs object `vo` to variation string expressions""" + def __call__(self, vo: models.Variation, **kwargs) -> list[str]: + """Translate a VRS variation to string expressions. + + :param vo: VRS variation to translate. + :param kwargs: Translator-specific options. + :returns: Translated string expressions. + """ class VariationFromStrProtocol(Protocol): @@ -41,8 +46,13 @@ class VariationFromStrProtocol(Protocol): string into a VRS object, with optional keyword arguments for customization. """ - def __call__(self, expr: str, **kwargs) -> models._VariationBase | None: - """Translate variation string `expr` to a VRS object""" + def __call__(self, expr: str, **kwargs) -> models.Variation | None: + """Translate a string expression to a VRS variation. + + :param expr: Variation string to translate. + :param kwargs: Translator-specific options. + :returns: Translated variation, or None when the expression is unsupported. + """ class _Translator(ABC): # noqa: B024 @@ -85,7 +95,7 @@ def __init__( def translate_from( self, var: str, fmt: str | None = None, **kwargs - ) -> models._VariationBase: + ) -> models.Variation: """Translate variation `var` to VRS object If `fmt` is None, guess the appropriate format and return the variant. @@ -113,6 +123,13 @@ def translate_from( Defaults value set in instance variable, `rle_seq_limit`. do_normalize (bool): `True` if fully justified normalization should be performed. `False` otherwise. Defaults to `True` + + :param var: Variation string to translate. + :param fmt: Optional source format. + :param kwargs: Translator-specific options. + :returns: Translated VRS variation. + :raises NotImplementedError: If ``fmt`` is unsupported. + :raises ValueError: If no translator can parse the variation. """ if fmt: try: @@ -136,13 +153,17 @@ def translate_from( msg = f"Unable to parse data as {', '.join(formats)}" raise ValueError(msg) - def translate_to(self, vo: models._VariationBase, fmt: str, **kwargs) -> list[str]: + def translate_to(self, vo: models.Variation, fmt: str, **kwargs) -> list[str]: """Translate vrs object `vo` to named format `fmt` 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 + :param vo: VRS variation to translate. + :param fmt: Target format. + :param kwargs: Translator-specific options. + :returns: Translated string expressions. + :raises NotImplementedError: If ``fmt`` is unsupported. """ try: t = self.to_translators[fmt] @@ -157,11 +178,19 @@ def translate_to(self, vo: models._VariationBase, fmt: str, **kwargs) -> list[st @lazy_property def hgvs_tools(self) -> HgvsTools: - """Instantiate and return an HgvsTools instance""" + """Instantiate an HGVS translation helper. + + :returns: Helper configured with this translator's data proxy. + """ return HgvsTools(self.data_proxy) - def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noqa: ARG002 - """Convert from dict representation of VRS JSON to VRS object""" + def _from_vrs(self, var: dict, **kwargs) -> models.Variation | None: # noqa: ARG002 + """Convert a VRS JSON mapping to a VRS variation. + + :param var: VRS JSON mapping. + :param kwargs: Reserved translator-specific options. + :returns: Matching VRS variation, or None for unsupported input. + """ if not isinstance(var, Mapping): return None if "type" not in var: diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index b836d9c1..bb17feff 100644 --- a/src/ga4gh/vrs/models.py +++ b/src/ga4gh/vrs/models.py @@ -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: ( @@ -433,6 +434,36 @@ class Expression(VRSMetadataMixin, Element, BaseModelForbidExtra): ) +######################################### +# abstract VRS classes +######################################### + + +class Variation(Ga4ghIdentifiableObject, ABC): + """A representation of the state of one or more biomolecules.""" + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + + expressions: list[Expression] | None = None + + +class MolecularVariation(Variation, ABC): + """A `Variation` on a contiguous molecule.""" + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + + +class SystemicVariation(Variation, ABC): + """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 + _abstract: ClassVar[bool] = True + + ######################################### # vrs numerics, comparators, and ranges ######################################### @@ -513,7 +544,19 @@ class sequenceString(VRSMetadataMixin, RootModel): ######################################### -class LengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): +######################################### +# sequence expressions +######################################### + + +class SequenceExpression(VRSMetadataMixin, _ValueObject, ABC): + """An expression describing a sequence.""" + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + + +class LengthExpression(SequenceExpression, BaseModelForbidExtra): """A sequence expressed only by its length.""" _maturity: ClassVar[Maturity] = Maturity.DRAFT @@ -530,7 +573,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["length", "type"] -class ReferenceLengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): +class ReferenceLengthExpression(SequenceExpression, BaseModelForbidExtra): """An expression of a length of a sequence from a repeating reference.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -554,7 +597,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["length", "repeatSubunitLength", "type"] -class LiteralSequenceExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): +class LiteralSequenceExpression(SequenceExpression, BaseModelForbidExtra): """An explicit expression of a Sequence.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -611,7 +654,19 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["refgetAccession", "type"] -class SequenceLocation(VRSMetadataMixin, Ga4ghIdentifiableObject, BaseModelForbidExtra): +######################################### +# locations +######################################### + + +class Location(Ga4ghIdentifiableObject, ABC): + """A contiguous segment of a biological sequence.""" + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + + +class SequenceLocation(Location, BaseModelForbidExtra): """A `Location` defined by an interval on a `Sequence`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -748,9 +803,7 @@ class ga4gh(_ValueObject.ga4gh): ] -class RelativeSequenceLocation( - VRSMetadataMixin, Ga4ghIdentifiableObject, BaseModelForbidExtra -): +class RelativeSequenceLocation(Location, 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. @@ -775,23 +828,12 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["baseSequenceLocation", "mappedSequenceLocation", "type"] -######################################### -# base variation -######################################### - - -class _VariationBase(Ga4ghIdentifiableObject, ABC): - """Base class for variation""" - - expressions: list[Expression] | None = None - - ######################################### # vrs molecular variation ######################################### -class Allele(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class Allele(MolecularVariation, BaseModelForbidExtra): """The state of a molecule at a `Location`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -837,7 +879,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["location", "state", "type"] -class RelativeAllele(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class RelativeAllele(MolecularVariation, 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 +910,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["mappedState", "baseState", "relativeLocation", "type"] -class CisPhasedBlock(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class CisPhasedBlock(MolecularVariation, BaseModelForbidExtra): """An ordered set of co-occurring `Variation` on the same molecule.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -902,7 +944,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -class Adjacency(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class Adjacency(MolecularVariation, 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 +990,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["adjoinedSequences", "linker", "type"] -class Terminus(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class Terminus(MolecularVariation, 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 +1037,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["component", "orientation", "type"] -class DerivativeMolecule(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class DerivativeMolecule(MolecularVariation, BaseModelForbidExtra): """The "Derivative Molecule" data class is a structure for describing a derivate molecule composed from multiple sequence components. """ @@ -1028,7 +1070,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 ######################################### -class CopyNumberCount(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class CopyNumberCount(SystemicVariation, BaseModelForbidExtra): """The absolute count of discrete copies of a `Location`, within a system (e.g. genome, cell, etc.). """ @@ -1052,7 +1094,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 inherent = ["copies", "location", "type"] -class CopyNumberChange(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): +class CopyNumberChange(SystemicVariation, BaseModelForbidExtra): """An assessment of the copy number of a `Location` within a system (e.g. genome, cell, etc.) relative to a baseline ploidy. """ @@ -1079,96 +1121,6 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["copyChange", "location", "type"] -######################################### -# vrs kinds of variation, expression, and location -######################################### - - -class MolecularVariation(VRSMetadataMixin, RootModel): - """A `variation` on a contiguous molecule.""" - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - - root: ( - Allele - | RelativeAllele - | CisPhasedBlock - | Adjacency - | Terminus - | DerivativeMolecule - ) = Field( - ..., - json_schema_extra={"description": "A `variation` on a contiguous molecule."}, - discriminator="type", - ) - - -class SequenceExpression(VRSMetadataMixin, RootModel): - """An expression describing a `Sequence`.""" - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - - root: LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression = ( - Field( - ..., - json_schema_extra={"description": "An expression describing a `Sequence`."}, - discriminator="type", - ) - ) - - -class Location(VRSMetadataMixin, RootModel): - """A contiguous segment of a biological sequence.""" - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - - root: SequenceLocation | RelativeSequenceLocation = Field( - ..., - json_schema_extra={ - "description": "A contiguous segment of a biological sequence." - }, - discriminator="type", - ) - - -class Variation(VRSMetadataMixin, RootModel): - """A representation of the state of one or more biomolecules.""" - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - - root: ( - Allele - | CisPhasedBlock - | Adjacency - | Terminus - | DerivativeMolecule - | CopyNumberChange - | CopyNumberCount - ) = Field( - ..., - json_schema_extra={ - "description": "A representation of the state of one or more biomolecules." - }, - discriminator="type", - ) - - -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={ - "description": "A Variation of multiple molecules in the context of a system, e.g. a genome, sample, or homologous chromosomes." - }, - discriminator="type", - ) - - # At end so classes exist (reffable_classes, union_reffable_classes, class_refatt_map, class_inherent) = ( pydantic_class_refatt_map() 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..8ef2bd7d 100644 --- a/tests/validation/test_model_metadata.py +++ b/tests/validation/test_model_metadata.py @@ -31,7 +31,10 @@ 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 GKS models. + """ params = [] for model_module, _, json_dir in SCHEMAS: schema_params = [] @@ -39,8 +42,13 @@ def _concrete_model_params(): 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 +56,50 @@ def _concrete_model_params(): def _abstract_model_params(): - """Return abstract model metadata found only in source schemas.""" + """Return abstract model metadata found only in source schemas. + + :returns: Pytest parameters for abstract GKS models and source definitions. + """ params = [] - for model_module, source_path, json_dir in SCHEMAS: + for model_module, source_path, _ 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: + if definition.get("abstract") is True: 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 +def _abstract_schema_model_params(): + """Return abstract model metadata discovered from source schemas. + + :returns: Pytest parameters for abstract GKS models. + """ + params = [] + for model_module, source_path, _ in SCHEMAS: + with source_path.open() as source_file: + definitions = yaml.safe_load(source_file)["$defs"] + for name, definition in definitions.items(): + if definition.get("abstract") is True: + params.append(pytest.param(getattr(model_module, name), id=name)) + 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 +116,30 @@ 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 their source-defined maturity. + + :param model: Abstract Pydantic model. + :param definition: Corresponding source schema definition. + """ assert "_maturity" in model.__dict__ assert model.maturity() == Maturity(definition["maturity"]) - assert not hasattr(model, "schema_id") + + +@pytest.mark.parametrize("model", _abstract_schema_model_params()) +def test_abstract_model_schema_metadata(model): + """Verify abstract models emit the abstract schema keyword. + + :param model: Abstract Pydantic model. + """ + assert model.model_json_schema()["abstract"] is True + + +@pytest.mark.parametrize("model", _abstract_schema_model_params()) +def test_abstract_models_cannot_be_instantiated(model): + """Verify abstract models reject direct construction. + + :param model: Abstract Pydantic model. + """ + kwargs = {} if model is core_models.Element else {"type": "test"} + with pytest.raises(ValueError, match="abstract and cannot be instantiated"): + model(**kwargs) diff --git a/tests/validation/test_schemas.py b/tests/validation/test_schemas.py index 322a30dc..dcab81fa 100644 --- a/tests/validation/test_schemas.py +++ b/tests/validation/test_schemas.py @@ -41,7 +41,7 @@ def _update_gks_schema_mapping( spec_class = cls_def["title"] gks_schema_mapping.schema_name[spec_class] = cls_def - if "properties" in cls_def: + if "properties" in cls_def and not cls_def.get("abstract"): gks_schema_mapping.concrete_classes.add(spec_class) elif cls_def.get("type") in {"array", "integer", "string"}: gks_schema_mapping.primitives.add(spec_class) From 0dfd8f16cba273cdde9b2843944295f8026d2778 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 24 Sep 2026 16:56:39 -0400 Subject: [PATCH 2/6] make AbstractGKSModel public --- README.md | 2 +- src/ga4gh/core/__init__.py | 3 ++- src/ga4gh/core/models.py | 12 +++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) 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..a67c7f88 100644 --- a/src/ga4gh/core/__init__.py +++ b/src/ga4gh/core/__init__.py @@ -26,7 +26,7 @@ GKSSchemaMixin, Maturity, ) -from ga4gh.core.models import GKSCoreMetadataMixin +from ga4gh.core.models import AbstractGKSModel, GKSCoreMetadataMixin from ga4gh.core.pydantic import is_curie_type, is_pydantic_instance, pydantic_copy from ga4gh.core.version import CORE_VERSION @@ -45,6 +45,7 @@ "GA4GH_DIGEST_REGEXP", "GA4GH_IR_REGEXP", "GA4GH_PREFIX_SEP", + "AbstractGKSModel", "GKSCoreMetadataMixin", "GKSMaturityMixin", "GKSMetadataMixin", diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index e4644e96..2e9e3f0c 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 @@ -34,8 +34,10 @@ class BaseModelForbidExtra(BaseModel): model_config = ConfigDict(extra="forbid") -class _AbstractGKSModel(GKSCoreMetadataMixin, BaseModel, ABC): - """Provide common runtime behavior for abstract GKS models.""" +class AbstractGKSModel(BaseModel, ABC): + """Base class for abstract GKS models.""" + + _abstract: ClassVar[bool] = True @model_validator(mode="after") def require_concrete_model(self) -> Self: @@ -132,7 +134,7 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Entity(_AbstractGKSModel): +class Entity(GKSCoreMetadataMixin, AbstractGKSModel): """Anything that exists, has existed, or will exist. Abstract base class to be extended by other classes. Do NOT instantiate directly. @@ -162,7 +164,7 @@ class Entity(_AbstractGKSModel): ) -class Element(_AbstractGKSModel): +class Element(GKSCoreMetadataMixin, AbstractGKSModel): """The base definition for all identifiable data objects. Abstract base class to be extended by other classes. Do NOT instantiate directly. From 4c9d0563499979f400be7125624249b9b8e483a9 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Thu, 24 Sep 2026 17:04:20 -0400 Subject: [PATCH 3/6] more fixes --- src/ga4gh/core/models.py | 18 +++++++----------- tests/validation/test_model_metadata.py | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index 2e9e3f0c..46007140 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 @@ -34,23 +33,20 @@ class BaseModelForbidExtra(BaseModel): model_config = ConfigDict(extra="forbid") -class AbstractGKSModel(BaseModel, ABC): - """Base class for abstract GKS models.""" +class AbstractGKSModel(BaseModel): + """Shared base class for abstract GKS models.""" _abstract: ClassVar[bool] = True - @model_validator(mode="after") - def require_concrete_model(self) -> Self: - """Reject direct construction of an abstract model. + def __init__(self, /, **data: object) -> None: + """Initialize a concrete model. - :raises ValueError: If an abstract model is instantiated directly. - :returns: The validated concrete model. + :raises TypeError: If an abstract model is instantiated directly. """ if type(self).__dict__.get("_abstract", False): msg = f"{type(self).__name__} is abstract and cannot be instantiated directly." - raise ValueError(msg) - - return self + raise TypeError(msg) + super().__init__(**data) class Relation(str, Enum): diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py index 8ef2bd7d..6d6951fa 100644 --- a/tests/validation/test_model_metadata.py +++ b/tests/validation/test_model_metadata.py @@ -6,7 +6,7 @@ import pytest import yaml -from ga4gh.core import core_models +from ga4gh.core import AbstractGKSModel, core_models from ga4gh.core.metadata import Maturity from ga4gh.vrs import models as vrs_models @@ -55,6 +55,21 @@ def _concrete_model_params(): return params +def test_abstract_gks_model_is_a_public_base(): + """Verify the shared GKS base is exported without core metadata.""" + + class OtherGKSModel(AbstractGKSModel): + value: str + + assert AbstractGKSModel is core_models.AbstractGKSModel + + with pytest.raises(TypeError, match="abstract and cannot be instantiated"): + AbstractGKSModel() + + assert OtherGKSModel(value="test").value == "test" + assert "$id" not in OtherGKSModel.model_json_schema() + + def _abstract_model_params(): """Return abstract model metadata found only in source schemas. @@ -141,5 +156,5 @@ def test_abstract_models_cannot_be_instantiated(model): :param model: Abstract Pydantic model. """ kwargs = {} if model is core_models.Element else {"type": "test"} - with pytest.raises(ValueError, match="abstract and cannot be instantiated"): + with pytest.raises(TypeError, match="abstract and cannot be instantiated"): model(**kwargs) From c6f42b96de05b0c69e0f0089f04bcd932a61990c Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 25 Sep 2026 10:50:02 -0400 Subject: [PATCH 4/6] dont make breaking changes + gks->gkm --- src/ga4gh/core/__init__.py | 10 +- src/ga4gh/core/metadata.py | 38 +++-- src/ga4gh/core/models.py | 36 ++-- src/ga4gh/vrs/extras/translator.py | 49 ++---- src/ga4gh/vrs/models.py | 186 +++++++++++++-------- tests/validation/test_model_metadata.py | 211 +++++++++++++++--------- tests/validation/test_models.py | 4 +- tests/validation/test_schemas.py | 66 ++++---- 8 files changed, 346 insertions(+), 254 deletions(-) diff --git a/src/ga4gh/core/__init__.py b/src/ga4gh/core/__init__.py index a67c7f88..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 AbstractGKSModel, 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,7 +48,10 @@ "GA4GH_DIGEST_REGEXP", "GA4GH_IR_REGEXP", "GA4GH_PREFIX_SEP", - "AbstractGKSModel", + "GKMCoreMetadataMixin", + "GKMMaturityMixin", + "GKMMetadataMixin", + "GKMSchemaMixin", "GKSCoreMetadataMixin", "GKSMaturityMixin", "GKSMetadataMixin", diff --git a/src/ga4gh/core/metadata.py b/src/ga4gh/core/metadata.py index ee2708bf..20462e15 100644 --- a/src/ga4gh/core/metadata.py +++ b/src/ga4gh/core/metadata.py @@ -1,9 +1,10 @@ -"""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 pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode +from typing_extensions import deprecated class Maturity(str, Enum): @@ -15,19 +16,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,8 +40,8 @@ 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 GKS model.""" +class GKMMetadataMixin(GKMMaturityMixin, GKMSchemaMixin): + """Provide maturity and schema metadata for a GKM model.""" _abstract: ClassVar[bool] = False @@ -48,7 +49,7 @@ class GKSMetadataMixin(GKSMaturityMixin, GKSSchemaMixin): def apply_schema_metadata( model_class: type, schema: dict[str, Any] ) -> dict[str, Any]: - """Add GKS metadata to a generated JSON Schema. + """Add GKM metadata to a generated JSON Schema. :param model_class: Pydantic model class that produced the schema. :param schema: Generated JSON Schema to annotate. @@ -86,13 +87,13 @@ def model_json_schema( schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, mode: JsonSchemaMode = "validation", ) -> dict[str, Any]: - """Generate JSON Schema with GKS metadata. + """Generate JSON Schema with GKM metadata. :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. - :returns: JSON Schema annotated with GKS metadata. + :returns: JSON Schema annotated with GKM metadata. """ schema = super().model_json_schema( by_alias=by_alias, @@ -102,3 +103,18 @@ def model_json_schema( ) return cls.apply_schema_metadata(cls, 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 46007140..b4b33f74 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -2,6 +2,7 @@ from __future__ import annotations +from abc import ABC from enum import Enum from typing import Annotated, Any, ClassVar, Literal @@ -13,42 +14,31 @@ 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 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.""" model_config = ConfigDict(extra="forbid") -class AbstractGKSModel(BaseModel): - """Shared base class for abstract GKS models.""" - - _abstract: ClassVar[bool] = True - - def __init__(self, /, **data: object) -> None: - """Initialize a concrete model. - - :raises TypeError: If an abstract model is instantiated directly. - """ - if type(self).__dict__.get("_abstract", False): - msg = f"{type(self).__name__} is abstract and cannot be instantiated directly." - raise TypeError(msg) - super().__init__(**data) - - class Relation(str, Enum): """A mapping relation between concepts as defined by the Simple Knowledge Organization System (SKOS). @@ -80,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 @@ -98,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 `_. @@ -130,7 +120,7 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Entity(GKSCoreMetadataMixin, AbstractGKSModel): +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. @@ -160,7 +150,7 @@ class Entity(GKSCoreMetadataMixin, AbstractGKSModel): ) -class Element(GKSCoreMetadataMixin, AbstractGKSModel): +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. diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index faa5b6b2..91c45532 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -30,13 +30,8 @@ class VariationToStrProtocol(Protocol): into variation strings, with optional keyword arguments for customization. """ - def __call__(self, vo: models.Variation, **kwargs) -> list[str]: - """Translate a VRS variation to string expressions. - - :param vo: VRS variation to translate. - :param kwargs: Translator-specific options. - :returns: Translated string expressions. - """ + def __call__(self, vo: models._VariationBase, **kwargs) -> list[str]: + """Translate vrs object `vo` to variation string expressions""" class VariationFromStrProtocol(Protocol): @@ -46,13 +41,8 @@ class VariationFromStrProtocol(Protocol): string into a VRS object, with optional keyword arguments for customization. """ - def __call__(self, expr: str, **kwargs) -> models.Variation | None: - """Translate a string expression to a VRS variation. - - :param expr: Variation string to translate. - :param kwargs: Translator-specific options. - :returns: Translated variation, or None when the expression is unsupported. - """ + def __call__(self, expr: str, **kwargs) -> models._VariationBase | None: + """Translate variation string `expr` to a VRS object""" class _Translator(ABC): # noqa: B024 @@ -95,7 +85,7 @@ def __init__( def translate_from( self, var: str, fmt: str | None = None, **kwargs - ) -> models.Variation: + ) -> models._VariationBase: """Translate variation `var` to VRS object If `fmt` is None, guess the appropriate format and return the variant. @@ -123,13 +113,6 @@ def translate_from( Defaults value set in instance variable, `rle_seq_limit`. do_normalize (bool): `True` if fully justified normalization should be performed. `False` otherwise. Defaults to `True` - - :param var: Variation string to translate. - :param fmt: Optional source format. - :param kwargs: Translator-specific options. - :returns: Translated VRS variation. - :raises NotImplementedError: If ``fmt`` is unsupported. - :raises ValueError: If no translator can parse the variation. """ if fmt: try: @@ -153,17 +136,13 @@ def translate_from( msg = f"Unable to parse data as {', '.join(formats)}" raise ValueError(msg) - def translate_to(self, vo: models.Variation, fmt: str, **kwargs) -> list[str]: + def translate_to(self, vo: models._VariationBase, fmt: str, **kwargs) -> list[str]: """Translate vrs object `vo` to named format `fmt` 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). - :param vo: VRS variation to translate. - :param fmt: Target format. - :param kwargs: Translator-specific options. - :returns: Translated string expressions. - :raises NotImplementedError: If ``fmt`` is unsupported. + :raise NotImplementedError: If `fmt` is not supported """ try: t = self.to_translators[fmt] @@ -178,19 +157,11 @@ def translate_to(self, vo: models.Variation, fmt: str, **kwargs) -> list[str]: @lazy_property def hgvs_tools(self) -> HgvsTools: - """Instantiate an HGVS translation helper. - - :returns: Helper configured with this translator's data proxy. - """ + """Instantiate and return an HgvsTools instance""" return HgvsTools(self.data_proxy) - def _from_vrs(self, var: dict, **kwargs) -> models.Variation | None: # noqa: ARG002 - """Convert a VRS JSON mapping to a VRS variation. - - :param var: VRS JSON mapping. - :param kwargs: Reserved translator-specific options. - :returns: Matching VRS variation, or None for unsupported input. - """ + 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 if "type" not in var: diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index bb17feff..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" @@ -434,36 +434,6 @@ class Expression(VRSMetadataMixin, Element, BaseModelForbidExtra): ) -######################################### -# abstract VRS classes -######################################### - - -class Variation(Ga4ghIdentifiableObject, ABC): - """A representation of the state of one or more biomolecules.""" - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - _abstract: ClassVar[bool] = True - - expressions: list[Expression] | None = None - - -class MolecularVariation(Variation, ABC): - """A `Variation` on a contiguous molecule.""" - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - _abstract: ClassVar[bool] = True - - -class SystemicVariation(Variation, ABC): - """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 - _abstract: ClassVar[bool] = True - - ######################################### # vrs numerics, comparators, and ranges ######################################### @@ -544,19 +514,7 @@ class sequenceString(VRSMetadataMixin, RootModel): ######################################### -######################################### -# sequence expressions -######################################### - - -class SequenceExpression(VRSMetadataMixin, _ValueObject, ABC): - """An expression describing a sequence.""" - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - _abstract: ClassVar[bool] = True - - -class LengthExpression(SequenceExpression, BaseModelForbidExtra): +class LengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A sequence expressed only by its length.""" _maturity: ClassVar[Maturity] = Maturity.DRAFT @@ -573,7 +531,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["length", "type"] -class ReferenceLengthExpression(SequenceExpression, BaseModelForbidExtra): +class ReferenceLengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """An expression of a length of a sequence from a repeating reference.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -597,7 +555,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["length", "repeatSubunitLength", "type"] -class LiteralSequenceExpression(SequenceExpression, BaseModelForbidExtra): +class LiteralSequenceExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """An explicit expression of a Sequence.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -654,19 +612,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["refgetAccession", "type"] -######################################### -# locations -######################################### - - -class Location(Ga4ghIdentifiableObject, ABC): - """A contiguous segment of a biological sequence.""" - - _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - _abstract: ClassVar[bool] = True - - -class SequenceLocation(Location, BaseModelForbidExtra): +class SequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): """A `Location` defined by an interval on a `Sequence`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -803,7 +749,7 @@ class ga4gh(_ValueObject.ga4gh): ] -class RelativeSequenceLocation(Location, 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. @@ -833,7 +779,13 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -class Allele(MolecularVariation, BaseModelForbidExtra): +class _VariationBase(Ga4ghIdentifiableObject, ABC): + """Base class for variation.""" + + expressions: list[Expression] | None = None + + +class Allele(_VariationBase, BaseModelForbidExtra): """The state of a molecule at a `Location`.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -879,7 +831,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["location", "state", "type"] -class RelativeAllele(MolecularVariation, 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 @@ -910,7 +862,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["mappedState", "baseState", "relativeLocation", "type"] -class CisPhasedBlock(MolecularVariation, BaseModelForbidExtra): +class CisPhasedBlock(_VariationBase, BaseModelForbidExtra): """An ordered set of co-occurring `Variation` on the same molecule.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -944,7 +896,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -class Adjacency(MolecularVariation, 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. """ @@ -990,7 +942,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["adjoinedSequences", "linker", "type"] -class Terminus(MolecularVariation, 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. @@ -1037,7 +989,7 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["component", "orientation", "type"] -class DerivativeMolecule(MolecularVariation, BaseModelForbidExtra): +class DerivativeMolecule(_VariationBase, BaseModelForbidExtra): """The "Derivative Molecule" data class is a structure for describing a derivate molecule composed from multiple sequence components. """ @@ -1070,7 +1022,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 ######################################### -class CopyNumberCount(SystemicVariation, BaseModelForbidExtra): +class CopyNumberCount(_VariationBase, BaseModelForbidExtra): """The absolute count of discrete copies of a `Location`, within a system (e.g. genome, cell, etc.). """ @@ -1094,7 +1046,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 inherent = ["copies", "location", "type"] -class CopyNumberChange(SystemicVariation, 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. """ @@ -1121,6 +1073,102 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["copyChange", "location", "type"] +######################################### +# Sealed-union adapters +######################################### + + +class MolecularVariation(VRSMetadataMixin, RootModel): + """A `Variation` on a contiguous molecule.""" + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + + root: ( + Allele + | RelativeAllele + | CisPhasedBlock + | Adjacency + | Terminus + | DerivativeMolecule + ) = Field( + ..., + json_schema_extra={"description": "A `variation` on a contiguous molecule."}, + discriminator="type", + ) + + +class SequenceExpression(VRSMetadataMixin, RootModel): + """An expression describing a `Sequence`.""" + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + + root: LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression = ( + Field( + ..., + json_schema_extra={"description": "An expression describing a `Sequence`."}, + discriminator="type", + ) + ) + + +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( + ..., + json_schema_extra={ + "description": "A contiguous segment of a biological sequence." + }, + discriminator="type", + ) + + +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 + | DerivativeMolecule + | CopyNumberChange + | CopyNumberCount + ) = Field( + ..., + json_schema_extra={ + "description": "A representation of the state of one or more biomolecules." + }, + discriminator="type", + ) + + +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 + _abstract: ClassVar[bool] = True + + root: CopyNumberChange | CopyNumberCount = Field( + ..., + json_schema_extra={ + "description": "A Variation of multiple molecules in the context of a system, e.g. a genome, sample, or homologous chromosomes." + }, + discriminator="type", + ) + + # At end so classes exist (reffable_classes, union_reffable_classes, class_refatt_map, class_inherent) = ( pydantic_class_refatt_map() diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py index 6d6951fa..8d750e01 100644 --- a/tests/validation/test_model_metadata.py +++ b/tests/validation/test_model_metadata.py @@ -1,42 +1,61 @@ -"""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 ga4gh.core import AbstractGKSModel, core_models -from ga4gh.core.metadata import Maturity +from jsonschema import Draft202012Validator +from pydantic import RootModel +from referencing import Registry, Resource + +from ga4gh.core import core_models +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. - :returns: Pytest parameters for concrete GKS models. + :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) @@ -55,59 +74,32 @@ def _concrete_model_params(): return params -def test_abstract_gks_model_is_a_public_base(): - """Verify the shared GKS base is exported without core metadata.""" - - class OtherGKSModel(AbstractGKSModel): - value: str - - assert AbstractGKSModel is core_models.AbstractGKSModel - - with pytest.raises(TypeError, match="abstract and cannot be instantiated"): - AbstractGKSModel() - - assert OtherGKSModel(value="test").value == "test" - assert "$id" not in OtherGKSModel.model_json_schema() - - 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 GKS models and source definitions. + :returns: Pytest parameters for abstract GKM models and JSON definitions. """ params = [] - for model_module, source_path, _ 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"] - - for name, definition in definitions.items(): + 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: schema_params.append( - pytest.param(getattr(model_module, name), definition, id=name) + pytest.param( + getattr(model_module, schema_path.name), + 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 -def _abstract_schema_model_params(): - """Return abstract model metadata discovered from source schemas. - - :returns: Pytest parameters for abstract GKS models. - """ - params = [] - for model_module, source_path, _ in SCHEMAS: - with source_path.open() as source_file: - definitions = yaml.safe_load(source_file)["$defs"] - for name, definition in definitions.items(): - if definition.get("abstract") is True: - params.append(pytest.param(getattr(model_module, name), id=name)) - return params - - @pytest.mark.parametrize(("model", "schema"), _concrete_model_params()) def test_concrete_model_metadata(model, schema): """Verify concrete model metadata matches generated JSON Schema. @@ -131,30 +123,99 @@ def test_concrete_model_metadata(model, schema): @pytest.mark.parametrize(("model", "definition"), _abstract_model_params()) def test_abstract_model_metadata(model, definition): - """Verify abstract models expose their source-defined maturity. + """Verify abstract models expose JSON Schema metadata. :param model: Abstract Pydantic model. - :param definition: Corresponding source schema definition. + :param definition: Corresponding JSON Schema definition. """ assert "_maturity" in model.__dict__ assert model.maturity() == Maturity(definition["maturity"]) - - -@pytest.mark.parametrize("model", _abstract_schema_model_params()) -def test_abstract_model_schema_metadata(model): - """Verify abstract models emit the abstract schema keyword. - - :param model: Abstract Pydantic model. - """ - assert model.model_json_schema()["abstract"] is True - - -@pytest.mark.parametrize("model", _abstract_schema_model_params()) -def test_abstract_models_cannot_be_instantiated(model): - """Verify abstract models reject direct construction. - - :param model: Abstract Pydantic model. - """ - kwargs = {} if model is core_models.Element else {"type": "test"} - with pytest.raises(TypeError, match="abstract and cannot be instantiated"): - model(**kwargs) + 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" + assert len(generated_schema["oneOf"]) == len(definition["oneOf"]) + 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_preserve_legacy_union_api(model, member, payload): + """Abstract VRS schemas retain the public sealed-union adapters.""" + result = model.model_validate(payload) + assert isinstance(result.root, member) + assert isinstance(model(root=payload).root, member) + + +def test_variation_adapter_validates_against_published_schema(): + """Validate the backward-compatible adapter output against the VRS schema.""" + schema_paths = [ + *SUBMODULES_DIR.glob("schema/vrs/json/*"), + *SUBMODULES_DIR.glob("submodules/gkm-core/schema/gkm-core/json/*"), + ] + schemas = [json.loads(path.read_text()) for path in schema_paths] + registry = Registry().with_resources( + (schema["$id"], Resource.from_contents(schema)) for schema in schemas + ) + variation_schema = next( + schema for schema in schemas if schema["title"] == "Variation" + ) + payload = { + "type": "RelativeAllele", + "relativeLocation": "ga4gh:VSL.test", + "baseState": {"type": "LiteralSequenceExpression", "sequence": "A"}, + "mappedState": {"type": "LiteralSequenceExpression", "sequence": "T"}, + } + variation = vrs_models.Variation.model_validate(payload) + + Draft202012Validator(variation_schema, registry=registry).validate( + variation.model_dump(mode="json", exclude_none=True) + ) 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 dcab81fa..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 and not cls_def.get("abstract"): - gks_schema_mapping.concrete_classes.add(spec_class) + 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 22e7260ac14aaa585b4c83111581219b236191bf Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 25 Sep 2026 10:56:55 -0400 Subject: [PATCH 5/6] rename --- tests/validation/test_model_metadata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py index 8d750e01..6f2fc801 100644 --- a/tests/validation/test_model_metadata.py +++ b/tests/validation/test_model_metadata.py @@ -188,8 +188,8 @@ def test_abstract_model_metadata(model, definition): ), ], ) -def test_abstract_vrs_models_preserve_legacy_union_api(model, member, payload): - """Abstract VRS schemas retain the public sealed-union adapters.""" +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) From 69e1a098e511d588e08824e45b9e037e7759aa8b Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 25 Sep 2026 11:04:57 -0400 Subject: [PATCH 6/6] rm --- tests/validation/test_model_metadata.py | 28 ------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py index 6f2fc801..1009915c 100644 --- a/tests/validation/test_model_metadata.py +++ b/tests/validation/test_model_metadata.py @@ -4,9 +4,7 @@ from pathlib import Path import pytest -from jsonschema import Draft202012Validator from pydantic import RootModel -from referencing import Registry, Resource from ga4gh.core import core_models from ga4gh.core.metadata import ( @@ -193,29 +191,3 @@ def test_abstract_vrs_models_dispatch_typed_payloads(model, member, payload): result = model.model_validate(payload) assert isinstance(result.root, member) assert isinstance(model(root=payload).root, member) - - -def test_variation_adapter_validates_against_published_schema(): - """Validate the backward-compatible adapter output against the VRS schema.""" - schema_paths = [ - *SUBMODULES_DIR.glob("schema/vrs/json/*"), - *SUBMODULES_DIR.glob("submodules/gkm-core/schema/gkm-core/json/*"), - ] - schemas = [json.loads(path.read_text()) for path in schema_paths] - registry = Registry().with_resources( - (schema["$id"], Resource.from_contents(schema)) for schema in schemas - ) - variation_schema = next( - schema for schema in schemas if schema["title"] == "Variation" - ) - payload = { - "type": "RelativeAllele", - "relativeLocation": "ga4gh:VSL.test", - "baseState": {"type": "LiteralSequenceExpression", "sequence": "A"}, - "mappedState": {"type": "LiteralSequenceExpression", "sequence": "T"}, - } - variation = vrs_models.Variation.model_validate(payload) - - Draft202012Validator(variation_schema, registry=registry).validate( - variation.model_dump(mode="json", exclude_none=True) - )