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
@@ -0,0 +1,246 @@
1
+ """Deterministic bottom-k MinHash and FracMinHash sketches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Iterable
7
+
8
+ from dnakit.core import Gap, Topology
9
+ from dnakit.core._json import FrozenDict
10
+ from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
11
+
12
+ from ._shared import SequenceInput, sequence_and_id, validate_bool, validate_positive_int
13
+ from .results import SketchResult
14
+
15
+ DEFAULT_MAX_SKETCH_HASHES = 1_000_000
16
+ DEFAULT_MAX_UNIQUE_HASHES = 1_000_000
17
+ _UINT64_SPACE = 1 << 64
18
+ _COMPLEMENT = str.maketrans("ACGT", "TGCA")
19
+
20
+
21
+ def _hash64(word: str, seed: int) -> int:
22
+ payload = seed.to_bytes(8, "little", signed=False) + word.encode("ascii")
23
+ return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big")
24
+
25
+
26
+ def _canonical(word: str) -> str:
27
+ reverse = word.translate(_COMPLEMENT)[::-1]
28
+ return min(word, reverse)
29
+
30
+
31
+ def _validate_common(
32
+ *,
33
+ k: int,
34
+ canonical: bool,
35
+ seed: int,
36
+ max_hashes: int,
37
+ max_unique_hashes: int,
38
+ ) -> None:
39
+ validate_positive_int(k, "k")
40
+ validate_bool(canonical, "canonical")
41
+ if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed < _UINT64_SPACE:
42
+ raise ConfigurationError(
43
+ "seed must be an integer in [0, 2**64).",
44
+ code="INVALID_SKETCH_SEED",
45
+ )
46
+ validate_positive_int(max_hashes, "max_hashes")
47
+ if max_hashes > DEFAULT_MAX_SKETCH_HASHES:
48
+ raise ConfigurationError(
49
+ "max_hashes exceeds the hard safety ceiling.",
50
+ code="SKETCH_HASH_LIMIT_INVALID",
51
+ context={"max_hashes": max_hashes, "hard_limit": DEFAULT_MAX_SKETCH_HASHES},
52
+ )
53
+ validate_positive_int(max_unique_hashes, "max_unique_hashes")
54
+ if max_unique_hashes > DEFAULT_MAX_UNIQUE_HASHES:
55
+ raise ConfigurationError(
56
+ "max_unique_hashes exceeds the hard safety ceiling.",
57
+ code="SKETCH_UNIQUE_HASH_LIMIT_INVALID",
58
+ context={
59
+ "max_unique_hashes": max_unique_hashes,
60
+ "hard_limit": DEFAULT_MAX_UNIQUE_HASHES,
61
+ },
62
+ )
63
+
64
+
65
+ def _unique_hashes(
66
+ value: SequenceInput,
67
+ *,
68
+ k: int,
69
+ canonical: bool,
70
+ seed: int,
71
+ max_unique_hashes: int,
72
+ ) -> tuple[str | None, int, int, tuple[int, ...]]:
73
+ sequence, sequence_id = sequence_and_id(value)
74
+ if sequence.topology is Topology.CIRCULAR:
75
+ raise ConfigurationError(
76
+ "Native sketches currently require a linear sequence.",
77
+ code="SKETCH_CIRCULAR_UNSUPPORTED",
78
+ hint="Rotate a circular sequence to an explicit origin before sketching.",
79
+ )
80
+ gaps = tuple(part for part in sequence.parts if isinstance(part, Gap))
81
+ if gaps:
82
+ raise UnsupportedGapOperationError(
83
+ "Sketches do not silently omit or cross explicit Gap objects.",
84
+ code="SKETCH_GAP_NOT_ALLOWED",
85
+ context={"gap_count": len(gaps)},
86
+ )
87
+ if any(symbol not in "ACGT" for symbol in sequence.symbols):
88
+ raise ConfigurationError(
89
+ "Native sketches require canonical A/C/G/T symbols.",
90
+ code="SKETCH_AMBIGUITY_NOT_ALLOWED",
91
+ )
92
+ words: Iterable[str] = (
93
+ sequence.symbols[index : index + k]
94
+ for index in range(max(0, sequence.symbol_length - k + 1))
95
+ )
96
+ if canonical:
97
+ words = (_canonical(word) for word in words)
98
+ unique_hashes: set[int] = set()
99
+ for word in words:
100
+ unique_hashes.add(_hash64(word, seed))
101
+ if len(unique_hashes) > max_unique_hashes:
102
+ raise ConfigurationError(
103
+ "Sketch calculation exceeds max_unique_hashes.",
104
+ code="SKETCH_UNIQUE_HASH_LIMIT_EXCEEDED",
105
+ context={"unique_hash_count_lower_bound": max_unique_hashes + 1},
106
+ )
107
+ hashes = tuple(sorted(unique_hashes))
108
+ observation_count = max(0, sequence.symbol_length - k + 1)
109
+ return sequence_id, sequence.symbol_length, observation_count, hashes
110
+
111
+
112
+ def minhash(
113
+ value: SequenceInput,
114
+ *,
115
+ k: int,
116
+ num_hashes: int = 1_000,
117
+ canonical: bool = True,
118
+ seed: int = 0,
119
+ max_hashes: int = DEFAULT_MAX_SKETCH_HASHES,
120
+ max_unique_hashes: int = DEFAULT_MAX_UNIQUE_HASHES,
121
+ ) -> SketchResult:
122
+ """Return the smallest distinct 64-bit SHA-256-derived k-mer hashes."""
123
+
124
+ _validate_common(
125
+ k=k,
126
+ canonical=canonical,
127
+ seed=seed,
128
+ max_hashes=max_hashes,
129
+ max_unique_hashes=max_unique_hashes,
130
+ )
131
+ validate_positive_int(num_hashes, "num_hashes")
132
+ if num_hashes > max_hashes:
133
+ raise ConfigurationError(
134
+ "num_hashes cannot exceed max_hashes.",
135
+ code="SKETCH_HASH_LIMIT_EXCEEDED",
136
+ )
137
+ sequence_id, symbol_length, observation_count, hashes = _unique_hashes(
138
+ value,
139
+ k=k,
140
+ canonical=canonical,
141
+ seed=seed,
142
+ max_unique_hashes=max_unique_hashes,
143
+ )
144
+ return SketchResult(
145
+ name="minhash",
146
+ method="bottom-k-sha256-64",
147
+ schema_version="dnakit.sketch.minhash.v1",
148
+ sequence_id=sequence_id,
149
+ symbol_length=symbol_length,
150
+ k=k,
151
+ canonical=canonical,
152
+ seed=seed,
153
+ selection="bottom_k",
154
+ hashes=hashes[:num_hashes],
155
+ num_hashes=num_hashes,
156
+ scaled=None,
157
+ threshold=None,
158
+ observation_count=observation_count,
159
+ unique_hash_count=len(hashes),
160
+ max_hashes=max_hashes,
161
+ parameters=FrozenDict(
162
+ {
163
+ "hash": "sha256-first-64-big-endian",
164
+ "deduplicate_kmers": True,
165
+ "max_unique_hashes": max_unique_hashes,
166
+ "topology": "linear",
167
+ }
168
+ ),
169
+ )
170
+
171
+
172
+ def fracminhash(
173
+ value: SequenceInput,
174
+ *,
175
+ k: int,
176
+ scaled: int = 1_000,
177
+ canonical: bool = True,
178
+ seed: int = 0,
179
+ max_hashes: int = DEFAULT_MAX_SKETCH_HASHES,
180
+ max_unique_hashes: int = DEFAULT_MAX_UNIQUE_HASHES,
181
+ ) -> SketchResult:
182
+ """Retain hashes below ``floor(2**64 / scaled)`` with a hard output limit."""
183
+
184
+ _validate_common(
185
+ k=k,
186
+ canonical=canonical,
187
+ seed=seed,
188
+ max_hashes=max_hashes,
189
+ max_unique_hashes=max_unique_hashes,
190
+ )
191
+ validate_positive_int(scaled, "scaled")
192
+ if scaled > _UINT64_SPACE:
193
+ raise ConfigurationError(
194
+ "scaled must not exceed 2**64.",
195
+ code="INVALID_FRACMINHASH_SCALED",
196
+ )
197
+ sequence_id, symbol_length, observation_count, hashes = _unique_hashes(
198
+ value,
199
+ k=k,
200
+ canonical=canonical,
201
+ seed=seed,
202
+ max_unique_hashes=max_unique_hashes,
203
+ )
204
+ threshold = _UINT64_SPACE // scaled
205
+ retained = tuple(item for item in hashes if item < threshold)
206
+ if len(retained) > max_hashes:
207
+ raise ConfigurationError(
208
+ "FracMinHash output exceeds max_hashes.",
209
+ code="SKETCH_HASH_LIMIT_EXCEEDED",
210
+ context={"retained_hashes": len(retained), "max_hashes": max_hashes},
211
+ hint="Increase scaled or explicitly raise max_hashes within the hard ceiling.",
212
+ )
213
+ return SketchResult(
214
+ name="fracminhash",
215
+ method="threshold-sha256-64",
216
+ schema_version="dnakit.sketch.fracminhash.v1",
217
+ sequence_id=sequence_id,
218
+ symbol_length=symbol_length,
219
+ k=k,
220
+ canonical=canonical,
221
+ seed=seed,
222
+ selection="scaled",
223
+ hashes=retained,
224
+ num_hashes=None,
225
+ scaled=scaled,
226
+ threshold=threshold,
227
+ observation_count=observation_count,
228
+ unique_hash_count=len(hashes),
229
+ max_hashes=max_hashes,
230
+ parameters=FrozenDict(
231
+ {
232
+ "hash": "sha256-first-64-big-endian",
233
+ "deduplicate_kmers": True,
234
+ "max_unique_hashes": max_unique_hashes,
235
+ "topology": "linear",
236
+ }
237
+ ),
238
+ )
239
+
240
+
241
+ __all__ = [
242
+ "DEFAULT_MAX_SKETCH_HASHES",
243
+ "DEFAULT_MAX_UNIQUE_HASHES",
244
+ "fracminhash",
245
+ "minhash",
246
+ ]
dnakit/io/__init__.py ADDED
@@ -0,0 +1,112 @@
1
+ """Streaming DNA record input and output."""
2
+
3
+ from dnakit.io.annotations import (
4
+ AGPComponent,
5
+ AGPDocument,
6
+ AGPEntry,
7
+ AGPGap,
8
+ AnnotationDocument,
9
+ AnnotationEntry,
10
+ AnnotationSource,
11
+ AnnotationTarget,
12
+ read_agp,
13
+ read_bed,
14
+ read_gff3,
15
+ write_agp,
16
+ write_bed,
17
+ write_gff3,
18
+ )
19
+ from dnakit.io.api import ReadMode, read, read_one, read_set, write
20
+ from dnakit.io.config import FeatureWritePolicy, ReadConfig, WriteConfig
21
+ from dnakit.io.indexed import (
22
+ FastaIndex,
23
+ FastaIndexEntry,
24
+ FastqIndex,
25
+ FastqIndexEntry,
26
+ build_fasta_index,
27
+ build_fastq_index,
28
+ iter_chunks,
29
+ load_fasta_index,
30
+ load_fastq_index,
31
+ )
32
+ from dnakit.io.metadata import (
33
+ MetadataValidationIssue,
34
+ MetadataValidationReport,
35
+ filter_by_metadata,
36
+ merge_metadata,
37
+ select_metadata,
38
+ validate_metadata,
39
+ with_metadata,
40
+ )
41
+ from dnakit.io.parquet import (
42
+ ParquetBackendStatus,
43
+ parquet_backend_status,
44
+ require_parquet_backend,
45
+ )
46
+ from dnakit.io.results import GeneratedID, WriteResult
47
+ from dnakit.io.source import RecordSource
48
+ from dnakit.io.tables import (
49
+ ParquetCompression,
50
+ TableColumnType,
51
+ TableExportResult,
52
+ TableReadResult,
53
+ TableSchema,
54
+ export_result,
55
+ export_table,
56
+ read_table,
57
+ )
58
+
59
+ __all__ = [
60
+ "AGPComponent",
61
+ "AGPDocument",
62
+ "AGPEntry",
63
+ "AGPGap",
64
+ "AnnotationDocument",
65
+ "AnnotationEntry",
66
+ "AnnotationSource",
67
+ "AnnotationTarget",
68
+ "FastaIndex",
69
+ "FastaIndexEntry",
70
+ "FastqIndex",
71
+ "FastqIndexEntry",
72
+ "FeatureWritePolicy",
73
+ "GeneratedID",
74
+ "MetadataValidationIssue",
75
+ "MetadataValidationReport",
76
+ "ParquetBackendStatus",
77
+ "ParquetCompression",
78
+ "ReadConfig",
79
+ "ReadMode",
80
+ "RecordSource",
81
+ "TableColumnType",
82
+ "TableExportResult",
83
+ "TableReadResult",
84
+ "TableSchema",
85
+ "WriteConfig",
86
+ "WriteResult",
87
+ "build_fasta_index",
88
+ "build_fastq_index",
89
+ "export_result",
90
+ "export_table",
91
+ "filter_by_metadata",
92
+ "iter_chunks",
93
+ "load_fasta_index",
94
+ "load_fastq_index",
95
+ "merge_metadata",
96
+ "parquet_backend_status",
97
+ "read",
98
+ "read_agp",
99
+ "read_bed",
100
+ "read_gff3",
101
+ "read_one",
102
+ "read_set",
103
+ "read_table",
104
+ "require_parquet_backend",
105
+ "select_metadata",
106
+ "validate_metadata",
107
+ "with_metadata",
108
+ "write",
109
+ "write_agp",
110
+ "write_bed",
111
+ "write_gff3",
112
+ ]
@@ -0,0 +1,105 @@
1
+ """Shared bounded text helpers for advanced codecs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from collections.abc import Callable, Iterator
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from typing import TextIO
11
+
12
+ from dnakit.exceptions import InputFormatError
13
+
14
+
15
+ @contextmanager
16
+ def open_text_source(source: str | os.PathLike[str] | TextIO) -> Iterator[TextIO]:
17
+ """Borrow streams and own path handles."""
18
+
19
+ if isinstance(source, (str, os.PathLike)):
20
+ path = Path(source)
21
+ try:
22
+ with path.open("r", encoding="utf-8", newline="") as handle:
23
+ yield handle
24
+ except OSError as exc:
25
+ raise InputFormatError(
26
+ "Could not open the advanced text input.",
27
+ code="INPUT_OPEN_FAILED",
28
+ context={"source": str(path), "reason": str(exc)},
29
+ ) from exc
30
+ return
31
+ if not hasattr(source, "read"):
32
+ raise TypeError("source must be a path or readable text stream.")
33
+ probe = source.read(0)
34
+ if not isinstance(probe, str):
35
+ raise TypeError("advanced annotation codecs require a decoded text stream.")
36
+ yield source
37
+
38
+
39
+ def checked_lines(handle: TextIO, *, max_line_length: int) -> Iterator[tuple[int, str]]:
40
+ """Yield newline-free lines while enforcing a hard character limit."""
41
+
42
+ if isinstance(max_line_length, bool) or not isinstance(max_line_length, int):
43
+ raise TypeError("max_line_length must be an integer.")
44
+ if max_line_length < 1:
45
+ raise ValueError("max_line_length must be positive.")
46
+ for line_number, raw in enumerate(handle, start=1):
47
+ line = raw.rstrip("\r\n")
48
+ if len(line) > max_line_length:
49
+ raise InputFormatError(
50
+ "An input line exceeds the configured resource limit.",
51
+ code="LINE_TOO_LONG",
52
+ context={
53
+ "line_number": line_number,
54
+ "line_length": len(line),
55
+ "max_line_length": max_line_length,
56
+ },
57
+ )
58
+ yield line_number, line
59
+
60
+
61
+ def write_text_path(
62
+ target: str | os.PathLike[str] | TextIO,
63
+ writer: Callable[[TextIO], int],
64
+ *,
65
+ overwrite: bool,
66
+ create_parents: bool,
67
+ ) -> int:
68
+ """Write text atomically for paths and directly to borrowed streams."""
69
+
70
+ if not isinstance(overwrite, bool) or not isinstance(create_parents, bool):
71
+ raise TypeError("overwrite and create_parents must be booleans.")
72
+ if not isinstance(target, (str, os.PathLike)):
73
+ if not hasattr(target, "write"):
74
+ raise TypeError("target must be a path or writable text stream.")
75
+ count = writer(target)
76
+ target.flush()
77
+ return count
78
+
79
+ path = Path(target)
80
+ if path.exists() and not overwrite:
81
+ raise FileExistsError(f"Refusing to overwrite existing output: {path}")
82
+ if not path.parent.exists():
83
+ if not create_parents:
84
+ raise FileNotFoundError(f"Output parent directory does not exist: {path.parent}")
85
+ path.parent.mkdir(parents=True, exist_ok=True)
86
+ descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
87
+ temporary = Path(name)
88
+ os.close(descriptor)
89
+ try:
90
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
91
+ count = writer(handle)
92
+ handle.flush()
93
+ os.fsync(handle.fileno())
94
+ if overwrite:
95
+ os.replace(temporary, path)
96
+ else:
97
+ os.link(temporary, path)
98
+ temporary.unlink()
99
+ except BaseException:
100
+ temporary.unlink(missing_ok=True)
101
+ raise
102
+ return count
103
+
104
+
105
+ __all__ = ["checked_lines", "open_text_source", "write_text_path"]