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/io/api.py ADDED
@@ -0,0 +1,759 @@
1
+ """Public file and stream I/O entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import gzip
7
+ import hashlib
8
+ import io
9
+ import os
10
+ import tempfile
11
+ from collections.abc import Callable, Iterable, Iterator
12
+ from contextlib import suppress
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, BinaryIO, Literal, TextIO, TypeAlias, cast, overload
16
+
17
+ from dnakit.core._json import to_json_compatible
18
+ from dnakit.core.collection import DNASet
19
+ from dnakit.core.enums import ExecutionMode, ImplementationLabel, OriginClass
20
+ from dnakit.core.facade import DNA
21
+ from dnakit.core.provenance import ArtifactRef, ImplementationInfo, Provenance
22
+ from dnakit.core.record import DNARecord
23
+ from dnakit.core.sequence import DNASequence
24
+ from dnakit.exceptions import ConfigurationError, InputFormatError
25
+
26
+ from ._formats import (
27
+ iter_delimited,
28
+ iter_fasta,
29
+ iter_fastq,
30
+ iter_json_array,
31
+ iter_json_lines,
32
+ write_delimited,
33
+ write_fasta,
34
+ write_fastq,
35
+ write_json_array,
36
+ write_json_lines,
37
+ )
38
+ from .config import ReadConfig, WriteConfig
39
+ from .genbank import iter_genbank, write_genbank
40
+ from .results import GeneratedID, WriteResult
41
+ from .source import RecordSource
42
+
43
+ PathSource: TypeAlias = str | os.PathLike[str]
44
+ ReadableSource: TypeAlias = PathSource | TextIO | BinaryIO
45
+ WritableTarget: TypeAlias = PathSource | TextIO | BinaryIO
46
+ WritableRecords: TypeAlias = DNA | DNASequence | DNARecord | Iterable[DNASequence | DNARecord]
47
+ ReadMode: TypeAlias = Literal["stream", "dna"]
48
+
49
+ _FORMAT_ALIASES = {
50
+ "fa": "fasta",
51
+ "fasta": "fasta",
52
+ "fna": "fasta",
53
+ "ffn": "fasta",
54
+ "faa": "fasta",
55
+ "fq": "fastq",
56
+ "fastq": "fastq",
57
+ "csv": "csv",
58
+ "tsv": "tsv",
59
+ "tab": "tsv",
60
+ "json": "json",
61
+ "jsonl": "jsonl",
62
+ "ndjson": "jsonl",
63
+ "gb": "genbank",
64
+ "gbk": "genbank",
65
+ "gbff": "genbank",
66
+ "genbank": "genbank",
67
+ }
68
+ _UNSUPPORTED_FORMATS = {
69
+ "parquet": "Parquet support requires the optional advanced table backend.",
70
+ "gff": "GFF support belongs to the advanced annotation codec.",
71
+ "gff3": "GFF3 support belongs to the advanced annotation codec.",
72
+ "bed": "BED support belongs to the advanced annotation codec.",
73
+ "agp": "AGP support belongs to the advanced assembly codec.",
74
+ "fai": "Indexed FASTA access belongs to the advanced indexing adapter.",
75
+ }
76
+ _MEDIA_TYPES = {
77
+ "fasta": "text/x-fasta",
78
+ "fastq": "text/x-fastq",
79
+ "csv": "text/csv",
80
+ "tsv": "text/tab-separated-values",
81
+ "json": "application/json",
82
+ "jsonl": "application/x-ndjson",
83
+ "genbank": "text/x-genbank",
84
+ }
85
+
86
+
87
+ class _BoundedTextReader:
88
+ """Proxy a decoded stream while bounding bytes returned to parsers."""
89
+
90
+ def __init__(self, raw: TextIO, *, encoding: str, limit: int, source: str | None) -> None:
91
+ self._raw = raw
92
+ self._encoding = encoding
93
+ self._encoder = codecs.getincrementalencoder(encoding)()
94
+ self._limit = limit
95
+ self._source = source
96
+ self._count = 0
97
+
98
+ def _checked(self, value: str) -> str:
99
+ self._count += len(self._encoder.encode(value))
100
+ if self._count > self._limit:
101
+ raise InputFormatError(
102
+ "Decoded input exceeds max_input_bytes.",
103
+ code="INPUT_BYTE_LIMIT_EXCEEDED",
104
+ context={"source": self._source, "max_input_bytes": self._limit},
105
+ )
106
+ return value
107
+
108
+ def _bounded_size(self, size: int | None) -> int:
109
+ remaining = self._limit - self._count
110
+ if size is None or size < 0:
111
+ return remaining + 1
112
+ return min(size, remaining + 1)
113
+
114
+ def read(self, size: int = -1) -> str:
115
+ return self._checked(self._raw.read(self._bounded_size(size)))
116
+
117
+ def readline(self, size: int = -1) -> str:
118
+ return self._checked(self._raw.readline(self._bounded_size(size)))
119
+
120
+ def __iter__(self) -> _BoundedTextReader:
121
+ return self
122
+
123
+ def __next__(self) -> str:
124
+ value = self.readline()
125
+ if not value:
126
+ raise StopIteration
127
+ return value
128
+
129
+ def __getattr__(self, name: str) -> object:
130
+ return getattr(self._raw, name)
131
+
132
+
133
+ class _BoundedBinaryWriter:
134
+ """Count physical output bytes before forwarding each binary write."""
135
+
136
+ def __init__(self, raw: BinaryIO, *, limit: int) -> None:
137
+ self._raw = raw
138
+ self._limit = limit
139
+ self._count = 0
140
+
141
+ def write(self, value: bytes | bytearray | memoryview) -> int:
142
+ size = len(value)
143
+ if self._count + size > self._limit:
144
+ raise InputFormatError(
145
+ "Serialized output exceeds max_output_bytes.",
146
+ code="OUTPUT_BYTE_LIMIT_EXCEEDED",
147
+ context={"max_output_bytes": self._limit},
148
+ )
149
+ written = self._raw.write(value)
150
+ self._count += written
151
+ return written
152
+
153
+ def flush(self) -> None:
154
+ self._raw.flush()
155
+
156
+ def __getattr__(self, name: str) -> object:
157
+ return getattr(self._raw, name)
158
+
159
+
160
+ class _BoundedTextWriter:
161
+ """Count encoded output bytes before forwarding writes to a text stream."""
162
+
163
+ def __init__(self, raw: TextIO, *, encoding: str, limit: int) -> None:
164
+ self._raw = raw
165
+ self._encoding = encoding
166
+ self._limit = limit
167
+ self._count = 0
168
+
169
+ def write(self, value: str) -> int:
170
+ size = len(value.encode(self._encoding))
171
+ if self._count + size > self._limit:
172
+ raise InputFormatError(
173
+ "Serialized output exceeds max_output_bytes.",
174
+ code="OUTPUT_BYTE_LIMIT_EXCEEDED",
175
+ context={"max_output_bytes": self._limit},
176
+ )
177
+ written = self._raw.write(value)
178
+ self._count += size
179
+ return written
180
+
181
+ def flush(self) -> None:
182
+ self._raw.flush()
183
+
184
+ def __getattr__(self, name: str) -> object:
185
+ return getattr(self._raw, name)
186
+
187
+
188
+ def _path(value: object) -> Path | None:
189
+ if isinstance(value, (str, os.PathLike)):
190
+ return Path(value)
191
+ return None
192
+
193
+
194
+ def _stream_name(stream: object) -> str | None:
195
+ value = getattr(stream, "name", None)
196
+ if not isinstance(value, (str, os.PathLike)):
197
+ return None
198
+ name = os.fspath(value)
199
+ return name if isinstance(name, str) and name else None
200
+
201
+
202
+ def _normalize_format(value: str) -> str:
203
+ if not isinstance(value, str):
204
+ raise TypeError("format must be a string or None.")
205
+ normalized = value.strip().lower().lstrip(".")
206
+ if normalized.endswith(".gz"):
207
+ normalized = normalized[:-3]
208
+ if normalized in _FORMAT_ALIASES:
209
+ return _FORMAT_ALIASES[normalized]
210
+ if normalized in _UNSUPPORTED_FORMATS:
211
+ raise InputFormatError(
212
+ f"Format {value!r} is not supported by the MVP.",
213
+ code="UNSUPPORTED_FORMAT",
214
+ context={"format": normalized},
215
+ hint=_UNSUPPORTED_FORMATS[normalized],
216
+ )
217
+ raise InputFormatError(
218
+ f"Unknown DNAKit I/O format {value!r}.",
219
+ code="UNSUPPORTED_FORMAT",
220
+ context={"format": normalized},
221
+ hint="Choose FASTA, FASTQ, GenBank, CSV, TSV, JSON, or JSONL.",
222
+ )
223
+
224
+
225
+ def _infer_format(name: str | None, explicit: str | None) -> str:
226
+ if explicit is not None:
227
+ return _normalize_format(explicit)
228
+ if name is None:
229
+ raise InputFormatError(
230
+ "format is required for a stream without a usable filename.",
231
+ code="FORMAT_REQUIRED",
232
+ hint=("Pass format='fasta', 'fastq', 'genbank', 'csv', 'tsv', 'json', or 'jsonl'."),
233
+ )
234
+ filename = Path(name).name.lower()
235
+ if filename.endswith(".gz"):
236
+ filename = filename[:-3]
237
+ suffix = Path(filename).suffix.lstrip(".")
238
+ if not suffix:
239
+ raise InputFormatError(
240
+ "Could not infer a format from the filename.",
241
+ code="FORMAT_REQUIRED",
242
+ context={"source": name},
243
+ )
244
+ return _normalize_format(suffix)
245
+
246
+
247
+ def _use_gzip(name: str | None, compression: str) -> bool:
248
+ if compression == "gzip":
249
+ return True
250
+ if compression == "none":
251
+ return False
252
+ return bool(name and name.lower().endswith(".gz"))
253
+
254
+
255
+ def _open_read_text(
256
+ source: ReadableSource, config: ReadConfig
257
+ ) -> tuple[TextIO, Callable[[], None], str | None]:
258
+ path = _path(source)
259
+ name = str(path) if path is not None else _stream_name(source)
260
+ compressed = _use_gzip(name, config.compression)
261
+ if path is not None:
262
+ try:
263
+ if compressed:
264
+ handle: TextIO = gzip.open( # noqa: SIM115 - lazy owner is RecordSource.
265
+ path, "rt", encoding=config.encoding, newline=""
266
+ )
267
+ else:
268
+ handle = path.open("r", encoding=config.encoding, newline="")
269
+ except OSError as exc:
270
+ raise InputFormatError(
271
+ "Could not open input path.",
272
+ code="INPUT_OPEN_FAILED",
273
+ context={"source": str(path), "reason": str(exc)},
274
+ ) from exc
275
+ bounded = _BoundedTextReader(
276
+ handle, encoding=config.encoding, limit=config.max_input_bytes, source=name
277
+ )
278
+ return cast(TextIO, bounded), handle.close, name
279
+
280
+ if not hasattr(source, "read"):
281
+ raise TypeError("source must be a path or a readable text/binary stream.")
282
+ close_source = config.close_source is True
283
+ probe = source.read(0)
284
+ if isinstance(probe, str):
285
+ text_handle = cast(TextIO, source)
286
+ if compressed:
287
+ if close_source:
288
+ text_handle.close()
289
+ raise ConfigurationError(
290
+ "A text stream cannot be decompressed by DNAKit.",
291
+ code="TEXT_STREAM_COMPRESSION_CONFLICT",
292
+ hint="Pass an already decompressed text stream with compression='none'.",
293
+ )
294
+ bounded = _BoundedTextReader(
295
+ text_handle, encoding=config.encoding, limit=config.max_input_bytes, source=name
296
+ )
297
+ return (
298
+ cast(TextIO, bounded),
299
+ text_handle.close if close_source else (lambda: None),
300
+ name,
301
+ )
302
+ if not isinstance(probe, bytes):
303
+ raise TypeError("source.read(0) must return str or bytes.")
304
+
305
+ binary_handle = cast(BinaryIO, source)
306
+ if compressed:
307
+ gzip_handle = gzip.GzipFile(fileobj=binary_handle, mode="rb")
308
+ gzip_wrapper = io.TextIOWrapper(gzip_handle, encoding=config.encoding, newline="")
309
+
310
+ def close_binary_gzip() -> None:
311
+ try:
312
+ gzip_wrapper.close()
313
+ finally:
314
+ if close_source:
315
+ binary_handle.close()
316
+
317
+ bounded = _BoundedTextReader(
318
+ gzip_wrapper, encoding=config.encoding, limit=config.max_input_bytes, source=name
319
+ )
320
+ return cast(TextIO, bounded), close_binary_gzip, name
321
+
322
+ binary_wrapper = io.TextIOWrapper(binary_handle, encoding=config.encoding, newline="")
323
+
324
+ def release_binary() -> None:
325
+ if binary_wrapper.closed:
326
+ return
327
+ if close_source:
328
+ binary_wrapper.close()
329
+ else:
330
+ binary_wrapper.detach()
331
+
332
+ bounded = _BoundedTextReader(
333
+ binary_wrapper, encoding=config.encoding, limit=config.max_input_bytes, source=name
334
+ )
335
+ return cast(TextIO, bounded), release_binary, name
336
+
337
+
338
+ def _reader(handle: TextIO, format: str, config: ReadConfig) -> Iterator[DNARecord]:
339
+ if format == "fasta":
340
+ return iter_fasta(handle, config)
341
+ if format == "fastq":
342
+ return iter_fastq(handle, config)
343
+ if format in {"csv", "tsv"}:
344
+ delimiter = config.delimiter or ("," if format == "csv" else "\t")
345
+ return iter_delimited(handle, config, delimiter=delimiter)
346
+ if format == "json":
347
+ return iter_json_array(handle, config)
348
+ if format == "jsonl":
349
+ return iter_json_lines(handle, config)
350
+ if format == "genbank":
351
+ return iter_genbank(handle, config)
352
+ raise AssertionError(f"Unreachable normalized format: {format}")
353
+
354
+
355
+ def _guard_reader(
356
+ iterator: Iterator[DNARecord], *, source_name: str | None, max_records: int
357
+ ) -> Iterator[DNARecord]:
358
+ try:
359
+ for index, record in enumerate(iterator, start=1):
360
+ if index > max_records:
361
+ raise InputFormatError(
362
+ "Input exceeds the configured record limit.",
363
+ code="INPUT_RECORD_LIMIT_EXCEEDED",
364
+ context={"source": source_name, "max_records": max_records},
365
+ )
366
+ yield record
367
+ except (InputFormatError, ConfigurationError):
368
+ raise
369
+ except UnicodeError as exc:
370
+ raise InputFormatError(
371
+ "Input could not be decoded with the configured encoding.",
372
+ code="INPUT_DECODE_FAILED",
373
+ context={"source": source_name, "reason": str(exc)},
374
+ ) from exc
375
+ except OSError as exc:
376
+ raise InputFormatError(
377
+ "Input stream could not be read or decompressed.",
378
+ code="INPUT_READ_FAILED",
379
+ context={"source": source_name, "reason": str(exc)},
380
+ ) from exc
381
+
382
+
383
+ @overload
384
+ def read(
385
+ source: ReadableSource,
386
+ *,
387
+ format: str | None = None,
388
+ config: ReadConfig | None = None,
389
+ mode: Literal["stream"] = "stream",
390
+ ) -> RecordSource: ...
391
+
392
+
393
+ @overload
394
+ def read(
395
+ source: ReadableSource,
396
+ *,
397
+ format: str | None = None,
398
+ config: ReadConfig | None = None,
399
+ mode: Literal["dna"],
400
+ ) -> DNA: ...
401
+
402
+
403
+ def read(
404
+ source: ReadableSource,
405
+ *,
406
+ format: str | None = None,
407
+ config: ReadConfig | None = None,
408
+ mode: ReadMode = "stream",
409
+ ) -> RecordSource | DNA:
410
+ """Read DNA with one name, choosing a lazy stream or unified DNA object."""
411
+
412
+ resolved = ReadConfig() if config is None else config
413
+ if not isinstance(resolved, ReadConfig):
414
+ raise TypeError("config must be ReadConfig or None.")
415
+ if mode not in {"stream", "dna"}:
416
+ raise ConfigurationError(
417
+ "mode must be 'stream' or 'dna'.",
418
+ code="INVALID_READ_MODE",
419
+ context={"mode": mode},
420
+ )
421
+ path = _path(source)
422
+ name = str(path) if path is not None else _stream_name(source)
423
+ resolved_format = _infer_format(name, format)
424
+ handle, close_callback, source_name = _open_read_text(source, resolved)
425
+ try:
426
+ iterator = _guard_reader(
427
+ _reader(handle, resolved_format, resolved),
428
+ source_name=source_name,
429
+ max_records=resolved.max_records,
430
+ )
431
+ except BaseException:
432
+ close_callback()
433
+ raise
434
+ records = RecordSource(
435
+ iterator,
436
+ close_callback=close_callback,
437
+ source_name=source_name,
438
+ format=resolved_format,
439
+ )
440
+ if mode == "dna":
441
+ return DNA(records.collect())
442
+ return records
443
+
444
+
445
+ def read_one(
446
+ source: ReadableSource,
447
+ *,
448
+ format: str | None = None,
449
+ config: ReadConfig | None = None,
450
+ ) -> DNARecord:
451
+ """Read exactly one record, rejecting empty and multi-record inputs."""
452
+
453
+ with read(source, format=format, config=config, mode="stream") as records:
454
+ try:
455
+ first = next(records)
456
+ except StopIteration as exc:
457
+ raise InputFormatError(
458
+ "Expected exactly one record, but the input was empty.",
459
+ code="EXPECTED_ONE_RECORD",
460
+ context={"record_count": 0},
461
+ ) from exc
462
+ try:
463
+ next(records)
464
+ except StopIteration:
465
+ return first
466
+ raise InputFormatError(
467
+ "Expected exactly one record, but the input contained multiple records.",
468
+ code="EXPECTED_ONE_RECORD",
469
+ context={"record_count": "at_least_2"},
470
+ )
471
+
472
+
473
+ def read_set(
474
+ source: ReadableSource,
475
+ *,
476
+ format: str | None = None,
477
+ config: ReadConfig | None = None,
478
+ ) -> DNASet:
479
+ """Explicitly materialize every input record into a repeatable DNASet."""
480
+
481
+ return read(source, format=format, config=config, mode="stream").collect()
482
+
483
+
484
+ def _records_with_ids(
485
+ records: WritableRecords,
486
+ config: WriteConfig,
487
+ generated_ids: list[GeneratedID],
488
+ ) -> Iterator[DNARecord]:
489
+ if isinstance(records, (DNASequence, DNARecord)):
490
+ values: Iterable[DNASequence | DNARecord] = (records,)
491
+ elif isinstance(records, Iterable):
492
+ values = records
493
+ else:
494
+ raise TypeError("records must be DNASequence, DNARecord, or an iterable of them.")
495
+ for index, value in enumerate(values):
496
+ if isinstance(value, DNARecord):
497
+ yield value
498
+ elif isinstance(value, DNASequence):
499
+ if config.anonymous_id_policy == "error":
500
+ raise InputFormatError(
501
+ "Anonymous DNASequence output is disabled by configuration.",
502
+ code="ANONYMOUS_SEQUENCE_ID_REQUIRED",
503
+ context={"input_index": index},
504
+ )
505
+ generated_id = f"{config.anonymous_id_prefix}{index + 1}"
506
+ generated_ids.append(GeneratedID(index, generated_id))
507
+ yield DNARecord(value, generated_id)
508
+ else:
509
+ raise TypeError(
510
+ f"records[{index}] must be DNASequence or DNARecord, not {type(value).__name__}."
511
+ )
512
+
513
+
514
+ def _writer(
515
+ handle: TextIO,
516
+ records: Iterable[DNARecord],
517
+ format: str,
518
+ config: WriteConfig,
519
+ ) -> int:
520
+ if format == "fasta":
521
+ return write_fasta(handle, records, config)
522
+ if format == "fastq":
523
+ return write_fastq(handle, records, config)
524
+ if format in {"csv", "tsv"}:
525
+ delimiter = config.delimiter or ("," if format == "csv" else "\t")
526
+ return write_delimited(handle, records, config, delimiter=delimiter)
527
+ if format == "json":
528
+ return write_json_array(handle, records, config)
529
+ if format == "jsonl":
530
+ return write_json_lines(handle, records, config)
531
+ if format == "genbank":
532
+ return write_genbank(handle, records, config)
533
+ raise AssertionError(f"Unreachable normalized format: {format}")
534
+
535
+
536
+ def _write_to_stream(
537
+ target: TextIO | BinaryIO,
538
+ records: Iterable[DNARecord],
539
+ format: str,
540
+ config: WriteConfig,
541
+ *,
542
+ compressed: bool,
543
+ ) -> int:
544
+ close_target = config.close_target is True
545
+ try:
546
+ cast(Any, target).write("")
547
+ is_text_target = True
548
+ except TypeError:
549
+ is_text_target = False
550
+ if is_text_target:
551
+ text_target = cast(TextIO, target)
552
+ if compressed:
553
+ if close_target:
554
+ text_target.close()
555
+ raise ConfigurationError(
556
+ "A text target cannot be gzip-compressed by DNAKit.",
557
+ code="TEXT_STREAM_COMPRESSION_CONFLICT",
558
+ hint="Pass a binary stream or an output path ending in .gz.",
559
+ )
560
+ try:
561
+ destination = (
562
+ cast(
563
+ TextIO,
564
+ _BoundedTextWriter(
565
+ text_target,
566
+ encoding=config.encoding,
567
+ limit=config.max_output_bytes,
568
+ ),
569
+ )
570
+ if config.max_output_bytes is not None
571
+ else text_target
572
+ )
573
+ count = _writer(destination, records, format, config)
574
+ text_target.flush()
575
+ return count
576
+ finally:
577
+ if close_target:
578
+ text_target.close()
579
+
580
+ binary_target = cast(BinaryIO, target)
581
+ bounded_target = (
582
+ cast(
583
+ BinaryIO,
584
+ _BoundedBinaryWriter(binary_target, limit=config.max_output_bytes),
585
+ )
586
+ if config.max_output_bytes is not None
587
+ else binary_target
588
+ )
589
+ raw: BinaryIO | None = None
590
+ try:
591
+ raw = (
592
+ cast(
593
+ BinaryIO,
594
+ gzip.GzipFile(
595
+ filename="",
596
+ fileobj=bounded_target,
597
+ mode="wb",
598
+ compresslevel=config.compression_level,
599
+ mtime=0,
600
+ ),
601
+ )
602
+ if compressed
603
+ else bounded_target
604
+ )
605
+ wrapper = io.TextIOWrapper(raw, encoding=config.encoding, newline="")
606
+ except BaseException:
607
+ if compressed and raw is not None:
608
+ with suppress(BaseException):
609
+ raw.close()
610
+ if close_target:
611
+ binary_target.close()
612
+ raise
613
+ try:
614
+ count = _writer(wrapper, records, format, config)
615
+ wrapper.flush()
616
+ return count
617
+ finally:
618
+ if compressed:
619
+ try:
620
+ wrapper.close()
621
+ finally:
622
+ if close_target:
623
+ binary_target.close()
624
+ elif close_target:
625
+ wrapper.close()
626
+ elif not wrapper.closed:
627
+ wrapper.detach()
628
+
629
+
630
+ def _artifact(path: Path, format: str) -> ArtifactRef:
631
+ digest = hashlib.sha256()
632
+ with path.open("rb") as handle:
633
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
634
+ digest.update(chunk)
635
+ stat = path.stat()
636
+ return ArtifactRef(
637
+ relative_path=os.path.relpath(path.resolve(), Path.cwd()),
638
+ media_type=_MEDIA_TYPES[format],
639
+ schema_version="dnakit-io-v1",
640
+ sha256=digest.hexdigest(),
641
+ byte_size=stat.st_size,
642
+ created_at=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
643
+ )
644
+
645
+
646
+ def _write_path(
647
+ path: Path,
648
+ records: Iterable[DNARecord],
649
+ format: str,
650
+ config: WriteConfig,
651
+ ) -> tuple[int, ArtifactRef]:
652
+ if path.exists() and not config.overwrite:
653
+ raise FileExistsError(f"Refusing to overwrite existing output: {path}")
654
+ parent = path.parent
655
+ if not parent.exists():
656
+ if config.create_parents:
657
+ parent.mkdir(parents=True, exist_ok=True)
658
+ else:
659
+ raise FileNotFoundError(f"Output parent directory does not exist: {parent}")
660
+ descriptor, temporary_name = tempfile.mkstemp(
661
+ prefix=f".{path.name}.", suffix=".tmp", dir=parent
662
+ )
663
+ temporary_path = Path(temporary_name)
664
+ os.close(descriptor)
665
+ compressed = _use_gzip(str(path), config.compression)
666
+ try:
667
+ with temporary_path.open("wb") as binary_target:
668
+ stream_config = WriteConfig(
669
+ encoding=config.encoding,
670
+ overwrite=config.overwrite,
671
+ create_parents=config.create_parents,
672
+ compression=config.compression,
673
+ compression_level=config.compression_level,
674
+ close_target=False,
675
+ line_width=config.line_width,
676
+ phred_offset=config.phred_offset,
677
+ delimiter=config.delimiter,
678
+ anonymous_id_policy=config.anonymous_id_policy,
679
+ anonymous_id_prefix=config.anonymous_id_prefix,
680
+ feature_policy=config.feature_policy,
681
+ json_indent=config.json_indent,
682
+ max_output_bytes=config.max_output_bytes,
683
+ )
684
+ count = _write_to_stream(
685
+ binary_target, records, format, stream_config, compressed=compressed
686
+ )
687
+ binary_target.flush()
688
+ os.fsync(binary_target.fileno())
689
+ if config.overwrite:
690
+ os.replace(temporary_path, path)
691
+ else:
692
+ os.link(temporary_path, path)
693
+ temporary_path.unlink()
694
+ except BaseException:
695
+ temporary_path.unlink(missing_ok=True)
696
+ raise
697
+ return count, _artifact(path, format)
698
+
699
+
700
+ def write(
701
+ records: WritableRecords,
702
+ target: WritableTarget,
703
+ *,
704
+ format: str | None = None,
705
+ config: WriteConfig | None = None,
706
+ ) -> WriteResult:
707
+ """Serialize records with stable anonymous IDs and safe path replacement."""
708
+
709
+ resolved = WriteConfig() if config is None else config
710
+ if not isinstance(resolved, WriteConfig):
711
+ raise TypeError("config must be WriteConfig or None.")
712
+ path = _path(target)
713
+ name = str(path) if path is not None else _stream_name(target)
714
+ resolved_format = _infer_format(name, format)
715
+ generated_ids: list[GeneratedID] = []
716
+ prepared = _records_with_ids(records, resolved, generated_ids)
717
+ if path is not None:
718
+ count, artifact = _write_path(path, prepared, resolved_format, resolved)
719
+ byte_count: int | None = artifact.byte_size
720
+ else:
721
+ if not hasattr(target, "write"):
722
+ raise TypeError("target must be a path or writable text/binary stream.")
723
+ count = _write_to_stream(
724
+ cast(TextIO | BinaryIO, target),
725
+ prepared,
726
+ resolved_format,
727
+ resolved,
728
+ compressed=_use_gzip(name, resolved.compression),
729
+ )
730
+ artifact = None
731
+ byte_count = None
732
+ return WriteResult(
733
+ resolved_format,
734
+ count,
735
+ byte_count=byte_count,
736
+ generated_ids=generated_ids,
737
+ target_artifact=artifact,
738
+ parameters=cast(dict[str, object], to_json_compatible(resolved)),
739
+ provenance=Provenance(
740
+ implementation=ImplementationInfo(
741
+ label=ImplementationLabel.REIMPLEMENTATION,
742
+ execution_mode=ExecutionMode.INTERNAL,
743
+ origin_class=OriginClass.STANDARD,
744
+ )
745
+ ),
746
+ )
747
+
748
+
749
+ __all__ = [
750
+ "PathSource",
751
+ "ReadMode",
752
+ "ReadableSource",
753
+ "WritableRecords",
754
+ "WritableTarget",
755
+ "read",
756
+ "read_one",
757
+ "read_set",
758
+ "write",
759
+ ]