seqevi 0.3.2__tar.gz → 0.3.4__tar.gz

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 (83) hide show
  1. {seqevi-0.3.2 → seqevi-0.3.4}/PKG-INFO +3 -3
  2. {seqevi-0.3.2 → seqevi-0.3.4}/README.md +2 -2
  3. {seqevi-0.3.2 → seqevi-0.3.4}/pyproject.toml +1 -1
  4. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/__init__.py +1 -1
  5. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/annotate.py +167 -0
  6. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/api.py +61 -2
  7. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/cli.py +136 -1
  8. seqevi-0.3.4/src/seqevi/progress.py +88 -0
  9. seqevi-0.3.4/tests/test_api.py +250 -0
  10. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_cli.py +116 -2
  11. seqevi-0.3.4/tests/test_progress.py +327 -0
  12. seqevi-0.3.2/tests/test_api.py +0 -91
  13. {seqevi-0.3.2 → seqevi-0.3.4}/LICENSE +0 -0
  14. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/__main__.py +0 -0
  15. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/adapters/__init__.py +0 -0
  16. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/adapters/base.py +0 -0
  17. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/adapters/dbcan_cazyme.py +0 -0
  18. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/adapters/eggnog.py +0 -0
  19. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/adapters/interpro_pfam.py +0 -0
  20. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/adapters/registry.py +0 -0
  21. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/distribution/__init__.py +0 -0
  22. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/distribution/manifest.py +0 -0
  23. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/distribution/oci.py +0 -0
  24. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/distribution/setup.py +0 -0
  25. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/errors.py +0 -0
  26. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/evidence.py +0 -0
  27. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/execution_profile.py +0 -0
  28. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/hashing.py +0 -0
  29. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/kits/__init__.py +0 -0
  30. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/kits/dbcan-cazyme.toml +0 -0
  31. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/resource_lock.py +0 -0
  32. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/result.py +0 -0
  33. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/runner.py +0 -0
  34. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/runtime_identity.py +0 -0
  35. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/sequence.py +0 -0
  36. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/service/__init__.py +0 -0
  37. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/service/app.py +0 -0
  38. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/service/config.py +0 -0
  39. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/service/persistence.py +0 -0
  40. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/__init__.py +0 -0
  41. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/_artifact_reader.py +0 -0
  42. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/_resolver.py +0 -0
  43. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/artifact.py +0 -0
  44. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/client.py +0 -0
  45. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/contract.py +0 -0
  46. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/factory.py +0 -0
  47. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/local.py +0 -0
  48. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/migration.py +0 -0
  49. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/migrations/__init__.py +0 -0
  50. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/migrations/env.py +0 -0
  51. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/migrations/versions/0001_initial_store.py +0 -0
  52. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/migrations/versions/0002_artifact_byte_size_bigint.py +0 -0
  53. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/migrations/versions/0003_evidence_claim_leases.py +0 -0
  54. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/migrations/versions/0004_claim_sessions.py +0 -0
  55. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/migrations/versions/__init__.py +0 -0
  56. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/schema.py +0 -0
  57. {seqevi-0.3.2 → seqevi-0.3.4}/src/seqevi/store/transport.py +0 -0
  58. {seqevi-0.3.2 → seqevi-0.3.4}/tests/__init__.py +0 -0
  59. {seqevi-0.3.2 → seqevi-0.3.4}/tests/support.py +0 -0
  60. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_acceptance_containment.py +0 -0
  61. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_acceptance_harnesses.py +0 -0
  62. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_annotate.py +0 -0
  63. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_architecture_contracts.py +0 -0
  64. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_artifact_store.py +0 -0
  65. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_claim_leases.py +0 -0
  66. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_dbcan_cazyme.py +0 -0
  67. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_dbcan_image_assets.py +0 -0
  68. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_dbcan_local_candidate_harness.py +0 -0
  69. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_deployment_assets.py +0 -0
  70. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_eggnog.py +0 -0
  71. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_evidence.py +0 -0
  72. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_execution_profile.py +0 -0
  73. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_incremental_reuse.py +0 -0
  74. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_interpro_pfam.py +0 -0
  75. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_local_store.py +0 -0
  76. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_managed_setup.py +0 -0
  77. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_oci.py +0 -0
  78. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_resource_lock.py +0 -0
  79. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_result_prototype.py +0 -0
  80. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_runner.py +0 -0
  81. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_runtime_identity.py +0 -0
  82. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_sequence.py +0 -0
  83. {seqevi-0.3.2 → seqevi-0.3.4}/tests/test_shared_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: seqevi
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Content-addressed, reusable protein sequence annotation evidence.
5
5
  Author-Email: Fuqing Zhang <fu.qing.zhang.work@gmail.com>, FuqingZhang <103730099+FuqingZh@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -45,11 +45,11 @@ single-file DuckDB result for the current FASTA.
