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..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,44 @@ 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 + + @staticmethod + def apply_schema_metadata( + model_class: type, schema: dict[str, Any] + ) -> dict[str, Any]: + """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. + :returns: The annotated JSON Schema. + """ + schema["$id"] = model_class.schema_id() + schema["maturity"] = model_class.maturity().value + + 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 + + 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 @classmethod def model_json_schema( @@ -50,7 +87,14 @@ 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 GKM metadata. + """ schema = super().model_json_schema( by_alias=by_alias, ref_template=ref_template, @@ -58,22 +102,19 @@ def model_json_schema( mode=mode, ) - schema["$id"] = cls.schema_id() - schema["maturity"] = cls.maturity().value + return cls.apply_schema_metadata(cls, schema) - ga4gh_class = getattr(cls, "ga4gh", None) - if not ga4gh_class: - return schema - ga4gh_metadata = {} +@deprecated("GKSMaturityMixin is deprecated; use GKMMaturityMixin instead.") +class GKSMaturityMixin(GKMMaturityMixin): + """Deprecated alias for :class:`GKMMaturityMixin`.""" - if prefix := getattr(ga4gh_class, "prefix", None): - ga4gh_metadata["prefix"] = prefix - if inherent := getattr(ga4gh_class, "inherent", None): - ga4gh_metadata["inherent"] = list(inherent) +@deprecated("GKSSchemaMixin is deprecated; use GKMSchemaMixin instead.") +class GKSSchemaMixin(GKMSchemaMixin): + """Deprecated alias for :class:`GKMSchemaMixin`.""" - if ga4gh_metadata: - schema["ga4gh"] = ga4gh_metadata - return schema +@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..b4b33f74 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,13 +120,14 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Entity(GKSMaturityMixin, BaseModel, ABC): +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, @@ -144,13 +150,14 @@ class Entity(GKSMaturityMixin, BaseModel, ABC): ) -class Element(GKSMaturityMixin, BaseModel, ABC): +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, @@ -177,7 +184,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 +210,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 +227,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 +243,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 +258,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 +282,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/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..1009915c 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,38 @@ 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: 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 @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 +121,73 @@ 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" + 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_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)