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/annotations.py
ADDED
|
@@ -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
|
+
]
|