45
45
  ## Status
46
46
 
47
47
  The target architecture and v1.1 result contracts are approved. The SeqEvi
48
- The 0.3.2 candidate source tree uses strict protein sequence identity, the single-host
48
+ 0.3.4 source tree uses strict protein sequence identity, the single-host
49
49
  SQLite/POSIX Store, the external tool runner, exact cache-miss orchestration,
50
50
  and atomic DuckDB result materialization.
51
51
 
52
- SeqEvi 0.3.2 provides managed setup for dbCAN only. The `eggnog` and
52
+ SeqEvi 0.3.4 provides managed setup for dbCAN only. The `eggnog` and
53
53
  `interpro-pfam` adapters remain supported through explicit runtimes and named
54
54
  host profiles; managed setup for them is later feature work. The
55
55
  [Slice D gate record](docs/benchmarks/20260806-v1.4-dbcan-public-release-gate.md)
@@ -11,11 +11,11 @@ single-file DuckDB result for the current FASTA.
11
11
  ## Status
12
12
 
13
13
  The target architecture and v1.1 result contracts are approved. The SeqEvi
14
- The 0.3.2 candidate source tree uses strict protein sequence identity, the single-host
14
+ 0.3.4 source tree uses strict protein sequence identity, the single-host
15
15
  SQLite/POSIX Store, the external tool runner, exact cache-miss orchestration,
16
16
  and atomic DuckDB result materialization.
17
17
 
18
- SeqEvi 0.3.2 provides managed setup for dbCAN only. The `eggnog` and
18
+ SeqEvi 0.3.4 provides managed setup for dbCAN only. The `eggnog` and
19
19
  `interpro-pfam` adapters remain supported through explicit runtimes and named
20
20
  host profiles; managed setup for them is later feature work. The
21
21
  [Slice D gate record](docs/benchmarks/20260806-v1.4-dbcan-public-release-gate.md)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "seqevi"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "Content-addressed, reusable protein sequence annotation evidence."
