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,517 @@
1
+ """Passive registrations and bounded execution for user-supplied CLI tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import signal
9
+ import stat
10
+ import subprocess
11
+ import threading
12
+ import time
13
+ from collections.abc import Iterable
14
+ from contextlib import suppress
15
+ from dataclasses import dataclass
16
+ from itertools import islice
17
+ from pathlib import Path
18
+ from typing import BinaryIO, cast
19
+
20
+ from dnakit.core import BackendInfo
21
+ from dnakit.exceptions import (
22
+ BackendExecutionError,
23
+ BackendTimeoutError,
24
+ BackendUnavailableError,
25
+ ConfigurationError,
26
+ )
27
+
28
+ _VERSION = re.compile(r"(?P<version>\d+(?:\.\d+)+(?:[-+._A-Za-z0-9]*)?)")
29
+ _MAX_EXTERNAL_ARGUMENTS = 256
30
+ _MAX_EXTERNAL_ARGUMENT_BYTES = 65_536
31
+ _MAX_MONITORED_OUTPUTS = 16
32
+
33
+
34
+ def _terminate_process_tree(process: subprocess.Popen[bytes]) -> None:
35
+ """Best-effort termination of a version command and its descendants."""
36
+
37
+ if process.poll() is not None:
38
+ return
39
+ if os.name == "posix":
40
+ try:
41
+ os.killpg(process.pid, signal.SIGKILL)
42
+ except ProcessLookupError:
43
+ return
44
+ except OSError:
45
+ process.kill()
46
+ else: # pragma: no cover - Linux is the required validation platform.
47
+ process.kill()
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class BoundedCommandResult:
52
+ """Captured metadata from one explicitly requested, bounded CLI execution."""
53
+
54
+ return_code: int
55
+ output: str
56
+ elapsed_seconds: float
57
+
58
+
59
+ def _validated_command_arguments(arguments: Iterable[str]) -> tuple[str, ...]:
60
+ if isinstance(arguments, (str, bytes)):
61
+ raise ConfigurationError("External command arguments must be an iterable of strings.")
62
+ try:
63
+ resolved = tuple(islice(iter(arguments), _MAX_EXTERNAL_ARGUMENTS + 1))
64
+ except TypeError as exc:
65
+ raise ConfigurationError(
66
+ "External command arguments must be an iterable of strings."
67
+ ) from exc
68
+ if len(resolved) > _MAX_EXTERNAL_ARGUMENTS:
69
+ raise ConfigurationError(
70
+ "External command exceeds the argument-count limit.",
71
+ code="EXTERNAL_ARGUMENT_LIMIT_EXCEEDED",
72
+ context={"max_arguments": _MAX_EXTERNAL_ARGUMENTS},
73
+ )
74
+ if any(
75
+ not isinstance(argument, str) or not argument or "\x00" in argument for argument in resolved
76
+ ):
77
+ raise ConfigurationError("External command arguments must be non-empty NUL-free strings.")
78
+ argument_bytes = sum(len(argument.encode("utf-8")) + 1 for argument in resolved)
79
+ if argument_bytes > _MAX_EXTERNAL_ARGUMENT_BYTES:
80
+ raise ConfigurationError(
81
+ "External command exceeds the encoded argument-size limit.",
82
+ code="EXTERNAL_ARGUMENT_LIMIT_EXCEEDED",
83
+ context={
84
+ "max_argument_bytes": _MAX_EXTERNAL_ARGUMENT_BYTES,
85
+ "observed_argument_bytes": argument_bytes,
86
+ },
87
+ )
88
+ return resolved
89
+
90
+
91
+ def execute_bounded_command(
92
+ executable_path: str | Path,
93
+ arguments: Iterable[str],
94
+ *,
95
+ backend_id: str,
96
+ cwd: str | Path,
97
+ timeout_seconds: float = 300.0,
98
+ max_output_bytes: int = 1_000_000,
99
+ monitored_output_paths: Iterable[str | Path] = (),
100
+ max_monitored_output_bytes: int = 100_000_000,
101
+ ) -> BoundedCommandResult:
102
+ """Run an explicit executable without a shell under time and output limits.
103
+
104
+ Domain adapters remain responsible for constructing a fixed command
105
+ whitelist. This executor only accepts an absolute resolved executable,
106
+ bounds the argument vector and captured output, isolates a POSIX process
107
+ group, and monitors declared output artifacts inside ``cwd``.
108
+ """
109
+
110
+ if not isinstance(backend_id, str) or not backend_id.strip():
111
+ raise ConfigurationError("backend_id must be a non-empty string.")
112
+ try:
113
+ executable = Path(executable_path).expanduser().resolve(strict=True)
114
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
115
+ raise BackendUnavailableError(
116
+ "Explicit external backend executable is unavailable.",
117
+ context={"backend_id": backend_id, "executable": str(executable_path)},
118
+ ) from exc
119
+ try:
120
+ executable_stat = executable.stat()
121
+ except OSError as exc: # pragma: no cover - resolve already verified the path.
122
+ raise BackendUnavailableError(
123
+ "Explicit external backend executable is unavailable.",
124
+ context={"backend_id": backend_id, "executable": str(executable)},
125
+ ) from exc
126
+ if not stat.S_ISREG(executable_stat.st_mode) or not os.access(executable, os.X_OK):
127
+ raise BackendUnavailableError(
128
+ "Explicit external backend path must be an executable regular file.",
129
+ context={"backend_id": backend_id, "executable": str(executable)},
130
+ )
131
+
132
+ try:
133
+ working_directory = Path(cwd).expanduser().resolve(strict=True)
134
+ except (OSError, RuntimeError, TypeError, ValueError) as exc:
135
+ raise ConfigurationError("External command cwd must be an existing directory.") from exc
136
+ if not working_directory.is_dir():
137
+ raise ConfigurationError("External command cwd must be an existing directory.")
138
+ resolved_arguments = _validated_command_arguments(arguments)
139
+ if (
140
+ isinstance(timeout_seconds, bool)
141
+ or not isinstance(timeout_seconds, (int, float))
142
+ or not 0 < timeout_seconds <= 86_400
143
+ ):
144
+ raise ConfigurationError("timeout_seconds must be in (0, 86400].")
145
+ for name, value, maximum in (
146
+ ("max_output_bytes", max_output_bytes, 100_000_000),
147
+ ("max_monitored_output_bytes", max_monitored_output_bytes, 10_000_000_000),
148
+ ):
149
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
150
+ raise ConfigurationError(f"{name} must be an integer in [1, {maximum}].")
151
+
152
+ if isinstance(monitored_output_paths, (str, bytes, Path)):
153
+ monitored_values: Iterable[str | Path] = (monitored_output_paths,)
154
+ else:
155
+ monitored_values = monitored_output_paths
156
+ try:
157
+ monitored = tuple(
158
+ islice(
159
+ (Path(path).expanduser() for path in monitored_values),
160
+ _MAX_MONITORED_OUTPUTS + 1,
161
+ )
162
+ )
163
+ except (TypeError, ValueError) as exc:
164
+ raise ConfigurationError("monitored_output_paths must contain filesystem paths.") from exc
165
+ if len(monitored) > _MAX_MONITORED_OUTPUTS:
166
+ raise ConfigurationError(
167
+ "Too many monitored external output paths.",
168
+ code="EXTERNAL_OUTPUT_PATH_LIMIT_EXCEEDED",
169
+ context={"max_paths": _MAX_MONITORED_OUTPUTS},
170
+ )
171
+ resolved_monitored: tuple[Path, ...] = tuple(
172
+ path.resolve(strict=False) if path.is_absolute() else (working_directory / path).resolve()
173
+ for path in monitored
174
+ )
175
+ if any(
176
+ path == working_directory or not path.is_relative_to(working_directory)
177
+ for path in resolved_monitored
178
+ ):
179
+ raise ConfigurationError(
180
+ "Monitored external outputs must stay inside cwd.",
181
+ code="UNSAFE_EXTERNAL_OUTPUT_PATH",
182
+ )
183
+
184
+ environment = os.environ.copy()
185
+ environment.update({"LANG": "C", "LC_ALL": "C"})
186
+ command = (str(executable), *resolved_arguments)
187
+ started = time.monotonic()
188
+ try:
189
+ process = subprocess.Popen(
190
+ command,
191
+ cwd=str(working_directory),
192
+ env=environment,
193
+ stdin=subprocess.DEVNULL,
194
+ stdout=subprocess.PIPE,
195
+ stderr=subprocess.STDOUT,
196
+ shell=False,
197
+ close_fds=True,
198
+ start_new_session=os.name == "posix",
199
+ )
200
+ except OSError as exc:
201
+ raise BackendExecutionError(
202
+ "External backend command could not be started.",
203
+ code="BACKEND_COMMAND_EXECUTION_FAILED",
204
+ context={"backend_id": backend_id, "error_type": type(exc).__name__},
205
+ ) from exc
206
+ if process.stdout is None: # pragma: no cover - guaranteed by stdout=PIPE.
207
+ process.kill()
208
+ process.wait()
209
+ raise BackendExecutionError("External backend output pipe could not be created.")
210
+
211
+ stdout = cast(BinaryIO, process.stdout)
212
+ output = bytearray()
213
+ output_too_large = threading.Event()
214
+
215
+ def read_bounded_output() -> None:
216
+ try:
217
+ while len(output) <= max_output_bytes:
218
+ remaining = max_output_bytes + 1 - len(output)
219
+ chunk = stdout.read(min(65_536, remaining))
220
+ if not chunk:
221
+ return
222
+ output.extend(chunk)
223
+ if len(output) > max_output_bytes:
224
+ output_too_large.set()
225
+ return
226
+ except (OSError, ValueError):
227
+ return
228
+
229
+ reader = threading.Thread(target=read_bounded_output, daemon=True)
230
+ reader.start()
231
+ timed_out = False
232
+ artifact_too_large: Path | None = None
233
+ monitored_output_bytes = 0
234
+ deadline = started + float(timeout_seconds)
235
+ try:
236
+ while process.poll() is None:
237
+ if output_too_large.is_set():
238
+ _terminate_process_tree(process)
239
+ break
240
+ monitored_output_bytes = 0
241
+ for path in resolved_monitored:
242
+ try:
243
+ monitored_output_bytes += path.stat().st_size
244
+ if monitored_output_bytes > max_monitored_output_bytes:
245
+ artifact_too_large = path
246
+ _terminate_process_tree(process)
247
+ break
248
+ except FileNotFoundError:
249
+ continue
250
+ except OSError as exc:
251
+ _terminate_process_tree(process)
252
+ raise BackendExecutionError(
253
+ "External backend output artifact could not be inspected.",
254
+ code="BACKEND_ARTIFACT_INSPECTION_FAILED",
255
+ context={"backend_id": backend_id, "path": str(path)},
256
+ ) from exc
257
+ if artifact_too_large is not None:
258
+ break
259
+ if time.monotonic() >= deadline:
260
+ timed_out = True
261
+ _terminate_process_tree(process)
262
+ break
263
+ time.sleep(0.005)
264
+ process.wait()
265
+ finally:
266
+ if process.poll() is None:
267
+ _terminate_process_tree(process)
268
+ process.wait()
269
+ reader.join(timeout=1.0)
270
+ if reader.is_alive() and os.name == "posix":
271
+ with suppress(OSError, ProcessLookupError):
272
+ os.killpg(process.pid, signal.SIGKILL)
273
+ stdout.close()
274
+ reader.join(timeout=1.0)
275
+
276
+ monitored_output_bytes = 0
277
+ for path in resolved_monitored:
278
+ try:
279
+ monitored_output_bytes += path.stat().st_size
280
+ if monitored_output_bytes > max_monitored_output_bytes:
281
+ artifact_too_large = path
282
+ break
283
+ except FileNotFoundError:
284
+ continue
285
+ except OSError as exc:
286
+ raise BackendExecutionError(
287
+ "External backend output artifact could not be inspected.",
288
+ code="BACKEND_ARTIFACT_INSPECTION_FAILED",
289
+ context={"backend_id": backend_id, "path": str(path)},
290
+ ) from exc
291
+ if timed_out:
292
+ raise BackendTimeoutError(
293
+ "External backend command exceeded its timeout.",
294
+ context={"backend_id": backend_id, "timeout_seconds": float(timeout_seconds)},
295
+ )
296
+ if output_too_large.is_set() or len(output) > max_output_bytes:
297
+ raise BackendExecutionError(
298
+ "External backend command output exceeded max_output_bytes.",
299
+ code="BACKEND_COMMAND_OUTPUT_LIMIT",
300
+ context={
301
+ "backend_id": backend_id,
302
+ "max_output_bytes": max_output_bytes,
303
+ "observed_bytes": len(output),
304
+ },
305
+ )
306
+ if artifact_too_large is not None:
307
+ raise BackendExecutionError(
308
+ "External backend artifact exceeded max_monitored_output_bytes.",
309
+ code="BACKEND_ARTIFACT_OUTPUT_LIMIT",
310
+ context={
311
+ "backend_id": backend_id,
312
+ "path": str(artifact_too_large),
313
+ "max_output_bytes": max_monitored_output_bytes,
314
+ "observed_bytes": monitored_output_bytes,
315
+ },
316
+ )
317
+ return BoundedCommandResult(
318
+ return_code=process.returncode,
319
+ output=bytes(output).decode("utf-8", errors="replace"),
320
+ elapsed_seconds=time.monotonic() - started,
321
+ )
322
+
323
+
324
+ @dataclass(frozen=True, slots=True)
325
+ class ExternalCLIAdapter:
326
+ """Explicit opt-in adapter handle that never executes during discovery or loading."""
327
+
328
+ backend_id: str
329
+ executable: str
330
+ capabilities: frozenset[str]
331
+ license_expression: str
332
+
333
+ def __post_init__(self) -> None:
334
+ if not all(
335
+ isinstance(value, str) and value.strip() for value in (self.backend_id, self.executable)
336
+ ):
337
+ raise ConfigurationError("External backend identifiers must be non-empty strings.")
338
+ if not self.capabilities:
339
+ raise ConfigurationError("External backend capabilities must not be empty.")
340
+
341
+ def probe(self) -> BackendInfo:
342
+ return passive_external_probe(
343
+ self.backend_id,
344
+ self.executable,
345
+ self.capabilities,
346
+ self.license_expression,
347
+ )
348
+
349
+ def version(
350
+ self,
351
+ *,
352
+ timeout_seconds: float = 5.0,
353
+ max_output_bytes: int = 1_000_000,
354
+ ) -> BackendInfo:
355
+ """Explicitly execute only the backend's version command with a timeout."""
356
+
357
+ info = self.probe()
358
+ if not info.available or info.executable_path is None:
359
+ raise BackendUnavailableError(
360
+ "External backend executable is unavailable.",
361
+ context={"backend_id": self.backend_id, "executable": self.executable},
362
+ )
363
+ if (
364
+ isinstance(timeout_seconds, bool)
365
+ or not isinstance(timeout_seconds, (int, float))
366
+ or not 0 < timeout_seconds <= 60
367
+ ):
368
+ raise ConfigurationError("timeout_seconds must be in (0, 60].")
369
+ if (
370
+ isinstance(max_output_bytes, bool)
371
+ or not isinstance(max_output_bytes, int)
372
+ or not 1 <= max_output_bytes <= 10_000_000
373
+ ):
374
+ raise ConfigurationError("max_output_bytes must be an integer in [1, 10000000].")
375
+ flag = "version" if self.backend_id == "mmseqs2" else "--version"
376
+ deadline = time.monotonic() + float(timeout_seconds)
377
+ try:
378
+ process = subprocess.Popen(
379
+ (info.executable_path, flag),
380
+ stdout=subprocess.PIPE,
381
+ stderr=subprocess.STDOUT,
382
+ start_new_session=os.name == "posix",
383
+ )
384
+ except OSError as exc:
385
+ raise BackendExecutionError(
386
+ "External backend version command could not be started.",
387
+ code="BACKEND_VERSION_EXECUTION_FAILED",
388
+ context={"backend_id": self.backend_id, "error_type": type(exc).__name__},
389
+ ) from exc
390
+ if process.stdout is None: # pragma: no cover - guaranteed by stdout=PIPE.
391
+ process.kill()
392
+ process.wait()
393
+ raise BackendExecutionError("External backend output pipe could not be created.")
394
+ stdout = cast(BinaryIO, process.stdout)
395
+ output = bytearray()
396
+ output_too_large = threading.Event()
397
+
398
+ def read_bounded_output() -> None:
399
+ while len(output) <= max_output_bytes:
400
+ remaining = max_output_bytes + 1 - len(output)
401
+ chunk = stdout.read(min(65_536, remaining))
402
+ if not chunk:
403
+ return
404
+ output.extend(chunk)
405
+ if len(output) > max_output_bytes:
406
+ output_too_large.set()
407
+ return
408
+
409
+ reader = threading.Thread(target=read_bounded_output, daemon=True)
410
+ reader.start()
411
+ timed_out = False
412
+ try:
413
+ while process.poll() is None:
414
+ if output_too_large.is_set():
415
+ _terminate_process_tree(process)
416
+ break
417
+ if time.monotonic() >= deadline:
418
+ timed_out = True
419
+ _terminate_process_tree(process)
420
+ break
421
+ time.sleep(0.005)
422
+ process.wait()
423
+ finally:
424
+ if process.poll() is None:
425
+ _terminate_process_tree(process)
426
+ process.wait()
427
+ reader.join(timeout=1.0)
428
+ stdout.close()
429
+ if timed_out:
430
+ raise BackendTimeoutError(
431
+ "External backend version command exceeded its timeout.",
432
+ context={
433
+ "backend_id": self.backend_id,
434
+ "timeout_seconds": float(timeout_seconds),
435
+ },
436
+ )
437
+ if output_too_large.is_set() or len(output) > max_output_bytes:
438
+ raise BackendExecutionError(
439
+ "External backend version output exceeded max_output_bytes.",
440
+ code="BACKEND_VERSION_OUTPUT_LIMIT",
441
+ context={
442
+ "backend_id": self.backend_id,
443
+ "max_output_bytes": max_output_bytes,
444
+ "observed_bytes": len(output),
445
+ },
446
+ )
447
+ text = bytes(output).decode("utf-8", errors="replace").strip()
448
+ match = _VERSION.search(text)
449
+ return BackendInfo(
450
+ info.name,
451
+ version=None if match is None else match.group("version"),
452
+ executable_path=info.executable_path,
453
+ license_expression=info.license_expression,
454
+ capabilities=info.capabilities,
455
+ available=process.returncode == 0,
456
+ metadata={
457
+ "probe_mode": "explicit-version-command",
458
+ "return_code": process.returncode,
459
+ "output_excerpt": text[:1_000],
460
+ "max_output_bytes": max_output_bytes,
461
+ },
462
+ )
463
+
464
+
465
+ def passive_external_probe(
466
+ backend_id: str,
467
+ executable: str,
468
+ capabilities: frozenset[str],
469
+ license_expression: str,
470
+ ) -> BackendInfo:
471
+ """Locate an executable without running it or reading sequence data."""
472
+
473
+ path = shutil.which(executable)
474
+ return BackendInfo(
475
+ backend_id,
476
+ executable_path=path,
477
+ license_expression=license_expression,
478
+ capabilities=capabilities,
479
+ available=path is not None,
480
+ metadata={
481
+ "probe_mode": "path-only",
482
+ "execution": "explicit opt-in only",
483
+ "redistributed": False,
484
+ },
485
+ )
486
+
487
+
488
+ EXTERNAL_CLI_ADAPTERS = (
489
+ ExternalCLIAdapter("blastn", "blastn", frozenset({"sequence-search"}), "LicenseRef-NCBI"),
490
+ ExternalCLIAdapter(
491
+ "mmseqs2",
492
+ "mmseqs",
493
+ frozenset({"sequence-search", "clustering"}),
494
+ "GPL-3.0-or-later",
495
+ ),
496
+ ExternalCLIAdapter(
497
+ "sourmash",
498
+ "sourmash",
499
+ frozenset({"sketch", "sequence-search", "similarity"}),
500
+ "BSD-3-Clause",
501
+ ),
502
+ ExternalCLIAdapter(
503
+ "dashing",
504
+ "dashing",
505
+ frozenset({"sketch", "similarity"}),
506
+ "GPL-3.0-only",
507
+ ),
508
+ )
509
+
510
+
511
+ __all__ = [
512
+ "EXTERNAL_CLI_ADAPTERS",
513
+ "BoundedCommandResult",
514
+ "ExternalCLIAdapter",
515
+ "execute_bounded_command",
516
+ "passive_external_probe",
517
+ ]