dnakit 0.1.0.dev0__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.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
dnakit/cli/app.py ADDED
@@ -0,0 +1,814 @@
1
+ """Minimal DNAKit command-line application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import re
9
+ import sys
10
+ import tempfile
11
+ from collections.abc import Iterable, Iterator, Sequence
12
+ from pathlib import Path
13
+ from typing import Annotated, NoReturn, Protocol
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
18
+ from rich.table import Table
19
+
20
+ from dnakit._version import __version__
21
+ from dnakit.core import DNAAlphabet, DNARecord, DNASequence
22
+ from dnakit.datasets import DeduplicationConfig, SplitConfig, deduplicate, split
23
+ from dnakit.descriptors import (
24
+ all_descriptors,
25
+ base_composition,
26
+ exact_repeat_fraction,
27
+ gc_at_content,
28
+ linguistic_complexity,
29
+ )
30
+ from dnakit.exceptions import DNAKitError
31
+ from dnakit.fingerprints import fracminhash, kmer_fingerprint, minhash
32
+ from dnakit.io import ReadConfig, WriteConfig, read, read_set, write
33
+ from dnakit.patterns import scan_motif, scan_orfs
34
+ from dnakit.references import DownloadProgress, GenomeDownloadResult, download_genome
35
+ from dnakit.similarity import compare
36
+ from dnakit.standardize import (
37
+ AmbiguityPolicy,
38
+ NormalizationConfig,
39
+ UPolicy,
40
+ ValidationConfig,
41
+ )
42
+ from dnakit.standardize import normalize as normalize_sequence
43
+ from dnakit.standardize import validate as validate_sequence
44
+ from dnakit.visualization import SaveConfig, build_html_report, save_html_report
45
+
46
+ app = typer.Typer(
47
+ help="Deterministic tools for DNA sequence analysis.",
48
+ no_args_is_help=True,
49
+ pretty_exceptions_show_locals=False,
50
+ )
51
+ console = Console()
52
+ error_console = Console(stderr=True)
53
+
54
+
55
+ class _JSONResult(Protocol):
56
+ def to_dict(self) -> dict[str, object]: ...
57
+
58
+
59
+ def _version_callback(value: bool) -> None:
60
+ """Print the package version before command dispatch."""
61
+ if value:
62
+ typer.echo(__version__)
63
+ raise typer.Exit
64
+
65
+
66
+ @app.callback()
67
+ def main(
68
+ version: Annotated[
69
+ bool,
70
+ typer.Option(
71
+ "--version",
72
+ callback=_version_callback,
73
+ help="Show the DNAKit version and exit.",
74
+ is_eager=True,
75
+ ),
76
+ ] = False,
77
+ ) -> None:
78
+ """Run DNAKit commands."""
79
+
80
+
81
+ @app.command("info")
82
+ def info() -> None:
83
+ """Show the local DNAKit runtime information."""
84
+ table = Table(title="DNAKit runtime", show_header=False)
85
+ table.add_column("Field", style="bold")
86
+ table.add_column("Value")
87
+ table.add_row("DNAKit", __version__)
88
+ table.add_row("Python", platform.python_version())
89
+ table.add_row("Executable", sys.executable)
90
+ table.add_row("Platform", platform.platform())
91
+ console.print(table)
92
+
93
+
94
+ @app.command("backends")
95
+ def backends() -> None:
96
+ """List registered optional backends without probing external programs."""
97
+ from dnakit.backends import backend_registry
98
+
99
+ table = Table(title="DNAKit backends")
100
+ table.add_column("Backend")
101
+ table.add_column("Status")
102
+ registrations = backend_registry.registrations()
103
+ if not registrations:
104
+ table.add_row("-", "No optional backends are registered in this build.")
105
+ for registration in registrations:
106
+ table.add_row(registration.backend_id, f"registered ({registration.source}); not probed")
107
+ console.print(table)
108
+
109
+
110
+ def _download_genome_with_progress(
111
+ query: str,
112
+ output_dir: Path,
113
+ *,
114
+ overwrite: bool,
115
+ keep_package: bool,
116
+ api_key: str | None,
117
+ enabled: bool,
118
+ ) -> GenomeDownloadResult:
119
+ if not enabled:
120
+ return download_genome(
121
+ query,
122
+ output_dir,
123
+ overwrite=overwrite,
124
+ keep_package=keep_package,
125
+ api_key=api_key,
126
+ )
127
+ with Progress(
128
+ SpinnerColumn(),
129
+ TextColumn("{task.description}"),
130
+ TextColumn("{task.completed} bytes"),
131
+ TimeElapsedColumn(),
132
+ console=error_console,
133
+ transient=True,
134
+ ) as display:
135
+ task = display.add_task("Resolving assembly", total=None)
136
+
137
+ def on_progress(event: DownloadProgress) -> None:
138
+ if event.phase == "resolve":
139
+ description = f"Resolving {event.query}"
140
+ elif event.phase == "download":
141
+ description = f"Downloading {event.accession}"
142
+ else:
143
+ description = f"Extracting {event.current_file or 'FASTA'}"
144
+ display.update(
145
+ task,
146
+ description=description,
147
+ total=event.total_bytes,
148
+ completed=event.bytes_completed,
149
+ )
150
+
151
+ return download_genome(
152
+ query,
153
+ output_dir,
154
+ overwrite=overwrite,
155
+ keep_package=keep_package,
156
+ api_key=api_key,
157
+ progress=on_progress,
158
+ )
159
+
160
+
161
+ @app.command("download-genome")
162
+ def download_genome_command(
163
+ query: Annotated[
164
+ str,
165
+ typer.Argument(help="NCBI assembly accession, fixed alias, or species name."),
166
+ ],
167
+ output_dir: Annotated[
168
+ Path,
169
+ typer.Argument(help="Directory for the FASTA and NCBI provenance files."),
170
+ ],
171
+ overwrite: Annotated[bool, typer.Option("--overwrite")] = False,
172
+ keep_package: Annotated[
173
+ bool,
174
+ typer.Option("--keep-package", help="Keep the downloaded NCBI ZIP package."),
175
+ ] = False,
176
+ api_key: Annotated[
177
+ str | None,
178
+ typer.Option("--api-key", help="Optional NCBI Datasets API key."),
179
+ ] = None,
180
+ progress: Annotated[
181
+ bool,
182
+ typer.Option("--progress/--no-progress", help="Show download progress on stderr."),
183
+ ] = True,
184
+ ) -> None:
185
+ """Download one NCBI genomic FASTA and verify its checksum."""
186
+
187
+ try:
188
+ result = _download_genome_with_progress(
189
+ query,
190
+ output_dir,
191
+ overwrite=overwrite,
192
+ keep_package=keep_package,
193
+ api_key=api_key,
194
+ enabled=progress,
195
+ )
196
+ _json_output(result.to_dict())
197
+ except (DNAKitError, OSError, TypeError, ValueError) as exc:
198
+ _abort(exc)
199
+
200
+
201
+ def _json_output(value: object) -> None:
202
+ typer.echo(json.dumps(value, ensure_ascii=False, sort_keys=True))
203
+
204
+
205
+ def _abort(error: BaseException) -> NoReturn:
206
+ error_console.print(f"[red]{error}[/red]")
207
+ raise typer.Exit(code=2)
208
+
209
+
210
+ def _tracked(records: Iterable[DNARecord], *, enabled: bool) -> Iterator[DNARecord]:
211
+ if not enabled:
212
+ yield from records
213
+ return
214
+ with Progress(
215
+ SpinnerColumn(),
216
+ TextColumn("Processed {task.completed} records"),
217
+ TimeElapsedColumn(),
218
+ console=error_console,
219
+ transient=True,
220
+ ) as progress:
221
+ task = progress.add_task("records", total=None)
222
+ for record in records:
223
+ yield record
224
+ progress.advance(task)
225
+
226
+
227
+ def _normalization_config(
228
+ *,
229
+ alphabet: str,
230
+ keep_ambiguous: bool,
231
+ keep_u: bool,
232
+ keep_other: bool,
233
+ u_policy: str | None,
234
+ ambiguity_policy: str | None,
235
+ ) -> NormalizationConfig:
236
+ try:
237
+ resolved_alphabet = None if alphabet == "auto" else DNAAlphabet(alphabet)
238
+ return NormalizationConfig(
239
+ alphabet=resolved_alphabet,
240
+ keep_ambiguous=keep_ambiguous,
241
+ keep_u=keep_u,
242
+ keep_other=keep_other,
243
+ u_policy=None if u_policy is None else UPolicy(u_policy),
244
+ ambiguity_policy=(
245
+ None if ambiguity_policy is None else AmbiguityPolicy(ambiguity_policy)
246
+ ),
247
+ )
248
+ except (DNAKitError, TypeError, ValueError) as exc:
249
+ _abort(exc)
250
+
251
+
252
+ @app.command("normalize")
253
+ def normalize_command(
254
+ sequence: Annotated[str, typer.Argument(help="Raw DNA sequence text.")],
255
+ alphabet: Annotated[
256
+ str, typer.Option(help="Output alphabet: auto, strict, or iupac.")
257
+ ] = "auto",
258
+ keep_ambiguous: Annotated[
259
+ bool,
260
+ typer.Option(
261
+ "--keep-ambiguous/--delete-ambiguous",
262
+ help="Keep IUPAC ambiguity symbols such as N, R, and Y.",
263
+ ),
264
+ ] = True,
265
+ keep_u: Annotated[
266
+ bool,
267
+ typer.Option("--keep-u/--delete-u", help="Keep uracil U instead of deleting it."),
268
+ ] = False,
269
+ keep_other: Annotated[
270
+ bool,
271
+ typer.Option(
272
+ "--keep-other/--delete-other",
273
+ help="Keep characters outside the DNA/IUPAC alphabet.",
274
+ ),
275
+ ] = False,
276
+ u_policy: Annotated[
277
+ str | None,
278
+ typer.Option(
279
+ "--u-policy",
280
+ help="Advanced U override: delete, replace, error, warn, or keep.",
281
+ ),
282
+ ] = None,
283
+ ambiguity_policy: Annotated[
284
+ str | None,
285
+ typer.Option(
286
+ "--ambiguity-policy",
287
+ help="Advanced IUPAC override: delete, ignore, error, mask, or probability.",
288
+ ),
289
+ ] = None,
290
+ include_raw: Annotated[
291
+ bool,
292
+ typer.Option(
293
+ "--include-raw/--omit-raw",
294
+ help="Include sensitive raw sequence text in JSON output.",
295
+ ),
296
+ ] = False,
297
+ ) -> None:
298
+ """Normalize one raw sequence and print an auditable JSON result."""
299
+
300
+ try:
301
+ result = normalize_sequence(
302
+ sequence,
303
+ config=_normalization_config(
304
+ alphabet=alphabet,
305
+ keep_ambiguous=keep_ambiguous,
306
+ keep_u=keep_u,
307
+ keep_other=keep_other,
308
+ u_policy=u_policy,
309
+ ambiguity_policy=ambiguity_policy,
310
+ ),
311
+ )
312
+ _json_output(result.to_dict(include_raw_content=include_raw))
313
+ if not result.is_valid:
314
+ raise typer.Exit(code=2)
315
+ except typer.Exit:
316
+ raise
317
+ except (DNAKitError, TypeError, ValueError) as exc:
318
+ _abort(exc)
319
+
320
+
321
+ @app.command("validate")
322
+ def validate_command(
323
+ sequence: Annotated[str, typer.Argument(help="Raw DNA sequence text.")],
324
+ allow_empty: Annotated[
325
+ bool, typer.Option("--allow-empty/--reject-empty", help="Allow an empty sequence.")
326
+ ] = False,
327
+ sequence_length: Annotated[
328
+ int | None,
329
+ typer.Option(
330
+ "--sequence-length",
331
+ help="Require this exact normalized nucleotide symbol length.",
332
+ ),
333
+ ] = None,
334
+ ) -> None:
335
+ """Normalize and validate one raw sequence, returning JSON."""
336
+
337
+ try:
338
+ normalized = normalize_sequence(sequence)
339
+ if normalized.sequence is None:
340
+ _json_output(normalized.to_dict())
341
+ raise typer.Exit(code=2)
342
+ report = validate_sequence(
343
+ normalized.sequence,
344
+ config=ValidationConfig(
345
+ allow_empty=allow_empty,
346
+ min_length=0 if allow_empty else 1,
347
+ sequence_length=sequence_length,
348
+ ),
349
+ )
350
+ _json_output(report.to_dict())
351
+ if not report.is_valid:
352
+ raise typer.Exit(code=2)
353
+ except typer.Exit:
354
+ raise
355
+ except (DNAKitError, TypeError, ValueError) as exc:
356
+ _abort(exc)
357
+
358
+
359
+ def _normalized_sequence(raw: str) -> DNASequence:
360
+ result = normalize_sequence(raw)
361
+ if result.sequence is None:
362
+ raise ValueError("Sequence normalization did not produce a valid sequence.")
363
+ return result.sequence
364
+
365
+
366
+ @app.command("describe")
367
+ def describe_command(
368
+ sequence: Annotated[str, typer.Argument(help="Raw DNA sequence text.")],
369
+ all_fields: Annotated[
370
+ bool,
371
+ typer.Option(
372
+ "--all/--compact",
373
+ help="Return the fixed 240-field schema or the legacy compact report.",
374
+ ),
375
+ ] = True,
376
+ ) -> None:
377
+ """Calculate the complete or compact descriptor report for one sequence."""
378
+
379
+ try:
380
+ value = _normalized_sequence(sequence)
381
+ if all_fields:
382
+ _json_output(all_descriptors(value).to_dict())
383
+ return
384
+ _json_output(
385
+ {
386
+ "base_composition": base_composition(value, ambiguity_policy="ignore").to_dict(),
387
+ "gc_at": gc_at_content(value, ambiguity_policy="ignore").to_dict(),
388
+ "complexity": linguistic_complexity(value, ambiguity_policy="ignore").to_dict(),
389
+ "repeat": exact_repeat_fraction(value, ambiguity_policy="ignore").to_dict(),
390
+ }
391
+ )
392
+ except (DNAKitError, TypeError, ValueError) as exc:
393
+ _abort(exc)
394
+
395
+
396
+ @app.command("fingerprint")
397
+ def fingerprint_command(
398
+ sequence: Annotated[str, typer.Argument(help="Raw DNA sequence text.")],
399
+ kind: Annotated[
400
+ str, typer.Option(help="Fingerprint type: kmer, minhash, or fracminhash.")
401
+ ] = "kmer",
402
+ k: Annotated[int, typer.Option(help="k-mer word length.")] = 3,
403
+ size: Annotated[int, typer.Option(help="MinHash count or FracMinHash scaled value.")] = 1_000,
404
+ ) -> None:
405
+ """Build one native exact or sketch fingerprint."""
406
+
407
+ try:
408
+ value = _normalized_sequence(sequence)
409
+ if kind == "kmer":
410
+ result: _JSONResult = kmer_fingerprint(value, k=k)
411
+ elif kind == "minhash":
412
+ result = minhash(value, k=k, num_hashes=size)
413
+ elif kind == "fracminhash":
414
+ result = fracminhash(value, k=k, scaled=size)
415
+ else:
416
+ raise ValueError("kind must be kmer, minhash, or fracminhash")
417
+ _json_output(result.to_dict())
418
+ except (DNAKitError, TypeError, ValueError) as exc:
419
+ _abort(exc)
420
+
421
+
422
+ @app.command("search")
423
+ def search_command(
424
+ target: Annotated[str, typer.Argument(help="Raw target DNA sequence.")],
425
+ pattern: Annotated[str, typer.Argument(help="Exact/IUPAC/regex motif.")],
426
+ mode: Annotated[str, typer.Option(help="Motif mode: exact, iupac, or regex.")] = "iupac",
427
+ ) -> None:
428
+ """Search one bounded motif on both strands."""
429
+
430
+ try:
431
+ result = scan_motif(
432
+ _normalized_sequence(target),
433
+ pattern,
434
+ mode=mode, # type: ignore[arg-type]
435
+ )
436
+ _json_output(result.to_dict())
437
+ except (DNAKitError, TypeError, ValueError) as exc:
438
+ _abort(exc)
439
+
440
+
441
+ @app.command("orfs")
442
+ def orfs_command(
443
+ sequence: Annotated[str, typer.Argument(help="Raw DNA sequence text.")],
444
+ min_length: Annotated[int, typer.Option("--min-length", help="Minimum nucleotide length.")] = 0,
445
+ complete_only: Annotated[
446
+ bool, typer.Option("--complete-only/--allow-incomplete", help="Require a stop codon.")
447
+ ] = True,
448
+ ) -> None:
449
+ """Scan six reading frames for start-anchored ORFs."""
450
+
451
+ try:
452
+ result = scan_orfs(
453
+ _normalized_sequence(sequence),
454
+ min_length=min_length,
455
+ require_complete=complete_only,
456
+ )
457
+ _json_output(result.to_dict())
458
+ except (DNAKitError, TypeError, ValueError) as exc:
459
+ _abort(exc)
460
+
461
+
462
+ @app.command("compare")
463
+ def compare_command(
464
+ left: Annotated[str, typer.Argument(help="Left raw DNA sequence.")],
465
+ right: Annotated[str, typer.Argument(help="Right raw DNA sequence.")],
466
+ method: Annotated[
467
+ str, typer.Option(help="exact, hamming, levenshtein, or kmer_jaccard.")
468
+ ] = "exact",
469
+ k: Annotated[int, typer.Option(help="k value for k-mer methods.")] = 3,
470
+ ) -> None:
471
+ """Compare two normalized sequences with one deterministic method."""
472
+
473
+ try:
474
+ left_sequence = _normalized_sequence(left)
475
+ right_sequence = _normalized_sequence(right)
476
+ result = (
477
+ compare(
478
+ left_sequence,
479
+ right_sequence,
480
+ method=method,
481
+ k=k,
482
+ )
483
+ if method.startswith("kmer_")
484
+ else compare(
485
+ left_sequence,
486
+ right_sequence,
487
+ method=method,
488
+ )
489
+ )
490
+ _json_output(result.to_dict())
491
+ except (DNAKitError, TypeError, ValueError) as exc:
492
+ _abort(exc)
493
+
494
+
495
+ @app.command("report")
496
+ def report_command(
497
+ input_path: Annotated[Path, typer.Argument(help="Input sequence file.")],
498
+ output_path: Annotated[Path, typer.Argument(help="Self-contained HTML output file.")],
499
+ input_format: Annotated[
500
+ str | None, typer.Option("--input-format", help="Override the inferred input format.")
501
+ ] = None,
502
+ title: Annotated[str, typer.Option(help="Report title.")] = "DNAKit report",
503
+ max_records: Annotated[
504
+ int, typer.Option("--max-records", help="Strict input and report record limit.")
505
+ ] = 1_000,
506
+ overwrite: Annotated[bool, typer.Option("--overwrite")] = False,
507
+ ) -> None:
508
+ """Build a bounded, read-only HTML report without external resources."""
509
+
510
+ try:
511
+ records = read_set(
512
+ input_path,
513
+ format=input_format,
514
+ config=ReadConfig(max_records=max_records),
515
+ )
516
+ artifact = build_html_report(
517
+ records,
518
+ title=title,
519
+ max_records=max_records,
520
+ results={
521
+ "dataset_summary": {
522
+ "record_count": len(records),
523
+ "total_symbol_length": sum(record.sequence.symbol_length for record in records),
524
+ }
525
+ },
526
+ )
527
+ result = save_html_report(
528
+ artifact,
529
+ output_path,
530
+ config=SaveConfig(overwrite=overwrite),
531
+ )
532
+ _json_output(result.to_dict())
533
+ except (DNAKitError, OSError, TypeError, ValueError) as exc:
534
+ _abort(exc)
535
+
536
+
537
+ @app.command("workflow")
538
+ def workflow_command(
539
+ config_path: Annotated[Path, typer.Argument(help="Workflow JSON/YAML configuration.")],
540
+ dry_run: Annotated[
541
+ bool,
542
+ typer.Option("--dry-run", help="Validate the workflow without creating outputs."),
543
+ ] = False,
544
+ resume: Annotated[
545
+ bool,
546
+ typer.Option("--resume", help="Resume only checksum-verified output steps."),
547
+ ] = False,
548
+ progress: Annotated[
549
+ bool,
550
+ typer.Option("--progress/--no-progress", help="Show workflow progress on stderr."),
551
+ ] = True,
552
+ ) -> None:
553
+ """Run a strict, bounded local workflow without commands or network access."""
554
+
555
+ from dnakit.cli.workflow import main as workflow_main
556
+
557
+ arguments = ["run", str(config_path)]
558
+ if dry_run:
559
+ arguments.append("--dry-run")
560
+ if resume:
561
+ arguments.append("--resume")
562
+ if not progress:
563
+ arguments.append("--no-progress")
564
+ code = workflow_main(arguments)
565
+ if code:
566
+ raise typer.Exit(code=code)
567
+
568
+
569
+ @app.command("convert")
570
+ def convert_command(
571
+ input_path: Annotated[Path, typer.Argument(help="Input sequence file.")],
572
+ output_path: Annotated[Path, typer.Argument(help="Output sequence file.")],
573
+ input_format: Annotated[
574
+ str | None, typer.Option("--input-format", help="Override the inferred input format.")
575
+ ] = None,
576
+ output_format: Annotated[
577
+ str | None, typer.Option("--output-format", help="Override the inferred output format.")
578
+ ] = None,
579
+ overwrite: Annotated[
580
+ bool, typer.Option("--overwrite", help="Replace an existing output atomically.")
581
+ ] = False,
582
+ progress: Annotated[
583
+ bool, typer.Option("--progress/--no-progress", help="Show record progress on stderr.")
584
+ ] = True,
585
+ ) -> None:
586
+ """Stream records between supported FASTA/FASTQ/table formats."""
587
+
588
+ try:
589
+ with read(input_path, format=input_format) as records:
590
+ result = write(
591
+ _tracked(records, enabled=progress),
592
+ output_path,
593
+ format=output_format,
594
+ config=WriteConfig(overwrite=overwrite),
595
+ )
596
+ _json_output(result.to_dict())
597
+ except (DNAKitError, OSError, TypeError, ValueError) as exc:
598
+ _abort(exc)
599
+
600
+
601
+ @app.command("deduplicate")
602
+ def deduplicate_command(
603
+ input_path: Annotated[Path, typer.Argument(help="Input sequence file.")],
604
+ output_path: Annotated[Path, typer.Argument(help="Non-redundant output file.")],
605
+ equivalence: Annotated[
606
+ str,
607
+ typer.Option(help="Duplicate equivalence: exact or reverse_complement."),
608
+ ] = "exact",
609
+ input_format: Annotated[str | None, typer.Option("--input-format")] = None,
610
+ output_format: Annotated[str | None, typer.Option("--output-format")] = None,
611
+ representative: Annotated[
612
+ str, typer.Option(help="Representative policy: first, last, or best_quality.")
613
+ ] = "first",
614
+ conflict_field: Annotated[str | None, typer.Option(help="Metadata field to audit.")] = None,
615
+ conflict_policy: Annotated[
616
+ str,
617
+ typer.Option(help="Conflict policy: error, drop_group, keep_representative, or keep_all."),
618
+ ] = "error",
619
+ overwrite: Annotated[bool, typer.Option("--overwrite")] = False,
620
+ ) -> None:
621
+ """Materialize and deduplicate records with an auditable mapping."""
622
+
623
+ try:
624
+ records = read_set(input_path, format=input_format)
625
+ result = deduplicate(
626
+ records,
627
+ equivalence=equivalence, # type: ignore[arg-type]
628
+ config=DeduplicationConfig(
629
+ representative_policy=representative, # type: ignore[arg-type]
630
+ conflict_field=conflict_field,
631
+ conflict_policy=conflict_policy, # type: ignore[arg-type]
632
+ ),
633
+ )
634
+ write(
635
+ result.records,
636
+ output_path,
637
+ format=output_format,
638
+ config=WriteConfig(overwrite=overwrite),
639
+ )
640
+ _json_output(result.to_dict())
641
+ except (DNAKitError, OSError, TypeError, ValueError) as exc:
642
+ _abort(exc)
643
+
644
+
645
+ _SAFE_SPLIT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]*", flags=re.ASCII)
646
+
647
+
648
+ def _parse_ratios(value: str) -> dict[str, float]:
649
+ if not isinstance(value, str):
650
+ raise ValueError("ratios must look like train=0.8,valid=0.1,test=0.1")
651
+ ratios: dict[str, float] = {}
652
+ for item in value.split(","):
653
+ pair = item.split("=", maxsplit=1)
654
+ if len(pair) != 2:
655
+ raise ValueError("ratios must look like train=0.8,valid=0.1,test=0.1")
656
+ name = pair[0].strip()
657
+ if _SAFE_SPLIT_NAME.fullmatch(name) is None:
658
+ raise ValueError(
659
+ "split names must contain only ASCII letters, digits, '_' or '-', "
660
+ "and must start with a letter or digit"
661
+ )
662
+ if name in ratios:
663
+ raise ValueError(f"duplicate split name: {name!r}")
664
+ try:
665
+ ratios[name] = float(pair[1])
666
+ except ValueError as exc:
667
+ raise ValueError("ratios must look like train=0.8,valid=0.1,test=0.1") from exc
668
+ return ratios
669
+
670
+
671
+ def _safe_output_suffix(value: str) -> str:
672
+ normalized = value.strip().lower().lstrip(".")
673
+ allowed = {
674
+ "fa",
675
+ "fasta",
676
+ "fna",
677
+ "ffn",
678
+ "fq",
679
+ "fastq",
680
+ "csv",
681
+ "tsv",
682
+ "json",
683
+ "jsonl",
684
+ }
685
+ if normalized not in allowed:
686
+ raise ValueError("output-format must be FASTA, FASTQ, CSV, TSV, JSON, or JSONL")
687
+ return normalized
688
+
689
+
690
+ def _write_manifest(path: Path, payload: object, *, overwrite: bool) -> None:
691
+ descriptor, temporary_name = tempfile.mkstemp(
692
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
693
+ )
694
+ temporary_path = Path(temporary_name)
695
+ os.close(descriptor)
696
+ try:
697
+ temporary_path.write_text(
698
+ json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
699
+ encoding="utf-8",
700
+ )
701
+ if overwrite:
702
+ os.replace(temporary_path, path)
703
+ else:
704
+ os.link(temporary_path, path)
705
+ temporary_path.unlink()
706
+ except BaseException:
707
+ temporary_path.unlink(missing_ok=True)
708
+ raise
709
+
710
+
711
+ def _commit_staged_files(files: Sequence[tuple[Path, Path]], *, overwrite: bool) -> None:
712
+ """Install a staged split as one rollback-capable local transaction."""
713
+
714
+ targets = tuple(target for _, target in files)
715
+ if len(targets) != len(set(targets)):
716
+ raise ValueError("split output paths collide with each other or the manifest")
717
+ existing = [str(path) for path in targets if path.exists()]
718
+ if existing and not overwrite:
719
+ raise FileExistsError(f"Refusing to overwrite existing split outputs: {existing}")
720
+ invalid_targets = [str(path) for path in targets if path.exists() and not path.is_file()]
721
+ if invalid_targets:
722
+ raise IsADirectoryError(f"Split output targets must be files: {invalid_targets}")
723
+
724
+ installed: list[Path] = []
725
+ backups: list[tuple[Path, Path]] = []
726
+ try:
727
+ if overwrite:
728
+ for index, (_, target) in enumerate(files):
729
+ if target.exists():
730
+ backup = files[0][0].parent / f".backup-{index}"
731
+ os.replace(target, backup)
732
+ backups.append((target, backup))
733
+ for staged, target in files:
734
+ os.replace(staged, target)
735
+ installed.append(target)
736
+ else:
737
+ for staged, target in files:
738
+ os.link(staged, target)
739
+ installed.append(target)
740
+ except BaseException:
741
+ for target in installed:
742
+ target.unlink(missing_ok=True)
743
+ for target, backup in reversed(backups):
744
+ if backup.exists():
745
+ os.replace(backup, target)
746
+ raise
747
+ for _, backup in backups:
748
+ backup.unlink(missing_ok=True)
749
+
750
+
751
+ @app.command("split")
752
+ def split_command(
753
+ input_path: Annotated[Path, typer.Argument(help="Input sequence file.")],
754
+ output_dir: Annotated[Path, typer.Argument(help="Directory for named subset files.")],
755
+ ratios: Annotated[
756
+ str, typer.Option(help="Comma-separated split fractions, for example train=0.8,test=0.2.")
757
+ ] = "train=0.8,valid=0.1,test=0.1",
758
+ method: Annotated[
759
+ str, typer.Option(help="Split method: random, hash, stratified, group, or similarity.")
760
+ ] = "random",
761
+ metadata_key: Annotated[
762
+ str | None, typer.Option(help="Metadata key used by stratified or group splitting.")
763
+ ] = None,
764
+ seed: Annotated[int, typer.Option(help="Deterministic random seed.")] = 0,
765
+ input_format: Annotated[str | None, typer.Option("--input-format")] = None,
766
+ output_format: Annotated[str, typer.Option("--output-format")] = "fasta",
767
+ overwrite: Annotated[bool, typer.Option("--overwrite")] = False,
768
+ ) -> None:
769
+ """Create named dataset subsets and an assignments JSON manifest."""
770
+
771
+ try:
772
+ records = read_set(input_path, format=input_format)
773
+ result = split(
774
+ records,
775
+ config=SplitConfig(
776
+ method=method, # type: ignore[arg-type]
777
+ ratios=_parse_ratios(ratios),
778
+ seed=seed,
779
+ metadata_key=metadata_key,
780
+ ),
781
+ )
782
+ output_dir.mkdir(parents=True, exist_ok=True)
783
+ suffix = _safe_output_suffix(output_format)
784
+ targets = {subset.name: output_dir / f"{subset.name}.{suffix}" for subset in result.subsets}
785
+ manifest_path = output_dir / "assignments.json"
786
+ final_paths = (*targets.values(), manifest_path)
787
+ if len(final_paths) != len(set(final_paths)):
788
+ raise ValueError("a split output path collides with assignments.json")
789
+ existing = [str(path) for path in final_paths if path.exists()]
790
+ if existing and not overwrite:
791
+ raise FileExistsError(f"Refusing to overwrite existing split outputs: {existing}")
792
+ with tempfile.TemporaryDirectory(prefix=".dnakit-split-", dir=output_dir) as staging:
793
+ staging_dir = Path(staging)
794
+ staged_files: list[tuple[Path, Path]] = []
795
+ for subset in result.subsets:
796
+ staged_path = staging_dir / f"{subset.name}.{suffix}"
797
+ write(
798
+ subset.records,
799
+ staged_path,
800
+ format=suffix,
801
+ )
802
+ staged_files.append((staged_path, targets[subset.name]))
803
+ staged_manifest = staging_dir / "assignments.json"
804
+ _write_manifest(staged_manifest, result.to_dict(), overwrite=False)
805
+ staged_files.append((staged_manifest, manifest_path))
806
+ _commit_staged_files(staged_files, overwrite=overwrite)
807
+ _json_output(result.to_dict())
808
+ except (DNAKitError, OSError, TypeError, ValueError) as exc:
809
+ _abort(exc)
810
+
811
+
812
+ def run() -> None:
813
+ """Run the command-line application."""
814
+ app()