diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 72a1df3..51f3d66 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -1,7 +1,7 @@ name: build on: push: - branches: + branches: - master pull_request: branches: @@ -30,6 +30,10 @@ jobs: run: | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test typings + run: | + pip install mypy + mypy mutalyzer_crossmapper - name: Test with pytest run: | pytest diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..7096477 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,15 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/README.rst b/README.rst index 7862443..2728059 100644 --- a/README.rst +++ b/README.rst @@ -27,79 +27,42 @@ HGVS position crossmapper This library provides an interface to convert (cross map) between different HGVS numbering_ systems. -Converting between the transcript oriented c. or n. and the genomic oriented g. +Converting between the transcript oriented ``c.`` or ``n.`` and the genomic oriented ``g.`` numbering systems can be difficult, especially when the transcript in question -resides on the complement strand. +resides on the complement strand. This library provides functions to convert between any HGVS +numbering system to standard (0-based) coordinates and vice versa. **Features:** -- Support for genomic positions to standard coordinates and vice versa. -- Support for noncoding positions to standard coordinates and vice versa. -- Support for coding positions to standard coordinates and vice versa. -- Support for protein positions to standard coordinates and vice versa. -- Basic classes for loci that can be used for genomic loci other than genes. +- Support for genomic (``g.``, ``m.``, ``o.``) positions to standard coordinates and vice versa. +- Support for noncoding (``n.``, ``r.``) positions to standard coordinates and vice versa. +- Support for coding (``c.``) positions to standard coordinates and vice versa. +- Support for protein (``p.``) positions to standard coordinates and vice versa. +- Basic classes that can be used for loci other than genes or transcripts. Please see ReadTheDocs_ for the latest documentation. - Quick start ----------- -The ``Genomic`` class provides an interface to conversions between genomic -positions and coordinates. - -.. code:: python - - >>> from mutalyzer_crossmapper import Genomic - >>> crossmap = Genomic() - >>> crossmap.coordinate_to_genomic(0) - 1 - >>> crossmap.genomic_to_coordinate(1) - 0 - -On top of the functionality provided by the ``Genomic`` class, the -``NonCoding`` class provides an interface to conversions between noncoding -positions and coordinates. - -.. code:: python - - >>> from mutalyzer_crossmapper import NonCoding - >>> exons = [(5, 8), (14, 20), (30, 35), (40, 44), (50, 52), (70, 72)] - >>> crossmap = NonCoding(exons) - >>> crossmap.coordinate_to_noncoding(35) - (14, 1, 0) - >>> crossmap.noncoding_to_coordinate((14, 1)) - 35 - -Add the flag ``inverted=True`` to the constructor when the transcript resides -on the reverse complement strand. - -On top of the functionality provided by the ``NonCoding`` class, the ``Coding`` -class provides an interface to conversions between coding positions and -coordinates as well as conversions between protein positions and coordinates. +The ``Coding`` class converts zero-based sequence coordinates to HGVS coding +points and back. Exon locations and the CDS are given as zero-based half-open +intervals. .. code:: python >>> from mutalyzer_crossmapper import Coding - >>> cds = (32, 43) - >>> crossmap = Coding(exons, cds) - >>> crossmap.coordinate_to_coding(31) - (-1, 0, -1, 0) - >>> crossmap.coding_to_coordinate((-1, 0, -1)) - 31 - -Again, the flag ``inverted=True`` can be used for transcripts that reside on -the reverse complement strand. - -Conversions between protein positions and coordinates are done as follows. - -.. code:: python - - >>> crossmap.coordinate_to_protein(41) - (2, 2, 0, 0, 0) - >>> crossmap.protein_to_coordinate((2, 2, 0, 0)) - 41 - - -.. _numbering: http://varnomen.hgvs.org/bg-material/numbering/ + >>> exons = [(5, 8), (11, 14)] + >>> crossmap = Coding(exons, cds=(6, 12)) + >>> point = crossmap.coordinate_to_coding(11) + >>> point + CodingPoint(position=3, offset=0, region='') + >>> crossmap.coding_to_coordinate(point) + 11 + +See the |library| for other numbering systems, intronic offsets, and +reverse complement transcripts. + +.. _numbering: https://hgvs-nomenclature.org/stable/background/numbering/ .. _ReadTheDocs: https://mutalyzer-crossmapper.readthedocs.io +.. |library| replace:: `library `__ diff --git a/docs/api.rst b/docs/api.rst index 1d18302..9ea1e49 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -10,3 +10,4 @@ API documentation api/location api/locus api/multi_locus + api/point diff --git a/docs/api/crossmap.rst b/docs/api/crossmap.rst index 5455c89..1b75735 100644 --- a/docs/api/crossmap.rst +++ b/docs/api/crossmap.rst @@ -1,6 +1,8 @@ Crossmapper =========== +.. automodule:: mutalyzer_crossmapper.crossmapper + .. autoclass:: mutalyzer_crossmapper.crossmapper.Genomic :members: diff --git a/docs/api/locus.rst b/docs/api/locus.rst index 93aadff..ff76384 100644 --- a/docs/api/locus.rst +++ b/docs/api/locus.rst @@ -1,5 +1,8 @@ Locus ===== +See :class:`~mutalyzer_crossmapper.locus.Point` for the point fields. + .. automodule:: mutalyzer_crossmapper.locus :members: + :exclude-members: Point diff --git a/docs/api/multi_locus.rst b/docs/api/multi_locus.rst index 53d971e..58460a4 100644 --- a/docs/api/multi_locus.rst +++ b/docs/api/multi_locus.rst @@ -1,5 +1,8 @@ MultiLocus ========== +See :class:`~mutalyzer_crossmapper.multi_locus.Point` for the point fields. + .. automodule:: mutalyzer_crossmapper.multi_locus :members: + :exclude-members: Point diff --git a/docs/api/point.rst b/docs/api/point.rst new file mode 100644 index 0000000..f242845 --- /dev/null +++ b/docs/api/point.rst @@ -0,0 +1,30 @@ +Point +===== + +The following point classes encode the HGVS numbering systems and use a +one-based ``position`` field. + +.. autoclass:: mutalyzer_crossmapper.crossmapper.GenomicPoint + :inherited-members: + :members: + +.. autoclass:: mutalyzer_crossmapper.crossmapper.NonCodingPoint + :inherited-members: + :members: + +.. autoclass:: mutalyzer_crossmapper.crossmapper.CodingPoint + :inherited-members: + :members: + +.. autoclass:: mutalyzer_crossmapper.crossmapper.ProteinPoint + :inherited-members: + :members: + +The internal point classes use zero-based positions relative to a locus or +concatenated loci. + +.. autoclass:: mutalyzer_crossmapper.locus.Point + :members: + +.. autoclass:: mutalyzer_crossmapper.multi_locus.Point + :members: diff --git a/docs/conf.py b/docs/conf.py index d87866b..1afc38b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,5 +7,9 @@ release = _get_metadata('Version') autoclass_content = 'both' -extensions = ['sphinx.ext.autodoc'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.intersphinx' + ] master_doc = 'index' diff --git a/docs/index.rst b/docs/index.rst index 7b6eb57..04ad073 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,4 +1,9 @@ +.. Use an internal guide link in place of the README's external link. + .. include:: ../README.rst + :end-before: .. |library| replace:: + +.. |library| replace:: :doc:`library ` .. toctree:: :maxdepth: 2 diff --git a/docs/introduction.rst b/docs/introduction.rst index 5d5e301..9f78c35 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -22,4 +22,4 @@ interface that is able to convert from any HGVS numbering system to a conventional *coordinate* system and back. -.. _numbering: http://varnomen.hgvs.org/bg-material/numbering/ +.. _numbering: https://hgvs-nomenclature.org/stable/background/numbering/ diff --git a/docs/library.rst b/docs/library.rst index 6ef095a..09ab869 100644 --- a/docs/library.rst +++ b/docs/library.rst @@ -1,28 +1,57 @@ Library ======= -The library provides a number of classes to perform various conversions. +The package provides conversion helpers between zero-based genomic +coordinates and HGVS-like point models for genomic, noncoding, coding, +and protein contexts. + +Coordinate And Location Conventions +----------------------------------- + +- Coordinates are zero-based integers. +- Locations are provided as half-open intervals: ``(start, end)`` with + ``start`` inclusive and ``end`` exclusive. +- HGVS-style positions exposed by the point classes are one-based. +- Conversions between numbering systems should be done via a coordinate. The ``Genomic`` class --------------------- The ``Genomic`` class provides an interface to conversions between genomic -positions and coordinates. +(``g.``, ``m.``, ``o.``) positions and coordinates. -.. code:: python +The ``GenomicPoint`` dataclass +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Genomic positions follow the HGVS genomic coordinate system. A genomic +position is represented by a ``GenomicPoint`` instance with a single +``position`` attribute. Below is an example of ``g.1`` in HGVS. + +.. code-block:: python + + >>> from mutalyzer_crossmapper import GenomicPoint + >>> GenomicPoint(position=1) + GenomicPoint(position=1) + +See :doc:`api/point` for the fields. + +Genomic Position Conversion +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python >>> from mutalyzer_crossmapper import Genomic >>> crossmap = Genomic() -The functions ``coordinate_to_genomic()`` and ``genomic_to_coordinate`` can be +The functions ``coordinate_to_genomic()`` and ``genomic_to_coordinate()`` can be used to convert to and from genomic positions. .. code:: python >>> crossmap.coordinate_to_genomic(0) - 1 - >>> crossmap.genomic_to_coordinate(1) + GenomicPoint(position=1) + >>> crossmap.genomic_to_coordinate(GenomicPoint(position=1)) 0 See section :doc:`api/crossmap` for a detailed description. @@ -32,293 +61,206 @@ The ``NonCoding`` class On top of the functionality provided by the ``Genomic`` class, the ``NonCoding`` class provides an interface to conversions between noncoding -positions and coordinates. Conversions between positioning systems should be -done via a coordinate. +(``n.``, ``r.``) positions and coordinates. -.. code:: python +The ``NonCodingPoint`` dataclass +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - >>> from mutalyzer_crossmapper import NonCoding - >>> exons = [(5, 8), (14, 20), (30, 35), (40, 44), (50, 52), (70, 72)] - >>> crossmap = NonCoding(exons) +.. code-block:: python -Now the functions ``coordinate_to_noncoding()`` and -``noncoding_to_coordinate()`` can be used. These functions use a 3-tuple to -represent a noncoding position. + >>> from mutalyzer_crossmapper import NonCodingPoint + >>> NonCodingPoint(position=14, offset=1, region='') + NonCodingPoint(position=14, offset=1, region='') -.. _table_noncoding: -.. list-table:: Noncoding positions. - :header-rows: 1 +See :doc:`api/point` for the fields. - * - index - - description - * - 0 - - Transcript position. - * - 1 - - Offset. - * - 2 - - Upstream or downstream offset. +Noncoding Position Conversion +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -In our example, the HGVS position "g.36" (coordinate ``35``) is equivalent to -position "n.14+1". We can convert between these two as follows. - -.. code:: python +.. code-block:: python + >>> from mutalyzer_crossmapper import NonCoding + >>> exons = [(5, 8), (14, 20), (30, 35), (40, 44), (50, 52), (70, 72)] + >>> crossmap = NonCoding(exons) >>> crossmap.coordinate_to_noncoding(35) - (14, 1, 0) + NonCodingPoint(position=14, offset=1, region='') + >>> crossmap.noncoding_to_coordinate(NonCodingPoint(position=14, offset=1, region='')) + 35 -When the coordinate is upstream or downstream of the transcript, the last -element of the tuple denotes the offset with respect to the transcript. This -makes it possible to distinguish between intronic positions and those outside -of the transcript. +Upstream and downstream positions are represented using ``region='u'`` and +``region='d'``: -.. code:: python +.. code-block:: python >>> crossmap.coordinate_to_noncoding(2) - (1, -3, -3) + NonCodingPoint(position=1, offset=-3, region='u') >>> crossmap.coordinate_to_noncoding(73) - (22, 2, 2) + NonCodingPoint(position=22, offset=2, region='d') -Note that this last element is optional (and ignored) when a conversion to a -coordinate is requested. +For reverse complement orientation, set ``inverted=True``: - >>> crossmap.noncoding_to_coordinate((14, 1)) - 35 +.. code-block:: python -For transcripts that reside on the reverse complement strand, the ``inverted`` -parameter should be set to ``True``. In our example, HGVS position "g.36" -(coordinate ``35``) is now equivalent to position "n.9-1". + >>> reverse = NonCoding(exons, inverted=True) + >>> reverse.coordinate_to_noncoding(35) + NonCodingPoint(position=9, offset=-1, region='') -.. code:: python +See :doc:`api/crossmap` for full API details. - >>> crossmap = NonCoding(exons, inverted=True) - >>> crossmap.coordinate_to_noncoding(35) - (9, -1, 0) - >>> crossmap.noncoding_to_coordinate((9, -1)) - 35 - -See section :doc:`api/crossmap` for a detailed description. The ``Coding`` class -------------------- -The ``Coding`` class provides an interface to all conversions between -positioning systems and coordinates. Conversions between positioning systems -should be done via a coordinate. +``Coding`` extends ``NonCoding`` with coding DNA position logic +(``c.``), using exon locations and one CDS interval. -.. code:: python +The ``CodingPoint`` dataclass +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + >>> from mutalyzer_crossmapper import CodingPoint + >>> CodingPoint(position=1, offset=3, region='*') + CodingPoint(position=1, offset=3, region='*') + +See :doc:`api/point` for the fields. + +Coding Position Conversion +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python >>> from mutalyzer_crossmapper import Coding >>> exons = [(5, 8), (14, 20), (30, 35), (40, 44), (50, 52), (70, 72)] >>> cds = (32, 43) >>> crossmap = Coding(exons, cds) - -On top of the functionality provided by the ``NonCoding`` class, the functions -``coordinate_to_coding()`` and ``coding_to_coordinate()`` can be used. These -functions use a 4-tuple to represent a coding position. - -.. list-table:: Coding positions. - :header-rows: 1 - - * - index - - description - * - 0 - - Transcript position. - * - 1 - - Offset. - * - 2 - - Region. - * - 3 - - Upstream or downstream offset. - -The region denotes the location of the position with respect to the CDS. This -is needed in order to work with the HGVS "-" and "*" positions. - -.. list-table:: Coding position regions. - :header-rows: 1 - - * - value - - description - - HGVS example - * - ``-1`` - - Upstream of the CDS. - - "c.-10" - * - ``0`` - - In the CDS. - - "c.1" - * - ``1`` - - Downstream of the CDS. - - "c.*10" - -In our example, the HGVS position "g.32" (coordinate ``31``) is equivalent to -position "c.-1". We can convert between these two as follows. - -.. code:: python - >>> crossmap.coordinate_to_coding(31) - (-1, 0, -1, 0) - >>> crossmap.coding_to_coordinate((-1, 0, -1)) + CodingPoint(position=1, offset=0, region='-') + >>> crossmap.coding_to_coordinate(CodingPoint(position=1, offset=0, region='-')) 31 -The ``coordinate_to_coding()`` function accepts an optional ``degenerate`` -argument. When set to ``True``, positions outside of the transcript are no -longer described using the offset notation. +The optional ``degenerate=True`` argument maps outside-transcript ``u``/``d`` +representations to degenerate ``-``/``*`` positions: -.. code:: python +.. code-block:: python >>> crossmap.coordinate_to_coding(4) - (-11, -1, -1, -1) - >>> crossmap.coordinate_to_coding(4, True) - (-12, 0, -1, -1) - -Additionally, the functions ``coordinate_to_protein()`` and -``protein_to_coordinate()`` can be used. These functions use a 5-tuple to -represent a protein position. - -.. list-table:: Protein positions. - :header-rows: 1 - - * - index - - description - * - 0 - - Protein position. - * - 1 - - Codon position. - * - 2 - - Offset. - * - 3 - - Region. - * - 4 - - Upstream or downstream offset. - -In our example the HGVS position "g.42" (coordinate ``41``) corresponds with -position "p.2". We can convert between these to as follows. + CodingPoint(position=11, offset=-1, region='u') + >>> crossmap.coordinate_to_coding(4, degenerate=True) + CodingPoint(position=12, offset=0, region='-') -.. code:: python +Protein Conversion +------------------ + +``Coding`` also exposes conversion to and from protein-position models. + +The ``ProteinPoint`` dataclass +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``ProteinPoint`` extends ``CodingPoint`` with a position within the codon. + +.. code-block:: python + + >>> from mutalyzer_crossmapper import ProteinPoint + >>> ProteinPoint(position=1, position_in_codon=3, offset=0, region='') + ProteinPoint(position=1, offset=0, region='', position_in_codon=3) + +See :doc:`api/point` for the fields. + +Protein Position Conversion +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + >>> from mutalyzer_crossmapper import Coding + >>> exons = [(5, 8), (14, 20), (30, 35), (40, 44), (50, 52), (70, 72)] + >>> cds = (32, 43) + >>> crossmap = Coding(exons, cds) >>> crossmap.coordinate_to_protein(41) - (2, 2, 0, 0, 0) - >>> crossmap.protein_to_coordinate((2, 2, 0, 0)) + ProteinPoint(position=2, offset=0, region='', position_in_codon=2) + >>> crossmap.protein_to_coordinate( + ... ProteinPoint(position=2, offset=0, region='', position_in_codon=2) + ... ) 41 -Note that the protein position only corresponds with the HGVS "p." notation -when the offset equals ``0`` and the region equals ``1``. In the following -table, we show a number of annotated examples. - -.. list-table:: Protein positions examples. - :header-rows: 1 - - * - coordinate - - protein position - - description - - HGVS position - * - ``4`` - - ``(-4, 2, -1, -1, -1)`` - - Upstream position. - - invalid - * - ``31`` - - ``(-1, 3, 0, -1, 0)`` - - 5' UTR position. - - invalid - * - ``36`` - - ``(1, 3, 2, 0, 0)`` - - Intronic position. - - invalid - * - ``40`` - - ``(2, 1, 0, 0, 0)`` - - Second amino acid, first nucleotide. - - "p.2" - * - ``41`` - - ``(2, 2, 0, 0, 0)`` - - Second amino acid, second nucleotide. - - "p.2" - * - ``43`` - - ``(1, 1, 0, 1, 0)`` - - 3' UTR position. - - invalid - * - ``43`` - - ``(2, 2, 2, 1, 2)`` - - Downstream position. - - invalid +Note that canonical HGVS ``p.`` positions correspond to points with +``offset == 0`` and ``region == ''``. -See section :doc:`api/crossmap` for a detailed description. +See :doc:`api/crossmap` for full API details. -Locations ---------- -In many cases we need to know the nearest location with respect to a -coordinate. For example, we need to know where the nearest exon is when we want -to describe a position in an intron. The ``nearest_location()`` can be used to -do exactly this. +Location Helper +--------------- -.. code:: python +``nearest_location()`` finds the closest location index for a coordinate. +When two boundaries are equally close, ``p`` controls tie-breaking +(``0``: left, ``1``: right). + +.. code-block:: python >>> from mutalyzer_crossmapper import nearest_location + >>> exons = [(5, 8), (14, 20), (30, 35), (40, 44), (50, 52), (70, 72)] >>> nearest_location(exons, 37) 2 - >>> nearest_location(exons, 38) + >>> nearest_location(exons, 37, p=1) 3 -Notice that coordinate ``37`` is in the center of intron 2. By default -``nearest_location()`` will return the left location in case of a draw. This -behaviour can be altered by setting the optional argument ``p`` to ``1``. - -.. code:: python - - >>> nearest_location(exons, 37, 1) - 3 +See :doc:`api/location` for full API details. -See section :doc:`api/location` for a detailed description. -Basic classes +Basic Classes ------------- -The ``Coding`` class makes use of a number of basic classes described in this -section. +These lower-level classes are used by ``NonCoding`` and ``Coding``. -The ``Locus`` class -^^^^^^^^^^^^^^^^^^^ +The ``Point`` dataclass +~~~~~~~~~~~~~~~~~~~~~~~ -The ``Locus`` class is used to deal with offsets with respect to a single -locus. +``Locus`` and ``MultiLocus`` each have their own ``Point`` dataclass. +``locus.Point`` holds a position and an offset; ``multi_locus.Point`` adds a +region to distinguish upstream and downstream positions from positions +within or between the loci. -.. code:: python +See :doc:`api/point` for both. - >>> from mutalyzer_crossmapper import Locus - >>> locus = Locus((10, 20)) -This class provides the functions ``to_position()`` and ``to_coordinate()`` for -converting from a locus position to a coordinate and vice versa. These -functions work with a 2-tuple, see the section about `The NonCoding class`_ -for the semantics. +The ``Locus`` class +~~~~~~~~~~~~~~~~~~~ + +``Locus`` maps one genomic interval to/from ``Point``. -.. code:: python +.. code-block:: python + >>> from mutalyzer_crossmapper.locus import Locus, Point + >>> locus = Locus((10, 20)) >>> locus.to_position(9) - (1, -1) + Point(position=0, offset=-1) + >>> locus.to_coordinate(Point(position=0, offset=-1)) + 9 -For loci that reside on the reverse complement strand, the optional -``inverted`` constructor parameter should be set to ``True``. +Set ``inverted=True`` for reverse complement orientation. + +See :doc:`api/locus` for full API details. -See section :doc:`api/locus` for a detailed description. The ``MultiLocus`` class -^^^^^^^^^^^^^^^^^^^^^^^^ +~~~~~~~~~~~~~~~~~~~~~~~~ -The ``MultiLocus`` class is used to deal with offsets with respect to multiple -loci. +``MultiLocus`` maps coordinates across multiple intervals to/from a unified +``Point`` model. -.. code:: python +.. code-block:: python - >>> from mutalyzer_crossmapper import MultiLocus + >>> from mutalyzer_crossmapper import MultiLocus, Point >>> multilocus = MultiLocus([(10, 20), (40, 50)]) - -The interface to this class is similar to that of the ``Locus`` class. - -.. code:: python - >>> multilocus.to_position(22) - (10, 3) + Point(position=9, offset=3, region='') + >>> multilocus.to_coordinate(Point(position=9, offset=3, region='')) + 22 >>> multilocus.to_position(38) - (11, -2) + Point(position=10, offset=-2, region='') + >>> multilocus.to_coordinate(Point(position=10, offset=-2, region='')) + 38 -See section :doc:`api/multi_locus` for a detailed description. +See :doc:`api/multi_locus` for full API details. diff --git a/docs/requirements.txt b/docs/requirements.txt index 7f5d7b8..3325317 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1 @@ -docutils==0.17.1 -sphinx-argparse -sphinx-autodoc-typehints +sphinx>=7.2 diff --git a/mutalyzer_crossmapper/__init__.py b/mutalyzer_crossmapper/__init__.py index 284f6c8..00feb23 100644 --- a/mutalyzer_crossmapper/__init__.py +++ b/mutalyzer_crossmapper/__init__.py @@ -1,34 +1,20 @@ -"""Crossmapper position conversion library. - -Definitions: - -- Coordinates are zero based, non-negative integers. -- Locations are zero based right-open non-negative integer intervals, - consistent with Python's range() and sequence slicing functions. -- Loci and exons are locations. -- An exon list is a list of locations that, when flattened, is an increasing - sequence. -- A position is a 2-tuple of which the first element is a one based non-zero - integer relative to an element in a location and the second element is an - integer offset relative to the first element. -""" -from pkg_resources import get_distribution - -from .crossmapper import Coding, Genomic, NonCoding +from importlib.metadata import metadata + +from .crossmapper import Coding, Genomic, NonCoding, GenomicPoint, NonCodingPoint, CodingPoint, ProteinPoint from .location import nearest_location from .locus import Locus -from .multi_locus import MultiLocus - - -def _get_metadata(name): - pkg = get_distribution('mutalyzer_crossmapper') - - for line in pkg.get_metadata_lines(pkg.PKG_INFO): - if line.startswith('{}: '.format(name)): - return line.split(': ')[1] - - return '' - +from .multi_locus import MultiLocus, Point + + +def _get_metadata(name: str) -> str: + """Get metadata from the package using importlib.metadata.""" + try: + meta = metadata("mutalyzer_crossmapper") + return meta[name] + except KeyError: + return '' + except Exception: + return '' _copyright_notice = 'Copyright (c) {} <{}>'.format( _get_metadata('Author'), _get_metadata('Author-email')) diff --git a/mutalyzer_crossmapper/crossmapper.py b/mutalyzer_crossmapper/crossmapper.py index 537efd3..f25c626 100644 --- a/mutalyzer_crossmapper/crossmapper.py +++ b/mutalyzer_crossmapper/crossmapper.py @@ -1,156 +1,707 @@ -from .multi_locus import MultiLocus - +"""Conversions between coordinates and points in the HGVS numbering systems. + +A coordinate is the zero-based index of a nucleotide in the sequence. A +point has a position in one of four numbering systems, genomic (g./m./o.), +noncoding (n./r.), coding (c.) or protein (p.), each represented by its own +dataclass. Outside the genomic system a point also has an offset and a +region. Conversions between numbering systems should be done via a +coordinate. +""" +from dataclasses import dataclass + +from .multi_locus import ( + MultiLocus, + Point, + _check_coordinate_within_length, + _check_location_end_within_length, +) +from .locus import _check_locus, _check_int, _check_non_negative_int, _check_positive_int +from .location import nearest_location + +@dataclass(slots=True) +class GenomicPoint: + """A position in the genomic numbering system (g./m./o.). + + :arg position: One-based position in the sequence, positive. + """ + position: int + + def __post_init__(self) -> None: + _check_int(self.position) + if self.position <= 0: + raise ValueError(f'Position {self.position} must be a positive integer.') + + def __str__(self) -> str: + return f'{self.position}' + + +class Genomic(): + """Convert coordinates to and from genomic points (g./m./o.). + + The HGVS genomic numbering system runs over the sequence + itself, one-based, so a conversion is a shift of one:: + + coordinate 0 1 2 3 4 + | | | | | + point position 1 2 3 4 5 + + >>> from mutalyzer_crossmapper import Genomic, GenomicPoint + >>> crossmap = Genomic() + >>> crossmap.coordinate_to_genomic(0) + GenomicPoint(position=1) + >>> crossmap.genomic_to_coordinate(GenomicPoint(position=1)) + 0 + """ + + def coordinate_to_genomic(self, coordinate: int, length: int | None = None) -> GenomicPoint: + """Convert a coordinate to a genomic point (g./m./o.). + + :arg coordinate: Zero-based coordinate, non-negative. + :arg length: Length of the sequence, None if unknown. + + :returns: Genomic point. + + :raises ValueError: If the coordinate is not a non-negative integer, + if the length is not a positive integer, or if the coordinate + goes outside of the sequence length. + """ + _check_non_negative_int(coordinate) + if length is not None: + _check_positive_int(length) + _check_coordinate_within_length(coordinate, length) + return GenomicPoint(coordinate + 1) -class Genomic(object): - """Genomic crossmap object.""" - def coordinate_to_genomic(self, coordinate): - """Convert a coordinate to a genomic position (g./m./o.). + def genomic_to_coordinate(self, point: GenomicPoint) -> int: + """Convert a genomic point (g./m./o.) to a coordinate. - :arg int coordinate: Coordinate. + :arg point: Genomic point. - :returns int: Genomic position. + :returns: Coordinate. """ - return coordinate + 1 + return point.position - 1 - def genomic_to_coordinate(self, position): - """Convert a genomic position (g./m./o.) to a coordinate. - :arg int position: Genomic position. +@dataclass(slots=True) +class NonCodingPoint(GenomicPoint): + """A position in the noncoding numbering system (n./r.), with an offset + and a region. - :returns int: Coordinate. - """ - return position - 1 + A non-zero offset counts nucleotides from an exon boundary, into an + intron or beyond the transcript. Within an intron either boundary may be + used, but converting from a coordinate always gives the nearest one. The + region is ``'u'`` upstream of the transcript and ``'d'`` downstream of + it, and empty within it. + :arg position: One-based transcript position, positive. + :arg offset: Offset in nucleotides, with respect to the transcript. + :arg region: One of ``''``, ``'u'`` or ``'d'``. + """ + offset: int = 0 + region: str = '' -class NonCoding(Genomic): - """NonCoding crossmap object.""" - def __init__(self, locations, inverted=False): - """ - :arg list locations: List of locus locations. - :arg bool inverted: Orientation. - """ - self._inverted = inverted + allowed_regions = ['', 'u', 'd'] - self._noncoding = MultiLocus(locations, inverted) + def __post_init__(self) -> None: + # Python version 3.11 and 3.10: cannot use super() due to conflicts with slots=True + GenomicPoint.__post_init__(self) - def coordinate_to_noncoding(self, coordinate): - """Convert a coordinate to a noncoding position (n./r.). + _check_int(self.offset) + if self.region not in self.allowed_regions: + raise ValueError( + f'Region {self.region} is invalid, it must be a string from {self.allowed_regions}.' + ) - :arg int coordinate: Coordinate. + def __str__(self) -> str: + if self.offset == 0: + return f'{self.region}{self.position}' + if self.region in ('u', 'd'): + return f'{self.region}{abs(self.offset)}' + return f'{self.region}{self.position}{self.offset:+}' - :returns tuple: Noncoding position. + +class NonCoding(Genomic): + """Convert coordinates to and from noncoding points (n./r.). + + On top of the functionality provided by the ``Genomic`` class, this class + adds the noncoding numbering system. + + The positions run over the exons only, so an intron coordinate takes an + offset with respect to the nearest exon boundary. For a transcript with + exons ``[(5, 8), (11, 14)]``:: + + coordinate 4 5 6 7 8 9 10 11 12 13 14 + | | | | | | | | | | | + point position 1 1 2 3 3 3 4 4 5 6 6 + offset -1 0 0 0 1 2 -1 0 0 0 1 + region u '' '' '' '' '' '' '' '' '' d + + >>> from mutalyzer_crossmapper import NonCoding, NonCodingPoint + >>> crossmap = NonCoding([(5, 8), (11, 14)]) + + The HGVS position "g.9" (coordinate ``8``) is equivalent to position + "n.3+1". + + >>> crossmap.coordinate_to_noncoding(8) + NonCodingPoint(position=3, offset=1, region='') + >>> crossmap.noncoding_to_coordinate(NonCodingPoint(position=3, offset=1)) + 8 + + When the coordinate is upstream or downstream of the transcript, the + region denotes on which side it lies. This makes it possible to + distinguish between intronic positions and those outside of the + transcript. + + >>> crossmap.coordinate_to_noncoding(4) + NonCodingPoint(position=1, offset=-1, region='u') + + For a transcript that resides on the reverse complement strand, set + ``inverted=True``:: + + coordinate 4 5 6 7 8 9 10 11 12 13 14 + | | | | | | | | | | | + point position 6 6 5 4 4 3 3 3 2 1 1 + offset 1 0 0 0 -1 2 1 0 0 0 -1 + region d '' '' '' '' '' '' '' '' '' u + + >>> inverted = NonCoding([(5, 8), (11, 14)], inverted=True) + >>> inverted.coordinate_to_noncoding(8) + NonCodingPoint(position=4, offset=-1, region='') + """ + + def __init__( + self, + locations: list[tuple[int, int]], + inverted: bool = False, + length: int | None = None, + ) -> None: + """Initialize a NonCoding object. + + :arg locations: List of exon locations as zero-based half-open + intervals, sorted ascending and non-overlapping. + :arg inverted: Orientation. + :arg length: Length of the sequence, None if unknown. + + :raises ValueError: If the locations are not a non-empty list of + valid exons, are out of order or overlapping, if the orientation + is not a boolean, or if the length is not a positive integer + large enough to contain the exons. """ - pos = self._noncoding.to_position(coordinate) + self._inverted = inverted + self._noncoding = MultiLocus(locations, inverted=inverted, length=length) - return pos[0] + 1, pos[1], pos[2] + def coordinate_to_noncoding(self, coordinate: int) -> NonCodingPoint: + """Convert a coordinate to a noncoding point (n./r.). - def noncoding_to_coordinate(self, position): - """Convert a noncoding position (n./r.) to a coordinate. + :arg coordinate: Zero-based coordinate, non-negative. - :arg tuple position: Noncoding position. + :returns: Noncoding point. - :returns int: Coordinate. + :raises ValueError: If the coordinate is not a non-negative integer, + or goes outside of the sequence length. + """ + point = self._noncoding.to_position(coordinate) + return NonCodingPoint( + position=point.position + 1, + offset=point.offset, + region=point.region + ) + + def noncoding_to_coordinate(self, point: NonCodingPoint) -> int: + """Convert a noncoding point (n./r.) to a coordinate. + + :arg point: Noncoding point. + + :returns: Coordinate. + + :raises ValueError: If the position is not at the boundary its region + requires, or the offset has the wrong sign or reaches beyond + the sequence length. + :raises IndexError: If the offset reaches beyond the intron, the + offset is at the wrong boundary, or the position exceeds the + length of the transcript. """ - if position[0] > 0: + try: return self._noncoding.to_coordinate( - (position[0] - 1, position[1])) - return self._noncoding.to_coordinate(position) + Point( + position=point.position - 1, + offset=point.offset, + region=point.region + ) + ) + except ValueError as error: + if 'Position' in str(error): + raise ValueError(str(error).replace(str(point.position - 1), str(point.position))) from error + raise error + except IndexError as error: + if 'Position' in str(error): + raise IndexError(str(error).replace(str(point.position - 1), str(point.position))) from error + raise error + + +@dataclass(slots=True) +class CodingPoint(NonCodingPoint): + """A position in the coding numbering system (c.), with an offset and + a region. + + The region denotes the location of the position with respect to the CDS. + This is needed in order to work with the HGVS "-" and "*" positions. + + :arg position: One-based position within its region, positive. + :arg offset: Offset in nucleotides, with respect to the transcript. + :arg region: ``'-'`` upstream of the CDS, ``''`` in the CDS, ``'*'`` + downstream of the CDS, or ``'u'`` and ``'d'`` outside the transcript. + """ + allowed_regions = ['', 'u', 'd', '-', '*'] + + +@dataclass(slots=True) +class ProteinPoint(CodingPoint): + """A codon position in the protein numbering system (p.), with an offset + and a region. + + :arg position: One-based codon position, positive. + :arg offset: Offset in nucleotides, with respect to the transcript. + :arg region: As for a coding position. + :arg position_in_codon: Position within the codon, 1, 2 or 3. + """ + position_in_codon: int = 1 + + def __post_init__(self) -> None: + CodingPoint.__post_init__(self) + + _check_int(self.position_in_codon) + if self.position_in_codon not in (1, 2, 3): + raise ValueError('Position_in_codon must be 1, 2, or 3.') + + def __str__(self) -> str: + if self.offset == 0 and self.region == '': + return f'{self.position}' + return '?' class Coding(NonCoding): - """Coding crossmap object.""" - def __init__(self, locations, cds, inverted=False): - """ - :arg list locations: List of locus locations. - :arg tuple cds: Locus location. - :arg bool inverted: Orientation. + """Convert coordinates to and from coding points (c.). + + On top of the functionality provided by the ``NonCoding`` class, this + class adds the coding and the protein numbering systems. The positions + are counted from the start of the CDS, and the region records whether a + position lies before it, in it or after it. For a transcript with exons + ``[(5, 8), (11, 14)]`` and CDS ``(6, 12)``:: + + coordinate 4 5 6 7 8 9 10 11 12 13 14 + | | | | | | | | | | | + point position 1 1 1 2 2 2 3 3 1 2 2 + offset -1 0 0 0 1 2 -1 0 0 0 1 + region u - '' '' '' '' '' '' * * d + + >>> from mutalyzer_crossmapper import Coding, CodingPoint + >>> crossmap = Coding([(5, 8), (11, 14)], (6, 12)) + + The HGVS position "g.6" (coordinate ``5``) is equivalent to position + "c.-1". + + >>> point = crossmap.coordinate_to_coding(5) + >>> point + CodingPoint(position=1, offset=0, region='-') + >>> crossmap.coding_to_coordinate(point) + 5 + + Likewise, the HGVS position "g.13" (coordinate ``12``) is equivalent to + position "c.*1". + + >>> crossmap.coordinate_to_coding(12) + CodingPoint(position=1, offset=0, region='*') + + The CDS spans a single codon here, so its three nucleotides are at the + coordinates ``6``, ``7`` and ``11``, the last one on the other side of + the intron. + + >>> crossmap.coordinate_to_protein(11) + ProteinPoint(position=1, offset=0, region='', position_in_codon=3) + + The noncoding conversions are inherited, so the same transcript can also + be addressed in the noncoding (n./r.) numbering, which counts over the + exons instead of from the start of the CDS. + + >>> crossmap.coordinate_to_noncoding(12) + NonCodingPoint(position=5, offset=0, region='') + """ + + def __init__( + self, + locations: list[tuple[int, int]], + cds: tuple[int, int], + inverted: bool = False, + length: int|None = None + ) -> None: + """Initialize a Coding object. + + :arg locations: List of exon locations as zero-based half-open + intervals, sorted ascending and non-overlapping. + :arg cds: Location of the CDS as a zero-based half-open interval, + with each end within an exon or at one of its boundaries. The end + of one exon and the start of the next describe the same CDS. + :arg inverted: Orientation. + :arg length: Length of the sequence, None if unknown. + + :raises ValueError: If the locations are not a non-empty list of + valid exons, are out of order or overlapping, if either CDS + boundary lies outside the transcript or strictly inside an + intron, if the orientation is not a boolean, or if the length is + not a positive integer large enough to contain the exons and the + CDS. """ - NonCoding.__init__(self, locations, inverted) - - b0 = self._noncoding.to_position(cds[0]) - b1 = self._noncoding.to_position(cds[1]) + NonCoding.__init__(self, locations, inverted=inverted, length=length) + self._check_cds(cds, locations, length) + cds_start = self._noncoding.to_position(cds[0]) + cds_end = self._noncoding.to_position(cds[1] - 1) if self._inverted: - self._coding = (b1[0] + b1[1] + 1, b0[0] + b0[1] + 1) - self._cds_len = (b0[0] + b0[1]) - (b1[0] + b1[1]) + cds_start, cds_end = cds_end, cds_start + + self._coding = ( + cds_start.position + cds_start.offset, + cds_end.position + cds_end.offset + 1, + ) + self._exons = (0, self._noncoding._position_length) + + def _check_cds( + self, + cds: tuple[int, int], + locations: list[tuple[int, int]], + length: int | None = None, + ) -> None: + """Check if the CDS is valid. + + Each CDS boundary must fall within an exon or coincide with an exon + boundary. Because the CDS and exon intervals are half-open, the end + of an exon and the start of the next exon represent the same + position in the spliced transcript. + """ + _check_locus(cds) + if length is not None: + _check_location_end_within_length(cds[1], length) + + for coordinate in cds: + if coordinate < locations[0][0]: + raise ValueError( + f'CDS boundary {coordinate} of {cds} lies before the first ' + f'exon {locations[0]}.' + ) + if coordinate > locations[-1][1]: + raise ValueError( + f'CDS boundary {coordinate} of {cds} lies after the last ' + f'exon {locations[-1]}.' + ) + + index = nearest_location(locations, coordinate) + start, end = locations[index] + if start <= coordinate <= end: + continue + + # The boundary is in the intron before or after the nearest exon. + left_index = index - 1 if coordinate < start else index + raise ValueError( + f'CDS boundary {coordinate} of {cds} lies inside an intron between ' + f'exons {locations[left_index]} and {locations[left_index + 1]}.' + ) + + def _validate_point(self, position: int, region: str) -> None: + """Validate a coding point model under HGVS rules. + + A position must stay within the stretch of the transcript that its + region numbers. The ``'u'`` and ``'d'`` regions lie outside the + transcript, so a position there anchors at the one its first or last + nucleotide carries. Position ``1`` is also accepted upstream, and + downstream when there is no 3' UTR. + + :arg position: Position. + :arg region: Region. + + :raises ValueError: If the position is beyond the ``'-'``, ``''`` or + ``'*'`` region it is in, or is not an anchor for the ``'u'`` or + ``'d'`` region. + """ + if region == 'u': + allowed: tuple[int, ...] + if self._exons[0] == self._coding[0]: + # Without a 5' UTR the anchor is the first coding position, + # and the degenerate correction anchors at position 1. + allowed = (1,) + else: + allowed = (self._coding[0],) + if position not in allowed: + raise ValueError(f'Position {position} is not in upstream boundary.') + if region == '-': + if position not in range(1, self._coding[0] + 1): + raise ValueError(f'Position {position} exceeds - region.') + if region == '': + if position not in range(1, self._coding[1] - self._coding[0] + 1): + raise ValueError(f'Position {position} exceeds coding region.') + if region == '*': + if position not in range(1, self._exons[1] - self._coding[1] + 1): + raise ValueError(f'Position {position} exceeds * region.') + if region == 'd': + if self._exons[1] == self._coding[1]: + # Without a 3' UTR the anchor is the last coding position, + # and the degenerate correction anchors at position 1. + allowed = (1, self._coding[1] - self._coding[0]) + else: + allowed = (self._exons[1] - self._coding[1],) + if position not in allowed: + raise ValueError(f'Position {position} is not in downstream boundary.') + + + def _coordinate_to_coding(self, coordinate: int) -> CodingPoint: + """Convert a coordinate to a coding point (c.). + + A coordinate outside the transcript is given the ``'u'`` or ``'d'`` + region, leaving the degenerate point to ``coordinate_to_coding``. + + :arg coordinate: Zero-based coordinate, non-negative. + + :returns: Coding point (c.). + """ + noncoding_point = self._noncoding.to_position(coordinate) + + position = noncoding_point.position + offset = noncoding_point.offset + region = noncoding_point.region + + if region == 'u': + if self._exons[0] == self._coding[0]: + position = 1 + else: + position = self._coding[0] + elif region == 'd': + if self._exons[1] == self._coding[1]: + position = self._coding[1] - self._coding[0] + else: + position = position - self._coding[1] + 1 + elif position < self._coding[0]: + position = self._coding[0] - position + region = '-' + elif position >= self._coding[1]: + position = position - self._coding[1] + 1 + region = '*' else: - self._coding = (b0[0] + b0[1], b1[0] + b1[1]) - self._cds_len = (b1[0] + b1[1]) - (b0[0] + b0[1]) - - def _coordinate_to_coding(self, coordinate): - """Convert a coordinate to a coding position (c./r.). - - :arg int coordinate: Coordinate. - - :returns tuple: Coding position (c./r.). + position = position - self._coding[0] + 1 + region = '' + return CodingPoint(position=position, offset=offset, region=region) + + def coordinate_to_coding(self, coordinate: int, degenerate: bool = False) -> CodingPoint: + """Convert a coordinate to a coding point (c.). + + A coordinate outside the transcript is given the ``'u'`` or ``'d'`` + region. With ``degenerate`` set it is expressed in the ``'-'`` or + ``'*'`` region instead, by counting on past the end of the + transcript, so only the outermost columns of the table above + change:: + + coordinate 4 5 6 7 8 9 10 11 12 13 14 + | | | | | | | | | | | + point position 2 1 1 2 2 2 3 3 1 2 3 + offset 0 0 0 0 1 2 -1 0 0 0 0 + region - - '' '' '' '' '' '' * * * + + >>> from mutalyzer_crossmapper import Coding + >>> crossmap = Coding([(5, 8), (11, 14)], (6, 12)) + >>> crossmap.coordinate_to_coding(4, degenerate=True) + CodingPoint(position=2, offset=0, region='-') + >>> crossmap.coordinate_to_coding(14, degenerate=True) + CodingPoint(position=3, offset=0, region='*') + + :arg coordinate: Zero-based coordinate, non-negative. + :arg degenerate: Return a degenerate coding point. + + :returns: Coding point (c.). + + :raises ValueError: If the coordinate is not a non-negative integer, + or goes outside of the sequence length. """ - pos = self._noncoding.to_position(coordinate) - - if pos[0] < self._coding[0]: - return pos[0] - self._coding[0], pos[1], -1, pos[2] - elif pos[0] >= self._coding[1]: - return pos[0] - self._coding[1] + 1, pos[1], 1, pos[2] - return pos[0] - self._coding[0] + 1, pos[1], 0, pos[2] - - def coordinate_to_coding(self, coordinate, degenerate=False): - """Convert a coordinate to a coding position (c./r.). - - :arg int coordinate: Coordinate. - :arg bool degenerate: Return a degenerate position. - - :returns tuple: Coding position (c./r.). + point = self._coordinate_to_coding(coordinate) + + if not degenerate: + return point + + offset = point.offset + if point.region == 'u': + if self._coding[0] == 0: + position = -offset + else: + position = point.position - offset + return CodingPoint(position=position, offset=0, region='-') + if point.region == 'd': + if self._exons[1] == self._coding[1]: + position = offset + else: + position = point.position + offset + return CodingPoint(position=position, offset=0, region='*') + return point + + def _coding_to_coordinate(self, point: CodingPoint) -> int: + """Convert a coding point (c.) to a coordinate. + + A degenerate point is rejected rather than corrected, so the + position must stay within the region it names. + + :arg point: Coding point (c.). + + :returns: Coordinate. """ - pos = self._coordinate_to_coding(coordinate) + region = point.region + position = point.position - if degenerate and pos[3]: - if pos[2] == 0: - if pos[0] == 1 and pos[1] < 0: - return pos[1], 0, -1, pos[3] - if pos[0] == self._cds_len and pos[1] > 0: - return pos[0] + pos[1] - self._cds_len, 0, 1, pos[3] - return pos[0] + pos[1], 0, pos[2], pos[3] + self._validate_point(position, region) - return pos - - def coding_to_coordinate(self, position): - """Convert a coding position (c./r.) to a coordinate. - - :arg tuple position: Coding position (c./r.). - - :returns int: Coordinate. - """ - if position[2] == -1: + if region in ('u', 'd'): + if region == 'u': + position = 1 + if region == 'd': + position = self._exons[1] return self._noncoding.to_coordinate( - (position[0] + self._coding[0], position[1])) - elif position[2] == 1: - return self._noncoding.to_coordinate( - (position[0] + self._coding[1] - 1, position[1])) - return self._noncoding.to_coordinate( - (position[0] + self._coding[0] - 1, position[1])) - - def coordinate_to_protein(self, coordinate): - """Convert a coordinate to a protein position (p.). + Point(position=position - 1, region=point.region, offset=point.offset) + ) - :arg int coordinate: Coordinate. + if region == '': + position = position + self._coding[0] - 1 + elif region == '-': + position = self._coding[0] - position + elif region == '*': + position = self._coding[1] + position - 1 - :returns tuple: Protein position (p.). + try: + return self._noncoding.to_coordinate( + Point(position=position, region='', offset=point.offset) + ) + except ValueError as error: + if 'Position' in str(error): + raise ValueError(str(error).replace(str(position), str(point.position))) from error + raise + + def coding_to_coordinate(self, point: CodingPoint) -> int: + """Convert a coding point (c.) to a coordinate. + + A degenerate point, one with offset zero whose position counts on + past the end of the transcript in the ``'-'`` or ``'*'`` region, is + silently corrected. + + :arg point: Coding point (c.). + + :returns: Coordinate. + + :raises ValueError: If the position is not in the region it claims, + a non-zero offset is not at an exon boundary, the offset has + the wrong sign, or the position or offset reaches beyond + the sequence. + :raises IndexError: If the offset reaches beyond the intron, or the + offset is at the wrong boundary. """ - pos = self.coordinate_to_coding(coordinate) - - if pos[2] == -1: - return (pos[0] // 3, pos[0] % 3 + 1, *pos[1:]) - return ((pos[0] + 2) // 3, (pos[0] + 2) % 3 + 1, *pos[1:]) - - def protein_to_coordinate(self, position): - """Convert a protein position (p.) to a coordinate. - - :arg tuple position: Protein position (p.). - - :returns int: Coordinate. + # Silently correct for degenerate points + corrected = None + if point.offset == 0: + utr5_length = self._coding[0] + utr3_length = self._exons[1] - self._coding[1] + + if point.region == '-' and point.position > utr5_length: + corrected = CodingPoint( + position=utr5_length if utr5_length else 1, + offset=utr5_length - point.position, + region='u', + ) + elif point.region == '*' and point.position > utr3_length: + corrected = CodingPoint( + position=utr3_length if utr3_length else 1, + offset=point.position - utr3_length, + region='d', + ) + + if corrected is None: + return self._coding_to_coordinate(point) + + try: + return self._coding_to_coordinate(corrected) + except ValueError as error: + # The correction rewrote the point, so report what was given. + raise ValueError( + f'Position {point.position} of the {point.region} region ' + f'exceeds the sequence.' + ) from error + + def coordinate_to_protein(self, coordinate: int) -> ProteinPoint: + """Convert a coordinate to a protein point (p.). + + The position is that of the codon, with ``position_in_codon`` + selecting one of its three nucleotides. A codon may be split by an + intron. For a transcript with exons ``[(5, 8), (11, 17)]`` and CDS + ``(6, 15)``, the first codon is at the coordinates ``6``, ``7`` and + ``11``:: + + coordinate 5 6 7 8 9 10 11 12 13 14 15 + | | | | | | | | | | | + point position 1 1 1 1 1 1 1 2 2 2 1 + offset 0 0 0 1 2 -1 0 0 0 0 0 + region - '' '' '' '' '' '' '' '' '' * + in codon 3 1 2 2 2 3 3 1 2 3 1 + + >>> from mutalyzer_crossmapper import Coding + >>> crossmap = Coding([(5, 8), (11, 17)], (6, 15)) + >>> crossmap.coordinate_to_protein(11) + ProteinPoint(position=1, offset=0, region='', position_in_codon=3) + >>> crossmap.coordinate_to_protein(12) + ProteinPoint(position=2, offset=0, region='', position_in_codon=1) + + :arg coordinate: Zero-based coordinate, non-negative. + + :returns: Protein point (p.). + + :raises ValueError: If the coordinate is not a non-negative integer, + or goes outside of the sequence length. """ - if position[3] == -1: - return self.coding_to_coordinate( - (3 * position[0] + position[1] - 1, *position[2:])) + point = self.coordinate_to_coding(coordinate) + + position = point.position + if point.region in ('-', 'u'): + return ProteinPoint( + position=-(-position // 3), + position_in_codon=-position % 3 + 1, + region=point.region, + offset=point.offset + ) + return ProteinPoint( + position=(position + 2) // 3, + position_in_codon=(position + 2) % 3 + 1, + region=point.region, + offset=point.offset + ) + + def protein_to_coordinate(self, point: ProteinPoint) -> int: + """Convert a protein point (p.) to a coordinate. + + A degenerate point is silently corrected, as in + ``coding_to_coordinate``. + + Note that conversion and validation are delegated to + ``coding_to_coordinate``. Error messages may therefore report + the intermediate coding nucleotide position rather than + the supplied protein position. + + :arg point: Protein point (p.). + + :returns: Coordinate. + + :raises ValueError: If the position is not in the region it claims, + a non-zero offset is not at an exon boundary, the offset has + the wrong sign, or the position or offset reaches beyond + the sequence. + :raises IndexError: If the offset reaches beyond the intron, or the + offset is at the wrong boundary. + """ + if point.region in ('-', 'u'): + position = 3 * point.position - point.position_in_codon + 1 + else: + position = 3 * point.position + point.position_in_codon - 3 return self.coding_to_coordinate( - (3 * position[0] + position[1] - 3, *position[2:])) + CodingPoint(position=position, offset=point.offset, region=point.region) + ) diff --git a/mutalyzer_crossmapper/location.py b/mutalyzer_crossmapper/location.py index e580672..37593b4 100644 --- a/mutalyzer_crossmapper/location.py +++ b/mutalyzer_crossmapper/location.py @@ -1,13 +1,13 @@ -def _nearest_boundary(lb, rb, c, p): +def _nearest_boundary(lb: int, rb: int, c: int, p: int) -> int: """Find the boundary nearest to `c`. In case of a draw, the parameter `p` decides which one is chosen. - :arg int lb: Left boundary. - :arg int rb: Right boundary. - :arg int c: Coordinate (`lb` <= `c` <= `rb`)). - :arg int p: Preference in case of a draw: 0: left, 1: right. + :arg lb: Left boundary. + :arg rb: Right boundary. + :arg c: Coordinate (`lb` <= `c` <= `rb`)). + :arg p: Preference in case of a draw: 0: left, 1: right. - :returns int: Nearest boundary: 0: left, 1: right. + :returns: Nearest boundary: 0: left, 1: right. """ dl = c - lb + 1 dr = rb - c @@ -19,15 +19,16 @@ def _nearest_boundary(lb, rb, c, p): return p -def nearest_location(ls, c, p=0): +def nearest_location(ls: list[tuple[int, int]], c: int, p: int = 0) -> int: """Find the location nearest to `c`. In case of a draw, the parameter `p` decides which index is chosen. - :arg list ls: List of locations. - :arg int c: Coordinate. - :arg int p: Preference in case of a draw: 0: left, 1: right. + :arg ls: Non-empty list of half-open intervals, sorted ascending and + non-overlapping. + :arg c: Coordinate. + :arg p: Preference in case of a draw: 0: left, 1: right. - :returns int: Nearest location. + :returns: Zero-based index of the nearest location. """ rb = len(ls) - 1 lb = 0 diff --git a/mutalyzer_crossmapper/locus.py b/mutalyzer_crossmapper/locus.py index 14a9d20..10aada0 100644 --- a/mutalyzer_crossmapper/locus.py +++ b/mutalyzer_crossmapper/locus.py @@ -1,42 +1,182 @@ -class Locus(object): - """Locus object.""" - def __init__(self, location, inverted=False): - """ - :arg tuple location: Locus location. - :arg bool inverted: Orientation. +"""Conversions between coordinates and points for a single locus. + +A locus is a half-open interval ``[start, end)`` defined on a sequence. +It can be described either by a coordinate, which is a zero-based index into +that sequence, or by a point, which is a zero-based position within the locus +plus an offset relative to its start or end. +""" +from dataclasses import dataclass + + +def _check_int(value: int) -> None: + """Check if the input value type is integer.""" + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError('Value must be an integer.') + + +def _check_non_negative_int(value: int) -> None: + """Check if the value is a non-negative integer.""" + _check_int(value) + if value < 0: + raise ValueError('Value must be non-negative.') + + +def _check_positive_int(value: int) -> None: + """Check if the value is a positive integer.""" + _check_int(value) + if value < 1: + raise ValueError(f'Value {value} is not positive.') + + +@dataclass(slots=True) +class Point: + """A position within a locus, with an offset from its start or end. + + The offset is zero for positions inside the locus. A non-zero offset is + only valid at a boundary: negative at the start, positive at the end. + + :arg position: Zero-based position within the locus. + :arg offset: Offset relative to the locus start or end. + """ + position: int + offset: int = 0 + + def __post_init__(self) -> None: + _check_non_negative_int(self.position) + _check_int(self.offset) + + +def _check_locus(location: tuple[int, int]) -> None: + """Check if the locus location is valid.""" + if not isinstance(location, tuple) or len(location) != 2: + raise ValueError('Locus must be a tuple of two values.') + + for value in location: + _check_non_negative_int(value) + + if location[0] >= location[1]: + raise ValueError( + f'Locus start {location[0]} must be smaller than locus end {location[1]}.') + + +class Locus(): + """Convert coordinates to and from points within a single locus. + + For ``Locus((10, 15))``, the half-open interval contains coordinates 10 to + 14 and positions 0 to 4. Coordinates outside the locus are represented + as boundary positions with offsets:: + + coordinate 8 9 10 11 12 13 14 15 16 + | | | | | | | | | + point position 0 0 0 1 2 3 4 4 4 + offset -2 -1 0 0 0 0 0 1 2 + + >>> from mutalyzer_crossmapper.locus import Locus, Point + >>> locus = Locus((10, 15)) + >>> locus.to_position(9) + Point(position=0, offset=-1) + >>> locus.to_position(15) + Point(position=4, offset=1) + >>> locus.to_coordinate(Point(position=4, offset=1)) + 15 + + For a locus on the reverse complement strand, set ``inverted=True``:: + + coordinate 8 9 10 11 12 13 14 15 16 + | | | | | | | | | + point position 4 4 4 3 2 1 0 0 0 + offset 2 1 0 0 0 0 0 -1 -2 + + >>> inverted = Locus((10, 15), inverted=True) + >>> inverted.to_position(14) + Point(position=0, offset=0) + >>> inverted.to_position(15) + Point(position=0, offset=-1) + """ + + def __init__(self, location: tuple[int, int], inverted: bool = False) -> None: + """Initialize a Locus object. + + :arg location: Half-open interval, with the start strictly smaller + than the end. + :arg inverted: Orientation. + + :raises ValueError: If the location is not a tuple of two non-negative + integers, its start is not smaller than its end, or the + orientation is not a boolean. """ - self._inverted = inverted + _check_locus(location) + if not isinstance(inverted, bool): + raise ValueError(f'Value {inverted} is not a boolean.') + self._inverted = inverted self.boundary = location[0], location[1] - 1 - self._end = self.boundary[1] - self.boundary[0] + self._end = location[1] - location[0] # one-based length of the locus - def to_position(self, coordinate): - """Convert a coordinate to a proper position. + def _validate_point(self, position: int, offset: int) -> None: + """Check that a non-zero offset is at the matching locus boundary. - :arg int coordinate: Coordinate. + Negative offsets belong at the start, positive ones at the end, and the + position must lie within the locus. + """ + if offset != 0 and position not in (0, self._end - 1): + raise ValueError(f'Position {position} is not at a locus boundary.') + if offset < 0 and position != 0: + raise IndexError(f'Offset {offset} should be at a locus start.') + if offset > 0 and position != self._end - 1: + raise IndexError(f'Offset {offset} should be at a locus end.') + if position > self._end - 1: + raise IndexError(f'Position {position} exceeds locus length.') + + def to_position(self, coordinate: int) -> Point: + """Convert a coordinate to a locus point. + + Coordinates outside the locus are converted to a boundary position + with a non-zero offset. + + :arg coordinate: Zero-based coordinate, non-negative. + + :returns: Locus point. - :returns tuple: Position. + :raises ValueError: If the coordinate is not a non-negative integer. """ + _check_non_negative_int(coordinate) + if self._inverted: if coordinate > self.boundary[1]: - return 0, self.boundary[1] - coordinate + return Point(position=0, offset=self.boundary[1] - coordinate) if coordinate < self.boundary[0]: - return self._end, self.boundary[0] - coordinate - return self.boundary[1] - coordinate, 0 + return Point(position=self._end - 1, offset=self.boundary[0] - coordinate) + return Point(position=self.boundary[1] - coordinate, offset=0) if coordinate < self.boundary[0]: - return 0, coordinate - self.boundary[0] + return Point(position=0, offset=coordinate - self.boundary[0]) if coordinate > self.boundary[1]: - return self._end, coordinate - self.boundary[1] - return coordinate - self.boundary[0], 0 + return Point(position=self._end - 1, offset=coordinate - self.boundary[1]) + return Point(position=coordinate - self.boundary[0], offset=0) + + def to_coordinate(self, point: Point) -> int: + """Convert a locus point to a coordinate. - def to_coordinate(self, position): - """Convert a position to a coordinate. + :arg point: Locus point, its position relative to this locus. - :arg int position: Position. + :returns: Coordinate. - :returns int: Coordinate. + :raises ValueError: If a non-zero offset is not at a locus boundary, or + the point converts to a negative coordinate. + :raises IndexError: If the position exceeds the locus length, or the + offset is at the wrong boundary. """ + self._validate_point(point.position, point.offset) + if self._inverted: - return self.boundary[1] - position[0] - position[1] - return self.boundary[0] + position[0] + position[1] + coordinate = self.boundary[1] - point.position - point.offset + else: + coordinate = self.boundary[0] + point.position + point.offset + if coordinate < 0: + raise ValueError( + f'Position {point.position} with offset {point.offset} converts to ' + f'negative coordinate {coordinate}.' + ) + + return coordinate diff --git a/mutalyzer_crossmapper/multi_locus.py b/mutalyzer_crossmapper/multi_locus.py index 9836549..e896953 100644 --- a/mutalyzer_crossmapper/multi_locus.py +++ b/mutalyzer_crossmapper/multi_locus.py @@ -1,47 +1,249 @@ +"""Conversions between coordinates and points for multiple loci. + +The loci are typically the exons of a transcript, with the gaps between them +its introns. They must be sorted and must not overlap, both of which are +checked on construction. Zero-based positions run continuously across them, +as if the loci were joined end to end. A coordinate that falls in a gap +between two loci is expressed as an offset from the nearest locus boundary. +""" from bisect import bisect_right from itertools import accumulate +from dataclasses import dataclass from .location import nearest_location -from .locus import Locus +from .locus import Locus, _check_non_negative_int, _check_positive_int +from .locus import Point as LocusPoint + + +@dataclass(slots=True) +class Point(LocusPoint): + """A position within multiple loci, with an offset and a region. + + The region is ``'u'`` upstream of the joined loci and ``'d'`` downstream, + and empty everywhere in between, including in a gap between two loci. + Upstream is at the lower coordinates only when not inverted. + + :arg position: Zero-based position within the concatenated loci. + :arg offset: Offset relative to a locus boundary. + :arg region: One of ``''``, ``'u'`` or ``'d'``. + """ + + region: str = '' + + def __post_init__(self) -> None: + LocusPoint.__post_init__(self) + if self.region not in ('', 'u', 'd'): + raise ValueError( + f'Region {self.region} is invalid, it must be a string from "", "u" or "d".' + ) + + +def _check_coordinate_within_length(value: int, length: int) -> None: + """Check if a zero-based coordinate is within sequence length.""" + if value >= length: + raise ValueError( + f'Coordinate {value} is not within the bounds of the sequence length {length}.' + ) + + +def _check_location_end_within_length(value: int, length: int) -> None: + """Check if a half-open interval end is within sequence length.""" + if value > length: + raise ValueError( + f'Location end {value} is inconsistent with sequence length {length}.' + ) + +def _check_locations(locations: list[tuple[int, int]], length: int | None = None) -> None: + """Check ordering, overlap and sequence bounds of the locations.""" + for l1, l2 in zip(locations, locations[1:]): + if l2[0] < l1[0]: + raise ValueError(f'Locus {l1} and locus {l2} are not in ascending order.') + if l2[0] < l1[1]: + raise ValueError(f'Locus {l1} and locus {l2} are overlapping.') -def _offsets(locations, orientation): + if length is not None: + _check_positive_int(length) + _check_location_end_within_length(locations[-1][1], length) + + +def _offsets(locations: list[tuple[int, int]], orientation: int) -> list[int]: """For each location, calculate the length of the preceding locations. - :arg list locations: List of locations. - :arg int orientation: Direction of {locations}. + :arg locations: List of locations. + :arg orientation: Direction of locations. - :returns list: List of cumulative location lengths. + :returns: List of cumulative location lengths. """ - return [0] + list(accumulate(map( - lambda x: x[1] - x[0], locations[::orientation][:-1]))) + return [0] + list(accumulate(map(lambda x: x[1] - x[0], locations[::orientation][:-1]))) -class MultiLocus(object): - """MultiLocus object.""" - def __init__(self, locations, inverted=False): - """ - :arg list locations: List of locus locations. - :arg bool inverted: Orientation. +class MultiLocus(): + """Convert coordinates to and from points across multiple loci. + + For ``MultiLocus([(10, 13), (16, 19)])``, positions 0 to 2 cover the first + locus and 3 to 5 the second. A coordinate in a gap between two loci takes + an offset from whichever locus boundary is nearest. When a gap is of odd + length, its middle coordinate is arbitrarily given a positive offset:: + + coordinate 9 10 11 12 13 14 15 16 17 18 19 + | | | | | | | | | | | + point position 0 0 1 2 2 2 3 3 4 5 5 + offset -1 0 0 0 1 2 -1 0 0 0 1 + region u '' '' '' '' '' '' '' '' '' d + + >>> from mutalyzer_crossmapper.multi_locus import MultiLocus, Point + >>> multi_locus = MultiLocus([(10, 13), (16, 19)]) + >>> multi_locus.to_position(14) + Point(position=2, offset=2, region='') + >>> multi_locus.to_coordinate(Point(position=2, offset=2, region='')) + 14 + + Only the offset staying within the gap is enforced, so a coordinate + there can also be described from the other locus. Converting back always + gives the nearest boundary form. + + >>> multi_locus.to_coordinate(Point(position=3, offset=-2, region='')) + 14 + >>> multi_locus.to_coordinate(Point(position=3, offset=-4, region='')) + Traceback (most recent call last): + IndexError: Offset -4 exceeds intron length. + + For loci on the reverse complement strand, set ``inverted=True``:: + + coordinate 9 10 11 12 13 14 15 16 17 18 19 + | | | | | | | | | | | + point position 5 5 4 3 3 2 2 2 1 0 0 + offset 1 0 0 0 -1 2 1 0 0 0 -1 + region d '' '' '' '' '' '' '' '' '' u + + >>> inverted = MultiLocus([(10, 13), (16, 19)], inverted=True) + >>> inverted.to_position(14) + Point(position=2, offset=2, region='') + >>> inverted.to_position(18) + Point(position=0, offset=0, region='') + """ + + def __init__( + self, + locations: list[tuple[int, int]], + inverted: bool = False, + length: int | None = None, + ) -> None: + """Initialize a MultiLocus object. + + :arg locations: List of half-open intervals, sorted ascending and + non-overlapping. + :arg inverted: Orientation. + :arg length: Length of the sequence, None if unknown. The + last location may end exactly at it. + + :raises ValueError: If the locations are not a non-empty list of + valid loci, are out of order or overlapping, if the orientation + is not a boolean, or if the length is not a positive integer the + last location fits in. """ + if not isinstance(locations, list) or not locations: + raise ValueError('Locations must be a non-empty list of tuples.') + if not isinstance(inverted, bool): + raise ValueError(f'Value {inverted} is not a boolean.') + + self._loci = [Locus(location, inverted) for location in locations] + _check_locations(locations, length) + self._locations = locations self._inverted = inverted + self._sequence_length = length - self._loci = [Locus(location, inverted) for location in locations] self._orientation = -1 if inverted else 1 self._offsets = _offsets(locations, self._orientation) + # number of positions covered by the loci, gaps excluded + self._position_length = sum(end - start for start, end in locations) + + def _validate_coord(self, coordinate: int) -> None: + """Check that the coordinate is within the sequence. + + Without a sequence length there is no upper bound to check against. + """ + if self._sequence_length is not None: + _check_coordinate_within_length(coordinate, self._sequence_length) + + def _validate_point(self, index: int, position: int, offset: int, region: str) -> None: + """Check that a point is described from the right side of the loci. + + In the ``'u'`` and ``'d'`` regions the position must be the outermost + one and the offset must point away from the loci. Elsewhere the offset + must stay within the gap between two loci that it reaches into, and a + point on the outer edge of the first or last locus belongs to ``'u'`` + or ``'d'`` instead. + + :arg index: Index of the locus. + :arg position: Position. + :arg offset: Offset. + :arg region: Region. - def _direction(self, index): + :raises ValueError: If the position is not the outermost one for its + region, the offset has the wrong sign, or it reaches beyond + the sequence. + :raises IndexError: If the offset reaches beyond the intron. + """ + if region == 'u': + if position != 0: + raise ValueError(f'Position {position} is not at upstream boundary.') + if offset >= 0: + raise ValueError(f'Offset {offset} at upstream region should be negative.') + + if region == 'd': + if position != self._position_length - 1: + raise ValueError(f'Position {position} is not at downstream boundary.') + if offset <= 0: + raise ValueError(f'Offset {offset} at downstream region should be positive.') + + if region in ('u', 'd'): + if self._orientation * offset < 0: + max_offset = self._loci[0].boundary[0] + elif self._sequence_length is not None: + max_offset = self._sequence_length - self._loci[-1].boundary[1] - 1 + else: + return + + if abs(offset) > max_offset: + side = 'upstream' if region == 'u' else 'downstream' + raise ValueError(f'Offset {offset} exceeds {side} region.') + + if region == '' and offset != 0: + if offset < 0 and index == 0 and position == 0: + raise ValueError( + f'Offset {offset} at the first locus should be in the upstream region.' + ) + if offset > 0 and index == len(self._loci) - 1 and position == self._position_length - 1: + raise ValueError( + f'Offset {offset} at the last locus should be in the downstream region.' + ) + + neighbor_index = index + (-1 if offset < 0 else 1) + if 0 <= neighbor_index < len(self._loci): + left_index = min(self._direction(index), self._direction(neighbor_index)) + gap_length = ( + self._loci[left_index + 1].boundary[0] + - self._loci[left_index].boundary[1] + - 1 + ) + if abs(offset) > gap_length: + raise IndexError(f'Offset {offset} exceeds intron length.') + + def _direction(self, index: int) -> int: + """Convert a locus index between position and coordinate order.""" if self._inverted: return len(self._offsets) - index - 1 return index - def outside(self, coordinate): + def _outside(self, coordinate: int) -> int: """Calculate the offset relative to this MultiLocus. - :arg int coordinate: Coordinate. + :arg coordinate: Coordinate. - :returns int: Negative: upstream, 0: inside, positive: downstream. + :returns: Negative: upstream, 0: inside, positive: downstream. """ if coordinate < self._loci[0].boundary[0]: return coordinate - self._loci[0].boundary[0] @@ -49,32 +251,75 @@ def outside(self, coordinate): return coordinate - self._loci[-1].boundary[1] return 0 - def to_position(self, coordinate): - """Convert a coordinate to a position. + def to_position(self, coordinate: int) -> Point: + """Convert a coordinate to a multi locus point. + + A coordinate in a gap between two loci is always given the nearest + boundary form, and one outside the loci altogether the ``'u'`` or + ``'d'`` region. + + :arg coordinate: Zero-based coordinate, non-negative. - :arg int coordinate: Coordinate. + :returns: Multi locus point. - :returns tuple: Position. + :raises ValueError: If the coordinate is not a non-negative integer, or + lies beyond the sequence. """ + _check_non_negative_int(coordinate) + self._validate_coord(coordinate) index = nearest_location(self._locations, coordinate, self._inverted) - outside = self._orientation * self.outside(coordinate) - location = self._loci[index].to_position(coordinate) + outside = self._orientation * self._outside(coordinate) + region = 'u' if outside < 0 else 'd' if outside > 0 else '' + point = self._loci[index].to_position(coordinate) - return ( - location[0] + self._offsets[self._direction(index)], - location[1], - outside) + return Point( + position=point.position + self._offsets[self._direction(index)], + offset=point.offset, + region=region, + ) - def to_coordinate(self, position): - """Convert a position to a coordinate. + def to_coordinate(self, point: Point) -> int: + """Convert a multi locus point to a coordinate. - :arg int position: Position. + A coordinate in a gap between two loci can be described from either + flanking locus and both forms are accepted, so converting the result + back with ``to_position`` does not always give the original point. - :returns int: Coordinate. + :arg point: Multi locus point, its position relative to the + concatenated loci. + + :returns: Coordinate. + + :raises ValueError: If the position is not the outermost one for its + region, the offset has the wrong sign, or it reaches beyond + the sequence. + :raises IndexError: If the offset reaches beyond the intron, or the + position exceeds the length of the loci. """ index = min( - len(self._offsets), - max(0, bisect_right(self._offsets, position[0]) - 1)) + len(self._offsets), max(0, bisect_right(self._offsets, point.position) - 1) + ) + self._validate_point(index, point.position, point.offset, point.region) + + try: + return self._loci[self._direction(index)].to_coordinate( + LocusPoint( + position=point.position - self._offsets[index], + offset=point.offset, + ) + ) - return self._loci[self._direction(index)].to_coordinate( - (position[0] - self._offsets[index], position[1])) + except ValueError as error: + if 'Position' in str(error): + raise ValueError( + str(error).replace( + str(point.position - self._offsets[index]), str(point.position) + ) + ) from error + raise + except IndexError as error: + if 'Position' in str(error): + raise IndexError( + f'Position {point.position} exceeds multi locus length.' + ) from error + raise diff --git a/setup.cfg b/setup.cfg index ca7be54..d0c8065 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,15 +17,16 @@ classifiers = [options] packages = find: +python_requires = >=3.10 [options.extras_require] tests = pytest-cov>=2.10.0 - pytest-pep8>=1.0.6 pytest>=5.4.3 [tool:pytest] -pep8ignore = docs/conf.py ALL +addopts = --doctest-modules --doctest-glob=*.rst +testpaths = mutalyzer_crossmapper tests README.rst docs/library.rst [coverage:run] source = mutalyzer_crossmapper diff --git a/tests/helper.py b/tests/helper.py index a17b119..59b5b1d 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -1,9 +1,9 @@ def invariant(f, x, f_i, y): - assert f(x) == y - assert f_i(y) == x + args = x if isinstance(x, tuple) else (x,) + assert f(*args) == y + assert f_i(y) == args[0] def degenerate_equal(f, coordinate, locations): assert f(locations[0]) == coordinate - assert len( - set(map(f, locations))) == 1 + assert len(set(map(f, locations))) == 1 diff --git a/tests/test_crossmapper.py b/tests/test_crossmapper.py index dbe6b47..61ce956 100644 --- a/tests/test_crossmapper.py +++ b/tests/test_crossmapper.py @@ -1,19 +1,156 @@ -from mutalyzer_crossmapper import Coding, Genomic, NonCoding +from mutalyzer_crossmapper.crossmapper import Genomic, NonCoding, Coding, GenomicPoint, NonCodingPoint, CodingPoint, ProteinPoint from helper import degenerate_equal, invariant +import pytest _exons = [(5, 8), (14, 20), (30, 35), (40, 44), (50, 52), (70, 72)] _cds = (32, 43) +def test_GenomicPoint_invalid_initialization(): + """GenomicPoint cannot be initialized with invalid position.""" + with pytest.raises(ValueError) as error: + GenomicPoint(position=0) + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + GenomicPoint(position=-1) + assert str(error.value) == 'Position -1 must be a positive integer.' + with pytest.raises(ValueError) as error: + GenomicPoint(position=[101]) + assert str(error.value) == 'Value must be an integer.' + + def test_Genomic(): """Genomic positions are coordinates incremented by one.""" crossmap = Genomic() invariant( - crossmap.coordinate_to_genomic, 0, crossmap.genomic_to_coordinate, 1) + crossmap.coordinate_to_genomic, + 0, + crossmap.genomic_to_coordinate, + GenomicPoint(position=1), + ) + invariant( + crossmap.coordinate_to_genomic, + 98, + crossmap.genomic_to_coordinate, + GenomicPoint(position=99), + ) + + +def test_Genomic_invalid_with_length(): + """Raise ValueError if coordinate is out of bounds.""" + crossmap = Genomic() + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_genomic(-1, 99) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_genomic(99, 99) + assert str(error.value) == 'Coordinate 99 is not within the bounds of the sequence length 99.' + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_genomic(0, 0) + assert str(error.value) == 'Value 0 is not positive.' + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_genomic(0, -1) + assert str(error.value) == 'Value -1 is not positive.' + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_genomic(0, '99') + assert str(error.value) == 'Value must be an integer.' + + +def test_Genomic_with_length(): + """Genomic positions are coordinates incremented by one.""" + crossmap = Genomic() + + invariant( + crossmap.coordinate_to_genomic, + (0, 99), + crossmap.genomic_to_coordinate, + GenomicPoint(position=1), + ) invariant( - crossmap.coordinate_to_genomic, 98, crossmap.genomic_to_coordinate, 99) + crossmap.coordinate_to_genomic, + (98, 99), + crossmap.genomic_to_coordinate, + GenomicPoint(position=99), + ) + + +def test_NonCodingPoint_invalid_initialization(): + """Raise error with invalid initialization.""" + with pytest.raises(ValueError) as error: + NonCodingPoint(position=0, offset=0, region='u') + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + NonCodingPoint(position=0, offset=0, region='d') + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + NonCodingPoint(position=0, offset=0, region='') + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + NonCodingPoint(position=0, offset=0, region='*') + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + NonCodingPoint(position=-1, offset=0, region='') + assert str(error.value) == 'Position -1 must be a positive integer.' + with pytest.raises(ValueError) as error: + NonCodingPoint(position=1, offset=None, region='u') + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + NonCodingPoint(position=1, offset=1, region='-') + assert str(error.value) == "Region - is invalid, it must be a string from ['', 'u', 'd']." + + +def test_NonCoding_invalid(): + """Raise ValueError if noncoding is invalid.""" + with pytest.raises(ValueError) as error: + NonCoding([()]) + assert str(error.value) == 'Locus must be a tuple of two values.' + with pytest.raises(ValueError) as error: + NonCoding([(10)]) + assert str(error.value) == 'Locus must be a tuple of two values.' + with pytest.raises(ValueError) as error: + NonCoding([(10, 20), (15, 25)]) + assert str(error.value) == 'Locus (10, 20) and locus (15, 25) are overlapping.' + with pytest.raises(ValueError) as error: + NonCoding([(None, 20), (30, None)]) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + NonCoding(_exons, length=70) + assert str(error.value) == 'Location end 72 is inconsistent with sequence length 70.' + + # Reverse orientation + with pytest.raises(ValueError) as error: + NonCoding([()], inverted=True) + assert str(error.value) == 'Locus must be a tuple of two values.' + with pytest.raises(ValueError) as error: + NonCoding([(10)], inverted=True) + assert str(error.value) == 'Locus must be a tuple of two values.' + with pytest.raises(ValueError) as error: + NonCoding([(10, 20), (15, 25)], inverted=True) + assert str(error.value) == 'Locus (10, 20) and locus (15, 25) are overlapping.' + with pytest.raises(ValueError) as error: + NonCoding([(None, 20), (30, None)], inverted=True) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + NonCoding(_exons, inverted=True, length=70) + assert str(error.value) == 'Location end 72 is inconsistent with sequence length 70.' + + +def test_NonCoding_invalid_with_length(): + """Raise ValueError if coordinate is out of bounds.""" + with pytest.raises(ValueError) as error: + NonCoding(_exons, length=70) + assert str(error.value) == 'Location end 72 is inconsistent with sequence length 70.' + + # Reverse orientation + with pytest.raises(ValueError) as error: + NonCoding(_exons, inverted=True, length=70) + assert str(error.value) == 'Location end 72 is inconsistent with sequence length 70.' + + with pytest.raises(ValueError) as error: + NonCoding(_exons, length=0) + assert str(error.value) == 'Value 0 is not positive.' def test_NonCoding(): @@ -22,112 +159,674 @@ def test_NonCoding(): # Boundary between upstream and transcript. invariant( - crossmap.coordinate_to_noncoding, 4, - crossmap.noncoding_to_coordinate, (1, -1, -1)) + crossmap.coordinate_to_noncoding, + 3 , + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=-2, region='u'), + ) + invariant( + crossmap.coordinate_to_noncoding, + 4, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=-1, region='u'), + ) invariant( - crossmap.coordinate_to_noncoding, 5, - crossmap.noncoding_to_coordinate, (1, 0, 0)) + crossmap.coordinate_to_noncoding, + 5, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=0, region=''), + ) # Boundary between downstream and transcript. invariant( - crossmap.coordinate_to_noncoding, 71, - crossmap.noncoding_to_coordinate, (22, 0, 0)) + crossmap.coordinate_to_noncoding, + 71, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=0, region=''), + ) invariant( - crossmap.coordinate_to_noncoding, 72, - crossmap.noncoding_to_coordinate, (22, 1, 1)) + crossmap.coordinate_to_noncoding, + 72, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=1, region='d'), + ) -def test_NonCoding_inverted(): - """Forward oriented noncoding transcript.""" - crossmap = NonCoding(_exons, inverted=True) +def test_NonCoding_with_length(): + """Raise ValueError if coordinate is out of bounds.""" + crossmap = NonCoding(_exons, length=75) # Boundary between upstream and transcript. invariant( - crossmap.coordinate_to_noncoding, 72, - crossmap.noncoding_to_coordinate, (1, -1, -1)) + crossmap.coordinate_to_noncoding, + 3 , + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=-2, region='u'), + ) + invariant( + crossmap.coordinate_to_noncoding, + 4, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=-1, region='u'), + ) invariant( - crossmap.coordinate_to_noncoding, 71, - crossmap.noncoding_to_coordinate, (1, 0, 0)) + crossmap.coordinate_to_noncoding, + 5, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=0, region=''), + ) # Boundary between downstream and transcript. invariant( - crossmap.coordinate_to_noncoding, 5, - crossmap.noncoding_to_coordinate, (22, 0, 0)) + crossmap.coordinate_to_noncoding, + 71, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=0, region=''), + ) invariant( - crossmap.coordinate_to_noncoding, 4, - crossmap.noncoding_to_coordinate, (22, 1, 1)) + crossmap.coordinate_to_noncoding, + 72, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=1, region='d'), + ) + # Boundary between downstream and sequence end. + invariant( + crossmap.coordinate_to_noncoding, + 74, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=3, region='d'), + ) + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_noncoding(75) + assert str(error.value) == 'Coordinate 75 is not within the bounds of the sequence length 75.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=4, region='d')) + assert str(error.value) == 'Offset 4 exceeds downstream region.' -def test_NonCoding_degenerate(): - """Forward oriented noncoding transcript.""" - crossmap = NonCoding(_exons) + +def test_NonCoding_inverted(): + """Reverse oriented noncoding transcript.""" + crossmap = NonCoding(_exons, True) # Boundary between upstream and transcript. - degenerate_equal( - crossmap.noncoding_to_coordinate, 4, - [(1, -1, -1), (-1, 0, -1)]) + invariant( + crossmap.coordinate_to_noncoding, + 72, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=-1, region='u'), + ) + invariant( + crossmap.coordinate_to_noncoding, + 71, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=0, region=''), + ) # Boundary between downstream and transcript. - degenerate_equal( - crossmap.noncoding_to_coordinate, 72, - [(22, 1, 1), (23, 0, 1)]) - - -def test_NonCoding_inverted_degenerate(): - """Forward oriented noncoding transcript.""" - crossmap = NonCoding(_exons, inverted=True) + invariant( + crossmap.coordinate_to_noncoding, + 5, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=0, region=''), + ) + invariant( + crossmap.coordinate_to_noncoding, + 4, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=1, region='d'), + ) + + +def test_NonCoding_inverted_with_length(): + """Reverse oriented noncoding transcript.""" + crossmap = NonCoding(_exons, True, 75) + + # Boundary between upstream and sequence end. + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_noncoding(75) + assert str(error.value) == 'Coordinate 75 is not within the bounds of the sequence length 75.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=-4, region='u')) + assert str(error.value) == 'Offset -4 exceeds upstream region.' + invariant( + crossmap.coordinate_to_noncoding, + 74, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=-3, region='u'), + ) # Boundary between upstream and transcript. - degenerate_equal( - crossmap.noncoding_to_coordinate, 72, - [(1, -1, -1), (-1, 0, -1)]) + invariant( + crossmap.coordinate_to_noncoding, + 72, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=-1, region='u'), + ) + invariant( + crossmap.coordinate_to_noncoding, + 71, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=1, offset=0, region=''), + ) # Boundary between downstream and transcript. - degenerate_equal( - crossmap.noncoding_to_coordinate, 4, - [(22, 1, 1), (23, 0, 1)]) + invariant( + crossmap.coordinate_to_noncoding, + 5, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=0, region=''), + ) + invariant( + crossmap.coordinate_to_noncoding, + 4, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=22, offset=1, region='d'), + ) + + +def test_NonCoding_invalid_position(): + """Raise error if position is not valid under HGVS rules.""" + crossmap = NonCoding(_exons, length=75) + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=0, offset=1, region='u')) + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=2, offset=1, region='u')) + assert str(error.value) == 'Position 2 is not at upstream boundary.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=23, offset=0, region='')) + assert str(error.value) == 'Position 23 exceeds multi locus length.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=21, offset=1, region='d')) + assert str(error.value) == 'Position 21 is not at downstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=30, offset=-1, region='d')) + assert str(error.value) == 'Position 30 is not at downstream boundary.' + + +def test_NonCoding_invalid_position_inverted(): + """Raise error if position is not valid under HGVS rules.""" + crossmap = NonCoding(_exons, inverted=True, length=75) + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=0, offset=1, region='u')) + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=2, offset=1, region='u')) + assert str(error.value) == 'Position 2 is not at upstream boundary.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=23, offset=0, region='')) + assert str(error.value) == 'Position 23 exceeds multi locus length.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=21, offset=1, region='d')) + assert str(error.value) == 'Position 21 is not at downstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=30, offset=-1, region='d')) + assert str(error.value) == 'Position 30 is not at downstream boundary.' + + +def test_NonCoding_invalid_offset(): + """Raise error if offset is not valid under HGVS rules.""" + crossmap = NonCoding(_exons, length=75) + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=0, region='u')) + assert error.value.args[0] == 'Offset 0 at upstream boundary should be negative.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=1, region='u')) + assert error.value.args[0] == 'Offset 1 at upstream boundary should be negative.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=-6, region='u')) + assert error.value.args[0] == 'Offset -6 exceeds upstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=-1, region='')) + assert error.value.args[0] == 'Offset -1 at the first exon should be in the upstream region.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=1, region='')) + assert error.value.args[0] == 'Offset 1 should be at a locus end.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=10, offset=1, region='')) + assert error.value.args[0] == 'Offset 1 should be at a locus end.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=10, offset=-11, region='')) + assert error.value.args[0] == 'Offset -11 exceeds intron length.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=-1, region='')) + assert error.value.args[0] == 'Offset -1 should be at a locus start.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=1, region='')) + assert error.value.args[0] == 'Offset 1 at the first exon should be in the downstream region.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=0, region='d')) + assert error.value.args[0] == 'Offset 0 at downstream boundary should be positive.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=-1, region='d')) + assert error.value.args[0] == 'Offset -1 at downstream boundary should be positive.' + + +def test_NonCoding_invalid_offset_inverted(): + """Raise error if offset is not valid under HGVS rules.""" + crossmap = NonCoding(_exons, inverted=True, length=75) + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=0, region='u')) + assert error.value.args[0] == 'Offset 0 at upstream boundary should be negative.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=1, region='u')) + assert error.value.args[0] == 'Offset 1 at upstream boundary should be negative.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=-5, region='u')) + assert error.value.args[0] == 'Offset -5 exceeds upstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=-1, region='')) + assert error.value.args[0] == 'Offset -1 at the first exon should be in the upstream region.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=1, offset=1, region='')) + assert error.value.args[0] == 'Offset 1 should be at a locus end.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=13, offset=-1, region='')) + assert error.value.args[0] == 'Offset -1 should be at a locus end.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=13, offset=11, region='')) + assert error.value.args[0] == 'Offset 11 exceeds intron length.' + with pytest.raises(IndexError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=-1, region='')) + assert error.value.args[0] == 'Offset -1 should be at a locus start.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=1, region='')) + assert error.value.args[0] == 'Offset 1 at the last exon on the reverse complement should be in the downstream region.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=0, region='d')) + assert error.value.args[0] == 'Offset 0 at downstream boundary should be positive.' + with pytest.raises(ValueError) as error: + crossmap.noncoding_to_coordinate(NonCodingPoint(position=22, offset=-1, region='d')) + assert error.value.args[0] == 'Offset -1 at downstream boundary should be positive.' + + +def test_NonCoding_location_end_equal_sequence_length(): + """Half-open exon end may equal sequence length.""" + crossmap = NonCoding([(0, 10)], length=10) + + invariant( + crossmap.coordinate_to_noncoding, + 9, + crossmap.noncoding_to_coordinate, + NonCodingPoint(position=10, offset=0, region=''), + ) + + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_noncoding(10) + assert str(error.value) == 'Coordinate 10 is not within the bounds of the sequence length 10.' + + +def test_CodingPoint_invalid_initialization(): + """Raise error with invalid initialization.""" + with pytest.raises(ValueError) as error: + CodingPoint(position=0, offset=0, region='-') + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + CodingPoint(position=0, offset=0, region='*') + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + CodingPoint(position=0, offset=0, region='') + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + CodingPoint(position=-1, offset=0, region='') + assert str(error.value) == 'Position -1 must be a positive integer.' + with pytest.raises(ValueError) as error: + CodingPoint(position=1, offset=None, region='') + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + CodingPoint(position=2, offset=1, region='upstream') + assert str(error.value) == "Region upstream is invalid, it must be a string from ['', 'u', 'd', '-', '*']." + + +def test_Coding_invalid(): + """Raise ValueError if coding is invalid.""" + + with pytest.raises(ValueError) as error: + Coding([(20, 20)], (20, 20)) + assert str(error.value) == 'Locus start 20 must be smaller than locus end 20.' + with pytest.raises(ValueError) as error: + Coding([(10, 20)], (9,15)) + assert str(error.value) == 'CDS boundary 9 of (9, 15) lies before the first exon (10, 20).' + with pytest.raises(ValueError) as error: + Coding([(10, 20)], (10,21)) + assert str(error.value) == 'CDS boundary 21 of (10, 21) lies after the last exon (10, 20).' + with pytest.raises(ValueError) as error: + Coding([(10, 20)], (15, 10)) + assert str(error.value) == 'Locus start 15 must be smaller than locus end 10.' + with pytest.raises(ValueError) as error: + Coding([], None) + assert str(error.value) == 'Locations must be a non-empty list of tuples.' + + # Ends that lie strictly between two exons. + with pytest.raises(ValueError) as error: + Coding([(5, 8), (11, 14)], (6, 9)) + assert str(error.value) == 'CDS boundary 9 of (6, 9) lies inside an intron between exons (5, 8) and (11, 14).' + with pytest.raises(ValueError) as error: + Coding([(5, 8), (11, 14)], (6, 10)) + assert str(error.value) == 'CDS boundary 10 of (6, 10) lies inside an intron between exons (5, 8) and (11, 14).' + with pytest.raises(ValueError) as error: + Coding([(5, 8), (11, 14), (20, 25)], (16, 22)) + assert str(error.value) == 'CDS boundary 16 of (16, 22) lies inside an intron between exons (11, 14) and (20, 25).' + + # Reverse orientation + with pytest.raises(ValueError) as error: + Coding([(20, 20)], (20, 20), inverted=True) + assert str(error.value) == 'Locus start 20 must be smaller than locus end 20.' + with pytest.raises(ValueError) as error: + Coding([(10, 20)], (9,15), inverted=True) + assert str(error.value) == 'CDS boundary 9 of (9, 15) lies before the first exon (10, 20).' + with pytest.raises(ValueError) as error: + Coding([(10, 20)], (10,21), inverted=True) + assert str(error.value) == 'CDS boundary 21 of (10, 21) lies after the last exon (10, 20).' + with pytest.raises(ValueError) as error: + Coding([(10, 20)], (15, 10), inverted=True) + assert str(error.value) == 'Locus start 15 must be smaller than locus end 10.' + with pytest.raises(ValueError) as error: + Coding([], None, inverted=True) + assert str(error.value) == 'Locations must be a non-empty list of tuples.' + + +def test_Coding_cds_end_at_exon_start(): + """A CDS end is half-open, so it may be the start of the next exon.""" + exons = [(5, 8), (11, 14)] + # Ending where the second exon starts describes the same CDS as ending + # at the end of the first exon. + boundary = Coding(exons, (6, 11)) + exon_end = Coding(exons, (6, 8)) + for coordinate in (5, 6, 7, 11, 12, 13): + assert (boundary.coordinate_to_coding(coordinate) + == exon_end.coordinate_to_coding(coordinate)) + + +def test_Coding_invalid_with_length(): + """Raise ValueError if coordinate is out of bounds.""" + with pytest.raises(ValueError) as error: + Coding(_exons, _cds, length=70) + assert str(error.value) == 'Location end 72 is inconsistent with sequence length 70.' + # Reverse orientation + with pytest.raises(ValueError) as error: + Coding(_exons, _cds, inverted=True, length=70) + assert str(error.value) == 'Location end 72 is inconsistent with sequence length 70.' + + with pytest.raises(ValueError) as error: + Coding(_exons, _cds, length=0) + assert str(error.value) == 'Value 0 is not positive.' + + # The exons fit in the sequence, the CDS end does not. + with pytest.raises(ValueError) as error: + Coding([(10, 20)], (12, 25), length=20) + assert str(error.value) == 'Location end 25 is inconsistent with sequence length 20.' def test_Coding(): """Forward oriented coding transcript.""" crossmap = Coding(_exons, _cds) + # Boundary between upstream and 5' UTR. + invariant( + crossmap.coordinate_to_coding, + 4, + crossmap.coding_to_coordinate, + CodingPoint(position=11, offset=-1, region='u'), + ) + invariant( + crossmap.coordinate_to_coding, + 5, + crossmap.coding_to_coordinate, + CodingPoint(position=11, offset=0, region='-'), + ) + # Boundary between 5' and CDS. invariant( - crossmap.coordinate_to_coding, 31, - crossmap.coding_to_coordinate, (-1, 0, -1, 0)) + crossmap.coordinate_to_coding, + 31, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='-'), + ) invariant( - crossmap.coordinate_to_coding, 32, - crossmap.coding_to_coordinate, (1, 0, 0, 0)) + crossmap.coordinate_to_coding, + 32, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) # Boundary between CDS and 3'. invariant( - crossmap.coordinate_to_coding, 42, - crossmap.coding_to_coordinate, (6, 0, 0, 0)) + crossmap.coordinate_to_coding, + 42, + crossmap.coding_to_coordinate, + CodingPoint(position=6, offset=0, region=''), + ) invariant( - crossmap.coordinate_to_coding, 43, - crossmap.coding_to_coordinate, (1, 0, 1, 0)) + crossmap.coordinate_to_coding, + 43, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='*'), + ) + + # Boundary between 3' and downstream. + invariant( + crossmap.coordinate_to_coding, + 71, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=0, region='*'), + ) + invariant( + crossmap.coordinate_to_coding, + 72, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=1, region='d'), + ) + + +def test_Coding_with_length(): + """Raise ValueError if coordinate is out of bounds.""" + crossmap = Coding(_exons, _cds, length=75) + # Boundary between upstream and 5' UTR. + invariant( + crossmap.coordinate_to_coding, + 4, + crossmap.coding_to_coordinate, + CodingPoint(position=11, offset=-1, region='u'), + ) + invariant( + crossmap.coordinate_to_coding, + 5, + crossmap.coding_to_coordinate, + CodingPoint(position=11, offset=0, region='-'), + ) + + # Boundary between 5' and CDS. + invariant( + crossmap.coordinate_to_coding, + 31, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='-'), + ) + invariant( + crossmap.coordinate_to_coding, + 32, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) + + # Boundary between CDS and 3'. + invariant( + crossmap.coordinate_to_coding, + 42, + crossmap.coding_to_coordinate, + CodingPoint(position=6, offset=0, region=''), + ) + invariant( + crossmap.coordinate_to_coding, + 43, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='*'), + ) + + # Boundary between 3' and downstream. + invariant( + crossmap.coordinate_to_coding, + 71, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=0, region='*'), + ) + invariant( + crossmap.coordinate_to_coding, + 72, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=1, region='d'), + ) + + # Boundary between downstream and sequence end. + invariant( + crossmap.coordinate_to_coding, + 74, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=3, region='d'), + ) + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_coding(75) + assert str(error.value) == 'Coordinate 75 is not within the bounds of the sequence length 75.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=5, offset=4, region='d')) def test_Coding_inverted(): """Reverse oriented coding transcript.""" crossmap = Coding(_exons, _cds, True) + # Boundary between upstream and 5' UTR. + invariant( + crossmap.coordinate_to_coding, + 72, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=-1, region='u'), + ) + invariant( + crossmap.coordinate_to_coding, + 71, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=0, region='-'), + ) + + # Boundary between 5' and CDS. + invariant( + crossmap.coordinate_to_coding, + 43, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='-'), + ) + invariant( + crossmap.coordinate_to_coding, + 42, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) + + # Boundary between CDS and 3'. + invariant( + crossmap.coordinate_to_coding, + 32, + crossmap.coding_to_coordinate, + CodingPoint(position=6, offset=0, region=''), + ) + invariant( + crossmap.coordinate_to_coding, + 31, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='*'), + ) + + # Boundary between 3' and downstream. + invariant( + crossmap.coordinate_to_coding, + 5, + crossmap.coding_to_coordinate, + CodingPoint(position=11, offset=0, region='*'), + ) + invariant( + crossmap.coordinate_to_coding, + 4, + crossmap.coding_to_coordinate, + CodingPoint(position=11, offset=1, region='d'), + ) + + +def test_Coding_inverted_with_length(): + """Reverse oriented coding transcript.""" + crossmap = Coding(_exons, _cds, True, 75) + + # Boundary between upstream and sequence end. + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_coding(75) + assert str(error.value) == 'Coordinate 75 is not within the bounds of the sequence length 75.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=5, offset=-4, region='u')) + assert str(error.value) == 'Offset -4 exceeds upstream region.' + invariant( + crossmap.coordinate_to_coding, + 74, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=-3, region='u'), + ) + + # Boundary between upstream and 5' UTR. + invariant( + crossmap.coordinate_to_coding, + 72, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=-1, region='u'), + ) + invariant( + crossmap.coordinate_to_coding, + 71, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=0, region='-'), + ) + # Boundary between 5' and CDS. invariant( - crossmap.coordinate_to_coding, 43, - crossmap.coding_to_coordinate, (-1, 0, -1, 0)) + crossmap.coordinate_to_coding, + 43, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='-'), + ) invariant( - crossmap.coordinate_to_coding, 42, - crossmap.coding_to_coordinate, (1, 0, 0, 0)) + crossmap.coordinate_to_coding, + 42, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) # Boundary between CDS and 3'. invariant( - crossmap.coordinate_to_coding, 32, - crossmap.coding_to_coordinate, (6, 0, 0, 0)) + crossmap.coordinate_to_coding, + 32, + crossmap.coding_to_coordinate, + CodingPoint(position=6, offset=0, region=''), + ) invariant( - crossmap.coordinate_to_coding, 31, - crossmap.coding_to_coordinate, (1, 0, 1, 0)) + crossmap.coordinate_to_coding, + 31, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='*'), + ) + + # Boundary between 3' and downstream. + invariant( + crossmap.coordinate_to_coding, + 5, + crossmap.coding_to_coordinate, + CodingPoint(position=11, offset=0, region='*'), + ) + invariant( + crossmap.coordinate_to_coding, + 4, + crossmap.coding_to_coordinate, + CodingPoint(position=11, offset=1, region='d'), + ) def test_Coding_regions(): @@ -136,40 +835,64 @@ def test_Coding_regions(): # Upstream odd length intron between two regions. invariant( - crossmap.coordinate_to_coding, 25, - crossmap.coding_to_coordinate, (-1, 5, -1, 0)) + crossmap.coordinate_to_coding, + 25, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=5, region='-'), + ) invariant( - crossmap.coordinate_to_coding, 26, - crossmap.coding_to_coordinate, (1, -4, 0, 0)) + crossmap.coordinate_to_coding, + 26, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-4, region=''), + ) # Downstream odd length intron between two regions. invariant( - crossmap.coordinate_to_coding, 44, - crossmap.coding_to_coordinate, (10, 5, 0, 0)) + crossmap.coordinate_to_coding, + 44, + crossmap.coding_to_coordinate, + CodingPoint(position=10, offset=5, region=''), + ) invariant( - crossmap.coordinate_to_coding, 45, - crossmap.coding_to_coordinate, (1, -4, 1, 0)) + crossmap.coordinate_to_coding, + 45, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-4, region='*'), + ) def test_Coding_regions_inverted(): """The CDS can start or end on a region boundary.""" - crossmap = Coding([(10, 21), (30, 40), (49, 60)], (30, 40), True) + crossmap = Coding([(10, 21), (30, 40), (49, 60)], (30, 40), inverted=True) # Upstream odd length intron between two regions. invariant( - crossmap.coordinate_to_coding, 44, - crossmap.coding_to_coordinate, (-1, 5, -1, 0)) + crossmap.coordinate_to_coding, + 44, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=5, region='-'), + ) invariant( - crossmap.coordinate_to_coding, 43, - crossmap.coding_to_coordinate, (1, -4, 0, 0)) + crossmap.coordinate_to_coding, + 43, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-4, region=''), + ) # Downstream odd length intron between two regions. invariant( - crossmap.coordinate_to_coding, 25, - crossmap.coding_to_coordinate, (10, 5, 0, 0)) + crossmap.coordinate_to_coding, + 25, + crossmap.coding_to_coordinate, + CodingPoint(position=10, offset=5, region=''), + ) invariant( - crossmap.coordinate_to_coding, 24, - crossmap.coding_to_coordinate, (1, -4, 1, 0)) + crossmap.coordinate_to_coding, + 24, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-4, region='*'), + ) def test_Coding_no_utr5(): @@ -178,170 +901,500 @@ def test_Coding_no_utr5(): # Direct transition from upstream to CDS. invariant( - crossmap.coordinate_to_coding, 9, - crossmap.coding_to_coordinate, (1, -1, 0, -1)) + crossmap.coordinate_to_coding, + 9, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-1, region='u'), + ) + invariant( + crossmap.coordinate_to_coding, + 10, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) + + degenerate_equal( + crossmap.coding_to_coordinate, + 9, + [ + CodingPoint(position=1, offset=-1, region='u'), + CodingPoint(position=1, offset=0, region='-'), + ], + ) + + +def test_Coding_no_utr5_unequal_utr3(): + """Without a 5' UTR the downstream anchor is the last coding position.""" + crossmap = Coding([(10, 20)], (10, 15)) + # Direct transition from CDS to downstream. + invariant( + crossmap.coordinate_to_coding, + 9, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-1, region='u'), + ) invariant( - crossmap.coordinate_to_coding, 10, - crossmap.coding_to_coordinate, (1, 0, 0, 0)) + crossmap.coordinate_to_coding, + 10, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) + def test_Coding_no_utr5_inverted(): """A 5' UTR may be missing.""" - crossmap = Coding([(10, 20)], (15, 20), True) + crossmap = Coding([(10, 20)], (15, 20), inverted=True) # Direct transition from upstream to CDS. invariant( - crossmap.coordinate_to_coding, 20, - crossmap.coding_to_coordinate, (1, -1, 0, -1)) + crossmap.coordinate_to_coding, + 20, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-1, region='u'), + ) invariant( - crossmap.coordinate_to_coding, 19, - crossmap.coding_to_coordinate, (1, 0, 0, 0)) + crossmap.coordinate_to_coding, + 19, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) + degenerate_equal( + crossmap.coding_to_coordinate, + 20, + [ + CodingPoint(position=1, offset=-1, region='u'), + CodingPoint(position=1, offset=0, region='-'), + ], + ) + + +def test_Coding_no_utr5_inverted_unequal_utr3(): + """A 5' UTR may be missing and the 3' UTR is unequal.""" + crossmap = Coding([(10, 20)], (15, 20), inverted=True) + + # Direct transition from upstream to CDS. + invariant( + crossmap.coordinate_to_coding, + 20, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-1, region='u'), + ) + invariant( + crossmap.coordinate_to_coding, + 19, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) + degenerate_equal( + crossmap.coding_to_coordinate, + 20, + [ + CodingPoint(position=1, offset=-1, region='u'), + CodingPoint(position=1, offset=0, region='-'), + ], + ) def test_Coding_no_utr3(): """A 3' UTR may be missing.""" crossmap = Coding([(10, 20)], (15, 20)) + # Direct transition from CDS to downstream. + invariant( + crossmap.coordinate_to_coding, + 19, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=0, region=''), + ) + invariant( + crossmap.coordinate_to_coding, + 20, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=1, region='d'), + ) + # Position 1 is also accepted downstream when there is no 3' UTR. + degenerate_equal( + crossmap.coding_to_coordinate, + 20, + [ + CodingPoint(position=1, offset=1, region='d'), + CodingPoint(position=1, offset=0, region='*'), + ], + ) + + +def test_Coding_no_utr3_unequal_utr5(): + """Without a 3' UTR the downstream anchor is the last coding position.""" + crossmap = Coding([(10, 20)], (12, 20)) # Direct transition from CDS to downstream. invariant( - crossmap.coordinate_to_coding, 19, - crossmap.coding_to_coordinate, (5, 0, 0, 0)) + crossmap.coordinate_to_coding, + 19, + crossmap.coding_to_coordinate, + CodingPoint(position=8, offset=0, region=''), + ) invariant( - crossmap.coordinate_to_coding, 20, - crossmap.coding_to_coordinate, (5, 1, 0, 1)) + crossmap.coordinate_to_coding, + 20, + crossmap.coding_to_coordinate, + CodingPoint(position=8, offset=1, region='d'), + ) def test_Coding_no_utr3_inverted(): """A 3' UTR may be missing.""" - crossmap = Coding([(10, 20)], (10, 15), True) + crossmap = Coding([(10, 20)], (10, 15), inverted=True) # Direct transition from CDS to downstream. invariant( - crossmap.coordinate_to_coding, 10, - crossmap.coding_to_coordinate, (5, 0, 0, 0)) + crossmap.coordinate_to_coding, + 10, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=0, region=''), + ) invariant( - crossmap.coordinate_to_coding, 9, - crossmap.coding_to_coordinate, (5, 1, 0, 1)) + crossmap.coordinate_to_coding, + 9, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=1, region='d'), + ) + + # Position 1 is also accepted downstream when there is no 3' UTR. + degenerate_equal( + crossmap.coding_to_coordinate, + 9, + [ + CodingPoint(position=1, offset=1, region='d'), + CodingPoint(position=1, offset=0, region='*'), + ], + ) + + +def test_Coding_no_utr3_inverted_unequal_utr5(): + """Without a 3' UTR the downstream anchor is the last coding position.""" + crossmap = Coding([(10, 20)], (10, 18), inverted=True) + + # Direct transition from CDS to downstream. + invariant( + crossmap.coordinate_to_coding, + 10, + crossmap.coding_to_coordinate, + CodingPoint(position=8, offset=0, region=''), + ) + invariant( + crossmap.coordinate_to_coding, + 9, + crossmap.coding_to_coordinate, + CodingPoint(position=8, offset=1, region='d'), + ) def test_Coding_small_utr5(): - """A 5' UTR may be of lenght one.""" + """A 5' UTR may be of length one.""" crossmap = Coding([(10, 20)], (11, 15)) # Transition from upstream to 5' UTR to CDS. invariant( - crossmap.coordinate_to_coding, 9, - crossmap.coding_to_coordinate, (-1, -1, -1, -1)) + crossmap.coordinate_to_coding, + 9, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-1, region='u'), + ) invariant( - crossmap.coordinate_to_coding, 10, - crossmap.coding_to_coordinate, (-1, 0, -1, 0)) + crossmap.coordinate_to_coding, + 10, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='-'), + ) invariant( - crossmap.coordinate_to_coding, 11, - crossmap.coding_to_coordinate, (1, 0, 0, 0)) + crossmap.coordinate_to_coding, + 11, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) def test_Coding_small_utr5_inverted(): - """A 5' UTR may be of lenght one.""" - crossmap = Coding([(10, 20)], (15, 19), True) + """A 5' UTR may be of length one.""" + crossmap = Coding([(10, 20)], (15, 19), inverted=True) # Transition from upstream to 5' UTR to CDS. invariant( - crossmap.coordinate_to_coding, 20, - crossmap.coding_to_coordinate, (-1, -1, -1, -1)) + crossmap.coordinate_to_coding, + 20, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=-1, region='u'), + ) invariant( - crossmap.coordinate_to_coding, 19, - crossmap.coding_to_coordinate, (-1, 0, -1, 0)) + crossmap.coordinate_to_coding, + 19, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='-'), + ) invariant( - crossmap.coordinate_to_coding, 18, - crossmap.coding_to_coordinate, (1, 0, 0, 0)) + crossmap.coordinate_to_coding, + 18, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region=''), + ) def test_Coding_small_utr3(): - """A 5' UTR may be of lenght one.""" + """A 5' UTR may be of length one.""" crossmap = Coding([(10, 20)], (15, 19)) # Transition from CDS to 3' UTR to downstream. invariant( - crossmap.coordinate_to_coding, 18, - crossmap.coding_to_coordinate, (4, 0, 0, 0)) + crossmap.coordinate_to_coding, + 18, + crossmap.coding_to_coordinate, + CodingPoint(position=4, offset=0, region=''), + ) invariant( - crossmap.coordinate_to_coding, 19, - crossmap.coding_to_coordinate, (1, 0, 1, 0)) + crossmap.coordinate_to_coding, + 19, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='*'), + ) invariant( - crossmap.coordinate_to_coding, 20, - crossmap.coding_to_coordinate, (1, 1, 1, 1)) + crossmap.coordinate_to_coding, + 20, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=1, region='d'), + ) def test_Coding_small_utr3_inverted(): - """A 5' UTR may be of lenght one.""" - crossmap = Coding([(10, 20)], (11, 15), True) + """A 5' UTR may be of length one.""" + crossmap = Coding([(10, 20)], (11, 15), inverted=True) # Transition from CDS to 3' UTR to downstream. invariant( - crossmap.coordinate_to_coding, 11, - crossmap.coding_to_coordinate, (4, 0, 0, 0)) + crossmap.coordinate_to_coding, + 11, + crossmap.coding_to_coordinate, + CodingPoint(position=4, offset=0, region=''), + ) + invariant( + crossmap.coordinate_to_coding, + 10, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=0, region='*'), + ) + invariant( + crossmap.coordinate_to_coding, + 9, + crossmap.coding_to_coordinate, + CodingPoint(position=1, offset=1, region='d'), + ) + + +def test_Coding_no_intron(): + crossmap = Coding([(10, 20), (20, 30)], (15, 25)) + + invariant( + crossmap.coordinate_to_coding, + 20, + crossmap.coding_to_coordinate, + CodingPoint(position=6, offset=0, region=''), + ) + + +def test_Coding_no_intron_inverted(): + crossmap = Coding([(10, 20), (20, 30)], (15, 25), inverted=True) + invariant( - crossmap.coordinate_to_coding, 10, - crossmap.coding_to_coordinate, (1, 0, 1, 0)) + crossmap.coordinate_to_coding, + 20, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=0, region=''), + ) + + +def test_Coding_one_base_intron(): + crossmap = Coding([(10, 19), (20, 30)], (15, 25)) + invariant( - crossmap.coordinate_to_coding, 9, - crossmap.coding_to_coordinate, (1, 1, 1, 1)) + crossmap.coordinate_to_coding, + 19, + crossmap.coding_to_coordinate, + CodingPoint(position=4, offset=1, region=''), + ) + + +def test_Coding_one_base_intron_inverted(): + crossmap = Coding([(10, 19), (20, 30)], (15, 25), inverted=True) + + invariant( + crossmap.coordinate_to_coding, + 19, + crossmap.coding_to_coordinate, + CodingPoint(position=5, offset=1, region=''), + ) def test_Coding_degenerate(): """Degenerate upstream and downstream positions are silently corrected.""" crossmap = Coding([(10, 20)], (11, 19)) + # Degenerate position in upstream. degenerate_equal( - crossmap.coding_to_coordinate, 9, - [(-1, -1, -1, -1), (-2, 0, -1, -1), (1, -2, 0, -1), (1, -10, 1, -1)]) + crossmap.coding_to_coordinate, + 9, + [ + CodingPoint(position=1, offset=-1, region='u'), + CodingPoint(position=2, offset=0, region='-'), + ], + ) degenerate_equal( - crossmap.coding_to_coordinate, 20, - [(1, 1, 1, 1), (2, 0, 1, 1), (8, 2, 0, 1), (-1, 10, -1, 1)]) + crossmap.coding_to_coordinate, + 20, + [ + CodingPoint(position=1, offset=1, region='d'), + CodingPoint(position=2, offset=0, region='*'), + ], + ) def test_Coding_inverted_degenerate(): """Degenerate upstream and downstream positions are silently corrected.""" - crossmap = Coding([(10, 20)], (11, 19), True) + crossmap = Coding([(10, 20)], (11, 19), inverted=True) degenerate_equal( - crossmap.coding_to_coordinate, 20, - [(-1, -1, -1, -1), (-2, 0, -1, -1), (1, -2, 0, -1), (1, -10, 1, -1)]) + crossmap.coding_to_coordinate, + 20, + [ + CodingPoint(position=1, offset=-1, region='u'), + CodingPoint(position=2, offset=0, region='-'), + ], + ) degenerate_equal( - crossmap.coding_to_coordinate, 9, - [(1, 1, 1, 1), (2, 0, 1, 1), (8, 2, 0, 1), (-1, 10, -1, 1)]) + crossmap.coding_to_coordinate, + 9, + [ + CodingPoint(position=1, offset=1, region='d'), + CodingPoint(position=2, offset=0, region='*'), + ], + ) def test_Coding_degenerate_return(): """Degenerate upstream and downstream positions may be returned.""" crossmap = Coding([(10, 20)], (11, 19)) - assert crossmap.coordinate_to_coding(9, True) == (-2, 0, -1, -1) - assert crossmap.coordinate_to_coding(20, True) == (2, 0, 1, 1) + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=2, offset=0, region='-') + assert crossmap.coordinate_to_coding(20, True) == CodingPoint(position=2, offset=0, region='*') def test_Coding_inverted_degenerate_return(): """Degenerate upstream and downstream positions may be returned.""" - crossmap = Coding([(10, 20)], (11, 19), True) + crossmap = Coding([(10, 20)], (11, 19), inverted=True) + + assert crossmap.coordinate_to_coding(20, True) == CodingPoint(position=2, offset=0, region='-') + assert crossmap.coordinate_to_coding(25, True) == CodingPoint(position=7, offset=0, region='-') + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=2, offset=0, region='*') + + +def test_Coding_no_utr5_degenerate_return(): + """A 5' UTR may be missing.""" + crossmap = Coding([(10, 20)], (10, 15)) + + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=1, offset=0, region='-') + assert crossmap.coordinate_to_coding(10, True) == CodingPoint(position=1, offset=0, region='') + assert crossmap.coordinate_to_coding(19, True) == CodingPoint(position=5, offset=0, region='*') + assert crossmap.coordinate_to_coding(20, True) == CodingPoint(position=6, offset=0, region='*') + + +def test_Coding_no_utr5_inverted_degenerate_return(): + """A 5' UTR may be missing.""" + crossmap = Coding([(10, 20)], (10, 15), inverted=True) + + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=1, offset=0, region='*') + assert crossmap.coordinate_to_coding(10, True) == CodingPoint(position=5, offset=0, region='') + assert crossmap.coordinate_to_coding(19, True) == CodingPoint(position=5, offset=0, region='-') + assert crossmap.coordinate_to_coding(20, True) == CodingPoint(position=6, offset=0, region='-') + + +def test_Coding_no_utr3_degenerate_return(): + """A 3' UTR may be missing.""" + crossmap = Coding([(10, 20)], (15, 20)) + + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=6, offset=0, region='-') + assert crossmap.coordinate_to_coding(10, True) == CodingPoint(position=5, offset=0, region='-') + assert crossmap.coordinate_to_coding(19, True) == CodingPoint(position=5, offset=0, region='') + assert crossmap.coordinate_to_coding(20, True) == CodingPoint(position=1, offset=0, region='*') + + +def test_Coding_no_utr3_inverted_degenerate_return(): + """A 3' UTR may be missing.""" + + crossmap = Coding([(10, 20)], (15, 20), inverted=True) + + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=6, offset=0, region='*') + assert crossmap.coordinate_to_coding(10, True) == CodingPoint(position=5, offset=0, region='*') + assert crossmap.coordinate_to_coding(19, True) == CodingPoint(position=1, offset=0, region='') + assert crossmap.coordinate_to_coding(20, True) == CodingPoint(position=1, offset=0, region='-') + + +def test_Coding_small_utr5_degenerate_return(): + """A 5' UTR may be of length one.""" + crossmap = Coding([(10, 20)], (11, 15)) + + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=2, offset=0, region='-') + assert crossmap.coordinate_to_coding(10, True) == CodingPoint(position=1, offset=0, region='-') + assert crossmap.coordinate_to_coding(11, True) == CodingPoint(position=1, offset=0, region='') + + +def test_Coding_small_utr5_inverted_degenerate_return(): + """A 5' UTR may be of length one.""" + crossmap = Coding([(10, 20)], (11, 15), inverted=True) + + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=2, offset=0, region='*') + assert crossmap.coordinate_to_coding(10, True) == CodingPoint(position=1, offset=0, region='*') + assert crossmap.coordinate_to_coding(11, True) == CodingPoint(position=4, offset=0, region='') + + +def test_Coding_small_utr3_degenerate_return(): + """A 3' UTR may be of length one.""" + crossmap = Coding([(10, 20)], (15, 19), inverted=False) + + assert crossmap.coordinate_to_coding(18, True) == CodingPoint(position=4, offset=0, region='') + assert crossmap.coordinate_to_coding(19, True) == CodingPoint(position=1, offset=0, region='*') + assert crossmap.coordinate_to_coding(20, True) == CodingPoint(position=2, offset=0, region='*') + + +def test_Coding_small_utr3_inverted_degenerate_return(): + """A 3' UTR may be of length one.""" + crossmap = Coding([(10, 20)], (15, 19), inverted=True) + + assert crossmap.coordinate_to_coding(18, True) == CodingPoint(position=1, offset=0, region='') + assert crossmap.coordinate_to_coding(19, True) == CodingPoint(position=1, offset=0, region='-') + assert crossmap.coordinate_to_coding(20, True) == CodingPoint(position=2, offset=0, region='-') + + +def test_Coding_two_exons_inverted_degenerate_return(): + """Degenerate upstream and downstream positions may be returned.""" + crossmap = Coding([(10, 20), (30, 40)], (18, 37), inverted=True) - assert crossmap.coordinate_to_coding(20, True) == (-2, 0, -1, -1) - assert crossmap.coordinate_to_coding(9, True) == (2, 0, 1, 1) + assert crossmap.coordinate_to_coding(5, True) == CodingPoint(position=13, offset=0, region='*') + assert crossmap.coordinate_to_coding(25, True) == CodingPoint(position=7, offset=5, region='') + assert crossmap.coordinate_to_coding(35, True) == CodingPoint(position=2, offset=0, region='') + assert crossmap.coordinate_to_coding(38, True) == CodingPoint(position=2, offset=0, region='-') def test_Coding_degenerate_no_return(): """Degenerate internal positions do not exist.""" crossmap = Coding([(10, 20), (30, 40)], (10, 40)) - assert (crossmap.coordinate_to_coding(25) == - crossmap.coordinate_to_coding(25, True)) + assert crossmap.coordinate_to_coding(25) == crossmap.coordinate_to_coding(25, True) def test_Coding_inverted_degenerate_no_return(): """Degenerate internal positions do not exist.""" - crossmap = Coding([(10, 20), (30, 40)], (10, 40), True) + crossmap = Coding([(10, 20), (30, 40)], (10, 40), inverted=True) - assert (crossmap.coordinate_to_coding(25) == - crossmap.coordinate_to_coding(25, True)) + assert crossmap.coordinate_to_coding(25) == crossmap.coordinate_to_coding(25, True) def test_Coding_no_utr_degenerate(): @@ -349,67 +1402,458 @@ def test_Coding_no_utr_degenerate(): crossmap = Coding([(10, 11)], (10, 11)) degenerate_equal( - crossmap.coding_to_coordinate, 9, - [(1, -1, 0, -1), (-1, 0, -1, -1), (1, -2, 1, -1)]) + crossmap.coding_to_coordinate, + 9, + [ + CodingPoint(position=1, offset=-1, region='u'), + CodingPoint(position=1, offset=0, region='-'), + ], + ) degenerate_equal( - crossmap.coding_to_coordinate, 11, - [(1, 1, 0, 1), (1, 0, 1, 1), (-1, 2, -1, 1)]) + crossmap.coding_to_coordinate, + 11, + [ + CodingPoint(position=1, offset=1, region='d'), + CodingPoint(position=1, offset=0, region='*'), + ], + ) def test_Coding_inverted_no_utr_degenerate(): """UTRs may be missing.""" - crossmap = Coding([(10, 11)], (10, 11), True) + crossmap = Coding([(10, 11)], (10, 11), inverted=True) degenerate_equal( - crossmap.coding_to_coordinate, 11, - [(1, -1, 0, -1), (-1, 0, -1, -1), (1, -2, 1, -1)]) + crossmap.coding_to_coordinate, + 11, + [ + CodingPoint(position=1, offset=-1, region='u'), + CodingPoint(position=1, offset=0, region='-'), + ], + ) degenerate_equal( - crossmap.coding_to_coordinate, 9, - [(1, 1, 0, 1), (1, 0, 1, 1), (-1, 2, -1, 1)]) + crossmap.coding_to_coordinate, + 9, + [ + CodingPoint(position=1, offset=1, region='d'), + CodingPoint(position=1, offset=0, region='*'), + ], + ) def test_Coding_no_utr_degenerate_return(): """UTRs may be missing.""" crossmap = Coding([(10, 11)], (10, 11)) - assert crossmap.coordinate_to_coding(8, True) == (-2, 0, -1, -2) - assert crossmap.coordinate_to_coding(9, True) == (-1, 0, -1, -1) - assert crossmap.coordinate_to_coding(11, True) == (1, 0, 1, 1) - assert crossmap.coordinate_to_coding(12, True) == (2, 0, 1, 2) + assert crossmap.coordinate_to_coding(8, True) == CodingPoint(position=2, offset=0, region='-') + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=1, offset=0, region='-') + assert crossmap.coordinate_to_coding(11, True) == CodingPoint(position=1, offset=0, region='*') + assert crossmap.coordinate_to_coding(12, True) == CodingPoint(position=2, offset=0, region='*') def test_Coding_inverted_no_utr_degenerate_return(): """UTRs may be missing.""" - crossmap = Coding([(10, 11)], (10, 11), True) + crossmap = Coding([(10, 11)], (10, 11), inverted=True) + + assert crossmap.coordinate_to_coding(11, True) == CodingPoint(position=1, offset=0, region='-') + assert crossmap.coordinate_to_coding(9, True) == CodingPoint(position=1, offset=0, region='*') + - assert crossmap.coordinate_to_coding(11, True) == (-1, 0, -1, -1) - assert crossmap.coordinate_to_coding(9, True) == (1, 0, 1, 1) +def test_Coding_invalid_position(): + """Raise error if position in coding point is invalid.""" + crossmap = Coding(_exons, _cds) + + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=0, offset=1, region='u')) + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=12, offset=-1, region='u')) + assert str(error.value) == 'Position 12 is not in upstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=-1, region='u')) + assert str(error.value) == 'Position 1 is not in upstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=13, offset=1, region='-')) + assert str(error.value) == 'Position 13 exceeds - region.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=-1, offset=0, region='-')) + assert str(error.value) == 'Position -1 must be a positive integer.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=0, offset=0, region='')) + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=7, offset=0, region='')) + assert str(error.value) == 'Position 7 exceeds coding region.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=6, offset=1, region='*')) + assert str(error.value) == 'Position 6 exceeds * region.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=None, offset=0, region='*')) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=6, offset=0, region='d')) + assert str(error.value) == 'Position 6 is not in downstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1000, offset=2, region='d')) + assert str(error.value) == 'Position 1000 is not in downstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=2, region='d')) + assert str(error.value) == 'Position 1 is not in downstream boundary.' + + +def test_Coding_inverted_invalid_position_inverted(): + """Raise error if position in coding point is invalid for inverted coding.""" + crossmap = Coding(_exons, _cds, inverted=True) + + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=0, offset=1, region='u')) + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=12, offset=-1, region='u')) + assert str(error.value) == 'Position 12 is not in upstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=-2, region='u')) + assert str(error.value) == 'Position 1 is not in upstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=13, offset=1, region='-')) + assert str(error.value) == 'Position 13 exceeds - region.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=-1, offset=0, region='-')) + assert str(error.value) == 'Position -1 must be a positive integer.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=0, offset=0, region='')) + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=7, offset=0, region='')) + assert str(error.value) == 'Position 7 exceeds coding region.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=13, offset=1, region='*')) + assert str(error.value) == 'Position 13 exceeds * region.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=None, offset=0, region='*')) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=6, offset=0, region='d')) + assert str(error.value) == 'Position 6 is not in downstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1000, offset=2, region='d')) + assert str(error.value) == 'Position 1000 is not in downstream boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=2, region='d')) + assert str(error.value) == 'Position 1 is not in downstream boundary.' + + +def test_Coding_invalid_offset(): + """Raise error if offset in coding point is invalid.""" + crossmap = Coding(_exons, _cds, length=75) + + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=11, offset=-6, region='u')) + assert str(error.value) == 'Offset -6 exceeds upstream region.' + # A degenerate position beyond the sequence is reported as written. + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=17, offset=0, region='-')) + assert str(error.value) == ( + 'Position 17 of the - region exceeds the sequence.') + + # The last sequence coordinate is valid; the next is out of bounds. + assert crossmap.coding_to_coordinate(CodingPoint(position=8, offset=0, region='*')) == 74 + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=9, offset=0, region='*')) + assert str(error.value) == ( + 'Position 9 of the * region exceeds the sequence.') + + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=1, region='-')) + assert str(error.value) == 'Position 1 is not at a locus boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=-1, region='-')) + assert str(error.value) == 'Position 1 is not at a locus boundary.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=-1, region='')) + assert str(error.value) == 'Position 1 is not at a locus boundary.' + with pytest.raises(IndexError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=3, offset=6, region='')) + assert str(error.value) == 'Offset 6 exceeds intron length.' + with pytest.raises(IndexError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=4, offset=12, region='*')) + assert str(error.value) == 'Offset 12 should be at a locus end.' + with pytest.raises(IndexError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=4, offset=-50, region='*')) + assert str(error.value) == 'Offset -50 exceeds intron length.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=5, offset=10, region='d')) + assert str(error.value) == 'Offset 10 exceeds downstream region.' + + +def test_Coding_invalid_offset_inverted(): + """Raise error if offset in coding point is invalid.""" + crossmap = Coding(_exons, _cds, inverted=True, length=75) + + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=5, offset=-6, region='u')) + assert str(error.value) == 'Offset -6 exceeds upstream region.' + # A degenerate position beyond the sequence is reported as written. + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=17, offset=0, region='*')) + assert str(error.value) == ( + 'Position 17 of the * region exceeds the sequence.') + + # The last sequence coordinate is valid; the next is out of bounds. + assert crossmap.coding_to_coordinate(CodingPoint(position=8, offset=0, region='-')) == 74 + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=9, offset=0, region='-')) + assert str(error.value) == ( + 'Position 9 of the - region exceeds the sequence.') + + with pytest.raises(IndexError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=1, region='-')) + assert str(error.value) == 'Offset 1 should be at a locus end.' + with pytest.raises(IndexError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=2, offset=-1, region='-')) + assert str(error.value) == 'Offset -1 should be at a locus start.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=1, offset=-1, region='')) + assert str(error.value) == 'Position 1 is not at a locus boundary.' + with pytest.raises(IndexError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=3, offset=6, region='')) + assert str(error.value) == 'Offset 6 exceeds intron length.' + with pytest.raises(IndexError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=4, offset=12, region='*')) + assert str(error.value) == 'Offset 12 exceeds intron length.' + with pytest.raises(IndexError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=4, offset=-50, region='*')) + assert str(error.value) == 'Offset -50 exceeds intron length.' + with pytest.raises(ValueError) as error: + crossmap.coding_to_coordinate(CodingPoint(position=11, offset=10, region='d')) + assert str(error.value) == 'Offset 10 exceeds downstream region.' + + +def test_Coding_protein_point_invalid_initialization(): + """Raise error if protein point is initialized with invalid values.""" + with pytest.raises(ValueError) as error: + ProteinPoint(position=0, offset=0, region='u', position_in_codon=1) + assert str(error.value) == 'Position 0 must be a positive integer.' + with pytest.raises(ValueError) as error: + ProteinPoint(position=1, offset=0, region='', position_in_codon=4) + assert str(error.value) == 'Position_in_codon must be 1, 2, or 3.' + with pytest.raises(ValueError) as error: + ProteinPoint(position=1, offset=0, region='', position_in_codon=0) + assert str(error.value) == 'Position_in_codon must be 1, 2, or 3.' + with pytest.raises(ValueError) as error: + ProteinPoint(position=1, offset=0, region='', position_in_codon=True) + assert str(error.value) == 'Value must be an integer.' def test_Coding_protein(): """Protein positions.""" crossmap = Coding(_exons, _cds) - # Boundary between 5' UTR and CDS. + # Boundary between upstream and 5' UTR + invariant( + crossmap.coordinate_to_protein, + 4, + crossmap.protein_to_coordinate, + ProteinPoint(position=4, offset=-1, region='u', position_in_codon=2) + ) + invariant( + crossmap.coordinate_to_protein, + 5, + crossmap.protein_to_coordinate, + ProteinPoint(position=4, offset=0, region='-', position_in_codon=2) + ) + + # Boundary between 5' UTR and CDS + invariant( + crossmap.coordinate_to_protein, + 31, + crossmap.protein_to_coordinate, + ProteinPoint(position=1, offset=0, region='-', position_in_codon=3), + ) + invariant( + crossmap.coordinate_to_protein, + 32, + crossmap.protein_to_coordinate, + ProteinPoint(position=1, offset=0, region='', position_in_codon=1), + ) + + # Intron boundary. + invariant( + crossmap.coordinate_to_protein, + 34, + crossmap.protein_to_coordinate, + ProteinPoint(position=1, offset=0, region='', position_in_codon=3), + ) + invariant( + crossmap.coordinate_to_protein, + 35, + crossmap.protein_to_coordinate, + ProteinPoint(position=1, offset=1, region='', position_in_codon=3), + ) + + # Boundary between CDS and 3' UTR. + invariant( + crossmap.coordinate_to_protein, + 42, + crossmap.protein_to_coordinate, + ProteinPoint(position=2, offset=0, region='', position_in_codon=3), + ) + invariant( + crossmap.coordinate_to_protein, + 43, + crossmap.protein_to_coordinate, + ProteinPoint(position=1, offset=0, region='*', position_in_codon=1), + ) + + # Boundary between 3' UTR and downstream invariant( - crossmap.coordinate_to_protein, 31, - crossmap.protein_to_coordinate, (-1, 3, 0, -1, 0)) + crossmap.coordinate_to_protein, + 71, + crossmap.protein_to_coordinate, + ProteinPoint(position=2, offset=0, region='*', position_in_codon=2) + ) invariant( - crossmap.coordinate_to_protein, 32, - crossmap.protein_to_coordinate, (1, 1, 0, 0, 0)) + crossmap.coordinate_to_protein, + 72, + crossmap.protein_to_coordinate, + ProteinPoint(position=2, offset=1, region='d', position_in_codon=2) + ) + + +def test_Coding_inverted_protein(): + """Protein positions.""" + crossmap = Coding(_exons, _cds, inverted = True) + + # Boundary between upstream and 5' UTR + invariant( + crossmap.coordinate_to_protein, + 4, + crossmap.protein_to_coordinate, + ProteinPoint(position=4, offset=1, region='d', position_in_codon=2) + ) + invariant( + crossmap.coordinate_to_protein, + 5, + crossmap.protein_to_coordinate, + ProteinPoint(position=4, offset=0, region='*', position_in_codon=2) + ) + + # Boundary between 5' UTR and CDS + invariant( + crossmap.coordinate_to_protein, + 31, + crossmap.protein_to_coordinate, + ProteinPoint(position=1, offset=0, region='*', position_in_codon=1), + ) + invariant( + crossmap.coordinate_to_protein, + 32, + crossmap.protein_to_coordinate, + ProteinPoint(position=2, offset=0, region='', position_in_codon=3), + ) # Intron boundary. invariant( - crossmap.coordinate_to_protein, 34, - crossmap.protein_to_coordinate, (1, 3, 0, 0, 0)) + crossmap.coordinate_to_protein, + 34, + crossmap.protein_to_coordinate, + ProteinPoint(position=2, offset=0, region='', position_in_codon=1), + ) invariant( - crossmap.coordinate_to_protein, 35, - crossmap.protein_to_coordinate, (1, 3, 1, 0, 0)) + crossmap.coordinate_to_protein, + 35, + crossmap.protein_to_coordinate, + ProteinPoint(position=2, offset=-1, region='', position_in_codon=1), + ) # Boundary between CDS and 3' UTR. invariant( - crossmap.coordinate_to_protein, 42, - crossmap.protein_to_coordinate, (2, 3, 0, 0, 0)) + crossmap.coordinate_to_protein, + 42, + crossmap.protein_to_coordinate, + ProteinPoint(position=1, offset=0, region='', position_in_codon=1), + ) + invariant( + crossmap.coordinate_to_protein, + 43, + crossmap.protein_to_coordinate, + ProteinPoint(position=1, offset=0, region='-', position_in_codon=3), + ) + + # Boundary between 3' UTR and downstream + invariant( + crossmap.coordinate_to_protein, + 71, + crossmap.protein_to_coordinate, + ProteinPoint(position=2, offset=0, region='-', position_in_codon=2) + ) + invariant( + crossmap.coordinate_to_protein, + 72, + crossmap.protein_to_coordinate, + ProteinPoint(position=2, offset=-1, region='u', position_in_codon=2) + ) + + +def test_Coding_protein_degenerate(): + """Degenerate upstream and downstream protein positions are corrected.""" + crossmap = Coding([(10, 20)], (12, 18)) + + # Degenerate position in upstream. + degenerate_equal( + crossmap.protein_to_coordinate, + 9, + [ + ProteinPoint(position=1, offset=-1, region='u', position_in_codon=2), + ProteinPoint(position=1, offset=0, region='-', position_in_codon=1), + ], + ) + # Degenerate position in downstream. + degenerate_equal( + crossmap.protein_to_coordinate, + 20, + [ + ProteinPoint(position=1, offset=1, region='d', position_in_codon=2), + ProteinPoint(position=1, offset=0, region='*', position_in_codon=3), + ], + ) + + +def test_Coding_inverted_protein_degenerate(): + """Degenerate upstream and downstream protein positions are corrected.""" + crossmap = Coding([(10, 20)], (12, 18), inverted=True) + + # Degenerate position in upstream. + degenerate_equal( + crossmap.protein_to_coordinate, + 20, + [ + ProteinPoint(position=1, offset=-1, region='u', position_in_codon=2), + ProteinPoint(position=1, offset=0, region='-', position_in_codon=1), + ], + ) + # Degenerate position in downstream. + degenerate_equal( + crossmap.protein_to_coordinate, + 9, + [ + ProteinPoint(position=1, offset=1, region='d', position_in_codon=2), + ProteinPoint(position=1, offset=0, region='*', position_in_codon=3), + ], + ) + + +def test_Coding_cds_end_equal_sequence_length(): + """Half-open exon/CDS end may equal sequence length.""" + crossmap = Coding([(0, 10)], (0, 10), length=10) + invariant( - crossmap.coordinate_to_protein, 43, - crossmap.protein_to_coordinate, (1, 1, 0, 1, 0)) + crossmap.coordinate_to_coding, + 9, + crossmap.coding_to_coordinate, + CodingPoint(position=10, offset=0, region=''), + ) + + with pytest.raises(ValueError) as error: + crossmap.coordinate_to_coding(10) + assert str(error.value) == 'Coordinate 10 is not within the bounds of the sequence length 10.' diff --git a/tests/test_locus.py b/tests/test_locus.py index a873416..9579ad4 100644 --- a/tests/test_locus.py +++ b/tests/test_locus.py @@ -1,43 +1,160 @@ -from mutalyzer_crossmapper import Locus +import pytest -from helper import degenerate_equal, invariant +from helper import invariant +from mutalyzer_crossmapper.locus import Locus, Point -def test_Locus(): - """Forward orientent Lovus.""" +def test_invalid_locus_initialization(): + """Test Locus initialization.""" + with pytest.raises(ValueError) as error: + Locus((10, 5)) + assert str(error.value) == 'Locus start 10 must be smaller than locus end 5.' + with pytest.raises(ValueError) as error: + Locus((10, 20, 30)) + assert str(error.value) == 'Locus must be a tuple of two values.' + with pytest.raises(ValueError) as error: + Locus((10, -5)) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + Locus((10, 20.5)) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + Locus((10, None)) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + Locus(('10', '20')) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + Locus((10, True)) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + Locus((10, 10)) + assert str(error.value) == 'Locus start 10 must be smaller than locus end 10.' + with pytest.raises(ValueError) as error: + Locus((10, 20), 100) + assert str(error.value) == 'Value 100 is not a boolean.' + + # Inverted Locus initialization + with pytest.raises(ValueError) as error: + Locus((10, 5), inverted=True) + assert str(error.value) == 'Locus start 10 must be smaller than locus end 5.' + with pytest.raises(ValueError) as error: + Locus((10, 20, 30), inverted=True) + assert str(error.value) == 'Locus must be a tuple of two values.' + with pytest.raises(ValueError) as error: + Locus((10, -5), inverted=True) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + Locus((10, 20.5), inverted=True) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + Locus((10, None), inverted=True) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + Locus(('10', '20'), inverted=True) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + Locus((10, 10), inverted=True) + assert str(error.value) == 'Locus start 10 must be smaller than locus end 10.' + + +def test_invalid_locus_coordinate(): + """Forward orientent Locus with invalid coordinate.""" locus = Locus((30, 35)) - invariant(locus.to_position, 29, locus.to_coordinate, (0, -1)) - invariant(locus.to_position, 30, locus.to_coordinate, (0, 0)) - invariant(locus.to_position, 31, locus.to_coordinate, (1, 0)) - invariant(locus.to_position, 33, locus.to_coordinate, (3, 0)) - invariant(locus.to_position, 34, locus.to_coordinate, (4, 0)) - invariant(locus.to_position, 35, locus.to_coordinate, (4, 1)) + with pytest.raises(ValueError) as error: + locus.to_position(-1) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + locus.to_position(3.5) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + locus.to_position('10') + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + locus.to_position(None) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + locus.to_position([10]) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + locus.to_position(True) + assert str(error.value) == 'Value must be an integer.' + +def test_locus_negative_coordinate(): + """An offset may not convert to a coordinate before the sequence start.""" + with pytest.raises(ValueError) as error: + Locus((0, 10)).to_coordinate(Point(position=0, offset=-1)) + assert str(error.value) == 'Position 0 with offset -1 converts to negative coordinate -1.' -def test_Locus_inverted(): - """Reverse orientent Lovus.""" + with pytest.raises(ValueError) as error: + Locus((0, 10), True).to_coordinate(Point(position=9, offset=1)) + assert str(error.value) == 'Position 9 with offset 1 converts to negative coordinate -1.' + + +def test_invalid_locus_point(): + """Forward orientent Locus with invalid point.""" + locus = Locus((30, 35)) + + with pytest.raises(ValueError) as error: + locus.to_coordinate(Point(position=-5, offset=0)) + assert str(error.value) == 'Value must be non-negative.' + + with pytest.raises(IndexError) as error: + locus.to_coordinate(Point(position=5, offset=0)) + assert str(error.value) == 'Position 5 exceeds locus length.' + with pytest.raises(IndexError) as error: + locus.to_coordinate(Point(position=0, offset=2)) + assert str(error.value) == 'Offset 2 should be at a locus end.' + with pytest.raises(IndexError) as error: + locus.to_coordinate(Point(position=4, offset=-2)) + assert str(error.value) == 'Offset -2 should be at a locus start.' + with pytest.raises(ValueError) as error: + locus.to_coordinate(Point(position=2, offset=1)) + assert str(error.value) == 'Position 2 is not at a locus boundary.' + + +def test_invalid_locus_inverted_point(): + """Reverse orientent Locus with invalid point.""" locus = Locus((30, 35), True) - invariant(locus.to_position, 35, locus.to_coordinate, (0, -1)) - invariant(locus.to_position, 34, locus.to_coordinate, (0, 0)) - invariant(locus.to_position, 33, locus.to_coordinate, (1, 0)) - invariant(locus.to_position, 31, locus.to_coordinate, (3, 0)) - invariant(locus.to_position, 30, locus.to_coordinate, (4, 0)) - invariant(locus.to_position, 29, locus.to_coordinate, (4, 1)) + with pytest.raises(ValueError) as error: + locus.to_coordinate(Point(position=-5, offset=0)) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(IndexError) as error: + locus.to_coordinate(Point(position=5, offset=0)) + assert str(error.value) == 'Position 5 exceeds locus length.' + with pytest.raises(IndexError) as error: + locus.to_coordinate(Point(position=0, offset=2)) + assert str(error.value) == 'Offset 2 should be at a locus end.' + with pytest.raises(IndexError) as error: + locus.to_coordinate(Point(position=4, offset=-2)) + assert str(error.value) == 'Offset -2 should be at a locus start.' + with pytest.raises(ValueError) as error: + locus.to_coordinate(Point(position=2, offset=1)) + assert str(error.value) == 'Position 2 is not at a locus boundary.' -def test_Locus_degenerate(): - """Degenerate positions are silently corrected.""" - locus = Locus((10, 20)) +def test_locus(): + """Forward orientent Locus.""" + locus = Locus((30, 35)) - degenerate_equal(locus.to_coordinate, 9, [(0, -1), (-1, 0)]) - degenerate_equal(locus.to_coordinate, 20, [(9, 1), (10, 0)]) + invariant(locus.to_position, 29, locus.to_coordinate, Point(position=0, offset=-1)) + invariant(locus.to_position, 30, locus.to_coordinate, Point(position=0, offset=0)) + invariant(locus.to_position, 31, locus.to_coordinate, Point(position=1, offset=0)) + invariant(locus.to_position, 33, locus.to_coordinate, Point(position=3, offset=0)) + invariant(locus.to_position, 34, locus.to_coordinate, Point(position=4, offset=0)) + invariant(locus.to_position, 35, locus.to_coordinate, Point(position=4, offset=1)) -def test_Locus_inverted_degenerate(): - """Degenerate positions are silently corrected.""" - locus = Locus((10, 20), True) +def test_locus_inverted(): + """Reverse orientent Locus.""" + locus = Locus((30, 35), True) - degenerate_equal(locus.to_coordinate, 20, [(0, -1), (-1, 0)]) - degenerate_equal(locus.to_coordinate, 9, [(9, 1), (10, 0)]) + invariant(locus.to_position, 35, locus.to_coordinate, Point(position=0, offset=-1)) + invariant(locus.to_position, 34, locus.to_coordinate, Point(position=0, offset=0)) + invariant(locus.to_position, 33, locus.to_coordinate, Point(position=1, offset=0)) + invariant(locus.to_position, 31, locus.to_coordinate, Point(position=3, offset=0)) + invariant(locus.to_position, 30, locus.to_coordinate, Point(position=4, offset=0)) + invariant(locus.to_position, 29, locus.to_coordinate, Point(position=4, offset=1)) diff --git a/tests/test_multi_locus.py b/tests/test_multi_locus.py index 6ce0013..4f6cf87 100644 --- a/tests/test_multi_locus.py +++ b/tests/test_multi_locus.py @@ -1,7 +1,8 @@ -from mutalyzer_crossmapper import MultiLocus -from mutalyzer_crossmapper.multi_locus import _offsets +from mutalyzer_crossmapper import multi_locus +from mutalyzer_crossmapper.multi_locus import _offsets, MultiLocus, Point +from helper import invariant -from helper import degenerate_equal, invariant +import pytest _locations = [(5, 8), (14, 20), (30, 35), (40, 44), (50, 52), (70, 72)] @@ -26,35 +27,178 @@ def test_offsets_adjacent_inverted(): assert _offsets([(1, 3), (3, 5)], -1) == [0, 2] +## Test MultiLocus model and its point model +def test_invalid_MultiLocus_initialization(): + """Test MultiLocus initialization.""" + with pytest.raises(ValueError) as error: + MultiLocus(([(10, 5), (20, 25)])) + assert str(error.value) == 'Locus start 10 must be smaller than locus end 5.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 20, 30), (40, 50)]) + assert str(error.value) == 'Locus must be a tuple of two values.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, -5), (20, 25)]) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 20.5), (30, 40)]) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + MultiLocus([(10.5, None), (20, 30)]) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + MultiLocus([('10', '20'), (30, 40)]) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 20), (15, 25)]) + assert str(error.value) == 'Locus (10, 20) and locus (15, 25) are overlapping.' + # Disjoint, so out of order rather than overlapping. + with pytest.raises(ValueError) as error: + MultiLocus([(30, 40), (10, 20)]) + assert str(error.value) == 'Locus (30, 40) and locus (10, 20) are not in ascending order.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 12), (15, 25)], length=24) + assert str(error.value) == 'Location end 25 is inconsistent with sequence length 24.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 12), (15, 25)], length=0) + assert str(error.value) == 'Value 0 is not positive.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 12), (15, 25)], length='25') + assert str(error.value) == 'Value must be an integer.' + # A bool would otherwise pass as a length, since bool subclasses int. + with pytest.raises(ValueError) as error: + MultiLocus([(10, 12), (15, 25)], length=True) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 12), (15, 25)], 100) + assert str(error.value) == 'Value 100 is not a boolean.' + + # Inverted MultiLocus initialization + with pytest.raises(ValueError) as error: + MultiLocus(([(10, 5), (20, 25)]), inverted=True) + assert str(error.value) == 'Locus start 10 must be smaller than locus end 5.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 20, 30), (40, 50)], inverted=True) + assert str(error.value) == 'Locus must be a tuple of two values.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, -5), (20, 25)], inverted=True) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 20.5), (30, 40)], inverted=True) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + MultiLocus([(10.5, None), (20, 30)], inverted=True) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + MultiLocus([('10', '20'), (30, 40)], inverted=True) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 20), (15, 25)], inverted=True, length=25) + assert str(error.value) == 'Locus (10, 20) and locus (15, 25) are overlapping.' + with pytest.raises(ValueError) as error: + MultiLocus([(10, 12), (15, 25)], inverted=True, length=24) + assert str(error.value) == 'Location end 25 is inconsistent with sequence length 24.' + + +def test_MultiLocus_invalid_coordinate(): + """Forward orientent MultiLocus with invalid coordinate.""" + multi_locus = MultiLocus([(30, 35), (40, 45)]) + with pytest.raises(ValueError) as error: + multi_locus.to_position(-1) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_position(46.7) + assert str(error.value) == 'Value must be an integer.' + with pytest.raises(ValueError) as error: + multi_locus.to_position('31') + assert str(error.value) == 'Value must be an integer.' + + +def test_invalid_Point_initialization(): + """Test Point initialization.""" + with pytest.raises(ValueError) as error: + Point(position=-1, offset=0, region='') + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + Point(position=0, offset=0, region='*') + assert str(error.value) == 'Region * is invalid, it must be a string from "", "u" or "d".' + with pytest.raises(ValueError) as error: + Point(position=0, offset=0, region=None) + assert str(error.value) == 'Region None is invalid, it must be a string from "", "u" or "d".' + with pytest.raises(ValueError) as error: + Point(position='11', offset=0, region='u') + assert str(error.value) == 'Value must be an integer.' + + def test_MultiLocus(): """Forward oriented MultiLocus.""" multi_locus = MultiLocus(_locations) # Boundary between upstream and the first locus. invariant( - multi_locus.to_position, 4, multi_locus.to_coordinate, (0, -1, -1)) + multi_locus.to_position, + 4, + multi_locus.to_coordinate, + Point(position=0, offset=-1, region='u'), + ) + invariant( - multi_locus.to_position, 5, multi_locus.to_coordinate, (0, 0, 0)) + multi_locus.to_position, + 5, + multi_locus.to_coordinate, + Point(position=0, offset=0, region=''), + ) # Internal locus. invariant( - multi_locus.to_position, 29, multi_locus.to_coordinate, (9, -1, 0)) - invariant( - multi_locus.to_position, 30, multi_locus.to_coordinate, (9, 0, 0)) - invariant( - multi_locus.to_position, 31, multi_locus.to_coordinate, (10, 0, 0)) - invariant( - multi_locus.to_position, 33, multi_locus.to_coordinate, (12, 0, 0)) - invariant( - multi_locus.to_position, 34, multi_locus.to_coordinate, (13, 0, 0)) - invariant( - multi_locus.to_position, 35, multi_locus.to_coordinate, (13, 1, 0)) + multi_locus.to_position, + 29, + multi_locus.to_coordinate, + Point(position=9, offset=-1, region=''), + ) + invariant( + multi_locus.to_position, + 30, + multi_locus.to_coordinate, + Point(position=9, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 31, + multi_locus.to_coordinate, + Point(position=10, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 33, + multi_locus.to_coordinate, + Point(position=12, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 34, + multi_locus.to_coordinate, + Point(position=13, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 35, + multi_locus.to_coordinate, + Point(position=13, offset=1, region=''), + ) # Boundary between the last locus and downstream. invariant( - multi_locus.to_position, 71, multi_locus.to_coordinate, (21, 0, 0)) + multi_locus.to_position, + 71, + multi_locus.to_coordinate, + Point(position=21, offset=0, region=''), + ) invariant( - multi_locus.to_position, 72, multi_locus.to_coordinate, (21, 1, 1)) + multi_locus.to_position, + 72, + multi_locus.to_coordinate, + Point(position=21, offset=1, region='d'), + ) def test_MultiLocus_inverted(): @@ -63,29 +207,129 @@ def test_MultiLocus_inverted(): # Boundary between upstream and the first locus. invariant( - multi_locus.to_position, 72, multi_locus.to_coordinate, (0, -1, -1)) + multi_locus.to_position, + 72, + multi_locus.to_coordinate, + Point(position=0, offset=-1, region='u'), + ) invariant( - multi_locus.to_position, 71, multi_locus.to_coordinate, (0, 0, 0)) + multi_locus.to_position, + 71, + multi_locus.to_coordinate, + Point(position=0, offset=0, region=''), + ) # Internal locus. invariant( - multi_locus.to_position, 35, multi_locus.to_coordinate, (8, -1, 0)) - invariant( - multi_locus.to_position, 34, multi_locus.to_coordinate, (8, 0, 0)) - invariant( - multi_locus.to_position, 33, multi_locus.to_coordinate, (9, 0, 0)) - invariant( - multi_locus.to_position, 31, multi_locus.to_coordinate, (11, 0, 0)) + multi_locus.to_position, + 35, + multi_locus.to_coordinate, + Point(position=8, offset=-1, region=''), + ) + invariant( + multi_locus.to_position, + 34, + multi_locus.to_coordinate, + Point(position=8, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 33, + multi_locus.to_coordinate, + Point(position=9, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 31, + multi_locus.to_coordinate, + Point(position=11, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 30, + multi_locus.to_coordinate, + Point(position=12, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 29, + multi_locus.to_coordinate, + Point(position=12, offset=1, region=''), + ) + + # Boundary between the last locus and downstream. invariant( - multi_locus.to_position, 30, multi_locus.to_coordinate, (12, 0, 0)) + multi_locus.to_position, + 5, + multi_locus.to_coordinate, + Point(position=21, offset=0, region=''), + ) invariant( - multi_locus.to_position, 29, multi_locus.to_coordinate, (12, 1, 0)) + multi_locus.to_position, + 4, + multi_locus.to_coordinate, + Point(position=21, offset=1, region='d'), + ) + + +def test_MultiLocus_with_length(): + """Forward oriented MultiLocus.""" + multi_locus = MultiLocus(_locations, length=74) # Boundary between the last locus and downstream. invariant( - multi_locus.to_position, 5, multi_locus.to_coordinate, (21, 0, 0)) - invariant( - multi_locus.to_position, 4, multi_locus.to_coordinate, (21, 1, 1)) + multi_locus.to_position, + 71, + multi_locus.to_coordinate, + Point(position=21, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 72, + multi_locus.to_coordinate, + Point(position=21, offset=1, region='d'), + ) + invariant( + multi_locus.to_position, + 73, + multi_locus.to_coordinate, + Point(position=21, offset=2, region='d'), + ) + # Boundary between the last base and beyond the last base. + with pytest.raises(ValueError) as error: + multi_locus.to_position(74) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=21, offset=3, region='d')) + + +def test_MultiLocus_inverted_with_length(): + """Inverted MultiLocus with length.""" + multi_locus = MultiLocus(_locations, True, 74) + + # Boundary between the first locus and upstream. + invariant( + multi_locus.to_position, + 71, + multi_locus.to_coordinate, + Point(position=0, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 72, + multi_locus.to_coordinate, + Point(position=0, offset=-1, region='u'), + ) + # Boundary between the first base beyond the first base. + invariant( + multi_locus.to_position, + 73, + multi_locus.to_coordinate, + Point(position=0, offset=-2, region='u'), + ) + with pytest.raises(ValueError) as error: + multi_locus.to_position(74) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=-3, region='u')) def test_MultiLocus_adjacent_loci(): @@ -93,19 +337,35 @@ def test_MultiLocus_adjacent_loci(): multi_locus = MultiLocus([(1, 3), (3, 5)]) invariant( - multi_locus.to_position, 2, multi_locus.to_coordinate, (1, 0, 0)) + multi_locus.to_position, + 2, + multi_locus.to_coordinate, + Point(position=1, offset=0, region=''), + ) invariant( - multi_locus.to_position, 3, multi_locus.to_coordinate, (2, 0, 0)) + multi_locus.to_position, + 3, + multi_locus.to_coordinate, + Point(position=2, offset=0, region=''), + ) def test_MultiLocus_adjacent_loci_inverted(): """Positions are continuous when loci are adjacent.""" - multi_locus = MultiLocus([(1, 3), (3, 5)], True) + multi_locus = MultiLocus([(1, 3), (3, 5)], inverted=True) invariant( - multi_locus.to_position, 3, multi_locus.to_coordinate, (1, 0, 0)) + multi_locus.to_position, + 3, + multi_locus.to_coordinate, + Point(position=1, offset=0, region=''), + ) invariant( - multi_locus.to_position, 2, multi_locus.to_coordinate, (2, 0, 0)) + multi_locus.to_position, + 2, + multi_locus.to_coordinate, + Point(position=2, offset=0, region=''), + ) def test_MultiLocus_offsets_odd(): @@ -113,19 +373,34 @@ def test_MultiLocus_offsets_odd(): multi_locus = MultiLocus([(1, 3), (6, 8)]) invariant( - multi_locus.to_position, 4, multi_locus.to_coordinate, (1, 2, 0)) + multi_locus.to_position, + 4, + multi_locus.to_coordinate, + Point(position=1, offset=2, region=''), + ) invariant( - multi_locus.to_position, 5, multi_locus.to_coordinate, (2, -1, 0)) + multi_locus.to_position, + 5, + multi_locus.to_coordinate, + Point(position=2, offset=-1, region=''), + ) def test_MultiLocus_offsets_odd_inverted(): """Offets exacly between two loci are assigned to the upstream locus.""" - multi_locus = MultiLocus([(1, 3), (6, 8)], True) - + multi_locus = MultiLocus([(1, 3), (6, 8)], inverted=True) invariant( - multi_locus.to_position, 4, multi_locus.to_coordinate, (1, 2, 0)) + multi_locus.to_position, + 4, + multi_locus.to_coordinate, + Point(position=1, offset=2, region=''), + ) invariant( - multi_locus.to_position, 3, multi_locus.to_coordinate, (2, -1, 0)) + multi_locus.to_position, + 3, + multi_locus.to_coordinate, + Point(position=2, offset=-1, region=''), + ) def test_MultiLocus_offsets_even(): @@ -133,36 +408,328 @@ def test_MultiLocus_offsets_even(): multi_locus = MultiLocus([(1, 3), (7, 9)]) invariant( - multi_locus.to_position, 4, multi_locus.to_coordinate, (1, 2, 0)) + multi_locus.to_position, + 4, + multi_locus.to_coordinate, + Point(position=1, offset=2, region=''), + ) invariant( - multi_locus.to_position, 5, multi_locus.to_coordinate, (2, -2, 0)) + multi_locus.to_position, + 5, + multi_locus.to_coordinate, + Point(position=2, offset=-2, region=''), + ) def test_MultiLocus_offsets_even_inverted(): """Offsets are assigned to the nearest locus.""" - multi_locus = MultiLocus([(1, 3), (7, 9)], True) - - invariant( - multi_locus.to_position, 5, multi_locus.to_coordinate, (1, 2, 0)) - invariant( - multi_locus.to_position, 4, multi_locus.to_coordinate, (2, -2, 0)) - - -def test_MultiLocus_degenerate(): - """Degenerate upstream and downstream positions are silently corrected.""" - multi_locus = MultiLocus(_locations) - - degenerate_equal( - multi_locus.to_coordinate, 4, [(0, -1, -1), (-1, 0, -1)]) - degenerate_equal( - multi_locus.to_coordinate, 72, [(21, 1, 1), (22, 0, 1)]) - - -def test_MultiLocus_inverted_degenerate(): - """Degenerate upstream and downstream positions are silently corrected.""" - multi_locus = MultiLocus(_locations, True) - - degenerate_equal( - multi_locus.to_coordinate, 72, [(0, -1, -1), (-1, 0, -1)]) - degenerate_equal( - multi_locus.to_coordinate, 4, [(21, 1, 1), (22, 0, 1)]) + multi_locus = MultiLocus([(1, 3), (7, 9)], inverted=True) + + invariant( + multi_locus.to_position, + 5, + multi_locus.to_coordinate, + Point(position=1, offset=2, region=''), + ) + invariant( + multi_locus.to_position, + 4, + multi_locus.to_coordinate, + Point(position=2, offset=-2, region=''), + ) + + +def test_one_base_exon(): + """One base exons.""" + multi_locus = MultiLocus([(1, 2), (4, 5)]) + invariant( + multi_locus.to_position, + 0, + multi_locus.to_coordinate, + Point(position=0, offset=-1, region='u'), + ) + invariant( + multi_locus.to_position, + 1, + multi_locus.to_coordinate, + Point(position=0, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 2, + multi_locus.to_coordinate, + Point(position=0, offset=1, region=''), + ) + invariant( + multi_locus.to_position, + 3, + multi_locus.to_coordinate, + Point(position=1, offset=-1, region=''), + ) + invariant( + multi_locus.to_position, + 4, + multi_locus.to_coordinate, + Point(position=1, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 5, + multi_locus.to_coordinate, + Point(position=1, offset=1, region='d'), + ) + + +def test_one_base_exon_inverted(): + """One base exons.""" + multi_locus = MultiLocus([(1, 2), (4, 5)], inverted=True) + invariant( + multi_locus.to_position, + 0, + multi_locus.to_coordinate, + Point(position=1, offset=1, region='d'), + ) + invariant( + multi_locus.to_position, + 1, + multi_locus.to_coordinate, + Point(position=1, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 2, + multi_locus.to_coordinate, + Point(position=1, offset=-1, region=''), + ) + invariant( + multi_locus.to_position, + 3, + multi_locus.to_coordinate, + Point(position=0, offset=1, region=''), + ) + invariant( + multi_locus.to_position, + 4, + multi_locus.to_coordinate, + Point(position=0, offset=0, region=''), + ) + invariant( + multi_locus.to_position, + 5, + multi_locus.to_coordinate, + Point(position=0, offset=-1, region='u'), + ) + + +def test_upstream_invalid_position(): + multi_locus = MultiLocus([(5, 10), (15, 20)], length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=1, offset=-1, region='u')) + assert str(error.value) == 'Position 1 is not at upstream boundary.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=-1, offset=-1, region='u')) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=20, offset=-1, region='u')) + assert str(error.value) == 'Position 20 is not at upstream boundary.' + + +def test_upstream_invalid_position_inverted(): + multi_locus = MultiLocus([(5, 10), (15, 20)], inverted=True, length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=1, offset=-1, region='u')) + assert str(error.value) == 'Position 1 is not at upstream boundary.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=-1, offset=-1, region='u')) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=20, offset=-1, region='u')) + assert str(error.value) == 'Position 20 is not at upstream boundary.' + + +def test_upstream_invalid_offset(): + multi_locus = MultiLocus([(5, 10), (15, 20)], length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=1, region='u')) + assert str(error.value) == 'Offset 1 at upstream region should be negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=-6, region='u')) + assert str(error.value) == 'Offset -6 exceeds upstream region.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=0, region='u')) + assert str(error.value) == 'Offset 0 at upstream region should be negative.' + + +def test_upstream_invalid_offset_inverted(): + multi_locus = MultiLocus([(5, 10), (15, 20)], inverted=True, length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=1, region='u')) + assert str(error.value) == 'Offset 1 at upstream region should be negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=-6, region='u')) + assert str(error.value) == 'Offset -6 exceeds upstream region.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=0, region='u')) + assert str(error.value) == 'Offset 0 at upstream region should be negative.' + + +def test_transcribed_invalid_position(): + multi_locus = MultiLocus([(5, 10), (15, 20)], length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=-1, offset=0, region='')) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=7, offset=1, region='')) + assert str(error.value) == 'Position 7 is not at a locus boundary.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=20, offset=0, region='')) + assert str(error.value) == 'Position 20 exceeds multi locus length.' + + +def test_transcribed_invalid_position_inverted(): + multi_locus = MultiLocus([(5, 10), (15, 20)], inverted=True, length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=-1, offset=0, region='')) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=7, offset=-1, region='')) + assert str(error.value) == 'Position 7 is not at a locus boundary.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=20, offset=0, region='')) + assert str(error.value) == 'Position 20 exceeds multi locus length.' + + +def test_transcribed_invalid_offset(): + multi_locus = MultiLocus([(5, 10), (15, 20)], length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=-5, region='')) + assert str(error.value) == 'Offset -5 at the first locus should be in the upstream region.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=0, offset=1, region='')) + assert str(error.value) == 'Offset 1 should be at a locus end.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=0, offset=10, region='')) + assert str(error.value) == 'Offset 10 exceeds intron length.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=4, offset=6, region='')) + assert str(error.value) == 'Offset 6 exceeds intron length.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=4, offset=-1, region='')) + assert str(error.value) == 'Offset -1 should be at a locus start.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=5, offset=2, region='')) + assert str(error.value) == 'Offset 2 should be at a locus end.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=5, offset=-6, region='')) + assert str(error.value) == 'Offset -6 exceeds intron length.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=1, region='')) + assert str(error.value) == 'Offset 1 at the last locus should be in the downstream region.' + + +def test_transcribed_invalid_offset_inverted(): + multi_locus = MultiLocus([(5, 10), (15, 20)], inverted=True, length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=5, region='')) + assert str(error.value) == 'Offset 5 at the last locus should be in the downstream region.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=9, offset=-1, region='')) + assert str(error.value) == 'Offset -1 should be at a locus start.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=9, offset=-10, region='')) + assert str(error.value) == 'Offset -10 exceeds intron length.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=5, offset=-6, region='')) + assert str(error.value) == 'Offset -6 exceeds intron length.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=5, offset=1, region='')) + assert str(error.value) == 'Offset 1 should be at a locus end.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=4, offset=6, region='')) + assert str(error.value) == 'Offset 6 exceeds intron length.' + with pytest.raises(IndexError) as error: + multi_locus.to_coordinate(Point(position=4, offset=-1, region='')) + assert str(error.value) == 'Offset -1 should be at a locus start.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=-5, region='')) + assert str(error.value) == 'Offset -5 at the first locus should be in the upstream region.' + + +def test_downstream_invalid_position(): + multi_locus = MultiLocus([(5, 10), (15, 20)], length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=1, region='d')) + assert str(error.value) == 'Position 0 is not at downstream boundary.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=-1, offset=1, region='d')) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=11, offset=1, region='d')) + assert str(error.value) == 'Position 11 is not at downstream boundary.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=100, offset=1, region='d')) + assert str(error.value) == 'Position 100 is not at downstream boundary.' + + +def test_downstream_invalid_position_inverted(): + multi_locus = MultiLocus([(5, 10), (15, 20)], inverted=True, length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=0, offset=1, region='d')) + assert str(error.value) == 'Position 0 is not at downstream boundary.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=-1, offset=1, region='d')) + assert str(error.value) == 'Value must be non-negative.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=11, offset=1, region='d')) + assert str(error.value) == 'Position 11 is not at downstream boundary.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=100, offset=1, region='d')) + assert str(error.value) == 'Position 100 is not at downstream boundary.' + + +def test_downstream_invalid_offset(): + multi_locus = MultiLocus([(5, 10), (15, 20)], length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=-1, region='d')) + assert str(error.value) == 'Offset -1 at downstream region should be positive.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=0, region='d')) + assert str(error.value) == 'Offset 0 at downstream region should be positive.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=-5, region='d')) + assert str(error.value) == 'Offset -5 at downstream region should be positive.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=6, region='d')) + assert str(error.value) == 'Offset 6 exceeds downstream region.' + + +def test_downstream_invalid_offset_inverted(): + multi_locus = MultiLocus([(5, 10), (15, 20)], inverted=True, length=25) + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=-1, region='d')) + assert str(error.value) == 'Offset -1 at downstream region should be positive.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=0, region='d')) + assert str(error.value) == 'Offset 0 at downstream region should be positive.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=-5, region='d')) + assert str(error.value) == 'Offset -5 at downstream region should be positive.' + with pytest.raises(ValueError) as error: + multi_locus.to_coordinate(Point(position=9, offset=6, region='d')) + assert str(error.value) == 'Offset 6 exceeds downstream region.' + + +def test_MultiLocus_location_end_equal_sequence_length(): + """Half-open location end may equal sequence length.""" + multi_locus = MultiLocus([(0, 10)], length=10) + + invariant( + multi_locus.to_position, + 9, + multi_locus.to_coordinate, + Point(position=9, offset=0, region=''), + ) + + with pytest.raises(ValueError) as error: + multi_locus.to_position(10) + assert str(error.value) == 'Coordinate 10 is not within the bounds of the sequence length 10.'