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,335 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import date
5
+ from pathlib import Path
6
+ from urllib.parse import unquote
7
+
8
+ import yaml
9
+
10
+ from .model import MaintenanceReport
11
+
12
+ SKIP_PARTS = {
13
+ ".git",
14
+ ".tmp",
15
+ ".venv",
16
+ ".pytest_cache",
17
+ ".ruff_cache",
18
+ "__pycache__",
19
+ "build",
20
+ "dist",
21
+ }
22
+ PUBLIC_REPOSITORY_PREFIXES = (
23
+ "https://github.com/e-south/reader/blob/main/",
24
+ "https://github.com/e-south/reader/tree/main/",
25
+ )
26
+ LINK_RE = re.compile(r"!?\[[^\]]+\]\(([^)]+)\)")
27
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
28
+ HTML_ANCHOR_RE = re.compile(r"""<a\s+(?:[^>]*?\s)?(?:id|name)=["']([^"']+)["']""", re.IGNORECASE)
29
+ FENCE_RE = re.compile(r"^\s*(```|~~~)")
30
+ DOC_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
31
+ REQUIRED_FRONTMATTER_FIELDS = {"doc_id", "surface", "owner", "last_verified", "summary"}
32
+ FRONTMATTER_DOCS = {
33
+ "ARCHITECTURE.md",
34
+ "DESIGN.md",
35
+ "QUALITY.md",
36
+ "RELIABILITY.md",
37
+ "SECURITY.md",
38
+ }
39
+ REQUIRED_LINKS = {
40
+ "README.md": {
41
+ "docs/README.md",
42
+ "docs/guides/getting_started.md",
43
+ "docs/guides/common_routes.md",
44
+ },
45
+ "docs/README.md": {
46
+ "guides/getting_started.md",
47
+ "guides/common_routes.md",
48
+ "guides/preflight_run_verify.md",
49
+ "guides/automation.md",
50
+ "guides/data_operations_plan.md",
51
+ "guides/experiment_bootstrap.md",
52
+ "guides/demo.md",
53
+ "core/cli.md",
54
+ "core/pipeline.md",
55
+ "repo-change-gate.md",
56
+ "repo-maintenance.md",
57
+ "../QUALITY.md",
58
+ "../RELIABILITY.md",
59
+ },
60
+ "docs/guides/experiment_bootstrap.md": {
61
+ "./data_operations_plan.md",
62
+ "./data_operations_plan/data_classes.md",
63
+ },
64
+ "docs/guides/data_operations_plan.md": {
65
+ "../../src/reader_workbench/workbench/dop/",
66
+ "../../.agents/skills/reader-data-operations-plan/SKILL.md",
67
+ "./data_operations_plan/operating_model.md",
68
+ "./data_operations_plan/data_classes.md",
69
+ "./data_operations_plan/metadata_minimums.md",
70
+ "./data_operations_plan/transfer_and_verification.md",
71
+ "./experiment_bootstrap.md",
72
+ "./preflight_run_verify.md",
73
+ },
74
+ "docs/guides/getting_started.md": {
75
+ "./package_namespace_migration.md",
76
+ },
77
+ "docs/repo-maintenance.md": {
78
+ "./guides/package_namespace_migration.md",
79
+ },
80
+ ".agents/skills/reader-data-operations-plan/SKILL.md": {
81
+ "../../../docs/guides/data_operations_plan.md",
82
+ "../../../docs/guides/data_operations_plan/operating_model.md",
83
+ "../../../docs/guides/experiment_bootstrap.md",
84
+ "./references/endpoint-contracts.md",
85
+ "./references/external-sources.md",
86
+ "./references/test-matrix.md",
87
+ "./references/workflow.md",
88
+ },
89
+ "docs/core/spec.md": {
90
+ "./pipeline.md",
91
+ "../../ARCHITECTURE.md",
92
+ "../../src/reader_workbench/protocols/",
93
+ "../../src/reader_workbench/workbench/dop/",
94
+ "../../src/reader_workbench/workbench/experiment/",
95
+ "../../src/reader_workbench/workbench/engine/",
96
+ "../../src/reader_workbench/plugins/",
97
+ "../../src/reader_workbench/contracts/",
98
+ "../repo-maintenance.md",
99
+ "../../QUALITY.md",
100
+ "../../RELIABILITY.md",
101
+ },
102
+ "docs/core/plugins.md": {
103
+ "./pipeline.md",
104
+ "./spec.md",
105
+ "../../ARCHITECTURE.md",
106
+ "../../src/reader_workbench/plugins/",
107
+ "../../src/reader_workbench/plugins/catalog.py",
108
+ "../../src/reader_workbench/protocols/compiler.py",
109
+ },
110
+ }
111
+
112
+
113
+ def _is_generated_experiment_output(path: Path, repo_root: Path) -> bool:
114
+ relative = path.relative_to(repo_root)
115
+ return bool(relative.parts) and relative.parts[0] == "experiments" and "outputs" in relative.parts
116
+
117
+
118
+ def iter_markdown_files(repo_root: Path) -> list[Path]:
119
+ repo_root = repo_root.resolve()
120
+ return sorted(
121
+ path
122
+ for path in repo_root.rglob("*.md")
123
+ if not any(part in SKIP_PARTS for part in path.parts) and not _is_generated_experiment_output(path, repo_root)
124
+ )
125
+
126
+
127
+ def iter_navigable_docs(repo_root: Path) -> list[Path]:
128
+ repo_root = repo_root.resolve()
129
+ top_level = [repo_root / path for path in sorted(FRONTMATTER_DOCS)]
130
+ return top_level + sorted((repo_root / "docs").rglob("*.md"))
131
+
132
+
133
+ def _frontmatter_payload(path: Path) -> tuple[dict[str, object] | None, str | None]:
134
+ lines = path.read_text(encoding="utf-8").splitlines()
135
+ if not lines or lines[0].strip() != "---":
136
+ return None, "missing opening front matter delimiter"
137
+ try:
138
+ closing = next(index for index, line in enumerate(lines[1:], start=1) if line.strip() == "---")
139
+ except StopIteration:
140
+ return None, "missing closing front matter delimiter"
141
+ try:
142
+ payload = yaml.safe_load("\n".join(lines[1:closing]))
143
+ except yaml.YAMLError as exc:
144
+ return None, f"invalid YAML front matter: {exc}"
145
+ if not isinstance(payload, dict):
146
+ return None, "front matter must be a YAML mapping"
147
+ return payload, None
148
+
149
+
150
+ def check_doc_frontmatter(files: list[Path], repo_root: Path) -> list[str]:
151
+ errors: list[str] = []
152
+ seen_ids: dict[str, Path] = {}
153
+ for path in files:
154
+ rel_path = path.relative_to(repo_root)
155
+ payload, parse_error = _frontmatter_payload(path)
156
+ if parse_error is not None:
157
+ errors.append(f"front matter: {rel_path}: {parse_error}")
158
+ continue
159
+ assert payload is not None
160
+ missing = sorted(REQUIRED_FRONTMATTER_FIELDS - set(payload))
161
+ if missing:
162
+ errors.append(f"front matter: {rel_path}: missing fields {missing}")
163
+ continue
164
+ for field in ("doc_id", "surface", "owner", "summary"):
165
+ value = payload[field]
166
+ if not isinstance(value, str) or not value.strip():
167
+ errors.append(f"front matter: {rel_path}: {field} must be a non-empty string")
168
+ summary = payload["summary"]
169
+ if isinstance(summary, str) and len(summary.strip()) > 200:
170
+ errors.append(f"front matter: {rel_path}: summary must be at most 200 characters")
171
+ doc_id = payload["doc_id"]
172
+ if isinstance(doc_id, str) and doc_id.strip():
173
+ if DOC_ID_RE.fullmatch(doc_id) is None:
174
+ errors.append(f"front matter: {rel_path}: invalid doc_id {doc_id!r}")
175
+ previous = seen_ids.get(doc_id)
176
+ if previous is not None:
177
+ errors.append(
178
+ f"front matter: {rel_path}: duplicate doc_id {doc_id!r} also used by {previous.relative_to(repo_root)}"
179
+ )
180
+ else:
181
+ seen_ids[doc_id] = path
182
+ verified_raw = payload["last_verified"]
183
+ if isinstance(verified_raw, date):
184
+ verified = verified_raw
185
+ elif not isinstance(verified_raw, str):
186
+ errors.append(f"front matter: {rel_path}: last_verified must be an ISO date")
187
+ continue
188
+ else:
189
+ try:
190
+ verified = date.fromisoformat(verified_raw)
191
+ except ValueError:
192
+ errors.append(f"front matter: {rel_path}: last_verified must be an ISO date")
193
+ continue
194
+ age_days = (date.today() - verified).days
195
+ if age_days < 0:
196
+ errors.append(f"front matter: {rel_path}: last_verified must not be in the future")
197
+ elif age_days > 365:
198
+ errors.append(f"front matter: {rel_path}: last_verified is stale ({age_days} days old)")
199
+ return errors
200
+
201
+
202
+ def normalize_target(source: Path, raw_target: str, *, repo_root: Path | None = None) -> Path | None:
203
+ target = raw_target.strip().split(" ", 1)[0]
204
+ for prefix in PUBLIC_REPOSITORY_PREFIXES:
205
+ if target.startswith(prefix):
206
+ if repo_root is None:
207
+ return None
208
+ relative_target = target.removeprefix(prefix).split("#", 1)[0]
209
+ return (repo_root / relative_target).resolve()
210
+ if target.startswith(("http://", "https://", "mailto:", "#")):
211
+ return None
212
+ target = target.split("#", 1)[0]
213
+ if not target:
214
+ return None
215
+ return (source.parent / target).resolve()
216
+
217
+
218
+ def normalize_anchor_target(source: Path, raw_target: str) -> tuple[Path, str] | None:
219
+ target = raw_target.strip().split(" ", 1)[0]
220
+ if target.startswith(("http://", "https://", "mailto:")) or "#" not in target:
221
+ return None
222
+ path_raw, anchor_raw = target.split("#", 1)
223
+ anchor = unquote(anchor_raw).strip()
224
+ if not anchor:
225
+ return None
226
+ target_path = source if not path_raw else (source.parent / path_raw).resolve()
227
+ if target_path.suffix.lower() != ".md":
228
+ return None
229
+ return target_path, anchor
230
+
231
+
232
+ def markdown_anchors(source: Path) -> set[str]:
233
+ anchors: set[str] = set()
234
+ slug_counts: dict[str, int] = {}
235
+ in_fence = False
236
+ for line in source.read_text().splitlines():
237
+ if FENCE_RE.match(line):
238
+ in_fence = not in_fence
239
+ continue
240
+ if in_fence:
241
+ continue
242
+ for explicit_anchor in HTML_ANCHOR_RE.findall(line):
243
+ anchors.add(explicit_anchor)
244
+ heading = HEADING_RE.match(line)
245
+ if heading is None:
246
+ continue
247
+ slug = github_heading_slug(heading.group(2))
248
+ count = slug_counts.get(slug, 0)
249
+ anchors.add(slug if count == 0 else f"{slug}-{count}")
250
+ slug_counts[slug] = count + 1
251
+ return anchors
252
+
253
+
254
+ def github_heading_slug(text: str) -> str:
255
+ text = re.sub(r"`([^`]*)`", r"\1", text)
256
+ text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
257
+ text = re.sub(r"<[^>]+>", "", text)
258
+ text = text.strip().lower()
259
+ text = re.sub(r"[^\w\s-]", "", text)
260
+ text = re.sub(r"\s+", "-", text)
261
+ return re.sub(r"-+", "-", text).strip("-")
262
+
263
+
264
+ def linked_paths(source: Path, *, repo_root: Path | None = None) -> set[Path]:
265
+ linked: set[Path] = set()
266
+ for raw_target in LINK_RE.findall(source.read_text()):
267
+ normalized = normalize_target(source, raw_target, repo_root=repo_root)
268
+ if normalized is not None:
269
+ linked.add(normalized)
270
+ return linked
271
+
272
+
273
+ def check_internal_links(files: list[Path], repo_root: Path) -> list[str]:
274
+ errors: list[str] = []
275
+ for file in files:
276
+ for raw_target in LINK_RE.findall(file.read_text()):
277
+ normalized = normalize_target(file, raw_target, repo_root=repo_root)
278
+ if normalized is None:
279
+ continue
280
+ rel_file = file.relative_to(repo_root)
281
+ if not normalized.is_relative_to(repo_root):
282
+ errors.append(f"link escapes repository: {rel_file} -> {raw_target}")
283
+ continue
284
+ if not normalized.exists():
285
+ errors.append(f"broken link: {rel_file} -> {raw_target}")
286
+ return errors
287
+
288
+
289
+ def check_markdown_anchors(files: list[Path], repo_root: Path) -> list[str]:
290
+ anchors_by_file = {file.resolve(): markdown_anchors(file) for file in files}
291
+ errors: list[str] = []
292
+ for file in files:
293
+ for raw_target in LINK_RE.findall(file.read_text()):
294
+ normalized = normalize_anchor_target(file, raw_target)
295
+ if normalized is None:
296
+ continue
297
+ target_file, anchor = normalized
298
+ if not target_file.exists():
299
+ continue
300
+ anchors = anchors_by_file.get(target_file.resolve())
301
+ if anchors is None:
302
+ continue
303
+ if anchor not in anchors:
304
+ rel_file = file.relative_to(repo_root)
305
+ errors.append(f"broken anchor: {rel_file} -> {raw_target}")
306
+ return errors
307
+
308
+
309
+ def check_required_routes(repo_root: Path) -> list[str]:
310
+ errors: list[str] = []
311
+ for rel_source, rel_targets in REQUIRED_LINKS.items():
312
+ source = repo_root / rel_source
313
+ linked = linked_paths(source, repo_root=repo_root)
314
+ for rel_target in sorted(rel_targets):
315
+ expected = (source.parent / rel_target).resolve()
316
+ if expected not in linked:
317
+ errors.append(f"missing required route: {rel_source} -> {rel_target}")
318
+ return errors
319
+
320
+
321
+ def check_docs(repo_root: Path) -> MaintenanceReport:
322
+ """Check documentation integrity in a Reader source checkout."""
323
+
324
+ repo_root = repo_root.resolve()
325
+ files = iter_markdown_files(repo_root)
326
+ errors = check_internal_links(files, repo_root)
327
+ errors.extend(check_markdown_anchors(files, repo_root))
328
+ errors.extend(check_required_routes(repo_root))
329
+ errors.extend(check_doc_frontmatter(iter_navigable_docs(repo_root), repo_root))
330
+ return MaintenanceReport(
331
+ check="docs",
332
+ repo_root=repo_root,
333
+ checked=len(files),
334
+ errors=tuple(errors),
335
+ )
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class MaintenanceReport:
9
+ """Typed result from a repository-maintenance check."""
10
+
11
+ check: str
12
+ repo_root: Path
13
+ checked: int
14
+ errors: tuple[str, ...]
15
+
16
+ @property
17
+ def ok(self) -> bool:
18
+ return not self.errors
19
+
20
+ def to_payload(self) -> dict[str, object]:
21
+ return {
22
+ "schema": "reader.maintenance/v1",
23
+ "check": self.check,
24
+ "status": "ok" if self.ok else "failed",
25
+ "repo_root": str(self.repo_root),
26
+ "checked": self.checked,
27
+ "errors": list(self.errors),
28
+ }
@@ -0,0 +1,39 @@
1
+ """Fail closed unless a release commit passed the canonical main-push checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ def require_successful_checks(payload: dict[str, Any], revision: str) -> dict[str, Any]:
12
+ """Return the latest qualifying CI run; an older success cannot mask a failure."""
13
+ runs = [
14
+ run
15
+ for run in payload["workflow_runs"]
16
+ if run.get("head_sha") == revision
17
+ and run.get("event") == "push"
18
+ and run.get("head_branch") == "main"
19
+ and run.get("path") == ".github/workflows/checks.yaml"
20
+ and (run.get("head_repository") or {}).get("full_name") == "e-south/reader"
21
+ ]
22
+ if not runs:
23
+ raise ValueError("No canonical main-push Checks run for the release commit")
24
+ latest = max(runs, key=lambda run: int(run["id"]))
25
+ if latest.get("status") != "completed" or latest.get("conclusion") != "success":
26
+ raise ValueError("The latest main-push Checks run has not completed successfully")
27
+ return latest
28
+
29
+
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser(description=__doc__)
32
+ parser.add_argument("--checks", type=Path, required=True)
33
+ parser.add_argument("--revision", required=True)
34
+ args = parser.parse_args()
35
+ print(json.dumps(require_successful_checks(json.loads(args.checks.read_text()), args.revision), indent=2))
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,124 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from .model import MaintenanceReport
7
+
8
+ FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
9
+ SOURCE_ROW_RE = re.compile(r"^\| https?://[^|]+ \| \d{4}-\d{2}-\d{2} \| [^|]+ \|$", re.MULTILINE)
10
+ SKILLS_RELATIVE_ROOT = Path(".agents") / "skills"
11
+
12
+
13
+ def iter_skill_dirs(skills_dir: Path) -> list[Path]:
14
+ return sorted(path for path in skills_dir.iterdir() if path.is_dir() and not path.name.startswith("."))
15
+
16
+
17
+ def read_text(path: Path) -> str:
18
+ return path.read_text(encoding="utf-8")
19
+
20
+
21
+ def frontmatter_block(text: str, skill_path: Path, repo_root: Path) -> str:
22
+ match = FRONTMATTER_RE.match(text)
23
+ if match is None:
24
+ raise ValueError(f"{skill_path.relative_to(repo_root)}: missing frontmatter")
25
+ return match.group(1)
26
+
27
+
28
+ def require_in_block(block: str, needle: str, skill_path: Path, label: str, repo_root: Path) -> list[str]:
29
+ if needle not in block:
30
+ return [f"{skill_path.relative_to(repo_root)}: missing {label}"]
31
+ return []
32
+
33
+
34
+ def audit_skill_dir(skill_dir: Path, repo_root: Path) -> list[str]:
35
+ errors: list[str] = []
36
+ skill_path = skill_dir / "SKILL.md"
37
+ if not skill_path.exists():
38
+ return [f"{skill_dir.relative_to(repo_root)}: missing SKILL.md"]
39
+
40
+ text = read_text(skill_path)
41
+ try:
42
+ frontmatter = frontmatter_block(text, skill_path, repo_root)
43
+ except ValueError as exc:
44
+ return [str(exc)]
45
+
46
+ errors.extend(
47
+ require_in_block(
48
+ frontmatter,
49
+ f"name: {skill_dir.name}",
50
+ skill_path,
51
+ "frontmatter name matching folder",
52
+ repo_root,
53
+ )
54
+ )
55
+ errors.extend(require_in_block(frontmatter, "description:", skill_path, "frontmatter description", repo_root))
56
+ errors.extend(require_in_block(frontmatter, "metadata:", skill_path, "metadata block", repo_root))
57
+ errors.extend(require_in_block(frontmatter, "version:", skill_path, "metadata.version", repo_root))
58
+ errors.extend(require_in_block(frontmatter, "category:", skill_path, "metadata.category", repo_root))
59
+ errors.extend(require_in_block(frontmatter, "tags:", skill_path, "metadata.tags", repo_root))
60
+
61
+ if "Use when" not in frontmatter or "Do not use" not in frontmatter:
62
+ errors.append(
63
+ f"{skill_path.relative_to(repo_root)}: frontmatter description must include "
64
+ "'Use when' and 'Do not use' routing boundaries"
65
+ )
66
+
67
+ required_sections = [
68
+ "## Purpose",
69
+ "## Scope",
70
+ "## Required Deliverables",
71
+ "## Output Contract",
72
+ "## Trigger Tests",
73
+ ]
74
+ for section in required_sections:
75
+ if section not in text:
76
+ errors.append(f"{skill_path.relative_to(repo_root)}: missing section {section}")
77
+
78
+ external_sources_path = skill_dir / "references" / "external-sources.md"
79
+ if not external_sources_path.exists():
80
+ errors.append(f"{skill_dir.relative_to(repo_root)}: missing references/external-sources.md")
81
+ elif "./references/external-sources.md" not in text:
82
+ errors.append(
83
+ f"{skill_path.relative_to(repo_root)}: top-level skill does not expose references/external-sources.md"
84
+ )
85
+ else:
86
+ external_sources = read_text(external_sources_path)
87
+ if "| URL | Retrieved | Mapped update |" not in external_sources:
88
+ errors.append(
89
+ f"{external_sources_path.relative_to(repo_root)}: missing source table header "
90
+ "'| URL | Retrieved | Mapped update |'"
91
+ )
92
+ if SOURCE_ROW_RE.search(external_sources) is None:
93
+ errors.append(
94
+ f"{external_sources_path.relative_to(repo_root)}: missing at least one source row "
95
+ "with URL, YYYY-MM-DD retrieved date, and mapped update"
96
+ )
97
+
98
+ return errors
99
+
100
+
101
+ def check_skills(repo_root: Path) -> MaintenanceReport:
102
+ """Check repo-local skill structure in a Reader source checkout."""
103
+
104
+ repo_root = repo_root.resolve()
105
+ skills_dir = repo_root / SKILLS_RELATIVE_ROOT
106
+ if not skills_dir.is_dir():
107
+ return MaintenanceReport(
108
+ check="skills",
109
+ repo_root=repo_root,
110
+ checked=0,
111
+ errors=(f"{SKILLS_RELATIVE_ROOT.as_posix()} directory missing",),
112
+ )
113
+
114
+ errors: list[str] = []
115
+ skill_dirs = iter_skill_dirs(skills_dir)
116
+ for skill_dir in skill_dirs:
117
+ errors.extend(audit_skill_dir(skill_dir, repo_root))
118
+
119
+ return MaintenanceReport(
120
+ check="skills",
121
+ repo_root=repo_root,
122
+ checked=len(skill_dirs),
123
+ errors=tuple(errors),
124
+ )
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+
5
+ _EXPORTS = {
6
+ "mpl": {"ensure_mpl_cache_dir"},
7
+ "sinks": {"PlotFigure", "normalize_plot_figures", "save_plot_figures"},
8
+ "style": {"DEFAULT_RC", "PaletteBook", "available_palettes", "new_fig_ax", "use_style"},
9
+ "utils": {"ensure_dir", "save_figure", "slugify"},
10
+ }
11
+
12
+ __all__ = tuple(sorted({name for names in _EXPORTS.values() for name in names}))
13
+
14
+
15
+ def __getattr__(name: str):
16
+ for module_name, names in _EXPORTS.items():
17
+ if name in names:
18
+ module = importlib.import_module(f"reader_workbench.plotting.{module_name}")
19
+ return getattr(module, name)
20
+ raise AttributeError(name)
@@ -0,0 +1,56 @@
1
+ """Matplotlib cache handling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ from reader_workbench.errors import ConfigError
9
+
10
+
11
+ def _find_repo_root() -> Path | None:
12
+ here = Path(__file__).resolve()
13
+ for parent in here.parents:
14
+ if (parent / "pyproject.toml").exists():
15
+ return parent
16
+ return None
17
+
18
+
19
+ def _default_cache_dir(base_dir: Path | None) -> Path:
20
+ if base_dir is not None:
21
+ return Path(base_dir).expanduser().resolve() / ".cache" / "matplotlib"
22
+ repo_root = _find_repo_root()
23
+ if repo_root is not None:
24
+ return repo_root / ".cache" / "matplotlib"
25
+ xdg = os.environ.get("XDG_CACHE_HOME")
26
+ if xdg:
27
+ return Path(xdg).expanduser().resolve() / "reader" / "matplotlib"
28
+ return Path.home() / ".cache" / "reader" / "matplotlib"
29
+
30
+
31
+ def ensure_mpl_cache_dir(*, base_dir: Path | None = None) -> Path:
32
+ """
33
+ Ensure MPLCONFIGDIR points to a writable directory.
34
+
35
+ Priority:
36
+ 1) MPLCONFIGDIR (if set)
37
+ 2) READER_MPLCONFIGDIR (if set)
38
+ 3) base_dir/.cache/matplotlib (if base_dir provided)
39
+ 4) <repo>/.cache/matplotlib (if running from a reader checkout)
40
+ 5) $XDG_CACHE_HOME/reader/matplotlib or ~/.cache/reader/matplotlib
41
+ """
42
+ env = os.environ.get("MPLCONFIGDIR") or os.environ.get("READER_MPLCONFIGDIR")
43
+ cache_dir = Path(env).expanduser() if env else _default_cache_dir(base_dir)
44
+
45
+ if cache_dir.exists() and not cache_dir.is_dir():
46
+ raise ConfigError(f"Matplotlib cache path is not a directory: {cache_dir}")
47
+ try:
48
+ cache_dir.mkdir(parents=True, exist_ok=True)
49
+ except Exception as exc:
50
+ raise ConfigError(
51
+ "Matplotlib cache directory is not writable. "
52
+ f"Set MPLCONFIGDIR or READER_MPLCONFIGDIR to a writable path (current: {cache_dir})."
53
+ ) from exc
54
+
55
+ os.environ["MPLCONFIGDIR"] = str(cache_dir)
56
+ return cache_dir
@@ -0,0 +1,69 @@
1
+ """Plot sinks for saving rendered figures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from contextlib import suppress
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from reader_workbench.errors import ExecutionError
12
+ from reader_workbench.plotting.utils import save_figure
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class PlotFigure:
17
+ fig: Any
18
+ filename: str
19
+ ext: str = "pdf"
20
+ dpi: int | None = None
21
+ description: str | None = None
22
+
23
+ def __post_init__(self) -> None:
24
+ if self.description is None:
25
+ return
26
+ if not isinstance(self.description, str) or not self.description.strip():
27
+ raise ExecutionError("PlotFigure.description must be a non-empty string when provided")
28
+ normalized = self.description.strip()
29
+ if "\n" in normalized or "\r" in normalized:
30
+ raise ExecutionError("PlotFigure.description must be a single line")
31
+ object.__setattr__(self, "description", normalized)
32
+
33
+
34
+ def normalize_plot_figures(rendered: Any, *, where: str) -> list[PlotFigure]:
35
+ if rendered is None:
36
+ return []
37
+ if isinstance(rendered, PlotFigure):
38
+ return [rendered]
39
+ if isinstance(rendered, Iterable) and not isinstance(rendered, str | bytes):
40
+ figures: list[PlotFigure] = []
41
+ for item in rendered:
42
+ if not isinstance(item, PlotFigure):
43
+ raise ExecutionError(f"{where}: render must return PlotFigure objects, got {type(item).__name__}")
44
+ figures.append(item)
45
+ return figures
46
+ raise ExecutionError(f"{where}: render must return PlotFigure or list[PlotFigure], got {type(rendered).__name__}")
47
+
48
+
49
+ def save_plot_figures(figures: list[PlotFigure], output_dir: Path) -> list[Path]:
50
+ saved: list[Path] = []
51
+ if not figures:
52
+ return saved
53
+ try:
54
+ import matplotlib.pyplot as plt # noqa: PLC0415
55
+ except Exception as exc: # pragma: no cover - dependency guard
56
+ raise ExecutionError("Plot saving requires matplotlib.") from exc
57
+ seen_figs: list[Any] = []
58
+ for item in figures:
59
+ ext = str(item.ext or "pdf").lstrip(".").lower()
60
+ if not item.filename or not str(item.filename).strip():
61
+ raise ExecutionError("PlotFigure.filename must be a non-empty string")
62
+ path = save_figure(item.fig, output_dir, str(item.filename), ext=ext, dpi=item.dpi)
63
+ saved.append(path)
64
+ if item.fig not in seen_figs:
65
+ seen_figs.append(item.fig)
66
+ for fig in seen_figs:
67
+ with suppress(Exception):
68
+ plt.close(fig)
69
+ return saved