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/cli.py ADDED
@@ -0,0 +1,610 @@
1
+ """SeqEvi command-line entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated, NoReturn
10
+
11
+ import typer
12
+
13
+ from . import __version__
14
+ from .adapters import (
15
+ AdapterConfiguration,
16
+ AdapterName,
17
+ create_adapter,
18
+ )
19
+ from .api import _run_annotation_application
20
+ from .distribution import SetupPlan, apply_setup, build_setup_plan
21
+ from .errors import AnnotationError, FastaValidationError, SetupError, StoreError
22
+ from .execution_profile import (
23
+ initialize_named_profile,
24
+ list_named_profiles,
25
+ load_execution_profile,
26
+ load_named_profile,
27
+ profile_example,
28
+ redacted_effective_configuration,
29
+ )
30
+ from .resource_lock import resource_lock_path
31
+
32
+ app = typer.Typer(
33
+ name="seqevi",
34
+ help="Content-addressed protein sequence annotation evidence.",
35
+ invoke_without_command=True,
36
+ no_args_is_help=False,
37
+ )
38
+ resource_app = typer.Typer(
39
+ name="resource",
40
+ help="Initialize and verify immutable database resource locks.",
41
+ no_args_is_help=True,
42
+ )
43
+ profile_app = typer.Typer(
44
+ name="profile",
45
+ help="Inspect and validate reusable external-tool execution profiles.",
46
+ no_args_is_help=True,
47
+ )
48
+ app.add_typer(resource_app, name="resource")
49
+ app.add_typer(profile_app, name="profile")
50
+
51
+
52
+ def _version_callback(value: bool) -> None:
53
+ if value:
54
+ typer.echo(__version__)
55
+ raise typer.Exit()
56
+
57
+
58
+ def _resolve_executable(value: str) -> Path:
59
+ candidate = str(Path(value).expanduser()) if value.startswith("~") else value
60
+ resolved = shutil.which(candidate)
61
+ if resolved is None:
62
+ raise typer.BadParameter(f"Executable not found or not executable: {value}")
63
+ return Path(resolved).resolve()
64
+
65
+
66
+ @app.callback()
67
+ def root(
68
+ context: typer.Context,
69
+ version: Annotated[
70
+ bool,
71
+ typer.Option(
72
+ "--version",
73
+ callback=_version_callback,
74
+ is_eager=True,
75
+ help="Show the SeqEvi version and exit.",
76
+ ),
77
+ ] = False,
78
+ ) -> None:
79
+ """Run SeqEvi commands or show top-level help."""
80
+
81
+ del version
82
+ if context.invoked_subcommand is None:
83
+ typer.echo(context.get_help())
84
+
85
+
86
+ @app.command("annotate")
87
+ def annotate_command(
88
+ fasta: Annotated[
89
+ Path,
90
+ typer.Option(
91
+ "--fasta",
92
+ exists=True,
93
+ file_okay=True,
94
+ dir_okay=False,
95
+ readable=True,
96
+ resolve_path=True,
97
+ help="Protein FASTA to annotate.",
98
+ ),
99
+ ],
100
+ output: Annotated[
101
+ Path,
102
+ typer.Option(
103
+ "-o",
104
+ "--output",
105
+ file_okay=True,
106
+ dir_okay=False,
107
+ writable=True,
108
+ resolve_path=True,
109
+ help="New DuckDB result file (normally ending in .duckdb).",
110
+ ),
111
+ ],
112
+ adapter: Annotated[
113
+ AdapterName | None,
114
+ typer.Option("--adapter", help="Official annotation adapter to run."),
115
+ ] = None,
116
+ executable: Annotated[
117
+ Path | None,
118
+ typer.Option(
119
+ "--executable",
120
+ parser=_resolve_executable,
121
+ metavar="EXECUTABLE",
122
+ help="Adapter tool executable or command name.",
123
+ ),
124
+ ] = None,
125
+ resource: Annotated[
126
+ Path | None,
127
+ typer.Option(
128
+ "--resource",
129
+ exists=True,
130
+ file_okay=False,
131
+ dir_okay=True,
132
+ readable=True,
133
+ resolve_path=True,
134
+ help="Native upstream annotation resource directory.",
135
+ ),
136
+ ] = None,
137
+ profile: Annotated[
138
+ str | None,
139
+ typer.Option(
140
+ "--profile",
141
+ help="Named profile under the SeqEvi user configuration directory.",
142
+ ),
143
+ ] = None,
144
+ config: Annotated[
145
+ Path | None,
146
+ typer.Option(
147
+ "--config",
148
+ exists=True,
149
+ file_okay=True,
150
+ dir_okay=False,
151
+ readable=True,
152
+ resolve_path=True,
153
+ help="Explicit execution-profile TOML file.",
154
+ ),
155
+ ] = None,
156
+ store: Annotated[
157
+ str | None,
158
+ typer.Option(
159
+ "--store",
160
+ envvar="SEQEVI_STORE",
161
+ help="Local Store path or shared Store HTTP(S) URL.",
162
+ ),
163
+ ] = None,
164
+ timeout_seconds: Annotated[
165
+ float | None,
166
+ typer.Option(
167
+ "--timeout-seconds",
168
+ min=0.001,
169
+ help="Optional external tool timeout in seconds.",
170
+ ),
171
+ ] = None,
172
+ threads: Annotated[
173
+ int | None,
174
+ typer.Option(
175
+ "--threads",
176
+ min=1,
177
+ help="Worker threads; overrides the profile default.",
178
+ ),
179
+ ] = None,
180
+ json_output: Annotated[
181
+ bool,
182
+ typer.Option(
183
+ "--json",
184
+ help="Emit one machine-readable JSON result or error document.",
185
+ ),
186
+ ] = False,
187
+ ) -> None:
188
+ """Reuse exact evidence and publish one immutable DuckDB result."""
189
+
190
+ try:
191
+ invocation = _run_annotation_application(
192
+ fasta=fasta,
193
+ output=output,
194
+ profile=profile,
195
+ config=config,
196
+ adapter=adapter,
197
+ executable=executable,
198
+ resource=resource,
199
+ store=store,
200
+ threads=threads,
201
+ timeout_seconds=timeout_seconds,
202
+ adapter_factory=create_adapter,
203
+ )
204
+ except (AnnotationError, FastaValidationError, StoreError) as error:
205
+ if json_output:
206
+ typer.echo(
207
+ json.dumps(
208
+ {
209
+ "schema_version": 1,
210
+ "status": "error",
211
+ "error_type": type(error).__name__,
212
+ "error": str(error),
213
+ },
214
+ sort_keys=True,
215
+ ),
216
+ err=True,
217
+ )
218
+ else:
219
+ typer.echo(f"Error: {error}", err=True)
220
+ raise typer.Exit(code=1) from error
221
+
222
+ summary = invocation.summary
223
+ if json_output:
224
+ typer.echo(
225
+ json.dumps(
226
+ {
227
+ "schema_version": 1,
228
+ "status": "ok",
229
+ "adapter": invocation.adapter,
230
+ "result_schema": invocation.result_schema_id,
231
+ "counts": {
232
+ "input_records": summary.input_records,
233
+ "unique_sequences": summary.unique_sequences,
234
+ "cache_hits": summary.cache_hits,
235
+ "computed": summary.computed,
236
+ "hits": summary.hits,
237
+ "no_hits": summary.no_hits,
238
+ },
239
+ "output": str(summary.output_dir),
240
+ "metrics": {
241
+ "elapsed_seconds": summary.metrics.elapsed_seconds,
242
+ "package_seconds": summary.metrics.package_seconds,
243
+ "configured_threads": summary.metrics.configured_threads,
244
+ },
245
+ },
246
+ sort_keys=True,
247
+ )
248
+ )
249
+ return
250
+
251
+ typer.echo(
252
+ f"Annotated {summary.unique_sequences} unique sequences "
253
+ f"({summary.cache_hits} cached, {summary.computed} computed); "
254
+ f"output: {summary.output_dir}"
255
+ )
256
+
257
+
258
+ @app.command("setup")
259
+ def setup_command(
260
+ kit: Annotated[
261
+ str,
262
+ typer.Argument(help="Managed kit to plan (currently dbcan-cazyme)."),
263
+ ],
264
+ resource: Annotated[
265
+ Path | None,
266
+ typer.Option(
267
+ "--resource",
268
+ metavar="PATH",
269
+ help="Caller-owned database root; required on first non-interactive setup.",
270
+ ),
271
+ ] = None,
272
+ profile_name: Annotated[
273
+ str | None,
274
+ typer.Option(
275
+ "--profile-name",
276
+ help="Named v2 profile to inspect or publish (default: kit name).",
277
+ ),
278
+ ] = None,
279
+ dry_run: Annotated[
280
+ bool,
281
+ typer.Option(
282
+ "--dry-run",
283
+ help="Build and display a read-only setup plan; never mutate state.",
284
+ ),
285
+ ] = False,
286
+ yes: Annotated[
287
+ bool,
288
+ typer.Option(
289
+ "--yes",
290
+ help="Apply the validated setup plan without confirmation.",
291
+ ),
292
+ ] = False,
293
+ json_output: Annotated[
294
+ bool,
295
+ typer.Option(
296
+ "--json",
297
+ help="Emit the same setup plan as one JSON document.",
298
+ ),
299
+ ] = False,
300
+ ) -> None:
301
+ """Plan managed adapter setup without installing tools or databases."""
302
+
303
+ if resource is None and not json_output and sys.stdin.isatty():
304
+ resource_text = typer.prompt("Caller-owned dbCAN resource directory")
305
+ resource = Path(resource_text)
306
+ try:
307
+ plan = build_setup_plan(
308
+ kit,
309
+ resource=resource,
310
+ profile_name=profile_name,
311
+ stdin_isatty=sys.stdin.isatty(),
312
+ )
313
+ except (AnnotationError, SetupError) as error:
314
+ _emit_setup_error(error, json_output=json_output)
315
+
316
+ if dry_run or not plan.ready_for_apply:
317
+ if json_output:
318
+ typer.echo(json.dumps(plan.as_dict(), sort_keys=True))
319
+ else:
320
+ typer.echo(_render_setup_plan(plan), nl=False)
321
+ if not plan.ready_for_apply:
322
+ raise typer.Exit(code=1)
323
+ return
324
+
325
+ if not yes and not sys.stdin.isatty():
326
+ _emit_setup_error(
327
+ SetupError("non-interactive setup apply requires --yes"),
328
+ json_output=json_output,
329
+ )
330
+ if not json_output:
331
+ typer.echo(_render_setup_plan(plan), nl=False)
332
+ if not yes:
333
+ confirmed = typer.confirm(
334
+ "Apply this setup plan?",
335
+ default=False,
336
+ err=json_output,
337
+ )
338
+ if not confirmed:
339
+ raise typer.Exit(code=1)
340
+ try:
341
+ applied = apply_setup(plan)
342
+ except (AnnotationError, SetupError) as error:
343
+ _emit_setup_error(error, json_output=json_output)
344
+
345
+ if json_output:
346
+ typer.echo(json.dumps(applied.as_dict(), sort_keys=True))
347
+ else:
348
+ typer.echo(_render_setup_plan(applied), nl=False)
349
+ if applied.status != "applied":
350
+ raise typer.Exit(code=1)
351
+
352
+
353
+ def _emit_setup_error(error: AnnotationError, *, json_output: bool) -> NoReturn:
354
+ if json_output:
355
+ typer.echo(
356
+ json.dumps(
357
+ {
358
+ "schema_version": 1,
359
+ "status": "error",
360
+ "error_type": type(error).__name__,
361
+ "error": str(error),
362
+ },
363
+ sort_keys=True,
364
+ ),
365
+ err=True,
366
+ )
367
+ else:
368
+ typer.echo(f"Error: {error}", err=True)
369
+ raise typer.Exit(code=1) from error
370
+
371
+
372
+ def _render_setup_plan(plan: SetupPlan) -> str:
373
+ """Render the typed plan without progress output or secret material."""
374
+
375
+ lines = [
376
+ "Setup plan (read-only; Slice A)",
377
+ f"status: {plan.status}",
378
+ f"adapter: {plan.adapter}",
379
+ f"kit_id: {plan.kit_id}",
380
+ "runtime:",
381
+ f" platform: {plan.runtime.platform}",
382
+ f" engine: {plan.runtime.engine}",
383
+ f" image: {plan.runtime.image}",
384
+ f" image_status: {plan.runtime.image_status}",
385
+ f" dbCAN: {plan.runtime.dbcan_version}",
386
+ f" DIAMOND: {plan.runtime.diamond_version}",
387
+ "resource:",
388
+ f" path: {plan.resource.path if plan.resource.path is not None else '(unresolved)'}",
389
+ f" status: {plan.resource.status}",
390
+ f" lock: {plan.resource.lock_path if plan.resource.lock_path is not None else '(none)'}",
391
+ "profile:",
392
+ f" name: {plan.profile.name}",
393
+ f" path: {plan.profile.path}",
394
+ f" status: {plan.profile.status}",
395
+ "actions:",
396
+ ]
397
+ lines.extend(f" - {action}" for action in plan.actions)
398
+ lines.extend(
399
+ (
400
+ f"smoke: {plan.smoke_status} ({plan.smoke_reason})",
401
+ f"next_command: {plan.next_command or ('managed OCI annotation is Slice C' if plan.status == 'applied' else '(setup apply is available with --yes)')}",
402
+ )
403
+ )
404
+ if plan.issues:
405
+ lines.append("issues:")
406
+ lines.extend(f" - {issue}" for issue in plan.issues)
407
+ return "\n".join(lines) + "\n"
408
+
409
+
410
+ @profile_app.command("example")
411
+ def profile_example_command(
412
+ adapter: Annotated[
413
+ AdapterName,
414
+ typer.Option("--adapter", help="Official adapter for the example profile."),
415
+ ],
416
+ ) -> None:
417
+ """Print a complete execution-profile TOML example."""
418
+
419
+ typer.echo(profile_example(adapter), nl=False)
420
+
421
+
422
+ @profile_app.command("validate")
423
+ def profile_validate_command(
424
+ config: Annotated[
425
+ Path,
426
+ typer.Option(
427
+ "--config",
428
+ exists=True,
429
+ file_okay=True,
430
+ dir_okay=False,
431
+ readable=True,
432
+ resolve_path=True,
433
+ help="Execution-profile TOML file to validate.",
434
+ ),
435
+ ],
436
+ ) -> None:
437
+ """Validate one profile without launching its annotation tool."""
438
+
439
+ try:
440
+ validated = load_execution_profile(config)
441
+ except AnnotationError as error:
442
+ typer.echo(f"Error: {error}", err=True)
443
+ raise typer.Exit(code=1) from error
444
+ typer.echo(
445
+ f"Valid {validated.adapter.value} profile: {validated.source}; "
446
+ f"resource: {validated.resource}"
447
+ )
448
+
449
+
450
+ @profile_app.command("init")
451
+ def profile_init_command(
452
+ name: Annotated[str, typer.Argument(help="Name for the new user profile.")],
453
+ adapter: Annotated[
454
+ AdapterName,
455
+ typer.Option("--adapter", help="Official adapter for the new profile."),
456
+ ],
457
+ ) -> None:
458
+ """Create a complete named profile without overwriting an existing file."""
459
+
460
+ try:
461
+ destination = initialize_named_profile(name, adapter)
462
+ except AnnotationError as error:
463
+ typer.echo(f"Error: {error}", err=True)
464
+ raise typer.Exit(code=1) from error
465
+ typer.echo(f"Created {adapter.value} profile: {destination}")
466
+
467
+
468
+ @profile_app.command("list")
469
+ def profile_list_command() -> None:
470
+ """List named profiles in deterministic order."""
471
+
472
+ try:
473
+ names = list_named_profiles()
474
+ except AnnotationError as error:
475
+ typer.echo(f"Error: {error}", err=True)
476
+ raise typer.Exit(code=1) from error
477
+ for name in names:
478
+ typer.echo(name)
479
+
480
+
481
+ @profile_app.command("show")
482
+ def profile_show_command(
483
+ name: Annotated[str, typer.Argument(help="Named user profile to inspect.")],
484
+ ) -> None:
485
+ """Show resolved profile configuration with environment values redacted."""
486
+
487
+ try:
488
+ selected = load_named_profile(name)
489
+ except AnnotationError as error:
490
+ typer.echo(f"Error: {error}", err=True)
491
+ raise typer.Exit(code=1) from error
492
+ typer.echo(redacted_effective_configuration(selected), nl=False)
493
+
494
+
495
+ @resource_app.command("verify")
496
+ def verify_resource_command(
497
+ adapter: Annotated[
498
+ AdapterName,
499
+ typer.Option("--adapter", help="Official annotation adapter to verify."),
500
+ ],
501
+ executable: Annotated[
502
+ Path,
503
+ typer.Option(
504
+ "--executable",
505
+ parser=_resolve_executable,
506
+ metavar="EXECUTABLE",
507
+ help="Adapter tool executable or command name.",
508
+ ),
509
+ ],
510
+ resource: Annotated[
511
+ Path,
512
+ typer.Option(
513
+ "--resource",
514
+ exists=True,
515
+ file_okay=False,
516
+ dir_okay=True,
517
+ readable=True,
518
+ resolve_path=True,
519
+ help="Native upstream annotation resource directory.",
520
+ ),
521
+ ],
522
+ ) -> None:
523
+ """Fully hash a database and verify its SeqEvi resource lock."""
524
+
525
+ try:
526
+ configured_adapter = create_adapter(
527
+ AdapterConfiguration(
528
+ name=adapter,
529
+ executable=executable,
530
+ database=resource,
531
+ verify_resource=True,
532
+ )
533
+ )
534
+ except AnnotationError as error:
535
+ typer.echo(f"Error: {error}", err=True)
536
+ raise typer.Exit(code=1) from error
537
+
538
+ lock_path = resource_lock_path(resource)
539
+ lock_status = str(lock_path) if lock_path.is_file() else "not persisted (read-only)"
540
+ typer.echo(
541
+ f"Verified resource {configured_adapter.contract.resource_id}; "
542
+ f"lock: {lock_status}"
543
+ )
544
+
545
+
546
+ @app.command("serve")
547
+ def serve_command(
548
+ database_url: Annotated[
549
+ str,
550
+ typer.Option(
551
+ "--database-url",
552
+ envvar="SEQEVI_DATABASE_URL",
553
+ help="PostgreSQL SQLAlchemy URL for shared metadata.",
554
+ ),
555
+ ],
556
+ artifacts_dir: Annotated[
557
+ Path,
558
+ typer.Option(
559
+ "--artifacts-dir",
560
+ envvar="SEQEVI_ARTIFACTS_DIR",
561
+ file_okay=False,
562
+ dir_okay=True,
563
+ writable=True,
564
+ resolve_path=True,
565
+ help="Writable POSIX directory for content-addressed artifacts.",
566
+ ),
567
+ ],
568
+ host: Annotated[
569
+ str,
570
+ typer.Option("--host", help="HTTP bind address."),
571
+ ] = "127.0.0.1",
572
+ port: Annotated[
573
+ int,
574
+ typer.Option("--port", min=1, max=65535, help="HTTP bind port."),
575
+ ] = 8000,
576
+ maximum_batch_size: Annotated[
577
+ int,
578
+ typer.Option("--maximum-batch-size", min=1, max=10000),
579
+ ] = 1000,
580
+ maximum_artifact_bytes: Annotated[
581
+ int,
582
+ typer.Option("--maximum-artifact-bytes", min=1),
583
+ ] = 512 * 1024 * 1024,
584
+ ) -> None:
585
+ """Run the passive PostgreSQL/POSIX shared Store service."""
586
+
587
+ try:
588
+ import uvicorn
589
+
590
+ from .service import ServiceSettings, create_service_app
591
+
592
+ settings = ServiceSettings(
593
+ database_url=database_url,
594
+ artifacts_dir=artifacts_dir,
595
+ maximum_batch_size=maximum_batch_size,
596
+ maximum_artifact_bytes=maximum_artifact_bytes,
597
+ )
598
+ uvicorn.run(create_service_app(settings), host=host, port=port)
599
+ except ImportError as error:
600
+ typer.echo(
601
+ "Error: shared Store dependencies are missing; install seqevi[server]",
602
+ err=True,
603
+ )
604
+ raise typer.Exit(code=1) from error
605
+
606
+
607
+ def main() -> None:
608
+ """Run the SeqEvi CLI."""
609
+
610
+ app()
@@ -0,0 +1,13 @@
1
+ """Managed adapter distribution boundaries."""
2
+
3
+ from .manifest import KitComponent, KitManifest, load_kit_manifest
4
+ from .setup import SetupPlan, apply_setup, build_setup_plan
5
+
6
+ __all__ = [
7
+ "KitComponent",
8
+ "KitManifest",
9
+ "SetupPlan",
10
+ "apply_setup",
11
+ "build_setup_plan",
12
+ "load_kit_manifest",
13
+ ]