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.
Files changed (210) hide show
  1. dnakit/__init__.py +108 -0
  2. dnakit/_version.py +5 -0
  3. dnakit/alignment/__init__.py +6 -0
  4. dnakit/alignment/pairwise.py +512 -0
  5. dnakit/alignment/results.py +188 -0
  6. dnakit/backends/__init__.py +80 -0
  7. dnakit/backends/external.py +517 -0
  8. dnakit/backends/registry.py +207 -0
  9. dnakit/batch.py +452 -0
  10. dnakit/cache/__init__.py +5 -0
  11. dnakit/cache/store.py +424 -0
  12. dnakit/chunking.py +920 -0
  13. dnakit/cli/__init__.py +5 -0
  14. dnakit/cli/__main__.py +6 -0
  15. dnakit/cli/app.py +814 -0
  16. dnakit/cli/workflow.py +94 -0
  17. dnakit/config/__init__.py +5 -0
  18. dnakit/config/loader.py +381 -0
  19. dnakit/core/__init__.py +80 -0
  20. dnakit/core/_json.py +206 -0
  21. dnakit/core/backend_info.py +70 -0
  22. dnakit/core/collection.py +163 -0
  23. dnakit/core/coordinates.py +295 -0
  24. dnakit/core/enums.py +95 -0
  25. dnakit/core/facade.py +719 -0
  26. dnakit/core/feature.py +90 -0
  27. dnakit/core/gap.py +82 -0
  28. dnakit/core/issues.py +62 -0
  29. dnakit/core/provenance.py +305 -0
  30. dnakit/core/record.py +161 -0
  31. dnakit/core/results.py +134 -0
  32. dnakit/core/sequence.py +327 -0
  33. dnakit/datasets/__init__.py +100 -0
  34. dnakit/datasets/_advanced_shared.py +198 -0
  35. dnakit/datasets/_metadata.py +33 -0
  36. dnakit/datasets/advanced_deduplicate.py +183 -0
  37. dnakit/datasets/advanced_split.py +302 -0
  38. dnakit/datasets/clustering.py +286 -0
  39. dnakit/datasets/config.py +531 -0
  40. dnakit/datasets/deduplicate.py +303 -0
  41. dnakit/datasets/evaluation.py +244 -0
  42. dnakit/datasets/filter.py +135 -0
  43. dnakit/datasets/neural_clustering.py +221 -0
  44. dnakit/datasets/results.py +520 -0
  45. dnakit/datasets/split.py +450 -0
  46. dnakit/descriptors/__init__.py +102 -0
  47. dnakit/descriptors/_dinucleotide.py +345 -0
  48. dnakit/descriptors/_shared.py +180 -0
  49. dnakit/descriptors/all.py +917 -0
  50. dnakit/descriptors/basic.py +193 -0
  51. dnakit/descriptors/codon.py +195 -0
  52. dnakit/descriptors/complexity.py +201 -0
  53. dnakit/descriptors/entropy.py +101 -0
  54. dnakit/descriptors/homopolymer.py +105 -0
  55. dnakit/descriptors/kmer.py +88 -0
  56. dnakit/descriptors/results.py +286 -0
  57. dnakit/descriptors/schema.py +414 -0
  58. dnakit/descriptors/window.py +203 -0
  59. dnakit/download/__init__.py +54 -0
  60. dnakit/download/catalogs.py +166 -0
  61. dnakit/download/ena.py +131 -0
  62. dnakit/download/encode.py +103 -0
  63. dnakit/download/files.py +469 -0
  64. dnakit/download/indexes.py +233 -0
  65. dnakit/download/metadata.py +164 -0
  66. dnakit/download/models.py +227 -0
  67. dnakit/download/ncbi.py +333 -0
  68. dnakit/download/sequence.py +133 -0
  69. dnakit/download/ucsc.py +99 -0
  70. dnakit/evaluation/__init__.py +81 -0
  71. dnakit/evaluation/_shared.py +288 -0
  72. dnakit/evaluation/collection.py +270 -0
  73. dnakit/evaluation/config.py +591 -0
  74. dnakit/evaluation/distribution.py +287 -0
  75. dnakit/evaluation/frechet.py +223 -0
  76. dnakit/evaluation/generative.py +381 -0
  77. dnakit/evaluation/reference.py +409 -0
  78. dnakit/evaluation/results.py +137 -0
  79. dnakit/evaluation/scorecard.py +211 -0
  80. dnakit/evaluation/sequence.py +403 -0
  81. dnakit/evaluation/synthesis.py +249 -0
  82. dnakit/exceptions.py +189 -0
  83. dnakit/fingerprints/__init__.py +109 -0
  84. dnakit/fingerprints/_shared.py +116 -0
  85. dnakit/fingerprints/advanced.py +829 -0
  86. dnakit/fingerprints/bit.py +229 -0
  87. dnakit/fingerprints/encoding.py +351 -0
  88. dnakit/fingerprints/kmer.py +170 -0
  89. dnakit/fingerprints/results.py +531 -0
  90. dnakit/fingerprints/sketch.py +246 -0
  91. dnakit/io/__init__.py +112 -0
  92. dnakit/io/_advanced_common.py +105 -0
  93. dnakit/io/_formats.py +994 -0
  94. dnakit/io/annotations.py +1179 -0
  95. dnakit/io/api.py +759 -0
  96. dnakit/io/config.py +315 -0
  97. dnakit/io/genbank.py +523 -0
  98. dnakit/io/indexed.py +1146 -0
  99. dnakit/io/metadata.py +452 -0
  100. dnakit/io/parquet.py +56 -0
  101. dnakit/io/results.py +131 -0
  102. dnakit/io/source.py +138 -0
  103. dnakit/io/tables.py +1237 -0
  104. dnakit/molbio/__init__.py +101 -0
  105. dnakit/molbio/_shared.py +266 -0
  106. dnakit/molbio/assembly.py +279 -0
  107. dnakit/molbio/crispr.py +181 -0
  108. dnakit/molbio/optimization.py +897 -0
  109. dnakit/molbio/primer3_design.py +667 -0
  110. dnakit/molbio/primers.py +899 -0
  111. dnakit/molbio/restriction.py +573 -0
  112. dnakit/molbio/results.py +583 -0
  113. dnakit/ops/__init__.py +588 -0
  114. dnakit/ops/_common.py +201 -0
  115. dnakit/ops/circular.py +193 -0
  116. dnakit/ops/concat.py +204 -0
  117. dnakit/ops/direction.py +27 -0
  118. dnakit/ops/edit.py +443 -0
  119. dnakit/ops/evolution.py +991 -0
  120. dnakit/ops/mutation.py +151 -0
  121. dnakit/ops/records.py +1012 -0
  122. dnakit/ops/translation.py +257 -0
  123. dnakit/patterns/__init__.py +81 -0
  124. dnakit/patterns/_shared.py +282 -0
  125. dnakit/patterns/coding.py +360 -0
  126. dnakit/patterns/crispr.py +337 -0
  127. dnakit/patterns/motif.py +721 -0
  128. dnakit/patterns/regions.py +341 -0
  129. dnakit/patterns/repeats.py +480 -0
  130. dnakit/patterns/restriction.py +281 -0
  131. dnakit/patterns/results.py +411 -0
  132. dnakit/py.typed +1 -0
  133. dnakit/references/__init__.py +27 -0
  134. dnakit/references/models.py +155 -0
  135. dnakit/references/ncbi.py +785 -0
  136. dnakit/representations/__init__.py +29 -0
  137. dnakit/representations/backends.py +928 -0
  138. dnakit/representations/checkpoints.py +356 -0
  139. dnakit/representations/extraction.py +220 -0
  140. dnakit/representations/models.py +395 -0
  141. dnakit/representations/results.py +79 -0
  142. dnakit/search/__init__.py +132 -0
  143. dnakit/search/_http.py +271 -0
  144. dnakit/search/_shared.py +108 -0
  145. dnakit/search/blast.py +516 -0
  146. dnakit/search/ena.py +245 -0
  147. dnakit/search/encode.py +102 -0
  148. dnakit/search/ensembl.py +874 -0
  149. dnakit/search/models.py +159 -0
  150. dnakit/search/ncbi.py +756 -0
  151. dnakit/search/ucsc.py +388 -0
  152. dnakit/secondary_structure/__init__.py +38 -0
  153. dnakit/secondary_structure/dotbracket.py +410 -0
  154. dnakit/secondary_structure/nupack.py +589 -0
  155. dnakit/secondary_structure/results.py +142 -0
  156. dnakit/similarity/__init__.py +125 -0
  157. dnakit/similarity/_shared.py +152 -0
  158. dnakit/similarity/approximate.py +258 -0
  159. dnakit/similarity/compare.py +162 -0
  160. dnakit/similarity/dashing.py +936 -0
  161. dnakit/similarity/distance.py +279 -0
  162. dnakit/similarity/index.py +365 -0
  163. dnakit/similarity/matrix.py +254 -0
  164. dnakit/similarity/results.py +676 -0
  165. dnakit/similarity/search.py +286 -0
  166. dnakit/similarity/sketch.py +96 -0
  167. dnakit/similarity/vector.py +662 -0
  168. dnakit/standardize/__init__.py +77 -0
  169. dnakit/standardize/_shared.py +100 -0
  170. dnakit/standardize/config.py +411 -0
  171. dnakit/standardize/gaps.py +1060 -0
  172. dnakit/standardize/normalize.py +565 -0
  173. dnakit/standardize/results.py +247 -0
  174. dnakit/standardize/validate.py +487 -0
  175. dnakit/structure3d/__init__.py +47 -0
  176. dnakit/structure3d/_shared.py +47 -0
  177. dnakit/structure3d/geometry.py +773 -0
  178. dnakit/structure3d/pdb.py +253 -0
  179. dnakit/structure3d/results.py +233 -0
  180. dnakit/structure3d/threedna.py +174 -0
  181. dnakit/thermodynamics/__init__.py +112 -0
  182. dnakit/thermodynamics/_shared.py +92 -0
  183. dnakit/thermodynamics/backends.py +653 -0
  184. dnakit/thermodynamics/calculations.py +837 -0
  185. dnakit/thermodynamics/config.py +175 -0
  186. dnakit/thermodynamics/equilibrium.py +340 -0
  187. dnakit/thermodynamics/optics.py +385 -0
  188. dnakit/thermodynamics/parameters.py +79 -0
  189. dnakit/thermodynamics/results.py +445 -0
  190. dnakit/visualization/__init__.py +43 -0
  191. dnakit/visualization/_svg.py +185 -0
  192. dnakit/visualization/advanced.py +314 -0
  193. dnakit/visualization/config.py +255 -0
  194. dnakit/visualization/export.py +349 -0
  195. dnakit/visualization/matrix.py +279 -0
  196. dnakit/visualization/report.py +431 -0
  197. dnakit/visualization/results.py +236 -0
  198. dnakit/visualization/sequence.py +459 -0
  199. dnakit/workflows/__init__.py +45 -0
  200. dnakit/workflows/manifest.py +365 -0
  201. dnakit/workflows/runner.py +1548 -0
  202. dnakit/workflows/schema.py +898 -0
  203. dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
  204. dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
  205. dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
  206. dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  207. dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
  208. dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
  209. dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
  210. 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
+ ]