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.
@@ -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
+