Skip to content

Commit 7a9a7fa

Browse files
authored
Merge pull request #504 from openclimatefix/claude/github-issue-500-review-855247
Catch marimo notebooks whose cells reference unbound names
2 parents aeee369 + 2502d86 commit 7a9a7fa

7 files changed

Lines changed: 501 additions & 66 deletions

File tree

.claude/skills/marimo-notebooks/SKILL.md

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
---
22
name: marimo-notebooks
33
description: >-
4-
Three authoring rules for this repo's Marimo notebooks (`packages/dashboard/*.py`,
5-
`packages/notebooks/*.py`), each reversing a normal Python habit: a leading underscore makes a
6-
name cell-local, so cross-cell helpers must be public; every import belongs in `with app.setup:`
7-
or ruff stops seeing it; and `ruff check --fix` must never be run over a notebook, because an
8-
autofix can insert an import outside `app.setup` and break it while reporting success. Load
9-
before creating or editing a Marimo notebook, or when one fails with a `NameError` on a helper
10-
or an import that looks present.
4+
Authoring rules for this repo's Marimo notebooks (`packages/dashboard/*.py`,
5+
`packages/notebooks/*.py`), most of them reversing a normal Python habit: a leading underscore
6+
makes a name cell-local, so cross-cell helpers must be public; every import belongs in `with
7+
app.setup:` or ruff stops seeing it; `ruff check --fix` must never be run over a notebook,
8+
because an autofix can insert an import outside `app.setup` and break it while reporting
9+
success; and a helper belongs in the `@app.function` form marimo itself writes. Load before
10+
creating or editing a Marimo notebook, when one fails with a `NameError` on a helper or an
11+
import that looks present, or when the `check-marimo-notebooks` hook reports a cell referencing
12+
a name no cell defines.
1113
---
1214

1315
# Authoring Marimo notebooks
@@ -47,3 +49,21 @@ does it, and ruff has no per-file fixability setting to prevent it (`unfixable`
4749
The pre-commit hook is split so notebooks are checked but never auto-fixed; a bare `uv run ruff
4850
check . --fix` typed by hand is *not* covered, so after running one, check `git diff` for an
4951
import that landed above `import marimo` and move it into `app.setup`.
52+
53+
`marimo check --fix` does not rescue a notebook in that state either: it deletes the module-level
54+
import and rewrites the cell that used the name as `def _(name)`, which leaves the name as a cell
55+
input nothing defines — broken in a second way.
56+
57+
Both shapes are caught by `scripts/check_marimo_notebooks.py`, which runs as a pre-commit hook over
58+
changed notebooks and over every notebook from `tests/test_marimo_notebooks.py`. So a mistake here
59+
fails the commit or CI rather than surviving to whoever next opens the notebook. What it catches
60+
and what it cannot:
61+
<https://openclimatefix.github.io/nged-substation-forecast/architecture/testing/#marimo-notebooks-bind-every-name-their-cells-reference>
62+
63+
## Let `marimo check --fix` settle a notebook's shape before committing
64+
65+
A helper hand-written inside an `@app.cell` gets rewritten to a top-level `@app.function` the next
66+
time marimo saves the notebook, so committing the hand-written form buys a large diff for no
67+
change. Write helpers — and any `test_*` function exercising them — in the `@app.function` form,
68+
or run `marimo check --fix` before committing. `packages/notebooks/plot_missing_NWP_data.py` is
69+
the worked example.

.pre-commit-config.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ repos:
4444
language: system
4545
types: [python]
4646
files: *marimo_notebooks
47+
# The hooks above never auto-fix a notebook, but a hand-typed `uv run ruff check . --fix`
48+
# still can, and `marimo check --fix` leaves the same breakage behind by another route. So
49+
# every commit that touches a notebook also checks its cells bind every name they reference
50+
# — see the script's docstring. `tests/test_marimo_notebooks.py` runs the same check in CI.
51+
- id: check-marimo-notebooks
52+
name: marimo notebooks (no unbound names)
53+
entry: uv run python scripts/check_marimo_notebooks.py
54+
language: system
55+
types: [python]
56+
files: *marimo_notebooks
4757
- id: ruff-format
4858
name: ruff format
4959
entry: uv run ruff format --force-exclude

docs/architecture/testing.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,42 @@ present):
239239
the only layer that can catch *future upstream drift* — a change in Dynamical.org's own conventions
240240
that the committed slice, frozen at capture time, cannot.
241241

