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,1179 @@
1
+ """Strict, bounded annotation codecs for GFF3, BED3-BED6, and AGP 2.1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import re
8
+ from collections.abc import Iterable
9
+ from dataclasses import dataclass
10
+ from typing import Any, Literal, TextIO, TypeAlias, cast
11
+ from urllib.parse import quote, unquote_to_bytes
12
+
13
+ from dnakit.core._json import to_json_compatible
14
+ from dnakit.core.coordinates import Interval
15
+ from dnakit.core.enums import GapKind, Strand
16
+ from dnakit.core.feature import DNAFeature
17
+ from dnakit.core.gap import Gap
18
+ from dnakit.exceptions import ConfigurationError, InputFormatError
19
+
20
+ from ._advanced_common import checked_lines, open_text_source, write_text_path
21
+
22
+ AnnotationSource: TypeAlias = str | os.PathLike[str] | TextIO
23
+ AnnotationTarget: TypeAlias = str | os.PathLike[str] | TextIO
24
+ AnnotationFormat = Literal["gff3", "bed"]
25
+
26
+
27
+ def _validate_limit(value: int, name: str) -> None:
28
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
29
+ raise ConfigurationError(
30
+ f"{name} must be a positive integer.",
31
+ code="INVALID_ANNOTATION_LIMIT",
32
+ context={"field": name, "value": value},
33
+ )
34
+
35
+
36
+ def _bounded_append(values: list[object], value: object, *, max_records: int) -> None:
37
+ if len(values) >= max_records:
38
+ raise InputFormatError(
39
+ "Annotation input exceeds max_records.",
40
+ code="ANNOTATION_RECORD_LIMIT_EXCEEDED",
41
+ context={"max_records": max_records},
42
+ )
43
+ values.append(value)
44
+
45
+
46
+ def _bounded_header(values: list[str], value: str, *, max_records: int) -> None:
47
+ if len(values) >= max_records:
48
+ raise InputFormatError(
49
+ "Annotation headers exceed the configured resource limit.",
50
+ code="ANNOTATION_HEADER_LIMIT_EXCEEDED",
51
+ context={"max_header_lines": max_records},
52
+ )
53
+ values.append(value)
54
+
55
+
56
+ def _strand_from_symbol(value: str, *, line_number: int, allow_unknown: bool = True) -> Strand:
57
+ mapping = {"+": Strand.FORWARD, "-": Strand.REVERSE, ".": Strand.UNKNOWN}
58
+ if allow_unknown:
59
+ mapping["?"] = Strand.UNKNOWN
60
+ try:
61
+ return mapping[value]
62
+ except KeyError as exc:
63
+ raise InputFormatError(
64
+ "Invalid annotation strand symbol.",
65
+ code="INVALID_ANNOTATION_STRAND",
66
+ context={"line_number": line_number, "strand": value},
67
+ ) from exc
68
+
69
+
70
+ def _strand_symbol(strand: Strand) -> str:
71
+ if strand is Strand.FORWARD:
72
+ return "+"
73
+ if strand is Strand.REVERSE:
74
+ return "-"
75
+ if strand is Strand.UNKNOWN:
76
+ return "."
77
+ raise InputFormatError(
78
+ "The annotation codec cannot serialize a both-strands feature.",
79
+ code="UNSUPPORTED_ANNOTATION_STRAND",
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class AnnotationEntry:
85
+ """A sequence identifier paired with one immutable DNA feature."""
86
+
87
+ sequence_id: str
88
+ feature: DNAFeature
89
+
90
+ def __post_init__(self) -> None:
91
+ if (
92
+ not isinstance(self.sequence_id, str)
93
+ or not self.sequence_id
94
+ or any(symbol.isspace() for symbol in self.sequence_id)
95
+ ):
96
+ raise ConfigurationError(
97
+ "AnnotationEntry sequence_id must be non-empty text.",
98
+ code="INVALID_ANNOTATION_ENTRY",
99
+ )
100
+ if not isinstance(self.feature, DNAFeature):
101
+ raise ConfigurationError(
102
+ "AnnotationEntry feature must be a DNAFeature.",
103
+ code="INVALID_ANNOTATION_ENTRY",
104
+ )
105
+
106
+ def to_dict(self) -> dict[str, Any]:
107
+ return cast(dict[str, Any], to_json_compatible(self))
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class AnnotationDocument:
112
+ """Bounded annotation document retaining safe header directives."""
113
+
114
+ format: AnnotationFormat
115
+ entries: tuple[AnnotationEntry, ...]
116
+ headers: tuple[str, ...] = ()
117
+
118
+ def __post_init__(self) -> None:
119
+ if not isinstance(self.entries, tuple) or not isinstance(self.headers, tuple):
120
+ raise ConfigurationError(
121
+ "Annotation document entries and headers must be tuples.",
122
+ code="INVALID_ANNOTATION_DOCUMENT",
123
+ )
124
+ if self.format not in {"gff3", "bed"}:
125
+ raise ConfigurationError("Unknown annotation format.", code="INVALID_ANNOTATION_FORMAT")
126
+ if any(not isinstance(item, AnnotationEntry) for item in self.entries):
127
+ raise ConfigurationError(
128
+ "entries must contain AnnotationEntry objects.", code="INVALID_ANNOTATION_DOCUMENT"
129
+ )
130
+ if any(not isinstance(item, str) or "\n" in item or "\r" in item for item in self.headers):
131
+ raise ConfigurationError(
132
+ "Annotation headers must be single-line strings.",
133
+ code="INVALID_ANNOTATION_DOCUMENT",
134
+ )
135
+
136
+ def to_dict(self) -> dict[str, Any]:
137
+ return cast(dict[str, Any], to_json_compatible(self))
138
+
139
+
140
+ def _decode_gff_value(value: str, *, line_number: int) -> str:
141
+ for match in re.finditer("%", value):
142
+ if not re.fullmatch(r"[0-9A-Fa-f]{2}", value[match.start() + 1 : match.start() + 3]):
143
+ raise InputFormatError(
144
+ "GFF3 attribute contains malformed percent encoding.",
145
+ code="INVALID_GFF3_ATTRIBUTE",
146
+ context={"line_number": line_number},
147
+ )
148
+ try:
149
+ return unquote_to_bytes(value).decode("utf-8")
150
+ except UnicodeDecodeError as exc:
151
+ raise InputFormatError(
152
+ "GFF3 attribute percent encoding is not valid UTF-8.",
153
+ code="INVALID_GFF3_ATTRIBUTE",
154
+ context={"line_number": line_number},
155
+ ) from exc
156
+
157
+
158
+ def _parse_gff_attributes(value: str, *, line_number: int) -> dict[str, object]:
159
+ if value == ".":
160
+ return {}
161
+ result: dict[str, object] = {}
162
+ for item in value.split(";"):
163
+ if not item or "=" not in item:
164
+ raise InputFormatError(
165
+ "GFF3 attributes must be semicolon-separated key=value pairs.",
166
+ code="INVALID_GFF3_ATTRIBUTE",
167
+ context={"line_number": line_number},
168
+ )
169
+ raw_key, raw_values = item.split("=", 1)
170
+ key = _decode_gff_value(raw_key, line_number=line_number)
171
+ if not key or key in result:
172
+ raise InputFormatError(
173
+ "GFF3 attribute names must be non-empty and unique.",
174
+ code="INVALID_GFF3_ATTRIBUTE",
175
+ context={"line_number": line_number, "attribute": key},
176
+ )
177
+ values = tuple(
178
+ _decode_gff_value(raw, line_number=line_number) for raw in raw_values.split(",")
179
+ )
180
+ if any(value == "" for value in values):
181
+ raise InputFormatError(
182
+ "GFF3 attribute values must be non-empty.",
183
+ code="INVALID_GFF3_ATTRIBUTE",
184
+ context={"line_number": line_number, "attribute": key},
185
+ )
186
+ result[key] = values[0] if len(values) == 1 else values
187
+ return result
188
+
189
+
190
+ def read_gff3(
191
+ source: AnnotationSource,
192
+ *,
193
+ max_records: int = 1_000_000,
194
+ max_line_length: int = 1_000_000,
195
+ max_header_lines: int = 10_000,
196
+ ) -> AnnotationDocument:
197
+ """Read GFF3 features using 1-based closed to 0-based half-open conversion."""
198
+
199
+ _validate_limit(max_records, "max_records")
200
+ _validate_limit(max_line_length, "max_line_length")
201
+ _validate_limit(max_header_lines, "max_header_lines")
202
+ entries: list[object] = []
203
+ headers: list[str] = []
204
+ seen_version = False
205
+ with open_text_source(source) as handle:
206
+ for line_number, line in checked_lines(handle, max_line_length=max_line_length):
207
+ if not line:
208
+ continue
209
+ if line == "##FASTA":
210
+ raise InputFormatError(
211
+ "Embedded FASTA is outside the DNAKit GFF3 annotation subset.",
212
+ code="UNSUPPORTED_GFF3_FASTA",
213
+ context={"line_number": line_number},
214
+ )
215
+ if line.startswith("#"):
216
+ if line.startswith("##gff-version"):
217
+ version_match = re.fullmatch(r"##gff-version 3(?:\.\d+){0,2}", line)
218
+ if version_match is None or seen_version or line_number != 1:
219
+ raise InputFormatError(
220
+ "GFF3 requires one supported version directive.",
221
+ code="INVALID_GFF3_VERSION",
222
+ context={"line_number": line_number},
223
+ )
224
+ seen_version = True
225
+ _bounded_header(headers, line, max_records=max_header_lines)
226
+ continue
227
+ if not seen_version:
228
+ raise InputFormatError(
229
+ "GFF3 data appears before '##gff-version 3'.",
230
+ code="GFF3_VERSION_REQUIRED",
231
+ context={"line_number": line_number},
232
+ )
233
+ columns = line.split("\t")
234
+ if len(columns) != 9:
235
+ raise InputFormatError(
236
+ "GFF3 records must contain exactly nine tab-separated columns.",
237
+ code="INVALID_GFF3_COLUMNS",
238
+ context={"line_number": line_number, "column_count": len(columns)},
239
+ )
240
+ (
241
+ seqid,
242
+ source_name,
243
+ feature_type,
244
+ start_raw,
245
+ end_raw,
246
+ score_raw,
247
+ strand_raw,
248
+ phase_raw,
249
+ attributes_raw,
250
+ ) = columns
251
+ if not seqid or seqid == "." or not feature_type or feature_type == ".":
252
+ raise InputFormatError(
253
+ "GFF3 seqid and type must be present.",
254
+ code="INVALID_GFF3_FIELD",
255
+ context={"line_number": line_number},
256
+ )
257
+ if not source_name:
258
+ raise InputFormatError(
259
+ "GFF3 source must be non-empty or '.'.",
260
+ code="INVALID_GFF3_FIELD",
261
+ context={"line_number": line_number},
262
+ )
263
+ try:
264
+ start = int(start_raw)
265
+ end = int(end_raw)
266
+ except ValueError as exc:
267
+ raise InputFormatError(
268
+ "GFF3 coordinates must be integers.",
269
+ code="INVALID_GFF3_COORDINATE",
270
+ context={"line_number": line_number},
271
+ ) from exc
272
+ if start < 1 or end < start:
273
+ raise InputFormatError(
274
+ "GFF3 coordinates must form a positive 1-based closed interval.",
275
+ code="INVALID_GFF3_COORDINATE",
276
+ context={"line_number": line_number},
277
+ )
278
+ try:
279
+ score = None if score_raw == "." else float(score_raw)
280
+ except ValueError as exc:
281
+ raise InputFormatError(
282
+ "GFF3 score must be numeric or '.'.",
283
+ code="INVALID_GFF3_SCORE",
284
+ context={"line_number": line_number},
285
+ ) from exc
286
+ if score is not None and not math.isfinite(score):
287
+ raise InputFormatError(
288
+ "GFF3 score must be finite.",
289
+ code="INVALID_GFF3_SCORE",
290
+ context={"line_number": line_number},
291
+ )
292
+ if phase_raw not in {".", "0", "1", "2"}:
293
+ raise InputFormatError(
294
+ "GFF3 phase must be 0, 1, 2, or '.'.",
295
+ code="INVALID_GFF3_PHASE",
296
+ context={"line_number": line_number},
297
+ )
298
+ if (feature_type == "CDS" and phase_raw == ".") or (
299
+ feature_type != "CDS" and phase_raw != "."
300
+ ):
301
+ raise InputFormatError(
302
+ "GFF3 phase is required only for CDS features.",
303
+ code="INVALID_GFF3_PHASE",
304
+ context={"line_number": line_number, "feature_type": feature_type},
305
+ )
306
+ attributes = _parse_gff_attributes(attributes_raw, line_number=line_number)
307
+ identifier = attributes.pop("ID", None)
308
+ name = attributes.pop("Name", None)
309
+ if identifier is not None and not isinstance(identifier, str):
310
+ raise InputFormatError(
311
+ "GFF3 ID must have exactly one value.",
312
+ code="INVALID_GFF3_ATTRIBUTE",
313
+ context={"line_number": line_number},
314
+ )
315
+ if name is not None and not isinstance(name, str):
316
+ raise InputFormatError(
317
+ "GFF3 Name must have exactly one value.",
318
+ code="INVALID_GFF3_ATTRIBUTE",
319
+ context={"line_number": line_number},
320
+ )
321
+ feature = DNAFeature(
322
+ feature_type,
323
+ Interval(start - 1, end),
324
+ id=identifier if isinstance(identifier, str) else None,
325
+ label=name if isinstance(name, str) else None,
326
+ strand=_strand_from_symbol(strand_raw, line_number=line_number),
327
+ score=score,
328
+ phase=None if phase_raw == "." else int(phase_raw),
329
+ qualifiers=attributes,
330
+ source=None if source_name == "." else source_name,
331
+ )
332
+ _bounded_append(entries, AnnotationEntry(seqid, feature), max_records=max_records)
333
+ if not seen_version:
334
+ raise InputFormatError(
335
+ "GFF3 input is missing '##gff-version 3'.", code="GFF3_VERSION_REQUIRED"
336
+ )
337
+ return AnnotationDocument(
338
+ "gff3", cast(tuple[AnnotationEntry, ...], tuple(entries)), tuple(headers)
339
+ )
340
+
341
+
342
+ _GFF_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.:^*$@!+_?-|"
343
+
344
+
345
+ def _encode_gff(value: str) -> str:
346
+ return quote(value, safe=_GFF_SAFE)
347
+
348
+
349
+ def _gff_attributes(feature: DNAFeature) -> str:
350
+ values: list[tuple[str, object]] = []
351
+ if feature.id is not None:
352
+ values.append(("ID", feature.id))
353
+ if feature.label is not None:
354
+ values.append(("Name", feature.label))
355
+ if "ID" in feature.qualifiers or "Name" in feature.qualifiers:
356
+ raise InputFormatError(
357
+ "Feature qualifiers conflict with reserved GFF3 ID or Name attributes.",
358
+ code="RESERVED_GFF3_ATTRIBUTE",
359
+ )
360
+ values.extend(feature.qualifiers.items())
361
+ rendered: list[str] = []
362
+ for key, value in values:
363
+ if not isinstance(key, str) or not key:
364
+ raise InputFormatError(
365
+ "GFF3 attribute names must be non-empty strings.", code="INVALID_GFF3_ATTRIBUTE"
366
+ )
367
+ sequence = value if isinstance(value, tuple) else (value,)
368
+ if not sequence or any(not isinstance(item, (str, int, float, bool)) for item in sequence):
369
+ raise InputFormatError(
370
+ "GFF3 attributes must contain non-empty scalar values.",
371
+ code="UNSUPPORTED_GFF3_ATTRIBUTE",
372
+ )
373
+ rendered.append(
374
+ f"{_encode_gff(key)}="
375
+ + ",".join(
376
+ _encode_gff(str(item).lower() if isinstance(item, bool) else str(item))
377
+ for item in sequence
378
+ )
379
+ )
380
+ return ";".join(rendered) if rendered else "."
381
+
382
+
383
+ def write_gff3(
384
+ document: AnnotationDocument | Iterable[AnnotationEntry],
385
+ target: AnnotationTarget,
386
+ *,
387
+ overwrite: bool = False,
388
+ create_parents: bool = False,
389
+ ) -> int:
390
+ """Write deterministic GFF3 with internal coordinate conversion."""
391
+
392
+ entries: Iterable[AnnotationEntry]
393
+ if isinstance(document, AnnotationDocument):
394
+ if document.format != "gff3":
395
+ raise ConfigurationError(
396
+ "AnnotationDocument format must be gff3.", code="INVALID_ANNOTATION_FORMAT"
397
+ )
398
+ entries = document.entries
399
+ extra_headers = tuple(
400
+ header for header in document.headers if not header.startswith("##gff-version")
401
+ )
402
+ else:
403
+ entries = document
404
+ extra_headers = ()
405
+
406
+ def writer(handle: TextIO) -> int:
407
+ handle.write("##gff-version 3\n")
408
+ for header in extra_headers:
409
+ if not header.startswith("#") or header == "##FASTA":
410
+ raise InputFormatError(
411
+ "Unsafe or unsupported GFF3 header.", code="INVALID_GFF3_HEADER"
412
+ )
413
+ handle.write(header + "\n")
414
+ count = 0
415
+ for entry in entries:
416
+ if not isinstance(entry, AnnotationEntry):
417
+ raise TypeError("GFF3 entries must be AnnotationEntry objects.")
418
+ feature = entry.feature
419
+ if not isinstance(feature.location, Interval) or len(feature.location) == 0:
420
+ raise InputFormatError(
421
+ "GFF3 subset output requires one non-empty interval per feature.",
422
+ code="UNSUPPORTED_GFF3_LOCATION",
423
+ )
424
+ source_name = feature.source or "."
425
+ score = "." if feature.score is None else format(feature.score, ".17g")
426
+ phase = "." if feature.phase is None else str(feature.phase)
427
+ if (feature.type == "CDS" and feature.phase is None) or (
428
+ feature.type != "CDS" and feature.phase is not None
429
+ ):
430
+ raise InputFormatError(
431
+ "GFF3 phase is required only for CDS features.",
432
+ code="INVALID_GFF3_PHASE",
433
+ context={"feature_type": feature.type},
434
+ )
435
+ fields = (
436
+ entry.sequence_id,
437
+ source_name,
438
+ feature.type,
439
+ str(feature.location.start + 1),
440
+ str(feature.location.end),
441
+ score,
442
+ _strand_symbol(feature.strand),
443
+ phase,
444
+ _gff_attributes(feature),
445
+ )
446
+ if any("\t" in value or "\n" in value or "\r" in value for value in fields):
447
+ raise InputFormatError(
448
+ "GFF3 fields must not contain tabs or line breaks.", code="INVALID_GFF3_FIELD"
449
+ )
450
+ handle.write("\t".join(fields) + "\n")
451
+ count += 1
452
+ return count
453
+
454
+ return write_text_path(target, writer, overwrite=overwrite, create_parents=create_parents)
455
+
456
+
457
+ def read_bed(
458
+ source: AnnotationSource,
459
+ *,
460
+ max_records: int = 1_000_000,
461
+ max_line_length: int = 1_000_000,
462
+ max_header_lines: int = 10_000,
463
+ ) -> AnnotationDocument:
464
+ """Read the BED3-BED6 subset into immutable features."""
465
+
466
+ _validate_limit(max_records, "max_records")
467
+ _validate_limit(max_line_length, "max_line_length")
468
+ _validate_limit(max_header_lines, "max_header_lines")
469
+ entries: list[object] = []
470
+ headers: list[str] = []
471
+ with open_text_source(source) as handle:
472
+ for line_number, line in checked_lines(handle, max_line_length=max_line_length):
473
+ if (
474
+ not line
475
+ or line.startswith("#")
476
+ or line.startswith("track ")
477
+ or line.startswith("browser ")
478
+ ):
479
+ if line:
480
+ _bounded_header(headers, line, max_records=max_header_lines)
481
+ continue
482
+ columns = line.split("\t")
483
+ if not 3 <= len(columns) <= 6:
484
+ raise InputFormatError(
485
+ "The DNAKit BED subset accepts exactly 3 through 6 columns.",
486
+ code="UNSUPPORTED_BED_COLUMNS" if len(columns) > 6 else "INVALID_BED_COLUMNS",
487
+ context={"line_number": line_number, "column_count": len(columns)},
488
+ )
489
+ chrom, start_raw, end_raw = columns[:3]
490
+ if not chrom:
491
+ raise InputFormatError(
492
+ "BED chromosome must be non-empty.",
493
+ code="INVALID_BED_FIELD",
494
+ context={"line_number": line_number},
495
+ )
496
+ try:
497
+ start, end = int(start_raw), int(end_raw)
498
+ except ValueError as exc:
499
+ raise InputFormatError(
500
+ "BED coordinates must be integers.",
501
+ code="INVALID_BED_COORDINATE",
502
+ context={"line_number": line_number},
503
+ ) from exc
504
+ if start < 0 or end <= start:
505
+ raise InputFormatError(
506
+ "BED requires a non-empty 0-based half-open interval.",
507
+ code="INVALID_BED_COORDINATE",
508
+ context={"line_number": line_number},
509
+ )
510
+ name = columns[3] if len(columns) >= 4 and columns[3] != "." else None
511
+ if name == "":
512
+ raise InputFormatError(
513
+ "BED name must be non-empty or '.'.",
514
+ code="INVALID_BED_FIELD",
515
+ context={"line_number": line_number},
516
+ )
517
+ score: float | None = None
518
+ if len(columns) >= 5 and columns[4] != ".":
519
+ try:
520
+ raw_score = int(columns[4])
521
+ except ValueError as exc:
522
+ raise InputFormatError(
523
+ "BED score must be an integer from 0 through 1000.",
524
+ code="INVALID_BED_SCORE",
525
+ context={"line_number": line_number},
526
+ ) from exc
527
+ if not 0 <= raw_score <= 1000:
528
+ raise InputFormatError(
529
+ "BED score must be in [0, 1000].",
530
+ code="INVALID_BED_SCORE",
531
+ context={"line_number": line_number},
532
+ )
533
+ score = float(raw_score)
534
+ strand = (
535
+ Strand.UNKNOWN
536
+ if len(columns) < 6
537
+ else _strand_from_symbol(columns[5], line_number=line_number, allow_unknown=False)
538
+ )
539
+ feature = DNAFeature(
540
+ "region",
541
+ Interval(start, end),
542
+ id=name,
543
+ label=name,
544
+ score=score,
545
+ strand=strand,
546
+ source="BED",
547
+ )
548
+ _bounded_append(entries, AnnotationEntry(chrom, feature), max_records=max_records)
549
+ return AnnotationDocument(
550
+ "bed", cast(tuple[AnnotationEntry, ...], tuple(entries)), tuple(headers)
551
+ )
552
+
553
+
554
+ def write_bed(
555
+ document: AnnotationDocument | Iterable[AnnotationEntry],
556
+ target: AnnotationTarget,
557
+ *,
558
+ overwrite: bool = False,
559
+ create_parents: bool = False,
560
+ ) -> int:
561
+ """Write the deterministic BED6 subset."""
562
+
563
+ entries: Iterable[AnnotationEntry]
564
+ headers: tuple[str, ...]
565
+ if isinstance(document, AnnotationDocument):
566
+ if document.format != "bed":
567
+ raise ConfigurationError(
568
+ "AnnotationDocument format must be bed.", code="INVALID_ANNOTATION_FORMAT"
569
+ )
570
+ entries = document.entries
571
+ headers = document.headers
572
+ else:
573
+ entries, headers = document, ()
574
+
575
+ def writer(handle: TextIO) -> int:
576
+ for header in headers:
577
+ if not (
578
+ header.startswith("#")
579
+ or header.startswith("track ")
580
+ or header.startswith("browser ")
581
+ ):
582
+ raise InputFormatError("Unsafe BED header.", code="INVALID_BED_HEADER")
583
+ handle.write(header + "\n")
584
+ count = 0
585
+ for entry in entries:
586
+ if not isinstance(entry, AnnotationEntry):
587
+ raise TypeError("BED entries must be AnnotationEntry objects.")
588
+ feature = entry.feature
589
+ if not isinstance(feature.location, Interval) or len(feature.location) == 0:
590
+ raise InputFormatError(
591
+ "BED6 output requires one non-empty interval.", code="UNSUPPORTED_BED_LOCATION"
592
+ )
593
+ if (
594
+ feature.type != "region"
595
+ or feature.phase is not None
596
+ or feature.qualifiers
597
+ or feature.source not in {None, "BED"}
598
+ or (
599
+ feature.id is not None
600
+ and feature.label is not None
601
+ and feature.id != feature.label
602
+ )
603
+ ):
604
+ raise InputFormatError(
605
+ "The BED3-BED6 subset would lose feature semantics.",
606
+ code="BED_FEATURE_LOSS_NOT_ALLOWED",
607
+ )
608
+ name = feature.id or feature.label
609
+ if feature.score is None:
610
+ score: str | None = None
611
+ elif not feature.score.is_integer() or not 0 <= feature.score <= 1000:
612
+ raise InputFormatError(
613
+ "BED score must be an integer from 0 through 1000.", code="INVALID_BED_SCORE"
614
+ )
615
+ else:
616
+ score = str(int(feature.score))
617
+ fields = [
618
+ entry.sequence_id,
619
+ str(feature.location.start),
620
+ str(feature.location.end),
621
+ ]
622
+ if name is not None or score is not None or feature.strand is not Strand.UNKNOWN:
623
+ fields.append(name or ".")
624
+ if score is not None or feature.strand is not Strand.UNKNOWN:
625
+ fields.append(score or "0")
626
+ if feature.strand is not Strand.UNKNOWN:
627
+ fields.append(_strand_symbol(feature.strand))
628
+ if any("\t" in item or "\n" in item or "\r" in item for item in fields):
629
+ raise InputFormatError(
630
+ "BED fields must not contain tabs or line breaks.", code="INVALID_BED_FIELD"
631
+ )
632
+ handle.write("\t".join(fields) + "\n")
633
+ count += 1
634
+ return count
635
+
636
+ return write_text_path(target, writer, overwrite=overwrite, create_parents=create_parents)
637
+
638
+
639
+ _COMPONENT_TYPES = frozenset("ADFGOPW")
640
+ _GAP_TYPES = frozenset("NU")
641
+ _GAP_KIND_BY_AGP = {
642
+ "scaffold": GapKind.SCAFFOLD,
643
+ "contig": GapKind.CONTIG,
644
+ "centromere": GapKind.CENTROMERE,
645
+ "short_arm": GapKind.SHORT_ARM,
646
+ "heterochromatin": GapKind.HETEROCHROMATIN,
647
+ "telomere": GapKind.TELOMERE,
648
+ "repeat": GapKind.REPEAT,
649
+ "contamination": GapKind.CONTAMINATION,
650
+ }
651
+ _AGP_GAP_TYPES = frozenset(_GAP_KIND_BY_AGP)
652
+ _AGP_LINKAGE_EVIDENCE = frozenset(
653
+ {
654
+ "paired-ends",
655
+ "align_genus",
656
+ "align_xgenus",
657
+ "align_trnscpt",
658
+ "within_clone",
659
+ "clone_contig",
660
+ "map",
661
+ "proximity_ligation",
662
+ "pcr",
663
+ "strobe",
664
+ "unspecified",
665
+ }
666
+ )
667
+
668
+
669
+ @dataclass(frozen=True, slots=True)
670
+ class AGPComponent:
671
+ object_id: str
672
+ object_interval: Interval
673
+ part_number: int
674
+ component_type: str
675
+ component_id: str
676
+ component_interval: Interval
677
+ orientation: str
678
+
679
+ def __post_init__(self) -> None:
680
+ if (
681
+ not isinstance(self.object_id, str)
682
+ or not self.object_id
683
+ or any(symbol.isspace() for symbol in self.object_id)
684
+ ):
685
+ raise ConfigurationError("AGP object_id must be non-empty.", code="INVALID_AGP_ENTRY")
686
+ if not isinstance(self.object_interval, Interval) or len(self.object_interval) == 0:
687
+ raise ConfigurationError(
688
+ "AGP object interval must be non-empty.", code="INVALID_AGP_ENTRY"
689
+ )
690
+ if (
691
+ isinstance(self.part_number, bool)
692
+ or not isinstance(self.part_number, int)
693
+ or self.part_number < 1
694
+ ):
695
+ raise ConfigurationError("AGP part_number must be positive.", code="INVALID_AGP_ENTRY")
696
+ if not isinstance(self.component_type, str) or self.component_type not in _COMPONENT_TYPES:
697
+ raise ConfigurationError("Invalid AGP component type.", code="INVALID_AGP_ENTRY")
698
+ if (
699
+ not isinstance(self.component_id, str)
700
+ or not self.component_id
701
+ or any(symbol.isspace() for symbol in self.component_id)
702
+ ):
703
+ raise ConfigurationError(
704
+ "AGP component_id must be non-empty.", code="INVALID_AGP_ENTRY"
705
+ )
706
+ if not isinstance(self.component_interval, Interval) or len(self.component_interval) == 0:
707
+ raise ConfigurationError(
708
+ "AGP component interval must be non-empty.", code="INVALID_AGP_ENTRY"
709
+ )
710
+ if len(self.component_interval) != len(self.object_interval):
711
+ raise ConfigurationError(
712
+ "AGP component and object spans must match.", code="INVALID_AGP_ENTRY"
713
+ )
714
+ if not isinstance(self.orientation, str) or self.orientation not in {
715
+ "+",
716
+ "-",
717
+ "?",
718
+ "0",
719
+ "na",
720
+ }:
721
+ raise ConfigurationError("Invalid AGP orientation.", code="INVALID_AGP_ENTRY")
722
+
723
+ def to_dict(self) -> dict[str, Any]:
724
+ return cast(dict[str, Any], to_json_compatible(self))
725
+
726
+
727
+ @dataclass(frozen=True, slots=True)
728
+ class AGPGap:
729
+ object_id: str
730
+ object_interval: Interval
731
+ part_number: int
732
+ component_type: str
733
+ gap: Gap
734
+ gap_type: str
735
+ linkage: bool
736
+ linkage_evidence: tuple[str, ...]
737
+
738
+ def __post_init__(self) -> None:
739
+ if (
740
+ not isinstance(self.object_id, str)
741
+ or not self.object_id
742
+ or any(symbol.isspace() for symbol in self.object_id)
743
+ ):
744
+ raise ConfigurationError("AGP object_id must be non-empty.", code="INVALID_AGP_ENTRY")
745
+ if not isinstance(self.object_interval, Interval) or len(self.object_interval) == 0:
746
+ raise ConfigurationError(
747
+ "AGP object interval must be non-empty.", code="INVALID_AGP_ENTRY"
748
+ )
749
+ if (
750
+ isinstance(self.part_number, bool)
751
+ or not isinstance(self.part_number, int)
752
+ or self.part_number < 1
753
+ ):
754
+ raise ConfigurationError("AGP part_number must be positive.", code="INVALID_AGP_ENTRY")
755
+ if (
756
+ not isinstance(self.component_type, str)
757
+ or self.component_type not in _GAP_TYPES
758
+ or not isinstance(self.gap, Gap)
759
+ ):
760
+ raise ConfigurationError("Invalid AGP gap entry.", code="INVALID_AGP_ENTRY")
761
+ if not isinstance(self.gap_type, str) or self.gap_type not in _AGP_GAP_TYPES:
762
+ raise ConfigurationError("Invalid AGP gap type.", code="INVALID_AGP_ENTRY")
763
+ if not isinstance(self.linkage, bool):
764
+ raise ConfigurationError("AGP linkage must be a boolean.", code="INVALID_AGP_ENTRY")
765
+ if not isinstance(self.linkage_evidence, tuple) or any(
766
+ not isinstance(item, str) or item not in _AGP_LINKAGE_EVIDENCE
767
+ for item in self.linkage_evidence
768
+ ):
769
+ raise ConfigurationError("Invalid AGP linkage evidence.", code="INVALID_AGP_ENTRY")
770
+ if not self.linkage and self.linkage_evidence:
771
+ raise ConfigurationError(
772
+ "Unlinked AGP gaps cannot carry linkage evidence.", code="INVALID_AGP_ENTRY"
773
+ )
774
+ if self.linkage and not self.linkage_evidence:
775
+ raise ConfigurationError(
776
+ "Linked AGP gaps require linkage evidence.", code="INVALID_AGP_ENTRY"
777
+ )
778
+ if self.component_type == "N" and self.gap.length != len(self.object_interval):
779
+ raise ConfigurationError(
780
+ "Known AGP gap span and length must match.", code="INVALID_AGP_ENTRY"
781
+ )
782
+ if self.component_type == "U" and self.gap.length is not None:
783
+ raise ConfigurationError(
784
+ "Unknown AGP gaps must use Gap(length=None).", code="INVALID_AGP_ENTRY"
785
+ )
786
+ if self.component_type == "U" and len(self.object_interval) != 100:
787
+ raise ConfigurationError(
788
+ "Unknown AGP gaps must occupy exactly 100 bases.",
789
+ code="INVALID_AGP_ENTRY",
790
+ )
791
+ if self.gap.crossable is not self.linkage or self.gap.evidence != self.linkage_evidence:
792
+ raise ConfigurationError(
793
+ "AGP Gap linkage fields must agree with the embedded Gap.",
794
+ code="INVALID_AGP_ENTRY",
795
+ )
796
+ if self.gap.kind is not _GAP_KIND_BY_AGP[self.gap_type]:
797
+ raise ConfigurationError(
798
+ "AGP gap_type must agree with the embedded Gap kind.",
799
+ code="INVALID_AGP_ENTRY",
800
+ )
801
+
802
+ def to_dict(self) -> dict[str, Any]:
803
+ return cast(dict[str, Any], to_json_compatible(self))
804
+
805
+
806
+ AGPEntry = AGPComponent | AGPGap
807
+
808
+
809
+ @dataclass(frozen=True, slots=True)
810
+ class AGPDocument:
811
+ entries: tuple[AGPEntry, ...]
812
+ headers: tuple[str, ...] = ("##agp-version 2.1",)
813
+
814
+ def __post_init__(self) -> None:
815
+ if not isinstance(self.entries, tuple) or not isinstance(self.headers, tuple):
816
+ raise ConfigurationError(
817
+ "AGP document entries and headers must be tuples.",
818
+ code="INVALID_AGP_DOCUMENT",
819
+ )
820
+ if any(not isinstance(entry, (AGPComponent, AGPGap)) for entry in self.entries):
821
+ raise ConfigurationError("Invalid AGP document entry.", code="INVALID_AGP_DOCUMENT")
822
+ if any(
823
+ not isinstance(header, str)
824
+ or not header.startswith("#")
825
+ or "\n" in header
826
+ or "\r" in header
827
+ for header in self.headers
828
+ ):
829
+ raise ConfigurationError("Invalid AGP document header.", code="INVALID_AGP_DOCUMENT")
830
+ versions = tuple(header for header in self.headers if header.startswith("##agp-version"))
831
+ if len(versions) > 1 or versions not in {(), ("##agp-version 2.1",)}:
832
+ raise ConfigurationError(
833
+ "AGP version pragma must be absent or exactly '##agp-version 2.1'.",
834
+ code="INVALID_AGP_DOCUMENT",
835
+ )
836
+
837
+ def to_dict(self) -> dict[str, Any]:
838
+ return cast(dict[str, Any], to_json_compatible(self))
839
+
840
+
841
+ def _positive_integer(value: str, *, field: str, line_number: int) -> int:
842
+ try:
843
+ result = int(value)
844
+ except ValueError as exc:
845
+ raise InputFormatError(
846
+ "AGP coordinate fields must be integers.",
847
+ code="INVALID_AGP_COORDINATE",
848
+ context={"line_number": line_number, "field": field},
849
+ ) from exc
850
+ if result < 1:
851
+ raise InputFormatError(
852
+ "AGP coordinate fields must be positive.",
853
+ code="INVALID_AGP_COORDINATE",
854
+ context={"line_number": line_number, "field": field},
855
+ )
856
+ return result
857
+
858
+
859
+ def read_agp(
860
+ source: AnnotationSource,
861
+ *,
862
+ max_records: int = 1_000_000,
863
+ max_line_length: int = 1_000_000,
864
+ max_header_lines: int = 10_000,
865
+ ) -> AGPDocument:
866
+ """Read AGP 2.1 components and explicit gaps with continuity validation."""
867
+
868
+ _validate_limit(max_records, "max_records")
869
+ _validate_limit(max_line_length, "max_line_length")
870
+ _validate_limit(max_header_lines, "max_header_lines")
871
+ entries: list[object] = []
872
+ headers: list[str] = []
873
+ state: dict[str, tuple[int, int]] = {}
874
+ active_object: str | None = None
875
+ completed_objects: set[str] = set()
876
+ seen_body = False
877
+ with open_text_source(source) as handle:
878
+ for line_number, line in checked_lines(handle, max_line_length=max_line_length):
879
+ if not line:
880
+ continue
881
+ if line.startswith("#"):
882
+ if seen_body:
883
+ raise InputFormatError(
884
+ "AGP comments are allowed only before the body.",
885
+ code="INVALID_AGP_HEADER_POSITION",
886
+ context={"line_number": line_number},
887
+ )
888
+ if line.startswith("##agp-version") and line != "##agp-version 2.1":
889
+ raise InputFormatError(
890
+ "Only AGP version 2.1 is supported.",
891
+ code="INVALID_AGP_VERSION",
892
+ context={"line_number": line_number},
893
+ )
894
+ _bounded_header(headers, line, max_records=max_header_lines)
895
+ continue
896
+ seen_body = True
897
+ columns = line.split("\t")
898
+ if len(columns) != 9:
899
+ raise InputFormatError(
900
+ "AGP rows must contain exactly nine tab-separated columns.",
901
+ code="INVALID_AGP_COLUMNS",
902
+ context={"line_number": line_number, "column_count": len(columns)},
903
+ )
904
+ object_id, begin_raw, end_raw, part_raw, component_type = columns[:5]
905
+ if not object_id:
906
+ raise InputFormatError(
907
+ "AGP object ID must be non-empty.",
908
+ code="INVALID_AGP_FIELD",
909
+ context={"line_number": line_number},
910
+ )
911
+ if active_object != object_id:
912
+ if object_id in completed_objects:
913
+ raise InputFormatError(
914
+ "AGP object rows must form one contiguous block.",
915
+ code="INVALID_AGP_CONTINUITY",
916
+ context={"line_number": line_number, "object_id": object_id},
917
+ )
918
+ if active_object is not None:
919
+ completed_objects.add(active_object)
920
+ active_object = object_id
921
+ begin = _positive_integer(begin_raw, field="object_beg", line_number=line_number)
922
+ end = _positive_integer(end_raw, field="object_end", line_number=line_number)
923
+ part_number = _positive_integer(part_raw, field="part_number", line_number=line_number)
924
+ if end < begin:
925
+ raise InputFormatError(
926
+ "AGP object interval end precedes begin.",
927
+ code="INVALID_AGP_COORDINATE",
928
+ context={"line_number": line_number},
929
+ )
930
+ expected = state.get(object_id, (1, 1))
931
+ if (begin, part_number) != expected:
932
+ raise InputFormatError(
933
+ "AGP rows for each object must be contiguous with sequential part numbers.",
934
+ code="INVALID_AGP_CONTINUITY",
935
+ context={
936
+ "line_number": line_number,
937
+ "expected_begin": expected[0],
938
+ "expected_part_number": expected[1],
939
+ },
940
+ )
941
+ state[object_id] = (end + 1, part_number + 1)
942
+ object_interval = Interval(begin - 1, end)
943
+ if component_type in _COMPONENT_TYPES:
944
+ component_id, component_begin_raw, component_end_raw, orientation = columns[5:]
945
+ if not component_id:
946
+ raise InputFormatError(
947
+ "AGP component ID must be non-empty.",
948
+ code="INVALID_AGP_FIELD",
949
+ context={"line_number": line_number},
950
+ )
951
+ component_begin = _positive_integer(
952
+ component_begin_raw, field="component_beg", line_number=line_number
953
+ )
954
+ component_end = _positive_integer(
955
+ component_end_raw, field="component_end", line_number=line_number
956
+ )
957
+ if component_end < component_begin or component_end - component_begin + 1 != len(
958
+ object_interval
959
+ ):
960
+ raise InputFormatError(
961
+ "AGP component span must equal its object span.",
962
+ code="INVALID_AGP_COMPONENT_SPAN",
963
+ context={"line_number": line_number},
964
+ )
965
+ if orientation not in {"+", "-", "?", "0", "na"}:
966
+ raise InputFormatError(
967
+ "Invalid AGP component orientation.",
968
+ code="INVALID_AGP_ORIENTATION",
969
+ context={"line_number": line_number},
970
+ )
971
+ entry: AGPEntry = AGPComponent(
972
+ object_id,
973
+ object_interval,
974
+ part_number,
975
+ component_type,
976
+ component_id,
977
+ Interval(component_begin - 1, component_end),
978
+ orientation,
979
+ )
980
+ elif component_type in _GAP_TYPES:
981
+ gap_length_raw, gap_type, linkage_raw, evidence_raw = columns[5:]
982
+ gap_length = _positive_integer(
983
+ gap_length_raw, field="gap_length", line_number=line_number
984
+ )
985
+ if gap_length != len(object_interval):
986
+ raise InputFormatError(
987
+ "AGP gap length must equal its object span.",
988
+ code="INVALID_AGP_GAP_SPAN",
989
+ context={"line_number": line_number},
990
+ )
991
+ if component_type == "U" and gap_length != 100:
992
+ raise InputFormatError(
993
+ "AGP U gaps must use length 100.",
994
+ code="INVALID_AGP_GAP_SPAN",
995
+ context={"line_number": line_number},
996
+ )
997
+ if linkage_raw not in {"yes", "no"}:
998
+ raise InputFormatError(
999
+ "AGP linkage must be yes or no.",
1000
+ code="INVALID_AGP_LINKAGE",
1001
+ context={"line_number": line_number},
1002
+ )
1003
+ evidence = () if evidence_raw == "na" else tuple(evidence_raw.split(";"))
1004
+ if any(not item for item in evidence):
1005
+ raise InputFormatError(
1006
+ "AGP linkage evidence is malformed.",
1007
+ code="INVALID_AGP_EVIDENCE",
1008
+ context={"line_number": line_number},
1009
+ )
1010
+ if gap_type not in _AGP_GAP_TYPES:
1011
+ raise InputFormatError(
1012
+ "Invalid AGP gap type.",
1013
+ code="INVALID_AGP_GAP_TYPE",
1014
+ context={"line_number": line_number, "gap_type": gap_type},
1015
+ )
1016
+ if any(item not in _AGP_LINKAGE_EVIDENCE for item in evidence):
1017
+ raise InputFormatError(
1018
+ "Invalid AGP linkage evidence.",
1019
+ code="INVALID_AGP_EVIDENCE",
1020
+ context={"line_number": line_number},
1021
+ )
1022
+ if linkage_raw == "no" and evidence:
1023
+ raise InputFormatError(
1024
+ "Unlinked AGP gaps must use 'na' linkage evidence.",
1025
+ code="INVALID_AGP_EVIDENCE",
1026
+ context={"line_number": line_number},
1027
+ )
1028
+ if linkage_raw == "yes" and not evidence:
1029
+ raise InputFormatError(
1030
+ "Linked AGP gaps require linkage evidence.",
1031
+ code="INVALID_AGP_EVIDENCE",
1032
+ context={"line_number": line_number},
1033
+ )
1034
+ valid_linkage = (
1035
+ (gap_type in {"scaffold", "contamination"} and linkage_raw == "yes")
1036
+ or (
1037
+ gap_type
1038
+ in {"contig", "centromere", "short_arm", "heterochromatin", "telomere"}
1039
+ and linkage_raw == "no"
1040
+ )
1041
+ or gap_type == "repeat"
1042
+ )
1043
+ if not valid_linkage or ("unspecified" in evidence and gap_type != "contamination"):
1044
+ raise InputFormatError(
1045
+ "AGP gap type, linkage, and evidence combination is invalid.",
1046
+ code="INVALID_AGP_LINKAGE_COMBINATION",
1047
+ context={"line_number": line_number, "gap_type": gap_type},
1048
+ )
1049
+ gap = Gap(
1050
+ None if component_type == "U" else gap_length,
1051
+ kind=_GAP_KIND_BY_AGP.get(gap_type, GapKind.UNKNOWN),
1052
+ crossable=linkage_raw == "yes",
1053
+ evidence=evidence,
1054
+ metadata={"agp_gap_type": gap_type},
1055
+ )
1056
+ entry = AGPGap(
1057
+ object_id,
1058
+ object_interval,
1059
+ part_number,
1060
+ component_type,
1061
+ gap,
1062
+ gap_type,
1063
+ linkage_raw == "yes",
1064
+ evidence,
1065
+ )
1066
+ else:
1067
+ raise InputFormatError(
1068
+ "Unknown AGP component type.",
1069
+ code="INVALID_AGP_COMPONENT_TYPE",
1070
+ context={"line_number": line_number, "component_type": component_type},
1071
+ )
1072
+ _bounded_append(entries, entry, max_records=max_records)
1073
+ return AGPDocument(
1074
+ cast(tuple[AGPEntry, ...], tuple(entries)), tuple(headers) or ("##agp-version 2.1",)
1075
+ )
1076
+
1077
+
1078
+ def write_agp(
1079
+ document: AGPDocument | Iterable[AGPEntry],
1080
+ target: AnnotationTarget,
1081
+ *,
1082
+ overwrite: bool = False,
1083
+ create_parents: bool = False,
1084
+ ) -> int:
1085
+ """Write deterministic AGP rows after validating object continuity."""
1086
+
1087
+ entries: Iterable[AGPEntry]
1088
+ headers: tuple[str, ...]
1089
+ if isinstance(document, AGPDocument):
1090
+ entries, headers = document.entries, document.headers
1091
+ else:
1092
+ entries, headers = document, ("##agp-version 2.1",)
1093
+
1094
+ def writer(handle: TextIO) -> int:
1095
+ for header in headers:
1096
+ if not header.startswith("#"):
1097
+ raise InputFormatError(
1098
+ "AGP headers must begin with '#'.", code="INVALID_AGP_HEADER"
1099
+ )
1100
+ handle.write(header + "\n")
1101
+ state: dict[str, tuple[int, int]] = {}
1102
+ count = 0
1103
+ for entry in entries:
1104
+ if not isinstance(entry, (AGPComponent, AGPGap)):
1105
+ raise TypeError("AGP entries must be AGPComponent or AGPGap objects.")
1106
+ begin, end = entry.object_interval.start + 1, entry.object_interval.end
1107
+ expected = state.get(entry.object_id, (1, 1))
1108
+ if (begin, entry.part_number) != expected:
1109
+ raise InputFormatError(
1110
+ "AGP entries must be contiguous and sequential.", code="INVALID_AGP_CONTINUITY"
1111
+ )
1112
+ state[entry.object_id] = (end + 1, entry.part_number + 1)
1113
+ prefix = [
1114
+ entry.object_id,
1115
+ str(begin),
1116
+ str(end),
1117
+ str(entry.part_number),
1118
+ entry.component_type,
1119
+ ]
1120
+ if isinstance(entry, AGPComponent):
1121
+ if entry.component_type not in _COMPONENT_TYPES or len(
1122
+ entry.component_interval
1123
+ ) != len(entry.object_interval):
1124
+ raise InputFormatError(
1125
+ "Invalid AGP component entry.", code="INVALID_AGP_COMPONENT_SPAN"
1126
+ )
1127
+ suffix = [
1128
+ entry.component_id,
1129
+ str(entry.component_interval.start + 1),
1130
+ str(entry.component_interval.end),
1131
+ entry.orientation,
1132
+ ]
1133
+ else:
1134
+ if entry.component_type not in _GAP_TYPES:
1135
+ raise InputFormatError(
1136
+ "Invalid AGP gap component type.", code="INVALID_AGP_COMPONENT_TYPE"
1137
+ )
1138
+ gap_length = len(entry.object_interval)
1139
+ if entry.gap.length is not None and entry.gap.length != gap_length:
1140
+ raise InputFormatError(
1141
+ "AGP gap length does not equal object span.", code="INVALID_AGP_GAP_SPAN"
1142
+ )
1143
+ evidence = ";".join(entry.linkage_evidence) if entry.linkage_evidence else "na"
1144
+ suffix = [
1145
+ str(gap_length),
1146
+ entry.gap_type,
1147
+ "yes" if entry.linkage else "no",
1148
+ evidence,
1149
+ ]
1150
+ fields = prefix + suffix
1151
+ if any(
1152
+ not value or "\t" in value or "\n" in value or "\r" in value for value in fields
1153
+ ):
1154
+ raise InputFormatError(
1155
+ "AGP fields must be non-empty and single-line.", code="INVALID_AGP_FIELD"
1156
+ )
1157
+ handle.write("\t".join(fields) + "\n")
1158
+ count += 1
1159
+ return count
1160
+
1161
+ return write_text_path(target, writer, overwrite=overwrite, create_parents=create_parents)
1162
+
1163
+
1164
+ __all__ = [
1165
+ "AGPComponent",
1166
+ "AGPDocument",
1167
+ "AGPEntry",
1168
+ "AGPGap",
1169
+ "AnnotationDocument",
1170
+ "AnnotationEntry",
1171
+ "AnnotationSource",
1172
+ "AnnotationTarget",
1173
+ "read_agp",
1174
+ "read_bed",
1175
+ "read_gff3",
1176
+ "write_agp",
1177
+ "write_bed",
1178
+ "write_gff3",
1179
+ ]