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.
Files changed (293) hide show
  1. reader_workbench/__init__.py +22 -0
  2. reader_workbench/__main__.py +4 -0
  3. reader_workbench/_version.py +17 -0
  4. reader_workbench/api/__init__.py +74 -0
  5. reader_workbench/api/_record_reads.py +75 -0
  6. reader_workbench/api/artifacts.py +79 -0
  7. reader_workbench/api/facade.py +538 -0
  8. reader_workbench/api/models.py +285 -0
  9. reader_workbench/api/notebooks.py +63 -0
  10. reader_workbench/contracts/__init__.py +18 -0
  11. reader_workbench/contracts/builtins/__init__.py +36 -0
  12. reader_workbench/contracts/builtins/cytometry.py +140 -0
  13. reader_workbench/contracts/builtins/four_state_event_window.py +214 -0
  14. reader_workbench/contracts/builtins/generic.py +21 -0
  15. reader_workbench/contracts/builtins/logic.py +149 -0
  16. reader_workbench/contracts/builtins/plate_reader.py +47 -0
  17. reader_workbench/contracts/catalog.py +257 -0
  18. reader_workbench/contracts/model.py +109 -0
  19. reader_workbench/domains/__init__.py +1 -0
  20. reader_workbench/domains/cytometry/__init__.py +3 -0
  21. reader_workbench/domains/cytometry/analysis/__init__.py +29 -0
  22. reader_workbench/domains/cytometry/analysis/events.py +182 -0
  23. reader_workbench/domains/cytometry/analysis/gating.py +175 -0
  24. reader_workbench/domains/cytometry/analysis/workflow.py +248 -0
  25. reader_workbench/domains/cytometry/io/__init__.py +3 -0
  26. reader_workbench/domains/cytometry/io/fcs.py +135 -0
  27. reader_workbench/domains/cytometry/plots/__init__.py +5 -0
  28. reader_workbench/domains/cytometry/plots/diagnostic.py +155 -0
  29. reader_workbench/domains/logic/__init__.py +3 -0
  30. reader_workbench/domains/logic/crosstalk/__init__.py +3 -0
  31. reader_workbench/domains/logic/crosstalk/pairs.py +661 -0
  32. reader_workbench/domains/logic/four_state_vector/__init__.py +7 -0
  33. reader_workbench/domains/logic/four_state_vector/builder.py +321 -0
  34. reader_workbench/domains/logic/four_state_vector/collection/__init__.py +20 -0
  35. reader_workbench/domains/logic/four_state_vector/collection/checks.py +49 -0
  36. reader_workbench/domains/logic/four_state_vector/collection/constants.py +29 -0
  37. reader_workbench/domains/logic/four_state_vector/collection/model.py +19 -0
  38. reader_workbench/domains/logic/four_state_vector/collection/render.py +383 -0
  39. reader_workbench/domains/logic/four_state_vector/collection/sources.py +185 -0
  40. reader_workbench/domains/logic/four_state_vector/config.py +214 -0
  41. reader_workbench/domains/logic/four_state_vector/diagnostic.py +361 -0
  42. reader_workbench/domains/logic/four_state_vector/heatmap.py +86 -0
  43. reader_workbench/domains/logic/four_state_vector/math.py +191 -0
  44. reader_workbench/domains/logic/four_state_vector/reference.py +85 -0
  45. reader_workbench/domains/logic/four_state_vector/selection.py +228 -0
  46. reader_workbench/domains/logic/four_state_vector/treatment_semantics.py +51 -0
  47. reader_workbench/domains/logic/four_state_vector/validation.py +19 -0
  48. reader_workbench/domains/logic/logic_symmetry/__init__.py +3 -0
  49. reader_workbench/domains/logic/logic_symmetry/encodings.py +93 -0
  50. reader_workbench/domains/logic/logic_symmetry/extract_corners.py +192 -0
  51. reader_workbench/domains/logic/logic_symmetry/main.py +236 -0
  52. reader_workbench/domains/logic/logic_symmetry/metrics.py +96 -0
  53. reader_workbench/domains/logic/logic_symmetry/overlay.py +129 -0
  54. reader_workbench/domains/logic/logic_symmetry/prep.py +138 -0
  55. reader_workbench/domains/logic/logic_symmetry/render.py +356 -0
  56. reader_workbench/domains/logic/treatment_columns.py +42 -0
  57. reader_workbench/domains/plate_reader/__init__.py +1 -0
  58. reader_workbench/domains/plate_reader/analysis/__init__.py +14 -0
  59. reader_workbench/domains/plate_reader/analysis/fold_change.py +474 -0
  60. reader_workbench/domains/plate_reader/analysis/four_state_event_window/__init__.py +21 -0
  61. reader_workbench/domains/plate_reader/analysis/four_state_event_window/aggregation.py +191 -0
  62. reader_workbench/domains/plate_reader/analysis/four_state_event_window/contract_fields.py +50 -0
  63. reader_workbench/domains/plate_reader/analysis/four_state_event_window/contracts.py +320 -0
  64. reader_workbench/domains/plate_reader/analysis/four_state_event_window/design_dispositions.py +54 -0
  65. reader_workbench/domains/plate_reader/analysis/four_state_event_window/disposition_records.py +140 -0
  66. reader_workbench/domains/plate_reader/analysis/four_state_event_window/event_sensitivity.py +27 -0
  67. reader_workbench/domains/plate_reader/analysis/four_state_event_window/materialize.py +274 -0
  68. reader_workbench/domains/plate_reader/analysis/four_state_event_window/observation_resampling.py +97 -0
  69. reader_workbench/domains/plate_reader/analysis/four_state_event_window/reduction.py +62 -0
  70. reader_workbench/domains/plate_reader/analysis/four_state_event_window/seeds.py +15 -0
  71. reader_workbench/domains/plate_reader/analysis/four_state_event_window/sources.py +308 -0
  72. reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusion_validation.py +94 -0
  73. reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusions.py +54 -0
  74. reader_workbench/domains/plate_reader/analysis/timepoints.py +76 -0
  75. reader_workbench/domains/plate_reader/io/__init__.py +6 -0
  76. reader_workbench/domains/plate_reader/io/sample_map.py +65 -0
  77. reader_workbench/domains/plate_reader/io/synergy_h1/__init__.py +6 -0
  78. reader_workbench/domains/plate_reader/io/synergy_h1/_kinetic.py +141 -0
  79. reader_workbench/domains/plate_reader/io/synergy_h1/_parser.py +295 -0
  80. reader_workbench/domains/plate_reader/io/synergy_h1/_shared.py +195 -0
  81. reader_workbench/domains/plate_reader/io/synergy_h1/_snapshot.py +130 -0
  82. reader_workbench/domains/plate_reader/ordering.py +59 -0
  83. reader_workbench/domains/plate_reader/plots/__init__.py +15 -0
  84. reader_workbench/domains/plate_reader/plots/_data.py +29 -0
  85. reader_workbench/domains/plate_reader/plots/common.py +346 -0
  86. reader_workbench/domains/plate_reader/plots/distributions.py +324 -0
  87. reader_workbench/domains/plate_reader/plots/dual_reporter_triptych.py +525 -0
  88. reader_workbench/domains/plate_reader/plots/dual_reporter_triptych_render.py +195 -0
  89. reader_workbench/domains/plate_reader/plots/four_state_event_window/__init__.py +26 -0
  90. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic.py +295 -0
  91. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_components.py +149 -0
  92. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_render.py +308 -0
  93. reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_style.py +51 -0
  94. reader_workbench/domains/plate_reader/plots/four_state_event_window/schema.py +8 -0
  95. reader_workbench/domains/plate_reader/plots/four_state_event_window/summary.py +140 -0
  96. reader_workbench/domains/plate_reader/plots/grouping.py +53 -0
  97. reader_workbench/domains/plate_reader/plots/panels/__init__.py +12 -0
  98. reader_workbench/domains/plate_reader/plots/panels/snapshot.py +161 -0
  99. reader_workbench/domains/plate_reader/plots/panels/snapshot_data.py +91 -0
  100. reader_workbench/domains/plate_reader/plots/panels/time_series.py +296 -0
  101. reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic.py +435 -0
  102. reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic_render.py +300 -0
  103. reader_workbench/domains/plate_reader/plots/snapshot_barplot/__init__.py +315 -0
  104. reader_workbench/domains/plate_reader/plots/snapshot_barplot/planning.py +168 -0
  105. reader_workbench/domains/plate_reader/plots/snapshot_heatmap/__init__.py +205 -0
  106. reader_workbench/domains/plate_reader/plots/snapshot_heatmap/inputs.py +103 -0
  107. reader_workbench/domains/plate_reader/plots/time_series.py +317 -0
  108. reader_workbench/domains/plate_reader/plots/ts_and_snap/__init__.py +479 -0
  109. reader_workbench/domains/plate_reader/plots/ts_and_snap/planning.py +283 -0
  110. reader_workbench/domains/time_series/__init__.py +29 -0
  111. reader_workbench/domains/time_series/aggregation.py +60 -0
  112. reader_workbench/domains/time_series/contracts.py +368 -0
  113. reader_workbench/domains/time_series/reduction.py +395 -0
  114. reader_workbench/errors.py +55 -0
  115. reader_workbench/maintenance/__init__.py +6 -0
  116. reader_workbench/maintenance/docs.py +335 -0
  117. reader_workbench/maintenance/model.py +28 -0
  118. reader_workbench/maintenance/release.py +39 -0
  119. reader_workbench/maintenance/skills.py +124 -0
  120. reader_workbench/plotting/__init__.py +20 -0
  121. reader_workbench/plotting/mpl.py +56 -0
  122. reader_workbench/plotting/sinks.py +69 -0
  123. reader_workbench/plotting/style.py +175 -0
  124. reader_workbench/plotting/utils.py +27 -0
  125. reader_workbench/plugins/__init__.py +1 -0
  126. reader_workbench/plugins/catalog.py +33 -0
  127. reader_workbench/plugins/export/__init__.py +0 -0
  128. reader_workbench/plugins/export/_paths.py +21 -0
  129. reader_workbench/plugins/export/csv.py +41 -0
  130. reader_workbench/plugins/export/xlsx.py +44 -0
  131. reader_workbench/plugins/ingest/__init__.py +0 -0
  132. reader_workbench/plugins/ingest/_discovery.py +58 -0
  133. reader_workbench/plugins/ingest/discovery_policy.py +66 -0
  134. reader_workbench/plugins/ingest/flow_cytometer.py +139 -0
  135. reader_workbench/plugins/ingest/synergy_h1.py +234 -0
  136. reader_workbench/plugins/manifests/__init__.py +1 -0
  137. reader_workbench/plugins/manifests/export.py +29 -0
  138. reader_workbench/plugins/manifests/ingest.py +29 -0
  139. reader_workbench/plugins/manifests/plot.py +161 -0
  140. reader_workbench/plugins/manifests/transform.py +172 -0
  141. reader_workbench/plugins/manifests/validator.py +18 -0
  142. reader_workbench/plugins/plot/__init__.py +0 -0
  143. reader_workbench/plugins/plot/_shared.py +55 -0
  144. reader_workbench/plugins/plot/cytometry_diagnostic.py +54 -0
  145. reader_workbench/plugins/plot/distributions.py +58 -0
  146. reader_workbench/plugins/plot/dual_reporter_triptych.py +224 -0
  147. reader_workbench/plugins/plot/four_state_event_window_diagnostic.py +133 -0
  148. reader_workbench/plugins/plot/four_state_event_window_summary.py +53 -0
  149. reader_workbench/plugins/plot/four_state_vector_collection.py +45 -0
  150. reader_workbench/plugins/plot/four_state_vector_diagnostic.py +105 -0
  151. reader_workbench/plugins/plot/four_state_vector_heatmap.py +63 -0
  152. reader_workbench/plugins/plot/logic_symmetry.py +56 -0
  153. reader_workbench/plugins/plot/single_reporter_diagnostic.py +279 -0
  154. reader_workbench/plugins/plot/snapshot_barplot.py +65 -0
  155. reader_workbench/plugins/plot/snapshot_heatmap.py +104 -0
  156. reader_workbench/plugins/plot/time_series.py +114 -0
  157. reader_workbench/plugins/plot/ts_and_snap.py +210 -0
  158. reader_workbench/plugins/transform/__init__.py +0 -0
  159. reader_workbench/plugins/transform/_four_state_vector.py +204 -0
  160. reader_workbench/plugins/transform/_labeling.py +109 -0
  161. reader_workbench/plugins/transform/alias.py +70 -0
  162. reader_workbench/plugins/transform/assay_labels.py +62 -0
  163. reader_workbench/plugins/transform/blank.py +79 -0
  164. reader_workbench/plugins/transform/crosstalk_pairs.py +180 -0
  165. reader_workbench/plugins/transform/cytometry_gating.py +120 -0
  166. reader_workbench/plugins/transform/fold_change.py +79 -0
  167. reader_workbench/plugins/transform/four_state_event_window.py +93 -0
  168. reader_workbench/plugins/transform/four_state_vector.py +62 -0
  169. reader_workbench/plugins/transform/four_state_vector_collection.py +41 -0
  170. reader_workbench/plugins/transform/logic_symmetry.py +67 -0
  171. reader_workbench/plugins/transform/outlier_filter.py +60 -0
  172. reader_workbench/plugins/transform/overflow.py +197 -0
  173. reader_workbench/plugins/transform/ratio.py +237 -0
  174. reader_workbench/plugins/transform/sample_map.py +170 -0
  175. reader_workbench/plugins/transform/sample_metadata.py +94 -0
  176. reader_workbench/plugins/validator/__init__.py +1 -0
  177. reader_workbench/plugins/validator/to_tidy_plus_map.py +155 -0
  178. reader_workbench/protocols/__init__.py +80 -0
  179. reader_workbench/protocols/_builtins_plate_reader_growth.py +179 -0
  180. reader_workbench/protocols/_builtins_plate_reader_variants.py +274 -0
  181. reader_workbench/protocols/builtins.py +1656 -0
  182. reader_workbench/protocols/compiler.py +22 -0
  183. reader_workbench/protocols/compilers/__init__.py +1 -0
  184. reader_workbench/protocols/compilers/common.py +100 -0
  185. reader_workbench/protocols/compilers/cytometry.py +87 -0
  186. reader_workbench/protocols/compilers/generic.py +14 -0
  187. reader_workbench/protocols/compilers/logic.py +245 -0
  188. reader_workbench/protocols/compilers/plate_reader.py +937 -0
  189. reader_workbench/protocols/compilers/plate_reader_pipeline.py +197 -0
  190. reader_workbench/protocols/model.py +1486 -0
  191. reader_workbench/protocols/semantic_coverage.py +234 -0
  192. reader_workbench/runtime/__init__.py +12 -0
  193. reader_workbench/runtime/builtin.py +23 -0
  194. reader_workbench/runtime/model.py +42 -0
  195. reader_workbench/workbench/__init__.py +60 -0
  196. reader_workbench/workbench/assets/__init__.py +22 -0
  197. reader_workbench/workbench/assets/types.py +118 -0
  198. reader_workbench/workbench/audit/__init__.py +5 -0
  199. reader_workbench/workbench/audit/experiments.py +307 -0
  200. reader_workbench/workbench/audit/staging.py +187 -0
  201. reader_workbench/workbench/cli/__init__.py +51 -0
  202. reader_workbench/workbench/cli/_lazy.py +9 -0
  203. reader_workbench/workbench/cli/_records_view.py +150 -0
  204. reader_workbench/workbench/cli/_surface_execution.py +443 -0
  205. reader_workbench/workbench/cli/audit.py +95 -0
  206. reader_workbench/workbench/cli/automation.py +229 -0
  207. reader_workbench/workbench/cli/demo.py +46 -0
  208. reader_workbench/workbench/cli/dop.py +91 -0
  209. reader_workbench/workbench/cli/experiments.py +635 -0
  210. reader_workbench/workbench/cli/helpers.py +232 -0
  211. reader_workbench/workbench/cli/main.py +59 -0
  212. reader_workbench/workbench/cli/maintenance.py +82 -0
  213. reader_workbench/workbench/cli/notebooks.py +260 -0
  214. reader_workbench/workbench/cli/pagination.py +117 -0
  215. reader_workbench/workbench/cli/protocols.py +336 -0
  216. reader_workbench/workbench/cli/shared.py +309 -0
  217. reader_workbench/workbench/cli/surfaces.py +534 -0
  218. reader_workbench/workbench/cli/verification.py +128 -0
  219. reader_workbench/workbench/commands.py +10 -0
  220. reader_workbench/workbench/config/__init__.py +47 -0
  221. reader_workbench/workbench/config/identity.py +13 -0
  222. reader_workbench/workbench/config/load.py +405 -0
  223. reader_workbench/workbench/config/model.py +274 -0
  224. reader_workbench/workbench/context.py +26 -0
  225. reader_workbench/workbench/decl/__init__.py +31 -0
  226. reader_workbench/workbench/decl/build.py +190 -0
  227. reader_workbench/workbench/decl/model.py +81 -0
  228. reader_workbench/workbench/dop/__init__.py +12 -0
  229. reader_workbench/workbench/dop/builtins.py +261 -0
  230. reader_workbench/workbench/dop/model.py +209 -0
  231. reader_workbench/workbench/engine/__init__.py +42 -0
  232. reader_workbench/workbench/engine/_shared.py +76 -0
  233. reader_workbench/workbench/engine/contracts.py +283 -0
  234. reader_workbench/workbench/engine/execution.py +326 -0
  235. reader_workbench/workbench/engine/file_outputs.py +260 -0
  236. reader_workbench/workbench/engine/inputs.py +161 -0
  237. reader_workbench/workbench/engine/invocations.py +507 -0
  238. reader_workbench/workbench/engine/planning.py +72 -0
  239. reader_workbench/workbench/engine/runtime.py +464 -0
  240. reader_workbench/workbench/engine/setup.py +149 -0
  241. reader_workbench/workbench/engine/validation.py +684 -0
  242. reader_workbench/workbench/experiment/__init__.py +47 -0
  243. reader_workbench/workbench/experiment/model.py +381 -0
  244. reader_workbench/workbench/experiments.py +133 -0
  245. reader_workbench/workbench/graph/__init__.py +47 -0
  246. reader_workbench/workbench/graph/nodes.py +102 -0
  247. reader_workbench/workbench/graph/normalize.py +177 -0
  248. reader_workbench/workbench/graph/refs.py +148 -0
  249. reader_workbench/workbench/input_discovery.py +19 -0
  250. reader_workbench/workbench/inspection/__init__.py +3 -0
  251. reader_workbench/workbench/inspection/catalogs.py +128 -0
  252. reader_workbench/workbench/inspection/common.py +92 -0
  253. reader_workbench/workbench/inspection/dop.py +64 -0
  254. reader_workbench/workbench/inspection/experiments.py +449 -0
  255. reader_workbench/workbench/inspection/inventory.py +68 -0
  256. reader_workbench/workbench/inspection/protocols.py +368 -0
  257. reader_workbench/workbench/inspection/readiness.py +333 -0
  258. reader_workbench/workbench/inspection/reports.py +367 -0
  259. reader_workbench/workbench/inspection/results.py +166 -0
  260. reader_workbench/workbench/inspection/runtime.py +287 -0
  261. reader_workbench/workbench/inspection/semantics.py +192 -0
  262. reader_workbench/workbench/inspection/validation.py +30 -0
  263. reader_workbench/workbench/notebooks/__init__.py +17 -0
  264. reader_workbench/workbench/notebooks/_launch_registry.py +112 -0
  265. reader_workbench/workbench/notebooks/_launch_runtime.py +104 -0
  266. reader_workbench/workbench/notebooks/components/__init__.py +21 -0
  267. reader_workbench/workbench/notebooks/components/deliverables.py +403 -0
  268. reader_workbench/workbench/notebooks/components/overview.py +119 -0
  269. reader_workbench/workbench/notebooks/eda.marimo.py.txt +153 -0
  270. reader_workbench/workbench/notebooks/launch.py +274 -0
  271. reader_workbench/workbench/notebooks/presentation.py +136 -0
  272. reader_workbench/workbench/notebooks/scaffold.py +60 -0
  273. reader_workbench/workbench/ontology.py +78 -0
  274. reader_workbench/workbench/paths.py +44 -0
  275. reader_workbench/workbench/ports/__init__.py +31 -0
  276. reader_workbench/workbench/ports/model.py +168 -0
  277. reader_workbench/workbench/records/__init__.py +44 -0
  278. reader_workbench/workbench/records/epoch.py +329 -0
  279. reader_workbench/workbench/records/evidence.py +247 -0
  280. reader_workbench/workbench/records/identity.py +87 -0
  281. reader_workbench/workbench/records/locking.py +185 -0
  282. reader_workbench/workbench/records/model.py +711 -0
  283. reader_workbench/workbench/records/sources.py +73 -0
  284. reader_workbench/workbench/records/store.py +1022 -0
  285. reader_workbench/workbench/records/verification.py +998 -0
  286. reader_workbench/workbench/registry.py +333 -0
  287. reader_workbench/workbench/spec_overrides.py +215 -0
  288. reader_workbench-1.0.0.dist-info/METADATA +91 -0
  289. reader_workbench-1.0.0.dist-info/RECORD +293 -0
  290. reader_workbench-1.0.0.dist-info/WHEEL +5 -0
  291. reader_workbench-1.0.0.dist-info/entry_points.txt +2 -0
  292. reader_workbench-1.0.0.dist-info/licenses/LICENSE +21 -0
  293. 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)