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.
- reader_workbench/__init__.py +22 -0
- reader_workbench/__main__.py +4 -0
- reader_workbench/_version.py +17 -0
- reader_workbench/api/__init__.py +74 -0
- reader_workbench/api/_record_reads.py +75 -0
- reader_workbench/api/artifacts.py +79 -0
- reader_workbench/api/facade.py +538 -0
- reader_workbench/api/models.py +285 -0
- reader_workbench/api/notebooks.py +63 -0
- reader_workbench/contracts/__init__.py +18 -0
- reader_workbench/contracts/builtins/__init__.py +36 -0
- reader_workbench/contracts/builtins/cytometry.py +140 -0
- reader_workbench/contracts/builtins/four_state_event_window.py +214 -0
- reader_workbench/contracts/builtins/generic.py +21 -0
- reader_workbench/contracts/builtins/logic.py +149 -0
- reader_workbench/contracts/builtins/plate_reader.py +47 -0
- reader_workbench/contracts/catalog.py +257 -0
- reader_workbench/contracts/model.py +109 -0
- reader_workbench/domains/__init__.py +1 -0
- reader_workbench/domains/cytometry/__init__.py +3 -0
- reader_workbench/domains/cytometry/analysis/__init__.py +29 -0
- reader_workbench/domains/cytometry/analysis/events.py +182 -0
- reader_workbench/domains/cytometry/analysis/gating.py +175 -0
- reader_workbench/domains/cytometry/analysis/workflow.py +248 -0
- reader_workbench/domains/cytometry/io/__init__.py +3 -0
- reader_workbench/domains/cytometry/io/fcs.py +135 -0
- reader_workbench/domains/cytometry/plots/__init__.py +5 -0
- reader_workbench/domains/cytometry/plots/diagnostic.py +155 -0
- reader_workbench/domains/logic/__init__.py +3 -0
- reader_workbench/domains/logic/crosstalk/__init__.py +3 -0
- reader_workbench/domains/logic/crosstalk/pairs.py +661 -0
- reader_workbench/domains/logic/four_state_vector/__init__.py +7 -0
- reader_workbench/domains/logic/four_state_vector/builder.py +321 -0
- reader_workbench/domains/logic/four_state_vector/collection/__init__.py +20 -0
- reader_workbench/domains/logic/four_state_vector/collection/checks.py +49 -0
- reader_workbench/domains/logic/four_state_vector/collection/constants.py +29 -0
- reader_workbench/domains/logic/four_state_vector/collection/model.py +19 -0
- reader_workbench/domains/logic/four_state_vector/collection/render.py +383 -0
- reader_workbench/domains/logic/four_state_vector/collection/sources.py +185 -0
- reader_workbench/domains/logic/four_state_vector/config.py +214 -0
- reader_workbench/domains/logic/four_state_vector/diagnostic.py +361 -0
- reader_workbench/domains/logic/four_state_vector/heatmap.py +86 -0
- reader_workbench/domains/logic/four_state_vector/math.py +191 -0
- reader_workbench/domains/logic/four_state_vector/reference.py +85 -0
- reader_workbench/domains/logic/four_state_vector/selection.py +228 -0
- reader_workbench/domains/logic/four_state_vector/treatment_semantics.py +51 -0
- reader_workbench/domains/logic/four_state_vector/validation.py +19 -0
- reader_workbench/domains/logic/logic_symmetry/__init__.py +3 -0
- reader_workbench/domains/logic/logic_symmetry/encodings.py +93 -0
- reader_workbench/domains/logic/logic_symmetry/extract_corners.py +192 -0
- reader_workbench/domains/logic/logic_symmetry/main.py +236 -0
- reader_workbench/domains/logic/logic_symmetry/metrics.py +96 -0
- reader_workbench/domains/logic/logic_symmetry/overlay.py +129 -0
- reader_workbench/domains/logic/logic_symmetry/prep.py +138 -0
- reader_workbench/domains/logic/logic_symmetry/render.py +356 -0
- reader_workbench/domains/logic/treatment_columns.py +42 -0
- reader_workbench/domains/plate_reader/__init__.py +1 -0
- reader_workbench/domains/plate_reader/analysis/__init__.py +14 -0
- reader_workbench/domains/plate_reader/analysis/fold_change.py +474 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/__init__.py +21 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/aggregation.py +191 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/contract_fields.py +50 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/contracts.py +320 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/design_dispositions.py +54 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/disposition_records.py +140 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/event_sensitivity.py +27 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/materialize.py +274 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/observation_resampling.py +97 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/reduction.py +62 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/seeds.py +15 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/sources.py +308 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusion_validation.py +94 -0
- reader_workbench/domains/plate_reader/analysis/four_state_event_window/well_exclusions.py +54 -0
- reader_workbench/domains/plate_reader/analysis/timepoints.py +76 -0
- reader_workbench/domains/plate_reader/io/__init__.py +6 -0
- reader_workbench/domains/plate_reader/io/sample_map.py +65 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/__init__.py +6 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_kinetic.py +141 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_parser.py +295 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_shared.py +195 -0
- reader_workbench/domains/plate_reader/io/synergy_h1/_snapshot.py +130 -0
- reader_workbench/domains/plate_reader/ordering.py +59 -0
- reader_workbench/domains/plate_reader/plots/__init__.py +15 -0
- reader_workbench/domains/plate_reader/plots/_data.py +29 -0
- reader_workbench/domains/plate_reader/plots/common.py +346 -0
- reader_workbench/domains/plate_reader/plots/distributions.py +324 -0
- reader_workbench/domains/plate_reader/plots/dual_reporter_triptych.py +525 -0
- reader_workbench/domains/plate_reader/plots/dual_reporter_triptych_render.py +195 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/__init__.py +26 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic.py +295 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_components.py +149 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_render.py +308 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/diagnostic_style.py +51 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/schema.py +8 -0
- reader_workbench/domains/plate_reader/plots/four_state_event_window/summary.py +140 -0
- reader_workbench/domains/plate_reader/plots/grouping.py +53 -0
- reader_workbench/domains/plate_reader/plots/panels/__init__.py +12 -0
- reader_workbench/domains/plate_reader/plots/panels/snapshot.py +161 -0
- reader_workbench/domains/plate_reader/plots/panels/snapshot_data.py +91 -0
- reader_workbench/domains/plate_reader/plots/panels/time_series.py +296 -0
- reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic.py +435 -0
- reader_workbench/domains/plate_reader/plots/single_reporter_diagnostic_render.py +300 -0
- reader_workbench/domains/plate_reader/plots/snapshot_barplot/__init__.py +315 -0
- reader_workbench/domains/plate_reader/plots/snapshot_barplot/planning.py +168 -0
- reader_workbench/domains/plate_reader/plots/snapshot_heatmap/__init__.py +205 -0
- reader_workbench/domains/plate_reader/plots/snapshot_heatmap/inputs.py +103 -0
- reader_workbench/domains/plate_reader/plots/time_series.py +317 -0
- reader_workbench/domains/plate_reader/plots/ts_and_snap/__init__.py +479 -0
- reader_workbench/domains/plate_reader/plots/ts_and_snap/planning.py +283 -0
- reader_workbench/domains/time_series/__init__.py +29 -0
- reader_workbench/domains/time_series/aggregation.py +60 -0
- reader_workbench/domains/time_series/contracts.py +368 -0
- reader_workbench/domains/time_series/reduction.py +395 -0
- reader_workbench/errors.py +55 -0
- reader_workbench/maintenance/__init__.py +6 -0
- reader_workbench/maintenance/docs.py +335 -0
- reader_workbench/maintenance/model.py +28 -0
- reader_workbench/maintenance/release.py +39 -0
- reader_workbench/maintenance/skills.py +124 -0
- reader_workbench/plotting/__init__.py +20 -0
- reader_workbench/plotting/mpl.py +56 -0
- reader_workbench/plotting/sinks.py +69 -0
- reader_workbench/plotting/style.py +175 -0
- reader_workbench/plotting/utils.py +27 -0
- reader_workbench/plugins/__init__.py +1 -0
- reader_workbench/plugins/catalog.py +33 -0
- reader_workbench/plugins/export/__init__.py +0 -0
- reader_workbench/plugins/export/_paths.py +21 -0
- reader_workbench/plugins/export/csv.py +41 -0
- reader_workbench/plugins/export/xlsx.py +44 -0
- reader_workbench/plugins/ingest/__init__.py +0 -0
- reader_workbench/plugins/ingest/_discovery.py +58 -0
- reader_workbench/plugins/ingest/discovery_policy.py +66 -0
- reader_workbench/plugins/ingest/flow_cytometer.py +139 -0
- reader_workbench/plugins/ingest/synergy_h1.py +234 -0
- reader_workbench/plugins/manifests/__init__.py +1 -0
- reader_workbench/plugins/manifests/export.py +29 -0
- reader_workbench/plugins/manifests/ingest.py +29 -0
- reader_workbench/plugins/manifests/plot.py +161 -0
- reader_workbench/plugins/manifests/transform.py +172 -0
- reader_workbench/plugins/manifests/validator.py +18 -0
- reader_workbench/plugins/plot/__init__.py +0 -0
- reader_workbench/plugins/plot/_shared.py +55 -0
- reader_workbench/plugins/plot/cytometry_diagnostic.py +54 -0
- reader_workbench/plugins/plot/distributions.py +58 -0
- reader_workbench/plugins/plot/dual_reporter_triptych.py +224 -0
- reader_workbench/plugins/plot/four_state_event_window_diagnostic.py +133 -0
- reader_workbench/plugins/plot/four_state_event_window_summary.py +53 -0
- reader_workbench/plugins/plot/four_state_vector_collection.py +45 -0
- reader_workbench/plugins/plot/four_state_vector_diagnostic.py +105 -0
- reader_workbench/plugins/plot/four_state_vector_heatmap.py +63 -0
- reader_workbench/plugins/plot/logic_symmetry.py +56 -0
- reader_workbench/plugins/plot/single_reporter_diagnostic.py +279 -0
- reader_workbench/plugins/plot/snapshot_barplot.py +65 -0
- reader_workbench/plugins/plot/snapshot_heatmap.py +104 -0
- reader_workbench/plugins/plot/time_series.py +114 -0
- reader_workbench/plugins/plot/ts_and_snap.py +210 -0
- reader_workbench/plugins/transform/__init__.py +0 -0
- reader_workbench/plugins/transform/_four_state_vector.py +204 -0
- reader_workbench/plugins/transform/_labeling.py +109 -0
- reader_workbench/plugins/transform/alias.py +70 -0
- reader_workbench/plugins/transform/assay_labels.py +62 -0
- reader_workbench/plugins/transform/blank.py +79 -0
- reader_workbench/plugins/transform/crosstalk_pairs.py +180 -0
- reader_workbench/plugins/transform/cytometry_gating.py +120 -0
- reader_workbench/plugins/transform/fold_change.py +79 -0
- reader_workbench/plugins/transform/four_state_event_window.py +93 -0
- reader_workbench/plugins/transform/four_state_vector.py +62 -0
- reader_workbench/plugins/transform/four_state_vector_collection.py +41 -0
- reader_workbench/plugins/transform/logic_symmetry.py +67 -0
- reader_workbench/plugins/transform/outlier_filter.py +60 -0
- reader_workbench/plugins/transform/overflow.py +197 -0
- reader_workbench/plugins/transform/ratio.py +237 -0
- reader_workbench/plugins/transform/sample_map.py +170 -0
- reader_workbench/plugins/transform/sample_metadata.py +94 -0
- reader_workbench/plugins/validator/__init__.py +1 -0
- reader_workbench/plugins/validator/to_tidy_plus_map.py +155 -0
- reader_workbench/protocols/__init__.py +80 -0
- reader_workbench/protocols/_builtins_plate_reader_growth.py +179 -0
- reader_workbench/protocols/_builtins_plate_reader_variants.py +274 -0
- reader_workbench/protocols/builtins.py +1656 -0
- reader_workbench/protocols/compiler.py +22 -0
- reader_workbench/protocols/compilers/__init__.py +1 -0
- reader_workbench/protocols/compilers/common.py +100 -0
- reader_workbench/protocols/compilers/cytometry.py +87 -0
- reader_workbench/protocols/compilers/generic.py +14 -0
- reader_workbench/protocols/compilers/logic.py +245 -0
- reader_workbench/protocols/compilers/plate_reader.py +937 -0
- reader_workbench/protocols/compilers/plate_reader_pipeline.py +197 -0
- reader_workbench/protocols/model.py +1486 -0
- reader_workbench/protocols/semantic_coverage.py +234 -0
- reader_workbench/runtime/__init__.py +12 -0
- reader_workbench/runtime/builtin.py +23 -0
- reader_workbench/runtime/model.py +42 -0
- reader_workbench/workbench/__init__.py +60 -0
- reader_workbench/workbench/assets/__init__.py +22 -0
- reader_workbench/workbench/assets/types.py +118 -0
- reader_workbench/workbench/audit/__init__.py +5 -0
- reader_workbench/workbench/audit/experiments.py +307 -0
- reader_workbench/workbench/audit/staging.py +187 -0
- reader_workbench/workbench/cli/__init__.py +51 -0
- reader_workbench/workbench/cli/_lazy.py +9 -0
- reader_workbench/workbench/cli/_records_view.py +150 -0
- reader_workbench/workbench/cli/_surface_execution.py +443 -0
- reader_workbench/workbench/cli/audit.py +95 -0
- reader_workbench/workbench/cli/automation.py +229 -0
- reader_workbench/workbench/cli/demo.py +46 -0
- reader_workbench/workbench/cli/dop.py +91 -0
- reader_workbench/workbench/cli/experiments.py +635 -0
- reader_workbench/workbench/cli/helpers.py +232 -0
- reader_workbench/workbench/cli/main.py +59 -0
- reader_workbench/workbench/cli/maintenance.py +82 -0
- reader_workbench/workbench/cli/notebooks.py +260 -0
- reader_workbench/workbench/cli/pagination.py +117 -0
- reader_workbench/workbench/cli/protocols.py +336 -0
- reader_workbench/workbench/cli/shared.py +309 -0
- reader_workbench/workbench/cli/surfaces.py +534 -0
- reader_workbench/workbench/cli/verification.py +128 -0
- reader_workbench/workbench/commands.py +10 -0
- reader_workbench/workbench/config/__init__.py +47 -0
- reader_workbench/workbench/config/identity.py +13 -0
- reader_workbench/workbench/config/load.py +405 -0
- reader_workbench/workbench/config/model.py +274 -0
- reader_workbench/workbench/context.py +26 -0
- reader_workbench/workbench/decl/__init__.py +31 -0
- reader_workbench/workbench/decl/build.py +190 -0
- reader_workbench/workbench/decl/model.py +81 -0
- reader_workbench/workbench/dop/__init__.py +12 -0
- reader_workbench/workbench/dop/builtins.py +261 -0
- reader_workbench/workbench/dop/model.py +209 -0
- reader_workbench/workbench/engine/__init__.py +42 -0
- reader_workbench/workbench/engine/_shared.py +76 -0
- reader_workbench/workbench/engine/contracts.py +283 -0
- reader_workbench/workbench/engine/execution.py +326 -0
- reader_workbench/workbench/engine/file_outputs.py +260 -0
- reader_workbench/workbench/engine/inputs.py +161 -0
- reader_workbench/workbench/engine/invocations.py +507 -0
- reader_workbench/workbench/engine/planning.py +72 -0
- reader_workbench/workbench/engine/runtime.py +464 -0
- reader_workbench/workbench/engine/setup.py +149 -0
- reader_workbench/workbench/engine/validation.py +684 -0
- reader_workbench/workbench/experiment/__init__.py +47 -0
- reader_workbench/workbench/experiment/model.py +381 -0
- reader_workbench/workbench/experiments.py +133 -0
- reader_workbench/workbench/graph/__init__.py +47 -0
- reader_workbench/workbench/graph/nodes.py +102 -0
- reader_workbench/workbench/graph/normalize.py +177 -0
- reader_workbench/workbench/graph/refs.py +148 -0
- reader_workbench/workbench/input_discovery.py +19 -0
- reader_workbench/workbench/inspection/__init__.py +3 -0
- reader_workbench/workbench/inspection/catalogs.py +128 -0
- reader_workbench/workbench/inspection/common.py +92 -0
- reader_workbench/workbench/inspection/dop.py +64 -0
- reader_workbench/workbench/inspection/experiments.py +449 -0
- reader_workbench/workbench/inspection/inventory.py +68 -0
- reader_workbench/workbench/inspection/protocols.py +368 -0
- reader_workbench/workbench/inspection/readiness.py +333 -0
- reader_workbench/workbench/inspection/reports.py +367 -0
- reader_workbench/workbench/inspection/results.py +166 -0
- reader_workbench/workbench/inspection/runtime.py +287 -0
- reader_workbench/workbench/inspection/semantics.py +192 -0
- reader_workbench/workbench/inspection/validation.py +30 -0
- reader_workbench/workbench/notebooks/__init__.py +17 -0
- reader_workbench/workbench/notebooks/_launch_registry.py +112 -0
- reader_workbench/workbench/notebooks/_launch_runtime.py +104 -0
- reader_workbench/workbench/notebooks/components/__init__.py +21 -0
- reader_workbench/workbench/notebooks/components/deliverables.py +403 -0
- reader_workbench/workbench/notebooks/components/overview.py +119 -0
- reader_workbench/workbench/notebooks/eda.marimo.py.txt +153 -0
- reader_workbench/workbench/notebooks/launch.py +274 -0
- reader_workbench/workbench/notebooks/presentation.py +136 -0
- reader_workbench/workbench/notebooks/scaffold.py +60 -0
- reader_workbench/workbench/ontology.py +78 -0
- reader_workbench/workbench/paths.py +44 -0
- reader_workbench/workbench/ports/__init__.py +31 -0
- reader_workbench/workbench/ports/model.py +168 -0
- reader_workbench/workbench/records/__init__.py +44 -0
- reader_workbench/workbench/records/epoch.py +329 -0
- reader_workbench/workbench/records/evidence.py +247 -0
- reader_workbench/workbench/records/identity.py +87 -0
- reader_workbench/workbench/records/locking.py +185 -0
- reader_workbench/workbench/records/model.py +711 -0
- reader_workbench/workbench/records/sources.py +73 -0
- reader_workbench/workbench/records/store.py +1022 -0
- reader_workbench/workbench/records/verification.py +998 -0
- reader_workbench/workbench/registry.py +333 -0
- reader_workbench/workbench/spec_overrides.py +215 -0
- reader_workbench-1.0.0.dist-info/METADATA +91 -0
- reader_workbench-1.0.0.dist-info/RECORD +293 -0
- reader_workbench-1.0.0.dist-info/WHEEL +5 -0
- reader_workbench-1.0.0.dist-info/entry_points.txt +2 -0
- reader_workbench-1.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|