seqevi 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.
Files changed (49) hide show
  1. seqevi/__init__.py +7 -0
  2. seqevi/__main__.py +6 -0
  3. seqevi/adapters/__init__.py +41 -0
  4. seqevi/adapters/base.py +131 -0
  5. seqevi/adapters/dbcan_cazyme.py +588 -0
  6. seqevi/adapters/eggnog.py +674 -0
  7. seqevi/adapters/interpro_pfam.py +757 -0
  8. seqevi/adapters/registry.py +68 -0
  9. seqevi/annotate.py +413 -0
  10. seqevi/api.py +390 -0
  11. seqevi/cli.py +610 -0
  12. seqevi/distribution/__init__.py +13 -0
  13. seqevi/distribution/manifest.py +199 -0
  14. seqevi/distribution/oci.py +490 -0
  15. seqevi/distribution/setup.py +752 -0
  16. seqevi/errors.py +73 -0
  17. seqevi/evidence.py +295 -0
  18. seqevi/execution_profile.py +526 -0
  19. seqevi/hashing.py +13 -0
  20. seqevi/kits/__init__.py +1 -0
  21. seqevi/kits/dbcan-cazyme.toml +35 -0
  22. seqevi/resource_lock.py +438 -0
  23. seqevi/result.py +682 -0
  24. seqevi/runner.py +163 -0
  25. seqevi/runtime_identity.py +104 -0
  26. seqevi/sequence.py +383 -0
  27. seqevi/service/__init__.py +11 -0
  28. seqevi/service/app.py +213 -0
  29. seqevi/service/config.py +38 -0
  30. seqevi/service/persistence.py +360 -0
  31. seqevi/store/__init__.py +14 -0
  32. seqevi/store/artifact.py +225 -0
  33. seqevi/store/client.py +311 -0
  34. seqevi/store/contract.py +33 -0
  35. seqevi/store/factory.py +38 -0
  36. seqevi/store/local.py +479 -0
  37. seqevi/store/migration.py +62 -0
  38. seqevi/store/migrations/__init__.py +1 -0
  39. seqevi/store/migrations/env.py +30 -0
  40. seqevi/store/migrations/versions/0001_initial_store.py +103 -0
  41. seqevi/store/migrations/versions/0002_artifact_byte_size_bigint.py +40 -0
  42. seqevi/store/migrations/versions/__init__.py +1 -0
  43. seqevi/store/schema.py +86 -0
  44. seqevi/store/transport.py +224 -0
  45. seqevi-0.2.0.dist-info/METADATA +333 -0
  46. seqevi-0.2.0.dist-info/RECORD +49 -0
  47. seqevi-0.2.0.dist-info/WHEEL +4 -0
  48. seqevi-0.2.0.dist-info/entry_points.txt +5 -0
  49. seqevi-0.2.0.dist-info/licenses/LICENSE +21 -0
