rainspout 1.0.0__tar.gz

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 (90) hide show
  1. rainspout-1.0.0/.github/workflows/ci.yml +26 -0
  2. rainspout-1.0.0/.github/workflows/publish.yml +60 -0
  3. rainspout-1.0.0/.gitignore +8 -0
  4. rainspout-1.0.0/LICENSE +21 -0
  5. rainspout-1.0.0/PKG-INFO +52 -0
  6. rainspout-1.0.0/RAINSPOUT_DESIGN.md +463 -0
  7. rainspout-1.0.0/README.md +26 -0
  8. rainspout-1.0.0/docs/CONFIG_AUTHORING.md +346 -0
  9. rainspout-1.0.0/docs/HANDLER_AUTHORING.md +505 -0
  10. rainspout-1.0.0/docs/PACKAGE_AUTHORING.md +228 -0
  11. rainspout-1.0.0/docs/README.md +141 -0
  12. rainspout-1.0.0/docs/STAGE_AUTHORING.md +499 -0
  13. rainspout-1.0.0/docs/templates/package_ci.yml +31 -0
  14. rainspout-1.0.0/docs/tutorials/01_add_a_handler.md +169 -0
  15. rainspout-1.0.0/docs/tutorials/02_add_a_stage.md +141 -0
  16. rainspout-1.0.0/docs/tutorials/03_create_a_run.md +186 -0
  17. rainspout-1.0.0/pyproject.toml +73 -0
  18. rainspout-1.0.0/src/rainspout/__init__.py +9 -0
  19. rainspout-1.0.0/src/rainspout/cli/__init__.py +1 -0
  20. rainspout-1.0.0/src/rainspout/cli/_mount.py +40 -0
  21. rainspout-1.0.0/src/rainspout/cli/build_image.py +46 -0
  22. rainspout-1.0.0/src/rainspout/cli/main.py +260 -0
  23. rainspout-1.0.0/src/rainspout/config.py +235 -0
  24. rainspout-1.0.0/src/rainspout/conformance.py +267 -0
  25. rainspout-1.0.0/src/rainspout/contracts/__init__.py +45 -0
  26. rainspout-1.0.0/src/rainspout/contracts/_enforcement.py +97 -0
  27. rainspout-1.0.0/src/rainspout/contracts/dimension.py +37 -0
  28. rainspout-1.0.0/src/rainspout/contracts/handler.py +361 -0
  29. rainspout-1.0.0/src/rainspout/contracts/metadata.py +90 -0
  30. rainspout-1.0.0/src/rainspout/contracts/models.py +37 -0
  31. rainspout-1.0.0/src/rainspout/contracts/reference.py +78 -0
  32. rainspout-1.0.0/src/rainspout/contracts/stage.py +129 -0
  33. rainspout-1.0.0/src/rainspout/dag.py +73 -0
  34. rainspout-1.0.0/src/rainspout/devtools/__init__.py +1 -0
  35. rainspout-1.0.0/src/rainspout/devtools/version_bump.py +97 -0
  36. rainspout-1.0.0/src/rainspout/discovery.py +43 -0
  37. rainspout-1.0.0/src/rainspout/driver.py +297 -0
  38. rainspout-1.0.0/src/rainspout/errors.py +77 -0
  39. rainspout-1.0.0/src/rainspout/oplog.py +96 -0
  40. rainspout-1.0.0/src/rainspout/provenance.py +76 -0
  41. rainspout-1.0.0/src/rainspout/registry.py +45 -0
  42. rainspout-1.0.0/src/rainspout/runner.py +171 -0
  43. rainspout-1.0.0/src/rainspout/testing.py +188 -0
  44. rainspout-1.0.0/src/rainspout/validation.py +258 -0
  45. rainspout-1.0.0/tests/conftest.py +13 -0
  46. rainspout-1.0.0/tests/fake_package.py +143 -0
  47. rainspout-1.0.0/tests/reference_content/__init__.py +0 -0
  48. rainspout-1.0.0/tests/reference_content/components.py +11 -0
  49. rainspout-1.0.0/tests/reference_content/handlers/__init__.py +0 -0
  50. rainspout-1.0.0/tests/reference_content/handlers/ref_grid_json/__init__.py +0 -0
  51. rainspout-1.0.0/tests/reference_content/handlers/ref_grid_json/example_data/7_alpha.json +1 -0
  52. rainspout-1.0.0/tests/reference_content/handlers/ref_grid_json/handler.py +39 -0
  53. rainspout-1.0.0/tests/reference_content/handlers/ref_grid_json/test_roundtrip.py +13 -0
  54. rainspout-1.0.0/tests/reference_content/handlers/ref_lines_txt/__init__.py +0 -0
  55. rainspout-1.0.0/tests/reference_content/handlers/ref_lines_txt/example_data/alpha/7.txt +2 -0
  56. rainspout-1.0.0/tests/reference_content/handlers/ref_lines_txt/handler.py +39 -0
  57. rainspout-1.0.0/tests/reference_content/handlers/ref_lines_txt/test_roundtrip.py +15 -0
  58. rainspout-1.0.0/tests/reference_content/handlers/ref_table_json/__init__.py +0 -0
  59. rainspout-1.0.0/tests/reference_content/handlers/ref_table_json/example_data/alpha.json +1 -0
  60. rainspout-1.0.0/tests/reference_content/handlers/ref_table_json/handler.py +41 -0
  61. rainspout-1.0.0/tests/reference_content/handlers/ref_table_json/test_roundtrip.py +13 -0
  62. rainspout-1.0.0/tests/reference_content/stages/__init__.py +0 -0
  63. rainspout-1.0.0/tests/reference_content/stages/ref_enrich/__init__.py +0 -0
  64. rainspout-1.0.0/tests/reference_content/stages/ref_enrich/stage.py +51 -0
  65. rainspout-1.0.0/tests/reference_content/stages/ref_enrich/test_ref_enrich.py +31 -0
  66. rainspout-1.0.0/tests/reference_content/stages/ref_snip/__init__.py +0 -0
  67. rainspout-1.0.0/tests/reference_content/stages/ref_snip/stage.py +41 -0
  68. rainspout-1.0.0/tests/reference_content/stages/ref_snip/test_ref_snip.py +20 -0
  69. rainspout-1.0.0/tests/roundtrip_handlers.py +130 -0
  70. rainspout-1.0.0/tests/runner_components.py +104 -0
  71. rainspout-1.0.0/tests/sample_components.py +93 -0
  72. rainspout-1.0.0/tests/test_cli.py +120 -0
  73. rainspout-1.0.0/tests/test_cli_verbs.py +208 -0
  74. rainspout-1.0.0/tests/test_config_models.py +123 -0
  75. rainspout-1.0.0/tests/test_conformance.py +154 -0
  76. rainspout-1.0.0/tests/test_data_plane.py +283 -0
  77. rainspout-1.0.0/tests/test_dimensions.py +132 -0
  78. rainspout-1.0.0/tests/test_discovery.py +63 -0
  79. rainspout-1.0.0/tests/test_driver.py +289 -0
  80. rainspout-1.0.0/tests/test_handler_contract.py +219 -0
  81. rainspout-1.0.0/tests/test_provenance.py +56 -0
  82. rainspout-1.0.0/tests/test_reference.py +46 -0
  83. rainspout-1.0.0/tests/test_reference_content.py +141 -0
  84. rainspout-1.0.0/tests/test_registry.py +54 -0
  85. rainspout-1.0.0/tests/test_runner.py +274 -0
  86. rainspout-1.0.0/tests/test_stage_contract.py +214 -0
  87. rainspout-1.0.0/tests/test_testing_helpers.py +179 -0
  88. rainspout-1.0.0/tests/test_validation.py +320 -0
  89. rainspout-1.0.0/tests/test_version_bump.py +84 -0
  90. rainspout-1.0.0/uv.lock +749 -0
