dnakit 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
]
|