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.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- 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"]
|