@@ -0,0 +1,26 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ checks:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ with:
14
+ fetch-depth: 0 # full history: the version-bump check diffs against main
15
+ - uses: astral-sh/setup-uv@v5
16
+ - name: Sync environment
17
+ run: uv sync
18
+ - name: Lint
19
+ run: uv run ruff check src tests
20
+ - name: Type-check
21
+ run: uv run mypy
22
+ - name: Tests + coverage floor
23
+ run: uv run pytest # --cov-fail-under=90 enforced via pyproject addopts
24
+ - name: Stage version-bump check
25
+ if: github.event_name == 'pull_request'
26
+ run: uv run python -m rainspout.devtools.version_bump --base origin/${{ github.base_ref }}
@@ -0,0 +1,60 @@
1
+ name: publish
2
+
3
+ # Publishing is tag-driven: pushing a tag `vX.Y.Z` runs the full gate suite,
4
+ # checks the tag matches the package version, builds sdist + wheel, and
5
+ # publishes to PyPI via trusted publishing (OIDC — no long-lived token).
6
+ # One-time setup on pypi.org: add this repo as a trusted publisher for the
7
+ # `rainspout` project (owner: strongmats, repo: rainspout,
8
+ # workflow: publish.yml, environment: pypi).
9
+
10
+ on:
11
+ push:
12
+ tags: ["v*"]
13
+
14
+ jobs:
15
+ checks:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: astral-sh/setup-uv@v5
20
+ - name: Sync environment
21
+ run: uv sync
22
+ - name: Lint
23
+ run: uv run ruff check src tests
24
+ - name: Type-check
25
+ run: uv run mypy
26
+ - name: Tests + coverage floor
27
+ run: uv run pytest
28
+ - name: Tag must match the package version
29
+ run: |
30
+ version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
31
+ if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
32
+ echo "tag ${GITHUB_REF_NAME} does not match pyproject version ${version}" >&2
33
+ exit 1
34
+ fi
35
+
36
+ build:
37
+ needs: checks
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: astral-sh/setup-uv@v5
42
+ - name: Build sdist and wheel
43
+ run: uv build
44
+ - uses: actions/upload-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+
49
+ publish:
50
+ needs: build
51
+ runs-on: ubuntu-latest
52
+ environment: pypi
53
+ permissions:
54
+ id-token: write # trusted publishing (OIDC)
55
+ steps:
56
+ - uses: actions/download-artifact@v4
57
+ with:
58
+ name: dist
59
+ path: dist/
60
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .coverage
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Strong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: rainspout
3
+ Version: 1.0.0
4
+ Summary: A domain-agnostic frame for building scientific data-processing pipelines
5
+ Project-URL: Homepage, https://github.com/strongmats/rainspout
6
+ Project-URL: Documentation, https://github.com/strongmats/rainspout/tree/main/docs
7
+ Project-URL: Issues, https://github.com/strongmats/rainspout/issues
8
+ Author-email: Matthew Strong <strongmats@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: data-processing,pipeline,provenance,scientific-computing,workflow
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: pydantic<3,>=2
23
+ Requires-Dist: pyyaml>=6
24
+ Requires-Dist: typer>=0.12
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Rainspout
28
+
29
+ A domain-agnostic frame for building scientific data-processing pipelines.
30
+ Rainspout contains no science of its own: content packages ship the stages
31
+ (processing steps) and handlers (storage adapters); one YAML config describes
32
+ a run; the `spout` command executes it, tracking what has already been done.
33
+
34
+ **Start with [`docs/README.md`](docs/README.md)** — the documentation is the
35
+ API: you can build a content package from the authoring guides alone, without
36
+ reading this repository's source.
37
+
38
+ ## Installation
39
+
40
+ ```
41
+ pip install rainspout # or: uv add rainspout
42
+ spout --help
43
+ ```
44
+
45
+ ## Development
46
+
47
+ ```
48
+ uv sync # create the environment
49
+ uv run pytest # tests (coverage floor enforced)
50
+ uv run ruff check # lint
51
+ uv run mypy # types
52
+ ```
@@ -0,0 +1,463 @@
1
+ # Rainspout — Consolidated Design Document (v2)
2
+
3
+ > **Rainspout** is a general-purpose scientific data-processing pipeline *framework*
4
+ > (the skeleton). It is domain-agnostic. **SkyCT** — the VLF ionospheric-tomography
5
+ > work — is one *content package* built on top of Rainspout, not the framework itself.
6
+ > The distribution/import name is `rainspout`; the CLI command is `spout`.
7
+
8
+ > This document is the **single consolidated source of truth**: the original design
9
+ > document with every decision from the Gate 1 review, the Gate 2 review, the Gate 2b
10
+ > question round, the Gate 2c corrections, and the Phase 1 review folded in. It
11
+ > supersedes the layered prompt-2 document + response stack. The authoring documents
12
+ > under `docs/` are the *normative developer-facing contract*; this document is the
13
+ > design rationale and decision record behind them. Where they could ever disagree,
14
+ > `docs/` governs component authors and this document governs the skeleton's builders —
15
+ > and the disagreement is a bug to be reported.
16
+
17
+ ---
18
+
19
+ ## A. Foundational philosophy
20
+
21
+ - A reusable **skeleton/shell** that scientific (or other) processing pipelines are
22
+ built on top of. The skeleton contains **no science**.
23
+ - **Separate "what / where / how."** Stages (what computation), handlers (where data
24
+ lives and how it is read/written), dimensions/driver (over what the run sweeps) —
25
+ each independent, none leaking into the others.
26
+ - **Break loudly, early.** Every misconfiguration fails at startup with a specific,
27
+ actionable message naming the offender — never a silent wrong result or a deep
28
+ runtime crash.
29
+ - **Declared-in-code, validated-against-config.** Everything a component needs is
30
+ declared in code and checked against config before any data moves.
31
+ - **Blind-authorable contracts.** Someone with only an authoring doc (no skeleton
32
+ access) can build a conforming component. The docs are the literal API boundary.
33
+ - **Documentation-driven design.** The authoring standards were written FIRST (Gate 2)
34
+ and define the interface; the machinery is built to satisfy them.
35
+ - **Deep-validate cheap things (settings); cheap-check expensive things (data).**
36
+ - **The skeleton is dimension-agnostic; stages are coordinate-aware.** No dimension
37
+ name is hardcoded anywhere in Rainspout — names come from the run config. Stages
38
+ *read* their work-item coordinate freely (position-dependent science is legitimate)
39
+ but never forge or alter it. *(Gate 2 Part B, replacing the earlier
40
+ "stages are dimension-blind" over-application.)*
41
+ - **Presence-and-wiring is the guarantee boundary for handler dependencies.** The
42
+ skeleton guarantees a stage that its declared dependencies are satisfied and
43
+ reference real, validly-configured components — exactly parallel to what it
44
+ guarantees for settings. How the stage *uses* a handler it was handed is private
45
+ and trust-based; misuse is an isolated runtime failure. *(Gate 2c.)*
46
+
47
+ ---
48
+
49
+ ## B. Core / content split (plugin architecture)
50
+
51
+ - **`rainspout` (the skeleton):** the brain (registry, config, validation, runner,
52
+ driver, two logging systems, CLI), the base-class contracts, and the authoring
53
+ standards. No science, no real handlers, no real stages.
54
+ - **Content packages** (e.g. `skyct`): concrete stages + handlers + example configs
55
+ (+ optional package CLI verbs + lifecycle/artifact commands), obeying the contracts
56
+ and registering via **entry points**. Multiple packages coexist without knowing
57
+ about each other.
58
+ - **The authoring docs are the inter-repo contract** and a first-class deliverable;
59
+ they live in `docs/` at the repo root (canonical home), written to be readable by a
60
+ newcomer (plain-language overview + glossary in `docs/README.md`, a "short version"
61
+ preamble on each standard).
62
+ - **Public API surface = `rainspout.contracts` + `rainspout.testing`, nothing else.**
63
+ Both carry a v1 **stability commitment** (packages pin `rainspout>=1,<2`); private
64
+ modules may change without notice. *(Gate 2 #9.)*
65
+ - **Installable packages** via entry points (not drag-drop folders); editable installs
66
+ (`uv add --editable ./pkg`) for live development; a package can move to its own repo
67
+ unchanged once its contracts stabilize.
68
+
69
+ ---
70
+
71
+ ## C. Handlers (the collapsed IO axis)
72
+
73
+ - **Combined handlers** replace freely-mixed Storage×Codec: each handler is ONE fixed
74
+ combination of {file type, in-file structure, filename convention, folder structure,
75
+ access channel}, killing the N×M testing burden.
76
+ - **Naming:** `datatype_channel_type` — **underscores only, no dots** (e.g.
77
+ `broadband_local_mat`) *(Gate 1 C1)*. Purely conventional, never parsed; the
78
+ registry enforces `^[a-z][a-z0-9_]*$` on every axis.
79
+ - **Author surface = private hooks; public verbs are final.** *(Gate 2 #1.)* Authors
80
+ implement `_load_cell` / `_save_cell` / `_catalog_cells` (+ optional `_probe`,
81
+ `_check_structure`); the base class owns final `load`, `load_one`, `save`,
82
+ `catalog`, `preflight`, whose docstrings document exactly what they do around the
83
+ hooks (range expansion, lazy per-cell iteration, single-cell-save enforcement,
84
+ error wrapping). Finality is enforced at class definition *and* against
85
+ post-definition monkey-patching *(Phase 1 review)*.
86
+ - **One input shape.** Every verb takes a dimension spec `{role: (values…)}`; a
87
+ single value is a tuple of one. **The driver expands config ranges before any
88
+ handler call** — handlers never parse range syntax *(Gate 2 #2)*. `load(spec)`
89
+ returns a lazy per-cell iterator of `Cell(coords, data, meta)`. **`load_one(coords)`**
90
+ is a final base convenience — exactly `load` with a one-value spec, returning
91
+ `(data, meta)` directly — and is what stages typically call on an auxiliary handler.
92
+ - **Dimension roles.** A handler declares `dimension_roles` + `dimension_types` — the
93
+ whole dimension vocabulary it knows, named before any config exists. Configs map
94
+ their dimension names onto roles; everything the handler receives is keyed by role.
95
+ - **Two positions, one contract.** *(Gate 2 Part B4; presentation confirmed in the
96
+ Phase 1 review: one concept, two entry points, not two species.)*
97
+ - **Seed handler** — named in the config's `seed:` block; the driver coordinates
98
+ with it per work item. Rigorously validated at startup: mapped roles ↔ iterated
99
+ dimensions exactly, types coercible, then the pre-flight probe.
100
+ - **Auxiliary handler** — handed to a stage as a `handler:`-wired dependency; the
101
+ **stage** computes the coordinates it asks for (via `load_one`/`load`), along
102
+ axes that by default should be assumed **unrelated** to the run's dimensions.
103
+ Not dimension-validated at startup; fitness proves out at runtime, isolated per
104
+ work item. *(Gate 2c #4.)*
105
+ - **Two INDEPENDENT range concepts**, both default-off, declared honestly:
106
+ 1. **Dimension-grid range** (`supports_grid_range`) — multi-cell `load` specs;
107
+ always served lazily per cell. A range asked of a non-range handler fails at
108
+ startup when the ask comes from the run's own wiring, or at call time (that work
109
+ item only) when a stage asks; `load_one` in a loop works against every handler.
110
+ 2. **Within-file windowing** (`supports_windowed_read`) — serve a slice inside one
111
+ stored file without materializing it (HDF5/memmap territory); window-argument
112
+ semantics are handler-defined and documented. Kept visibly distinct from
113
+ `LazyReference.window()` (inter-stage, in-flight) — both docs carry "not to be
114
+ confused with" notes *(Gate 1 Q8)*.
115
+ - **`catalog` surveys only the asked window** *(Gate 1 Q2)* — real-time polls make
116
+ unbounded surveys unaffordable; report a cell only if a load would plausibly
117
+ succeed (cheapest test, no deep validation); yield lazily; optional catalog file
118
+ (validated JSON: handler, roles, entries `{coords, extras}`, generated-at) written
119
+ by the base class.
120
+ - **All structural knowledge is handler-private.** Base directory down — folders,
121
+ filenames, in-file layout, connections — opaque to the skeleton. **Lifecycle is
122
+ private**: per-transaction by default; sessions managed internally; no external
123
+ setup ordering.
124
+ - **Metadata: one file, no sidecars.** *(Gate 2 #6, replacing the sidecar fallback.)*
125
+ The shared metadata block (schema version, `run_id`, coords, provenance chain) is
126
+ embedded **in the data file itself**; the skeleton defines no sidecar convention
127
+ and auxiliary metadata files are discouraged. Metadata handling is **optional**:
128
+ a handler may deliberately ignore it (conforming, provenance-severing, must be
129
+ stated in its docstring). Plain-text formats embed responsibly via one clearly
130
+ delimited, strippable section (e.g. a single `# rainspout-meta: {…}` comment line —
131
+ taught in Tutorial 1). Foreign/metadata-less data always loads with a fresh
132
+ empty-provenance block.
133
+ - **Compression on save** where the format supports it.
134
+
135
+ ---
136
+
137
+ ## D. Stages
138
+
139
+ - **"Stage"** = one processing step (never "module", reserved for `.py` files).
140
+ - **Thin orchestrator.** Declarations + a short `run` calling module-level science
141
+ functions (testable without the skeleton). More than ~a screen of `run` means
142
+ science is leaking into the orchestrator.
143
+ - **Coordinate-aware, not dimension-blind.** *(Gate 2 Part B1/B2.)* Every reference a
144
+ stage receives exposes `ref.coords`: the work item's read-only `{dimension: value}`
145
+ mapping, stamped by the driver at seed time, flowing downstream automatically.
146
+ Stages read it freely; they never forge or alter it (trustworthy provenance). Keys
147
+ are the config author's dimension names, so a stage needing a specific axis takes
148
+ the dimension name as a **bounded setting** (e.g. `time_dim: str = "time"`) rather
149
+ than hardcoding it *(Gate 2b Q3)*.
150
+ - **Three strictly separated input kinds:**
151
+ - **Settings** — static config, deep-validated (`settings_model`, `extra="forbid"`,
152
+ frozen). **Every field bounded** (Field ranges / Literal / Enum / constrained
153
+ strings); unbounded only as a justified, commented exception drawing a lint-style
154
+ conformance warning. *(Gate 1 review addition.)*
155
+ - **Dependencies** — named data inputs (`dependencies_model`); the field's **type
156
+ annotation declares its wiring kind** *(Gate 2 #5)*: `LazyReference` ↔ `from:`
157
+ (an upstream stage or the seed entry), `Handler` ↔ `handler:` (an instance from
158
+ the `handlers:` block). Mismatches fail at startup naming stage + field.
159
+ - **Resources** — a handler's fetch config; never a stage's concern.
160
+ - **Construction & injection.** *(Gate 2c #3.)* Stages construct nothing: the driver
161
+ constructs each stage at validation time (settings through the un-bypassable base
162
+ `__init__`), and per work item resolves and injects dependencies into `run` —
163
+ `from:` fields as coordinate-stamped references, `handler:` fields as ready-to-call
164
+ handler instances built from configured resources. This is also what makes stages
165
+ testable (fakes injected through the same door).
166
+ - **No seed-loader stage.** *(Gate 2 Part B3.)* Data enters through the config's
167
+ `seed:` block; the first stage wires `from: <seed_name>`. No package writes a
168
+ pass-through loader.
169
+ - **Auxiliary handler use is private to the stage.** *(Gate 2c #4.)* The config says
170
+ which handler; the stage decides what to load, deriving coordinates from
171
+ `ref.coords`/settings as it sees fit — a window of cells is simply a loop over
172
+ `load_one`. No skeleton binding, projection, or coordinate validation; loading is
173
+ the only legitimate stage-side use (saving is config-designated). The formerly
174
+ flagged "windowed loader" v1 limitation is dissolved by this model.
175
+ - **Setup hook** — `setup()`, idempotent, after validation, before any work item;
176
+ `spout setup` runs all of them.
177
+ - **Status / progress / warnings / errors:** mandatory `set_status` (≥once per run);
178
+ optional-but-recommended `progress()` (None where totals are unknowable); warnings
179
+ via `add_warning` (output still valid; recorded; still counts as succeeded);
180
+ errors are exceptions (prefer `StageError`) — fail this (stage, work item),
181
+ skip its downstream for this work item only, continue the run. Cheap shape/type
182
+ checks only; never deep per-element validation.
183
+ - **Versioning:** required `version`, bumped on ANY stage-code edit. Enforced by a
184
+ CI diff check; backstopped by a code hash recorded in every provenance entry.
185
+ **Code/test hash boundary** *(Gate 1 Q4 + Gate 2 #7)*: `test_*.py`, `*_test.py`,
186
+ `fixtures/`, `example_data/` are excluded from the hash and the bump requirement.
187
+ - **One stage = one self-contained directory** *(Gate 1 Q4)* — class, science
188
+ modules, fixtures, and its mandated test together; interior structure free;
189
+ copying the directory into another package (plus a registration import) works
190
+ unchanged.
191
+
192
+ ---
193
+
194
+ ## E. Config, DAG, driver, dimensions
195
+
196
+ - **One `.yml` file per run; six top-level keys** (`run`, `dimensions`, `iteration`,
197
+ `seed`, `handlers`, `stages`); unknown keys rejected; examples written in YAML
198
+ block form *(Gate 2c #2)*.
199
+ - **`seed:` — named entries.** *(Gate 2 Part B3 + Gate 2b Q1.)* Each entry gives a
200
+ registry handler, resources, and a role map; the driver loads the seed cell per
201
+ work item, stamps the coordinate, and offers it under the entry's name (`from:
202
+ raw`). Seed names share the upstream namespace with stage instance names.
203
+ **Exactly one entry in v1** — a second fails loudly — but the plural shape makes
204
+ multiple seeds (future branching: sferic + transmitter → tomography) a non-breaking
205
+ addition.
206
+ - **`handlers:` — named instances for the two other storage touchpoints:**
207
+ stage-callable inputs (`handler:`-wired dependencies; **no role map** — the stage
208
+ computes its own coordinates) and save targets (**role map required**, held to the
209
+ seed's standard: complete, onto the iterated dimensions, startup-checked, because
210
+ the driver writes at work-item granularity). A `dimensions:` map anywhere may only
211
+ name declared dimensions — a pure dangling-reference check that **never** compares
212
+ config names to handler-internal names *(Gate 2b Q4)*.
213
+ - **The same `handler:` key wires inputs and saves** — direction comes from
214
+ `dependencies:` vs `save:`, never from the key name *(Gate 2c #1; `loader:` is
215
+ dead vocabulary)*.
216
+ - **Dimensions**: named axes, list form (order preserved, duplicates rejected) or
217
+ inclusive `start/stop/step` ranges (numeric, or date/datetime with `1d`/`6h`/`30m`/
218
+ `15s` steps), expanded by the driver. Dimension names are the config author's
219
+ bookkeeping *(Gate 2 Part B6)* — enforced only at the seed edge and save targets,
220
+ via mappings; no stage-declared "expected dimensions" (deliberately rejected).
221
+ - **A work item** = one point in the dimension cross-product; the whole DAG runs once
222
+ per work item at one granularity. **No mid-DAG collapsing** — aggregation across a
223
+ dimension is a separate run with a different config reading what this one saved.
224
+ - **Linear chains in v1, general machinery underneath.** *(Gate 2 Part B5.)* The
225
+ runner/validator use general graph machinery (topological sort, dependency
226
+ resolution); the v1 single-chain restriction (seed → stage → … → stage) is a
227
+ distinct, loud validation rule, not an assumption baked into the code — branching
228
+ later is non-breaking.
229
+ - **Retrograde and real-time are one mechanism.** The **delta** =
230
+ `exists − attempted` *(Gate 1 Q1)*: the seed's `catalog` minus every work item in
231
+ the operational log, success **or** failure — poison inputs are not hammered.
232
+ Retrograde drains the delta once; realtime drains, sleeps `poll_frequency`
233
+ (required iff realtime, forbidden otherwise), recomputes, forever; never polls
234
+ mid-run. Clean stop *(Gate 1 Q9)*: Ctrl-C/SIGTERM between work items finishes the
235
+ current item, flushes the log, exits 0; a mid-item interrupt just never records
236
+ success, so resume redoes it.
237
+ - **Re-run controls** *(Gate 2 #11 + Gate 2b Q2)*:
238
+ - `--retry-failed` re-queues **failed** cells only; never touches succeeded ones.
239
+ - `--select dim=value` narrows the space first, then the log is subtracted as
240
+ usual (an attempted selected cell is still skipped); composes with
241
+ `--retry-failed` to re-queue exactly the selected failures.
242
+ - `--force-rewrite` — the separate, bluntly named flag that re-runs **succeeded**
243
+ cells (overwriting good output), usually with `--select`. Never overloaded onto
244
+ `--retry-failed`.
245
+ - **Failure isolation per work item**; **sequential in v1** (work items independent,
246
+ future parallelism preserved).
247
+ - **Config-designated saving**: any stage, anywhere in the DAG, may carry
248
+ `save: {handler: <instance>}`; only saved outputs persist; everything else stays
249
+ lazy/in-memory and is discarded per work item. A save is the output-symmetric
250
+ mirror of the seed (both driver-operated, both role-mapped).
251
+ - **Lazy inter-stage passing**: stages exchange `LazyReference`s; `get()` universal,
252
+ `window()` an advertised capability; materialization on demand; contract first
253
+ even where v1 passes in-memory objects underneath.
254
+
255
+ ---
256
+
257
+ ## F. Logging, provenance, resume
258
+
259
+ - **Two systems, never merged:**
260
+ - **Operational log** — keyed per (stage, work item): ran/succeeded/failed,
261
+ warnings/errors, timestamps. Source of truth for resume.
262
+ - **Provenance chain** — travels WITH the data: ordered
263
+ `{stage_name, stage_version, code_hash, settings_used, timestamp}` entries,
264
+ persisted inside the metadata block on every save. Scientific reproducibility.
265
+ - **Identity:** `run_id` per run (`<name>-<UTC stamp>-<suffix>`) + a per-work-item
266
+ sub-key (`cell_id`, the canonical serialized coordinate,
267
+ `day=2026-01-01|sensor=s1`) threading through log records, provenance entries,
268
+ and saved metadata. *(Gate 1 §3.5; confirmed in the Phase 1 review.)*
269
+ - **The log follows the run definition** *(Phase 4 review → Phase 5 decision)*:
270
+ default location `.rainspout/<run.name>.oplog.jsonl` **next to the config
271
+ file**, overridable with the optional `run.oplog:` key (relative paths resolve
272
+ against the config's directory). Never derived from the working directory —
273
+ the resume/delta story requires that a later run always finds the history it
274
+ must subtract; anchoring to the run definition makes that automatic, and a
275
+ deliberate `run.name` change starts a fresh history.
276
+ - Warnings ⇒ succeeded (skipped on resume). Failures ⇒ not re-run by default
277
+ (`--retry-failed` is the escape hatch). Definition errors ⇒ fatal before any data.
278
+
279
+ ---
280
+
281
+ ## G. Settings & validation (cross-cutting)
282
+
283
+ - **Pydantic v2 everywhere**, `extra="forbid"`, frozen after validation.
284
+ - **Un-bypassable validation:** the base `__init__` validates; subclasses may not
285
+ define `__init__` (class-definition-time check; mixins with `__init__` rejected);
286
+ post-definition monkey-patching of `__init__` or final methods is blocked by the
287
+ contract metaclass and covered by negative tests *(Phase 1 review)*. Post-validation
288
+ init goes in `setup()`.
289
+ - **`run` = `validate` + execution; no skip path.** Gates in order: (1) config parse,
290
+ (2) registry resolution, (3) seed rule, (4) DAG validation (wiring kinds, dangling
291
+ references, acyclicity, v1 linearity), (5) per-component settings/resources
292
+ validation. All definition-time, instant, touching no data.
293
+ - **Startup pre-flight probe — seed only.** *(Gate 1 review + Gate 2 #4.)* Steps:
294
+ (1) role-map check, (2) type check from `dimension_types`, (3) structural probe on
295
+ the first cataloged cell in the run window — default `_probe` = full load +
296
+ `_check_structure`, overridable where loads are expensive, (4) empty catalog ⇒
297
+ probe skipped with a loud logged notice (legitimate in realtime). Auxiliary
298
+ instances get resources validation only. **Deliberately NO inter-stage structural
299
+ checks** — a wrong structure between stages is an isolated per-work-item runtime
300
+ failure; validate the storage edges strictly, never burden the internal seams.
301
+ - **Every failure mode has a named-offender message:** bad/out-of-range setting;
302
+ missing/extra/misspelled dependency; wrong wiring kind; `from:` → nonexistent
303
+ upstream; DAG cycle; branching in v1; bad handler resources; malformed config;
304
+ unknown registry key (listing known keys); seed role-map miss; uncoercible
305
+ dimension values; save target without/with-incomplete role map; multiple seeds;
306
+ seed/stage name collision; `poll_frequency` misuse; range on a non-range handler;
307
+ unbumped version.
308
+
309
+ ---
310
+
311
+ ## H. Registration & discovery
312
+
313
+ - **One uniform gesture on every axis:** subclass the contract base with a `name` —
314
+ `__init_subclass__` validates the whole class-level contract and registers it.
315
+ Missing/invalid/duplicate names fail at import naming the class (duplicates name
316
+ both parties). The registry maps name → class per axis and is never hand-edited.
317
+ - **Entry points:** `rainspout.components` (one collector module per package;
318
+ import = registration; collisions fail loudly naming both packages) and
319
+ `rainspout.verbs` (a Typer app; mount name = entry-point name). *(Gate 2 #10.)*
320
+ Two documented discovery facts: entry-point changes need `uv sync` (collector-module
321
+ additions are live), and a component missing from the collector fails **silently** —
322
+ verify with `spout catalog`.
323
+
324
+ ---
325
+
326
+ ## I. CLI
327
+
328
+ - **Philosophy:** clean, composable commands; the user's cron/sbatch/Docker
329
+ orchestrates. Orchestration is out of scope.
330
+ - **Verbs:** `run` (with `--dry-run` planning, `--select`, `--retry-failed`,
331
+ `--force-rewrite`), `validate` (definition-only, instant), `catalog`, `setup`,
332
+ `build-image` (v1-minimal: Dockerfile from `uv.lock` + installed entry-point
333
+ packages, nothing cleverer — *Gate 1 Q6*), `test-package`, plus
334
+ **package-contributed verbs** (`spout <package> <verb>`).
335
+
336
+ ---
337
+
338
+ ## J. Lifecycle commands & artifacts (the dissolved "sims")
339
+
340
+ - Packages may ship lifecycle verbs (`train`, `simulate`, `build`, `calibrate`)
341
+ producing **artifacts** — versioned inputs written **through a handler**, loaded by
342
+ a consuming stage as an ordinary `handler:` dependency (artifact version via a
343
+ bounded setting), its version landing in provenance. No separate machinery.
344
+
345
+ ---
346
+
347
+ ## K. Testing & coverage
348
+
349
+ - **Skeleton:** 90% coverage floor (aim 95%+), `--cov-fail-under=90`, hard-failing
350
+ CI; `# pragma: no cover` only with justification — and enforcement paths are
351
+ covered by **negative tests**, never exempted *(Phase 1 review)*.
352
+ - **`rainspout.testing` is public, stable API** *(Gate 2 #9)*: `run_stage` (real
353
+ validation path; wraps deps; optional `coords=`), `from_handler_data` (fake handler
354
+ whose `load_one` returns the object for any coordinates), `assert_roundtrip`
355
+ (load → save → load → equal → catalog; float-tolerant; `equal=` escape hatch;
356
+ checks **preservation of whatever exists** — data always, metadata iff the handler
357
+ claims it; never fails a handler merely for ignoring metadata).
358
+ - **Mandated test shapes**, statically shape-checked at package load (cheap; no
359
+ coverage measurement at load): stages ship `STAGE` + `EXAMPLE_SETTINGS` + a
360
+ known-output test + a failure-path test; handlers ship `HANDLER` +
361
+ `EXAMPLE_RESOURCES` + `EXAMPLE_COORDS` + the round-trip test + tiny committed
362
+ example data. Tests live **inside the component's directory** *(Gate 2 #8)*;
363
+ package-level `tests/` is integration-only. Package coverage is enforced in the
364
+ package's own CI (skeleton CI template; `spout test-package` on demand).
365
+ - **Reference content vs example package:** skeleton `tests/` reference content is
366
+ adversarial and machinery-proving — including a mid-DAG extra `handler:` dependency
367
+ and a trivial, observable setup-hook exercise (**no Cython/C toolchain in CI** —
368
+ *Gate 1 Q7*). The clean example package teaches a user and is the yardstick for
369
+ **blind-agent verification**: a separate agent with only `docs/` + scientific code
370
+ must produce a conforming, runnable package.
371
+
372
+ ---
373
+
374
+ ## L. Tooling
375
+
376
+ uv (lock committed) · Typer · Pydantic v2 · structlog (`run_id`-threaded) · pytest +
377
+ pytest-cov · ruff + mypy · GitHub Actions (lint, type, test, coverage, version-bump).
378
+ Content-package territory: h5py/scipy, ONNX Runtime, Docker (`build-image` target).
379
+
380
+ ---
381
+
382
+ ## M. Build status (the gated process, as executed)
383
+
384
+ 1. **Gate 1** — plan + restatement + proposed answers: delivered, approved with
385
+ corrections (reports/1, prompts/2).
386
+ 2. **Gate 2** — authoring docs + tutorials: delivered (reports/2), approved with
387
+ corrections (prompts/3), revised through 2b (question round) and 2c (config
388
+ naming + auxiliary model); `docs/` at the repo root is the canonical set.
389
+ 3. **Phase 1 — contracts & registry: DONE** (approved with one required change,
390
+ satisfied: negative tests + metaclass guard for the `__init__` invariant).
391
+ 4. **Phase 2 — config & DAG validation, `spout validate`: DONE** (this document
392
+ accompanies its delivery).
393
+ 5. **Phase 3 — handlers & data plane: DONE** (verb bodies, metadata/provenance
394
+ models, `rainspout.testing`).
395
+ 6. **Phase 4 — runner: DONE** (approved; oplog-location question answered in
396
+ Phase 5: the log follows the run definition, §F).
397
+ 7. **Phase 5 — driver + `spout run`: DONE**.
398
+ 8. **Phase 6 — CLI complete (catalog/setup/test-package/build-image, package
399
+ verbs): DONE**.
400
+ 9. **Phase 7 — reference content + example package + CI: DONE** (system
401
+ complete to v1 scope; review gate passed).
402
+ 10. **Phase 8 — literal tutorial verification: DONE.** All three tutorials
403
+ executed step-for-step in a clean environment against the built system;
404
+ divergences fixed on whichever side was wrong (see the decision record).
405
+ Skeleton version set to **1.0.0** — the docs' `rainspout>=1,<2` pin and
406
+ stability commitment now resolve against the real package.
407
+ 11. **Phase 9 — blind-authorability verification: PASSED** (run by an
408
+ independent agent chosen by the project owner to avoid biasing; a
409
+ docs-only build of a conforming package succeeded). The gated build is
410
+ complete. Released under the MIT license; published to PyPI as
411
+ `rainspout` from github.com/strongmats/rainspout (tag-driven trusted
412
+ publishing).
413
+
414
+ ---
415
+
416
+ ## N. Decision record (what changed at each review, in one place)
417
+
418
+ | Decision | Where settled |
419
+ |---|---|
420
+ | Handler names underscore-only, no dots | Gate 1 C1 |
421
+ | Delta = exists − **attempted**; `--retry-failed` escape hatch | Gate 1 Q1 |
422
+ | Window-bounded `catalog` | Gate 1 Q2 |
423
+ | Dimension roles + startup pre-flight probe (4 steps, empty-catalog notice) | Gate 1 Q3 / Gate 2 #4 |
424
+ | One stage = one self-contained **directory**; tests outside the code hash | Gate 1 Q4 / Gate 2 #7 |
425
+ | `__init__` ban incl. mixins, definition-time | Gate 1 Q5 (+ Phase 1 review: metaclass + negative tests) |
426
+ | `build-image` v1-minimal | Gate 1 Q6 |
427
+ | No Cython/C toolchain in CI; observable setup exercise instead | Gate 1 Q7 |
428
+ | Two windowing layers kept visibly distinct | Gate 1 Q8 |
429
+ | Clean realtime stop semantics | Gate 1 Q9 |
430
+ | Bounded settings mandatory | Gate 1 review |
431
+ | No inter-stage structural checks | Gate 1 review |
432
+ | Private hooks under final public verbs | Gate 2 #1 |
433
+ | Driver expands ranges; handlers see explicit values | Gate 2 #2 |
434
+ | Role-map syntax + seed exactness | Gate 2 #3/#4, Part B4 |
435
+ | Wiring kinds as type annotations | Gate 2 #5 |
436
+ | Single-file metadata, no sidecars; optional posture; preserves-what-exists | Gate 2 #6 |
437
+ | Tests inside the component directory | Gate 2 #8 |
438
+ | `rainspout.testing` = stable public API | Gate 2 #9 |
439
+ | Entry-point groups + discovery gotchas documented | Gate 2 #10 |
440
+ | `--select` × delta semantics | Gate 2 #11 |
441
+ | Stages coordinate-aware; skeleton name-agnostic | Gate 2 B1/B2 |
442
+ | `seed:` config block; no seed-loader stage | Gate 2 B3 |
443
+ | Seed vs auxiliary validation split | Gate 2 B4 |
444
+ | Linear v1 on general graph machinery | Gate 2 B5 |
445
+ | Dimension names = config bookkeeping; no stage-declared dims | Gate 2 B6 |
446
+ | **Named** seed entries (`from: raw`); one in v1 | Gate 2b Q1 |
447
+ | `--force-rewrite` as a separate flag | Gate 2b Q2 |
448
+ | Coordinate keys read via bounded settings | Gate 2b Q3 |
449
+ | Aux dangling-reference typo check (config-internal only) | Gate 2b Q4 |
450
+ | `handler:` key for both dependencies and saves (`loader:` retired) | Gate 2c #1 |
451
+ | Block-form YAML in all examples | Gate 2c #2 |
452
+ | Driver constructs stages, injects resolved dependencies | Gate 2c #3 |
453
+ | Auxiliary handlers stage-callable (`load_one(coords)`); presence-and-wiring guarantee; save targets alone carry role maps; windowed-loader limitation dissolved | Gate 2c #4 |
454
+ | `run_id` per-run + per-work-item sub-key | Gate 1 §3.5, confirmed Phase 1 review |
455
+ | Oplog follows the run definition (`.rainspout/<name>.oplog.jsonl` next to the config; `run.oplog:` override resolves against the config dir, never CWD) | Phase 4 review / Phase 5 |
456
+ | Collector imports carry `# noqa: F401` (linters auto-remove them otherwise) | Phase 7 |
457
+ | Skeleton version 1.0.0: the documented `rainspout>=1,<2` pin must resolve | Phase 8 |
458
+ | Emptied YAML mapping keys (`dependencies:` with nothing under it) read as empty, so validation reaches the real named-offender error | Phase 8 |
459
+ | Registry lookups name their owner (`seed 'raw': unknown handler …`) | Phase 8 |
460
+ | Handler runtime errors print canonical coords + the underlying exception type | Phase 8 |
461
+ | Tutorial 3 uses relative `./data` paths; resource values are handler-interpreted (relative paths resolve against the invocation directory, unlike `run.oplog:`) | Phase 8 |
462
+ | Conditional settings = Pydantic discriminated unions (supported natively); bounded rule applies inside every arm — the conformance lint recurses into nested models; nested settings models declared frozen | Phase 8 addendum |
463
+ | Docs code-display convention: annotated templates (`contract:`/`yours:` per line) + worked examples in one fixed made-up domain; framework imports are only `rainspout.contracts`/`rainspout.testing`, stated once in the docs README | Phase 8 addendum 2 |
@@ -0,0 +1,26 @@
1
+ # Rainspout
2
+
3
+ A domain-agnostic frame for building scientific data-processing pipelines.
4
+ Rainspout contains no science of its own: content packages ship the stages
5
+ (processing steps) and handlers (storage adapters); one YAML config describes
6
+ a run; the `spout` command executes it, tracking what has already been done.
7
+
8
+ **Start with [`docs/README.md`](docs/README.md)** — the documentation is the
9
+ API: you can build a content package from the authoring guides alone, without
10
+ reading this repository's source.
11
+
12
+ ## Installation
13
+
14
+ ```
15
+ pip install rainspout # or: uv add rainspout
16
+ spout --help
17
+ ```
18
+
19
+ ## Development
20
+
21
+ ```
22
+ uv sync # create the environment
23
+ uv run pytest # tests (coverage floor enforced)
24
+ uv run ruff check # lint
25
+ uv run mypy # types
26
+ ```