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/cli.py ADDED
@@ -0,0 +1,409 @@
1
+ """
2
+ CodonAdaptPy Command-Line Interface
3
+
4
+ This module exposes standalone subcommands for complete sequence analysis,
5
+ multi-host comparison, reference construction/cataloging, and constrained
6
+ optimization or deoptimization. It also exposes MAFFT/trimAL/IQ-TREE/FastTree
7
+ phylogenetics with ETE3-backed static tree and temporal-diagnostic figures.
8
+ CLI workflows call the same class-based API
9
+ used by Python callers and write reproducible, structured output directories.
10
+
11
+ Commands:
12
+ - analyze: Analyze one sequence or a batch input file.
13
+ - compare: Compare sequences against multiple host reference profiles.
14
+ - optimize: Generate ranked synonymous sequence candidates.
15
+ - phylogeny: Align sequences, infer an ML tree, and optionally time-scale it.
16
+ - reference: Build, inspect, list, or validate codon reference profiles.
17
+
18
+ Functions:
19
+ - build_parser: Construct the documented argument parser.
20
+ - main: Execute the requested subcommand and return a process status.
21
+
22
+ :Created: July 20, 2026
23
+ :Updated: July 20, 2026
24
+ :Author: Naveen Duhan
25
+ :Version: 1.0.0
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import shlex
33
+ import sys
34
+ from dataclasses import asdict
35
+ from pathlib import Path
36
+
37
+ from .__version__ import __author__, __version__
38
+ from .aggregation import CDSAggregator
39
+ from .analyzer import AnalysisConfig, analyzer_from_reference_files
40
+ from .exceptions import CodonAdaptPyError
41
+ from .models import AnalysisResult, ValidationConfig
42
+ from .optimizer import CodonOptimizer, OptimizationConfig
43
+ from .parsers import SequenceParser
44
+ from .phylo_visualization import ETE3TreePlotter
45
+ from .phylogenetics import PhylogeneticAnalyzer, TemporalAnalyzer
46
+ from .references import ReferenceManager
47
+ from .reporting import ReportGenerator
48
+ from .visualization import PlotManager
49
+
50
+
51
+ def build_parser() -> argparse.ArgumentParser:
52
+ """Construct the top-level parser and all standalone subcommands."""
53
+
54
+ parser = argparse.ArgumentParser(
55
+ prog="codonadaptpy",
56
+ description="Codon usage, host adaptation, evolutionary analysis, and synonymous sequence design.",
57
+ )
58
+ parser.add_argument("--version", action="version", version=f"CodonAdaptPy {__version__} ({__author__})")
59
+ subparsers = parser.add_subparsers(dest="command", required=True)
60
+
61
+ analyze = subparsers.add_parser("analyze", help="Analyze a sequence or supported batch file.")
62
+ _add_analysis_arguments(analyze)
63
+ analyze.set_defaults(handler=_run_analyze)
64
+
65
+ compare = subparsers.add_parser("compare", help="Compare sequences with multiple host references.")
66
+ _add_analysis_arguments(compare)
67
+ compare.set_defaults(handler=_run_analyze)
68
+
69
+ phylogeny = subparsers.add_parser("phylogeny", help="Infer and visualize a maximum-likelihood phylogeny.")
70
+ phylogeny.add_argument("input", help="Input FASTA or supported sequence file.")
71
+ phylogeny.add_argument("--format", choices=("fasta", "genbank", "csv", "tsv", "zip"))
72
+ phylogeny.add_argument("--metadata", help="CSV/TSV metadata joined by identifier.")
73
+ phylogeny.add_argument("--metadata-id-column", default="identifier")
74
+ phylogeny.add_argument("--group-key", default="group", help="Metadata field used to color tips.")
75
+ phylogeny.add_argument("--date-key", help="Metadata field containing decimal sampling years.")
76
+ phylogeny.add_argument("--temporal", action="store_true", help="Run deterministic temporal diagnostics.")
77
+ phylogeny.add_argument("--output", "-o", default="codonadaptpy_phylogeny")
78
+ phylogeny.add_argument("--tree-method", choices=("iqtree", "fasttree"), default="iqtree")
79
+ phylogeny.add_argument("--align-method", choices=("auto", "linsi", "einsi", "fftnsi", "fftns"), default="auto")
80
+ phylogeny.add_argument("--model", default="MFP", help="IQ-TREE model or ModelFinder setting.")
81
+ phylogeny.add_argument("--bootstrap", type=int, default=1000)
82
+ phylogeny.add_argument("--threads", type=int, default=1)
83
+ phylogeny.add_argument("--no-trim", action="store_true", help="Skip trimAL alignment trimming.")
84
+ phylogeny.set_defaults(handler=_run_phylogeny)
85
+
86
+ optimize = subparsers.add_parser("optimize", help="Optimize or deoptimize a coding sequence.")
87
+ optimize.add_argument("input", help="Input FASTA path or pasted coding sequence.")
88
+ optimize.add_argument("--reference", required=True, help="Reference profile JSON/CSV/TSV.")
89
+ optimize.add_argument("--output", "-o", default="codonadaptpy_optimization", help="Output directory.")
90
+ optimize.add_argument("--direction", choices=("optimize", "deoptimize", "match"), default="optimize")
91
+ optimize.add_argument("--candidates", type=int, default=5)
92
+ optimize.add_argument("--iterations", type=int, default=5000)
93
+ optimize.add_argument("--seed", type=int, default=1)
94
+ optimize.add_argument("--genetic-code", type=int, default=1)
95
+ optimize.add_argument("--target-gc", type=float)
96
+ optimize.add_argument("--gc-tolerance", type=float, default=0.05)
97
+ optimize.add_argument("--avoid-motif", action="append", default=[])
98
+ optimize.add_argument("--preserve-motif", action="append", default=[])
99
+ optimize.add_argument("--remove-site", action="append", default=[])
100
+ optimize.add_argument("--add-site", action="append", default=[])
101
+ optimize.add_argument("--max-homopolymer", type=int, default=6)
102
+ optimize.add_argument("--target-cpg", type=float)
103
+ optimize.add_argument("--target-upa", type=float)
104
+ optimize.set_defaults(handler=_run_optimize)
105
+
106
+ reference = subparsers.add_parser("reference", help="Build or inspect reference profiles.")
107
+ reference_sub = reference.add_subparsers(dest="reference_command", required=True)
108
+ reference_list = reference_sub.add_parser("list", help="List bundled profiles.")
109
+ reference_list.set_defaults(handler=_run_reference_list)
110
+ reference_build = reference_sub.add_parser("build", help="Build a profile from a CDS file.")
111
+ reference_build.add_argument("input")
112
+ reference_build.add_argument("--name", required=True)
113
+ reference_build.add_argument("--output", "-o", required=True)
114
+ reference_build.add_argument("--source", default="user CDS set")
115
+ reference_build.add_argument("--reference-version", default="1")
116
+ reference_build.add_argument("--genetic-code", type=int, default=1)
117
+ reference_build.set_defaults(handler=_run_reference_build)
118
+ reference_show = reference_sub.add_parser("show", help="Validate and print a profile.")
119
+ reference_show.add_argument("input")
120
+ reference_show.set_defaults(handler=_run_reference_show)
121
+ return parser
122
+
123
+
124
+ def _add_analysis_arguments(parser: argparse.ArgumentParser) -> None:
125
+ """Attach shared sequence-analysis options to a subcommand parser."""
126
+
127
+ parser.add_argument("input", help="FASTA, GenBank, CSV/TSV, ZIP, or pasted sequence.")
128
+ parser.add_argument("--format", choices=("fasta", "genbank", "csv", "tsv", "zip"))
129
+ parser.add_argument("--metadata", help="CSV/TSV metadata joined to sequence identifiers.")
130
+ parser.add_argument("--metadata-id-column", default="identifier")
131
+ parser.add_argument("--aggregate-by", help="Metadata field identifying isolates for genome-wide CDS pooling.")
132
+ parser.add_argument("--gene-key", default="gene", help="Metadata field containing gene names.")
133
+ parser.add_argument(
134
+ "--focus-gene",
135
+ action="append",
136
+ default=[],
137
+ help="Gene to report separately during aggregation; repeat as needed.",
138
+ )
139
+ parser.add_argument("--reference", help="Target reference profile JSON/CSV/TSV.")
140
+ parser.add_argument(
141
+ "--host", action="append", default=[], help="Host reference profile; repeat for multiple hosts."
142
+ )
143
+ parser.add_argument("--output", "-o", default="codonadaptpy_results", help="Output directory.")
144
+ parser.add_argument("--formats", default="json,csv,html", help="Comma-separated json,csv,tsv,xlsx,html,pdf.")
145
+ parser.add_argument("--plots", action="store_true", help="Write RSCU, ENC-GC3, and CAI plots as HTML.")
146
+ parser.add_argument("--genetic-code", type=int, default=1)
147
+ parser.add_argument("--frame", type=int, choices=(0, 1, 2), default=0)
148
+ parser.add_argument("--partial", action="store_true")
149
+ parser.add_argument("--no-require-start", action="store_true")
150
+ parser.add_argument("--no-require-stop", action="store_true")
151
+ parser.add_argument("--ambiguous", choices=("reject", "skip", "allow"), default="reject")
152
+ parser.add_argument("--window", type=int, default=30)
153
+ parser.add_argument("--step", type=int, default=1)
154
+ parser.add_argument("--simulate-cai", type=int, default=0)
155
+ parser.add_argument("--seed", type=int, default=1)
156
+ parser.add_argument(
157
+ "--lenient", action="store_true", help="Return invalid reports instead of stopping at validation errors."
158
+ )
159
+
160
+
161
+ def _run_phylogeny(args: argparse.Namespace) -> int:
162
+ """Run alignment, ML inference, ETE3 plotting, and optional temporal analysis."""
163
+
164
+ parser = SequenceParser()
165
+ records = parser.parse(args.input, fmt=args.format)
166
+ if args.metadata:
167
+ records = parser.attach_metadata(records, args.metadata, identifier_column=args.metadata_id_column)
168
+ output = Path(args.output)
169
+ run = PhylogeneticAnalyzer().run(
170
+ records,
171
+ output / "inference",
172
+ name="phylogeny",
173
+ align_method=args.align_method,
174
+ tree_method=args.tree_method,
175
+ trim=not args.no_trim,
176
+ trim_required=not args.no_trim,
177
+ model=args.model,
178
+ bootstrap=args.bootstrap,
179
+ threads=args.threads,
180
+ )
181
+ groups = {record.identifier: str(record.metadata.get(args.group_key, "Unknown")) for record in records}
182
+ plotter = ETE3TreePlotter()
183
+ figures = output / "figures"
184
+ tree_figure = plotter.tree(run.tree, groups=groups, title="Maximum-likelihood phylogeny")
185
+ for suffix in ("png", "pdf"):
186
+ plotter.save(tree_figure, figures / f"phylogeny.{suffix}", dpi=600)
187
+ report: dict[str, object] = {"phylogeny": run.to_dict()}
188
+ if args.temporal:
189
+ if not args.date_key:
190
+ raise ValueError("--date-key is required with --temporal.")
191
+ dates: dict[str, float] = {}
192
+ for record in records:
193
+ try:
194
+ dates[record.identifier] = float(record.metadata[args.date_key])
195
+ except (KeyError, TypeError, ValueError):
196
+ continue
197
+ temporal = TemporalAnalyzer()
198
+ signal = temporal.temporal_signal(run.tree, dates, optimize_root=True)
199
+ dated_tree = temporal.date_tree(run.tree, signal)
200
+ time_figure = plotter.tree(
201
+ dated_tree,
202
+ groups=groups,
203
+ title="Exploratory strict-clock time tree",
204
+ time_scaled=True,
205
+ )
206
+ signal_figure = plotter.temporal_signal(signal, groups=groups)
207
+ trajectory_figure = plotter.lineage_through_time(
208
+ {"All samples": temporal.lineage_through_time(dated_tree)}
209
+ )
210
+ for stem, figure in (
211
+ ("time_tree", time_figure),
212
+ ("temporal_signal", signal_figure),
213
+ ("lineage_through_time", trajectory_figure),
214
+ ):
215
+ for suffix in ("png", "pdf"):
216
+ plotter.save(figure, figures / f"{stem}.{suffix}", dpi=600)
217
+ dated_tree.write(
218
+ format=1,
219
+ features=["calendar_date", "observed_date", "fitted_date"],
220
+ outfile=str(output / "inference" / "phylogeny.dated.nwk"),
221
+ )
222
+ report["temporal_signal"] = signal.to_dict()
223
+ report["temporal_warning"] = (
224
+ "Root-to-tip strict-clock and LTT outputs are exploratory, deterministic estimates."
225
+ )
226
+ output.mkdir(parents=True, exist_ok=True)
227
+ (output / "phylogeny.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
228
+ print(f"Phylogenetic analysis complete; results: {output}")
229
+ return 0
230
+
231
+
232
+ def _run_analyze(args: argparse.Namespace) -> int:
233
+ """Execute analysis or multi-host comparison and write selected outputs."""
234
+
235
+ validation = ValidationConfig(
236
+ reading_frame=args.frame,
237
+ genetic_code=args.genetic_code,
238
+ require_start=not args.no_require_start,
239
+ require_stop=not args.no_require_stop,
240
+ ambiguous_policy=args.ambiguous,
241
+ allow_partial=args.partial,
242
+ )
243
+ config = AnalysisConfig(validation, args.window, args.step, args.simulate_cai, args.seed, not args.lenient)
244
+ analyzer = analyzer_from_reference_files(args.reference, args.host, config=config)
245
+ parser = SequenceParser()
246
+ records = parser.parse(args.input, fmt=args.format)
247
+ if args.metadata:
248
+ records = parser.attach_metadata(records, args.metadata, identifier_column=args.metadata_id_column)
249
+ output = Path(args.output)
250
+ output.mkdir(parents=True, exist_ok=True)
251
+ reporter = ReportGenerator()
252
+ formats = {item.strip().lower() for item in args.formats.split(",") if item.strip()}
253
+ supported = {"json", "csv", "tsv", "xlsx", "html", "pdf"}
254
+ unknown = formats - supported
255
+ if unknown:
256
+ raise ValueError(f"Unsupported output formats: {', '.join(sorted(unknown))}")
257
+
258
+ if args.aggregate_by:
259
+ isolate_results = CDSAggregator(analyzer).analyze(
260
+ records,
261
+ isolate_key=args.aggregate_by,
262
+ gene_key=args.gene_key,
263
+ focus_genes=tuple(args.focus_gene),
264
+ )
265
+ genome_results = [result.genome_result for result in isolate_results]
266
+ gene_results = [gene_result for result in isolate_results for gene_result in result.gene_results]
267
+ _write_result_set(reporter, genome_results, output / "genome_wide", formats)
268
+ _write_result_set(reporter, gene_results, output / "per_gene", formats)
269
+ for focus_gene in args.focus_gene:
270
+ focused = [
271
+ gene_result for result in isolate_results for gene_result in result.focus_gene_results[focus_gene]
272
+ ]
273
+ safe_name = "".join(
274
+ character if character.isalnum() or character in "-_" else "_" for character in focus_gene
275
+ )
276
+ _write_result_set(reporter, focused, output / f"focus_{safe_name}", formats)
277
+ (output / "aggregation.json").write_text(
278
+ json.dumps([result.to_dict() for result in isolate_results], indent=2, sort_keys=True) + "\n",
279
+ encoding="utf-8",
280
+ )
281
+ results = genome_results
282
+ analyzed_message = f"Aggregated {len(gene_results)} CDS(s) across {len(genome_results)} isolate(s)"
283
+ else:
284
+ if args.focus_gene:
285
+ raise ValueError("--focus-gene requires --aggregate-by.")
286
+ results = analyzer.analyze_many(records)
287
+ _write_result_set(reporter, results, output, formats)
288
+ analyzed_message = f"Analyzed {len(results)} sequence(s)"
289
+
290
+ if args.plots:
291
+ _write_plots(results, output / ("genome_wide" if args.aggregate_by else ""))
292
+ (output / "command.txt").write_text(" ".join(shlex.quote(item) for item in sys.argv) + "\n", encoding="utf-8")
293
+ (output / "config.json").write_text(json.dumps(asdict(config), indent=2, sort_keys=True) + "\n", encoding="utf-8")
294
+ print(f"{analyzed_message}; results: {output}")
295
+ return 0
296
+
297
+
298
+ def _write_result_set(
299
+ reporter: ReportGenerator,
300
+ results: list[AnalysisResult],
301
+ output: Path,
302
+ formats: set[str],
303
+ ) -> None:
304
+ """Write one consistently named result set in selected formats."""
305
+
306
+ output.mkdir(parents=True, exist_ok=True)
307
+ writers = {
308
+ "json": lambda: reporter.write_json(results, output / "analysis.json"),
309
+ "csv": lambda: reporter.write_table(results, output / "summary.csv"),
310
+ "tsv": lambda: reporter.write_table(results, output / "summary.tsv"),
311
+ "xlsx": lambda: reporter.write_excel(results, output / "analysis.xlsx"),
312
+ "html": lambda: reporter.write_html(results, output / "report.html"),
313
+ "pdf": lambda: reporter.write_pdf(results, output / "report.pdf"),
314
+ }
315
+ for output_format in sorted(formats):
316
+ writers[output_format]()
317
+
318
+
319
+ def _write_plots(results: list[AnalysisResult], output: Path) -> None:
320
+ """Write the standard interactive plot collection for a result set."""
321
+
322
+ output.mkdir(parents=True, exist_ok=True)
323
+ plotter = PlotManager()
324
+ plotter.save(plotter.rscu_heatmap(results), output / "rscu_heatmap.html")
325
+ plotter.save(plotter.enc_gc3(results), output / "enc_gc3.html")
326
+ plotter.save(plotter.neutrality(results), output / "neutrality.html")
327
+ plotter.save(plotter.parity_rule_2(results), output / "parity_rule_2.html")
328
+ for result in results:
329
+ if result.sliding_windows.get("cai"):
330
+ plotter.save(plotter.sliding_cai(result), output / f"{result.identifier}_sliding_cai.html")
331
+
332
+
333
+ def _run_optimize(args: argparse.Namespace) -> int:
334
+ """Execute constrained synonymous design for one input record."""
335
+
336
+ manager = ReferenceManager()
337
+ profile = manager.load(args.reference)
338
+ records = SequenceParser().parse(args.input)
339
+ if len(records) != 1:
340
+ raise ValueError("Optimization accepts exactly one sequence per invocation.")
341
+ config = OptimizationConfig(
342
+ direction=args.direction,
343
+ candidates=args.candidates,
344
+ iterations=args.iterations,
345
+ seed=args.seed,
346
+ target_gc=args.target_gc,
347
+ gc_tolerance=args.gc_tolerance,
348
+ avoid_motifs=tuple(args.avoid_motif),
349
+ preserve_motifs=tuple(args.preserve_motif),
350
+ remove_restriction_sites=tuple(args.remove_site),
351
+ add_restriction_sites=tuple(args.add_site),
352
+ max_homopolymer=args.max_homopolymer,
353
+ target_cpg=args.target_cpg,
354
+ target_upa=args.target_upa,
355
+ )
356
+ result = CodonOptimizer(profile.counts, genetic_code=args.genetic_code).optimize(records[0].sequence, config)
357
+ output = Path(args.output)
358
+ output.mkdir(parents=True, exist_ok=True)
359
+ (output / "optimization.json").write_text(
360
+ json.dumps(result.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8"
361
+ )
362
+ ReportGenerator().write_optimized_fasta(result, output / "optimized.fasta")
363
+ (output / "command.txt").write_text(" ".join(shlex.quote(item) for item in sys.argv) + "\n", encoding="utf-8")
364
+ print(f"Generated {len(result.candidates)} candidate(s); results: {output}")
365
+ return 0
366
+
367
+
368
+ def _run_reference_list(args: argparse.Namespace) -> int:
369
+ """Print the bundled reference catalog."""
370
+
371
+ print(json.dumps(ReferenceManager().list(), indent=2))
372
+ return 0
373
+
374
+
375
+ def _run_reference_build(args: argparse.Namespace) -> int:
376
+ """Build and persist a profile from validated CDS input."""
377
+
378
+ manager = ReferenceManager()
379
+ records = SequenceParser().parse(args.input)
380
+ profile = manager.from_sequences(
381
+ args.name, records, genetic_code=args.genetic_code, version=args.reference_version, source=args.source
382
+ )
383
+ manager.save(profile.name, args.output)
384
+ print(f"Built reference {profile.name!r} from {len(records)} sequence(s): {args.output}")
385
+ return 0
386
+
387
+
388
+ def _run_reference_show(args: argparse.Namespace) -> int:
389
+ """Validate and display a reference profile."""
390
+
391
+ manager = ReferenceManager()
392
+ profile = manager.load(args.input)
393
+ print(json.dumps(profile.to_dict(), indent=2, sort_keys=True))
394
+ return 0
395
+
396
+
397
+ def main(argv: list[str] | None = None) -> int:
398
+ """Run the CLI and convert anticipated errors to concise status messages."""
399
+
400
+ parser = build_parser()
401
+ args = parser.parse_args(argv)
402
+ try:
403
+ return int(args.handler(args))
404
+ except (CodonAdaptPyError, OSError, ValueError) as exc:
405
+ parser.exit(2, f"codonadaptpy: error: {exc}\n")
406
+
407
+
408
+ if __name__ == "__main__":
409
+ raise SystemExit(main())