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,283 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+
9
+ from reader_workbench.contracts import ContractCatalog
10
+ from reader_workbench.errors import ContractError, ExecutionError
11
+ from reader_workbench.workbench.graph import OutputRef
12
+ from reader_workbench.workbench.ports import InputPortSpec, OutputPortSpec
13
+ from reader_workbench.workbench.records import PathDescription, SourceRecordCollection
14
+ from reader_workbench.workbench.registry import Plugin
15
+
16
+
17
+ def _assert_input_ports(
18
+ plugin: Plugin,
19
+ inputs: dict[str, Any],
20
+ *,
21
+ contracts: ContractCatalog,
22
+ where: str,
23
+ ) -> None:
24
+ declared = plugin.input_ports()
25
+ allowed = set(declared)
26
+ for name, port in declared.items():
27
+ if name not in inputs:
28
+ if port.optional:
29
+ continue
30
+ raise ExecutionError(f"[{where}] input '{name}' is required by plugin but not provided in 'reads'")
31
+ value = inputs[name]
32
+ _assert_input_port_value(
33
+ name=name,
34
+ port=port,
35
+ value=value,
36
+ contracts=contracts,
37
+ where=where,
38
+ )
39
+ extra = sorted(set(inputs) - allowed)
40
+ if extra:
41
+ raise ExecutionError(f"[{where}] unexpected inputs provided: {extra} (allowed: {sorted(allowed)})")
42
+
43
+
44
+ def _assert_output_ports(
45
+ output_ports: Mapping[str, OutputPortSpec],
46
+ outputs: dict[str, Any],
47
+ *,
48
+ contracts: ContractCatalog,
49
+ where: str,
50
+ ) -> None:
51
+ if set(outputs) != set(output_ports):
52
+ raise ExecutionError(f"[{where}] plugin must emit outputs {sorted(output_ports)} but emitted {sorted(outputs)}")
53
+ for name, port in output_ports.items():
54
+ value = outputs[name]
55
+ if port.kind == "dataframe":
56
+ if not isinstance(value, pd.DataFrame):
57
+ raise ExecutionError(
58
+ f"[{where}] output '{name}' must be a DataFrame for dataframe port kind, got {type(value).__name__}"
59
+ )
60
+ try:
61
+ contracts.validate(value, contract_id=port.contract or "", where=where)
62
+ except ContractError as err:
63
+ raise ExecutionError(str(err)) from err
64
+ continue
65
+ if isinstance(value, pd.DataFrame):
66
+ msg = f"[{where}] output '{name}' is declared as {port.kind} but returned a DataFrame"
67
+ raise ExecutionError(msg)
68
+ _coerce_file_output(port=port, value=value, where=where, name=name)
69
+
70
+
71
+ def _resolve_runtime_output_ports(
72
+ plugin: Plugin,
73
+ *,
74
+ inputs: Mapping[str, Any],
75
+ outputs: Mapping[str, Any],
76
+ cfg: Any,
77
+ contracts: ContractCatalog,
78
+ where: str,
79
+ ) -> dict[str, OutputPortSpec]:
80
+ declared = dict(plugin.output_ports())
81
+ resolved = dict(plugin.resolve_output_ports(inputs=inputs, outputs=outputs, cfg=cfg, where=where))
82
+ if set(resolved) != set(declared):
83
+ raise ExecutionError(
84
+ f"[{where}] runtime output ports must match declared outputs: "
85
+ f"declared={sorted(declared)} resolved={sorted(resolved)}"
86
+ )
87
+ for name, declared_port in declared.items():
88
+ resolved_port = resolved[name]
89
+ if resolved_port.kind != declared_port.kind:
90
+ raise ExecutionError(
91
+ f"[{where}] runtime output port '{name}' changed kind from {declared_port.kind!r} "
92
+ f"to {resolved_port.kind!r}"
93
+ )
94
+ if declared_port.kind != "dataframe":
95
+ if resolved_port.contract is not None:
96
+ raise ExecutionError(
97
+ f"[{where}] runtime output port '{name}' of kind {declared_port.kind!r} "
98
+ "must not resolve a dataframe contract"
99
+ )
100
+ continue
101
+ if declared_port.contract is None or resolved_port.contract is None:
102
+ raise ExecutionError(f"[{where}] dataframe output port '{name}' must resolve a non-empty contract id")
103
+ try:
104
+ if contracts.satisfies(actual=resolved_port.contract, expected=declared_port.contract):
105
+ continue
106
+ except ContractError as err:
107
+ raise ExecutionError(str(err)) from err
108
+ raise ExecutionError(
109
+ f"[{where}] runtime contract for output '{name}' must satisfy declared contract "
110
+ f"{declared_port.contract!r}, got {resolved_port.contract!r}"
111
+ )
112
+ return resolved
113
+
114
+
115
+ def _resolve_output_labels(
116
+ *,
117
+ step_id: str,
118
+ output_ports: Mapping[str, OutputPortSpec],
119
+ writes: dict[str, OutputRef],
120
+ ) -> dict[str, OutputRef]:
121
+ unknown = sorted(set(writes) - set(output_ports))
122
+ if unknown:
123
+ raise ExecutionError(
124
+ f"[{step_id}] writes includes unknown outputs: {unknown} (expected: {sorted(output_ports)})"
125
+ )
126
+ labels: dict[str, OutputRef] = {}
127
+ for out_name, port in output_ports.items():
128
+ if port.kind != "dataframe":
129
+ if out_name in writes:
130
+ raise ExecutionError(
131
+ f"[{step_id}] writes cannot target output '{out_name}' (port kind is {port.kind!r})."
132
+ )
133
+ continue
134
+ label = writes[out_name] if out_name in writes else OutputRef(record_id=f"{step_id}/{out_name}")
135
+ if not isinstance(label, OutputRef) or not label.record_id.strip():
136
+ raise ExecutionError(f"[{step_id}] writes for '{out_name}' must be a non-empty record ref.")
137
+ labels[out_name] = label
138
+ if len({ref.record_id for ref in labels.values()}) != len(labels):
139
+ rendered = {name: ref.record_id for name, ref in labels.items()}
140
+ raise ExecutionError(f"[{step_id}] writes produce duplicate output labels: {rendered}")
141
+ return labels
142
+
143
+
144
+ def collect_file_output_paths(
145
+ *,
146
+ output_ports: Mapping[str, OutputPortSpec],
147
+ outputs: Mapping[str, Any],
148
+ where: str,
149
+ ) -> list[Path]:
150
+ collected: list[Path] = []
151
+ for name, port in output_ports.items():
152
+ if port.kind == "dataframe":
153
+ continue
154
+ collected.extend(_coerce_file_output(port=port, value=outputs[name], where=where, name=name))
155
+ return collected
156
+
157
+
158
+ def collect_file_output_descriptions(
159
+ *,
160
+ output_ports: Mapping[str, OutputPortSpec],
161
+ outputs: Mapping[str, Any],
162
+ ) -> list[PathDescription]:
163
+ descriptions: list[PathDescription] = []
164
+ for name, port in output_ports.items():
165
+ if port.kind == "dataframe":
166
+ continue
167
+ value = outputs[name]
168
+ if isinstance(value, PathDescription):
169
+ descriptions.append(value)
170
+ elif isinstance(value, Sequence) and not isinstance(value, str | bytes):
171
+ descriptions.extend(item for item in value if isinstance(item, PathDescription))
172
+ return descriptions
173
+
174
+
175
+ def _assert_input_port_value(
176
+ *,
177
+ name: str,
178
+ port: InputPortSpec,
179
+ value: Any,
180
+ contracts: ContractCatalog,
181
+ where: str,
182
+ ) -> None:
183
+ if port.kind == "dataframe":
184
+ actual_contract = getattr(value, "contract_id", None)
185
+ if actual_contract is None:
186
+ raise ExecutionError(
187
+ f"[{where}] input '{name}' expects a dataframe artifact but got {type(value).__name__}"
188
+ )
189
+ if port.contract is None:
190
+ return
191
+ try:
192
+ if contracts.satisfies(actual=actual_contract, expected=port.contract):
193
+ return
194
+ except ContractError as err:
195
+ msg = str(err)
196
+ else:
197
+ msg = f"[{where}] input '{name}' must be contract {port.contract} but got {actual_contract}"
198
+ raise ExecutionError(msg)
199
+ if port.kind == "record_collection":
200
+ if not isinstance(value, SourceRecordCollection):
201
+ raise ExecutionError(
202
+ f"[{where}] input '{name}' expects a source record collection but got {type(value).__name__}"
203
+ )
204
+ for item in value:
205
+ actual_contract = getattr(item.record, "contract_id", None)
206
+ if actual_contract is None:
207
+ raise ExecutionError(
208
+ f"[{where}] input '{name}' source {item.ref.resource_id!r} is not a dataframe artifact"
209
+ )
210
+ if port.contract is None:
211
+ continue
212
+ try:
213
+ compatible = contracts.satisfies(actual=actual_contract, expected=port.contract)
214
+ except ContractError as err:
215
+ raise ExecutionError(str(err)) from err
216
+ if not compatible:
217
+ raise ExecutionError(
218
+ f"[{where}] input '{name}' source {item.ref.resource_id!r} must satisfy "
219
+ f"contract {port.contract!r} but got {actual_contract!r}"
220
+ )
221
+ return
222
+ if port.kind == "file_path":
223
+ if isinstance(value, Path):
224
+ return
225
+ raise ExecutionError(f"[{where}] input '{name}' expects a file path but got {type(value).__name__}")
226
+ if port.kind == "file_set":
227
+ if isinstance(value, tuple) and value and all(isinstance(item, Path) for item in value):
228
+ return
229
+ raise ExecutionError(
230
+ f"[{where}] input '{name}' expects a non-empty tuple of file paths but got {type(value).__name__}"
231
+ )
232
+ if port.kind == "file_bundle":
233
+ files = getattr(value, "files", None)
234
+ if files is None:
235
+ raise ExecutionError(f"[{where}] input '{name}' expects a file bundle but got {type(value).__name__}")
236
+ return
237
+ raise ExecutionError(f"[{where}] input '{name}' uses unknown port kind {port.kind!r}")
238
+
239
+
240
+ def _coerce_file_output(
241
+ *,
242
+ port: OutputPortSpec,
243
+ value: Any,
244
+ where: str,
245
+ name: str,
246
+ ) -> list[Path]:
247
+ if port.kind == "file_path":
248
+ if isinstance(value, PathDescription):
249
+ return [value.path]
250
+ if isinstance(value, str | Path):
251
+ return [_validated_output_path(value, where=where, name=name)]
252
+ raise ExecutionError(f"[{where}] output '{name}' must be a path-like value for file_path ports")
253
+ if port.kind == "file_bundle":
254
+ if isinstance(value, PathDescription):
255
+ return [value.path]
256
+ if isinstance(value, str | Path):
257
+ return [_validated_output_path(value, where=where, name=name)]
258
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes):
259
+ if not value:
260
+ raise ExecutionError(f"[{where}] output '{name}' file bundle must contain at least one file")
261
+ paths: list[Path] = []
262
+ for item in value:
263
+ if isinstance(item, PathDescription):
264
+ paths.append(item.path)
265
+ continue
266
+ if not isinstance(item, str | Path):
267
+ raise ExecutionError(
268
+ f"[{where}] output '{name}' file bundle entries must be path-like or PathDescription, "
269
+ f"got {type(item).__name__}"
270
+ )
271
+ paths.append(_validated_output_path(item, where=where, name=name))
272
+ return paths
273
+ raise ExecutionError(f"[{where}] output '{name}' must be a list of path-like values for file_bundle ports")
274
+ raise ExecutionError(f"[{where}] output '{name}' uses unknown non-dataframe port kind {port.kind!r}")
275
+
276
+
277
+ def _validated_output_path(value: str | Path, *, where: str, name: str) -> Path:
278
+ if isinstance(value, str) and not value.strip():
279
+ raise ExecutionError(f"[{where}] output '{name}' paths must be non-empty")
280
+ path = Path(value)
281
+ if path == Path("."):
282
+ raise ExecutionError(f"[{where}] output '{name}' paths must identify files")
283
+ return path
@@ -0,0 +1,326 @@
1
+ from __future__ import annotations
2
+
3
+ from contextlib import nullcontext
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+ from rich.console import Console
9
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
10
+
11
+ from reader_workbench.errors import ExecutionError, ReaderError
12
+ from reader_workbench.workbench.context import RunContext
13
+ from reader_workbench.workbench.graph import (
14
+ FileRef,
15
+ ProvenanceInput,
16
+ RecordCollectionRef,
17
+ RecordRef,
18
+ ResourceRef,
19
+ SourceRecordRef,
20
+ )
21
+ from reader_workbench.workbench.records import PathDescription, RecordInputEvidence, RecordStore
22
+
23
+ from ._shared import digest_cfg
24
+ from .contracts import (
25
+ _assert_input_ports,
26
+ _assert_output_ports,
27
+ _resolve_output_labels,
28
+ _resolve_runtime_output_ports,
29
+ collect_file_output_descriptions,
30
+ collect_file_output_paths,
31
+ )
32
+ from .file_outputs import FileOutputTransaction
33
+ from .inputs import _resolve_inputs, resolve_missing_file_inputs
34
+
35
+
36
+ def _runtime_provenance_inputs(*, step: Any, inputs: dict[str, Any]) -> list[ProvenanceInput]:
37
+ provenance: list[ProvenanceInput] = []
38
+ declared_reads = dict(step.reads or {})
39
+ for label in sorted(inputs):
40
+ ref = declared_reads.get(label)
41
+ value = inputs[label]
42
+ if isinstance(ref, RecordCollectionRef):
43
+ provenance.extend(
44
+ ProvenanceInput(
45
+ label=f"{label}[{item.resource_id}]",
46
+ ref=item,
47
+ discovery_policy="source_record",
48
+ )
49
+ for item in ref.records
50
+ )
51
+ continue
52
+ if isinstance(value, tuple) and value and all(isinstance(item, Path) for item in value):
53
+ for index, path in enumerate(value):
54
+ item_ref = ref if ref is not None and len(value) == 1 else FileRef(path=path)
55
+ if isinstance(item_ref, ResourceRef):
56
+ policy = "declared_resource"
57
+ elif ref is not None:
58
+ policy = "declared_file"
59
+ else:
60
+ policy = "plugin_discovery"
61
+ provenance.append(
62
+ ProvenanceInput(
63
+ label=f"{label}[{index}]",
64
+ ref=item_ref,
65
+ discovery_policy=policy,
66
+ )
67
+ )
68
+ continue
69
+ if ref is None:
70
+ if not isinstance(value, Path):
71
+ raise ExecutionError(
72
+ f"{step.id}: resolved input {label!r} has no declared reference and is not a file path"
73
+ )
74
+ ref = FileRef(path=value)
75
+ discovery_policy = "plugin_discovery"
76
+ elif isinstance(ref, RecordRef):
77
+ discovery_policy = "record"
78
+ elif isinstance(ref, SourceRecordRef):
79
+ discovery_policy = "source_record"
80
+ elif isinstance(ref, ResourceRef):
81
+ discovery_policy = "declared_resource"
82
+ else:
83
+ discovery_policy = "declared_file"
84
+ provenance.append(ProvenanceInput(label=label, ref=ref, discovery_policy=discovery_policy))
85
+ return provenance
86
+
87
+
88
+ def _persist_dataframe_outputs(
89
+ *,
90
+ store: RecordStore,
91
+ ctx: RunContext,
92
+ step: Any,
93
+ cfg: Any,
94
+ input_evidence: tuple[RecordInputEvidence, ...],
95
+ outputs: dict[str, Any],
96
+ resolved_output_ports: dict[str, Any],
97
+ output_labels: dict[str, Any],
98
+ phase: str,
99
+ ) -> None:
100
+ for out_name, obj in outputs.items():
101
+ port = resolved_output_ports[out_name]
102
+ if port.kind != "dataframe":
103
+ continue
104
+ if isinstance(obj, pd.DataFrame):
105
+ store.persist_dataframe(
106
+ producer_id=step.id,
107
+ producer_kind="pipeline" if phase == "pipeline" else ("plot" if phase == "plots" else "export"),
108
+ producer_plugin=step.plugin,
109
+ out_name=out_name,
110
+ record_id=output_labels[out_name].record_id,
111
+ df=obj,
112
+ contract_id=port.contract or "",
113
+ inputs=input_evidence,
114
+ config_digest=ctx.config_digest,
115
+ producer_config_digest=digest_cfg(cfg),
116
+ source_recipe=step.source_recipe,
117
+ )
118
+ continue
119
+ raise ExecutionError(f"{phase} {step.id}: unsupported output type for {out_name}")
120
+
121
+
122
+ def _persist_file_bundle_record(
123
+ *,
124
+ store: RecordStore,
125
+ ctx: RunContext,
126
+ step: Any,
127
+ cfg: Any,
128
+ input_evidence: tuple[RecordInputEvidence, ...],
129
+ outputs: dict[str, Any],
130
+ resolved_output_ports: dict[str, Any],
131
+ phase: str,
132
+ description: str,
133
+ protocol_figure_description: str | None,
134
+ ) -> None:
135
+ explicit_files = collect_file_output_paths(
136
+ output_ports=resolved_output_ports,
137
+ outputs=outputs,
138
+ where=f"{phase}:{step.id}",
139
+ )
140
+ if not explicit_files:
141
+ raise ExecutionError(f"{phase} {step.id}: must emit at least one explicit file output")
142
+ producer_kind = "plot" if phase == "plots" else "export"
143
+ record_files = sorted(
144
+ {_record_output_path(path, outputs_dir=ctx.outputs_dir) for path in explicit_files},
145
+ key=str,
146
+ )
147
+ path_descriptions: tuple[PathDescription, ...] = ()
148
+ if phase == "plots":
149
+ explicit_descriptions = collect_file_output_descriptions(
150
+ output_ports=resolved_output_ports,
151
+ outputs=outputs,
152
+ )
153
+ explicit_by_path: dict[Path, str] = {}
154
+ for item in explicit_descriptions:
155
+ record_path = _record_output_path(item.path, outputs_dir=ctx.outputs_dir)
156
+ if record_path in explicit_by_path:
157
+ raise ExecutionError(f"plots {step.id}: duplicate descriptions for {record_path}")
158
+ explicit_by_path[record_path] = item.description
159
+ fallback_description = protocol_figure_description or description
160
+ path_descriptions = tuple(
161
+ PathDescription(path=path, description=explicit_by_path.get(path, fallback_description))
162
+ for path in record_files
163
+ )
164
+ store.append_file_bundle(
165
+ producer_kind=producer_kind,
166
+ producer_id=step.id,
167
+ producer_plugin=step.plugin,
168
+ record_id=f"{producer_kind}:{step.id}",
169
+ inputs=input_evidence,
170
+ config_digest=ctx.config_digest,
171
+ producer_config_digest=digest_cfg(cfg),
172
+ files=record_files,
173
+ description=description,
174
+ path_descriptions=path_descriptions,
175
+ source_recipe=step.source_recipe,
176
+ )
177
+
178
+
179
+ def _record_output_path(path: Path, *, outputs_dir: Path) -> Path:
180
+ try:
181
+ return path.relative_to(outputs_dir)
182
+ except ValueError:
183
+ return path
184
+
185
+
186
+ def _protocol_figure_description(*, ctx: RunContext, step: Any) -> str | None:
187
+ for figure in ctx.protocol.descriptor.figures:
188
+ if figure.id == step.id:
189
+ return figure.summary
190
+ return None
191
+
192
+
193
+ def execute_step(*, step: Any, phase: str, store: RecordStore, ctx: RunContext, registry: Any) -> None:
194
+ descriptor = registry.resolve_descriptor(step.plugin)
195
+ plugin_cls = descriptor.cls
196
+ if ctx.protocol is None:
197
+ raise ExecutionError(f"{phase} {step.id}: run context is missing a bound protocol")
198
+ effective_with = ctx.protocol.effective_plugin_config(plugin_id=step.plugin, step_with=(step.with_ or {}))
199
+ cfg = plugin_cls.ConfigModel.model_validate(effective_with)
200
+ plugin = plugin_cls()
201
+ plugin.bind_runtime(descriptor=descriptor, contracts=registry.contracts)
202
+ input_ports = plugin.input_ports()
203
+ output_ports = plugin.output_ports()
204
+ output_labels = _resolve_output_labels(
205
+ step_id=step.id,
206
+ output_ports=output_ports,
207
+ writes=(step.writes or {}),
208
+ )
209
+ inputs = _resolve_inputs(store, step.reads or {}, input_ports=input_ports, exp_dir=ctx.exp_dir)
210
+ inputs = resolve_missing_file_inputs(
211
+ plugin=plugin,
212
+ exp_dir=ctx.exp_dir,
213
+ cfg=cfg,
214
+ inputs=inputs,
215
+ input_ports=input_ports,
216
+ )
217
+ _assert_input_ports(
218
+ plugin,
219
+ inputs,
220
+ contracts=registry.contracts,
221
+ where=step.id,
222
+ )
223
+ provenance_inputs = _runtime_provenance_inputs(step=step, inputs=inputs)
224
+ input_evidence = store.capture_inputs(provenance_inputs, resolved_inputs=inputs)
225
+
226
+ plug_inputs: dict[str, Any] = {}
227
+ for key, value in inputs.items():
228
+ if hasattr(value, "load_dataframe"):
229
+ plug_inputs[key] = value.load_dataframe()
230
+ else:
231
+ plug_inputs[key] = value
232
+
233
+ transaction_context = (
234
+ FileOutputTransaction(context=ctx, step_id=step.id, phase=phase)
235
+ if phase in {"plots", "exports"}
236
+ else nullcontext()
237
+ )
238
+ with transaction_context as file_transaction:
239
+ plugin_context = file_transaction.context if file_transaction is not None else ctx
240
+ try:
241
+ outputs = plugin.run(plugin_context, plug_inputs, cfg)
242
+ except ReaderError:
243
+ raise
244
+ except Exception as err:
245
+ raise ExecutionError(f"{phase} {step.id} crashed: {err}") from err
246
+
247
+ resolved_output_ports = _resolve_runtime_output_ports(
248
+ plugin,
249
+ inputs=inputs,
250
+ outputs=outputs,
251
+ cfg=cfg,
252
+ contracts=registry.contracts,
253
+ where=step.id,
254
+ )
255
+ _assert_output_ports(
256
+ resolved_output_ports,
257
+ outputs,
258
+ contracts=registry.contracts,
259
+ where=step.id,
260
+ )
261
+ _persist_dataframe_outputs(
262
+ store=store,
263
+ ctx=ctx,
264
+ step=step,
265
+ cfg=cfg,
266
+ input_evidence=input_evidence,
267
+ outputs=outputs,
268
+ resolved_output_ports=resolved_output_ports,
269
+ output_labels=output_labels,
270
+ phase=phase,
271
+ )
272
+ if phase in {"plots", "exports"}:
273
+ outputs = file_transaction.promote(
274
+ outputs=outputs,
275
+ output_ports=resolved_output_ports,
276
+ where=f"{phase}:{step.id}",
277
+ )
278
+ protocol_figure_description = _protocol_figure_description(ctx=ctx, step=step) if phase == "plots" else None
279
+ description = protocol_figure_description or descriptor.summary
280
+ _persist_file_bundle_record(
281
+ store=store,
282
+ ctx=ctx,
283
+ step=step,
284
+ cfg=cfg,
285
+ input_evidence=input_evidence,
286
+ outputs=outputs,
287
+ resolved_output_ports=resolved_output_ports,
288
+ phase=phase,
289
+ description=description,
290
+ protocol_figure_description=protocol_figure_description,
291
+ )
292
+ file_transaction.commit()
293
+
294
+
295
+ def run_steps(
296
+ *,
297
+ items: list[Any],
298
+ phase: str,
299
+ verbose: bool,
300
+ console: Console,
301
+ ctx: RunContext,
302
+ store: RecordStore,
303
+ registry: Any,
304
+ ) -> None:
305
+ if not items:
306
+ return
307
+ if verbose:
308
+ for ordinal, step in enumerate(items, 1):
309
+ ctx.logger.info("→ %s %s [%d/%d] plugin=%s", phase, step.id, ordinal, len(items), step.plugin)
310
+ execute_step(step=step, phase=phase, store=store, ctx=ctx, registry=registry)
311
+ return
312
+
313
+ with Progress(
314
+ SpinnerColumn(style="accent"),
315
+ TextColumn("[progress.description]{task.description}"),
316
+ BarColumn(),
317
+ TextColumn("{task.completed}/{task.total}"),
318
+ TimeElapsedColumn(),
319
+ console=console,
320
+ transient=True,
321
+ ) as progress:
322
+ task = progress.add_task(f"{phase.title()} ({len(items)} steps)", total=len(items))
323
+ for step in items:
324
+ progress.update(task, description=f"{phase}: {step.id}")
325
+ execute_step(step=step, phase=phase, store=store, ctx=ctx, registry=registry)
326
+ progress.advance(task)