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,247 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from reader_workbench.errors import RecordError
|
|
10
|
+
from reader_workbench.workbench.experiments import ExperimentCatalog, ExperimentLocation
|
|
11
|
+
from reader_workbench.workbench.graph import FileRef, InputRef, RecordRef, ResourceRef, SourceRecordRef
|
|
12
|
+
|
|
13
|
+
from .identity import is_sha256_digest
|
|
14
|
+
|
|
15
|
+
_DISCOVERY_POLICIES = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"declared_file",
|
|
18
|
+
"declared_resource",
|
|
19
|
+
"plugin_discovery",
|
|
20
|
+
"record",
|
|
21
|
+
"source_record",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
SourceExperimentResolver = Callable[[str], ExperimentLocation]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ArtifactEvidence:
|
|
29
|
+
relative_path: Path
|
|
30
|
+
size_bytes: int
|
|
31
|
+
content_digest: str
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
path = Path(self.relative_path)
|
|
35
|
+
if path.is_absolute() or not path.parts or any(part == ".." for part in path.parts):
|
|
36
|
+
raise RecordError("artifact evidence path must be relative and confined")
|
|
37
|
+
if not isinstance(self.size_bytes, int) or self.size_bytes < 0:
|
|
38
|
+
raise RecordError("artifact evidence size_bytes must be a non-negative integer")
|
|
39
|
+
if not is_sha256_digest(self.content_digest):
|
|
40
|
+
raise RecordError("artifact evidence content_digest must be a sha256 digest")
|
|
41
|
+
object.__setattr__(self, "relative_path", path)
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, object]:
|
|
44
|
+
return {
|
|
45
|
+
"path": self.relative_path.as_posix(),
|
|
46
|
+
"size_bytes": self.size_bytes,
|
|
47
|
+
"content_digest": self.content_digest,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, payload: Any) -> ArtifactEvidence:
|
|
52
|
+
if not isinstance(payload, dict) or set(payload) != {"path", "size_bytes", "content_digest"}:
|
|
53
|
+
raise RecordError("artifact evidence must contain only path, size_bytes, and content_digest")
|
|
54
|
+
raw_path = payload.get("path")
|
|
55
|
+
if not isinstance(raw_path, str) or not raw_path.strip():
|
|
56
|
+
raise RecordError("artifact evidence path must be a non-empty string")
|
|
57
|
+
return cls(
|
|
58
|
+
relative_path=Path(raw_path),
|
|
59
|
+
size_bytes=payload.get("size_bytes"),
|
|
60
|
+
content_digest=payload.get("content_digest"),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class RecordInputEvidence:
|
|
66
|
+
"""Immutable evidence for one input consumed by a persisted record."""
|
|
67
|
+
|
|
68
|
+
label: str
|
|
69
|
+
ref: InputRef
|
|
70
|
+
discovery_policy: str
|
|
71
|
+
artifact: ArtifactEvidence | None = None
|
|
72
|
+
record_revision_digest: str | None = None
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
if not isinstance(self.label, str) or not self.label.strip():
|
|
76
|
+
raise RecordError("record input evidence label must be a non-empty string")
|
|
77
|
+
if self.discovery_policy not in _DISCOVERY_POLICIES:
|
|
78
|
+
raise RecordError(
|
|
79
|
+
"record input evidence discovery_policy must be one of " + ", ".join(sorted(_DISCOVERY_POLICIES))
|
|
80
|
+
)
|
|
81
|
+
if isinstance(self.ref, RecordRef):
|
|
82
|
+
if self.discovery_policy != "record":
|
|
83
|
+
raise RecordError("record references must use discovery_policy 'record'")
|
|
84
|
+
if self.artifact is not None:
|
|
85
|
+
raise RecordError("record references must not include artifact evidence")
|
|
86
|
+
if not is_sha256_digest(self.record_revision_digest):
|
|
87
|
+
raise RecordError("record references must include a sha256 record_revision_digest")
|
|
88
|
+
return
|
|
89
|
+
if isinstance(self.ref, SourceRecordRef):
|
|
90
|
+
if self.discovery_policy != "source_record":
|
|
91
|
+
raise RecordError("source record references must use discovery_policy 'source_record'")
|
|
92
|
+
if self.artifact is not None:
|
|
93
|
+
raise RecordError("source record references must not include artifact evidence")
|
|
94
|
+
if not is_sha256_digest(self.record_revision_digest):
|
|
95
|
+
raise RecordError("source record references must include a sha256 record_revision_digest")
|
|
96
|
+
return
|
|
97
|
+
if not isinstance(self.ref, (FileRef, ResourceRef)):
|
|
98
|
+
raise RecordError("record input evidence contains an unsupported reference")
|
|
99
|
+
if self.discovery_policy in {"record", "source_record"}:
|
|
100
|
+
raise RecordError("file and resource inputs must not use record discovery policies")
|
|
101
|
+
if self.artifact is None:
|
|
102
|
+
raise RecordError("file and resource inputs must include artifact evidence")
|
|
103
|
+
if self.record_revision_digest is not None:
|
|
104
|
+
raise RecordError("file and resource inputs must not include record_revision_digest")
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> dict[str, object]:
|
|
107
|
+
base: dict[str, object] = {
|
|
108
|
+
"label": self.label,
|
|
109
|
+
"discovery_policy": self.discovery_policy,
|
|
110
|
+
}
|
|
111
|
+
if isinstance(self.ref, RecordRef):
|
|
112
|
+
return {
|
|
113
|
+
**base,
|
|
114
|
+
"kind": "record",
|
|
115
|
+
"record": self.ref.record_id,
|
|
116
|
+
"record_revision_digest": self.record_revision_digest,
|
|
117
|
+
}
|
|
118
|
+
if isinstance(self.ref, SourceRecordRef):
|
|
119
|
+
return {
|
|
120
|
+
**base,
|
|
121
|
+
"kind": "source_record",
|
|
122
|
+
"resource": self.ref.resource_id,
|
|
123
|
+
"experiment": self.ref.experiment_id,
|
|
124
|
+
"record": self.ref.record_id,
|
|
125
|
+
"record_revision_digest": self.record_revision_digest,
|
|
126
|
+
}
|
|
127
|
+
if isinstance(self.ref, ResourceRef):
|
|
128
|
+
base.update({"kind": "resource", "resource": self.ref.resource_id})
|
|
129
|
+
else:
|
|
130
|
+
base["kind"] = "file"
|
|
131
|
+
base["artifact"] = self.artifact.to_dict() if self.artifact else None
|
|
132
|
+
return base
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_dict(
|
|
136
|
+
cls,
|
|
137
|
+
payload: Any,
|
|
138
|
+
*,
|
|
139
|
+
experiment_root: Path,
|
|
140
|
+
source_experiment_resolver: SourceExperimentResolver | None = None,
|
|
141
|
+
) -> RecordInputEvidence:
|
|
142
|
+
if not isinstance(payload, dict):
|
|
143
|
+
raise RecordError("record input evidence must be a JSON object")
|
|
144
|
+
kind = payload.get("kind")
|
|
145
|
+
label = payload.get("label")
|
|
146
|
+
discovery_policy = payload.get("discovery_policy")
|
|
147
|
+
if kind == "record":
|
|
148
|
+
expected = {
|
|
149
|
+
"label",
|
|
150
|
+
"kind",
|
|
151
|
+
"record",
|
|
152
|
+
"discovery_policy",
|
|
153
|
+
"record_revision_digest",
|
|
154
|
+
}
|
|
155
|
+
if set(payload) != expected:
|
|
156
|
+
raise RecordError("record input evidence has unknown or missing fields")
|
|
157
|
+
record_id = payload.get("record")
|
|
158
|
+
if not isinstance(record_id, str) or not record_id:
|
|
159
|
+
raise RecordError("record input evidence must include a non-empty record id")
|
|
160
|
+
return cls(
|
|
161
|
+
label=label,
|
|
162
|
+
ref=RecordRef(record_id=record_id),
|
|
163
|
+
discovery_policy=discovery_policy,
|
|
164
|
+
record_revision_digest=payload.get("record_revision_digest"),
|
|
165
|
+
)
|
|
166
|
+
if kind == "source_record":
|
|
167
|
+
expected = {
|
|
168
|
+
"label",
|
|
169
|
+
"kind",
|
|
170
|
+
"resource",
|
|
171
|
+
"experiment",
|
|
172
|
+
"record",
|
|
173
|
+
"discovery_policy",
|
|
174
|
+
"record_revision_digest",
|
|
175
|
+
}
|
|
176
|
+
if set(payload) != expected:
|
|
177
|
+
raise RecordError("source record input evidence has unknown or missing fields")
|
|
178
|
+
resource_id = payload.get("resource")
|
|
179
|
+
experiment_id = payload.get("experiment")
|
|
180
|
+
record_id = payload.get("record")
|
|
181
|
+
if any(not isinstance(value, str) or not value for value in (resource_id, experiment_id, record_id)):
|
|
182
|
+
raise RecordError("source record input evidence requires resource, experiment, and record identities")
|
|
183
|
+
try:
|
|
184
|
+
resolver = source_experiment_resolver
|
|
185
|
+
if resolver is None:
|
|
186
|
+
resolver = ExperimentCatalog.from_experiment_root(experiment_root).resolve
|
|
187
|
+
location = resolver(experiment_id)
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
raise RecordError(f"Could not resolve source experiment {experiment_id!r}: {exc}") from exc
|
|
190
|
+
return cls(
|
|
191
|
+
label=label,
|
|
192
|
+
ref=SourceRecordRef(
|
|
193
|
+
resource_id=resource_id,
|
|
194
|
+
experiment_id=location.id,
|
|
195
|
+
record_id=record_id,
|
|
196
|
+
experiment_root=location.root,
|
|
197
|
+
outputs_dir=location.outputs_dir,
|
|
198
|
+
),
|
|
199
|
+
discovery_policy=discovery_policy,
|
|
200
|
+
record_revision_digest=payload.get("record_revision_digest"),
|
|
201
|
+
)
|
|
202
|
+
if kind not in {"file", "resource"}:
|
|
203
|
+
raise RecordError(f"record input evidence has unknown kind {kind!r}")
|
|
204
|
+
expected = {"label", "kind", "discovery_policy", "artifact"}
|
|
205
|
+
if kind == "resource":
|
|
206
|
+
expected.add("resource")
|
|
207
|
+
if set(payload) != expected:
|
|
208
|
+
raise RecordError("record input evidence has unknown or missing fields")
|
|
209
|
+
artifact = ArtifactEvidence.from_dict(payload.get("artifact"))
|
|
210
|
+
resolved = experiment_root.resolve(strict=False) / artifact.relative_path
|
|
211
|
+
if kind == "resource":
|
|
212
|
+
resource_id = payload.get("resource")
|
|
213
|
+
if not isinstance(resource_id, str) or not resource_id:
|
|
214
|
+
raise RecordError("resource input evidence must include a non-empty resource id")
|
|
215
|
+
ref: InputRef = ResourceRef(resource_id=resource_id, path=resolved)
|
|
216
|
+
else:
|
|
217
|
+
ref = FileRef(path=resolved)
|
|
218
|
+
return cls(
|
|
219
|
+
label=label,
|
|
220
|
+
ref=ref,
|
|
221
|
+
discovery_policy=discovery_policy,
|
|
222
|
+
artifact=artifact,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def capture_artifact_evidence(path: Path, *, root: Path) -> ArtifactEvidence:
|
|
227
|
+
root_resolved = root.resolve(strict=False)
|
|
228
|
+
resolved = path.resolve(strict=True)
|
|
229
|
+
try:
|
|
230
|
+
relative = resolved.relative_to(root_resolved)
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
raise RecordError(f"artifact {path} must resolve within the outputs directory {root}") from exc
|
|
233
|
+
if not resolved.is_file():
|
|
234
|
+
raise RecordError(f"artifact {path} must be a regular file")
|
|
235
|
+
return ArtifactEvidence(
|
|
236
|
+
relative_path=relative,
|
|
237
|
+
size_bytes=resolved.stat().st_size,
|
|
238
|
+
content_digest=_sha256_file(resolved),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _sha256_file(path: Path, *, chunk_size: int = 1024 * 1024) -> str:
|
|
243
|
+
digest = hashlib.sha256()
|
|
244
|
+
with path.open("rb") as artifact:
|
|
245
|
+
for chunk in iter(lambda: artifact.read(chunk_size), b""):
|
|
246
|
+
digest.update(chunk)
|
|
247
|
+
return "sha256:" + digest.hexdigest()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from reader_workbench._version import package_version
|
|
11
|
+
from reader_workbench.errors import RecordError
|
|
12
|
+
|
|
13
|
+
_PACKAGED_SOURCE_SUFFIXES = frozenset({".py", ".pyi"})
|
|
14
|
+
_PACKAGED_DATA_GLOBS = ("workbench/notebooks/*.marimo.py.txt",)
|
|
15
|
+
_PACKAGED_DATA_NAMES = frozenset({"py.typed"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def digest_json(payload: Any) -> str:
|
|
19
|
+
raw = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str).encode()
|
|
20
|
+
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_sha256_digest(value: object) -> bool:
|
|
24
|
+
if not isinstance(value, str) or not value.startswith("sha256:"):
|
|
25
|
+
return False
|
|
26
|
+
digest = value.removeprefix("sha256:")
|
|
27
|
+
return len(digest) == 64 and all(character in "0123456789abcdef" for character in digest)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class BuildIdentity:
|
|
32
|
+
reader_version: str
|
|
33
|
+
source_digest: str
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
if not isinstance(self.reader_version, str) or not self.reader_version.strip():
|
|
37
|
+
raise RecordError("build_identity.reader_version must be a non-empty string")
|
|
38
|
+
if not is_sha256_digest(self.source_digest):
|
|
39
|
+
raise RecordError("build_identity.source_digest must be a sha256 digest")
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, str]:
|
|
42
|
+
return {
|
|
43
|
+
"reader_version": self.reader_version,
|
|
44
|
+
"source_digest": self.source_digest,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_dict(cls, payload: Any) -> BuildIdentity:
|
|
49
|
+
if not isinstance(payload, dict) or set(payload) != {"reader_version", "source_digest"}:
|
|
50
|
+
raise RecordError("build_identity must contain only reader_version and source_digest")
|
|
51
|
+
return cls(
|
|
52
|
+
reader_version=payload.get("reader_version"),
|
|
53
|
+
source_digest=payload.get("source_digest"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _packaged_runtime_files(package_root: Path) -> tuple[Path, ...]:
|
|
58
|
+
files: set[Path] = set()
|
|
59
|
+
for path in package_root.rglob("*"):
|
|
60
|
+
if not path.is_file():
|
|
61
|
+
continue
|
|
62
|
+
relative = path.relative_to(package_root)
|
|
63
|
+
if relative.parts[0] == "tests":
|
|
64
|
+
continue
|
|
65
|
+
if path.suffix in _PACKAGED_SOURCE_SUFFIXES or path.name in _PACKAGED_DATA_NAMES:
|
|
66
|
+
files.add(path)
|
|
67
|
+
for pattern in _PACKAGED_DATA_GLOBS:
|
|
68
|
+
files.update(path for path in package_root.glob(pattern) if path.is_file())
|
|
69
|
+
return tuple(sorted(files, key=lambda item: item.relative_to(package_root).as_posix()))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _source_digest(package_root: Path) -> str:
|
|
73
|
+
digest = hashlib.sha256()
|
|
74
|
+
for path in _packaged_runtime_files(package_root):
|
|
75
|
+
relative = path.relative_to(package_root).as_posix().encode("utf-8")
|
|
76
|
+
digest.update(len(relative).to_bytes(8, "big"))
|
|
77
|
+
digest.update(relative)
|
|
78
|
+
data = path.read_bytes()
|
|
79
|
+
digest.update(len(data).to_bytes(8, "big"))
|
|
80
|
+
digest.update(data)
|
|
81
|
+
return "sha256:" + digest.hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@cache
|
|
85
|
+
def current_build_identity() -> BuildIdentity:
|
|
86
|
+
package_root = Path(__file__).resolve().parents[2]
|
|
87
|
+
return BuildIdentity(reader_version=package_version(), source_digest=_source_digest(package_root))
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Filesystem-safe, process-shared locks for provenance publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from filelock import BaseFileLock, Timeout
|
|
14
|
+
|
|
15
|
+
if os.name == "posix":
|
|
16
|
+
import fcntl
|
|
17
|
+
elif os.name == "nt": # pragma: win32 cover
|
|
18
|
+
import msvcrt
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProvenanceFileLock(BaseFileLock):
|
|
22
|
+
"""A reentrant advisory lock that never truncates an existing target.
|
|
23
|
+
|
|
24
|
+
``BaseFileLock`` supplies timeout polling and per-thread reentrancy. This
|
|
25
|
+
implementation owns the filesystem boundary: it opens without truncation,
|
|
26
|
+
validates the opened descriptor, acquires an OS lock, and then proves that
|
|
27
|
+
the locked inode is still the single-link object named by the lock path.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def _acquire(self) -> None:
|
|
31
|
+
if os.name not in {"posix", "nt"}: # pragma: no cover - fail closed on unsupported runtimes
|
|
32
|
+
raise NotImplementedError("Secure provenance locks require POSIX flock or Windows msvcrt locking")
|
|
33
|
+
|
|
34
|
+
descriptor = -1
|
|
35
|
+
parent_descriptor = -1
|
|
36
|
+
parent_stat: os.stat_result | None = None
|
|
37
|
+
locked = False
|
|
38
|
+
try:
|
|
39
|
+
lock_path = Path(self.lock_file)
|
|
40
|
+
if os.name == "posix":
|
|
41
|
+
parent_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_DIRECTORY", 0)
|
|
42
|
+
parent_flags |= getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
43
|
+
parent_descriptor = os.open(lock_path.parent, parent_flags)
|
|
44
|
+
parent_stat = os.fstat(parent_descriptor)
|
|
45
|
+
if not stat.S_ISDIR(parent_stat.st_mode):
|
|
46
|
+
raise OSError("Provenance lock parent must be a directory")
|
|
47
|
+
descriptor = os.open(
|
|
48
|
+
lock_path.name,
|
|
49
|
+
self._open_flags(),
|
|
50
|
+
self._context.mode,
|
|
51
|
+
dir_fd=parent_descriptor,
|
|
52
|
+
)
|
|
53
|
+
self._validate_descriptor(
|
|
54
|
+
descriptor,
|
|
55
|
+
lock_path=lock_path,
|
|
56
|
+
parent_descriptor=parent_descriptor,
|
|
57
|
+
parent_stat=parent_stat,
|
|
58
|
+
)
|
|
59
|
+
else: # pragma: win32 cover
|
|
60
|
+
descriptor = os.open(lock_path, self._open_flags(), self._context.mode)
|
|
61
|
+
self._validate_descriptor(descriptor, lock_path=lock_path)
|
|
62
|
+
|
|
63
|
+
if not self._try_lock(descriptor):
|
|
64
|
+
return
|
|
65
|
+
locked = True
|
|
66
|
+
self._validate_descriptor(
|
|
67
|
+
descriptor,
|
|
68
|
+
lock_path=lock_path,
|
|
69
|
+
parent_descriptor=parent_descriptor if parent_descriptor >= 0 else None,
|
|
70
|
+
parent_stat=parent_stat,
|
|
71
|
+
)
|
|
72
|
+
self._context.lock_file_fd = descriptor
|
|
73
|
+
descriptor = -1
|
|
74
|
+
locked = False
|
|
75
|
+
finally:
|
|
76
|
+
if locked:
|
|
77
|
+
self._unlock(descriptor)
|
|
78
|
+
if descriptor >= 0:
|
|
79
|
+
os.close(descriptor)
|
|
80
|
+
if parent_descriptor >= 0:
|
|
81
|
+
os.close(parent_descriptor)
|
|
82
|
+
|
|
83
|
+
def _release(self) -> None:
|
|
84
|
+
descriptor = self._context.lock_file_fd
|
|
85
|
+
if descriptor is None: # pragma: no cover - BaseFileLock guards this path
|
|
86
|
+
return
|
|
87
|
+
self._context.lock_file_fd = None
|
|
88
|
+
try:
|
|
89
|
+
self._unlock(descriptor)
|
|
90
|
+
finally:
|
|
91
|
+
os.close(descriptor)
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _open_flags() -> int:
|
|
95
|
+
flags = os.O_RDWR | os.O_CREAT
|
|
96
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
97
|
+
flags |= getattr(os, "O_NOFOLLOW", 0)
|
|
98
|
+
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
99
|
+
flags |= getattr(os, "O_BINARY", 0)
|
|
100
|
+
return flags
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _validate_descriptor(
|
|
104
|
+
descriptor: int,
|
|
105
|
+
*,
|
|
106
|
+
lock_path: Path,
|
|
107
|
+
parent_descriptor: int | None = None,
|
|
108
|
+
parent_stat: os.stat_result | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
opened_stat = os.fstat(descriptor)
|
|
111
|
+
if not stat.S_ISREG(opened_stat.st_mode) or opened_stat.st_nlink != 1:
|
|
112
|
+
raise OSError("Provenance lock must be a regular file with a single link")
|
|
113
|
+
|
|
114
|
+
if parent_descriptor is None:
|
|
115
|
+
named_stat = os.stat(lock_path, follow_symlinks=False)
|
|
116
|
+
else:
|
|
117
|
+
named_stat = os.stat(lock_path.name, dir_fd=parent_descriptor, follow_symlinks=False)
|
|
118
|
+
current_parent = os.stat(lock_path.parent, follow_symlinks=False)
|
|
119
|
+
if parent_stat is None or not _same_inode(parent_stat, current_parent):
|
|
120
|
+
raise OSError("Provenance lock parent changed while the lock was acquired")
|
|
121
|
+
if not stat.S_ISREG(named_stat.st_mode) or named_stat.st_nlink != 1:
|
|
122
|
+
raise OSError("Provenance lock must be a regular file with a single link")
|
|
123
|
+
if not _same_inode(opened_stat, named_stat):
|
|
124
|
+
raise OSError("Provenance lock target changed while the lock was acquired")
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _try_lock(descriptor: int) -> bool:
|
|
128
|
+
if os.name == "posix":
|
|
129
|
+
try:
|
|
130
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
131
|
+
except OSError as exc:
|
|
132
|
+
if exc.errno in {errno.EACCES, errno.EAGAIN}:
|
|
133
|
+
return False
|
|
134
|
+
if exc.errno == errno.ENOSYS:
|
|
135
|
+
raise NotImplementedError("The filesystem does not support provenance file locks") from exc
|
|
136
|
+
raise
|
|
137
|
+
return True
|
|
138
|
+
try: # pragma: win32 cover
|
|
139
|
+
msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
|
|
140
|
+
except OSError as exc: # pragma: win32 cover
|
|
141
|
+
if exc.errno in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
|
|
142
|
+
return False
|
|
143
|
+
raise
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _unlock(descriptor: int) -> None:
|
|
148
|
+
if os.name == "posix":
|
|
149
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
150
|
+
else: # pragma: win32 cover
|
|
151
|
+
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@contextmanager
|
|
155
|
+
def provenance_lock_scope(
|
|
156
|
+
lock: Any,
|
|
157
|
+
*,
|
|
158
|
+
acquire_error: Exception,
|
|
159
|
+
release_error: Exception,
|
|
160
|
+
release_note: str,
|
|
161
|
+
) -> Iterator[None]:
|
|
162
|
+
"""Hold a provenance lock without masking a protected operation failure."""
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
lock.acquire()
|
|
166
|
+
except (Timeout, OSError, NotImplementedError) as exc:
|
|
167
|
+
raise acquire_error from exc
|
|
168
|
+
body_error: BaseException | None = None
|
|
169
|
+
try:
|
|
170
|
+
yield
|
|
171
|
+
except BaseException as exc:
|
|
172
|
+
body_error = exc
|
|
173
|
+
raise
|
|
174
|
+
finally:
|
|
175
|
+
try:
|
|
176
|
+
lock.release()
|
|
177
|
+
except (Timeout, OSError, NotImplementedError) as exc:
|
|
178
|
+
if body_error is not None:
|
|
179
|
+
body_error.add_note(f"{release_note} ({type(exc).__name__}).")
|
|
180
|
+
else:
|
|
181
|
+
raise release_error from exc
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _same_inode(left: os.stat_result, right: os.stat_result) -> bool:
|
|
185
|
+
return (left.st_dev, left.st_ino) == (right.st_dev, right.st_ino)
|