From fddbd44c2d1eeabe54efb9f1a39f7276bd3a10f5 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 6 Nov 2026 11:43:16 -0500 Subject: [PATCH 1/2] style: add docstrings and type annotations close #98 * Also updates ruff rules --- pyproject.toml | 38 +--- src/ga4gh/core/digests.py | 6 +- src/ga4gh/core/enderef.py | 58 ++++-- src/ga4gh/core/identifiers.py | 142 +++++++++---- src/ga4gh/core/metadata.py | 19 +- src/ga4gh/core/models.py | 62 ++---- src/ga4gh/core/pydantic.py | 45 +++- src/ga4gh/vrs/dataproxy.py | 172 ++++++++++++---- src/ga4gh/vrs/enderef.py | 50 ++++- src/ga4gh/vrs/extras/annotator/cli.py | 53 +++-- src/ga4gh/vrs/extras/annotator/vcf.py | 176 ++++++++++------ src/ga4gh/vrs/extras/decorators.py | 15 +- src/ga4gh/vrs/extras/object_store.py | 85 ++++++-- src/ga4gh/vrs/extras/translator.py | 70 +++++-- src/ga4gh/vrs/models.py | 285 ++++++++++++++++---------- src/ga4gh/vrs/normalize.py | 132 +++++++++--- src/ga4gh/vrs/utils/hgvs_tools.py | 230 ++++++++++++--------- 17 files changed, 1107 insertions(+), 531 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index afd6613c..365f697b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,10 +141,7 @@ select = [ "RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf ] fixable = ["ALL"] -# ANN002 - missing-type-args -# ANN003 - missing-type-kwargs # D203 - one-blank-line-before-class -# D205 - blank-line-after-summary # D206 - indent-with-spaces* # D213 - multi-line-summary-second-line # D300 - triple-single-quotes* @@ -159,8 +156,7 @@ fixable = ["ALL"] # PLC0206 - dict-index-missing-items # *ignored for compatibility with formatter ignore = [ - "ANN002", "ANN003", - "D203", "D205", "D206", "D213", "D300", "D400", "D415", + "D203", "D206", "D213", "D300", "D400", "D415", "E111", "E114", "E117", "E501", "W191", "S321", @@ -168,52 +164,28 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -# ANN001 - missing-type-function-argument -# ANN2 - missing-return-type -# D100 - undocumented-public-module -# D102 - undocumented-public-class -# D103 - undocumented-public-function +# ANN - type annotations +# D - docstrings # S101 - assert # B011 - assert-false # INP001 - implicit-namespace-package # SLF001 - private-member-access "tests/*" = [ - "ANN001", - "ANN2", - "D100", - "D102", - "D103", + "ANN", + "D", "S101", "B011", "INP001", "SLF001", ] -"src/ga4gh/vrs/normalize.py" = [ - "ANN001", - "ANN201", - "ANN202", -] -"src/ga4gh/vrs/extras/object_store.py" = [ - "ANN", - "D", -] -"src/ga4gh/vrs/enderef.py" = [ - "ANN", - "D", -] "src/ga4gh/vrs/dataproxy.py" = [ "B019", ] -"src/ga4gh/core/pydantic.py" = [ - "ANN401", -] "src/ga4gh/vrs/models.py" = [ "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f "E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w "I", # https://docs.astral.sh/ruff/rules/#isort-i "N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n - "D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d - "ANN", # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann "ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async "S", # https://docs.astral.sh/ruff/rules/#flake8-bandit-s "B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b diff --git a/src/ga4gh/core/digests.py b/src/ga4gh/core/digests.py index 72b18dbb..21fc0923 100644 --- a/src/ga4gh/core/digests.py +++ b/src/ga4gh/core/digests.py @@ -10,8 +10,7 @@ def sha512t24u(blob: bytes) -> str: - """Generate a base64url-encode, truncated SHA-512 digest for given - binary data + """Generate a base64url-encode, truncated SHA-512 digest for given binary data The sha512t24u digest is a convention for constructing and formatting digests for use as object identifiers. Specifically:: @@ -27,6 +26,9 @@ def sha512t24u(blob: bytes) -> str: >>> sha512t24u(b"ACGT") 'aKF498dAxcJAqme6QYQ7EZ07-fiw8Kw2' + :param blob: Binary data to digest. + :return: URL-safe base64 encoding of the first 24 digest bytes. + """ digest_size = 24 digest = hashlib.sha512(blob).digest() diff --git a/src/ga4gh/core/enderef.py b/src/ga4gh/core/enderef.py index b8feff67..a7549977 100644 --- a/src/ga4gh/core/enderef.py +++ b/src/ga4gh/core/enderef.py @@ -5,11 +5,12 @@ These functions require a "class referable attribute" map, which will typically be generated from the schema by -build_class_referable_attribute_map() in .models.py. +pydantic_class_refatt_map() in .models.py. """ import logging +from collections.abc import Mapping, MutableMapping, Sequence from pydantic.main import BaseModel @@ -25,13 +26,12 @@ def ga4gh_enref( - o, # noqa: ANN001 - cra_map, # noqa: ANN001 - object_store=None, # noqa: ANN001 + o: BaseModel, + cra_map: Mapping[str, Sequence[str]], + object_store: MutableMapping[str, BaseModel] | None = None, return_id_obj_tuple: bool = False, -) -> tuple: - """Recursively convert "referable attributes" from inlined to - referenced form. Returns a new object. +) -> BaseModel | tuple[str | None, BaseModel]: + """Recursively convert "referable attributes" from inlined to referenced form. Returns a new object. :param o: VRS object :param cra_map: class referrable-attribute map; { o.type: [attr, attr, ...] } @@ -39,17 +39,29 @@ def ga4gh_enref( objects as they are referenced. If not given, referenced objects are not stored. :param return_id_obj_tuple: include ID with return value or not :return: new, enref'd object, as well as object ID if param set - :raise TypeError: if any object IDs are non-GA4GH CURIEs + :raises TypeError: If a root-model CURIE reference has a non-GA4GH prefix. + Plain string references are left unchanged. + :raises ValueError: If the input is not an identifiable Pydantic model. """ - def _id_and_store(o) -> str | None: # noqa: ANN001 + def _id_and_store(o: BaseModel) -> str | None: + """Compute an identifier and store the object when a store is available. + + :param o: Object to process. + :return: Computed identifier, or None for an unidentifiable object. + """ _id = ga4gh_identify(o) if _id and object_store is not None: object_store[_id] = o return _id def _enref(o: BaseModel) -> str | None: - """depth-first recursive, in-place enref of object; returns id of object""" + """Replace referable attributes in place and identify the visited object. + + :param o: Object to process. + :return: Identifier of the updated object, or None. + :raises TypeError: If a root-model CURIE has a non-GA4GH prefix. + """ ref_att_names = cra_map.get(o.type, []) for ran in ref_att_names: v = getattr(o, ran) @@ -83,19 +95,33 @@ def _enref(o: BaseModel) -> str | None: return (_id, o) if return_id_obj_tuple else o -def ga4gh_deref(o, cra_map, object_store) -> BaseModel: # noqa: ANN001 - """Convert "referable attributes" in-place from referenced to inlined - form. +def ga4gh_deref( + o: BaseModel, + cra_map: Mapping[str, Sequence[str]], + object_store: Mapping[str, BaseModel], +) -> BaseModel: + """Copy an object and replace referable identifiers with inline objects. `object_store` must be a mappable object and is required for dereferencing. - Raises KeyError if any object cannot be dereferenced + The top-level input is copied. Objects retrieved from the store are inserted + directly and may be mutated while their own references are resolved. + :param o: Object to process. + :param cra_map: Mapping from model names to referable attribute names. + :param object_store: Mapping from identifiers to referenced objects. + :return: Copy with identifier references replaced by objects. + :raises ValueError: If the input is not an identifiable Pydantic model. + :raises KeyError: If a referenced object is absent from the store. """ - def _deref(o: BaseModel): # noqa: ANN202 - """depth-first recursive, in-place deref of object; returns id of object""" + def _deref(o: BaseModel) -> BaseModel: + """Resolve references recursively, mutating each visited object. + + :param o: Object to process. + :return: Updated object with inline references. + """ if o.type not in cra_map: _logger.warning("%s not in cra_map %s", o.type, cra_map) return o diff --git a/src/ga4gh/core/identifiers.py b/src/ga4gh/core/identifiers.py index 07dfc3db..dabbe786 100644 --- a/src/ga4gh/core/identifiers.py +++ b/src/ga4gh/core/identifiers.py @@ -1,24 +1,49 @@ -"""serializes, digests, and identifies GA4GH objects +"""Serialize, digest, and identify GA4GH models. -In GA4GH schemas with nested objects, serialize, digest, and identify -are entangled. +For an Allele without cached IDs or digests, the main steps are +(intermediate helper calls omitted):: -For example, here is a call path for ga4gh_identify called on an Allele: ga4gh_identify(allele) - + ga4gh_digest(allele) - ++ ga4gh_serialize(allele) - +++ ga4gh_digest(allele.location) - ++++ ga4gh_serialize(allele.location) - +++ ga4gh_serialize(allele.state) + + allele.compute_ga4gh_identifier() + + allele.compute_digest() + + allele.ga4gh_serialize() + + location.get_or_create_digest() # identifiable child + + state.ga4gh_serialize() # inline value object + + encode_canonical_json(...) + + sha512t24u(...) -For that reason, they are implemented here in one file. +Given an Allele, call the public helpers:: + from ga4gh.core import ga4gh_digest, ga4gh_identify, ga4gh_serialize + + identifier = ga4gh_identify(allele, in_place="never") # GA4GH CURIE + digest = ga4gh_digest(allele) # digest component + serialized = ga4gh_serialize(allele) # canonical JSON bytes + +``in_place="never"`` leaves the ID unchanged; ``"always"`` stores the result; +``"default"`` stores it only when the ID is None. Digests may still be cached. + +To reuse any nonempty ID:: + + from ga4gh.core import VrsObjectIdentifierIs, use_ga4gh_compute_identifier_when + + with use_ga4gh_compute_identifier_when(VrsObjectIdentifierIs.MISSING): + identifier = ga4gh_identify(allele) + +``GA4GH_INVALID`` instead requires an ID matching the GA4GH pattern. ``ANY`` +always calls the model identifier method, which may reuse cached IDs or digests. +After editing an allele, refresh changed identifiable children first, then:: + + identifier = allele.get_or_create_ga4gh_identifier( + in_place="always", recompute=True + ) """ import contextvars import re from contextlib import ContextDecorator from enum import Enum, IntEnum +from types import TracebackType from canonicaljson import encode_canonical_json from pydantic import BaseModel @@ -38,14 +63,10 @@ class VrsObjectIdentifierIs(IntEnum): - """Defines the state for when the `ga4gh_identify` method should compute - an identifier ('id' attribute) for the specified object. The options are: - ANY - Always compute the identifier (this is the default behavior) - GA4GH_INVALID - Compute the identifier if it is missing or is present but syntactically invalid - MISSING - Only compute the identifier if missing - - The default behavior is safe and ensures that the identifiers are correct, - but at a performance cost. Where the source of inputs to `ga4gh_identify` + """Defines the state for when the `ga4gh_identify` method should compute an identifier ('id' attribute) for the specified object. The options are: ANY - Always compute the identifier (this is the default behavior) GA4GH_INVALID - Compute the identifier if it is missing or is present but syntactically invalid MISSING - Only compute the identifier if missing + + The default policy calls the model identifier method. That method may reuse + an existing valid identifier or digest; this policy does not force recomputation. Where the source of inputs to `ga4gh_identify` are well controlled, for example when annotating a VCF file with VRS IDs, using `MISSING` can improve performance. """ @@ -56,14 +77,17 @@ class VrsObjectIdentifierIs(IntEnum): class PrevVrsVersion(str, Enum): - """Define previous VRS versions that are supported for computing digests and - identifiers based on the current VRS model - """ + """Define previous VRS versions that are supported for computing digests and identifiers based on the current VRS model""" V1_3 = "1.3" @classmethod - def validate(cls, version) -> None: # noqa: ANN001 + def validate(cls, version: object) -> None: + """Validate a supported prior VRS version. + + :param version: Version value to validate; None selects the current version. + :raises ValueError: If the version is unsupported. + """ if version is not None and version not in cls.__members__.values(): err_msg = f"Expected `PrevVrsVersion`, but got {version}" raise ValueError(err_msg) @@ -73,8 +97,7 @@ def validate(cls, version) -> None: # noqa: ANN001 class use_ga4gh_compute_identifier_when(ContextDecorator): # noqa: N801 - """Context manager that defines when to compute identifiers - for all operations within the context. For example: + """Context manager that defines when to compute identifiers for all operations within the context. For example: with use_ga4gh_compute_identifier_when(VrsObjectIdentifierIs.GA4GH_INVALID): VCFAnnotator(...).annotate(...) @@ -86,18 +109,34 @@ def my_method(): """ def __init__(self, when: VrsObjectIdentifierIs) -> None: + """Set the identifier computation policy. + + :param when: Identifier computation policy for the context. + """ self.when = when self.token = None def __enter__(self) -> None: + """Apply the identifier computation policy.""" self.token = ga4gh_compute_identifier_when.set(self.when) - def __exit__(self, exc_type, exc, exc_tb) -> None: # noqa: ANN001 + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Restore the previous identifier computation policy. + + :param exc_type: Exception class from the context body, if any. + :param exc: Exception instance from the context body, if any. + :param exc_tb: Traceback from the context body, if any. + """ ga4gh_compute_identifier_when.reset(self.token) -def is_ga4gh_identifier(ir: str) -> bool: - """Check whether a string is a valid GA4GH identifier +def is_ga4gh_identifier(ir: object) -> bool: + """Check whether a value starts with the GA4GH namespace prefix >>> is_ga4gh_identifier("ga4gh:SQ.0123abcd") True @@ -108,31 +147,34 @@ def is_ga4gh_identifier(ir: str) -> bool: >>> is_ga4gh_identifier(None) False + :param ir: Value or root model to inspect. + :return: Whether the value has the GA4GH namespace prefix. """ return str(get_pydantic_root(ir)).startswith(NS_W_SEP) def ga4gh_identify( - vro, # noqa: ANN001 + vro: BaseModel, in_place: str = "default", as_version: PrevVrsVersion | None = None, ) -> str | None: - """Return the GA4GH digest-based id for the object, as a CURIE - (string). Returns None if object is not identifiable. + """Return the GA4GH digest-based id for the object, as a CURIE (string). Returns None if object is not identifiable. This function has three options for in_place editing of vro.id: - 'default': the standard identifier update behavior for GA4GH identifiable objects, this mode will update the vro.id - field if the field is empty + field only when it is None - 'always': this will update the vro.id field any time the identifier is computed (compute behavior is controlled by the use_ga4gh_compute_identifier_when context) - 'never': the vro.id field will not be edited in-place, even when empty - If ``as_version`` is provided, other parameters are ignored and an identifier is - returned following the conventions of the VRS version indicated by ``as_version_``. - Raises ``ValueError`` if ``as_version`` is not a ``PrevVrsVersion``. + The context policy may return an existing ID without computing one. When + computation occurs, ``as_version`` selects legacy serialization and bypasses + in-place ID updates; it does not override the context policy. + Accepts a supported PrevVrsVersion member or its string value. + Raises ValueError for other version values. >>> from ga4gh.core import ga4gh_identify >>> import ga4gh.vrs @@ -145,6 +187,14 @@ def ga4gh_identify( ... ) >>> ga4gh_identify(location) 'ga4gh:SL.4t6JnYWqHwYw9WzBT_lmWBb3tLQNalkT' + + :param vro: GA4GH model implementing is_ga4gh_identifiable and the digest + and identifier methods used for identifiable objects. + :param in_place: Identifier update policy: default, always, or never. + :param as_version: Optional prior VRS version to use. + :return: Identifier, or None for a non-identifiable model. + :raises ValueError: If as_version is unsupported. + :raises AttributeError: If the model lacks the required GA4GH methods. """ PrevVrsVersion.validate(as_version) @@ -176,8 +226,9 @@ def ga4gh_digest( Only GA4GH identifiable objects are GA4GH digestible. If ``as_version`` is provided, other parameters are ignored and a digest is returned - following the conventions of the VRS version indicated by ``as_version_``. - Raises ``ValueError`` if ``as_version`` is not a ``PrevVrsVersion``. + following the conventions of the VRS version indicated by ``as_version``. + Accepts a supported PrevVrsVersion member or its string value. + Raises ValueError for other version values. >>> from ga4gh.core import ga4gh_digest >>> import ga4gh.vrs @@ -190,6 +241,14 @@ def ga4gh_digest( ... ) >>> ga4gh_digest(location) '4t6JnYWqHwYw9WzBT_lmWBb3tLQNalkT' + + :param vro: GA4GH model implementing is_ga4gh_identifiable and the digest + and identifier methods used for identifiable objects. + :param overwrite: Recompute an existing digest when true. + :param as_version: Optional prior VRS version to use. + :return: Digest, or None for a non-identifiable model. + :raises ValueError: If as_version is unsupported. + :raises AttributeError: If the model lacks the required GA4GH methods. """ PrevVrsVersion.validate(as_version) @@ -202,11 +261,18 @@ def ga4gh_digest( def ga4gh_serialize( obj: BaseModel, as_version: PrevVrsVersion | None = None -) -> bytes | None: +) -> bytes | str: """Serialize an object for use in computed digest computation. If ``as_version`` is provided, the returned serialization follows - the conventions of the VRS version indicated by ``as_version_``. + the conventions of the VRS version indicated by ``as_version``. + + :param obj: Model implementing ga4gh_serialize, and ga4gh_serialize_as_version + when legacy serialization is requested. + :param as_version: Optional prior VRS version to use. + :return: Canonical JSON bytes, or legacy serialization text. + :raises ValueError: If as_version is unsupported. + :raises AttributeError: If the model lacks the required GA4GH methods. """ PrevVrsVersion.validate(as_version) diff --git a/src/ga4gh/core/metadata.py b/src/ga4gh/core/metadata.py index a428572f..fa68d62e 100644 --- a/src/ga4gh/core/metadata.py +++ b/src/ga4gh/core/metadata.py @@ -30,7 +30,10 @@ class GKMMaturityMixin: @classmethod def maturity(cls) -> Maturity: - """Return the GKM maturity level for the model.""" + """Return the GKM maturity level for the model. + + :return: Declared maturity level. + """ return cls._maturity @@ -43,7 +46,10 @@ class GKMSchemaMixin: @classmethod def schema_id(cls) -> str: - """Return the canonical JSON Schema identifier for the model.""" + """Return the canonical JSON Schema identifier for the model. + + :return: Canonical schema URI. + """ return f"{cls._schema_base_uri}/{cls._product_name}/{cls._product_version}/json/{cls.__name__}" @@ -56,8 +62,8 @@ class GKMMetadataMixin(GKMMaturityMixin, GKMSchemaMixin): 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. + :param schema: Generated JSON Schema to mutate in place. + :return: The annotated JSON Schema. """ schema["$id"] = cls.schema_id() schema["maturity"] = cls.maturity().value @@ -101,8 +107,9 @@ def model_json_schema( :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. + :param union_format: How to represent unions; ignored on Pydantic versions + older than 2.12. + :return: JSON Schema annotated with GKM metadata. """ schema_kwargs: dict[str, Any] = { "by_alias": by_alias, diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index cfd005cb..95226b4c 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -39,9 +39,7 @@ class BaseModelForbidExtra(BaseModel): class Relation(str, Enum): - """A mapping relation between concepts as defined by the Simple Knowledge - Organization System (SKOS). - """ + """A mapping relation between concepts as defined by the Simple Knowledge Organization System (SKOS).""" CLOSE_MATCH = "closeMatch" EXACT_MATCH = "exactMatch" @@ -51,14 +49,7 @@ class Relation(str, Enum): 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). - """ + """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" @@ -70,11 +61,7 @@ class MembershipOperator(str, Enum): 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. - """ + """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 @@ -88,19 +75,22 @@ class code(GKMCoreMetadataMixin, 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 `_. - MAY be a JSON Pointer as an IRI fragment, as described by `RFC6901 section 6 - `_. - """ + """An IRI Reference (either an IRI or a relative-reference), according to `RFC3986 section 4.1 `_ and `RFC3987 section 2.1 `_. MAY be a JSON Pointer as an IRI fragment, as described by `RFC6901 section 6 `_.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE - def __hash__(self) -> int: # noqa: D105 + def __hash__(self) -> int: + """Hash the underlying IRI. + + :return: Hash of the underlying value. + """ return self.root.__hash__() - def ga4gh_serialize(self) -> str: # noqa: D102 + def ga4gh_serialize(self) -> str: + """Serialize an IRI reference for GA4GH digest computation. + + :return: Digest for a GA4GH reference, otherwise the original IRI. + """ m = GA4GH_IR_REGEXP.match(self.root) if m is not None: return m["digest"] @@ -147,12 +137,7 @@ def get_extensions_by_name(self, name: str) -> list[Extension]: # 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. - """ + """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 @@ -203,9 +188,7 @@ class Entity(GKMCoreMetadataMixin, BaseModel): class Coding(Element, BaseModelForbidExtra): - """A structured representation of a code for a defined concept in a terminology or - code system. - """ + """A structured representation of a code for a defined concept in a terminology or code system.""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -246,12 +229,7 @@ class ConceptMapping(Element, 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 - consequences that both result from the presence of a particular genetic variant - (membership operator = OR). - """ + """A set of concepts that may be considered as dependent (occurring together), or independent (existing separately) in the context of some knowledge reported about them, as indicated by a set membership operator. e.g. a set of independent molecular consequences that both result from the presence of a particular genetic variant (membership operator = OR).""" model_config = ConfigDict(use_enum_values=True) @@ -303,7 +281,11 @@ class MappableConcept(Entity, BaseModelForbidExtra): @model_validator(mode="after") def require_name_or_primary_coding(self) -> Self: - """Ensure that ``name`` or ``primaryCoding`` is provided""" + """Ensure that ``name`` or ``primaryCoding`` is provided + + :return: Validated object. + :raises ValueError: If both name and primaryCoding are None. + """ if self.primaryCoding is None and self.name is None: err_msg = "One of `name` or `primaryCoding` must be provided." raise ValueError(err_msg) diff --git a/src/ga4gh/core/pydantic.py b/src/ga4gh/core/pydantic.py index 0a41f778..2f8f74b2 100644 --- a/src/ga4gh/core/pydantic.py +++ b/src/ga4gh/core/pydantic.py @@ -1,17 +1,22 @@ """Provide helpful methods related to pydantic objects.""" import re -from typing import Any +from collections.abc import Sequence +from typing import Literal, TypeVar from pydantic import BaseModel, RootModel +_Model = TypeVar("_Model", bound=BaseModel) -def getattr_in(obj, names) -> Any: # noqa: ANN001 - """Call getattr on obj and the successive returns from getattr - using names as attribute names in order. If any return is None, - terminate early, and return None. + +def getattr_in(obj: object, names: Sequence[str]) -> object: + """Call getattr on obj and the successive returns from getattr using names as attribute names in order. If any return is None, terminate early, and return None. Like clojure (get-in obj names) or (some-> obj .name1 .name2 ...) + + :param obj: Object to inspect. + :param names: Attribute names to traverse in order. + :return: Final attribute value, or None if traversal stops early or names is empty. """ names_i = 0 v = None @@ -24,11 +29,14 @@ def getattr_in(obj, names) -> Any: # noqa: ANN001 return v -def is_curie_type(o: Any) -> bool: - """Return true if the object is a str-like matching the CURIE pattern. +def is_curie_type(o: object) -> re.Match[str] | Literal[False] | None: + """Match a string or root-model value against the CURIE pattern. If object is a Pydantic custom root type, extracts the value first, which enables (for example) a GA4GH iriReference pydantic model object to be passed. + + :param o: Object to process. + :return: Regex match for a CURIE, None for a nonmatching string, or False for other values. """ if isinstance(o, RootModel): o = o.root @@ -37,18 +45,33 @@ def is_curie_type(o: Any) -> bool: return False -def is_pydantic_instance(o: Any) -> bool: # noqa: D103 +def is_pydantic_instance(o: object) -> bool: + """Check whether a value is a Pydantic model. + + :param o: Object to process. + :return: Whether the value is a Pydantic model. + """ return isinstance(o, BaseModel) -def get_pydantic_root(obj: Any | RootModel) -> Any: - """If o is a Pydantic custom root type, return the root object, else return the input obj""" +def get_pydantic_root(obj: object) -> object: + """If o is a Pydantic custom root type, return the root object, else return the input obj + + :param obj: Object to inspect. + :return: Root value for a root model, otherwise the input. + """ if isinstance(obj, RootModel): return obj.root return obj -def pydantic_copy(obj: BaseModel) -> BaseModel: # noqa: D103 +def pydantic_copy(obj: _Model) -> _Model: + """Reconstruct a model from its serialized field values. + + :param obj: Object to inspect. + :return: New instance reconstructed from the model data. + :raises RuntimeError: If the input is not a Pydantic model. + """ pydantic_class = type(obj) if not issubclass(pydantic_class, BaseModel): msg = f"Argument was not a pydantic model: {pydantic_class!r}" diff --git a/src/ga4gh/vrs/dataproxy.py b/src/ga4gh/vrs/dataproxy.py index b4ea56cf..eca1a3c7 100644 --- a/src/ga4gh/vrs/dataproxy.py +++ b/src/ga4gh/vrs/dataproxy.py @@ -1,8 +1,6 @@ -"""provides an abstract class for all data access required for -VRS, and a concrete implementation based on seqrepo. +"""provides an abstract class for all data access required for VRS, and a concrete implementation based on seqrepo. See https://vr-spec.readthedocs.io/en/1.1/impl-guide/required_data.html - """ import datetime @@ -11,8 +9,12 @@ import os from abc import ABC, abstractmethod from collections.abc import Sequence +from typing import TYPE_CHECKING, NoReturn from urllib.parse import urlparse +if TYPE_CHECKING: + from biocommons.seqrepo import SeqRepo + import requests from bioutils.accessions import coerce_namespace @@ -26,10 +28,13 @@ class DataProxyValidationError(Exception): def validate_start_le_end(identifier: str, start: int | None, end: int | None) -> None: - """Reject an interval whose ``start`` is after its ``end``, since vrs-python treats - every sequence as linear. + """Reject an interval whose ``start`` is after its ``end``, since vrs-python treats every sequence as linear. :raises DataProxyValidationError: If both are defined and ``start > end`` + + :param identifier: Sequence identifier to resolve. + :param start: Zero-based inclusive start, or None for the sequence start. + :param end: Zero-based exclusive end, or None for the sequence end. """ if start is not None and end is not None and start > end: err_msg = f"Interval start must not be after end on {identifier}: start={start}, end={end}" @@ -61,12 +66,17 @@ def get_sequence( >> dp.get_sequence("NM_000551.3", 0, 10) 'CCTCGCCTCC' + :param identifier: Sequence identifier to resolve. + :param start: Zero-based inclusive start, or None for the sequence start. + :param end: Zero-based exclusive end, or None for the sequence end. + :return: Requested sequence or subsequence. + :raises KeyError: If the sequence identifier is not found. + :raises DataProxyValidationError: If start exceeds end. """ @abstractmethod def get_metadata(self, identifier: str) -> dict: - """For a given identifier, return a structure (dict) containing - sequence length, aliases, and other optional info + """For a given identifier, return a structure (dict) containing sequence length, aliases, and other optional info If the given sequence does not exist, KeyError is raised. @@ -81,18 +91,17 @@ def get_metadata(self, identifier: str) -> dict: 'alphabet': 'ACGT', 'length': 4560} + :param identifier: Sequence identifier to resolve. + :return: Sequence metadata, including length and aliases. + :raises KeyError: If the sequence identifier is not found. """ @staticmethod def extract_sequence_type(alias: str) -> str | None: """Provide a convenient way to extract the sequence type from an accession by matching its prefix to a known set of prefixes. - Args: - alias (str): The accession string. - - Returns: - str or None: The sequence type associated with the accession string, or None if no matching prefix is found. - + :param alias: Sequence accession or alias. + :return: HGVS sequence type, or None for an unknown prefix. """ prefix_dict = { "refseq:NM_": "c", @@ -118,15 +127,18 @@ def extract_sequence_type(alias: str) -> str | None: def translate_sequence_identifier( self, identifier: str, namespace: str | None = None ) -> list[str]: - """Translate given identifier to a list of identifiers in the - specified namespace. + """Translate given identifier to a list of identifiers in the specified namespace. `identifier` must be a string `namespace` is case-sensitive - On success, returns string identifier. Raises KeyError if given + On success, returns a list of matching identifiers. Raises KeyError if given identifier isn't found. + :param identifier: Sequence identifier to resolve. + :param namespace: Optional namespace used to filter sequence aliases. + :return: Distinct matching sequence aliases. + :raises KeyError: If the sequence identifier is not found. """ try: md = self.get_metadata(identifier) @@ -138,11 +150,11 @@ def translate_sequence_identifier( aliases = [a for a in aliases if a.startswith(nsd)] return aliases - def derive_refget_accession(self, ac: str) -> str | None: + def derive_refget_accession(self, ac: str | None) -> str | None: """Derive the refget accession from a public accession identifier :param ac: public accession in simple or curie form from which to derive the refget accession - :return: Refget Accession if found + :return: Refget accession, or None for a missing input or unresolved alias. """ if ac is None: return None @@ -170,10 +182,7 @@ def validate_ref_seq( ref: str, require_validation: bool = True, ) -> None: - """Determine whether or not the expected reference sequence matches the actual - reference sequence. Returns ``None``, but invalid results are logged at level - WARN by default. If ``require_validation`` is ``True``, then invalid data will - cause a ``DataProxyValidationError`` to be raised. + """Determine whether or not the expected reference sequence matches the actual reference sequence. Returns ``None``, but invalid results are logged at level WARN by default. If ``require_validation`` is ``True``, then invalid data will cause a ``DataProxyValidationError`` to be raised. :param sequence_id: Sequence ID to use :param start_pos: Start pos (inter-residue) on the sequence_id @@ -183,7 +192,7 @@ def validate_ref_seq( :param require_validation: If ``True`` and if validation checks fail, a ``DataProxyValidationError`` will be raised. Error message will always be logged. - :raises DataProxyValidationError: If excepted reference sequence does not match + :raises DataProxyValidationError: If expected reference sequence does not match the actual reference sequence and ``require_validation`` is ``True``. Raised regardless of ``require_validation`` if the location is invalid (see ``validate_location_bounds``). @@ -261,11 +270,19 @@ def validate_location_bounds( class _SeqRepoDataProxyBase(_DataProxy): + """Cache sequence access and metadata shared by SeqRepo backends.""" + # wraps seqreqpo classes in order to provide translation to/from # `ga4gh` identifiers. @functools.lru_cache def get_metadata(self, identifier: str) -> dict: + """Fetch sequence metadata. + + :param identifier: Sequence identifier to resolve. + :return: Sequence metadata, including length and aliases. + :raises KeyError: If the sequence identifier is not found. + """ md = self._get_metadata(identifier) md["aliases"] = list(a for a in md["aliases"]) # noqa: C400 return md @@ -275,24 +292,45 @@ def get_sequence( self, identifier: str, start: int | None = None, end: int | None = None ) -> str: # Checked here because backends disagree on how to handle it + """Fetch a sequence or subsequence. + + :param identifier: Sequence identifier to resolve. + :param start: Zero-based inclusive start, or None for the sequence start. + :param end: Zero-based exclusive end, or None for the sequence end. + :return: Requested sequence or subsequence. + :raises KeyError: If the sequence identifier is not found. + :raises DataProxyValidationError: If start exceeds end. + """ validate_start_le_end(identifier, start, end) return self._get_sequence(identifier, start=start, end=end) @abstractmethod def _get_metadata(self, identifier: str) -> dict: # pragma: no cover - pass + """Fetch sequence metadata from the backend. + + :param identifier: Sequence identifier to resolve. + :return: Sequence metadata, including length and aliases. + :raises KeyError: If the sequence identifier is not found. + """ @abstractmethod def _get_sequence( self, identifier: str, start: int | None = None, end: int | None = None ) -> str: # pragma: no cover - pass + """Fetch a sequence or subsequence from the backend. + + :param identifier: Sequence identifier to resolve. + :param start: Zero-based inclusive start, or None for the sequence start. + :param end: Zero-based exclusive end, or None for the sequence end. + :return: Requested sequence or subsequence. + :raises KeyError: If the sequence identifier is not found. + """ class SeqRepoDataProxy(_SeqRepoDataProxyBase): """DataProxy based on a local instance of SeqRepo""" - def __init__(self, sr) -> None: # noqa: ANN001 + def __init__(self, sr: "SeqRepo") -> None: """Initialize DataProxy instance. :param sr: SeqRepo instance @@ -304,9 +342,23 @@ def _get_sequence( self, identifier: str, start: int | None = None, end: int | None = None ) -> str: # fetch raises KeyError if not found + """Fetch a sequence or subsequence from the backend. + + :param identifier: Sequence identifier to resolve. + :param start: Zero-based inclusive start, or None for the sequence start. + :param end: Zero-based exclusive end, or None for the sequence end. + :return: Requested sequence or subsequence. + :raises KeyError: If the sequence identifier is not found. + """ return self.sr.fetch_uri(coerce_namespace(identifier), start, end) def _get_metadata(self, identifier: str) -> dict: + """Fetch sequence metadata from the backend. + + :param identifier: Sequence identifier to resolve. + :return: Sequence metadata, including length and aliases. + :raises KeyError: If the sequence identifier is not found. + """ ns, a = coerce_namespace(identifier).split(":", 2) r = list(self.sr.aliases.find_aliases(namespace=ns, alias=a)) if len(r) == 0: @@ -332,6 +384,7 @@ def __init__(self, base_url: str, disable_healthcheck: bool = False) -> None: :param base_url: root URL to server :param disable_healthcheck: Whether healthcheck should be disabled + :raises requests.RequestException: If the enabled health check fails. """ super().__init__() self.base_url = f"{base_url}/{self.rest_version}/" @@ -343,6 +396,15 @@ def __init__(self, base_url: str, disable_healthcheck: bool = False) -> None: def _get_sequence( self, identifier: str, start: int | None = None, end: int | None = None ) -> str: + """Fetch a sequence or subsequence from the backend. + + :param identifier: Sequence identifier to resolve. + :param start: Zero-based inclusive start, or None for the sequence start. + :param end: Zero-based exclusive end, or None for the sequence end. + :return: Requested sequence or subsequence. + :raises KeyError: If the sequence identifier is not found. + :raises requests.RequestException: If the request fails with an error other than a missing sequence. + """ url = self.base_url + f"sequence/{identifier}" _logger.info("Fetching %s", url) params = {"start": start, "end": end} @@ -353,6 +415,13 @@ def _get_sequence( return resp.text def _get_metadata(self, identifier: str) -> dict: + """Fetch sequence metadata from the backend. + + :param identifier: Sequence identifier to resolve. + :return: Sequence metadata, including length and aliases. + :raises KeyError: If the sequence identifier is not found. + :raises requests.RequestException: If the request fails with an error other than a missing sequence. + """ url = self.base_url + f"metadata/{identifier}" _logger.info("Fetching %s", url) resp = requests.get(url) # noqa: S113 @@ -362,30 +431,52 @@ def _get_metadata(self, identifier: str) -> dict: return resp.json() -class SequenceProxy(Sequence): - """Provides efficient and transparent string-like access, including - random access slicing and reversing, to a biological sequence that - is stored elsewhere. +class SequenceProxy(Sequence[str]): + """Fetch sequence characters and contiguous slices through a data proxy. + Slice steps and reversed iteration are unsupported. Coordinates are passed + to the backend without Python-style negative-index normalization. """ - def __init__(self, dp: _DataProxy, alias: str) -> None: # noqa: D107 + def __init__(self, dp: _DataProxy, alias: str) -> None: + """Initialize the sequence proxy. + + :param dp: Sequence data proxy. + :param alias: Sequence accession or alias. + """ self.dp = dp self.alias = alias self._md = self.dp.get_metadata(self.alias) - def __str__(self) -> str: # noqa: D105 + def __str__(self) -> str: + """Fetch the complete sequence. + + :return: Complete sequence. + """ return self.dp.get_sequence(self.alias) - def __len__(self): # noqa: D105 ANN204 + def __len__(self) -> int: + """Return the sequence length. + + :return: Sequence length. + """ return self._md["length"] - def __reversed__(self): # noqa: D105 ANN204 + def __reversed__(self) -> NoReturn: + """Reject reversed iteration. + + :raises NotImplementedError: Reversed iteration is unsupported. + """ msg = "Reversed iteration of a SequenceProxy is not implemented" raise NotImplementedError(msg) - def __getitem__(self, key) -> str: # noqa: ANN001 - """Return sequence for key (slice), fetching if necessary""" + def __getitem__(self, key: int | slice) -> str: + """Return sequence for key (slice), fetching if necessary + + :param key: Integer position or contiguous slice. + :return: Sequence at the requested position or slice. + :raises ValueError: If the slice specifies any step, including 1. + """ if isinstance(key, int): key = slice(key, key + 1) if key.step is not None: @@ -404,7 +495,7 @@ def _isoformat(o: datetime.datetime) -> str: :param o: date object to format :return: ISO8601-formatted string equivalent - :raise TypeError: if object given isn't a Python datetime instance + :raises TypeError: if object given isn't a Python datetime instance """ # stolen from connexion flask_app.py if not isinstance(o, datetime.datetime): @@ -430,15 +521,18 @@ def create_dataproxy( ) -> _DataProxy: """Create a dataproxy from uri or GA4GH_VRS_DATAPROXY_URI - :param uri: Dataproxy URI. Currently accepted URI schemes: + :param uri: Dataproxy URI, or None to read GA4GH_VRS_DATAPROXY_URI. + No default server is assumed. Currently accepted URI schemes: * seqrepo+file:///path/to/seqrepo/root * seqrepo+:../relative/path/to/seqrepo/root * seqrepo+http://localhost:5000/seqrepo * seqrepo+https://somewhere:5000/seqrepo :param disable_healthcheck: Whether healthcheck should be disabled in REST dataproxy - :raise ValueError: if URI doesn't match recognized schemes, e.g. is missing provider + :raises ValueError: if no URI is configured or the URI does not match recognized schemes, e.g. is missing provider prefix (`"seqrepo+"`) + + :return: Data proxy for the requested provider. """ uri = uri or os.environ.get("GA4GH_VRS_DATAPROXY_URI", None) diff --git a/src/ga4gh/vrs/enderef.py b/src/ga4gh/vrs/enderef.py index d4616046..1fa1cf6d 100644 --- a/src/ga4gh/vrs/enderef.py +++ b/src/ga4gh/vrs/enderef.py @@ -1,3 +1,26 @@ +"""Convert VRS objects between inline objects and identifier references. + +Given an Allele with an inline SequenceLocation:: + + from ga4gh.vrs import vrs_deref, vrs_enref + + store = {} + allele_id, referenced = vrs_enref(allele, store, return_id_obj_tuple=True) + restored = vrs_deref(referenced, store) + +The round trip is:: + + allele.location -> SequenceLocation(...) + referenced.location -> "ga4gh:SL." + store[allele_id] -> referenced Allele + restored.location -> stored SequenceLocation + +Both operations copy the top-level input. Dereferencing inserts store values +directly, so resolving nested references can mutate those stored objects. +""" + +from collections.abc import Mapping, MutableMapping + from pydantic.main import BaseModel from ga4gh.core import ga4gh_deref, ga4gh_enref @@ -5,7 +28,21 @@ from .models import class_refatt_map -def vrs_enref(o, object_store=None, return_id_obj_tuple: bool = False): +def vrs_enref( + o: BaseModel, + object_store: MutableMapping[str, BaseModel] | None = None, + return_id_obj_tuple: bool = False, +) -> BaseModel | tuple[str | None, BaseModel]: + """Copy a VRS object and replace referable attributes with identifiers. + + :param o: Identifiable VRS object to copy. + :param object_store: Optional store for objects replaced by references. + :param return_id_obj_tuple: Include the identifier alongside the copied object. + :return: Referenced copy, optionally paired with its identifier. + :raises ValueError: If the input is not an identifiable Pydantic object. + :raises TypeError: If a root-model CURIE reference has a non-GA4GH prefix. + Plain string references are left unchanged. + """ return ga4gh_enref( o, cra_map=class_refatt_map, @@ -14,5 +51,14 @@ def vrs_enref(o, object_store=None, return_id_obj_tuple: bool = False): ) -def vrs_deref(o, object_store) -> BaseModel: +def vrs_deref(o: BaseModel, object_store: Mapping[str, BaseModel]) -> BaseModel: + """Copy a VRS object and resolve its identifier references. + + :param o: Identifiable VRS object to copy. + :param object_store: Store mapping identifiers to referenced objects. Retrieved + objects are inserted directly and may be mutated during dereferencing. + :return: Copy with references replaced by inline objects. + :raises ValueError: If the input is not an identifiable Pydantic object. + :raises KeyError: If a referenced identifier is absent from the store. + """ return ga4gh_deref(o, cra_map=class_refatt_map, object_store=object_store) diff --git a/src/ga4gh/vrs/extras/annotator/cli.py b/src/ga4gh/vrs/extras/annotator/cli.py index 4ebdb206..4b4ed802 100644 --- a/src/ga4gh/vrs/extras/annotator/cli.py +++ b/src/ga4gh/vrs/extras/annotator/cli.py @@ -1,12 +1,13 @@ """Define command-line interface for VRS annotator tool. -$ vrs-annotate vcf input.vcf.gz --vcf_out output.vcf.gz --vrs_pickle_out vrs_objects.pkl +$ vrs-annotate vcf input.vcf.gz --vcf-out output.vcf.gz --pkl-out vrs_objects.pkl """ import logging from collections.abc import Callable from enum import Enum +from os import PathLike from pathlib import Path from timeit import default_timer as timer @@ -48,7 +49,13 @@ def _log_level_option(func: Callable) -> Callable: :return: same command, wrapped with log level option """ - def _set_log_level(ctx: dict, param: str, value: _LogLevel) -> None: # noqa: ARG001 + def _set_log_level(ctx: click.Context, param: click.Parameter, value: str) -> None: # noqa: ARG001 + """Set the module log level from the command option. + + :param ctx: Click command context. + :param param: Click parameter being converted. + :param value: Value to convert. + """ level_map = { _LogLevel.DEBUG: logging.DEBUG, _LogLevel.INFO: logging.INFO, @@ -74,15 +81,28 @@ class PathOrDash(click.ParamType): name = "path-or-dash" - def __init__(self, **kwargs) -> None: - """Initialize PathOrDash""" + def __init__(self, **kwargs: object) -> None: + """Initialize PathOrDash + + :param kwargs: Options for click.Path, including path_type (defaults to Path). + """ self._path_type = kwargs.pop("path_type", Path) self._inner = click.Path(**kwargs, path_type=self._path_type) def convert( - self, value: str, param: click.Parameter | None, ctx: click.Context | None - ) -> str | Path: - """Convert the value using click.Path if it is not '-'""" + self, + value: str | bytes | PathLike[str], + param: click.Parameter | None, + ctx: click.Context | None, + ) -> str | bytes | PathLike[str]: + """Convert the value using click.Path if it is not '-' + + :param value: Value to convert. + :param param: Click parameter being converted. + :param ctx: Click command context. + :return: Dash, or a validated path converted using the configured path_type. + :raises click.BadParameter: If the path fails validation. + """ if value == "-": return value # Use click.Path for validation @@ -171,7 +191,7 @@ def _annotate_vcf_cli( pkl_out: Path | None, ndjson_out: Path | None, vrs_attributes: bool, - dataproxy_uri: str, + dataproxy_uri: str | None, assembly: str, skip_ref: bool, require_validation: bool, @@ -186,9 +206,7 @@ def _annotate_vcf_cli( Sequence data from a provider such as SeqRepo is required. Use the `--dataproxy-uri` option or the environment variable `GA4GH_VRS_DATAPROXY_URI` to define its location - (the former will take priority over the latter when both are set). This option - falls back upon the VRS-Python library default `seqrepo+http://localhost:5000/seqrepo` - value if it is not otherwise declared. + (the former will take priority over the latter when both are set). A URI must be supplied through one of these sources; there is no default server. Currently accepted URI schemes: @@ -197,7 +215,18 @@ def _annotate_vcf_cli( * seqrepo+https://somewhere:5000/seqrepo * seqrepo+file:///path/to/seqrepo/root * seqrepo+:../relative/path/to/seqrepo/root - """ # noqa: D301 + + :param vcf_in: Input VCF path or dash for standard input. + :param vcf_out: Output VCF path or dash for standard output. + :param pkl_out: Optional pickle output path. + :param ndjson_out: Optional NDJSON output path. + :param vrs_attributes: Include VRS attributes in VCF INFO fields. + :param dataproxy_uri: URI for the sequence data provider. + :param assembly: Reference assembly name. + :param skip_ref: Skip reference-allele annotation when true. + :param require_validation: Require reference sequence validation. + :param silent: Suppress progress output when true. + """ # noqa: D301 -- Click requires the interpreted backspace escape. data_proxy = create_dataproxy(dataproxy_uri) annotator = VcfAnnotator(data_proxy) start = timer() diff --git a/src/ga4gh/vrs/extras/annotator/vcf.py b/src/ga4gh/vrs/extras/annotator/vcf.py index 44fc7793..8e126d28 100644 --- a/src/ga4gh/vrs/extras/annotator/vcf.py +++ b/src/ga4gh/vrs/extras/annotator/vcf.py @@ -1,4 +1,39 @@ -"""Annotate VCFs with VRS identifiers and attributes.""" +"""Annotate VCFs with VRS identifiers and attributes. + +The main processing path is:: + + annotate(...) + + for each record: _get_vrs_data(...) + + for each allele: _get_vrs_object(...) + + AlleleTranslator.from_gnomad(...) + + on_vrs_object(...) # return an allele or None + + on_vrs_object_collection(...) # when collection is enabled + +For example, keep only alleles with literal states:: + + from pathlib import Path + from ga4gh.vrs.extras.annotator.vcf import VcfAnnotator + from ga4gh.vrs.models import Allele, LiteralSequenceExpression + + + class LiteralStateAnnotator(VcfAnnotator): + def on_vrs_object( + self, vcf_coords: str, vrs_allele: Allele, **kwargs: object + ) -> Allele | None: + if isinstance(vrs_allele.state, LiteralSequenceExpression): + return vrs_allele + return None + + + # data_proxy must serve the input VCF's reference assembly. + LiteralStateAnnotator(data_proxy).annotate( + Path("input.vcf"), output_ndjson_path=Path("literal-alleles.ndjson") + ) + +A pickle or NDJSON output enables VcfAnnotator's collection hook. Returning None +excludes the allele from collection. If writing a VCF, its record remains with +missing annotation values. Per-record errors are logged and written to VRS_Error. +""" import abc import logging @@ -88,8 +123,9 @@ def dump_alleles_to_ndjson( ) -> None: """Create NDJSON dump of all alleles ingested from VCF - :param alleles: all alleles + constructed + annotated during VCF ingestion :param output_ndjson_path: location to save NDJSON file to + + :param allele_collection: Alleles to write. """ with output_ndjson_path.open("w") as f: for allele in allele_collection: @@ -111,15 +147,17 @@ class AbstractVcfAnnotator(abc.ABC): collect_alleles: bool = False - def __init__(self, data_proxy: _DataProxy, **kwargs) -> None: # noqa: ARG002 + def __init__(self, data_proxy: _DataProxy, **kwargs: object) -> None: # noqa: ARG002 """Initialize the VCFAnnotator class. :param data_proxy: GA4GH sequence dataproxy instance. + + :param kwargs: Additional options passed to the implementation. """ self.data_proxy = data_proxy self.tlr = AlleleTranslator(self.data_proxy) - def should_collect_alleles(self, **kwargs) -> bool: # noqa: ARG002 + def should_collect_alleles(self, **kwargs: object) -> bool: # noqa: ARG002 """Determine whether allele aggregation is necessary. This method is called to initialize (or not) an initial allele collection for @@ -128,17 +166,24 @@ def should_collect_alleles(self, **kwargs) -> bool: # noqa: ARG002 arguments. :return: ``True`` if alleles should be collected + + :param kwargs: Additional options passed to the implementation. """ return self.collect_alleles @abc.abstractmethod - def raise_for_output_args(self, output_vcf_path: Path | None, **kwargs) -> None: + def raise_for_output_args( + self, output_vcf_path: Path | Literal["-"] | None, **kwargs: object + ) -> None: """Raise an exception if no output appears to be configured or declared. Child classes should implement this to ensure that any other outputs are checked on top of an annotated VCF output. - :raise VCFAnnotatorError: if no output args are shown + :raises VcfAnnotatorError: if no output args are shown + + :param output_vcf_path: Optional annotated VCF output path. + :param kwargs: Additional options passed to the implementation. """ def _update_vcf_header( @@ -225,10 +270,9 @@ def annotate( assembly: str = "GRCh38", compute_for_ref: bool = True, require_validation: bool = True, - **kwargs, + **kwargs: object, ) -> None: - """Given a VCF, produce an output VCF annotated with VRS allele IDs, and/or - additional storage outputs as implemented in a specific child class. + """Given a VCF, produce an output VCF annotated with VRS allele IDs, and/or additional storage outputs as implemented in a specific child class. :param input_vcf_path: Location of input VCF (or "-" to read from stdin) :param output_vcf_path: The path for the output VCF file (or "-" to write to stdout) @@ -239,10 +283,12 @@ def annotate( :param compute_for_ref: If true, compute VRS IDs for the reference allele :param require_validation: If `True`, validation checks (i.e., REF value matches expected REF at given location) must pass in order to return a VRS - object for a record. If `False` then VRS object will be returned even if - validation checks fail, although all instances of failed validation are - logged as warnings regardless. - :raise VCFAnnotatorError: if no output formats are selected + object for a record. False permits reference mismatches, which are + logged as warnings; location bounds are still validated. Per-record + errors are logged and written to VRS_Error when VCF output is enabled. + :raises VcfAnnotatorError: if no output formats are selected + + :param kwargs: Additional options passed to the implementation. """ self.raise_for_output_args(output_vcf_path, **kwargs) # This can be pushed up to the click arg parsing too @@ -331,30 +377,32 @@ def annotate( @abc.abstractmethod def on_vrs_object( - self, vcf_coords: str, vrs_allele: Allele, **kwargs + self, vcf_coords: str, vrs_allele: Allele, **kwargs: object ) -> Allele | None: - """Perform side-effects (eg additional annotation or storage) or additional - filtering on VRS alleles as they are constructed during VCF annotation. + """Perform side-effects (eg additional annotation or storage) or additional filtering on VRS alleles as they are constructed during VCF annotation. - Reimplement in a child class to add custom logic. Otherwise, simply pass through - ``vrs_allele`` without altering it further or storing it. + Implementations return the retained allele or None to discard it. The + abstract method itself performs no processing. :param vcf_coords: CHR-POS-REF-ALT from VCF for this allele :param vrs_allele: allele translated from coords - :return: final VRS allele + :return: Final allele, or None to filter it from collection. + + :param kwargs: Additional options passed to the implementation. """ @abc.abstractmethod def on_vrs_object_collection( - self, vrs_alleles_collection: list[Allele] | None, **kwargs + self, vrs_alleles_collection: list[Allele] | None, **kwargs: object ) -> None: - """Perform clean-up operations (eg file writing) on VRS objects collected - during VCF ingestion. + """Perform clean-up operations (eg file writing) on VRS objects collected during VCF ingestion. Reimplement in a child class to add custom logic. Otherwise, this method does nothing. :param vrs_alleles_collection: VRS alleles constructed from ingested VCF + + :param kwargs: Additional options passed to the implementation. """ def _get_vrs_object( @@ -365,25 +413,25 @@ def _get_vrs_object( assembly: str, vrs_attributes: bool = False, require_validation: bool = True, - **kwargs, + **kwargs: object, ) -> None: - """Get VRS object given `vcf_coords`. `vrs_data` and `vrs_field_data` will - be mutated. + """Translate one allele and append it and its attributes to the supplied collections. :param vcf_coords: Allele to get VRS object for. Format is chr-pos-ref-alt :param allele_collection: All constructed VRS objects. Can be `None` if no data dumps will be created. - :param vrs_field_data: If `vrs_data`, keys are VRS Fields and values are list - of VRS data. Empty dict otherwise. + :param vrs_field_data: Mapping from requested INFO field names to lists to append to. + An empty mapping disables attribute collection. :param assembly: The assembly used in `vcf_coords` :param vrs_attributes: If `True` will include VRS_Start, VRS_End, VRS_State fields in the INFO field. If `False` will not include these fields. Only used if `output_vcf` set to `True`. :param require_validation: If `True` then validation checks must pass in order - to return a VRS object. If `False` then VRS object will be returned even if - validation checks fail. Defaults to `True`. + to accept a reference match. False permits reference mismatches; + invalid location bounds still raise. Defaults to True. :param kwargs: Additional options passed to ``on_vrs_object``. :raises Exception: If translation fails. The exception is logged and re-raised. + :raises VcfAnnotatorError: If a requested attribute has an unsupported state or length. """ try: vrs_obj = self.tlr.from_gnomad( @@ -467,13 +515,13 @@ def _get_vrs_object( def _get_vrs_data( self, record: pysam.VariantRecord, - allele_collection: list | None, + allele_collection: list[Allele] | None, assembly: str, additional_info_fields: list[FieldName], vrs_attributes: bool = False, compute_for_ref: bool = True, require_validation: bool = True, - **kwargs, + **kwargs: object, ) -> dict: """Get VRS data for record's reference and alt alleles. @@ -488,10 +536,12 @@ def _get_vrs_data( :param compute_for_ref: If true, compute VRS IDs for the reference allele :param require_validation: If `True` then validation checks must pass in order to return a VRS object. A `DataProxyValidationError` will be raised if - validation checks fail. If `False` then VRS object will be returned even if - validation checks fail. Defaults to `True`. + validation checks fail. False permits reference mismatches; + invalid location bounds still raise. Defaults to True. :return: A dictionary mapping VRS-related INFO fields to lists of associated - values. Will be empty if `create_annotated_vcf` is false. + values. Empty when additional_info_fields is empty. + + :param kwargs: Additional options passed to the implementation. """ vrs_field_data = {field.value: [] for field in additional_info_fields} @@ -542,28 +592,27 @@ class VcfAnnotator(AbstractVcfAnnotator): pkl_arg_name = "output_pkl_path" ndjson_arg_name = "output_ndjson_path" - def should_collect_alleles(self, **kwargs) -> bool: + def should_collect_alleles(self, **kwargs: object) -> bool: """Inhibit allele collection parameter if no means of output are given. - :kwparam output_pkl_path: Optional path to output PKL dump of all alleles - :kwparam output_ndjson_path: Optional path to output NDJSON dump of all alleles - :return: ``True`` if at least one of the output path args is ``True`` + :return: ``True`` if at least one output path is truthy + + :param kwargs: Optional output_pkl_path and output_ndjson_path destinations. """ return bool(kwargs.get("output_pkl_path") or kwargs.get("output_ndjson_path")) @use_ga4gh_compute_identifier_when(VrsObjectIdentifierIs.MISSING) def annotate( self, - input_vcf_path: Path, - output_vcf_path: Path | None = None, + input_vcf_path: Path | Literal["-"], + output_vcf_path: Path | Literal["-"] | None = None, vrs_attributes: bool = False, assembly: str = "GRCh38", compute_for_ref: bool = True, require_validation: bool = True, - **kwargs, + **kwargs: object, ) -> None: - """Given a VCF, produce an output VCF annotated with VRS allele IDs, and/or - a PKL or NDJSON dump of the corresponding VRS objects. + """Given a VCF, produce an output VCF annotated with VRS allele IDs, and/or a PKL or NDJSON dump of the corresponding VRS objects. :param input_vcf_path: Location of input VCF :param output_vcf_path: The path for the output VCF file @@ -574,12 +623,12 @@ def annotate( :param compute_for_ref: If true, compute VRS IDs for the reference allele :param require_validation: If `True`, validation checks (i.e., REF value matches expected REF at given location) must pass in order to return a VRS - object for a record. If `False` then VRS object will be returned even if - validation checks fail, although all instances of failed validation are - logged as warnings regardless. - :kwparam output_pkl_path: Optional path to output PKL dump of all alleles - :kwparam output_ndjson_path: Optional path to output NDJSON dump of all alleles - :raise VCFAnnotatorError: if no output formats are selected + object for a record. False permits reference mismatches, which are + logged as warnings; location bounds are still validated. Per-record + errors are logged and written to VRS_Error when VCF output is enabled. + :raises VcfAnnotatorError: if no output formats are selected + + :param kwargs: Optional output_pkl_path and output_ndjson_path destinations. """ return super().annotate( input_vcf_path, @@ -591,14 +640,15 @@ def annotate( **kwargs, ) - def raise_for_output_args(self, output_vcf_path: Path | None, **kwargs) -> None: - """Raise an exception if no output (VCF, PKL, NDJSON) appears to be configured - or declared. + def raise_for_output_args( + self, output_vcf_path: Path | Literal["-"] | None, **kwargs: object + ) -> None: + """Raise an exception if no output (VCF, PKL, NDJSON) appears to be configured or declared. :param output_vcf_path: VCF output path arg passed to `annotate()` - :kwparam output_pkl_path: optional path to PKL output - :kwparam output_ndjson_path: optional path to NDJSON output - :raise VCFAnnotatorArgsError: if no output args are given + :raises VcfAnnotatorArgsError: if no output args are given + + :param kwargs: Optional output_pkl_path and output_ndjson_path destinations. """ if ( output_vcf_path is None @@ -612,26 +662,28 @@ def on_vrs_object( self, vcf_coords: str, # noqa: ARG002 vrs_allele: Allele, - **kwargs, # noqa: ARG002 + **kwargs: object, # noqa: ARG002 ) -> Allele | None: - """Perform side-effects (eg additional annotation or storage) or additional - filtering on VRS alleles as they are constructed during VCF annotation. + """Perform side-effects (eg additional annotation or storage) or additional filtering on VRS alleles as they are constructed during VCF annotation. For this basic implementation, no additional operations are performed. :param vcf_coords: CHR-POS-REF-ALT from VCF for this allele :param vrs_allele: allele translated from coords - :return: final VRS allele + :return: Final allele, or None to filter it from collection. + + :param kwargs: Optional output_pkl_path and output_ndjson_path destinations. """ return vrs_allele def on_vrs_object_collection( - self, vrs_alleles_collection: list[Allele] | None, **kwargs + self, vrs_alleles_collection: list[Allele] | None, **kwargs: object ) -> None: - """Perform clean-up operations (eg file writing) on VRS objects collected - during VCF ingestion. + """Perform clean-up operations (eg file writing) on VRS objects collected during VCF ingestion. :param vrs_alleles_collection: VRS alleles constructed from ingested VCF + + :param kwargs: Optional output_pkl_path and output_ndjson_path destinations. """ if vrs_alleles_collection is not None: output_pkl_path = kwargs.get(self.pkl_arg_name) diff --git a/src/ga4gh/vrs/extras/decorators.py b/src/ga4gh/vrs/extras/decorators.py index c64d2bee..96d75785 100644 --- a/src/ga4gh/vrs/extras/decorators.py +++ b/src/ga4gh/vrs/extras/decorators.py @@ -1,17 +1,26 @@ """decorators for vrs-python""" from collections.abc import Callable +from typing import TypeVar +_Instance = TypeVar("_Instance") +_Value = TypeVar("_Value") -def lazy_property(fn: Callable) -> property: + +def lazy_property(fn: Callable[[_Instance], _Value]) -> property: """Provide a decorator that makes a property lazy-evaluated. - [mv] + :param fn: Function whose result is cached on each instance. + :return: Property that caches its value on first access. """ attr_name = "_lazy_" + fn.__name__ @property - def _lazy_property(self): # noqa: ANN001 ANN202 + def _lazy_property(self: _Instance) -> _Value: + """Compute and cache the property value on first access. + + :return: Cached or newly computed value. + """ if not hasattr(self, attr_name): setattr(self, attr_name, fn(self)) return getattr(self, attr_name) diff --git a/src/ga4gh/vrs/extras/object_store.py b/src/ga4gh/vrs/extras/object_store.py index 576da868..02bf5412 100644 --- a/src/ga4gh/vrs/extras/object_store.py +++ b/src/ga4gh/vrs/extras/object_store.py @@ -1,25 +1,31 @@ +"""Store pickled Python objects in a SQLite-backed mutable mapping.""" + import pickle import sqlite3 -from collections.abc import MutableMapping +from collections.abc import Iterator, MutableMapping from threading import Lock -from typing import Any +from types import TracebackType +from typing import Self -class Sqlite3MutableMapping(MutableMapping): - """Class that can be used like a Python dictionary but that uses a sqlite3 database - as the storage. Can also be opened as a contextmanager. +class Sqlite3MutableMapping(MutableMapping[str, object]): + """Store a mutable mapping in SQLite. - If not used as a contextmanager, user must call commit and/or close. + The context manager commits or rolls back pending transactions. With + autocommit enabled, earlier writes have already been committed and cannot + be rolled back by the context manager. Call ``close`` to + release the connection. With autocommit disabled, call ``commit`` to persist + changes outside a context manager. """ def __init__( self, sqlite3_db: str | sqlite3.Connection, autocommit: bool = True ) -> None: - """Connect to the sqlite3 database specified by an existing sqlite3.Connection - or a connection string. + """Open a database connection and initialize the mapping schema. - - autocommit: if False, disables commit after every setitem/delitem. - Significant performance implication (>10X speedup) + :param sqlite3_db: Database path or existing connection. Schema initialization + commits pending work on an existing connection; close also closes it. + :param autocommit: Commit after each insertion or deletion when true. """ if isinstance(sqlite3_db, str): sqlite3_db = sqlite3.connect(sqlite3_db, check_same_thread=True) @@ -30,6 +36,7 @@ def __init__( self._create_schema() def _create_schema(self) -> None: + """Create the mapping table and unique key index.""" cur = self.db.cursor() try: cur.execute("create table if not exists mapping (key text, value blob)") @@ -41,12 +48,16 @@ def _create_schema(self) -> None: cur.close() def __del__(self) -> None: + """Commit pending changes and close the connection.""" self.close() - def __delitem__(self, key: Any) -> None: - # Raise KeyError + def __delitem__(self, key: str) -> None: + """Delete a stored value. + + :param key: Key to remove. + :raises KeyError: If the key is absent. + """ self[key] - # Delete if found cur = self.db.cursor() try: cur.execute("delete from mapping where key = ?", (key,)) @@ -55,7 +66,12 @@ def __delitem__(self, key: Any) -> None: finally: cur.close() - def __setitem__(self, key: Any, value: Any) -> None: + def __setitem__(self, key: str, value: object) -> None: + """Pickle and store a value, replacing any existing entry. + + :param key: Storage key. + :param value: Picklable value to store. + """ cur = self.db.cursor() try: ser = pickle.dumps(value) @@ -68,7 +84,13 @@ def __setitem__(self, key: Any, value: Any) -> None: finally: cur.close() - def __getitem__(self, key: Any) -> Any: + def __getitem__(self, key: str) -> object: + """Load and unpickle a stored value. + + :param key: Storage key. + :return: Stored Python object. + :raises KeyError: If the key is absent. + """ cur = self.db.cursor() try: rows = cur.execute("select value from mapping where key = ?", (key,)) @@ -80,7 +102,11 @@ def __getitem__(self, key: Any) -> Any: finally: cur.close() - def __iter__(self): + def __iter__(self) -> Iterator[str]: + """Iterate over stored keys. + + :return: Iterator yielding each key. + """ cur = self.db.cursor() try: rows = cur.execute("select key from mapping") @@ -89,7 +115,11 @@ def __iter__(self): finally: cur.close() - def __len__(self): + def __len__(self) -> int: + """Count stored entries. + + :return: Number of entries. + """ cur = self.db.cursor() try: rows = cur.execute("select count(*) from mapping") @@ -98,18 +128,35 @@ def __len__(self): cur.close() def commit(self) -> None: + """Commit pending changes to the database.""" self.db.commit() def close(self) -> None: + """Commit pending changes and close the connection once.""" with self._closed_lock: if not self._closed: self.commit() self.db.close() self._closed = True - def __enter__(self): + def __enter__(self) -> Self: + """Enter the database transaction context. + + :return: This mapping. + """ self.db.__enter__() return self - def __exit__(self, exc_type, exc_value, traceback) -> None: + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Commit a successful transaction or roll back a failed transaction. + + :param exc_type: Exception class, if the context raised. + :param exc_value: Exception instance, if the context raised. + :param traceback: Traceback for the exception, if any. + """ self.db.__exit__(exc_type, exc_value, traceback) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index b7b21aab..c2e8d067 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -1,7 +1,7 @@ """Translates various external formats into VRS models. Input formats: VRS (serialized), hgvs, spdi, gnomad (vcf) -Output formats: VRS (serialized), hgvs, spdi, gnomad (vcf) +Output formats: hgvs and spdi for Alleles; no copy-number exporters. """ @@ -44,8 +44,13 @@ class VariationToStrProtocol(Protocol): into variation strings, with optional keyword arguments for customization. """ - def __call__(self, vo: models._VariationBase, **kwargs) -> list[str]: - """Translate vrs object `vo` to variation string expressions""" + def __call__(self, vo: models._VariationBase, **kwargs: object) -> list[str]: + """Translate vrs object `vo` to variation string expressions + + :param vo: VRS object to translate. + :param kwargs: Additional options passed to the implementation. + :return: Translated expressions. + """ class VariationFromStrProtocol(Protocol): @@ -55,8 +60,15 @@ class VariationFromStrProtocol(Protocol): string into a VRS object, with optional keyword arguments for customization. """ - def __call__(self, expr: str, **kwargs) -> models._VariationBase | None: - """Translate variation string `expr` to a VRS object""" + def __call__( + self, expr: str | dict, **kwargs: object + ) -> models._VariationBase | None: + """Translate variation string `expr` to a VRS object + + :param expr: Variation expression or VRS dictionary for the selected parser. + :param kwargs: Additional options passed to the implementation. + :return: Translated VRS object, or None if unsupported. + """ class _Translator(ABC): # noqa: B024 @@ -102,7 +114,7 @@ def __init__( self.from_vrs_translators: dict[str, VariationToStrProtocol] = {} def to_vrs( - self, var: str | dict, fmt: str | None = None, **kwargs + self, var: str | dict, fmt: str | None = None, **kwargs: object ) -> models._VariationBase: """Translate a variation expression or VRS JSON dictionary to a VRS object. @@ -137,8 +149,8 @@ def to_vrs( including zero. If supplied, returns CopyNumberCount and takes precedence over ``copy_change``. Defaults to None. * ``copy_change`` (models.CopyChange or None): Copy change used when - ``copies`` is omitted or None. Defaults to LOSS (EFO:0030067) for - deletions and GAIN (EFO:0030070) for duplications. + ``copies`` is omitted or None. Defaults to CopyChange.LOSS ("loss") for + deletions and CopyChange.GAIN ("gain") for duplications. :return: Translated VRS variation. :raises NotImplementedError: If the requested format is unsupported. :raises ValueError: If the input cannot be parsed or represented. @@ -166,7 +178,9 @@ def to_vrs( msg = f"Unable to parse data as {', '.join(formats)}" raise ValueError(msg) - def from_vrs(self, vo: models._VariationBase, fmt: str, **kwargs) -> list[str]: + def from_vrs( + self, vo: models._VariationBase, fmt: str, **kwargs: object + ) -> list[str]: """Translate a VRS object to expressions in the requested format. :param vo: VRS variation to translate. @@ -198,7 +212,11 @@ def from_vrs(self, vo: models._VariationBase, fmt: str, **kwargs) -> list[str]: return t(vo, **kwargs) - def from_vrs_dict(self, var: dict, **kwargs) -> models._VariationBase | None: # noqa: ARG002 + def from_vrs_dict( + self, + var: object, + **kwargs: object, # noqa: ARG002 + ) -> models._VariationBase | None: """Parse a VRS JSON dictionary into a VRS variation object. :param var: Dictionary containing a VRS variation and its ``type``. @@ -217,7 +235,10 @@ def from_vrs_dict(self, var: dict, **kwargs) -> models._VariationBase | None: # @lazy_property def hgvs_tools(self) -> HgvsTools: - """Instantiate and return an HgvsTools instance""" + """Instantiate and return an HgvsTools instance + + :return: Lazily initialized HGVS tools. + """ return HgvsTools(self.data_proxy) @@ -289,7 +310,7 @@ def from_gnomad( require_validation: bool = True, do_normalize: bool = True, rle_seq_limit: int | None | TranslationDefault = USE_INSTANCE_DEFAULT, - **kwargs, # noqa: ARG002 + **kwargs: object, # noqa: ARG002 ) -> models.Allele | None: """Parse gnomAD-style VCF expression into VRS Allele @@ -371,7 +392,7 @@ def from_hgvs( *, do_normalize: bool = True, rle_seq_limit: int | None | TranslationDefault = USE_INSTANCE_DEFAULT, - **kwargs, # noqa: ARG002 + **kwargs: object, # noqa: ARG002 ) -> models.Allele | None: """Parse an HGVS expression into a VRS Allele. @@ -406,7 +427,7 @@ def from_spdi( *, do_normalize: bool = True, rle_seq_limit: int | None | TranslationDefault = USE_INSTANCE_DEFAULT, - **kwargs, # noqa: ARG002 + **kwargs: object, # noqa: ARG002 ) -> models.Allele | None: """Parse SPDI expression in to a GA4GH Allele @@ -474,7 +495,7 @@ def to_hgvs( self, vo: models.Allele, namespace: str | None = "refseq", - **kwargs, # noqa: ARG002 + **kwargs: object, # noqa: ARG002 ) -> list[str]: """Generate HGVS expressions for a VRS Allele. @@ -492,7 +513,7 @@ def to_spdi( namespace: str | None = "refseq", *, ref_seq_limit: int | None = 0, - **kwargs, # noqa: ARG002 + **kwargs: object, # noqa: ARG002 ) -> list[str]: """Generate a *list* of SPDI expressions for VRS Allele. @@ -503,7 +524,7 @@ def to_spdi( translations. If `ref_seq_limit` is specified, the reference sequence is - included in the SPDI expression only if it is below the limit. + included in the SPDI expression only if its length is at most 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. @@ -512,7 +533,10 @@ def to_spdi( If no alias translations are available, an empty list is returned. - If the VRS object cannot be expressed as SPDI, raises ValueError. + Requires an inline SequenceLocation with integer bounds and either a + LiteralSequenceExpression or a ReferenceLengthExpression with integer + length and a positive repeatSubunitLength. Unsupported model shapes + are not validated before their fields are accessed. SPDI and VRS use identical normalization. The incoming Allele is expected to be normalized per VRS spec. @@ -523,7 +547,7 @@ def to_spdi( None always includes the reference sequence. :param kwargs: Unused options accepted for format dispatch. :return: SPDI expressions, or an empty list if no aliases are available. - :raises ValueError: If the object cannot be represented as SPDI. + :raises ValueError: If a reference repeat subunit cannot be reconstructed. """ sequence = f"ga4gh:{vo.location.get_refget_accession()}" aliases = self.data_proxy.translate_sequence_identifier(sequence, namespace) @@ -624,7 +648,7 @@ def from_hgvs( *, copies: int | models.Range | None = None, copy_change: models.CopyChange | None = None, - **kwargs, # noqa: ARG002 + **kwargs: object, # noqa: ARG002 ) -> models.CopyNumberChange | models.CopyNumberCount | None: """Parse an HGVS deletion or duplication into a VRS copy number object. @@ -679,7 +703,11 @@ def from_hgvs( def _post_process_imported_cnv( self, copy_number: models.CopyNumberChange | models.CopyNumberCount ) -> models.CopyNumberChange | models.CopyNumberCount: - """Provide common post-processing for imported Copy Numbers IN-PLACE.""" + """Provide common post-processing for imported Copy Numbers IN-PLACE. + + :param copy_number: Copy-number object to update in place. + :return: Updated copy-number object. + """ if self.identify: copy_number.id = ga4gh_identify(copy_number) copy_number.location.id = ga4gh_identify(copy_number.location) diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index 8e990dc2..0f21770c 100644 --- a/src/ga4gh/vrs/models.py +++ b/src/ga4gh/vrs/models.py @@ -15,6 +15,7 @@ import sys from abc import ABC from collections import OrderedDict +from collections.abc import Iterator from enum import Enum from types import UnionType from typing import Annotated, ClassVar, Literal, get_args, get_origin @@ -49,13 +50,18 @@ from ga4gh.vrs.version import VRS_VERSION -def flatten(vals): - """Flatten vals recursively, lazily using yield""" +def flatten(vals: object) -> Iterator[object]: + """Yield values from nested lists and sets. - def is_coll(thing) -> bool: + :param vals: Scalar or nested collection to flatten. + :return: Iterator over leaf values. + """ + + def is_coll(thing: object) -> bool: """Return True if the thing looks like a collection. - This is not exhaustive, do not use in general. + :param thing: Value to inspect. + :return: Whether the value is exactly a built-in list or set, excluding subclasses. """ return type(thing) in [list, set] @@ -66,8 +72,12 @@ def is_coll(thing) -> bool: yield vals -def flatten_type(t): - """Flatten a complex type into a list of constituent types.""" +def flatten_type(t: object) -> list[object]: + """Flatten a complex type into a list of constituent types. + + :param t: Type annotation to inspect. + :return: Constituent types or literal values. + """ origin = get_origin(t) args = get_args(t) @@ -80,18 +90,28 @@ def flatten_type(t): def overlaps(a: list, b: list) -> bool: - """Return true if there are any elements in common between a and b""" - return len(set(a).intersection(set(b))) > 0 + """Return true if there are any elements in common between a and b + :param a: First collection. + :param b: Second collection. + :return: Whether the collections share any values. + """ + return len(set(a).intersection(set(b))) > 0 -def pydantic_class_refatt_map(): - """Build a map of class names to their field names that are referable types. - As in, types with an identifier that can be referred to elsewhere, collapsed to that identifier and dereferenced. +def pydantic_class_refatt_map() -> tuple[ + list[type[BaseModel]], + list[type[BaseModel]], + dict[str, list[str]], + dict[str, list[str]], +]: + """Collect referable model classes, field references, and inherent fields. - Returns a map like: + Referable fields can contain identifiable objects or references to them. + For example, the referable-field map includes {"Allele": ["location"]}. - {"Allele": ["location"], ...} + :return: Referable classes, root-model unions containing them, the referable-field + map, and the inherent-field map, in that order. """ # Things defined here that are classes that inherit from BaseModel this_module = sys.modules[__name__] @@ -184,10 +204,7 @@ class Orientation(str, Enum): class ResidueAlphabet(str, Enum): - """The interpretation of the character codes referred to by the refget accession, - where "aa" specifies an amino acid character set, and "na" specifies a nucleic acid - character set. - """ + """The interpretation of the character codes referred to by the refget accession, where "aa" specifies an amino acid character set, and "na" specifies a nucleic acid character set.""" AA = "aa" NA = "na" @@ -203,10 +220,7 @@ class MoleculeType(str, Enum): class CopyChange(str, Enum): - """MUST use one of the defined enumerations that are based on the corresponding EFO - ontological terms for copy number variation. See Implementation Guidance for more - details. - """ + """MUST use one of the defined enumerations that are based on the corresponding EFO ontological terms for copy number variation. See Implementation Guidance for more details.""" COMPLETE_GENOMIC_LOSS = "complete genomic loss" HIGH_LEVEL_LOSS = "high-level loss" @@ -219,9 +233,7 @@ class CopyChange(str, Enum): class Syntax(str, Enum): - """The syntax used to describe the variation. The value should be one of the - supported syntaxes. - """ + """The syntax used to describe the variation. The value should be one of the supported syntaxes.""" HGVS_C = "hgvs.c" HGVS_P = "hgvs.p" @@ -235,21 +247,18 @@ class Syntax(str, Enum): 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. - """ + """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): +def _recurse_ga4gh_serialize(obj: object) -> object: + """Serialize nested values using GA4GH digests for identifiable objects. + + :param obj: Value to serialize. + :return: Serialized value or unchanged scalar. + """ if isinstance(obj, Ga4ghIdentifiableObject): return obj.get_or_create_digest() if isinstance(obj, _ValueObject): @@ -271,14 +280,20 @@ class VRSMetadataMixin(GKMMetadataMixin): 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. - """ + """A contextual value whose equality is based on value, not identity. See https://en.wikipedia.org/wiki/Value_object for more on Value Objects.""" def __hash__(self) -> int: + """Hash the canonical JSON representation. + + :return: Hash of the serialized value. + """ return encode_canonical_json(self.ga4gh_serialize()).decode("utf-8").__hash__() def ga4gh_serialize(self) -> dict: + """Serialize inherent fields for GA4GH digest computation. + + :return: Mapping of inherent fields to serialized values. + """ out = OrderedDict() for k in self.ga4gh.inherent: v = getattr(self, k) @@ -286,18 +301,21 @@ def ga4gh_serialize(self) -> dict: return out class ga4gh: # noqa: N801 + """Define GA4GH serialization metadata for this model.""" + inherent: list[str] @staticmethod def is_ga4gh_identifiable() -> bool: + """Report whether this class supports GA4GH computed identifiers. + + :return: Whether instances are identifiable. + """ return False 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. - """ + """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 @@ -310,14 +328,27 @@ class Ga4ghIdentifiableObject(VRSMetadataMixin, _ValueObject): description="A sha512t24u digest created using the VRS Computed Identifier algorithm.", ) - def __lt__(self, other) -> bool: + def __lt__(self, other: "Ga4ghIdentifiableObject") -> bool: + """Compare objects by their computed digests. + + :param other: Object to compare. + :return: Whether this digest sorts before the other digest. + """ return self.get_or_create_digest() < other.get_or_create_digest() @staticmethod def is_ga4gh_identifiable() -> bool: + """Report whether this class supports GA4GH computed identifiers. + + :return: Whether instances are identifiable. + """ return True def has_valid_ga4gh_id(self) -> bool | str | None: + """Check the existing identifier against the GA4GH identifier pattern. + + :return: Match status, or the unset or empty identifier. + """ return self.id and GA4GH_IR_REGEXP.match(self.id) is not None def compute_digest( @@ -328,7 +359,12 @@ def compute_digest( Stores the digest in the object if ``store`` is ``True``. If ``as_version`` is provided, other parameters are ignored and a digest is - returned following the conventions of the VRS version indicated by ``as_version_``. + returned following the conventions of the VRS version indicated by ``as_version``. + + :param store: Save the computed digest on the object. + :param as_version: Optional prior VRS version for serialization. + :return: Computed digest. + :raises AttributeError: If prior-version serialization is unsupported. """ if as_version is None: digest = sha512t24u(encode_canonical_json(self.ga4gh_serialize())) @@ -345,25 +381,36 @@ def compute_digest( return digest def get_or_create_ga4gh_identifier( - self, in_place: str = "default", recompute: bool = False, as_version=None + self, + in_place: str = "default", + recompute: bool = False, + as_version: PrevVrsVersion | None = None, ) -> str: - """Sets and returns a GA4GH Computed Identifier for the object. - Overwrites the existing identifier if overwrite is True. + """Get a GA4GH computed identifier and optionally update the object ID. This function has three options for in_place editing of vro.id: - 'default': the standard identifier update behavior for GA4GH identifiable objects, this mode will update the vro.id - field if the field is empty + field only when it is None - 'always': this will update the vro.id field any time the identifier is computed - 'never': the vro.id field will not be edited in-place, even when empty - Digests will be recalculated even if present if recompute is True. + ``recompute`` applies when this call computes an identifier. A valid existing + ID returned by the default policy bypasses digest computation. An invalid + existing ID is replaced in the return value, but is only stored with the + always policy. If ``as_version`` is provided, other parameters are ignored and an identifier is returned following the conventions of the VRS version indicated by - ``as_version_``. + ``as_version``. + + :param in_place: ID update policy: default, always, or never. + :param recompute: Recompute an existing digest. + :param as_version: Optional prior VRS version. + :return: GA4GH computed identifier. + :raises ValueError: If the update policy is invalid. """ if as_version is not None: return self.compute_ga4gh_identifier(as_version=as_version) @@ -384,12 +431,18 @@ def get_or_create_ga4gh_identifier( else: return self.compute_ga4gh_identifier(recompute) - def compute_ga4gh_identifier(self, recompute: bool = False, as_version=None) -> str: + def compute_ga4gh_identifier( + self, recompute: bool = False, as_version: PrevVrsVersion | None = None + ) -> str: """Return a GA4GH Computed Identifier. If ``as_version`` is provided, other parameters are ignored and a computed identifier is returned following the conventions of the VRS version indicated by - ``as_version_``. + ``as_version``. + + :param recompute: Recompute an existing digest. + :param as_version: Optional prior VRS version. + :return: GA4GH computed identifier. """ if as_version is None: self.get_or_create_digest(recompute) @@ -399,22 +452,23 @@ def compute_ga4gh_identifier(self, recompute: bool = False, as_version=None) -> return f"{CURIE_NAMESPACE}{CURIE_SEP}{self.ga4gh.priorPrefix[as_version]}{GA4GH_PREFIX_SEP}{digest}" def get_or_create_digest(self, recompute: bool = False) -> str: - """Set and returns a sha512t24u digest of the GA4GH Identifiable Object, or create - the digest if it does not exist. + """Set and returns a sha512t24u digest of the GA4GH Identifiable Object, or create the digest if it does not exist. + + :param recompute: Recompute an existing digest when true. + :return: Existing or newly computed digest. """ if self.digest is None or recompute: return self.compute_digest() return self.digest class ga4gh(_ValueObject.ga4gh): # noqa: N801 + """Define GA4GH serialization metadata for this model.""" + prefix: str 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. - """ + """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 @@ -476,11 +530,7 @@ def validate_range(cls, v: list[int | None]) -> list[int | None]: # noqa: N805 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. - """ + """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 @@ -493,11 +543,7 @@ class residue(VRSMetadataMixin, 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. - """ + """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 @@ -528,6 +574,8 @@ class LengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): ) class ga4gh(_ValueObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + inherent = ["length", "type"] @@ -552,6 +600,8 @@ class ReferenceLengthExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidE ) class ga4gh(_ValueObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + inherent = ["length", "repeatSubunitLength", "type"] @@ -567,6 +617,8 @@ class LiteralSequenceExpression(VRSMetadataMixin, _ValueObject, BaseModelForbidE sequence: sequenceString = Field(..., description="the literal sequence") class ga4gh(_ValueObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + inherent = ["sequence", "type"] @@ -609,6 +661,8 @@ class SequenceReference(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): ) class ga4gh(_ValueObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + inherent = ["refgetAccession", "type"] @@ -646,7 +700,7 @@ def validate_start_end( :param v: ``start`` or ``end`` value :param info: Validation info :raises ValueError: If ``start`` or ``end`` has a value less than 0 - :return: Sequence Location + :return: The validated coordinate or range, or None. """ if v is not None: if isinstance(v, int): @@ -660,11 +714,14 @@ def validate_start_end( return v def ga4gh_serialize_as_version(self, as_version: PrevVrsVersion) -> str: - """Return a serialized string following the conventions for SequenceLocation - serialization as defined in the VRS version specified by ``as_version``. + """Return a serialized string following the conventions for SequenceLocation serialization as defined in the VRS version specified by ``as_version``. :raises ValueError: If ``sequenceReference`` is not a ``SequenceReference`` object; ``start`` or ``end`` are not an int or list. + + :param as_version: Prior VRS version to use. + :raises TypeError: If the requested version is unsupported. + :return: Serialization for the requested prior version. """ if as_version == PrevVrsVersion.V1_3: if not isinstance(self.sequenceReference, SequenceReference): @@ -692,6 +749,10 @@ def ga4gh_serialize_as_version(self, as_version: PrevVrsVersion) -> str: raise TypeError(msg) def get_refget_accession(self) -> str | None: + """Get the sequence reference accession or IRI. + + :return: Accession or IRI, or None if no reference is available. + """ if isinstance(self.sequenceReference, SequenceReference): return self.sequenceReference.refgetAccession if isinstance(self.sequenceReference, iriReference): @@ -699,15 +760,15 @@ def get_refget_accession(self) -> str | None: return None class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 + """Define GA4GH serialization metadata for this model.""" + prefix = "SL" priorPrefix = {PrevVrsVersion.V1_3.value: "VSL"} # noqa: N815 inherent = ["end", "sequenceReference", "start", "type"] class SequenceOffsetLocation(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): - """A location defined by an offset relative to an anchor on a mapped sequence - reference. - """ + """A location defined by an offset relative to an anchor on a mapped sequence reference.""" model_config = ConfigDict(use_enum_values=True) @@ -739,6 +800,8 @@ class SequenceOffsetLocation(VRSMetadataMixin, _ValueObject, BaseModelForbidExtr ) class ga4gh(_ValueObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + inherent = [ "sequenceReference", "anchor", @@ -750,10 +813,7 @@ class ga4gh(_ValueObject.ga4gh): 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. - """ + """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 @@ -770,6 +830,8 @@ class RelativeSequenceLocation(Ga4ghIdentifiableObject, BaseModelForbidExtra): ) class ga4gh(Ga4ghIdentifiableObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + prefix = "RSL" inherent = ["baseSequenceLocation", "mappedSequenceLocation", "type"] @@ -801,11 +863,14 @@ class Allele(_VariationBase, BaseModelForbidExtra): ) def ga4gh_serialize_as_version(self, as_version: PrevVrsVersion) -> str: - """Return a serialized string following the conventions for - Allele serialization as defined in the VRS version specified by 'as_version`. + """Return a serialized string following the conventions for Allele serialization as defined in the VRS version specified by ``as_version``. :raises ValueError: If ``state`` is not a ``LiteralSequenceExpression`` or ``ReferenceLengthExpression``; ``state.sequence`` is null. + + :param as_version: Prior VRS version to use. + :raises TypeError: If the requested version is unsupported. + :return: Serialization for the requested prior version. """ location_digest = self.location.compute_digest(as_version=as_version) @@ -826,6 +891,8 @@ def ga4gh_serialize_as_version(self, as_version: PrevVrsVersion) -> str: raise TypeError(msg) class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N801 + """Define GA4GH serialization metadata for this model.""" + prefix = "VA" priorPrefix = {PrevVrsVersion.V1_3.value: "VA"} # noqa: N815 inherent = ["location", "state", "type"] @@ -858,6 +925,8 @@ class RelativeAllele(_VariationBase, BaseModelForbidExtra): ) class ga4gh(Ga4ghIdentifiableObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + prefix = "RA" inherent = ["mappedState", "baseState", "relativeLocation", "type"] @@ -882,11 +951,17 @@ class CisPhasedBlock(_VariationBase, BaseModelForbidExtra): ) def ga4gh_serialize(self) -> dict: + """Serialize inherent fields for GA4GH digest computation. + + :return: Mapping of inherent fields to serialized values. + """ out = _ValueObject.ga4gh_serialize(self) out["members"] = sorted(out["members"]) return out class ga4gh(Ga4ghIdentifiableObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + prefix = "CPB" inherent = ["members", "type"] @@ -897,9 +972,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): 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. - """ + """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 @@ -924,11 +997,16 @@ class Adjacency(_VariationBase, BaseModelForbidExtra): ) @field_validator("adjoinedSequences", mode="after") - def validate_adjoined_sequences(cls, v) -> list[iriReference | SequenceLocation]: - """Ensure ``adjoinedSequences`` do not have both ``start`` and ``end`` + def validate_adjoined_sequences( + cls, v: list[iriReference | SequenceLocation] + ) -> list[iriReference | SequenceLocation]: + """Reject adjoined sequence locations with two truthy endpoints. - :raises ValueError: If an adjoined sequence has both ``start`` and ``end`` + :raises ValueError: If both endpoints are truthy. Zero and None do not + trigger this check. :return: Adjoined sequences represented as iri reference or sequence location + + :param v: Adjoined sequence references or locations to validate. """ for adjoined_seq in v: if isinstance(adjoined_seq, SequenceLocation): @@ -938,15 +1016,14 @@ def validate_adjoined_sequences(cls, v) -> list[iriReference | SequenceLocation] return v class ga4gh(Ga4ghIdentifiableObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + prefix = "AJ" inherent = ["adjoinedSequences", "linker", "type"] 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. - """ + """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 @@ -959,14 +1036,14 @@ class Terminus(_VariationBase, BaseModelForbidExtra): ) class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 + """Define GA4GH serialization metadata for this model.""" + prefix = "TM" inherent = ["location", "type"] class TraversalBlock(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): - """A component used to describe the orientation of applicable molecular variation - within a DerivativeMolecule. - """ + """A component used to describe the orientation of applicable molecular variation within a DerivativeMolecule.""" model_config = ConfigDict(use_enum_values=True) @@ -986,13 +1063,13 @@ class TraversalBlock(VRSMetadataMixin, _ValueObject, BaseModelForbidExtra): ) class ga4gh(_ValueObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + inherent = ["component", "orientation", "type"] class DerivativeMolecule(_VariationBase, BaseModelForbidExtra): - """The "Derivative Molecule" data class is a structure for describing a derivate - molecule composed from multiple sequence components. - """ + """The "Derivative Molecule" data class is a structure for describing a derivate molecule composed from multiple sequence components.""" _maturity: ClassVar[Maturity] = Maturity.DRAFT @@ -1013,6 +1090,8 @@ class DerivativeMolecule(_VariationBase, BaseModelForbidExtra): ) class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 + """Define GA4GH serialization metadata for this model.""" + prefix = "DM" inherent = ["components", "type"] @@ -1023,9 +1102,7 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 class CopyNumberCount(_VariationBase, BaseModelForbidExtra): - """The absolute count of discrete copies of a `Location`, within a system - (e.g. genome, cell, etc.). - """ + """The absolute count of discrete copies of a `Location`, within a system (e.g. genome, cell, etc.).""" _maturity: ClassVar[Maturity] = Maturity.TRIAL_USE @@ -1042,14 +1119,14 @@ class CopyNumberCount(_VariationBase, BaseModelForbidExtra): ) class ga4gh(Ga4ghIdentifiableObject.ga4gh): # noqa: N815 + """Define GA4GH serialization metadata for this model.""" + prefix = "CN" inherent = ["copies", "location", "type"] 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. - """ + """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) @@ -1069,6 +1146,8 @@ class CopyNumberChange(_VariationBase, BaseModelForbidExtra): ) class ga4gh(Ga4ghIdentifiableObject.ga4gh): + """Define GA4GH serialization metadata for this model.""" + prefix = "CX" inherent = ["copyChange", "location", "type"] @@ -1153,9 +1232,7 @@ class Variation(VRSMetadataMixin, RootModel): class SystemicVariation(VRSMetadataMixin, RootModel): - """A Variation of multiple molecules in the context of a system, e.g. a genome, - sample, or homologous chromosomes. - """ + """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 diff --git a/src/ga4gh/vrs/normalize.py b/src/ga4gh/vrs/normalize.py index 9747febd..f00882de 100644 --- a/src/ga4gh/vrs/normalize.py +++ b/src/ga4gh/vrs/normalize.py @@ -1,11 +1,35 @@ -"""VRS object normalization functions +"""Normalize VRS objects. -See https://vrs.ga4gh.org/en/stable/impl-guide/normalization.html +Allele normalization validates bounds, trims shared sequence, expands repeat +ambiguity, and chooses a literal or reference-length expression. Substitutions +and identity alleles return before expansion. + +For reference ``CAAAG``, inserting ``A`` inside the run expands as follows:: + + input: [2, 2), literal "A" + expanded: [1, 4), reference "AAA", alternate "AAAA" + output: ReferenceLengthExpression(length=4, repeatSubunitLength=1) + +A substitution needs only trimming: ``ACGT -> ATGT`` at [0, 4) becomes +``C -> T`` at [1, 2), with a LiteralSequenceExpression. + +Given an inline literal Allele and a data proxy serving its reference:: + + from ga4gh.vrs import normalize + compact = normalize(allele, data_proxy, rle_seq_limit=0) + with_sequence = normalize(allele, data_proxy, rle_seq_limit=None) + +0 omits the optional RLE sequence; None retains it without a length limit. +A positive limit retains the complete sequence only if it fits. Use the original +literal input for each call: existing RLE states are returned unchanged. + +See https://vrs.ga4gh.org/en/stable/impl-guide/normalization.html """ import itertools import logging +from collections.abc import Iterator from enum import IntEnum from typing import NamedTuple @@ -85,12 +109,9 @@ def _get_new_allele_location_pos( def _normalize_allele( - input_allele: models.Allele, data_proxy: _DataProxy, rle_seq_limit: int = 50 -): - """Normalize Allele using "fully-justified" normalization adapted from NCBI's - VOCA. Fully-justified normalization expands such ambiguous representation over the - entire region of ambiguity, resulting in an unambiguous representation that may be - readily compared with other alleles. + input_allele: models.Allele, data_proxy: _DataProxy, rle_seq_limit: int | None = 50 +) -> models.Allele: + """Normalize Allele using "fully-justified" normalization adapted from NCBI's VOCA. Fully-justified normalization expands such ambiguous representation over the entire region of ambiguity, resulting in an unambiguous representation that may be readily compared with other alleles. This function assumes that IRIs are dereferenced, providing a `SequenceReference` as the `allele.location.sequenceReference`. If a `SequenceReference` is not provided, the allele @@ -111,6 +132,7 @@ def _normalize_allele( of the `sequence`. To exclude `sequence` from the response, set to 0. For no limit, set to `None`. + :return: Normalized allele, or the input if normalization is unsupported. :raises DataProxyValidationError: If the allele location is out of bounds on its sequence """ @@ -252,7 +274,7 @@ def _trim_for_normalization( alleles: tuple[None, str], start: LocationPos, end: LocationPos, -): +) -> tuple[tuple[int, int], tuple[str | None, str]]: """Trim common prefix and suffix from the intervals. Return the trimmed interval and trimmed alleles: @@ -260,6 +282,14 @@ def _trim_for_normalization( If the alleles are the same, return the original interval and alleles. The first allele (ref) will be populated by bioutils. + + :param ref_seq: Reference sequence proxy. + :param ival: Input start and end coordinates. + :param alleles: Reference placeholder and alternate sequence. + :param start: Representative start position. + :param end: Representative end position. + :return: Trimmed interval and allele sequences. + :raises ValueError: If trimming fails for a non-reference allele. """ try: trim_ival, trim_alleles = _normalize( @@ -286,7 +316,13 @@ def _set_location_from_interval( start_pos_type: PosType, end_pos_type: PosType, ) -> None: - """Update ``allele`` start and end location""" + """Update ``allele`` start and end location + + :param allele: Allele to modify in place. + :param ival: Start and end coordinates. + :param start_pos_type: Representation of the original start position. + :param end_pos_type: Representation of the original end position. + """ allele.location.start = _get_new_allele_location_pos(ival[0], start_pos_type) allele.location.end = _get_new_allele_location_pos(ival[1], end_pos_type) @@ -296,15 +332,17 @@ def denormalize_reference_length_expression( repeat_subunit_length: int, alt_length: int, ) -> str: - """Reverse the process of repeat subunit compaction for a ReferenceLengthExpression. - The alt is reference-derived so repeat the repeat subunit until it is it to alt_length - characters long. May include a trailing partial copy of the repeat subunit. + """Reverse the process of repeat subunit compaction for a ReferenceLengthExpression. The alt is reference-derived so repeat the repeat subunit until it is alt_length characters long. May include a trailing partial copy of the repeat subunit. e.g. "ACGT" (repeat_subunit_length=4, length=10) -> "ACGTACGTAC" (ACGT ACGT AC) :param ref_seq: The reference sequence to be denormalized. :param repeat_subunit_length: The length of the repeat subunit. :param alt_length: The length of the alternate sequence that was compacted during normalization. + + :return: Expanded alternate sequence. + :raises ValueError: If the subunit length is negative or exceeds the reference length. + :raises ZeroDivisionError: If the repeat subunit length is zero. """ repeat_subunit = ref_seq[:repeat_subunit_length] if len(repeat_subunit) != repeat_subunit_length: @@ -318,8 +356,12 @@ def denormalize_reference_length_expression( return alt -def _factor_gen(n: int): - """Yield all factors of an integer `n`, in descending order""" +def _factor_gen(n: int) -> Iterator[int]: + """Yield all factors of an integer in descending order. + + :param n: Positive integer to factor. Nonpositive inputs yield no values. + :return: Iterator yielding factors from largest to smallest. + """ lower_factors = [] i = 1 while i * i <= n: @@ -332,13 +374,21 @@ def _factor_gen(n: int): def _define_rle_allele( - allele: BaseModel, + allele: models.Allele, length: int, repeat_subunit_length: int, - rle_seq_limit, - extended_alt_seq, -): - # Otherwise, create the Allele as an RLE + rle_seq_limit: int | None, + extended_alt_seq: str, +) -> models.Allele: + """Replace an allele state with a reference length expression. + + :param allele: Allele to modify in place. + :param length: Alternate sequence length. + :param repeat_subunit_length: Length of the repeating unit. + :param rle_seq_limit: Maximum sequence length to retain, or None for no limit. + :param extended_alt_seq: Expanded alternate sequence. + :return: Updated allele. + """ allele.state = models.ReferenceLengthExpression( length=length, repeatSubunitLength=repeat_subunit_length ) @@ -349,7 +399,16 @@ def _define_rle_allele( return allele -def _is_valid_cycle(template_start, template: str, target) -> bool: +def _is_valid_cycle(template_start: int, template: str, target: str) -> bool: + """Check whether the target suffix repeats the template cycle. + + :param template_start: Start of a nonempty repeating unit within the template. + :param template: Reference sequence containing the repeating unit. + :param target: Alternate sequence whose suffix is checked. + :return: Whether all suffix characters match the cycle. The template-length + prefix is not compared; an empty suffix matches. + :raises StopIteration: If the cycle is empty and the target suffix is not. + """ cycle = itertools.cycle(template[template_start:]) for char in target[len(template) :]: # noqa: SIM110 if char != next(cycle): @@ -361,9 +420,15 @@ def _is_valid_cycle(template_start, template: str, target) -> bool: def _normalize_cis_phased_block( - o, + o: models.CisPhasedBlock, data_proxy: _DataProxy | None = None, # noqa: ARG001 -): +) -> models.CisPhasedBlock: + """Sort block members by their GA4GH digests in place. + + :param o: Block to normalize. + :param data_proxy: Unused sequence data proxy. + :return: Updated block. + """ o.members = sorted(o.members, key=ga4gh_digest) return o @@ -374,16 +439,21 @@ def _normalize_cis_phased_block( } -def normalize(vo, data_proxy: _DataProxy | None = None, **kwargs): +def normalize( + vo: BaseModel, data_proxy: _DataProxy | None = None, **kwargs: int | None +) -> BaseModel: """Normalize given vrs object, regardless of type - :param vo: - :param data_proxy: GA4GH sequence dataproxy instance, if needed - :keyword rle_seq_limit: If RLE is set as the new state, set the limit for the length - of the `sequence`. To exclude `state.sequence`, set to 0. - :return: normalized object, or unmodified input object if the normalization algorithm - does not provide normalization steps for the given type. - :raise TypeError: if given object isn't a pydantic.BaseModel + :param vo: VRS object to normalize. + :param kwargs: Allele handler options. rle_seq_limit limits retained RLE sequence + length; 0 omits it and None removes the limit. CisPhasedBlock accepts + no extra options. + :param data_proxy: Required for supported Allele normalization; unused by + CisPhasedBlock normalization. + :return: Normalized object of the input type. Alleles may be copied; + CisPhasedBlock members are sorted in place. Unsupported types are + returned unchanged. The input must expose a VRS type attribute. + :raises TypeError: if given object isn't a pydantic.BaseModel """ if not is_pydantic_instance(vo): msg = f"Object is of class {vo.__class__} (with parents {vo.__class__.__mro__}), but normalize requires objects that inherit from pydantic.BaseModel." diff --git a/src/ga4gh/vrs/utils/hgvs_tools.py b/src/ga4gh/vrs/utils/hgvs_tools.py index 165a38df..8e48c2c9 100644 --- a/src/ga4gh/vrs/utils/hgvs_tools.py +++ b/src/ga4gh/vrs/utils/hgvs_tools.py @@ -1,4 +1,29 @@ -"""Provide extra tools for working with hgvs expressions.""" +"""Convert HGVS expressions and coordinates. + +HGVS uses 1-based positions; VRS uses interbase bounds. Omitting the accession:: + + HGVS VRS start VRS end + g.100A>T 99 100 + g.100_101insT 100 100 + g.(100_110)_(200_210)del Range([99,109]) Range([200,210]) + +Insertions use the boundary after the start residue for both bounds. For ranges, +starts shift by one and ends stay unchanged. AlleleTranslator rejects uncertain +ranges; use CnvTranslator for uncertain del/dup copy-number expressions. + +With a configured data_proxy and accessible UTA database:: + + from ga4gh.vrs.extras.translator import AlleleTranslator + + translator = AlleleTranslator(data_proxy) + try: + allele = translator.from_hgvs("NC_000019.10:g.44908822C>T") + expressions = translator.to_hgvs(allele, namespace="refseq") + finally: + translator.hgvs_tools.close() # Closes UTA, not the supplied data proxy. + +Coding coordinates pass through c_to_n on import and n_to_c on export. +""" import logging import re @@ -34,33 +59,40 @@ def _is_uncertain_range(pos: _HgvsPos) -> TypeGuard[hgvs.location.Interval]: - """Return True if ``pos`` is a nested :class:`hgvs.location.Interval` - representing an uncertain range (e.g. ``(A_B)`` from ``(A_B)_(C_D)del``). + """Return True if ``pos`` is a nested :class:`hgvs.location.Interval` representing an uncertain range (e.g. ``(A_B)`` from ``(A_B)_(C_D)del``). Note that hgvs 2.0.0a0 also emits a non-uncertain ``Interval`` (with ``start == end``) for the exact side of a mixed expression like ``g.100_(200_?)del``, so the ``.uncertain`` check is required to distinguish real uncertain ranges from that wrapper shape. + + :param pos: HGVS position to inspect or convert. + :return: Whether the position is an uncertain interval. """ return isinstance(pos, hgvs.location.Interval) and pos.uncertain def _has_intron_offset(pos: _HgvsPos) -> bool: - """Return True if ``pos`` is a :class:`hgvs.location.BaseOffsetPosition` - with a non-zero intron offset (e.g. ``100+5`` or ``200-3``). + """Return True if ``pos`` is a :class:`hgvs.location.BaseOffsetPosition` with a non-zero intron offset (e.g. ``100+5`` or ``200-3``). VRS :class:`SequenceLocation` coordinates are transcript-relative; a non-zero offset means the position lies inside an intron, which has no faithful single-integer representation in VRS. This helper lets the low-level converters refuse such inputs defensively; upstream callers typically reject at the variant level via :meth:`HgvsTools.is_intronic`. + + :param pos: HGVS position to inspect or convert. + :return: Whether the position has a nonzero intron offset. """ return isinstance(pos, hgvs.location.BaseOffsetPosition) and bool(pos.offset) def _shift_hgvs_to_vrs(base: int | None, side: _Side) -> int | None: - """Convert an HGVS 1-based inclusive coordinate to a VRS 0-based interbase - coordinate, accounting for which side of the outer interval it is on. + """Convert an HGVS 1-based inclusive coordinate to a VRS 0-based interbase coordinate, accounting for which side of the outer interval it is on. + + :param base: HGVS coordinate, or None if unknown. + :param side: Start or end side of the variant interval. + :return: Interbase coordinate, or None if unknown. """ if base is None: return None @@ -68,25 +100,28 @@ def _shift_hgvs_to_vrs(base: int | None, side: _Side) -> int | None: def _shift_vrs_to_hgvs(value: int | None, side: _Side) -> int | None: - """Inverse of :func:`_shift_hgvs_to_vrs`.""" + """Inverse of :func:`_shift_hgvs_to_vrs`. + + :param value: Value to convert. + :param side: Start or end side of the variant interval. + :return: HGVS coordinate, or None if unknown. + """ if value is None: return None return value + 1 if side == "start" else value def _hgvs_pos_to_vrs(pos: _HgvsPos, side: _Side) -> _VrsPos: - """Convert the ``pos.start`` or ``pos.end`` of a parsed hgvs variant to the - corresponding VRS value for :attr:`models.SequenceLocation.start` / - :attr:`models.SequenceLocation.end`. + """Convert the ``pos.start`` or ``pos.end`` of a parsed hgvs variant to the corresponding VRS value for :attr:`models.SequenceLocation.start` / :attr:`models.SequenceLocation.end`. Handles three shapes of ``pos``: * :class:`hgvs.location.SimplePosition` / :class:`hgvs.location.BaseOffsetPosition` - — a certain position; returns an ``int``. - * :class:`hgvs.location.Interval` with ``uncertain=True`` — a nested + represents a certain position; returns an ``int``. + * :class:`hgvs.location.Interval` with ``uncertain=True``, a nested uncertain range (e.g. ``(A_B)`` from ``(A_B)_(C_D)dup``); returns a :class:`models.Range`. - * :class:`hgvs.location.Interval` with ``uncertain=False`` — the + * :class:`hgvs.location.Interval` with ``uncertain=False``, the ``start==end`` wrapper hgvs emits for the exact side of a mixed expression like ``g.100_(200_?)del``; unwrapped to an ``int``. @@ -99,12 +134,15 @@ def _hgvs_pos_to_vrs(pos: _HgvsPos, side: _Side) -> _VrsPos: :param side: ``"start"`` if ``pos`` is the outer-left side of the variant (HGVS→VRS subtracts 1), ``"end"`` for the outer-right side (no shift). - :returns: An ``int`` for a certain position, a :class:`models.Range` for an + :return: An ``int`` for a certain position, a :class:`models.Range` for an uncertain range, or ``None`` if the position is fully unknown. :raises TypeError: if ``pos`` is a :class:`~hgvs.location.BaseOffsetInterval` (c./n. transcript range), which has no VRS equivalent. :raises ValueError: if ``pos`` is an intronic - :class:`~hgvs.location.BaseOffsetPosition` (non-zero ``offset``). + :class:`~hgvs.location.BaseOffsetPosition` (non-zero ``offset``), or a + non-uncertain interval has different endpoints. + + :param pos: HGVS position to inspect or convert. """ if isinstance(pos, hgvs.location.Interval): if isinstance(pos, hgvs.location.BaseOffsetInterval): @@ -145,8 +183,7 @@ def _vrs_pos_to_hgvs( *, as_interval: bool = False, ) -> hgvs.location.SimplePosition | hgvs.location.Interval: - """Convert a VRS :attr:`models.SequenceLocation.start` / ``end`` value to - the corresponding hgvs position object. + """Convert a VRS :attr:`models.SequenceLocation.start` / ``end`` value to the corresponding hgvs position object. For an ``int``, returns a :class:`hgvs.location.SimplePosition`. For a :class:`models.Range` (or equivalent 2-element list), returns a nested @@ -158,6 +195,11 @@ def _vrs_pos_to_hgvs( representation of mixed certain/uncertain outer intervals (e.g. ``g.N_(M_?)del``), where both sides of the outer interval must have the same kind of inner position for the hgvs formatter to work. + + :param value: Value to convert. + :param side: Start or end side of the variant interval. + :param as_interval: Wrap an exact position in an interval when true. + :return: HGVS position or interval. """ if isinstance(value, models.Range): value = value.root @@ -256,23 +298,19 @@ def __exit__( def parse(self, hgvs_str: str) -> HgvsSequenceVariant | None: """Parse the given HGVS string and returns the corresponding variant. - Args: - hgvs_str (str): The HGVS string to parse. - - Returns: - Variant: The parsed variant object, or None if the HGVS string is invalid. - + :param hgvs_str: HGVS expression to parse. + :return: Parsed variant, or None if the expression prefix is unsupported. + :raises hgvs.exceptions.HGVSParseError: If a supported expression has invalid syntax. """ if not self.hgvs_re.match(hgvs_str): return None return self.parser.parse_hgvs_variant(hgvs_str) def has_uncertain_range(self, sv: HgvsSequenceVariant) -> bool: - """Return True if either endpoint of ``sv.posedit.pos`` is a nested - uncertain :class:`hgvs.location.Interval` (e.g. the ``(A_B)`` sides of - ``(A_B)_(C_D)del``). Surfaces the module-private - :func:`_is_uncertain_range` check to external callers without - requiring them to import an underscore helper across module boundaries. + """Return True if either endpoint of ``sv.posedit.pos`` is a nested uncertain :class:`hgvs.location.Interval` (e.g. the ``(A_B)`` sides of ``(A_B)_(C_D)del``). Surfaces the module-private :func:`_is_uncertain_range` check to external callers without requiring them to import an underscore helper across module boundaries. + + :param sv: Parsed HGVS sequence variant. + :return: Whether either endpoint is an uncertain range. """ return _is_uncertain_range(sv.posedit.pos.start) or _is_uncertain_range( sv.posedit.pos.end @@ -281,12 +319,8 @@ def has_uncertain_range(self, sv: HgvsSequenceVariant) -> bool: def is_intronic(self, sv: HgvsSequenceVariant) -> bool: """Check if the given SequenceVariant is intronic. - Args: - sv (hgvs.sequencevariant.SequenceVariant): The SequenceVariant to check. - - Returns: - bool: True if the SequenceVariant is intronic, False otherwise. - + :param sv: Parsed HGVS sequence variant. + :return: Whether either endpoint is intronic. """ # Coding (c.) intervals are BaseOffsetInterval, but RNA (r.) intervals # are plain Interval even when their endpoints carry a base-offset @@ -300,8 +334,12 @@ def is_intronic(self, sv: HgvsSequenceVariant) -> bool: 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""" + def get_edit_type(self, sv: HgvsSequenceVariant | None) -> str | None: + """Safely extract ``type`` property from SequenceVariant + + :param sv: Parsed HGVS sequence variant. + :return: Edit type, or None if no edit is present. + """ if sv is None or sv.posedit is None or sv.posedit.edit is None: return None return sv.posedit.edit.type @@ -311,22 +349,11 @@ def get_position_and_state( ) -> tuple[_VrsPos, _VrsPos, str]: """Get the details of a sequence variant. - Args: - sv (hgvs.sequencevariant.SequenceVariant): The sequence variant object. - - Returns: - tuple: A tuple containing the VRS start position, end position, - and state of the variant. Exact allele-style inputs return integer - bounds; uncertain-range inputs passed directly may return - :class:`models.Range` bounds. - - Raises: - ValueError: If the HGVS variant type is unsupported, or if ``pos`` - is an intronic :class:`~hgvs.location.BaseOffsetPosition`. - TypeError: If ``pos`` is a :class:`~hgvs.location.BaseOffsetInterval`. - DataProxyValidationError: If an identity or duplication's location is - invalid. - + :param sv: Parsed HGVS sequence variant. + :return: VRS start, end, and literal alternate sequence. + :raises ValueError: If the edit type or an intronic position is unsupported. + :raises TypeError: If a transcript range cannot be represented in VRS. + :raises AttributeError: If an operation needing sequence data is called without a data proxy. """ pos = sv.posedit.pos edit_type = sv.posedit.edit.type @@ -367,36 +394,15 @@ def get_position_and_state( return start, end, state def extract_allele_values(self, hgvs_expr: str) -> dict | None: - """Parse hgvs into a VRS Allele Object - - kwargs: - rle_seq_limit Optional(int): If RLE is set as the new state after - normalization, this sets the limit for the length of the `sequence`. - To exclude `sequence` from the response, set to 0. - For no limit, set to `None`. - Defaults value set in instance variable, `rle_seq_limit`. - do_normalize (bool): `True` if fully justified normalization should be - performed. `False` otherwise. Defaults to `True` - - #>>> a = tlr.from_hgvs("NC_000007.14:g.55181320A>T") - #>>> a.model_dump() - { - 'location': { - 'end': 55181320, - 'start': 55181319, - 'sequenceReference': { - 'type': 'SequenceReference', - 'refgetAccession': 'SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul' - }, - 'type': 'SequenceLocation' - }, - 'state': { - 'sequence': 'T', - 'type': 'LiteralSequenceExpression' - }, - 'type': 'Allele' - } - + """Extract allele construction fields from an HGVS expression. + + :param hgvs_expr: HGVS expression to translate. + :return: Allele fields, or None for an unsupported expression prefix or + unresolved accession. Invalid HGVS syntax raises HGVSParseError. + :raises ValueError: If the variant is intronic or its edit type is unsupported. + :raises AttributeError: If an operation needing sequence data is called without a data proxy. + :raises TypeError: If a transcript range cannot be represented in VRS. + :raises hgvs.exceptions.HGVSParseError: If supported HGVS syntax is malformed. """ sv = self.parse(hgvs_expr) if not sv: @@ -434,6 +440,12 @@ def build_cnv_location( point into the 5' UTR. Handles both certain positions and uncertain- range endpoints; the resulting ``start`` / ``end`` are ints for exact positions and :class:`models.Range` values for uncertain ranges. + + :param sv: Parsed HGVS sequence variant. + :param refget_accession: Refget accession for the location sequence. + :return: Sequence location with translated coordinates. + :raises TypeError: If a transcript range cannot be represented in VRS. + :raises ValueError: If a position is intronic or an exact interval has unequal endpoints. """ if sv.type == "c": sv = self.c_to_n(sv) @@ -445,7 +457,9 @@ def build_cnv_location( end=_hgvs_pos_to_vrs(sv.posedit.pos.end, side="end"), ) - def from_allele(self, vo: models.Allele, namespace: str | None = None) -> list[str]: + def from_allele( + self, vo: models.Allele | None, namespace: str | None = None + ) -> list[str]: """Generate a *list* of HGVS expressions for VRS Allele. If `namespace` is not None, returns HGVS strings for the @@ -459,9 +473,12 @@ def from_allele(self, vo: models.Allele, namespace: str | None = None) -> list[s If the VRS object cannot be expressed as HGVS, raises ValueError. - This method assumes that IRIs are dereferenced, providing a `SequenceReference` - as the `vo.location.sequenceReference`. If a `SequenceReference` is not - provided, raises TypeError + :param vo: VRS object to translate. + :param namespace: Optional namespace used to filter sequence aliases. + :return: Distinct HGVS expressions for supported matching aliases, or an empty + list for None. Protein accessions raise ValueError. + :raises ValueError: If the allele or sequence reference cannot be represented. + :raises AttributeError: If an operation needing sequence data is called without a data proxy. """ if vo is None: return [] @@ -518,9 +535,23 @@ def from_allele(self, vo: models.Allele, namespace: str | None = None) -> list[s return list(set(hgvs_exprs)) def _to_sequence_variant( - self, vo: models.Allele, sequence_type: str, sequence: str, accession: str + self, + vo: models.Allele, + sequence_type: str | None, + sequence: str, + accession: str, ) -> HgvsSequenceVariant: - """Create a SequenceVariant object from an Allele object.""" + """Create a SequenceVariant object from an Allele object. + + :param vo: VRS object to translate. + :param sequence_type: HGVS sequence type, such as g, n, or c; None when the + alias prefix is unknown. + :param sequence: Sequence identifier used to retrieve reference bases. + :param accession: Accession for the HGVS expression. + :return: HGVS variant representing the allele. Missing UTA data is logged + and leaves the constructed variant unnormalized. + :raises ValueError: If the sequence type is protein. + """ # build interval and edit depending on sequence type if sequence_type == "p": msg = "Only nucleic acid variation is currently supported" @@ -581,15 +612,30 @@ def _to_sequence_variant( return var def normalize(self, hgvs: HgvsSequenceVariant) -> HgvsSequenceVariant: - """Perform hgvs library `normalize()` method""" + """Perform hgvs library `normalize()` method + + :param hgvs: HGVS variant to normalize or map. + :return: Normalized HGVS variant. + :raises hgvs.exceptions.HGVSError: If the HGVS operation fails validation or required data is unavailable. + """ return self.normalizer.normalize(hgvs) def n_to_c(self, hgvs: HgvsSequenceVariant) -> HgvsSequenceVariant: - """Perform hgvs library `n_to_c` method""" + """Perform hgvs library `n_to_c` method + + :param hgvs: HGVS variant to normalize or map. + :return: Variant in coding coordinates. + :raises hgvs.exceptions.HGVSError: If the HGVS operation fails validation or required data is unavailable. + """ return self.variant_mapper.n_to_c(hgvs) def c_to_n(self, hgvs: HgvsSequenceVariant) -> HgvsSequenceVariant: - """Perform hgvs library `c_to_n` method""" + """Perform hgvs library `c_to_n` method + + :param hgvs: HGVS variant to normalize or map. + :return: Variant in transcript coordinates. + :raises hgvs.exceptions.HGVSError: If the HGVS operation fails validation or required data is unavailable. + """ return self.variant_mapper.c_to_n(hgvs) From 238539b12ea749a5ca9b91a057fb861be079eaa4 Mon Sep 17 00:00:00 2001 From: Kori Kuzma Date: Fri, 6 Nov 2026 13:19:02 -0500 Subject: [PATCH 2/2] cleanup? --- src/ga4gh/core/digests.py | 7 +- src/ga4gh/core/enderef.py | 3 +- src/ga4gh/core/identifiers.py | 52 +++++++------ src/ga4gh/core/models.py | 10 ++- src/ga4gh/core/pydantic.py | 8 +- src/ga4gh/vrs/dataproxy.py | 36 ++++----- src/ga4gh/vrs/extras/annotator/cli.py | 3 +- src/ga4gh/vrs/extras/annotator/vcf.py | 10 +-- src/ga4gh/vrs/extras/object_store.py | 9 +-- src/ga4gh/vrs/extras/translator.py | 103 ++++++++++---------------- src/ga4gh/vrs/models.py | 66 +++++++++++++---- src/ga4gh/vrs/normalize.py | 29 +++++--- src/ga4gh/vrs/utils/hgvs_tools.py | 73 +++++++++--------- 13 files changed, 221 insertions(+), 188 deletions(-) diff --git a/src/ga4gh/core/digests.py b/src/ga4gh/core/digests.py index 21fc0923..1ffbe2d4 100644 --- a/src/ga4gh/core/digests.py +++ b/src/ga4gh/core/digests.py @@ -12,14 +12,15 @@ def sha512t24u(blob: bytes) -> str: """Generate a base64url-encode, truncated SHA-512 digest for given binary data - The sha512t24u digest is a convention for constructing and - formatting digests for use as object identifiers. Specifically:: + The sha512t24u digest is a convention for constructing and formatting digests for + use as object identifiers. Specifically:: * generate a SHA512 digest on binary data * truncate at 24 bytes * encode using base64url encoding - Examples: + For example: + >>> sha512t24u(b"") 'z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXc' diff --git a/src/ga4gh/core/enderef.py b/src/ga4gh/core/enderef.py index a7549977..9b5a4751 100644 --- a/src/ga4gh/core/enderef.py +++ b/src/ga4gh/core/enderef.py @@ -102,8 +102,7 @@ def ga4gh_deref( ) -> BaseModel: """Copy an object and replace referable identifiers with inline objects. - `object_store` must be a mappable object and is required for - dereferencing. + `object_store` must be a mappable object and is required for dereferencing. The top-level input is copied. Objects retrieved from the store are inserted directly and may be mutated while their own references are resolved. diff --git a/src/ga4gh/core/identifiers.py b/src/ga4gh/core/identifiers.py index dabbe786..a36b747e 100644 --- a/src/ga4gh/core/identifiers.py +++ b/src/ga4gh/core/identifiers.py @@ -63,12 +63,16 @@ class VrsObjectIdentifierIs(IntEnum): - """Defines the state for when the `ga4gh_identify` method should compute an identifier ('id' attribute) for the specified object. The options are: ANY - Always compute the identifier (this is the default behavior) GA4GH_INVALID - Compute the identifier if it is missing or is present but syntactically invalid MISSING - Only compute the identifier if missing + """Control when ga4gh_identify calls the model identifier method. - The default policy calls the model identifier method. That method may reuse - an existing valid identifier or digest; this policy does not force recomputation. Where the source of inputs to `ga4gh_identify` - are well controlled, for example when annotating a VCF file with VRS IDs, - using `MISSING` can improve performance. + * ``ANY``: Always call the method (default). + * ``GA4GH_INVALID``: Call it when the ID is missing or syntactically invalid. + * ``MISSING``: Call it only when the ID is None or empty. + + The default policy calls the model identifier method. That method may reuse an + existing valid identifier or digest; this policy does not force recomputation. Where + the source of inputs to `ga4gh_identify` are well controlled, for example when + annotating a VCF file with VRS IDs, using `MISSING` can improve performance. """ ANY = 0 @@ -77,7 +81,7 @@ class VrsObjectIdentifierIs(IntEnum): class PrevVrsVersion(str, Enum): - """Define previous VRS versions that are supported for computing digests and identifiers based on the current VRS model""" + """Define prior VRS versions supported for digest and identifier computation.""" V1_3 = "1.3" @@ -97,15 +101,17 @@ def validate(cls, version: object) -> None: class use_ga4gh_compute_identifier_when(ContextDecorator): # noqa: N801 - """Context manager that defines when to compute identifiers for all operations within the context. For example: + """Apply an identifier policy within a context or decorated function. + + As a context manager:: - with use_ga4gh_compute_identifier_when(VrsObjectIdentifierIs.GA4GH_INVALID): - VCFAnnotator(...).annotate(...) + with use_ga4gh_compute_identifier_when(VrsObjectIdentifierIs.GA4GH_INVALID): + VCFAnnotator(...).annotate(...) - Or: + As a decorator:: - @use_ga4gh_compute_identifier_when(VrsObjectIdentifierIs.GA4GH_INVALID) - def my_method(): + @use_ga4gh_compute_identifier_when(VrsObjectIdentifierIs.GA4GH_INVALID) + def my_method(): ... """ def __init__(self, when: VrsObjectIdentifierIs) -> None: @@ -158,9 +164,10 @@ def ga4gh_identify( in_place: str = "default", as_version: PrevVrsVersion | None = None, ) -> str | None: - """Return the GA4GH digest-based id for the object, as a CURIE (string). Returns None if object is not identifiable. + """Return a GA4GH identifier, or None for a non-identifiable object. This function has three options for in_place editing of vro.id: + - 'default': the standard identifier update behavior for GA4GH identifiable objects, this mode will update the vro.id field only when it is None @@ -170,11 +177,10 @@ def ga4gh_identify( - 'never': the vro.id field will not be edited in-place, even when empty - The context policy may return an existing ID without computing one. When - computation occurs, ``as_version`` selects legacy serialization and bypasses - in-place ID updates; it does not override the context policy. - Accepts a supported PrevVrsVersion member or its string value. - Raises ValueError for other version values. + The context policy may return an existing ID without computing one. When computation + occurs, ``as_version`` selects legacy serialization and bypasses in-place ID + updates; it does not override the context policy. Accepts a supported PrevVrsVersion + member or its string value. Raises ValueError for other version values. >>> from ga4gh.core import ga4gh_identify >>> import ga4gh.vrs @@ -226,9 +232,9 @@ def ga4gh_digest( Only GA4GH identifiable objects are GA4GH digestible. If ``as_version`` is provided, other parameters are ignored and a digest is returned - following the conventions of the VRS version indicated by ``as_version``. - Accepts a supported PrevVrsVersion member or its string value. - Raises ValueError for other version values. + following the conventions of the VRS version indicated by ``as_version``. Accepts a + supported PrevVrsVersion member or its string value. Raises ValueError for other + version values. >>> from ga4gh.core import ga4gh_digest >>> import ga4gh.vrs @@ -264,8 +270,8 @@ def ga4gh_serialize( ) -> bytes | str: """Serialize an object for use in computed digest computation. - If ``as_version`` is provided, the returned serialization follows - the conventions of the VRS version indicated by ``as_version``. + If ``as_version`` is provided, the returned serialization follows the conventions of + the VRS version indicated by ``as_version``. :param obj: Model implementing ga4gh_serialize, and ga4gh_serialize_as_version when legacy serialization is requested. diff --git a/src/ga4gh/core/models.py b/src/ga4gh/core/models.py index 95226b4c..583d04fe 100644 --- a/src/ga4gh/core/models.py +++ b/src/ga4gh/core/models.py @@ -49,7 +49,15 @@ class Relation(str, Enum): 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).""" + """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" diff --git a/src/ga4gh/core/pydantic.py b/src/ga4gh/core/pydantic.py index 2f8f74b2..8d87d415 100644 --- a/src/ga4gh/core/pydantic.py +++ b/src/ga4gh/core/pydantic.py @@ -10,7 +10,9 @@ def getattr_in(obj: object, names: Sequence[str]) -> object: - """Call getattr on obj and the successive returns from getattr using names as attribute names in order. If any return is None, terminate early, and return None. + """Traverse successive attributes by name. + + Stop and return None when an attribute is missing or its value is None. Like clojure (get-in obj names) or (some-> obj .name1 .name2 ...) @@ -32,8 +34,8 @@ def getattr_in(obj: object, names: Sequence[str]) -> object: def is_curie_type(o: object) -> re.Match[str] | Literal[False] | None: """Match a string or root-model value against the CURIE pattern. - If object is a Pydantic custom root type, extracts the value first, - which enables (for example) a GA4GH iriReference pydantic model object to be passed. + If object is a Pydantic custom root type, extracts the value first, which enables + (for example) a GA4GH iriReference pydantic model object to be passed. :param o: Object to process. :return: Regex match for a CURIE, None for a nonmatching string, or False for other values. diff --git a/src/ga4gh/vrs/dataproxy.py b/src/ga4gh/vrs/dataproxy.py index eca1a3c7..04ca6b8f 100644 --- a/src/ga4gh/vrs/dataproxy.py +++ b/src/ga4gh/vrs/dataproxy.py @@ -44,12 +44,10 @@ def validate_start_le_end(identifier: str, start: int | None, end: int | None) - class _DataProxy(ABC): """abstract class / interface for VRS data needs - The proxy MUST support the use of GA4GH sequence identifers (i.e., - `ga4gh:SQ...`) as keys, and return these identifiers among the - aliases for a sequence. These identifiers may be supported - natively by the data source or synthesized by the proxy from the - data source or synthesized. - + The proxy MUST support the use of GA4GH sequence identifers (i.e., `ga4gh:SQ...`) as + keys, and return these identifiers among the aliases for a sequence. These + identifiers may be supported natively by the data source or synthesized by the proxy + from the data source or synthesized. """ @abstractmethod @@ -60,10 +58,10 @@ def get_sequence( start and end are optional - If the given sequence does not exist, KeyError is raised. If start is - after end, DataProxyValidationError may be raised. + If the given sequence does not exist, KeyError is raised. If start is after end, + DataProxyValidationError may be raised. - >> dp.get_sequence("NM_000551.3", 0, 10) + >>> dp.get_sequence("NM_000551.3", 0, 10) 'CCTCGCCTCC' :param identifier: Sequence identifier to resolve. @@ -80,7 +78,7 @@ def get_metadata(self, identifier: str) -> dict: If the given sequence does not exist, KeyError is raised. - >> dp.get_metadata("NM_000551.3") + >>> dp.get_metadata("NM_000551.3") {'added': '2016-08-24T05:03:11Z', 'aliases': ['MD5:215137b1973c1a5afcf86be7d999574a', 'RefSeq:NM_000551.3', @@ -98,7 +96,7 @@ def get_metadata(self, identifier: str) -> dict: @staticmethod def extract_sequence_type(alias: str) -> str | None: - """Provide a convenient way to extract the sequence type from an accession by matching its prefix to a known set of prefixes. + """Infer the HGVS sequence type from a recognized accession prefix. :param alias: Sequence accession or alias. :return: HGVS sequence type, or None for an unknown prefix. @@ -129,8 +127,7 @@ def translate_sequence_identifier( ) -> list[str]: """Translate given identifier to a list of identifiers in the specified namespace. - `identifier` must be a string - `namespace` is case-sensitive + `identifier` must be a string `namespace` is case-sensitive On success, returns a list of matching identifiers. Raises KeyError if given identifier isn't found. @@ -182,7 +179,10 @@ def validate_ref_seq( ref: str, require_validation: bool = True, ) -> None: - """Determine whether or not the expected reference sequence matches the actual reference sequence. Returns ``None``, but invalid results are logged at level WARN by default. If ``require_validation`` is ``True``, then invalid data will cause a ``DataProxyValidationError`` to be raised. + """Validate the expected reference sequence against the data proxy. + + Reference mismatches are logged as warnings. With require_validation=True, they + also raise DataProxyValidationError. Invalid location bounds always raise. :param sequence_id: Sequence ID to use :param start_pos: Start pos (inter-residue) on the sequence_id @@ -226,8 +226,8 @@ def validate_location_bounds( There is no ``require_validation`` option: an out-of-bounds location has no meaning, so the error is always raised, including from ``validate_ref_seq``. - Sequence backends may silently truncate out-of-range fetches, so this check - must be made before relying on fetched sequence. + Sequence backends may silently truncate out-of-range fetches, so this check must + be made before relying on fetched sequence. :param sequence_id: Sequence ID to use :param start_pos: Start pos (inter-residue) on the sequence_id @@ -434,8 +434,8 @@ def _get_metadata(self, identifier: str) -> dict: class SequenceProxy(Sequence[str]): """Fetch sequence characters and contiguous slices through a data proxy. - Slice steps and reversed iteration are unsupported. Coordinates are passed - to the backend without Python-style negative-index normalization. + Slice steps and reversed iteration are unsupported. Coordinates are passed to the + backend without Python-style negative-index normalization. """ def __init__(self, dp: _DataProxy, alias: str) -> None: diff --git a/src/ga4gh/vrs/extras/annotator/cli.py b/src/ga4gh/vrs/extras/annotator/cli.py index 4b4ed802..a27a37dd 100644 --- a/src/ga4gh/vrs/extras/annotator/cli.py +++ b/src/ga4gh/vrs/extras/annotator/cli.py @@ -206,7 +206,8 @@ def _annotate_vcf_cli( Sequence data from a provider such as SeqRepo is required. Use the `--dataproxy-uri` option or the environment variable `GA4GH_VRS_DATAPROXY_URI` to define its location - (the former will take priority over the latter when both are set). A URI must be supplied through one of these sources; there is no default server. + (the former will take priority over the latter when both are set). A URI must be + supplied through one of these sources; there is no default server. Currently accepted URI schemes: diff --git a/src/ga4gh/vrs/extras/annotator/vcf.py b/src/ga4gh/vrs/extras/annotator/vcf.py index 8e126d28..c7d7716f 100644 --- a/src/ga4gh/vrs/extras/annotator/vcf.py +++ b/src/ga4gh/vrs/extras/annotator/vcf.py @@ -381,8 +381,8 @@ def on_vrs_object( ) -> Allele | None: """Perform side-effects (eg additional annotation or storage) or additional filtering on VRS alleles as they are constructed during VCF annotation. - Implementations return the retained allele or None to discard it. The - abstract method itself performs no processing. + Implementations return the retained allele or None to discard it. The abstract + method itself performs no processing. :param vcf_coords: CHR-POS-REF-ALT from VCF for this allele :param vrs_allele: allele translated from coords @@ -583,9 +583,9 @@ def _get_vrs_data( class VcfAnnotator(AbstractVcfAnnotator): """Annotate VCFs with VRS allele IDs. - Uses pysam to ingest a VCF and produce a copy annotated with VRS IDs - (and, optionally, normalized allele attributes). Alleles are translated - using the VRS-Python translator class. + Uses pysam to ingest a VCF and produce a copy annotated with VRS IDs (and, + optionally, normalized allele attributes). Alleles are translated using the + VRS-Python translator class. """ collect_alleles = True diff --git a/src/ga4gh/vrs/extras/object_store.py b/src/ga4gh/vrs/extras/object_store.py index 02bf5412..7fceb371 100644 --- a/src/ga4gh/vrs/extras/object_store.py +++ b/src/ga4gh/vrs/extras/object_store.py @@ -11,11 +11,10 @@ class Sqlite3MutableMapping(MutableMapping[str, object]): """Store a mutable mapping in SQLite. - The context manager commits or rolls back pending transactions. With - autocommit enabled, earlier writes have already been committed and cannot - be rolled back by the context manager. Call ``close`` to - release the connection. With autocommit disabled, call ``commit`` to persist - changes outside a context manager. + The context manager commits or rolls back pending transactions. With autocommit + enabled, earlier writes have already been committed and cannot be rolled back by the + context manager. Call ``close`` to release the connection. With autocommit disabled, + call ``commit`` to persist changes outside a context manager. """ def __init__( diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index c2e8d067..a3e3aa58 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -25,9 +25,9 @@ class TranslationDefault(Enum): """Mark options that use the translator instance setting. - USE_INSTANCE_DEFAULT distinguishes an omitted option from an explicit None. - For rle_seq_limit, the default uses the instance limit, while None means no - limit. Enum members preserve their identity through copying and pickling. + USE_INSTANCE_DEFAULT distinguishes an omitted option from an explicit None. For + rle_seq_limit, the default uses the instance limit, while None means no limit. Enum + members preserve their identity through copying and pickling. """ INSTANCE = "instance" @@ -40,8 +40,8 @@ class TranslationDefault(Enum): class VariationToStrProtocol(Protocol): """Protocol for translating VRS objects to other string expressions. - This protocol defines a callable interface for translating a VRS object - into variation strings, with optional keyword arguments for customization. + This protocol defines a callable interface for translating a VRS object into + variation strings, with optional keyword arguments for customization. """ def __call__(self, vo: models._VariationBase, **kwargs: object) -> list[str]: @@ -56,8 +56,8 @@ def __call__(self, vo: models._VariationBase, **kwargs: object) -> list[str]: class VariationFromStrProtocol(Protocol): """Protocol for translating variation strings to VRS objects. - This protocol defines a callable interface for translating a variation - string into a VRS object, with optional keyword arguments for customization. + This protocol defines a callable interface for translating a variation string into a + VRS object, with optional keyword arguments for customization. """ def __call__( @@ -77,11 +77,11 @@ class _Translator(ABC): # noqa: B024 Translates various variation formats to and from GA4GH VRS models Format-specific `from_*` parsers follow this pattern: + * If the argument does not appear to be an appropriate type, None is returned * Otherwise, the argument is expected to be of the correct type. If an error occurs during processing, an exception is raised. * Otherwise, the VRS object is returned - """ gnomad_re = re.compile( @@ -118,8 +118,8 @@ def to_vrs( ) -> models._VariationBase: """Translate a variation expression or VRS JSON dictionary to a VRS object. - If fmt is omitted, try the registered parsers in order. Format-specific - methods return None for unrecognized input; this method raises ValueError. + If fmt is omitted, try the registered parsers in order. Format-specific methods + return None for unrecognized input; this method raises ValueError. :param var: Variation expression, or a VRS JSON dictionary with fmt="vrs". :param fmt: Input format, or None to try the registered parsers. @@ -326,24 +326,14 @@ def from_gnomad( :raises DataProxyValidationError: If reference validation is required and fails, or the location is outside the reference sequence. - #>>> a = tlr.from_gnomad("1-55516888-G-GA") - #>>> a.model_dump() - { - 'location': { - 'end': 55516888, - 'start': 55516887, - 'sequenceReference': { - 'refgetAccession': 'SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO', - 'type': 'SequenceReference' - }, - 'type': 'SequenceLocation' - }, - 'state': { - 'sequence': 'GA', - 'type': 'LiteralSequenceExpression' - }, - 'type': 'Allele' - } + With ``tlr`` configured for the reference sequence, disable normalization + to inspect the input coordinates and alternate sequence: + + >>> a = tlr.from_gnomad("1-55516888-G-GA", do_normalize=False) + >>> a.location.start, a.location.end + (55516887, 55516888) + >>> a.state.sequence.root + 'GA' """ if not isinstance(gnomad_expr, str): @@ -440,24 +430,15 @@ def from_spdi( :return: Translated Allele, or None if the expression or accession is unrecognized. :raises DataProxyValidationError: If the location is outside the reference sequence. - #>>> a = tlr.from_spdi("NC_000013.11:32936731:1:C") - #>>> a.model_dump() - { - 'location': { - 'end': 32936732, - 'start': 32936731, - 'sequenceReference': { - 'refgetAccession': 'SQ._0wi-qoDrvram155UmcSC-zA5ZK4fpLT', - 'type': 'SequenceReference' - }, - 'type': 'SequenceLocation' - }, - 'state': { - 'sequence': 'C', - 'type': 'LiteralSequenceExpression' - }, - 'type': 'Allele' - } + With ``tlr`` configured for the reference sequence, disable normalization + to inspect the input coordinates and alternate sequence: + + >>> a = tlr.from_spdi("NC_000013.11:32936731:1:C", do_normalize=False) + >>> a.location.start, a.location.end + (32936731, 32936732) + >>> a.state.sequence.root + 'C' + """ if not isinstance(spdi_expr, str): return None @@ -517,29 +498,25 @@ def to_spdi( ) -> list[str]: """Generate a *list* of SPDI expressions for VRS Allele. - If `namespace` is not None, returns SPDI strings for the - specified namespace. + If `namespace` is not None, returns SPDI strings for the specified namespace. - If `namespace` is None, returns SPDI strings for all alias - translations. + If `namespace` is None, returns SPDI strings for all alias translations. - If `ref_seq_limit` is specified, the reference sequence is - included in the SPDI expression only if its length is at most 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). + If `ref_seq_limit` is specified, the reference sequence is included in the SPDI + expression only if its length is at most 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). - If no alias translations are available, an empty list is - returned. + If no alias translations are available, an empty list is returned. Requires an inline SequenceLocation with integer bounds and either a - LiteralSequenceExpression or a ReferenceLengthExpression with integer - length and a positive repeatSubunitLength. Unsupported model shapes - are not validated before their fields are accessed. + LiteralSequenceExpression or a ReferenceLengthExpression with integer length and + a positive repeatSubunitLength. Unsupported model shapes are not validated + before their fields are accessed. - SPDI and VRS use identical normalization. The incoming Allele - is expected to be normalized per VRS spec. + SPDI and VRS use identical normalization. The incoming Allele is expected to be + normalized per VRS spec. :param vo: Allele to translate. :param namespace: Sequence identifier namespace, or None for all aliases. diff --git a/src/ga4gh/vrs/models.py b/src/ga4gh/vrs/models.py index 0f21770c..4555a1dc 100644 --- a/src/ga4gh/vrs/models.py +++ b/src/ga4gh/vrs/models.py @@ -107,8 +107,8 @@ def pydantic_class_refatt_map() -> tuple[ ]: """Collect referable model classes, field references, and inherent fields. - Referable fields can contain identifiable objects or references to them. - For example, the referable-field map includes {"Allele": ["location"]}. + Referable fields can contain identifiable objects or references to them. For + example, the referable-field map includes {"Allele": ["location"]}. :return: Referable classes, root-model unions containing them, the referable-field map, and the inherent-field map, in that order. @@ -220,7 +220,10 @@ class MoleculeType(str, Enum): class CopyChange(str, Enum): - """MUST use one of the defined enumerations that are based on the corresponding EFO ontological terms for copy number variation. See Implementation Guidance for more details.""" + """MUST use one of the defined enumerations that are based on the corresponding EFO ontological terms for copy number variation. + + See Implementation Guidance for more details. + """ COMPLETE_GENOMIC_LOSS = "complete genomic loss" HIGH_LEVEL_LOSS = "high-level loss" @@ -247,7 +250,15 @@ class Syntax(str, Enum): 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.""" + """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" @@ -280,7 +291,10 @@ class VRSMetadataMixin(GKMMetadataMixin): 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.""" + """A contextual value whose equality is based on value, not identity. + + See https://en.wikipedia.org/wiki/Value_object for more on Value Objects. + """ def __hash__(self) -> int: """Hash the canonical JSON representation. @@ -315,7 +329,11 @@ def is_ga4gh_identifiable() -> bool: 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.""" + """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 @@ -359,7 +377,8 @@ def compute_digest( Stores the digest in the object if ``store`` is ``True``. If ``as_version`` is provided, other parameters are ignored and a digest is - returned following the conventions of the VRS version indicated by ``as_version``. + returned following the conventions of the VRS version indicated by + ``as_version``. :param store: Save the computed digest on the object. :param as_version: Optional prior VRS version for serialization. @@ -389,6 +408,7 @@ def get_or_create_ga4gh_identifier( """Get a GA4GH computed identifier and optionally update the object ID. This function has three options for in_place editing of vro.id: + - 'default': the standard identifier update behavior for GA4GH identifiable objects, this mode will update the vro.id field only when it is None @@ -397,10 +417,9 @@ def get_or_create_ga4gh_identifier( - 'never': the vro.id field will not be edited in-place, even when empty - ``recompute`` applies when this call computes an identifier. A valid existing - ID returned by the default policy bypasses digest computation. An invalid - existing ID is replaced in the return value, but is only stored with the - always policy. + ``recompute`` applies when this call computes an identifier. A valid existing ID + returned by the default policy bypasses digest computation. An invalid existing + ID is replaced in the return value, but is only stored with the always policy. If ``as_version`` is provided, other parameters are ignored and an identifier is returned following the conventions of the VRS version indicated by @@ -452,7 +471,7 @@ def compute_ga4gh_identifier( return f"{CURIE_NAMESPACE}{CURIE_SEP}{self.ga4gh.priorPrefix[as_version]}{GA4GH_PREFIX_SEP}{digest}" def get_or_create_digest(self, recompute: bool = False) -> str: - """Set and returns a sha512t24u digest of the GA4GH Identifiable Object, or create the digest if it does not exist. + """Return the cached digest, computing and storing it when needed. :param recompute: Recompute an existing digest when true. :return: Existing or newly computed digest. @@ -468,7 +487,11 @@ class ga4gh(_ValueObject.ga4gh): # noqa: N801 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.""" + """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 @@ -543,7 +566,10 @@ class residue(VRSMetadataMixin, 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.""" + """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 @@ -813,7 +839,11 @@ class ga4gh(_ValueObject.ga4gh): 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.""" + """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 @@ -1023,7 +1053,11 @@ class ga4gh(Ga4ghIdentifiableObject.ga4gh): 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.""" + """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 diff --git a/src/ga4gh/vrs/normalize.py b/src/ga4gh/vrs/normalize.py index f00882de..72d1ccc1 100644 --- a/src/ga4gh/vrs/normalize.py +++ b/src/ga4gh/vrs/normalize.py @@ -111,20 +111,24 @@ def _get_new_allele_location_pos( def _normalize_allele( input_allele: models.Allele, data_proxy: _DataProxy, rle_seq_limit: int | None = 50 ) -> models.Allele: - """Normalize Allele using "fully-justified" normalization adapted from NCBI's VOCA. Fully-justified normalization expands such ambiguous representation over the entire region of ambiguity, resulting in an unambiguous representation that may be readily compared with other alleles. + """Normalize an Allele using fully justified normalization. + + Adapted from NCBI's VOCA, this expands ambiguous representations over the entire + region of ambiguity so they can be compared with other alleles. This function assumes that IRIs are dereferenced, providing a `SequenceReference` as - the `allele.location.sequenceReference`. If a `SequenceReference` is not provided, the allele - will be returned as is with no normalization. + the `allele.location.sequenceReference`. If a `SequenceReference` is not provided, + the allele will be returned as is with no normalization. - Does not attempt to normalize Alleles with definite ranges or non-LiteralSequenceExpression - states and will instead return the `input_allele`. + Does not attempt to normalize Alleles with definite ranges or + non-LiteralSequenceExpression states and will instead return the `input_allele`. - If attempting to re-normalize a ReferenceLengthExpression allele, - use denormalize_reference_length_expression to construct a LiteralSequenceExpression + If attempting to re-normalize a ReferenceLengthExpression allele, use + denormalize_reference_length_expression to construct a LiteralSequenceExpression allele and then call normalize() on that LiteralSequenceExpression allele. - See: https://vrs.ga4gh.org/en/2.0/conventions/normalization.html#literalsequenceexpression-alleles + See: + https://vrs.ga4gh.org/en/2.0/conventions/normalization.html#literalsequenceexpression-alleles :param input_allele: Input VRS Allele object :param data_proxy: SeqRepo dataproxy @@ -280,8 +284,8 @@ def _trim_for_normalization( Return the trimmed interval and trimmed alleles: ((trim_start, trim_end), (trim_ref, trim_alt)) - If the alleles are the same, return the original interval and alleles. - The first allele (ref) will be populated by bioutils. + If the alleles are the same, return the original interval and alleles. The first + allele (ref) will be populated by bioutils. :param ref_seq: Reference sequence proxy. :param ival: Input start and end coordinates. @@ -332,7 +336,10 @@ def denormalize_reference_length_expression( repeat_subunit_length: int, alt_length: int, ) -> str: - """Reverse the process of repeat subunit compaction for a ReferenceLengthExpression. The alt is reference-derived so repeat the repeat subunit until it is alt_length characters long. May include a trailing partial copy of the repeat subunit. + """Expand a ReferenceLengthExpression into its alternate sequence. + + Repeat the reference subunit to alt_length characters, including a trailing partial + copy when needed. e.g. "ACGT" (repeat_subunit_length=4, length=10) -> "ACGTACGTAC" (ACGT ACGT AC) diff --git a/src/ga4gh/vrs/utils/hgvs_tools.py b/src/ga4gh/vrs/utils/hgvs_tools.py index 8e48c2c9..c8ea4baf 100644 --- a/src/ga4gh/vrs/utils/hgvs_tools.py +++ b/src/ga4gh/vrs/utils/hgvs_tools.py @@ -59,12 +59,12 @@ def _is_uncertain_range(pos: _HgvsPos) -> TypeGuard[hgvs.location.Interval]: - """Return True if ``pos`` is a nested :class:`hgvs.location.Interval` representing an uncertain range (e.g. ``(A_B)`` from ``(A_B)_(C_D)del``). + """Check whether an HGVS position is an uncertain interval. - Note that hgvs 2.0.0a0 also emits a non-uncertain ``Interval`` (with - ``start == end``) for the exact side of a mixed expression like - ``g.100_(200_?)del``, so the ``.uncertain`` check is required to distinguish - real uncertain ranges from that wrapper shape. + Note that hgvs 2.0.0a0 also emits a non-uncertain ``Interval`` (with ``start == + end``) for the exact side of a mixed expression like ``g.100_(200_?)del``, so the + ``.uncertain`` check is required to distinguish real uncertain ranges from that + wrapper shape. :param pos: HGVS position to inspect or convert. :return: Whether the position is an uncertain interval. @@ -73,13 +73,13 @@ def _is_uncertain_range(pos: _HgvsPos) -> TypeGuard[hgvs.location.Interval]: def _has_intron_offset(pos: _HgvsPos) -> bool: - """Return True if ``pos`` is a :class:`hgvs.location.BaseOffsetPosition` with a non-zero intron offset (e.g. ``100+5`` or ``200-3``). + """Check whether an HGVS position has a nonzero intron offset. - VRS :class:`SequenceLocation` coordinates are transcript-relative; a - non-zero offset means the position lies inside an intron, which has no - faithful single-integer representation in VRS. This helper lets the - low-level converters refuse such inputs defensively; upstream callers - typically reject at the variant level via :meth:`HgvsTools.is_intronic`. + VRS :class:`SequenceLocation` coordinates are transcript-relative; a non-zero offset + means the position lies inside an intron, which has no faithful single-integer + representation in VRS. This helper lets the low-level converters refuse such inputs + defensively; upstream callers typically reject at the variant level via + :meth:`HgvsTools.is_intronic`. :param pos: HGVS position to inspect or convert. :return: Whether the position has a nonzero intron offset. @@ -88,7 +88,7 @@ def _has_intron_offset(pos: _HgvsPos) -> bool: def _shift_hgvs_to_vrs(base: int | None, side: _Side) -> int | None: - """Convert an HGVS 1-based inclusive coordinate to a VRS 0-based interbase coordinate, accounting for which side of the outer interval it is on. + """Convert an HGVS coordinate to the corresponding VRS interbase coordinate. :param base: HGVS coordinate, or None if unknown. :param side: Start or end side of the variant interval. @@ -112,7 +112,7 @@ def _shift_vrs_to_hgvs(value: int | None, side: _Side) -> int | None: def _hgvs_pos_to_vrs(pos: _HgvsPos, side: _Side) -> _VrsPos: - """Convert the ``pos.start`` or ``pos.end`` of a parsed hgvs variant to the corresponding VRS value for :attr:`models.SequenceLocation.start` / :attr:`models.SequenceLocation.end`. + """Convert an HGVS variant endpoint to a VRS coordinate or range. Handles three shapes of ``pos``: @@ -183,7 +183,7 @@ def _vrs_pos_to_hgvs( *, as_interval: bool = False, ) -> hgvs.location.SimplePosition | hgvs.location.Interval: - """Convert a VRS :attr:`models.SequenceLocation.start` / ``end`` value to the corresponding hgvs position object. + """Convert a VRS coordinate or range to an HGVS position. For an ``int``, returns a :class:`hgvs.location.SimplePosition`. For a :class:`models.Range` (or equivalent 2-element list), returns a nested @@ -191,10 +191,10 @@ def _vrs_pos_to_hgvs( within a Range become ``SimplePosition(base=None)`` (rendered as ``?``). When ``as_interval`` is True, an ``int`` value is wrapped in a non-uncertain - ``Interval`` with ``start == end``. This matches the hgvs parser's - representation of mixed certain/uncertain outer intervals (e.g. - ``g.N_(M_?)del``), where both sides of the outer interval must have the - same kind of inner position for the hgvs formatter to work. + ``Interval`` with ``start == end``. This matches the hgvs parser's representation of + mixed certain/uncertain outer intervals (e.g. ``g.N_(M_?)del``), where both sides of + the outer interval must have the same kind of inner position for the hgvs formatter + to work. :param value: Value to convert. :param side: Start or end side of the variant interval. @@ -223,8 +223,8 @@ def _vrs_pos_to_hgvs( class HgvsTools: """Provide tools for working with HGVS notation. - Use a context manager or call ``close()`` to close this instance's UTA - connection. The caller owns the supplied data proxy, which stays open. + Use a context manager or call ``close()`` to close this instance's UTA connection. + The caller owns the supplied data proxy, which stays open. Attributes: parser (hgvs.parser.Parser): The HGVS parser object. @@ -240,8 +240,8 @@ class HgvsTools: def __init__(self, data_proxy: _DataProxy | None = None) -> None: """Create the HGVS parser, normalizer, variant mapper, and UTA connection. - Close the UTA connection if normalizer or variant mapper setup fails. - If cleanup also fails, raise its error with the setup error chained. + Close the UTA connection if normalizer or variant mapper setup fails. If cleanup + also fails, raise its error with the setup error chained. :param data_proxy: Optional caller-owned GA4GH data proxy. """ @@ -259,9 +259,9 @@ def __init__(self, data_proxy: _DataProxy | None = None) -> None: def close(self) -> None: """Close this instance's UTA connection. - Repeated calls after success do nothing. Cleanup errors propagate; - callers can retry. Keep all resource references and leave the supplied - data proxy open. Operations that need UTA are unsupported after closure. + Repeated calls after success do nothing. Cleanup errors propagate; callers can + retry. Keep all resource references and leave the supplied data proxy open. + Operations that need UTA are unsupported after closure. """ if not self._closed: self.uta_conn.close() @@ -307,7 +307,9 @@ def parse(self, hgvs_str: str) -> HgvsSequenceVariant | None: return self.parser.parse_hgvs_variant(hgvs_str) def has_uncertain_range(self, sv: HgvsSequenceVariant) -> bool: - """Return True if either endpoint of ``sv.posedit.pos`` is a nested uncertain :class:`hgvs.location.Interval` (e.g. the ``(A_B)`` sides of ``(A_B)_(C_D)del``). Surfaces the module-private :func:`_is_uncertain_range` check to external callers without requiring them to import an underscore helper across module boundaries. + """Check whether either variant endpoint is an uncertain interval. + + For example, the endpoints in ``(A_B)_(C_D)del`` are nested uncertain intervals. :param sv: Parsed HGVS sequence variant. :return: Whether either endpoint is an uncertain range. @@ -435,11 +437,11 @@ def build_cnv_location( ) -> models.SequenceLocation: """Build a VRS :class:`SequenceLocation` from a parsed hgvs CNV variant. - CDS-relative (``c.``) inputs are translated to transcript-relative - (``n.``) coordinates first; without that step the VRS location would - point into the 5' UTR. Handles both certain positions and uncertain- - range endpoints; the resulting ``start`` / ``end`` are ints for exact - positions and :class:`models.Range` values for uncertain ranges. + CDS-relative (``c.``) inputs are translated to transcript-relative (``n.``) + coordinates first; without that step the VRS location would point into the 5' + UTR. Handles both certain positions and uncertain- range endpoints; the + resulting ``start`` / ``end`` are ints for exact positions and + :class:`models.Range` values for uncertain ranges. :param sv: Parsed HGVS sequence variant. :param refget_accession: Refget accession for the location sequence. @@ -462,14 +464,11 @@ def from_allele( ) -> list[str]: """Generate a *list* of HGVS expressions for VRS Allele. - If `namespace` is not None, returns HGVS strings for the - specified namespace. + If `namespace` is not None, returns HGVS strings for the specified namespace. - If `namespace` is None, returns HGVS strings for all alias - translations. + If `namespace` is None, returns HGVS strings for all alias translations. - If no alias translations are available, an empty list is - returned. + If no alias translations are available, an empty list is returned. If the VRS object cannot be expressed as HGVS, raises ValueError.