diff --git a/.gitmodules b/.gitmodules index 6ee1c44a..2981416e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "submodules/vrs"] path = submodules/vrs url = https://github.com/ga4gh/vrs.git - branch = 2.0 + branch = 2.1 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 1b219d1a..7170324d 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,14 +20,43 @@ is_ga4gh_identifier, use_ga4gh_compute_identifier_when, ) +from ga4gh.core.metadata import ( + GKMMaturityMixin, + GKMMetadataMixin, + GKMSchemaMixin, + GKSMaturityMixin, + GKSMetadataMixin, + GKSSchemaMixin, + Maturity, +) +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 + +try: + __version__ = package_version(__name__) +except PackageNotFoundError: # pragma: nocover + __version__ = "unknown" +finally: + del package_version, PackageNotFoundError + __all__ = [ + "CORE_VERSION", "CURIE_NAMESPACE", "CURIE_SEP", "GA4GH_DIGEST_REGEXP", "GA4GH_IR_REGEXP", "GA4GH_PREFIX_SEP", + "GKMCoreMetadataMixin", + "GKMMaturityMixin", + "GKMMetadataMixin", + "GKMSchemaMixin", + "GKSCoreMetadataMixin", + "GKSMaturityMixin", + "GKSMetadataMixin", + "GKSSchemaMixin", + "Maturity", "PrevVrsVersion", "VrsObjectIdentifierIs", "core_models", @@ -42,10 +72,3 @@ "sha512t24u", "use_ga4gh_compute_identifier_when", ] - -try: - __version__ = version(__name__) -except PackageNotFoundError: # pragma: nocover - __version__ = "unknown" -finally: - del version, PackageNotFoundError diff --git a/src/ga4gh/core/metadata.py b/src/ga4gh/core/metadata.py new file mode 100644 index 00000000..a428572f --- /dev/null +++ b/src/ga4gh/core/metadata.py @@ -0,0 +1,134 @@ +"""Provide shared metadata types for GA4GH GKM models.""" + +from enum import Enum +from typing import Any, ClassVar, Literal + +import pydantic +from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode +from typing_extensions import deprecated + +# union_format was introduced in pydantic 2.12 +# (https://pydantic.dev/docs/validation/latest/get-started/changelog/#v2120-2025-10-07) +PYDANTIC_HAS_UNION_FORMAT = tuple( + int(p) for p in pydantic.__version__.split(".")[:2] +) >= (2, 12) + + +class Maturity(str, Enum): + """Maturity levels for GA4GH product features.""" + + DRAFT = "draft" + TRIAL_USE = "trial use" + NORMATIVE = "normative" + DEPRECATED = "deprecated" + + +class GKMMaturityMixin: + """Provide maturity metadata for a GA4GH GKM model.""" + + _maturity: ClassVar[Maturity] + + @classmethod + def maturity(cls) -> Maturity: + """Return the GKM maturity level for the model.""" + return cls._maturity + + +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] + _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 GKMMetadataMixin(GKMMaturityMixin, GKMSchemaMixin): + """Provide maturity and schema metadata for a GKM model.""" + + _abstract: ClassVar[bool] = False + + @classmethod + def apply_schema_metadata(cls, schema: dict[str, Any]) -> dict[str, Any]: + """Add GKM metadata to a generated JSON Schema. + + :param schema: Generated JSON Schema to annotate. + :returns: The annotated JSON Schema. + """ + schema["$id"] = cls.schema_id() + schema["maturity"] = cls.maturity().value + + if cls.__dict__.get("_abstract", False): + schema["abstract"] = True + + # GA4GH identifier metadata is optional and applies only when declared. + ga4gh_class = getattr(cls, "ga4gh", None) + if not ga4gh_class: + return schema + + 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( + cls, + by_alias: bool = True, + ref_template: str = "#/$defs/{model}", + schema_generator: type[GenerateJsonSchema] = GenerateJsonSchema, + mode: JsonSchemaMode = "validation", + *, + union_format: Literal["any_of", "primitive_type_array"] = "any_of", + ) -> dict[str, Any]: + """Generate JSON Schema with GKM metadata. + + Default values follow Pydantic defaults. + + :param by_alias: Whether to use field aliases. + :param ref_template: Template for schema references. + :param schema_generator: Pydantic schema generator class. + :param mode: Pydantic schema generation mode. + :param union_format: How to represent unions in the generated schema. + :returns: JSON Schema annotated with GKM metadata. + """ + schema_kwargs: dict[str, Any] = { + "by_alias": by_alias, + "ref_template": ref_template, + "schema_generator": schema_generator, + "mode": mode, + } + + if PYDANTIC_HAS_UNION_FORMAT: + schema_kwargs["union_format"] = union_format + + schema = super().model_json_schema(**schema_kwargs) # pyright: ignore[reportAttributeAccessIssue] + + return cls.apply_schema_metadata(schema) + + +@deprecated("GKSMaturityMixin is deprecated; use GKMMaturityMixin instead.") +class GKSMaturityMixin(GKMMaturityMixin): + """Deprecated alias for :class:`GKMMaturityMixin`.""" + + +@deprecated("GKSSchemaMixin is deprecated; use GKMSchemaMixin instead.") +class GKSSchemaMixin(GKMSchemaMixin): + """Deprecated alias for :class:`GKMSchemaMixin`.""" + + +@deprecated("GKSMetadataMixin is deprecated; use GKMMetadataMixin instead.") +class GKSMetadataMixin(GKMMetadataMixin): + """Deprecated alias for :class:`GKMMetadataMixin`.""" diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index aabfb45c..6cb0cf3b 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -1,10 +1,9 @@ -"""GKS Core Class Definitions""" +"""GKM Core Class Definitions""" from __future__ import annotations -from abc import ABC from enum import Enum -from typing import Annotated, Any +from typing import Annotated, Any, ClassVar, Literal from pydantic import ( BaseModel, @@ -14,9 +13,23 @@ 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 GKMMetadataMixin, Maturity +from ga4gh.core.version import CORE_VERSION + + +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): @@ -37,18 +50,34 @@ class Relation(str, Enum): RELATED_MATCH = "relatedMatch" +class MembershipOperator(str, Enum): + """The logical relationship between concepts in the set, in the context of some + knowledge reported about them. The value 'AND' indicates that the concepts are + dependent and occur together in this context - i.e. the reported assertion is not + necessarily true for each concept on its own - only in combination with the + other(s). The value 'OR' indicates that each concept applies independently in this + context - i.e. the reported assertion is necessarily true for each concept on its + own, independent of the presence of the other(s). + """ + + AND = "AND" + OR = "OR" + + ######################################### # Primitive data types ######################################### -class code(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 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={ @@ -58,7 +87,7 @@ class code(RootModel): # noqa: N801 ) -class iriReference(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 `_. @@ -66,6 +95,8 @@ class iriReference(RootModel): # noqa: N801 `_. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + def __hash__(self) -> int: # noqa: D105 return self.root.__hash__() @@ -88,12 +119,63 @@ def ga4gh_serialize(self) -> str: # noqa: D102 ######################################### -class Entity(BaseModel, ABC): - """Anything that exists, has existed, or will exist. +class Element(GKMCoreMetadataMixin, BaseModel): + """The base definition for all identifiable data objects.""" + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + + id: str | None = Field( + default=None, + description="The 'logical' identifier of the data element in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", + ) + extensions: list[Extension] | None = Field( + default=None, + description="A list of extensions to the Entity, that allow for capture of information not directly supported by elements defined in the model.", + ) + + def get_extensions_by_name(self, name: str) -> list[Extension]: + """Fetch all contained extension exactly matching the provided name + + :param name: name of extension to fetch + :return: a list of all matching extensions, empty if no matches found (or if instance contains no extensions) + """ + if not self.extensions: + return [] + return [e for e in self.extensions if e.name == name] + - Abstract base class to be extended by other classes. Do NOT instantiate directly. +# Not an ABC, but needs to go here so there's no circular imports +class Extension(Element, BaseModelForbidExtra): + """The Extension class provides entities with a means to include additional + attributes that are outside of the specified standard but needed by a given content + provider or system implementer. These extensions are not expected to be natively + understood, but may be used for pre-negotiated exchange of message attributes + between systems. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + + name: str = Field( + ..., + description="A name for the Extension. Should be indicative of its meaning and/or the type of information it value represents.", + ) + value: int | float | str | bool | dict[str, Any] | list[Any] | None = Field( + ..., + description="The value of the Extension - can be any primitive or structured object", + ) + description: str | None = Field( + default=None, + description="A description of the meaning or utility of the Extension, to explain the type of information it is meant to hold.", + ) + + +class Entity(GKMCoreMetadataMixin, BaseModel): + """Anything that exists, has existed, or will exist.""" + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + id: str | None = Field( default=None, description="The 'logical' identifier of the Entity in the system of record, e.g. a UUID. This 'id' is unique within a given system, but may or may not be globally unique outside the system. It is used within a system to reference an object from another.", @@ -115,32 +197,6 @@ class Entity(BaseModel, ABC): ) -class Element(BaseModel, ABC): - """The base definition for all identifiable data objects. - - Abstract base class to be extended by other classes. Do NOT instantiate directly. - """ - - 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.", - ) - extensions: list[Extension] | None = Field( - default=None, - description="A list of extensions to the Entity, that allow for capture of information not directly supported by elements defined in the model.", - ) - - def get_extensions_by_name(self, name: str) -> list[Extension]: - """Fetch all contained extension exactly matching the provided name - - :param name: name of extension to fetch - :return: a list of all matching extensions, empty if no matches found (or if instance contains no extensions) - """ - if not self.extensions: - return [] - return [e for e in self.extensions if e.name == name] - - ######################################### # General-purpose data classes ######################################### @@ -151,6 +207,8 @@ class Coding(Element, BaseModelForbidExtra): 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.", @@ -175,6 +233,8 @@ class ConceptMapping(Element, BaseModelForbidExtra): 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.", @@ -185,31 +245,46 @@ class ConceptMapping(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 - understood, but may be used for pre-negotiated exchange of message attributes - between systems. +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 + consequences that both result from the presence of a particular genetic variant + (membership operator = OR). """ - name: str = Field( - ..., - description="A name for the Extension. Should be indicative of its meaning and/or the type of information it value represents.", + model_config = ConfigDict(use_enum_values=True) + + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + + type: Literal["ConceptSet"] = Field( + default="ConceptSet", + description='MUST be "ConceptSet".', ) - value: float | str | bool | dict[str, Any] | list[Any] | None = Field( + conceptSetType: str | None = Field( # noqa: N815 + default=None, + description="A term indicating the type of concept being represented by the ConceptSet.", + ) + concepts: list[MappableConcept | ConceptSet | iriReference] = Field( ..., - description="The value of the Extension - can be any primitive or structured object", + description="A list of concepts that are dependent (occurring together), or independent (existing separately), depending on the membership operator.", + min_length=2, ) - description: str | None = Field( - default=None, - description="A description of the meaning or utility of the Extension, to explain the type of information it is meant to hold.", + membershipOperator: MembershipOperator = Field( # noqa: N815 + ..., + description="The logical relationship between concepts in the set, in the context of some knowledge reported about them. The value 'AND' indicates that the concepts are dependent and occur together in this context - i.e. the reported assertion is not necessarily true for each concept on its own - only in combination with the other(s). The value 'OR' indicates that each concept applies independently in this context - i.e. the reported assertion is necessarily true for each concept on its own, independent of the presence of the other(s).", ) -class MappableConcept(Element, 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 + + type: Literal["MappableConcept"] = Field( + default="MappableConcept", + description='MUST be "MappableConcept".', + ) conceptType: str | None = Field( # noqa: N815 default=None, description="A term indicating the type of concept being represented by the MappableConcept.", diff --git a/src/ga4gh/core/version.py b/src/ga4gh/core/version.py new file mode 100644 index 00000000..979d401a --- /dev/null +++ b/src/ga4gh/core/version.py @@ -0,0 +1,3 @@ +"""Define GKM-Core version""" + +CORE_VERSION = "1.3.0" diff --git a/src/ga4gh/vrs/__init__.py b/src/ga4gh/vrs/__init__.py index 16d36647..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.0.1" - __all__ = [ "VRS_VERSION", "VrsType", diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 667ca0f5..327fa942 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -137,8 +137,14 @@ def translate_to(self, vo: models._VariationBase, fmt: str, **kwargs) -> list[st kwargs: ref_seq_limit Optional(int): If vo.state is a ReferenceLengthExpression, and `ref_seq_limit` is specified, and `fmt` is `spdi`, the reference sequence is included in the SPDI expression if it is below the limit Otherwise only the length of the reference sequence is included. If the limit is None, the reference sequence is always included. In all cases, the alt sequence is included. Default is 0 (never include reference sequence). + :raise NotImplementedError: If `fmt` is not supported """ - t = self.to_translators[fmt] + try: + t = self.to_translators[fmt] + except KeyError as e: + msg = f"{fmt} is not supported" + raise NotImplementedError(msg) from e + return t(vo, **kwargs) ############################################################################ @@ -149,7 +155,7 @@ def hgvs_tools(self) -> HgvsTools: """Instantiate and return an HgvsTools instance""" return HgvsTools(self.data_proxy) - def _from_vrs(self, var: dict) -> models._VariationBase | None: + def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noqa: ARG002 """Convert from dict representation of VRS JSON to VRS object""" if not isinstance(var, Mapping): return None diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index ad2978d5..8e990dc2 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 GKMMetadataMixin, Maturity from ga4gh.core.pydantic import get_pydantic_root, getattr_in +from ga4gh.vrs.version import VRS_VERSION def flatten(vals): @@ -161,7 +163,10 @@ class VrsType(str, Enum): LIT_SEQ_EXPR = "LiteralSequenceExpression" SEQ_REF = "SequenceReference" SEQ_LOC = "SequenceLocation" + SEQ_OFFSET_LOCATION = "SequenceOffsetLocation" + RELATIVE_SEQ_LOC = "RelativeSequenceLocation" ALLELE = "Allele" + RELATIVE_ALLELE = "RelativeAllele" CIS_PHASED_BLOCK = "CisPhasedBlock" ADJACENCY = "Adjacency" TERMINUS = "Terminus" @@ -229,6 +234,21 @@ class Syntax(str, Enum): SPDI = "spdi" +class AnchorOrientation(str, Enum): + """Indicates which side of a discontinuous anchor on the sequenceReference is used + as the reference point for interpreting offsetStart/offsetEnd. The anchor is an + inter-residue coordinate on the sequenceReference. When that anchor corresponds to a + boundary whose realization on a base sequence yields two distinct locations (e.g., + an exon junction), this property disambiguates which anchor side on the + sequenceReference is intended. `left` denotes the side immediately preceding the + anchor in sequenceReference coordinate order; `right` denotes the side immediately + following the anchor in sequenceReference coordinate order. + """ + + LEFT = "left" + RIGHT = "right" + + def _recurse_ga4gh_serialize(obj): if isinstance(obj, Ga4ghIdentifiableObject): return obj.get_or_create_digest() @@ -243,6 +263,13 @@ def _recurse_ga4gh_serialize(obj): return obj +class VRSMetadataMixin(GKMMetadataMixin): + """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. @@ -266,12 +293,15 @@ def is_ga4gh_identifiable() -> bool: return False -class Ga4ghIdentifiableObject(_ValueObject, ABC): +class Ga4ghIdentifiableObject(VRSMetadataMixin, _ValueObject): """A contextual value object for which a GA4GH computed identifier can be created. All GA4GH Identifiable Objects may have computed digests from the VRS Computed Identifier algorithm. """ + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + type: str digest: ( Annotated[str, StringConstraints(pattern=r"^[0-9A-Za-z_\-]{32}$")] | None @@ -380,12 +410,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( @@ -407,9 +439,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={ @@ -441,13 +475,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={ @@ -456,13 +492,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={ @@ -476,9 +514,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}"', @@ -491,9 +531,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}"', @@ -503,7 +545,7 @@ class ReferenceLengthExpression(_ValueObject, BaseModelForbidExtra): ) sequence: sequenceString | None = Field( default=None, - description="the literal Sequence encoded by the Reference Length Expression.", + description="the literal sequence encoded by the Reference Length Expression.", ) repeatSubunitLength: int = Field( ..., description="The number of residues in the repeat subunit." @@ -513,9 +555,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}"', @@ -531,9 +575,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( @@ -569,6 +615,8 @@ class ga4gh(_ValueObject.ga4gh): class SequenceLocation(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}"' ) @@ -656,15 +704,74 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["end", "sequenceReference", "start", "type"] -######################################### -# base variation -######################################### +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) -class _VariationBase(Ga4ghIdentifiableObject, ABC): - """Base class for variation""" + _maturity: ClassVar[Maturity] = Maturity.DRAFT - expressions: list[Expression] | None = None + type: Literal["SequenceOffsetLocation"] = Field( + default=VrsType.SEQ_OFFSET_LOCATION.value, + description=f'MUST be "{VrsType.SEQ_OFFSET_LOCATION.value}"', + ) + sequenceReference: SequenceReference | iriReference = Field( + ..., + description="A sequence reference that has been mapped from which a relative location is defined.", + ) + anchor: int = Field( + ..., + description="The inter-residue position on the sequence reference from which the relative location offset is calculated.", + ) + anchorOrientation: AnchorOrientation = Field( + ..., + description="Indicates which side of a discontinuous anchor on the sequenceReference is used as the reference point for interpreting offsetStart/offsetEnd. The anchor is an inter-residue coordinate on the sequenceReference. When that anchor corresponds to a boundary whose realization on a base sequence yields two distinct locations (e.g., an exon junction), this property disambiguates which anchor side on the sequenceReference is intended. `left` denotes the side immediately preceding the anchor in sequenceReference coordinate order; `right` denotes the side immediately following the anchor in sequenceReference coordinate order.", + ) + offsetStart: int | Range | None = Field( + default=None, + description="The start offset, in inter-residue coordinates, from the anchor realization selected by anchorOrientation on the sequenceReference.", + ) + offsetEnd: int | Range | None = Field( + default=None, + description="The end offset, in inter-residue coordinates, from the anchor realization selected by anchorOrientation on the sequenceReference.", + ) + + class ga4gh(_ValueObject.ga4gh): + inherent = [ + "sequenceReference", + "anchor", + "anchorOrientation", + "offsetStart", + "offsetEnd", + "type", + ] + + +class RelativeSequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): + """A location on a base sequence and its position relative to a boundary offset on a + mapped sequence gap. Typically used to describe intronic locations that exist with + respect to a mapped RNA transcript sequence. + """ + + _maturity: ClassVar[Maturity] = Maturity.DRAFT + + type: Literal["RelativeSequenceLocation"] = Field( + default=VrsType.RELATIVE_SEQ_LOC.value, + description=f'MUST be "{VrsType.RELATIVE_SEQ_LOC.value}"', + ) + baseSequenceLocation: SequenceLocation | iriReference = Field( + ..., description="An absolute location on a sequence." + ) + mappedSequenceLocation: SequenceOffsetLocation | iriReference = Field( + ..., + description="A location relative to an offset on a mapped sequence.", + ) + + class ga4gh(Ga4ghIdentifiableObject.ga4gh): + prefix = "RSL" + inherent = ["baseSequenceLocation", "mappedSequenceLocation", "type"] ######################################### @@ -672,9 +779,17 @@ class _VariationBase(Ga4ghIdentifiableObject, ABC): ######################################### +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 + type: Literal["Allele"] = Field( default=VrsType.ALLELE.value, description=f'MUST be "{VrsType.ALLELE.value}"' ) @@ -716,9 +831,42 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 inherent = ["location", "state", "type"] +class RelativeAllele(_VariationBase, BaseModelForbidExtra): + """An Allele defined on a mapped location relative to a base location. Often used to describe intronic variants.""" + + _maturity: ClassVar[Maturity] = Maturity.DRAFT + + type: Literal["RelativeAllele"] = Field( + default=VrsType.RELATIVE_ALLELE.value, + description=f'MUST be "{VrsType.RELATIVE_ALLELE.value}"', + ) + mappedState: ( + LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression + ) = Field( + ..., + description='The state of the RelativeAllele as expressed on the mapped sequence. This will differ from the base state when mapping to a reverse complement sequence, commonly observed when representing the state on transcripts mapped to the "negative strand" of a chromosome.', + ) + baseState: ( + LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression + ) = Field( + ..., + description="The state of the RelativeAllele as expressed on the base sequence.", + ) + relativeLocation: RelativeSequenceLocation | iriReference = Field( + ..., + description="The relative location at which the baseState and mappedState are expressed.", + ) + + class ga4gh(Ga4ghIdentifiableObject.ga4gh): + prefix = "RA" + inherent = ["mappedState", "baseState", "relativeLocation", "type"] + + class CisPhasedBlock(_VariationBase, BaseModelForbidExtra): """An ordered set of co-occurring `Variation` on the same molecule.""" + _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}"', @@ -753,6 +901,8 @@ class Adjacency(_VariationBase, BaseModelForbidExtra): 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}".', @@ -798,6 +948,8 @@ class Terminus(_VariationBase, BaseModelForbidExtra): 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}".', @@ -811,13 +963,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}".', @@ -840,6 +994,8 @@ class DerivativeMolecule(_VariationBase, BaseModelForbidExtra): 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}".', @@ -871,6 +1027,8 @@ class CopyNumberCount(_VariationBase, BaseModelForbidExtra): (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}"', @@ -895,6 +1053,8 @@ class CopyNumberChange(_VariationBase, BaseModelForbidExtra): 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}"', @@ -914,23 +1074,36 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): ######################################### -# vrs kinds of variation, expression, and location +# Sealed-union adapters ######################################### -class MolecularVariation(RootModel): - """A `variation` on a contiguous molecule.""" +class MolecularVariation(VRSMetadataMixin, RootModel): + """A `Variation` on a contiguous molecule.""" - root: Allele | CisPhasedBlock | Adjacency | Terminus | DerivativeMolecule = Field( + _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(RootModel): +class SequenceExpression(VRSMetadataMixin, RootModel): """An expression describing a `Sequence`.""" + _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE + _abstract: ClassVar[bool] = True + root: LiteralSequenceExpression | ReferenceLengthExpression | LengthExpression = ( Field( ..., @@ -940,10 +1113,13 @@ class SequenceExpression(RootModel): ) -class Location(RootModel): +class Location(VRSMetadataMixin, RootModel): """A contiguous segment of a biological sequence.""" - root: SequenceLocation = Field( + _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." @@ -952,11 +1128,15 @@ 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 + _abstract: ClassVar[bool] = True + root: ( Allele + | RelativeAllele | CisPhasedBlock | Adjacency | Terminus @@ -972,11 +1152,14 @@ 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 + _abstract: ClassVar[bool] = True + root: CopyNumberChange | CopyNumberCount = Field( ..., json_schema_extra={ diff --git a/src/ga4gh/vrs/utils/hgvs_tools.py b/src/ga4gh/vrs/utils/hgvs_tools.py index 4eff6818..3ba0496d 100644 --- a/src/ga4gh/vrs/utils/hgvs_tools.py +++ b/src/ga4gh/vrs/utils/hgvs_tools.py @@ -249,9 +249,17 @@ def is_intronic(self, sv: HgvsSequenceVariant) -> bool: bool: True if the SequenceVariant is intronic, False otherwise. """ - if isinstance(sv.posedit.pos, hgvs.location.BaseOffsetInterval): - return sv.posedit.pos.start.is_intronic or sv.posedit.pos.end.is_intronic - return False + # Coding (c.) intervals are BaseOffsetInterval, but RNA (r.) intervals + # are plain Interval even when their endpoints carry a base-offset + # position, so inspect the endpoints directly rather than the interval + # type. Non-offset coordinates (g./p.) use position types without + # is_intronic and are correctly reported as not intronic. + pos = sv.posedit.pos + start = getattr(pos, "start", None) + end = getattr(pos, "end", None) + return ( + isinstance(start, hgvs.location.BaseOffsetPosition) and start.is_intronic + ) or (isinstance(end, hgvs.location.BaseOffsetPosition) and end.is_intronic) def get_edit_type(self, sv: HgvsSequenceVariant) -> str | None: """Safely extract ``type`` property from SequenceVariant""" diff --git a/src/ga4gh/vrs/version.py b/src/ga4gh/vrs/version.py new file mode 100644 index 00000000..c9499152 --- /dev/null +++ b/src/ga4gh/vrs/version.py @@ -0,0 +1,3 @@ +"""Define VRS version""" + +VRS_VERSION = "2.1.1" diff --git a/submodules/vrs b/submodules/vrs index d2e87dd8..8e2bcd3f 160000 --- a/submodules/vrs +++ b/submodules/vrs @@ -1 +1 @@ -Subproject commit d2e87dd878a9a344c1910105c5acf5bdf8087af4 +Subproject commit 8e2bcd3fab401e0f55518d5590fa7c757868ae78 diff --git a/tests/extras/test_allele_translator.py b/tests/extras/test_allele_translator.py index 60657d3b..fd900d70 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -329,6 +329,11 @@ def test_from_invalid(tlr): ): tlr.translate_from("BRAF amplication") + with pytest.raises( + ValueError, match="Unable to parse data as gnomad, hgvs, spdi, vrs" + ): + tlr.translate_from("BRAF amplication", assembly_name="GRCh37") + @pytest.mark.vcr def test_from_gnomad(tlr): @@ -951,20 +956,10 @@ def test_normalize_microsatellite_counts(tlr, case): ) -# TODO: Readd these tests -# @pytest.mark.vcr -# def test_errors(tlr): -# with pytest.raises(ValueError): -# tlr._from_gnomad("NM_182763.2:c.688+403C>T") -# -# with pytest.raises(ValueError): -# tlr._from_hgvs("NM_182763.2:c.688+403C>T") -# -# with pytest.raises(ValueError): -# tlr._from_hgvs("NM_182763.2:c.688_690inv") -# -# with pytest.raises(ValueError): -# tlr._from_spdi("NM_182763.2:c.688+403C>T") +@pytest.mark.vcr +def test_translate_to_invalid_fmt(tlr): + with pytest.raises(NotImplementedError, match="gnomad is not supported"): + tlr.translate_to(models.Allele.model_validate(snv_output), fmt="gnomad") def test_from_hgvs_uncertain_range_raises(tlr): diff --git a/tests/extras/test_hgvs_tools.py b/tests/extras/test_hgvs_tools.py new file mode 100644 index 00000000..fde53e60 --- /dev/null +++ b/tests/extras/test_hgvs_tools.py @@ -0,0 +1,42 @@ +"""Tests for ga4gh.vrs.utils.hgvs_tools.""" + +import hgvs.parser +import pytest + +from ga4gh.vrs.utils.hgvs_tools import HgvsTools + + +@pytest.fixture(scope="module") +def hgvs_tools(): + # is_intronic only parses expressions, so build HgvsTools without the + # network-dependent __init__ (which opens a UTA connection). + tools = HgvsTools.__new__(HgvsTools) + tools.parser = hgvs.parser.Parser() + return tools + + +@pytest.mark.parametrize( + ("hgvs_expr", "expected"), + [ + # coding (c.) + ("NM_000000.1:c.76-1G>A", True), # intronic (acceptor side) + ("NM_000000.1:c.87+1A>T", True), # intronic (donor side) + ("NM_000000.1:c.100A>T", False), # exonic + # RNA (r.) - previously missed: the interval is a plain Interval, not a + # BaseOffsetInterval, so the old isinstance check returned False + ("NR_000000.1:r.76-1g>a", True), # intronic (acceptor side) + ("NR_000000.1:r.77+1g>a", True), # intronic (donor side) + ("NR_000000.1:r.100a>u", False), # exonic + # RNA (r.) exonic - real-world ClinVar pins from issue #628 (offset 0) + ("NR_001566.1:r.398_399del", False), + ("NR_001566.1:r.245del", False), + ("NM_001374385.1:r.2843_2931del", False), + ("NM_001323289.2:r.2632c>a", False), + # genomic (g.) - no base-offset positions, never intronic + ("NC_000001.11:g.100A>T", False), + ], +) +def test_is_intronic(hgvs_tools, hgvs_expr, expected): + sv = hgvs_tools.parse(hgvs_expr) + assert sv is not None + assert hgvs_tools.is_intronic(sv) is expected diff --git a/tests/test_hgvs_tools.py b/tests/test_hgvs_uncertain_ranges.py similarity index 100% rename from tests/test_hgvs_tools.py rename to tests/test_hgvs_uncertain_ranges.py diff --git a/tests/test_vrs.py b/tests/test_vrs.py index 975854ab..47e372e0 100644 --- a/tests/test_vrs.py +++ b/tests/test_vrs.py @@ -284,6 +284,8 @@ def test_enref2(): def test_class_refatt_map(): class_refatt_map_expected = { "Allele": ["location"], + "RelativeAllele": ["relativeLocation"], + "RelativeSequenceLocation": ["baseSequenceLocation"], "CisPhasedBlock": ["members"], "CopyNumberCount": ["location"], "CopyNumberChange": ["location"], diff --git a/tests/validation/test_model_metadata.py b/tests/validation/test_model_metadata.py new file mode 100644 index 00000000..e868af64 --- /dev/null +++ b/tests/validation/test_model_metadata.py @@ -0,0 +1,204 @@ +"""Test model metadata against the GKM JSON schemas.""" + +import json +from pathlib import Path + +import pytest +from pydantic import RootModel + +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" / "json", + ), + ( + vrs_models, + 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 GKM models. + """ + 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) + + 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) + return params + + +def _abstract_model_params(): + """Return abstract model metadata from JSON Schema files. + + :returns: Pytest parameters for abstract GKM models and JSON definitions. + """ + params = [] + for model_module, json_dir in SCHEMAS: + schema_params = [] + for schema_path in sorted(json_dir.iterdir()): + with schema_path.open() as schema_file: + definition = json.load(schema_file) + if definition.get("abstract") is True: + model = getattr(model_module, schema_path.name, None) + if model is None: + continue # abstract schema with no corresponding Pydantic model + + schema_params.append( + pytest.param( + model, + definition, + id=schema_path.name, + ) + ) + + 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): + """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() + 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): + """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"]) + generated_schema = model.model_json_schema() + assert generated_schema["$id"] == definition["$id"] + assert generated_schema["maturity"] == definition["maturity"] + assert generated_schema["abstract"] is True + if issubclass(model, RootModel): + # These are public compatibility adapters for the former sealed unions. + # Pydantic adds a discriminator mapping and local $defs references, whereas + # the published abstract schemas use portable references. + assert generated_schema["discriminator"]["propertyName"] == "type" + + # Compare union membership, not just cardinality, so a wrong or missing + # member (e.g. RelativeAllele) is caught. Both schemas use $ref entries + # whose trailing path segment is the member class name. + def _member_names(schema_obj): + return {entry["$ref"].rsplit("/", 1)[-1] for entry in schema_obj["oneOf"]} + + assert _member_names(generated_schema) == _member_names(definition) + else: + assert generated_schema.get("discriminator") == definition.get("discriminator") + assert generated_schema.get("oneOf") == definition.get("oneOf") + + +@pytest.mark.parametrize( + ("model", "member", "payload"), + [ + ( + vrs_models.Variation, + vrs_models.CopyNumberChange, + { + "type": "CopyNumberChange", + "location": "ga4gh:VSL.test", + "copyChange": "loss", + }, + ), + ( + vrs_models.MolecularVariation, + vrs_models.Allele, + { + "type": "Allele", + "location": "ga4gh:VSL.test", + "state": {"type": "LiteralSequenceExpression", "sequence": "A"}, + }, + ), + ( + vrs_models.SystemicVariation, + vrs_models.CopyNumberCount, + {"type": "CopyNumberCount", "location": "ga4gh:VSL.test", "copies": 2}, + ), + ( + vrs_models.SequenceExpression, + vrs_models.LiteralSequenceExpression, + {"type": "LiteralSequenceExpression", "sequence": "A"}, + ), + ( + vrs_models.Location, + vrs_models.SequenceLocation, + { + "type": "SequenceLocation", + "sequenceReference": "SQ.test", + "start": 1, + "end": 2, + }, + ), + ], +) +def test_abstract_vrs_models_dispatch_typed_payloads(model, member, payload): + """Abstract VRS models dispatch typed payloads to their concrete members.""" + result = model.model_validate(payload) + assert isinstance(result.root, member) + assert isinstance(model(root=payload).root, member) diff --git a/tests/validation/test_models.py b/tests/validation/test_models.py index e126a5b5..7371bf4a 100644 --- a/tests/validation/test_models.py +++ b/tests/validation/test_models.py @@ -134,9 +134,9 @@ def test_valid_types(): for enum_val in VrsType.__members__.values(): enum_val = enum_val.value if hasattr(models, enum_val): - gks_class = getattr(models, enum_val) + gkm_class = getattr(models, enum_val) try: - assert gks_class(type=enum_val) + assert gkm_class(type=enum_val) except ValidationError as e: found_type_mismatch = False for error in e.errors(): diff --git a/tests/validation/test_schemas.py b/tests/validation/test_schemas.py index 88ad9591..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" / "gks-core" / "schema" / "gks-core" / "json" + 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)