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/genbank.py ADDED
@@ -0,0 +1,523 @@
1
+ """A deliberately bounded, documented subset of the GenBank flat-file format.
2
+
3
+ The codec supports common LOCUS, DEFINITION, ACCESSION, VERSION, FEATURES and
4
+ ORIGIN records. It does not claim complete INSDC compatibility and rejects
5
+ fuzzy, remote and other unsupported feature-location syntax explicitly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Iterable, Iterator, Mapping
12
+ from typing import TextIO, cast
13
+
14
+ from dnakit.core._json import JSONValue
15
+ from dnakit.core.coordinates import CompoundLocation, Interval, Location, UnresolvedLocation
16
+ from dnakit.core.enums import DNAAlphabet, Strand, Strandedness, Topology
17
+ from dnakit.core.feature import DNAFeature
18
+ from dnakit.core.record import DNARecord
19
+ from dnakit.core.sequence import STRICT_SYMBOLS, DNASequence
20
+ from dnakit.exceptions import InputFormatError
21
+
22
+ from .config import ReadConfig, WriteConfig
23
+
24
+ _DATE_RE = re.compile(r"^\d{2}-[A-Z]{3}-\d{4}$")
25
+ _INTERVAL_RE = re.compile(r"^(\d+)(?:\.\.(\d+))?$")
26
+ _ORIGIN_TOKEN_RE = re.compile(r"[A-Za-z]+")
27
+ _RESERVED = {
28
+ "dnakit_id",
29
+ "dnakit_label",
30
+ "dnakit_score",
31
+ "dnakit_phase",
32
+ "dnakit_source",
33
+ }
34
+
35
+
36
+ def _error(message: str, code: str, line_number: int | None = None) -> InputFormatError:
37
+ context: dict[str, object] = {}
38
+ if line_number is not None:
39
+ context["line_number"] = line_number
40
+ return InputFormatError(message, code=code, context=context)
41
+
42
+
43
+ def _parse_location(text: str, *, line_number: int) -> tuple[Location, Strand]:
44
+ value = text.strip()
45
+ strand = Strand.FORWARD
46
+ if value.startswith("complement(") and value.endswith(")"):
47
+ value = value[11:-1].strip()
48
+ strand = Strand.REVERSE
49
+ if value.startswith("join(") and value.endswith(")"):
50
+ tokens = tuple(item.strip() for item in value[5:-1].split(","))
51
+ else:
52
+ tokens = (value,)
53
+ parts: list[Interval] = []
54
+ for token in tokens:
55
+ match = _INTERVAL_RE.fullmatch(token)
56
+ if match is None:
57
+ raise _error(
58
+ "The GenBank subset does not support this feature location.",
59
+ "UNSUPPORTED_GENBANK_LOCATION",
60
+ line_number,
61
+ )
62
+ start = int(match.group(1))
63
+ end = int(match.group(2) or match.group(1))
64
+ if start < 1 or end < start:
65
+ raise _error(
66
+ "GenBank feature coordinates are invalid.", "INVALID_GENBANK_LOCATION", line_number
67
+ )
68
+ parts.append(Interval(start - 1, end))
69
+ if not parts:
70
+ raise _error("GenBank join() must not be empty.", "INVALID_GENBANK_LOCATION", line_number)
71
+ return (parts[0] if len(parts) == 1 else CompoundLocation(parts)), strand
72
+
73
+
74
+ def _qualifier_scalar(value: str) -> JSONValue:
75
+ if value == "true":
76
+ return True
77
+ if value == "false":
78
+ return False
79
+ if value == "null":
80
+ return None
81
+ if re.fullmatch(r"-?(?:0|[1-9]\d*)", value):
82
+ return int(value)
83
+ if re.fullmatch(r"-?(?:(?:0|[1-9]\d*)\.\d+|(?:0|[1-9]\d*)(?:\.\d+)?[eE][+-]?\d+)", value):
84
+ return float(value)
85
+ return value
86
+
87
+
88
+ def _parse_qualifier(text: str, *, line_number: int) -> tuple[str, JSONValue]:
89
+ body = text.strip()
90
+ if not body.startswith("/"):
91
+ raise _error(
92
+ "Malformed GenBank feature qualifier.", "INVALID_GENBANK_QUALIFIER", line_number
93
+ )
94
+ body = body[1:]
95
+ value: JSONValue
96
+ if "=" not in body:
97
+ key, value = body, True
98
+ else:
99
+ key, raw_value = body.split("=", 1)
100
+ if raw_value.startswith('"'):
101
+ if not raw_value.endswith('"') or len(raw_value) < 2:
102
+ raise _error(
103
+ "Multiline GenBank qualifiers are outside the supported subset.",
104
+ "UNSUPPORTED_GENBANK_QUALIFIER",
105
+ line_number,
106
+ )
107
+ value = raw_value[1:-1].replace('""', '"')
108
+ else:
109
+ value = _qualifier_scalar(raw_value)
110
+ if not key or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", key):
111
+ raise _error("Invalid GenBank qualifier name.", "INVALID_GENBANK_QUALIFIER", line_number)
112
+ return key, value
113
+
114
+
115
+ def _append_qualifier(values: dict[str, object], key: str, value: JSONValue) -> None:
116
+ previous = values.get(key)
117
+ if previous is None:
118
+ values[key] = value
119
+ elif isinstance(previous, list):
120
+ previous.append(value)
121
+ else:
122
+ values[key] = [previous, value]
123
+
124
+
125
+ def _feature_from_parts(
126
+ key: str,
127
+ location_text: str,
128
+ qualifier_items: list[tuple[str, JSONValue]],
129
+ *,
130
+ line_number: int,
131
+ ) -> DNAFeature:
132
+ location, strand = _parse_location(location_text, line_number=line_number)
133
+ qualifiers: dict[str, object] = {}
134
+ reserved: dict[str, JSONValue] = {}
135
+ for name, value in qualifier_items:
136
+ if name in _RESERVED:
137
+ if name in reserved:
138
+ raise _error(
139
+ "A DNAKit GenBank qualifier is duplicated.",
140
+ "INVALID_GENBANK_QUALIFIER",
141
+ line_number,
142
+ )
143
+ reserved[name] = value
144
+ else:
145
+ _append_qualifier(qualifiers, name, value)
146
+ try:
147
+ identifier = reserved.get("dnakit_id")
148
+ label = reserved.get("dnakit_label")
149
+ score = reserved.get("dnakit_score")
150
+ phase = reserved.get("dnakit_phase")
151
+ source = reserved.get("dnakit_source")
152
+ for name, value in (
153
+ ("dnakit_id", identifier),
154
+ ("dnakit_label", label),
155
+ ("dnakit_source", source),
156
+ ):
157
+ if value is not None and not isinstance(value, str):
158
+ raise TypeError(f"{name} must be text")
159
+ if score is not None and (isinstance(score, bool) or not isinstance(score, (int, float))):
160
+ raise TypeError("dnakit_score must be numeric")
161
+ if phase is not None and (isinstance(phase, bool) or not isinstance(phase, int)):
162
+ raise TypeError("dnakit_phase must be an integer")
163
+ return DNAFeature(
164
+ key,
165
+ location,
166
+ id=identifier if isinstance(identifier, str) else None,
167
+ strand=strand,
168
+ label=label if isinstance(label, str) else None,
169
+ score=float(score)
170
+ if isinstance(score, (int, float)) and not isinstance(score, bool)
171
+ else None,
172
+ phase=phase if isinstance(phase, int) and not isinstance(phase, bool) else None,
173
+ qualifiers=qualifiers,
174
+ source=source if isinstance(source, str) else None,
175
+ )
176
+ except (TypeError, ValueError) as exc:
177
+ raise _error(
178
+ "Invalid typed DNAKit GenBank qualifier.", "INVALID_GENBANK_QUALIFIER", line_number
179
+ ) from exc
180
+
181
+
182
+ def iter_genbank(handle: TextIO, config: ReadConfig) -> Iterator[DNARecord]:
183
+ """Parse the auditable GenBank subset lazily."""
184
+
185
+ lines: list[tuple[int, str]] = []
186
+ for line_number, raw in enumerate(handle, start=1):
187
+ line = raw.rstrip("\r\n")
188
+ if len(line) > config.max_field_size:
189
+ raise _error(
190
+ "A GenBank line exceeds max_field_size.", "GENBANK_LINE_TOO_LONG", line_number
191
+ )
192
+ if line == "//":
193
+ yield _parse_record(lines, config)
194
+ lines = []
195
+ else:
196
+ if len(lines) >= config.max_record_lines:
197
+ raise _error(
198
+ "A GenBank record exceeds max_record_lines.",
199
+ "GENBANK_RECORD_LINE_LIMIT_EXCEEDED",
200
+ line_number,
201
+ )
202
+ lines.append((line_number, line))
203
+ if lines:
204
+ raise _error(
205
+ "GenBank record is missing the // terminator.", "TRUNCATED_GENBANK_RECORD", lines[-1][0]
206
+ )
207
+
208
+
209
+ def _parse_record(lines: list[tuple[int, str]], config: ReadConfig) -> DNARecord:
210
+ if not lines or not lines[0][1].startswith("LOCUS"):
211
+ raise _error(
212
+ "GenBank record must begin with LOCUS.",
213
+ "GENBANK_MISSING_LOCUS",
214
+ lines[0][0] if lines else None,
215
+ )
216
+ locus_fields = lines[0][1].split()
217
+ if len(locus_fields) < 4 or locus_fields[0] != "LOCUS" or locus_fields[2].isdigit() is False:
218
+ raise _error("Malformed GenBank LOCUS line.", "INVALID_GENBANK_LOCUS", lines[0][0])
219
+ locus = locus_fields[1]
220
+ expected_length = int(locus_fields[2])
221
+ if expected_length < 0 or locus_fields[3].lower() != "bp":
222
+ raise _error("Malformed GenBank LOCUS length.", "INVALID_GENBANK_LOCUS", lines[0][0])
223
+ if expected_length > config.max_sequence_symbols:
224
+ raise _error(
225
+ "GenBank record exceeds max_sequence_symbols.",
226
+ "SEQUENCE_SYMBOL_LIMIT_EXCEEDED",
227
+ lines[0][0],
228
+ )
229
+ lower_fields = [item.lower() for item in locus_fields]
230
+ topology = Topology.CIRCULAR if "circular" in lower_fields else Topology.LINEAR
231
+ strandedness = (
232
+ Strandedness.DOUBLE
233
+ if any("ds-dna" in item for item in lower_fields)
234
+ else config.strandedness
235
+ )
236
+ division = (
237
+ locus_fields[-2]
238
+ if len(locus_fields) >= 2 and _DATE_RE.fullmatch(locus_fields[-1])
239
+ else None
240
+ )
241
+ date = locus_fields[-1] if _DATE_RE.fullmatch(locus_fields[-1]) else None
242
+
243
+ definition_parts: list[str] = []
244
+ accession: str | None = None
245
+ version: str | None = None
246
+ feature_rows: list[tuple[str, str, int, list[tuple[str, JSONValue]]]] = []
247
+ sequence_chunks: list[str] = []
248
+ section = "header"
249
+ seen_origin = False
250
+ sequence_symbol_count = 0
251
+ active_header_tag: str | None = None
252
+ for line_number, line in lines[1:]:
253
+ if line.startswith("FEATURES"):
254
+ section = "features"
255
+ continue
256
+ if line.startswith("ORIGIN"):
257
+ section = "origin"
258
+ seen_origin = True
259
+ continue
260
+ if section == "header":
261
+ tag = line[:12].strip()
262
+ value = line[12:].strip()
263
+ if tag:
264
+ active_header_tag = tag
265
+ if tag == "DEFINITION" or (not tag and active_header_tag == "DEFINITION"):
266
+ definition_parts.append(value)
267
+ elif tag == "ACCESSION":
268
+ fields = value.split()
269
+ accession = fields[0] if fields else None
270
+ elif tag == "VERSION":
271
+ fields = value.split()
272
+ version = fields[0] if fields else None
273
+ elif section == "features":
274
+ key = line[5:21].strip() if len(line) >= 21 else ""
275
+ value = line[21:].strip() if len(line) >= 21 else ""
276
+ if key:
277
+ feature_rows.append((key, value, line_number, []))
278
+ elif value.startswith("/"):
279
+ if not feature_rows:
280
+ raise _error(
281
+ "GenBank qualifier appears before a feature.",
282
+ "INVALID_GENBANK_QUALIFIER",
283
+ line_number,
284
+ )
285
+ name, qualifier = _parse_qualifier(value, line_number=line_number)
286
+ feature_rows[-1][3].append((name, qualifier))
287
+ elif value:
288
+ raise _error(
289
+ "Wrapped GenBank feature locations are outside the supported subset.",
290
+ "UNSUPPORTED_GENBANK_LOCATION",
291
+ line_number,
292
+ )
293
+ elif line.strip():
294
+ raise _error(
295
+ "Malformed GenBank feature line.",
296
+ "INVALID_GENBANK_FEATURE",
297
+ line_number,
298
+ )
299
+ else:
300
+ stripped = line.strip()
301
+ if stripped and re.fullmatch(r"\d+(?:\s+[A-Za-z]+)*", stripped) is None:
302
+ raise _error(
303
+ "Malformed GenBank ORIGIN sequence line.",
304
+ "INVALID_GENBANK_ORIGIN",
305
+ line_number,
306
+ )
307
+ tokens = _ORIGIN_TOKEN_RE.findall(line)
308
+ sequence_symbol_count += sum(len(token) for token in tokens)
309
+ if sequence_symbol_count > config.max_sequence_symbols:
310
+ raise _error(
311
+ "GenBank record exceeds max_sequence_symbols.",
312
+ "SEQUENCE_SYMBOL_LIMIT_EXCEEDED",
313
+ line_number,
314
+ )
315
+ sequence_chunks.extend(tokens)
316
+
317
+ sequence_text = "".join(sequence_chunks).upper()
318
+ if not seen_origin:
319
+ raise _error("GenBank record is missing ORIGIN.", "GENBANK_MISSING_ORIGIN", lines[0][0])
320
+ if len(sequence_text) != expected_length:
321
+ raise _error(
322
+ "GenBank LOCUS length does not equal the ORIGIN sequence length.",
323
+ "GENBANK_LENGTH_MISMATCH",
324
+ lines[0][0],
325
+ )
326
+ alphabet = DNAAlphabet.STRICT if set(sequence_text) <= STRICT_SYMBOLS else config.alphabet
327
+ try:
328
+ sequence = DNASequence(
329
+ sequence_text,
330
+ alphabet=alphabet,
331
+ topology=topology,
332
+ strandedness=strandedness,
333
+ )
334
+ features = tuple(
335
+ _feature_from_parts(key, location, qualifiers, line_number=line_number)
336
+ for key, location, line_number, qualifiers in feature_rows
337
+ )
338
+ metadata = {
339
+ "genbank": {
340
+ "locus": locus,
341
+ "accession": accession,
342
+ "version": version,
343
+ "division": division,
344
+ "date": date,
345
+ "codec": "dnakit.genbank.subset.v1",
346
+ }
347
+ }
348
+ description = " ".join(definition_parts).strip()
349
+ return DNARecord(
350
+ sequence,
351
+ version or accession or locus,
352
+ description="" if description == "." else description,
353
+ features=features,
354
+ metadata=metadata,
355
+ )
356
+ except Exception as exc:
357
+ if isinstance(exc, InputFormatError):
358
+ raise
359
+ raise _error(
360
+ "GenBank record violates DNAKit object constraints.",
361
+ "INVALID_GENBANK_RECORD",
362
+ lines[0][0],
363
+ ) from exc
364
+
365
+
366
+ def _format_location(location: Location, strand: Strand) -> str:
367
+ if isinstance(location, UnresolvedLocation):
368
+ raise InputFormatError(
369
+ "Unresolved locations cannot be represented by the GenBank subset.",
370
+ code="UNSUPPORTED_GENBANK_LOCATION",
371
+ )
372
+ parts = (location,) if isinstance(location, Interval) else location.parts
373
+ tokens = [
374
+ f"{part.start + 1}..{part.end}" if len(part) != 1 else str(part.start + 1) for part in parts
375
+ ]
376
+ value = tokens[0] if len(tokens) == 1 else f"join({','.join(tokens)})"
377
+ if strand is Strand.REVERSE:
378
+ return f"complement({value})"
379
+ if strand not in {Strand.FORWARD, Strand.UNKNOWN}:
380
+ raise InputFormatError(
381
+ "The GenBank subset represents only forward, reverse, or unknown feature strands.",
382
+ code="UNSUPPORTED_GENBANK_STRAND",
383
+ )
384
+ return value
385
+
386
+
387
+ def _serialize_qualifier(name: str, value: object) -> str:
388
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", name):
389
+ raise InputFormatError("Invalid GenBank qualifier name.", code="INVALID_GENBANK_QUALIFIER")
390
+ if value is True:
391
+ return f"/{name}"
392
+ if value is False or value is None or isinstance(value, (int, float)):
393
+ return (
394
+ f"/{name}={str(value).lower() if isinstance(value, bool) or value is None else value}"
395
+ )
396
+ if not isinstance(value, str) or "\n" in value or "\r" in value:
397
+ raise InputFormatError(
398
+ "GenBank qualifier values must be JSON scalars or arrays of scalars.",
399
+ code="UNSUPPORTED_GENBANK_QUALIFIER",
400
+ )
401
+ return f'/{name}="{value.replace(chr(34), chr(34) * 2)}"'
402
+
403
+
404
+ def _feature_qualifiers(feature: DNAFeature) -> Iterator[tuple[str, object]]:
405
+ for key in feature.qualifiers:
406
+ if key in _RESERVED:
407
+ raise InputFormatError(
408
+ "Feature qualifiers conflict with reserved DNAKit GenBank names.",
409
+ code="RESERVED_GENBANK_QUALIFIER",
410
+ context={"qualifier": key},
411
+ )
412
+ if feature.id is not None:
413
+ yield "dnakit_id", feature.id
414
+ if feature.label is not None:
415
+ yield "dnakit_label", feature.label
416
+ if feature.score is not None:
417
+ yield "dnakit_score", feature.score
418
+ if feature.phase is not None:
419
+ yield "dnakit_phase", feature.phase
420
+ if feature.source is not None:
421
+ yield "dnakit_source", feature.source
422
+ for key, value in feature.qualifiers.items():
423
+ if isinstance(value, tuple):
424
+ for item in value:
425
+ yield key, item
426
+ else:
427
+ yield key, value
428
+
429
+
430
+ def write_genbank(handle: TextIO, records: Iterable[DNARecord], config: WriteConfig) -> int:
431
+ """Serialize records in the deterministic DNAKit GenBank subset."""
432
+
433
+ count = 0
434
+ for record in records:
435
+ if record.sequence.is_gapped:
436
+ raise InputFormatError(
437
+ "The GenBank subset cannot serialize explicit Gap objects.",
438
+ code="GAP_LOSS_NOT_ALLOWED",
439
+ context={"record_id": record.id},
440
+ )
441
+ if any(symbol.isspace() for symbol in record.id) or len(record.id) > 32:
442
+ raise InputFormatError(
443
+ "GenBank record IDs must be 1-32 characters without whitespace.",
444
+ code="INVALID_GENBANK_ID",
445
+ context={"record_id": record.id},
446
+ )
447
+ metadata_raw: object = record.metadata.get("genbank", {})
448
+ metadata = metadata_raw if isinstance(metadata_raw, Mapping) else {}
449
+ locus = metadata.get("locus", record.id)
450
+ accession = metadata.get("accession", record.id)
451
+ version = metadata.get("version", record.id)
452
+ division = metadata.get("division", "UNK") or "UNK"
453
+ date = metadata.get("date", "01-JAN-1980") or "01-JAN-1980"
454
+ for label, value in (
455
+ ("locus", locus),
456
+ ("accession", accession),
457
+ ("version", version),
458
+ ("division", division),
459
+ ("date", date),
460
+ ):
461
+ if not isinstance(value, str) or not value or any(ch in value for ch in "\r\n"):
462
+ raise InputFormatError(
463
+ "GenBank metadata fields must be non-empty single-line strings.",
464
+ code="INVALID_GENBANK_METADATA",
465
+ context={"field": label},
466
+ )
467
+ if (
468
+ len(cast(str, locus)) > 32
469
+ or any(symbol.isspace() for symbol in cast(str, locus))
470
+ or any(symbol.isspace() for symbol in cast(str, accession))
471
+ or any(symbol.isspace() for symbol in cast(str, version))
472
+ or re.fullmatch(r"[A-Z]{3}", cast(str, division)) is None
473
+ ):
474
+ raise InputFormatError(
475
+ "GenBank locus/accession/version/division metadata is not representable.",
476
+ code="INVALID_GENBANK_METADATA",
477
+ )
478
+ if not _DATE_RE.fullmatch(cast(str, date)):
479
+ raise InputFormatError(
480
+ "GenBank date must use DD-MMM-YYYY.", code="INVALID_GENBANK_METADATA"
481
+ )
482
+ sequence = record.sequence.to_string()
483
+ topology = record.sequence.topology.value
484
+ stranded = "ds-DNA" if record.sequence.strandedness is Strandedness.DOUBLE else "DNA"
485
+ locus_prefix = f"LOCUS {locus:<32} {len(sequence):>11} bp "
486
+ handle.write(f"{locus_prefix}{stranded:<6} {topology:<8} {division:>3} {date}\n")
487
+ definition = record.description or "."
488
+ if "\n" in definition or "\r" in definition:
489
+ raise InputFormatError(
490
+ "GenBank descriptions must be single-line text.", code="INVALID_GENBANK_METADATA"
491
+ )
492
+ handle.write(f"DEFINITION {definition}\n")
493
+ handle.write(f"ACCESSION {accession}\n")
494
+ handle.write(f"VERSION {version}\n")
495
+ handle.write("FEATURES Location/Qualifiers\n")
496
+ for feature in record.features:
497
+ location = _format_location(feature.location, feature.strand)
498
+ if len(feature.type) > 15 or any(symbol.isspace() for symbol in feature.type):
499
+ raise InputFormatError(
500
+ "GenBank feature keys must fit the 15-character key field.",
501
+ code="INVALID_GENBANK_FEATURE",
502
+ )
503
+ handle.write(f" {feature.type:<15} {location}\n")
504
+ for name, value in _feature_qualifiers(feature):
505
+ rendered = _serialize_qualifier(name, value)
506
+ if len(rendered) > 58:
507
+ raise InputFormatError(
508
+ "Multiline GenBank qualifiers are outside the supported subset.",
509
+ code="UNSUPPORTED_GENBANK_QUALIFIER",
510
+ )
511
+ handle.write(f" {rendered}\n")
512
+ handle.write("ORIGIN\n")
513
+ lower = sequence.lower()
514
+ for offset in range(0, len(lower), 60):
515
+ block = lower[offset : offset + 60]
516
+ grouped = " ".join(block[index : index + 10] for index in range(0, len(block), 10))
517
+ handle.write(f"{offset + 1:>9} {grouped}\n")
518
+ handle.write("//\n")
519
+ count += 1
520
+ return count
521
+
522
+
523
+ __all__ = ["iter_genbank", "write_genbank"]