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,333 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata as md
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Callable, Iterable, Mapping
6
+ from dataclasses import dataclass, replace
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import pandas as pd
11
+ from pydantic import BaseModel
12
+
13
+ from reader_workbench.contracts import ContractCatalog, ContractId, OutputContractSurface
14
+ from reader_workbench.errors import ContractError, RegistryError
15
+ from reader_workbench.workbench.assets import AssetCatalog, AssetDescriptor, plugin_category_from_id
16
+ from reader_workbench.workbench.ports import (
17
+ InputPortSpec,
18
+ OutputPortSpec,
19
+ validate_input_ports,
20
+ validate_output_ports,
21
+ )
22
+
23
+
24
+ class PluginConfig(BaseModel):
25
+ """Base class for per-plugin configs (pydantic v2)."""
26
+
27
+ model_config = {"extra": "forbid"}
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class PreflightIssue:
32
+ kind: str
33
+ message: str
34
+
35
+
36
+ class Plugin(ABC):
37
+ """Contract-driven plugin interface."""
38
+
39
+ ConfigModel = PluginConfig
40
+
41
+ def __init__(self) -> None:
42
+ self._descriptor: AssetDescriptor | None = None
43
+ self._contracts: ContractCatalog | None = None
44
+
45
+ @classmethod
46
+ @abstractmethod
47
+ def input_ports(cls) -> Mapping[str, InputPortSpec]:
48
+ """Typed input port declarations."""
49
+
50
+ @classmethod
51
+ @abstractmethod
52
+ def output_ports(cls) -> Mapping[str, OutputPortSpec]:
53
+ """Typed output port declarations."""
54
+
55
+ def resolve_output_ports(
56
+ self,
57
+ *,
58
+ inputs: Mapping[str, Any],
59
+ outputs: Mapping[str, Any],
60
+ cfg: PluginConfig,
61
+ where: str,
62
+ ) -> Mapping[str, OutputPortSpec]:
63
+ """Resolve runtime output ports; default is the declared minimum."""
64
+ del inputs, outputs, cfg, where
65
+ return dict(type(self).output_ports())
66
+
67
+ @classmethod
68
+ def output_port_surfaces(cls) -> Mapping[str, OutputContractSurface]:
69
+ surfaces: dict[str, OutputContractSurface] = {}
70
+ for name, port in cls.output_ports().items():
71
+ surface = port.contract_surface
72
+ if surface is not None:
73
+ surfaces[name] = surface
74
+ return surfaces
75
+
76
+ @classmethod
77
+ def preflight_readiness(
78
+ cls,
79
+ *,
80
+ exp_dir: Path,
81
+ cfg: PluginConfig,
82
+ reads: Mapping[str, Any],
83
+ ) -> tuple[PreflightIssue, ...]:
84
+ del exp_dir, cfg, reads
85
+ return ()
86
+
87
+ @classmethod
88
+ def validate_semantic_references(cls, *, experiment: Any, cfg: PluginConfig) -> None:
89
+ """Validate experiment annotation references owned by this plugin's config."""
90
+
91
+ del experiment, cfg
92
+
93
+ @classmethod
94
+ def resolve_missing_file_inputs(
95
+ cls,
96
+ *,
97
+ exp_dir: Path,
98
+ cfg: PluginConfig,
99
+ inputs: Mapping[str, Any],
100
+ ) -> Mapping[str, Path]:
101
+ """Resolve optional file inputs that were not bound in the graph."""
102
+ del exp_dir, cfg, inputs
103
+ return {}
104
+
105
+ @classmethod
106
+ def passthrough_output_ports(
107
+ cls,
108
+ *,
109
+ outputs: Mapping[str, OutputPortSpec],
110
+ passthrough: Mapping[str, str],
111
+ promoted_examples: Mapping[str, tuple[ContractId, ...]] | None = None,
112
+ note: str | None = None,
113
+ ) -> dict[str, OutputPortSpec]:
114
+ ports = dict(outputs)
115
+ promoted_examples = promoted_examples or {}
116
+ for out_name in passthrough:
117
+ port = ports.get(out_name)
118
+ if port is None or port.kind != "dataframe" or port.contract is None:
119
+ continue
120
+ ports[out_name] = replace(
121
+ port,
122
+ surface=OutputContractSurface(
123
+ minimum=port.contract,
124
+ runtime_mode="passthrough",
125
+ promoted=tuple(promoted_examples.get(out_name, ())),
126
+ note=note,
127
+ ),
128
+ )
129
+ return ports
130
+
131
+ @classmethod
132
+ def promoted_output_ports(
133
+ cls,
134
+ *,
135
+ outputs: Mapping[str, OutputPortSpec],
136
+ promotions: Mapping[str, tuple[ContractId, ...]],
137
+ note: str | None = None,
138
+ ) -> dict[str, OutputPortSpec]:
139
+ ports = dict(outputs)
140
+ for out_name, promoted in promotions.items():
141
+ port = ports.get(out_name)
142
+ if port is None or port.kind != "dataframe" or port.contract is None:
143
+ continue
144
+ ports[out_name] = replace(
145
+ port,
146
+ surface=OutputContractSurface(
147
+ minimum=port.contract,
148
+ runtime_mode="promoted",
149
+ promoted=tuple(promoted),
150
+ note=note,
151
+ ),
152
+ )
153
+ return ports
154
+
155
+ def inherit_dataframe_output_ports(
156
+ self,
157
+ *,
158
+ inputs: Mapping[str, Any],
159
+ outputs: Mapping[str, Any],
160
+ passthrough: Mapping[str, str],
161
+ where: str,
162
+ ) -> dict[str, OutputPortSpec]:
163
+ """
164
+ Preserve stricter dataframe contracts across pass-through transforms when
165
+ the emitted dataframe still validates against the input contract.
166
+ """
167
+ resolved = dict(type(self).output_ports())
168
+ for out_name, in_name in passthrough.items():
169
+ if out_name not in resolved or in_name not in inputs or out_name not in outputs:
170
+ continue
171
+ port = resolved[out_name]
172
+ if port.kind != "dataframe" or port.contract is None:
173
+ continue
174
+ actual = getattr(inputs[in_name], "contract_id", None)
175
+ if actual in (None, "none"):
176
+ continue
177
+ if not self.contracts.satisfies(actual=actual, expected=port.contract):
178
+ continue
179
+ if not isinstance(outputs[out_name], pd.DataFrame):
180
+ continue
181
+ try:
182
+ self.contracts.validate(outputs[out_name], contract_id=actual, where=f"{where}:{out_name}")
183
+ except ContractError:
184
+ continue
185
+ resolved[out_name] = replace(port, contract=actual)
186
+ return resolved
187
+
188
+ @abstractmethod
189
+ def run(self, ctx, inputs: dict[str, Any], cfg: PluginConfig) -> dict[str, Any]:
190
+ """Execute and return dict of outputs by label."""
191
+
192
+ def bind_runtime(self, *, descriptor: AssetDescriptor, contracts: ContractCatalog) -> None:
193
+ if descriptor.cls is not type(self):
194
+ raise RegistryError(
195
+ f"Descriptor {descriptor.plugin_id!r} points to {descriptor.cls.__module__}.{descriptor.cls.__name__}, "
196
+ f"not {type(self).__module__}.{type(self).__name__}"
197
+ )
198
+ self._descriptor = descriptor
199
+ self._contracts = contracts
200
+
201
+ @property
202
+ def descriptor(self) -> AssetDescriptor:
203
+ if self._descriptor is None:
204
+ raise RegistryError(
205
+ f"Plugin instance {type(self).__module__}.{type(self).__name__} is missing a bound descriptor"
206
+ )
207
+ return self._descriptor
208
+
209
+ @property
210
+ def contracts(self) -> ContractCatalog:
211
+ if self._contracts is None:
212
+ raise RegistryError(
213
+ f"Plugin instance {type(self).__module__}.{type(self).__name__} is missing a bound contract catalog"
214
+ )
215
+ return self._contracts
216
+
217
+ @property
218
+ def plugin_id(self) -> str:
219
+ return self.descriptor.plugin_id
220
+
221
+ @property
222
+ def plugin_key(self) -> str:
223
+ return self.descriptor.key
224
+
225
+ @property
226
+ def plugin_category(self) -> str:
227
+ return self.descriptor.category
228
+
229
+
230
+ class Registry:
231
+ """Descriptor-driven plugin registry with explicit built-ins and explicit entry points."""
232
+
233
+ def __init__(self, *, contracts: ContractCatalog) -> None:
234
+ self._descriptors: dict[str, AssetDescriptor] = {}
235
+ self.contracts = contracts
236
+
237
+ def register(self, descriptor: AssetDescriptor) -> None:
238
+ if not issubclass(descriptor.cls, Plugin):
239
+ raise RegistryError(
240
+ f"Plugin descriptor {descriptor.plugin_id!r} must point to a Plugin subclass, "
241
+ f"got {descriptor.cls.__module__}.{descriptor.cls.__name__}"
242
+ )
243
+ if descriptor.plugin in self._descriptors:
244
+ raise RegistryError(f"Duplicate plugin {descriptor.plugin!r}")
245
+ validate_input_ports(descriptor.cls.input_ports(), where=descriptor.plugin)
246
+ outputs = validate_output_ports(descriptor.cls.output_ports(), where=descriptor.plugin)
247
+ surfaces = descriptor.cls.output_port_surfaces()
248
+ for name, surface in surfaces.items():
249
+ port = outputs.get(name)
250
+ if port is None:
251
+ raise RegistryError(f"{descriptor.plugin}: output port surface {name!r} does not match a declared port")
252
+ if port.kind != "dataframe":
253
+ raise RegistryError(f"{descriptor.plugin}: output port surface {name!r} requires a dataframe port")
254
+ if surface.minimum != port.contract:
255
+ raise RegistryError(
256
+ f"{descriptor.plugin}: output port surface {name!r} minimum {surface.minimum!r} "
257
+ f"must match declared contract {port.contract!r}"
258
+ )
259
+ self._descriptors[descriptor.plugin] = descriptor
260
+
261
+ def categories(self) -> Mapping[str, Mapping[str, type[Plugin]]]:
262
+ grouped: dict[str, dict[str, type[Plugin]]] = {
263
+ "ingest": {},
264
+ "transform": {},
265
+ "plot": {},
266
+ "export": {},
267
+ "validator": {},
268
+ }
269
+ for descriptor in self._descriptors.values():
270
+ category = descriptor.category
271
+ grouped[category][descriptor.key] = descriptor.cls
272
+ return grouped
273
+
274
+ def catalog(self) -> AssetCatalog:
275
+ return AssetCatalog(list(self._descriptors.values()))
276
+
277
+ def resolve_descriptor(self, plugin: str) -> AssetDescriptor:
278
+ try:
279
+ return self._descriptors[plugin]
280
+ except KeyError:
281
+ available = ", ".join(sorted(self._descriptors))
282
+ raise RegistryError(f"Unknown plugin '{plugin}'. Installed: {available}") from None
283
+
284
+ def resolve(self, plugin: str) -> type[Plugin]:
285
+ return self.resolve_descriptor(plugin).cls
286
+
287
+
288
+ def _coerce_external_descriptor(loaded: Any, *, ep_name: str) -> AssetDescriptor:
289
+ if isinstance(loaded, AssetDescriptor):
290
+ descriptor = loaded
291
+ elif isinstance(loaded, type):
292
+ raise RegistryError(
293
+ f"Entry point {ep_name!r} must expose a plugin descriptor or descriptor factory, not a plugin class"
294
+ )
295
+ elif isinstance(loaded, Callable):
296
+ descriptor = loaded()
297
+ else:
298
+ descriptor = loaded
299
+ if not isinstance(descriptor, AssetDescriptor):
300
+ raise RegistryError(
301
+ f"Entry point {ep_name!r} must load an AssetDescriptor or a zero-arg callable returning one"
302
+ )
303
+ if ep_name != descriptor.plugin_id:
304
+ raise RegistryError(f"Entry point {ep_name!r} must match descriptor plugin id {descriptor.plugin_id!r}")
305
+ return descriptor
306
+
307
+
308
+ def load_plugin_catalog(
309
+ *,
310
+ contracts: ContractCatalog,
311
+ builtin_descriptors: Iterable[AssetDescriptor],
312
+ categories: set[str] | None = None,
313
+ ) -> Registry:
314
+ """Register supplied built-ins and coordinated external plugin entry points."""
315
+ reg = Registry(contracts=contracts)
316
+ wanted = set(categories) if categories else None
317
+
318
+ selected_builtin_descriptors = tuple(
319
+ descriptor for descriptor in builtin_descriptors if wanted is None or descriptor.category in wanted
320
+ )
321
+ if not selected_builtin_descriptors:
322
+ raise RegistryError("No built-in plugin descriptors were supplied for the requested categories.")
323
+ for descriptor in selected_builtin_descriptors:
324
+ reg.register(descriptor)
325
+
326
+ for ep in md.entry_points(group="reader_workbench.plugins"):
327
+ entry_point_category = plugin_category_from_id(ep.name)
328
+ if wanted is not None and entry_point_category not in wanted:
329
+ continue
330
+ descriptor = _coerce_external_descriptor(ep.load(), ep_name=ep.name)
331
+ reg.register(descriptor)
332
+
333
+ return reg
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ import yaml
7
+
8
+ from reader_workbench.errors import ConfigError
9
+ from reader_workbench.workbench.experiment import ResourceCatalog
10
+ from reader_workbench.workbench.graph import (
11
+ FileRef,
12
+ InputRef,
13
+ OutputRef,
14
+ RecordRef,
15
+ ResourceRef,
16
+ select_workbench_specs,
17
+ )
18
+ from reader_workbench.workbench.paths import resolve_path_within_root
19
+
20
+
21
+ def build_surface_command(
22
+ command: str,
23
+ job_path: Path,
24
+ *,
25
+ only: list[str] | None,
26
+ exclude: list[str] | None,
27
+ list_only: bool = False,
28
+ dry_run: bool = False,
29
+ log_level: str = "INFO",
30
+ inputs: list[str] | None = None,
31
+ sets: list[str] | None = None,
32
+ ) -> list[str]:
33
+ parts = ["uv", "run", *command.split(), str(job_path)]
34
+ if list_only:
35
+ parts += ["--list"]
36
+ if only:
37
+ for value in only:
38
+ parts += ["--only", value]
39
+ if exclude:
40
+ for value in exclude:
41
+ parts += ["--exclude", value]
42
+ if dry_run:
43
+ parts += ["--dry-run"]
44
+ if log_level and log_level != "INFO":
45
+ parts += ["--log-level", log_level]
46
+ for raw in inputs or []:
47
+ parts += ["--input", raw]
48
+ for raw in sets or []:
49
+ parts += ["--set", raw]
50
+ return parts
51
+
52
+
53
+ def select_surface_specs(steps, *, only: list[str], exclude: list[str], kind: str):
54
+ try:
55
+ return select_workbench_specs(steps, only=only, exclude=exclude, kind_label=kind)
56
+ except ConfigError as err:
57
+ raise typer.BadParameter(f"{err} Use --list to see valid ids.") from err
58
+
59
+
60
+ def parse_input_overrides(
61
+ raw_inputs: list[str],
62
+ *,
63
+ root: Path,
64
+ resources: ResourceCatalog,
65
+ ) -> dict[str, InputRef]:
66
+ overrides: dict[str, InputRef] = {}
67
+ for raw in raw_inputs:
68
+ if "=" not in raw:
69
+ raise typer.BadParameter("--input expects KEY=VALUE")
70
+ key, value = raw.split("=", 1)
71
+ key = key.strip()
72
+ value = value.strip()
73
+ if not key:
74
+ raise typer.BadParameter("--input key cannot be empty")
75
+ if not value:
76
+ raise typer.BadParameter("--input value cannot be empty")
77
+ overrides[key] = _coerce_cli_input_ref(yaml.safe_load(value), root=root, resources=resources)
78
+ return overrides
79
+
80
+
81
+ def parse_set_overrides(raw_sets: list[str]) -> list[tuple[str, object]]:
82
+ overrides: list[tuple[str, object]] = []
83
+ for raw in raw_sets:
84
+ if "=" not in raw:
85
+ raise typer.BadParameter("--set expects PATH=VALUE")
86
+ path, value_raw = raw.split("=", 1)
87
+ path = path.strip()
88
+ if not path:
89
+ raise typer.BadParameter("--set path cannot be empty")
90
+ overrides.append((path, yaml.safe_load(value_raw)))
91
+ return overrides
92
+
93
+
94
+ def apply_step_overrides(
95
+ steps,
96
+ *,
97
+ input_overrides: dict[str, InputRef],
98
+ set_overrides: list[tuple[str, object]],
99
+ root: Path,
100
+ resources: ResourceCatalog,
101
+ ):
102
+ updated = []
103
+ for step in steps:
104
+ if hasattr(step, "model_copy"):
105
+ cloned = step.model_copy(deep=True)
106
+ reads = dict(cloned.reads or {})
107
+ with_block = dict(cloned.with_ or {})
108
+ writes = dict(cloned.writes or {})
109
+ else:
110
+ reads = dict(step.reads or {})
111
+ with_block = dict(step.with_ or {})
112
+ writes = dict(step.writes or {})
113
+ if input_overrides:
114
+ reads.update(input_overrides)
115
+ for path, value in set_overrides:
116
+ parts = [item for item in path.split(".") if item]
117
+ if not parts:
118
+ raise typer.BadParameter("--set path cannot be empty")
119
+ section = parts[0]
120
+ if section not in {"reads", "with", "writes"}:
121
+ raise typer.BadParameter("--set path must start with reads., with., or writes.")
122
+ if section in {"reads", "writes"}:
123
+ if len(parts) != 2:
124
+ raise typer.BadParameter(f"--set {section} expects a single key (e.g., {section}.foo=bar)")
125
+ target = reads if section == "reads" else writes
126
+ target[parts[1]] = (
127
+ _coerce_cli_input_ref(value, root=root, resources=resources)
128
+ if section == "reads"
129
+ else _coerce_cli_output_ref(value)
130
+ )
131
+ else:
132
+ if len(parts) < 2:
133
+ raise typer.BadParameter("--set with.* requires a key (e.g., with.foo=bar)")
134
+ _set_nested(with_block, parts[1:], value)
135
+ if hasattr(step, "model_copy"):
136
+ cloned.reads = reads
137
+ cloned.with_ = with_block
138
+ cloned.writes = writes
139
+ updated.append(cloned)
140
+ continue
141
+ payload = {
142
+ "id": step.id,
143
+ "plugin": step.plugin,
144
+ "reads": reads,
145
+ "with_": with_block,
146
+ "writes": writes,
147
+ "source_recipe": getattr(step, "source_recipe", None),
148
+ }
149
+ if hasattr(step, "kind"):
150
+ payload["kind"] = step.kind
151
+ updated.append(step.__class__(**payload))
152
+ return updated
153
+
154
+
155
+ def _coerce_cli_input_ref(value, *, root: Path, resources: ResourceCatalog) -> InputRef:
156
+ if isinstance(value, (RecordRef, FileRef, ResourceRef)):
157
+ return value
158
+ if isinstance(value, dict):
159
+ record = value.get("record")
160
+ file_path = value.get("file")
161
+ resource_id = value.get("resource")
162
+ populated = [item for item in (record, file_path, resource_id) if item is not None]
163
+ if len(populated) != 1:
164
+ raise typer.BadParameter("reads.* must declare exactly one of record, file, or resource")
165
+ if isinstance(record, str) and record.strip():
166
+ return RecordRef(record_id=record.strip())
167
+ if isinstance(file_path, str) and file_path.strip():
168
+ path = Path(file_path.strip()).expanduser()
169
+ try:
170
+ path = resolve_path_within_root(path, root=root)
171
+ except ValueError as err:
172
+ raise typer.BadParameter(
173
+ "reads.* file bindings must stay under the experiment root after resolving symlinks"
174
+ ) from err
175
+ return FileRef(path=path)
176
+ if isinstance(resource_id, str) and resource_id.strip():
177
+ return _resolve_cli_resource_ref(resource_id.strip(), resources=resources)
178
+ raise typer.BadParameter("reads.* binding values must be non-empty strings")
179
+ if isinstance(value, str) and value.strip():
180
+ return RecordRef(record_id=value.strip())
181
+ raise typer.BadParameter("reads.* expects a YAML/JSON mapping like {record: ...}, {file: ...}, or {resource: ...}")
182
+
183
+
184
+ def _resolve_cli_resource_ref(resource_id: str, *, resources: ResourceCatalog) -> ResourceRef:
185
+ if not resource_id:
186
+ raise typer.BadParameter("resource bindings require a non-empty resource id")
187
+ try:
188
+ resource = resources.require_file(resource_id)
189
+ except ValueError as err:
190
+ raise typer.BadParameter(str(err)) from err
191
+ return ResourceRef(resource_id=resource_id, path=resource.path.resolve())
192
+
193
+
194
+ def _coerce_cli_output_ref(value) -> OutputRef:
195
+ if isinstance(value, OutputRef):
196
+ return value
197
+ if isinstance(value, dict):
198
+ record = value.get("record")
199
+ if isinstance(record, str) and record.strip():
200
+ return OutputRef(record_id=record.strip())
201
+ raise typer.BadParameter("writes.* must declare {record: ...}")
202
+ if isinstance(value, str) and value.strip():
203
+ return OutputRef(record_id=value.strip())
204
+ raise typer.BadParameter("writes.* expects a record id or a {record: ...} mapping")
205
+
206
+
207
+ def _set_nested(mapping: dict, keys: list[str], value) -> None:
208
+ current = mapping
209
+ for key in keys[:-1]:
210
+ if key not in current:
211
+ current[key] = {}
212
+ if not isinstance(current[key], dict):
213
+ raise typer.BadParameter(f"--set path invalid (non-mapping at '{key}')")
214
+ current = current[key]
215
+ current[keys[-1]] = value
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: reader-workbench
3
+ Version: 1.0.0
4
+ Summary: Validated, traceable analysis workflows for experimental instrument data
5
+ Author: Eric J. South
6
+ License-Expression: MIT
7
+ Project-URL: Documentation, https://github.com/e-south/reader/tree/main/docs
8
+ Project-URL: Issues, https://github.com/e-south/reader/issues
9
+ Project-URL: Repository, https://github.com/e-south/reader
10
+ Keywords: data-analysis,data-pipelines,scientific-computing,workbench
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Requires-Python: <3.13,>=3.12
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy>=1.26
21
+ Requires-Dist: matplotlib>=3.8
22
+ Requires-Dist: pandas>=2.1
23
+ Requires-Dist: pydantic>=2.6
24
+ Requires-Dist: pyyaml>=6.0.2
25
+ Requires-Dist: typer>=0.16.0
26
+ Requires-Dist: click>=8.3.3
27
+ Requires-Dist: rich>=13.7
28
+ Requires-Dist: pyarrow>=23.0.1
29
+ Requires-Dist: openpyxl>=3.1
30
+ Requires-Dist: seaborn>=0.13
31
+ Requires-Dist: pillow>=12.3.0
32
+ Requires-Dist: polars>=1.36.1
33
+ Requires-Dist: flowio>=1.4.0
34
+ Requires-Dist: filelock<4,>=3.20.3
35
+ Dynamic: license-file
36
+
37
+ # ![Reader data workbench](https://raw.githubusercontent.com/e-south/reader/v1.0.0/assets/reader-banner.png)
38
+
39
+ [![Checks](https://github.com/e-south/reader/actions/workflows/checks.yaml/badge.svg?branch=main)](https://github.com/e-south/reader/actions/workflows/checks.yaml)
40
+ [![Python 3.12](https://img.shields.io/badge/python-3.12-3776AB.svg)](https://www.python.org/downloads/release/python-3120/)
41
+ [![MIT license](https://img.shields.io/badge/license-MIT-3D8068.svg)](https://github.com/e-south/reader/blob/main/LICENSE)
42
+
43
+ Reader ingests instrument files and experiment metadata, applies declared
44
+ transformations, and writes validated records, plots, exports, and notebooks.
45
+ Every unit of work—including a cross-experiment aggregate—has an owned directory
46
+ beneath `experiments/`, with source material in `inputs/`, a `reader/v8`
47
+ contract in `config.yaml`, and generated artifacts in `outputs/`.
48
+ The distribution and import package are named `reader-workbench` and
49
+ `reader_workbench`; the installed command remains `reader`.
50
+
51
+ ## Install
52
+
53
+ Install Reader as a command-line tool:
54
+
55
+ ```bash
56
+ uv tool install reader-workbench
57
+ ```
58
+
59
+ For development, install from a checkout:
60
+
61
+ ```bash
62
+ git clone https://github.com/e-south/reader.git
63
+ cd reader
64
+ uv sync --locked --group dev
65
+ uv run reader demo
66
+ ```
67
+
68
+ The installed command is `reader`:
69
+
70
+ ```bash
71
+ reader demo
72
+ reader protocols
73
+ reader init ./experiments/my_experiment --protocol plate_reader/single_reporter_screen
74
+ ```
75
+
76
+ The demo prints a guided command tour. It does not execute a pipeline or write
77
+ files. Every generated starter can be inspected and validated before data is
78
+ added.
79
+
80
+ ## Learn more
81
+
82
+ - [Getting started](https://github.com/e-south/reader/blob/main/docs/guides/getting_started.md) — install Reader, run the
83
+ demo, and scaffold a first experiment.
84
+ - [Common tasks](https://github.com/e-south/reader/blob/main/docs/guides/common_routes.md) — shortest commands for
85
+ discovery, validation, execution, and automation.
86
+ - [Python API](https://github.com/e-south/reader/blob/main/docs/core/python_api.md) — typed, task-oriented experiment and
87
+ plugin interfaces for integrations.
88
+ - [Documentation index](https://github.com/e-south/reader/blob/main/docs/README.md) — complete user, reference, and
89
+ maintainer documentation.
90
+
91
+ Reader is available under the [MIT license](https://github.com/e-south/reader/blob/main/LICENSE).