diff --git a/src/ga4gh/vrs/__init__.py b/src/ga4gh/vrs/__init__.py index 16d36647..11cef649 100644 --- a/src/ga4gh/vrs/__init__.py +++ b/src/ga4gh/vrs/__init__.py @@ -5,7 +5,7 @@ from ga4gh.vrs import models from ga4gh.vrs.enderef import vrs_deref, vrs_enref from ga4gh.vrs.models import VrsType -from ga4gh.vrs.normalize import normalize +from ga4gh.vrs.normalize import RleSubunitMode, normalize try: __version__ = version(__name__) @@ -19,6 +19,7 @@ __all__ = [ "VRS_VERSION", + "RleSubunitMode", "VrsType", "models", "normalize", diff --git a/src/ga4gh/vrs/extras/translator.py b/src/ga4gh/vrs/extras/translator.py index 91c45532..89416d0b 100644 --- a/src/ga4gh/vrs/extras/translator.py +++ b/src/ga4gh/vrs/extras/translator.py @@ -17,7 +17,11 @@ from ga4gh.vrs import models, normalize from ga4gh.vrs.dataproxy import SequenceProxy, _DataProxy from ga4gh.vrs.extras.decorators import lazy_property -from ga4gh.vrs.normalize import denormalize_reference_length_expression +from ga4gh.vrs.normalize import ( + DEFAULT_RLE_SUBUNIT_MODE, + RleSubunitMode, + denormalize_reference_length_expression, +) from ga4gh.vrs.utils.hgvs_tools import HgvsTools _logger = logging.getLogger(__name__) @@ -75,11 +79,13 @@ def __init__( default_assembly_name: str = "GRCh38", identify: bool = True, rle_seq_limit: int | None = 50, + rle_subunit_mode: RleSubunitMode = DEFAULT_RLE_SUBUNIT_MODE, ) -> None: self.default_assembly_name = default_assembly_name self.data_proxy = data_proxy self.identify = identify self.rle_seq_limit = rle_seq_limit + self.rle_subunit_mode = RleSubunitMode(rle_subunit_mode) self.from_translators: dict[str, VariationFromStrProtocol] = {} self.to_translators: dict[str, VariationToStrProtocol] = {} @@ -111,6 +117,10 @@ def translate_from( To exclude `sequence` from the response, set to 0. For no limit, set to `None`. Defaults value set in instance variable, `rle_seq_limit`. + rle_subunit_mode (RleSubunitMode): Which valid factor of the seed + length to select as the `repeatSubunitLength` for a + reference-derived ambiguous insertion. + Defaults value set in instance variable, `rle_subunit_mode`. do_normalize (bool): `True` if fully justified normalization should be performed. `False` otherwise. Defaults to `True` """ @@ -181,9 +191,15 @@ def __init__( data_proxy: _DataProxy, default_assembly_name: str = "GRCh38", identify: bool = True, + rle_subunit_mode: RleSubunitMode = DEFAULT_RLE_SUBUNIT_MODE, ) -> None: """Initialize AlleleTranslator class""" - super().__init__(data_proxy, default_assembly_name, identify) + super().__init__( + data_proxy, + default_assembly_name, + identify, + rle_subunit_mode=rle_subunit_mode, + ) self.from_translators = { "beacon": self._from_beacon, @@ -232,6 +248,10 @@ def _from_beacon(self, beacon_expr: str, **kwargs) -> models.Allele | None: To exclude `sequence` from the response, set to 0. For no limit, set to `None`. Defaults value set in instance variable, `rle_seq_limit`. + rle_subunit_mode (RleSubunitMode): Which valid factor of the seed length + to select as the `repeatSubunitLength` for a reference-derived + ambiguous insertion. + Defaults value set in instance variable, `rle_subunit_mode`. do_normalize (bool): `True` if fully justified normalization should be performed. `False` otherwise. Defaults to `True` @@ -297,6 +317,10 @@ def _from_gnomad(self, gnomad_expr: str, **kwargs) -> models.Allele | None: To exclude `sequence` from the response, set to 0. For no limit, set to `None`. Defaults value set in instance variable, `rle_seq_limit`. + rle_subunit_mode (RleSubunitMode): Which valid factor of the seed length + to select as the `repeatSubunitLength` for a reference-derived + ambiguous insertion. + Defaults value set in instance variable, `rle_subunit_mode`. do_normalize (bool): `True` if fully justified normalization should be performed. `False` otherwise. Defaults to `True` @@ -371,6 +395,10 @@ def _from_spdi(self, spdi_expr: str, **kwargs) -> models.Allele | None: To exclude `sequence` from the response, set to 0. For no limit, set to `None`. Defaults value set in instance variable, `rle_seq_limit`. + rle_subunit_mode (RleSubunitMode): Which valid factor of the seed length + to select as the `repeatSubunitLength` for a reference-derived + ambiguous insertion. + Defaults value set in instance variable, `rle_subunit_mode`. do_normalize (bool): `True` if fully justified normalization should be performed. `False` otherwise. Defaults to `True` @@ -509,6 +537,9 @@ def _post_process_imported_allele( normalization, this sets the limit for the length of the `sequence`. To exclude `sequence` from the response, set to 0. For no limit, set to `None`. + rle_subunit_mode (RleSubunitMode): Which valid factor of the seed length to + select as the `repeatSubunitLength` for a reference-derived ambiguous + insertion. Defaults value set in instance variable, `rle_subunit_mode`. do_normalize (bool): `True` if fully justified normalization should be performed. `False` otherwise. Defaults to `True` """ @@ -517,6 +548,7 @@ def _post_process_imported_allele( allele, self.data_proxy, rle_seq_limit=kwargs.get("rle_seq_limit", self.rle_seq_limit), + rle_subunit_mode=kwargs.get("rle_subunit_mode", self.rle_subunit_mode), ) if self.identify: diff --git a/src/ga4gh/vrs/normalize.py b/src/ga4gh/vrs/normalize.py index fad73d87..088f9870 100644 --- a/src/ga4gh/vrs/normalize.py +++ b/src/ga4gh/vrs/normalize.py @@ -6,7 +6,8 @@ import itertools import logging -from enum import IntEnum +from collections.abc import Iterator +from enum import Enum, IntEnum from typing import NamedTuple from bioutils.normalize import NormalizationMode @@ -20,6 +21,26 @@ _logger = logging.getLogger(__name__) +class RleSubunitMode(str, Enum): + """Define which repeat subunit length to select for a reference-derived ambiguous + insertion normalized to a ``ReferenceLengthExpression``. More than one factor of + the seed length may be circularly expandable to recreate the alternate sequence; + this selects which of them becomes the ``repeatSubunitLength``. The options are: + LARGEST - Select the greatest valid factor (VRS 2.0.x normalization) + SMALLEST - Select the smallest valid factor (VRS 2.1 normalization) + + ``repeatSubunitLength`` is part of the ``ReferenceLengthExpression`` digest, so the + two modes may produce different identifiers for the same input Allele. + """ + + LARGEST = "largest" + SMALLEST = "smallest" + + +# VRS 2.0.x normalization selects the greatest valid factor +DEFAULT_RLE_SUBUNIT_MODE = RleSubunitMode.LARGEST + + class PosType(IntEnum): """Define the kind of position on a location""" @@ -85,7 +106,10 @@ def _get_new_allele_location_pos( def _normalize_allele( - input_allele: models.Allele, data_proxy: _DataProxy, rle_seq_limit: int = 50 + input_allele: models.Allele, + data_proxy: _DataProxy, + rle_seq_limit: int = 50, + rle_subunit_mode: RleSubunitMode = DEFAULT_RLE_SUBUNIT_MODE, ): """Normalize Allele using "fully-justified" normalization adapted from NCBI's VOCA. Fully-justified normalization expands such ambiguous representation over the @@ -111,7 +135,12 @@ def _normalize_allele( of the `sequence`. To exclude `sequence` from the response, set to 0. For no limit, set to `None`. + :param rle_subunit_mode: Which valid factor of the seed length to select as the + `repeatSubunitLength` for a reference-derived ambiguous insertion. Defaults to + `DEFAULT_RLE_SUBUNIT_MODE`. See `RleSubunitMode`. """ + rle_subunit_mode = RleSubunitMode(rle_subunit_mode) + # Algorithm applies to LiteralSequenceExpression alleles only; other states are returned unchanged if not isinstance(input_allele.state, models.LiteralSequenceExpression): _logger.warning( @@ -204,7 +233,7 @@ def _normalize_allele( # 5.c if len_extended_alt > len_extended_ref: - factors = _factor_gen(seed_length) + factors = _factor_gen(seed_length, rle_subunit_mode) for cycle_length in factors: if cycle_length > len_extended_ref: continue @@ -308,17 +337,32 @@ def denormalize_reference_length_expression( return alt -def _factor_gen(n: int): - """Yield all factors of an integer `n`, in descending order""" - lower_factors = [] +def _factor_gen( + n: int, mode: RleSubunitMode = DEFAULT_RLE_SUBUNIT_MODE +) -> Iterator[int]: + """Yield all factors of an integer `n` + + :param n: The integer to factor + :param mode: `RleSubunitMode.LARGEST` to yield factors in descending order, + `RleSubunitMode.SMALLEST` to yield them in ascending order. The first valid + factor found by the caller is the one selected, so the order determines whether + the largest or smallest valid factor wins. + """ + paired_factors = [] i = 1 while i * i <= n: if n % i == 0: - yield n // i - if n // i != i: - lower_factors.append(i) + # i <= sqrt(n) <= n // i. Yield the factor that is already in the requested + # order, and save its pair to yield in reverse once the loop finishes. + if mode == RleSubunitMode.SMALLEST: + factor, pair = i, n // i + else: + factor, pair = n // i, i + yield factor + if pair != factor: + paired_factors.append(pair) i += 1 - yield from reversed(lower_factors) + yield from reversed(paired_factors) def _define_rle_allele( @@ -371,6 +415,9 @@ def normalize(vo, data_proxy: _DataProxy | None = None, **kwargs): :param data_proxy: GA4GH sequence dataproxy instance, if needed :keyword rle_seq_limit: If RLE is set as the new state, set the limit for the length of the `sequence`. To exclude `state.sequence`, set to 0. + :keyword rle_subunit_mode: Which valid factor of the seed length to select as the + `repeatSubunitLength` for a reference-derived ambiguous insertion. Defaults to + `DEFAULT_RLE_SUBUNIT_MODE`. See `RleSubunitMode`. :return: normalized object, or unmodified input object if the normalization algorithm does not provide normalization steps for the given type. :raise TypeError: if given object isn't a pydantic.BaseModel diff --git a/tests/cassettes/test_normalize_rle_subunit_mode[microsatellite_ins_2_units].yaml b/tests/cassettes/test_normalize_rle_subunit_mode[microsatellite_ins_2_units].yaml new file mode 100644 index 00000000..5b1cc262 --- /dev/null +++ b/tests/cassettes/test_normalize_rle_subunit_mode[microsatellite_ins_2_units].yaml @@ -0,0 +1,170 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007183&end=1007183 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007182&end=1007183 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007181&end=1007182 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007180&end=1007181 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007179&end=1007180 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007178&end=1007179 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007177&end=1007178 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007176&end=1007177 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007175&end=1007176 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007174&end=1007175 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007173&end=1007174 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007172&end=1007173 + response: + body: + string: C + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007183&end=1007184 + response: + body: + string: C + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007173&end=1007183 + response: + body: + string: GTGTGTGTGT + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/cassettes/test_normalize_rle_subunit_mode[poly_a_ins_2bp].yaml b/tests/cassettes/test_normalize_rle_subunit_mode[poly_a_ins_2bp].yaml new file mode 100644 index 00000000..27dde796 --- /dev/null +++ b/tests/cassettes/test_normalize_rle_subunit_mode[poly_a_ins_2bp].yaml @@ -0,0 +1,134 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900409&end=236900418 + response: + body: + string: AAAAAAAAA + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900418&end=236900418 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900417&end=236900418 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900416&end=236900417 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900415&end=236900416 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900414&end=236900415 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900411&end=236900412 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900410&end=236900411 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900409&end=236900410 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900408&end=236900409 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900418&end=236900419 + response: + body: + string: G + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_rle_subunit_mode_smallest_to_spdi[microsatellite_ins_2_units].yaml b/tests/extras/cassettes/test_rle_subunit_mode_smallest_to_spdi[microsatellite_ins_2_units].yaml new file mode 100644 index 00000000..ac8aefe4 --- /dev/null +++ b/tests/extras/cassettes/test_rle_subunit_mode_smallest_to_spdi[microsatellite_ins_2_units].yaml @@ -0,0 +1,182 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007183&end=1007183 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007182&end=1007183 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007181&end=1007182 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007180&end=1007181 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007179&end=1007180 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007178&end=1007179 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007177&end=1007178 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007176&end=1007177 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007175&end=1007176 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007174&end=1007175 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007173&end=1007174 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007172&end=1007173 + response: + body: + string: C + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007183&end=1007184 + response: + body: + string: C + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=1007173&end=1007183 + response: + body: + string: GTGTGTGTGT + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/NC_000001.11?start=1007173&end=1007183 + response: + body: + string: GTGTGTGTGT + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_rle_subunit_mode_smallest_to_spdi[poly_a_ins_2bp].yaml b/tests/extras/cassettes/test_rle_subunit_mode_smallest_to_spdi[poly_a_ins_2bp].yaml new file mode 100644 index 00000000..b12d4ea9 --- /dev/null +++ b/tests/extras/cassettes/test_rle_subunit_mode_smallest_to_spdi[poly_a_ins_2bp].yaml @@ -0,0 +1,14 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/NC_000001.11?start=236900409&end=236900418 + response: + body: + string: AAAAAAAAA + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/tests/extras/cassettes/test_rle_subunit_mode_translator[init_smallest].yaml b/tests/extras/cassettes/test_rle_subunit_mode_translator[init_smallest].yaml new file mode 100644 index 00000000..ff6ed795 --- /dev/null +++ b/tests/extras/cassettes/test_rle_subunit_mode_translator[init_smallest].yaml @@ -0,0 +1,158 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900409&end=236900418 + response: + body: + string: AAAAAAAAA + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900418&end=236900418 + response: + body: + string: '' + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900417&end=236900418 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900416&end=236900417 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900415&end=236900416 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900414&end=236900415 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900413&end=236900414 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900412&end=236900413 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900411&end=236900412 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900410&end=236900411 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900409&end=236900410 + 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.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900408&end=236900409 + response: + body: + string: T + headers: {} + status: + code: 200 + message: OK +- request: + body: null + headers: {} + method: GET + uri: http://localhost:5000/seqrepo/1/sequence/ga4gh:SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO?start=236900418&end=236900419 + response: + body: + string: G + 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..a32a3efb 100644 --- a/tests/extras/test_allele_translator.py +++ b/tests/extras/test_allele_translator.py @@ -3,6 +3,7 @@ from ga4gh.vrs import models from ga4gh.vrs.dataproxy import DataProxyValidationError from ga4gh.vrs.extras.translator import AlleleTranslator +from ga4gh.vrs.normalize import RleSubunitMode @pytest.fixture(scope="module") @@ -800,6 +801,78 @@ def test_rle_seq_limit(tlr): assert output_hgvs_expr == [input_hgvs_expr] +# Insert AA into the chr1 A x 9 tract: repeatSubunitLength 2 under LARGEST, 1 under +# SMALLEST +rle_subunit_mode_spdi = "NC_000001.11:236900409:AAAAAAAAA:AAAAAAAAAAA" + +rle_subunit_mode_translator_cases = [ + { + "id": "init_smallest", + "init_mode": RleSubunitMode.SMALLEST, + "call_kwargs": {}, + "expected": 1, + }, + { + "id": "call_overrides_init", + "init_mode": RleSubunitMode.SMALLEST, + "call_kwargs": {"rle_subunit_mode": RleSubunitMode.LARGEST}, + "expected": 2, + }, +] + + +@pytest.mark.parametrize( + "case", rle_subunit_mode_translator_cases, ids=lambda c: c["id"] +) +@pytest.mark.vcr +def test_rle_subunit_mode_translator(rest_dataproxy, case): + """The translator applies its constructor `rle_subunit_mode`, which a per-call + `rle_subunit_mode` overrides + """ + translator = AlleleTranslator( + rest_dataproxy, identify=False, rle_subunit_mode=case["init_mode"] + ) + allele = translator.translate_from( + rle_subunit_mode_spdi, "spdi", **case["call_kwargs"] + ) + assert allele.state.repeatSubunitLength == case["expected"] + + +rle_subunit_mode_smallest_to_spdi_cases = [ + { + "id": "poly_a_ins_2bp", + "spdi": "NC_000001.11:236900409:AAAAAAAAA:AAAAAAAAAAA", + "repeat_subunit_length": 1, + "expected": "NC_000001.11:236900409:9:AAAAAAAAAAA", + }, + { + "id": "microsatellite_ins_2_units", + "spdi": "NC_000001.11:1007183::GTGT", + "repeat_subunit_length": 2, + "expected": "NC_000001.11:1007173:10:GTGTGTGTGTGTGT", + }, +] + + +@pytest.mark.parametrize( + "case", rle_subunit_mode_smallest_to_spdi_cases, ids=lambda c: c["id"] +) +@pytest.mark.vcr +def test_rle_subunit_mode_smallest_to_spdi(tlr, case): + """A SMALLEST RLE without its sequence translates back to the input variant, since + translate_to rebuilds the sequence from the reference and repeatSubunitLength + """ + allele = tlr.translate_from( + case["spdi"], + "spdi", + rle_seq_limit=0, + rle_subunit_mode=RleSubunitMode.SMALLEST, + ) + assert allele.state.repeatSubunitLength == case["repeat_subunit_length"] + assert allele.state.sequence is None + assert tlr.translate_to(allele, "spdi") == [case["expected"]] + + @pytest.mark.vcr def test_to_hgvs_iri_ref_keyerror(tlr): # iriReference is passed diff --git a/tests/test_vrs_normalize.py b/tests/test_vrs_normalize.py index 83c27cdd..a57c9533 100644 --- a/tests/test_vrs_normalize.py +++ b/tests/test_vrs_normalize.py @@ -1,6 +1,7 @@ import pytest from ga4gh.vrs import models, normalize +from ga4gh.vrs.normalize import RleSubunitMode, _factor_gen # Single nucleotide same-as-reference allele. allele_dict1 = { @@ -929,3 +930,93 @@ 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) + + +############################################################################# +# RLE repeat subunit mode + +_CHR1 = "SQ.Ya6Rs7DHhDeg7YaOSg1EoNi3U_nQ9SvO" # GRCh38 chr1, NC_000001.11 + + +def _rle(length: int, sequence: str, repeat_subunit_length: int) -> dict: + """Build a ReferenceLengthExpression state dict""" + return { + "type": "ReferenceLengthExpression", + "length": length, + "sequence": sequence, + "repeatSubunitLength": repeat_subunit_length, + } + + +rle_subunit_mode_cases = [ + { + "id": "poly_a_ins_2bp", + # chr1 A x 9 tract, insert AA: seed length 2, factors 2 and 1 are both valid + "input": (_CHR1, 236900409, 236900418, "A" * 11), + "normalized_span": (236900409, 236900418), + "largest": _rle(11, "A" * 11, 2), + "smallest": _rle(11, "A" * 11, 1), + }, + { + "id": "microsatellite_ins_2_units", + # chr1 GT x 5 tract, insert GTGT: seed length 4, SMALLEST rejects factor 1 + "input": (_CHR1, 1007183, 1007183, "GTGT"), + "normalized_span": (1007173, 1007183), + "largest": _rle(14, "GT" * 7, 4), + "smallest": _rle(14, "GT" * 7, 2), + }, +] + + +def _build_allele(refget_accession: str, start: int, end: int, sequence: str) -> dict: + """Build an unnormalized LiteralSequenceExpression Allele dict""" + return { + "type": "Allele", + "location": { + "type": "SequenceLocation", + "sequenceReference": { + "type": "SequenceReference", + "refgetAccession": refget_accession, + }, + "start": start, + "end": end, + }, + "state": {"type": "LiteralSequenceExpression", "sequence": sequence}, + } + + +@pytest.mark.parametrize("case", rle_subunit_mode_cases, ids=lambda c: c["id"]) +@pytest.mark.vcr +def test_normalize_rle_subunit_mode(rest_dataproxy, case): + """Each mode normalizes to its expected state, and the default is LARGEST""" + allele = models.Allele(**_build_allele(*case["input"])) + + normalized_by_mode = {} + for mode in RleSubunitMode: + normalized = normalize( + allele, rest_dataproxy, rle_seq_limit=None, rle_subunit_mode=mode + ) + location = (normalized.location.start, normalized.location.end) + assert location == case["normalized_span"], mode + assert normalized.state.model_dump(exclude_none=True) == case[mode.value], mode + normalized_by_mode[mode] = normalized + + default = normalize(allele, rest_dataproxy, rle_seq_limit=None) + assert default == normalized_by_mode[RleSubunitMode.LARGEST] + + +@pytest.mark.parametrize("mode", ["typo", None]) +def test_normalize_rle_subunit_mode_invalid(mode): + """Normalization rejects an invalid `rle_subunit_mode`""" + allele = models.Allele(**_build_allele(*rle_subunit_mode_cases[0]["input"])) + with pytest.raises(ValueError, match="is not a valid RleSubunitMode"): + normalize(allele, rle_subunit_mode=mode) + + +def test_factor_gen(): + """Factors are yielded once each, in descending (LARGEST) or ascending (SMALLEST) + order + """ + largest = [36, 18, 12, 9, 6, 4, 3, 2, 1] + assert list(_factor_gen(36, RleSubunitMode.LARGEST)) == largest + assert list(_factor_gen(36, RleSubunitMode.SMALLEST)) == largest[::-1]