gseek 0.2.0__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.
gseek/__init__.py ADDED
@@ -0,0 +1,198 @@
1
+ """gseek: unified, reproducible DNA alignment infrastructure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gseek.adapter import (
6
+ AlignmentAdapter,
7
+ AlignPlan,
8
+ AlignRequest,
9
+ DistanceAdapter,
10
+ DistanceRequest,
11
+ IndexPlan,
12
+ IndexRequest,
13
+ ParseContext,
14
+ PlanContext,
15
+ )
16
+ from gseek.api import (
17
+ align,
18
+ backends,
19
+ distance,
20
+ find,
21
+ find_backends,
22
+ get_backend,
23
+ register_adapter,
24
+ replay,
25
+ verify,
26
+ )
27
+ from gseek.capabilities import (
28
+ BackendSpec,
29
+ Capabilities,
30
+ ToolchainProbe,
31
+ ToolRequirement,
32
+ )
33
+ from gseek.errors import (
34
+ CacheError,
35
+ ConfigError,
36
+ DuplicateIdError,
37
+ ExecutionError,
38
+ GseekError,
39
+ IndexBuildError,
40
+ IndexLockError,
41
+ InputError,
42
+ ManifestError,
43
+ ParseError,
44
+ ReplayMismatchError,
45
+ ToolNotFoundError,
46
+ ToolVersionError,
47
+ UnsupportedFeatureError,
48
+ UsageError,
49
+ render_error,
50
+ )
51
+ from gseek.execution import (
52
+ CommandPlan,
53
+ Executor,
54
+ ProcessResult,
55
+ RunWorkspace,
56
+ ToolProbe,
57
+ ToolResolver,
58
+ create_workspace,
59
+ )
60
+ from gseek.index import IndexPolicy, IndexStore, compute_index_key, default_cache_root
61
+ from gseek.model import (
62
+ AlignmentRecord,
63
+ AlignmentResult,
64
+ AlignmentStats,
65
+ CommandRecord,
66
+ DistanceRecord,
67
+ DistanceResult,
68
+ DistanceStats,
69
+ FileRef,
70
+ IndexRef,
71
+ InputRef,
72
+ Issue,
73
+ QuerySummary,
74
+ RunManifest,
75
+ TargetSummary,
76
+ ToolRef,
77
+ build_alignment_stats,
78
+ )
79
+ from gseek.provenance import (
80
+ alignment_result_hash,
81
+ canonical_json,
82
+ distance_result_hash,
83
+ gseek_version,
84
+ read_manifest,
85
+ recipe_hash,
86
+ result_hash,
87
+ sha256_file,
88
+ verify_manifest,
89
+ write_manifest,
90
+ )
91
+ from gseek.replay import ReplayResult
92
+ from gseek.types import (
93
+ ALIGNMENT_SCHEMA,
94
+ DISTANCE_SCHEMA,
95
+ INDEX_SCHEMA,
96
+ RUN_SCHEMA,
97
+ IndexScope,
98
+ InputKind,
99
+ Interval,
100
+ JsonValue,
101
+ Molecule,
102
+ OutputKind,
103
+ RunStatus,
104
+ Strand,
105
+ )
106
+ from gseek.version import __version__
107
+
108
+ __all__ = [
109
+ "ALIGNMENT_SCHEMA",
110
+ "DISTANCE_SCHEMA",
111
+ "INDEX_SCHEMA",
112
+ "RUN_SCHEMA",
113
+ "AlignPlan",
114
+ "AlignRequest",
115
+ "AlignmentAdapter",
116
+ "AlignmentRecord",
117
+ "AlignmentResult",
118
+ "AlignmentStats",
119
+ "BackendSpec",
120
+ "CacheError",
121
+ "Capabilities",
122
+ "CommandPlan",
123
+ "CommandRecord",
124
+ "ConfigError",
125
+ "DistanceAdapter",
126
+ "DistanceRecord",
127
+ "DistanceRequest",
128
+ "DistanceResult",
129
+ "DistanceStats",
130
+ "DuplicateIdError",
131
+ "ExecutionError",
132
+ "Executor",
133
+ "FileRef",
134
+ "GseekError",
135
+ "IndexBuildError",
136
+ "IndexLockError",
137
+ "IndexPlan",
138
+ "IndexPolicy",
139
+ "IndexRef",
140
+ "IndexRequest",
141
+ "IndexScope",
142
+ "IndexStore",
143
+ "InputError",
144
+ "InputKind",
145
+ "InputRef",
146
+ "Interval",
147
+ "Issue",
148
+ "JsonValue",
149
+ "ManifestError",
150
+ "Molecule",
151
+ "OutputKind",
152
+ "ParseContext",
153
+ "ParseError",
154
+ "PlanContext",
155
+ "ProcessResult",
156
+ "QuerySummary",
157
+ "ReplayMismatchError",
158
+ "ReplayResult",
159
+ "RunManifest",
160
+ "RunStatus",
161
+ "RunWorkspace",
162
+ "Strand",
163
+ "TargetSummary",
164
+ "ToolNotFoundError",
165
+ "ToolProbe",
166
+ "ToolRef",
167
+ "ToolRequirement",
168
+ "ToolResolver",
169
+ "ToolVersionError",
170
+ "ToolchainProbe",
171
+ "UnsupportedFeatureError",
172
+ "UsageError",
173
+ "__version__",
174
+ "align",
175
+ "alignment_result_hash",
176
+ "backends",
177
+ "build_alignment_stats",
178
+ "canonical_json",
179
+ "compute_index_key",
180
+ "create_workspace",
181
+ "default_cache_root",
182
+ "distance",
183
+ "distance_result_hash",
184
+ "find",
185
+ "find_backends",
186
+ "get_backend",
187
+ "gseek_version",
188
+ "read_manifest",
189
+ "recipe_hash",
190
+ "register_adapter",
191
+ "render_error",
192
+ "replay",
193
+ "result_hash",
194
+ "sha256_file",
195
+ "verify",
196
+ "verify_manifest",
197
+ "write_manifest",
198
+ ]
gseek/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """``python -m gseek`` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from gseek.cli import main
6
+
7
+ if __name__ == "__main__": # pragma: no cover
8
+ raise SystemExit(main())
gseek/adapter.py ADDED
@@ -0,0 +1,292 @@
1
+ """Adapter protocol: pure planning and parsing, no subprocesses.
2
+
3
+ An adapter turns a declarative request into commands plus a parser. The runner
4
+ owns execution, index publication, provenance, and result construction. This
5
+ split is what makes adapters testable without installing external tools.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterable, Iterator, Mapping
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Protocol, runtime_checkable
14
+
15
+ from gseek.capabilities import BackendSpec, ToolchainProbe, ToolRequirement, supports
16
+ from gseek.errors import ToolNotFoundError, ToolVersionError, UnsupportedFeatureError
17
+ from gseek.execution import CommandPlan, ToolProbe, ToolResolver
18
+ from gseek.index import IndexPolicy
19
+ from gseek.io import detect_input_kind
20
+ from gseek.model import AlignmentRecord, IndexRef, InputRef, ToolRef
21
+ from gseek.provenance import semantic_params
22
+ from gseek.types import InputKind, JsonValue, Molecule, OutputKind
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class AlignRequest:
27
+ """One query (or read pair) against one target set."""
28
+
29
+ query: tuple[Path, ...]
30
+ target: tuple[Path, ...]
31
+ query_kind: InputKind | None = None
32
+ paired: bool = False
33
+ params: Mapping[str, JsonValue] = field(default_factory=dict)
34
+ threads: int = 1
35
+ timeout_s: float | None = None
36
+ index_policy: IndexPolicy = IndexPolicy.AUTO
37
+ target_filter: frozenset[str] | None = None
38
+ work_dir: Path | None = None
39
+ keep_work: bool = False
40
+
41
+ def __post_init__(self) -> None:
42
+ if not self.query:
43
+ raise UnsupportedFeatureError("at least one query file is required")
44
+ if not self.target:
45
+ raise UnsupportedFeatureError("at least one target file is required")
46
+ if self.paired and len(self.query) != 2:
47
+ raise UnsupportedFeatureError("paired mode requires exactly two query files")
48
+ if not self.paired and len(self.query) != 1:
49
+ raise UnsupportedFeatureError("single-end mode requires exactly one query file")
50
+ if self.threads < 1:
51
+ raise UnsupportedFeatureError(f"threads must be >= 1, got {self.threads}")
52
+ if self.timeout_s is not None and self.timeout_s <= 0:
53
+ raise UnsupportedFeatureError(f"timeout_s must be positive, got {self.timeout_s}")
54
+
55
+ @property
56
+ def resolved_query_kind(self) -> InputKind:
57
+ return self.query_kind or detect_input_kind(self.query[0])
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class DistanceRequest:
62
+ """One or more queries against a sketch/distance reference set."""
63
+
64
+ query: tuple[Path, ...]
65
+ reference: tuple[Path, ...]
66
+ query_kind: InputKind | None = None
67
+ params: Mapping[str, JsonValue] = field(default_factory=dict)
68
+ threads: int = 1
69
+ timeout_s: float | None = None
70
+ index_policy: IndexPolicy = IndexPolicy.AUTO
71
+ external_reference: bool = False
72
+ work_dir: Path | None = None
73
+ keep_work: bool = False
74
+
75
+ def __post_init__(self) -> None:
76
+ if not self.query:
77
+ raise UnsupportedFeatureError("at least one query file is required")
78
+ if not self.reference:
79
+ raise UnsupportedFeatureError("at least one reference file is required")
80
+ if self.threads < 1:
81
+ raise UnsupportedFeatureError(f"threads must be >= 1, got {self.threads}")
82
+ if self.external_reference and len(self.reference) != 1:
83
+ raise UnsupportedFeatureError(
84
+ "external_reference requires exactly one pre-built reference file"
85
+ )
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class IndexRequest:
90
+ target: tuple[Path, ...]
91
+ params: Mapping[str, JsonValue] = field(default_factory=dict)
92
+ threads: int = 1
93
+ force: bool = False
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class PlanContext:
98
+ """Paths and immutable context available while planning commands."""
99
+
100
+ workspace: Path
101
+ index_dir: Path | None = None
102
+ threads: int = 1
103
+ query_refs: tuple[InputRef, ...] = ()
104
+ target_refs: tuple[InputRef, ...] = ()
105
+
106
+ def path(self, *parts: str) -> Path:
107
+ target = self.workspace.joinpath(*parts)
108
+ target.parent.mkdir(parents=True, exist_ok=True)
109
+ return target
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class IndexPlan:
114
+ """Commands that build an index plus the expected entry/artifacts."""
115
+
116
+ commands: tuple[CommandPlan, ...]
117
+ entry: str
118
+ artifacts: tuple[str, ...] = ()
119
+ params: Mapping[str, JsonValue] = field(default_factory=dict)
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class AlignPlan:
124
+ """Commands that perform one alignment plus how to parse their output."""
125
+
126
+ commands: tuple[CommandPlan, ...]
127
+ stream: bool = False
128
+ result_files: tuple[Path, ...] = ()
129
+ extra: Mapping[str, JsonValue] = field(default_factory=dict)
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class ParseContext:
134
+ """Everything a parser needs to normalize one result set."""
135
+
136
+ request: AlignRequest
137
+ plan: AlignPlan
138
+ workspace: Path
139
+ index: IndexRef | None = None
140
+ tools: tuple[ToolRef, ...] = ()
141
+
142
+
143
+ @runtime_checkable
144
+ class AlignmentAdapter(Protocol):
145
+ """A backend that produces :class:`AlignmentRecord` rows."""
146
+
147
+ spec: BackendSpec
148
+
149
+ def probe(self, resolver: ToolResolver, *, strict: bool = False) -> ToolchainProbe: ...
150
+
151
+ def semantic_params(self, params: Mapping[str, JsonValue]) -> dict[str, JsonValue]: ...
152
+
153
+ def plan_index(self, request: IndexRequest, ctx: PlanContext) -> IndexPlan | None: ...
154
+
155
+ def plan_align(
156
+ self,
157
+ request: AlignRequest,
158
+ index: IndexRef | None,
159
+ ctx: PlanContext,
160
+ ) -> AlignPlan: ...
161
+
162
+ def parse_align(
163
+ self,
164
+ ctx: ParseContext,
165
+ stream: Iterable[str] | None = None,
166
+ ) -> Iterator[AlignmentRecord]: ...
167
+
168
+
169
+ @runtime_checkable
170
+ class DistanceAdapter(Protocol):
171
+ """A backend that produces distance/containment rows."""
172
+
173
+ spec: BackendSpec
174
+
175
+ def probe(self, resolver: ToolResolver, *, strict: bool = False) -> ToolchainProbe: ...
176
+
177
+ def semantic_params(self, params: Mapping[str, JsonValue]) -> dict[str, JsonValue]: ...
178
+
179
+ def plan_index(self, request: IndexRequest, ctx: PlanContext) -> IndexPlan | None: ...
180
+
181
+ def plan_distance(
182
+ self,
183
+ request: DistanceRequest,
184
+ index: IndexRef | None,
185
+ ctx: PlanContext,
186
+ ) -> AlignPlan: ...
187
+
188
+ def parse_distance(
189
+ self,
190
+ ctx: ParseContext,
191
+ stream: Iterable[str] | None = None,
192
+ ) -> Iterator[object]: ...
193
+
194
+
195
+ class BaseAdapter:
196
+ """Shared validation/probing helpers for concrete adapters."""
197
+
198
+ spec: BackendSpec
199
+
200
+ def probe(self, resolver: ToolResolver, *, strict: bool = False) -> ToolchainProbe:
201
+ """Resolve and version-check every declared tool."""
202
+ probes = []
203
+ failed: list[tuple[ToolRequirement, ToolProbe]] = []
204
+ for requirement in self.spec.tools:
205
+ probe = resolver.probe(requirement, strict=False)
206
+ probes.append(probe)
207
+ if not probe.ok and not requirement.optional:
208
+ failed.append((requirement, probe))
209
+ failures = [f"{probe.name}: {probe.error or 'unavailable'}" for _, probe in failed]
210
+ ok = not failures
211
+ result = ToolchainProbe(
212
+ backend=self.spec.name,
213
+ tools=tuple(probes),
214
+ ok=ok,
215
+ error="; ".join(failures),
216
+ )
217
+ if strict and failed:
218
+ missing = any(probe.path is None for _, probe in failed)
219
+ error_type = ToolNotFoundError if missing else ToolVersionError
220
+ hint = "; ".join(requirement.hint for requirement, _ in failed if requirement.hint)
221
+ raise error_type(
222
+ f"backend {self.spec.name!r} is unavailable: {result.error}",
223
+ hint=hint,
224
+ context={"backend": self.spec.name},
225
+ )
226
+ return result
227
+
228
+ def semantic_params(self, params: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
229
+ return semantic_params(params)
230
+
231
+ def plan_index(self, request: IndexRequest, ctx: PlanContext) -> IndexPlan | None:
232
+ if self.spec.capabilities.index_scope.value != "none":
233
+ raise NotImplementedError(f"{self.spec.name} declares an index but does not plan one")
234
+ return None
235
+
236
+ def check_align_request(self, request: AlignRequest) -> InputKind:
237
+ """Validate input kind/paired/preset against the capability declaration."""
238
+ query_kind = request.resolved_query_kind
239
+ if not supports(
240
+ self.spec,
241
+ input_kind=query_kind,
242
+ output_kind=OutputKind.ALIGNMENT,
243
+ paired=request.paired,
244
+ ):
245
+ raise UnsupportedFeatureError(
246
+ f"backend {self.spec.name!r} does not support "
247
+ f"input={query_kind.value}, paired={request.paired}",
248
+ context={"backend": self.spec.name, "input": query_kind.value},
249
+ )
250
+ preset = request.params.get("preset")
251
+ if (
252
+ preset is not None
253
+ and self.spec.capabilities.presets
254
+ and str(preset) not in self.spec.capabilities.presets
255
+ ):
256
+ raise UnsupportedFeatureError(
257
+ f"unknown preset {preset!r} for backend {self.spec.name!r}; "
258
+ f"expected one of {self.spec.capabilities.presets}",
259
+ context={"backend": self.spec.name, "preset": str(preset)},
260
+ )
261
+ return query_kind
262
+
263
+ def describe(self) -> dict[str, JsonValue]:
264
+ from gseek.provenance import to_jsonable
265
+
266
+ return to_jsonable(self.spec)
267
+
268
+
269
+ def request_molecule(params: Mapping[str, JsonValue]) -> Molecule:
270
+ raw = str(params.get("molecule", Molecule.DNA.value)).lower()
271
+ try:
272
+ return Molecule(raw)
273
+ except ValueError as exc:
274
+ raise UnsupportedFeatureError(
275
+ f"unknown molecule {raw!r}",
276
+ context={"molecule": raw},
277
+ ) from exc
278
+
279
+
280
+ __all__ = [
281
+ "AlignPlan",
282
+ "AlignRequest",
283
+ "AlignmentAdapter",
284
+ "BaseAdapter",
285
+ "DistanceAdapter",
286
+ "DistanceRequest",
287
+ "IndexPlan",
288
+ "IndexRequest",
289
+ "ParseContext",
290
+ "PlanContext",
291
+ "request_molecule",
292
+ ]
@@ -0,0 +1,17 @@
1
+ """Built-in adapters.
2
+
3
+ Concrete modules land per the milestone plan:
4
+
5
+ - M1: :mod:`gseek.adapters.blast`, :mod:`gseek.adapters.minimap2`
6
+ - M2: :mod:`gseek.adapters.kma`, :mod:`gseek.adapters.mummer`
7
+ - M3: :mod:`gseek.adapters.bwa`, :mod:`gseek.adapters.mash`,
8
+ :mod:`gseek.adapters.sourmash`
9
+ - M4: protein BLAST modes, sourmash gather, :mod:`gseek.adapters.skani`
10
+
11
+ Each module exposes a class named ``ADAPTER``; add its dotted module path to
12
+ :data:`gseek.registry.BUILTIN_MODULES` when it is ready.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __all__: list[str] = []