Source code for codonadaptpy.validation

"""
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