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,260 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ from contextlib import suppress
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+ from tempfile import mkdtemp
9
+ from typing import Any
10
+
11
+ from reader_workbench.errors import ExecutionError
12
+ from reader_workbench.workbench.context import RunContext
13
+ from reader_workbench.workbench.records import PathDescription
14
+
15
+ from .contracts import collect_file_output_paths
16
+
17
+ _SAFE_PREFIX = re.compile(r"[^A-Za-z0-9_.-]+")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class _Promotion:
22
+ final_path: Path
23
+ backup_path: Path
24
+ previous_exists: bool
25
+
26
+
27
+ class FileOutputTransaction:
28
+ """Stage one plot/export step and restore prior files unless it commits."""
29
+
30
+ def __init__(self, *, context: RunContext, step_id: str, phase: str) -> None:
31
+ self._final_context = context
32
+ staging_parent = _confined_staging_parent(context.outputs_dir)
33
+ prefix = _SAFE_PREFIX.sub("_", step_id).strip("._") or "file-output"
34
+ self._phase = phase
35
+ self._revision_name = prefix
36
+ self._staging_root = Path(mkdtemp(prefix=f"{prefix}__", dir=staging_parent))
37
+ self.context = replace(
38
+ context,
39
+ outputs_dir=self._staging_root,
40
+ artifacts_dir=self._mirror_path(context.artifacts_dir),
41
+ plots_dir=self._mirror_path(context.plots_dir),
42
+ exports_dir=self._mirror_path(context.exports_dir),
43
+ records_path=self._mirror_path(context.records_path),
44
+ )
45
+ self.context.artifacts_dir.mkdir(parents=True, exist_ok=True)
46
+ self.context.plots_dir.mkdir(parents=True, exist_ok=True)
47
+ self.context.exports_dir.mkdir(parents=True, exist_ok=True)
48
+ self.context.records_path.parent.mkdir(parents=True, exist_ok=True)
49
+ self._promotions: list[_Promotion] = []
50
+ self._revision_dir: Path | None = None
51
+ self._committed = False
52
+
53
+ def __enter__(self) -> FileOutputTransaction:
54
+ return self
55
+
56
+ def __exit__(self, exc_type, exc, traceback) -> bool:
57
+ try:
58
+ if not self._committed:
59
+ self._rollback()
60
+ finally:
61
+ shutil.rmtree(self._staging_root, ignore_errors=True)
62
+ with suppress(OSError):
63
+ self._staging_root.parent.rmdir()
64
+ return False
65
+
66
+ def promote(
67
+ self,
68
+ *,
69
+ outputs: dict[str, Any],
70
+ output_ports: dict[str, Any],
71
+ where: str,
72
+ ) -> dict[str, Any]:
73
+ staged_paths = collect_file_output_paths(output_ports=output_ports, outputs=outputs, where=where)
74
+ if not staged_paths:
75
+ raise ExecutionError(f"{where}: must emit at least one explicit file output")
76
+ path_map, promotion_paths = self._path_maps(staged_paths, where=where)
77
+ record_path_map = self._publish_record_revision(
78
+ path_map=path_map,
79
+ promotion_paths=promotion_paths,
80
+ where=where,
81
+ )
82
+ rollback_root = self._staging_root / ".rollback"
83
+ final_root = self._final_context.outputs_dir.resolve(strict=True)
84
+ for final_path in promotion_paths.values():
85
+ _assert_confined_destination(final_path, final_root=final_root, where=where)
86
+
87
+ for staged_path, final_path in promotion_paths.items():
88
+ _assert_confined_destination(final_path, final_root=final_root, where=where)
89
+ relative_path = final_path.relative_to(final_root)
90
+ backup_path = rollback_root / relative_path
91
+ previous_exists = final_path.exists()
92
+ promotion = _Promotion(
93
+ final_path=final_path,
94
+ backup_path=backup_path,
95
+ previous_exists=previous_exists,
96
+ )
97
+
98
+ final_path.parent.mkdir(parents=True, exist_ok=True)
99
+ _assert_confined_destination(final_path, final_root=final_root, where=where)
100
+ if previous_exists:
101
+ if not final_path.is_file() or final_path.is_symlink():
102
+ raise ExecutionError(f"{where}: output target must be a regular file: {final_path}")
103
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
104
+ _assert_confined_destination(final_path, final_root=final_root, where=where)
105
+ self._promotions.append(promotion)
106
+ final_path.replace(backup_path)
107
+ else:
108
+ self._promotions.append(promotion)
109
+ _assert_confined_destination(final_path, final_root=final_root, where=where)
110
+ staged_path.replace(final_path)
111
+
112
+ return {
113
+ name: _remap_output_value(value, path_map=record_path_map)
114
+ if output_ports[name].kind in {"file_path", "file_bundle"}
115
+ else value
116
+ for name, value in outputs.items()
117
+ }
118
+
119
+ def commit(self) -> None:
120
+ self._committed = True
121
+
122
+ def _mirror_path(self, path: Path) -> Path:
123
+ try:
124
+ relative_path = path.relative_to(self._final_context.outputs_dir)
125
+ except ValueError as exc:
126
+ raise ExecutionError(
127
+ f"Runtime output path must stay within {self._final_context.outputs_dir}: {path}"
128
+ ) from exc
129
+ return self._staging_root / relative_path
130
+
131
+ def _path_maps(self, paths: list[Path], *, where: str) -> tuple[dict[Path, Path], dict[Path, Path]]:
132
+ staging_root = self._staging_root.resolve(strict=True)
133
+ final_root = self._final_context.outputs_dir.resolve(strict=True)
134
+ path_map: dict[Path, Path] = {}
135
+ promotion_paths: dict[Path, Path] = {}
136
+ final_paths: set[Path] = set()
137
+ for raw_path in paths:
138
+ staged_path = raw_path if raw_path.is_absolute() else staging_root / raw_path
139
+ if staged_path.is_symlink():
140
+ raise ExecutionError(f"{where}: staged output must be a regular file: {staged_path}")
141
+ try:
142
+ resolved = staged_path.resolve(strict=True)
143
+ relative_path = resolved.relative_to(staging_root)
144
+ except (OSError, ValueError) as exc:
145
+ raise ExecutionError(f"{where}: file outputs must stay within the staged outputs directory") from exc
146
+ if not resolved.is_file():
147
+ raise ExecutionError(f"{where}: staged output must be a regular file: {staged_path}")
148
+ final_path = final_root / relative_path
149
+ if final_path in final_paths:
150
+ raise ExecutionError(f"{where}: duplicate file output target: {final_path}")
151
+ path_map[Path(raw_path)] = final_path
152
+ promotion_paths[resolved] = final_path
153
+ final_paths.add(final_path)
154
+ return path_map, promotion_paths
155
+
156
+ def _publish_record_revision(
157
+ self,
158
+ *,
159
+ path_map: dict[Path, Path],
160
+ promotion_paths: dict[Path, Path],
161
+ where: str,
162
+ ) -> dict[Path, Path]:
163
+ """Publish one immutable copy of the declared bundle for record history."""
164
+
165
+ final_root = self._final_context.outputs_dir.resolve(strict=True)
166
+ revision_parent = final_root / "artifacts" / "file_bundles" / self._phase
167
+ _assert_confined_destination(revision_parent, final_root=final_root, where=where)
168
+ revision_parent.mkdir(parents=True, exist_ok=True)
169
+ _assert_confined_destination(revision_parent, final_root=final_root, where=where)
170
+ revision_dir = _next_revision_dir(revision_parent / self._revision_name)
171
+
172
+ staged_revision = self._staging_root / ".record-revision"
173
+ staged_revision.mkdir()
174
+ snapshot_by_final: dict[Path, Path] = {}
175
+ for staged_path, final_path in promotion_paths.items():
176
+ relative_path = final_path.relative_to(final_root)
177
+ snapshot = staged_revision / relative_path
178
+ snapshot.parent.mkdir(parents=True, exist_ok=True)
179
+ shutil.copy2(staged_path, snapshot)
180
+ snapshot_by_final[final_path] = revision_dir / relative_path
181
+ self._revision_dir = revision_dir
182
+ staged_revision.replace(revision_dir)
183
+ return {raw_path: snapshot_by_final[final_path] for raw_path, final_path in path_map.items()}
184
+
185
+ def _rollback(self) -> None:
186
+ for promotion in reversed(self._promotions):
187
+ if promotion.previous_exists:
188
+ if not promotion.backup_path.exists():
189
+ continue
190
+ promotion.final_path.unlink(missing_ok=True)
191
+ promotion.final_path.parent.mkdir(parents=True, exist_ok=True)
192
+ promotion.backup_path.replace(promotion.final_path)
193
+ else:
194
+ promotion.final_path.unlink(missing_ok=True)
195
+ self._promotions.clear()
196
+ if self._revision_dir is not None:
197
+ revision_parent = self._revision_dir.parent
198
+ shutil.rmtree(self._revision_dir, ignore_errors=True)
199
+ self._revision_dir = None
200
+ with suppress(OSError):
201
+ revision_parent.rmdir()
202
+ with suppress(OSError):
203
+ revision_parent.parent.rmdir()
204
+
205
+
206
+ def _next_revision_dir(base: Path) -> Path:
207
+ revision = 1
208
+ while True:
209
+ candidate = base if revision == 1 else base.with_name(f"{base.name}__r{revision}")
210
+ if not candidate.exists() and not candidate.is_symlink():
211
+ return candidate
212
+ revision += 1
213
+
214
+
215
+ def _confined_staging_parent(outputs_dir: Path) -> Path:
216
+ staging_parent = outputs_dir / ".staging"
217
+ message = "File-output staging directory must stay within the experiment outputs directory"
218
+ if staging_parent.is_symlink():
219
+ raise ExecutionError(message)
220
+ try:
221
+ outputs_root = outputs_dir.resolve(strict=True)
222
+ staging_parent.mkdir(parents=True, exist_ok=True)
223
+ resolved = staging_parent.resolve(strict=True)
224
+ resolved.relative_to(outputs_root)
225
+ except (OSError, ValueError) as exc:
226
+ raise ExecutionError(message) from exc
227
+ if staging_parent.is_symlink() or not resolved.is_dir():
228
+ raise ExecutionError(message)
229
+ return resolved
230
+
231
+
232
+ def _assert_confined_destination(final_path: Path, *, final_root: Path, where: str) -> None:
233
+ message = f"{where}: destination path must not contain symlinks or leave the outputs directory"
234
+ try:
235
+ relative_path = final_path.relative_to(final_root)
236
+ current = final_root
237
+ for part in relative_path.parts:
238
+ current /= part
239
+ if current.is_symlink():
240
+ raise ExecutionError(message)
241
+ if current.exists():
242
+ current.resolve(strict=True).relative_to(final_root)
243
+ except ExecutionError:
244
+ raise
245
+ except (OSError, ValueError) as exc:
246
+ raise ExecutionError(message) from exc
247
+
248
+
249
+ def _remap_output_value(value: Any, *, path_map: dict[Path, Path]) -> Any:
250
+ if isinstance(value, PathDescription):
251
+ return PathDescription(path=path_map[value.path], description=value.description)
252
+ if isinstance(value, str):
253
+ return str(path_map[Path(value)])
254
+ if isinstance(value, Path):
255
+ return path_map[value]
256
+ if isinstance(value, tuple):
257
+ return tuple(_remap_output_value(item, path_map=path_map) for item in value)
258
+ if isinstance(value, list):
259
+ return [_remap_output_value(item, path_map=path_map) for item in value]
260
+ raise ExecutionError(f"File output values must be path-like, got {type(value).__name__}")
@@ -0,0 +1,161 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from reader_workbench.errors import ExecutionError
7
+ from reader_workbench.workbench.graph import FileRef, InputRef, RecordCollectionRef, RecordRef, ResourceRef
8
+ from reader_workbench.workbench.paths import resolve_path_within_root
9
+ from reader_workbench.workbench.ports import InputPortSpec
10
+ from reader_workbench.workbench.records import RecordStore, SourceRecordCollection, resolve_source_record
11
+ from reader_workbench.workbench.registry import Plugin, PluginConfig
12
+
13
+
14
+ def _metadata_like_files(inputs_dir: Path) -> list[Path]:
15
+ patterns = [
16
+ "metadata.*",
17
+ "metadata_filtered.*",
18
+ "sample_map.*",
19
+ "sample_metadata.*",
20
+ "plate_map.*",
21
+ ]
22
+ matches: list[Path] = []
23
+ for pattern in patterns:
24
+ matches.extend(path for path in inputs_dir.glob(pattern) if path.is_file())
25
+ return sorted(matches)
26
+
27
+
28
+ def _missing_metadata_hint(label: str, path: Path, exp_dir: Path | None) -> str | None:
29
+ if exp_dir is None:
30
+ return None
31
+ inputs_dir = exp_dir / "inputs"
32
+ if not inputs_dir.exists():
33
+ return None
34
+
35
+ metadata_labels = {"sample_map", "plate_map", "metadata", "sample_metadata"}
36
+ name = path.name.lower()
37
+ is_metadataish = label in metadata_labels or name.startswith(("metadata", "sample_map", "plate_map"))
38
+ if not is_metadataish:
39
+ return None
40
+
41
+ canonical = inputs_dir / path.name
42
+ hint = f"Canonical location is {canonical} (update config: reads.{label}: {{file: ./inputs/{path.name}}})."
43
+
44
+ candidates = _metadata_like_files(inputs_dir)
45
+ if candidates:
46
+ rels = [str(candidate.relative_to(exp_dir)) for candidate in candidates]
47
+ preview = ", ".join(rels[:3])
48
+ tail = "" if len(rels) <= 3 else f" (+{len(rels) - 3} more)"
49
+ hint += f" Found metadata-like files in inputs/: {preview}{tail}."
50
+ return hint
51
+
52
+
53
+ def _resolve_inputs(
54
+ store: RecordStore,
55
+ reads: dict[str, InputRef],
56
+ *,
57
+ input_ports: dict[str, InputPortSpec],
58
+ exp_dir: Path | None = None,
59
+ ) -> dict[str, Any]:
60
+ inputs: dict[str, Any] = {}
61
+ for label, target in reads.items():
62
+ expected = input_ports.get(label)
63
+ if expected is None:
64
+ raise ExecutionError(f"Unsupported input binding for unknown port '{label}'")
65
+ if isinstance(target, FileRef | ResourceRef):
66
+ if expected.kind not in {"file_path", "file_set"}:
67
+ raise ExecutionError(
68
+ f"Input '{label}' expects port kind {expected.kind!r} and cannot bind to a file/resource ref"
69
+ )
70
+ path = target.path
71
+ if exp_dir is not None:
72
+ try:
73
+ path = resolve_path_within_root(path, root=exp_dir)
74
+ except ValueError as exc:
75
+ raise ExecutionError(
76
+ f"Input file for '{label}' must stay under the experiment root after resolving symlinks"
77
+ ) from exc
78
+ if not path.exists():
79
+ hint = _missing_metadata_hint(label, path, exp_dir)
80
+ if hint:
81
+ raise ExecutionError(f"Input file missing for '{label}': {path}. {hint}")
82
+ raise ExecutionError(f"Input file missing for '{label}': {path}")
83
+ if path.is_dir():
84
+ raise ExecutionError(f"Input file path is a directory for '{label}': {path}")
85
+ inputs[label] = (path,) if expected.kind == "file_set" else path
86
+ continue
87
+ if isinstance(target, RecordCollectionRef):
88
+ if expected.kind != "record_collection":
89
+ raise ExecutionError(
90
+ f"Input '{label}' expects port kind {expected.kind!r} and cannot bind to a record collection"
91
+ )
92
+ inputs[label] = SourceRecordCollection(
93
+ tuple(resolve_source_record(ref, contracts=store.contracts) for ref in target.records)
94
+ )
95
+ continue
96
+ if not isinstance(target, RecordRef):
97
+ raise ExecutionError(f"Unsupported input binding for '{label}': {target!r}")
98
+ if expected.kind == "dataframe":
99
+ inputs[label] = store.read_dataframe(target.record_id)
100
+ continue
101
+ if expected.kind == "file_bundle":
102
+ inputs[label] = store.read_record(target.record_id)
103
+ continue
104
+ raise ExecutionError(
105
+ f"Input '{label}' expects port kind {expected.kind!r} and cannot bind to a record ref {target.record_id!r}"
106
+ )
107
+ return inputs
108
+
109
+
110
+ def resolve_missing_file_inputs(
111
+ *,
112
+ plugin: Plugin,
113
+ exp_dir: Path,
114
+ cfg: PluginConfig,
115
+ inputs: dict[str, Any],
116
+ input_ports: dict[str, InputPortSpec],
117
+ ) -> dict[str, Any]:
118
+ try:
119
+ additions = dict(type(plugin).resolve_missing_file_inputs(exp_dir=exp_dir, cfg=cfg, inputs=inputs))
120
+ except ExecutionError:
121
+ raise
122
+ except Exception as exc:
123
+ raise ExecutionError(f"{plugin.plugin_id}: failed to resolve missing file inputs: {exc}") from exc
124
+ conflicts = sorted(set(additions) & set(inputs))
125
+ if conflicts:
126
+ raise ExecutionError(f"{plugin.plugin_id}: missing-file resolver cannot replace bound inputs: {conflicts}")
127
+ unknown = sorted(set(additions) - set(input_ports))
128
+ if unknown:
129
+ raise ExecutionError(f"{plugin.plugin_id}: missing-file resolver returned unknown inputs: {unknown}")
130
+
131
+ resolved = dict(inputs)
132
+ for label, value in additions.items():
133
+ port = input_ports[label]
134
+ if port.kind not in {"file_path", "file_set"} or not port.optional:
135
+ raise ExecutionError(
136
+ f"{plugin.plugin_id}: missing-file resolver may only fill optional file_path or file_set ports; "
137
+ f"{label!r} is {port.kind!r} (optional={port.optional})"
138
+ )
139
+ values = value if port.kind == "file_set" else (value,)
140
+ if not isinstance(values, tuple) or not values or any(not isinstance(item, Path) for item in values):
141
+ expected = "a non-empty tuple of Paths" if port.kind == "file_set" else "a Path"
142
+ raise ExecutionError(
143
+ f"{plugin.plugin_id}: missing-file resolver input {label!r} must be {expected}, "
144
+ f"got {type(value).__name__}"
145
+ )
146
+ confined_values: list[Path] = []
147
+ for item in values:
148
+ try:
149
+ confined = resolve_path_within_root(item, root=exp_dir)
150
+ except ValueError as exc:
151
+ raise ExecutionError(
152
+ f"{plugin.plugin_id}: resolved input {label!r} must stay under the experiment root "
153
+ "after resolving symlinks"
154
+ ) from exc
155
+ if not confined.exists():
156
+ raise ExecutionError(f"Resolved input file missing for {label!r}: {confined}")
157
+ if not confined.is_file():
158
+ raise ExecutionError(f"Resolved input path is not a file for {label!r}: {confined}")
159
+ confined_values.append(confined)
160
+ resolved[label] = tuple(confined_values) if port.kind == "file_set" else confined_values[0]
161
+ return resolved