From 1c5d16a4bc905f297f0c602d931dc0f19b4d2e8b Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Fri, 2 Oct 2026 14:03:35 -0600 Subject: [PATCH] Cookbook checker: hold each recipe to its own imports (#75) The checker prepends a shared preamble to every partial snippet, and that preamble imports the whole client API, so a recipe's own imports were never consulted: #74 shipped a recipe using dfc, and another using QueryParams, PV and bc, that type-checked cleanly and raised NameError when run on its own. A third pass (pure ast) now requires every name a partial snippet uses that the preamble imports to be bound somewhere in the same recipe. The set of names is parsed from the preamble itself, so its fixtures (client, params, the carried-forward ids) stay out of scope. A second canary self-tests the pass in both directions. The six "Imports used by the examples" blocks were all cookbook:skip, so a misspelled import there was never checked either; they are now checked like any other snippet and supply the bindings the pass looks for. conventions.md gains an imports block and connecting.md's one partial block an inline import, the two real misses the pass found. Plan: plan/tickets/75/plan.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CajTMkjkkzeoWXLSgMpn5k --- .dev/tools/check-cookbook-snippets.py | 150 ++++++++++++++++++-- CLAUDE.md | 5 + doc/cookbook/README.md | 12 +- doc/cookbook/connecting.md | 2 + doc/cookbook/conventions.md | 11 ++ doc/cookbook/datasets-and-annotations.md | 1 - doc/cookbook/ingestion.md | 1 - doc/cookbook/machine-configuration.md | 1 - doc/cookbook/pv-metadata.md | 1 - doc/cookbook/query.md | 1 - doc/cookbook/sample-status.md | 1 - plan/tickets/75/plan.md | 166 +++++++++++++++++++++++ 12 files changed, 335 insertions(+), 17 deletions(-) create mode 100644 plan/tickets/75/plan.md diff --git a/.dev/tools/check-cookbook-snippets.py b/.dev/tools/check-cookbook-snippets.py index 4c692dd..c5799a9 100755 --- a/.dev/tools/check-cookbook-snippets.py +++ b/.dev/tools/check-cookbook-snippets.py @@ -1,13 +1,18 @@ #!/usr/bin/env python3 """Verify the Python snippets in doc/cookbook/*.md against the installed dp_python_lib. -Two passes, because they have different blind spots: +Three passes, because they have different blind spots: 1. ast.parse() -- syntax errors. 2. mypy -- wrong attribute names, wrong method names, wrong keyword arguments, wrong arity. This is the class of error that matters most here: a recipe that writes `result.pv_metadata_list` when the attribute is `result.pv_metadata` is valid Python and sails through pass 1. + 3. imports -- every name a `# cookbook:partial` snippet uses that the preamble + *imports* must also be bound somewhere in the snippet's own recipe + (#75). The preamble supplies those names to pass 2, so without this a + recipe that never imports `dfc` type-checks cleanly and still raises + NameError for a reader who copies it; #74 shipped that twice. Background: on the dp-grpc side, extracting and compiling the Java snippets found four real defects that a careful multi-agent proto-verification pass had missed entirely. Name-checking @@ -35,6 +40,7 @@ from __future__ import annotations import argparse +import ast import importlib.util import os import re @@ -246,8 +252,6 @@ def extract(path: Path) -> list[Snippet]: def check_syntax(snippet: Snippet) -> list[str]: """Pass 1: does it parse at all?""" - import ast - try: ast.parse(snippet.code) except SyntaxError as exc: @@ -354,6 +358,100 @@ def check_types(snippets: list[Snippet], verbose: bool) -> list[str]: return errors +def preamble_imports() -> set[str]: + """The names PREAMBLE binds by import -- the names pass 3 holds each recipe to importing itself. + + Parsed from the preamble rather than listed, so there is no second list to keep in step. Its + fixtures (`client`, `params`, the carried-forward ids, `acquire()`) are assignments and defs, not + imports, so they are excluded by construction: recipes legitimately carry those between snippets. + """ + names: set[str] = set() + for node in ast.walk(ast.parse(PREAMBLE)): + if isinstance(node, ast.Import): + names.update(a.asname or a.name.split(".")[0] for a in node.names) + elif isinstance(node, ast.ImportFrom): + names.update(a.asname or a.name for a in node.names) + return names + + +def bound_names(tree: ast.AST) -> set[str]: + """Every name a snippet binds, anywhere in it. + + Deliberately looser than Python: scope and order are ignored, so a name imported inside a + function or in a later block still counts. Recipes are flat scripts, and the question is + whether the recipe imports the name at all -- which is what #74 got wrong. + """ + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.update(a.asname or a.name.split(".")[0] for a in node.names) + elif isinstance(node, ast.ImportFrom): + names.update(a.asname or a.name for a in node.names) + elif isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)): + names.add(node.id) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + names.add(node.name) + elif isinstance(node, ast.arg): + names.add(node.arg) + elif isinstance(node, ast.ExceptHandler) and node.name: + names.add(node.name) + return names + + +def check_recipe_imports(snippets: list[Snippet]) -> list[str]: + """Pass 3: does each recipe import the preamble-imported names its partial snippets use? + + Bindings come from every checked snippet in the file (the imports block, inline imports, + standalone and no-mypy blocks); uses only from partial ones, since a standalone snippet is + type-checked without the preamble and mypy already reports a missing import there. Skipped + snippets contribute neither. One error per name per file, at its first use. Expects + snippets that parse; pass 1 has already reported any that do not. + """ + imported = preamble_imports() + by_file: dict[Path, list[Snippet]] = {} + for snippet in snippets: + if not snippet.skip: + by_file.setdefault(snippet.path, []).append(snippet) + + errors: list[str] = [] + for path, file_snippets in by_file.items(): + bound: set[str] = set() + uses: dict[str, list[int]] = {} + for snippet in file_snippets: + tree = ast.parse(snippet.code) + bound |= bound_names(tree) + if not snippet.partial: + continue + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id in imported: + uses.setdefault(node.id, []).append(snippet.start_line + node.lineno - 1) + + for name, lines in uses.items(): + if name in bound: + continue + lines.sort() + more = f" [+{len(lines) - 1} more use{'s' if len(lines) > 2 else ''}]" if len(lines) > 1 else "" + errors.append( + f"{display_path(path)}:{lines[0]}: '{name}' is used but never imported by this recipe " + f"(the checker preamble supplies it; add it to the recipe's imports){more}" + ) + return errors + + +# Pass 3's self-test recipe: a partial snippet using a preamble import the recipe never imports. +# Checked alone it must be reported; with IMPORTS_CANARY_FIX alongside it, it must not be. The +# first catches a rule that stops matching; the second, one that ignores bindings and would then +# fail the real cookbook for a confusing reason. +IMPORTS_CANARY = """\ +# cookbook:partial +columns = dfc.data_frame_columns(frame) +""" + +IMPORTS_CANARY_FIX = """\ +from dp_python_lib.client import data_frame_conversions as dfc +""" + + # A snippet that MUST fail. If mypy stops resolving dp_python_lib -- a moved src layout, a # missing MYPYPATH, an uninstalled package -- it reports success on everything and the checker # becomes a rubber stamp that looks exactly like clean docs. This canary makes that loud. @@ -365,7 +463,7 @@ def check_types(snippets: list[Snippet], verbose: bool) -> list[str]: def self_test(verbose: bool) -> list[str]: - """Confirm the mypy pass can still detect a known-bad attribute.""" + """Confirm pass 2 still flags a known-bad attribute, and pass 3 a missing import (and only that).""" canary = Snippet( path=REPO_ROOT / "", start_line=1, @@ -384,7 +482,38 @@ def self_test(verbose: bool) -> list[str]: " Verify with: MYPYPATH=$PWD/src .venv/bin/mypy --ignore-missing-imports " "--follow-imports=silent " ] - return [] + + errors: list[str] = [] + imported = preamble_imports() + if "dfc" not in imported: + errors.append( + "SELF-TEST FAILED: the preamble's imports were not found (expected 'dfc' among " + f"{sorted(imported)}), so the recipe import check would check nothing." + ) + + def canary_snippet(code: str, start_line: int, partial: bool) -> Snippet: + return Snippet( + path=REPO_ROOT / "", + start_line=start_line, + code=code, + partial=partial, + skip=False, + no_mypy=False, + ) + + if not check_recipe_imports([canary_snippet(IMPORTS_CANARY, 1, True)]): + errors.append( + "SELF-TEST FAILED: the recipe import check did not flag 'dfc' used without an import, " + "so a recipe missing its imports would pass." + ) + fixed = [canary_snippet(IMPORTS_CANARY_FIX, 1, False), canary_snippet(IMPORTS_CANARY, 5, True)] + found = check_recipe_imports(fixed) + if found: + errors.append( + "SELF-TEST FAILED: the recipe import check flagged a name the recipe does import, " + f"so it is ignoring bindings: {found}" + ) + return errors def main() -> int: @@ -394,7 +523,7 @@ def main() -> int: parser.add_argument( "--no-self-test", action="store_true", - help="skip the canary that verifies name checking still works", + help="skip the canaries that verify name and import checking still work", ) args = parser.parse_args() @@ -433,7 +562,7 @@ def main() -> int: # Verify the checker itself works before trusting a clean result from it. if not args.no_self_test: if args.verbose: - print(" running self-test (canary)...", file=sys.stderr) + print(" running self-test (canaries)...", file=sys.stderr) canary_errors = self_test(args.verbose) if canary_errors: print("\nFAIL: checker self-test failed\n") @@ -450,8 +579,13 @@ def main() -> int: errors.extend(found) syntax_failed.add(id(snippet)) + parsed = [s for s in checked if id(s) not in syntax_failed] + + # Pass 3 needs only the AST, so run it before the slow mypy pass; its errors are sorted in below. + errors.extend(check_recipe_imports(parsed)) + # Pass 2, excluding anything that already failed to parse. - errors.extend(check_types([s for s in checked if id(s) not in syntax_failed], args.verbose)) + errors.extend(check_types(parsed, args.verbose)) files_desc = f"{len(paths)} file{'s' if len(paths) != 1 else ''}" counts = f"{len(checked)} snippet{'s' if len(checked) != 1 else ''} in {files_desc}" diff --git a/CLAUDE.md b/CLAUDE.md index 36915ed..b64b046 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,11 @@ ruff format --check . # verify formatting without writing (what CI runs) Two paths are excluded deliberately: `src/dp_python_lib/grpc/` (generated, regenerated wholesale) and `*.md` (ruff would reformat the hand-wrapped Python snippets in this file and `doc/cookbook/`; those are verified instead by `.dev/tools/check-cookbook-snippets.py`). +That checker also holds each recipe to its own imports (#75): its shared preamble supplies the +library's imports to every partial snippet, which hid a missing import from mypy twice in #74, so +every name a partial snippet uses that the preamble imports must be bound somewhere in the same +recipe. It still cannot check values carried *between* snippets (`dataset_id`, `provider_id`); +running a recipe as one script with only its own imports remains the way to verify those. When a rule fires on something intentional, suppress it with a per-line `# noqa: RULE` plus a comment saying why, rather than reshaping correct code to satisfy the linter. Existing diff --git a/doc/cookbook/README.md b/doc/cookbook/README.md index 25d5b7b..d8d998c 100644 --- a/doc/cookbook/README.md +++ b/doc/cookbook/README.md @@ -66,7 +66,8 @@ Attribute names and values are the facility's; tag values are illustrative place ## Verifying the examples Every Python snippet in this directory is mechanically checked — parsed for syntax, then -type-checked against the installed package to catch wrong attribute and method names: +type-checked against the installed package to catch wrong attribute and method names — and each +recipe is checked for importing every library name its fragments use: ```bash pip install -e .[dev] @@ -75,7 +76,12 @@ pip install -e .[dev] The checker self-tests before each run: if mypy ever stops resolving `dp_python_lib`, it would report success on every snippet regardless of correctness, so a canary asserts that a known-bad -attribute is still flagged. +attribute is still flagged. A second canary does the same for the import check. Snippets carry `# cookbook:partial` when they are fragments that assume a client, and -`# cookbook:skip` or `# cookbook:no-mypy` where checking does not apply. +`# cookbook:skip` or `# cookbook:no-mypy` where checking does not apply. A partial snippet is +type-checked with a shared preamble that supplies `client` and the library's imports, so the +import check is what holds a recipe to its own imports: every name a partial snippet uses that the +preamble imports must be bound somewhere in the same recipe — usually its "Imports used by the +examples" block, which is checked like any other snippet. Do not mark that block +`# cookbook:skip`; a skipped block binds nothing. diff --git a/doc/cookbook/connecting.md b/doc/cookbook/connecting.md index e76fa0e..8444032 100644 --- a/doc/cookbook/connecting.md +++ b/doc/cookbook/connecting.md @@ -174,6 +174,8 @@ a key present in the YAML file silently ignored its `MLDP_*` variable. ```python # cookbook:partial +from dp_python_lib.client import QueryParams, PvQuery as PV + if client.query is None: raise RuntimeError("no query channel configured") diff --git a/doc/cookbook/conventions.md b/doc/cookbook/conventions.md index e58b5f8..2049e1d 100644 --- a/doc/cookbook/conventions.md +++ b/doc/cookbook/conventions.md @@ -11,6 +11,17 @@ For the wire-level view of these same conventions — the protobuf messages and pattern this library wraps — see the [dp-grpc cookbook](https://github.com/osprey-dcs/dp-grpc/blob/main/doc/cookbook/conventions.md). +### Imports used by the examples + +```python +from dp_python_lib.client import ( + MldpClient, + SavePvMetadataRequestParams, + PvMetadataQuery as Q, + to_timestamp, +) +``` + ## Contents - [Checking results](#checking-results) — the one pattern every call shares diff --git a/doc/cookbook/datasets-and-annotations.md b/doc/cookbook/datasets-and-annotations.md index 49dce1b..6c0cfad 100644 --- a/doc/cookbook/datasets-and-annotations.md +++ b/doc/cookbook/datasets-and-annotations.md @@ -15,7 +15,6 @@ channel is configured, so guard on it before reaching through. ### Imports used by the examples ```python -# cookbook:skip from datetime import datetime, timezone from dp_python_lib.client import ( diff --git a/doc/cookbook/ingestion.md b/doc/cookbook/ingestion.md index ce21b39..6351f3e 100644 --- a/doc/cookbook/ingestion.md +++ b/doc/cookbook/ingestion.md @@ -17,7 +17,6 @@ All examples use `client.ingestion_client`. ### Imports used by the examples ```python -# cookbook:skip import contextlib from datetime import datetime, timedelta, timezone diff --git a/doc/cookbook/machine-configuration.md b/doc/cookbook/machine-configuration.md index 29d375f..d863ed2 100644 --- a/doc/cookbook/machine-configuration.md +++ b/doc/cookbook/machine-configuration.md @@ -11,7 +11,6 @@ unless an annotation channel is configured, so guard on `client.annotation` befo ### Imports used by the examples ```python -# cookbook:skip from datetime import datetime, timezone from dp_python_lib.client import ( diff --git a/doc/cookbook/pv-metadata.md b/doc/cookbook/pv-metadata.md index 2a2e7ba..734063c 100644 --- a/doc/cookbook/pv-metadata.md +++ b/doc/cookbook/pv-metadata.md @@ -12,7 +12,6 @@ unless an annotation channel is configured, so guard on `client.annotation` befo ### Imports used by the examples ```python -# cookbook:skip from dp_python_lib.client import ( MldpClient, SavePvMetadataRequestParams, diff --git a/doc/cookbook/query.md b/doc/cookbook/query.md index a531b0c..ba64ef0 100644 --- a/doc/cookbook/query.md +++ b/doc/cookbook/query.md @@ -17,7 +17,6 @@ All examples use `client.query`, which is `None` unless a query channel is confi ### Imports used by the examples ```python -# cookbook:skip from datetime import datetime, timezone from dp_python_lib.client import ( diff --git a/doc/cookbook/sample-status.md b/doc/cookbook/sample-status.md index c7aba41..9b3104b 100644 --- a/doc/cookbook/sample-status.md +++ b/doc/cookbook/sample-status.md @@ -13,7 +13,6 @@ unless an annotation channel is configured, so guard on `client.annotation` befo ### Imports used by the examples ```python -# cookbook:skip from datetime import datetime, timezone from dp_python_lib.client import ( diff --git a/plan/tickets/75/plan.md b/plan/tickets/75/plan.md new file mode 100644 index 0000000..34f4dbd --- /dev/null +++ b/plan/tickets/75/plan.md @@ -0,0 +1,166 @@ +# Issue #75 — Cookbook checker: verify each recipe imports the names its snippets use + +**Status:** triaged and implemented 2026-10-02; plan and implementation land in one PR (the ticket +is small enough that a separate plan-only PR was declined). + +## Overview + +`.dev/tools/check-cookbook-snippets.py` gains a third pass: every name a `# cookbook:partial` snippet +uses that the checker preamble *imports* must also be bound somewhere in the snippet's own recipe. +The six recipes' "Imports used by the examples" blocks stop being `# cookbook:skip`, so they are +type-checked themselves and supply the bindings the new pass looks for. `conventions.md` and +`connecting.md`, the two pages that rely on preamble imports today, get imports of their own. For +cookbook authors and reviewers: the class of miss that #74 hit twice becomes a CI failure. + +## Background / triage findings + +- **The draft's diagnosis holds.** The preamble (`PREAMBLE`, checker L70–188) imports 66 names, and + every partial snippet is checked with it prepended, so a recipe's own imports are never consulted. + A prototype of the proposed rule (pure `ast`, below) run against the cookbook as it stood before each + #74 fix reports exactly the two misses the ticket names: `dfc` in `query.md` (at `eedae6a~1`), and + `QueryParams` / `PV` / `bc` in `ingestion.md` (reconstructed by deleting those three imports, since + that fix landed inside 00d9487 rather than as its own commit). Against today's six recipes it + reports nothing. +- **The imports blocks themselves are never checked — a gap the ticket does not mention.** All six + "Imports used by the examples" blocks open with `# cookbook:skip`, as they have since the cookbook + was written (495ed2f, #15); they are the only skipped blocks in the cookbook. So a misspelled name + *in the imports block* passes today: changing `PvQuery as PV` to `PvQuerry as PV` and + `query_conversions` to `query_conversion` in `query.md` is reported by nothing. With the skip + removed, mypy flags both (`Module "dp_python_lib.client" has no attribute "PvQuerry"`), and the + current cookbook still passes (128 snippets, 0 skipped). This also settles the draft's step 1: + the blocks have to be checked anyway, and once they are, their bindings need no special handling. +- **The `conventions.md` / `connecting.md` question in the draft resolves to "real hits".** Both + uses are in fenced, checked code blocks, not prose: + - `conventions.md` uses `Q` (L69 onward), `SavePvMetadataRequestParams` (L235, L241), and + `to_timestamp` (L269 onward) and has no imports block at all. It is a cross-cutting page that + recipes link to, but a reader copying from it meets the same `NameError`. + - `connecting.md`'s "Sub-clients can be None" block (L175) uses `QueryParams` and `PV`. Every + other block on the page is standalone and imports what it uses. + Decision (2026-10-02): give both pages imports rather than an opt-out directive (D5). +- **The draft's prose hits need no special handling.** The checker only ever sees fenced + ` ```python ` blocks, so `Q.attributes(...)` in a callout or `dfb.serialized_column()` in a bullet + is invisible to it by construction. +- **`ruff --select F821` is the wrong tool here** (the draft offers it as one option). mypy already + reports any name the preamble does not define, so the only gap is names the preamble *does* define; + F821 over a concatenated recipe would re-report mypy's errors and need a stub header for the + fixtures. It would also tie the checker to ruff, while the checker's one external tool (mypy) is + deliberately resolved from the running interpreter. +- **No open ticket folds in.** #61's step 5 waits on a stub sync carrying `.pyi` files (dp-grpc#158 + is closed, but no `grpc-sync-*` PR has brought them, and dp-grpc's latest release is still + rel-1.16.0); its "checker gets stricter" item is about mypy, not imports. #68 waits on + osprey-dcs/dp-grpc#165 and osprey-dcs/dp-service#302, both open. #76 (PyPI) touches the cookbook's + install text only. + +## Design decisions + +**D1. Pure `ast`, no new dependency.** The pass parses each snippet (already done in pass 1) and +walks it. Rejected: `ruff --select F821` (above). + +**D2. "Used" means a `Load` of a name the preamble imports.** The set is computed by parsing +`PREAMBLE`'s own `import` / `from … import` statements (asname if present, else the top-level module +for `import a.b`, else the imported name), so it tracks the preamble with no second list to maintain. +The preamble's fixtures (`client`, `begin`/`end`, `params`, `t0`/`t1`, the carried-forward ids, +`since`, `request`, `acquire()`) are not imports and are therefore out of scope automatically, as +the ticket asks. + +**D3. "Bound" means bound anywhere in the same file's checked blocks.** Imports (`import`, +`from … import`, with `as`), assignment and other `Store` targets (`for`, `with … as`, +comprehensions, walrus), `def` / `class` names, parameters, and `except … as` names, collected from +every non-skip block in the file: the imports block, inline imports, standalone blocks, and +`no-mypy` blocks. Order and scope are ignored: a name imported in a later block, or inside a +function, still counts. That is looser than Python, but recipes are flat scripts and no real case +needs the strictness; the job is "does the recipe import it at all", which is exactly what failed in +#74. Rejected: order-sensitive, module-level-only binding (more code, no case it would catch). +`# cookbook:skip` blocks bind nothing, because they may be pseudo-code or deliberately wrong. + +**D4. Only partial blocks are checked for uses.** A standalone block is checked without the +preamble, so mypy already reports a missing import there as `Name "x" is not defined`; checking it +again would double-report. Partial `no-mypy` blocks *are* checked, since this pass does not depend +on mypy. + +**D5. Unskip the imports blocks; add imports to the two shared pages.** Each recipe's imports block +loses `# cookbook:skip` and is then a standalone block like any other: syntax-checked, type-checked, +and a source of bindings. No new directive. `conventions.md` gets its own "Imports used by the +examples" block (after the intro, before Contents, matching the recipes); `connecting.md`'s one +partial block gets an inline `from dp_python_lib.client import QueryParams, PvQuery as PV`, since the +page has no imports block and every other block there is self-contained. Rejected: a documented +opt-out directive for shared pages — it would exempt exactly the pages most often copied from. + +**D6. One error per name per file, at its first use.** Format: +`doc/cookbook/query.md:434: 'dfc' is used but never imported by this recipe (the checker preamble +supplies it; add it to the recipe's imports) [+3 more uses]`. Sorted with the other errors by file +and line. + +**D7. Self-test, in the style of the existing canary.** Before the real run, the pass is fed two +synthetic recipes: one partial snippet using `dfc` with no import anywhere (must report `dfc`), and +the same snippet plus a separate block importing `data_frame_conversions as dfc` (must report +nothing). Also asserted: the preamble-import set is non-empty and contains `dfc`, so a preamble +refactor that the parser stops seeing (say, imports moved under a conditional the walk skips) fails +loudly. The first case catches a rule that stops matching; the second catches one that ignores +bindings and would then fail on the real cookbook for a confusing reason. Both run under the +existing `--no-self-test` switch. + +**D8. No `NEXT.md` entry.** The change is to repository tooling and to cookbook import lines; no user +of the library or its release artifacts behaves differently. Same call as #70. + +## Implementation tasks + +**`.dev/tools/check-cookbook-snippets.py`** +- Module docstring: "Two passes" becomes three; describe pass 3 and why it exists (the preamble + masks a recipe's own imports). +- `preamble_imports() -> set[str]` from `ast.parse(PREAMBLE)`. +- `bound_names(tree) -> set[str]` per D3. +- `check_recipe_imports(snippets) -> list[str]`: group by `snippet.path`; bindings from every + non-skip snippet that parses; uses from partial non-skip snippets; errors per D6. +- `self_test()`: add the D7 cases next to the canary; report failures in the same + `SELF-TEST FAILED: ...` form. +- `main()`: run pass 3 after pass 1, over snippets that parsed (it needs the AST; a syntax error is + already reported). As built it also runs before pass 2: it needs nothing from mypy, and the + errors are sorted by file and line afterwards anyway, so the order is invisible in the output. + +**Cookbook** +- Remove `# cookbook:skip` from the imports block in `datasets-and-annotations.md`, `ingestion.md`, + `machine-configuration.md`, `pv-metadata.md`, `query.md`, `sample-status.md`. +- `conventions.md`: add "### Imports used by the examples" with `from dp_python_lib.client import + MldpClient, SavePvMetadataRequestParams, PvMetadataQuery as Q, to_timestamp`. As built, those + are the three names the pass reported plus `MldpClient`, listed to match the other recipes' + blocks; `datetime` / `timezone` were left out because the page's one block using them already + imports them inline. +- `connecting.md` L175 block: inline import per D5. +- `README.md` "Verifying the examples": add that each recipe must import what its snippets use, and + that this is checked; the directives line no longer needs to cover the imports block. + +**`CLAUDE.md`** +- In Linting and Formatting, where the checker is first mentioned, one sentence: the checker also + enforces that each recipe's own imports cover its partial snippets, because the shared preamble + would otherwise hide a missing import. It still cannot check values carried *between* snippets; + running a recipe as a script with only its own imports remains the way to verify those. + +**Verification** +- `python .dev/tools/check-cookbook-snippets.py` passes on the branch. +- Against `git show eedae6a~1:doc/cookbook/query.md` and an `ingestion.md` with the three imports + removed, it reports `dfc` and `QueryParams` / `PV` / `bc` respectively. +- Misspelling a name in an imports block fails the run. +- `ruff check` / `ruff format --check` are unaffected (`.dev/` and `*.md` are excluded); CI needs no + change, since the quality job already runs the script. + +## Out of scope + +- Checking values carried between snippets (`dataset_id`, `provider_id`, …). The preamble seeds + those deliberately; a recipe binding the wrong name is still caught only by running it as one + script, as the preamble's own comment says. +- Pruning preamble imports no recipe uses (e.g. the `typing` aliases). Harmless once the new pass + exists, and unrelated. +- Making the checker importable as a module (its `@dataclass` needs `sys.modules` registration when + loaded by path); the self-test lives inside it, so nothing needs that. + +## Dependencies and sequencing + +None. Independent of #61, #68, and #76. + +## Open questions + +Resolved 2026-10-02: + +1. **Shared pages: imports or opt-out?** Imports (D5). +2. **PR shape?** One PR carrying the plan and the implementation, `Closes #75`.