5
5
  authors = [
6
6
  { name = "Fuqing Zhang", email = "fu.qing.zhang.work@gmail.com" },
@@ -1,6 +1,6 @@
1
1
  """Content-addressed protein sequence annotation evidence."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.4"
4
4
 
5
5
  from .api import annotate, scan_annotations
6
6
 
@@ -26,6 +26,14 @@ from .evidence import (
26
26
  EvidenceSource,
27
27
  EvidenceStatus,
28
28
  )
29
+ from .progress import (
30
+ ProgressEvent,
31
+ ProgressPhase,
32
+ ProgressSink,
33
+ ProgressState,
34
+ ProgressUnit,
35
+ emit_progress,
36
+ )
29
37
  from .result import RESULT_FORMAT_VERSION, materialize_result_database
30
38
  from .runner import ToolCancelledError, ToolCommand, ToolRunResult, ToolRunner
31
39
  from .sequence import (
@@ -134,6 +142,7 @@ def run_annotation(
134
142
  threads: int = 1,
135
143
  output_format: Literal["duckdb"] = "duckdb",
136
144
  result_metadata: Mapping[str, str] | None = None,
145
+ progress_sink: ProgressSink | None = None,
137
146
  ) -> AnnotationSummary:
138
147
  """Resolve exact evidence, compute misses, and publish one DuckDB result."""
139
148
 
@@ -148,11 +157,30 @@ def run_annotation(
148
157
 
149
158
  started = time.perf_counter()
150
159
  fasta_started = time.perf_counter()
160
+ emit_progress(
161
+ progress_sink,
162
+ ProgressEvent(
163
+ ProgressPhase.STAGING,
164
+ ProgressState.STARTED,
165
+ "staging FASTA",
166
+ ),
167
+ )
151
168
  fasta_stage_root = Path(
152
169
  tempfile.mkdtemp(prefix=".seqevi-fasta-", dir=output_dir.parent)
153
170
  )
154
171
  stage = stage_fasta(fasta_path, fasta_stage_root)
155
172
  fasta_staging_seconds = time.perf_counter() - fasta_started
173
+ emit_progress(
174
+ progress_sink,
175
+ ProgressEvent(
176
+ ProgressPhase.STAGING,
177
+ ProgressState.COMPLETED,
178
+ "FASTA staged",
179
+ completed=stage.unique_sequences,
180
+ total=stage.unique_sequences,
181
+ unit=ProgressUnit.SEQUENCES,
182
+ ),
183
+ )
156
184
  try:
157
185
  work_dir = Path(
158
186
  tempfile.mkdtemp(prefix=".seqevi-annotate-", dir=output_dir.parent)
@@ -201,6 +229,14 @@ def run_annotation(
201
229
  claim_session.cancellation_signal if claim_session is not None else None
202
230
  ),
203
231
  )
232
+ emit_progress(
233
+ progress_sink,
234
+ ProgressEvent(
235
+ ProgressPhase.STORE_LOOKUP,
236
+ ProgressState.STARTED,
237
+ "store lookup",
238
+ ),
239
+ )
204
240
 
205
241
  for identity_batch in batched(iter_staged_identities(stage), _STORE_BATCH_SIZE):
206
242
  queries = tuple(
@@ -238,6 +274,17 @@ def run_annotation(
238
274
  cached = store.lookup_many(queries)
239
275
  store_lookup_seconds += time.perf_counter() - lookup_started
240
276
  lookup_batches += 1
277
+ emit_progress(
278
+ progress_sink,
279
+ ProgressEvent(
280
+ ProgressPhase.STORE_LOOKUP,
281
+ ProgressState.RUNNING,
282
+ "store lookup",
283
+ completed=len(keys_by_sequence_id),
284
+ total=stage.unique_sequences,
285
+ unit=ProgressUnit.SEQUENCES,
286
+ ),
287
+ )
241
288
  if claim_session is None:
242
289
  for query in queries:
243
290
  if query.key not in cached:
@@ -259,6 +306,7 @@ def run_annotation(
259
306
  timeout_seconds=timeout_seconds,
260
307
  threads=threads,
261
308
  claim_session=claim_session,
309
+ progress_sink=progress_sink,
262
310
  )
263
311
  commit_batches += batch_metrics.commit_batches
264
312
  adapter_seconds += batch_metrics.adapter_seconds
@@ -281,6 +329,7 @@ def run_annotation(
281
329
  timeout_seconds=timeout_seconds,
282
330
  threads=threads,
283
331
  claim_session=claim_session,
332
+ progress_sink=progress_sink,
284
333
  )
285
334
  commit_batches += batch_metrics.commit_batches
286
335
  adapter_seconds += batch_metrics.adapter_seconds
@@ -288,7 +337,16 @@ def run_annotation(
288
337
  existing_finalizations += batch_metrics.existing_finalizations
289
338
  computed_ids.difference_update(batch_metrics.peer_completed_sequence_ids)
290
339
 
340
+ waited_for_claim = bool(busy_queries)
291
341
  while busy_queries:
342
+ emit_progress(
343
+ progress_sink,
344
+ ProgressEvent(
345
+ ProgressPhase.CLAIM_WAIT,
346
+ ProgressState.RUNNING,
347
+ "waiting for peer claims",
348
+ ),
349
+ )
292
350
  time.sleep(busy_retry_after or 1.0)
293
351
  next_retry_after: float | None = None
294
352
  next_busy = []
@@ -328,6 +386,7 @@ def run_annotation(
328
386
  timeout_seconds=timeout_seconds,
329
387
  threads=threads,
330
388
  claim_session=claim_session,
389
+ progress_sink=progress_sink,
331
390
  )
332
391
  commit_batches += batch_metrics.commit_batches
333
392
  adapter_seconds += batch_metrics.adapter_seconds
@@ -339,8 +398,38 @@ def run_annotation(
339
398
  busy_queries = next_busy
340
399
  busy_retry_after = next_retry_after
341
400
 
401
+ if waited_for_claim:
402
+ emit_progress(
403
+ progress_sink,
404
+ ProgressEvent(
405
+ ProgressPhase.CLAIM_WAIT,
406
+ ProgressState.COMPLETED,
407
+ "peer claims resolved",
408
+ ),
409
+ )
410
+
411
+ emit_progress(
412
+ progress_sink,
413
+ ProgressEvent(
414
+ ProgressPhase.STORE_LOOKUP,
415
+ ProgressState.COMPLETED,
416
+ "store lookup completed",
417
+ completed=stage.unique_sequences,
418
+ total=stage.unique_sequences,
419
+ unit=ProgressUnit.SEQUENCES,
420
+ ),
421
+ )
422
+
342
423
  if claim_session is not None:
343
424
  claim_session.raise_if_lost()
425
+ emit_progress(
426
+ progress_sink,
427
+ ProgressEvent(
428
+ ProgressPhase.STORE_FETCH,
429
+ ProgressState.STARTED,
430
+ "fetching evidence",
431
+ ),
432
+ )
344
433
  fetch_started = time.perf_counter()
345
434
  fetched_by_key = store.fetch_many(keys_by_sequence_id.values())
346
435
  store_fetch_seconds = time.perf_counter() - fetch_started
@@ -351,6 +440,17 @@ def run_annotation(
351
440
  raise AnnotationError(
352
441
  f"Store did not expose {len(missing_keys)} terminal evidence records"
353
442
  )
443
+ emit_progress(
444
+ progress_sink,
445
+ ProgressEvent(
446
+ ProgressPhase.STORE_FETCH,
447
+ ProgressState.COMPLETED,
448
+ "evidence fetched",
449
+ completed=len(fetched_by_key),
450
+ total=stage.unique_sequences,
451
+ unit=ProgressUnit.SEQUENCES,
452
+ ),
453
+ )
354
454
  fetched_by_sequence_id = {
355
455
  key.sequence_id: fetched for key, fetched in fetched_by_key.items()
356
456
  }
@@ -366,6 +466,14 @@ def run_annotation(
366
466
  statuses = [
367
467
  fetched.record.status for fetched in fetched_by_sequence_id.values()
368
468
  ]
469
+ emit_progress(
470
+ progress_sink,
471
+ ProgressEvent(
472
+ ProgressPhase.PACKAGE,
473
+ ProgressState.STARTED,
474
+ "packaging result",
475
+ ),
476
+ )
369
477
  package_started = time.perf_counter()
370
478
  if output_format != "duckdb":
371
479
  raise AnnotationError(f"unsupported output format: {output_format}")
@@ -413,6 +521,17 @@ def run_annotation(
413
521
  unlink_owned_output()
414
522
  raise
415
523
  package_seconds = time.perf_counter() - package_started
524
+ emit_progress(
525
+ progress_sink,
526
+ ProgressEvent(
527
+ ProgressPhase.PACKAGE,
528
+ ProgressState.COMPLETED,
529
+ "result packaged",
530
+ completed=stage.unique_sequences,
531
+ total=stage.unique_sequences,
532
+ unit=ProgressUnit.SEQUENCES,
533
+ ),
534
+ )
416
535
  except BaseException as error:
417
536
  primary_failure = error
418
537
  if not isinstance(error, Exception):
@@ -538,6 +657,7 @@ def _run_annotation_batch(
538
657
  timeout_seconds: float | None,
539
658
  threads: int,
540
659
  claim_session: ClaimSession | None = None,
660
+ progress_sink: ProgressSink | None = None,
541
661
  ) -> _BatchMetrics:
542
662
  batch_dir = work_dir / f"batch-{batch_number:06d}"
543
663
  batch_dir.mkdir()
@@ -546,6 +666,15 @@ def _run_annotation_batch(
546
666
  claim_session.raise_if_lost()
547
667
  with misses_fasta.open("w", encoding="ascii", newline="\n") as handle:
548
668
  handle.writelines(iter_fasta_lines(identities))
669
+ tool_message = f"{_tool_display_name(adapter)} · tool batch {batch_number}"
670
+ emit_progress(
671
+ progress_sink,
672
+ ProgressEvent(
673
+ ProgressPhase.TOOL,
674
+ ProgressState.STARTED,
675
+ f"{tool_message} running",
676
+ ),
677
+ )
549
678
  adapter_started = time.perf_counter()
550
679
  try:
551
680
  batch = adapter.run_batch(
@@ -561,12 +690,31 @@ def _run_annotation_batch(
561
690
  claim_session.raise_if_lost()
562
691
  raise
563
692
  adapter_seconds = time.perf_counter() - adapter_started
693
+ emit_progress(
694
+ progress_sink,
695
+ ProgressEvent(
696
+ ProgressPhase.TOOL,
697
+ ProgressState.COMPLETED,
698
+ f"{tool_message} completed",
699
+ completed=len(identities),
700
+ total=len(identities),
701
+ unit=ProgressUnit.SEQUENCES,
702
+ ),
703
+ )
564
704
  if claim_session is not None:
565
705
  claim_session.raise_if_lost()
566
706
  commits = _build_commits(batch=batch, identities=identities, adapter=adapter)
567
707
  batches = 0
568
708
  peer_completed_sequence_ids: set[str] = set()
569
709
  existing_finalizations = 0
710
+ emit_progress(
711
+ progress_sink,
712
+ ProgressEvent(
713
+ ProgressPhase.STORE_COMMIT,
714
+ ProgressState.STARTED,
715
+ f"committing tool batch {batch_number}",
716
+ ),
717
+ )
570
718
  commit_started = time.perf_counter()
571
719
  for commit_batch in batched(commits, _STORE_BATCH_SIZE):
572
720
  if claim_session is None:
@@ -585,6 +733,17 @@ def _run_annotation_batch(
585
733
  outcome is CommitOutcome.EXISTING for outcome in outcomes
586
734
  )
587
735
  batches += 1
736
+ emit_progress(
737
+ progress_sink,
738
+ ProgressEvent(
739
+ ProgressPhase.STORE_COMMIT,
740
+ ProgressState.COMPLETED,
741
+ f"tool batch {batch_number} committed",
742
+ completed=len(identities),
743
+ total=len(identities),
744
+ unit=ProgressUnit.SEQUENCES,
745
+ ),
746
+ )
588
747
  return _BatchMetrics(
589
748
  commit_batches=batches,
590
749
  adapter_seconds=adapter_seconds,
@@ -594,6 +753,14 @@ def _run_annotation_batch(
594
753
  )
595
754
 
596
755
 
756
+ def _tool_display_name(adapter: AnnotationAdapter) -> str:
757
+ return {
758
+ "eggnog": "eggNOG-mapper",
759
+ "interpro-pfam": "InterProScan/Pfam",
760
+ "dbcan-cazyme": "dbCAN",
761
+ }.get(adapter.contract.name, adapter.contract.name)
762
+
763
+
597
764
  def _build_commits(
598
765
  *,
599
766
  batch: AdapterBatchResult,
@@ -24,6 +24,13 @@ from .execution_profile import (
24
24
  load_execution_profile,
25
25
  load_named_profile,
26
26
  )
27
+ from .progress import (
28
+ ProgressEvent,
29
+ ProgressPhase,
30
+ ProgressSink,
31
+ ProgressState,
32
+ emit_progress,
33
+ )
27
34
  from .result import RESULT_FORMAT_VERSION, scan_annotations as _scan_annotations
28
35
  from .store import open_evidence_store
29
36
 
@@ -162,7 +169,16 @@ def _run_annotation_application(
162
169
  threads: int | None,
163
170
  timeout_seconds: float | None,
164
171
  adapter_factory: Callable[[AdapterConfiguration], AnnotationAdapter] | None = None,
172
+ progress_sink: ProgressSink | None = None,
165
173
  ) -> AnnotationInvocation:
174
+ emit_progress(
175
+ progress_sink,
176
+ ProgressEvent(
177
+ ProgressPhase.ANNOTATION,
178
+ ProgressState.STARTED,
179
+ "starting annotation",
180
+ ),
181
+ )
166
182
  inputs = resolve_annotation_inputs(
167
183
  profile=profile,
168
184
  config=config,
@@ -174,6 +190,14 @@ def _run_annotation_application(
174
190
  timeout_seconds=timeout_seconds,
175
191
  )
176
192
  if inputs.profile is not None and inputs.profile.version == 2:
193
+ emit_progress(
194
+ progress_sink,
195
+ ProgressEvent(
196
+ ProgressPhase.ANNOTATION,
197
+ ProgressState.RUNNING,
198
+ "managed annotation running",
199
+ ),
200
+ )
177
201
  managed = run_oci_annotation(
178
202
  fasta=fasta.expanduser().resolve(),
179
203
  output=output.expanduser().resolve(),
@@ -182,12 +206,29 @@ def _run_annotation_application(
182
206
  threads=inputs.threads,
183
207
  timeout_seconds=inputs.timeout_seconds,
184
208
  )
185
- return AnnotationInvocation(
209
+ emit_progress(
210
+ progress_sink,
211
+ ProgressEvent(
212
+ ProgressPhase.FINALIZATION,
213
+ ProgressState.STARTED,
214
+ "validating result",
215
+ ),
216
+ )
217
+ invocation = AnnotationInvocation(
186
218
  relation=_scan_annotations(managed.output),
187
219
  summary=managed.summary,
188
220
  adapter=managed.adapter,
189
221
  result_schema_id=managed.result_schema_id,
190
222
  )
223
+ emit_progress(
224
+ progress_sink,
225
+ ProgressEvent(
226
+ ProgressPhase.COMPLETED,
227
+ ProgressState.COMPLETED,
228
+ "annotation completed",
229
+ ),
230
+ )
231
+ return invocation
191
232
  if inputs.executable is None:
192
233
  raise AnnotationError("local annotation requires an executable")
193
234
  factory = create_adapter if adapter_factory is None else adapter_factory
@@ -210,13 +251,31 @@ def _run_annotation_application(
210
251
  threads=inputs.threads,
211
252
  output_format="duckdb",
212
253
  result_metadata=metadata,
254
+ progress_sink=progress_sink,
255
+ )
256
+ emit_progress(
257
+ progress_sink,
258
+ ProgressEvent(
259
+ ProgressPhase.FINALIZATION,
260
+ ProgressState.STARTED,
261
+ "closing Store and validating result",
262
+ ),
213
263
  )
214
- return AnnotationInvocation(
264
+ invocation = AnnotationInvocation(
215
265
  relation=_scan_annotations(summary.output_dir),
216
266
  summary=summary,
217
267
  adapter=metadata["Adapter"],
218
268
  result_schema_id=metadata["ResultSchemaID"],
219
269
  )
270
+ emit_progress(
271
+ progress_sink,
272
+ ProgressEvent(
273
+ ProgressPhase.COMPLETED,
274
+ ProgressState.COMPLETED,
275
+ "annotation completed",
276
+ ),
277
+ )
278
+ return invocation
220
279
 
221
280
 
222
281
  def resolve_annotation_inputs(
@@ -3,10 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import logging
6
7
  import shutil
7
8
  import sys
9
+ import threading
10
+ import time
11
+ from collections.abc import Callable
8
12
  from pathlib import Path
9
- from typing import Annotated, NoReturn
13
+ from typing import Annotated, NoReturn, TextIO
10
14
 
11
15
  import typer
12
16
 
@@ -27,8 +31,12 @@ from .execution_profile import (
27
31
  profile_example,
28
32
  redacted_effective_configuration,
29
33
  )
34
+ from .progress import ProgressEvent, ProgressState
30
35
  from .resource_lock import resource_lock_path
31
36
 
37
+ _LOGGER = logging.getLogger(__name__)
38
+ _PROGRESS_REFRESH_SECONDS = 1.0
39
+
32
40
  app = typer.Typer(
33
41
  name="seqevi",
34
42
  help="Content-addressed protein sequence annotation evidence.",
@@ -63,6 +71,116 @@ def _resolve_executable(value: str) -> Path:
63
71
  return Path(resolved).resolve()
64
72
 
65
73
 
74
+ class _ProgressRenderer:
75
+ """Render the latest private progress event on one transient stderr line."""
76
+
77
+ def __init__(
78
+ self,
79
+ *,
80
+ stream: TextIO,
81
+ refresh_seconds: float = _PROGRESS_REFRESH_SECONDS,
82
+ monotonic: Callable[[], float] = time.monotonic,
83
+ ) -> None:
84
+ self._stream = stream
85
+ self._refresh_seconds = refresh_seconds
86
+ self._monotonic = monotonic
87
+ self._lock = threading.Lock()
88
+ self._stop = threading.Event()
89
+ self._latest: ProgressEvent | None = None
90
+ self._phase_started = monotonic()
91
+ self._line_visible = False
92
+ self._thread: threading.Thread | None = None
93
+
94
+ def __call__(self, event: ProgressEvent) -> None:
95
+ now = self._monotonic()
96
+ with self._lock:
97
+ if (
98
+ self._latest is None
99
+ or self._latest.phase is not event.phase
100
+ or event.state is ProgressState.STARTED
101
+ ):
102
+ self._phase_started = now
103
+ self._latest = event
104
+ self._write_locked(now)
105
+ if self._thread is None:
106
+ self._thread = threading.Thread(
107
+ target=self._refresh,
108
+ name="seqevi-progress",
109
+ daemon=True,
110
+ )
111
+ self._thread.start()
112
+
113
+ def close(self) -> None:
114
+ """Stop the timer and remove the transient line before final output."""
115
+
116
+ self._stop.set()
117
+ if self._thread is not None:
118
+ self._thread.join()
119
+ with self._lock:
120
+ if self._line_visible:
121
+ self._stream.write("\r\x1b[K")
122
+ self._stream.flush()
123
+ self._line_visible = False
124
+
125
+ def _refresh(self) -> None:
126
+ try:
127
+ while not self._stop.wait(self._refresh_seconds):
128
+ with self._lock:
129
+ self._write_locked(self._monotonic())
130
+ except Exception:
131
+ _LOGGER.exception("annotation progress renderer refresh failed")
132
+
133
+ def _write_locked(self, now: float) -> None:
134
+ if self._latest is None:
135
+ return
136
+ elapsed = (
137
+ self._latest.elapsed_seconds
138
+ if self._latest.elapsed_seconds is not None
139
+ else max(0.0, now - self._phase_started)
140
+ )
141
+ text = _render_progress_event(self._latest, elapsed_seconds=elapsed)
142
+ self._stream.write(f"\r{text}\x1b[K")
143
+ self._stream.flush()
144
+ self._line_visible = True
145
+
146
+
147
+ def _render_progress_event(event: ProgressEvent, *, elapsed_seconds: float) -> str:
148
+ parts = [f"[seqevi] {event.message}"]
149
+ if event.completed is not None:
150
+ assert event.total is not None
151
+ assert event.unit is not None
152
+ fraction = f"{event.completed:,}/{event.total:,} {event.unit.value}"
153
+ percentage = 100.0 if event.total == 0 else event.completed / event.total * 100
154
+ parts.append(f"{fraction} ({percentage:.0f}%)")
155
+ if event.state is not ProgressState.COMPLETED:
156
+ parts.append(_format_elapsed(elapsed_seconds))
157
+ return " · ".join(parts)
158
+
159
+
160
+ def _format_elapsed(elapsed_seconds: float) -> str:
161
+ seconds = int(max(0.0, elapsed_seconds))
162
+ hours, remainder = divmod(seconds, 3_600)
163
+ minutes, seconds = divmod(remainder, 60)
164
+ if hours:
165
+ return f"{hours}h{minutes:02d}m{seconds:02d}s"
166
+ if minutes:
167
+ return f"{minutes}m{seconds:02d}s"
168
+ return f"{seconds}s"
169
+
170
+
171
+ def _stderr_is_interactive() -> bool:
172
+ return sys.stderr.isatty()
173
+
174
+
175
+ def _close_progress_renderer(renderer: _ProgressRenderer | None) -> None:
176
+ if renderer is None:
177
+ return
178
+ try:
179
+ renderer.close()
180
+ except Exception:
181
+ _LOGGER.exception("annotation progress renderer shutdown failed")
182
+
183
+
66
184
  @app.callback()
67
185
  def root(
68
186
  context: typer.Context,
@@ -177,6 +295,13 @@ def annotate_command(
177
295
  help="Worker threads; overrides the profile default.",
178
296
  ),
179
297
  ] = None,
298
+ progress: Annotated[
299
+ bool,
300
+ typer.Option(
301
+ "--progress",
302
+ help="Show interactive SeqEvi phase and liveness progress on stderr.",
303
+ ),
304
+ ] = False,
180
305
  json_output: Annotated[
181
306
  bool,
182
307
  typer.Option(
@@ -187,6 +312,11 @@ def annotate_command(
187
312
  ) -> None:
188
313
  """Reuse exact evidence and publish one immutable DuckDB result."""
189
314
 
315
+ renderer = (
316
+ _ProgressRenderer(stream=sys.stderr)
317
+ if progress and not json_output and _stderr_is_interactive()
318
+ else None
319
+ )
190
320
  try:
191
321
  invocation = _run_annotation_application(
192
322
  fasta=fasta,
@@ -200,8 +330,11 @@ def annotate_command(
200
330
  threads=threads,
201
331
  timeout_seconds=timeout_seconds,
202
332
  adapter_factory=create_adapter,
333
+ progress_sink=renderer,
203
334
  )
204
335
  except (AnnotationError, FastaValidationError, StoreError) as error:
336
+ _close_progress_renderer(renderer)
337
+ renderer = None
205
338
  if json_output:
206
339
  typer.echo(
207
340
  json.dumps(
@@ -218,6 +351,8 @@ def annotate_command(
218
351
  else:
219
352
  typer.echo(f"Error: {error}", err=True)
220
353
  raise typer.Exit(code=1) from error
354
+ finally:
355
+ _close_progress_renderer(renderer)
221
356
 
222
357
  summary = invocation.summary
223
358
  if json_output: