CodonAdaptPy 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codonadaptpy/__init__.py +67 -0
- codonadaptpy/__main__.py +17 -0
- codonadaptpy/__version__.py +21 -0
- codonadaptpy/aggregation.py +94 -0
- codonadaptpy/analyzer.py +270 -0
- codonadaptpy/cli.py +409 -0
- codonadaptpy/comparative.py +538 -0
- codonadaptpy/exceptions.py +39 -0
- codonadaptpy/genetic_code.py +115 -0
- codonadaptpy/metrics/__init__.py +28 -0
- codonadaptpy/metrics/adaptation.py +133 -0
- codonadaptpy/metrics/composition.py +173 -0
- codonadaptpy/metrics/evolutionary.py +60 -0
- codonadaptpy/metrics/host.py +145 -0
- codonadaptpy/metrics/pairs.py +107 -0
- codonadaptpy/metrics/usage.py +155 -0
- codonadaptpy/models.py +128 -0
- codonadaptpy/optimizer.py +194 -0
- codonadaptpy/parsers.py +220 -0
- codonadaptpy/phylo_visualization.py +334 -0
- codonadaptpy/phylogenetics.py +602 -0
- codonadaptpy/py.typed +1 -0
- codonadaptpy/references.py +169 -0
- codonadaptpy/reporting.py +174 -0
- codonadaptpy/validation.py +169 -0
- codonadaptpy/visualization.py +1073 -0
- codonadaptpy-1.0.0.dist-info/METADATA +346 -0
- codonadaptpy-1.0.0.dist-info/RECORD +32 -0
- codonadaptpy-1.0.0.dist-info/WHEEL +5 -0
- codonadaptpy-1.0.0.dist-info/entry_points.txt +2 -0
- codonadaptpy-1.0.0.dist-info/licenses/LICENSE +14 -0
- codonadaptpy-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Reference-Data Management
|
|
3
|
+
|
|
4
|
+
This module creates, validates, loads, saves, versions, and catalogs codon
|
|
5
|
+
reference profiles. A reference may be derived from validated CDS records or
|
|
6
|
+
loaded from a JSON/CSV/TSV table. The package intentionally labels its bundled
|
|
7
|
+
uniform profile as synthetic; organism-specific biological profiles must carry
|
|
8
|
+
source and version metadata supplied by the user or distributor.
|
|
9
|
+
|
|
10
|
+
Classes:
|
|
11
|
+
- ReferenceProfile: Named codon counts with provenance metadata.
|
|
12
|
+
- ReferenceManager: Registry and persistence interface for profiles.
|
|
13
|
+
|
|
14
|
+
:Created: July 20, 2026
|
|
15
|
+
:Updated: July 20, 2026
|
|
16
|
+
:Author: Naveen Duhan
|
|
17
|
+
:Version: 1.0.0
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import csv
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .exceptions import ReferenceDataError
|
|
30
|
+
from .genetic_code import GeneticCode
|
|
31
|
+
from .models import SequenceRecord, ValidationConfig
|
|
32
|
+
from .validation import SequenceValidator
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class ReferenceProfile:
|
|
37
|
+
"""Represent versioned codon counts and their scientific provenance."""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
counts: dict[str, float]
|
|
41
|
+
genetic_code: int = 1
|
|
42
|
+
version: str = "1"
|
|
43
|
+
source: str = "user-supplied"
|
|
44
|
+
description: str = ""
|
|
45
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
"""Return profile content in JSON-compatible form."""
|
|
49
|
+
|
|
50
|
+
return asdict(self)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ReferenceManager:
|
|
54
|
+
"""Manage named codon profiles and reproducible reference persistence."""
|
|
55
|
+
|
|
56
|
+
def __init__(self) -> None:
|
|
57
|
+
"""Initialize a registry containing an explicitly synthetic baseline."""
|
|
58
|
+
|
|
59
|
+
code = GeneticCode.from_ncbi(1)
|
|
60
|
+
self._profiles: dict[str, ReferenceProfile] = {
|
|
61
|
+
"uniform_standard": ReferenceProfile(
|
|
62
|
+
name="uniform_standard",
|
|
63
|
+
counts={codon: 1.0 for codon, amino_acid in code.forward_table.items() if amino_acid != "*"},
|
|
64
|
+
source="CodonAdaptPy synthetic baseline",
|
|
65
|
+
description="Non-biological equal-count profile for testing and normalization.",
|
|
66
|
+
metadata={"biological": False},
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def register(self, profile: ReferenceProfile, *, replace: bool = False) -> None:
|
|
71
|
+
"""Validate and register a profile under its unique name."""
|
|
72
|
+
|
|
73
|
+
if profile.name in self._profiles and not replace:
|
|
74
|
+
existing = self._profiles[profile.name]
|
|
75
|
+
if existing.to_dict() == profile.to_dict():
|
|
76
|
+
return
|
|
77
|
+
raise ReferenceDataError(f"Reference profile already exists: {profile.name}")
|
|
78
|
+
code = GeneticCode.from_ncbi(profile.genetic_code)
|
|
79
|
+
sense = {codon for codon, amino_acid in code.forward_table.items() if amino_acid != "*"}
|
|
80
|
+
invalid = set(profile.counts) - set(code.forward_table)
|
|
81
|
+
if invalid:
|
|
82
|
+
raise ReferenceDataError(f"Reference contains unsupported codons: {', '.join(sorted(invalid))}")
|
|
83
|
+
if any(float(value) < 0 for value in profile.counts.values()):
|
|
84
|
+
raise ReferenceDataError("Reference counts must be non-negative.")
|
|
85
|
+
profile.counts = {codon: float(profile.counts.get(codon, 0.0)) for codon in sorted(sense)}
|
|
86
|
+
if not sum(profile.counts.values()):
|
|
87
|
+
raise ReferenceDataError("Reference contains no positive sense-codon counts.")
|
|
88
|
+
self._profiles[profile.name] = profile
|
|
89
|
+
|
|
90
|
+
def from_sequences(
|
|
91
|
+
self,
|
|
92
|
+
name: str,
|
|
93
|
+
records: list[SequenceRecord],
|
|
94
|
+
*,
|
|
95
|
+
genetic_code: int = 1,
|
|
96
|
+
version: str = "1",
|
|
97
|
+
source: str = "user CDS set",
|
|
98
|
+
) -> ReferenceProfile:
|
|
99
|
+
"""Build a reference by pooling strictly validated coding sequences."""
|
|
100
|
+
|
|
101
|
+
validator = SequenceValidator(ValidationConfig(genetic_code=genetic_code))
|
|
102
|
+
code = validator.genetic_code
|
|
103
|
+
counts = {codon: 0.0 for codon, amino_acid in code.forward_table.items() if amino_acid != "*"}
|
|
104
|
+
checksums: list[str] = []
|
|
105
|
+
for record in records:
|
|
106
|
+
report = validator.validate(record, raise_on_error=True)
|
|
107
|
+
framed = report.normalized_sequence[validator.config.reading_frame :]
|
|
108
|
+
for index in range(0, len(framed) - 2, 3):
|
|
109
|
+
codon = framed[index : index + 3]
|
|
110
|
+
if codon in counts:
|
|
111
|
+
counts[codon] += 1
|
|
112
|
+
checksums.append(hashlib.sha256(report.normalized_sequence.encode()).hexdigest())
|
|
113
|
+
profile = ReferenceProfile(
|
|
114
|
+
name,
|
|
115
|
+
counts,
|
|
116
|
+
genetic_code,
|
|
117
|
+
version,
|
|
118
|
+
source,
|
|
119
|
+
metadata={"sequence_count": len(records), "sequence_sha256": checksums},
|
|
120
|
+
)
|
|
121
|
+
self.register(profile)
|
|
122
|
+
return profile
|
|
123
|
+
|
|
124
|
+
def get(self, name: str) -> ReferenceProfile:
|
|
125
|
+
"""Return a registered profile or raise a domain-specific error."""
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
return self._profiles[name]
|
|
129
|
+
except KeyError as exc:
|
|
130
|
+
raise ReferenceDataError(f"Unknown reference profile: {name}") from exc
|
|
131
|
+
|
|
132
|
+
def list(self) -> list[dict[str, Any]]:
|
|
133
|
+
"""Return catalog metadata without duplicating full count tables."""
|
|
134
|
+
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
"name": profile.name,
|
|
138
|
+
"version": profile.version,
|
|
139
|
+
"source": profile.source,
|
|
140
|
+
"genetic_code": profile.genetic_code,
|
|
141
|
+
"description": profile.description,
|
|
142
|
+
}
|
|
143
|
+
for profile in self._profiles.values()
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
def save(self, name: str, path: str | Path) -> Path:
|
|
147
|
+
"""Serialize a registered profile to deterministic JSON."""
|
|
148
|
+
|
|
149
|
+
destination = Path(path)
|
|
150
|
+
destination.write_text(json.dumps(self.get(name).to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
151
|
+
return destination
|
|
152
|
+
|
|
153
|
+
def load(self, path: str | Path, *, replace: bool = False) -> ReferenceProfile:
|
|
154
|
+
"""Load JSON or two-column CSV/TSV reference data and register it."""
|
|
155
|
+
|
|
156
|
+
source_path = Path(path)
|
|
157
|
+
if source_path.suffix.lower() == ".json":
|
|
158
|
+
payload = json.loads(source_path.read_text(encoding="utf-8"))
|
|
159
|
+
profile = ReferenceProfile(**payload)
|
|
160
|
+
else:
|
|
161
|
+
delimiter = "," if source_path.suffix.lower() == ".csv" else "\t"
|
|
162
|
+
with source_path.open(encoding="utf-8", newline="") as handle:
|
|
163
|
+
rows = list(csv.DictReader(handle, delimiter=delimiter))
|
|
164
|
+
counts = {
|
|
165
|
+
row["codon"].upper().replace("U", "T"): float(row.get("count", row.get("frequency", 0))) for row in rows
|
|
166
|
+
}
|
|
167
|
+
profile = ReferenceProfile(source_path.stem, counts, source=str(source_path))
|
|
168
|
+
self.register(profile, replace=replace)
|
|
169
|
+
return profile
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Reproducible File Reports
|
|
3
|
+
|
|
4
|
+
This module exports analysis and optimization results to JSON, CSV, TSV, FASTA,
|
|
5
|
+
Excel, HTML, and optional PDF. Reports include software, input checksum,
|
|
6
|
+
genetic-code, reference-version, random-seed, and command provenance already
|
|
7
|
+
captured by the analyzer.
|
|
8
|
+
|
|
9
|
+
Classes:
|
|
10
|
+
- ReportGenerator: Write deterministic machine-readable and human reports.
|
|
11
|
+
|
|
12
|
+
:Created: July 20, 2026
|
|
13
|
+
:Updated: July 20, 2026
|
|
14
|
+
:Author: Naveen Duhan
|
|
15
|
+
:Version: 1.0.0
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import csv
|
|
21
|
+
import html
|
|
22
|
+
import json
|
|
23
|
+
from dataclasses import asdict
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .exceptions import OptionalDependencyError
|
|
28
|
+
from .models import AnalysisResult
|
|
29
|
+
from .optimizer import OptimizationResult
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ReportGenerator:
|
|
33
|
+
"""Export package results to machine-readable and publication-friendly files."""
|
|
34
|
+
|
|
35
|
+
def write_json(self, results: list[AnalysisResult], path: str | Path) -> Path:
|
|
36
|
+
"""Write complete nested analysis results as indented JSON."""
|
|
37
|
+
|
|
38
|
+
destination = Path(path)
|
|
39
|
+
destination.write_text(
|
|
40
|
+
json.dumps([result.to_dict() for result in results], indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
41
|
+
)
|
|
42
|
+
return destination
|
|
43
|
+
|
|
44
|
+
def write_table(self, results: list[AnalysisResult], path: str | Path, *, delimiter: str | None = None) -> Path:
|
|
45
|
+
"""Write one flattened scalar-metric row per sequence as CSV or TSV."""
|
|
46
|
+
|
|
47
|
+
destination = Path(path)
|
|
48
|
+
separator = delimiter or ("\t" if destination.suffix.lower() == ".tsv" else ",")
|
|
49
|
+
rows = [self._summary_row(result) for result in results]
|
|
50
|
+
fields = sorted({key for row in rows for key in row})
|
|
51
|
+
with destination.open("w", encoding="utf-8", newline="") as handle:
|
|
52
|
+
writer = csv.DictWriter(handle, fieldnames=fields, delimiter=separator, lineterminator="\n")
|
|
53
|
+
writer.writeheader()
|
|
54
|
+
writer.writerows(rows)
|
|
55
|
+
return destination
|
|
56
|
+
|
|
57
|
+
def write_html(self, results: list[AnalysisResult], path: str | Path) -> Path:
|
|
58
|
+
"""Write a self-contained HTML summary without a template dependency."""
|
|
59
|
+
|
|
60
|
+
destination = Path(path)
|
|
61
|
+
rows = [self._summary_row(result) for result in results]
|
|
62
|
+
fields = sorted({key for row in rows for key in row})
|
|
63
|
+
header = "".join(f"<th>{html.escape(field)}</th>" for field in fields)
|
|
64
|
+
body = "".join(
|
|
65
|
+
"<tr>" + "".join(f"<td>{html.escape(str(row.get(field, '')))}</td>" for field in fields) + "</tr>"
|
|
66
|
+
for row in rows
|
|
67
|
+
)
|
|
68
|
+
document = f"""<!doctype html>
|
|
69
|
+
<html lang="en">
|
|
70
|
+
<head>
|
|
71
|
+
<meta charset="utf-8"><title>CodonAdaptPy report</title>
|
|
72
|
+
<style>
|
|
73
|
+
body {{ font-family: system-ui; margin: 2rem; color: #18212b; }}
|
|
74
|
+
table {{ border-collapse: collapse; width: 100%; }}
|
|
75
|
+
th, td {{ border: 1px solid #ccd3da; padding: .45rem; text-align: right; }}
|
|
76
|
+
th {{ background: #edf3f7; }}
|
|
77
|
+
td:first-child, th:first-child {{ text-align: left; }}
|
|
78
|
+
.note {{ padding: 1rem; background: #fff6dc; border-left: 4px solid #d99b00; }}
|
|
79
|
+
</style>
|
|
80
|
+
</head>
|
|
81
|
+
<body>
|
|
82
|
+
<h1>CodonAdaptPy analysis report</h1>
|
|
83
|
+
<p class="note">Codon-usage similarity is descriptive and is not direct proof of expression,
|
|
84
|
+
replication, virulence, host switching, transmission, or fitness.</p>
|
|
85
|
+
<table><thead><tr>{header}</tr></thead><tbody>{body}</tbody></table>
|
|
86
|
+
</body>
|
|
87
|
+
</html>"""
|
|
88
|
+
destination.write_text(document, encoding="utf-8")
|
|
89
|
+
return destination
|
|
90
|
+
|
|
91
|
+
def write_excel(self, results: list[AnalysisResult], path: str | Path) -> Path:
|
|
92
|
+
"""Write summary, codon counts, RSCU, validation, and host sheets."""
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
import pandas as pd
|
|
96
|
+
except ImportError as exc:
|
|
97
|
+
raise OptionalDependencyError("Excel reports require CodonAdaptPy[analysis,io].") from exc
|
|
98
|
+
destination = Path(path)
|
|
99
|
+
with pd.ExcelWriter(destination) as writer:
|
|
100
|
+
pd.DataFrame([self._summary_row(result) for result in results]).to_excel(
|
|
101
|
+
writer, sheet_name="Summary", index=False
|
|
102
|
+
)
|
|
103
|
+
pd.DataFrame([{"identifier": result.identifier, **result.codon_counts} for result in results]).to_excel(
|
|
104
|
+
writer, sheet_name="Codon_counts", index=False
|
|
105
|
+
)
|
|
106
|
+
pd.DataFrame([{"identifier": result.identifier, **result.rscu} for result in results]).to_excel(
|
|
107
|
+
writer, sheet_name="RSCU", index=False
|
|
108
|
+
)
|
|
109
|
+
pd.DataFrame(
|
|
110
|
+
[{"identifier": result.identifier, **result.dinucleotide_odds} for result in results]
|
|
111
|
+
).to_excel(writer, sheet_name="Dinucleotide_OE", index=False)
|
|
112
|
+
pd.DataFrame(
|
|
113
|
+
[{"identifier": result.identifier, **result.dinucleotide_bias} for result in results]
|
|
114
|
+
).to_excel(writer, sheet_name="Dinucleotide_bias", index=False)
|
|
115
|
+
validation_rows = [
|
|
116
|
+
{"identifier": result.identifier, **asdict(issue)}
|
|
117
|
+
for result in results
|
|
118
|
+
for issue in result.validation.issues
|
|
119
|
+
]
|
|
120
|
+
pd.DataFrame(validation_rows).to_excel(writer, sheet_name="Validation", index=False)
|
|
121
|
+
host_rows = [
|
|
122
|
+
{"identifier": result.identifier, "host": host, **metrics}
|
|
123
|
+
for result in results
|
|
124
|
+
for host, metrics in result.host_comparisons.items()
|
|
125
|
+
]
|
|
126
|
+
pd.DataFrame(host_rows).to_excel(writer, sheet_name="Hosts", index=False)
|
|
127
|
+
return destination
|
|
128
|
+
|
|
129
|
+
def write_pdf(self, results: list[AnalysisResult], path: str | Path) -> Path:
|
|
130
|
+
"""Render the HTML report to PDF using the optional report extra."""
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
from weasyprint import HTML
|
|
134
|
+
except ImportError as exc:
|
|
135
|
+
raise OptionalDependencyError("PDF reports require CodonAdaptPy[report].") from exc
|
|
136
|
+
destination = Path(path)
|
|
137
|
+
html_path = destination.with_suffix(".html")
|
|
138
|
+
self.write_html(results, html_path)
|
|
139
|
+
HTML(filename=str(html_path)).write_pdf(str(destination))
|
|
140
|
+
return destination
|
|
141
|
+
|
|
142
|
+
def write_optimized_fasta(self, result: OptimizationResult, path: str | Path) -> Path:
|
|
143
|
+
"""Write ranked optimization candidates in FASTA format."""
|
|
144
|
+
|
|
145
|
+
destination = Path(path)
|
|
146
|
+
lines: list[str] = []
|
|
147
|
+
for candidate in result.candidates:
|
|
148
|
+
lines.extend(
|
|
149
|
+
[
|
|
150
|
+
f">candidate_{candidate.rank} score={candidate.score:.6f} "
|
|
151
|
+
f"cai={candidate.cai:.6f} gc={candidate.gc:.6f}",
|
|
152
|
+
candidate.sequence,
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
destination.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
156
|
+
return destination
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _summary_row(result: AnalysisResult) -> dict[str, Any]:
|
|
160
|
+
"""Flatten scalar metrics and host metrics for tabular exports."""
|
|
161
|
+
|
|
162
|
+
row: dict[str, Any] = {"identifier": result.identifier, "valid": result.validation.is_valid}
|
|
163
|
+
row.update(
|
|
164
|
+
{
|
|
165
|
+
key: value
|
|
166
|
+
for key, value in result.metrics.items()
|
|
167
|
+
if isinstance(value, (str, int, float, bool)) or value is None
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
row.update({f"dinucleotide_oe.{pair}": value for pair, value in result.dinucleotide_odds.items()})
|
|
171
|
+
row.update({f"dinucleotide_bias.{pair}": value for pair, value in result.dinucleotide_bias.items()})
|
|
172
|
+
for host, metrics in result.host_comparisons.items():
|
|
173
|
+
row.update({f"host.{host}.{key}": value for key, value in metrics.items()})
|
|
174
|
+
return row
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CodonAdaptPy Coding-Sequence Validation
|
|
3
|
+
|
|
4
|
+
This module normalizes DNA/RNA input and applies configurable coding-sequence
|
|
5
|
+
quality checks. Validation is non-destructive: every finding is returned in a
|
|
6
|
+
structured report, while strict callers may request an exception for errors.
|
|
7
|
+
|
|
8
|
+
Checks:
|
|
9
|
+
- Empty input, whitespace, RNA conversion, and unsupported symbols
|
|
10
|
+
- Reading-frame offset and triplet divisibility
|
|
11
|
+
- Required start and terminal stop codons
|
|
12
|
+
- Internal stops and ambiguous codons
|
|
13
|
+
- Partial-sequence policy and supported genetic code
|
|
14
|
+
|
|
15
|
+
Classes:
|
|
16
|
+
- SequenceValidator: Reusable validator configured for one analysis policy.
|
|
17
|
+
|
|
18
|
+
:Created: July 20, 2026
|
|
19
|
+
:Updated: July 20, 2026
|
|
20
|
+
:Author: Naveen Duhan
|
|
21
|
+
:Version: 1.0.0
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from typing import Literal, cast
|
|
28
|
+
|
|
29
|
+
from .exceptions import SequenceValidationError
|
|
30
|
+
from .genetic_code import GeneticCode
|
|
31
|
+
from .models import SequenceRecord, ValidationConfig, ValidationIssue, ValidationReport
|
|
32
|
+
|
|
33
|
+
IUPAC_DNA = frozenset("ACGTRYSWKMBDHVN")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SequenceValidator:
|
|
37
|
+
"""Validate and normalize coding sequences under a fixed policy."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: ValidationConfig | None = None) -> None:
|
|
40
|
+
"""Initialize the validator and resolve its selected genetic code."""
|
|
41
|
+
|
|
42
|
+
self.config = config or ValidationConfig()
|
|
43
|
+
self.genetic_code = GeneticCode.from_ncbi(self.config.genetic_code)
|
|
44
|
+
|
|
45
|
+
def validate(self, record: SequenceRecord, *, raise_on_error: bool = False) -> ValidationReport:
|
|
46
|
+
"""Validate one sequence record and return every detected issue.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
record:
|
|
51
|
+
Input record containing an identifier and nucleotide sequence.
|
|
52
|
+
raise_on_error:
|
|
53
|
+
Raise :class:`SequenceValidationError` after collecting errors.
|
|
54
|
+
The default returns an invalid report for batch-friendly behavior.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
raw = re.sub(r"\s+", "", record.sequence).upper()
|
|
58
|
+
issues: list[ValidationIssue] = []
|
|
59
|
+
if not raw:
|
|
60
|
+
issues.append(ValidationIssue("empty_sequence", "The sequence is empty.", "error"))
|
|
61
|
+
return self._finish(record.identifier, raw, issues, raise_on_error)
|
|
62
|
+
|
|
63
|
+
if "U" in raw:
|
|
64
|
+
severity = cast(Literal["warning", "error"], "warning" if self.config.convert_rna else "error")
|
|
65
|
+
issues.append(
|
|
66
|
+
ValidationIssue(
|
|
67
|
+
"rna_input",
|
|
68
|
+
"RNA base U was detected." + (" It was converted to T." if self.config.convert_rna else ""),
|
|
69
|
+
severity,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if self.config.convert_rna:
|
|
73
|
+
raw = raw.replace("U", "T")
|
|
74
|
+
|
|
75
|
+
invalid = [(index, base) for index, base in enumerate(raw) if base not in IUPAC_DNA]
|
|
76
|
+
for index, base in invalid[:25]:
|
|
77
|
+
issues.append(ValidationIssue("invalid_character", f"Unsupported nucleotide {base!r}.", "error", index))
|
|
78
|
+
if len(invalid) > 25:
|
|
79
|
+
issues.append(
|
|
80
|
+
ValidationIssue(
|
|
81
|
+
"invalid_character_limit", f"{len(invalid) - 25} additional invalid symbols were omitted.", "error"
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
framed = raw[self.config.reading_frame :]
|
|
86
|
+
if self.config.reading_frame and len(raw) <= self.config.reading_frame:
|
|
87
|
+
issues.append(
|
|
88
|
+
ValidationIssue(
|
|
89
|
+
"invalid_reading_frame", "The reading-frame offset removes the complete sequence.", "error"
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
remainder = len(framed) % 3
|
|
93
|
+
if remainder:
|
|
94
|
+
severity = cast(Literal["warning", "error"], "warning" if self.config.allow_partial else "error")
|
|
95
|
+
issues.append(
|
|
96
|
+
ValidationIssue(
|
|
97
|
+
"incomplete_codon",
|
|
98
|
+
f"Sequence length in frame leaves {remainder} trailing nucleotide(s).",
|
|
99
|
+
severity,
|
|
100
|
+
len(raw) - remainder,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
codons = [framed[index : index + 3] for index in range(0, len(framed) - 2, 3)]
|
|
105
|
+
ambiguous = [(index, codon) for index, codon in enumerate(codons) if set(codon) - set("ACGT")]
|
|
106
|
+
for index, codon in ambiguous:
|
|
107
|
+
severity = cast(
|
|
108
|
+
Literal["warning", "error"], "error" if self.config.ambiguous_policy == "reject" else "warning"
|
|
109
|
+
)
|
|
110
|
+
action = {"reject": "rejected", "skip": "skipped", "allow": "retained"}[self.config.ambiguous_policy]
|
|
111
|
+
issues.append(
|
|
112
|
+
ValidationIssue(
|
|
113
|
+
"ambiguous_codon",
|
|
114
|
+
f"Ambiguous codon {codon} will be {action}.",
|
|
115
|
+
severity,
|
|
116
|
+
self.config.reading_frame + index * 3,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if codons:
|
|
121
|
+
if (
|
|
122
|
+
self.config.require_start
|
|
123
|
+
and not self.config.allow_partial
|
|
124
|
+
and codons[0] not in self.genetic_code.start_codons
|
|
125
|
+
):
|
|
126
|
+
issues.append(
|
|
127
|
+
ValidationIssue(
|
|
128
|
+
"missing_start",
|
|
129
|
+
f"First codon {codons[0]} is not a start codon for genetic code {self.genetic_code.table_id}.",
|
|
130
|
+
"error",
|
|
131
|
+
self.config.reading_frame,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
if (
|
|
135
|
+
self.config.require_stop
|
|
136
|
+
and not self.config.allow_partial
|
|
137
|
+
and codons[-1] not in self.genetic_code.stop_codons
|
|
138
|
+
):
|
|
139
|
+
issues.append(
|
|
140
|
+
ValidationIssue(
|
|
141
|
+
"missing_stop",
|
|
142
|
+
f"Final codon {codons[-1]} is not a stop codon for genetic code {self.genetic_code.table_id}.",
|
|
143
|
+
"error",
|
|
144
|
+
self.config.reading_frame + (len(codons) - 1) * 3,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
for index, codon in enumerate(codons[:-1]):
|
|
148
|
+
if codon in self.genetic_code.stop_codons:
|
|
149
|
+
issues.append(
|
|
150
|
+
ValidationIssue(
|
|
151
|
+
"internal_stop",
|
|
152
|
+
f"Internal stop codon {codon} was found.",
|
|
153
|
+
"error",
|
|
154
|
+
self.config.reading_frame + index * 3,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return self._finish(record.identifier, raw, issues, raise_on_error)
|
|
159
|
+
|
|
160
|
+
def _finish(
|
|
161
|
+
self, identifier: str, sequence: str, issues: list[ValidationIssue], raise_on_error: bool
|
|
162
|
+
) -> ValidationReport:
|
|
163
|
+
"""Build a report and optionally convert error findings to an exception."""
|
|
164
|
+
|
|
165
|
+
report = ValidationReport(identifier, sequence, issues, self.config.genetic_code, self.config.reading_frame)
|
|
166
|
+
if raise_on_error and not report.is_valid:
|
|
167
|
+
raise SequenceValidationError("; ".join(issue.message for issue in report.errors))
|
|
168
|
+
return report
|
|
169
|
+
|