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/core/facade.py
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
"""One user-facing entry point for one or many DNA records."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Iterator, Mapping
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from itertools import islice
|
|
8
|
+
from typing import TYPE_CHECKING, TypeAlias, cast
|
|
9
|
+
|
|
10
|
+
from dnakit.core._json import FrozenDict
|
|
11
|
+
from dnakit.core.collection import DNASet
|
|
12
|
+
from dnakit.core.coordinates import CompoundLocation, Interval, Location, UnresolvedLocation
|
|
13
|
+
from dnakit.core.enums import DNAAlphabet, Strandedness, Topology
|
|
14
|
+
from dnakit.core.feature import DNAFeature
|
|
15
|
+
from dnakit.core.gap import Gap
|
|
16
|
+
from dnakit.core.record import DNARecord
|
|
17
|
+
from dnakit.core.sequence import DNASequence
|
|
18
|
+
from dnakit.exceptions import (
|
|
19
|
+
ConfigurationError,
|
|
20
|
+
FeatureError,
|
|
21
|
+
InvalidAlphabetError,
|
|
22
|
+
SequenceError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from dnakit.standardize.config import NormalizationConfig
|
|
27
|
+
from dnakit.standardize.results import NormalizationResult
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
FeatureInput: TypeAlias = DNAFeature | Mapping[str, object]
|
|
31
|
+
_MAX_DEFAULT_RECORDS = 100_000
|
|
32
|
+
_RECORD_SPEC_KEYS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"sequence",
|
|
35
|
+
"parts",
|
|
36
|
+
"id",
|
|
37
|
+
"description",
|
|
38
|
+
"features",
|
|
39
|
+
"metadata",
|
|
40
|
+
"letter_annotations",
|
|
41
|
+
"alphabet",
|
|
42
|
+
"topology",
|
|
43
|
+
"strandedness",
|
|
44
|
+
"normalization_config",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
_FEATURE_SPEC_KEYS = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"type",
|
|
50
|
+
"location",
|
|
51
|
+
"start",
|
|
52
|
+
"end",
|
|
53
|
+
"id",
|
|
54
|
+
"strand",
|
|
55
|
+
"label",
|
|
56
|
+
"score",
|
|
57
|
+
"phase",
|
|
58
|
+
"qualifiers",
|
|
59
|
+
"source",
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class _RecordOptions:
|
|
66
|
+
id: str | None = None
|
|
67
|
+
description: str | None = None
|
|
68
|
+
features: tuple[DNAFeature, ...] | None = None
|
|
69
|
+
metadata: Mapping[str, object] | None = None
|
|
70
|
+
letter_annotations: Mapping[str, tuple[int | float, ...]] | None = None
|
|
71
|
+
alphabet: DNAAlphabet | str | None = None
|
|
72
|
+
topology: Topology | str | None = None
|
|
73
|
+
strandedness: Strandedness | str | None = None
|
|
74
|
+
normalization_config: NormalizationConfig | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, init=False)
|
|
78
|
+
class DNA:
|
|
79
|
+
"""A stable facade over one or many DNA records.
|
|
80
|
+
|
|
81
|
+
Raw text is normalized with a retained audit. A mapping can describe one
|
|
82
|
+
record, while an iterable can contain raw sequences, records, or record
|
|
83
|
+
mappings. Existing ``DNASequence``, ``DNARecord`` and ``DNASet`` values are
|
|
84
|
+
accepted so advanced and legacy code can enter the same public path.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
_dataset: DNASet
|
|
88
|
+
normalizations: tuple[NormalizationResult | None, ...]
|
|
89
|
+
|
|
90
|
+
__hash__ = None # type: ignore[assignment]
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
data: object,
|
|
95
|
+
*,
|
|
96
|
+
id: str | None = None,
|
|
97
|
+
description: str | None = None,
|
|
98
|
+
features: FeatureInput | Iterable[FeatureInput] | None = None,
|
|
99
|
+
metadata: Mapping[str, object] | None = None,
|
|
100
|
+
letter_annotations: Mapping[str, Iterable[int | float]] | None = None,
|
|
101
|
+
alphabet: DNAAlphabet | str | None = None,
|
|
102
|
+
topology: Topology | str | None = None,
|
|
103
|
+
strandedness: Strandedness | str | None = None,
|
|
104
|
+
normalization_config: NormalizationConfig | None = None,
|
|
105
|
+
name: str | None = None,
|
|
106
|
+
source: str | None = None,
|
|
107
|
+
version: str | None = None,
|
|
108
|
+
collection_metadata: Mapping[str, object] | None = None,
|
|
109
|
+
max_records: int = _MAX_DEFAULT_RECORDS,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Build one consistent object from one sequence or a bounded collection."""
|
|
112
|
+
|
|
113
|
+
_validate_max_records(max_records)
|
|
114
|
+
defaults = _RecordOptions(
|
|
115
|
+
id=id,
|
|
116
|
+
description=description,
|
|
117
|
+
features=None if features is None else _coerce_features(features),
|
|
118
|
+
metadata=metadata,
|
|
119
|
+
letter_annotations=_materialize_annotations(letter_annotations),
|
|
120
|
+
alphabet=alphabet,
|
|
121
|
+
topology=topology,
|
|
122
|
+
strandedness=strandedness,
|
|
123
|
+
normalization_config=normalization_config,
|
|
124
|
+
)
|
|
125
|
+
items, inherited_normalizations, inherited_dataset = _materialize_data(
|
|
126
|
+
data, max_records=max_records
|
|
127
|
+
)
|
|
128
|
+
if len(items) > 1 and defaults.id is not None:
|
|
129
|
+
raise ConfigurationError(
|
|
130
|
+
"A shared id cannot identify multiple DNA records.",
|
|
131
|
+
code="MULTIPLE_DNA_SHARED_ID",
|
|
132
|
+
hint="Put a distinct 'id' in each record mapping or omit it for generated IDs.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
records: list[DNARecord] = []
|
|
136
|
+
audits: list[NormalizationResult | None] = []
|
|
137
|
+
for index, item in enumerate(items):
|
|
138
|
+
inherited_audit = (
|
|
139
|
+
inherited_normalizations[index] if index < len(inherited_normalizations) else None
|
|
140
|
+
)
|
|
141
|
+
record, audit = _build_record(
|
|
142
|
+
item,
|
|
143
|
+
index=index,
|
|
144
|
+
defaults=defaults,
|
|
145
|
+
inherited_audit=inherited_audit,
|
|
146
|
+
)
|
|
147
|
+
records.append(record)
|
|
148
|
+
audits.append(audit)
|
|
149
|
+
|
|
150
|
+
resolved_name = name if name is not None else getattr(inherited_dataset, "name", None)
|
|
151
|
+
resolved_source = (
|
|
152
|
+
source if source is not None else getattr(inherited_dataset, "source", None)
|
|
153
|
+
)
|
|
154
|
+
resolved_version = (
|
|
155
|
+
version if version is not None else getattr(inherited_dataset, "version", None)
|
|
156
|
+
)
|
|
157
|
+
resolved_collection_metadata = (
|
|
158
|
+
collection_metadata
|
|
159
|
+
if collection_metadata is not None
|
|
160
|
+
else getattr(inherited_dataset, "metadata", None)
|
|
161
|
+
)
|
|
162
|
+
dataset = DNASet(
|
|
163
|
+
records,
|
|
164
|
+
name=resolved_name,
|
|
165
|
+
source=resolved_source,
|
|
166
|
+
version=resolved_version,
|
|
167
|
+
metadata=resolved_collection_metadata,
|
|
168
|
+
)
|
|
169
|
+
object.__setattr__(self, "_dataset", dataset)
|
|
170
|
+
object.__setattr__(self, "normalizations", tuple(audits))
|
|
171
|
+
|
|
172
|
+
def __len__(self) -> int:
|
|
173
|
+
"""Return the number of contained DNA records."""
|
|
174
|
+
|
|
175
|
+
return len(self._dataset)
|
|
176
|
+
|
|
177
|
+
def __iter__(self) -> Iterator[DNARecord]:
|
|
178
|
+
"""Iterate over compatible internal records for dataset algorithms."""
|
|
179
|
+
|
|
180
|
+
return iter(self._dataset)
|
|
181
|
+
|
|
182
|
+
def __getitem__(self, index: int | slice) -> DNA:
|
|
183
|
+
"""Select one or more records while staying in the public DNA type."""
|
|
184
|
+
|
|
185
|
+
indices = range(len(self))[index]
|
|
186
|
+
if isinstance(indices, int):
|
|
187
|
+
return self._derive(((indices, self.records[indices]),))
|
|
188
|
+
return self._derive((position, self.records[position]) for position in indices)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def records(self) -> tuple[DNARecord, ...]:
|
|
192
|
+
"""The immutable compatible records used by advanced APIs."""
|
|
193
|
+
|
|
194
|
+
return self._dataset.records
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def record_count(self) -> int:
|
|
198
|
+
"""Number of contained records."""
|
|
199
|
+
|
|
200
|
+
return len(self)
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def is_single(self) -> bool:
|
|
204
|
+
"""Whether this object contains exactly one DNA record."""
|
|
205
|
+
|
|
206
|
+
return len(self) == 1
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def record(self) -> DNARecord:
|
|
210
|
+
"""Return the only record, rejecting empty or multi-record data."""
|
|
211
|
+
|
|
212
|
+
if not self.is_single:
|
|
213
|
+
raise SequenceError(
|
|
214
|
+
"This operation requires exactly one DNA record.",
|
|
215
|
+
code="SINGLE_DNA_REQUIRED",
|
|
216
|
+
context={"record_count": len(self)},
|
|
217
|
+
hint="Select one record or use a dataset-level operation.",
|
|
218
|
+
)
|
|
219
|
+
return self.records[0]
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def sequence(self) -> DNASequence:
|
|
223
|
+
"""Return the sequence of a single-record object."""
|
|
224
|
+
|
|
225
|
+
return self.record.sequence
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def id(self) -> str:
|
|
229
|
+
"""Return the identifier of a single-record object."""
|
|
230
|
+
|
|
231
|
+
return self.record.id
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def ids(self) -> tuple[str, ...]:
|
|
235
|
+
"""Return every record identifier in stable input order."""
|
|
236
|
+
|
|
237
|
+
return self._dataset.ids
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def description(self) -> str:
|
|
241
|
+
"""Return the description of a single-record object."""
|
|
242
|
+
|
|
243
|
+
return self.record.description
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def features(self) -> tuple[DNAFeature, ...]:
|
|
247
|
+
"""Return the features of a single-record object."""
|
|
248
|
+
|
|
249
|
+
return self.record.features
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def metadata(self) -> Mapping[str, object]:
|
|
253
|
+
"""Return record metadata for one record, otherwise collection metadata."""
|
|
254
|
+
|
|
255
|
+
return self.record.metadata if self.is_single else self._dataset.metadata
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def collection_metadata(self) -> FrozenDict:
|
|
259
|
+
"""Return metadata attached to the collection itself."""
|
|
260
|
+
|
|
261
|
+
return self._dataset.metadata
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def name(self) -> str | None:
|
|
265
|
+
return self._dataset.name
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def source(self) -> str | None:
|
|
269
|
+
return self._dataset.source
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def version(self) -> str | None:
|
|
273
|
+
return self._dataset.version
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def normalization(self) -> NormalizationResult | None:
|
|
277
|
+
"""Return the retained normalization audit for one record."""
|
|
278
|
+
|
|
279
|
+
_ = self.record
|
|
280
|
+
return self.normalizations[0]
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def symbols(self) -> str:
|
|
284
|
+
"""Convenience access to normalized symbols for one record."""
|
|
285
|
+
|
|
286
|
+
return self.sequence.symbols
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def symbol_length(self) -> int:
|
|
290
|
+
return self.sequence.symbol_length
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def parts(self) -> tuple[str | Gap, ...]:
|
|
294
|
+
return self.sequence.parts
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def is_gapped(self) -> bool:
|
|
298
|
+
return self.sequence.is_gapped
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def has_unknown_length(self) -> bool:
|
|
302
|
+
return self.sequence.has_unknown_length
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def canonical_base_count(self) -> int:
|
|
306
|
+
return self.sequence.canonical_base_count
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def ambiguity_count(self) -> int:
|
|
310
|
+
return self.sequence.ambiguity_count
|
|
311
|
+
|
|
312
|
+
@property
|
|
313
|
+
def coordinate_span(self) -> int | None:
|
|
314
|
+
return self.sequence.coordinate_span
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def alphabet(self) -> DNAAlphabet:
|
|
318
|
+
return self.sequence.alphabet
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def topology(self) -> Topology:
|
|
322
|
+
return self.sequence.topology
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
def strandedness(self) -> Strandedness:
|
|
326
|
+
return self.sequence.strandedness
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def dataset(self) -> DNASet:
|
|
330
|
+
"""Return the compatible immutable dataset for advanced APIs."""
|
|
331
|
+
|
|
332
|
+
return self._dataset
|
|
333
|
+
|
|
334
|
+
def _derive(self, indexed_records: Iterable[tuple[int, DNARecord]]) -> DNA:
|
|
335
|
+
"""Build a facade result while retaining audits for unchanged source sequences."""
|
|
336
|
+
|
|
337
|
+
selected = tuple(indexed_records)
|
|
338
|
+
indices = tuple(index for index, _ in selected)
|
|
339
|
+
if any(
|
|
340
|
+
isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < len(self)
|
|
341
|
+
for index in indices
|
|
342
|
+
):
|
|
343
|
+
raise IndexError("A derived DNA record index is outside the source object.")
|
|
344
|
+
if len(indices) != len(set(indices)):
|
|
345
|
+
raise ValueError("A derived DNA object cannot select one source record twice.")
|
|
346
|
+
dataset = DNASet(
|
|
347
|
+
(record for _, record in selected),
|
|
348
|
+
name=self.name,
|
|
349
|
+
source=self.source,
|
|
350
|
+
version=self.version,
|
|
351
|
+
metadata=self.collection_metadata,
|
|
352
|
+
)
|
|
353
|
+
derived = object.__new__(DNA)
|
|
354
|
+
object.__setattr__(derived, "_dataset", dataset)
|
|
355
|
+
object.__setattr__(
|
|
356
|
+
derived, "normalizations", tuple(self.normalizations[i] for i in indices)
|
|
357
|
+
)
|
|
358
|
+
return derived
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
SingleDNAInput: TypeAlias = DNA | DNASequence | DNARecord
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def resolve_single_dna(value: object) -> tuple[DNASequence, DNARecord | None]:
|
|
365
|
+
"""Resolve one facade/record/sequence without exposing constructor choices."""
|
|
366
|
+
|
|
367
|
+
if isinstance(value, DNA):
|
|
368
|
+
record = value.record
|
|
369
|
+
return record.sequence, record
|
|
370
|
+
if isinstance(value, DNARecord):
|
|
371
|
+
return value.sequence, value
|
|
372
|
+
if isinstance(value, DNASequence):
|
|
373
|
+
return value, None
|
|
374
|
+
raise TypeError("value must be DNA, DNARecord, or DNASequence.")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _validate_max_records(value: int) -> None:
|
|
378
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
379
|
+
raise ConfigurationError(
|
|
380
|
+
"max_records must be a positive integer.",
|
|
381
|
+
code="INVALID_DNA_RECORD_LIMIT",
|
|
382
|
+
context={"max_records": value},
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _materialize_data(
|
|
387
|
+
data: object,
|
|
388
|
+
*,
|
|
389
|
+
max_records: int,
|
|
390
|
+
) -> tuple[tuple[object, ...], tuple[NormalizationResult | None, ...], DNASet | None]:
|
|
391
|
+
if isinstance(data, DNA):
|
|
392
|
+
return data.records, data.normalizations, data.dataset
|
|
393
|
+
if isinstance(data, DNASet):
|
|
394
|
+
return data.records, (None,) * len(data), data
|
|
395
|
+
if isinstance(data, (str, bytes, DNASequence, DNARecord, Mapping)):
|
|
396
|
+
return (data,), (), None
|
|
397
|
+
if not isinstance(data, Iterable):
|
|
398
|
+
raise TypeError(
|
|
399
|
+
"DNA data must be sequence text, a record mapping, a DNA object, or an iterable."
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
items = tuple(islice(data, max_records + 1))
|
|
403
|
+
if len(items) > max_records:
|
|
404
|
+
raise ConfigurationError(
|
|
405
|
+
"DNA input exceeds max_records.",
|
|
406
|
+
code="DNA_RECORD_LIMIT_EXCEEDED",
|
|
407
|
+
context={"max_records": max_records},
|
|
408
|
+
)
|
|
409
|
+
if any(isinstance(item, Gap) for item in items):
|
|
410
|
+
if any(not isinstance(item, (str, Gap)) for item in items):
|
|
411
|
+
raise TypeError("A gapped sequence may contain only text fragments and Gap objects.")
|
|
412
|
+
return (items,), (), None
|
|
413
|
+
return cast(tuple[object, ...], items), (), None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _build_record(
|
|
417
|
+
item: object,
|
|
418
|
+
*,
|
|
419
|
+
index: int,
|
|
420
|
+
defaults: _RecordOptions,
|
|
421
|
+
inherited_audit: NormalizationResult | None,
|
|
422
|
+
) -> tuple[DNARecord, NormalizationResult | None]:
|
|
423
|
+
raw, options = _record_spec(item, defaults=defaults)
|
|
424
|
+
source_record = raw if isinstance(raw, DNARecord) else None
|
|
425
|
+
sequence_input = source_record.sequence if source_record is not None else raw
|
|
426
|
+
|
|
427
|
+
sequence_changed = any(
|
|
428
|
+
value is not None
|
|
429
|
+
for value in (
|
|
430
|
+
options.alphabet,
|
|
431
|
+
options.topology,
|
|
432
|
+
options.strandedness,
|
|
433
|
+
options.normalization_config,
|
|
434
|
+
)
|
|
435
|
+
)
|
|
436
|
+
if source_record is not None and not sequence_changed:
|
|
437
|
+
sequence = source_record.sequence
|
|
438
|
+
audit = inherited_audit
|
|
439
|
+
else:
|
|
440
|
+
sequence, audit = _normalize_sequence(
|
|
441
|
+
sequence_input,
|
|
442
|
+
alphabet=options.alphabet,
|
|
443
|
+
topology=options.topology,
|
|
444
|
+
strandedness=options.strandedness,
|
|
445
|
+
config=options.normalization_config,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
record_id = options.id or (source_record.id if source_record is not None else None)
|
|
449
|
+
if record_id is None:
|
|
450
|
+
record_id = f"sequence_{index + 1}"
|
|
451
|
+
description = (
|
|
452
|
+
options.description
|
|
453
|
+
if options.description is not None
|
|
454
|
+
else source_record.description
|
|
455
|
+
if source_record is not None
|
|
456
|
+
else ""
|
|
457
|
+
)
|
|
458
|
+
features = (
|
|
459
|
+
options.features
|
|
460
|
+
if options.features is not None
|
|
461
|
+
else source_record.features
|
|
462
|
+
if source_record is not None
|
|
463
|
+
else ()
|
|
464
|
+
)
|
|
465
|
+
metadata = (
|
|
466
|
+
options.metadata
|
|
467
|
+
if options.metadata is not None
|
|
468
|
+
else source_record.metadata
|
|
469
|
+
if source_record is not None
|
|
470
|
+
else None
|
|
471
|
+
)
|
|
472
|
+
annotations = (
|
|
473
|
+
options.letter_annotations
|
|
474
|
+
if options.letter_annotations is not None
|
|
475
|
+
else source_record.letter_annotations
|
|
476
|
+
if source_record is not None
|
|
477
|
+
else None
|
|
478
|
+
)
|
|
479
|
+
return (
|
|
480
|
+
DNARecord(
|
|
481
|
+
sequence,
|
|
482
|
+
record_id,
|
|
483
|
+
description=description,
|
|
484
|
+
features=features,
|
|
485
|
+
metadata=metadata,
|
|
486
|
+
letter_annotations=annotations,
|
|
487
|
+
),
|
|
488
|
+
audit,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _record_spec(item: object, *, defaults: _RecordOptions) -> tuple[object, _RecordOptions]:
|
|
493
|
+
from dnakit.standardize.config import NormalizationConfig
|
|
494
|
+
|
|
495
|
+
if not isinstance(item, Mapping):
|
|
496
|
+
return item, defaults
|
|
497
|
+
unknown = set(item) - _RECORD_SPEC_KEYS
|
|
498
|
+
if unknown:
|
|
499
|
+
raise ConfigurationError(
|
|
500
|
+
"A DNA record mapping contains unknown fields.",
|
|
501
|
+
code="UNKNOWN_DNA_RECORD_FIELD",
|
|
502
|
+
context={"fields": sorted(str(key) for key in unknown)},
|
|
503
|
+
)
|
|
504
|
+
has_sequence = "sequence" in item
|
|
505
|
+
has_parts = "parts" in item
|
|
506
|
+
if has_sequence == has_parts:
|
|
507
|
+
raise ConfigurationError(
|
|
508
|
+
"A DNA record mapping must contain exactly one of 'sequence' or 'parts'.",
|
|
509
|
+
code="DNA_RECORD_SEQUENCE_REQUIRED",
|
|
510
|
+
)
|
|
511
|
+
raw = item["sequence"] if has_sequence else item["parts"]
|
|
512
|
+
|
|
513
|
+
local_features = (
|
|
514
|
+
_coerce_features(cast(object, item["features"]))
|
|
515
|
+
if "features" in item
|
|
516
|
+
else defaults.features
|
|
517
|
+
)
|
|
518
|
+
local_metadata = _optional_mapping(item.get("metadata"), name="metadata")
|
|
519
|
+
if "metadata" not in item:
|
|
520
|
+
local_metadata = defaults.metadata
|
|
521
|
+
elif defaults.metadata is not None and local_metadata is not None:
|
|
522
|
+
local_metadata = {**defaults.metadata, **local_metadata}
|
|
523
|
+
local_annotations = (
|
|
524
|
+
_materialize_annotations(
|
|
525
|
+
cast(Mapping[str, Iterable[int | float]] | None, item.get("letter_annotations"))
|
|
526
|
+
)
|
|
527
|
+
if "letter_annotations" in item
|
|
528
|
+
else defaults.letter_annotations
|
|
529
|
+
)
|
|
530
|
+
return raw, _RecordOptions(
|
|
531
|
+
id=cast(str | None, item.get("id", defaults.id)),
|
|
532
|
+
description=cast(str | None, item.get("description", defaults.description)),
|
|
533
|
+
features=local_features,
|
|
534
|
+
metadata=local_metadata,
|
|
535
|
+
letter_annotations=local_annotations,
|
|
536
|
+
alphabet=cast(DNAAlphabet | str | None, item.get("alphabet", defaults.alphabet)),
|
|
537
|
+
topology=cast(Topology | str | None, item.get("topology", defaults.topology)),
|
|
538
|
+
strandedness=cast(
|
|
539
|
+
Strandedness | str | None,
|
|
540
|
+
item.get("strandedness", defaults.strandedness),
|
|
541
|
+
),
|
|
542
|
+
normalization_config=cast(
|
|
543
|
+
NormalizationConfig | None,
|
|
544
|
+
item.get("normalization_config", defaults.normalization_config),
|
|
545
|
+
),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _normalize_sequence(
|
|
550
|
+
raw: object,
|
|
551
|
+
*,
|
|
552
|
+
alphabet: DNAAlphabet | str | None,
|
|
553
|
+
topology: Topology | str | None,
|
|
554
|
+
strandedness: Strandedness | str | None,
|
|
555
|
+
config: NormalizationConfig | None,
|
|
556
|
+
) -> tuple[DNASequence, NormalizationResult]:
|
|
557
|
+
from dnakit.standardize.config import NormalizationConfig
|
|
558
|
+
from dnakit.standardize.normalize import normalize
|
|
559
|
+
|
|
560
|
+
if not isinstance(raw, (str, bytes, DNASequence, Iterable)) or isinstance(raw, Mapping):
|
|
561
|
+
raise TypeError("Each DNA item must be text, DNASequence, DNARecord, or string/Gap parts.")
|
|
562
|
+
if config is not None and not isinstance(config, NormalizationConfig):
|
|
563
|
+
raise ConfigurationError(
|
|
564
|
+
"normalization_config must be NormalizationConfig or None.",
|
|
565
|
+
code="INVALID_DNA_NORMALIZATION_CONFIG",
|
|
566
|
+
)
|
|
567
|
+
resolved_alphabet = _coerce_alphabet(alphabet)
|
|
568
|
+
if config is not None and resolved_alphabet is not None:
|
|
569
|
+
if config.alphabet is not None and config.alphabet is not resolved_alphabet:
|
|
570
|
+
raise ConfigurationError(
|
|
571
|
+
"alphabet conflicts with normalization_config.alphabet.",
|
|
572
|
+
code="DNA_ALPHABET_CONFIG_CONFLICT",
|
|
573
|
+
)
|
|
574
|
+
config = replace(config, alphabet=resolved_alphabet)
|
|
575
|
+
elif config is None and resolved_alphabet is not None:
|
|
576
|
+
config = NormalizationConfig(alphabet=resolved_alphabet)
|
|
577
|
+
|
|
578
|
+
normalized = normalize(
|
|
579
|
+
cast(str | bytes | Iterable[str | Gap] | DNASequence, raw), config=config
|
|
580
|
+
)
|
|
581
|
+
if normalized.sequence is None:
|
|
582
|
+
raise InvalidAlphabetError(
|
|
583
|
+
"DNA input could not be converted into a valid sequence.",
|
|
584
|
+
code="DNA_NORMALIZATION_FAILED",
|
|
585
|
+
context={"issue_codes": [issue.code for issue in normalized.issues]},
|
|
586
|
+
hint="Inspect the retained normalization policy or correct the input symbols.",
|
|
587
|
+
)
|
|
588
|
+
source_sequence = raw if isinstance(raw, DNASequence) else None
|
|
589
|
+
resolved_topology = (
|
|
590
|
+
topology
|
|
591
|
+
if topology is not None
|
|
592
|
+
else source_sequence.topology
|
|
593
|
+
if source_sequence is not None
|
|
594
|
+
else Topology.LINEAR
|
|
595
|
+
)
|
|
596
|
+
resolved_strandedness = (
|
|
597
|
+
strandedness
|
|
598
|
+
if strandedness is not None
|
|
599
|
+
else source_sequence.strandedness
|
|
600
|
+
if source_sequence is not None
|
|
601
|
+
else Strandedness.SINGLE
|
|
602
|
+
)
|
|
603
|
+
sequence = DNASequence(
|
|
604
|
+
normalized.sequence.parts,
|
|
605
|
+
alphabet=normalized.sequence.alphabet,
|
|
606
|
+
topology=resolved_topology,
|
|
607
|
+
strandedness=resolved_strandedness,
|
|
608
|
+
)
|
|
609
|
+
return sequence, replace(normalized, sequence=sequence)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _coerce_alphabet(value: DNAAlphabet | str | None) -> DNAAlphabet | None:
|
|
613
|
+
if value is None:
|
|
614
|
+
return None
|
|
615
|
+
try:
|
|
616
|
+
return value if isinstance(value, DNAAlphabet) else DNAAlphabet(value)
|
|
617
|
+
except (TypeError, ValueError) as exc:
|
|
618
|
+
raise ConfigurationError(
|
|
619
|
+
"Unknown DNA alphabet.",
|
|
620
|
+
code="INVALID_DNA_ALPHABET",
|
|
621
|
+
context={"alphabet": value},
|
|
622
|
+
hint="Choose 'strict' or 'iupac'.",
|
|
623
|
+
) from exc
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _coerce_features(value: object) -> tuple[DNAFeature, ...]:
|
|
627
|
+
if value is None:
|
|
628
|
+
return ()
|
|
629
|
+
if isinstance(value, (DNAFeature, Mapping)):
|
|
630
|
+
items = (value,)
|
|
631
|
+
elif isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
|
|
632
|
+
items = tuple(value)
|
|
633
|
+
else:
|
|
634
|
+
raise FeatureError(
|
|
635
|
+
"features must be a feature, a feature mapping, or an iterable of them.",
|
|
636
|
+
code="INVALID_DNA_FEATURES",
|
|
637
|
+
)
|
|
638
|
+
return tuple(_coerce_feature(item) for item in items)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _coerce_feature(value: object) -> DNAFeature:
|
|
642
|
+
if isinstance(value, DNAFeature):
|
|
643
|
+
return value
|
|
644
|
+
if not isinstance(value, Mapping):
|
|
645
|
+
raise FeatureError(
|
|
646
|
+
"Every feature must be DNAFeature or a mapping.",
|
|
647
|
+
code="INVALID_DNA_FEATURE",
|
|
648
|
+
context={"type": type(value).__name__},
|
|
649
|
+
)
|
|
650
|
+
unknown = set(value) - _FEATURE_SPEC_KEYS
|
|
651
|
+
if unknown:
|
|
652
|
+
raise FeatureError(
|
|
653
|
+
"A feature mapping contains unknown fields.",
|
|
654
|
+
code="UNKNOWN_DNA_FEATURE_FIELD",
|
|
655
|
+
context={"fields": sorted(str(key) for key in unknown)},
|
|
656
|
+
)
|
|
657
|
+
if "type" not in value:
|
|
658
|
+
raise FeatureError("A feature mapping requires 'type'.", code="DNA_FEATURE_TYPE_REQUIRED")
|
|
659
|
+
has_location = "location" in value
|
|
660
|
+
has_interval = "start" in value or "end" in value
|
|
661
|
+
if has_location and has_interval:
|
|
662
|
+
raise FeatureError(
|
|
663
|
+
"Use either location or start/end for a feature, not both.",
|
|
664
|
+
code="DNA_FEATURE_LOCATION_CONFLICT",
|
|
665
|
+
)
|
|
666
|
+
if has_location:
|
|
667
|
+
location = value["location"]
|
|
668
|
+
if not isinstance(location, (Interval, CompoundLocation, UnresolvedLocation)):
|
|
669
|
+
raise FeatureError(
|
|
670
|
+
"feature location must be Interval, CompoundLocation, or UnresolvedLocation.",
|
|
671
|
+
code="INVALID_DNA_FEATURE_LOCATION",
|
|
672
|
+
)
|
|
673
|
+
else:
|
|
674
|
+
if "start" not in value or "end" not in value:
|
|
675
|
+
raise FeatureError(
|
|
676
|
+
"A simple feature mapping requires both start and end.",
|
|
677
|
+
code="DNA_FEATURE_INTERVAL_REQUIRED",
|
|
678
|
+
)
|
|
679
|
+
location = Interval(cast(int, value["start"]), cast(int, value["end"]))
|
|
680
|
+
qualifiers = _optional_mapping(value.get("qualifiers"), name="qualifiers")
|
|
681
|
+
return DNAFeature(
|
|
682
|
+
cast(str, value["type"]),
|
|
683
|
+
cast(Location, location),
|
|
684
|
+
id=cast(str | None, value.get("id")),
|
|
685
|
+
strand=cast(str, value.get("strand", "unknown")),
|
|
686
|
+
label=cast(str | None, value.get("label")),
|
|
687
|
+
score=cast(float | None, value.get("score")),
|
|
688
|
+
phase=cast(int | None, value.get("phase")),
|
|
689
|
+
qualifiers=qualifiers,
|
|
690
|
+
source=cast(str | None, value.get("source")),
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _optional_mapping(value: object, *, name: str) -> Mapping[str, object] | None:
|
|
695
|
+
if value is None:
|
|
696
|
+
return None
|
|
697
|
+
if not isinstance(value, Mapping):
|
|
698
|
+
raise ConfigurationError(
|
|
699
|
+
f"{name} must be a mapping or None.",
|
|
700
|
+
code="INVALID_DNA_MAPPING",
|
|
701
|
+
context={"field": name, "type": type(value).__name__},
|
|
702
|
+
)
|
|
703
|
+
return cast(Mapping[str, object], value)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _materialize_annotations(
|
|
707
|
+
value: Mapping[str, Iterable[int | float]] | None,
|
|
708
|
+
) -> Mapping[str, tuple[int | float, ...]] | None:
|
|
709
|
+
if value is None:
|
|
710
|
+
return None
|
|
711
|
+
if not isinstance(value, Mapping):
|
|
712
|
+
raise ConfigurationError(
|
|
713
|
+
"letter_annotations must be a mapping or None.",
|
|
714
|
+
code="INVALID_DNA_LETTER_ANNOTATIONS",
|
|
715
|
+
)
|
|
716
|
+
return {name: tuple(values) for name, values in value.items()}
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
__all__ = ["DNA", "FeatureInput", "SingleDNAInput", "resolve_single_dna"]
|