reader-workbench 1.0.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.
- reader_workbench/__init__.py +22 -0
- reader_workbench/__main__.py +4 -0
- reader_workbench/_version.py +17 -0
- reader_workbench/api/__init__.py +74 -0
- reader_workbench/api/_record_reads.py +75 -0
- reader_workbench/api/artifacts.py +79 -0
- reader_workbench/api/facade.py +538 -0
- reader_workbench/api/models.py +285 -0
- reader_workbench/api/notebooks.py +63 -0
- reader_workbench/contracts/__init__.py +18 -0
- reader_workbench/contracts/builtins/__init__.py +36 -0
- reader_workbench/contracts/builtins/cytometry.py +140 -0
- reader_workbench/contracts/builtins/four_state_event_window.py +214 -0
- reader_workbench/contracts/builtins/generic.py +21 -0
- reader_workbench/contracts/builtins/logic.py +149 -0
- reader_workbench/contracts/builtins/plate_reader.py +47 -0
- reader_workbench/contracts/catalog.py +257 -0
- reader_workbench/contracts/model.py +109 -0
- reader_workbench/domains/__init__.py +1 -0
- reader_workbench/domains/cytometry/__init__.py +3 -0
- reader_workbench/domains/cytometry/analysis/__init__.py +29 -0
- reader_workbench/domains/cytometry/analysis/events.py +182 -0
- reader_workbench/domains/cytometry/analysis/gating.py +175 -0
- reader_workbench/domains/cytometry/analysis/workflow.py +248 -0
- reader_workbench/domains/cytometry/io/__init__.py +3 -0
- reader_workbench/domains/cytometry/io/fcs.py +135 -0
- reader_workbench/domains/cytometry/plots/__init__.py +5 -0
- reader_workbench/domains/cytometry/plots/diagnostic.py +155 -0
- reader_workbench/domains/logic/__init__.py +3 -0
- reader_workbench/domains/logic/crosstalk/__init__.py +3 -0
- reader_workbench/domains/logic/crosstalk/pairs.py +661 -0
- reader_workbench/domains/logic/four_state_vector/__init__.py +7 -0
- reader_workbench/domains/logic/four_state_vector/builder.py +321 -0
- reader_workbench/domains/logic/four_state_vector/collection/__init__.py +20 -0
- reader_workbench/domains/logic/four_state_vector/collection/checks.py +49 -0
- reader_workbench/domains/logic/four_state_vector/collection/constants.py +29 -0
- reader_workbench/domains/logic/four_state_vector/collection/model.py +19 -0
- reader_workbench/domains/logic/four_state_vector/collection/render.py +383 -0
- reader_workbench/domains/logic/four_state_vector/collection/sources.py +185 -0
- reader_workbench/domains/logic/four_state_vector/config.py +214 -0
- reader_workbench/domains/logic/four_state_vector/diagnostic.py +361 -0
- reader_workbench/domains/logic/four_state_vector/heatmap.py +86 -0
- reader_workbench/domains/logic/four_state_vector/math.py +191 -0
- reader_workbench/domains/logic/four_state_vector/reference.py +85 -0
- reader_workbench/domains/logic/four_state_vector/selection.py +228 -0
- reader_workbench/domains/logic/four_state_vector/treatment_semantics.py +51 -0
- reader_workbench/domains/logic/four_state_vector/validation.py +19 -0
- reader_workbench/domains/logic/logic_symmetry/__init__.py +3 -0
- reader_workbench/domains/logic/logic_symmetry/encodings.py +93 -0
- reader_workbench/domains/logic/logic_symmetry/extract_corners.py +192 -0
- reader_workbench/domains/logic/logic_symmetry/main.py +236 -0
- reader_workbench/domains/logic/logic_symmetry/metrics.py +96 -0
- reader_workbench/domains/logic/logic_symmetry/overlay.py +129 -0
- reader_workbench/domains/logic/logic_symmetry/prep.py +138 -0
- reader_workbench/domains/logic/logic_symmetry/render.py +356 -0
- reader_workbench/domains/logic/treatment_columns.py +42 -0
- reader_workbench/domains/plate_reader/__init__.py +1 -0
- reader_workbench/domains/plate_reader/analysis/__init__.py +14 -0
- reader_workbench/domains/plate_reader/analysis/fold_change.py +474 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/__init__.py +21 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/aggregation.py +191 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/contract_fields.py +50 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/contracts.py +320 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/design_dispositions.py +54 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/disposition_records.py +140 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/event_sensitivity.py +27 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/materialize.py +274 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/observation_resampling.py +97 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/reduction.py +62 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/seeds.py +15 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/sources.py +308 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusion_validation.py +94 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusions.py +54 -0
- reader_workbench/domains/plate_reader/analysis/timepoints.py +76 -0
- reader_workbench/domains/plate_reader/io/__init__.py +6 -0
- reader_workbench/domains/plate_reader/io/sample_map.py +65 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/__init__.py +6 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_kinetic.py +141 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_parser.py +295 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_shared.py +195 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_snapshot.py +130 -0
- reader_workbench/domains/plate_reader/ordering.py +59 -0
- reader_workbench/domains/plate_reader/plots/__init__.py +15 -0
- reader_workbench/domains/plate_reader/plots/_data.py +29 -0
- reader_workbench/domains/plate_reader/plots/common.py +346 -0
- reader_workbench/domains/plate_reader/plots/distributions.py +324 -0
- reader_workbench/domains/plate_reader/plots/dual_reporter_triptych.py +525 -0
- reader_workbench/domains/plate_reader/plots/dual_reporter_triptych_render.py +195 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/__init__.py +26 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic.py +295 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_components.py +149 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_render.py +308 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_style.py +51 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/schema.py +8 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/summary.py +140 -0
- reader_workbench/domains/plate_reader/plots/grouping.py +53 -0
- reader_workbench/domains/plate_reader/plots/panels/__init__.py +12 -0
- reader_workbench/domains/plate_reader/plots/panels/snapshot.py +161 -0
- reader_workbench/domains/plate_reader/plots/panels/snapshot_data.py +91 -0
- reader_workbench/domains/plate_reader/plots/panels/time_series.py +296 -0
- reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic.py +435 -0
- reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic_render.py +300 -0
- reader_workbench/domains/plate_reader/plots/snapshot_barplot/__init__.py +315 -0
- reader_workbench/domains/plate_reader/plots/snapshot_barplot/planning.py +168 -0
- reader_workbench/domains/plate_reader/plots/snapshot_heatmap/__init__.py +205 -0
- reader_workbench/domains/plate_reader/plots/snapshot_heatmap/inputs.py +103 -0
- reader_workbench/domains/plate_reader/plots/time_series.py +317 -0
- reader_workbench/domains/plate_reader/plots/ts_and_snap/__init__.py +479 -0
- reader_workbench/domains/plate_reader/plots/ts_and_snap/planning.py +283 -0
- reader_workbench/domains/time_series/__init__.py +29 -0
- reader_workbench/domains/time_series/aggregation.py +60 -0
- reader_workbench/domains/time_series/contracts.py +368 -0
- reader_workbench/domains/time_series/reduction.py +395 -0
- reader_workbench/errors.py +55 -0
- reader_workbench/maintenance/__init__.py +6 -0
- reader_workbench/maintenance/docs.py +335 -0
- reader_workbench/maintenance/model.py +28 -0
- reader_workbench/maintenance/release.py +39 -0
- reader_workbench/maintenance/skills.py +124 -0
- reader_workbench/plotting/__init__.py +20 -0
- reader_workbench/plotting/mpl.py +56 -0
- reader_workbench/plotting/sinks.py +69 -0
- reader_workbench/plotting/style.py +175 -0
- reader_workbench/plotting/utils.py +27 -0
- reader_workbench/plugins/__init__.py +1 -0
- reader_workbench/plugins/catalog.py +33 -0
- reader_workbench/plugins/export/__init__.py +0 -0
- reader_workbench/plugins/export/_paths.py +21 -0
- reader_workbench/plugins/export/csv.py +41 -0
- reader_workbench/plugins/export/xlsx.py +44 -0
- reader_workbench/plugins/ingest/__init__.py +0 -0
- reader_workbench/plugins/ingest/_discovery.py +58 -0
- reader_workbench/plugins/ingest/discovery_policy.py +66 -0
- reader_workbench/plugins/ingest/flow_cytometer.py +139 -0
- reader_workbench/plugins/ingest/synergy_h1.py +234 -0
- reader_workbench/plugins/manifests/__init__.py +1 -0
- reader_workbench/plugins/manifests/export.py +29 -0
- reader_workbench/plugins/manifests/ingest.py +29 -0
- reader_workbench/plugins/manifests/plot.py +161 -0
- reader_workbench/plugins/manifests/transform.py +172 -0
- reader_workbench/plugins/manifests/validator.py +18 -0
- reader_workbench/plugins/plot/__init__.py +0 -0
- reader_workbench/plugins/plot/_shared.py +55 -0
- reader_workbench/plugins/plot/cytometry_diagnostic.py +54 -0
- reader_workbench/plugins/plot/distributions.py +58 -0
- reader_workbench/plugins/plot/dual_reporter_triptych.py +224 -0
- reader_workbench/plugins/plot/four_state_event_window_diagnostic.py +133 -0
- reader_workbench/plugins/plot/four_state_event_window_summary.py +53 -0
- reader_workbench/plugins/plot/four_state_vector_collection.py +45 -0
- reader_workbench/plugins/plot/four_state_vector_diagnostic.py +105 -0
- reader_workbench/plugins/plot/four_state_vector_heatmap.py +63 -0
- reader_workbench/plugins/plot/logic_symmetry.py +56 -0
- reader_workbench/plugins/plot/single_reporter_diagnostic.py +279 -0
- reader_workbench/plugins/plot/snapshot_barplot.py +65 -0
- reader_workbench/plugins/plot/snapshot_heatmap.py +104 -0
- reader_workbench/plugins/plot/time_series.py +114 -0
- reader_workbench/plugins/plot/ts_and_snap.py +210 -0
- reader_workbench/plugins/transform/__init__.py +0 -0
- reader_workbench/plugins/transform/_four_state_vector.py +204 -0
- reader_workbench/plugins/transform/_labeling.py +109 -0
- reader_workbench/plugins/transform/alias.py +70 -0
- reader_workbench/plugins/transform/assay_labels.py +62 -0
- reader_workbench/plugins/transform/blank.py +79 -0
- reader_workbench/plugins/transform/crosstalk_pairs.py +180 -0
- reader_workbench/plugins/transform/cytometry_gating.py +120 -0
- reader_workbench/plugins/transform/fold_change.py +79 -0
- reader_workbench/plugins/transform/four_state_event_window.py +93 -0
- reader_workbench/plugins/transform/four_state_vector.py +62 -0
- reader_workbench/plugins/transform/four_state_vector_collection.py +41 -0
- reader_workbench/plugins/transform/logic_symmetry.py +67 -0
- reader_workbench/plugins/transform/outlier_filter.py +60 -0
- reader_workbench/plugins/transform/overflow.py +197 -0
- reader_workbench/plugins/transform/ratio.py +237 -0
- reader_workbench/plugins/transform/sample_map.py +170 -0
- reader_workbench/plugins/transform/sample_metadata.py +94 -0
- reader_workbench/plugins/validator/__init__.py +1 -0
- reader_workbench/plugins/validator/to_tidy_plus_map.py +155 -0
- reader_workbench/protocols/__init__.py +80 -0
- reader_workbench/protocols/_builtins_plate_reader_growth.py +179 -0
- reader_workbench/protocols/_builtins_plate_reader_variants.py +274 -0
- reader_workbench/protocols/builtins.py +1656 -0
- reader_workbench/protocols/compiler.py +22 -0
- reader_workbench/protocols/compilers/__init__.py +1 -0
- reader_workbench/protocols/compilers/common.py +100 -0
- reader_workbench/protocols/compilers/cytometry.py +87 -0
- reader_workbench/protocols/compilers/generic.py +14 -0
- reader_workbench/protocols/compilers/logic.py +245 -0
- reader_workbench/protocols/compilers/plate_reader.py +937 -0
- reader_workbench/protocols/compilers/plate_reader_pipeline.py +197 -0
- reader_workbench/protocols/model.py +1486 -0
- reader_workbench/protocols/semantic_coverage.py +234 -0
- reader_workbench/runtime/__init__.py +12 -0
- reader_workbench/runtime/builtin.py +23 -0
- reader_workbench/runtime/model.py +42 -0
- reader_workbench/workbench/__init__.py +60 -0
- reader_workbench/workbench/assets/__init__.py +22 -0
- reader_workbench/workbench/assets/types.py +118 -0
- reader_workbench/workbench/audit/__init__.py +5 -0
- reader_workbench/workbench/audit/experiments.py +307 -0
- reader_workbench/workbench/audit/staging.py +187 -0
- reader_workbench/workbench/cli/__init__.py +51 -0
- reader_workbench/workbench/cli/_lazy.py +9 -0
- reader_workbench/workbench/cli/_records_view.py +150 -0
- reader_workbench/workbench/cli/_surface_execution.py +443 -0
- reader_workbench/workbench/cli/audit.py +95 -0
- reader_workbench/workbench/cli/automation.py +229 -0
- reader_workbench/workbench/cli/demo.py +46 -0
- reader_workbench/workbench/cli/dop.py +91 -0
- reader_workbench/workbench/cli/experiments.py +635 -0
- reader_workbench/workbench/cli/helpers.py +232 -0
- reader_workbench/workbench/cli/main.py +59 -0
- reader_workbench/workbench/cli/maintenance.py +82 -0
- reader_workbench/workbench/cli/notebooks.py +260 -0
- reader_workbench/workbench/cli/pagination.py +117 -0
- reader_workbench/workbench/cli/protocols.py +336 -0
- reader_workbench/workbench/cli/shared.py +309 -0
- reader_workbench/workbench/cli/surfaces.py +534 -0
- reader_workbench/workbench/cli/verification.py +128 -0
- reader_workbench/workbench/commands.py +10 -0
- reader_workbench/workbench/config/__init__.py +47 -0
- reader_workbench/workbench/config/identity.py +13 -0
- reader_workbench/workbench/config/load.py +405 -0
- reader_workbench/workbench/config/model.py +274 -0
- reader_workbench/workbench/context.py +26 -0
- reader_workbench/workbench/decl/__init__.py +31 -0
- reader_workbench/workbench/decl/build.py +190 -0
- reader_workbench/workbench/decl/model.py +81 -0
- reader_workbench/workbench/dop/__init__.py +12 -0
- reader_workbench/workbench/dop/builtins.py +261 -0
- reader_workbench/workbench/dop/model.py +209 -0
- reader_workbench/workbench/engine/__init__.py +42 -0
- reader_workbench/workbench/engine/_shared.py +76 -0
- reader_workbench/workbench/engine/contracts.py +283 -0
- reader_workbench/workbench/engine/execution.py +326 -0
- reader_workbench/workbench/engine/file_outputs.py +260 -0
- reader_workbench/workbench/engine/inputs.py +161 -0
- reader_workbench/workbench/engine/invocations.py +507 -0
- reader_workbench/workbench/engine/planning.py +72 -0
- reader_workbench/workbench/engine/runtime.py +464 -0
- reader_workbench/workbench/engine/setup.py +149 -0
- reader_workbench/workbench/engine/validation.py +684 -0
- reader_workbench/workbench/experiment/__init__.py +47 -0
- reader_workbench/workbench/experiment/model.py +381 -0
- reader_workbench/workbench/experiments.py +133 -0
- reader_workbench/workbench/graph/__init__.py +47 -0
- reader_workbench/workbench/graph/nodes.py +102 -0
- reader_workbench/workbench/graph/normalize.py +177 -0
- reader_workbench/workbench/graph/refs.py +148 -0
- reader_workbench/workbench/input_discovery.py +19 -0
- reader_workbench/workbench/inspection/__init__.py +3 -0
- reader_workbench/workbench/inspection/catalogs.py +128 -0
- reader_workbench/workbench/inspection/common.py +92 -0
- reader_workbench/workbench/inspection/dop.py +64 -0
- reader_workbench/workbench/inspection/experiments.py +449 -0
- reader_workbench/workbench/inspection/inventory.py +68 -0
- reader_workbench/workbench/inspection/protocols.py +368 -0
- reader_workbench/workbench/inspection/readiness.py +333 -0
- reader_workbench/workbench/inspection/reports.py +367 -0
- reader_workbench/workbench/inspection/results.py +166 -0
- reader_workbench/workbench/inspection/runtime.py +287 -0
- reader_workbench/workbench/inspection/semantics.py +192 -0
- reader_workbench/workbench/inspection/validation.py +30 -0
- reader_workbench/workbench/notebooks/__init__.py +17 -0
- reader_workbench/workbench/notebooks/_launch_registry.py +112 -0
- reader_workbench/workbench/notebooks/_launch_runtime.py +104 -0
- reader_workbench/workbench/notebooks/components/__init__.py +21 -0
- reader_workbench/workbench/notebooks/components/deliverables.py +403 -0
- reader_workbench/workbench/notebooks/components/overview.py +119 -0
- reader_workbench/workbench/notebooks/eda.marimo.py.txt +153 -0
- reader_workbench/workbench/notebooks/launch.py +274 -0
- reader_workbench/workbench/notebooks/presentation.py +136 -0
- reader_workbench/workbench/notebooks/scaffold.py +60 -0
- reader_workbench/workbench/ontology.py +78 -0
- reader_workbench/workbench/paths.py +44 -0
- reader_workbench/workbench/ports/__init__.py +31 -0
- reader_workbench/workbench/ports/model.py +168 -0
- reader_workbench/workbench/records/__init__.py +44 -0
- reader_workbench/workbench/records/epoch.py +329 -0
- reader_workbench/workbench/records/evidence.py +247 -0
- reader_workbench/workbench/records/identity.py +87 -0
- reader_workbench/workbench/records/locking.py +185 -0
- reader_workbench/workbench/records/model.py +711 -0
- reader_workbench/workbench/records/sources.py +73 -0
- reader_workbench/workbench/records/store.py +1022 -0
- reader_workbench/workbench/records/verification.py +998 -0
- reader_workbench/workbench/registry.py +333 -0
- reader_workbench/workbench/spec_overrides.py +215 -0
- reader_workbench-1.0.0.dist-info/METADATA +91 -0
- reader_workbench-1.0.0.dist-info/RECORD +293 -0
- reader_workbench-1.0.0.dist-info/WHEEL +5 -0
- reader_workbench-1.0.0.dist-info/entry_points.txt +2 -0
- reader_workbench-1.0.0.dist-info/licenses/LICENSE +21 -0
- reader_workbench-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from dataclasses import dataclass, replace
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import mkdtemp
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from reader_workbench.errors import ExecutionError
|
|
12
|
+
from reader_workbench.workbench.context import RunContext
|
|
13
|
+
from reader_workbench.workbench.records import PathDescription
|
|
14
|
+
|
|
15
|
+
from .contracts import collect_file_output_paths
|
|
16
|
+
|
|
17
|
+
_SAFE_PREFIX = re.compile(r"[^A-Za-z0-9_.-]+")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class _Promotion:
|
|
22
|
+
final_path: Path
|
|
23
|
+
backup_path: Path
|
|
24
|
+
previous_exists: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FileOutputTransaction:
|
|
28
|
+
"""Stage one plot/export step and restore prior files unless it commits."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, context: RunContext, step_id: str, phase: str) -> None:
|
|
31
|
+
self._final_context = context
|
|
32
|
+
staging_parent = _confined_staging_parent(context.outputs_dir)
|
|
33
|
+
prefix = _SAFE_PREFIX.sub("_", step_id).strip("._") or "file-output"
|
|
34
|
+
self._phase = phase
|
|
35
|
+
self._revision_name = prefix
|
|
36
|
+
self._staging_root = Path(mkdtemp(prefix=f"{prefix}__", dir=staging_parent))
|
|
37
|
+
self.context = replace(
|
|
38
|
+
context,
|
|
39
|
+
outputs_dir=self._staging_root,
|
|
40
|
+
artifacts_dir=self._mirror_path(context.artifacts_dir),
|
|
41
|
+
plots_dir=self._mirror_path(context.plots_dir),
|
|
42
|
+
exports_dir=self._mirror_path(context.exports_dir),
|
|
43
|
+
records_path=self._mirror_path(context.records_path),
|
|
44
|
+
)
|
|
45
|
+
self.context.artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self.context.plots_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
self.context.exports_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
self.context.records_path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
self._promotions: list[_Promotion] = []
|
|
50
|
+
self._revision_dir: Path | None = None
|
|
51
|
+
self._committed = False
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> FileOutputTransaction:
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, exc_type, exc, traceback) -> bool:
|
|
57
|
+
try:
|
|
58
|
+
if not self._committed:
|
|
59
|
+
self._rollback()
|
|
60
|
+
finally:
|
|
61
|
+
shutil.rmtree(self._staging_root, ignore_errors=True)
|
|
62
|
+
with suppress(OSError):
|
|
63
|
+
self._staging_root.parent.rmdir()
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def promote(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
outputs: dict[str, Any],
|
|
70
|
+
output_ports: dict[str, Any],
|
|
71
|
+
where: str,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
staged_paths = collect_file_output_paths(output_ports=output_ports, outputs=outputs, where=where)
|
|
74
|
+
if not staged_paths:
|
|
75
|
+
raise ExecutionError(f"{where}: must emit at least one explicit file output")
|
|
76
|
+
path_map, promotion_paths = self._path_maps(staged_paths, where=where)
|
|
77
|
+
record_path_map = self._publish_record_revision(
|
|
78
|
+
path_map=path_map,
|
|
79
|
+
promotion_paths=promotion_paths,
|
|
80
|
+
where=where,
|
|
81
|
+
)
|
|
82
|
+
rollback_root = self._staging_root / ".rollback"
|
|
83
|
+
final_root = self._final_context.outputs_dir.resolve(strict=True)
|
|
84
|
+
for final_path in promotion_paths.values():
|
|
85
|
+
_assert_confined_destination(final_path, final_root=final_root, where=where)
|
|
86
|
+
|
|
87
|
+
for staged_path, final_path in promotion_paths.items():
|
|
88
|
+
_assert_confined_destination(final_path, final_root=final_root, where=where)
|
|
89
|
+
relative_path = final_path.relative_to(final_root)
|
|
90
|
+
backup_path = rollback_root / relative_path
|
|
91
|
+
previous_exists = final_path.exists()
|
|
92
|
+
promotion = _Promotion(
|
|
93
|
+
final_path=final_path,
|
|
94
|
+
backup_path=backup_path,
|
|
95
|
+
previous_exists=previous_exists,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
_assert_confined_destination(final_path, final_root=final_root, where=where)
|
|
100
|
+
if previous_exists:
|
|
101
|
+
if not final_path.is_file() or final_path.is_symlink():
|
|
102
|
+
raise ExecutionError(f"{where}: output target must be a regular file: {final_path}")
|
|
103
|
+
backup_path.parent.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
_assert_confined_destination(final_path, final_root=final_root, where=where)
|
|
105
|
+
self._promotions.append(promotion)
|
|
106
|
+
final_path.replace(backup_path)
|
|
107
|
+
else:
|
|
108
|
+
self._promotions.append(promotion)
|
|
109
|
+
_assert_confined_destination(final_path, final_root=final_root, where=where)
|
|
110
|
+
staged_path.replace(final_path)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
name: _remap_output_value(value, path_map=record_path_map)
|
|
114
|
+
if output_ports[name].kind in {"file_path", "file_bundle"}
|
|
115
|
+
else value
|
|
116
|
+
for name, value in outputs.items()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def commit(self) -> None:
|
|
120
|
+
self._committed = True
|
|
121
|
+
|
|
122
|
+
def _mirror_path(self, path: Path) -> Path:
|
|
123
|
+
try:
|
|
124
|
+
relative_path = path.relative_to(self._final_context.outputs_dir)
|
|
125
|
+
except ValueError as exc:
|
|
126
|
+
raise ExecutionError(
|
|
127
|
+
f"Runtime output path must stay within {self._final_context.outputs_dir}: {path}"
|
|
128
|
+
) from exc
|
|
129
|
+
return self._staging_root / relative_path
|
|
130
|
+
|
|
131
|
+
def _path_maps(self, paths: list[Path], *, where: str) -> tuple[dict[Path, Path], dict[Path, Path]]:
|
|
132
|
+
staging_root = self._staging_root.resolve(strict=True)
|
|
133
|
+
final_root = self._final_context.outputs_dir.resolve(strict=True)
|
|
134
|
+
path_map: dict[Path, Path] = {}
|
|
135
|
+
promotion_paths: dict[Path, Path] = {}
|
|
136
|
+
final_paths: set[Path] = set()
|
|
137
|
+
for raw_path in paths:
|
|
138
|
+
staged_path = raw_path if raw_path.is_absolute() else staging_root / raw_path
|
|
139
|
+
if staged_path.is_symlink():
|
|
140
|
+
raise ExecutionError(f"{where}: staged output must be a regular file: {staged_path}")
|
|
141
|
+
try:
|
|
142
|
+
resolved = staged_path.resolve(strict=True)
|
|
143
|
+
relative_path = resolved.relative_to(staging_root)
|
|
144
|
+
except (OSError, ValueError) as exc:
|
|
145
|
+
raise ExecutionError(f"{where}: file outputs must stay within the staged outputs directory") from exc
|
|
146
|
+
if not resolved.is_file():
|
|
147
|
+
raise ExecutionError(f"{where}: staged output must be a regular file: {staged_path}")
|
|
148
|
+
final_path = final_root / relative_path
|
|
149
|
+
if final_path in final_paths:
|
|
150
|
+
raise ExecutionError(f"{where}: duplicate file output target: {final_path}")
|
|
151
|
+
path_map[Path(raw_path)] = final_path
|
|
152
|
+
promotion_paths[resolved] = final_path
|
|
153
|
+
final_paths.add(final_path)
|
|
154
|
+
return path_map, promotion_paths
|
|
155
|
+
|
|
156
|
+
def _publish_record_revision(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
path_map: dict[Path, Path],
|
|
160
|
+
promotion_paths: dict[Path, Path],
|
|
161
|
+
where: str,
|
|
162
|
+
) -> dict[Path, Path]:
|
|
163
|
+
"""Publish one immutable copy of the declared bundle for record history."""
|
|
164
|
+
|
|
165
|
+
final_root = self._final_context.outputs_dir.resolve(strict=True)
|
|
166
|
+
revision_parent = final_root / "artifacts" / "file_bundles" / self._phase
|
|
167
|
+
_assert_confined_destination(revision_parent, final_root=final_root, where=where)
|
|
168
|
+
revision_parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
_assert_confined_destination(revision_parent, final_root=final_root, where=where)
|
|
170
|
+
revision_dir = _next_revision_dir(revision_parent / self._revision_name)
|
|
171
|
+
|
|
172
|
+
staged_revision = self._staging_root / ".record-revision"
|
|
173
|
+
staged_revision.mkdir()
|
|
174
|
+
snapshot_by_final: dict[Path, Path] = {}
|
|
175
|
+
for staged_path, final_path in promotion_paths.items():
|
|
176
|
+
relative_path = final_path.relative_to(final_root)
|
|
177
|
+
snapshot = staged_revision / relative_path
|
|
178
|
+
snapshot.parent.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
shutil.copy2(staged_path, snapshot)
|
|
180
|
+
snapshot_by_final[final_path] = revision_dir / relative_path
|
|
181
|
+
self._revision_dir = revision_dir
|
|
182
|
+
staged_revision.replace(revision_dir)
|
|
183
|
+
return {raw_path: snapshot_by_final[final_path] for raw_path, final_path in path_map.items()}
|
|
184
|
+
|
|
185
|
+
def _rollback(self) -> None:
|
|
186
|
+
for promotion in reversed(self._promotions):
|
|
187
|
+
if promotion.previous_exists:
|
|
188
|
+
if not promotion.backup_path.exists():
|
|
189
|
+
continue
|
|
190
|
+
promotion.final_path.unlink(missing_ok=True)
|
|
191
|
+
promotion.final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
promotion.backup_path.replace(promotion.final_path)
|
|
193
|
+
else:
|
|
194
|
+
promotion.final_path.unlink(missing_ok=True)
|
|
195
|
+
self._promotions.clear()
|
|
196
|
+
if self._revision_dir is not None:
|
|
197
|
+
revision_parent = self._revision_dir.parent
|
|
198
|
+
shutil.rmtree(self._revision_dir, ignore_errors=True)
|
|
199
|
+
self._revision_dir = None
|
|
200
|
+
with suppress(OSError):
|
|
201
|
+
revision_parent.rmdir()
|
|
202
|
+
with suppress(OSError):
|
|
203
|
+
revision_parent.parent.rmdir()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _next_revision_dir(base: Path) -> Path:
|
|
207
|
+
revision = 1
|
|
208
|
+
while True:
|
|
209
|
+
candidate = base if revision == 1 else base.with_name(f"{base.name}__r{revision}")
|
|
210
|
+
if not candidate.exists() and not candidate.is_symlink():
|
|
211
|
+
return candidate
|
|
212
|
+
revision += 1
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _confined_staging_parent(outputs_dir: Path) -> Path:
|
|
216
|
+
staging_parent = outputs_dir / ".staging"
|
|
217
|
+
message = "File-output staging directory must stay within the experiment outputs directory"
|
|
218
|
+
if staging_parent.is_symlink():
|
|
219
|
+
raise ExecutionError(message)
|
|
220
|
+
try:
|
|
221
|
+
outputs_root = outputs_dir.resolve(strict=True)
|
|
222
|
+
staging_parent.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
resolved = staging_parent.resolve(strict=True)
|
|
224
|
+
resolved.relative_to(outputs_root)
|
|
225
|
+
except (OSError, ValueError) as exc:
|
|
226
|
+
raise ExecutionError(message) from exc
|
|
227
|
+
if staging_parent.is_symlink() or not resolved.is_dir():
|
|
228
|
+
raise ExecutionError(message)
|
|
229
|
+
return resolved
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _assert_confined_destination(final_path: Path, *, final_root: Path, where: str) -> None:
|
|
233
|
+
message = f"{where}: destination path must not contain symlinks or leave the outputs directory"
|
|
234
|
+
try:
|
|
235
|
+
relative_path = final_path.relative_to(final_root)
|
|
236
|
+
current = final_root
|
|
237
|
+
for part in relative_path.parts:
|
|
238
|
+
current /= part
|
|
239
|
+
if current.is_symlink():
|
|
240
|
+
raise ExecutionError(message)
|
|
241
|
+
if current.exists():
|
|
242
|
+
current.resolve(strict=True).relative_to(final_root)
|
|
243
|
+
except ExecutionError:
|
|
244
|
+
raise
|
|
245
|
+
except (OSError, ValueError) as exc:
|
|
246
|
+
raise ExecutionError(message) from exc
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _remap_output_value(value: Any, *, path_map: dict[Path, Path]) -> Any:
|
|
250
|
+
if isinstance(value, PathDescription):
|
|
251
|
+
return PathDescription(path=path_map[value.path], description=value.description)
|
|
252
|
+
if isinstance(value, str):
|
|
253
|
+
return str(path_map[Path(value)])
|
|
254
|
+
if isinstance(value, Path):
|
|
255
|
+
return path_map[value]
|
|
256
|
+
if isinstance(value, tuple):
|
|
257
|
+
return tuple(_remap_output_value(item, path_map=path_map) for item in value)
|
|
258
|
+
if isinstance(value, list):
|
|
259
|
+
return [_remap_output_value(item, path_map=path_map) for item in value]
|
|
260
|
+
raise ExecutionError(f"File output values must be path-like, got {type(value).__name__}")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from reader_workbench.errors import ExecutionError
|
|
7
|
+
from reader_workbench.workbench.graph import FileRef, InputRef, RecordCollectionRef, RecordRef, ResourceRef
|
|
8
|
+
from reader_workbench.workbench.paths import resolve_path_within_root
|
|
9
|
+
from reader_workbench.workbench.ports import InputPortSpec
|
|
10
|
+
from reader_workbench.workbench.records import RecordStore, SourceRecordCollection, resolve_source_record
|
|
11
|
+
from reader_workbench.workbench.registry import Plugin, PluginConfig
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _metadata_like_files(inputs_dir: Path) -> list[Path]:
|
|
15
|
+
patterns = [
|
|
16
|
+
"metadata.*",
|
|
17
|
+
"metadata_filtered.*",
|
|
18
|
+
"sample_map.*",
|
|
19
|
+
"sample_metadata.*",
|
|
20
|
+
"plate_map.*",
|
|
21
|
+
]
|
|
22
|
+
matches: list[Path] = []
|
|
23
|
+
for pattern in patterns:
|
|
24
|
+
matches.extend(path for path in inputs_dir.glob(pattern) if path.is_file())
|
|
25
|
+
return sorted(matches)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _missing_metadata_hint(label: str, path: Path, exp_dir: Path | None) -> str | None:
|
|
29
|
+
if exp_dir is None:
|
|
30
|
+
return None
|
|
31
|
+
inputs_dir = exp_dir / "inputs"
|
|
32
|
+
if not inputs_dir.exists():
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
metadata_labels = {"sample_map", "plate_map", "metadata", "sample_metadata"}
|
|
36
|
+
name = path.name.lower()
|
|
37
|
+
is_metadataish = label in metadata_labels or name.startswith(("metadata", "sample_map", "plate_map"))
|
|
38
|
+
if not is_metadataish:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
canonical = inputs_dir / path.name
|
|
42
|
+
hint = f"Canonical location is {canonical} (update config: reads.{label}: {{file: ./inputs/{path.name}}})."
|
|
43
|
+
|
|
44
|
+
candidates = _metadata_like_files(inputs_dir)
|
|
45
|
+
if candidates:
|
|
46
|
+
rels = [str(candidate.relative_to(exp_dir)) for candidate in candidates]
|
|
47
|
+
preview = ", ".join(rels[:3])
|
|
48
|
+
tail = "" if len(rels) <= 3 else f" (+{len(rels) - 3} more)"
|
|
49
|
+
hint += f" Found metadata-like files in inputs/: {preview}{tail}."
|
|
50
|
+
return hint
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_inputs(
|
|
54
|
+
store: RecordStore,
|
|
55
|
+
reads: dict[str, InputRef],
|
|
56
|
+
*,
|
|
57
|
+
input_ports: dict[str, InputPortSpec],
|
|
58
|
+
exp_dir: Path | None = None,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
inputs: dict[str, Any] = {}
|
|
61
|
+
for label, target in reads.items():
|
|
62
|
+
expected = input_ports.get(label)
|
|
63
|
+
if expected is None:
|
|
64
|
+
raise ExecutionError(f"Unsupported input binding for unknown port '{label}'")
|
|
65
|
+
if isinstance(target, FileRef | ResourceRef):
|
|
66
|
+
if expected.kind not in {"file_path", "file_set"}:
|
|
67
|
+
raise ExecutionError(
|
|
68
|
+
f"Input '{label}' expects port kind {expected.kind!r} and cannot bind to a file/resource ref"
|
|
69
|
+
)
|
|
70
|
+
path = target.path
|
|
71
|
+
if exp_dir is not None:
|
|
72
|
+
try:
|
|
73
|
+
path = resolve_path_within_root(path, root=exp_dir)
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise ExecutionError(
|
|
76
|
+
f"Input file for '{label}' must stay under the experiment root after resolving symlinks"
|
|
77
|
+
) from exc
|
|
78
|
+
if not path.exists():
|
|
79
|
+
hint = _missing_metadata_hint(label, path, exp_dir)
|
|
80
|
+
if hint:
|
|
81
|
+
raise ExecutionError(f"Input file missing for '{label}': {path}. {hint}")
|
|
82
|
+
raise ExecutionError(f"Input file missing for '{label}': {path}")
|
|
83
|
+
if path.is_dir():
|
|
84
|
+
raise ExecutionError(f"Input file path is a directory for '{label}': {path}")
|
|
85
|
+
inputs[label] = (path,) if expected.kind == "file_set" else path
|
|
86
|
+
continue
|
|
87
|
+
if isinstance(target, RecordCollectionRef):
|
|
88
|
+
if expected.kind != "record_collection":
|
|
89
|
+
raise ExecutionError(
|
|
90
|
+
f"Input '{label}' expects port kind {expected.kind!r} and cannot bind to a record collection"
|
|
91
|
+
)
|
|
92
|
+
inputs[label] = SourceRecordCollection(
|
|
93
|
+
tuple(resolve_source_record(ref, contracts=store.contracts) for ref in target.records)
|
|
94
|
+
)
|
|
95
|
+
continue
|
|
96
|
+
if not isinstance(target, RecordRef):
|
|
97
|
+
raise ExecutionError(f"Unsupported input binding for '{label}': {target!r}")
|
|
98
|
+
if expected.kind == "dataframe":
|
|
99
|
+
inputs[label] = store.read_dataframe(target.record_id)
|
|
100
|
+
continue
|
|
101
|
+
if expected.kind == "file_bundle":
|
|
102
|
+
inputs[label] = store.read_record(target.record_id)
|
|
103
|
+
continue
|
|
104
|
+
raise ExecutionError(
|
|
105
|
+
f"Input '{label}' expects port kind {expected.kind!r} and cannot bind to a record ref {target.record_id!r}"
|
|
106
|
+
)
|
|
107
|
+
return inputs
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_missing_file_inputs(
|
|
111
|
+
*,
|
|
112
|
+
plugin: Plugin,
|
|
113
|
+
exp_dir: Path,
|
|
114
|
+
cfg: PluginConfig,
|
|
115
|
+
inputs: dict[str, Any],
|
|
116
|
+
input_ports: dict[str, InputPortSpec],
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
try:
|
|
119
|
+
additions = dict(type(plugin).resolve_missing_file_inputs(exp_dir=exp_dir, cfg=cfg, inputs=inputs))
|
|
120
|
+
except ExecutionError:
|
|
121
|
+
raise
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
raise ExecutionError(f"{plugin.plugin_id}: failed to resolve missing file inputs: {exc}") from exc
|
|
124
|
+
conflicts = sorted(set(additions) & set(inputs))
|
|
125
|
+
if conflicts:
|
|
126
|
+
raise ExecutionError(f"{plugin.plugin_id}: missing-file resolver cannot replace bound inputs: {conflicts}")
|
|
127
|
+
unknown = sorted(set(additions) - set(input_ports))
|
|
128
|
+
if unknown:
|
|
129
|
+
raise ExecutionError(f"{plugin.plugin_id}: missing-file resolver returned unknown inputs: {unknown}")
|
|
130
|
+
|
|
131
|
+
resolved = dict(inputs)
|
|
132
|
+
for label, value in additions.items():
|
|
133
|
+
port = input_ports[label]
|
|
134
|
+
if port.kind not in {"file_path", "file_set"} or not port.optional:
|
|
135
|
+
raise ExecutionError(
|
|
136
|
+
f"{plugin.plugin_id}: missing-file resolver may only fill optional file_path or file_set ports; "
|
|
137
|
+
f"{label!r} is {port.kind!r} (optional={port.optional})"
|
|
138
|
+
)
|
|
139
|
+
values = value if port.kind == "file_set" else (value,)
|
|
140
|
+
if not isinstance(values, tuple) or not values or any(not isinstance(item, Path) for item in values):
|
|
141
|
+
expected = "a non-empty tuple of Paths" if port.kind == "file_set" else "a Path"
|
|
142
|
+
raise ExecutionError(
|
|
143
|
+
f"{plugin.plugin_id}: missing-file resolver input {label!r} must be {expected}, "
|
|
144
|
+
f"got {type(value).__name__}"
|
|
145
|
+
)
|
|
146
|
+
confined_values: list[Path] = []
|
|
147
|
+
for item in values:
|
|
148
|
+
try:
|
|
149
|
+
confined = resolve_path_within_root(item, root=exp_dir)
|
|
150
|
+
except ValueError as exc:
|
|
151
|
+
raise ExecutionError(
|
|
152
|
+
f"{plugin.plugin_id}: resolved input {label!r} must stay under the experiment root "
|
|
153
|
+
"after resolving symlinks"
|
|
154
|
+
) from exc
|
|
155
|
+
if not confined.exists():
|
|
156
|
+
raise ExecutionError(f"Resolved input file missing for {label!r}: {confined}")
|
|
157
|
+
if not confined.is_file():
|
|
158
|
+
raise ExecutionError(f"Resolved input path is not a file for {label!r}: {confined}")
|
|
159
|
+
confined_values.append(confined)
|
|
160
|
+
resolved[label] = tuple(confined_values) if port.kind == "file_set" else confined_values[0]
|
|
161
|
+
return resolved
|