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,667 @@
1
+ """Explicit bounded adapter for caller-installed ``primer3_core``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import re
8
+ import stat
9
+ import tempfile
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+
13
+ from dnakit.backends import execute_bounded_command
14
+ from dnakit.core import (
15
+ BackendInfo,
16
+ Citation,
17
+ ExecutionMode,
18
+ ImplementationInfo,
19
+ ImplementationLabel,
20
+ Issue,
21
+ IssueSeverity,
22
+ OriginClass,
23
+ Provenance,
24
+ )
25
+ from dnakit.exceptions import BackendExecutionError, BackendUnavailableError, ConfigurationError
26
+
27
+ from ._shared import freeze_parameters, reverse_complement_text, validate_positive_int
28
+ from .results import PrimerDesignCandidate, PrimerDesignInterfaceResult
29
+
30
+ _BOULDER_KEY = re.compile(r"^[A-Z][A-Z0-9_]{0,255}$")
31
+ _POSITION_KEY = re.compile(r"^PRIMER_(?:LEFT|RIGHT)_\d+$")
32
+ _INTEGER_KEY = re.compile(r"(?:_NUM_RETURNED|_PRODUCT_SIZE)$")
33
+ _FLOAT_KEY = re.compile(r"(?:_TM|_GC_PERCENT|_PENALTY)$")
34
+
35
+
36
+ def _explicit_executable(value: str | Path | None) -> tuple[str | None, str | None]:
37
+ if value is None:
38
+ return None, None
39
+ if not isinstance(value, (str, Path)):
40
+ raise ConfigurationError(
41
+ "primer3_core_path must be an explicit filesystem path or None.",
42
+ code="INVALID_PRIMER3_EXECUTABLE",
43
+ )
44
+ requested = str(value)
45
+ if (
46
+ not requested.strip()
47
+ or any(character in requested for character in ("\x00", "\r", "\n"))
48
+ or (not Path(requested).is_absolute() and "/" not in requested and "\\" not in requested)
49
+ ):
50
+ raise ConfigurationError(
51
+ "primer3_core_path must be explicit, not a PATH command name.",
52
+ code="INVALID_PRIMER3_EXECUTABLE",
53
+ )
54
+ try:
55
+ resolved = Path(requested).expanduser().resolve(strict=True)
56
+ details = resolved.stat()
57
+ except (OSError, RuntimeError, ValueError):
58
+ return requested, None
59
+ if not stat.S_ISREG(details.st_mode) or not os.access(resolved, os.X_OK):
60
+ return requested, None
61
+ return requested, str(resolved)
62
+
63
+
64
+ def _explicit_parameter_directory(value: str | Path | None) -> tuple[str | None, str | None]:
65
+ if value is None:
66
+ return None, None
67
+ if not isinstance(value, (str, Path)) or not str(value).strip():
68
+ raise ConfigurationError(
69
+ "thermodynamic_parameters_path must be an explicit directory path or None.",
70
+ code="INVALID_PRIMER3_PARAMETER_PATH",
71
+ )
72
+ requested = str(value)
73
+ try:
74
+ resolved = Path(requested).expanduser().resolve(strict=True)
75
+ except (OSError, RuntimeError, ValueError):
76
+ return requested, None
77
+ return requested, str(resolved) if resolved.is_dir() else None
78
+
79
+
80
+ def _provenance(info: BackendInfo) -> Provenance:
81
+ return Provenance(
82
+ implementation=ImplementationInfo(
83
+ label=ImplementationLabel.ADAPTER,
84
+ execution_mode=ExecutionMode.EXTERNAL,
85
+ origin_class=OriginClass.INTEGRATION,
86
+ license_expression=info.license_expression,
87
+ citations=(
88
+ Citation(
89
+ "primer3",
90
+ title="Primer3--new capabilities and interfaces",
91
+ doi="10.1093/nar/gks596",
92
+ ),
93
+ ),
94
+ ),
95
+ backend=info,
96
+ )
97
+
98
+
99
+ def _bounded_text(value: object, name: str, *, max_length: int) -> str:
100
+ if not isinstance(value, str) or len(value) > max_length:
101
+ raise BackendExecutionError(
102
+ f"Primer3 output {name} must be bounded text.",
103
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
104
+ context={"field": name},
105
+ )
106
+ return value
107
+
108
+
109
+ def _output_int(value: object, name: str, *, minimum: int = 0) -> int:
110
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
111
+ raise BackendExecutionError(
112
+ f"Primer3 output {name} must be an integer >= {minimum}.",
113
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
114
+ context={"field": name},
115
+ )
116
+ return value
117
+
118
+
119
+ def _output_float(value: object, name: str) -> float:
120
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
121
+ raise BackendExecutionError(
122
+ f"Primer3 output {name} must be finite numeric data.",
123
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
124
+ context={"field": name},
125
+ )
126
+ return float(value)
127
+
128
+
129
+ def _position_length(value: object, name: str) -> tuple[int, int]:
130
+ if not isinstance(value, (tuple, list)) or len(value) != 2:
131
+ raise BackendExecutionError(
132
+ f"Primer3 output {name} must contain position and length.",
133
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
134
+ context={"field": name},
135
+ )
136
+ return (
137
+ _output_int(value[0], f"{name}[0]"),
138
+ _output_int(value[1], f"{name}[1]", minimum=1),
139
+ )
140
+
141
+
142
+ def _condition_number(conditions: Mapping[str, object], name: str) -> float:
143
+ value = conditions[name]
144
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
145
+ raise ConfigurationError(f"Primer design condition {name} must be numeric.")
146
+ return float(value)
147
+
148
+
149
+ def _boulder_value(key: str, value: object) -> str:
150
+ if key == "SEQUENCE_TARGET":
151
+ if not isinstance(value, list) or len(value) != 2:
152
+ raise AssertionError("SEQUENCE_TARGET must contain start and length.")
153
+ text = f"{value[0]},{value[1]}"
154
+ elif key == "SEQUENCE_EXCLUDED_REGION":
155
+ if not isinstance(value, list):
156
+ raise AssertionError("SEQUENCE_EXCLUDED_REGION must be a list.")
157
+ text = " ".join(f"{item[0]},{item[1]}" for item in value)
158
+ elif key == "PRIMER_PRODUCT_SIZE_RANGE":
159
+ if not isinstance(value, list) or len(value) != 1:
160
+ raise AssertionError("PRIMER_PRODUCT_SIZE_RANGE must contain one interval.")
161
+ text = f"{value[0][0]}-{value[0][1]}"
162
+ elif isinstance(value, bool):
163
+ text = "1" if value else "0"
164
+ elif isinstance(value, (int, float, str)):
165
+ text = str(value)
166
+ else:
167
+ raise ConfigurationError(
168
+ "Primer3 request contains an unsupported Boulder-IO value.",
169
+ code="INVALID_PRIMER3_DESIGN_INPUT",
170
+ context={"key": key, "value_type": type(value).__name__},
171
+ )
172
+ if any(character in text for character in ("\x00", "\r", "\n")):
173
+ raise ConfigurationError(
174
+ "Primer3 Boulder-IO values cannot contain line breaks or NUL.",
175
+ code="INVALID_PRIMER3_DESIGN_INPUT",
176
+ context={"key": key},
177
+ )
178
+ return text
179
+
180
+
181
+ def _boulder_record(sequence_args: Mapping[str, object], global_args: Mapping[str, object]) -> str:
182
+ lines = [
183
+ f"{key}={_boulder_value(key, value)}"
184
+ for arguments in (sequence_args, global_args)
185
+ for key, value in arguments.items()
186
+ ]
187
+ return "\n".join((*lines, "=", ""))
188
+
189
+
190
+ def _parse_boulder_value(key: str, value: str) -> object:
191
+ try:
192
+ if _POSITION_KEY.fullmatch(key):
193
+ parts = value.split(",")
194
+ if len(parts) != 2:
195
+ raise ValueError
196
+ return [int(parts[0]), int(parts[1])]
197
+ if _INTEGER_KEY.search(key):
198
+ return int(value)
199
+ if _FLOAT_KEY.search(key):
200
+ parsed = float(value)
201
+ if not math.isfinite(parsed):
202
+ raise ValueError
203
+ return parsed
204
+ except ValueError as exc:
205
+ raise BackendExecutionError(
206
+ "primer3_core returned malformed numeric Boulder-IO output.",
207
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
208
+ context={"key": key},
209
+ ) from exc
210
+ return value
211
+
212
+
213
+ def _parse_boulder_output(
214
+ text: str, *, max_result_keys: int, max_text_length: int
215
+ ) -> dict[str, object]:
216
+ result: dict[str, object] = {}
217
+ terminated = False
218
+ lines = text.splitlines()
219
+ for index, line in enumerate(lines):
220
+ if line == "=":
221
+ terminated = True
222
+ if any(trailing.strip() for trailing in lines[index + 1 :]):
223
+ raise BackendExecutionError(
224
+ "primer3_core returned data after its Boulder-IO record terminator.",
225
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
226
+ )
227
+ break
228
+ if "=" not in line:
229
+ raise BackendExecutionError(
230
+ "primer3_core returned malformed Boulder-IO output.",
231
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
232
+ )
233
+ key, value = line.split("=", 1)
234
+ if not _BOULDER_KEY.fullmatch(key) or key in result:
235
+ raise BackendExecutionError(
236
+ "primer3_core returned an invalid or duplicate Boulder-IO key.",
237
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
238
+ context={"key": key[:256]},
239
+ )
240
+ if len(value) > max_text_length:
241
+ raise BackendExecutionError(
242
+ "primer3_core returned an oversized Boulder-IO value.",
243
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
244
+ context={"key": key},
245
+ )
246
+ result[key] = _parse_boulder_value(key, value)
247
+ if len(result) > max_result_keys:
248
+ raise BackendExecutionError(
249
+ "primer3_core output exceeded max_result_keys.",
250
+ code="PRIMER3_DESIGN_OUTPUT_LIMIT_EXCEEDED",
251
+ )
252
+ if not terminated:
253
+ raise BackendExecutionError(
254
+ "primer3_core output did not terminate its Boulder-IO record.",
255
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
256
+ )
257
+ return result
258
+
259
+
260
+ def _read_bounded(path: Path, max_bytes: int) -> str:
261
+ try:
262
+ if not path.is_file():
263
+ raise OSError
264
+ with path.open("rb") as handle:
265
+ payload = handle.read(max_bytes + 1)
266
+ if len(payload) > max_bytes:
267
+ raise OSError
268
+ return payload.decode("utf-8")
269
+ except (OSError, UnicodeDecodeError) as exc:
270
+ raise BackendExecutionError(
271
+ "primer3_core output artifact is missing, oversized, or invalid UTF-8.",
272
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
273
+ context={"artifact": path.name, "max_bytes": max_bytes},
274
+ ) from exc
275
+
276
+
277
+ class Primer3CLIDesignAdapter:
278
+ """Run primer design through an explicit user-installed ``primer3_core`` path."""
279
+
280
+ def __init__(
281
+ self,
282
+ primer3_core_path: str | Path | None = None,
283
+ *,
284
+ thermodynamic_parameters_path: str | Path | None = None,
285
+ timeout_seconds: float = 60.0,
286
+ max_output_bytes: int = 10_000_000,
287
+ ) -> None:
288
+ self._requested_executable, self._path = _explicit_executable(primer3_core_path)
289
+ self._requested_parameters, self._parameters_path = _explicit_parameter_directory(
290
+ thermodynamic_parameters_path
291
+ )
292
+ if (
293
+ isinstance(timeout_seconds, bool)
294
+ or not isinstance(timeout_seconds, (int, float))
295
+ or not 0 < timeout_seconds <= 300
296
+ ):
297
+ raise ConfigurationError("timeout_seconds must be in (0, 300].")
298
+ if (
299
+ isinstance(max_output_bytes, bool)
300
+ or not isinstance(max_output_bytes, int)
301
+ or not 1 <= max_output_bytes <= 100_000_000
302
+ ):
303
+ raise ConfigurationError("max_output_bytes must be in [1, 100000000].")
304
+ self._timeout_seconds = float(timeout_seconds)
305
+ self._max_output_bytes = max_output_bytes
306
+
307
+ @property
308
+ def info(self) -> BackendInfo:
309
+ parameters_valid = self._requested_parameters is None or self._parameters_path is not None
310
+ available = self._path is not None and parameters_valid
311
+ return BackendInfo(
312
+ "primer3-cli",
313
+ executable_path=self._path,
314
+ license_expression="GPL-2.0-or-later",
315
+ capabilities=("primer_design",) if available else (),
316
+ available=available,
317
+ metadata={
318
+ "adapter_status": "explicit-cli-execution",
319
+ "requested_primer3_core_path": self._requested_executable,
320
+ "resolved_primer3_core_path": self._path,
321
+ "requested_thermodynamic_parameters_path": self._requested_parameters,
322
+ "resolved_thermodynamic_parameters_path": self._parameters_path,
323
+ "probe_mode": "explicit-path-stat-only",
324
+ "automatic_path_search": False,
325
+ "automatic_install": False,
326
+ "automatic_download": False,
327
+ "redistributed": False,
328
+ },
329
+ )
330
+
331
+ def ensure_available(self) -> None:
332
+ if self._requested_parameters is not None and self._parameters_path is None:
333
+ raise BackendUnavailableError(
334
+ "The explicit Primer3 thermodynamic parameter directory is unavailable.",
335
+ code="PRIMER3_PARAMETER_PATH_UNAVAILABLE",
336
+ context={"requested_path": self._requested_parameters},
337
+ )
338
+ if self._path is None:
339
+ raise BackendUnavailableError(
340
+ "The explicit primer3_core executable is unavailable.",
341
+ code="PRIMER3_CLI_UNAVAILABLE",
342
+ context={"requested_executable": self._requested_executable},
343
+ hint="Install Primer3 separately and pass primer3_core_path explicitly.",
344
+ )
345
+
346
+ @staticmethod
347
+ def _arguments(
348
+ result: PrimerDesignInterfaceResult,
349
+ ) -> tuple[dict[str, object], dict[str, object]]:
350
+ request = result.request
351
+ conditions = request.thermodynamic_conditions
352
+ primer_min, primer_max = request.primer_length_range
353
+ tm_min, tm_max = request.tm_range_celsius
354
+ gc_min, gc_max = request.gc_range
355
+ product_min, product_max = request.product_length_range
356
+ sequence_args: dict[str, object] = {
357
+ "SEQUENCE_ID": "dnakit-template",
358
+ "SEQUENCE_TEMPLATE": request.template.symbols,
359
+ "SEQUENCE_TARGET": [request.target_start, request.target_end - request.target_start],
360
+ "SEQUENCE_EXCLUDED_REGION": [
361
+ [start, end - start] for start, end in request.excluded_regions
362
+ ],
363
+ }
364
+ global_args: dict[str, object] = {
365
+ "PRIMER_TASK": "generic",
366
+ "PRIMER_PICK_LEFT_PRIMER": 1,
367
+ "PRIMER_PICK_INTERNAL_OLIGO": 0,
368
+ "PRIMER_PICK_RIGHT_PRIMER": 1,
369
+ "PRIMER_NUM_RETURN": request.candidate_count,
370
+ "PRIMER_OPT_SIZE": (primer_min + primer_max) // 2,
371
+ "PRIMER_MIN_SIZE": primer_min,
372
+ "PRIMER_MAX_SIZE": primer_max,
373
+ "PRIMER_OPT_TM": (tm_min + tm_max) / 2.0,
374
+ "PRIMER_MIN_TM": tm_min,
375
+ "PRIMER_MAX_TM": tm_max,
376
+ "PRIMER_MIN_GC": gc_min * 100.0,
377
+ "PRIMER_MAX_GC": gc_max * 100.0,
378
+ "PRIMER_PRODUCT_SIZE_RANGE": [[product_min, product_max]],
379
+ "PRIMER_SALT_MONOVALENT": (
380
+ _condition_number(conditions, "sodium_molar")
381
+ + _condition_number(conditions, "potassium_molar")
382
+ )
383
+ * 1_000.0,
384
+ "PRIMER_SALT_DIVALENT": _condition_number(conditions, "magnesium_molar") * 1_000.0,
385
+ "PRIMER_DNTP_CONC": _condition_number(conditions, "dntp_molar") * 1_000.0,
386
+ "PRIMER_DNA_CONC": _condition_number(conditions, "strand_concentration_molar")
387
+ * 1_000_000_000.0,
388
+ "PRIMER_DMSO_CONC": _condition_number(conditions, "dmso_percent"),
389
+ "PRIMER_DMSO_FACTOR": _condition_number(conditions, "dmso_factor_celsius_per_percent"),
390
+ "PRIMER_FORMAMIDE_CONC": _condition_number(conditions, "formamide_molar"),
391
+ }
392
+ return sequence_args, global_args
393
+
394
+ @staticmethod
395
+ def _candidate(
396
+ raw: Mapping[str, object],
397
+ *,
398
+ rank: int,
399
+ template: str,
400
+ max_text_length: int,
401
+ ) -> PrimerDesignCandidate:
402
+ template_length = len(template)
403
+ prefix = f"PRIMER_PAIR_{rank}"
404
+ left_position, left_length = _position_length(
405
+ raw.get(f"PRIMER_LEFT_{rank}"), f"left_{rank}"
406
+ )
407
+ right_anchor, right_length = _position_length(
408
+ raw.get(f"PRIMER_RIGHT_{rank}"), f"right_{rank}"
409
+ )
410
+ left_start = left_position
411
+ left_end = left_start + left_length
412
+ right_start = right_anchor - right_length + 1
413
+ right_end = right_anchor + 1
414
+ if not 0 <= left_start < left_end <= right_start < right_end <= template_length:
415
+ raise BackendExecutionError(
416
+ "Primer3 returned candidate coordinates outside the template.",
417
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
418
+ context={"candidate_rank": rank},
419
+ )
420
+ left_sequence = _bounded_text(
421
+ raw.get(f"PRIMER_LEFT_{rank}_SEQUENCE"),
422
+ f"left_sequence_{rank}",
423
+ max_length=max_text_length,
424
+ ).upper()
425
+ right_sequence = _bounded_text(
426
+ raw.get(f"PRIMER_RIGHT_{rank}_SEQUENCE"),
427
+ f"right_sequence_{rank}",
428
+ max_length=max_text_length,
429
+ ).upper()
430
+ expected_left = template[left_start:left_end]
431
+ expected_right = reverse_complement_text(template[right_start:right_end])
432
+ if (
433
+ len(left_sequence) != left_length
434
+ or len(right_sequence) != right_length
435
+ or left_sequence != expected_left
436
+ or right_sequence != expected_right
437
+ ):
438
+ raise BackendExecutionError(
439
+ "Primer3 returned candidate sequences inconsistent with template coordinates.",
440
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
441
+ context={"candidate_rank": rank},
442
+ )
443
+ penalty_value = raw.get(f"{prefix}_PENALTY")
444
+ try:
445
+ product_size = _output_int(raw.get(f"{prefix}_PRODUCT_SIZE"), "product_size", minimum=1)
446
+ if product_size != right_end - left_start:
447
+ raise ConfigurationError(
448
+ "Primer3 product size does not match candidate coordinates."
449
+ )
450
+ return PrimerDesignCandidate(
451
+ rank=rank,
452
+ left_primer_sequence=left_sequence,
453
+ right_primer_sequence=right_sequence,
454
+ left_start=left_start,
455
+ left_end=left_end,
456
+ right_start=right_start,
457
+ right_end=right_end,
458
+ product_size=product_size,
459
+ left_tm_celsius=_output_float(raw.get(f"PRIMER_LEFT_{rank}_TM"), f"left_tm_{rank}"),
460
+ right_tm_celsius=_output_float(
461
+ raw.get(f"PRIMER_RIGHT_{rank}_TM"), f"right_tm_{rank}"
462
+ ),
463
+ left_gc_fraction=_output_float(
464
+ raw.get(f"PRIMER_LEFT_{rank}_GC_PERCENT"), f"left_gc_{rank}"
465
+ )
466
+ / 100.0,
467
+ right_gc_fraction=_output_float(
468
+ raw.get(f"PRIMER_RIGHT_{rank}_GC_PERCENT"), f"right_gc_{rank}"
469
+ )
470
+ / 100.0,
471
+ pair_penalty=(
472
+ None
473
+ if penalty_value is None
474
+ else _output_float(penalty_value, f"pair_penalty_{rank}")
475
+ ),
476
+ )
477
+ except ConfigurationError as exc:
478
+ raise BackendExecutionError(
479
+ "Primer3 returned an internally inconsistent candidate.",
480
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
481
+ context={"candidate_rank": rank},
482
+ ) from exc
483
+
484
+ def _execute(
485
+ self,
486
+ record: str,
487
+ *,
488
+ max_result_keys: int,
489
+ max_text_length: int,
490
+ ) -> dict[str, object]:
491
+ self.ensure_available()
492
+ assert self._path is not None
493
+ with tempfile.TemporaryDirectory(prefix="dnakit-primer3-design-") as workspace_text:
494
+ workspace = Path(workspace_text)
495
+ input_path = workspace / "input.boulder"
496
+ output_path = workspace / "output.boulder"
497
+ error_path = workspace / "error.txt"
498
+ try:
499
+ with input_path.open("x", encoding="utf-8", newline="\n") as handle:
500
+ handle.write(record)
501
+ handle.flush()
502
+ os.fsync(handle.fileno())
503
+ except OSError as exc:
504
+ raise BackendExecutionError(
505
+ "DNAKit could not stage the primer3_core input.",
506
+ code="PRIMER3_DESIGN_INPUT_STAGING_FAILED",
507
+ ) from exc
508
+ arguments = (
509
+ "--strict_tags",
510
+ "--io_version=4",
511
+ "--output=output.boulder",
512
+ "--error=error.txt",
513
+ "input.boulder",
514
+ )
515
+ result = execute_bounded_command(
516
+ self._path,
517
+ arguments,
518
+ backend_id="primer3-cli",
519
+ cwd=workspace,
520
+ timeout_seconds=self._timeout_seconds,
521
+ max_output_bytes=min(self._max_output_bytes, 1_000_000),
522
+ monitored_output_paths=(output_path, error_path),
523
+ max_monitored_output_bytes=self._max_output_bytes,
524
+ )
525
+ error_text = ""
526
+ if error_path.exists():
527
+ error_text = _read_bounded(error_path, self._max_output_bytes)
528
+ if result.return_code != 0:
529
+ raise BackendExecutionError(
530
+ "primer3_core primer design failed.",
531
+ code="PRIMER3_CLI_EXECUTION_FAILED",
532
+ context={
533
+ "return_code": result.return_code,
534
+ "process_output_excerpt": result.output[:1_000],
535
+ "error_excerpt": error_text[:1_000],
536
+ },
537
+ )
538
+ output_text = _read_bounded(output_path, self._max_output_bytes)
539
+ return _parse_boulder_output(
540
+ output_text,
541
+ max_result_keys=max_result_keys,
542
+ max_text_length=max_text_length,
543
+ )
544
+
545
+ def design(
546
+ self,
547
+ prepared: PrimerDesignInterfaceResult,
548
+ *,
549
+ max_returned_candidates: int = 10_000,
550
+ max_result_keys: int = 100_000,
551
+ max_text_length: int = 100_000,
552
+ max_template_length: int = 1_000_000,
553
+ ) -> PrimerDesignInterfaceResult:
554
+ """Execute one prepared request; no discovery or execution happens earlier."""
555
+
556
+ if not isinstance(prepared, PrimerDesignInterfaceResult):
557
+ raise ConfigurationError("prepared must be a PrimerDesignInterfaceResult.")
558
+ if prepared.execution_performed:
559
+ raise ConfigurationError("A completed primer-design result cannot be executed again.")
560
+ validate_positive_int(max_returned_candidates, "max_returned_candidates", maximum=10_000)
561
+ validate_positive_int(max_result_keys, "max_result_keys", maximum=1_000_000)
562
+ validate_positive_int(max_text_length, "max_text_length", maximum=1_000_000)
563
+ validate_positive_int(
564
+ max_template_length,
565
+ "max_template_length",
566
+ maximum=10_000_000,
567
+ )
568
+ template_length = prepared.request.template.symbol_length
569
+ if template_length > max_template_length:
570
+ raise ConfigurationError(
571
+ "Primer design template exceeds max_template_length.",
572
+ code="PRIMER3_DESIGN_TEMPLATE_LIMIT_EXCEEDED",
573
+ context={
574
+ "template_length": template_length,
575
+ "max_template_length": max_template_length,
576
+ },
577
+ )
578
+ output_limit = min(prepared.request.candidate_count, max_returned_candidates)
579
+ sequence_args, global_args = self._arguments(prepared)
580
+ global_args["PRIMER_NUM_RETURN"] = output_limit
581
+ if self._parameters_path is not None:
582
+ global_args["PRIMER_THERMODYNAMIC_PARAMETERS_PATH"] = self._parameters_path + os.sep
583
+ raw = self._execute(
584
+ _boulder_record(sequence_args, global_args),
585
+ max_result_keys=max_result_keys,
586
+ max_text_length=max_text_length,
587
+ )
588
+ if len(raw) > max_result_keys or any(
589
+ not isinstance(key, str) or not key or len(key) > 256 for key in raw
590
+ ):
591
+ raise BackendExecutionError(
592
+ "primer3_core returned an invalid or oversized result mapping.",
593
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
594
+ )
595
+ if any(isinstance(value, str) and len(value) > max_text_length for value in raw.values()):
596
+ raise BackendExecutionError(
597
+ "primer3_core returned oversized text output.",
598
+ code="INVALID_PRIMER3_DESIGN_OUTPUT",
599
+ )
600
+ backend_error = _bounded_text(
601
+ raw.get("PRIMER_ERROR", ""), "PRIMER_ERROR", max_length=max_text_length
602
+ )
603
+ if backend_error:
604
+ raise BackendExecutionError(
605
+ "primer3_core rejected the primer-design request.",
606
+ code="PRIMER3_CLI_DESIGN_REJECTED",
607
+ context={"backend_error": backend_error},
608
+ )
609
+ returned = _output_int(raw.get("PRIMER_PAIR_NUM_RETURNED", 0), "pair_count")
610
+ if returned > output_limit:
611
+ raise BackendExecutionError(
612
+ "primer3_core returned more candidates than the configured limit.",
613
+ code="PRIMER3_DESIGN_OUTPUT_LIMIT_EXCEEDED",
614
+ context={"returned": returned, "output_limit": output_limit},
615
+ )
616
+ candidates = tuple(
617
+ self._candidate(
618
+ raw,
619
+ rank=rank,
620
+ template=prepared.request.template.symbols,
621
+ max_text_length=max_text_length,
622
+ )
623
+ for rank in range(returned)
624
+ )
625
+ warning = _bounded_text(
626
+ raw.get("PRIMER_WARNING", ""), "PRIMER_WARNING", max_length=max_text_length
627
+ )
628
+ issues = (
629
+ (Issue("PRIMER3_DESIGN_WARNING", IssueSeverity.WARNING, warning),) if warning else ()
630
+ )
631
+ info = self.info
632
+ return PrimerDesignInterfaceResult(
633
+ request=prepared.request,
634
+ backend_name=info.name,
635
+ status=(
636
+ "execution-complete-with-candidates"
637
+ if candidates
638
+ else "execution-complete-no-candidates"
639
+ ),
640
+ execution_performed=True,
641
+ candidates=candidates,
642
+ reason="The user explicitly invoked a separately installed primer3_core executable.",
643
+ method="primer3-cli-core-design-adapter",
644
+ algorithm_version="dnakit-primer3-cli-design-adapter-v1",
645
+ parameters=freeze_parameters(
646
+ {
647
+ "explicit_execution": True,
648
+ "automatic_path_search": False,
649
+ "automatic_install": False,
650
+ "automatic_probe": False,
651
+ "automatic_execution": False,
652
+ "requested_candidates": prepared.request.candidate_count,
653
+ "returned_candidates": len(candidates),
654
+ "max_returned_candidates": max_returned_candidates,
655
+ "max_result_keys": max_result_keys,
656
+ "max_text_length": max_text_length,
657
+ "max_template_length": max_template_length,
658
+ "primer3_core_path": self._path,
659
+ "thermodynamic_parameters_path": self._parameters_path,
660
+ }
661
+ ),
662
+ provenance=_provenance(info),
663
+ issues=issues,
664
+ )
665
+
666
+
667
+ __all__ = ["Primer3CLIDesignAdapter"]