"""
CodonAdaptPy Coding-Sequence Validation
This module normalizes DNA/RNA input and applies configurable coding-sequence
quality checks. Validation is non-destructive: every finding is returned in a
structured report, while strict callers may request an exception for errors.
Checks:
- Empty input, whitespace, RNA conversion, and unsupported symbols
- Reading-frame offset and triplet divisibility
- Required start and terminal stop codons
- Internal stops and ambiguous codons
- Partial-sequence policy and supported genetic code
Classes:
- SequenceValidator: Reusable validator configured for one analysis policy.
:Created: July 20, 2026
:Updated: July 20, 2026
:Author: Naveen Duhan
:Version: 1.0.2
"""
from __future__ import annotations
import re
from typing import Literal, cast
from .exceptions import SequenceValidationError
from .genetic_code import GeneticCode
from .models import SequenceRecord, ValidationConfig, ValidationIssue, ValidationReport
IUPAC_DNA = frozenset("ACGTRYSWKMBDHVN")
[docs]
class SequenceValidator:
"""Validate and normalize coding sequences under a fixed policy."""
def __init__(self, config: ValidationConfig | None = None) -> None:
"""Initialize the validator and resolve its selected genetic code."""
self.config = config or ValidationConfig()
self.genetic_code = GeneticCode.from_ncbi(self.config.genetic_code)
[docs]
def validate(self, record: SequenceRecord, *, raise_on_error: bool = False) -> ValidationReport:
"""Validate one sequence record and return every detected issue.
Parameters
----------
record:
Input record containing an identifier and nucleotide sequence.
raise_on_error:
Raise :class:`SequenceValidationError` after collecting errors.
The default returns an invalid report for batch-friendly behavior.
"""
raw = re.sub(r"\s+", "", record.sequence).upper()
issues: list[ValidationIssue] = []
if not raw:
issues.append(ValidationIssue("empty_sequence", "The sequence is empty.", "error"))
return self._finish(record.identifier, raw, issues, raise_on_error)
if "U" in raw:
severity = cast(Literal["warning", "error"], "warning" if self.config.convert_rna else "error")
issues.append(
ValidationIssue(
"rna_input",
"RNA base U was detected." + (" It was converted to T." if self.config.convert_rna else ""),
severity,
)
)
if self.config.convert_rna:
raw = raw.replace("U", "T")
invalid = [(index, base) for index, base in enumerate(raw) if base not in IUPAC_DNA]
for index, base in invalid[:25]:
issues.append(ValidationIssue("invalid_character", f"Unsupported nucleotide {base!r}.", "error", index))
if len(invalid) > 25:
issues.append(
ValidationIssue(
"invalid_character_limit", f"{len(invalid) - 25} additional invalid symbols were omitted.", "error"
)
)
framed = raw[self.config.reading_frame :]
if self.config.reading_frame and len(raw) <= self.config.reading_frame:
issues.append(
ValidationIssue(
"invalid_reading_frame", "The reading-frame offset removes the complete sequence.", "error"
)
)
remainder = len(framed) % 3
if remainder:
severity = cast(Literal["warning", "error"], "warning" if self.config.allow_partial else "error")
issues.append(
ValidationIssue(
"incomplete_codon",
f"Sequence length in frame leaves {remainder} trailing nucleotide(s).",
severity,
len(raw) - remainder,
)
)
codons = [framed[index : index + 3] for index in range(0, len(framed) - 2, 3)]
ambiguous = [(index, codon) for index, codon in enumerate(codons) if set(codon) - set("ACGT")]
for index, codon in ambiguous:
severity = cast(
Literal["warning", "error"], "error" if self.config.ambiguous_policy == "reject" else "warning"
)
action = {"reject": "rejected", "skip": "skipped", "allow": "retained"}[self.config.ambiguous_policy]
issues.append(
ValidationIssue(
"ambiguous_codon",
f"Ambiguous codon {codon} will be {action}.",
severity,
self.config.reading_frame + index * 3,
)
)
if codons:
if (
self.config.require_start
and not self.config.allow_partial
and codons[0] not in self.genetic_code.start_codons
):
issues.append(
ValidationIssue(
"missing_start",
f"First codon {codons[0]} is not a start codon for genetic code {self.genetic_code.table_id}.",
"error",
self.config.reading_frame,
)
)
if (
self.config.require_stop
and not self.config.allow_partial
and codons[-1] not in self.genetic_code.stop_codons
):
issues.append(
ValidationIssue(
"missing_stop",
f"Final codon {codons[-1]} is not a stop codon for genetic code {self.genetic_code.table_id}.",
"error",
self.config.reading_frame + (len(codons) - 1) * 3,
)
)
for index, codon in enumerate(codons[:-1]):
if codon in self.genetic_code.stop_codons:
issues.append(
ValidationIssue(
"internal_stop",
f"Internal stop codon {codon} was found.",
"error",
self.config.reading_frame + index * 3,
)
)
return self._finish(record.identifier, raw, issues, raise_on_error)
def _finish(
self, identifier: str, sequence: str, issues: list[ValidationIssue], raise_on_error: bool
) -> ValidationReport:
"""Build a report and optionally convert error findings to an exception."""
report = ValidationReport(identifier, sequence, issues, self.config.genetic_code, self.config.reading_frame)
if raise_on_error and not report.is_valid:
raise SequenceValidationError("; ".join(issue.message for issue in report.errors))
return report