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,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)