From 9a46409a8dcb44cb50a7073af85d46d5b2e33d2e Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Wed, 23 Sep 2026 16:07:02 -0400 Subject: [PATCH 01/11] fix(translator): look up VRS model class by type in _from_vrs models is a module, so models[var["type"]] raised TypeError for every input and the "vrs" format was unusable. Resolve the class via VrsType, returning None for unknown types as intended. --- src/ga4gh/vrs/extras/translator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 91c45532..d4ca9d94 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -167,8 +167,8 @@ def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noq if "type" not in var: return None try: - model = models[var["type"]] - except KeyError: + model = getattr(models, models.VrsType(var["type"]).value) + except ValueError: return None return model(**var) From da9a058a2e6aa62e170a770074ac877efa59d973 Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Wed, 23 Sep 2026 16:07:19 -0400 Subject: [PATCH 02/11] fix(translator): reject out-of-bounds sequence locations SeqRepo silently truncates out-of-range fetches, so translators minted permanent VRS identifiers for locations that do not exist on their sequence, or reported them as a misleading reference mismatch against ''. Zero-width insertions past the end got no diagnostic at all, and the CNV and vrs paths never fetch sequence. Add _DataProxy.validate_location_bounds, which raises DataProxyValidationError (unconditionally; there is no require_validation escape hatch) when any defined start/end value lies outside [0, len]. start and end are checked independently so circular start > end remains valid, pos == len is a valid insertion point, and undefined Range endpoints are skipped. Call it from _create_allele (hgvs, spdi, beacon, gnomad), from _from_gnomad before validate_ref_seq, from CnvTranslator._from_hgvs, and from _from_vrs. The original input accession is threaded through as sequence_id and namespace-coerced with the new coerce_accession_namespace (shared with derive_refget_accession) so the length lookup is a metadata cache hit, adding no requests. --- src/ga4gh/vrs/dataproxy.py | 109 ++++++++++++++++++++++++++++- src/ga4gh/vrs/extras/translator.py | 56 ++++++++++++++- src/ga4gh/vrs/utils/hgvs_tools.py | 1 + 3 files changed, 160 insertions(+), 6 deletions(-) diff --git a/src/ga4gh/vrs/dataproxy.py b/src/ga4gh/vrs/dataproxy.py index 32b8d561..7c9ba5c4 100644 --- a/src/ga4gh/vrs/dataproxy.py +++ b/src/ga4gh/vrs/dataproxy.py @@ -11,11 +11,15 @@ import os from abc import ABC, abstractmethod from collections.abc import Sequence +from typing import TYPE_CHECKING from urllib.parse import urlparse import requests from bioutils.accessions import coerce_namespace +if TYPE_CHECKING: + from ga4gh.vrs.models import Range + _logger = logging.getLogger(__name__) @@ -23,6 +27,76 @@ class DataProxyValidationError(Exception): """Class for validation errors during data proxy methods""" +def coerce_accession_namespace(ac: str) -> str: + """Return ``ac`` as a namespaced CURIE, inferring the namespace if none is given + + e.g. ``NM_000551.3`` -> ``refseq:NM_000551.3``. Identifiers that already carry a + namespace (``GRCh38:1``, ``ga4gh:SQ.…``) are returned unchanged. + + Metadata lookups are cached per identifier, so callers that must hit the same + cache entry (e.g. deriving a refget accession and then validating bounds on the + same input) should both go through this function. + + :param ac: accession in simple or CURIE form + :return: accession in CURIE form + """ + if ":" not in ac[1:]: + ac = coerce_namespace(ac) + return ac + + +def _defined_values(pos: "int | Range | None") -> list[int]: + """Return the defined values of a location coordinate + + An int yields itself, a ``Range`` yields its non-``None`` members, and ``None`` + (an undefined endpoint) yields nothing. + + :param pos: ``start`` or ``end`` of a ``SequenceLocation`` + :return: defined coordinate values + """ + if pos is None: + return [] + if isinstance(pos, int): + return [pos] + return [v for v in pos.root if v is not None] + + +def _check_location_bounds( + sequence_id: str, + seq_len: int, + start_pos: "int | Range | None", + end_pos: "int | Range | None", +) -> None: + """Raise if any defined value of ``start``/``end`` lies outside ``[0, seq_len]`` + + Each coordinate is checked independently; the relationship between ``start`` and + ``end`` is never inspected, since ``start > end`` is valid on circular sequences. + ``pos == seq_len`` is valid (an insertion point after the final residue). + Undefined endpoints, and the undefined side of an indefinite ``Range``, are + skipped rather than treated as 0. + + :param sequence_id: identifier of the sequence, used in the error message + :param seq_len: length of the sequence + :param start_pos: ``start`` of the location + :param end_pos: ``end`` of the location + :raises DataProxyValidationError: if a defined coordinate is out of bounds + """ + bad = [ + (name, pos) + for name, pos in (("start", start_pos), ("end", end_pos)) + if any(v < 0 or v > seq_len for v in _defined_values(pos)) + ] + if bad: + # Range is shown as its list form, e.g. end=[4500, 4600] + detail = ", ".join(f"{name}={getattr(pos, 'root', pos)}" for name, pos in bad) + err_msg = ( + f"Location out of bounds on {sequence_id}: {detail} " + f"not within [0, {seq_len}]" + ) + _logger.warning(err_msg) + raise DataProxyValidationError(err_msg) + + class _DataProxy(ABC): """abstract class / interface for VRS data needs @@ -133,9 +207,8 @@ def derive_refget_accession(self, ac: str) -> str | None: if ac is None: return None - if ":" not in ac[1:]: - # always coerce the namespace if none provided - ac = coerce_namespace(ac) + # always coerce the namespace if none provided + ac = coerce_accession_namespace(ac) refget_accession = None try: @@ -181,6 +254,36 @@ def validate_ref_seq( if require_validation: raise DataProxyValidationError(err_msg) + def validate_location_bounds( + self, + sequence_id: str, + start_pos: "int | Range | None", + end_pos: "int | Range | None", + ) -> None: + """Ensure that ``start_pos`` and ``end_pos`` are representable on ``sequence_id``. + + Each defined coordinate must be within ``[0, len(sequence)]`` (inter-residue). + Undefined (``None``) endpoints are skipped, and for a ``Range`` the largest + defined member is checked. ``start_pos`` and ``end_pos`` are checked + independently, so ``start_pos > end_pos`` (circular sequences) is permitted. + + Unlike ``validate_ref_seq``, there is no ``require_validation`` option: an + out-of-bounds location has no meaning, so the error is always raised. 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 + :param end_pos: End pos (inter-residue) on the sequence_id + :raises DataProxyValidationError: If a defined coordinate is out of bounds + :raises KeyError: If ``sequence_id`` is not found + """ + # Coerce the same way derive_refget_accession does, so that the metadata + # lookup hits the same cache entry + sequence_id = coerce_accession_namespace(sequence_id) + seq_len = self.get_metadata(sequence_id)["length"] + _check_location_bounds(sequence_id, seq_len, start_pos, end_pos) + class _SeqRepoDataProxyBase(_DataProxy): # wraps seqreqpo classes in order to provide translation to/from diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index d4ca9d94..bcff8265 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -170,7 +170,37 @@ def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noq model = getattr(models, models.VrsType(var["type"]).value) except ValueError: return None - return model(**var) + vo = model(**var) + + # Nothing downstream of this path fetches or normalizes, so this is the only + # opportunity to reject a location that does not exist on its sequence + location = getattr(vo, "location", None) + if isinstance(location, models.SequenceLocation) and isinstance( + location.sequenceReference, models.SequenceReference + ): + self._validate_location_bounds( + f"ga4gh:{location.sequenceReference.refgetAccession}", + location.start, + location.end, + ) + return vo + + def _validate_location_bounds( + self, + sequence_id: str, + start: int | models.Range | None, + end: int | models.Range | None, + ) -> None: + """Raise if ``start``/``end`` are not representable on ``sequence_id`` + + :param sequence_id: Sequence identifier as given in the input expression. + Use the same identifier that was passed to ``derive_refget_accession`` so + that the length lookup is served from the dataproxy's metadata cache. + :param start: Start (inter-residue) of the location + :param end: End (inter-residue) of the location + :raises DataProxyValidationError: If ``start`` or ``end`` is out of bounds + """ + self.data_proxy.validate_location_bounds(sequence_id, start, end) class AlleleTranslator(_Translator): @@ -203,6 +233,8 @@ def _create_allele(self, values: dict, **kwargs) -> models.Allele: Args: values (dict): The values to use for creating the allele object. + 'sequence_id' (str): The sequence identifier from the input + expression, used to validate `start` and `end`. 'refget_accession' (str): The accession ID of the reference genome. 'start' (int): The start position of the allele. 'end' (int): The end position of the allele. @@ -212,7 +244,13 @@ def _create_allele(self, values: dict, **kwargs) -> models.Allele: Returns: models.Allele: The created allele object. + Raises: + DataProxyValidationError: If `start` or `end` is out of bounds. + """ + self._validate_location_bounds( + values["sequence_id"], values["start"], values["end"] + ) seq_ref = models.SequenceReference(refgetAccession=values["refget_accession"]) location = models.SequenceLocation( sequenceReference=seq_ref, start=values["start"], end=values["end"] @@ -276,6 +314,7 @@ def _from_beacon(self, beacon_expr: str, **kwargs) -> models.Allele | None: ins_seq = alt values = { + "sequence_id": sequence, "refget_accession": refget_accession, "start": start, "end": end, @@ -340,6 +379,9 @@ def _from_gnomad(self, gnomad_expr: str, **kwargs) -> models.Allele | None: ins_seq = alt # validation checks + # Bounds must be checked before the ref check: an out-of-bounds fetch may be + # silently truncated, which would be misreported as a reference mismatch + self._validate_location_bounds(sequence, start, end) self.data_proxy.validate_ref_seq( sequence, start, @@ -349,6 +391,7 @@ def _from_gnomad(self, gnomad_expr: str, **kwargs) -> models.Allele | None: ) values = { + "sequence_id": sequence, "refget_accession": refget_accession, "start": start, "end": end, @@ -414,6 +457,7 @@ def _from_spdi(self, spdi_expr: str, **kwargs) -> models.Allele | None: ins_seq = g["ins_seq"] values = { + "sequence_id": g["ac"], "refget_accession": refget_accession, "start": start, "end": end, @@ -551,6 +595,8 @@ def _from_hgvs( CopyNumberCount copy_change: Copy change. If not provided, default is EFO:0030067 for deletions and EFO:0030070 for duplications + + :raises DataProxyValidationError: If the location is out of bounds """ # sv = self._get_parsed_hgvs(hgvs_dup_del_expr) sv = self.hgvs_tools.parse(hgvs_dup_del_expr) @@ -570,12 +616,16 @@ def _from_hgvs( if not refget_accession: return None + start = sv.posedit.pos.start.base - 1 + end = sv.posedit.pos.end.base + self._validate_location_bounds(sv.ac, start, end) + location = models.SequenceLocation( sequenceReference=models.SequenceReference( refgetAccession=refget_accession ), - start=sv.posedit.pos.start.base - 1, - end=sv.posedit.pos.end.base, + start=start, + end=end, ) copies = kwargs.get("copies") diff --git a/src/ga4gh/vrs/utils/hgvs_tools.py b/src/ga4gh/vrs/utils/hgvs_tools.py index 503d4022..5ff3f38f 100644 --- a/src/ga4gh/vrs/utils/hgvs_tools.py +++ b/src/ga4gh/vrs/utils/hgvs_tools.py @@ -182,6 +182,7 @@ def extract_allele_values(self, hgvs_expr: str) -> dict | None: (start, end, state) = self.get_position_and_state(sv) return { + "sequence_id": sv.ac, "refget_accession": refget_accession, "start": start, "end": end, From 965684afd7b8e9a0d70716e684cb51c5db4468ef Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Wed, 23 Sep 2026 16:07:28 -0400 Subject: [PATCH 03/11] fix(normalize): reject out-of-bounds allele locations before fetching Second layer for routes the translator checks do not cover: normalize() called directly on a hand-built Allele, the annotator, and denormalize/renormalize round trips. Uses the length SequenceProxy already fetched, so it adds no I/O, and runs before the definite-range early return so those locations are checked too. Fails before bioutils can compute on truncated sequence. --- src/ga4gh/vrs/normalize.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/ga4gh/vrs/normalize.py b/src/ga4gh/vrs/normalize.py index fad73d87..27c4bd73 100644 --- a/src/ga4gh/vrs/normalize.py +++ b/src/ga4gh/vrs/normalize.py @@ -15,7 +15,7 @@ from ga4gh.core import ga4gh_digest, is_pydantic_instance, pydantic_copy from ga4gh.vrs import models -from ga4gh.vrs.dataproxy import SequenceProxy, _DataProxy +from ga4gh.vrs.dataproxy import SequenceProxy, _check_location_bounds, _DataProxy _logger = logging.getLogger(__name__) @@ -111,6 +111,8 @@ def _normalize_allele( of the `sequence`. To exclude `sequence` from the response, set to 0. For no limit, set to `None`. + :raises DataProxyValidationError: If the allele location is out of bounds on its + sequence """ # Algorithm applies to LiteralSequenceExpression alleles only; other states are returned unchanged if not isinstance(input_allele.state, models.LiteralSequenceExpression): @@ -130,6 +132,14 @@ def _normalize_allele( # 0: Get reference sequence and interval ref_seq = SequenceProxy(data_proxy, alias) + + # Reject locations that do not exist on the sequence before anything is fetched, + # since out-of-range fetches may be silently truncated by the sequence backend. + # Done before the early returns below, which skip definite ranges. + _check_location_bounds( + alias, len(ref_seq), input_allele.location.start, input_allele.location.end + ) + start = _get_allele_location_pos(input_allele, use_start=True) if start is None: return input_allele From 1d8e654ea6d4046234cb84b1e1d232175ea766ff Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Wed, 23 Sep 2026 16:07:28 -0400 Subject: [PATCH 04/11] test: cover out-of-bounds location validation Parameterized cases for validate_location_bounds (stub proxy), the normalize guard (local test SeqRepo), and every translator input path: hgvs g./n./c./p. (including p.Terdel), spdi (including a zero-width insertion past the end), gnomad (bounds message rather than reference mismatch, with and without require_validation), beacon, vrs, and CNV hgvs. In-bounds edge cases (terminal residue, insertion at end, circular start > end, indefinite ranges) are accepted. Each translator test uses a fresh REST dataproxy so its cassette is self-contained regardless of test order. --- ...s[cnv-hgvs-copy-number-change-at-end].yaml | 25 ++ ...st_in_bounds[hgvs-c-terminal-residue].yaml | 100 ++++++ ...st_in_bounds[hgvs-p-terminal-residue].yaml | 98 ++++++ ...test_in_bounds[spdi-insertion-at-end].yaml | 58 ++++ ...test_in_bounds[spdi-terminal-residue].yaml | 46 +++ ...nds[vrs-allele-circular-start-gt-end].yaml | 31 ++ ...s-cnv-indefinite-ranges-open-outward].yaml | 18 + .../test_out_of_bounds[beacon-past-end].yaml | 25 ++ ...cnv-hgvs-copy-number-change-past-end].yaml | 25 ++ ...[cnv-hgvs-copy-number-count-past-end].yaml | 25 ++ ..._out_of_bounds[gnomad-negative-start].yaml | 25 ++ ...nomad-past-end-no-require-validation].yaml | 25 ++ .../test_out_of_bounds[gnomad-past-end].yaml | 25 ++ .../test_out_of_bounds[hgvs-c-past-end].yaml | 18 + .../test_out_of_bounds[hgvs-g-past-end].yaml | 26 ++ ..._of_bounds[hgvs-n-insertion-past-end].yaml | 18 + .../test_out_of_bounds[hgvs-n-past-end].yaml | 18 + ...bounds[hgvs-p-ter-at-length-plus-one].yaml | 26 ++ ...ut_of_bounds[spdi-insertion-past-end].yaml | 18 + .../test_out_of_bounds[spdi-past-end].yaml | 18 + ...ele-start-past-end-with-start-gt-end].yaml | 18 + ...[vrs-cnv-definite-range-end-past-end].yaml | 18 + tests/extras/test_location_bounds.py | 320 ++++++++++++++++++ tests/test_dataproxy.py | 134 +++++++- tests/test_vrs_normalize.py | 83 +++++ 25 files changed, 1240 insertions(+), 1 deletion(-) create mode 100644 tests/extras/cassettes/test_in_bounds[cnv-hgvs-copy-number-change-at-end].yaml create mode 100644 tests/extras/cassettes/test_in_bounds[hgvs-c-terminal-residue].yaml create mode 100644 tests/extras/cassettes/test_in_bounds[hgvs-p-terminal-residue].yaml create mode 100644 tests/extras/cassettes/test_in_bounds[spdi-insertion-at-end].yaml create mode 100644 tests/extras/cassettes/test_in_bounds[spdi-terminal-residue].yaml create mode 100644 tests/extras/cassettes/test_in_bounds[vrs-allele-circular-start-gt-end].yaml create mode 100644 tests/extras/cassettes/test_in_bounds[vrs-cnv-indefinite-ranges-open-outward].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-count-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[gnomad-negative-start].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[gnomad-past-end-no-require-validation].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[gnomad-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[hgvs-c-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[hgvs-n-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[spdi-past-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[vrs-allele-start-past-end-with-start-gt-end].yaml create mode 100644 tests/extras/cassettes/test_out_of_bounds[vrs-cnv-definite-range-end-past-end].yaml create mode 100644 tests/extras/test_location_bounds.py diff --git a/tests/extras/cassettes/test_in_bounds[cnv-hgvs-copy-number-change-at-end].yaml b/tests/extras/cassettes/test_in_bounds[cnv-hgvs-copy-number-change-at-end].yaml new file mode 100644 index 00000000..1e5e9d6b --- /dev/null +++ b/tests/extras/cassettes/test_in_bounds[cnv-hgvs-copy-number-change-at-end].yaml @@ -0,0 +1,25 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NC_000007.14 + response: + body: + string: "{\n \"added\": \"2016-08-27T21:23:35Z\",\n \"aliases\": [\n \"GRCh38:7\",\n + \ \"GRCh38:chr7\",\n \"GRCh38.p1:7\",\n \"GRCh38.p1:chr7\",\n \"GRCh38.p10:7\",\n + \ \"GRCh38.p10:chr7\",\n \"GRCh38.p11:7\",\n \"GRCh38.p11:chr7\",\n + \ \"GRCh38.p12:7\",\n \"GRCh38.p12:chr7\",\n \"GRCh38.p2:7\",\n \"GRCh38.p2:chr7\",\n + \ \"GRCh38.p3:7\",\n \"GRCh38.p3:chr7\",\n \"GRCh38.p4:7\",\n \"GRCh38.p4:chr7\",\n + \ \"GRCh38.p5:7\",\n \"GRCh38.p5:chr7\",\n \"GRCh38.p6:7\",\n \"GRCh38.p6:chr7\",\n + \ \"GRCh38.p7:7\",\n \"GRCh38.p7:chr7\",\n \"GRCh38.p8:7\",\n \"GRCh38.p8:chr7\",\n + \ \"GRCh38.p9:7\",\n \"GRCh38.p9:chr7\",\n \"MD5:cc044cc2256a1141212660fb07b6171e\",\n + \ \"NCBI:NC_000007.14\",\n \"refseq:NC_000007.14\",\n \"SEGUID:4+JjCcBVhPCr8vdIhUKFycPv8bY\",\n + \ \"SHA1:e3e26309c05584f0abf2f748854285c9c3eff1b6\",\n \"VMC:GS_F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n + \ \"sha512t24u:F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n \"ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\"\n + \ ],\n \"alphabet\": \"ACGNRSTY\",\n \"length\": 159345973\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[hgvs-c-terminal-residue].yaml b/tests/extras/cassettes/test_in_bounds[hgvs-c-terminal-residue].yaml new file mode 100644 index 00000000..e1df8cbb --- /dev/null +++ b/tests/extras/cassettes/test_in_bounds[hgvs-c-terminal-residue].yaml @@ -0,0 +1,100 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=nucleotide&id=NM_000551.3&rettype=fasta&seq_start=4560&seq_stop=4560&tool=bioutils&email=biocommons-dev@googlegroups.com + response: + body: + string: '>NM_000551.3:4560-4560 Homo sapiens von Hippel-Lindau tumor suppressor + (VHL), transcript variant 1, mRNA + + G + + + ' + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4559&end=4560 + response: + body: + string: G + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4558&end=4559 + response: + body: + string: A + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4559&end=4559 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4560&end=4560 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[hgvs-p-terminal-residue].yaml b/tests/extras/cassettes/test_in_bounds[hgvs-p-terminal-residue].yaml new file mode 100644 index 00000000..db3c2911 --- /dev/null +++ b/tests/extras/cassettes/test_in_bounds[hgvs-p-terminal-residue].yaml @@ -0,0 +1,98 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NP_001346993.1 + response: + body: + string: "{\n \"added\": \"2016-08-24T04:59:23Z\",\n \"aliases\": [\n \"Ensembl:ENSP00000480268.1\",\n + \ \"ensembl:ENSP00000480268.1\",\n \"Ensembl:ENSP00000491180.1\",\n \"ensembl:ENSP00000491180.1\",\n + \ \"Ensembl:ENSP00000491338.1\",\n \"ensembl:ENSP00000491338.1\",\n \"Ensembl:ENSP00000491353.1\",\n + \ \"ensembl:ENSP00000491353.1\",\n \"Ensembl:ENSP00000492701.1\",\n \"ensembl:ENSP00000492701.1\",\n + \ \"Ensembl:ENSP00000498790.1\",\n \"ensembl:ENSP00000498790.1\",\n \"MD5:fecf2eee2cdc50588a641e472e062be1\",\n + \ \"NCBI:NP_001346993.1\",\n \"refseq:NP_001346993.1\",\n \"NCBI:NP_001347000.1\",\n + \ \"refseq:NP_001347000.1\",\n \"NCBI:NP_001355060.1\",\n \"refseq:NP_001355060.1\",\n + \ \"NCBI:XP_011534418.1\",\n \"refseq:XP_011534418.1\",\n \"NCBI:XP_024302319.1\",\n + \ \"refseq:XP_024302319.1\",\n \"NCBI:XP_054212402.1\",\n \"refseq:XP_054212402.1\",\n + \ \"NCBI:XP_054212403.1\",\n \"refseq:XP_054212403.1\",\n \"SEGUID:8Lnknw+hAAOAjiLGHus4bfyDy0k\",\n + \ \"SHA1:f0b9e49f0fa10003808e22c61eeb386dfc83cb49\",\n \"VMC:GS_IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n + \ \"sha512t24u:IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n \"ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\"\n + \ ],\n \"alphabet\": \"ACDEFGHIKLMNPQRSTVWY\",\n \"length\": 193\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer + response: + body: + string: "{\n \"added\": \"2016-08-24T04:59:23Z\",\n \"aliases\": [\n \"Ensembl:ENSP00000480268.1\",\n + \ \"ensembl:ENSP00000480268.1\",\n \"Ensembl:ENSP00000491180.1\",\n \"ensembl:ENSP00000491180.1\",\n + \ \"Ensembl:ENSP00000491338.1\",\n \"ensembl:ENSP00000491338.1\",\n \"Ensembl:ENSP00000491353.1\",\n + \ \"ensembl:ENSP00000491353.1\",\n \"Ensembl:ENSP00000492701.1\",\n \"ensembl:ENSP00000492701.1\",\n + \ \"Ensembl:ENSP00000498790.1\",\n \"ensembl:ENSP00000498790.1\",\n \"MD5:fecf2eee2cdc50588a641e472e062be1\",\n + \ \"NCBI:NP_001346993.1\",\n \"refseq:NP_001346993.1\",\n \"NCBI:NP_001347000.1\",\n + \ \"refseq:NP_001347000.1\",\n \"NCBI:NP_001355060.1\",\n \"refseq:NP_001355060.1\",\n + \ \"NCBI:XP_011534418.1\",\n \"refseq:XP_011534418.1\",\n \"NCBI:XP_024302319.1\",\n + \ \"refseq:XP_024302319.1\",\n \"NCBI:XP_054212402.1\",\n \"refseq:XP_054212402.1\",\n + \ \"NCBI:XP_054212403.1\",\n \"refseq:XP_054212403.1\",\n \"SEGUID:8Lnknw+hAAOAjiLGHus4bfyDy0k\",\n + \ \"SHA1:f0b9e49f0fa10003808e22c61eeb386dfc83cb49\",\n \"VMC:GS_IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n + \ \"sha512t24u:IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n \"ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\"\n + \ ],\n \"alphabet\": \"ACDEFGHIKLMNPQRSTVWY\",\n \"length\": 193\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer?start=192&end=193 + response: + body: + string: L + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer?start=191&end=192 + response: + body: + string: S + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer?start=192&end=192 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer?start=193&end=193 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[spdi-insertion-at-end].yaml b/tests/extras/cassettes/test_in_bounds[spdi-insertion-at-end].yaml new file mode 100644 index 00000000..b88723ee --- /dev/null +++ b/tests/extras/cassettes/test_in_bounds[spdi-insertion-at-end].yaml @@ -0,0 +1,58 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4560&end=4560 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4559&end=4560 + response: + body: + string: G + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[spdi-terminal-residue].yaml b/tests/extras/cassettes/test_in_bounds[spdi-terminal-residue].yaml new file mode 100644 index 00000000..b6027bb6 --- /dev/null +++ b/tests/extras/cassettes/test_in_bounds[spdi-terminal-residue].yaml @@ -0,0 +1,46 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4559&end=4560 + response: + body: + string: G + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[vrs-allele-circular-start-gt-end].yaml b/tests/extras/cassettes/test_in_bounds[vrs-allele-circular-start-gt-end].yaml new file mode 100644 index 00000000..dc2182d0 --- /dev/null +++ b/tests/extras/cassettes/test_in_bounds[vrs-allele-circular-start-gt-end].yaml @@ -0,0 +1,31 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct + response: + body: + string: "{\n \"added\": \"2016-08-24T06:13:07Z\",\n \"aliases\": [\n \"Ensembl:MT\",\n + \ \"ensembl:MT\",\n \"GRCh37.p10:MT\",\n \"GRCh37.p10:chrM\",\n \"GRCh37.p11:MT\",\n + \ \"GRCh37.p11:chrM\",\n \"GRCh37.p12:MT\",\n \"GRCh37.p12:chrM\",\n + \ \"GRCh37.p13:MT\",\n \"GRCh37.p13:chrM\",\n \"GRCh37.p2:MT\",\n + \ \"GRCh37.p2:chrM\",\n \"GRCh37.p5:MT\",\n \"GRCh37.p5:chrM\",\n + \ \"GRCh37.p9:MT\",\n \"GRCh37.p9:chrM\",\n \"GRCh38:MT\",\n \"GRCh38:chrM\",\n + \ \"GRCh38.p1:MT\",\n \"GRCh38.p1:chrM\",\n \"GRCh38.p10:MT\",\n \"GRCh38.p10:chrM\",\n + \ \"GRCh38.p11:MT\",\n \"GRCh38.p11:chrM\",\n \"GRCh38.p12:MT\",\n + \ \"GRCh38.p12:chrM\",\n \"GRCh38.p2:MT\",\n \"GRCh38.p2:chrM\",\n + \ \"GRCh38.p3:MT\",\n \"GRCh38.p3:chrM\",\n \"GRCh38.p4:MT\",\n \"GRCh38.p4:chrM\",\n + \ \"GRCh38.p5:MT\",\n \"GRCh38.p5:chrM\",\n \"GRCh38.p6:MT\",\n \"GRCh38.p6:chrM\",\n + \ \"GRCh38.p7:MT\",\n \"GRCh38.p7:chrM\",\n \"GRCh38.p8:MT\",\n \"GRCh38.p8:chrM\",\n + \ \"GRCh38.p9:MT\",\n \"GRCh38.p9:chrM\",\n \"MD5:c68f52674c9fb33aef52dcf399755519\",\n + \ \"NCBI:NC_012920.1\",\n \"refseq:NC_012920.1\",\n \"SEGUID:eQNFYXnsCzhp/MkfBUBVnuFZzTA\",\n + \ \"SHA1:7903456179ec0b3869fcc91f0540559ee159cd30\",\n \"VMC:GS_k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n + \ \"sha512t24u:k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n \"ga4gh:SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n + \ \"hs37-1kg:MT\",\n \"hs37d5:MT\"\n ],\n \"alphabet\": \"ACGNT\",\n + \ \"length\": 16569\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[vrs-cnv-indefinite-ranges-open-outward].yaml b/tests/extras/cassettes/test_in_bounds[vrs-cnv-indefinite-ranges-open-outward].yaml new file mode 100644 index 00000000..3be97074 --- /dev/null +++ b/tests/extras/cassettes/test_in_bounds[vrs-cnv-indefinite-ranges-open-outward].yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml new file mode 100644 index 00000000..2c11ee79 --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml @@ -0,0 +1,25 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/GRCh38:1 + response: + body: + string: "{\n \"added\": \"2016-08-27T21:17:00Z\",\n \"aliases\": [\n \"GRCh38:1\",\n + \ \"GRCh38:chr1\",\n \"GRCh38.p1:1\",\n \"GRCh38.p1:chr1\",\n \"GRCh38.p10:1\",\n + \ \"GRCh38.p10:chr1\",\n \"GRCh38.p11:1\",\n \"GRCh38.p11:chr1\",\n + \ \"GRCh38.p12:1\",\n \"GRCh38.p12:chr1\",\n \"GRCh38.p2:1\",\n \"GRCh38.p2:chr1\",\n + \ \"GRCh38.p3:1\",\n \"GRCh38.p3:chr1\",\n \"GRCh38.p4:1\",\n \"GRCh38.p4:chr1\",\n + \ \"GRCh38.p5:1\",\n \"GRCh38.p5:chr1\",\n \"GRCh38.p6:1\",\n \"GRCh38.p6:chr1\",\n + \ \"GRCh38.p7:1\",\n \"GRCh38.p7:chr1\",\n \"GRCh38.p8:1\",\n \"GRCh38.p8:chr1\",\n + \ \"GRCh38.p9:1\",\n \"GRCh38.p9:chr1\",\n \"MD5:6aef897c3d6ff0c78aff06ac189178dd\",\n + \ \"NCBI:NC_000001.11\",\n \"refseq:NC_000001.11\",\n \"SEGUID:FCUd6VJ6uikS/VWLbhGdVmj2rOA\",\n + \ \"SHA1:14251de9527aba2912fd558b6e119d5668f6ace0\",\n \"VMC:GS_Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n + \ \"sha512t24u:Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n \"ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\"\n + \ ],\n \"alphabet\": \"ACGMNRT\",\n \"length\": 248956422\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml new file mode 100644 index 00000000..1e5e9d6b --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml @@ -0,0 +1,25 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NC_000007.14 + response: + body: + string: "{\n \"added\": \"2016-08-27T21:23:35Z\",\n \"aliases\": [\n \"GRCh38:7\",\n + \ \"GRCh38:chr7\",\n \"GRCh38.p1:7\",\n \"GRCh38.p1:chr7\",\n \"GRCh38.p10:7\",\n + \ \"GRCh38.p10:chr7\",\n \"GRCh38.p11:7\",\n \"GRCh38.p11:chr7\",\n + \ \"GRCh38.p12:7\",\n \"GRCh38.p12:chr7\",\n \"GRCh38.p2:7\",\n \"GRCh38.p2:chr7\",\n + \ \"GRCh38.p3:7\",\n \"GRCh38.p3:chr7\",\n \"GRCh38.p4:7\",\n \"GRCh38.p4:chr7\",\n + \ \"GRCh38.p5:7\",\n \"GRCh38.p5:chr7\",\n \"GRCh38.p6:7\",\n \"GRCh38.p6:chr7\",\n + \ \"GRCh38.p7:7\",\n \"GRCh38.p7:chr7\",\n \"GRCh38.p8:7\",\n \"GRCh38.p8:chr7\",\n + \ \"GRCh38.p9:7\",\n \"GRCh38.p9:chr7\",\n \"MD5:cc044cc2256a1141212660fb07b6171e\",\n + \ \"NCBI:NC_000007.14\",\n \"refseq:NC_000007.14\",\n \"SEGUID:4+JjCcBVhPCr8vdIhUKFycPv8bY\",\n + \ \"SHA1:e3e26309c05584f0abf2f748854285c9c3eff1b6\",\n \"VMC:GS_F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n + \ \"sha512t24u:F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n \"ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\"\n + \ ],\n \"alphabet\": \"ACGNRSTY\",\n \"length\": 159345973\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-count-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-count-past-end].yaml new file mode 100644 index 00000000..1e5e9d6b --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-count-past-end].yaml @@ -0,0 +1,25 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NC_000007.14 + response: + body: + string: "{\n \"added\": \"2016-08-27T21:23:35Z\",\n \"aliases\": [\n \"GRCh38:7\",\n + \ \"GRCh38:chr7\",\n \"GRCh38.p1:7\",\n \"GRCh38.p1:chr7\",\n \"GRCh38.p10:7\",\n + \ \"GRCh38.p10:chr7\",\n \"GRCh38.p11:7\",\n \"GRCh38.p11:chr7\",\n + \ \"GRCh38.p12:7\",\n \"GRCh38.p12:chr7\",\n \"GRCh38.p2:7\",\n \"GRCh38.p2:chr7\",\n + \ \"GRCh38.p3:7\",\n \"GRCh38.p3:chr7\",\n \"GRCh38.p4:7\",\n \"GRCh38.p4:chr7\",\n + \ \"GRCh38.p5:7\",\n \"GRCh38.p5:chr7\",\n \"GRCh38.p6:7\",\n \"GRCh38.p6:chr7\",\n + \ \"GRCh38.p7:7\",\n \"GRCh38.p7:chr7\",\n \"GRCh38.p8:7\",\n \"GRCh38.p8:chr7\",\n + \ \"GRCh38.p9:7\",\n \"GRCh38.p9:chr7\",\n \"MD5:cc044cc2256a1141212660fb07b6171e\",\n + \ \"NCBI:NC_000007.14\",\n \"refseq:NC_000007.14\",\n \"SEGUID:4+JjCcBVhPCr8vdIhUKFycPv8bY\",\n + \ \"SHA1:e3e26309c05584f0abf2f748854285c9c3eff1b6\",\n \"VMC:GS_F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n + \ \"sha512t24u:F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n \"ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\"\n + \ ],\n \"alphabet\": \"ACGNRSTY\",\n \"length\": 159345973\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[gnomad-negative-start].yaml b/tests/extras/cassettes/test_out_of_bounds[gnomad-negative-start].yaml new file mode 100644 index 00000000..2c11ee79 --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[gnomad-negative-start].yaml @@ -0,0 +1,25 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/GRCh38:1 + response: + body: + string: "{\n \"added\": \"2016-08-27T21:17:00Z\",\n \"aliases\": [\n \"GRCh38:1\",\n + \ \"GRCh38:chr1\",\n \"GRCh38.p1:1\",\n \"GRCh38.p1:chr1\",\n \"GRCh38.p10:1\",\n + \ \"GRCh38.p10:chr1\",\n \"GRCh38.p11:1\",\n \"GRCh38.p11:chr1\",\n + \ \"GRCh38.p12:1\",\n \"GRCh38.p12:chr1\",\n \"GRCh38.p2:1\",\n \"GRCh38.p2:chr1\",\n + \ \"GRCh38.p3:1\",\n \"GRCh38.p3:chr1\",\n \"GRCh38.p4:1\",\n \"GRCh38.p4:chr1\",\n + \ \"GRCh38.p5:1\",\n \"GRCh38.p5:chr1\",\n \"GRCh38.p6:1\",\n \"GRCh38.p6:chr1\",\n + \ \"GRCh38.p7:1\",\n \"GRCh38.p7:chr1\",\n \"GRCh38.p8:1\",\n \"GRCh38.p8:chr1\",\n + \ \"GRCh38.p9:1\",\n \"GRCh38.p9:chr1\",\n \"MD5:6aef897c3d6ff0c78aff06ac189178dd\",\n + \ \"NCBI:NC_000001.11\",\n \"refseq:NC_000001.11\",\n \"SEGUID:FCUd6VJ6uikS/VWLbhGdVmj2rOA\",\n + \ \"SHA1:14251de9527aba2912fd558b6e119d5668f6ace0\",\n \"VMC:GS_Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n + \ \"sha512t24u:Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n \"ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\"\n + \ ],\n \"alphabet\": \"ACGMNRT\",\n \"length\": 248956422\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[gnomad-past-end-no-require-validation].yaml b/tests/extras/cassettes/test_out_of_bounds[gnomad-past-end-no-require-validation].yaml new file mode 100644 index 00000000..2c11ee79 --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[gnomad-past-end-no-require-validation].yaml @@ -0,0 +1,25 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/GRCh38:1 + response: + body: + string: "{\n \"added\": \"2016-08-27T21:17:00Z\",\n \"aliases\": [\n \"GRCh38:1\",\n + \ \"GRCh38:chr1\",\n \"GRCh38.p1:1\",\n \"GRCh38.p1:chr1\",\n \"GRCh38.p10:1\",\n + \ \"GRCh38.p10:chr1\",\n \"GRCh38.p11:1\",\n \"GRCh38.p11:chr1\",\n + \ \"GRCh38.p12:1\",\n \"GRCh38.p12:chr1\",\n \"GRCh38.p2:1\",\n \"GRCh38.p2:chr1\",\n + \ \"GRCh38.p3:1\",\n \"GRCh38.p3:chr1\",\n \"GRCh38.p4:1\",\n \"GRCh38.p4:chr1\",\n + \ \"GRCh38.p5:1\",\n \"GRCh38.p5:chr1\",\n \"GRCh38.p6:1\",\n \"GRCh38.p6:chr1\",\n + \ \"GRCh38.p7:1\",\n \"GRCh38.p7:chr1\",\n \"GRCh38.p8:1\",\n \"GRCh38.p8:chr1\",\n + \ \"GRCh38.p9:1\",\n \"GRCh38.p9:chr1\",\n \"MD5:6aef897c3d6ff0c78aff06ac189178dd\",\n + \ \"NCBI:NC_000001.11\",\n \"refseq:NC_000001.11\",\n \"SEGUID:FCUd6VJ6uikS/VWLbhGdVmj2rOA\",\n + \ \"SHA1:14251de9527aba2912fd558b6e119d5668f6ace0\",\n \"VMC:GS_Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n + \ \"sha512t24u:Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n \"ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\"\n + \ ],\n \"alphabet\": \"ACGMNRT\",\n \"length\": 248956422\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[gnomad-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[gnomad-past-end].yaml new file mode 100644 index 00000000..2c11ee79 --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[gnomad-past-end].yaml @@ -0,0 +1,25 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/GRCh38:1 + response: + body: + string: "{\n \"added\": \"2016-08-27T21:17:00Z\",\n \"aliases\": [\n \"GRCh38:1\",\n + \ \"GRCh38:chr1\",\n \"GRCh38.p1:1\",\n \"GRCh38.p1:chr1\",\n \"GRCh38.p10:1\",\n + \ \"GRCh38.p10:chr1\",\n \"GRCh38.p11:1\",\n \"GRCh38.p11:chr1\",\n + \ \"GRCh38.p12:1\",\n \"GRCh38.p12:chr1\",\n \"GRCh38.p2:1\",\n \"GRCh38.p2:chr1\",\n + \ \"GRCh38.p3:1\",\n \"GRCh38.p3:chr1\",\n \"GRCh38.p4:1\",\n \"GRCh38.p4:chr1\",\n + \ \"GRCh38.p5:1\",\n \"GRCh38.p5:chr1\",\n \"GRCh38.p6:1\",\n \"GRCh38.p6:chr1\",\n + \ \"GRCh38.p7:1\",\n \"GRCh38.p7:chr1\",\n \"GRCh38.p8:1\",\n \"GRCh38.p8:chr1\",\n + \ \"GRCh38.p9:1\",\n \"GRCh38.p9:chr1\",\n \"MD5:6aef897c3d6ff0c78aff06ac189178dd\",\n + \ \"NCBI:NC_000001.11\",\n \"refseq:NC_000001.11\",\n \"SEGUID:FCUd6VJ6uikS/VWLbhGdVmj2rOA\",\n + \ \"SHA1:14251de9527aba2912fd558b6e119d5668f6ace0\",\n \"VMC:GS_Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n + \ \"sha512t24u:Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n \"ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\"\n + \ ],\n \"alphabet\": \"ACGMNRT\",\n \"length\": 248956422\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-c-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-c-past-end].yaml new file mode 100644 index 00000000..ab28a89d --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-c-past-end].yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml new file mode 100644 index 00000000..6c1671e7 --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml @@ -0,0 +1,26 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NC_000019.10 + response: + body: + string: "{\n \"added\": \"2016-08-24T08:19:02Z\",\n \"aliases\": [\n \"Ensembl:19\",\n + \ \"ensembl:19\",\n \"GRCh38:19\",\n \"GRCh38:chr19\",\n \"GRCh38.p1:19\",\n + \ \"GRCh38.p1:chr19\",\n \"GRCh38.p10:19\",\n \"GRCh38.p10:chr19\",\n + \ \"GRCh38.p11:19\",\n \"GRCh38.p11:chr19\",\n \"GRCh38.p12:19\",\n + \ \"GRCh38.p12:chr19\",\n \"GRCh38.p2:19\",\n \"GRCh38.p2:chr19\",\n + \ \"GRCh38.p3:19\",\n \"GRCh38.p3:chr19\",\n \"GRCh38.p4:19\",\n \"GRCh38.p4:chr19\",\n + \ \"GRCh38.p5:19\",\n \"GRCh38.p5:chr19\",\n \"GRCh38.p6:19\",\n \"GRCh38.p6:chr19\",\n + \ \"GRCh38.p7:19\",\n \"GRCh38.p7:chr19\",\n \"GRCh38.p8:19\",\n \"GRCh38.p8:chr19\",\n + \ \"GRCh38.p9:19\",\n \"GRCh38.p9:chr19\",\n \"MD5:b0eba2c7bb5c953d1e06a508b5e487de\",\n + \ \"NCBI:NC_000019.10\",\n \"refseq:NC_000019.10\",\n \"SEGUID:AHxM5/L8jIX08UhBBkKXkiO5rhY\",\n + \ \"SHA1:007c4ce7f2fc8c85f4f148410642979223b9ae16\",\n \"VMC:GS_IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n + \ \"sha512t24u:IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n \"ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\"\n + \ ],\n \"alphabet\": \"ACGNT\",\n \"length\": 58617616\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml new file mode 100644 index 00000000..ab28a89d --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-n-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-past-end].yaml new file mode 100644 index 00000000..ab28a89d --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-past-end].yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml new file mode 100644 index 00000000..dba45e9a --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml @@ -0,0 +1,26 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NP_001346993.1 + response: + body: + string: "{\n \"added\": \"2016-08-24T04:59:23Z\",\n \"aliases\": [\n \"Ensembl:ENSP00000480268.1\",\n + \ \"ensembl:ENSP00000480268.1\",\n \"Ensembl:ENSP00000491180.1\",\n \"ensembl:ENSP00000491180.1\",\n + \ \"Ensembl:ENSP00000491338.1\",\n \"ensembl:ENSP00000491338.1\",\n \"Ensembl:ENSP00000491353.1\",\n + \ \"ensembl:ENSP00000491353.1\",\n \"Ensembl:ENSP00000492701.1\",\n \"ensembl:ENSP00000492701.1\",\n + \ \"Ensembl:ENSP00000498790.1\",\n \"ensembl:ENSP00000498790.1\",\n \"MD5:fecf2eee2cdc50588a641e472e062be1\",\n + \ \"NCBI:NP_001346993.1\",\n \"refseq:NP_001346993.1\",\n \"NCBI:NP_001347000.1\",\n + \ \"refseq:NP_001347000.1\",\n \"NCBI:NP_001355060.1\",\n \"refseq:NP_001355060.1\",\n + \ \"NCBI:XP_011534418.1\",\n \"refseq:XP_011534418.1\",\n \"NCBI:XP_024302319.1\",\n + \ \"refseq:XP_024302319.1\",\n \"NCBI:XP_054212402.1\",\n \"refseq:XP_054212402.1\",\n + \ \"NCBI:XP_054212403.1\",\n \"refseq:XP_054212403.1\",\n \"SEGUID:8Lnknw+hAAOAjiLGHus4bfyDy0k\",\n + \ \"SHA1:f0b9e49f0fa10003808e22c61eeb386dfc83cb49\",\n \"VMC:GS_IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n + \ \"sha512t24u:IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n \"ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\"\n + \ ],\n \"alphabet\": \"ACDEFGHIKLMNPQRSTVWY\",\n \"length\": 193\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml new file mode 100644 index 00000000..ab28a89d --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[spdi-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[spdi-past-end].yaml new file mode 100644 index 00000000..ab28a89d --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[spdi-past-end].yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[vrs-allele-start-past-end-with-start-gt-end].yaml b/tests/extras/cassettes/test_out_of_bounds[vrs-allele-start-past-end-with-start-gt-end].yaml new file mode 100644 index 00000000..3be97074 --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[vrs-allele-start-past-end-with-start-gt-end].yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[vrs-cnv-definite-range-end-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[vrs-cnv-definite-range-end-past-end].yaml new file mode 100644 index 00000000..3be97074 --- /dev/null +++ b/tests/extras/cassettes/test_out_of_bounds[vrs-cnv-definite-range-end-past-end].yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/test_location_bounds.py b/tests/extras/test_location_bounds.py new file mode 100644 index 00000000..77e53402 --- /dev/null +++ b/tests/extras/test_location_bounds.py @@ -0,0 +1,320 @@ +"""Out-of-bounds SequenceLocations are rejected on every translator input path + +Sequence lengths used below: + NM_000551.3 4560 + NP_001346993.1 193 + NC_000019.10 58617616 + NC_000007.14 159345973 + GRCh38:1 248956422 + NC_012920.1 16569 +""" + +import os +import re + +import pytest +from hgvs.exceptions import HGVSInvalidIntervalError + +from ga4gh.vrs import models +from ga4gh.vrs.dataproxy import DataProxyValidationError, SeqRepoRESTDataProxy +from ga4gh.vrs.extras.translator import AlleleTranslator, CnvTranslator + +NM_000551_3 = "SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_" +NC_012920_1 = "SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct" + + +@pytest.fixture +def data_proxy() -> SeqRepoRESTDataProxy: + # Metadata lookups are lru_cached per dataproxy instance, so a fresh instance per + # test keeps each cassette self-contained regardless of test order + return SeqRepoRESTDataProxy( + base_url=os.environ.get("SEQREPO_REST_URL", "http://localhost:5000/seqrepo"), + disable_healthcheck=True, + ) + + +@pytest.fixture +def allele_tlr(data_proxy: SeqRepoRESTDataProxy) -> AlleleTranslator: + return AlleleTranslator(data_proxy=data_proxy) + + +@pytest.fixture +def cnv_tlr(data_proxy: SeqRepoRESTDataProxy) -> CnvTranslator: + return CnvTranslator(data_proxy=data_proxy) + + +def _vrs_location( + refget_accession: str, + start: int | list[int | None], + end: int | list[int | None], +) -> dict: + return { + "type": "SequenceLocation", + "sequenceReference": { + "type": "SequenceReference", + "refgetAccession": refget_accession, + }, + "start": start, + "end": end, + } + + +def _vrs_allele( + refget_accession: str, + start: int | list[int | None], + end: int | list[int | None], +) -> dict: + return { + "type": "Allele", + "location": _vrs_location(refget_accession, start, end), + "state": {"type": "LiteralSequenceExpression", "sequence": "A"}, + } + + +def _vrs_copy_number_change( + refget_accession: str, + start: int | list[int | None], + end: int | list[int | None], +) -> dict: + return { + "type": "CopyNumberChange", + "location": _vrs_location(refget_accession, start, end), + "copyChange": models.CopyChange.LOSS.value, + } + + +def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: + return ( + f"Location out of bounds on {sequence_id}: {detail} not within [0, {seq_len}]" + ) + + +OUT_OF_BOUNDS = [ + pytest.param( + "allele_tlr", + "hgvs", + "NC_000019.10:g.58617617C>T", + {}, + DataProxyValidationError, + _bounds_msg("refseq:NC_000019.10", "end=58617617", 58617616), + id="hgvs-g-past-end", + ), + pytest.param( + "allele_tlr", + "hgvs", + "NM_000551.3:n.4561del", + {}, + DataProxyValidationError, + _bounds_msg("refseq:NM_000551.3", "end=4561", 4560), + id="hgvs-n-past-end", + ), + pytest.param( + "allele_tlr", + "hgvs", + "NM_000551.3:n.4561_4562insA", + {}, + DataProxyValidationError, + _bounds_msg("refseq:NM_000551.3", "start=4561, end=4561", 4560), + id="hgvs-n-insertion-past-end", + ), + # ClinVar references the stop codon, which is not part of the protein sequence + pytest.param( + "allele_tlr", + "hgvs", + "NP_001346993.1:p.Ter194del", + {}, + DataProxyValidationError, + _bounds_msg("refseq:NP_001346993.1", "end=194", 193), + id="hgvs-p-ter-at-length-plus-one", + ), + # c. coordinates past the transcript end are already rejected by hgvs when + # mapping c. to n., before the bounds check is reached + pytest.param( + "allele_tlr", + "hgvs", + "NM_000551.3:c.*3706del", + {}, + HGVSInvalidIntervalError, + "c.*3706 coordinate is out of bounds", + id="hgvs-c-past-end", + ), + pytest.param( + "allele_tlr", + "spdi", + "NM_000551.3:4560:1:A", + {}, + DataProxyValidationError, + _bounds_msg("refseq:NM_000551.3", "end=4561", 4560), + id="spdi-past-end", + ), + # Zero-width: an out-of-range fetch returns "" and would compare equal to the + # empty reference, so only a coordinate check can catch this + pytest.param( + "allele_tlr", + "spdi", + "NM_000551.3:5000:0:AAA", + {}, + DataProxyValidationError, + _bounds_msg("refseq:NM_000551.3", "start=5000, end=5000", 4560), + id="spdi-insertion-past-end", + ), + # Must report the bounds error, not "Reference mismatch ... correct ref is ''" + pytest.param( + "allele_tlr", + "gnomad", + "1-248956423-A-T", + {}, + DataProxyValidationError, + _bounds_msg("GRCh38:1", "end=248956423", 248956422), + id="gnomad-past-end", + ), + pytest.param( + "allele_tlr", + "gnomad", + "1-248956423-A-T", + {"require_validation": False}, + DataProxyValidationError, + _bounds_msg("GRCh38:1", "end=248956423", 248956422), + id="gnomad-past-end-no-require-validation", + ), + pytest.param( + "allele_tlr", + "gnomad", + "1-0-A-T", + {}, + DataProxyValidationError, + _bounds_msg("GRCh38:1", "start=-1", 248956422), + id="gnomad-negative-start", + ), + pytest.param( + "allele_tlr", + "beacon", + "1 : 248956423 A > T", + {}, + DataProxyValidationError, + _bounds_msg("GRCh38:1", "end=248956423", 248956422), + id="beacon-past-end", + ), + pytest.param( + "allele_tlr", + "vrs", + _vrs_allele(NM_000551_3, 99999999, 5), + {}, + DataProxyValidationError, + _bounds_msg(f"ga4gh:{NM_000551_3}", "start=99999999", 4560), + id="vrs-allele-start-past-end-with-start-gt-end", + ), + pytest.param( + "allele_tlr", + "vrs", + _vrs_copy_number_change(NM_000551_3, 4400, [4500, 4600]), + {}, + DataProxyValidationError, + _bounds_msg(f"ga4gh:{NM_000551_3}", "end=[4500, 4600]", 4560), + id="vrs-cnv-definite-range-end-past-end", + ), + pytest.param( + "cnv_tlr", + "hgvs", + "NC_000007.14:g.159400000_159400100del", + {"copies": 3}, + DataProxyValidationError, + _bounds_msg("refseq:NC_000007.14", "start=159399999, end=159400100", 159345973), + id="cnv-hgvs-copy-number-count-past-end", + ), + pytest.param( + "cnv_tlr", + "hgvs", + "NC_000007.14:g.159400000_159400100del", + {}, + DataProxyValidationError, + _bounds_msg("refseq:NC_000007.14", "start=159399999, end=159400100", 159345973), + id="cnv-hgvs-copy-number-change-past-end", + ), +] + + +IN_BOUNDS = [ + pytest.param( + "allele_tlr", + "hgvs", + "NM_000551.3:c.*3705del", + {"start": 4559, "end": 4560}, + id="hgvs-c-terminal-residue", + ), + pytest.param( + "allele_tlr", + "hgvs", + "NP_001346993.1:p.Leu193del", + {"start": 192, "end": 193}, + id="hgvs-p-terminal-residue", + ), + pytest.param( + "allele_tlr", + "spdi", + "NM_000551.3:4559:1:A", + {"start": 4559, "end": 4560}, + id="spdi-terminal-residue", + ), + pytest.param( + "allele_tlr", + "spdi", + "NM_000551.3:4560:0:AAA", + {"start": 4560, "end": 4560}, + id="spdi-insertion-at-end", + ), + pytest.param( + "allele_tlr", + "vrs", + _vrs_allele(NC_012920_1, 16566, 5), + {"start": 16566, "end": 5}, + id="vrs-allele-circular-start-gt-end", + ), + pytest.param( + "allele_tlr", + "vrs", + _vrs_copy_number_change(NM_000551_3, [None, 4400], [4500, None]), + {"start": [None, 4400], "end": [4500, None]}, + id="vrs-cnv-indefinite-ranges-open-outward", + ), + pytest.param( + "cnv_tlr", + "hgvs", + "NC_000007.14:g.159345900_159345973del", + {"start": 159345899, "end": 159345973}, + id="cnv-hgvs-copy-number-change-at-end", + ), +] + + +@pytest.mark.parametrize( + ("tlr_fixture", "fmt", "var", "kwargs", "exc_type", "msg"), OUT_OF_BOUNDS +) +@pytest.mark.vcr +def test_out_of_bounds( + request: pytest.FixtureRequest, + tlr_fixture: str, + fmt: str, + var: str | dict, + kwargs: dict, + exc_type: type[Exception], + msg: str, +) -> None: + tlr = request.getfixturevalue(tlr_fixture) + with pytest.raises(exc_type, match=f"^{re.escape(msg)}$"): + tlr.translate_from(var, fmt=fmt, **kwargs) + + +@pytest.mark.parametrize(("tlr_fixture", "fmt", "var", "expected_location"), IN_BOUNDS) +@pytest.mark.vcr +def test_in_bounds( + request: pytest.FixtureRequest, + tlr_fixture: str, + fmt: str, + var: str | dict, + expected_location: dict, +) -> None: + tlr = request.getfixturevalue(tlr_fixture) + vo = tlr.translate_from(var, fmt=fmt) + location = vo.location.model_dump() + assert {k: location[k] for k in expected_location} == expected_location diff --git a/tests/test_dataproxy.py b/tests/test_dataproxy.py index f306c27a..1b4229cc 100644 --- a/tests/test_dataproxy.py +++ b/tests/test_dataproxy.py @@ -1,9 +1,16 @@ +import logging import os import re import pytest -from ga4gh.vrs.dataproxy import create_dataproxy +from ga4gh.vrs import models +from ga4gh.vrs.dataproxy import ( + DataProxyValidationError, + _DataProxy, + coerce_accession_namespace, + create_dataproxy, +) @pytest.mark.parametrize("dp", ["rest_dataproxy", "dataproxy"]) @@ -65,3 +72,128 @@ def test_data_proxy_configs(): ), ): create_dataproxy("file:///path/to/seqrepo/root") + + +@pytest.mark.parametrize( + ("ac", "expected"), + [ + ("NM_000551.3", "refseq:NM_000551.3"), + ("NC_000001.11", "refseq:NC_000001.11"), + ("refseq:NM_000551.3", "refseq:NM_000551.3"), + ("GRCh38:1", "GRCh38:1"), + ( + "ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_", + "ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_", + ), + ], +) +def test_coerce_accession_namespace(ac: str, expected: str) -> None: + assert coerce_accession_namespace(ac) == expected + + +class _StubDataProxy(_DataProxy): + """Dataproxy serving only sequence lengths, recording each metadata lookup""" + + def __init__(self, lengths: dict[str, int]) -> None: + self.lengths = lengths + self.metadata_requests: list[str] = [] + + def get_sequence( + self, identifier: str, start: int | None = None, end: int | None = None + ) -> str: + raise NotImplementedError + + def get_metadata(self, identifier: str) -> dict: + self.metadata_requests.append(identifier) + return {"length": self.lengths[identifier], "aliases": []} + + +BOUNDS_SEQ_ID = "refseq:NM_000551.3" +BOUNDS_SEQ_LEN = 4560 + +# (start, end) that are representable on a sequence of length BOUNDS_SEQ_LEN +LOCATION_BOUNDS_VALID = [ + pytest.param(10, 20, id="interior"), + pytest.param(0, 0, id="zero-width-at-start"), + pytest.param(4559, 4560, id="terminal-residue"), + pytest.param(4560, 4560, id="insertion-at-end"), + pytest.param(4000, 5, id="circular-start-gt-end"), + pytest.param(4400, models.Range([4500, None]), id="indefinite-end-open-upper"), + pytest.param(models.Range([None, 10]), 20, id="indefinite-start-open-lower"), + pytest.param( + models.Range([0, 10]), models.Range([4500, 4560]), id="definite-ranges" + ), + pytest.param(None, 10, id="start-undefined"), + pytest.param(10, None, id="end-undefined"), + pytest.param(None, None, id="both-undefined"), +] + +# (start, end, offending coordinates as reported in the error message) +LOCATION_BOUNDS_INVALID = [ + pytest.param(4559, 4561, "end=4561", id="one-past-end"), + pytest.param(5000, 5000, "start=5000, end=5000", id="zero-width-past-end"), + pytest.param(99999999, 5, "start=99999999", id="start-past-end-with-start-gt-end"), + pytest.param(-1, 1, "start=-1", id="negative-start"), + pytest.param(0, -1, "end=-1", id="negative-end"), + pytest.param( + 4400, models.Range([4500, 4600]), "end=[4500, 4600]", id="definite-end-past-end" + ), + pytest.param( + 4400, + models.Range([4561, None]), + "end=[4561, None]", + id="indefinite-end-lower-bound-past-end", + ), + pytest.param( + models.Range([-5, 10]), 20, "start=[-5, 10]", id="range-with-negative-member" + ), +] + + +@pytest.mark.parametrize(("start", "end"), LOCATION_BOUNDS_VALID) +def test_validate_location_bounds_valid( + start: int | models.Range | None, + end: int | models.Range | None, + caplog: pytest.LogCaptureFixture, +) -> None: + dp = _StubDataProxy({BOUNDS_SEQ_ID: BOUNDS_SEQ_LEN}) + dp.validate_location_bounds(BOUNDS_SEQ_ID, start, end) + assert not caplog.records + + +@pytest.mark.parametrize(("start", "end", "detail"), LOCATION_BOUNDS_INVALID) +def test_validate_location_bounds_invalid( + start: int | models.Range | None, + end: int | models.Range | None, + detail: str, + caplog: pytest.LogCaptureFixture, +) -> None: + dp = _StubDataProxy({BOUNDS_SEQ_ID: BOUNDS_SEQ_LEN}) + expected_msg = ( + f"Location out of bounds on {BOUNDS_SEQ_ID}: {detail} " + f"not within [0, {BOUNDS_SEQ_LEN}]" + ) + with pytest.raises(DataProxyValidationError, match=f"^{re.escape(expected_msg)}$"): + dp.validate_location_bounds(BOUNDS_SEQ_ID, start, end) + # logged at WARNING as well as raised; there is no warn-only mode + assert [(r.levelno, r.getMessage()) for r in caplog.records] == [ + (logging.WARNING, expected_msg) + ] + + +@pytest.mark.parametrize("sequence_id", ["NM_000551.3", "refseq:NM_000551.3"]) +def test_validate_location_bounds_cache_key(sequence_id: str) -> None: + """The length lookup must use the same identifier as derive_refget_accession, so + that it is served from the dataproxy's metadata cache instead of a new request + """ + dp = _StubDataProxy({BOUNDS_SEQ_ID: BOUNDS_SEQ_LEN}) + dp.validate_location_bounds(sequence_id, 0, 1) + with pytest.raises(DataProxyValidationError, match=re.escape(BOUNDS_SEQ_ID)): + dp.validate_location_bounds(sequence_id, 0, BOUNDS_SEQ_LEN + 1) + assert dp.metadata_requests == [BOUNDS_SEQ_ID, BOUNDS_SEQ_ID] + + +def test_validate_location_bounds_unknown_sequence() -> None: + dp = _StubDataProxy({}) + with pytest.raises(KeyError): + dp.validate_location_bounds("NM_000551.3", 0, 1) diff --git a/tests/test_vrs_normalize.py b/tests/test_vrs_normalize.py index 83c27cdd..4e476a65 100644 --- a/tests/test_vrs_normalize.py +++ b/tests/test_vrs_normalize.py @@ -1,6 +1,9 @@ +import re + import pytest from ga4gh.vrs import models, normalize +from ga4gh.vrs.dataproxy import DataProxyValidationError # Single nucleotide same-as-reference allele. allele_dict1 = { @@ -929,3 +932,83 @@ def test_normalize_partial_rle_del_ins(rest_dataproxy): tail_del_4 = models.Allele(**tail_del_4bp) tail_del_4_norm = normalize(tail_del_4, rest_dataproxy, rle_seq_limit=0) assert tail_del_4_norm == models.Allele(**tail_del_4bp_normalized) + + +# NM_000551.3, length 4560. Present in the test SeqRepo, so these use the local +# dataproxy fixture and need no cassettes. +BOUNDS_REFGET_AC = "SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_" +BOUNDS_SEQ_LEN = 4560 + + +def _bounds_allele( + start: int | list[int | None], end: int | list[int | None], sequence: str +) -> models.Allele: + return models.Allele( + location=models.SequenceLocation( + sequenceReference=models.SequenceReference( + refgetAccession=BOUNDS_REFGET_AC + ), + start=start, + end=end, + ), + state=models.LiteralSequenceExpression(sequence=sequence), + ) + + +@pytest.mark.parametrize( + ("start", "end", "sequence", "expected_start", "expected_end"), + [ + pytest.param(4559, 4560, "A", 4559, 4560, id="terminal-residue"), + pytest.param(4560, 4560, "A", 4560, 4560, id="insertion-at-end"), + # an undefined outer endpoint is representable and must not be rejected + # (the deletion is also rolled right by one base by normalization) + pytest.param( + [None, 4400], + [4500, None], + "", + [None, 4400], + [4501, None], + id="indefinite-ranges-open-outward", + ), + ], +) +def test_normalize_location_in_bounds( + dataproxy, + start: int | list[int | None], + end: int | list[int | None], + sequence: str, + expected_start: int | list[int | None], + expected_end: int | list[int | None], +) -> None: + allele = normalize(_bounds_allele(start, end, sequence), dataproxy) + location = allele.location.model_dump() + assert (location["start"], location["end"]) == (expected_start, expected_end) + + +@pytest.mark.parametrize( + ("start", "end", "detail"), + [ + pytest.param(4559, 4561, "end=4561", id="one-past-end"), + pytest.param(5000, 5000, "start=5000, end=5000", id="insertion-past-end"), + pytest.param( + 99999999, 5, "start=99999999", id="start-past-end-with-start-gt-end" + ), + # Definite ranges are otherwise returned without normalization, so the + # bounds check must run before that early return + pytest.param( + 4400, [4500, 4600], "end=[4500, 4600]", id="definite-range-end-past-end" + ), + ], +) +def test_normalize_location_out_of_bounds( + dataproxy, + start: int | list[int | None], + end: int | list[int | None], + detail: str, +) -> None: + expected_msg = ( + f"Location out of bounds on ga4gh:{BOUNDS_REFGET_AC}: {detail} " + f"not within [0, {BOUNDS_SEQ_LEN}]" + ) + with pytest.raises(DataProxyValidationError, match=f"^{re.escape(expected_msg)}$"): + normalize(_bounds_allele(start, end, "A"), dataproxy) From 6c81988b4db0e9def136a991d0595d91593bb385 Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Thu, 24 Sep 2026 15:36:03 -0400 Subject: [PATCH 05/11] cleanup: trim location bounds validation - Import Range at runtime instead of under TYPE_CHECKING; there is no import cycle. Use isinstance(pos, Range) rather than duck-typing .root. - Fix validate_location_bounds docstring: every defined Range member is checked, not just the largest. - Drop the warning log before raising; the error is always raised. - Make the accession coercion helper private. - Remove the passthrough _Translator._validate_location_bounds wrapper and call the dataproxy method directly. - Trim tests that duplicated coverage across the helper, normalize, and translator levels, along with their cassettes. --- src/ga4gh/vrs/dataproxy.py | 37 +++--- src/ga4gh/vrs/extras/translator.py | 30 +---- ...s[cnv-hgvs-copy-number-change-at-end].yaml | 25 ----- ...st_in_bounds[hgvs-c-terminal-residue].yaml | 100 ----------------- ...test_in_bounds[spdi-terminal-residue].yaml | 46 -------- ...s-cnv-indefinite-ranges-open-outward].yaml | 18 --- ...[cnv-hgvs-copy-number-count-past-end].yaml | 25 ----- .../test_out_of_bounds[hgvs-c-past-end].yaml | 18 --- .../test_out_of_bounds[hgvs-n-past-end].yaml | 18 --- .../test_out_of_bounds[spdi-past-end].yaml | 18 --- ...[vrs-cnv-definite-range-end-past-end].yaml | 18 --- tests/extras/test_location_bounds.py | 106 +----------------- tests/test_dataproxy.py | 48 +------- tests/test_vrs_normalize.py | 7 -- 14 files changed, 25 insertions(+), 489 deletions(-) delete mode 100644 tests/extras/cassettes/test_in_bounds[cnv-hgvs-copy-number-change-at-end].yaml delete mode 100644 tests/extras/cassettes/test_in_bounds[hgvs-c-terminal-residue].yaml delete mode 100644 tests/extras/cassettes/test_in_bounds[spdi-terminal-residue].yaml delete mode 100644 tests/extras/cassettes/test_in_bounds[vrs-cnv-indefinite-ranges-open-outward].yaml delete mode 100644 tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-count-past-end].yaml delete mode 100644 tests/extras/cassettes/test_out_of_bounds[hgvs-c-past-end].yaml delete mode 100644 tests/extras/cassettes/test_out_of_bounds[hgvs-n-past-end].yaml delete mode 100644 tests/extras/cassettes/test_out_of_bounds[spdi-past-end].yaml delete mode 100644 tests/extras/cassettes/test_out_of_bounds[vrs-cnv-definite-range-end-past-end].yaml diff --git a/src/ga4gh/vrs/dataproxy.py b/src/ga4gh/vrs/dataproxy.py index 7c9ba5c4..f38a7f9f 100644 --- a/src/ga4gh/vrs/dataproxy.py +++ b/src/ga4gh/vrs/dataproxy.py @@ -11,14 +11,12 @@ import os from abc import ABC, abstractmethod from collections.abc import Sequence -from typing import TYPE_CHECKING from urllib.parse import urlparse import requests from bioutils.accessions import coerce_namespace -if TYPE_CHECKING: - from ga4gh.vrs.models import Range +from ga4gh.vrs.models import Range _logger = logging.getLogger(__name__) @@ -27,7 +25,7 @@ class DataProxyValidationError(Exception): """Class for validation errors during data proxy methods""" -def coerce_accession_namespace(ac: str) -> str: +def _coerce_accession_namespace(ac: str) -> str: """Return ``ac`` as a namespaced CURIE, inferring the namespace if none is given e.g. ``NM_000551.3`` -> ``refseq:NM_000551.3``. Identifiers that already carry a @@ -45,7 +43,7 @@ def coerce_accession_namespace(ac: str) -> str: return ac -def _defined_values(pos: "int | Range | None") -> list[int]: +def _defined_values(pos: int | Range | None) -> list[int]: """Return the defined values of a location coordinate An int yields itself, a ``Range`` yields its non-``None`` members, and ``None`` @@ -56,16 +54,16 @@ def _defined_values(pos: "int | Range | None") -> list[int]: """ if pos is None: return [] - if isinstance(pos, int): - return [pos] - return [v for v in pos.root if v is not None] + if isinstance(pos, Range): + return [v for v in pos.root if v is not None] + return [pos] def _check_location_bounds( sequence_id: str, seq_len: int, - start_pos: "int | Range | None", - end_pos: "int | Range | None", + start_pos: int | Range | None, + end_pos: int | Range | None, ) -> None: """Raise if any defined value of ``start``/``end`` lies outside ``[0, seq_len]`` @@ -88,12 +86,13 @@ def _check_location_bounds( ] if bad: # Range is shown as its list form, e.g. end=[4500, 4600] - detail = ", ".join(f"{name}={getattr(pos, 'root', pos)}" for name, pos in bad) + detail = ", ".join( + f"{name}={pos.root if isinstance(pos, Range) else pos}" for name, pos in bad + ) err_msg = ( f"Location out of bounds on {sequence_id}: {detail} " f"not within [0, {seq_len}]" ) - _logger.warning(err_msg) raise DataProxyValidationError(err_msg) @@ -208,7 +207,7 @@ def derive_refget_accession(self, ac: str) -> str | None: return None # always coerce the namespace if none provided - ac = coerce_accession_namespace(ac) + ac = _coerce_accession_namespace(ac) refget_accession = None try: @@ -257,15 +256,15 @@ def validate_ref_seq( def validate_location_bounds( self, sequence_id: str, - start_pos: "int | Range | None", - end_pos: "int | Range | None", + start_pos: int | Range | None, + end_pos: int | Range | None, ) -> None: """Ensure that ``start_pos`` and ``end_pos`` are representable on ``sequence_id``. Each defined coordinate must be within ``[0, len(sequence)]`` (inter-residue). - Undefined (``None``) endpoints are skipped, and for a ``Range`` the largest - defined member is checked. ``start_pos`` and ``end_pos`` are checked - independently, so ``start_pos > end_pos`` (circular sequences) is permitted. + Undefined (``None``) endpoints are skipped, and for a ``Range`` every defined + member is checked. ``start_pos`` and ``end_pos`` are checked independently, so + ``start_pos > end_pos`` (circular sequences) is permitted. Unlike ``validate_ref_seq``, there is no ``require_validation`` option: an out-of-bounds location has no meaning, so the error is always raised. Sequence @@ -280,7 +279,7 @@ def validate_location_bounds( """ # Coerce the same way derive_refget_accession does, so that the metadata # lookup hits the same cache entry - sequence_id = coerce_accession_namespace(sequence_id) + sequence_id = _coerce_accession_namespace(sequence_id) seq_len = self.get_metadata(sequence_id)["length"] _check_location_bounds(sequence_id, seq_len, start_pos, end_pos) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index bcff8265..520062cd 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -178,30 +178,13 @@ def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noq if isinstance(location, models.SequenceLocation) and isinstance( location.sequenceReference, models.SequenceReference ): - self._validate_location_bounds( + self.data_proxy.validate_location_bounds( f"ga4gh:{location.sequenceReference.refgetAccession}", location.start, location.end, ) return vo - def _validate_location_bounds( - self, - sequence_id: str, - start: int | models.Range | None, - end: int | models.Range | None, - ) -> None: - """Raise if ``start``/``end`` are not representable on ``sequence_id`` - - :param sequence_id: Sequence identifier as given in the input expression. - Use the same identifier that was passed to ``derive_refget_accession`` so - that the length lookup is served from the dataproxy's metadata cache. - :param start: Start (inter-residue) of the location - :param end: End (inter-residue) of the location - :raises DataProxyValidationError: If ``start`` or ``end`` is out of bounds - """ - self.data_proxy.validate_location_bounds(sequence_id, start, end) - class AlleleTranslator(_Translator): """Class for translating formats to and from VRS Alleles""" @@ -244,11 +227,8 @@ def _create_allele(self, values: dict, **kwargs) -> models.Allele: Returns: models.Allele: The created allele object. - Raises: - DataProxyValidationError: If `start` or `end` is out of bounds. - """ - self._validate_location_bounds( + self.data_proxy.validate_location_bounds( values["sequence_id"], values["start"], values["end"] ) seq_ref = models.SequenceReference(refgetAccession=values["refget_accession"]) @@ -381,7 +361,7 @@ def _from_gnomad(self, gnomad_expr: str, **kwargs) -> models.Allele | None: # validation checks # Bounds must be checked before the ref check: an out-of-bounds fetch may be # silently truncated, which would be misreported as a reference mismatch - self._validate_location_bounds(sequence, start, end) + self.data_proxy.validate_location_bounds(sequence, start, end) self.data_proxy.validate_ref_seq( sequence, start, @@ -595,8 +575,6 @@ def _from_hgvs( CopyNumberCount copy_change: Copy change. If not provided, default is EFO:0030067 for deletions and EFO:0030070 for duplications - - :raises DataProxyValidationError: If the location is out of bounds """ # sv = self._get_parsed_hgvs(hgvs_dup_del_expr) sv = self.hgvs_tools.parse(hgvs_dup_del_expr) @@ -618,7 +596,7 @@ def _from_hgvs( start = sv.posedit.pos.start.base - 1 end = sv.posedit.pos.end.base - self._validate_location_bounds(sv.ac, start, end) + self.data_proxy.validate_location_bounds(sv.ac, start, end) location = models.SequenceLocation( sequenceReference=models.SequenceReference( diff --git a/tests/extras/cassettes/test_in_bounds[cnv-hgvs-copy-number-change-at-end].yaml b/tests/extras/cassettes/test_in_bounds[cnv-hgvs-copy-number-change-at-end].yaml deleted file mode 100644 index 1e5e9d6b..00000000 --- a/tests/extras/cassettes/test_in_bounds[cnv-hgvs-copy-number-change-at-end].yaml +++ /dev/null @@ -1,25 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NC_000007.14 - response: - body: - string: "{\n \"added\": \"2016-08-27T21:23:35Z\",\n \"aliases\": [\n \"GRCh38:7\",\n - \ \"GRCh38:chr7\",\n \"GRCh38.p1:7\",\n \"GRCh38.p1:chr7\",\n \"GRCh38.p10:7\",\n - \ \"GRCh38.p10:chr7\",\n \"GRCh38.p11:7\",\n \"GRCh38.p11:chr7\",\n - \ \"GRCh38.p12:7\",\n \"GRCh38.p12:chr7\",\n \"GRCh38.p2:7\",\n \"GRCh38.p2:chr7\",\n - \ \"GRCh38.p3:7\",\n \"GRCh38.p3:chr7\",\n \"GRCh38.p4:7\",\n \"GRCh38.p4:chr7\",\n - \ \"GRCh38.p5:7\",\n \"GRCh38.p5:chr7\",\n \"GRCh38.p6:7\",\n \"GRCh38.p6:chr7\",\n - \ \"GRCh38.p7:7\",\n \"GRCh38.p7:chr7\",\n \"GRCh38.p8:7\",\n \"GRCh38.p8:chr7\",\n - \ \"GRCh38.p9:7\",\n \"GRCh38.p9:chr7\",\n \"MD5:cc044cc2256a1141212660fb07b6171e\",\n - \ \"NCBI:NC_000007.14\",\n \"refseq:NC_000007.14\",\n \"SEGUID:4+JjCcBVhPCr8vdIhUKFycPv8bY\",\n - \ \"SHA1:e3e26309c05584f0abf2f748854285c9c3eff1b6\",\n \"VMC:GS_F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n - \ \"sha512t24u:F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n \"ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\"\n - \ ],\n \"alphabet\": \"ACGNRSTY\",\n \"length\": 159345973\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[hgvs-c-terminal-residue].yaml b/tests/extras/cassettes/test_in_bounds[hgvs-c-terminal-residue].yaml deleted file mode 100644 index e1df8cbb..00000000 --- a/tests/extras/cassettes/test_in_bounds[hgvs-c-terminal-residue].yaml +++ /dev/null @@ -1,100 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=nucleotide&id=NM_000551.3&rettype=fasta&seq_start=4560&seq_stop=4560&tool=bioutils&email=biocommons-dev@googlegroups.com - response: - body: - string: '>NM_000551.3:4560-4560 Homo sapiens von Hippel-Lindau tumor suppressor - (VHL), transcript variant 1, mRNA - - G - - - ' - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4559&end=4560 - response: - body: - string: G - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4558&end=4559 - response: - body: - string: A - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4559&end=4559 - response: - body: - string: '' - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4560&end=4560 - response: - body: - string: '' - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[spdi-terminal-residue].yaml b/tests/extras/cassettes/test_in_bounds[spdi-terminal-residue].yaml deleted file mode 100644 index b6027bb6..00000000 --- a/tests/extras/cassettes/test_in_bounds[spdi-terminal-residue].yaml +++ /dev/null @@ -1,46 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_?start=4559&end=4560 - response: - body: - string: G - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[vrs-cnv-indefinite-ranges-open-outward].yaml b/tests/extras/cassettes/test_in_bounds[vrs-cnv-indefinite-ranges-open-outward].yaml deleted file mode 100644 index 3be97074..00000000 --- a/tests/extras/cassettes/test_in_bounds[vrs-cnv-indefinite-ranges-open-outward].yaml +++ /dev/null @@ -1,18 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-count-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-count-past-end].yaml deleted file mode 100644 index 1e5e9d6b..00000000 --- a/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-count-past-end].yaml +++ /dev/null @@ -1,25 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NC_000007.14 - response: - body: - string: "{\n \"added\": \"2016-08-27T21:23:35Z\",\n \"aliases\": [\n \"GRCh38:7\",\n - \ \"GRCh38:chr7\",\n \"GRCh38.p1:7\",\n \"GRCh38.p1:chr7\",\n \"GRCh38.p10:7\",\n - \ \"GRCh38.p10:chr7\",\n \"GRCh38.p11:7\",\n \"GRCh38.p11:chr7\",\n - \ \"GRCh38.p12:7\",\n \"GRCh38.p12:chr7\",\n \"GRCh38.p2:7\",\n \"GRCh38.p2:chr7\",\n - \ \"GRCh38.p3:7\",\n \"GRCh38.p3:chr7\",\n \"GRCh38.p4:7\",\n \"GRCh38.p4:chr7\",\n - \ \"GRCh38.p5:7\",\n \"GRCh38.p5:chr7\",\n \"GRCh38.p6:7\",\n \"GRCh38.p6:chr7\",\n - \ \"GRCh38.p7:7\",\n \"GRCh38.p7:chr7\",\n \"GRCh38.p8:7\",\n \"GRCh38.p8:chr7\",\n - \ \"GRCh38.p9:7\",\n \"GRCh38.p9:chr7\",\n \"MD5:cc044cc2256a1141212660fb07b6171e\",\n - \ \"NCBI:NC_000007.14\",\n \"refseq:NC_000007.14\",\n \"SEGUID:4+JjCcBVhPCr8vdIhUKFycPv8bY\",\n - \ \"SHA1:e3e26309c05584f0abf2f748854285c9c3eff1b6\",\n \"VMC:GS_F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n - \ \"sha512t24u:F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n \"ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\"\n - \ ],\n \"alphabet\": \"ACGNRSTY\",\n \"length\": 159345973\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-c-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-c-past-end].yaml deleted file mode 100644 index ab28a89d..00000000 --- a/tests/extras/cassettes/test_out_of_bounds[hgvs-c-past-end].yaml +++ /dev/null @@ -1,18 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-n-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-past-end].yaml deleted file mode 100644 index ab28a89d..00000000 --- a/tests/extras/cassettes/test_out_of_bounds[hgvs-n-past-end].yaml +++ /dev/null @@ -1,18 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[spdi-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[spdi-past-end].yaml deleted file mode 100644 index ab28a89d..00000000 --- a/tests/extras/cassettes/test_out_of_bounds[spdi-past-end].yaml +++ /dev/null @@ -1,18 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NM_000551.3 - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[vrs-cnv-definite-range-end-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[vrs-cnv-definite-range-end-past-end].yaml deleted file mode 100644 index 3be97074..00000000 --- a/tests/extras/cassettes/test_out_of_bounds[vrs-cnv-definite-range-end-past-end].yaml +++ /dev/null @@ -1,18 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/test_location_bounds.py b/tests/extras/test_location_bounds.py index 77e53402..7e88dd8e 100644 --- a/tests/extras/test_location_bounds.py +++ b/tests/extras/test_location_bounds.py @@ -13,9 +13,7 @@ import re import pytest -from hgvs.exceptions import HGVSInvalidIntervalError -from ga4gh.vrs import models from ga4gh.vrs.dataproxy import DataProxyValidationError, SeqRepoRESTDataProxy from ga4gh.vrs.extras.translator import AlleleTranslator, CnvTranslator @@ -71,18 +69,6 @@ def _vrs_allele( } -def _vrs_copy_number_change( - refget_accession: str, - start: int | list[int | None], - end: int | list[int | None], -) -> dict: - return { - "type": "CopyNumberChange", - "location": _vrs_location(refget_accession, start, end), - "copyChange": models.CopyChange.LOSS.value, - } - - def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: return ( f"Location out of bounds on {sequence_id}: {detail} not within [0, {seq_len}]" @@ -95,25 +81,14 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "hgvs", "NC_000019.10:g.58617617C>T", {}, - DataProxyValidationError, _bounds_msg("refseq:NC_000019.10", "end=58617617", 58617616), id="hgvs-g-past-end", ), - pytest.param( - "allele_tlr", - "hgvs", - "NM_000551.3:n.4561del", - {}, - DataProxyValidationError, - _bounds_msg("refseq:NM_000551.3", "end=4561", 4560), - id="hgvs-n-past-end", - ), pytest.param( "allele_tlr", "hgvs", "NM_000551.3:n.4561_4562insA", {}, - DataProxyValidationError, _bounds_msg("refseq:NM_000551.3", "start=4561, end=4561", 4560), id="hgvs-n-insertion-past-end", ), @@ -123,30 +98,9 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "hgvs", "NP_001346993.1:p.Ter194del", {}, - DataProxyValidationError, _bounds_msg("refseq:NP_001346993.1", "end=194", 193), id="hgvs-p-ter-at-length-plus-one", ), - # c. coordinates past the transcript end are already rejected by hgvs when - # mapping c. to n., before the bounds check is reached - pytest.param( - "allele_tlr", - "hgvs", - "NM_000551.3:c.*3706del", - {}, - HGVSInvalidIntervalError, - "c.*3706 coordinate is out of bounds", - id="hgvs-c-past-end", - ), - pytest.param( - "allele_tlr", - "spdi", - "NM_000551.3:4560:1:A", - {}, - DataProxyValidationError, - _bounds_msg("refseq:NM_000551.3", "end=4561", 4560), - id="spdi-past-end", - ), # Zero-width: an out-of-range fetch returns "" and would compare equal to the # empty reference, so only a coordinate check can catch this pytest.param( @@ -154,7 +108,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "spdi", "NM_000551.3:5000:0:AAA", {}, - DataProxyValidationError, _bounds_msg("refseq:NM_000551.3", "start=5000, end=5000", 4560), id="spdi-insertion-past-end", ), @@ -164,7 +117,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "gnomad", "1-248956423-A-T", {}, - DataProxyValidationError, _bounds_msg("GRCh38:1", "end=248956423", 248956422), id="gnomad-past-end", ), @@ -173,7 +125,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "gnomad", "1-248956423-A-T", {"require_validation": False}, - DataProxyValidationError, _bounds_msg("GRCh38:1", "end=248956423", 248956422), id="gnomad-past-end-no-require-validation", ), @@ -182,7 +133,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "gnomad", "1-0-A-T", {}, - DataProxyValidationError, _bounds_msg("GRCh38:1", "start=-1", 248956422), id="gnomad-negative-start", ), @@ -191,7 +141,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "beacon", "1 : 248956423 A > T", {}, - DataProxyValidationError, _bounds_msg("GRCh38:1", "end=248956423", 248956422), id="beacon-past-end", ), @@ -200,34 +149,14 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "vrs", _vrs_allele(NM_000551_3, 99999999, 5), {}, - DataProxyValidationError, _bounds_msg(f"ga4gh:{NM_000551_3}", "start=99999999", 4560), id="vrs-allele-start-past-end-with-start-gt-end", ), - pytest.param( - "allele_tlr", - "vrs", - _vrs_copy_number_change(NM_000551_3, 4400, [4500, 4600]), - {}, - DataProxyValidationError, - _bounds_msg(f"ga4gh:{NM_000551_3}", "end=[4500, 4600]", 4560), - id="vrs-cnv-definite-range-end-past-end", - ), - pytest.param( - "cnv_tlr", - "hgvs", - "NC_000007.14:g.159400000_159400100del", - {"copies": 3}, - DataProxyValidationError, - _bounds_msg("refseq:NC_000007.14", "start=159399999, end=159400100", 159345973), - id="cnv-hgvs-copy-number-count-past-end", - ), pytest.param( "cnv_tlr", "hgvs", "NC_000007.14:g.159400000_159400100del", {}, - DataProxyValidationError, _bounds_msg("refseq:NC_000007.14", "start=159399999, end=159400100", 159345973), id="cnv-hgvs-copy-number-change-past-end", ), @@ -235,13 +164,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: IN_BOUNDS = [ - pytest.param( - "allele_tlr", - "hgvs", - "NM_000551.3:c.*3705del", - {"start": 4559, "end": 4560}, - id="hgvs-c-terminal-residue", - ), pytest.param( "allele_tlr", "hgvs", @@ -249,13 +171,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: {"start": 192, "end": 193}, id="hgvs-p-terminal-residue", ), - pytest.param( - "allele_tlr", - "spdi", - "NM_000551.3:4559:1:A", - {"start": 4559, "end": 4560}, - id="spdi-terminal-residue", - ), pytest.param( "allele_tlr", "spdi", @@ -270,26 +185,10 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: {"start": 16566, "end": 5}, id="vrs-allele-circular-start-gt-end", ), - pytest.param( - "allele_tlr", - "vrs", - _vrs_copy_number_change(NM_000551_3, [None, 4400], [4500, None]), - {"start": [None, 4400], "end": [4500, None]}, - id="vrs-cnv-indefinite-ranges-open-outward", - ), - pytest.param( - "cnv_tlr", - "hgvs", - "NC_000007.14:g.159345900_159345973del", - {"start": 159345899, "end": 159345973}, - id="cnv-hgvs-copy-number-change-at-end", - ), ] -@pytest.mark.parametrize( - ("tlr_fixture", "fmt", "var", "kwargs", "exc_type", "msg"), OUT_OF_BOUNDS -) +@pytest.mark.parametrize(("tlr_fixture", "fmt", "var", "kwargs", "msg"), OUT_OF_BOUNDS) @pytest.mark.vcr def test_out_of_bounds( request: pytest.FixtureRequest, @@ -297,11 +196,10 @@ def test_out_of_bounds( fmt: str, var: str | dict, kwargs: dict, - exc_type: type[Exception], msg: str, ) -> None: tlr = request.getfixturevalue(tlr_fixture) - with pytest.raises(exc_type, match=f"^{re.escape(msg)}$"): + with pytest.raises(DataProxyValidationError, match=f"^{re.escape(msg)}$"): tlr.translate_from(var, fmt=fmt, **kwargs) diff --git a/tests/test_dataproxy.py b/tests/test_dataproxy.py index 1b4229cc..8d50f9d3 100644 --- a/tests/test_dataproxy.py +++ b/tests/test_dataproxy.py @@ -1,4 +1,3 @@ -import logging import os import re @@ -8,7 +7,6 @@ from ga4gh.vrs.dataproxy import ( DataProxyValidationError, _DataProxy, - coerce_accession_namespace, create_dataproxy, ) @@ -74,29 +72,11 @@ def test_data_proxy_configs(): create_dataproxy("file:///path/to/seqrepo/root") -@pytest.mark.parametrize( - ("ac", "expected"), - [ - ("NM_000551.3", "refseq:NM_000551.3"), - ("NC_000001.11", "refseq:NC_000001.11"), - ("refseq:NM_000551.3", "refseq:NM_000551.3"), - ("GRCh38:1", "GRCh38:1"), - ( - "ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_", - "ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_", - ), - ], -) -def test_coerce_accession_namespace(ac: str, expected: str) -> None: - assert coerce_accession_namespace(ac) == expected - - class _StubDataProxy(_DataProxy): - """Dataproxy serving only sequence lengths, recording each metadata lookup""" + """Dataproxy serving only sequence lengths""" def __init__(self, lengths: dict[str, int]) -> None: self.lengths = lengths - self.metadata_requests: list[str] = [] def get_sequence( self, identifier: str, start: int | None = None, end: int | None = None @@ -104,7 +84,6 @@ def get_sequence( raise NotImplementedError def get_metadata(self, identifier: str) -> dict: - self.metadata_requests.append(identifier) return {"length": self.lengths[identifier], "aliases": []} @@ -154,11 +133,9 @@ def get_metadata(self, identifier: str) -> dict: def test_validate_location_bounds_valid( start: int | models.Range | None, end: int | models.Range | None, - caplog: pytest.LogCaptureFixture, ) -> None: dp = _StubDataProxy({BOUNDS_SEQ_ID: BOUNDS_SEQ_LEN}) dp.validate_location_bounds(BOUNDS_SEQ_ID, start, end) - assert not caplog.records @pytest.mark.parametrize(("start", "end", "detail"), LOCATION_BOUNDS_INVALID) @@ -166,7 +143,6 @@ def test_validate_location_bounds_invalid( start: int | models.Range | None, end: int | models.Range | None, detail: str, - caplog: pytest.LogCaptureFixture, ) -> None: dp = _StubDataProxy({BOUNDS_SEQ_ID: BOUNDS_SEQ_LEN}) expected_msg = ( @@ -175,25 +151,3 @@ def test_validate_location_bounds_invalid( ) with pytest.raises(DataProxyValidationError, match=f"^{re.escape(expected_msg)}$"): dp.validate_location_bounds(BOUNDS_SEQ_ID, start, end) - # logged at WARNING as well as raised; there is no warn-only mode - assert [(r.levelno, r.getMessage()) for r in caplog.records] == [ - (logging.WARNING, expected_msg) - ] - - -@pytest.mark.parametrize("sequence_id", ["NM_000551.3", "refseq:NM_000551.3"]) -def test_validate_location_bounds_cache_key(sequence_id: str) -> None: - """The length lookup must use the same identifier as derive_refget_accession, so - that it is served from the dataproxy's metadata cache instead of a new request - """ - dp = _StubDataProxy({BOUNDS_SEQ_ID: BOUNDS_SEQ_LEN}) - dp.validate_location_bounds(sequence_id, 0, 1) - with pytest.raises(DataProxyValidationError, match=re.escape(BOUNDS_SEQ_ID)): - dp.validate_location_bounds(sequence_id, 0, BOUNDS_SEQ_LEN + 1) - assert dp.metadata_requests == [BOUNDS_SEQ_ID, BOUNDS_SEQ_ID] - - -def test_validate_location_bounds_unknown_sequence() -> None: - dp = _StubDataProxy({}) - with pytest.raises(KeyError): - dp.validate_location_bounds("NM_000551.3", 0, 1) diff --git a/tests/test_vrs_normalize.py b/tests/test_vrs_normalize.py index 4e476a65..2542d287 100644 --- a/tests/test_vrs_normalize.py +++ b/tests/test_vrs_normalize.py @@ -958,8 +958,6 @@ def _bounds_allele( @pytest.mark.parametrize( ("start", "end", "sequence", "expected_start", "expected_end"), [ - pytest.param(4559, 4560, "A", 4559, 4560, id="terminal-residue"), - pytest.param(4560, 4560, "A", 4560, 4560, id="insertion-at-end"), # an undefined outer endpoint is representable and must not be rejected # (the deletion is also rolled right by one base by normalization) pytest.param( @@ -988,11 +986,6 @@ def test_normalize_location_in_bounds( @pytest.mark.parametrize( ("start", "end", "detail"), [ - pytest.param(4559, 4561, "end=4561", id="one-past-end"), - pytest.param(5000, 5000, "start=5000, end=5000", id="insertion-past-end"), - pytest.param( - 99999999, 5, "start=99999999", id="start-past-end-with-start-gt-end" - ), # Definite ranges are otherwise returned without normalization, so the # bounds check must run before that early return pytest.param( From 3fb4abcd30338845bdbc59826dcfa573fe3247a3 Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Thu, 24 Sep 2026 15:44:10 -0400 Subject: [PATCH 06/11] cleanup: inline single-use bounds helpers Call bioutils coerce_namespace directly, which already leaves namespaced identifiers unchanged, and restore derive_refget_accession to its original form. Fold _defined_values into _check_location_bounds, its only caller. --- src/ga4gh/vrs/dataproxy.py | 54 +++++++------------------------------- 1 file changed, 10 insertions(+), 44 deletions(-) diff --git a/src/ga4gh/vrs/dataproxy.py b/src/ga4gh/vrs/dataproxy.py index f38a7f9f..6a412cdb 100644 --- a/src/ga4gh/vrs/dataproxy.py +++ b/src/ga4gh/vrs/dataproxy.py @@ -25,40 +25,6 @@ class DataProxyValidationError(Exception): """Class for validation errors during data proxy methods""" -def _coerce_accession_namespace(ac: str) -> str: - """Return ``ac`` as a namespaced CURIE, inferring the namespace if none is given - - e.g. ``NM_000551.3`` -> ``refseq:NM_000551.3``. Identifiers that already carry a - namespace (``GRCh38:1``, ``ga4gh:SQ.…``) are returned unchanged. - - Metadata lookups are cached per identifier, so callers that must hit the same - cache entry (e.g. deriving a refget accession and then validating bounds on the - same input) should both go through this function. - - :param ac: accession in simple or CURIE form - :return: accession in CURIE form - """ - if ":" not in ac[1:]: - ac = coerce_namespace(ac) - return ac - - -def _defined_values(pos: int | Range | None) -> list[int]: - """Return the defined values of a location coordinate - - An int yields itself, a ``Range`` yields its non-``None`` members, and ``None`` - (an undefined endpoint) yields nothing. - - :param pos: ``start`` or ``end`` of a ``SequenceLocation`` - :return: defined coordinate values - """ - if pos is None: - return [] - if isinstance(pos, Range): - return [v for v in pos.root if v is not None] - return [pos] - - def _check_location_bounds( sequence_id: str, seq_len: int, @@ -79,11 +45,11 @@ def _check_location_bounds( :param end_pos: ``end`` of the location :raises DataProxyValidationError: if a defined coordinate is out of bounds """ - bad = [ - (name, pos) - for name, pos in (("start", start_pos), ("end", end_pos)) - if any(v < 0 or v > seq_len for v in _defined_values(pos)) - ] + bad = [] + for name, pos in (("start", start_pos), ("end", end_pos)): + values = pos.root if isinstance(pos, Range) else [pos] + if any(v is not None and not 0 <= v <= seq_len for v in values): + bad.append((name, pos)) if bad: # Range is shown as its list form, e.g. end=[4500, 4600] detail = ", ".join( @@ -206,8 +172,9 @@ def derive_refget_accession(self, ac: str) -> str | None: if ac is None: return None - # always coerce the namespace if none provided - ac = _coerce_accession_namespace(ac) + if ":" not in ac[1:]: + # always coerce the namespace if none provided + ac = coerce_namespace(ac) refget_accession = None try: @@ -277,9 +244,8 @@ def validate_location_bounds( :raises DataProxyValidationError: If a defined coordinate is out of bounds :raises KeyError: If ``sequence_id`` is not found """ - # Coerce the same way derive_refget_accession does, so that the metadata - # lookup hits the same cache entry - sequence_id = _coerce_accession_namespace(sequence_id) + # same cache key as derive_refget_accession + sequence_id = coerce_namespace(sequence_id) seq_len = self.get_metadata(sequence_id)["length"] _check_location_bounds(sequence_id, seq_len, start_pos, end_pos) From 0c6ea81808df8c4f6a64ae94092a1235a6266f40 Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Thu, 24 Sep 2026 18:00:13 -0400 Subject: [PATCH 07/11] cleanup: validate bounds against the refget accession Look up the sequence length by the refget accession already in the allele values instead of threading the input accession through every translator. This is the same metadata lookup normalize makes next, so it adds no request on the default path. Out-of-bounds errors from _create_allele now name the ga4gh:SQ id. test_from_beacon (do_normalize=False) gains the ga4gh metadata request in its cassette. --- src/ga4gh/vrs/extras/translator.py | 7 +-- src/ga4gh/vrs/utils/hgvs_tools.py | 1 - tests/extras/cassettes/test_from_beacon.yaml | 53 +++++++++++++++++++ .../test_out_of_bounds[beacon-past-end].yaml | 23 ++++++++ .../test_out_of_bounds[hgvs-g-past-end].yaml | 24 +++++++++ ..._of_bounds[hgvs-n-insertion-past-end].yaml | 16 ++++++ ...bounds[hgvs-p-ter-at-length-plus-one].yaml | 24 +++++++++ ...ut_of_bounds[spdi-insertion-past-end].yaml | 16 ++++++ tests/extras/test_location_bounds.py | 13 +++-- 9 files changed, 165 insertions(+), 12 deletions(-) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 520062cd..e2eb4bff 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -216,8 +216,6 @@ def _create_allele(self, values: dict, **kwargs) -> models.Allele: Args: values (dict): The values to use for creating the allele object. - 'sequence_id' (str): The sequence identifier from the input - expression, used to validate `start` and `end`. 'refget_accession' (str): The accession ID of the reference genome. 'start' (int): The start position of the allele. 'end' (int): The end position of the allele. @@ -229,7 +227,7 @@ def _create_allele(self, values: dict, **kwargs) -> models.Allele: """ self.data_proxy.validate_location_bounds( - values["sequence_id"], values["start"], values["end"] + f"ga4gh:{values['refget_accession']}", values["start"], values["end"] ) seq_ref = models.SequenceReference(refgetAccession=values["refget_accession"]) location = models.SequenceLocation( @@ -294,7 +292,6 @@ def _from_beacon(self, beacon_expr: str, **kwargs) -> models.Allele | None: ins_seq = alt values = { - "sequence_id": sequence, "refget_accession": refget_accession, "start": start, "end": end, @@ -371,7 +368,6 @@ def _from_gnomad(self, gnomad_expr: str, **kwargs) -> models.Allele | None: ) values = { - "sequence_id": sequence, "refget_accession": refget_accession, "start": start, "end": end, @@ -437,7 +433,6 @@ def _from_spdi(self, spdi_expr: str, **kwargs) -> models.Allele | None: ins_seq = g["ins_seq"] values = { - "sequence_id": g["ac"], "refget_accession": refget_accession, "start": start, "end": end, diff --git a/src/ga4gh/vrs/utils/hgvs_tools.py b/src/ga4gh/vrs/utils/hgvs_tools.py index 5ff3f38f..503d4022 100644 --- a/src/ga4gh/vrs/utils/hgvs_tools.py +++ b/src/ga4gh/vrs/utils/hgvs_tools.py @@ -182,7 +182,6 @@ def extract_allele_values(self, hgvs_expr: str) -> dict | None: (start, end, state) = self.get_position_and_state(sv) return { - "sequence_id": sv.ac, "refget_accession": refget_accession, "start": start, "end": end, diff --git a/tests/extras/cassettes/test_from_beacon.yaml b/tests/extras/cassettes/test_from_beacon.yaml index e04892cc..c485e8e4 100644 --- a/tests/extras/cassettes/test_from_beacon.yaml +++ b/tests/extras/cassettes/test_from_beacon.yaml @@ -52,4 +52,57 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl + response: + body: + string: "{\n \"added\": \"2016-08-24T08:19:02Z\",\n \"aliases\": [\n \"Ensembl:19\",\n + \ \"ensembl:19\",\n \"GRCh38:19\",\n \"GRCh38:chr19\",\n \"GRCh38.p1:19\",\n + \ \"GRCh38.p1:chr19\",\n \"GRCh38.p10:19\",\n \"GRCh38.p10:chr19\",\n + \ \"GRCh38.p11:19\",\n \"GRCh38.p11:chr19\",\n \"GRCh38.p12:19\",\n + \ \"GRCh38.p12:chr19\",\n \"GRCh38.p2:19\",\n \"GRCh38.p2:chr19\",\n + \ \"GRCh38.p3:19\",\n \"GRCh38.p3:chr19\",\n \"GRCh38.p4:19\",\n \"GRCh38.p4:chr19\",\n + \ \"GRCh38.p5:19\",\n \"GRCh38.p5:chr19\",\n \"GRCh38.p6:19\",\n \"GRCh38.p6:chr19\",\n + \ \"GRCh38.p7:19\",\n \"GRCh38.p7:chr19\",\n \"GRCh38.p8:19\",\n \"GRCh38.p8:chr19\",\n + \ \"GRCh38.p9:19\",\n \"GRCh38.p9:chr19\",\n \"MD5:b0eba2c7bb5c953d1e06a508b5e487de\",\n + \ \"NCBI:NC_000019.10\",\n \"refseq:NC_000019.10\",\n \"SEGUID:AHxM5/L8jIX08UhBBkKXkiO5rhY\",\n + \ \"SHA1:007c4ce7f2fc8c85f4f148410642979223b9ae16\",\n \"VMC:GS_IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n + \ \"sha512t24u:IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n \"ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\"\n + \ ],\n \"alphabet\": \"ACGNT\",\n \"length\": 58617616\n}\n" + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct + response: + body: + string: "{\n \"added\": \"2016-08-24T06:13:07Z\",\n \"aliases\": [\n \"Ensembl:MT\",\n + \ \"ensembl:MT\",\n \"GRCh37.p10:MT\",\n \"GRCh37.p10:chrM\",\n \"GRCh37.p11:MT\",\n + \ \"GRCh37.p11:chrM\",\n \"GRCh37.p12:MT\",\n \"GRCh37.p12:chrM\",\n + \ \"GRCh37.p13:MT\",\n \"GRCh37.p13:chrM\",\n \"GRCh37.p2:MT\",\n + \ \"GRCh37.p2:chrM\",\n \"GRCh37.p5:MT\",\n \"GRCh37.p5:chrM\",\n + \ \"GRCh37.p9:MT\",\n \"GRCh37.p9:chrM\",\n \"GRCh38:MT\",\n \"GRCh38:chrM\",\n + \ \"GRCh38.p1:MT\",\n \"GRCh38.p1:chrM\",\n \"GRCh38.p10:MT\",\n \"GRCh38.p10:chrM\",\n + \ \"GRCh38.p11:MT\",\n \"GRCh38.p11:chrM\",\n \"GRCh38.p12:MT\",\n + \ \"GRCh38.p12:chrM\",\n \"GRCh38.p2:MT\",\n \"GRCh38.p2:chrM\",\n + \ \"GRCh38.p3:MT\",\n \"GRCh38.p3:chrM\",\n \"GRCh38.p4:MT\",\n \"GRCh38.p4:chrM\",\n + \ \"GRCh38.p5:MT\",\n \"GRCh38.p5:chrM\",\n \"GRCh38.p6:MT\",\n \"GRCh38.p6:chrM\",\n + \ \"GRCh38.p7:MT\",\n \"GRCh38.p7:chrM\",\n \"GRCh38.p8:MT\",\n \"GRCh38.p8:chrM\",\n + \ \"GRCh38.p9:MT\",\n \"GRCh38.p9:chrM\",\n \"MD5:c68f52674c9fb33aef52dcf399755519\",\n + \ \"NCBI:NC_012920.1\",\n \"refseq:NC_012920.1\",\n \"SEGUID:eQNFYXnsCzhp/MkfBUBVnuFZzTA\",\n + \ \"SHA1:7903456179ec0b3869fcc91f0540559ee159cd30\",\n \"VMC:GS_k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n + \ \"sha512t24u:k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n \"ga4gh:SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n + \ \"hs37-1kg:MT\",\n \"hs37d5:MT\"\n ],\n \"alphabet\": \"ACGNT\",\n + \ \"length\": 16569\n}\n" + headers: {} + status: + code: 200 + message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml index 2c11ee79..b5467b90 100644 --- a/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml @@ -22,4 +22,27 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO + response: + body: + string: "{\n \"added\": \"2016-08-27T21:17:00Z\",\n \"aliases\": [\n \"GRCh38:1\",\n + \ \"GRCh38:chr1\",\n \"GRCh38.p1:1\",\n \"GRCh38.p1:chr1\",\n \"GRCh38.p10:1\",\n + \ \"GRCh38.p10:chr1\",\n \"GRCh38.p11:1\",\n \"GRCh38.p11:chr1\",\n + \ \"GRCh38.p12:1\",\n \"GRCh38.p12:chr1\",\n \"GRCh38.p2:1\",\n \"GRCh38.p2:chr1\",\n + \ \"GRCh38.p3:1\",\n \"GRCh38.p3:chr1\",\n \"GRCh38.p4:1\",\n \"GRCh38.p4:chr1\",\n + \ \"GRCh38.p5:1\",\n \"GRCh38.p5:chr1\",\n \"GRCh38.p6:1\",\n \"GRCh38.p6:chr1\",\n + \ \"GRCh38.p7:1\",\n \"GRCh38.p7:chr1\",\n \"GRCh38.p8:1\",\n \"GRCh38.p8:chr1\",\n + \ \"GRCh38.p9:1\",\n \"GRCh38.p9:chr1\",\n \"MD5:6aef897c3d6ff0c78aff06ac189178dd\",\n + \ \"NCBI:NC_000001.11\",\n \"refseq:NC_000001.11\",\n \"SEGUID:FCUd6VJ6uikS/VWLbhGdVmj2rOA\",\n + \ \"SHA1:14251de9527aba2912fd558b6e119d5668f6ace0\",\n \"VMC:GS_Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n + \ \"sha512t24u:Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n \"ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\"\n + \ ],\n \"alphabet\": \"ACGMNRT\",\n \"length\": 248956422\n}\n" + headers: {} + status: + code: 200 + message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml index 6c1671e7..8ad112b6 100644 --- a/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml @@ -23,4 +23,28 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl + response: + body: + string: "{\n \"added\": \"2016-08-24T08:19:02Z\",\n \"aliases\": [\n \"Ensembl:19\",\n + \ \"ensembl:19\",\n \"GRCh38:19\",\n \"GRCh38:chr19\",\n \"GRCh38.p1:19\",\n + \ \"GRCh38.p1:chr19\",\n \"GRCh38.p10:19\",\n \"GRCh38.p10:chr19\",\n + \ \"GRCh38.p11:19\",\n \"GRCh38.p11:chr19\",\n \"GRCh38.p12:19\",\n + \ \"GRCh38.p12:chr19\",\n \"GRCh38.p2:19\",\n \"GRCh38.p2:chr19\",\n + \ \"GRCh38.p3:19\",\n \"GRCh38.p3:chr19\",\n \"GRCh38.p4:19\",\n \"GRCh38.p4:chr19\",\n + \ \"GRCh38.p5:19\",\n \"GRCh38.p5:chr19\",\n \"GRCh38.p6:19\",\n \"GRCh38.p6:chr19\",\n + \ \"GRCh38.p7:19\",\n \"GRCh38.p7:chr19\",\n \"GRCh38.p8:19\",\n \"GRCh38.p8:chr19\",\n + \ \"GRCh38.p9:19\",\n \"GRCh38.p9:chr19\",\n \"MD5:b0eba2c7bb5c953d1e06a508b5e487de\",\n + \ \"NCBI:NC_000019.10\",\n \"refseq:NC_000019.10\",\n \"SEGUID:AHxM5/L8jIX08UhBBkKXkiO5rhY\",\n + \ \"SHA1:007c4ce7f2fc8c85f4f148410642979223b9ae16\",\n \"VMC:GS_IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n + \ \"sha512t24u:IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n \"ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\"\n + \ ],\n \"alphabet\": \"ACGNT\",\n \"length\": 58617616\n}\n" + headers: {} + status: + code: 200 + message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml index ab28a89d..3bc0bd3d 100644 --- a/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml @@ -15,4 +15,20 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml index dba45e9a..8515159f 100644 --- a/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml @@ -23,4 +23,28 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer + response: + body: + string: "{\n \"added\": \"2016-08-24T04:59:23Z\",\n \"aliases\": [\n \"Ensembl:ENSP00000480268.1\",\n + \ \"ensembl:ENSP00000480268.1\",\n \"Ensembl:ENSP00000491180.1\",\n \"ensembl:ENSP00000491180.1\",\n + \ \"Ensembl:ENSP00000491338.1\",\n \"ensembl:ENSP00000491338.1\",\n \"Ensembl:ENSP00000491353.1\",\n + \ \"ensembl:ENSP00000491353.1\",\n \"Ensembl:ENSP00000492701.1\",\n \"ensembl:ENSP00000492701.1\",\n + \ \"Ensembl:ENSP00000498790.1\",\n \"ensembl:ENSP00000498790.1\",\n \"MD5:fecf2eee2cdc50588a641e472e062be1\",\n + \ \"NCBI:NP_001346993.1\",\n \"refseq:NP_001346993.1\",\n \"NCBI:NP_001347000.1\",\n + \ \"refseq:NP_001347000.1\",\n \"NCBI:NP_001355060.1\",\n \"refseq:NP_001355060.1\",\n + \ \"NCBI:XP_011534418.1\",\n \"refseq:XP_011534418.1\",\n \"NCBI:XP_024302319.1\",\n + \ \"refseq:XP_024302319.1\",\n \"NCBI:XP_054212402.1\",\n \"refseq:XP_054212402.1\",\n + \ \"NCBI:XP_054212403.1\",\n \"refseq:XP_054212403.1\",\n \"SEGUID:8Lnknw+hAAOAjiLGHus4bfyDy0k\",\n + \ \"SHA1:f0b9e49f0fa10003808e22c61eeb386dfc83cb49\",\n \"VMC:GS_IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n + \ \"sha512t24u:IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n \"ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\"\n + \ ],\n \"alphabet\": \"ACDEFGHIKLMNPQRSTVWY\",\n \"length\": 193\n}\n" + headers: {} + status: + code: 200 + message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml index ab28a89d..3bc0bd3d 100644 --- a/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml @@ -15,4 +15,20 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ + response: + body: + string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n + \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n + \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n + \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n + \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" + headers: {} + status: + code: 200 + message: OK version: 1 diff --git a/tests/extras/test_location_bounds.py b/tests/extras/test_location_bounds.py index 7e88dd8e..82cc0846 100644 --- a/tests/extras/test_location_bounds.py +++ b/tests/extras/test_location_bounds.py @@ -17,7 +17,10 @@ from ga4gh.vrs.dataproxy import DataProxyValidationError, SeqRepoRESTDataProxy from ga4gh.vrs.extras.translator import AlleleTranslator, CnvTranslator +NC_000001_11 = "SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO" +NC_000019_10 = "SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl" NM_000551_3 = "SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_" +NP_001346993_1 = "SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer" NC_012920_1 = "SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct" @@ -81,7 +84,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "hgvs", "NC_000019.10:g.58617617C>T", {}, - _bounds_msg("refseq:NC_000019.10", "end=58617617", 58617616), + _bounds_msg(f"ga4gh:{NC_000019_10}", "end=58617617", 58617616), id="hgvs-g-past-end", ), pytest.param( @@ -89,7 +92,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "hgvs", "NM_000551.3:n.4561_4562insA", {}, - _bounds_msg("refseq:NM_000551.3", "start=4561, end=4561", 4560), + _bounds_msg(f"ga4gh:{NM_000551_3}", "start=4561, end=4561", 4560), id="hgvs-n-insertion-past-end", ), # ClinVar references the stop codon, which is not part of the protein sequence @@ -98,7 +101,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "hgvs", "NP_001346993.1:p.Ter194del", {}, - _bounds_msg("refseq:NP_001346993.1", "end=194", 193), + _bounds_msg(f"ga4gh:{NP_001346993_1}", "end=194", 193), id="hgvs-p-ter-at-length-plus-one", ), # Zero-width: an out-of-range fetch returns "" and would compare equal to the @@ -108,7 +111,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "spdi", "NM_000551.3:5000:0:AAA", {}, - _bounds_msg("refseq:NM_000551.3", "start=5000, end=5000", 4560), + _bounds_msg(f"ga4gh:{NM_000551_3}", "start=5000, end=5000", 4560), id="spdi-insertion-past-end", ), # Must report the bounds error, not "Reference mismatch ... correct ref is ''" @@ -141,7 +144,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "beacon", "1 : 248956423 A > T", {}, - _bounds_msg("GRCh38:1", "end=248956423", 248956422), + _bounds_msg(f"ga4gh:{NC_000001_11}", "end=248956423", 248956422), id="beacon-past-end", ), pytest.param( From 4b3684fa24df2604f5446468234333bf2562c739 Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Thu, 24 Sep 2026 18:37:22 -0400 Subject: [PATCH 08/11] cleanup: address review of location bounds validation - Leave _from_vrs input as-is: like the rest of that path, it is not validated or normalized. Test the VrsType lookup fix directly, including the unknown and missing type cases. - Validate CNV bounds against the refget accession, matching the allele paths. - Call validate_location_bounds from normalize instead of importing the private helper, and fold the helper into the method. - Drop redundant bounds test rows and their cassettes; add type hints. --- src/ga4gh/vrs/dataproxy.py | 54 +++------- src/ga4gh/vrs/extras/translator.py | 19 +--- src/ga4gh/vrs/normalize.py | 6 +- ...st_in_bounds[hgvs-p-terminal-residue].yaml | 98 ------------------- ...nds[vrs-allele-circular-start-gt-end].yaml | 31 ------ ...cnv-hgvs-copy-number-change-past-end].yaml | 23 +++++ .../test_out_of_bounds[hgvs-g-past-end].yaml | 50 ---------- ...ele-start-past-end-with-start-gt-end].yaml | 18 ---- tests/extras/test_allele_translator.py | 31 ++++++ tests/extras/test_location_bounds.py | 68 +------------ tests/test_vrs_normalize.py | 6 +- 11 files changed, 85 insertions(+), 319 deletions(-) delete mode 100644 tests/extras/cassettes/test_in_bounds[hgvs-p-terminal-residue].yaml delete mode 100644 tests/extras/cassettes/test_in_bounds[vrs-allele-circular-start-gt-end].yaml delete mode 100644 tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml delete mode 100644 tests/extras/cassettes/test_out_of_bounds[vrs-allele-start-past-end-with-start-gt-end].yaml diff --git a/src/ga4gh/vrs/dataproxy.py b/src/ga4gh/vrs/dataproxy.py index 6a412cdb..db6a456c 100644 --- a/src/ga4gh/vrs/dataproxy.py +++ b/src/ga4gh/vrs/dataproxy.py @@ -25,43 +25,6 @@ class DataProxyValidationError(Exception): """Class for validation errors during data proxy methods""" -def _check_location_bounds( - sequence_id: str, - seq_len: int, - start_pos: int | Range | None, - end_pos: int | Range | None, -) -> None: - """Raise if any defined value of ``start``/``end`` lies outside ``[0, seq_len]`` - - Each coordinate is checked independently; the relationship between ``start`` and - ``end`` is never inspected, since ``start > end`` is valid on circular sequences. - ``pos == seq_len`` is valid (an insertion point after the final residue). - Undefined endpoints, and the undefined side of an indefinite ``Range``, are - skipped rather than treated as 0. - - :param sequence_id: identifier of the sequence, used in the error message - :param seq_len: length of the sequence - :param start_pos: ``start`` of the location - :param end_pos: ``end`` of the location - :raises DataProxyValidationError: if a defined coordinate is out of bounds - """ - bad = [] - for name, pos in (("start", start_pos), ("end", end_pos)): - values = pos.root if isinstance(pos, Range) else [pos] - if any(v is not None and not 0 <= v <= seq_len for v in values): - bad.append((name, pos)) - if bad: - # Range is shown as its list form, e.g. end=[4500, 4600] - detail = ", ".join( - f"{name}={pos.root if isinstance(pos, Range) else pos}" for name, pos in bad - ) - err_msg = ( - f"Location out of bounds on {sequence_id}: {detail} " - f"not within [0, {seq_len}]" - ) - raise DataProxyValidationError(err_msg) - - class _DataProxy(ABC): """abstract class / interface for VRS data needs @@ -247,7 +210,22 @@ def validate_location_bounds( # same cache key as derive_refget_accession sequence_id = coerce_namespace(sequence_id) seq_len = self.get_metadata(sequence_id)["length"] - _check_location_bounds(sequence_id, seq_len, start_pos, end_pos) + bad = [] + for name, pos in (("start", start_pos), ("end", end_pos)): + values = pos.root if isinstance(pos, Range) else [pos] + if any(v is not None and not 0 <= v <= seq_len for v in values): + bad.append((name, pos)) + if bad: + # Range is shown as its list form, e.g. end=[4500, 4600] + detail = ", ".join( + f"{name}={pos.root if isinstance(pos, Range) else pos}" + for name, pos in bad + ) + err_msg = ( + f"Location out of bounds on {sequence_id}: {detail} " + f"not within [0, {seq_len}]" + ) + raise DataProxyValidationError(err_msg) class _SeqRepoDataProxyBase(_DataProxy): diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index e2eb4bff..5434f25f 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -170,20 +170,7 @@ def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noq model = getattr(models, models.VrsType(var["type"]).value) except ValueError: return None - vo = model(**var) - - # Nothing downstream of this path fetches or normalizes, so this is the only - # opportunity to reject a location that does not exist on its sequence - location = getattr(vo, "location", None) - if isinstance(location, models.SequenceLocation) and isinstance( - location.sequenceReference, models.SequenceReference - ): - self.data_proxy.validate_location_bounds( - f"ga4gh:{location.sequenceReference.refgetAccession}", - location.start, - location.end, - ) - return vo + return model(**var) class AlleleTranslator(_Translator): @@ -591,7 +578,9 @@ def _from_hgvs( start = sv.posedit.pos.start.base - 1 end = sv.posedit.pos.end.base - self.data_proxy.validate_location_bounds(sv.ac, start, end) + self.data_proxy.validate_location_bounds( + f"ga4gh:{refget_accession}", start, end + ) location = models.SequenceLocation( sequenceReference=models.SequenceReference( diff --git a/src/ga4gh/vrs/normalize.py b/src/ga4gh/vrs/normalize.py index 27c4bd73..9747febd 100644 --- a/src/ga4gh/vrs/normalize.py +++ b/src/ga4gh/vrs/normalize.py @@ -15,7 +15,7 @@ from ga4gh.core import ga4gh_digest, is_pydantic_instance, pydantic_copy from ga4gh.vrs import models -from ga4gh.vrs.dataproxy import SequenceProxy, _check_location_bounds, _DataProxy +from ga4gh.vrs.dataproxy import SequenceProxy, _DataProxy _logger = logging.getLogger(__name__) @@ -136,8 +136,8 @@ def _normalize_allele( # Reject locations that do not exist on the sequence before anything is fetched, # since out-of-range fetches may be silently truncated by the sequence backend. # Done before the early returns below, which skip definite ranges. - _check_location_bounds( - alias, len(ref_seq), input_allele.location.start, input_allele.location.end + data_proxy.validate_location_bounds( + alias, input_allele.location.start, input_allele.location.end ) start = _get_allele_location_pos(input_allele, use_start=True) diff --git a/tests/extras/cassettes/test_in_bounds[hgvs-p-terminal-residue].yaml b/tests/extras/cassettes/test_in_bounds[hgvs-p-terminal-residue].yaml deleted file mode 100644 index db3c2911..00000000 --- a/tests/extras/cassettes/test_in_bounds[hgvs-p-terminal-residue].yaml +++ /dev/null @@ -1,98 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NP_001346993.1 - response: - body: - string: "{\n \"added\": \"2016-08-24T04:59:23Z\",\n \"aliases\": [\n \"Ensembl:ENSP00000480268.1\",\n - \ \"ensembl:ENSP00000480268.1\",\n \"Ensembl:ENSP00000491180.1\",\n \"ensembl:ENSP00000491180.1\",\n - \ \"Ensembl:ENSP00000491338.1\",\n \"ensembl:ENSP00000491338.1\",\n \"Ensembl:ENSP00000491353.1\",\n - \ \"ensembl:ENSP00000491353.1\",\n \"Ensembl:ENSP00000492701.1\",\n \"ensembl:ENSP00000492701.1\",\n - \ \"Ensembl:ENSP00000498790.1\",\n \"ensembl:ENSP00000498790.1\",\n \"MD5:fecf2eee2cdc50588a641e472e062be1\",\n - \ \"NCBI:NP_001346993.1\",\n \"refseq:NP_001346993.1\",\n \"NCBI:NP_001347000.1\",\n - \ \"refseq:NP_001347000.1\",\n \"NCBI:NP_001355060.1\",\n \"refseq:NP_001355060.1\",\n - \ \"NCBI:XP_011534418.1\",\n \"refseq:XP_011534418.1\",\n \"NCBI:XP_024302319.1\",\n - \ \"refseq:XP_024302319.1\",\n \"NCBI:XP_054212402.1\",\n \"refseq:XP_054212402.1\",\n - \ \"NCBI:XP_054212403.1\",\n \"refseq:XP_054212403.1\",\n \"SEGUID:8Lnknw+hAAOAjiLGHus4bfyDy0k\",\n - \ \"SHA1:f0b9e49f0fa10003808e22c61eeb386dfc83cb49\",\n \"VMC:GS_IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n - \ \"sha512t24u:IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n \"ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\"\n - \ ],\n \"alphabet\": \"ACDEFGHIKLMNPQRSTVWY\",\n \"length\": 193\n}\n" - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer - response: - body: - string: "{\n \"added\": \"2016-08-24T04:59:23Z\",\n \"aliases\": [\n \"Ensembl:ENSP00000480268.1\",\n - \ \"ensembl:ENSP00000480268.1\",\n \"Ensembl:ENSP00000491180.1\",\n \"ensembl:ENSP00000491180.1\",\n - \ \"Ensembl:ENSP00000491338.1\",\n \"ensembl:ENSP00000491338.1\",\n \"Ensembl:ENSP00000491353.1\",\n - \ \"ensembl:ENSP00000491353.1\",\n \"Ensembl:ENSP00000492701.1\",\n \"ensembl:ENSP00000492701.1\",\n - \ \"Ensembl:ENSP00000498790.1\",\n \"ensembl:ENSP00000498790.1\",\n \"MD5:fecf2eee2cdc50588a641e472e062be1\",\n - \ \"NCBI:NP_001346993.1\",\n \"refseq:NP_001346993.1\",\n \"NCBI:NP_001347000.1\",\n - \ \"refseq:NP_001347000.1\",\n \"NCBI:NP_001355060.1\",\n \"refseq:NP_001355060.1\",\n - \ \"NCBI:XP_011534418.1\",\n \"refseq:XP_011534418.1\",\n \"NCBI:XP_024302319.1\",\n - \ \"refseq:XP_024302319.1\",\n \"NCBI:XP_054212402.1\",\n \"refseq:XP_054212402.1\",\n - \ \"NCBI:XP_054212403.1\",\n \"refseq:XP_054212403.1\",\n \"SEGUID:8Lnknw+hAAOAjiLGHus4bfyDy0k\",\n - \ \"SHA1:f0b9e49f0fa10003808e22c61eeb386dfc83cb49\",\n \"VMC:GS_IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n - \ \"sha512t24u:IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n \"ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\"\n - \ ],\n \"alphabet\": \"ACDEFGHIKLMNPQRSTVWY\",\n \"length\": 193\n}\n" - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer?start=192&end=193 - response: - body: - string: L - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer?start=191&end=192 - response: - body: - string: S - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer?start=192&end=192 - response: - body: - string: '' - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer?start=193&end=193 - response: - body: - string: '' - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_in_bounds[vrs-allele-circular-start-gt-end].yaml b/tests/extras/cassettes/test_in_bounds[vrs-allele-circular-start-gt-end].yaml deleted file mode 100644 index dc2182d0..00000000 --- a/tests/extras/cassettes/test_in_bounds[vrs-allele-circular-start-gt-end].yaml +++ /dev/null @@ -1,31 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct - response: - body: - string: "{\n \"added\": \"2016-08-24T06:13:07Z\",\n \"aliases\": [\n \"Ensembl:MT\",\n - \ \"ensembl:MT\",\n \"GRCh37.p10:MT\",\n \"GRCh37.p10:chrM\",\n \"GRCh37.p11:MT\",\n - \ \"GRCh37.p11:chrM\",\n \"GRCh37.p12:MT\",\n \"GRCh37.p12:chrM\",\n - \ \"GRCh37.p13:MT\",\n \"GRCh37.p13:chrM\",\n \"GRCh37.p2:MT\",\n - \ \"GRCh37.p2:chrM\",\n \"GRCh37.p5:MT\",\n \"GRCh37.p5:chrM\",\n - \ \"GRCh37.p9:MT\",\n \"GRCh37.p9:chrM\",\n \"GRCh38:MT\",\n \"GRCh38:chrM\",\n - \ \"GRCh38.p1:MT\",\n \"GRCh38.p1:chrM\",\n \"GRCh38.p10:MT\",\n \"GRCh38.p10:chrM\",\n - \ \"GRCh38.p11:MT\",\n \"GRCh38.p11:chrM\",\n \"GRCh38.p12:MT\",\n - \ \"GRCh38.p12:chrM\",\n \"GRCh38.p2:MT\",\n \"GRCh38.p2:chrM\",\n - \ \"GRCh38.p3:MT\",\n \"GRCh38.p3:chrM\",\n \"GRCh38.p4:MT\",\n \"GRCh38.p4:chrM\",\n - \ \"GRCh38.p5:MT\",\n \"GRCh38.p5:chrM\",\n \"GRCh38.p6:MT\",\n \"GRCh38.p6:chrM\",\n - \ \"GRCh38.p7:MT\",\n \"GRCh38.p7:chrM\",\n \"GRCh38.p8:MT\",\n \"GRCh38.p8:chrM\",\n - \ \"GRCh38.p9:MT\",\n \"GRCh38.p9:chrM\",\n \"MD5:c68f52674c9fb33aef52dcf399755519\",\n - \ \"NCBI:NC_012920.1\",\n \"refseq:NC_012920.1\",\n \"SEGUID:eQNFYXnsCzhp/MkfBUBVnuFZzTA\",\n - \ \"SHA1:7903456179ec0b3869fcc91f0540559ee159cd30\",\n \"VMC:GS_k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n - \ \"sha512t24u:k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n \"ga4gh:SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n - \ \"hs37-1kg:MT\",\n \"hs37d5:MT\"\n ],\n \"alphabet\": \"ACGNT\",\n - \ \"length\": 16569\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml index 1e5e9d6b..9c6d4a31 100644 --- a/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml @@ -22,4 +22,27 @@ interactions: status: code: 200 message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul + response: + body: + string: "{\n \"added\": \"2016-08-27T21:23:35Z\",\n \"aliases\": [\n \"GRCh38:7\",\n + \ \"GRCh38:chr7\",\n \"GRCh38.p1:7\",\n \"GRCh38.p1:chr7\",\n \"GRCh38.p10:7\",\n + \ \"GRCh38.p10:chr7\",\n \"GRCh38.p11:7\",\n \"GRCh38.p11:chr7\",\n + \ \"GRCh38.p12:7\",\n \"GRCh38.p12:chr7\",\n \"GRCh38.p2:7\",\n \"GRCh38.p2:chr7\",\n + \ \"GRCh38.p3:7\",\n \"GRCh38.p3:chr7\",\n \"GRCh38.p4:7\",\n \"GRCh38.p4:chr7\",\n + \ \"GRCh38.p5:7\",\n \"GRCh38.p5:chr7\",\n \"GRCh38.p6:7\",\n \"GRCh38.p6:chr7\",\n + \ \"GRCh38.p7:7\",\n \"GRCh38.p7:chr7\",\n \"GRCh38.p8:7\",\n \"GRCh38.p8:chr7\",\n + \ \"GRCh38.p9:7\",\n \"GRCh38.p9:chr7\",\n \"MD5:cc044cc2256a1141212660fb07b6171e\",\n + \ \"NCBI:NC_000007.14\",\n \"refseq:NC_000007.14\",\n \"SEGUID:4+JjCcBVhPCr8vdIhUKFycPv8bY\",\n + \ \"SHA1:e3e26309c05584f0abf2f748854285c9c3eff1b6\",\n \"VMC:GS_F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n + \ \"sha512t24u:F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n \"ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\"\n + \ ],\n \"alphabet\": \"ACGNRSTY\",\n \"length\": 159345973\n}\n" + headers: {} + status: + code: 200 + message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml deleted file mode 100644 index 8ad112b6..00000000 --- a/tests/extras/cassettes/test_out_of_bounds[hgvs-g-past-end].yaml +++ /dev/null @@ -1,50 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/refseq:NC_000019.10 - response: - body: - string: "{\n \"added\": \"2016-08-24T08:19:02Z\",\n \"aliases\": [\n \"Ensembl:19\",\n - \ \"ensembl:19\",\n \"GRCh38:19\",\n \"GRCh38:chr19\",\n \"GRCh38.p1:19\",\n - \ \"GRCh38.p1:chr19\",\n \"GRCh38.p10:19\",\n \"GRCh38.p10:chr19\",\n - \ \"GRCh38.p11:19\",\n \"GRCh38.p11:chr19\",\n \"GRCh38.p12:19\",\n - \ \"GRCh38.p12:chr19\",\n \"GRCh38.p2:19\",\n \"GRCh38.p2:chr19\",\n - \ \"GRCh38.p3:19\",\n \"GRCh38.p3:chr19\",\n \"GRCh38.p4:19\",\n \"GRCh38.p4:chr19\",\n - \ \"GRCh38.p5:19\",\n \"GRCh38.p5:chr19\",\n \"GRCh38.p6:19\",\n \"GRCh38.p6:chr19\",\n - \ \"GRCh38.p7:19\",\n \"GRCh38.p7:chr19\",\n \"GRCh38.p8:19\",\n \"GRCh38.p8:chr19\",\n - \ \"GRCh38.p9:19\",\n \"GRCh38.p9:chr19\",\n \"MD5:b0eba2c7bb5c953d1e06a508b5e487de\",\n - \ \"NCBI:NC_000019.10\",\n \"refseq:NC_000019.10\",\n \"SEGUID:AHxM5/L8jIX08UhBBkKXkiO5rhY\",\n - \ \"SHA1:007c4ce7f2fc8c85f4f148410642979223b9ae16\",\n \"VMC:GS_IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n - \ \"sha512t24u:IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n \"ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\"\n - \ ],\n \"alphabet\": \"ACGNT\",\n \"length\": 58617616\n}\n" - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl - response: - body: - string: "{\n \"added\": \"2016-08-24T08:19:02Z\",\n \"aliases\": [\n \"Ensembl:19\",\n - \ \"ensembl:19\",\n \"GRCh38:19\",\n \"GRCh38:chr19\",\n \"GRCh38.p1:19\",\n - \ \"GRCh38.p1:chr19\",\n \"GRCh38.p10:19\",\n \"GRCh38.p10:chr19\",\n - \ \"GRCh38.p11:19\",\n \"GRCh38.p11:chr19\",\n \"GRCh38.p12:19\",\n - \ \"GRCh38.p12:chr19\",\n \"GRCh38.p2:19\",\n \"GRCh38.p2:chr19\",\n - \ \"GRCh38.p3:19\",\n \"GRCh38.p3:chr19\",\n \"GRCh38.p4:19\",\n \"GRCh38.p4:chr19\",\n - \ \"GRCh38.p5:19\",\n \"GRCh38.p5:chr19\",\n \"GRCh38.p6:19\",\n \"GRCh38.p6:chr19\",\n - \ \"GRCh38.p7:19\",\n \"GRCh38.p7:chr19\",\n \"GRCh38.p8:19\",\n \"GRCh38.p8:chr19\",\n - \ \"GRCh38.p9:19\",\n \"GRCh38.p9:chr19\",\n \"MD5:b0eba2c7bb5c953d1e06a508b5e487de\",\n - \ \"NCBI:NC_000019.10\",\n \"refseq:NC_000019.10\",\n \"SEGUID:AHxM5/L8jIX08UhBBkKXkiO5rhY\",\n - \ \"SHA1:007c4ce7f2fc8c85f4f148410642979223b9ae16\",\n \"VMC:GS_IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n - \ \"sha512t24u:IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n \"ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\"\n - \ ],\n \"alphabet\": \"ACGNT\",\n \"length\": 58617616\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[vrs-allele-start-past-end-with-start-gt-end].yaml b/tests/extras/cassettes/test_out_of_bounds[vrs-allele-start-past-end-with-start-gt-end].yaml deleted file mode 100644 index 3be97074..00000000 --- a/tests/extras/cassettes/test_out_of_bounds[vrs-allele-start-past-end-with-start-gt-end].yaml +++ /dev/null @@ -1,18 +0,0 @@ -interactions: -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK -version: 1 diff --git a/tests/extras/test_allele_translator.py b/tests/extras/test_allele_translator.py index 15f87a89..bf504280 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -337,6 +337,37 @@ def test_from_invalid(tlr): tlr.translate_from("BRAF amplication", assembly_name="GRCh37") +@pytest.mark.parametrize( + "var", + [ + pytest.param(snv_output, id="allele"), + # VRS input is trusted as-is: not validated against the sequence or normalized + pytest.param( + { + **snv_output, + "location": {**snv_output["location"], "start": 99999999, "end": 5}, + }, + id="allele-out-of-bounds-unvalidated", + ), + ], +) +def test_from_vrs(tlr: AlleleTranslator, var: dict) -> None: + vo = tlr.translate_from(var, fmt="vrs") + assert vo.model_dump(exclude_none=True) == var + + +@pytest.mark.parametrize( + "var", + [ + pytest.param({"type": "Bogus"}, id="unknown-type"), + pytest.param({"location": snv_output["location"]}, id="missing-type"), + ], +) +def test_from_vrs_invalid(tlr: AlleleTranslator, var: dict) -> None: + with pytest.raises(ValueError, match="^Unable to parse data as vrs variation$"): + tlr.translate_from(var, fmt="vrs") + + @pytest.mark.vcr def test_from_beacon(tlr): do_normalize = False diff --git a/tests/extras/test_location_bounds.py b/tests/extras/test_location_bounds.py index 82cc0846..e64632b1 100644 --- a/tests/extras/test_location_bounds.py +++ b/tests/extras/test_location_bounds.py @@ -1,12 +1,11 @@ """Out-of-bounds SequenceLocations are rejected on every translator input path +except ``vrs``, whose input is trusted as-is Sequence lengths used below: NM_000551.3 4560 NP_001346993.1 193 - NC_000019.10 58617616 NC_000007.14 159345973 GRCh38:1 248956422 - NC_012920.1 16569 """ import os @@ -18,10 +17,9 @@ from ga4gh.vrs.extras.translator import AlleleTranslator, CnvTranslator NC_000001_11 = "SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO" -NC_000019_10 = "SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl" +NC_000007_14 = "SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul" NM_000551_3 = "SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_" NP_001346993_1 = "SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer" -NC_012920_1 = "SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct" @pytest.fixture @@ -44,34 +42,6 @@ def cnv_tlr(data_proxy: SeqRepoRESTDataProxy) -> CnvTranslator: return CnvTranslator(data_proxy=data_proxy) -def _vrs_location( - refget_accession: str, - start: int | list[int | None], - end: int | list[int | None], -) -> dict: - return { - "type": "SequenceLocation", - "sequenceReference": { - "type": "SequenceReference", - "refgetAccession": refget_accession, - }, - "start": start, - "end": end, - } - - -def _vrs_allele( - refget_accession: str, - start: int | list[int | None], - end: int | list[int | None], -) -> dict: - return { - "type": "Allele", - "location": _vrs_location(refget_accession, start, end), - "state": {"type": "LiteralSequenceExpression", "sequence": "A"}, - } - - def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: return ( f"Location out of bounds on {sequence_id}: {detail} not within [0, {seq_len}]" @@ -79,14 +49,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: OUT_OF_BOUNDS = [ - pytest.param( - "allele_tlr", - "hgvs", - "NC_000019.10:g.58617617C>T", - {}, - _bounds_msg(f"ga4gh:{NC_000019_10}", "end=58617617", 58617616), - id="hgvs-g-past-end", - ), pytest.param( "allele_tlr", "hgvs", @@ -147,33 +109,20 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: _bounds_msg(f"ga4gh:{NC_000001_11}", "end=248956423", 248956422), id="beacon-past-end", ), - pytest.param( - "allele_tlr", - "vrs", - _vrs_allele(NM_000551_3, 99999999, 5), - {}, - _bounds_msg(f"ga4gh:{NM_000551_3}", "start=99999999", 4560), - id="vrs-allele-start-past-end-with-start-gt-end", - ), pytest.param( "cnv_tlr", "hgvs", "NC_000007.14:g.159400000_159400100del", {}, - _bounds_msg("refseq:NC_000007.14", "start=159399999, end=159400100", 159345973), + _bounds_msg( + f"ga4gh:{NC_000007_14}", "start=159399999, end=159400100", 159345973 + ), id="cnv-hgvs-copy-number-change-past-end", ), ] IN_BOUNDS = [ - pytest.param( - "allele_tlr", - "hgvs", - "NP_001346993.1:p.Leu193del", - {"start": 192, "end": 193}, - id="hgvs-p-terminal-residue", - ), pytest.param( "allele_tlr", "spdi", @@ -181,13 +130,6 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: {"start": 4560, "end": 4560}, id="spdi-insertion-at-end", ), - pytest.param( - "allele_tlr", - "vrs", - _vrs_allele(NC_012920_1, 16566, 5), - {"start": 16566, "end": 5}, - id="vrs-allele-circular-start-gt-end", - ), ] diff --git a/tests/test_vrs_normalize.py b/tests/test_vrs_normalize.py index 2542d287..0a30792b 100644 --- a/tests/test_vrs_normalize.py +++ b/tests/test_vrs_normalize.py @@ -3,7 +3,7 @@ import pytest from ga4gh.vrs import models, normalize -from ga4gh.vrs.dataproxy import DataProxyValidationError +from ga4gh.vrs.dataproxy import DataProxyValidationError, SeqRepoDataProxy # Single nucleotide same-as-reference allele. allele_dict1 = { @@ -971,7 +971,7 @@ def _bounds_allele( ], ) def test_normalize_location_in_bounds( - dataproxy, + dataproxy: SeqRepoDataProxy, start: int | list[int | None], end: int | list[int | None], sequence: str, @@ -994,7 +994,7 @@ def test_normalize_location_in_bounds( ], ) def test_normalize_location_out_of_bounds( - dataproxy, + dataproxy: SeqRepoDataProxy, start: int | list[int | None], end: int | list[int | None], detail: str, From e3790a434b46e3e3f88b10f3d2575d2dca84da5e Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Wed, 30 Sep 2026 23:42:31 -0400 Subject: [PATCH 09/11] cleanup: name both input and refget ids in bounds errors Out-of-bounds errors now name the sequence as given (e.g. GRCh38:1) followed by the refget accession it resolved to, taken from the aliases in the same metadata lookup. Pass the input accession from every translator path again so errors are consistent across formats, and so the length lookup reuses the cached input-id metadata. This restores test_from_beacon.yaml to its original form; bounds cassettes are re-recorded. --- src/ga4gh/vrs/dataproxy.py | 10 +++- src/ga4gh/vrs/extras/translator.py | 11 ++-- src/ga4gh/vrs/utils/hgvs_tools.py | 1 + tests/extras/cassettes/test_from_beacon.yaml | 53 ---------------- .../test_out_of_bounds[beacon-past-end].yaml | 23 ------- ...cnv-hgvs-copy-number-change-past-end].yaml | 23 ------- ..._of_bounds[hgvs-n-insertion-past-end].yaml | 16 ----- ...bounds[hgvs-p-ter-at-length-plus-one].yaml | 24 -------- ...ut_of_bounds[spdi-insertion-past-end].yaml | 16 ----- tests/extras/test_location_bounds.py | 26 ++++---- tests/test_dataproxy.py | 60 ++++++++++++++++--- 11 files changed, 83 insertions(+), 180 deletions(-) diff --git a/src/ga4gh/vrs/dataproxy.py b/src/ga4gh/vrs/dataproxy.py index db6a456c..bb23e039 100644 --- a/src/ga4gh/vrs/dataproxy.py +++ b/src/ga4gh/vrs/dataproxy.py @@ -209,7 +209,8 @@ def validate_location_bounds( """ # same cache key as derive_refget_accession sequence_id = coerce_namespace(sequence_id) - seq_len = self.get_metadata(sequence_id)["length"] + md = self.get_metadata(sequence_id) + seq_len = md["length"] bad = [] for name, pos in (("start", start_pos), ("end", end_pos)): values = pos.root if isinstance(pos, Range) else [pos] @@ -221,8 +222,13 @@ def validate_location_bounds( f"{name}={pos.root if isinstance(pos, Range) else pos}" for name, pos in bad ) + # Name the sequence as given, plus the refget accession it resolved to + refget_ac = next((a for a in md["aliases"] if a.startswith("ga4gh:")), None) + seq_name = sequence_id + if refget_ac and refget_ac != sequence_id: + seq_name += f" ({refget_ac})" err_msg = ( - f"Location out of bounds on {sequence_id}: {detail} " + f"Location out of bounds on {seq_name}: {detail} " f"not within [0, {seq_len}]" ) raise DataProxyValidationError(err_msg) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 5434f25f..a1b2975b 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -203,6 +203,8 @@ def _create_allele(self, values: dict, **kwargs) -> models.Allele: Args: values (dict): The values to use for creating the allele object. + 'sequence_id' (str): The sequence identifier from the input + expression, used to validate `start` and `end`. 'refget_accession' (str): The accession ID of the reference genome. 'start' (int): The start position of the allele. 'end' (int): The end position of the allele. @@ -214,7 +216,7 @@ def _create_allele(self, values: dict, **kwargs) -> models.Allele: """ self.data_proxy.validate_location_bounds( - f"ga4gh:{values['refget_accession']}", values["start"], values["end"] + values["sequence_id"], values["start"], values["end"] ) seq_ref = models.SequenceReference(refgetAccession=values["refget_accession"]) location = models.SequenceLocation( @@ -279,6 +281,7 @@ def _from_beacon(self, beacon_expr: str, **kwargs) -> models.Allele | None: ins_seq = alt values = { + "sequence_id": sequence, "refget_accession": refget_accession, "start": start, "end": end, @@ -355,6 +358,7 @@ def _from_gnomad(self, gnomad_expr: str, **kwargs) -> models.Allele | None: ) values = { + "sequence_id": sequence, "refget_accession": refget_accession, "start": start, "end": end, @@ -420,6 +424,7 @@ def _from_spdi(self, spdi_expr: str, **kwargs) -> models.Allele | None: ins_seq = g["ins_seq"] values = { + "sequence_id": g["ac"], "refget_accession": refget_accession, "start": start, "end": end, @@ -578,9 +583,7 @@ def _from_hgvs( start = sv.posedit.pos.start.base - 1 end = sv.posedit.pos.end.base - self.data_proxy.validate_location_bounds( - f"ga4gh:{refget_accession}", start, end - ) + self.data_proxy.validate_location_bounds(sv.ac, start, end) location = models.SequenceLocation( sequenceReference=models.SequenceReference( diff --git a/src/ga4gh/vrs/utils/hgvs_tools.py b/src/ga4gh/vrs/utils/hgvs_tools.py index 503d4022..5ff3f38f 100644 --- a/src/ga4gh/vrs/utils/hgvs_tools.py +++ b/src/ga4gh/vrs/utils/hgvs_tools.py @@ -182,6 +182,7 @@ def extract_allele_values(self, hgvs_expr: str) -> dict | None: (start, end, state) = self.get_position_and_state(sv) return { + "sequence_id": sv.ac, "refget_accession": refget_accession, "start": start, "end": end, diff --git a/tests/extras/cassettes/test_from_beacon.yaml b/tests/extras/cassettes/test_from_beacon.yaml index c485e8e4..e04892cc 100644 --- a/tests/extras/cassettes/test_from_beacon.yaml +++ b/tests/extras/cassettes/test_from_beacon.yaml @@ -52,57 +52,4 @@ interactions: status: code: 200 message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl - response: - body: - string: "{\n \"added\": \"2016-08-24T08:19:02Z\",\n \"aliases\": [\n \"Ensembl:19\",\n - \ \"ensembl:19\",\n \"GRCh38:19\",\n \"GRCh38:chr19\",\n \"GRCh38.p1:19\",\n - \ \"GRCh38.p1:chr19\",\n \"GRCh38.p10:19\",\n \"GRCh38.p10:chr19\",\n - \ \"GRCh38.p11:19\",\n \"GRCh38.p11:chr19\",\n \"GRCh38.p12:19\",\n - \ \"GRCh38.p12:chr19\",\n \"GRCh38.p2:19\",\n \"GRCh38.p2:chr19\",\n - \ \"GRCh38.p3:19\",\n \"GRCh38.p3:chr19\",\n \"GRCh38.p4:19\",\n \"GRCh38.p4:chr19\",\n - \ \"GRCh38.p5:19\",\n \"GRCh38.p5:chr19\",\n \"GRCh38.p6:19\",\n \"GRCh38.p6:chr19\",\n - \ \"GRCh38.p7:19\",\n \"GRCh38.p7:chr19\",\n \"GRCh38.p8:19\",\n \"GRCh38.p8:chr19\",\n - \ \"GRCh38.p9:19\",\n \"GRCh38.p9:chr19\",\n \"MD5:b0eba2c7bb5c953d1e06a508b5e487de\",\n - \ \"NCBI:NC_000019.10\",\n \"refseq:NC_000019.10\",\n \"SEGUID:AHxM5/L8jIX08UhBBkKXkiO5rhY\",\n - \ \"SHA1:007c4ce7f2fc8c85f4f148410642979223b9ae16\",\n \"VMC:GS_IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n - \ \"sha512t24u:IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\",\n \"ga4gh:SQ.IIB53T8CNeJJdUqzn9V_JnRtQadwWCbl\"\n - \ ],\n \"alphabet\": \"ACGNT\",\n \"length\": 58617616\n}\n" - headers: {} - status: - code: 200 - message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct - response: - body: - string: "{\n \"added\": \"2016-08-24T06:13:07Z\",\n \"aliases\": [\n \"Ensembl:MT\",\n - \ \"ensembl:MT\",\n \"GRCh37.p10:MT\",\n \"GRCh37.p10:chrM\",\n \"GRCh37.p11:MT\",\n - \ \"GRCh37.p11:chrM\",\n \"GRCh37.p12:MT\",\n \"GRCh37.p12:chrM\",\n - \ \"GRCh37.p13:MT\",\n \"GRCh37.p13:chrM\",\n \"GRCh37.p2:MT\",\n - \ \"GRCh37.p2:chrM\",\n \"GRCh37.p5:MT\",\n \"GRCh37.p5:chrM\",\n - \ \"GRCh37.p9:MT\",\n \"GRCh37.p9:chrM\",\n \"GRCh38:MT\",\n \"GRCh38:chrM\",\n - \ \"GRCh38.p1:MT\",\n \"GRCh38.p1:chrM\",\n \"GRCh38.p10:MT\",\n \"GRCh38.p10:chrM\",\n - \ \"GRCh38.p11:MT\",\n \"GRCh38.p11:chrM\",\n \"GRCh38.p12:MT\",\n - \ \"GRCh38.p12:chrM\",\n \"GRCh38.p2:MT\",\n \"GRCh38.p2:chrM\",\n - \ \"GRCh38.p3:MT\",\n \"GRCh38.p3:chrM\",\n \"GRCh38.p4:MT\",\n \"GRCh38.p4:chrM\",\n - \ \"GRCh38.p5:MT\",\n \"GRCh38.p5:chrM\",\n \"GRCh38.p6:MT\",\n \"GRCh38.p6:chrM\",\n - \ \"GRCh38.p7:MT\",\n \"GRCh38.p7:chrM\",\n \"GRCh38.p8:MT\",\n \"GRCh38.p8:chrM\",\n - \ \"GRCh38.p9:MT\",\n \"GRCh38.p9:chrM\",\n \"MD5:c68f52674c9fb33aef52dcf399755519\",\n - \ \"NCBI:NC_012920.1\",\n \"refseq:NC_012920.1\",\n \"SEGUID:eQNFYXnsCzhp/MkfBUBVnuFZzTA\",\n - \ \"SHA1:7903456179ec0b3869fcc91f0540559ee159cd30\",\n \"VMC:GS_k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n - \ \"sha512t24u:k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n \"ga4gh:SQ.k3grVkjY-hoWcCUojHw6VU6GE3MZ8Sct\",\n - \ \"hs37-1kg:MT\",\n \"hs37d5:MT\"\n ],\n \"alphabet\": \"ACGNT\",\n - \ \"length\": 16569\n}\n" - headers: {} - status: - code: 200 - message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml index b5467b90..2c11ee79 100644 --- a/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[beacon-past-end].yaml @@ -22,27 +22,4 @@ interactions: status: code: 200 message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO - response: - body: - string: "{\n \"added\": \"2016-08-27T21:17:00Z\",\n \"aliases\": [\n \"GRCh38:1\",\n - \ \"GRCh38:chr1\",\n \"GRCh38.p1:1\",\n \"GRCh38.p1:chr1\",\n \"GRCh38.p10:1\",\n - \ \"GRCh38.p10:chr1\",\n \"GRCh38.p11:1\",\n \"GRCh38.p11:chr1\",\n - \ \"GRCh38.p12:1\",\n \"GRCh38.p12:chr1\",\n \"GRCh38.p2:1\",\n \"GRCh38.p2:chr1\",\n - \ \"GRCh38.p3:1\",\n \"GRCh38.p3:chr1\",\n \"GRCh38.p4:1\",\n \"GRCh38.p4:chr1\",\n - \ \"GRCh38.p5:1\",\n \"GRCh38.p5:chr1\",\n \"GRCh38.p6:1\",\n \"GRCh38.p6:chr1\",\n - \ \"GRCh38.p7:1\",\n \"GRCh38.p7:chr1\",\n \"GRCh38.p8:1\",\n \"GRCh38.p8:chr1\",\n - \ \"GRCh38.p9:1\",\n \"GRCh38.p9:chr1\",\n \"MD5:6aef897c3d6ff0c78aff06ac189178dd\",\n - \ \"NCBI:NC_000001.11\",\n \"refseq:NC_000001.11\",\n \"SEGUID:FCUd6VJ6uikS/VWLbhGdVmj2rOA\",\n - \ \"SHA1:14251de9527aba2912fd558b6e119d5668f6ace0\",\n \"VMC:GS_Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n - \ \"sha512t24u:Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\",\n \"ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO\"\n - \ ],\n \"alphabet\": \"ACGMNRT\",\n \"length\": 248956422\n}\n" - headers: {} - status: - code: 200 - message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml index 9c6d4a31..1e5e9d6b 100644 --- a/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[cnv-hgvs-copy-number-change-past-end].yaml @@ -22,27 +22,4 @@ interactions: status: code: 200 message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul - response: - body: - string: "{\n \"added\": \"2016-08-27T21:23:35Z\",\n \"aliases\": [\n \"GRCh38:7\",\n - \ \"GRCh38:chr7\",\n \"GRCh38.p1:7\",\n \"GRCh38.p1:chr7\",\n \"GRCh38.p10:7\",\n - \ \"GRCh38.p10:chr7\",\n \"GRCh38.p11:7\",\n \"GRCh38.p11:chr7\",\n - \ \"GRCh38.p12:7\",\n \"GRCh38.p12:chr7\",\n \"GRCh38.p2:7\",\n \"GRCh38.p2:chr7\",\n - \ \"GRCh38.p3:7\",\n \"GRCh38.p3:chr7\",\n \"GRCh38.p4:7\",\n \"GRCh38.p4:chr7\",\n - \ \"GRCh38.p5:7\",\n \"GRCh38.p5:chr7\",\n \"GRCh38.p6:7\",\n \"GRCh38.p6:chr7\",\n - \ \"GRCh38.p7:7\",\n \"GRCh38.p7:chr7\",\n \"GRCh38.p8:7\",\n \"GRCh38.p8:chr7\",\n - \ \"GRCh38.p9:7\",\n \"GRCh38.p9:chr7\",\n \"MD5:cc044cc2256a1141212660fb07b6171e\",\n - \ \"NCBI:NC_000007.14\",\n \"refseq:NC_000007.14\",\n \"SEGUID:4+JjCcBVhPCr8vdIhUKFycPv8bY\",\n - \ \"SHA1:e3e26309c05584f0abf2f748854285c9c3eff1b6\",\n \"VMC:GS_F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n - \ \"sha512t24u:F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\",\n \"ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul\"\n - \ ],\n \"alphabet\": \"ACGNRSTY\",\n \"length\": 159345973\n}\n" - headers: {} - status: - code: 200 - message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml index 3bc0bd3d..ab28a89d 100644 --- a/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-n-insertion-past-end].yaml @@ -15,20 +15,4 @@ interactions: status: code: 200 message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml b/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml index 8515159f..dba45e9a 100644 --- a/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[hgvs-p-ter-at-length-plus-one].yaml @@ -23,28 +23,4 @@ interactions: status: code: 200 message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer - response: - body: - string: "{\n \"added\": \"2016-08-24T04:59:23Z\",\n \"aliases\": [\n \"Ensembl:ENSP00000480268.1\",\n - \ \"ensembl:ENSP00000480268.1\",\n \"Ensembl:ENSP00000491180.1\",\n \"ensembl:ENSP00000491180.1\",\n - \ \"Ensembl:ENSP00000491338.1\",\n \"ensembl:ENSP00000491338.1\",\n \"Ensembl:ENSP00000491353.1\",\n - \ \"ensembl:ENSP00000491353.1\",\n \"Ensembl:ENSP00000492701.1\",\n \"ensembl:ENSP00000492701.1\",\n - \ \"Ensembl:ENSP00000498790.1\",\n \"ensembl:ENSP00000498790.1\",\n \"MD5:fecf2eee2cdc50588a641e472e062be1\",\n - \ \"NCBI:NP_001346993.1\",\n \"refseq:NP_001346993.1\",\n \"NCBI:NP_001347000.1\",\n - \ \"refseq:NP_001347000.1\",\n \"NCBI:NP_001355060.1\",\n \"refseq:NP_001355060.1\",\n - \ \"NCBI:XP_011534418.1\",\n \"refseq:XP_011534418.1\",\n \"NCBI:XP_024302319.1\",\n - \ \"refseq:XP_024302319.1\",\n \"NCBI:XP_054212402.1\",\n \"refseq:XP_054212402.1\",\n - \ \"NCBI:XP_054212403.1\",\n \"refseq:XP_054212403.1\",\n \"SEGUID:8Lnknw+hAAOAjiLGHus4bfyDy0k\",\n - \ \"SHA1:f0b9e49f0fa10003808e22c61eeb386dfc83cb49\",\n \"VMC:GS_IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n - \ \"sha512t24u:IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\",\n \"ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer\"\n - \ ],\n \"alphabet\": \"ACDEFGHIKLMNPQRSTVWY\",\n \"length\": 193\n}\n" - headers: {} - status: - code: 200 - message: OK version: 1 diff --git a/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml b/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml index 3bc0bd3d..ab28a89d 100644 --- a/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml +++ b/tests/extras/cassettes/test_out_of_bounds[spdi-insertion-past-end].yaml @@ -15,20 +15,4 @@ interactions: status: code: 200 message: OK -- request: - body: null - headers: {} - method: GET - uri: http://localhost:5000/seqrepo/1/metadata/ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_ - response: - body: - string: "{\n \"added\": \"2016-08-24T05:03:11Z\",\n \"aliases\": [\n \"MD5:215137b1973c1a5afcf86be7d999574a\",\n - \ \"NCBI:NM_000551.3\",\n \"refseq:NM_000551.3\",\n \"SEGUID:T12L0p2X5E8DbnL0+SwI4Wc1S6g\",\n - \ \"SHA1:4f5d8bd29d97e44f036e72f4f92c08e167354ba8\",\n \"VMC:GS_v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n - \ \"sha512t24u:v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\",\n \"ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_\"\n - \ ],\n \"alphabet\": \"ACGT\",\n \"length\": 4560\n}\n" - headers: {} - status: - code: 200 - message: OK version: 1 diff --git a/tests/extras/test_location_bounds.py b/tests/extras/test_location_bounds.py index e64632b1..c7147593 100644 --- a/tests/extras/test_location_bounds.py +++ b/tests/extras/test_location_bounds.py @@ -16,10 +16,13 @@ from ga4gh.vrs.dataproxy import DataProxyValidationError, SeqRepoRESTDataProxy from ga4gh.vrs.extras.translator import AlleleTranslator, CnvTranslator -NC_000001_11 = "SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO" -NC_000007_14 = "SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul" -NM_000551_3 = "SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_" -NP_001346993_1 = "SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer" +# Refget accession each input sequence resolves to, named alongside it in errors +REFGET_ACCESSIONS = { + "GRCh38:1": "ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO", + "refseq:NC_000007.14": "ga4gh:SQ.F-LrLMe1SRpfUZHkQmvkVKFEGaoDeHul", + "refseq:NM_000551.3": "ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_", + "refseq:NP_001346993.1": "ga4gh:SQ.IPAWzkahAXVA3fBdoFluaU4NA3xTYUer", +} @pytest.fixture @@ -44,7 +47,8 @@ def cnv_tlr(data_proxy: SeqRepoRESTDataProxy) -> CnvTranslator: def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: return ( - f"Location out of bounds on {sequence_id}: {detail} not within [0, {seq_len}]" + f"Location out of bounds on {sequence_id} ({REFGET_ACCESSIONS[sequence_id]}): " + f"{detail} not within [0, {seq_len}]" ) @@ -54,7 +58,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "hgvs", "NM_000551.3:n.4561_4562insA", {}, - _bounds_msg(f"ga4gh:{NM_000551_3}", "start=4561, end=4561", 4560), + _bounds_msg("refseq:NM_000551.3", "start=4561, end=4561", 4560), id="hgvs-n-insertion-past-end", ), # ClinVar references the stop codon, which is not part of the protein sequence @@ -63,7 +67,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "hgvs", "NP_001346993.1:p.Ter194del", {}, - _bounds_msg(f"ga4gh:{NP_001346993_1}", "end=194", 193), + _bounds_msg("refseq:NP_001346993.1", "end=194", 193), id="hgvs-p-ter-at-length-plus-one", ), # Zero-width: an out-of-range fetch returns "" and would compare equal to the @@ -73,7 +77,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "spdi", "NM_000551.3:5000:0:AAA", {}, - _bounds_msg(f"ga4gh:{NM_000551_3}", "start=5000, end=5000", 4560), + _bounds_msg("refseq:NM_000551.3", "start=5000, end=5000", 4560), id="spdi-insertion-past-end", ), # Must report the bounds error, not "Reference mismatch ... correct ref is ''" @@ -106,7 +110,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "beacon", "1 : 248956423 A > T", {}, - _bounds_msg(f"ga4gh:{NC_000001_11}", "end=248956423", 248956422), + _bounds_msg("GRCh38:1", "end=248956423", 248956422), id="beacon-past-end", ), pytest.param( @@ -114,9 +118,7 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: "hgvs", "NC_000007.14:g.159400000_159400100del", {}, - _bounds_msg( - f"ga4gh:{NC_000007_14}", "start=159399999, end=159400100", 159345973 - ), + _bounds_msg("refseq:NC_000007.14", "start=159399999, end=159400100", 159345973), id="cnv-hgvs-copy-number-change-past-end", ), ] diff --git a/tests/test_dataproxy.py b/tests/test_dataproxy.py index 8d50f9d3..14feebd7 100644 --- a/tests/test_dataproxy.py +++ b/tests/test_dataproxy.py @@ -73,10 +73,10 @@ def test_data_proxy_configs(): class _StubDataProxy(_DataProxy): - """Dataproxy serving only sequence lengths""" + """Dataproxy serving only metadata, keyed by identifier""" - def __init__(self, lengths: dict[str, int]) -> None: - self.lengths = lengths + def __init__(self, metadata: dict[str, dict]) -> None: + self.metadata = metadata def get_sequence( self, identifier: str, start: int | None = None, end: int | None = None @@ -84,12 +84,20 @@ def get_sequence( raise NotImplementedError def get_metadata(self, identifier: str) -> dict: - return {"length": self.lengths[identifier], "aliases": []} + return self.metadata[identifier] BOUNDS_SEQ_ID = "refseq:NM_000551.3" +BOUNDS_REFGET_ID = "ga4gh:SQ.v_QTc1p-MUYdgrRv4LMT6ByXIOsdw3C_" BOUNDS_SEQ_LEN = 4560 + +def _bounds_dp(aliases: list[str]) -> _StubDataProxy: + """Stub serving one sequence's metadata under each of its aliases""" + md = {"length": BOUNDS_SEQ_LEN, "aliases": aliases} + return _StubDataProxy(dict.fromkeys(aliases, md)) + + # (start, end) that are representable on a sequence of length BOUNDS_SEQ_LEN LOCATION_BOUNDS_VALID = [ pytest.param(10, 20, id="interior"), @@ -134,7 +142,7 @@ def test_validate_location_bounds_valid( start: int | models.Range | None, end: int | models.Range | None, ) -> None: - dp = _StubDataProxy({BOUNDS_SEQ_ID: BOUNDS_SEQ_LEN}) + dp = _bounds_dp([BOUNDS_SEQ_ID, BOUNDS_REFGET_ID]) dp.validate_location_bounds(BOUNDS_SEQ_ID, start, end) @@ -144,10 +152,48 @@ def test_validate_location_bounds_invalid( end: int | models.Range | None, detail: str, ) -> None: - dp = _StubDataProxy({BOUNDS_SEQ_ID: BOUNDS_SEQ_LEN}) + dp = _bounds_dp([BOUNDS_SEQ_ID, BOUNDS_REFGET_ID]) expected_msg = ( - f"Location out of bounds on {BOUNDS_SEQ_ID}: {detail} " + f"Location out of bounds on {BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID}): {detail} " f"not within [0, {BOUNDS_SEQ_LEN}]" ) with pytest.raises(DataProxyValidationError, match=f"^{re.escape(expected_msg)}$"): dp.validate_location_bounds(BOUNDS_SEQ_ID, start, end) + + +# (sequence_id as given, aliases of the sequence, name used in the error message) +LOCATION_BOUNDS_SEQUENCE_NAMES = [ + pytest.param( + BOUNDS_SEQ_ID, + [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], + f"{BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID})", + id="input-and-refget", + ), + pytest.param( + "NM_000551.3", + [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], + f"{BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID})", + id="bare-accession-coerced", + ), + pytest.param( + BOUNDS_REFGET_ID, + [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], + BOUNDS_REFGET_ID, + id="refget-input-named-once", + ), + pytest.param(BOUNDS_SEQ_ID, [BOUNDS_SEQ_ID], BOUNDS_SEQ_ID, id="no-refget-alias"), +] + + +@pytest.mark.parametrize( + ("sequence_id", "aliases", "seq_name"), LOCATION_BOUNDS_SEQUENCE_NAMES +) +def test_validate_location_bounds_sequence_name( + sequence_id: str, aliases: list[str], seq_name: str +) -> None: + dp = _bounds_dp(aliases) + expected_prefix = f"Location out of bounds on {seq_name}: " + with pytest.raises( + DataProxyValidationError, match=f"^{re.escape(expected_prefix)}" + ): + dp.validate_location_bounds(sequence_id, 0, BOUNDS_SEQ_LEN + 1) From 31ba35ef41f71e3ffdc482efd0e823d5ba3e6573 Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Wed, 30 Sep 2026 23:53:34 -0400 Subject: [PATCH 10/11] cleanup: use dict test cases to match existing parametrize style --- tests/extras/test_allele_translator.py | 52 +++---- tests/extras/test_location_bounds.py | 178 +++++++++++------------- tests/test_dataproxy.py | 182 ++++++++++++++----------- tests/test_vrs_normalize.py | 76 +++++------ 4 files changed, 247 insertions(+), 241 deletions(-) diff --git a/tests/extras/test_allele_translator.py b/tests/extras/test_allele_translator.py index bf504280..09a58797 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -337,35 +337,35 @@ def test_from_invalid(tlr): tlr.translate_from("BRAF amplication", assembly_name="GRCh37") -@pytest.mark.parametrize( - "var", - [ - pytest.param(snv_output, id="allele"), - # VRS input is trusted as-is: not validated against the sequence or normalized - pytest.param( - { - **snv_output, - "location": {**snv_output["location"], "start": 99999999, "end": 5}, - }, - id="allele-out-of-bounds-unvalidated", - ), - ], -) -def test_from_vrs(tlr: AlleleTranslator, var: dict) -> None: - vo = tlr.translate_from(var, fmt="vrs") - assert vo.model_dump(exclude_none=True) == var +from_vrs_cases = [ + {"id": "allele", "var": snv_output}, + # VRS input is trusted as-is: not validated against the sequence or normalized + { + "id": "allele-out-of-bounds-unvalidated", + "var": { + **snv_output, + "location": {**snv_output["location"], "start": 99999999, "end": 5}, + }, + }, +] -@pytest.mark.parametrize( - "var", - [ - pytest.param({"type": "Bogus"}, id="unknown-type"), - pytest.param({"location": snv_output["location"]}, id="missing-type"), - ], -) -def test_from_vrs_invalid(tlr: AlleleTranslator, var: dict) -> None: +@pytest.mark.parametrize("case", from_vrs_cases, ids=lambda c: c["id"]) +def test_from_vrs(tlr: AlleleTranslator, case: dict) -> None: + vo = tlr.translate_from(case["var"], fmt="vrs") + assert vo.model_dump(exclude_none=True) == case["var"] + + +from_vrs_invalid_cases = [ + {"id": "unknown-type", "var": {"type": "Bogus"}}, + {"id": "missing-type", "var": {"location": snv_output["location"]}}, +] + + +@pytest.mark.parametrize("case", from_vrs_invalid_cases, ids=lambda c: c["id"]) +def test_from_vrs_invalid(tlr: AlleleTranslator, case: dict) -> None: with pytest.raises(ValueError, match="^Unable to parse data as vrs variation$"): - tlr.translate_from(var, fmt="vrs") + tlr.translate_from(case["var"], fmt="vrs") @pytest.mark.vcr diff --git a/tests/extras/test_location_bounds.py b/tests/extras/test_location_bounds.py index c7147593..d3e88e56 100644 --- a/tests/extras/test_location_bounds.py +++ b/tests/extras/test_location_bounds.py @@ -52,114 +52,96 @@ def _bounds_msg(sequence_id: str, detail: str, seq_len: int) -> str: ) -OUT_OF_BOUNDS = [ - pytest.param( - "allele_tlr", - "hgvs", - "NM_000551.3:n.4561_4562insA", - {}, - _bounds_msg("refseq:NM_000551.3", "start=4561, end=4561", 4560), - id="hgvs-n-insertion-past-end", - ), +out_of_bounds_cases = [ + { + "id": "hgvs-n-insertion-past-end", + "tlr_fixture": "allele_tlr", + "fmt": "hgvs", + "var": "NM_000551.3:n.4561_4562insA", + "msg": _bounds_msg("refseq:NM_000551.3", "start=4561, end=4561", 4560), + }, # ClinVar references the stop codon, which is not part of the protein sequence - pytest.param( - "allele_tlr", - "hgvs", - "NP_001346993.1:p.Ter194del", - {}, - _bounds_msg("refseq:NP_001346993.1", "end=194", 193), - id="hgvs-p-ter-at-length-plus-one", - ), + { + "id": "hgvs-p-ter-at-length-plus-one", + "tlr_fixture": "allele_tlr", + "fmt": "hgvs", + "var": "NP_001346993.1:p.Ter194del", + "msg": _bounds_msg("refseq:NP_001346993.1", "end=194", 193), + }, # Zero-width: an out-of-range fetch returns "" and would compare equal to the # empty reference, so only a coordinate check can catch this - pytest.param( - "allele_tlr", - "spdi", - "NM_000551.3:5000:0:AAA", - {}, - _bounds_msg("refseq:NM_000551.3", "start=5000, end=5000", 4560), - id="spdi-insertion-past-end", - ), + { + "id": "spdi-insertion-past-end", + "tlr_fixture": "allele_tlr", + "fmt": "spdi", + "var": "NM_000551.3:5000:0:AAA", + "msg": _bounds_msg("refseq:NM_000551.3", "start=5000, end=5000", 4560), + }, # Must report the bounds error, not "Reference mismatch ... correct ref is ''" - pytest.param( - "allele_tlr", - "gnomad", - "1-248956423-A-T", - {}, - _bounds_msg("GRCh38:1", "end=248956423", 248956422), - id="gnomad-past-end", - ), - pytest.param( - "allele_tlr", - "gnomad", - "1-248956423-A-T", - {"require_validation": False}, - _bounds_msg("GRCh38:1", "end=248956423", 248956422), - id="gnomad-past-end-no-require-validation", - ), - pytest.param( - "allele_tlr", - "gnomad", - "1-0-A-T", - {}, - _bounds_msg("GRCh38:1", "start=-1", 248956422), - id="gnomad-negative-start", - ), - pytest.param( - "allele_tlr", - "beacon", - "1 : 248956423 A > T", - {}, - _bounds_msg("GRCh38:1", "end=248956423", 248956422), - id="beacon-past-end", - ), - pytest.param( - "cnv_tlr", - "hgvs", - "NC_000007.14:g.159400000_159400100del", - {}, - _bounds_msg("refseq:NC_000007.14", "start=159399999, end=159400100", 159345973), - id="cnv-hgvs-copy-number-change-past-end", - ), + { + "id": "gnomad-past-end", + "tlr_fixture": "allele_tlr", + "fmt": "gnomad", + "var": "1-248956423-A-T", + "msg": _bounds_msg("GRCh38:1", "end=248956423", 248956422), + }, + { + "id": "gnomad-past-end-no-require-validation", + "tlr_fixture": "allele_tlr", + "fmt": "gnomad", + "var": "1-248956423-A-T", + "kwargs": {"require_validation": False}, + "msg": _bounds_msg("GRCh38:1", "end=248956423", 248956422), + }, + { + "id": "gnomad-negative-start", + "tlr_fixture": "allele_tlr", + "fmt": "gnomad", + "var": "1-0-A-T", + "msg": _bounds_msg("GRCh38:1", "start=-1", 248956422), + }, + { + "id": "beacon-past-end", + "tlr_fixture": "allele_tlr", + "fmt": "beacon", + "var": "1 : 248956423 A > T", + "msg": _bounds_msg("GRCh38:1", "end=248956423", 248956422), + }, + { + "id": "cnv-hgvs-copy-number-change-past-end", + "tlr_fixture": "cnv_tlr", + "fmt": "hgvs", + "var": "NC_000007.14:g.159400000_159400100del", + "msg": _bounds_msg( + "refseq:NC_000007.14", "start=159399999, end=159400100", 159345973 + ), + }, ] -IN_BOUNDS = [ - pytest.param( - "allele_tlr", - "spdi", - "NM_000551.3:4560:0:AAA", - {"start": 4560, "end": 4560}, - id="spdi-insertion-at-end", - ), +in_bounds_cases = [ + { + "id": "spdi-insertion-at-end", + "tlr_fixture": "allele_tlr", + "fmt": "spdi", + "var": "NM_000551.3:4560:0:AAA", + "expected_location": {"start": 4560, "end": 4560}, + }, ] -@pytest.mark.parametrize(("tlr_fixture", "fmt", "var", "kwargs", "msg"), OUT_OF_BOUNDS) +@pytest.mark.parametrize("case", out_of_bounds_cases, ids=lambda c: c["id"]) @pytest.mark.vcr -def test_out_of_bounds( - request: pytest.FixtureRequest, - tlr_fixture: str, - fmt: str, - var: str | dict, - kwargs: dict, - msg: str, -) -> None: - tlr = request.getfixturevalue(tlr_fixture) - with pytest.raises(DataProxyValidationError, match=f"^{re.escape(msg)}$"): - tlr.translate_from(var, fmt=fmt, **kwargs) - - -@pytest.mark.parametrize(("tlr_fixture", "fmt", "var", "expected_location"), IN_BOUNDS) +def test_out_of_bounds(request: pytest.FixtureRequest, case: dict) -> None: + tlr = request.getfixturevalue(case["tlr_fixture"]) + with pytest.raises(DataProxyValidationError, match=f"^{re.escape(case['msg'])}$"): + tlr.translate_from(case["var"], fmt=case["fmt"], **case.get("kwargs", {})) + + +@pytest.mark.parametrize("case", in_bounds_cases, ids=lambda c: c["id"]) @pytest.mark.vcr -def test_in_bounds( - request: pytest.FixtureRequest, - tlr_fixture: str, - fmt: str, - var: str | dict, - expected_location: dict, -) -> None: - tlr = request.getfixturevalue(tlr_fixture) - vo = tlr.translate_from(var, fmt=fmt) - location = vo.location.model_dump() +def test_in_bounds(request: pytest.FixtureRequest, case: dict) -> None: + tlr = request.getfixturevalue(case["tlr_fixture"]) + location = tlr.translate_from(case["var"], fmt=case["fmt"]).location.model_dump() + expected_location = case["expected_location"] assert {k: location[k] for k in expected_location} == expected_location diff --git a/tests/test_dataproxy.py b/tests/test_dataproxy.py index 14feebd7..4752424a 100644 --- a/tests/test_dataproxy.py +++ b/tests/test_dataproxy.py @@ -98,102 +98,126 @@ def _bounds_dp(aliases: list[str]) -> _StubDataProxy: return _StubDataProxy(dict.fromkeys(aliases, md)) -# (start, end) that are representable on a sequence of length BOUNDS_SEQ_LEN -LOCATION_BOUNDS_VALID = [ - pytest.param(10, 20, id="interior"), - pytest.param(0, 0, id="zero-width-at-start"), - pytest.param(4559, 4560, id="terminal-residue"), - pytest.param(4560, 4560, id="insertion-at-end"), - pytest.param(4000, 5, id="circular-start-gt-end"), - pytest.param(4400, models.Range([4500, None]), id="indefinite-end-open-upper"), - pytest.param(models.Range([None, 10]), 20, id="indefinite-start-open-lower"), - pytest.param( - models.Range([0, 10]), models.Range([4500, 4560]), id="definite-ranges" - ), - pytest.param(None, 10, id="start-undefined"), - pytest.param(10, None, id="end-undefined"), - pytest.param(None, None, id="both-undefined"), +# Locations representable on a sequence of length BOUNDS_SEQ_LEN +location_bounds_valid_cases = [ + {"id": "interior", "start": 10, "end": 20}, + {"id": "zero-width-at-start", "start": 0, "end": 0}, + {"id": "terminal-residue", "start": 4559, "end": 4560}, + {"id": "insertion-at-end", "start": 4560, "end": 4560}, + {"id": "circular-start-gt-end", "start": 4000, "end": 5}, + { + "id": "indefinite-end-open-upper", + "start": 4400, + "end": models.Range([4500, None]), + }, + { + "id": "indefinite-start-open-lower", + "start": models.Range([None, 10]), + "end": 20, + }, + { + "id": "definite-ranges", + "start": models.Range([0, 10]), + "end": models.Range([4500, 4560]), + }, + {"id": "start-undefined", "start": None, "end": 10}, + {"id": "end-undefined", "start": 10, "end": None}, + {"id": "both-undefined", "start": None, "end": None}, ] -# (start, end, offending coordinates as reported in the error message) -LOCATION_BOUNDS_INVALID = [ - pytest.param(4559, 4561, "end=4561", id="one-past-end"), - pytest.param(5000, 5000, "start=5000, end=5000", id="zero-width-past-end"), - pytest.param(99999999, 5, "start=99999999", id="start-past-end-with-start-gt-end"), - pytest.param(-1, 1, "start=-1", id="negative-start"), - pytest.param(0, -1, "end=-1", id="negative-end"), - pytest.param( - 4400, models.Range([4500, 4600]), "end=[4500, 4600]", id="definite-end-past-end" - ), - pytest.param( - 4400, - models.Range([4561, None]), - "end=[4561, None]", - id="indefinite-end-lower-bound-past-end", - ), - pytest.param( - models.Range([-5, 10]), 20, "start=[-5, 10]", id="range-with-negative-member" - ), +# Locations not representable on a sequence of length BOUNDS_SEQ_LEN, with the +# offending coordinates as reported in the error message +location_bounds_invalid_cases = [ + {"id": "one-past-end", "start": 4559, "end": 4561, "detail": "end=4561"}, + { + "id": "zero-width-past-end", + "start": 5000, + "end": 5000, + "detail": "start=5000, end=5000", + }, + { + "id": "start-past-end-with-start-gt-end", + "start": 99999999, + "end": 5, + "detail": "start=99999999", + }, + {"id": "negative-start", "start": -1, "end": 1, "detail": "start=-1"}, + {"id": "negative-end", "start": 0, "end": -1, "detail": "end=-1"}, + { + "id": "definite-end-past-end", + "start": 4400, + "end": models.Range([4500, 4600]), + "detail": "end=[4500, 4600]", + }, + { + "id": "indefinite-end-lower-bound-past-end", + "start": 4400, + "end": models.Range([4561, None]), + "detail": "end=[4561, None]", + }, + { + "id": "range-with-negative-member", + "start": models.Range([-5, 10]), + "end": 20, + "detail": "start=[-5, 10]", + }, ] -@pytest.mark.parametrize(("start", "end"), LOCATION_BOUNDS_VALID) -def test_validate_location_bounds_valid( - start: int | models.Range | None, - end: int | models.Range | None, -) -> None: +@pytest.mark.parametrize("case", location_bounds_valid_cases, ids=lambda c: c["id"]) +def test_validate_location_bounds_valid(case: dict) -> None: dp = _bounds_dp([BOUNDS_SEQ_ID, BOUNDS_REFGET_ID]) - dp.validate_location_bounds(BOUNDS_SEQ_ID, start, end) + dp.validate_location_bounds(BOUNDS_SEQ_ID, case["start"], case["end"]) -@pytest.mark.parametrize(("start", "end", "detail"), LOCATION_BOUNDS_INVALID) -def test_validate_location_bounds_invalid( - start: int | models.Range | None, - end: int | models.Range | None, - detail: str, -) -> None: +@pytest.mark.parametrize("case", location_bounds_invalid_cases, ids=lambda c: c["id"]) +def test_validate_location_bounds_invalid(case: dict) -> None: dp = _bounds_dp([BOUNDS_SEQ_ID, BOUNDS_REFGET_ID]) expected_msg = ( - f"Location out of bounds on {BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID}): {detail} " - f"not within [0, {BOUNDS_SEQ_LEN}]" + f"Location out of bounds on {BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID}): " + f"{case['detail']} not within [0, {BOUNDS_SEQ_LEN}]" ) with pytest.raises(DataProxyValidationError, match=f"^{re.escape(expected_msg)}$"): - dp.validate_location_bounds(BOUNDS_SEQ_ID, start, end) - - -# (sequence_id as given, aliases of the sequence, name used in the error message) -LOCATION_BOUNDS_SEQUENCE_NAMES = [ - pytest.param( - BOUNDS_SEQ_ID, - [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], - f"{BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID})", - id="input-and-refget", - ), - pytest.param( - "NM_000551.3", - [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], - f"{BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID})", - id="bare-accession-coerced", - ), - pytest.param( - BOUNDS_REFGET_ID, - [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], - BOUNDS_REFGET_ID, - id="refget-input-named-once", - ), - pytest.param(BOUNDS_SEQ_ID, [BOUNDS_SEQ_ID], BOUNDS_SEQ_ID, id="no-refget-alias"), + dp.validate_location_bounds(BOUNDS_SEQ_ID, case["start"], case["end"]) + + +# Name used for the sequence in the error message, given the sequence_id passed in +# and the aliases of the sequence +location_bounds_sequence_name_cases = [ + { + "id": "input-and-refget", + "sequence_id": BOUNDS_SEQ_ID, + "aliases": [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], + "seq_name": f"{BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID})", + }, + { + "id": "bare-accession-coerced", + "sequence_id": "NM_000551.3", + "aliases": [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], + "seq_name": f"{BOUNDS_SEQ_ID} ({BOUNDS_REFGET_ID})", + }, + { + "id": "refget-input-named-once", + "sequence_id": BOUNDS_REFGET_ID, + "aliases": [BOUNDS_SEQ_ID, BOUNDS_REFGET_ID], + "seq_name": BOUNDS_REFGET_ID, + }, + { + "id": "no-refget-alias", + "sequence_id": BOUNDS_SEQ_ID, + "aliases": [BOUNDS_SEQ_ID], + "seq_name": BOUNDS_SEQ_ID, + }, ] @pytest.mark.parametrize( - ("sequence_id", "aliases", "seq_name"), LOCATION_BOUNDS_SEQUENCE_NAMES + "case", location_bounds_sequence_name_cases, ids=lambda c: c["id"] ) -def test_validate_location_bounds_sequence_name( - sequence_id: str, aliases: list[str], seq_name: str -) -> None: - dp = _bounds_dp(aliases) - expected_prefix = f"Location out of bounds on {seq_name}: " +def test_validate_location_bounds_sequence_name(case: dict) -> None: + dp = _bounds_dp(case["aliases"]) + expected_prefix = f"Location out of bounds on {case['seq_name']}: " with pytest.raises( DataProxyValidationError, match=f"^{re.escape(expected_prefix)}" ): - dp.validate_location_bounds(sequence_id, 0, BOUNDS_SEQ_LEN + 1) + dp.validate_location_bounds(case["sequence_id"], 0, BOUNDS_SEQ_LEN + 1) diff --git a/tests/test_vrs_normalize.py b/tests/test_vrs_normalize.py index 0a30792b..d6f62545 100644 --- a/tests/test_vrs_normalize.py +++ b/tests/test_vrs_normalize.py @@ -955,53 +955,53 @@ def _bounds_allele( ) +normalize_location_in_bounds_cases = [ + # an undefined outer endpoint is representable and must not be rejected + # (the deletion is also rolled right by one base by normalization) + { + "id": "indefinite-ranges-open-outward", + "start": [None, 4400], + "end": [4500, None], + "sequence": "", + "expected_start": [None, 4400], + "expected_end": [4501, None], + }, +] + + @pytest.mark.parametrize( - ("start", "end", "sequence", "expected_start", "expected_end"), - [ - # an undefined outer endpoint is representable and must not be rejected - # (the deletion is also rolled right by one base by normalization) - pytest.param( - [None, 4400], - [4500, None], - "", - [None, 4400], - [4501, None], - id="indefinite-ranges-open-outward", - ), - ], + "case", normalize_location_in_bounds_cases, ids=lambda c: c["id"] ) -def test_normalize_location_in_bounds( - dataproxy: SeqRepoDataProxy, - start: int | list[int | None], - end: int | list[int | None], - sequence: str, - expected_start: int | list[int | None], - expected_end: int | list[int | None], -) -> None: - allele = normalize(_bounds_allele(start, end, sequence), dataproxy) - location = allele.location.model_dump() - assert (location["start"], location["end"]) == (expected_start, expected_end) +def test_normalize_location_in_bounds(dataproxy: SeqRepoDataProxy, case: dict) -> None: + allele = _bounds_allele(case["start"], case["end"], case["sequence"]) + location = normalize(allele, dataproxy).location.model_dump() + assert (location["start"], location["end"]) == ( + case["expected_start"], + case["expected_end"], + ) + + +normalize_location_out_of_bounds_cases = [ + # Definite ranges are otherwise returned without normalization, so the + # bounds check must run before that early return + { + "id": "definite-range-end-past-end", + "start": 4400, + "end": [4500, 4600], + "detail": "end=[4500, 4600]", + }, +] @pytest.mark.parametrize( - ("start", "end", "detail"), - [ - # Definite ranges are otherwise returned without normalization, so the - # bounds check must run before that early return - pytest.param( - 4400, [4500, 4600], "end=[4500, 4600]", id="definite-range-end-past-end" - ), - ], + "case", normalize_location_out_of_bounds_cases, ids=lambda c: c["id"] ) def test_normalize_location_out_of_bounds( - dataproxy: SeqRepoDataProxy, - start: int | list[int | None], - end: int | list[int | None], - detail: str, + dataproxy: SeqRepoDataProxy, case: dict ) -> None: expected_msg = ( - f"Location out of bounds on ga4gh:{BOUNDS_REFGET_AC}: {detail} " + f"Location out of bounds on ga4gh:{BOUNDS_REFGET_AC}: {case['detail']} " f"not within [0, {BOUNDS_SEQ_LEN}]" ) with pytest.raises(DataProxyValidationError, match=f"^{re.escape(expected_msg)}$"): - normalize(_bounds_allele(start, end, "A"), dataproxy) + normalize(_bounds_allele(case["start"], case["end"], "A"), dataproxy) From dd353c821aaa20cb846805afdc0c01eac860fa4d Mon Sep 17 00:00:00 2001 From: Kyle Ferriter Date: Tue, 6 Oct 2026 00:40:14 -0400 Subject: [PATCH 11/11] revert: drop _from_vrs fix from location bounds branch The fix landed on main separately (#656, follow-up #668), so remove it and its tests to keep this branch focused on location bounds validation. --- src/ga4gh/vrs/extras/translator.py | 4 ++-- tests/extras/test_allele_translator.py | 31 -------------------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index a1b2975b..120eaee4 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -167,8 +167,8 @@ def _from_vrs(self, var: dict, **kwargs) -> models._VariationBase | None: # noq if "type" not in var: return None try: - model = getattr(models, models.VrsType(var["type"]).value) - except ValueError: + model = models[var["type"]] + except KeyError: return None return model(**var) diff --git a/tests/extras/test_allele_translator.py b/tests/extras/test_allele_translator.py index 09a58797..15f87a89 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -337,37 +337,6 @@ def test_from_invalid(tlr): tlr.translate_from("BRAF amplication", assembly_name="GRCh37") -from_vrs_cases = [ - {"id": "allele", "var": snv_output}, - # VRS input is trusted as-is: not validated against the sequence or normalized - { - "id": "allele-out-of-bounds-unvalidated", - "var": { - **snv_output, - "location": {**snv_output["location"], "start": 99999999, "end": 5}, - }, - }, -] - - -@pytest.mark.parametrize("case", from_vrs_cases, ids=lambda c: c["id"]) -def test_from_vrs(tlr: AlleleTranslator, case: dict) -> None: - vo = tlr.translate_from(case["var"], fmt="vrs") - assert vo.model_dump(exclude_none=True) == case["var"] - - -from_vrs_invalid_cases = [ - {"id": "unknown-type", "var": {"type": "Bogus"}}, - {"id": "missing-type", "var": {"location": snv_output["location"]}}, -] - - -@pytest.mark.parametrize("case", from_vrs_invalid_cases, ids=lambda c: c["id"]) -def test_from_vrs_invalid(tlr: AlleleTranslator, case: dict) -> None: - with pytest.raises(ValueError, match="^Unable to parse data as vrs variation$"): - tlr.translate_from(case["var"], fmt="vrs") - - @pytest.mark.vcr def test_from_beacon(tlr): do_normalize = False