242+
## Marimo notebooks bind every name their cells reference
243+
244+
Marimo rebuilds a notebook from its `with app.setup:` block plus its `@app.cell` functions, and
245+
never runs the module-level statements in between. A name bound at module level is therefore
246+
invisible to every cell: the notebook raises `NameError` the next time it is opened, while ruff, ty
247+
and pytest all pass, because the file they were handed is valid Python. Two tools produce exactly
248+
that shape from a working notebook — `ruff check --fix`, which writes an import an autofix needs
249+
into the top-level import block, and `marimo check --fix`, which deletes such an import and
250+
rewrites the cell that used the name as `def _(name)`, leaving a cell input nothing defines.
251+
252+
`scripts/check_marimo_notebooks.py` reads each cell's `refs` and `defs` and reports any name a cell
253+
references that no cell binds. It runs as a pre-commit hook over changed notebooks, and
254+
`tests/test_marimo_notebooks.py` runs it over every notebook in `packages/notebooks/` and
255+
`packages/dashboard/`. Three properties are worth knowing:
256+
257+
- **It is static.** Nothing executes, so the check needs none of the notebooks' runtime
258+
dependencies — only marimo itself, which the root environment has via the `dashboard` dev
259+
dependency. It cannot catch a notebook that binds every name and still fails inside a Polars or
260+
Altair call; executing the notebooks is not an option, because they read real Delta tables and
261+
S3.
262+
- **It rides on private marimo API.** `Cell.refs` and `Cell.defs` are documented, but loading a
263+
notebook without running it is not. So the checker raises rather than reporting "no findings"
264+
whenever a file does not parse into at least one cell, and the tests keep a positive control —
265+
a deliberately broken notebook, held as a string so ruff never sees it — that fails if a marimo
266+
release stops the check detecting a real breakage.
267+
- **Every `.py` file directly inside those two directories must be a notebook**, and a file that
268+
is not one is a finding. The ruff pre-commit hooks share that assumption: they use it to decide
269+
which files must never be auto-fixed.
270+
271+
Testing what a notebook's cells actually *do* is a separate job, and
272+
`packages/notebooks/plot_missing_NWP_data.py` is the worked example. Its chart-building helper is
273+
an `@app.function` — marimo's form for a top-level reusable function — so an ordinary `test_*`
274+
function in the same notebook can exercise it on a synthetic frame. Naming the notebook in
275+
`python_files` is what makes a plain `uv run pytest` collect it. The authoring rules for writing
276+
one are in the `marimo-notebooks` skill.
277+
242278
## Assertion style for Patito frames
243279

244280
Build a frame, attach the model, cast, and validate for the happy path:
Lines changed: 104 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import marimo
22

3-
__generated_with = "0.23.6"
3+
__generated_with = "0.23.16"
44
app = marimo.App()
55

66
with app.setup:
@@ -9,13 +9,14 @@
99
import altair as alt
1010
import plotting.ocf_theme # noqa: F401 — registers OCF Altair theme as side effect
1111
import polars as pl
12+
from contracts.weather_schemas import Nwp
1213
from plotting.ocf_theme import GRID, ORANGE_RED
1314

1415

1516
@app.cell
1617
def _():
17-
# TODO: Load polars DataFrame from local Delta Table of NWP data.
18-
return
18+
df = Nwp.scan_delta().drop("nwp_model_id")
19+
return (df,)
1920

2021

2122
@app.cell
@@ -38,71 +39,83 @@ def _():
3839
return (nwp_vars,)
3940

4041