seqevi/api.py ADDED
@@ -0,0 +1,390 @@
1
+ """Public Python application boundary for SeqEvi annotation results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import duckdb
11
+
12
+ from . import __version__
13
+ from .adapters import (
14
+ AdapterConfiguration,
15
+ AdapterName,
16
+ AnnotationAdapter,
17
+ create_adapter,
18
+ )
19
+ from .annotate import AnnotationSummary, run_annotation
20
+ from .distribution.oci import run_oci_annotation
21
+ from .errors import AnnotationError
22
+ from .execution_profile import (
23
+ ExecutionProfile,
24
+ load_execution_profile,
25
+ load_named_profile,
26
+ )
27
+ from .result import RESULT_FORMAT_VERSION, scan_annotations as _scan_annotations
28
+ from .store import open_evidence_store
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class ResolvedAnnotationInputs:
33
+ """Concrete inputs after profile/config resolution.
34
+
35
+ For a managed v2 profile, ``executable`` is ``None`` and ``profile`` holds
36
+ the immutable OCI runtime. The application boundary dispatches that case
37
+ without exposing Docker branches to scientific adapters.
38
+ """
39
+
40
+ adapter: AdapterName
41
+ executable: Path | None
42
+ resource: Path
43
+ store: str | Path | None
44
+ threads: int
45
+ timeout_seconds: float | None
46
+ environment: tuple[tuple[str, str], ...] = ()
47
+ profile: ExecutionProfile | None = None
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class AnnotationInvocation:
52
+ """Internal shared application result used by API and CLI presenters."""
53
+
54
+ relation: duckdb.DuckDBPyRelation
55
+ summary: AnnotationSummary
56
+ adapter: str
57
+ result_schema_id: str
58
+
59
+
60
+ def annotate(
61
+ fasta: str | Path,
62
+ *,
63
+ output: str | Path,
64
+ profile: str | None = None,
65
+ config: str | Path | None = None,
66
+ adapter: AdapterName | str | None = None,
67
+ executable: str | Path | None = None,
68
+ resource: str | Path | None = None,
69
+ store: str | Path | None = None,
70
+ threads: int | None = None,
71
+ timeout_seconds: float | None = None,
72
+ ) -> duckdb.DuckDBPyRelation:
73
+ """Annotate a protein FASTA and return its native DuckDB relation.
74
+
75
+ Args:
76
+ fasta: Input protein FASTA path.
77
+ output: New `.duckdb` result path. Existing paths are rejected.
78
+ profile: Named profile under the user's SeqEvi configuration directory.
79
+ config: Explicit execution-profile TOML path.
80
+ adapter: Official adapter name for complete explicit mode.
81
+ executable: External adapter launcher path or command name.
82
+ resource: Native upstream annotation resource directory.
83
+ store: Local Store path or shared Store URL.
84
+ threads: Operational worker-thread override.
85
+ timeout_seconds: Optional external-tool timeout.
86
+
87
+ Returns:
88
+ The `main.annotations` DuckDB relation. It keeps the read-only result
89
+ connection alive after this function returns.
90
+
91
+ Raises:
92
+ AnnotationError: If configuration, annotation, or result publication
93
+ fails. The function does not translate errors into CLI exit codes.
94
+
95
+ Examples:
96
+ >>> relation = annotate(
97
+ ... "proteins.faa",
98
+ ... output="annotations.duckdb",
99
+ ... profile="interpro-pfam",
100
+ ... )
101
+ >>> "InputID" in relation.columns
102
+ True
103
+
104
+ Notes:
105
+ The Store remains the incremental evidence cache. The returned file is
106
+ a complete immutable snapshot for this invocation, including cached
107
+ sequences and newly computed misses.
108
+ """
109
+
110
+ invocation = _run_annotation_application(
111
+ fasta=Path(fasta),
112
+ output=Path(output),
113
+ profile=profile,
114
+ config=config,
115
+ adapter=adapter,
116
+ executable=executable,
117
+ resource=resource,
118
+ store=store,
119
+ threads=threads,
120
+ timeout_seconds=timeout_seconds,
121
+ )
122
+ return invocation.relation
123
+
124
+
125
+ def scan_annotations(path: str | Path) -> duckdb.DuckDBPyRelation:
126
+ """Open an existing SeqEvi result read-only as a native relation.
127
+
128
+ Args:
129
+ path: Published `.duckdb` result path.
130
+
131
+ Returns:
132
+ The validated `main.annotations` relation. The relation remains usable
133
+ after this function returns and exposes DuckDB's native `select`,
134
+ `filter`, Polars, and Arrow reader methods.
135
+
136
+ Raises:
137
+ AnnotationError: If the path is missing or fails the result contract.
138
+
139
+ Examples:
140
+ >>> relation = scan_annotations("annotations.duckdb")
141
+ >>> relation.columns[:2]
142
+ ['InputOrder', 'InputID']
143
+
144
+ Notes:
145
+ This function opens the database read-only and never upgrades storage,
146
+ writes a WAL, or changes the `_seqevi` catalog.
147
+ """
148
+
149
+ return _scan_annotations(Path(path))
150
+
151
+
152
+ def _run_annotation_application(
153
+ *,
154
+ fasta: Path,
155
+ output: Path,
156
+ profile: str | None,
157
+ config: str | Path | None,
158
+ adapter: AdapterName | str | None,
159
+ executable: str | Path | None,
160
+ resource: str | Path | None,
161
+ store: str | Path | None,
162
+ threads: int | None,
163
+ timeout_seconds: float | None,
164
+ adapter_factory: Callable[[AdapterConfiguration], AnnotationAdapter] | None = None,
165
+ ) -> AnnotationInvocation:
166
+ inputs = resolve_annotation_inputs(
167
+ profile=profile,
168
+ config=config,
169
+ adapter=adapter,
170
+ executable=executable,
171
+ resource=resource,
172
+ store=store,
173
+ threads=threads,
174
+ timeout_seconds=timeout_seconds,
175
+ )
176
+ if inputs.profile is not None and inputs.profile.version == 2:
177
+ managed = run_oci_annotation(
178
+ fasta=fasta.expanduser().resolve(),
179
+ output=output.expanduser().resolve(),
180
+ profile=inputs.profile,
181
+ store=inputs.store,
182
+ threads=inputs.threads,
183
+ timeout_seconds=inputs.timeout_seconds,
184
+ )
185
+ return AnnotationInvocation(
186
+ relation=_scan_annotations(managed.output),
187
+ summary=managed.summary,
188
+ adapter=managed.adapter,
189
+ result_schema_id=managed.result_schema_id,
190
+ )
191
+ if inputs.executable is None:
192
+ raise AnnotationError("local annotation requires an executable")
193
+ factory = create_adapter if adapter_factory is None else adapter_factory
194
+ configured_adapter = factory(
195
+ AdapterConfiguration(
196
+ name=inputs.adapter,
197
+ executable=inputs.executable,
198
+ database=inputs.resource,
199
+ environment=inputs.environment,
200
+ )
201
+ )
202
+ metadata = _result_metadata_template(configured_adapter)
203
+ with open_evidence_store(inputs.store) as evidence_store:
204
+ summary = run_annotation(
205
+ fasta_path=fasta.expanduser().resolve(),
206
+ output_dir=output.expanduser().resolve(),
207
+ adapter=configured_adapter,
208
+ store=evidence_store,
209
+ timeout_seconds=inputs.timeout_seconds,
210
+ threads=inputs.threads,
211
+ output_format="duckdb",
212
+ result_metadata=metadata,
213
+ )
214
+ return AnnotationInvocation(
215
+ relation=_scan_annotations(summary.output_dir),
216
+ summary=summary,
217
+ adapter=metadata["Adapter"],
218
+ result_schema_id=metadata["ResultSchemaID"],
219
+ )
220
+
221
+
222
+ def resolve_annotation_inputs(
223
+ *,
224
+ profile: str | None,
225
+ config: str | Path | None,
226
+ adapter: AdapterName | str | None,
227
+ executable: str | Path | None,
228
+ resource: str | Path | None,
229
+ store: str | Path | None,
230
+ threads: int | None,
231
+ timeout_seconds: float | None,
232
+ ) -> ResolvedAnnotationInputs:
233
+ """Resolve one profile, config file, or complete explicit identity set."""
234
+
235
+ if profile is not None and config is not None:
236
+ raise AnnotationError("--profile and --config cannot be used together")
237
+ selected_profile: ExecutionProfile | None = None
238
+ if profile is not None:
239
+ selected_profile = load_named_profile(profile)
240
+ elif config is not None:
241
+ selected_profile = load_execution_profile(Path(config))
242
+
243
+ explicit_identity = (adapter, executable, resource)
244
+ if selected_profile is not None:
245
+ if any(value is not None for value in explicit_identity):
246
+ raise AnnotationError(
247
+ "--adapter, --executable and --resource cannot be combined with "
248
+ "--profile or --config"
249
+ )
250
+ return _resolved_from_profile(
251
+ selected_profile,
252
+ store=store,
253
+ threads=threads,
254
+ timeout_seconds=timeout_seconds,
255
+ )
256
+
257
+ missing = [
258
+ option
259
+ for option, value in (
260
+ ("--adapter", adapter),
261
+ ("--executable", executable),
262
+ ("--resource", resource),
263
+ )
264
+ if value is None
265
+ ]
266
+ if missing:
267
+ raise AnnotationError(
268
+ "explicit mode requires "
269
+ + ", ".join(missing)
270
+ + "; alternatively select --profile or --config"
271
+ )
272
+ assert adapter is not None
273
+ assert executable is not None
274
+ assert resource is not None
275
+ return ResolvedAnnotationInputs(
276
+ adapter=_coerce_adapter_name(adapter),
277
+ executable=_resolve_executable(executable),
278
+ resource=_resolve_resource(resource),
279
+ store=store,
280
+ threads=threads if threads is not None else 1,
281
+ timeout_seconds=timeout_seconds,
282
+ profile=None,
283
+ )
284
+
285
+
286
+ def _resolved_from_profile(
287
+ profile: ExecutionProfile,
288
+ *,
289
+ store: str | Path | None,
290
+ threads: int | None,
291
+ timeout_seconds: float | None,
292
+ ) -> ResolvedAnnotationInputs:
293
+ if profile.version == 2:
294
+ return ResolvedAnnotationInputs(
295
+ adapter=profile.adapter,
296
+ executable=None,
297
+ resource=profile.resource,
298
+ store=store if store is not None else profile.store,
299
+ threads=(
300
+ threads
301
+ if threads is not None
302
+ else profile.threads
303
+ if profile.threads is not None
304
+ else 1
305
+ ),
306
+ timeout_seconds=(
307
+ timeout_seconds
308
+ if timeout_seconds is not None
309
+ else profile.timeout_seconds
310
+ ),
311
+ profile=profile,
312
+ )
313
+ if profile.executable is None:
314
+ raise AnnotationError("local execution profile has no executable")
315
+ return ResolvedAnnotationInputs(
316
+ adapter=profile.adapter,
317
+ executable=profile.executable,
318
+ resource=profile.resource,
319
+ store=store if store is not None else profile.store,
320
+ threads=(
321
+ threads
322
+ if threads is not None
323
+ else profile.threads
324
+ if profile.threads is not None
325
+ else 1
326
+ ),
327
+ timeout_seconds=(
328
+ timeout_seconds if timeout_seconds is not None else profile.timeout_seconds
329
+ ),
330
+ environment=profile.environment,
331
+ profile=profile,
332
+ )
333
+
334
+
335
+ def _coerce_adapter_name(value: AdapterName | str) -> AdapterName:
336
+ try:
337
+ return value if isinstance(value, AdapterName) else AdapterName(value)
338
+ except ValueError as error:
339
+ raise AnnotationError(f"unknown adapter: {value}") from error
340
+
341
+
342
+ def _resolve_executable(value: str | Path) -> Path:
343
+ value_text = str(value)
344
+ candidate = (
345
+ value_text.replace("~", str(Path.home()), 1)
346
+ if value_text.startswith("~")
347
+ else value_text
348
+ )
349
+ resolved = shutil.which(candidate)
350
+ if resolved is None:
351
+ raise AnnotationError(f"executable not found or not executable: {value}")
352
+ return Path(resolved).resolve()
353
+
354
+
355
+ def _resolve_resource(value: str | Path) -> Path:
356
+ resolved = Path(value).expanduser().resolve()
357
+ if not resolved.is_dir():
358
+ raise AnnotationError(f"annotation resource is not a directory: {resolved}")
359
+ return resolved
360
+
361
+
362
+ def _result_metadata_template(adapter: AnnotationAdapter) -> dict[str, str]:
363
+ name = adapter.contract.name
364
+ if name == "eggnog":
365
+ upstream_tool = "eggNOG-mapper"
366
+ upstream_version = str(getattr(adapter, "tool_version", "unknown"))
367
+ schema_id = "eggnog-mapper/2"
368
+ elif name == "interpro-pfam":
369
+ upstream_tool = "InterProScan"
370
+ upstream_version = str(getattr(adapter, "interproscan_version", "unknown"))
371
+ schema_id = "interproscan-pfam/5"
372
+ elif name == "dbcan-cazyme":
373
+ upstream_tool = "dbCAN"
374
+ upstream_version = str(getattr(adapter, "dbcan_version", "unknown"))
375
+ schema_id = "dbcan-cazyme/5"
376
+ else:
377
+ upstream_tool = name
378
+ upstream_version = "unknown"
379
+ schema_id = f"{name}/1"
380
+ return {
381
+ "ResultFormatVersion": RESULT_FORMAT_VERSION,
382
+ "ResultSchemaID": schema_id,
383
+ "SeqEviVersion": __version__,
384
+ "Adapter": adapter.contract.name,
385
+ "AdapterContractVersion": adapter.contract.version,
386
+ "UpstreamTool": upstream_tool,
387
+ "UpstreamToolVersion": upstream_version,
388
+ "ToolRuntimeDigest": adapter.contract.tool_runtime_digest,
389
+ "ResourceID": adapter.contract.resource_id,
390
+ }