"""The exceptions more than one tier must be able to name.
`MalformedResourceError` places the blame on the *resource*: its records or
its configuration do not hold to what its kind can mean. It subclasses
`ValueError` so it falls inside `cli_errors.RESOURCE_ERRORS` by construction,
which is what makes `grr_manage` report it as one attributed line rather than
as an unexpected internal error carrying a traceback (ADR 0008).
The module is a leaf -- it imports nothing from GAIn -- so the score layer,
the table layer and the CLI tier can raise and catch the same exception
without any of them acquiring a dependency on another.
`HistogramError` is here so that `cli_errors.RESOURCE_ERRORS` can name it
without importing the histogram module; `cli_errors` says why it may not.
"""
from __future__ import annotations
from collections.abc import Sequence
[docs]
class HistogramError(Exception):
"""A histogram-specific failure of one resource.
Raised for a categorical histogram past its cardinality limit, which
the statistics scan catches and nullifies, and for a histogram file
that cannot be read, which reaches ``grr_manage``'s one-line reporting
tier. A plain ``Exception``, not a :class:`MalformedResourceError`,
because the second case is a fault of the resource's state rather than
of its records or configuration.
"""
[docs]
def overlapping_records_error(
resource_id: str, chrom: str, pos: int, prev_end: int,
) -> MalformedResourceError:
"""Refuse a position score whose record claims a position already taken.
Built here rather than at either raise site because two paths detect this
one rule -- the per-record region read and the vectorized statistics scan
-- and a reader who meets the message from one of them must not have to
wonder whether the other words it differently.
"""
return MalformedResourceError(
f"<{resource_id}> is malformed: multiple values for positions "
f"{chrom}:{pos}, which overlaps the preceding record ending at "
f"{chrom}:{prev_end}; a position score allows at most one record "
f"per position")
[docs]
def backwards_records_error(
resource_id: str, chrom: str, pos: int, prev_pos: int, kind: str,
) -> MalformedResourceError:
"""Refuse a score whose records move backwards along a contig.
The sibling of :func:`overlapping_records_error`, and here for the same
reason. The position rule has been built from one helper since it was
detected on two paths; this rule is detected on two paths for each of TWO
kinds, and was written out at all four sites -- six copies of one
sentence, agreeing only for as long as nobody edited one of them.
``kind`` is the possessive naming the kind whose promise was broken
("a fragment score's"), so the message still says which validator fired.
It is passed by each raise site rather than read off a shared class
attribute: an attribute two validators interpret for themselves is what
ADR 0008 unwound, and a *message* fragment is not a rule.
"""
return MalformedResourceError(
f"<{resource_id}> is malformed: the record at {chrom}:{pos} follows "
f"the record at {chrom}:{prev_pos}; {kind} records must not move "
f"backwards")
[docs]
def score_configuration_error(
resource_id: str, score_id: str, detail: str,
) -> MalformedResourceError:
"""Refuse a score whose DEFINITION says what the score cannot be.
The sibling of :func:`overlapping_records_error` for the other half of
what makes a resource malformed: not a record that breaks its kind's
promise, but a definition claiming a value the score cannot hold.
The rules live in three layers, and each raise site phrases its own
``detail``: the VCF header/config merge (the ``_refuse_*`` helpers in
``vcf_scores``, for an ``id``, an address or a ``type:`` the header
contradicts, and for a field declared ``Number=G``), the construction
convergence point (``refuse_unfoldable_histograms``, for a number
histogram over a value type none accumulates), and open, once a tabular
table's header is known (``validate_scoredefs``, for a column address
the header cannot honour, and ``resolve_score_indices``, which holds
the DEFINITIONS -- not the config -- to the same address rules as it
resolves each one to its payload column, and refuses a VCF definition
with no INFO key). The ``Number=G`` rule is the one a
``scores:`` entry need not have caused: a header-only resource has
none, and the claim is the header's.
What is shared is the ADDRESS: which resource, and which score in it.
That is the half a reader needs to find the file to edit, it is the half
no raise site can word differently without sending someone to the
wrong place, and it is why it is built here rather than at each site.
The prefix matches ``ResourceConfigValidationMixin`` so that a caller
reading a config error sees one wording; the TYPE is
:class:`MalformedResourceError`, so a caller that already catches "this
resource's own config is bad" by type catches these too rather than
matching on a string.
"""
return MalformedResourceError(
f"Invalid configuration: {resource_id}: score {score_id!r} {detail}")
[docs]
def inverted_span_error(
chrom: str, pos_begin: int, pos_end: int,
ref: str | None, alt: str | None,
) -> OSError:
"""Refuse a single record whose end precedes its begin.
Returned rather than raised, so the raise stays at the site that read the
slots -- and so the several sites that perform this check share one
message. Off the hot path by construction: a caller compares two integers
per record and only calls this when the comparison fails.
Not to be confused with :func:`backwards_records_error`, despite the
neighbouring vocabulary: that one refuses a resource whose records move
backwards *along a contig*, raises :class:`MalformedResourceError`, and
belongs to the scan's validation. This one is about a single record's own
two ends, and stays an ``OSError`` -- the type the read path has always
raised for it, and the type the tests pin.
Takes the five decoded values rather than the record they came from, for
two reasons. Reading the slots here would mean importing them from
``genomic_position_table.record``, whose package ``__init__`` imports
``table_tabix``, which imports this module -- a cycle, and the end of this
module's leaf status (see the module docstring). And a record's last slot
is the backend's payload, so a helper handed the whole record must be
careful never to interpolate it: ``f"{record}"`` would print an entire
``pysam.VariantRecord`` -- whose repr is the whole VCF line -- or a
``TupleProxy``. Named values cannot make that mistake.
``ref`` and ``alt`` are what tell two records at one position apart, so
the message names them when the record carries them, and says nothing in
their place when it does not (ADR 0027).
"""
ref_alt = f" {ref}->{alt}" if ref is not None or alt is not None else ""
return OSError(
f"The resource record {chrom}:{pos_begin}-{pos_end}{ref_alt} "
f"has a region with end {pos_end} smaller than the "
f"beginning {pos_begin}.")
[docs]
def index_column_mismatch_error(
resource_id: str,
index_filename: str,
mismatches: Sequence[tuple[str, int, int]],
*,
end_is_implied: bool = False,
) -> MalformedResourceError:
"""Refuse a tabix table whose index was built over other columns.
Each entry of ``mismatches`` is a field, the column its index was built
over, and the column its table *resolves* to -- resolves, not states: a
coordinate no configuration and no header names is read through a
hardcoded fallback, and a fallback the index disagrees with splits the
read from the filter exactly as a contradictory config entry does. Both
columns are named because the remedy is a one-line edit and a reader
cannot write it from either number alone. ``end_is_implied`` says the
index records no end column at all, so its end column is its begin column
by definition rather than by anyone's choice.
Built here rather than at the raise site for the same reason
:func:`overlapping_records_error` is: the rule is one rule, and it must
read identically wherever it is reported.
"""
fields = "; ".join(
f"{field} is indexed on column {indexed} but the configuration "
f"resolves it to column {configured}"
for field, indexed, configured in mismatches
)
implied_note = (
" The index records no end column, so every record ends where it "
"begins as far as the index is concerned."
) if end_is_implied else ""
return MalformedResourceError(
f"<{resource_id}> is malformed: its table is configured over columns "
f"its index {index_filename} was not built from: {fields}."
f"{implied_note} A region query is filtered by the indexed columns "
f"and read through the configured ones, so a record the index returns "
f"is dropped without a trace, and a record the configured span "
f"reaches over is never fetched at all. Rebuild the index over the "
f"configured columns, or configure the table with the indexed ones.")