41-
@app.cell
42-
def _(df, nwp_vars):
43-
def plot_null_distribution(df, target_init_time, target_h3_index, nwp_vars):
44-
# 1. Filter down to the specific init_time and h3_index
45-
filtered = df.filter(
46-
(pl.col("init_time") == target_init_time) & (pl.col("h3_index") == target_h3_index)
47-
)
42+
@app.function
43+
def plot_null_distribution(df, target_init_time, target_h3_index, nwp_vars):
44+
"""Chart where `nwp_vars` is missing, one row per ensemble member.
4845
49-
# 2. Unpivot (melt) the data so we can plot all variables on a single Y-axis
50-
melted = filtered.unpivot(
51-
on=nwp_vars,
52-
index=["valid_time", "ensemble_member"],
53-
variable_name="variable",
54-
value_name="value",
55-
)
46+
Args:
47+
df: Lazy scan of the NWP Delta table.
48+
target_init_time: The single NWP run to plot.
49+
target_h3_index: The single H3 cell to plot.
50+
nwp_vars: The NWP variable name (or list of names) to plot.
51+
"""
52+
# 1. Filter down to the specific init_time and h3_index
53+
filtered = df.filter(
54+
(pl.col("init_time") == target_init_time) & (pl.col("h3_index") == target_h3_index)
55+
)
5656

57-
# 3. Create a boolean flag for missing data and a combined label for the Y-axis
58-
plot_df = melted.with_columns(
59-
# Check for both database Nulls and float NaNs
60-
is_missing=pl.col("value").is_null() | pl.col("value").is_nan(),
61-
# Create a string like "temperature_2m (Member 0)":
62-
# row_label=(
63-
# pl.col("variable") + " (Member " + pl.col("ensemble_member").cast(pl.Utf8) + ")"
64-
# ),
65-
row_label=pl.col("ensemble_member"),
66-
)
57+
# 2. Unpivot (melt) the data so we can plot all variables on a single Y-axis
58+
melted = filtered.unpivot(
59+
on=nwp_vars,
60+
index=["valid_time", "ensemble_member"],
61+
variable_name="variable",
62+
value_name="value",
63+
)
6764

68-
# 5. Build the Altair Chart
69-
base = alt.Chart(plot_df).encode(
70-
y=alt.Y("row_label:N", title="Ensemble Member", sort="ascending")
71-
)
65+
# 3. Create a boolean flag for missing data and a combined label for the Y-axis. Altair
66+
# only accepts materialised data, so collect once the filter has cut the scan down to a
67+
# single NWP run and a single H3 cell.
68+
plot_df = melted.with_columns(
69+
# Check for both database Nulls and float NaNs
70+
is_missing=pl.col("value").is_null() | pl.col("value").is_nan(),
71+
# Create a string like "temperature_2m (Member 0)":
72+
# row_label=(
73+
# pl.col("variable") + " (Member " + pl.col("ensemble_member").cast(pl.Utf8) + ")"
74+
# ),
75+
row_label=pl.col("ensemble_member"),
76+
).collect()
77+
78+
# 5. Build the Altair Chart
79+
base = alt.Chart(plot_df).encode(
80+
y=alt.Y("row_label:N", title="Ensemble Member", sort="ascending")
81+
)
7282

73-
# Layer 1: A light gray background line showing the full time series extent
74-
background_line = base.mark_line(color=GRID, strokeWidth=1)
75-
background_lines = background_line.encode( # ty: ignore[unresolved-attribute]
76-
x=alt.X("valid_time:T", title="Valid Time"),
77-
detail="row_label:N", # Ensures lines don't connect across different rows
78-
)
83+
# Layer 1: A light gray background line showing the full time series extent
84+
background_line = base.mark_line(color=GRID, strokeWidth=1)
85+
background_lines = background_line.encode( # ty: ignore[unresolved-attribute]
86+
x=alt.X("valid_time:T", title="Valid Time"),
87+
detail="row_label:N", # Ensures lines don't connect across different rows
88+
)
7989

