From daceb0b6cd773f8c4c5b6dff560fb7f4a5ff7b34 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 14 Aug 2026 13:42:27 -0400 Subject: [PATCH] feat: expose json schema metadata to core and vrs models close #649 * add `schema_id` and `maturity` class methods to core and vrs models * include `$id`, `maturity`, and `ga4gh` metadata in `model_json_schema` --- src/ga4gh/core/__init__.py | 21 ++++- src/ga4gh/core/metadata.py | 79 +++++++++++++++++ src/ga4gh/core/models.py | 47 +++++++--- src/ga4gh/core/version.py | 3 + src/ga4gh/vrs/__init__.py | 10 +-- src/ga4gh/vrs/models.py | 113 ++++++++++++++++++------ src/ga4gh/vrs/version.py | 3 + tests/validation/test_model_metadata.py | 89 +++++++++++++++++++ 8 files changed, 320 insertions(+), 45 deletions(-) create mode 100644 src/ga4gh/core/metadata.py create mode 100644 src/ga4gh/core/version.py create mode 100644 src/ga4gh/vrs/version.py create mode 100644 tests/validation/test_model_metadata.py diff --git a/src/ga4gh/core/__init__.py b/src/ga4gh/core/__init__.py index 5b09f6cd..b6da1c78 100644 --- a/src/ga4gh/core/__init__.py +++ b/src/ga4gh/core/__init__.py @@ -1,6 +1,7 @@ """Python support used across GA4GH projects""" -from importlib.metadata import PackageNotFoundError, version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version import ga4gh.core.models as core_models from ga4gh.core.digests import sha512t24u @@ -19,16 +20,23 @@ is_ga4gh_identifier, use_ga4gh_compute_identifier_when, ) +from ga4gh.core.metadata import ( + GKSMaturityMixin, + GKSMetadataMixin, + GKSSchemaMixin, + Maturity, +) +from ga4gh.core.models import GKSCoreMetadataMixin from ga4gh.core.pydantic import is_curie_type, is_pydantic_instance, pydantic_copy +from ga4gh.core.version import CORE_VERSION try: - __version__ = version(__name__) + __version__ = package_version(__name__) except PackageNotFoundError: # pragma: nocover __version__ = "unknown" finally: - del version, PackageNotFoundError + del package_version, PackageNotFoundError -CORE_VERSION = "1.1.0" __all__ = [ "CORE_VERSION", @@ -37,6 +45,11 @@ "GA4GH_DIGEST_REGEXP", "GA4GH_IR_REGEXP", "GA4GH_PREFIX_SEP", + "GKSCoreMetadataMixin", + "GKSMaturityMixin", + "GKSMetadataMixin", + "GKSSchemaMixin", + "Maturity", "PrevVrsVersion", "VrsObjectIdentifierIs", "core_models", diff --git a/src/ga4gh/core/metadata.py b/src/ga4gh/core/metadata.py new file mode 100644 index 00000000..1945fe44 --- /dev/null +++ b/src/ga4gh/core/metadata.py @@ -0,0 +1,79 @@ +"""Provide shared metadata types for GA4GH GKS models.""" + +from enum import Enum +from typing import Any, ClassVar + +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode + + +class Maturity(str, Enum): + """Maturity levels for GA4GH product features.""" + + DRAFT = "draft" + TRIAL_USE = "trial use" + NORMATIVE = "normative" + DEPRECATED = "deprecated" + + +class GKSMaturityMixin: + """Provide maturity metadata for a GA4GH GKS model.""" + + _maturity: ClassVar[Maturity] + + @classmethod + def maturity(cls) -> Maturity: + """Return the GKS maturity level for the model.""" + return cls._maturity + + +class GKSSchemaMixin: + """Provide a canonical JSON Schema identifier for a GA4GH GKS model.""" + + _schema_base_uri: ClassVar[str] = "https://w3id.org/ga4gh/schema" + _product_name: ClassVar[str] + _product_version: ClassVar[str] + + @classmethod + def schema_id(cls) -> str: + """Return the canonical JSON Schema identifier for the model.""" + return f"{cls._schema_base_uri}/{cls._product_name}/{cls._product_version}/json/{cls.__name__}" + + +class GKSMetadataMixin(GKSMaturityMixin, GKSSchemaMixin): + """Provide maturity and schema metadata for a concrete GKS model.""" + + @classmethod + def model_json_schema( + cls, + by_alias: bool = True, + ref_template: str = "#/$defs/{model}", + schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, + mode: JsonSchemaMode = "validation", + ) -> dict[str, Any]: + """Generate JSON Schema with GKS metadata.""" + schema = super().model_json_schema( + by_alias=by_alias, + ref_template=ref_template, + schema_generator=schema_generator, + mode=mode, + ) + + schema["$id"] = cls.schema_id() + schema["maturity"] = cls.maturity().value + + ga4gh_class = getattr(cls, "ga4gh", None) + if not ga4gh_class: + return schema + + ga4gh_metadata = {} + + if prefix := getattr(ga4gh_class, "prefix", None): + ga4gh_metadata["prefix"] = prefix + + if inherent := getattr(ga4gh_class, "inherent", None): + ga4gh_metadata["inherent"] = list(inherent) + + if ga4gh_metadata: + schema["ga4gh"] = ga4gh_metadata + + return schema diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index 7708ac7a..7492194b 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -4,7 +4,7 @@ from abc import ABC from enum import Enum -from typing import Annotated, Any, Literal +from typing import Annotated, Any, ClassVar, Literal from pydantic import ( BaseModel, @@ -17,6 +17,15 @@ from typing_extensions import Self from ga4gh.core.identifiers import GA4GH_IR_REGEXP +from ga4gh.core.metadata import GKSMaturityMixin, GKSMetadataMixin, Maturity +from ga4gh.core.version import CORE_VERSION + + +class GKSCoreMetadataMixin(GKSMetadataMixin): + """Provide GKS-Core model metadata.""" + + _product_name = "gks-core" + _product_version = CORE_VERSION class BaseModelForbidExtra(BaseModel): @@ -56,13 +65,15 @@ class MembershipOperator(str, Enum): ######################################### -class code(RootModel): # noqa: N801 +class code(GKSCoreMetadataMixin, RootModel): # noqa: N801 """Indicates that the value is taken from a set of controlled strings defined elsewhere. Technically, a code is restricted to a string which has at least one character and no leading or trailing whitespace, and where there is no whitespace other than single spaces in the contents. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: Annotated[str, StringConstraints(pattern=r"\S+( \S+)*")] = Field( ..., json_schema_extra={ @@ -72,7 +83,7 @@ class code(RootModel): # noqa: N801 ) -class iriReference(RootModel): # noqa: N801 +class iriReference(GKSCoreMetadataMixin, RootModel): # noqa: N801 """An IRI Reference (either an IRI or a relative-reference), according to `RFC3986 section 4.1 `_ and `RFC3987 section 2.1 `_. @@ -80,6 +91,8 @@ class iriReference(RootModel): # noqa: N801 `_. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + def __hash__(self) -> int: # noqa: D105 return self.root.__hash__() @@ -102,12 +115,14 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Entity(BaseModel, ABC): +class Entity(GKSMaturityMixin, BaseModel, ABC): """Anything that exists, has existed, or will exist. Abstract base class to be extended by other classes. Do NOT instantiate directly. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + id: str | None = Field( default=None, description="The 'logical' identifier of the Entity in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", @@ -129,12 +144,14 @@ class Entity(BaseModel, ABC): ) -class Element(BaseModel, ABC): +class Element(GKSMaturityMixin, BaseModel, ABC): """The base definition for all identifiable data objects. Abstract base class to be extended by other classes. Do NOT instantiate directly. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + id: str | None = Field( default=None, description="The 'logical' identifier of the data element in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", @@ -160,11 +177,13 @@ def get_extensions_by_name(self, name: str) -> list[Extension]: ######################################### -class Coding(Element, BaseModelForbidExtra): +class Coding(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """A structured representation of a code for a defined concept in a terminology or code system. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + name: str | None = Field( default=None, description="The human-readable name for the coded concept, as defined by the code system.", @@ -184,11 +203,13 @@ class Coding(Element, BaseModelForbidExtra): ) -class ConceptMapping(Element, BaseModelForbidExtra): +class ConceptMapping(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """A mapping to a concept in a terminology or code system.""" model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + coding: Coding = Field( ..., description="A structured representation of a code for a defined concept in a terminology or code system.", @@ -199,7 +220,7 @@ class ConceptMapping(Element, BaseModelForbidExtra): ) -class ConceptSet(Element, BaseModelForbidExtra): +class ConceptSet(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """A set of concepts that may be considered as dependent (occurring together), or independent (existing separately) in the context of some knowledge reported about them, as indicated by a set membership operator. e.g. a set of independent molecular @@ -209,6 +230,8 @@ class ConceptSet(Element, BaseModelForbidExtra): model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["ConceptSet"] = Field( default="ConceptSet", description='MUST be "ConceptSet".', @@ -224,7 +247,7 @@ class ConceptSet(Element, BaseModelForbidExtra): ) -class Extension(Element, BaseModelForbidExtra): +class Extension(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """The Extension class provides entities with a means to include additional attributes that are outside of the specified standard but needed by a given content provider or system implementer. These extensions are not expected to be natively @@ -232,6 +255,8 @@ class Extension(Element, BaseModelForbidExtra): between systems. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + name: str = Field( ..., description="A name for the Extension. Should be indicative of its meaning and/or the type of information it value represents.", @@ -246,9 +271,11 @@ class Extension(Element, BaseModelForbidExtra): ) -class MappableConcept(Element, BaseModelForbidExtra): +class MappableConcept(GKSCoreMetadataMixin, Element, BaseModelForbidExtra): """A concept based on a primaryCoding and/or name that may be mapped to one or more other `Codings`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + conceptType: str | None = Field( # noqa: N815 default=None, description="A term indicating the type of concept being represented by the MappableConcept.", diff --git a/src/ga4gh/core/version.py b/src/ga4gh/core/version.py new file mode 100644 index 00000000..74c4d4f1 --- /dev/null +++ b/src/ga4gh/core/version.py @@ -0,0 +1,3 @@ +"""Define GKS-Core version""" + +CORE_VERSION = "1.1.0" diff --git a/src/ga4gh/vrs/__init__.py b/src/ga4gh/vrs/__init__.py index 24889aa6..f6f9eada 100644 --- a/src/ga4gh/vrs/__init__.py +++ b/src/ga4gh/vrs/__init__.py @@ -1,22 +1,22 @@ """Public interface to the GA4GH Variation Representation reference implementation""" -from importlib.metadata import PackageNotFoundError, version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version from ga4gh.vrs import models from ga4gh.vrs.enderef import vrs_deref, vrs_enref from ga4gh.vrs.models import VrsType from ga4gh.vrs.normalize import normalize +from ga4gh.vrs.version import VRS_VERSION try: - __version__ = version(__name__) + __version__ = package_version(__name__) except PackageNotFoundError: # pragma: nocover __version__ = "unknown" finally: - del version, PackageNotFoundError + del package_version, PackageNotFoundError -VRS_VERSION = "2.1.0-snapshot.2026-02.2" - __all__ = [ "VRS_VERSION", "VrsType", diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index e8029b01..2ffc0a1c 100644 --- a/src/ga4gh/vrs/models.py +++ b/src/ga4gh/vrs/models.py @@ -17,7 +17,7 @@ from collections import OrderedDict from enum import Enum from types import UnionType -from typing import Annotated, Literal, get_args, get_origin +from typing import Annotated, ClassVar, Literal, get_args, get_origin from canonicaljson import encode_canonical_json from pydantic import ( @@ -44,7 +44,9 @@ Entity, iriReference, ) +from ga4gh.core.metadata import GKSMetadataMixin, Maturity from ga4gh.core.pydantic import get_pydantic_root, getattr_in +from ga4gh.vrs.version import VRS_VERSION def flatten(vals): @@ -261,6 +263,13 @@ def _recurse_ga4gh_serialize(obj): return obj +class VRSMetadataMixin(GKSMetadataMixin): + """Provide metadata for a concrete VRS model.""" + + _product_name = "vrs" + _product_version = VRS_VERSION + + class _ValueObject(Entity, ABC): """A contextual value whose equality is based on value, not identity. See https://en.wikipedia.org/wiki/Value_object for more on Value Objects. @@ -398,12 +407,14 @@ class ga4gh(_ValueObject.ga4gh): # noqa: N801 prefix: str -class Expression(Element, BaseModelForbidExtra): +class Expression(VRSMetadataMixin, Element, BaseModelForbidExtra): """Representation of a variation by a specified nomenclature or syntax for a Variation object. Common examples of expressions for the description of molecular variation include the HGVS and ISCN nomenclatures. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + model_config = ConfigDict(use_enum_values=True) syntax: Syntax = Field( @@ -425,9 +436,11 @@ class Expression(Element, BaseModelForbidExtra): ######################################### -class Range(RootModel): +class Range(VRSMetadataMixin, RootModel): """An inclusive range of values bounded by one or more integers.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: list[int | None] = Field( ..., json_schema_extra={ @@ -459,13 +472,15 @@ def validate_range(cls, v: list[int | None]) -> list[int | None]: # noqa: N805 return v -class residue(RootModel): +class residue(VRSMetadataMixin, RootModel): """A character representing a specific residue (i.e., molecular species) or groupings of these ("ambiguity codes"), using `one-letter IUPAC abbreviations `_ for nucleic acids and amino acids. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: Annotated[str, StringConstraints(pattern=r"[A-Z*\-]")] = Field( ..., json_schema_extra={ @@ -474,13 +489,15 @@ class residue(RootModel): ) -class sequenceString(RootModel): +class sequenceString(VRSMetadataMixin, RootModel): """A character string of `Residues` that represents a biological sequence using the conventional sequence order (5'-to-3' for nucleic acid sequences, and amino-to-carboxyl for amino acid sequences). IUPAC ambiguity codes are permitted in Sequence Strings. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: Annotated[str, StringConstraints(pattern=r"^[A-Z*\-]*$")] = Field( ..., json_schema_extra={ @@ -494,9 +511,11 @@ class sequenceString(RootModel): ######################################### -class LengthExpression(_ValueObject, BaseModelForbidExtra): +class LengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A sequence expressed only by its length.""" + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["LengthExpression"] = Field( default=VrsType.LEN_EXPR.value, description=f'MUST be "{VrsType.LEN_EXPR.value}"', @@ -509,9 +528,11 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["length", "type"] -class ReferenceLengthExpression(_ValueObject, BaseModelForbidExtra): +class ReferenceLengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """An expression of a length of a sequence from a repeating reference.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["ReferenceLengthExpression"] = Field( default=VrsType.REF_LEN_EXPR.value, description=f'MUST be "{VrsType.REF_LEN_EXPR.value}"', @@ -531,9 +552,11 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["length", "repeatSubunitLength", "type"] -class LiteralSequenceExpression(_ValueObject, BaseModelForbidExtra): +class LiteralSequenceExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """An explicit expression of a Sequence.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["LiteralSequenceExpression"] = Field( default=VrsType.LIT_SEQ_EXPR.value, description=f'MUST be "{VrsType.LIT_SEQ_EXPR.value}"', @@ -549,9 +572,11 @@ class ga4gh(_ValueObject.ga4gh): ######################################### -class SequenceReference(_ValueObject, BaseModelForbidExtra): +class SequenceReference(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A sequence of nucleic or amino acid character codes.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + model_config = ConfigDict(use_enum_values=True) type: Literal["SequenceReference"] = Field( @@ -584,9 +609,11 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["refgetAccession", "type"] -class SequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): +class SequenceLocation(VRSMetadataMixin, Ga4ghIdentifiableObject, BaseModelForbidExtra): """A `Location` defined by an interval on a `Sequence`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["SequenceLocation"] = Field( default=VrsType.SEQ_LOC.value, description=f'MUST be "{VrsType.SEQ_LOC.value}"' ) @@ -674,13 +701,15 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["end", "sequenceReference", "start", "type"] -class SequenceOffsetLocation(_ValueObject, BaseModelForbidExtra): +class SequenceOffsetLocation(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A location defined by an offset relative to an anchor on a mapped sequence reference. """ model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["SequenceOffsetLocation"] = Field( default=VrsType.SEQ_OFFSET_LOCATION.value, description=f'MUST be "{VrsType.SEQ_OFFSET_LOCATION.value}"', @@ -717,12 +746,16 @@ class ga4gh(_ValueObject.ga4gh): ] -class RelativeSequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): +class RelativeSequenceLocation( + VRSMetadataMixin, Ga4ghIdentifiableObject, BaseModelForbidExtra +): """A location on a base sequence and its position relative to a boundary offset on a mapped sequence gap. Typically used to describe intronic locations that exist with respect to a mapped RNA transcript sequence. """ + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["RelativeSequenceLocation"] = Field( default=VrsType.RELATIVE_SEQ_LOC.value, description=f'MUST be "{VrsType.RELATIVE_SEQ_LOC.value}"', @@ -756,9 +789,11 @@ class _VariationBase(Ga4ghIdentifiableObject, ABC): ######################################### -class Allele(_VariationBase, BaseModelForbidExtra): +class Allele(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The state of a molecule at a `Location`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["Allele"] = Field( default=VrsType.ALLELE.value, description=f'MUST be "{VrsType.ALLELE.value}"' ) @@ -800,9 +835,11 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["location", "state", "type"] -class RelativeAllele(_VariationBase, BaseModelForbidExtra): +class RelativeAllele(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """An Allele defined on a mapped location relative to a base location. Often used to describe intronic variants.""" + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["RelativeAllele"] = Field( default=VrsType.RELATIVE_ALLELE.value, description=f'MUST be "{VrsType.RELATIVE_ALLELE.value}"', @@ -829,9 +866,11 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["mappedState", "baseState", "relativeLocation", "type"] -class CisPhasedBlock(_VariationBase, BaseModelForbidExtra): +class CisPhasedBlock(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """An ordered set of co-occurring `Variation` on the same molecule.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["CisPhasedBlock"] = Field( default=VrsType.CIS_PHASED_BLOCK.value, description=f'MUST be "{VrsType.CIS_PHASED_BLOCK.value}"', @@ -861,11 +900,13 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -class Adjacency(_VariationBase, BaseModelForbidExtra): +class Adjacency(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The `Adjacency` class represents the adjoining of the end of a sequence with the beginning of an adjacent sequence, potentially with an intervening linker sequence. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["Adjacency"] = Field( default=VrsType.ADJACENCY.value, description=f'MUST be "{VrsType.ADJACENCY.value}".', @@ -905,12 +946,14 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): inherent = ["adjoinedSequences", "linker", "type"] -class Terminus(_VariationBase, BaseModelForbidExtra): +class Terminus(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The `Terminus` data class provides a structure for describing the end (terminus) of a sequence. Structurally similar to Adjacency but the linker sequence is not allowed and it removes the unnecessary array structure. """ + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["Terminus"] = Field( default=VrsType.TERMINUS.value, description=f'MUST be "{VrsType.TERMINUS.value}".', @@ -924,13 +967,15 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 inherent = ["location", "type"] -class TraversalBlock(_ValueObject, BaseModelForbidExtra): +class TraversalBlock(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): """A component used to describe the orientation of applicable molecular variation within a DerivativeMolecule. """ model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["TraversalBlock"] = Field( default=VrsType.TRAVERSAL_BLOCK.value, description=f'MUST be "{VrsType.TRAVERSAL_BLOCK.value}".', @@ -948,11 +993,13 @@ class ga4gh(_ValueObject.ga4gh): inherent = ["component", "orientation", "type"] -class DerivativeMolecule(_VariationBase, BaseModelForbidExtra): +class DerivativeMolecule(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The "Derivative Molecule" data class is a structure for describing a derivate molecule composed from multiple sequence components. """ + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["DerivativeMolecule"] = Field( default=VrsType.DERIVATIVE_MOL.value, description=f'MUST be "{VrsType.DERIVATIVE_MOL.value}".', @@ -979,11 +1026,13 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 ######################################### -class CopyNumberCount(_VariationBase, BaseModelForbidExtra): +class CopyNumberCount(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """The absolute count of discrete copies of a `Location`, within a system (e.g. genome, cell, etc.). """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + type: Literal["CopyNumberCount"] = Field( default=VrsType.CN_COUNT.value, description=f'MUST be "{VrsType.CN_COUNT.value}"', @@ -1001,13 +1050,15 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 inherent = ["copies", "location", "type"] -class CopyNumberChange(_VariationBase, BaseModelForbidExtra): +class CopyNumberChange(VRSMetadataMixin, _VariationBase, BaseModelForbidExtra): """An assessment of the copy number of a `Location` within a system (e.g. genome, cell, etc.) relative to a baseline ploidy. """ model_config = ConfigDict(use_enum_values=True) + _maturity: ClassVar[Maturity] = Maturity.DRAFT + type: Literal["CopyNumberChange"] = Field( default=VrsType.CN_CHANGE.value, description=f'MUST be "{VrsType.CN_CHANGE.value}"', @@ -1031,9 +1082,11 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -class MolecularVariation(RootModel): +class MolecularVariation(VRSMetadataMixin, RootModel): """A `variation` on a contiguous molecule.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: ( Allele | RelativeAllele @@ -1048,9 +1101,11 @@ class MolecularVariation(RootModel): ) -class SequenceExpression(RootModel): +class SequenceExpression(VRSMetadataMixin, RootModel): """An expression describing a `Sequence`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression = ( Field( ..., @@ -1060,9 +1115,11 @@ class SequenceExpression(RootModel): ) -class Location(RootModel): +class Location(VRSMetadataMixin, RootModel): """A contiguous segment of a biological sequence.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: SequenceLocation | RelativeSequenceLocation = Field( ..., json_schema_extra={ @@ -1072,9 +1129,11 @@ class Location(RootModel): ) -class Variation(RootModel): +class Variation(VRSMetadataMixin, RootModel): """A representation of the state of one or more biomolecules.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: ( Allele | CisPhasedBlock @@ -1092,11 +1151,13 @@ class Variation(RootModel): ) -class SystemicVariation(RootModel): +class SystemicVariation(VRSMetadataMixin, RootModel): """A Variation of multiple molecules in the context of a system, e.g. a genome, sample, or homologous chromosomes. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + root: CopyNumberChange | CopyNumberCount = Field( ..., json_schema_extra={ diff --git a/src/ga4gh/vrs/version.py b/src/ga4gh/vrs/version.py new file mode 100644 index 00000000..281da683 --- /dev/null +++ b/src/ga4gh/vrs/version.py @@ -0,0 +1,3 @@ +"""Define VRS version""" + +VRS_VERSION = "2.1.0-snapshot.2026-02.2" diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py new file mode 100644 index 00000000..95b0f2dc --- /dev/null +++ b/tests/validation/test_model_metadata.py @@ -0,0 +1,89 @@ +"""Test model metadata against the GKS source and JSON schemas.""" + +import json +from pathlib import Path + +import pytest +import yaml + +from ga4gh.core import core_models +from ga4gh.core.metadata import Maturity +from ga4gh.vrs import models as vrs_models + +SUBMODULES_DIR = Path(__file__).parents[2] / "submodules" / "vrs" +SCHEMAS = ( + ( + core_models, + SUBMODULES_DIR + / "submodules" + / "gks-core" + / "schema" + / "gks-core" + / "gks-core-source.yaml", + SUBMODULES_DIR / "submodules" / "gks-core" / "schema" / "gks-core" / "json", + ), + ( + vrs_models, + SUBMODULES_DIR / "schema" / "vrs" / "vrs-source.yaml", + SUBMODULES_DIR / "schema" / "vrs" / "json", + ), +) + + +def _concrete_model_params(): + """Return concrete model metadata discovered from JSON Schema files.""" + params = [] + for model_module, _, json_dir in SCHEMAS: + schema_params = [] + for schema_path in sorted(json_dir.iterdir()): + model = getattr(model_module, schema_path.name, None) + if model is None: + continue # date and datetime use standard-library classes + with schema_path.open() as schema_file: + schema = json.load(schema_file) + schema_params.append(pytest.param(model, schema, id=schema["title"])) + assert schema_params, f"No concrete models discovered in {json_dir}" + params.extend(schema_params) + return params + + +def _abstract_model_params(): + """Return abstract model metadata found only in source schemas.""" + params = [] + for model_module, source_path, json_dir in SCHEMAS: + schema_params = [] + with source_path.open() as source_file: + definitions = yaml.safe_load(source_file)["$defs"] + concrete_names = {path.name for path in json_dir.iterdir()} + for name, definition in definitions.items(): + if name not in concrete_names and "heritableProperties" in definition: + schema_params.append( + pytest.param(getattr(model_module, name), definition, id=name) + ) + assert schema_params, f"No abstract models discovered in {source_path}" + params.extend(schema_params) + return params + + +@pytest.mark.parametrize(("model", "schema"), _concrete_model_params()) +def test_concrete_model_metadata(model, schema): + """Concrete model metadata matches its generated JSON Schema.""" + assert model.schema_id() == schema["$id"] + assert model.maturity() == Maturity(schema["maturity"]) + generated_schema = model.model_json_schema() + assert generated_schema["$id"] == schema["$id"] + assert generated_schema["maturity"] == schema["maturity"] + if ga4gh_metadata := schema.get("ga4gh"): + assert generated_schema["ga4gh"].get("prefix") == ga4gh_metadata.get("prefix") + assert set(generated_schema["ga4gh"]["inherent"]) == set( + ga4gh_metadata["inherent"] + ) + else: + assert "ga4gh" not in generated_schema + + +@pytest.mark.parametrize(("model", "definition"), _abstract_model_params()) +def test_abstract_model_metadata(model, definition): + """Abstract models expose source-defined maturity but no schema identifier.""" + assert model.maturity() == Maturity(definition["maturity"]) + assert not hasattr(model, "schema_id")