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/ops/_common.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Internal helpers shared by deterministic sequence operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from dnakit.core import DNAAlphabet, DNASequence, Gap, Interval, Strandedness, Topology
|
|
8
|
+
from dnakit.exceptions import (
|
|
9
|
+
ConfigurationError,
|
|
10
|
+
CoordinateError,
|
|
11
|
+
SequenceError,
|
|
12
|
+
UnknownLengthError,
|
|
13
|
+
UnsupportedGapOperationError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def require_sequence(sequence: DNASequence) -> DNASequence:
|
|
18
|
+
"""Return a validated sequence argument with a domain-specific error."""
|
|
19
|
+
|
|
20
|
+
if not isinstance(sequence, DNASequence):
|
|
21
|
+
raise SequenceError(
|
|
22
|
+
"A DNASequence object is required.",
|
|
23
|
+
code="INVALID_SEQUENCE_ARGUMENT",
|
|
24
|
+
context={"type": type(sequence).__name__},
|
|
25
|
+
)
|
|
26
|
+
return sequence
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolved_span(sequence: DNASequence, *, operation: str) -> int:
|
|
30
|
+
"""Return a coordinate span, refusing to guess an unknown gap length."""
|
|
31
|
+
|
|
32
|
+
span = sequence.coordinate_span
|
|
33
|
+
if span is None:
|
|
34
|
+
raise UnknownLengthError(
|
|
35
|
+
f"{operation} requires every Gap to have a known length.",
|
|
36
|
+
code="UNRESOLVED_OPERATION_COORDINATES",
|
|
37
|
+
context={"operation": operation},
|
|
38
|
+
hint="Resolve unknown Gap.length values before using coordinate-based operations.",
|
|
39
|
+
)
|
|
40
|
+
return span
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_position(position: int, *, span: int, allow_end: bool = True) -> None:
|
|
44
|
+
"""Validate one zero-based position or insertion boundary."""
|
|
45
|
+
|
|
46
|
+
if isinstance(position, bool) or not isinstance(position, int):
|
|
47
|
+
raise CoordinateError(
|
|
48
|
+
"Position must be a non-negative integer.",
|
|
49
|
+
context={"position": position, "sequence_span": span},
|
|
50
|
+
)
|
|
51
|
+
upper_ok = position <= span if allow_end else position < span
|
|
52
|
+
if position < 0 or not upper_ok:
|
|
53
|
+
relation = "at most" if allow_end else "smaller than"
|
|
54
|
+
raise CoordinateError(
|
|
55
|
+
f"Position must be a non-negative integer {relation} the sequence span.",
|
|
56
|
+
context={"position": position, "sequence_span": span},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_interval(start: int, end: int, *, span: int) -> Interval:
|
|
61
|
+
"""Build and bounds-check an internal zero-based, half-open interval."""
|
|
62
|
+
|
|
63
|
+
interval = Interval(start, end)
|
|
64
|
+
if interval.end > span:
|
|
65
|
+
raise CoordinateError(
|
|
66
|
+
"Interval exceeds the sequence coordinate span.",
|
|
67
|
+
context={"start": start, "end": end, "sequence_span": span},
|
|
68
|
+
)
|
|
69
|
+
return interval
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def copy_gap(gap: Gap, *, length: int) -> Gap:
|
|
73
|
+
"""Copy a gap while replacing only its known coordinate length."""
|
|
74
|
+
|
|
75
|
+
return Gap(
|
|
76
|
+
length,
|
|
77
|
+
kind=gap.kind,
|
|
78
|
+
crossable=gap.crossable,
|
|
79
|
+
evidence=gap.evidence,
|
|
80
|
+
metadata=gap.metadata,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def interval_overlaps_gap(sequence: DNASequence, interval: Interval) -> bool:
|
|
85
|
+
"""Return whether a resolved interval contains any gap coordinate."""
|
|
86
|
+
|
|
87
|
+
cursor = 0
|
|
88
|
+
for part in sequence.parts:
|
|
89
|
+
part_length = len(part) if isinstance(part, str) else part.length
|
|
90
|
+
if part_length is None:
|
|
91
|
+
raise UnknownLengthError(
|
|
92
|
+
"Gap overlap cannot be resolved for an unknown-length Gap.",
|
|
93
|
+
code="UNRESOLVED_GAP_COORDINATES",
|
|
94
|
+
)
|
|
95
|
+
part_end = cursor + part_length
|
|
96
|
+
if isinstance(part, Gap) and interval.start < part_end and cursor < interval.end:
|
|
97
|
+
return True
|
|
98
|
+
cursor = part_end
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def position_inside_gap(sequence: DNASequence, position: int) -> bool:
|
|
103
|
+
"""Return whether an insertion boundary lies strictly inside a gap."""
|
|
104
|
+
|
|
105
|
+
cursor = 0
|
|
106
|
+
for part in sequence.parts:
|
|
107
|
+
part_length = len(part) if isinstance(part, str) else part.length
|
|
108
|
+
if part_length is None:
|
|
109
|
+
raise UnknownLengthError(
|
|
110
|
+
"Insertion coordinates cannot be resolved for an unknown-length Gap.",
|
|
111
|
+
code="UNRESOLVED_GAP_COORDINATES",
|
|
112
|
+
)
|
|
113
|
+
part_end = cursor + part_length
|
|
114
|
+
if isinstance(part, Gap) and cursor < position < part_end:
|
|
115
|
+
return True
|
|
116
|
+
cursor = part_end
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def reject_gap_overlap(sequence: DNASequence, interval: Interval, *, operation: str) -> None:
|
|
121
|
+
"""Reject edits that would silently consume assembly-gap coordinates."""
|
|
122
|
+
|
|
123
|
+
if interval_overlaps_gap(sequence, interval):
|
|
124
|
+
raise UnsupportedGapOperationError(
|
|
125
|
+
f"{operation} cannot overlap a Gap.",
|
|
126
|
+
code="EDIT_OVERLAPS_GAP",
|
|
127
|
+
context={"operation": operation, "start": interval.start, "end": interval.end},
|
|
128
|
+
hint="Edit nucleotide fragments separately or resolve the assembly gap first.",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def infer_alphabet(symbols: str, *, fallback: DNAAlphabet) -> DNAAlphabet:
|
|
133
|
+
"""Infer the narrowest DNA alphabet for already-normalized symbols."""
|
|
134
|
+
|
|
135
|
+
if not symbols:
|
|
136
|
+
return fallback
|
|
137
|
+
return DNAAlphabet.STRICT if set(symbols) <= set("ACGT") else DNAAlphabet.IUPAC
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def promote_alphabet(*alphabets: DNAAlphabet) -> DNAAlphabet:
|
|
141
|
+
"""Return IUPAC if any contributing sequence requires it."""
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
DNAAlphabet.IUPAC
|
|
145
|
+
if any(alphabet is DNAAlphabet.IUPAC for alphabet in alphabets)
|
|
146
|
+
else DNAAlphabet.STRICT
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def coerce_fragment(
|
|
151
|
+
fragment: DNASequence | str,
|
|
152
|
+
*,
|
|
153
|
+
fallback_alphabet: DNAAlphabet,
|
|
154
|
+
strandedness: Strandedness,
|
|
155
|
+
) -> DNASequence:
|
|
156
|
+
"""Convert a normalized string or linear sequence to an embeddable fragment."""
|
|
157
|
+
|
|
158
|
+
if isinstance(fragment, str):
|
|
159
|
+
alphabet = infer_alphabet(fragment, fallback=fallback_alphabet)
|
|
160
|
+
return DNASequence(fragment, alphabet=alphabet, strandedness=strandedness)
|
|
161
|
+
if not isinstance(fragment, DNASequence):
|
|
162
|
+
raise SequenceError(
|
|
163
|
+
"A sequence fragment must be DNASequence or normalized DNA text.",
|
|
164
|
+
code="INVALID_SEQUENCE_FRAGMENT",
|
|
165
|
+
context={"type": type(fragment).__name__},
|
|
166
|
+
)
|
|
167
|
+
if fragment.topology is Topology.CIRCULAR:
|
|
168
|
+
raise ConfigurationError(
|
|
169
|
+
"A circular DNASequence cannot be embedded as a linear fragment.",
|
|
170
|
+
code="CIRCULAR_FRAGMENT_NOT_SUPPORTED",
|
|
171
|
+
)
|
|
172
|
+
if fragment.strandedness is not strandedness:
|
|
173
|
+
raise ConfigurationError(
|
|
174
|
+
"All sequence fragments must have matching strandedness.",
|
|
175
|
+
code="STRANDEDNESS_MISMATCH",
|
|
176
|
+
context={
|
|
177
|
+
"expected": strandedness.value,
|
|
178
|
+
"observed": fragment.strandedness.value,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
return fragment
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def combine_parts(
|
|
185
|
+
parts: Iterable[str | Gap],
|
|
186
|
+
*,
|
|
187
|
+
alphabet: DNAAlphabet,
|
|
188
|
+
topology: Topology,
|
|
189
|
+
strandedness: Strandedness,
|
|
190
|
+
) -> DNASequence:
|
|
191
|
+
"""Construct a fresh immutable sequence from operation output parts."""
|
|
192
|
+
|
|
193
|
+
return DNASequence(
|
|
194
|
+
parts,
|
|
195
|
+
alphabet=alphabet,
|
|
196
|
+
topology=topology,
|
|
197
|
+
strandedness=strandedness,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__: list[str] = []
|
dnakit/ops/circular.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Deterministic origin operations for circular DNA sequences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Literal, TypeAlias
|
|
7
|
+
|
|
8
|
+
from dnakit.core import DNASequence, Topology
|
|
9
|
+
from dnakit.core._json import FrozenDict, to_json_compatible
|
|
10
|
+
from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
|
|
11
|
+
from dnakit.ops._common import require_sequence, resolved_span
|
|
12
|
+
from dnakit.ops.edit import _split_at
|
|
13
|
+
|
|
14
|
+
CircularOperation: TypeAlias = Literal["rotate", "canonical_origin"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class CircularOperationResult:
|
|
19
|
+
"""A circular sequence with an auditable origin-selection decision."""
|
|
20
|
+
|
|
21
|
+
sequence: DNASequence
|
|
22
|
+
operation: CircularOperation
|
|
23
|
+
requested_offset: int | None
|
|
24
|
+
effective_offset: int
|
|
25
|
+
sequence_span: int
|
|
26
|
+
rule: str
|
|
27
|
+
parameters: FrozenDict
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
if self.operation not in {"rotate", "canonical_origin"}:
|
|
31
|
+
raise ConfigurationError("Unknown circular operation result kind.")
|
|
32
|
+
if self.sequence.topology is not Topology.CIRCULAR:
|
|
33
|
+
raise ConfigurationError("A circular operation result must remain circular.")
|
|
34
|
+
if self.requested_offset is not None and (
|
|
35
|
+
isinstance(self.requested_offset, bool) or not isinstance(self.requested_offset, int)
|
|
36
|
+
):
|
|
37
|
+
raise ConfigurationError("requested_offset must be an integer or None.")
|
|
38
|
+
if self.sequence_span <= 0:
|
|
39
|
+
raise ConfigurationError("A circular operation requires a positive sequence span.")
|
|
40
|
+
if not 0 <= self.effective_offset < self.sequence_span:
|
|
41
|
+
raise ConfigurationError("effective_offset must lie within the circular span.")
|
|
42
|
+
if self.sequence.coordinate_span != self.sequence_span:
|
|
43
|
+
raise ConfigurationError("sequence_span must match the transformed sequence.")
|
|
44
|
+
if not isinstance(self.rule, str) or not self.rule.strip():
|
|
45
|
+
raise ConfigurationError("A circular operation result rule must be non-empty.")
|
|
46
|
+
if not isinstance(self.parameters, FrozenDict):
|
|
47
|
+
raise ConfigurationError("Circular operation parameters must be FrozenDict.")
|
|
48
|
+
|
|
49
|
+
def to_dict(self) -> dict[str, object]:
|
|
50
|
+
"""Return a JSON-compatible audit payload without flattening explicit gaps."""
|
|
51
|
+
|
|
52
|
+
payload = {
|
|
53
|
+
"operation": self.operation,
|
|
54
|
+
"requested_offset": self.requested_offset,
|
|
55
|
+
"effective_offset": self.effective_offset,
|
|
56
|
+
"sequence_span": self.sequence_span,
|
|
57
|
+
"rule": self.rule,
|
|
58
|
+
"parameters": self.parameters,
|
|
59
|
+
}
|
|
60
|
+
converted = to_json_compatible(payload)
|
|
61
|
+
assert isinstance(converted, dict)
|
|
62
|
+
return converted
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _require_circular(sequence: DNASequence, *, operation: str) -> DNASequence:
|
|
66
|
+
source = require_sequence(sequence)
|
|
67
|
+
if source.topology is not Topology.CIRCULAR:
|
|
68
|
+
raise ConfigurationError(
|
|
69
|
+
f"{operation} requires topology='circular'.",
|
|
70
|
+
code="CIRCULAR_TOPOLOGY_REQUIRED",
|
|
71
|
+
context={"operation": operation, "topology": source.topology.value},
|
|
72
|
+
)
|
|
73
|
+
return source
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def rotate(sequence: DNASequence, offset: int) -> CircularOperationResult:
|
|
77
|
+
"""Left-rotate a circular sequence so ``offset`` becomes coordinate zero.
|
|
78
|
+
|
|
79
|
+
Offsets are normalized modulo the resolved coordinate span. A rotation may
|
|
80
|
+
split a nucleotide fragment, but never an explicit Gap. Unknown-length Gaps
|
|
81
|
+
are rejected because the effective coordinate cannot be determined.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
source = _require_circular(sequence, operation="rotate")
|
|
85
|
+
if isinstance(offset, bool) or not isinstance(offset, int):
|
|
86
|
+
raise ConfigurationError(
|
|
87
|
+
"Circular rotation offset must be an integer.",
|
|
88
|
+
code="INVALID_ROTATION_OFFSET",
|
|
89
|
+
context={"offset": offset},
|
|
90
|
+
)
|
|
91
|
+
span = resolved_span(source, operation="rotate")
|
|
92
|
+
effective = offset % span
|
|
93
|
+
try:
|
|
94
|
+
left, right = _split_at(source, effective)
|
|
95
|
+
except UnsupportedGapOperationError as exc:
|
|
96
|
+
raise UnsupportedGapOperationError(
|
|
97
|
+
"A circular origin cannot be placed inside a Gap.",
|
|
98
|
+
code="ROTATION_ORIGIN_INSIDE_GAP",
|
|
99
|
+
context={"requested_offset": offset, "effective_offset": effective},
|
|
100
|
+
hint="Choose a nucleotide or Gap boundary as the circular origin.",
|
|
101
|
+
) from exc
|
|
102
|
+
rotated = DNASequence(
|
|
103
|
+
(*right, *left),
|
|
104
|
+
alphabet=source.alphabet,
|
|
105
|
+
topology=Topology.CIRCULAR,
|
|
106
|
+
strandedness=source.strandedness,
|
|
107
|
+
)
|
|
108
|
+
return CircularOperationResult(
|
|
109
|
+
rotated,
|
|
110
|
+
"rotate",
|
|
111
|
+
offset,
|
|
112
|
+
effective,
|
|
113
|
+
span,
|
|
114
|
+
"left_rotation_offset_modulo_coordinate_span",
|
|
115
|
+
FrozenDict(
|
|
116
|
+
{
|
|
117
|
+
"direction": "left",
|
|
118
|
+
"offset_normalization": "modulo_coordinate_span",
|
|
119
|
+
"gap_interior_policy": "error",
|
|
120
|
+
}
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _minimal_rotation_offset(text: str) -> int:
|
|
126
|
+
"""Return the smallest offset having the lexicographically minimal rotation.
|
|
127
|
+
|
|
128
|
+
This is Booth's linear-time algorithm with deterministic smallest-index
|
|
129
|
+
tie-breaking for periodic strings.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
length = len(text)
|
|
133
|
+
if length < 2:
|
|
134
|
+
return 0
|
|
135
|
+
doubled = text + text
|
|
136
|
+
first, second, matched = 0, 1, 0
|
|
137
|
+
while first < length and second < length and matched < length:
|
|
138
|
+
left = doubled[first + matched]
|
|
139
|
+
right = doubled[second + matched]
|
|
140
|
+
if left == right:
|
|
141
|
+
matched += 1
|
|
142
|
+
continue
|
|
143
|
+
if left > right:
|
|
144
|
+
first = first + matched + 1
|
|
145
|
+
if first <= second:
|
|
146
|
+
first = second + 1
|
|
147
|
+
else:
|
|
148
|
+
second = second + matched + 1
|
|
149
|
+
if second <= first:
|
|
150
|
+
second = first + 1
|
|
151
|
+
matched = 0
|
|
152
|
+
return min(first, second)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def canonical_origin(sequence: DNASequence) -> CircularOperationResult:
|
|
156
|
+
"""Choose the lexicographically minimal forward rotation as coordinate zero.
|
|
157
|
+
|
|
158
|
+
The rule compares normalized DNA symbols using their ordinary code-point
|
|
159
|
+
order and preserves strand orientation. Equal rotations select the smallest
|
|
160
|
+
offset. Explicit gaps are rejected because omitting or serializing them into
|
|
161
|
+
a comparison token would change the sequence model.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
source = _require_circular(sequence, operation="canonical_origin")
|
|
165
|
+
if source.is_gapped:
|
|
166
|
+
raise UnsupportedGapOperationError(
|
|
167
|
+
"Canonical lexicographic origin selection does not accept explicit Gaps.",
|
|
168
|
+
code="CANONICAL_ORIGIN_GAPPED_SEQUENCE",
|
|
169
|
+
hint="Resolve the origin explicitly with rotate() at a Gap boundary.",
|
|
170
|
+
)
|
|
171
|
+
span = resolved_span(source, operation="canonical_origin")
|
|
172
|
+
offset = _minimal_rotation_offset(source.symbols)
|
|
173
|
+
rotated = rotate(source, offset).sequence
|
|
174
|
+
return CircularOperationResult(
|
|
175
|
+
rotated,
|
|
176
|
+
"canonical_origin",
|
|
177
|
+
None,
|
|
178
|
+
offset,
|
|
179
|
+
span,
|
|
180
|
+
"lexicographically_minimal_forward_rotation_then_smallest_offset",
|
|
181
|
+
FrozenDict(
|
|
182
|
+
{
|
|
183
|
+
"comparison": "normalized_symbol_code_point_order",
|
|
184
|
+
"orientation": "forward_only",
|
|
185
|
+
"tie_break": "smallest_offset",
|
|
186
|
+
"algorithm": "booth",
|
|
187
|
+
"algorithm_complexity": "O(n)",
|
|
188
|
+
}
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
__all__ = ["CircularOperation", "CircularOperationResult", "canonical_origin", "rotate"]
|
dnakit/ops/concat.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Concatenation of immutable linear DNA sequences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from dnakit.core import DNAAlphabet, DNASequence, Gap, Topology
|
|
8
|
+
from dnakit.exceptions import ConfigurationError, UnsupportedGapOperationError
|
|
9
|
+
from dnakit.ops._common import coerce_fragment, combine_parts, promote_alphabet
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def concat(
|
|
13
|
+
sequences: Iterable[DNASequence | str],
|
|
14
|
+
*,
|
|
15
|
+
linker: DNASequence | str | None = None,
|
|
16
|
+
gap: Gap | None = None,
|
|
17
|
+
) -> DNASequence:
|
|
18
|
+
"""Concatenate at least two linear sequences with an optional separator.
|
|
19
|
+
|
|
20
|
+
``linker`` and ``gap`` are mutually exclusive. No rotation, implicit
|
|
21
|
+
reverse-complement, or feature synchronization is performed.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
raw = tuple(sequences)
|
|
25
|
+
if len(raw) < 2:
|
|
26
|
+
raise ConfigurationError(
|
|
27
|
+
"concat requires at least two sequences.",
|
|
28
|
+
code="INSUFFICIENT_CONCAT_INPUTS",
|
|
29
|
+
context={"count": len(raw)},
|
|
30
|
+
)
|
|
31
|
+
if linker is not None and gap is not None:
|
|
32
|
+
raise ConfigurationError(
|
|
33
|
+
"linker and gap are mutually exclusive.",
|
|
34
|
+
code="MULTIPLE_CONCAT_SEPARATORS",
|
|
35
|
+
)
|
|
36
|
+
if gap is not None and not isinstance(gap, Gap):
|
|
37
|
+
raise ConfigurationError(
|
|
38
|
+
"gap must be a Gap object.",
|
|
39
|
+
code="INVALID_CONCAT_GAP",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
first_raw = raw[0]
|
|
43
|
+
if isinstance(first_raw, DNASequence):
|
|
44
|
+
first = first_raw
|
|
45
|
+
elif isinstance(first_raw, str):
|
|
46
|
+
first = DNASequence(first_raw, alphabet=_infer_text_alphabet(first_raw))
|
|
47
|
+
else:
|
|
48
|
+
raise ConfigurationError(
|
|
49
|
+
"concat inputs must be DNASequence or normalized DNA text.",
|
|
50
|
+
code="INVALID_CONCAT_INPUT",
|
|
51
|
+
context={"index": 0, "type": type(first_raw).__name__},
|
|
52
|
+
)
|
|
53
|
+
if first.topology is Topology.CIRCULAR:
|
|
54
|
+
raise ConfigurationError(
|
|
55
|
+
"Circular input concatenation is not supported in the MVP.",
|
|
56
|
+
code="CIRCULAR_CONCAT_NOT_SUPPORTED",
|
|
57
|
+
)
|
|
58
|
+
fragments = [
|
|
59
|
+
coerce_fragment(
|
|
60
|
+
item,
|
|
61
|
+
fallback_alphabet=first.alphabet,
|
|
62
|
+
strandedness=first.strandedness,
|
|
63
|
+
)
|
|
64
|
+
for item in raw
|
|
65
|
+
]
|
|
66
|
+
separator: DNASequence | Gap | None
|
|
67
|
+
if linker is not None:
|
|
68
|
+
separator = coerce_fragment(
|
|
69
|
+
linker,
|
|
70
|
+
fallback_alphabet=first.alphabet,
|
|
71
|
+
strandedness=first.strandedness,
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
separator = gap
|
|
75
|
+
|
|
76
|
+
alphabets = [fragment.alphabet for fragment in fragments]
|
|
77
|
+
if isinstance(separator, DNASequence):
|
|
78
|
+
alphabets.append(separator.alphabet)
|
|
79
|
+
alphabet = promote_alphabet(*alphabets)
|
|
80
|
+
parts: list[str | Gap] = []
|
|
81
|
+
for index, fragment in enumerate(fragments):
|
|
82
|
+
if index and separator is not None:
|
|
83
|
+
if isinstance(separator, DNASequence):
|
|
84
|
+
parts.extend(separator.parts)
|
|
85
|
+
else:
|
|
86
|
+
parts.append(separator)
|
|
87
|
+
parts.extend(fragment.parts)
|
|
88
|
+
return combine_parts(
|
|
89
|
+
parts,
|
|
90
|
+
alphabet=alphabet,
|
|
91
|
+
topology=Topology.LINEAR,
|
|
92
|
+
strandedness=first.strandedness,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def concat_overlap(
|
|
97
|
+
sequences: Iterable[DNASequence | str],
|
|
98
|
+
*,
|
|
99
|
+
min_overlap: int = 1,
|
|
100
|
+
max_overlap: int | None = None,
|
|
101
|
+
) -> DNASequence:
|
|
102
|
+
"""Concatenate two linear sequences while retaining an exact overlap once.
|
|
103
|
+
|
|
104
|
+
The longest exact suffix/prefix overlap is selected automatically. IUPAC
|
|
105
|
+
symbols are compared literally; ambiguity compatibility is not inferred.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
_validate_overlap_bounds(min_overlap, max_overlap)
|
|
109
|
+
raw = tuple(sequences)
|
|
110
|
+
if len(raw) != 2:
|
|
111
|
+
raise ConfigurationError(
|
|
112
|
+
"concat_overlap requires exactly two sequences.",
|
|
113
|
+
code="INVALID_OVERLAP_CONCAT_INPUTS",
|
|
114
|
+
context={"count": len(raw)},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
first_raw = raw[0]
|
|
118
|
+
if isinstance(first_raw, DNASequence):
|
|
119
|
+
first = first_raw
|
|
120
|
+
elif isinstance(first_raw, str):
|
|
121
|
+
first = DNASequence(first_raw, alphabet=_infer_text_alphabet(first_raw))
|
|
122
|
+
else:
|
|
123
|
+
raise ConfigurationError(
|
|
124
|
+
"concat_overlap inputs must be DNASequence or normalized DNA text.",
|
|
125
|
+
code="INVALID_OVERLAP_CONCAT_INPUT",
|
|
126
|
+
context={"index": 0, "type": type(first_raw).__name__},
|
|
127
|
+
)
|
|
128
|
+
if first.topology is Topology.CIRCULAR:
|
|
129
|
+
raise ConfigurationError(
|
|
130
|
+
"Circular input overlap concatenation is not supported.",
|
|
131
|
+
code="CIRCULAR_CONCAT_NOT_SUPPORTED",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
fragments = tuple(
|
|
135
|
+
coerce_fragment(
|
|
136
|
+
item,
|
|
137
|
+
fallback_alphabet=first.alphabet,
|
|
138
|
+
strandedness=first.strandedness,
|
|
139
|
+
)
|
|
140
|
+
for item in raw
|
|
141
|
+
)
|
|
142
|
+
if any(fragment.is_gapped for fragment in fragments):
|
|
143
|
+
raise UnsupportedGapOperationError(
|
|
144
|
+
"concat_overlap cannot silently discard explicit Gap objects.",
|
|
145
|
+
code="OVERLAP_CONCAT_GAPPED_INPUT",
|
|
146
|
+
hint="Resolve the gaps first or use concat() when the Gap should be retained.",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
overlap_length = _find_overlap_length(
|
|
150
|
+
fragments[0].symbols,
|
|
151
|
+
fragments[1].symbols,
|
|
152
|
+
min_overlap=min_overlap,
|
|
153
|
+
max_overlap=max_overlap,
|
|
154
|
+
)
|
|
155
|
+
alphabet = promote_alphabet(*(fragment.alphabet for fragment in fragments))
|
|
156
|
+
return combine_parts(
|
|
157
|
+
[fragments[0].symbols + fragments[1].symbols[overlap_length:]],
|
|
158
|
+
alphabet=alphabet,
|
|
159
|
+
topology=Topology.LINEAR,
|
|
160
|
+
strandedness=first.strandedness,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _validate_overlap_bounds(min_overlap: int, max_overlap: int | None) -> None:
|
|
165
|
+
if isinstance(min_overlap, bool) or not isinstance(min_overlap, int) or min_overlap < 1:
|
|
166
|
+
raise ConfigurationError("min_overlap must be a positive integer.")
|
|
167
|
+
if max_overlap is not None and (
|
|
168
|
+
isinstance(max_overlap, bool) or not isinstance(max_overlap, int) or max_overlap < 1
|
|
169
|
+
):
|
|
170
|
+
raise ConfigurationError("max_overlap must be a positive integer or None.")
|
|
171
|
+
if max_overlap is not None and min_overlap > max_overlap:
|
|
172
|
+
raise ConfigurationError("min_overlap cannot exceed max_overlap.")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _find_overlap_length(
|
|
176
|
+
left: str,
|
|
177
|
+
right: str,
|
|
178
|
+
*,
|
|
179
|
+
min_overlap: int,
|
|
180
|
+
max_overlap: int | None,
|
|
181
|
+
) -> int:
|
|
182
|
+
upper = min(len(left), len(right))
|
|
183
|
+
if max_overlap is not None:
|
|
184
|
+
upper = min(upper, max_overlap)
|
|
185
|
+
for length in range(upper, min_overlap - 1, -1):
|
|
186
|
+
if left[-length:] == right[:length]:
|
|
187
|
+
return length
|
|
188
|
+
raise ConfigurationError(
|
|
189
|
+
"No exact suffix/prefix overlap satisfies the configured bounds.",
|
|
190
|
+
code="OVERLAP_NOT_FOUND",
|
|
191
|
+
context={
|
|
192
|
+
"left_length": len(left),
|
|
193
|
+
"right_length": len(right),
|
|
194
|
+
"min_overlap": min_overlap,
|
|
195
|
+
"max_overlap": max_overlap,
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _infer_text_alphabet(text: str) -> DNAAlphabet:
|
|
201
|
+
return DNAAlphabet.STRICT if set(text) <= set("ACGT") else DNAAlphabet.IUPAC
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
__all__ = ["concat", "concat_overlap"]
|
dnakit/ops/direction.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Direction-changing operations for immutable DNA sequences."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dnakit.core import DNASequence
|
|
6
|
+
from dnakit.ops._common import require_sequence
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def reverse(sequence: DNASequence) -> DNASequence:
|
|
10
|
+
"""Return a new sequence with part order and nucleotide order reversed."""
|
|
11
|
+
|
|
12
|
+
return require_sequence(sequence).reverse()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def complement(sequence: DNASequence) -> DNASequence:
|
|
16
|
+
"""Return a new sequence using the complete DNA IUPAC complement table."""
|
|
17
|
+
|
|
18
|
+
return require_sequence(sequence).complement()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def reverse_complement(sequence: DNASequence) -> DNASequence:
|
|
22
|
+
"""Return a new reverse-complemented sequence, preserving explicit gaps."""
|
|
23
|
+
|
|
24
|
+
return require_sequence(sequence).reverse_complement()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["complement", "reverse", "reverse_complement"]
|