80-
# Layer 2: Red ticks superimposed exactly where the data is missing
81-
missing_marks = (
82-
base.transform_filter(alt.datum.is_missing)
83-
.mark_tick(
84-
color=ORANGE_RED,
85-
thickness=3, # Make the red mark stand out
86-
size=12, # Height of the tick mark
87-
)
88-
.encode(x="valid_time:T") # ty: ignore[unresolved-attribute] # astral-sh/ty#2520
90+
# Layer 2: Red ticks superimposed exactly where the data is missing
91+
missing_marks = (
92+
base.transform_filter(alt.datum.is_missing)
93+
.mark_tick(
94+
color=ORANGE_RED,
95+
thickness=3, # Make the red mark stand out
96+
size=12, # Height of the tick mark
8997
)
98+
.encode(x="valid_time:T") # ty: ignore[unresolved-attribute] # astral-sh/ty#2520
99+
)
90100

91-
# Combine the layers and configure the chart size
92-
return (
93-
(background_lines + missing_marks)
94-
.properties(
95-
title=(
96-
f"Missing NWP Data | init_time: {target_init_time.strftime('%Y-%m-%d')}"
97-
f" | {nwp_vars} | H3: {target_h3_index}"
98-
),
99-
width=800,
100-
# Dynamically scales chart height based on the number of rows
101-
height=alt.Step(10),
102-
)
103-
.configure_axis(labelFontSize=11, titleFontSize=13)
101+
# Combine the layers and configure the chart size
102+
return (
103+
(background_lines + missing_marks)
104+
.properties(
105+
title=(
106+
f"Missing NWP Data | init_time: {target_init_time.strftime('%Y-%m-%d')}"
107+
f" | {nwp_vars} | H3: {target_h3_index}"
108+
),
109+
width=800,
110+
# Dynamically scales chart height based on the number of rows
111+
height=alt.Step(10),
104112
)
113+
.configure_axis(labelFontSize=11, titleFontSize=13)
114+
)
115+
105116

117+
@app.cell
118+
def _(df, nwp_vars):
106119
chart = plot_null_distribution(
107120
df,
108121
target_init_time=datetime(2026, 5, 1, tzinfo=UTC),
@@ -113,5 +126,37 @@ def plot_null_distribution(df, target_init_time, target_h3_index, nwp_vars):
113126
return
114127

115128

129+
@app.function
130+
def test_plot_null_distribution_flags_nulls_and_nans():
131+
init_time = datetime(2026, 5, 1, tzinfo=UTC)
132+
other_init_time = datetime(2026, 5, 2, tzinfo=UTC)
133+
h3_index = 599148110664433663
134+
other_h3_index = 599148110664433662
135+
# The last two readings are missing too, but belong to another NWP run and another H3 cell, so
136+
# the filter must drop them rather than plot them.
137+
readings = pl.LazyFrame(
138+
{
139+
"init_time": [init_time] * 4 + [other_init_time, init_time],
140+
"valid_time": [datetime(2026, 5, 1, hour=h, tzinfo=UTC) for h in range(6)],
141+
"ensemble_member": [0, 0, 0, 0, 0, 0],
142+
"h3_index": [h3_index] * 5 + [other_h3_index],
143+
"temperature_2m": pl.Series(
144+
[1.0, None, float("nan"), 4.0, None, None], dtype=pl.Float32
145+
),
146+
}
147+
)
148+
149+
chart = plot_null_distribution(
150+
readings,
151+
target_init_time=init_time,
152+
target_h3_index=h3_index,
153+
nwp_vars="temperature_2m",
154+
)
155+
156+
# Altair inlines the chart's data as its single named dataset.
157+
(rows,) = chart.to_dict()["datasets"].values()
158+
assert [row["is_missing"] for row in rows] == [False, True, True, False]
159+
160+
116161
if __name__ == "__main__":
117162
app.run()

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,12 @@ docstring-code-line-length = 100
299299
# with `uv run pytest --run-network` — see docs/architecture/testing.md. The gate lives in a hook,
300300
# not in an `addopts` `-m "not network"`, because any caller-supplied `-m` would silently override it.
301301
addopts = "--import-mode=importlib"
302+
# The first two patterns are pytest's default, restated because naming any pattern replaces the
303+
# default list. The notebook is named as well because a marimo notebook is an ordinary Python file
304+
# that pytest can collect, and this one holds a `test_*` function exercising its chart-building
305+
# helper. See docs/architecture/testing.md. Patterns containing a `/` match against the path
306+
# rather than the basename, so this reaches exactly one file.
307+
python_files = ["test_*.py", "*_test.py", "packages/notebooks/plot_missing_NWP_data.py"]
302308
# Put the root `tests/` dir on sys.path so its integration tests can import shared, non-fixture
303309
# test data (e.g. `_nwp_test_data`) by bare name — importlib mode does not add each test file's
304310
# own directory to sys.path the way prepend mode does.

0 commit comments

Comments
 (0)