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,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def resolve_path_within_root(raw: str | Path, *, root: Path) -> Path:
|
|
7
|
+
root_path = root.resolve()
|
|
8
|
+
raw_path = Path(raw).expanduser()
|
|
9
|
+
resolved = (root_path / raw_path).resolve() if not raw_path.is_absolute() else raw_path.resolve()
|
|
10
|
+
try:
|
|
11
|
+
resolved.relative_to(root_path)
|
|
12
|
+
except ValueError as err:
|
|
13
|
+
raise ValueError(f"{resolved} escapes {root_path}") from err
|
|
14
|
+
return resolved
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def resolve_confined_sink_root(path: str | Path, *, root: Path, label: str) -> Path:
|
|
18
|
+
"""Validate a writable sink root without following symlinked path components."""
|
|
19
|
+
|
|
20
|
+
root_path = Path(root).expanduser().absolute()
|
|
21
|
+
candidate = Path(path).expanduser()
|
|
22
|
+
if not candidate.is_absolute():
|
|
23
|
+
candidate = root_path / candidate
|
|
24
|
+
candidate = candidate.absolute()
|
|
25
|
+
try:
|
|
26
|
+
relative = candidate.relative_to(root_path)
|
|
27
|
+
except ValueError as exc:
|
|
28
|
+
raise ValueError(f"{label} sink root must stay within {root_path}") from exc
|
|
29
|
+
|
|
30
|
+
cursor = root_path
|
|
31
|
+
if cursor.is_symlink():
|
|
32
|
+
raise ValueError(f"{label} sink root must not use symlink path components: {cursor}")
|
|
33
|
+
for part in relative.parts:
|
|
34
|
+
cursor = cursor / part
|
|
35
|
+
if cursor.is_symlink():
|
|
36
|
+
raise ValueError(f"{label} sink root must not use symlink path components: {cursor}")
|
|
37
|
+
|
|
38
|
+
resolved_root = root_path.resolve(strict=False)
|
|
39
|
+
resolved_candidate = candidate.resolve(strict=False)
|
|
40
|
+
try:
|
|
41
|
+
resolved_candidate.relative_to(resolved_root)
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
raise ValueError(f"{label} sink root must resolve within {resolved_root}") from exc
|
|
44
|
+
return candidate
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from .model import (
|
|
2
|
+
InputPortSpec,
|
|
3
|
+
OutputPortSpec,
|
|
4
|
+
PortKind,
|
|
5
|
+
dataframe_input,
|
|
6
|
+
dataframe_output,
|
|
7
|
+
file_bundle_input,
|
|
8
|
+
file_bundle_output,
|
|
9
|
+
file_path_input,
|
|
10
|
+
file_path_output,
|
|
11
|
+
file_set_input,
|
|
12
|
+
record_collection_input,
|
|
13
|
+
validate_input_ports,
|
|
14
|
+
validate_output_ports,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"InputPortSpec",
|
|
19
|
+
"OutputPortSpec",
|
|
20
|
+
"PortKind",
|
|
21
|
+
"dataframe_input",
|
|
22
|
+
"dataframe_output",
|
|
23
|
+
"file_bundle_input",
|
|
24
|
+
"file_bundle_output",
|
|
25
|
+
"file_path_input",
|
|
26
|
+
"file_path_output",
|
|
27
|
+
"file_set_input",
|
|
28
|
+
"record_collection_input",
|
|
29
|
+
"validate_input_ports",
|
|
30
|
+
"validate_output_ports",
|
|
31
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from reader_workbench.contracts import ContractId, OutputContractSurface
|
|
8
|
+
from reader_workbench.errors import RegistryError
|
|
9
|
+
|
|
10
|
+
type PortKind = Literal["dataframe", "record_collection", "file_path", "file_set", "file_bundle"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class InputPortSpec:
|
|
15
|
+
name: str
|
|
16
|
+
kind: PortKind
|
|
17
|
+
contract: ContractId | None = None
|
|
18
|
+
optional: bool = False
|
|
19
|
+
|
|
20
|
+
def __post_init__(self) -> None:
|
|
21
|
+
_validate_port_name(self.name)
|
|
22
|
+
if self.kind in {"dataframe", "record_collection"}:
|
|
23
|
+
label = "Dataframe" if self.kind == "dataframe" else "Record collection"
|
|
24
|
+
_validate_dataframe_contract(self.contract, where=f"{label} input port {self.name!r}", optional=True)
|
|
25
|
+
return
|
|
26
|
+
if self.contract is not None:
|
|
27
|
+
raise RegistryError(f"Input port {self.name!r} of kind {self.kind!r} must not declare a dataframe contract")
|
|
28
|
+
|
|
29
|
+
def render(self) -> str:
|
|
30
|
+
if self.kind == "dataframe":
|
|
31
|
+
return self.contract or "dataframe"
|
|
32
|
+
if self.kind == "record_collection":
|
|
33
|
+
return f"record collection ({self.contract or 'any dataframe contract'})"
|
|
34
|
+
if self.kind == "file_path":
|
|
35
|
+
return "file"
|
|
36
|
+
if self.kind == "file_set":
|
|
37
|
+
return "file set"
|
|
38
|
+
return "file bundle"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class OutputPortSpec:
|
|
43
|
+
name: str
|
|
44
|
+
kind: PortKind
|
|
45
|
+
contract: ContractId | None = None
|
|
46
|
+
surface: OutputContractSurface | None = None
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
_validate_port_name(self.name)
|
|
50
|
+
if self.kind == "file_set":
|
|
51
|
+
raise RegistryError("Output ports must not use input-only kind 'file_set'")
|
|
52
|
+
if self.kind == "dataframe":
|
|
53
|
+
_validate_dataframe_contract(self.contract, where=f"Dataframe output port {self.name!r}", optional=False)
|
|
54
|
+
return
|
|
55
|
+
if self.contract is not None:
|
|
56
|
+
raise RegistryError(
|
|
57
|
+
f"Output port {self.name!r} of kind {self.kind!r} must not declare a dataframe contract"
|
|
58
|
+
)
|
|
59
|
+
if self.surface is not None:
|
|
60
|
+
raise RegistryError(f"Non-dataframe output port {self.name!r} must not declare a contract surface")
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def contract_surface(self) -> OutputContractSurface | None:
|
|
64
|
+
if self.kind != "dataframe":
|
|
65
|
+
return None
|
|
66
|
+
if self.surface is not None:
|
|
67
|
+
return self.surface
|
|
68
|
+
return OutputContractSurface(minimum=self.contract or "")
|
|
69
|
+
|
|
70
|
+
def render(self) -> str:
|
|
71
|
+
if self.kind == "dataframe":
|
|
72
|
+
surface = self.contract_surface
|
|
73
|
+
if surface is None:
|
|
74
|
+
raise RegistryError(f"Dataframe output port {self.name!r} is missing a contract surface")
|
|
75
|
+
return surface.render()
|
|
76
|
+
if self.kind == "file_path":
|
|
77
|
+
return "file"
|
|
78
|
+
return "file bundle"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dataframe_input(name: str, contract: ContractId | None = None, *, optional: bool = False) -> InputPortSpec:
|
|
82
|
+
return InputPortSpec(name=name, kind="dataframe", contract=contract, optional=optional)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def record_collection_input(name: str, contract: ContractId | None = None, *, optional: bool = False) -> InputPortSpec:
|
|
86
|
+
return InputPortSpec(name=name, kind="record_collection", contract=contract, optional=optional)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def file_path_input(name: str, *, optional: bool = False) -> InputPortSpec:
|
|
90
|
+
return InputPortSpec(name=name, kind="file_path", optional=optional)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def file_set_input(name: str, *, optional: bool = False) -> InputPortSpec:
|
|
94
|
+
return InputPortSpec(name=name, kind="file_set", optional=optional)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def file_bundle_input(name: str, *, optional: bool = False) -> InputPortSpec:
|
|
98
|
+
return InputPortSpec(name=name, kind="file_bundle", optional=optional)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def dataframe_output(
|
|
102
|
+
name: str,
|
|
103
|
+
contract: ContractId,
|
|
104
|
+
*,
|
|
105
|
+
surface: OutputContractSurface | None = None,
|
|
106
|
+
) -> OutputPortSpec:
|
|
107
|
+
return OutputPortSpec(name=name, kind="dataframe", contract=contract, surface=surface)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def file_path_output(name: str) -> OutputPortSpec:
|
|
111
|
+
return OutputPortSpec(name=name, kind="file_path")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def file_bundle_output(name: str) -> OutputPortSpec:
|
|
115
|
+
return OutputPortSpec(name=name, kind="file_bundle")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def validate_input_ports(
|
|
119
|
+
ports: Mapping[str, InputPortSpec],
|
|
120
|
+
*,
|
|
121
|
+
where: str,
|
|
122
|
+
) -> dict[str, InputPortSpec]:
|
|
123
|
+
normalized: dict[str, InputPortSpec] = {}
|
|
124
|
+
for key, port in ports.items():
|
|
125
|
+
if not isinstance(port, InputPortSpec):
|
|
126
|
+
raise RegistryError(f"{where}: input port {key!r} must be an InputPortSpec")
|
|
127
|
+
if key != port.name:
|
|
128
|
+
raise RegistryError(f"{where}: input port key {key!r} must match declared name {port.name!r}")
|
|
129
|
+
if key in normalized:
|
|
130
|
+
raise RegistryError(f"{where}: duplicate input port {key!r}")
|
|
131
|
+
normalized[key] = port
|
|
132
|
+
return normalized
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def validate_output_ports(
|
|
136
|
+
ports: Mapping[str, OutputPortSpec],
|
|
137
|
+
*,
|
|
138
|
+
where: str,
|
|
139
|
+
) -> dict[str, OutputPortSpec]:
|
|
140
|
+
normalized: dict[str, OutputPortSpec] = {}
|
|
141
|
+
for key, port in ports.items():
|
|
142
|
+
if not isinstance(port, OutputPortSpec):
|
|
143
|
+
raise RegistryError(f"{where}: output port {key!r} must be an OutputPortSpec")
|
|
144
|
+
if key != port.name:
|
|
145
|
+
raise RegistryError(f"{where}: output port key {key!r} must match declared name {port.name!r}")
|
|
146
|
+
if key in normalized:
|
|
147
|
+
raise RegistryError(f"{where}: duplicate output port {key!r}")
|
|
148
|
+
normalized[key] = port
|
|
149
|
+
return normalized
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _validate_port_name(name: str) -> None:
|
|
153
|
+
if not isinstance(name, str) or not name.strip():
|
|
154
|
+
raise RegistryError("Port names must be non-empty strings")
|
|
155
|
+
if name.endswith("?"):
|
|
156
|
+
raise RegistryError(f"Port name {name!r} must not encode optionality with a '?' suffix")
|
|
157
|
+
if name == "files":
|
|
158
|
+
raise RegistryError("Port name 'files' is reserved; use a typed file_path, file_set, or file_bundle port")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _validate_dataframe_contract(contract: ContractId | None, *, where: str, optional: bool) -> None:
|
|
162
|
+
if contract is None and optional:
|
|
163
|
+
return
|
|
164
|
+
if not isinstance(contract, str) or not contract.strip():
|
|
165
|
+
requirement = "must be a non-empty contract id" if optional else "must declare a non-empty contract id"
|
|
166
|
+
raise RegistryError(f"{where} {requirement}")
|
|
167
|
+
if contract in {"none", "files"}:
|
|
168
|
+
raise RegistryError(f"{where} uses reserved contract id {contract!r}")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from .evidence import ArtifactEvidence, RecordInputEvidence, capture_artifact_evidence
|
|
2
|
+
from .identity import BuildIdentity, current_build_identity, digest_json
|
|
3
|
+
from .model import (
|
|
4
|
+
DataFrameArtifactRecord,
|
|
5
|
+
FileBundleRecord,
|
|
6
|
+
PathDescription,
|
|
7
|
+
RecordProducer,
|
|
8
|
+
RecordRecipeSource,
|
|
9
|
+
WorkbenchRecord,
|
|
10
|
+
record_from_dict,
|
|
11
|
+
record_paths,
|
|
12
|
+
record_revision_digest,
|
|
13
|
+
record_to_dict,
|
|
14
|
+
verify_record_artifact_integrity,
|
|
15
|
+
)
|
|
16
|
+
from .sources import ResolvedSourceRecord, SourceRecordCollection, resolve_source_record
|
|
17
|
+
from .store import RecordStore
|
|
18
|
+
from .verification import RecordVerificationScope, verify_record_store
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ArtifactEvidence",
|
|
22
|
+
"BuildIdentity",
|
|
23
|
+
"DataFrameArtifactRecord",
|
|
24
|
+
"FileBundleRecord",
|
|
25
|
+
"PathDescription",
|
|
26
|
+
"RecordProducer",
|
|
27
|
+
"RecordInputEvidence",
|
|
28
|
+
"RecordRecipeSource",
|
|
29
|
+
"RecordStore",
|
|
30
|
+
"ResolvedSourceRecord",
|
|
31
|
+
"SourceRecordCollection",
|
|
32
|
+
"RecordVerificationScope",
|
|
33
|
+
"WorkbenchRecord",
|
|
34
|
+
"capture_artifact_evidence",
|
|
35
|
+
"current_build_identity",
|
|
36
|
+
"digest_json",
|
|
37
|
+
"record_from_dict",
|
|
38
|
+
"record_revision_digest",
|
|
39
|
+
"resolve_source_record",
|
|
40
|
+
"record_paths",
|
|
41
|
+
"record_to_dict",
|
|
42
|
+
"verify_record_store",
|
|
43
|
+
"verify_record_artifact_integrity",
|
|
44
|
+
]
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Filesystem transaction for starting a fresh generated-output epoch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
from collections.abc import Callable, Iterable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from reader_workbench.errors import RecordError
|
|
13
|
+
from reader_workbench.workbench.paths import resolve_confined_sink_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def replace_generated_epoch(
|
|
17
|
+
*,
|
|
18
|
+
outputs_root: Path,
|
|
19
|
+
artifacts_root: Path,
|
|
20
|
+
manifests_root: Path,
|
|
21
|
+
plots_root: Path,
|
|
22
|
+
exports_root: Path,
|
|
23
|
+
log_path: Path,
|
|
24
|
+
preserved_paths: Iterable[Path],
|
|
25
|
+
lock_path: Path,
|
|
26
|
+
initialize: Callable[[], str],
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Stage owned output roots, initialize a new epoch, and remove the old epoch.
|
|
29
|
+
|
|
30
|
+
The writer lock remains in place while the other manifest entries move. Paths
|
|
31
|
+
outside the named owned roots are never enumerated or removed.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
root, manifests, lock, owned_roots, owned_files = validate_generated_epoch_boundary(
|
|
35
|
+
outputs_root=outputs_root,
|
|
36
|
+
artifacts_root=artifacts_root,
|
|
37
|
+
manifests_root=manifests_root,
|
|
38
|
+
plots_root=plots_root,
|
|
39
|
+
exports_root=exports_root,
|
|
40
|
+
log_path=log_path,
|
|
41
|
+
preserved_paths=preserved_paths,
|
|
42
|
+
lock_path=lock_path,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
staging = root / f".reader-reset.{uuid4()}.staging"
|
|
46
|
+
staged_roots: list[tuple[Path, Path]] = []
|
|
47
|
+
staged_manifest_entries: list[tuple[Path, Path]] = []
|
|
48
|
+
staged_files: list[tuple[Path, Path]] = []
|
|
49
|
+
initialization_started = False
|
|
50
|
+
cleanup_started = False
|
|
51
|
+
try:
|
|
52
|
+
staging.mkdir(mode=0o700)
|
|
53
|
+
roots_staging = staging / "roots"
|
|
54
|
+
manifests_staging = staging / "manifests"
|
|
55
|
+
files_staging = staging / "files"
|
|
56
|
+
roots_staging.mkdir()
|
|
57
|
+
manifests_staging.mkdir()
|
|
58
|
+
files_staging.mkdir()
|
|
59
|
+
|
|
60
|
+
for index, source in enumerate(owned_roots):
|
|
61
|
+
if not source.exists():
|
|
62
|
+
continue
|
|
63
|
+
destination = roots_staging / f"{index}-{source.name}"
|
|
64
|
+
source.rename(destination)
|
|
65
|
+
staged_roots.append((source, destination))
|
|
66
|
+
|
|
67
|
+
if manifests.exists():
|
|
68
|
+
for source in sorted(manifests.iterdir(), key=lambda path: path.name):
|
|
69
|
+
if source == lock:
|
|
70
|
+
continue
|
|
71
|
+
destination = manifests_staging / source.name
|
|
72
|
+
source.rename(destination)
|
|
73
|
+
staged_manifest_entries.append((source, destination))
|
|
74
|
+
|
|
75
|
+
for index, source in enumerate(owned_files):
|
|
76
|
+
if not source.exists():
|
|
77
|
+
continue
|
|
78
|
+
destination = files_staging / f"{index}-{source.name}"
|
|
79
|
+
source.rename(destination)
|
|
80
|
+
staged_files.append((source, destination))
|
|
81
|
+
|
|
82
|
+
initialization_started = True
|
|
83
|
+
epoch_id = initialize()
|
|
84
|
+
cleanup_started = True
|
|
85
|
+
shutil.rmtree(staging)
|
|
86
|
+
return epoch_id
|
|
87
|
+
except BaseException as exc:
|
|
88
|
+
if cleanup_started:
|
|
89
|
+
raise RecordError(
|
|
90
|
+
"The fresh generated-output epoch was initialized, but Reader could not remove all staged prior "
|
|
91
|
+
f"state at {staging}. Do not retry blindly; inspect the staging directory and run reader verify."
|
|
92
|
+
) from exc
|
|
93
|
+
if staging.exists() and not cleanup_started:
|
|
94
|
+
rollback_succeeded = False
|
|
95
|
+
try:
|
|
96
|
+
_rollback(
|
|
97
|
+
manifests=manifests,
|
|
98
|
+
lock=lock,
|
|
99
|
+
owned_roots=owned_roots,
|
|
100
|
+
owned_files=owned_files,
|
|
101
|
+
staged_roots=staged_roots,
|
|
102
|
+
staged_manifest_entries=staged_manifest_entries,
|
|
103
|
+
staged_files=staged_files,
|
|
104
|
+
discard_current=initialization_started,
|
|
105
|
+
)
|
|
106
|
+
except BaseException as rollback_error:
|
|
107
|
+
exc.add_note(
|
|
108
|
+
"Reader also could not fully restore the prior generated-output epoch "
|
|
109
|
+
f"({rollback_error}). Remaining staged state is retained at {staging}."
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
rollback_succeeded = True
|
|
113
|
+
if rollback_succeeded:
|
|
114
|
+
try:
|
|
115
|
+
shutil.rmtree(staging)
|
|
116
|
+
except OSError as cleanup_error:
|
|
117
|
+
exc.add_note(f"Reader restored the prior epoch but could not remove empty staging at {staging}.")
|
|
118
|
+
exc.add_note(f"Staging cleanup failed with {type(cleanup_error).__name__}.")
|
|
119
|
+
if isinstance(exc, RecordError):
|
|
120
|
+
raise
|
|
121
|
+
raise RecordError(f"Could not start a fresh generated-output epoch: {exc}") from exc
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate_generated_epoch_boundary(
|
|
125
|
+
*,
|
|
126
|
+
outputs_root: Path,
|
|
127
|
+
artifacts_root: Path,
|
|
128
|
+
manifests_root: Path,
|
|
129
|
+
plots_root: Path,
|
|
130
|
+
exports_root: Path,
|
|
131
|
+
log_path: Path,
|
|
132
|
+
preserved_paths: Iterable[Path],
|
|
133
|
+
lock_path: Path,
|
|
134
|
+
) -> tuple[Path, Path, Path, tuple[Path, ...], tuple[Path, ...]]:
|
|
135
|
+
"""Validate the complete reset boundary without mutating it."""
|
|
136
|
+
|
|
137
|
+
root = Path(outputs_root).expanduser().absolute()
|
|
138
|
+
manifests = Path(manifests_root).expanduser().absolute()
|
|
139
|
+
lock = Path(lock_path).expanduser().absolute()
|
|
140
|
+
owned_files = (_confined(log_path, root=root, label="reader log"),)
|
|
141
|
+
owned_roots = _minimal_owned_roots(
|
|
142
|
+
root=root,
|
|
143
|
+
candidates=(artifacts_root, plots_root, exports_root),
|
|
144
|
+
manifests_root=manifests,
|
|
145
|
+
)
|
|
146
|
+
preserved = tuple(_confined(path, root=root, label="preserved output") for path in preserved_paths)
|
|
147
|
+
assert_no_interrupted_epoch(root)
|
|
148
|
+
_validate_reset_boundary(
|
|
149
|
+
root=root,
|
|
150
|
+
manifests=manifests,
|
|
151
|
+
lock=lock,
|
|
152
|
+
owned_roots=owned_roots,
|
|
153
|
+
preserved=preserved,
|
|
154
|
+
)
|
|
155
|
+
for owned_file in owned_files:
|
|
156
|
+
_validate_existing_file(owned_file)
|
|
157
|
+
return root, manifests, lock, owned_roots, owned_files
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def assert_no_interrupted_epoch(outputs_root: Path) -> None:
|
|
161
|
+
"""Reject writes while recovery evidence from an interrupted reset remains."""
|
|
162
|
+
|
|
163
|
+
root = Path(outputs_root).expanduser().absolute()
|
|
164
|
+
stale_transactions = _stale_transactions(root)
|
|
165
|
+
if not stale_transactions:
|
|
166
|
+
return
|
|
167
|
+
paths = ", ".join(str(path) for path in stale_transactions)
|
|
168
|
+
raise RecordError(
|
|
169
|
+
"An unfinished generated-output reset exists at "
|
|
170
|
+
f"{paths}. Reader will not mutate outputs or discard recovery evidence. "
|
|
171
|
+
"Inspect the retained roots/, manifests/, and files/ entries; restore the prior epoch or archive "
|
|
172
|
+
"confirmed residue, then remove the staging directory before running Reader again."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _minimal_owned_roots(
|
|
177
|
+
*,
|
|
178
|
+
root: Path,
|
|
179
|
+
candidates: Iterable[Path],
|
|
180
|
+
manifests_root: Path,
|
|
181
|
+
) -> tuple[Path, ...]:
|
|
182
|
+
normalized: list[Path] = []
|
|
183
|
+
for candidate in candidates:
|
|
184
|
+
path = _confined(candidate, root=root, label="generated output")
|
|
185
|
+
if path == root:
|
|
186
|
+
raise RecordError(
|
|
187
|
+
"A fresh generated-output epoch requires plots and exports to use dedicated subdirectories; "
|
|
188
|
+
"a flattened sink would make generated files indistinguishable from preserved output-root content."
|
|
189
|
+
)
|
|
190
|
+
if _contains(manifests_root, path):
|
|
191
|
+
continue
|
|
192
|
+
if path not in normalized:
|
|
193
|
+
normalized.append(path)
|
|
194
|
+
return tuple(
|
|
195
|
+
path
|
|
196
|
+
for path in sorted(normalized, key=lambda item: len(item.parts))
|
|
197
|
+
if not any(_contains(parent, path) for parent in normalized if parent != path)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _validate_reset_boundary(
|
|
202
|
+
*,
|
|
203
|
+
root: Path,
|
|
204
|
+
manifests: Path,
|
|
205
|
+
lock: Path,
|
|
206
|
+
owned_roots: tuple[Path, ...],
|
|
207
|
+
preserved: tuple[Path, ...],
|
|
208
|
+
) -> None:
|
|
209
|
+
_confined(manifests, root=root, label="manifests")
|
|
210
|
+
_confined(lock, root=manifests, label="record lock")
|
|
211
|
+
for owned in (*owned_roots, manifests):
|
|
212
|
+
if any(keep != root and _overlaps(owned, keep) for keep in preserved):
|
|
213
|
+
raise RecordError(
|
|
214
|
+
f"Generated output sink {owned} overlaps preserved output path; "
|
|
215
|
+
"use distinct plots, exports, and notebooks subdirectories before resetting records."
|
|
216
|
+
)
|
|
217
|
+
_validate_existing_tree(owned)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _validate_existing_tree(root: Path) -> None:
|
|
221
|
+
if root.is_symlink():
|
|
222
|
+
raise RecordError(f"Generated output sink must not be a symlink: {root}")
|
|
223
|
+
if not root.exists():
|
|
224
|
+
return
|
|
225
|
+
if not root.is_dir():
|
|
226
|
+
raise RecordError(f"Generated output sink must be a directory: {root}")
|
|
227
|
+
for current, directories, files in os.walk(root, topdown=True, followlinks=False):
|
|
228
|
+
current_path = Path(current)
|
|
229
|
+
for name in (*directories, *files):
|
|
230
|
+
candidate = current_path / name
|
|
231
|
+
if candidate.is_symlink():
|
|
232
|
+
raise RecordError(f"Generated output state must not contain symlinks: {candidate}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _validate_existing_file(path: Path) -> None:
|
|
236
|
+
if path.is_symlink():
|
|
237
|
+
raise RecordError(f"Reader-owned output must not be a symlink: {path}")
|
|
238
|
+
if not path.exists():
|
|
239
|
+
return
|
|
240
|
+
metadata = path.stat(follow_symlinks=False)
|
|
241
|
+
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
|
|
242
|
+
raise RecordError(f"Reader-owned output must be a regular file with a single link: {path}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _stale_transactions(root: Path) -> tuple[Path, ...]:
|
|
246
|
+
if not root.exists():
|
|
247
|
+
return ()
|
|
248
|
+
return tuple(
|
|
249
|
+
sorted(
|
|
250
|
+
(
|
|
251
|
+
path
|
|
252
|
+
for path in root.iterdir()
|
|
253
|
+
if path.name.startswith(".reader-reset.") and path.name.endswith(".staging")
|
|
254
|
+
),
|
|
255
|
+
key=lambda path: path.name,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _rollback(
|
|
261
|
+
*,
|
|
262
|
+
manifests: Path,
|
|
263
|
+
lock: Path,
|
|
264
|
+
owned_roots: tuple[Path, ...],
|
|
265
|
+
owned_files: tuple[Path, ...],
|
|
266
|
+
staged_roots: list[tuple[Path, Path]],
|
|
267
|
+
staged_manifest_entries: list[tuple[Path, Path]],
|
|
268
|
+
staged_files: list[tuple[Path, Path]],
|
|
269
|
+
discard_current: bool,
|
|
270
|
+
) -> None:
|
|
271
|
+
if discard_current and manifests.exists():
|
|
272
|
+
for current in tuple(manifests.iterdir()):
|
|
273
|
+
if current != lock:
|
|
274
|
+
_remove_entry(current)
|
|
275
|
+
elif not manifests.exists():
|
|
276
|
+
manifests.mkdir(parents=True)
|
|
277
|
+
for source, destination in reversed(staged_manifest_entries):
|
|
278
|
+
if source.exists() or source.is_symlink():
|
|
279
|
+
_remove_entry(source)
|
|
280
|
+
destination.rename(source)
|
|
281
|
+
|
|
282
|
+
if discard_current:
|
|
283
|
+
for source in owned_files:
|
|
284
|
+
if source.exists() or source.is_symlink():
|
|
285
|
+
_remove_entry(source)
|
|
286
|
+
for source, destination in reversed(staged_files):
|
|
287
|
+
if source.exists() or source.is_symlink():
|
|
288
|
+
if not discard_current:
|
|
289
|
+
raise RecordError(f"Cannot restore staged generated output because its target reappeared: {source}")
|
|
290
|
+
_remove_entry(source)
|
|
291
|
+
destination.rename(source)
|
|
292
|
+
|
|
293
|
+
if discard_current:
|
|
294
|
+
for source in reversed(owned_roots):
|
|
295
|
+
if source.exists() or source.is_symlink():
|
|
296
|
+
_remove_entry(source)
|
|
297
|
+
for source, destination in reversed(staged_roots):
|
|
298
|
+
if source.exists() or source.is_symlink():
|
|
299
|
+
if not discard_current:
|
|
300
|
+
raise RecordError(f"Cannot restore staged generated output because its target reappeared: {source}")
|
|
301
|
+
_remove_entry(source)
|
|
302
|
+
source.parent.mkdir(parents=True, exist_ok=True)
|
|
303
|
+
destination.rename(source)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _remove_entry(path: Path) -> None:
|
|
307
|
+
if path.is_symlink() or not path.is_dir():
|
|
308
|
+
path.unlink(missing_ok=True)
|
|
309
|
+
elif path.exists():
|
|
310
|
+
shutil.rmtree(path)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _confined(path: Path, *, root: Path, label: str) -> Path:
|
|
314
|
+
try:
|
|
315
|
+
return resolve_confined_sink_root(path, root=root, label=label)
|
|
316
|
+
except ValueError as exc:
|
|
317
|
+
raise RecordError(str(exc)) from exc
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _contains(parent: Path, child: Path) -> bool:
|
|
321
|
+
try:
|
|
322
|
+
child.relative_to(parent)
|
|
323
|
+
except ValueError:
|
|
324
|
+
return False
|
|
325
|
+
return True
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _overlaps(first: Path, second: Path) -> bool:
|
|
329
|
+
return _contains(first, second) or _contains(second, first)
|