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
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
"""Explicit bounded adapters for caller-installed Primer3 CLI programs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
from dnakit.backends import execute_bounded_command
|
|
14
|
+
from dnakit.core import (
|
|
15
|
+
BackendInfo,
|
|
16
|
+
Citation,
|
|
17
|
+
DNASequence,
|
|
18
|
+
ExecutionMode,
|
|
19
|
+
ImplementationInfo,
|
|
20
|
+
ImplementationLabel,
|
|
21
|
+
Issue,
|
|
22
|
+
IssueSeverity,
|
|
23
|
+
OriginClass,
|
|
24
|
+
Provenance,
|
|
25
|
+
)
|
|
26
|
+
from dnakit.exceptions import BackendExecutionError, BackendUnavailableError, ConfigurationError
|
|
27
|
+
|
|
28
|
+
from ._shared import canonical_linear_symbols
|
|
29
|
+
from .config import ThermodynamicConditions
|
|
30
|
+
from .results import ConditionalCapability, Primer3ThermodynamicResult
|
|
31
|
+
|
|
32
|
+
_PRIMER3_CAPABILITIES = frozenset({"hairpin", "self_dimer", "heterodimer", "tm"})
|
|
33
|
+
_PRIMER3_STRUCTURE_CAPABILITIES = frozenset({"hairpin", "self_dimer", "heterodimer"})
|
|
34
|
+
_THERMODYNAMIC_LINE = re.compile(
|
|
35
|
+
r"^Calculated thermodynamical parameters for dimer:\s*"
|
|
36
|
+
r"(?:\d+\s+)?dS\s*=\s*(?P<ds>[-+0-9.eE]+)\s+"
|
|
37
|
+
r"dH\s*=\s*(?P<dh>[-+0-9.eE]+)\s+"
|
|
38
|
+
r"dG\s*=\s*(?P<dg>[-+0-9.eE]+)\s+"
|
|
39
|
+
r"t\s*=\s*(?P<tm>[-+0-9.eE]+)\s*$"
|
|
40
|
+
)
|
|
41
|
+
_NO_STRUCTURE_LINE = re.compile(r"^0\s+dS\s*=.*(?:inf|nan)", re.IGNORECASE)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _primer3_number(value: object, field: str) -> float:
|
|
45
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
46
|
+
raise ValueError(f"{field} must be finite numeric output")
|
|
47
|
+
return float(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_primer3_result(
|
|
51
|
+
result: object,
|
|
52
|
+
*,
|
|
53
|
+
capability: str,
|
|
54
|
+
sequences_5to3: tuple[str, ...],
|
|
55
|
+
conditions: ThermodynamicConditions,
|
|
56
|
+
max_loop: int | None,
|
|
57
|
+
output_structure: bool,
|
|
58
|
+
error_code: str = "INVALID_PRIMER3_RESULT",
|
|
59
|
+
) -> Primer3ThermodynamicResult:
|
|
60
|
+
"""Validate that an adapter result is bound to the exact resolved request."""
|
|
61
|
+
|
|
62
|
+
if not isinstance(result, Primer3ThermodynamicResult):
|
|
63
|
+
raise BackendExecutionError(
|
|
64
|
+
"Primer3 adapter returned an unsupported result object.",
|
|
65
|
+
code=error_code,
|
|
66
|
+
context={"result_type": type(result).__name__, "capability": capability},
|
|
67
|
+
)
|
|
68
|
+
mismatches: list[str] = []
|
|
69
|
+
if result.capability != capability:
|
|
70
|
+
mismatches.append("capability")
|
|
71
|
+
if result.sequences_5to3 != sequences_5to3:
|
|
72
|
+
mismatches.append("sequences_5to3")
|
|
73
|
+
if result.conditions != conditions:
|
|
74
|
+
mismatches.append("conditions")
|
|
75
|
+
if result.max_loop != max_loop:
|
|
76
|
+
mismatches.append("max_loop")
|
|
77
|
+
if not output_structure and result.ascii_structure is not None:
|
|
78
|
+
mismatches.append("ascii_structure")
|
|
79
|
+
if result.backend != result.provenance.backend:
|
|
80
|
+
mismatches.append("provenance.backend")
|
|
81
|
+
if result.provenance.implementation.label is not ImplementationLabel.ADAPTER:
|
|
82
|
+
mismatches.append("provenance.implementation")
|
|
83
|
+
if not result.backend.available or capability not in result.backend.capabilities:
|
|
84
|
+
mismatches.append("backend.capabilities")
|
|
85
|
+
if mismatches:
|
|
86
|
+
raise BackendExecutionError(
|
|
87
|
+
"Primer3 adapter result does not match the resolved request contract.",
|
|
88
|
+
code=error_code,
|
|
89
|
+
context={"capability": capability, "mismatched_fields": tuple(mismatches)},
|
|
90
|
+
)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _explicit_executable(value: str | Path | None, name: str) -> tuple[str | None, str | None]:
|
|
95
|
+
if value is None:
|
|
96
|
+
return None, None
|
|
97
|
+
if not isinstance(value, (str, Path)):
|
|
98
|
+
raise ConfigurationError(
|
|
99
|
+
f"{name} must be an explicit filesystem path or None.",
|
|
100
|
+
code="INVALID_PRIMER3_EXECUTABLE",
|
|
101
|
+
)
|
|
102
|
+
requested = str(value)
|
|
103
|
+
if (
|
|
104
|
+
not requested.strip()
|
|
105
|
+
or any(character in requested for character in ("\x00", "\r", "\n"))
|
|
106
|
+
or (not Path(requested).is_absolute() and "/" not in requested and "\\" not in requested)
|
|
107
|
+
):
|
|
108
|
+
raise ConfigurationError(
|
|
109
|
+
f"{name} must be an explicit filesystem path, not a PATH command name.",
|
|
110
|
+
code="INVALID_PRIMER3_EXECUTABLE",
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
resolved = Path(requested).expanduser().resolve(strict=True)
|
|
114
|
+
details = resolved.stat()
|
|
115
|
+
except (OSError, RuntimeError, ValueError):
|
|
116
|
+
return requested, None
|
|
117
|
+
if not stat.S_ISREG(details.st_mode) or not os.access(resolved, os.X_OK):
|
|
118
|
+
return requested, None
|
|
119
|
+
return requested, str(resolved)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _explicit_parameter_directory(
|
|
123
|
+
value: str | Path | None,
|
|
124
|
+
) -> tuple[str | None, str | None]:
|
|
125
|
+
if value is None:
|
|
126
|
+
return None, None
|
|
127
|
+
if not isinstance(value, (str, Path)) or not str(value).strip():
|
|
128
|
+
raise ConfigurationError(
|
|
129
|
+
"thermodynamic_parameters_path must be an explicit directory path or None.",
|
|
130
|
+
code="INVALID_PRIMER3_PARAMETER_PATH",
|
|
131
|
+
)
|
|
132
|
+
requested = str(value)
|
|
133
|
+
try:
|
|
134
|
+
resolved = Path(requested).expanduser().resolve(strict=True)
|
|
135
|
+
except (OSError, RuntimeError, ValueError):
|
|
136
|
+
return requested, None
|
|
137
|
+
return requested, str(resolved) if resolved.is_dir() else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _conditions(value: ThermodynamicConditions | None) -> ThermodynamicConditions:
|
|
141
|
+
resolved = ThermodynamicConditions() if value is None else value
|
|
142
|
+
if not isinstance(resolved, ThermodynamicConditions):
|
|
143
|
+
raise ConfigurationError("conditions must be ThermodynamicConditions or None.")
|
|
144
|
+
return resolved
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _max_loop(value: int) -> int:
|
|
148
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 30:
|
|
149
|
+
raise ConfigurationError(
|
|
150
|
+
"max_loop must be an integer in [1, 30].",
|
|
151
|
+
code="INVALID_PRIMER3_MAX_LOOP",
|
|
152
|
+
)
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _number_argument(value: float) -> str:
|
|
157
|
+
return format(value, ".15g")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _adapter_provenance(info: BackendInfo) -> Provenance:
|
|
161
|
+
return Provenance(
|
|
162
|
+
implementation=ImplementationInfo(
|
|
163
|
+
label=ImplementationLabel.ADAPTER,
|
|
164
|
+
execution_mode=ExecutionMode.EXTERNAL,
|
|
165
|
+
origin_class=OriginClass.INTEGRATION,
|
|
166
|
+
license_expression=info.license_expression,
|
|
167
|
+
citations=(
|
|
168
|
+
Citation(
|
|
169
|
+
"primer3",
|
|
170
|
+
title="Primer3--new capabilities and interfaces",
|
|
171
|
+
doi="10.1093/nar/gks596",
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
),
|
|
175
|
+
backend=info,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@runtime_checkable
|
|
180
|
+
class ThermodynamicsBackend(Protocol):
|
|
181
|
+
"""Minimal backend-neutral interface exposed to higher-level workflows."""
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def info(self) -> BackendInfo: ...
|
|
185
|
+
|
|
186
|
+
def supports(self, capability: str) -> bool: ...
|
|
187
|
+
|
|
188
|
+
def ensure_available(self, capability: str) -> None: ...
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Primer3CLIAdapter:
|
|
192
|
+
"""Call only explicit user-installed ``oligotm`` and ``ntthal`` paths.
|
|
193
|
+
|
|
194
|
+
Construction and :attr:`info` are passive. No PATH search, import,
|
|
195
|
+
download, installation, or subprocess execution occurs until a calculation
|
|
196
|
+
method is explicitly called.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
*,
|
|
202
|
+
oligotm_path: str | Path | None = None,
|
|
203
|
+
ntthal_path: str | Path | None = None,
|
|
204
|
+
thermodynamic_parameters_path: str | Path | None = None,
|
|
205
|
+
timeout_seconds: float = 30.0,
|
|
206
|
+
max_output_bytes: int = 1_000_000,
|
|
207
|
+
) -> None:
|
|
208
|
+
self._requested_oligotm, self._oligotm_path = _explicit_executable(
|
|
209
|
+
oligotm_path, "oligotm_path"
|
|
210
|
+
)
|
|
211
|
+
self._requested_ntthal, self._ntthal_path = _explicit_executable(ntthal_path, "ntthal_path")
|
|
212
|
+
self._requested_parameters, self._parameters_path = _explicit_parameter_directory(
|
|
213
|
+
thermodynamic_parameters_path
|
|
214
|
+
)
|
|
215
|
+
if (
|
|
216
|
+
isinstance(timeout_seconds, bool)
|
|
217
|
+
or not isinstance(timeout_seconds, (int, float))
|
|
218
|
+
or not 0 < timeout_seconds <= 300
|
|
219
|
+
):
|
|
220
|
+
raise ConfigurationError("timeout_seconds must be in (0, 300].")
|
|
221
|
+
if (
|
|
222
|
+
isinstance(max_output_bytes, bool)
|
|
223
|
+
or not isinstance(max_output_bytes, int)
|
|
224
|
+
or not 1 <= max_output_bytes <= 10_000_000
|
|
225
|
+
):
|
|
226
|
+
raise ConfigurationError("max_output_bytes must be in [1, 10000000].")
|
|
227
|
+
self._timeout_seconds = float(timeout_seconds)
|
|
228
|
+
self._max_output_bytes = max_output_bytes
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def info(self) -> BackendInfo:
|
|
232
|
+
paths_valid = self._requested_parameters is None or self._parameters_path is not None
|
|
233
|
+
capabilities: set[str] = set()
|
|
234
|
+
if self._oligotm_path is not None:
|
|
235
|
+
capabilities.add("tm")
|
|
236
|
+
if paths_valid and self._ntthal_path is not None:
|
|
237
|
+
capabilities.update(_PRIMER3_STRUCTURE_CAPABILITIES)
|
|
238
|
+
executable_path = (
|
|
239
|
+
self._ntthal_path
|
|
240
|
+
if paths_valid and self._ntthal_path is not None
|
|
241
|
+
else self._oligotm_path
|
|
242
|
+
)
|
|
243
|
+
return BackendInfo(
|
|
244
|
+
"primer3-cli",
|
|
245
|
+
version=None,
|
|
246
|
+
executable_path=executable_path,
|
|
247
|
+
license_expression="GPL-2.0-or-later",
|
|
248
|
+
capabilities=tuple(sorted(capabilities)),
|
|
249
|
+
available=bool(capabilities),
|
|
250
|
+
metadata={
|
|
251
|
+
"adapter_status": "explicit-cli-execution",
|
|
252
|
+
"requested_oligotm_path": self._requested_oligotm,
|
|
253
|
+
"resolved_oligotm_path": self._oligotm_path,
|
|
254
|
+
"requested_ntthal_path": self._requested_ntthal,
|
|
255
|
+
"resolved_ntthal_path": self._ntthal_path,
|
|
256
|
+
"requested_thermodynamic_parameters_path": self._requested_parameters,
|
|
257
|
+
"resolved_thermodynamic_parameters_path": self._parameters_path,
|
|
258
|
+
"probe_mode": "explicit-path-stat-only",
|
|
259
|
+
"version_probe_executed": False,
|
|
260
|
+
"automatic_path_search": False,
|
|
261
|
+
"automatic_install": False,
|
|
262
|
+
"automatic_download": False,
|
|
263
|
+
"redistributed": False,
|
|
264
|
+
},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def supports(self, capability: str) -> bool:
|
|
268
|
+
return capability in _PRIMER3_CAPABILITIES
|
|
269
|
+
|
|
270
|
+
def ensure_available(self, capability: str) -> None:
|
|
271
|
+
if not self.supports(capability):
|
|
272
|
+
raise BackendExecutionError(
|
|
273
|
+
"Primer3 does not declare the requested thermodynamic capability.",
|
|
274
|
+
code="BACKEND_CAPABILITY_UNSUPPORTED",
|
|
275
|
+
context={"backend": "primer3-cli", "capability": capability},
|
|
276
|
+
)
|
|
277
|
+
if (
|
|
278
|
+
capability in _PRIMER3_STRUCTURE_CAPABILITIES
|
|
279
|
+
and self._requested_parameters is not None
|
|
280
|
+
and self._parameters_path is None
|
|
281
|
+
):
|
|
282
|
+
raise BackendUnavailableError(
|
|
283
|
+
"The explicit Primer3 thermodynamic parameter directory is unavailable.",
|
|
284
|
+
code="PRIMER3_PARAMETER_PATH_UNAVAILABLE",
|
|
285
|
+
context={"requested_path": self._requested_parameters},
|
|
286
|
+
)
|
|
287
|
+
path = self._oligotm_path if capability == "tm" else self._ntthal_path
|
|
288
|
+
requested = self._requested_oligotm if capability == "tm" else self._requested_ntthal
|
|
289
|
+
if path is None:
|
|
290
|
+
raise BackendUnavailableError(
|
|
291
|
+
"The required explicit Primer3 CLI executable is unavailable.",
|
|
292
|
+
code="PRIMER3_CLI_UNAVAILABLE",
|
|
293
|
+
context={"capability": capability, "requested_executable": requested},
|
|
294
|
+
hint="Install Primer3 separately and pass the executable's explicit path.",
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _run(self, executable: str, arguments: tuple[str, ...], capability: str) -> str:
|
|
298
|
+
with tempfile.TemporaryDirectory(prefix="dnakit-primer3-") as workspace:
|
|
299
|
+
result = execute_bounded_command(
|
|
300
|
+
executable,
|
|
301
|
+
arguments,
|
|
302
|
+
backend_id="primer3-cli",
|
|
303
|
+
cwd=workspace,
|
|
304
|
+
timeout_seconds=self._timeout_seconds,
|
|
305
|
+
max_output_bytes=self._max_output_bytes,
|
|
306
|
+
)
|
|
307
|
+
if result.return_code != 0:
|
|
308
|
+
raise BackendExecutionError(
|
|
309
|
+
"Primer3 CLI calculation failed.",
|
|
310
|
+
code="PRIMER3_CLI_EXECUTION_FAILED",
|
|
311
|
+
context={
|
|
312
|
+
"capability": capability,
|
|
313
|
+
"return_code": result.return_code,
|
|
314
|
+
"output_excerpt": result.output[:1_000],
|
|
315
|
+
},
|
|
316
|
+
)
|
|
317
|
+
return result.output
|
|
318
|
+
|
|
319
|
+
@staticmethod
|
|
320
|
+
def _oligotm_arguments(symbols: str, conditions: ThermodynamicConditions) -> tuple[str, ...]:
|
|
321
|
+
# oligotm currently caps argc at 14, so values equal to its documented
|
|
322
|
+
# defaults are omitted and only effective overrides are passed.
|
|
323
|
+
condition_options: list[tuple[str, float]] = []
|
|
324
|
+
candidates = (
|
|
325
|
+
("-mv", conditions.monovalent_molar * 1_000.0, 50.0),
|
|
326
|
+
("-dv", conditions.magnesium_molar * 1_000.0, 1.5),
|
|
327
|
+
("-n", conditions.dntp_molar * 1_000.0, 0.6),
|
|
328
|
+
("-d", conditions.strand_concentration_molar * 1_000_000_000.0, 50.0),
|
|
329
|
+
("-dm", conditions.dmso_percent, 0.0),
|
|
330
|
+
("-df", conditions.dmso_factor_celsius_per_percent, 0.6),
|
|
331
|
+
("-fo", conditions.formamide_molar, 0.0),
|
|
332
|
+
)
|
|
333
|
+
for flag, value, default in candidates:
|
|
334
|
+
if value != default:
|
|
335
|
+
condition_options.append((flag, value))
|
|
336
|
+
if len(condition_options) > 4:
|
|
337
|
+
raise ConfigurationError(
|
|
338
|
+
"The installed oligotm CLI cannot represent all requested condition overrides.",
|
|
339
|
+
code="PRIMER3_OLIGOTM_ARGUMENT_LIMIT",
|
|
340
|
+
context={"override_count": len(condition_options), "maximum": 4},
|
|
341
|
+
)
|
|
342
|
+
# Pin both scientific method selectors instead of relying on the
|
|
343
|
+
# installed executable's defaults. oligotm caps argc at 14, leaving
|
|
344
|
+
# room for four non-default condition flags plus the sequence.
|
|
345
|
+
options = [("-tp", 1.0), ("-sc", 1.0), *condition_options]
|
|
346
|
+
arguments = tuple(
|
|
347
|
+
item for flag, value in options for item in (flag, _number_argument(value))
|
|
348
|
+
)
|
|
349
|
+
return (*arguments, symbols)
|
|
350
|
+
|
|
351
|
+
def tm(
|
|
352
|
+
self,
|
|
353
|
+
sequence: DNASequence,
|
|
354
|
+
*,
|
|
355
|
+
conditions: ThermodynamicConditions | None = None,
|
|
356
|
+
) -> Primer3ThermodynamicResult:
|
|
357
|
+
self.ensure_available("tm")
|
|
358
|
+
symbols = canonical_linear_symbols(
|
|
359
|
+
sequence, operation="Primer3 oligotm", min_length=2, max_length=36
|
|
360
|
+
)
|
|
361
|
+
resolved = _conditions(conditions)
|
|
362
|
+
assert self._oligotm_path is not None
|
|
363
|
+
output = self._run(
|
|
364
|
+
self._oligotm_path,
|
|
365
|
+
self._oligotm_arguments(symbols, resolved),
|
|
366
|
+
"tm",
|
|
367
|
+
)
|
|
368
|
+
try:
|
|
369
|
+
tm_celsius = float(output.strip())
|
|
370
|
+
if not math.isfinite(tm_celsius):
|
|
371
|
+
raise ValueError
|
|
372
|
+
except ValueError as exc:
|
|
373
|
+
raise BackendExecutionError(
|
|
374
|
+
"oligotm returned invalid Tm output.",
|
|
375
|
+
code="INVALID_PRIMER3_CLI_OUTPUT",
|
|
376
|
+
context={"capability": "tm", "output_excerpt": output[:1_000]},
|
|
377
|
+
) from exc
|
|
378
|
+
info = self.info
|
|
379
|
+
return Primer3ThermodynamicResult(
|
|
380
|
+
capability="tm",
|
|
381
|
+
sequences_5to3=(symbols,),
|
|
382
|
+
structure_found=None,
|
|
383
|
+
tm_celsius=tm_celsius,
|
|
384
|
+
delta_g_kcal_per_mol=None,
|
|
385
|
+
delta_h_kcal_per_mol=None,
|
|
386
|
+
delta_s_cal_per_k_mol=None,
|
|
387
|
+
ascii_structure=None,
|
|
388
|
+
conditions=resolved,
|
|
389
|
+
max_loop=None,
|
|
390
|
+
method="primer3-cli-oligotm-santalucia",
|
|
391
|
+
algorithm_version="dnakit-primer3-cli-adapter-v1",
|
|
392
|
+
backend=info,
|
|
393
|
+
provenance=_adapter_provenance(info),
|
|
394
|
+
issues=(),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
def _structure(
|
|
398
|
+
self,
|
|
399
|
+
capability: str,
|
|
400
|
+
sequence_a: DNASequence,
|
|
401
|
+
sequence_b: DNASequence | None,
|
|
402
|
+
*,
|
|
403
|
+
conditions: ThermodynamicConditions | None,
|
|
404
|
+
max_loop: int,
|
|
405
|
+
output_structure: bool,
|
|
406
|
+
) -> Primer3ThermodynamicResult:
|
|
407
|
+
self.ensure_available(capability)
|
|
408
|
+
if not isinstance(output_structure, bool):
|
|
409
|
+
raise ConfigurationError("output_structure must be boolean.")
|
|
410
|
+
symbols_a = canonical_linear_symbols(
|
|
411
|
+
sequence_a, operation=f"Primer3 {capability}", min_length=1, max_length=60
|
|
412
|
+
)
|
|
413
|
+
symbols_b = (
|
|
414
|
+
None
|
|
415
|
+
if sequence_b is None
|
|
416
|
+
else canonical_linear_symbols(
|
|
417
|
+
sequence_b, operation=f"Primer3 {capability}", min_length=1, max_length=60
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
resolved = _conditions(conditions)
|
|
421
|
+
if resolved.dmso_percent != 0.0 or resolved.formamide_molar != 0.0:
|
|
422
|
+
raise ConfigurationError(
|
|
423
|
+
"ntthal does not expose DMSO or formamide condition flags.",
|
|
424
|
+
code="PRIMER3_NTTHAL_COSOLVENT_UNSUPPORTED",
|
|
425
|
+
)
|
|
426
|
+
loop = _max_loop(max_loop)
|
|
427
|
+
mode = "HAIRPIN" if capability == "hairpin" else "ANY"
|
|
428
|
+
arguments = [
|
|
429
|
+
"-a",
|
|
430
|
+
mode,
|
|
431
|
+
"-mv",
|
|
432
|
+
_number_argument(resolved.monovalent_molar * 1_000.0),
|
|
433
|
+
"-dv",
|
|
434
|
+
_number_argument(resolved.magnesium_molar * 1_000.0),
|
|
435
|
+
"-n",
|
|
436
|
+
_number_argument(resolved.dntp_molar * 1_000.0),
|
|
437
|
+
"-d",
|
|
438
|
+
_number_argument(resolved.strand_concentration_molar * 1_000_000_000.0),
|
|
439
|
+
"-t",
|
|
440
|
+
_number_argument(resolved.temperature_celsius),
|
|
441
|
+
"-maxloop",
|
|
442
|
+
str(loop),
|
|
443
|
+
]
|
|
444
|
+
if self._parameters_path is not None:
|
|
445
|
+
arguments.extend(("-path", self._parameters_path))
|
|
446
|
+
arguments.extend(("-s1", symbols_a))
|
|
447
|
+
if capability == "self_dimer":
|
|
448
|
+
arguments.extend(("-s2", symbols_a))
|
|
449
|
+
elif capability == "heterodimer":
|
|
450
|
+
if symbols_b is None:
|
|
451
|
+
raise AssertionError("Heterodimer calculations require a second sequence.")
|
|
452
|
+
arguments.extend(("-s2", symbols_b))
|
|
453
|
+
assert self._ntthal_path is not None
|
|
454
|
+
output = self._run(self._ntthal_path, tuple(arguments), capability)
|
|
455
|
+
lines = output.splitlines()
|
|
456
|
+
match = _THERMODYNAMIC_LINE.fullmatch(lines[0].strip()) if lines else None
|
|
457
|
+
issues: tuple[Issue, ...] = ()
|
|
458
|
+
if match is None and lines and _NO_STRUCTURE_LINE.match(lines[0].strip()):
|
|
459
|
+
structure_found = False
|
|
460
|
+
tm_celsius = delta_g = delta_h = delta_s = 0.0
|
|
461
|
+
ascii_structure = None
|
|
462
|
+
issues = (
|
|
463
|
+
Issue(
|
|
464
|
+
"PRIMER3_NO_STRUCTURE",
|
|
465
|
+
IssueSeverity.INFO,
|
|
466
|
+
"ntthal reported no predicted secondary structure.",
|
|
467
|
+
),
|
|
468
|
+
)
|
|
469
|
+
elif match is not None:
|
|
470
|
+
try:
|
|
471
|
+
tm_celsius = float(match.group("tm"))
|
|
472
|
+
delta_g = float(match.group("dg")) / 1_000.0
|
|
473
|
+
delta_h = float(match.group("dh")) / 1_000.0
|
|
474
|
+
delta_s = float(match.group("ds"))
|
|
475
|
+
if not all(math.isfinite(item) for item in (tm_celsius, delta_g, delta_h, delta_s)):
|
|
476
|
+
raise ValueError
|
|
477
|
+
except ValueError as exc:
|
|
478
|
+
raise BackendExecutionError(
|
|
479
|
+
"ntthal returned non-finite thermodynamic output.",
|
|
480
|
+
code="INVALID_PRIMER3_CLI_OUTPUT",
|
|
481
|
+
context={"capability": capability},
|
|
482
|
+
) from exc
|
|
483
|
+
structure_found = True
|
|
484
|
+
ascii_structure = "\n".join(lines[1:]) if output_structure and len(lines) > 1 else None
|
|
485
|
+
else:
|
|
486
|
+
raise BackendExecutionError(
|
|
487
|
+
"ntthal returned unrecognized thermodynamic output.",
|
|
488
|
+
code="INVALID_PRIMER3_CLI_OUTPUT",
|
|
489
|
+
context={"capability": capability, "output_excerpt": output[:1_000]},
|
|
490
|
+
)
|
|
491
|
+
info = self.info
|
|
492
|
+
return Primer3ThermodynamicResult(
|
|
493
|
+
capability=capability,
|
|
494
|
+
sequences_5to3=(symbols_a,) if symbols_b is None else (symbols_a, symbols_b),
|
|
495
|
+
structure_found=structure_found,
|
|
496
|
+
tm_celsius=tm_celsius,
|
|
497
|
+
delta_g_kcal_per_mol=delta_g,
|
|
498
|
+
delta_h_kcal_per_mol=delta_h,
|
|
499
|
+
delta_s_cal_per_k_mol=delta_s,
|
|
500
|
+
ascii_structure=ascii_structure,
|
|
501
|
+
conditions=resolved,
|
|
502
|
+
max_loop=loop,
|
|
503
|
+
method=f"primer3-cli-ntthal-{capability.replace('_', '-')}",
|
|
504
|
+
algorithm_version="dnakit-primer3-cli-adapter-v1",
|
|
505
|
+
backend=info,
|
|
506
|
+
provenance=_adapter_provenance(info),
|
|
507
|
+
issues=issues,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def hairpin(
|
|
511
|
+
self,
|
|
512
|
+
sequence: DNASequence,
|
|
513
|
+
*,
|
|
514
|
+
conditions: ThermodynamicConditions | None = None,
|
|
515
|
+
max_loop: int = 30,
|
|
516
|
+
output_structure: bool = False,
|
|
517
|
+
) -> Primer3ThermodynamicResult:
|
|
518
|
+
return self._structure(
|
|
519
|
+
"hairpin",
|
|
520
|
+
sequence,
|
|
521
|
+
None,
|
|
522
|
+
conditions=conditions,
|
|
523
|
+
max_loop=max_loop,
|
|
524
|
+
output_structure=output_structure,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
def self_dimer(
|
|
528
|
+
self,
|
|
529
|
+
sequence: DNASequence,
|
|
530
|
+
*,
|
|
531
|
+
conditions: ThermodynamicConditions | None = None,
|
|
532
|
+
max_loop: int = 30,
|
|
533
|
+
output_structure: bool = False,
|
|
534
|
+
) -> Primer3ThermodynamicResult:
|
|
535
|
+
return self._structure(
|
|
536
|
+
"self_dimer",
|
|
537
|
+
sequence,
|
|
538
|
+
None,
|
|
539
|
+
conditions=conditions,
|
|
540
|
+
max_loop=max_loop,
|
|
541
|
+
output_structure=output_structure,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def heterodimer(
|
|
545
|
+
self,
|
|
546
|
+
sequence_a: DNASequence,
|
|
547
|
+
sequence_b: DNASequence,
|
|
548
|
+
*,
|
|
549
|
+
conditions: ThermodynamicConditions | None = None,
|
|
550
|
+
max_loop: int = 30,
|
|
551
|
+
output_structure: bool = False,
|
|
552
|
+
) -> Primer3ThermodynamicResult:
|
|
553
|
+
return self._structure(
|
|
554
|
+
"heterodimer",
|
|
555
|
+
sequence_a,
|
|
556
|
+
sequence_b,
|
|
557
|
+
conditions=conditions,
|
|
558
|
+
max_loop=max_loop,
|
|
559
|
+
output_structure=output_structure,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def probe_primer3(
|
|
564
|
+
*,
|
|
565
|
+
oligotm_path: str | Path | None = None,
|
|
566
|
+
ntthal_path: str | Path | None = None,
|
|
567
|
+
thermodynamic_parameters_path: str | Path | None = None,
|
|
568
|
+
) -> BackendInfo:
|
|
569
|
+
"""Inspect explicit Primer3 paths without searching, importing, or executing."""
|
|
570
|
+
|
|
571
|
+
return Primer3CLIAdapter(
|
|
572
|
+
oligotm_path=oligotm_path,
|
|
573
|
+
ntthal_path=ntthal_path,
|
|
574
|
+
thermodynamic_parameters_path=thermodynamic_parameters_path,
|
|
575
|
+
).info
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def conditional_capabilities(
|
|
579
|
+
*,
|
|
580
|
+
primer3_ntthal_path: str | Path | None = None,
|
|
581
|
+
primer3_thermodynamic_parameters_path: str | Path | None = None,
|
|
582
|
+
) -> tuple[ConditionalCapability, ...]:
|
|
583
|
+
"""Describe conditional structure capabilities without automatic discovery."""
|
|
584
|
+
|
|
585
|
+
primer3 = probe_primer3(
|
|
586
|
+
ntthal_path=primer3_ntthal_path,
|
|
587
|
+
thermodynamic_parameters_path=primer3_thermodynamic_parameters_path,
|
|
588
|
+
)
|
|
589
|
+
from dnakit.secondary_structure import probe_nupack
|
|
590
|
+
|
|
591
|
+
nupack = probe_nupack()
|
|
592
|
+
execution_supported = all(
|
|
593
|
+
capability in primer3.capabilities for capability in _PRIMER3_STRUCTURE_CAPABILITIES
|
|
594
|
+
)
|
|
595
|
+
status = "available-user-supplied-cli" if execution_supported else "conditional-unavailable"
|
|
596
|
+
return (
|
|
597
|
+
ConditionalCapability(
|
|
598
|
+
"THERMO-008",
|
|
599
|
+
"hairpin",
|
|
600
|
+
status,
|
|
601
|
+
execution_supported,
|
|
602
|
+
False,
|
|
603
|
+
False,
|
|
604
|
+
("Primer3 CLI", "NUPACK-manual-only"),
|
|
605
|
+
primer3,
|
|
606
|
+
"Requires an independently installed Primer3 ntthal executable and explicit path.",
|
|
607
|
+
),
|
|
608
|
+
ConditionalCapability(
|
|
609
|
+
"THERMO-009",
|
|
610
|
+
"self_dimer",
|
|
611
|
+
status,
|
|
612
|
+
execution_supported,
|
|
613
|
+
False,
|
|
614
|
+
False,
|
|
615
|
+
("Primer3 CLI", "NUPACK-manual-only"),
|
|
616
|
+
primer3,
|
|
617
|
+
"Requires an independently installed Primer3 ntthal executable and explicit path.",
|
|
618
|
+
),
|
|
619
|
+
ConditionalCapability(
|
|
620
|
+
"THERMO-010",
|
|
621
|
+
"heterodimer",
|
|
622
|
+
status,
|
|
623
|
+
execution_supported,
|
|
624
|
+
False,
|
|
625
|
+
False,
|
|
626
|
+
("Primer3 CLI", "NUPACK-manual-only"),
|
|
627
|
+
primer3,
|
|
628
|
+
"Requires an independently installed Primer3 ntthal executable and explicit path.",
|
|
629
|
+
),
|
|
630
|
+
ConditionalCapability(
|
|
631
|
+
"THERMO-011",
|
|
632
|
+
"secondary_structure",
|
|
633
|
+
("available-user-installed-adapter" if nupack.available else "conditional-unavailable"),
|
|
634
|
+
nupack.available,
|
|
635
|
+
False,
|
|
636
|
+
False,
|
|
637
|
+
("NUPACK",),
|
|
638
|
+
nupack,
|
|
639
|
+
(
|
|
640
|
+
"A bounded explicit adapter is available, but NUPACK must be separately "
|
|
641
|
+
"licensed and installed; DNAKit never installs or downloads it automatically."
|
|
642
|
+
),
|
|
643
|
+
),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
__all__ = [
|
|
648
|
+
"Primer3CLIAdapter",
|
|
649
|
+
"ThermodynamicsBackend",
|
|
650
|
+
"conditional_capabilities",
|
|
651
|
+
"probe_primer3",
|
|
652
|
+
"validate_primer3_result",
|
|
653
|
+
]
|