From dff8721638a555138db4e61bef41c6a5249b0e5f Mon Sep 17 00:00:00 2001 From: samuel Date: Wed, 2 Sep 2026 13:36:10 +0200 Subject: [PATCH 1/3] test(docs): run every tutorial end to end against a live backend The doc pages had drifted out of sync with SDK 0.2.0 and nothing caught it, because nothing ran them. This adds a suite that does: it assembles each page's own fenced code into a program, runs it against a real stack, and then asks the backend whether the page's promise came true. The code always comes from the page, never a copy, so there is no second copy to drift and nowhere to "fix" a red test except the documentation. Covers all 71 pages that contain runnable code (Python; Java and Rust runners are implemented and selectable with --langs). 205 of 232 checks pass; the 27 failures are real defects, each verified by reproducing it with plain SDK calls outside the harness before it was believed. What makes it hold up: - Block-count pinning. Plans address blocks by index, so an edited page fails the plan instead of silently re-pointing it at different code. - Bounded-run substitutions that must keep matching, so a rewritten loop fails loudly rather than hanging CI. - A coverage gate: a new page with runnable code is red until someone writes a plan or records why it cannot run. UNTRIAGED.toml is empty and should stay so. - Assertions on the backend, not on exit codes. Several tutorials catch their own exceptions by design, so exiting 0 proves nothing about whether data landed. - Sweeps before and after each run, so the suite is repeatable rather than passing only against a virgin backend. - 18 harness self-tests (test_harness.py) that prove those guards actually fire. They need no backend, so CI can run them anywhere. Live listeners are bounded and fed real traffic rather than skipped, graph reads are settled before assertions, and pages needing credentials or optional packages skip with the reason instead of failing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LoqhJA3haV5BqiqHvhPBZf --- .claude/skills/doc-tutorial-tests/SKILL.md | 177 +++++++++ .gitignore | 10 + doctests/.env.example | 15 + doctests/README.md | 103 +++++ doctests/backend.py | 366 ++++++++++++++++++ doctests/bin/newplan.py | 139 +++++++ doctests/bin/triage.py | 190 +++++++++ doctests/conftest.py | 144 +++++++ doctests/docblocks.py | 144 +++++++ doctests/entities.py | 225 +++++++++++ doctests/java-classpath.gradle | 12 + doctests/plans.py | 298 ++++++++++++++ doctests/plans/UNTRIAGED.toml | 10 + .../plans/advanced__asset-health-score.toml | 34 ++ .../advanced__data-cleaning-lineage.toml | 37 ++ .../plans/advanced__demand-forecasting.toml | 33 ++ .../plans/advanced__fraud-classification.toml | 36 ++ .../plans/advanced__generate-sample-data.toml | 66 ++++ .../plans/advanced__kmeans-clustering.toml | 35 ++ .../advanced__lstm-anomaly-detection.toml | 38 ++ .../plans/advanced__lstm-forecasting.toml | 37 ++ .../advanced__oxygen-crash-early-warning.toml | 24 ++ .../advanced__pca-process-monitoring.toml | 33 ++ .../advanced__predictive-maintenance.toml | 34 ++ .../advanced__random-forest-soft-sensor.toml | 32 ++ .../advanced__xgboost-failure-prediction.toml | 34 ++ doctests/plans/guides__attach-files.toml | 50 +++ doctests/plans/guides__correlate-alarms.toml | 57 +++ doctests/plans/guides__detect-events.toml | 37 ++ doctests/plans/guides__ingest-timeseries.toml | 45 +++ .../plans/guides__model-assets-graph.toml | 29 ++ .../plans/guides__query-and-aggregate.toml | 36 ++ .../plans/guides__realtime-subscriptions.toml | 33 ++ doctests/plans/guides__work-with-units.toml | 25 ++ ...__agriculture-food__precision-farming.toml | 46 +++ ...ies__agriculture-food__salmon-farming.toml | 56 +++ ...ries__built-environment__construction.toml | 34 ++ ...s__built-environment__smart-buildings.toml | 57 +++ ...stries__energy-utilities__air-quality.toml | 31 ++ ...stries__energy-utilities__ev-charging.toml | 54 +++ .../industries__energy-utilities__grid.toml | 46 +++ .../industries__energy-utilities__waste.toml | 70 ++++ .../industries__energy-utilities__water.toml | 64 +++ .../industries__energy-utilities__wind.toml | 48 +++ .../industries__financial-services__aml.toml | 68 ++++ ...__financial-services__insurance-fraud.toml | 65 ++++ ...s__financial-services__portfolio-risk.toml | 37 ++ .../industries__healthcare__cold-chain.toml | 53 +++ ...ries__healthcare__hospital-operations.toml | 59 +++ ...dustries__healthcare__medical-devices.toml | 41 ++ .../industries__healthcare__patient-flow.toml | 41 ++ ...ries__manufacturing-process__discrete.toml | 64 +++ ...stries__manufacturing-process__pharma.toml | 43 ++ ..._manufacturing-process__semiconductor.toml | 43 ++ ...industries__mining-metals__operations.toml | 60 +++ ...ries__mining-metals__processing-plant.toml | 41 ++ ...tries__mining-metals__tailings-safety.toml | 55 +++ .../industries__oil-and-gas__drilling.toml | 55 +++ .../industries__oil-and-gas__emissions.toml | 32 ++ .../industries__oil-and-gas__pipeline.toml | 54 +++ .../industries__oil-and-gas__production.toml | 90 +++++ .../industries__oil-and-gas__refining.toml | 41 ++ .../industries__oil-and-gas__storage.toml | 49 +++ ...__technology-operations__data-centers.toml | 69 ++++ ...tries__technology-operations__network.toml | 52 +++ ..._technology-operations__observability.toml | 75 ++++ ...tries__transport-logistics__aerospace.toml | 41 ++ ...stries__transport-logistics__airports.toml | 53 +++ ...tries__transport-logistics__last-mile.toml | 53 +++ ...stries__transport-logistics__maritime.toml | 56 +++ ...ndustries__transport-logistics__ports.toml | 41 ++ ...industries__transport-logistics__rail.toml | 64 +++ ...dustries__transport-logistics__retail.toml | 33 ++ ...es__transport-logistics__supply-chain.toml | 65 ++++ doctests/plans/quickstart.toml | 33 ++ doctests/plans/reference__client.toml | 37 ++ doctests/plans/reference__datasets.toml | 44 +++ doctests/plans/reference__events.toml | 50 +++ doctests/plans/reference__files.toml | 59 +++ doctests/plans/reference__resources.toml | 63 +++ doctests/plans/reference__subscriptions.toml | 82 ++++ doctests/plans/reference__timeseries.toml | 60 +++ doctests/plans/reference__units.toml | 41 ++ doctests/plans/tutorial-complete.toml | 60 +++ doctests/plans/tutorial.toml | 77 ++++ doctests/run.sh | 11 + doctests/runners.py | 319 +++++++++++++++ doctests/setup.sh | 31 ++ doctests/test_coverage.py | 84 ++++ doctests/test_harness.py | 217 +++++++++++ doctests/test_tutorials.py | 266 +++++++++++++ doctests/tutorial_support.py | 183 +++++++++ 92 files changed, 6434 insertions(+) create mode 100644 .claude/skills/doc-tutorial-tests/SKILL.md create mode 100644 doctests/.env.example create mode 100644 doctests/README.md create mode 100644 doctests/backend.py create mode 100755 doctests/bin/newplan.py create mode 100755 doctests/bin/triage.py create mode 100644 doctests/conftest.py create mode 100644 doctests/docblocks.py create mode 100644 doctests/entities.py create mode 100644 doctests/java-classpath.gradle create mode 100644 doctests/plans.py create mode 100644 doctests/plans/UNTRIAGED.toml create mode 100644 doctests/plans/advanced__asset-health-score.toml create mode 100644 doctests/plans/advanced__data-cleaning-lineage.toml create mode 100644 doctests/plans/advanced__demand-forecasting.toml create mode 100644 doctests/plans/advanced__fraud-classification.toml create mode 100644 doctests/plans/advanced__generate-sample-data.toml create mode 100644 doctests/plans/advanced__kmeans-clustering.toml create mode 100644 doctests/plans/advanced__lstm-anomaly-detection.toml create mode 100644 doctests/plans/advanced__lstm-forecasting.toml create mode 100644 doctests/plans/advanced__oxygen-crash-early-warning.toml create mode 100644 doctests/plans/advanced__pca-process-monitoring.toml create mode 100644 doctests/plans/advanced__predictive-maintenance.toml create mode 100644 doctests/plans/advanced__random-forest-soft-sensor.toml create mode 100644 doctests/plans/advanced__xgboost-failure-prediction.toml create mode 100644 doctests/plans/guides__attach-files.toml create mode 100644 doctests/plans/guides__correlate-alarms.toml create mode 100644 doctests/plans/guides__detect-events.toml create mode 100644 doctests/plans/guides__ingest-timeseries.toml create mode 100644 doctests/plans/guides__model-assets-graph.toml create mode 100644 doctests/plans/guides__query-and-aggregate.toml create mode 100644 doctests/plans/guides__realtime-subscriptions.toml create mode 100644 doctests/plans/guides__work-with-units.toml create mode 100644 doctests/plans/industries__agriculture-food__precision-farming.toml create mode 100644 doctests/plans/industries__agriculture-food__salmon-farming.toml create mode 100644 doctests/plans/industries__built-environment__construction.toml create mode 100644 doctests/plans/industries__built-environment__smart-buildings.toml create mode 100644 doctests/plans/industries__energy-utilities__air-quality.toml create mode 100644 doctests/plans/industries__energy-utilities__ev-charging.toml create mode 100644 doctests/plans/industries__energy-utilities__grid.toml create mode 100644 doctests/plans/industries__energy-utilities__waste.toml create mode 100644 doctests/plans/industries__energy-utilities__water.toml create mode 100644 doctests/plans/industries__energy-utilities__wind.toml create mode 100644 doctests/plans/industries__financial-services__aml.toml create mode 100644 doctests/plans/industries__financial-services__insurance-fraud.toml create mode 100644 doctests/plans/industries__financial-services__portfolio-risk.toml create mode 100644 doctests/plans/industries__healthcare__cold-chain.toml create mode 100644 doctests/plans/industries__healthcare__hospital-operations.toml create mode 100644 doctests/plans/industries__healthcare__medical-devices.toml create mode 100644 doctests/plans/industries__healthcare__patient-flow.toml create mode 100644 doctests/plans/industries__manufacturing-process__discrete.toml create mode 100644 doctests/plans/industries__manufacturing-process__pharma.toml create mode 100644 doctests/plans/industries__manufacturing-process__semiconductor.toml create mode 100644 doctests/plans/industries__mining-metals__operations.toml create mode 100644 doctests/plans/industries__mining-metals__processing-plant.toml create mode 100644 doctests/plans/industries__mining-metals__tailings-safety.toml create mode 100644 doctests/plans/industries__oil-and-gas__drilling.toml create mode 100644 doctests/plans/industries__oil-and-gas__emissions.toml create mode 100644 doctests/plans/industries__oil-and-gas__pipeline.toml create mode 100644 doctests/plans/industries__oil-and-gas__production.toml create mode 100644 doctests/plans/industries__oil-and-gas__refining.toml create mode 100644 doctests/plans/industries__oil-and-gas__storage.toml create mode 100644 doctests/plans/industries__technology-operations__data-centers.toml create mode 100644 doctests/plans/industries__technology-operations__network.toml create mode 100644 doctests/plans/industries__technology-operations__observability.toml create mode 100644 doctests/plans/industries__transport-logistics__aerospace.toml create mode 100644 doctests/plans/industries__transport-logistics__airports.toml create mode 100644 doctests/plans/industries__transport-logistics__last-mile.toml create mode 100644 doctests/plans/industries__transport-logistics__maritime.toml create mode 100644 doctests/plans/industries__transport-logistics__ports.toml create mode 100644 doctests/plans/industries__transport-logistics__rail.toml create mode 100644 doctests/plans/industries__transport-logistics__retail.toml create mode 100644 doctests/plans/industries__transport-logistics__supply-chain.toml create mode 100644 doctests/plans/quickstart.toml create mode 100644 doctests/plans/reference__client.toml create mode 100644 doctests/plans/reference__datasets.toml create mode 100644 doctests/plans/reference__events.toml create mode 100644 doctests/plans/reference__files.toml create mode 100644 doctests/plans/reference__resources.toml create mode 100644 doctests/plans/reference__subscriptions.toml create mode 100644 doctests/plans/reference__timeseries.toml create mode 100644 doctests/plans/reference__units.toml create mode 100644 doctests/plans/tutorial-complete.toml create mode 100644 doctests/plans/tutorial.toml create mode 100755 doctests/run.sh create mode 100644 doctests/runners.py create mode 100755 doctests/setup.sh create mode 100644 doctests/test_coverage.py create mode 100644 doctests/test_harness.py create mode 100644 doctests/test_tutorials.py create mode 100644 doctests/tutorial_support.py diff --git a/.claude/skills/doc-tutorial-tests/SKILL.md b/.claude/skills/doc-tutorial-tests/SKILL.md new file mode 100644 index 0000000..7b476a8 --- /dev/null +++ b/.claude/skills/doc-tutorial-tests/SKILL.md @@ -0,0 +1,177 @@ +--- +name: doc-tutorial-tests +description: Run, write and repair the end-to-end tests that prove every tutorial in these docs still works against a live DataHub backend. Use when a doc-tutorial test fails, when adding or editing a page that contains runnable code, when a page needs a test plan, after an SDK upgrade, or when asked whether the documentation still works. Triggers on "doctest", "doc tests", "tutorial test", "does this tutorial still work", "the docs are broken", "add a test for this page", "UNTRIAGED.toml", "test plan for a doc page", "doctests/plans". +--- + +# Documentation tutorial tests + +Proves one thing and proves it properly: **the code on a doc page, run in the order +the page presents it, against a real backend, does what the page says it does.** + +Not that snippets parse. Not that method names look plausible. That a reader who +follows the page gets the result the page promises. + +## The shape of it + +``` +docs/quickstart.mdx ──┐ + ├─► compose ─► one program ─► run live ─► assert on the backend +doctests/plans/…toml ─┘ +``` + +The code always comes from the page, never from a copy. A plan supplies only what +a reader has that the page does not restate, and declares what must be true +afterwards. The consequence worth internalising: **there is nowhere to "fix" a +failing test except the documentation.** That is deliberate. + +## Run it + +```bash +./doctests/setup.sh # once — venv + SDK built from source +./doctests/run.sh # every planned tutorial, Python +./doctests/run.sh -k quickstart # one page +./doctests/run.sh --langs all # Java and Rust too +./doctests/run.sh --keep -s -k tutorial # leave the data behind and watch it run +``` + +Needs a reachable stack in `doctests/.env` (see `.env.example`). **Never point it at +production** — it creates and deletes entities under the docs' own external ids. +No backend configured means skips, not failures. + +## Reading a failure + +The report names the doc line. Work the causes in this order — the first one that +fits is almost always right. + +| What you see | What it means | Where to fix | +| --- | --- | --- | +| `ImportError` / `AttributeError` on an SDK name | The SDK renamed or removed it; the page is behind. | The page. | +| HTTP 400 with a field name | The page's payload is wrong (a missing `unit`, a bad `value_type`). | The page. | +| `NameError` on a variable | The page uses something no block defines. Either the page has a gap, or the plan's prologue should supply what the prose establishes. | Usually the page. | +| A blamed line inside `doctests/plans (prologue …)` | The plan's fixture broke, not the tutorial. | The plan. | +| `replacement '…' no longer matches` | A bounded-run substitution went stale because the page changed. | Re-read the page, then the plan. | +| `now has N blocks; plan was written against M` | Someone added or removed a fence. Block numbering has shifted, so the plan may now select different code. | Re-read, update plan and count together. | +| HTTP 500 or 409 on a `create` | Almost always the entity is still there — the plan's `owns` is missing an id, so the sweep left it behind. Check `owns` before believing the server is broken. | The plan's `owns`. | +| `PanicException` | An SDK bug: the bindings panicked instead of raising. | File it. Leave the test red; it is telling the truth. | +| Backend does not hold what the page promises | The program exited 0 without doing the job. Common where a tutorial catches its own exceptions. | The page. | + +### The rule + +**A red test is a claim about the documentation, so verify it before believing it.** +Reproduce the failure with plain SDK calls, outside the harness, before filing it as a +doc bug. Several failures that looked like documentation defects turned out to be +harness bugs — a retrieve limit above the API's cap, an assertion about a file the page +deliberately deletes, a `[expect]` naming a branch the page only takes sometimes. The +suite is not evidence until its own claim has been checked. + +**Never make a test pass by weakening what it checks.** Deleting an `expect`, +dropping a `replace`, or handing the doc code a working import in a prologue all +produce green — over code that is still broken for every reader. If a check is +wrong, say why in the plan comment. If a tutorial is genuinely broken, leave it red +until the page is fixed. + +## Adding a plan for a page + +```bash +./doctests/bin/triage.py docs/guides/my-page.mdx # what the page needs to run +./doctests/bin/newplan.py docs/guides/my-page.mdx # scaffold, pre-filled from the page +``` + +`triage.py` walks the page's AST and reports the mechanical half: names it uses but +never defines, packages it needs, calls that never return, and the external ids it +creates (including ones minted at run time, which become `prefix_*` patterns). The +judgement half is yours. Read the page as a reader would, and fill in: + +- **`requires`** — the page(s) this one continues from, by plan slug. A guide that + opens "you already have a client" should `requires = ["quickstart"]`, so the + quickstart genuinely runs first. Never hand-write that setup in a prologue: it + would keep passing after the quickstart breaks. +- **`prologue`** — only what the prose establishes but no block shows: a `handle` + callback the page describes, a local file the page says you have, the fixture + world the page's premise assumes. If you find yourself importing the SDK here to + make doc code work, stop — you are hiding the bug you were hired to find. +- **`only` / `exclude`** — by default every block of the language runs, concatenated, + which is what makes it end-to-end. Narrow it when a page carries alternatives + (sync *and* async) or a complete listing that repeats the steps above it. +- **`[[replace]]`** — bound anything that would never terminate. Every one must still + match the page, so a rewritten loop fails loudly instead of hanging CI. +- **`requires_env` / `requires_python`** — prerequisites the environment may not have. + Missing ones skip with a reason; an unconfigurable environment is not a broken doc. +- **`[owns]`** — every external id the page creates, so the run is swept clean before + and after. Ids minted at run time can be given as `prefix_*` patterns. **Anything a + prologue or inject creates belongs here too** — a fixture the sweep does not know + about survives the run, and the next one fails on a duplicate create that reads like + a documentation bug. +- **`[expect]`** — the point of the whole exercise. Exit code 0 only proves nothing + threw. State what must exist, how many datapoints must be readable, what the page + tells the reader they will see on stdout. +- **`requires_once`** — for a page whose job is to *populate* the backend rather than + teach (`advanced/generate-sample-data` seeds every series the ML recipes read). It + runs once per session and is not composed in. Such a page keeps its data for the + rest of the session; the session fixture cleans up at the end. +- **`independent`** — run each block as its own program, with a sweep between them. + Right for an API reference, where the fences are separate examples and one of them + deletes what another needs. Wrong for a tutorial, whose steps only mean anything in + sequence — which is why concatenation is the default. +- **`inject`** — code spliced before one block, for what a reader has only part way + down the page (step 2 using the arrays step 1 just built under other names). + +Test programs may `from tutorial_support import ...` for the things pages leave open: +`Recorder` (a placeholder that remembers what it was handed and returns something +usable), `take` (bound a listener by count and wall clock), `feed` (write datapoints in +the background so a subscription actually receives traffic), `wait_for_datapoints` +(let an eventually-consistent write land before the page reads it back). + +A page can carry more than one plan when it makes more than one promise — see +`tutorial.toml` (the step-by-step path) beside `tutorial-complete.toml` (the finished +program). Coverage is keyed by the page a plan points at, not the plan's filename. + +## Coverage + +Every page containing runnable code must be either planned or listed in +`doctests/plans/UNTRIAGED.toml` with a reason. A new tutorial is **red until someone +decides**, which is what stops coverage from quietly decaying. + +`UNTRIAGED.toml` is currently **empty**: every page with runnable code has a plan. +Keep it that way — it is a backlog, not a waiver, and an entry added there is a +tutorial nobody is checking. + +```bash +./doctests/bin/newplan.py --untriaged "needs a populated asset model" docs/industries/x.mdx +``` + +## Known traps in this codebase + +- **A series created without `value_type` becomes a long column.** Inserting a float + into it fails with 422 `Could not parse value: 11.4 to long`. Verified directly: + `value_type="float"` accepts the same insert. So `value_type` is effectively + required for any float series — `review/API-SURFACE.md` says otherwise and is wrong. +- **`unit` is required on timeseries create** — omitting it is HTTP 400 + `timeseries.unit.not.blank`. The single most common bug in these docs. +- **Aggregates are asymmetric** — request `"avg"`, read back `.average`. There is no + `count` aggregate; asking for one is silently dropped. +- **`retrieve` is named differently per language** — Java `retrieve`, Python and Rust + `retrieve_datapoints`. +- **Java `.unit(x)` is a trap** — it aliases `setUnitExternalId` and leaves the required + `unit` blank. Use `.setUnit(x)`. +- **Rust aggregates need `Some(vec![...])`** — a bare `vec![...]` does not compile. +- **Reads are eventually consistent, and the graph lags most.** `fetch_related` returns + nothing for a second or two after the edges are written. Never assert — or build a + fixture — immediately after a write; the harness polls (`settle_secs`) and so should + any check you add. +- **`delete` and `by_ids` take bare external-id strings on every service.** Some also + accept an `IdCollection`; `resources`, `events` and `files` raise `TypeError` for it. + This one is worth knowing because the sweep swallows exceptions by design, so the + wrong shape does not fail — it silently leaves entities behind, and the *next* run + fails on a duplicate create that looks exactly like a documentation bug. If a page + fails with HTTP 500 on a create, check for leftovers before believing the page. +- **Duplicate creates answer differently per service.** Timeseries gives a clean 409; + `resources.create` gives **500** with an empty body; `datasets.create` returns an + **empty list** and the page then fails on `[0]`. A 500 on create usually means the + entity is already there, not that the server is broken. +- **A deleted file keeps its path.** Delete moves a file to trash and nothing purges + it, so re-uploading to the same path fails permanently. Pages that upload can be run + once per stack; see the note in `plans/reference__files.toml`. + +`review/API-SURFACE.md` holds the fuller per-language method inventory. Verify against +the SDK source before trusting either — both drift. diff --git a/.gitignore b/.gitignore index 09a50c0..f0da117 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,13 @@ yarn-error.log* # IDE /.idea/ *.iml + +# Doc-tutorial test suite: local venv, credentials and build caches. +# The tests, plans and harness themselves are committed — only these are not. +/doctests/.venv/ +/doctests/.env +/doctests/.token +/doctests/.java-classpath +/doctests/.rust-runner/ +/doctests/**/__pycache__/ +/doctests/.pytest_cache/ diff --git a/doctests/.env.example b/doctests/.env.example new file mode 100644 index 0000000..0e4ed5f --- /dev/null +++ b/doctests/.env.example @@ -0,0 +1,15 @@ +# Where the tutorials are run. Never point this at production: the suite creates +# and deletes the entities the docs use, under the docs' own external ids. +BASE_URL=http://localhost:8081 + +# Authenticate as the SDK does — a static token: +# TOKEN=eyJhbGciOi... + +# …or OAuth2 client credentials: +# CLIENT_ID= +# CLIENT_SECRET= +# TOKEN_URI= + +# …or, for a local stack, a command that prints a token as its last line. +# Its result is cached for a few minutes so a full run mints once. +# DOCTEST_TOKEN_CMD=node ../datahub-platform/.claude/dev/get-token.js diff --git a/doctests/README.md b/doctests/README.md new file mode 100644 index 0000000..a72a93e --- /dev/null +++ b/doctests/README.md @@ -0,0 +1,103 @@ +# Documentation tutorial tests + +These tests run the tutorials in `docs/` end to end against a live DataHub backend and +check that each one does what its page says it does. + +The distinction that matters: they do not lint snippets or compare method names against +a list. They take the code a reader would copy, in the order the page presents it, run +it, and then ask the backend whether the promised thing exists. + +``` +docs/quickstart.mdx ──┐ + ├─► compose ─► one program ─► run live ─► assert on the backend +doctests/plans/*.toml ┘ +``` + +## Quick start + +```bash +./doctests/setup.sh # once: venv + the SDK compiled from source +$EDITOR doctests/.env # point it at a stack (see .env.example) +./doctests/run.sh # run every planned tutorial +``` + +`setup.sh` builds the PyO3 bindings from a local `dataplatform-rust-sdk` checkout +(override with `DOCTEST_RUST_SDK_PATH`), so the docs are tested against the SDK you +actually have — re-run it after an SDK change to see what that change did to the docs. + +**Never point `BASE_URL` at production.** Each run creates and deletes entities under +the docs' own external ids (`engine_temperature`, `plant_oslo`, …). With no backend +configured the suite skips rather than fails. + +## What each part is + +| Path | Role | +| --- | --- | +| `docblocks.py` | Pulls fenced blocks out of `.mdx`, with heading, tab and line number. | +| `plans/*.toml` | Per-page declarations: which blocks, what setup, what must be true after. | +| `plans/UNTRIAGED.toml` | Pages with runnable code and no plan yet — the backlog. | +| `runners.py` | Composes blocks into one program and runs it (Python, Java, Rust). | +| `backend.py` | Config, cleanup sweeps, and the outcome checks. | +| `test_tutorials.py` | One test per (page, language), plus the block-count drift guard. | +| `test_coverage.py` | Refuses to let a page with runnable code go unaccounted for. | +| `entities.py` | Reads which entities a page creates, so a plan can own and assert them. | +| `tutorial_support.py` | Helpers a test program may import: bounded listen, traffic feed, placeholder stubs. | +| `bin/newplan.py` | Scaffolds a plan from a page. | +| `bin/triage.py` | Reports what a page needs before it can run: free names, packages, blocking calls, ids. | + +## Writing a plan + +`./doctests/bin/triage.py ` says what the page needs; `./doctests/bin/newplan.py ` +writes the scaffold. The knobs, in rough order of how often they are needed: + +| Key | For | +| --- | --- | +| `requires` | Pages this one continues from. Their blocks are prepended, so the guide is tested the way a reader arrives at it. | +| `requires_once` | A page whose job is to *populate* the backend. Runs once per session, not composed in. | +| `prologue` | What the prose establishes but no block shows. | +| `inject` | Code spliced before one block — for what a reader has only part way down the page. | +| `replace` | Bounded-run substitutions. Must keep matching, so a rewritten loop fails loudly instead of hanging. | +| `only` / `exclude` | Narrow the blocks. Needed where a page shows two ways to do one thing. | +| `independent` | Run each block as its own program. Right for API reference, wrong for a tutorial. | +| `requires_env` / `requires_python` | Prerequisites the environment may lack — these skip, not fail. | +| `owns` | Every external id the page creates, so the run is repeatable. `prefix_*` for ids minted at run time. | +| `expect` | What must be true on the backend afterwards. | + +## Three design choices worth knowing + +**Code is never copied into a test.** Every program is assembled from the page at run +time, so there is no second copy to drift. It also means a failing test cannot be fixed +here — only in the documentation. + +**Prerequisites are real runs, not fixtures.** A guide that opens "you already have a +client" declares `requires = ["quickstart"]`, and the quickstart's own blocks are +prepended. Writing that setup by hand would keep the guide green after the quickstart +broke, which is the failure mode this suite exists to prevent. + +**Exit code 0 is not a pass.** Several tutorials catch their own exceptions by design — +the memory-ingest daemon must survive a bad tick — so their exit code says nothing about +whether data landed. `[expect]` goes to the backend and checks. + +## Languages + +Python runs by default: its toolchain is the one this repo can assume, so it is the one +that can be held green. Java and Rust runners are implemented and wired — `--langs all`, +or `DOCTEST_LANGS=rust` — but need `DOCTEST_JAVA_REPO` (a `datahub-platform` checkout, +for the SDK jar) and a Rust toolchain respectively, and their per-page scenarios are +mostly still to be written. A missing toolchain skips with the reason. + +## Wiring it into CI + +Not configured here, because it needs an infrastructure decision this repo cannot make +on its own: the suite requires a running DataHub stack. Once there is one CI can reach, +a job is small — `setup.sh`, then `run.sh` with `BASE_URL` and credentials from secrets. +Until then, run it locally before merging a change to any page with code on it. + +The suite is also worth running from the **SDK** side: an SDK change that breaks a +documented call should fail there, where the change is being made, rather than being +discovered later here. + +## Working on it + +See `.claude/skills/doc-tutorial-tests/SKILL.md` for how to write a plan, how to read a +failure, and the known traps in this API. diff --git a/doctests/backend.py b/doctests/backend.py new file mode 100644 index 0000000..2bee85d --- /dev/null +++ b/doctests/backend.py @@ -0,0 +1,366 @@ +"""Talking to the live backend: config, a cleanup client, and outcome checks. + +The doc programs build their own clients, exactly as a reader would. This module +is the *harness'* own connection, used for the two things around a run: making the +backend clean before and after, and asking it afterwards whether the tutorial +actually did what the page claims. + +Configuration follows the SDK's own contract (``BASE_URL`` plus either ``TOKEN`` or +the OAuth2 client-credentials trio), read from ``doctests/.env`` if present so a +developer configures this once. One addition, ``DOCTEST_TOKEN_CMD``, covers the +local-stack case where a token comes from a mint script rather than a client +secret; its output is cached briefly so a full suite run mints once, not per test. +""" + +from __future__ import annotations + +import contextlib +import os +import subprocess +import time +from pathlib import Path + +HERE = Path(__file__).parent +ENV_FILE = HERE / ".env" +_TOKEN_CACHE = HERE / ".token" +_TOKEN_TTL = 240 # seconds; tokens outlive a suite run but not a coffee break + + +class BackendUnavailable(Exception): + """No usable configuration or no reachable backend — the suite skips, not fails. + + A missing backend is not a broken tutorial, and reporting it as one trains + people to ignore red. It is reported as a skip with the reason attached. + """ + + +@contextlib.contextmanager +def quiet(): + """Silence the SDK's response-body chatter. + + The bindings print every response body from Rust, so it lands on fd 1 directly + and ``redirect_stdout`` cannot see it. Cleanup would otherwise bury the actual + test output under kilobytes of JSON, so the redirect happens at the fd level. + """ + saved = os.dup(1) + devnull = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(devnull, 1) + yield + finally: + os.dup2(saved, 1) + os.close(devnull) + os.close(saved) + + +def _read_env_file(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + out: dict[str, str] = {} + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + out[key.strip()] = value.strip().strip('"').strip("'") + return out + + +def _mint(cmd: str) -> str: + """Run a token-mint command; its last non-empty stdout line is the token.""" + if _TOKEN_CACHE.exists() and time.time() - _TOKEN_CACHE.stat().st_mtime < _TOKEN_TTL: + cached = _TOKEN_CACHE.read_text(encoding="utf-8").strip() + if cached: + return cached + proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120) + lines = [ln.strip() for ln in proc.stdout.splitlines() if ln.strip()] + if proc.returncode != 0 or not lines: + raise BackendUnavailable( + f"DOCTEST_TOKEN_CMD failed (exit {proc.returncode}): {proc.stderr.strip()[:300] or '(no stderr)'}" + ) + token = lines[-1] + _TOKEN_CACHE.write_text(token, encoding="utf-8") + _TOKEN_CACHE.chmod(0o600) + return token + + +def config() -> dict[str, str]: + """The environment a doc program runs under. Process env wins over the file.""" + env = {**_read_env_file(ENV_FILE), **{k: v for k, v in os.environ.items() if k in _PASSTHROUGH}} + + base = env.get("BASE_URL") + if not base: + raise BackendUnavailable( + "No BASE_URL. Copy doctests/.env.example to doctests/.env and point it at a stack." + ) + + if not env.get("TOKEN"): + mint = env.get("DOCTEST_TOKEN_CMD") or os.environ.get("DOCTEST_TOKEN_CMD") + if mint: + env["TOKEN"] = _mint(mint) + elif not all(env.get(k) for k in ("CLIENT_ID", "CLIENT_SECRET", "TOKEN_URI")): + raise BackendUnavailable( + "No credentials. Set TOKEN, or CLIENT_ID/CLIENT_SECRET/TOKEN_URI, or DOCTEST_TOKEN_CMD." + ) + return env + + +_PASSTHROUGH = ( + "BASE_URL", + "TOKEN", + "CLIENT_ID", + "CLIENT_SECRET", + "TOKEN_URI", + "PROJECT_NAME", + "DOCTEST_TOKEN_CMD", +) + + +def client(env: dict[str, str]): + """The harness' own SDK client, built from the same env the doc programs get.""" + try: + import intellistream_datahub_sdk as sdk + except ImportError as exc: # pragma: no cover - setup problem, not a doc problem + raise BackendUnavailable( + "The SDK is not installed in this venv. Run doctests/setup.sh." + ) from exc + + for key in _PASSTHROUGH: + if env.get(key): + os.environ[key] = env[key] + try: + with quiet(): + return sdk.DataHubClient.from_env() + except Exception as exc: + raise BackendUnavailable(f"Could not build a client for {env['BASE_URL']}: {exc}") from exc + + +# ---------------------------------------------------------------- cleanup + +# Every service's delete/by_ids accepts a bare external id string; only some also +# accept an IdCollection. Passing the wrong wrapper raises a TypeError from PyO3, +# which — inside the deliberately-forgiving sweep — would be swallowed, leaving +# entities behind and making the next run fail on a duplicate create that looks +# like a documentation bug. Strings are the one shape all of them take. + + +def _expand(cli, kind: str, values: list[str]) -> list[str]: + """Resolve any ``*`` patterns to concrete external ids. + + Several guides mint an id at run time — ``f"overheat_press_07_{timestamp}"`` — + so a plan cannot name what it will create. It declares the shape instead, and + the sweep asks the backend which ids currently match. Without this those rows + accumulate on the stack forever, one per suite run. + """ + literal = [v for v in values if "*" not in v] + patterns = [v for v in values if "*" in v] + if not patterns: + return literal + + found: list[str] = [] + for pattern in patterns: + try: + if kind == "events": + page = cli.events.filter(external_id=[pattern], limit=1000) + elif kind == "subscriptions": + import fnmatch + page = [s for s in cli.subscriptions.list() + if fnmatch.fnmatch(s.external_id or "", pattern)] + else: + page = cli.resources.filter(external_id=[pattern], limit=1000) + found.extend(e.external_id for e in page if getattr(e, "external_id", None)) + except Exception: + continue + return literal + found + + +def sweep(cli, owns: dict[str, list[str]]) -> None: + """Delete every entity a page declares it owns, before and after a run. + + Deleting *before* is what makes a run repeatable: the doc's fixed external ids + (``engine_temperature`` and friends) would otherwise 409 on the second run, and + a suite that only passes on a virgin backend is a suite nobody runs twice. + + Order matters — the backend refuses to delete a node that is the start of an + edge, and a dataset sits above what belongs to it — so leaves go first and + datasets last. Failures are swallowed on purpose: "already absent" is the + desired state, and a delete that cannot run is caught by the run that follows. + """ + order = [ + ("subscriptions", cli.subscriptions.delete), + ("events", cli.events.delete), + ("files", cli.files.delete), + ("timeseries", cli.timeseries.delete), + ("resources", cli.resources.delete), + ("datasets", cli.datasets.delete), + ] + unknown = set(owns) - {name for name, _ in order} + if unknown: + raise ValueError(f"`owns` has unknown entity type(s): {sorted(unknown)}") + + with quiet(): + # Repeat while the pass is still removing things. The backend refuses to delete + # a node that is the start of an edge, so a chain needs one pass per link and + # the depth is not knowable from here. A single pass silently leaves the head of + # every chain behind — and because a duplicate create answers 500, the next run + # fails with what looks like a server fault in the middle of a tutorial. + remaining = None + for _ in range(6): + deleted = 0 + for name, delete in order: + values = owns.get(name) or [] + if not values: + continue + for value in _expand(cli, name, values): + # One at a time: a batch delete fails wholesale if a single id is + # absent, which is the normal case on the pre-run sweep. + try: + delete([value]) + deleted += 1 + except Exception: + pass + if deleted == 0 or deleted == remaining: + break + remaining = deleted + + +# ---------------------------------------------------------------- assertions + +def _poll(check, timeout: float, interval: float = 0.5): + """Retry ``check`` until it reports nothing wrong, or the window closes. + + Reads go through eventually-consistent projections, so a datapoint written a + millisecond ago is genuinely not visible yet. Asserting immediately would make + this suite flaky, and a flaky documentation test gets muted rather than fixed — + which costs more than the bug it was built to catch. So the check is retried + and the *last* result is what gets reported: a tutorial that never lands its + data still fails, just after the backend has been given a fair chance. + """ + deadline = time.monotonic() + timeout + problems = check() + while problems and time.monotonic() < deadline: + time.sleep(interval) + problems = check() + return problems + + +def missing_entities(cli, expect: dict[str, list[str]], timeout: float = 30.0) -> list[str]: + """Which declared entities the tutorial failed to leave behind.""" + lookups = { + "timeseries": cli.timeseries.by_ids, + "resources": cli.resources.by_ids, + "datasets": cli.datasets.by_ids, + "events": cli.events.by_ids, + "files": lambda v: [n for x in v for n in cli.files.get_by_external_id(x)], + "subscriptions": lambda v: [x for x in cli.subscriptions.list() if x.external_id in set(v)], + } + unknown = set(expect) - set(lookups) + if unknown: + raise ValueError(f"`expect` has unknown entity type(s): {sorted(unknown)}") + + def check() -> list[str]: + gone: list[str] = [] + with quiet(): + for kind, wanted in expect.items(): + if not wanted: + continue + # An id minted at run time cannot be named, only shaped. A pattern + # asserts "the page produced at least one of these" — which is the + # real claim for a page whose payoff is an event it raises. + patterns = [w for w in wanted if "*" in w] + literals = [w for w in wanted if "*" not in w] + for pattern in patterns: + if not _expand(cli, kind, [pattern]): + gone.append(f"{kind}:{pattern} (nothing matched)") + if not literals: + continue + try: + found = {getattr(e, "external_id", None) for e in lookups[kind](literals)} + except Exception as exc: + gone.extend(f"{kind}:{w} (lookup failed: {str(exc)[:120]})" for w in literals) + continue + gone.extend(f"{kind}:{w}" for w in literals if w not in found) + return gone + + return _poll(check, timeout) if expect else [] + + +def datapoint_shortfall(cli, expect: dict[str, int], timeout: float = 30.0) -> list[str]: + """Series that hold fewer datapoints than the tutorial claims to have written. + + This is the check that separates "the program did not crash" from "the tutorial + worked". Several pages catch their own exceptions by design — the memory-ingest + daemon must never die on a bad tick — so their exit code says nothing at all + about whether data landed. Reading it back is the only honest proof. + """ + import datetime as _dt + + import intellistream_datahub_sdk as sdk + + def _as_datetime(value): + """Datapoint timestamps come back as datetimes or as strings, per endpoint.""" + if isinstance(value, _dt.datetime): + return value if value.tzinfo else value.replace(tzinfo=_dt.timezone.utc) + text = str(value).replace("Z", "+00:00") + try: + parsed = _dt.datetime.fromisoformat(text) + except ValueError: + return _dt.datetime.fromtimestamp(int(value) / 1000, _dt.timezone.utc) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=_dt.timezone.utc) + + # The retrieve endpoint rejects an unbounded window, so the check spans a + # generously wide one: tutorials write "now", but a few of them backfill + # historical ranges, and this must count those too. + now = _dt.datetime.now(_dt.timezone.utc) + start_at, end_at = now - _dt.timedelta(days=730), now + _dt.timedelta(days=1) + + # The retrieve endpoint refuses a limit above 100k, so a page claiming a million + # readings has to be verified by paging. Asking for the whole million in one + # call fails with a 400 that looks like the tutorial's fault and is not. + PAGE = 100_000 + + def count_datapoints(external_id: str, minimum: int) -> int: + """Count readable datapoints, walking the window forward in 100k slices. + + The obvious approach — one call with a big limit — is capped at 100k, and the + response carries no cursor to continue with (verified: 250k points come back + as 100k with `next_cursor` None). So the window is advanced instead: each + slice starts just after the last timestamp seen. That is what lets a page + claiming a million readings actually be held to it, rather than to the first + hundred thousand. + """ + seen = 0 + cursor_time = start_at + while seen < minimum and cursor_time < end_at: + got = cli.timeseries.retrieve_datapoints( + sdk.RetrieveFilter( + ts=sdk.IdCollection(external_id=external_id), + start=cursor_time, + end=end_at, + limit=PAGE, + ) + ) + points = [p for g in got for p in g.get_datapoints()] + if not points: + break + seen += len(points) + last = max(_as_datetime(p.timestamp) for p in points) + if last <= cursor_time: + break # no forward progress; stop rather than spin + cursor_time = last + _dt.timedelta(milliseconds=1) + return seen + + def check() -> list[str]: + short: list[str] = [] + with quiet(): + for external_id, minimum in expect.items(): + try: + count = count_datapoints(external_id, minimum) + except Exception as exc: + short.append(f"{external_id}: could not read datapoints back ({str(exc)[:120]})") + continue + if count < minimum: + short.append(f"{external_id}: {count} datapoint(s), expected at least {minimum}") + return short + + return _poll(check, timeout) if expect else [] diff --git a/doctests/bin/newplan.py b/doctests/bin/newplan.py new file mode 100755 index 0000000..2485006 --- /dev/null +++ b/doctests/bin/newplan.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Scaffold a test plan for a doc page. + +Writes a plan pre-filled with what can be read off the page — its block counts and +a commented inventory of every block with its heading and line number — so the +person (or agent) writing the plan starts from the page's actual shape instead of +from a blank file. It never guesses at prologues or owned ids: those require +reading the tutorial, which is the part that has to be done by someone who +understands what it teaches. + + ./doctests/bin/newplan.py docs/guides/ingest-timeseries.mdx + ./doctests/bin/newplan.py --untriaged "needs an MQTT broker" docs/guides/x.mdx +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) + +import docblocks # noqa: E402 +import plans as plans_mod # noqa: E402 + +REPO = HERE.parent.parent + + +def inventory(page: docblocks.Page) -> str: + rows = [] + for b in page.blocks: + if b.lang not in docblocks.EXECUTABLE: + continue + title = f' title={b.title}' if b.title else "" + rows.append(f"# {b.lang:6} #{b.lang_index:<3} L{b.start_line:<5} {b.heading}{title}") + return "\n".join(rows) or "# (no runnable blocks)" + + +def scaffold(page: docblocks.Page) -> str: + counts = page.counts() + present = [l for l in docblocks.EXECUTABLE if counts.get(l)] + lines = [ + f'page = "{page.rel}"', + "", + "# The blocks on this page, for reference while writing the plan below:", + inventory(page), + "", + "# Pinned so an edit to the page fails this plan instead of silently", + "# re-pointing its block selections. Update deliberately, after re-reading.", + "[blocks]", + ] + lines += [f"{lang} = {counts[lang]}" for lang in present] + + for lang in present: + comment = "#" if lang == "python" else "//" + lines += [ + "", + f"[{lang}]", + 'disabled = "not written yet - remove this line once the scenario below runs"', + "", + "# By default every block of this language runs, concatenated in reading", + "# order, which is what makes this an end-to-end test of the tutorial.", + f"# only = [1] {comment} run just these blocks (by the numbers above)", + f"# exclude = [3] {comment} run all but these", + "# timeout = 180", + "", + "# What a reader already has that the page does not restate.", + "# prologue = \"\"\"", + "# \"\"\"", + "", + "# Bound anything that would otherwise never finish. Every replacement must", + "# still match the page, so a doc edit surfaces here rather than hanging CI.", + f"# [[{lang}.replace]]", + '# find = "while True:"', + '# repl = "for _doctest_tick in range(2):"', + ] + + lines += [ + "", + "# External ids this page creates. Deleted before the run (so reruns are clean)", + "# and after it (so the backend does not accumulate doc fixtures).", + "# Types: timeseries, events, resources, datasets, subscriptions, files.", + "[owns]", + "# timeseries = []", + "", + "# What must be true afterwards. Exit code 0 only proves nothing threw;", + "# these prove the tutorial did the thing it claims to teach.", + "[expect]", + "# timeseries = []", + "# stdout = []", + "# [expect.datapoints]", + '# "some_external_id" = 1', + "", + ] + return "\n".join(lines) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("pages", nargs="+", help="doc page path(s), e.g. docs/quickstart.mdx") + ap.add_argument("--untriaged", metavar="REASON", + help="instead of a plan, add the page(s) to UNTRIAGED.toml with this reason") + ap.add_argument("--force", action="store_true", help="overwrite an existing plan") + args = ap.parse_args() + + for raw in args.pages: + path = (REPO / raw).resolve() if not Path(raw).is_absolute() else Path(raw) + if not path.exists(): + print(f"error: {raw} does not exist", file=sys.stderr) + return 1 + page = docblocks.load(path, REPO) + + if args.untriaged: + _append_untriaged(page.slug, args.untriaged) + print(f"UNTRIAGED.toml += {page.slug} ({args.untriaged})") + continue + + target = plans_mod.PLAN_DIR / f"{page.slug}.toml" + if target.exists() and not args.force: + print(f"error: {target.relative_to(REPO)} exists (use --force)", file=sys.stderr) + return 1 + target.write_text(scaffold(page), encoding="utf-8") + print(f"wrote {target.relative_to(REPO)} ({page.counts()})") + return 0 + + +def _append_untriaged(slug: str, reason: str) -> None: + path = plans_mod.UNTRIAGED + text = path.read_text(encoding="utf-8") if path.exists() else "[pages]\n" + if f'"{slug}"' in text or f"\n{slug} =" in text: + return + if not text.rstrip().endswith("[pages]") and "[pages]" not in text: + text += "\n[pages]\n" + path.write_text(text.rstrip("\n") + f'\n"{slug}" = "{reason}"\n', encoding="utf-8") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/doctests/bin/triage.py b/doctests/bin/triage.py new file mode 100755 index 0000000..e6faa9f --- /dev/null +++ b/doctests/bin/triage.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Report what a page's code needs before it can run, so a plan can be written fast. + +Writing a plan by hand means reading a page and noticing every name it uses but +never defines, every call that blocks forever, and every external id it creates. +That is mechanical work and easy to get subtly wrong — a missed id leaks rows onto +the stack, a missed blocking call hangs CI for a full timeout. + +This does the mechanical half and leaves the judgement: it walks the composed +program's AST and reports free variables, third-party imports, blocking +constructs, and the external ids the page appears to create. + + ./doctests/bin/triage.py docs/industries/energy-utilities/wind.mdx + ./doctests/bin/triage.py --all --unplanned # everything still in the backlog +""" + +from __future__ import annotations + +import argparse +import ast +import builtins +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) + +import docblocks # noqa: E402 +import entities # noqa: E402 +import plans as plans_mod # noqa: E402 + +REPO = HERE.parent.parent + +# Calls that never return on their own, so a plan must exclude or bound the block. +BLOCKING = { + "listen": "websocket listen loop — runs until the connection drops", + "show": "matplotlib show() — blocks on a GUI window (set MPLBACKEND=Agg)", + "input": "waits for the reader to type something", + "sleep": "sleeps; check the duration is bounded", +} +# Packages a reader installs for the recipe, not part of the SDK. +THIRD_PARTY = {"numpy", "pandas", "sklearn", "torch", "matplotlib", "networkx", + "scipy", "xgboost", "statsmodels", "psutil", "seaborn", "plotly", + "tensorflow", "keras"} + + +class Scope(ast.NodeVisitor): + """Collect names bound at any point in the module against names merely read. + + Deliberately flow-insensitive: a name bound anywhere counts as bound. The goal + is to surface what a page never defines at all, and a stricter analysis would + drown that signal in false positives from ordinary control flow. + """ + + def __init__(self) -> None: + self.bound: set[str] = set() + self.read: set[str] = set() + self.attr_roots: set[str] = set() + + def visit_Name(self, node: ast.Name) -> None: + if isinstance(node.ctx, ast.Store): + self.bound.add(node.id) + elif isinstance(node.ctx, ast.Load): + self.read.add(node.id) + self.generic_visit(node) + + def _bind_args(self, args: ast.arguments) -> None: + for a in (*args.posonlyargs, *args.args, *args.kwonlyargs): + self.bound.add(a.arg) + for a in (args.vararg, args.kwarg): + if a: + self.bound.add(a.arg) + + def visit_FunctionDef(self, node) -> None: + self.bound.add(node.name) + self._bind_args(node.args) + self.generic_visit(node) + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_Lambda(self, node) -> None: + self._bind_args(node.args) + self.generic_visit(node) + + def visit_ClassDef(self, node) -> None: + self.bound.add(node.name) + self.generic_visit(node) + + def visit_alias(self, node: ast.alias) -> None: + self.bound.add((node.asname or node.name).split(".")[0]) + + def visit_ExceptHandler(self, node) -> None: + if node.name: + self.bound.add(node.name) + self.generic_visit(node) + + def visit_Global(self, node) -> None: + self.bound.update(node.names) + + visit_Nonlocal = visit_Global + + +def analyse(page: docblocks.Page, lang: str = "python") -> dict: + blocks = page.of_lang(lang) + source = "\n".join(b.body for b in blocks) + + out: dict = {"blocks": len(blocks), "source": source, "syntax_error": None, + "free": [], "third_party": [], "blocking": [], "creates": []} + if not blocks: + return out + + try: + tree = ast.parse(source) + except SyntaxError as exc: + # Fragments often do not parse as one unit; fall back to per-block parsing + # so the rest of the report still has something to say. + out["syntax_error"] = f"line {exc.lineno}: {exc.msg}" + trees = [] + for b in blocks: + try: + trees.append(ast.parse(b.body)) + except SyntaxError: + pass + tree = ast.Module(body=[n for t in trees for n in t.body], type_ignores=[]) + + scope = Scope() + scope.visit(tree) + known = set(dir(builtins)) | {"__name__", "__file__", "_"} + out["free"] = sorted(scope.read - scope.bound - known) + + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + mod = node.module if isinstance(node, ast.ImportFrom) else None + names = [mod] if mod else [a.name for a in node.names] + for n in names: + root = (n or "").split(".")[0] + if root in THIRD_PARTY: + out["third_party"].append(root) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute): + if node.func.attr in BLOCKING: + out["blocking"].append(f"{node.func.attr}() — {BLOCKING[node.func.attr]}") + if isinstance(node, ast.While) and isinstance(node.test, ast.Constant) and node.test.value is True: + out["blocking"].append("while True: — needs a bounded-run replacement") + + out["third_party"] = sorted(set(out["third_party"])) + out["blocking"] = sorted(set(out["blocking"])) + # External ids are the cleanup contract; a literal is all a static pass can see, + # and an f-string id is flagged so the plan author writes a `prefix_*` pattern. + owned = entities.owned(source) + out["creates"] = sorted(i for ids in owned.values() for i in ids if "*" not in i) + out["dynamic_ids"] = sorted(i for ids in owned.values() for i in ids if "*" in i) + out["owned"] = owned + return out + + +def report(page: docblocks.Page) -> None: + a = analyse(page) + print(f"\n\033[1m{page.rel}\033[0m ({a['blocks']} python blocks)") + if a["syntax_error"]: + print(f" concatenation does not parse: {a['syntax_error']}") + for key, label in (("third_party", "needs packages"), ("blocking", "blocking"), + ("free", "undefined names"), ("creates", "creates ids"), + ("dynamic_ids", "runtime ids")): + if a.get(key): + print(f" {label:16} {', '.join(map(str, a[key]))}") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("pages", nargs="*") + ap.add_argument("--all", action="store_true", help="every page with runnable code") + ap.add_argument("--unplanned", action="store_true", help="only pages without a plan") + args = ap.parse_args() + + if args.all or args.unplanned: + pages = [p for p in docblocks.all_pages(REPO) if p.of_lang("python")] + if args.unplanned: + planned = {docblocks.slug_for(p.page) for p in plans_mod.load_all().values()} + pages = [p for p in pages if p.slug not in planned] + else: + pages = [docblocks.load((REPO / p).resolve(), REPO) for p in args.pages] + + for page in pages: + report(page) + print(f"\n{len(pages)} page(s)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/doctests/conftest.py b/doctests/conftest.py new file mode 100644 index 0000000..84efa3b --- /dev/null +++ b/doctests/conftest.py @@ -0,0 +1,144 @@ +"""Shared fixtures: one backend connection and one language selection per session. + +Both are session-scoped because both are expensive in the way that matters — a +token mint per test would dominate the run, and re-deciding the language set per +test would make a partial run's report inconsistent with itself. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +import backend +import docblocks +import plans as plans_mod +import runners +from docblocks import EXECUTABLE + +REPO = Path(__file__).parent.parent + + +def pytest_addoption(parser): + parser.addoption( + "--langs", + default=os.environ.get("DOCTEST_LANGS", "python"), + help=("Comma-separated languages to execute (python,java,rust) or 'all'. " + "Defaults to python: it is the only one whose toolchain this repo can " + "assume, so it is the one CI can hold green."), + ) + parser.addoption( + "--keep", + action="store_true", + help="Leave the entities a tutorial created on the backend, for inspection.", + ) + + +@pytest.fixture(scope="session") +def langs(pytestconfig) -> set[str]: + raw = pytestconfig.getoption("--langs").strip().lower() + if raw == "all": + return set(EXECUTABLE) + chosen = {p.strip() for p in raw.split(",") if p.strip()} + unknown = chosen - set(EXECUTABLE) + if unknown: + raise pytest.UsageError(f"--langs: unknown language(s) {sorted(unknown)}; pick from {EXECUTABLE}") + return chosen + + +@pytest.fixture(scope="session") +def env() -> dict[str, str]: + try: + return backend.config() + except backend.BackendUnavailable as exc: + pytest.skip(f"backend not configured: {exc}", allow_module_level=True) + + +@pytest.fixture(scope="session") +def cli(env): + try: + return backend.client(env) + except backend.BackendUnavailable as exc: + pytest.skip(f"backend not reachable: {exc}", allow_module_level=True) + + + +@pytest.fixture(scope="session") +def seed(env, cli, tmp_path_factory): + """Run a data-generating page once per session, and remember that it ran. + + `advanced/generate-sample-data` seeds the series every ML recipe reads. Composing + it into all thirteen recipes would mean generating a fortnight of minute-resolution + data thirteen times over; running it once and letting the recipes find the data is + both faster and closer to what the docs actually tell a reader to do. + + A failure here is reported against the seeding page, not the recipe that asked for + it — otherwise thirteen tests all blame their own doc for one broken fixture. + """ + done: dict[tuple[str, str], str | None] = {} + planted: list[dict] = [] + + def ensure(slug: str, lang: str) -> None: + key = (slug, lang) + if key in done: + if done[key]: + pytest.fail(done[key]) + return + + all_plans = plans_mod.load_all() + links = plans_mod.chain(slug, lang, all_plans) + sections = [] + for link in links: + page = docblocks.load(REPO / link.page, REPO) + sections.append((link.lang(lang), page.of_lang(lang), link.page)) + source, line_map = runners.compose(lang, sections) + + owns = plans_mod.merged_owns(links) + backend.sweep(cli, owns) + workdir = tmp_path_factory.mktemp(f"seed-{slug}") + result = runners.RUNNERS[lang](source, line_map, workdir, env, links[-1].lang(lang)) + + if result.ok: + # Exiting 0 only means the writes were accepted. The recipes that depend + # on this read the data immediately, and reads are eventually consistent — + # so hand over only once the fixture is actually visible. Skipping this + # makes the suite pass on a warm backend and fail on a cold one, which is + # the worst kind of flake: it looks like the recipes are broken. + fixture = all_plans[slug] + missing = backend.missing_entities(cli, fixture.expect_exists, fixture.settle_secs) + short = backend.datapoint_shortfall(cli, fixture.expect_datapoints, fixture.settle_secs) + if missing or short: + done[key] = ( + f"The data-seeding page {fixture.page} [{lang}] ran, but its data never " + f"became readable: {', '.join(missing + short)}.\n" + "Every tutorial that depends on it would fail for a reason that is not its own." + ) + pytest.fail(done[key]) + done[key] = None + planted.append(owns) + else: + done[key] = ( + f"The data-seeding page {all_plans[slug].page} [{lang}] failed, so every " + f"tutorial that depends on it cannot be tested.\n" + f"Fix that page first — run: ./doctests/run.sh -k '{slug}'\n\n" + f"{runners.tidy(result.stderr, 2000)}" + ) + pytest.fail(done[key]) + + yield ensure + + # Cleanup for the seeding pages happens here, once, rather than after each of + # their own tests — those keep their data alive for the recipes that need it. + all_plans = plans_mod.load_all() + fixtures = {dep for plan in all_plans.values() + for lp in plan.langs.values() for dep in lp.requires_once} + for slug in sorted(fixtures): + for lang in ("python",): + try: + planted.append(plans_mod.merged_owns(plans_mod.chain(slug, lang, all_plans))) + except plans_mod.PlanError: + pass + for owns in planted: + backend.sweep(cli, owns) diff --git a/doctests/docblocks.py b/doctests/docblocks.py new file mode 100644 index 0000000..96f6174 --- /dev/null +++ b/doctests/docblocks.py @@ -0,0 +1,144 @@ +"""Pull fenced code blocks out of a Docusaurus page. + +The whole suite rests on this: a doc test must execute *the code the reader sees*, +never a copy of it. So every runnable program is assembled from the page's own +fences at test time. If a page is edited, the next run assembles the new text. + +What a block carries beyond its body is what makes plans readable and stable: + +- ``lang_index`` — position among the blocks of the *same* language on the page. + Plans address blocks by this, not by absolute position, so adding a ``bash`` + fence to a page does not renumber every Python block in its plan. +- ``heading`` — the nearest preceding ``#`` heading, so a failure can say + "Step 2 — Ensure the time series exist" instead of "block 4". +- ``tab`` — the enclosing ````, which is how the language + tabs are built. A ``bash`` fence inside the Python tab belongs to the Python + reader's flow even though its language is not ``python``. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +# A fence opener: indent, >=3 backticks, optional language, optional metastring +# (Docusaurus uses the metastring for `title="x.py"`). +_FENCE = re.compile(r"^(\s*)(`{3,})[ \t]*([A-Za-z0-9_+#-]*)[ \t]*(.*)$") +_HEADING = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*#*$") +_TAB_OPEN = re.compile(r"]*?\bvalue=[\"']([^\"']+)[\"']") +_TAB_CLOSE = re.compile(r"") + +# Languages this suite knows how to execute. Everything else (bash, toml, text, +# json, kotlin, http) is documentation *about* running, not a program. +EXECUTABLE = ("python", "java", "rust") + + +@dataclass(frozen=True) +class Block: + """One fenced code block, with enough context to name it in a failure.""" + + index: int # 1-based, among all blocks on the page + lang_index: int # 1-based, among blocks of this language — what plans use + lang: str + meta: str # the metastring, e.g. 'title="memory_ingest.py"' + body: str + start_line: int # 1-based line of the opening fence, for jump-to-source + heading: str + tab: str | None + + @property + def title(self) -> str | None: + m = re.search(r'title=["\']([^"\']+)["\']', self.meta) + return m.group(1) if m else None + + def where(self, page: str) -> str: + """A clickable, human location: ``docs/quickstart.mdx:57 (python #2)``.""" + return f"{page}:{self.start_line} ({self.lang} #{self.lang_index})" + + +@dataclass +class Page: + path: Path + rel: str # repo-relative, e.g. docs/guides/ingest-timeseries.mdx + slug: str # plan filename stem, e.g. guides__ingest-timeseries + blocks: list[Block] + + def of_lang(self, lang: str) -> list[Block]: + return [b for b in self.blocks if b.lang == lang] + + def counts(self) -> dict[str, int]: + out: dict[str, int] = {} + for b in self.blocks: + out[b.lang] = out.get(b.lang, 0) + 1 + return out + + +def slug_for(rel: str) -> str: + """docs/guides/x.mdx -> guides__x — a flat, filesystem-safe plan name.""" + stem = re.sub(r"\.mdx?$", "", rel) + stem = re.sub(r"^docs/", "", stem) + return stem.replace("/", "__") + + +def parse(text: str) -> list[Block]: + lines = text.split("\n") + blocks: list[Block] = [] + heading = "" + tabs: list[str] = [] + open_fence: tuple[str, str, str, int] | None = None # ticks, lang, meta, start + body: list[str] = [] + per_lang: dict[str, int] = {} + + for n, line in enumerate(lines, start=1): + fence = _FENCE.match(line) + + if open_fence is not None: + ticks, lang, meta, start = open_fence + # A closing fence is >= as many backticks as the opener and nothing else. + if fence and fence.group(2).startswith(ticks) and not fence.group(3) and not fence.group(4): + per_lang[lang] = per_lang.get(lang, 0) + 1 + blocks.append( + Block( + index=len(blocks) + 1, + lang_index=per_lang[lang], + lang=lang, + meta=meta, + body="\n".join(body), + start_line=start, + heading=heading, + tab=tabs[-1] if tabs else None, + ) + ) + open_fence, body = None, [] + else: + body.append(line) + continue + + if fence and fence.group(2): + open_fence = (fence.group(2), (fence.group(3) or "text").lower(), fence.group(4).strip(), n) + body = [] + continue + + h = _HEADING.match(line) + if h: + heading = h.group(2) + for m in _TAB_OPEN.finditer(line): + tabs.append(m.group(1)) + for _ in _TAB_CLOSE.finditer(line): + if tabs: + tabs.pop() + + return blocks + + +def load(path: Path, root: Path) -> Page: + rel = str(path.relative_to(root)) + return Page(path=path, rel=rel, slug=slug_for(rel), blocks=parse(path.read_text(encoding="utf-8"))) + + +def all_pages(root: Path) -> list[Page]: + """Every doc page, sorted, so test ids and reports are stable run to run.""" + docs = root / "docs" + files = sorted(p for p in docs.rglob("*") if p.suffix in (".md", ".mdx")) + return [load(p, root) for p in files] diff --git a/doctests/entities.py b/doctests/entities.py new file mode 100644 index 0000000..6a5c8be --- /dev/null +++ b/doctests/entities.py @@ -0,0 +1,225 @@ +"""Work out which entities a page's code creates, so a plan can own and assert them. + +Getting this list right is what makes a run repeatable. Anything a page creates and +a plan fails to declare survives the sweep, and the *next* run fails on a duplicate +create — an error that looks like a documentation bug and is not one. So the reading +has to cope with how these pages actually write ids, which is rarely a plain literal: + + TimeSeries(external_id="engine_temperature") # literal + [Resource(external_id=x) for x in ["plant", "line"]] # comprehension + for s, u in [("flow_in", "m3h"), ...]: create(TimeSeries(external_id=s)) # loop + TimeSeries(external_id=f"{cell}_prb_util") # built from a loop var + Event(external_id=f"kick_{int(now.timestamp())}") # minted at run time + +The first four resolve to concrete ids. The last cannot — it is only known while the +program runs — so it becomes a ``prefix_*`` pattern the sweep expands against the +backend instead. +""" + +from __future__ import annotations + +import ast + +# Constructor name -> the `owns` bucket its instances belong to. +CONSTRUCTORS = { + "TimeSeries": "timeseries", + "Resource": "resources", + "Event": "events", + "Subscription": "subscriptions", + "Dataset": "datasets", + "FileUpload": "files", +} + + +def _ctor_bucket(call: ast.Call) -> str | None: + fn = call.func + name = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", None) + return CONSTRUCTORS.get(name) + + +def _strings(node: ast.AST) -> list[str]: + return [n.value for n in ast.walk(node) + if isinstance(n, ast.Constant) and isinstance(n.value, str)] + + +def _from_joinedstr(node: ast.JoinedStr, bindings: dict[str, list[str]]) -> list[str]: + """Resolve an f-string id, using loop bindings where the parts are known. + + ``f"{cell}_prb_util"`` with ``cell`` bound to two literals yields both ids. A part + that cannot be resolved — a timestamp, a counter — collapses the whole thing to a + prefix pattern, which is the honest answer: the id is not knowable until it exists. + """ + prefix, resolvable = "", True + options = [""] + for part in node.values: + if isinstance(part, ast.Constant) and isinstance(part.value, str): + options = [o + part.value for o in options] + if resolvable: + prefix += part.value + elif isinstance(part, ast.FormattedValue) and isinstance(part.value, ast.Name) \ + and part.value.id in bindings: + values = bindings[part.value.id] + options = [o + v for o in options for v in values] + resolvable = False if not values else resolvable + if not prefix: + resolvable = False + else: + resolvable = False + break + else: + return sorted(set(options)) + return [prefix.rstrip("_") + "_*"] if prefix else [] + + +def _loop_bindings(tree: ast.AST) -> dict[str, list[str]]: + """Names bound by `for` loops over literal sequences, mapped to their values.""" + out: dict[str, list[str]] = {} + for node in ast.walk(tree): + if not isinstance(node, (ast.For, ast.comprehension)): + continue + target = node.target + iterable = node.iter + if isinstance(target, ast.Name): + # `for s in ["a", "b"]` — but a list of tuples would over-collect, so only + # take the strings when the elements are strings. + if isinstance(iterable, (ast.List, ast.Tuple)) and \ + all(isinstance(e, ast.Constant) for e in iterable.elts): + out.setdefault(target.id, []).extend(_strings(iterable)) + else: + out.setdefault(target.id, []).extend(_strings(iterable)) + elif isinstance(target, ast.Tuple) and isinstance(iterable, (ast.List, ast.Tuple)): + # `for s, u in [("flow_in", "m3h"), ...]` — position matters. + for pos, elt in enumerate(target.elts): + if not isinstance(elt, ast.Name): + continue + values = [] + for row in iterable.elts: + if isinstance(row, (ast.Tuple, ast.List)) and pos < len(row.elts): + cell = row.elts[pos] + if isinstance(cell, ast.Constant) and isinstance(cell.value, str): + values.append(cell.value) + if values: + out.setdefault(elt.id, []).extend(values) + return out + + +def _plausible(value: str) -> bool: + """Filter out strings that are clearly not external ids.""" + return bool(value) and " " not in value and not value.startswith(("http", "/", ".")) + + +def _helper_creators(tree: ast.AST) -> dict[str, tuple[int, str]]: + """Locally-defined functions that create an entity from one of their parameters. + + Pages that seed a lot of data factor the boilerplate into a helper — + ``def ingest(external_id, index, values, ...)`` that creates the series and writes + to it — and then call it thirty times with a literal id. Without following that + one hop, every one of those ids is invisible to the sweep, the page cannot be run + twice, and the failure surfaces as a duplicate-create in the middle of a tutorial. + + Returns ``{function name: (parameter position, bucket)}``. + """ + out: dict[str, tuple[int, str]] = {} + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + params = [a.arg for a in (*node.args.posonlyargs, *node.args.args)] + for inner in ast.walk(node): + if not isinstance(inner, ast.Call): + continue + bucket = _ctor_bucket(inner) + if not bucket: + continue + for kw in inner.keywords: + if kw.arg == "external_id" and isinstance(kw.value, ast.Name) \ + and kw.value.id in params: + out[node.name] = (params.index(kw.value.id), bucket) + return out + + +def owned(source: str, include_edge_refs: bool = True) -> dict[str, list[str]]: + """`owns`-shaped mapping of entity kind -> external ids (and `prefix_*` patterns). + + ``include_edge_refs`` decides whether the nodes an edge *points at* count. For + cleanup they should — an edge can name a node the page created elsewhere, and a + missed id is a leak. For assertions they must not: a page can legitimately draw an + edge to something it never creates, and asserting that it exists would turn the + page's own bug into an assertion about the wrong thing. + """ + try: + tree = ast.parse(source) + except SyntaxError: + return {} + + bindings = _loop_bindings(tree) + helpers = _helper_creators(tree) + out: dict[str, set[str]] = {} + + def add(bucket: str, value: str) -> None: + if _plausible(value): + out.setdefault(bucket, set()).add(value) + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + bucket = _ctor_bucket(node) + if bucket: + for kw in node.keywords: + if kw.arg != "external_id": + continue + v = kw.value + if isinstance(v, ast.Constant) and isinstance(v.value, str): + add(bucket, v.value) + elif isinstance(v, ast.JoinedStr): + for candidate in _from_joinedstr(v, bindings): + add(bucket, candidate) + elif isinstance(v, ast.Name) and v.id in bindings: + for candidate in bindings[v.id]: + add(bucket, candidate) + # A call to a local helper that creates an entity from one of its arguments. + fname = node.func.id if isinstance(node.func, ast.Name) else None + if fname in helpers: + pos, bucket = helpers[fname] + if pos < len(node.args): + arg = node.args[pos] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + add(bucket, arg.value) + elif isinstance(arg, ast.JoinedStr): + for candidate in _from_joinedstr(arg, bindings): + add(bucket, candidate) + elif isinstance(arg, ast.Name) and arg.id in bindings: + for candidate in bindings[arg.id]: + add(bucket, candidate) + + # An edge names two resources by external id. + fn = node.func + name = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", None) + if include_edge_refs and name == "by_external_ids" and len(node.args) >= 2: + for arg in node.args[:2]: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + add("resources", arg.value) + + # Comprehensions need no special case: the constructor inside one is an + # ordinary Call, and its `external_id=` resolves through the same loop + # bindings. Taking every string out of the iterable instead would sweep up + # whatever else rides along in it — the labels in + # `[(x, "Cell"), (y, "Controller")]` are not external ids. + + # `ts=` names a series — but only on a write. The same keyword on a RetrieveFilter + # is a *read*, and treating that as ownership makes a page claim series it merely + # looks at. For a recipe reading a shared fixture that is actively harmful: the + # sweep would delete the fixture out from under the next test. + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \ + and node.func.attr in ("insert_from_lists", "insert_datapoints"): + for kw in node.keywords: + if kw.arg == "ts": + if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str): + add("timeseries", kw.value.value) + elif isinstance(kw.value, ast.JoinedStr): + for candidate in _from_joinedstr(kw.value, bindings): + add("timeseries", candidate) + elif isinstance(kw.value, ast.Name) and kw.value.id in bindings: + for candidate in bindings[kw.value.id]: + add("timeseries", candidate) + + return {k: sorted(v) for k, v in out.items()} diff --git a/doctests/java-classpath.gradle b/doctests/java-classpath.gradle new file mode 100644 index 0000000..745d3a5 --- /dev/null +++ b/doctests/java-classpath.gradle @@ -0,0 +1,12 @@ +// Gradle init script: print the datahub-java-sdk runtime classpath so the Java +// runner can compile a doc snippet against the same jars an application would. +// Used via `gradlew -I java-classpath.gradle :datahub-java-sdk:printSdkCp`. +allprojects { + tasks.register("printSdkCp") { + doLast { + def cp = (configurations.findByName("runtimeClasspath")?.files ?: []) as List + cp += tasks.findByName("jar")?.archiveFile?.get()?.asFile + println "SDKCP=" + cp.findAll { it != null }.join(File.pathSeparator) + } + } +} diff --git a/doctests/plans.py b/doctests/plans.py new file mode 100644 index 0000000..bb91a4d --- /dev/null +++ b/doctests/plans.py @@ -0,0 +1,298 @@ +"""Test plans: the per-page declaration of how a tutorial is run and verified. + +A plan is deliberately *thin*. It never contains the tutorial's code — that always +comes from the page. It contains only the things a reader supplies from context and +a test harness cannot guess: + +- which of the page's blocks form the runnable program, +- the prologue a reader would already have (a `readings` list the page says "you + have", a directory to write into), +- bounded-run substitutions for code that would otherwise never terminate, +- the external ids the page creates, so a run can start and end clean, +- what must be true on the backend afterwards for the tutorial to have worked. + +Two rules keep plans honest, both enforced in code rather than by review: + +1. ``[blocks]`` records how many fences of each language the page has. Edit the + page and the count moves, the plan stops matching, and the suite fails until a + human re-reads it. A doc test that silently keeps passing while the doc changes + underneath it is worse than no test. +2. Every ``replace`` must actually match. A substitution that has stopped applying + is a substitution that is quietly no longer bounding the loop it was written for. +""" + +from __future__ import annotations + +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +from docblocks import EXECUTABLE + +PLAN_DIR = Path(__file__).parent / "plans" +UNTRIAGED = PLAN_DIR / "UNTRIAGED.toml" + + +class PlanError(Exception): + """A plan is malformed. Always a bug in the plan, never in the docs.""" + + +@dataclass +class Replacement: + find: str + repl: str + # Some substitutions only apply to one block; most apply to the whole program. + required: bool = True + + def apply(self, src: str) -> str: + """Apply where it matches. Whether a required replacement matched *anywhere* + is checked once per plan by `validate_replacements` — applying is per + fragment, and in independent mode a replacement rightly touches one block.""" + return src.replace(self.find, self.repl) if self.find in src else src + + +@dataclass +class Injection: + """Code spliced in just before one block. + + A prologue runs before the whole page, which is the wrong place for anything a + reader only has *part way down*: step 2 of a tutorial often uses arrays that + step 1 has just built under different names ("your `timestamps`" is the `idx` + from the demo-data block). Defining those up front is impossible; defining them + at the point the reader reaches them is exactly right. + """ + + before: int # 1-based index among this language's blocks + code: str + + +@dataclass +class LangPlan: + lang: str + disabled: str | None = None # a reason, when this language is not run + # Pages this one continues from, by plan slug. Their program is prepended, so + # a guide that opens "you already have a client" is tested the way a reader + # arrives at it: having actually run the quickstart. Writing that setup by + # hand in a prologue instead would quietly hide a broken quickstart, which is + # the one thing a documentation test must never do. + requires: list[str] = field(default_factory=list) + # Pages whose job is to *populate the backend* rather than to be continued from — + # `advanced/generate-sample-data` seeds every series the ML recipes read. Those run + # once per session and are not composed into the dependent program: thirteen recipes + # each re-generating a fortnight of minute data would dominate the suite, and the + # recipes build their own client anyway. What they need from it is the data. + requires_once: list[str] = field(default_factory=list) + only: list[int] = field(default_factory=list) + exclude: list[int] = field(default_factory=list) + # Lines hoisted to the top of the file: Java `import`s, Rust `use`s, Python + # imports the page assumes you already ran in an earlier tab. + imports: str = "" + prologue: str = "" + epilogue: str = "" + replace: list[Replacement] = field(default_factory=list) + inject: list[Injection] = field(default_factory=list) + # Run each block as its own program instead of concatenating them. + # + # Right for an API reference, wrong for a tutorial. A reference page lists + # independent examples — create a series, delete a series, write datapoints — + # and running them as one program invents conflicts the reader would never hit: + # the delete example removes what the write example needs. A tutorial is the + # opposite: its steps only mean anything in sequence, which is why concatenation + # is the default and this has to be asked for. + independent: bool = False + env: dict[str, str] = field(default_factory=dict) + # Environment a tutorial genuinely needs, beyond a reachable backend — e.g. a + # page that teaches the OAuth2 client-credentials constructor cannot be tested + # honestly against a token-only stack. Missing vars skip the test with that + # reason rather than failing it, because an unconfigurable environment is not + # a broken document. + requires_env: list[str] = field(default_factory=list) + # Python packages the page tells the reader to install. + requires_python: list[str] = field(default_factory=list) + timeout: int = 180 + + def validate_replacements(self, blocks: list, page: str) -> None: + """Every required replacement must still match something the plan runs. + + This is the guard that stops a bounded-run substitution from lapsing: if a + page rewrites the loop a `replace` was written for, the substitution silently + stops applying and the next run hangs until its timeout. Checked against the + whole selection, since `independent` composes one block at a time. + """ + haystack = "\n".join([self.prologue, self.epilogue, + *(b.body for b in self.select(blocks)), + *(i.code for i in self.inject)]) + stale = [r.find for r in self.replace if r.required and r.find not in haystack] + if stale: + raise PlanError( + f"{page} [{self.lang}]: replacement(s) {stale!r} no longer match the page. " + "The doc changed — re-read it and update (or drop) them. Do not delete " + "one just to get green: it exists to bound a run." + ) + + def validate_injects(self, blocks: list, page: str) -> None: + """Every injection must land on a block this plan actually runs. + + Block numbering shifts when a page is edited, so an injection left pointing + at nothing is a plan that has quietly stopped supplying what its page needs. + Checked against the whole selection rather than during composition, because + `independent` composes one block at a time. + """ + homes = {b.lang_index for b in self.select(blocks)} + orphans = sorted({i.before for i in self.inject} - homes) + if orphans: + raise PlanError( + f"{page} [{self.lang}]: inject targets block(s) {orphans}, which this plan " + "does not run. Block numbering may have shifted — re-read the page." + ) + + def select(self, blocks: list) -> list: + """The blocks that make up the program, in the order the reader meets them.""" + if self.only: + by_index = {b.lang_index: b for b in blocks} + missing = [i for i in self.only if i not in by_index] + if missing: + raise PlanError( + f"{self.lang}: plan selects block(s) {missing} but the page has " + f"{len(blocks)} {self.lang} block(s)." + ) + return [by_index[i] for i in self.only] + return [b for b in blocks if b.lang_index not in set(self.exclude)] + + +@dataclass +class Plan: + slug: str + page: str + path: Path + disabled: str | None + blocks: dict[str, int] + langs: dict[str, LangPlan] + owns: dict[str, list[str]] + expect_exists: dict[str, list[str]] + expect_datapoints: dict[str, int] + expect_stdout: list[str] + # How long to let eventually-consistent reads settle before calling it a failure. + settle_secs: float + + def lang(self, lang: str) -> LangPlan: + return self.langs.get(lang, LangPlan(lang=lang, disabled="no scenario declared for this language")) + + +def chain(slug: str, lang: str, all_plans: dict[str, "Plan"], _seen: tuple[str, ...] = ()) -> list["Plan"]: + """The plans to run, prerequisites first, ending with `slug`. + + Depth-first so a chain of two hops (a guide that needs a guide that needs the + quickstart) arrives in reading order. A cycle is a plan bug and says so. + """ + if slug in _seen: + raise PlanError(f"`requires` forms a cycle: {' -> '.join((*_seen, slug))}") + if slug not in all_plans: + raise PlanError(f"`requires` names {slug!r}, which has no plan in doctests/plans/.") + + plan = all_plans[slug] + out: list[Plan] = [] + for dep in plan.lang(lang).requires: + for p in chain(dep, lang, all_plans, (*_seen, slug)): + if p.slug not in {q.slug for q in out}: + out.append(p) + out.append(plan) + return out + + +def merged_owns(plans: list["Plan"]) -> dict[str, list[str]]: + """Everything a whole chain creates, so the sweep covers the prerequisites too.""" + out: dict[str, list[str]] = {} + for p in plans: + for kind, ids in p.owns.items(): + out.setdefault(kind, []) + out[kind].extend(i for i in ids if i not in out[kind]) + return out + + +def _replacements(raw: list, where: str) -> list[Replacement]: + out = [] + for item in raw: + if "find" not in item or "repl" not in item: + raise PlanError(f"{where}: each [[replace]] needs both `find` and `repl`.") + out.append(Replacement(find=item["find"], repl=item["repl"], required=item.get("required", True))) + return out + + +def load(path: Path) -> Plan: + try: + raw = tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + raise PlanError(f"{path.name}: not valid TOML — {exc}") from exc + + if "page" not in raw: + raise PlanError(f"{path.name}: missing `page` (the doc file this plan tests).") + + langs: dict[str, LangPlan] = {} + for lang in EXECUTABLE: + section = raw.get(lang) + if section is None: + continue + langs[lang] = LangPlan( + lang=lang, + disabled=section.get("disabled"), + requires=list(section.get("requires", [])), + requires_once=list(section.get("requires_once", [])), + only=section.get("only", []), + exclude=section.get("exclude", []), + imports=section.get("imports", ""), + prologue=section.get("prologue", ""), + epilogue=section.get("epilogue", ""), + replace=_replacements(section.get("replace", []), f"{path.name} [{lang}]"), + independent=bool(section.get("independent", False)), + inject=[ + Injection(before=int(i["before"]), code=i["code"]) + for i in section.get("inject", []) + ], + env={str(k): str(v) for k, v in section.get("env", {}).items()}, + requires_env=list(section.get("requires_env", [])), + requires_python=list(section.get("requires_python", [])), + timeout=int(section.get("timeout", 180)), + ) + if langs[lang].only and langs[lang].exclude: + raise PlanError(f"{path.name} [{lang}]: use `only` or `exclude`, not both.") + + expect = raw.get("expect", {}) + return Plan( + slug=path.stem, + page=raw["page"], + path=path, + disabled=raw.get("disabled"), + blocks={str(k): int(v) for k, v in raw.get("blocks", {}).items()}, + langs=langs, + owns={str(k): list(v) for k, v in raw.get("owns", {}).items()}, + expect_exists={ + str(k): list(v) for k, v in expect.items() if k not in ("datapoints", "stdout", "settle_secs") + }, + expect_datapoints={str(k): int(v) for k, v in expect.get("datapoints", {}).items()}, + expect_stdout=list(expect.get("stdout", [])), + settle_secs=float(expect.get("settle_secs", 30.0)), + ) + + +def load_all() -> dict[str, Plan]: + return {p.stem: load(p) for p in sorted(PLAN_DIR.glob("*.toml")) if p.name != UNTRIAGED.name} + + +def untriaged() -> dict[str, str]: + """Slug -> reason for pages that carry runnable code but have no scenario yet. + + This list is the suite's backlog, and it is meant to shrink. It exists so a + page can be *knowingly* uncovered; a page that is neither planned nor listed + here fails the coverage gate, which is what stops a new tutorial from landing + untested. + """ + if not UNTRIAGED.exists(): + return {} + raw = tomllib.loads(UNTRIAGED.read_text(encoding="utf-8")) + out = {} + for slug, reason in raw.get("pages", {}).items(): + if not str(reason).strip(): + raise PlanError(f"UNTRIAGED.toml: {slug} needs a reason, not an empty string.") + out[slug] = str(reason) + return out diff --git a/doctests/plans/UNTRIAGED.toml b/doctests/plans/UNTRIAGED.toml new file mode 100644 index 0000000..9317915 --- /dev/null +++ b/doctests/plans/UNTRIAGED.toml @@ -0,0 +1,10 @@ +# Pages that carry runnable code but have no test plan yet. +# +# This is a backlog, not a waiver. Every entry is a tutorial nobody is checking, +# so the list is meant to shrink: pick one, run `./doctests/bin/newplan.py `, +# write the scenario, delete the line. The coverage test refuses to let a page be +# both planned and listed, or listed and gone, so the file cannot drift out of date. +# +# Currently empty: every page with runnable code has a plan. + +[pages] diff --git a/doctests/plans/advanced__asset-health-score.toml b/doctests/plans/advanced__asset-health-score.toml new file mode 100644 index 0000000..b4ff913 --- /dev/null +++ b/doctests/plans/advanced__asset-health-score.toml @@ -0,0 +1,34 @@ +page = "docs/advanced/asset-health-score.mdx" + +# Blocks on this page: +# python #1 L44 1. Pull the latest value of each signal +# java #1 L68 1. Pull the latest value of each signal +# rust #1 L86 1. Pull the latest value of each signal +# python #2 L113 2. Normalise each signal to a "badness" in 0–1 +# python #3 L132 3. Weight, combine, and classify +# python #4 L146 4. Publish the score and flag the bad ones + +[blocks] +python = 4 +java = 1 +rust = 1 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["health_critical_pump_07_*"] +timeseries = ["pump_07_health_score"] + +[expect] +timeseries = ["pump_07_health_score"] diff --git a/doctests/plans/advanced__data-cleaning-lineage.toml b/doctests/plans/advanced__data-cleaning-lineage.toml new file mode 100644 index 0000000..5661dda --- /dev/null +++ b/doctests/plans/advanced__data-cleaning-lineage.toml @@ -0,0 +1,37 @@ +page = "docs/advanced/data-cleaning-lineage.mdx" + +# Blocks on this page: +# python #1 L36 1. Run the transformations, writing each result back +# python #2 L59 1. Run the transformations, writing each result back +# python #3 L96 2. Record the lineage graph +# python #4 L138 3. The payoff — trace it backward +# python #5 L151 4. The payoff — trace it forward (impact analysis) + +[blocks] +python = 5 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 4 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "engine_health_score") +""" + +[owns] +resources = ["assemble_fn", "band_fn", "clean_temp_fn", "clean_vib_fn", "rms_fn", "roc_fn", "roll_mean_fn", "score_fn"] +timeseries = ["engine_feature_vector", "engine_health_score", "engine_temp_roc", "engine_temp_roll_mean", "engine_temperature_clean", "engine_vib_band", "engine_vib_rms", "engine_vibration_clean"] + +[expect] +resources = ["assemble_fn", "band_fn", "clean_temp_fn", "clean_vib_fn", "rms_fn", "roc_fn", "roll_mean_fn", "score_fn"] +timeseries = ["engine_feature_vector", "engine_health_score", "engine_temp_roc", "engine_temp_roll_mean", "engine_temperature_clean", "engine_vib_band", "engine_vib_rms", "engine_vibration_clean"] diff --git a/doctests/plans/advanced__demand-forecasting.toml b/doctests/plans/advanced__demand-forecasting.toml new file mode 100644 index 0000000..55700c5 --- /dev/null +++ b/doctests/plans/advanced__demand-forecasting.toml @@ -0,0 +1,33 @@ +page = "docs/advanced/demand-forecasting.mdx" + +# Blocks on this page: +# python #1 L40 1. Load the history +# java #1 L60 1. Load the history +# rust #1 L78 1. Load the history +# python #2 L106 2. Build lag + calendar features +# python #3 L126 3. Train the forecaster +# python #4 L146 4. Forecast the horizon and write it back + +[blocks] +python = 4 +java = 1 +rust = 1 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +timeseries = ["feeder_f12_load_mw_forecast"] + +[expect] +timeseries = ["feeder_f12_load_mw_forecast"] diff --git a/doctests/plans/advanced__fraud-classification.toml b/doctests/plans/advanced__fraud-classification.toml new file mode 100644 index 0000000..0c5626a --- /dev/null +++ b/doctests/plans/advanced__fraud-classification.toml @@ -0,0 +1,36 @@ +page = "docs/advanced/fraud-classification.mdx" + +# Blocks on this page: +# python #1 L46 1. Pull the account's network +# java #1 L58 1. Pull the account's network +# rust #1 L71 1. Pull the account's network +# python #2 L92 2. Turn the network shape into features +# python #3 L126 3. Add behavioural features from the transaction series +# python #4 L157 4. Train on confirmed outcomes +# python #5 L190 5. Score live alerts and explain the call + +[blocks] +python = 5 +java = 1 +rust = 1 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["sar_candidate_*"] + +# The page's payoff is a SAR candidate raised for a flagged account, plus the score it prints. +[expect] +events = ["sar_candidate_*"] +stdout = ["average precision:"] diff --git a/doctests/plans/advanced__generate-sample-data.toml b/doctests/plans/advanced__generate-sample-data.toml new file mode 100644 index 0000000..2ad6836 --- /dev/null +++ b/doctests/plans/advanced__generate-sample-data.toml @@ -0,0 +1,66 @@ +page = "docs/advanced/generate-sample-data.mdx" + +# Blocks on this page: +# python #1 L20 A reusable ingest helper +# python #2 L39 A. A degrading sensor signal +# python #3 L56 A. A degrading sensor signal +# python #4 L70 B. A production decline curve +# python #5 L89 C. Correlated multivariate process data, with a fault +# python #6 L113 D. Sparse lab samples vs. dense sensors +# python #7 L128 E. A labelled failure history (events) and the sensors leading up to it +# python #8 L141 E. A labelled failure history (events) and the sensors leading up to it +# python #9 L163 F. A small graph +# python #10 L181 F. A small graph +# python #11 L200 G. An electrical feeder load curve +# python #12 L219 H. Fish-pen oxygen, with crash episodes +# python #13 L242 I. Operating channels — one unit's regimes and a peer fleet +# python #14 L273 J. Correlated drilling channels, with a kick +# python #15 L299 K. Messy raw sensors to clean + +[blocks] +python = 15 + +[python] +timeout = 600 + +# This page exists to populate a workspace, so it owns a lot — and much of it is +# minted at run time: the fraud graph builds `account_1..30` from a range, and the +# process/drilling/operating-mode fixtures ingest whatever keys their generator +# functions return. Those are patterns; the sweep expands them against the backend. +# Anything missed here survives the sweep and makes the page fail on its next run +# with a duplicate create, which is exactly what "Safe to re-run" must not do. +[owns] +resources = ["account_*"] +events = ["esp_failure_a12_*"] +timeseries = [ + "account_*", + "cdu_1_*", + "rig_dw1_*", + "unit_3_*", + "pump_07_*", "pump_08_*", "pump_11_*", "pump_19_*", "pump_esp_a12_*", + "pen_h_07_*", + "engine_temperature_raw", "engine_vibration_raw", + "feeder_f12_load_mw", "well_a12_oil_rate_bpd", +] + +# A representative sample rather than the whole inventory: if the helper works at all +# it works for every call, and naming one series per fixture family keeps the check +# meaningful without restating the page. +[expect] +timeseries = [ + "pump_07_vibration_mm_s", + "well_a12_oil_rate_bpd", + "cdu_1_feed_bpd", + "unit_3_load_mw", + "feeder_f12_load_mw", + "pen_h_07_dissolved_oxygen_mg_l", + "engine_temperature_raw", +] + +# Datapoint counts, not just existence: the recipes read these series, and a series +# that exists but is still empty fails them for a reason that is not their own. +[expect.datapoints] +pump_07_vibration_mm_s = 1000 +cdu_1_feed_bpd = 1000 +unit_3_load_mw = 1000 +pen_h_07_dissolved_oxygen_mg_l = 1000 diff --git a/doctests/plans/advanced__kmeans-clustering.toml b/doctests/plans/advanced__kmeans-clustering.toml new file mode 100644 index 0000000..f48bdf4 --- /dev/null +++ b/doctests/plans/advanced__kmeans-clustering.toml @@ -0,0 +1,35 @@ +page = "docs/advanced/kmeans-clustering.mdx" + +# Blocks on this page: +# python #1 L41 1. Asset cohorts — group assets that behave alike +# java #1 L70 1. Asset cohorts — group assets that behave alike +# rust #1 L87 1. Asset cohorts — group assets that behave alike +# python #2 L113 The payoff: the asset that doesn't fit its cohort +# python #3 L130 2. Operating regimes — group an asset's *states* +# python #4 L158 3. Network communities — cluster the graph +# python #5 L191 Choosing k + +[blocks] +python = 5 +java = 1 +rust = 1 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["peer_outlier_*"] + +# The page's payoff: the asset that does not fit its cohort is raised as an event. +[expect] +events = ["peer_outlier_*"] diff --git a/doctests/plans/advanced__lstm-anomaly-detection.toml b/doctests/plans/advanced__lstm-anomaly-detection.toml new file mode 100644 index 0000000..582577a --- /dev/null +++ b/doctests/plans/advanced__lstm-anomaly-detection.toml @@ -0,0 +1,38 @@ +page = "docs/advanced/lstm-anomaly-detection.mdx" + +# Blocks on this page: +# python #1 L41 1. Load the normal multivariate window +# java #1 L67 1. Load the normal multivariate window +# rust #1 L83 1. Load the normal multivariate window +# python #2 L109 2. Build sequences and the autoencoder +# python #3 L135 3. Score by reconstruction error and set a limit +# python #4 L147 4. Watch live and raise an event + +[blocks] +python = 4 +java = 1 +rust = 1 + +[python] +timeout = 600 +# The page tells the reader to install this; without it the test skips with that +# reason rather than failing, because a missing optional dependency says nothing +# about whether the tutorial is correct. +requires_python = ["tensorflow"] +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["kick_detected_dw1_*"] +timeseries = ["rig_dw1_anomaly_score"] + +[expect] +timeseries = ["rig_dw1_anomaly_score"] diff --git a/doctests/plans/advanced__lstm-forecasting.toml b/doctests/plans/advanced__lstm-forecasting.toml new file mode 100644 index 0000000..9ad71f7 --- /dev/null +++ b/doctests/plans/advanced__lstm-forecasting.toml @@ -0,0 +1,37 @@ +page = "docs/advanced/lstm-forecasting.mdx" + +# Blocks on this page: +# python #1 L43 1. Load the history +# java #1 L66 1. Load the history +# rust #1 L84 1. Load the history +# python #2 L114 2. Frame it as input window → forecast horizon +# python #3 L132 3. Train the LSTM +# python #4 L149 4. Forecast the horizon and write it back + +[blocks] +python = 4 +java = 1 +rust = 1 + +[python] +timeout = 600 +# The page tells the reader to install this; without it the test skips with that +# reason rather than failing, because a missing optional dependency says nothing +# about whether the tutorial is correct. +requires_python = ["tensorflow"] +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +timeseries = ["well_a12_oil_rate_forecast"] + +[expect] +timeseries = ["well_a12_oil_rate_forecast"] diff --git a/doctests/plans/advanced__oxygen-crash-early-warning.toml b/doctests/plans/advanced__oxygen-crash-early-warning.toml new file mode 100644 index 0000000..5ac9294 --- /dev/null +++ b/doctests/plans/advanced__oxygen-crash-early-warning.toml @@ -0,0 +1,24 @@ +page = "docs/advanced/oxygen-crash-early-warning.mdx" + +# Blocks on this page: +# python #1 L43 1. Load the signals that precede a crash +# python #2 L69 2. Engineer features and a *forward-looking* label +# python #3 L92 3. Train the classifier +# python #4 L113 4. Score live and warn ahead of time + +[blocks] +python = 4 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[owns] +events = ["oxygen_crash_predicted_h07_*"] +timeseries = ["pen_h_07_crash_risk"] + +[expect] +timeseries = ["pen_h_07_crash_risk"] diff --git a/doctests/plans/advanced__pca-process-monitoring.toml b/doctests/plans/advanced__pca-process-monitoring.toml new file mode 100644 index 0000000..d8058ba --- /dev/null +++ b/doctests/plans/advanced__pca-process-monitoring.toml @@ -0,0 +1,33 @@ +page = "docs/advanced/pca-process-monitoring.mdx" + +# Blocks on this page: +# python #1 L43 1. Learn normal operation +# java #1 L72 1. Learn normal operation +# rust #1 L90 1. Learn normal operation +# python #2 L118 2. Define the monitoring statistics and their limits +# python #3 L140 3. Monitor live and name the culprit sensor + +[blocks] +python = 3 +java = 1 +rust = 1 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["process_deviation_cdu1_*"] + +# The page's payoff: a deviation event once the process leaves its normal envelope. +[expect] +events = ["process_deviation_cdu1_*"] diff --git a/doctests/plans/advanced__predictive-maintenance.toml b/doctests/plans/advanced__predictive-maintenance.toml new file mode 100644 index 0000000..5ed1b44 --- /dev/null +++ b/doctests/plans/advanced__predictive-maintenance.toml @@ -0,0 +1,34 @@ +page = "docs/advanced/predictive-maintenance.mdx" + +# Blocks on this page: +# python #1 L47 Step 1 — Get a stretch of healthy data +# java #1 L67 Step 1 — Get a stretch of healthy data +# rust #1 L83 Step 1 — Get a stretch of healthy data +# python #2 L120 Step 2 — Turn the raw wiggle into a few meaningful numbers +# python #3 L156 Step 3 — Let the model learn "normal" +# python #4 L176 Step 4 — Score new data, and write the answer back + +[blocks] +python = 4 +java = 1 +rust = 1 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["degradation_predicted_pump_07_*"] +timeseries = ["pump_07_vibration_anomaly"] + +[expect] +timeseries = ["pump_07_vibration_anomaly"] diff --git a/doctests/plans/advanced__random-forest-soft-sensor.toml b/doctests/plans/advanced__random-forest-soft-sensor.toml new file mode 100644 index 0000000..c396cf7 --- /dev/null +++ b/doctests/plans/advanced__random-forest-soft-sensor.toml @@ -0,0 +1,32 @@ +page = "docs/advanced/random-forest-soft-sensor.mdx" + +# Blocks on this page: +# python #1 L42 1. Assemble lab targets against sensor features +# java #1 L66 1. Assemble lab targets against sensor features +# rust #1 L84 1. Assemble lab targets against sensor features +# python #2 L111 2. Train the regressor +# python #3 L131 3. Infer continuously and publish the virtual sensor + +[blocks] +python = 3 +java = 1 +rust = 1 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +timeseries = ["cdu_1_product_sulfur_ppm_soft"] + +[expect] +timeseries = ["cdu_1_product_sulfur_ppm_soft"] diff --git a/doctests/plans/advanced__xgboost-failure-prediction.toml b/doctests/plans/advanced__xgboost-failure-prediction.toml new file mode 100644 index 0000000..b2ca4b2 --- /dev/null +++ b/doctests/plans/advanced__xgboost-failure-prediction.toml @@ -0,0 +1,34 @@ +page = "docs/advanced/xgboost-failure-prediction.mdx" + +# Blocks on this page: +# python #1 L41 1. Engineer one feature row per asset per day +# java #1 L69 1. Engineer one feature row per asset per day +# rust #1 L86 1. Engineer one feature row per asset per day +# python #2 L112 2. Label from the failure history +# python #3 L139 3. Train — with class imbalance and early stopping +# python #4 L163 4. Score live, with the reasons attached + +[blocks] +python = 4 +java = 1 +rust = 1 + +[python] +timeout = 600 +# Every series this recipe reads is seeded by the sample-data page, which the +# docs tell the reader to run first. It runs once per session rather than being +# composed into all thirteen recipes. +requires_once = ["advanced__generate-sample-data"] + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["failure_predicted_a12_*"] +timeseries = ["pump_esp_a12_failure_risk"] + +[expect] +timeseries = ["pump_esp_a12_failure_risk"] diff --git a/doctests/plans/guides__attach-files.toml b/doctests/plans/guides__attach-files.toml new file mode 100644 index 0000000..ab2d766 --- /dev/null +++ b/doctests/plans/guides__attach-files.toml @@ -0,0 +1,50 @@ +page = "docs/guides/attach-files.mdx" + +# python #1 L44 — Upload a file +# python #2 L98 — List a directory +# python #3 L139 — Delete +[blocks] +python = 3 +java = 4 +rust = 3 + +[python] +requires = ["quickstart"] +timeout = 180 + +# The page says "# local file" against the path, so the reader is expected to +# have one. Creating it is fixture work; the upload call is the code under test. +prologue = """ +import pathlib as _pathlib +_pathlib.Path("calibration_a12.pdf").write_bytes(b"%PDF-1.4\\n% doc-test fixture\\n%%EOF\\n") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# Block 3 deletes the file itself, which is the page's own teardown; the sweep is +# the backstop for a run that fails before reaching it. +# KNOWN PLATFORM LIMITATION — this page cannot be made repeatable yet. +# Deleting a file moves it to trash but keeps its *path* reserved, and the SDK +# exposes no way to purge trash (`list_trash` and `restore` exist; nothing removes). +# So the second upload to `/certificates/2026/calibration_a12.pdf` fails with +# "File with submitted path or externalId already exists" forever, on any stack where +# this page has run once. Verified directly: delete reports success, trash size does +# not change, and re-upload still conflicts. +# +# Left failing on purpose. It is a real defect a reader meets the moment they upload, +# delete and re-upload — not something to hide by renaming the path in this plan. + +[owns] +timeseries = ["engine_temperature"] +files = ["calibration_pump_esp_a12"] + +# The page's last block deletes the file on purpose, so asserting it survives the +# run would be asserting against the tutorial's own final step. What must be true +# is that the upload and the directory listing worked — the listing prints the +# file, so that is the evidence. +[expect] +stdout = ["calibration_a12.pdf"] diff --git a/doctests/plans/guides__correlate-alarms.toml b/doctests/plans/guides__correlate-alarms.toml new file mode 100644 index 0000000..5be7c87 --- /dev/null +++ b/doctests/plans/guides__correlate-alarms.toml @@ -0,0 +1,57 @@ +page = "docs/guides/correlate-alarms.mdx" + +# python #1 L57 — 2. Walk each alarm's neighbourhood +# python #2 L110 — 3. Find the shared subsystem +[blocks] +python = 2 +java = 2 +rust = 2 + +[python] +requires = ["quickstart"] +timeout = 180 + +# Step 1 of the guide is prose: two alarming sensors that both sit under a shared +# subsystem. The graph below *is* that premise, built with the API the sibling +# guide teaches, so the page's own two blocks stay the code under test. +prologue = """ +import intellistream_datahub_sdk as _fixture_sdk +client.resources.create( + [ + _fixture_sdk.Resource(external_id="cooling_system", name="Cooling system", labels=["System"]), + _fixture_sdk.Resource(external_id="sensor_a", name="Sensor A", labels=["Sensor"]), + _fixture_sdk.Resource(external_id="sensor_b", name="Sensor B", labels=["Sensor"]), + ], + [ + _fixture_sdk.RelForm.by_external_ids("sensor_a", "cooling_system", "PART_OF"), + _fixture_sdk.RelForm.by_external_ids("sensor_b", "cooling_system", "PART_OF"), + ], +) + +# The guide's premise is an established graph, not one created a millisecond ago. +# The graph read path lags writes by a second or two, so `fetch_related` returns +# nothing until it catches up. Waiting here is fixture correctness: it puts the +# reader's world in place before the page's own code starts. +import time as _time +for _attempt in range(30): + if client.resources.fetch_related( + external_id="sensor_a", depth=5, relationship_types=["PART_OF"]).nodes: + break + _time.sleep(0.5) +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + + +[owns] +timeseries = ["engine_temperature"] +resources = ["sensor_a", "sensor_b", "cooling_system"] + +# The guide's whole payoff is naming the shared subsystem, and its own comment +# says the answer is {'cooling_system'} — so that is what the test demands to see. +[expect] +stdout = ["Both alarms are part of:", "cooling_system"] diff --git a/doctests/plans/guides__detect-events.toml b/doctests/plans/guides__detect-events.toml new file mode 100644 index 0000000..b13bc31 --- /dev/null +++ b/doctests/plans/guides__detect-events.toml @@ -0,0 +1,37 @@ +page = "docs/guides/detect-events.mdx" + +# python #1 L52 — Detect a threshold breach and record it +# python #2 L123 — Query the events later +[blocks] +python = 2 +java = 2 +rust = 2 + +[python] +requires = ["quickstart"] +timeout = 180 + +# The guide's premise is a series that has breached its limit. The quickstart +# leaves 92.4 °C behind, under the 110 threshold, so the detection branch would +# never be entered and the test would pass without testing anything. Putting a +# breach in the data is the reader's situation, not a workaround. +prologue = """ +import pandas as _pd +client.timeseries.insert_from_lists( + timestamps=[_pd.Timestamp.now(tz="UTC")], values=[115.0], ts="engine_temperature") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The event id carries a timestamp, so it cannot be named ahead of the run; the +# sweep resolves the pattern against the backend instead. +[owns] +timeseries = ["engine_temperature"] +events = ["overheat_press_07_*"] + +[expect] +timeseries = ["engine_temperature"] diff --git a/doctests/plans/guides__ingest-timeseries.toml b/doctests/plans/guides__ingest-timeseries.toml new file mode 100644 index 0000000..0e4bf2c --- /dev/null +++ b/doctests/plans/guides__ingest-timeseries.toml @@ -0,0 +1,45 @@ +page = "docs/guides/ingest-timeseries.mdx" + +# python #1 L47 — Ingest a million readings +[blocks] +python = 1 +java = 1 +rust = 1 + +# The page opens on a reader who already has a series. Rather than conjuring one, +# the quickstart runs first: this tests that the two pages compose, which is how +# they are actually read. +[python] +requires = ["quickstart"] +timeout = 900 # a million datapoints is the point of the page, not a detail to trim + +[java] +disabled = "the Java block is a fragment over an undefined `readings` map; needs a prologue decision." + +[rust] +disabled = "the Rust block is a fragment over an undefined `readings` vec; needs a prologue decision." + +# INTERMITTENT, AND THE INTERMITTENCE IS THE BUG. +# This page fails on roughly half of runs with +# pyo3_runtime.PanicException: assertion `left == right` failed; left: 200, right: 204 +# from dataplatform-rust-sdk `src/timeseries/mod.rs:392`, which does +# assert_eq!(r.get_http_status_code().unwrap(), 204); +# in production code. The backend answers a batched insert with 200 sometimes and 204 +# others; both are successes, and the SDK panics on one of them. A panic crosses the +# PyO3 boundary as `pyo3_runtime.PanicException`, so a caller cannot even catch it. +# +# The page itself is correct — the same million-point insert succeeds when run by hand. +# Do not paper over this by relaxing the plan: the flake is the signal. + +[owns] +timeseries = ["engine_temperature"] + +[expect] +timeseries = ["engine_temperature"] +# A million rows take a while to become visible to the read path. +settle_secs = 180.0 + +# The page's headline is a million readings. Checking for one datapoint would let +# a silently-truncating batch path pass, so the assertion matches the claim. +[expect.datapoints] +engine_temperature = 1000000 diff --git a/doctests/plans/guides__model-assets-graph.toml b/doctests/plans/guides__model-assets-graph.toml new file mode 100644 index 0000000..1556e03 --- /dev/null +++ b/doctests/plans/guides__model-assets-graph.toml @@ -0,0 +1,29 @@ +page = "docs/guides/model-assets-graph.mdx" + +# python #1 L64 — Create the graph +# python #2 L140 — Read the graph back +[blocks] +python = 2 +java = 2 +rust = 2 + +[python] +requires = ["quickstart"] +timeout = 180 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# Edges are deleted with their end nodes; the sweep already orders resources +# before datasets and retries, so naming the three nodes is enough. + +[owns] +timeseries = ["engine_temperature"] +resources = ["plant_oslo", "line_a", "press_07"] + +[expect] +resources = ["plant_oslo", "line_a", "press_07"] +stdout = ["3 nodes, 2 edges"] diff --git a/doctests/plans/guides__query-and-aggregate.toml b/doctests/plans/guides__query-and-aggregate.toml new file mode 100644 index 0000000..dc373cc --- /dev/null +++ b/doctests/plans/guides__query-and-aggregate.toml @@ -0,0 +1,36 @@ +page = "docs/guides/query-and-aggregate.mdx" + +# python #1 L39 — Raw datapoints for a window +# python #2 L104 — Hourly averages +# python #3 L184 — Paging large windows +[blocks] +python = 3 +java = 3 +rust = 3 + +[python] +requires = ["quickstart"] +timeout = 180 + +# Block 3 pages over `start`/`end` and hands each point to `handle`, all three of +# which the prose describes rather than shows. This is what a reader supplies. +prologue = """ +import pandas as _pd +start = _pd.Timestamp.now(tz="UTC") - _pd.Timedelta(days=1) +end = _pd.Timestamp.now(tz="UTC") +_seen = [] +def handle(dp): + _seen.append(dp) +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +timeseries = ["engine_temperature"] + +[expect] +timeseries = ["engine_temperature"] diff --git a/doctests/plans/guides__realtime-subscriptions.toml b/doctests/plans/guides__realtime-subscriptions.toml new file mode 100644 index 0000000..70c2b9c --- /dev/null +++ b/doctests/plans/guides__realtime-subscriptions.toml @@ -0,0 +1,33 @@ +page = "docs/guides/realtime-subscriptions.mdx" + +# python #1 L33 — 1. Create a subscription +# python #2 L107 — 2. Listen and ack (not covered — see below) +# python #3 L151 — Change the interest set at runtime (not covered — see below) +[blocks] +python = 3 +java = 4 +rust = 3 + +[python] +requires = ["quickstart"] +only = [1] +timeout = 120 + +# Blocks 2 and 3 are deliberately left out rather than quietly bounded. Block 2 +# blocks forever on a websocket and only returns when a datapoint arrives, and +# block 3 uses `listener` after the `with` that owns it has closed. Testing them +# needs a second process writing datapoints while the listener runs — worth +# building, but a different harness, and pretending otherwise would mean a green +# test over code nobody ran. The [blocks] pin above still catches edits to them. +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +timeseries = ["engine_temperature"] +subscriptions = ["engine_room"] + +[expect] +subscriptions = ["engine_room"] diff --git a/doctests/plans/guides__work-with-units.toml b/doctests/plans/guides__work-with-units.toml new file mode 100644 index 0000000..42c56d4 --- /dev/null +++ b/doctests/plans/guides__work-with-units.toml @@ -0,0 +1,25 @@ +page = "docs/guides/work-with-units.mdx" + +# python #1 L28 — Browse the catalogue +# python #2 L62 — Tag a series with a unit +# python #3 L103 — Look up a specific unit +[blocks] +python = 3 +java = 3 +rust = 3 + +[python] +requires = ["quickstart"] +timeout = 120 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +timeseries = ["engine_temperature", "wellhead_pressure_bar"] + +[expect] +timeseries = ["wellhead_pressure_bar"] diff --git a/doctests/plans/industries__agriculture-food__precision-farming.toml b/doctests/plans/industries__agriculture-food__precision-farming.toml new file mode 100644 index 0000000..badeb2b --- /dev/null +++ b/doctests/plans/industries__agriculture-food__precision-farming.toml @@ -0,0 +1,46 @@ +page = "docs/industries/agriculture-food/precision-farming.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L44 1. Model fields and stream soil sensors +# python #2 L58 1. Model fields and stream soil sensors +# rust #1 L73 1. Model fields and stream soil sensors +# java #2 L101 2. Attach the drone imagery to the field +# python #3 L118 2. Attach the drone imagery to the field +# rust #2 L129 2. Attach the drone imagery to the field +# java #3 L147 2. Attach the drone imagery to the field +# python #4 L155 2. Attach the drone imagery to the field +# rust #3 L163 2. Attach the drone imagery to the field +# python #5 L180 See the result + +[blocks] +python = 5 +java = 3 +rust = 3 + +[python] +timeout = 600 + +prologue = """ + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# Step 1 writes 'your timestamps'; the demo block above just built exactly that as `idx`. +[[python.inject]] +before = 2 +code = """ +timestamps = idx +""" + +[owns] +files = ["scan_north_40_2026_06_28"] +timeseries = ["field_north_40_soil_moisture", "field_north_40_soil_temp_c"] + +[expect] +timeseries = ["field_north_40_soil_moisture", "field_north_40_soil_temp_c"] diff --git a/doctests/plans/industries__agriculture-food__salmon-farming.toml b/doctests/plans/industries__agriculture-food__salmon-farming.toml new file mode 100644 index 0000000..18cf5ad --- /dev/null +++ b/doctests/plans/industries__agriculture-food__salmon-farming.toml @@ -0,0 +1,56 @@ +page = "docs/industries/agriculture-food/salmon-farming.mdx" + +# Blocks on this page: +# python #1 L27 Set up demo data +# java #1 L51 1. Watch pen conditions live — and aerate before a crash +# python #2 L73 1. Watch pen conditions live — and aerate before a crash +# rust #1 L88 1. Watch pen conditions live — and aerate before a crash +# java #2 L119 2. Protect feed margin +# python #3 L133 2. Protect feed margin +# rust #2 L146 2. Protect feed margin + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +oxygen_below = Recorder("oxygen_below") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["farm_hardanger_pens"]) as listener:""" +repl = """with client.subscriptions.listen(["farm_hardanger_pens"]) as listener: + feed(client, ["pen_h_07_dissolved_oxygen_mg_l"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["low_oxygen_h07_*"] +subscriptions = ["farm_hardanger_pens"] +timeseries = ["pen_h_07_biomass_kg", "pen_h_07_dissolved_oxygen_mg_l", "pen_h_07_feed_kg", "pen_h_07_water_temp_c"] + +[expect] +subscriptions = ["farm_hardanger_pens"] +timeseries = ["pen_h_07_biomass_kg", "pen_h_07_dissolved_oxygen_mg_l", "pen_h_07_feed_kg", "pen_h_07_water_temp_c"] diff --git a/doctests/plans/industries__built-environment__construction.toml b/doctests/plans/industries__built-environment__construction.toml new file mode 100644 index 0000000..a19aea6 --- /dev/null +++ b/doctests/plans/industries__built-environment__construction.toml @@ -0,0 +1,34 @@ +page = "docs/industries/built-environment/construction.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L47 1. Model the site and attach its documents +# python #2 L79 1. Model the site and attach its documents +# rust #1 L97 1. Model the site and attach its documents +# java #2 L133 2. Record a safety incident against its zone +# python #3 L147 2. Record a safety incident against its zone +# rust #2 L158 2. Record a safety incident against its zone + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["safety_incident_l12_*"] +files = ["drawing_level_12_structural_rev_c"] +resources = ["site_harbour_tower", "zone_level_12"] +timeseries = ["crane_02_hours"] + +[expect] +resources = ["site_harbour_tower", "zone_level_12"] +timeseries = ["crane_02_hours"] diff --git a/doctests/plans/industries__built-environment__smart-buildings.toml b/doctests/plans/industries__built-environment__smart-buildings.toml new file mode 100644 index 0000000..fcc7a4d --- /dev/null +++ b/doctests/plans/industries__built-environment__smart-buildings.toml @@ -0,0 +1,57 @@ +page = "docs/industries/built-environment/smart-buildings.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L51 1. Watch comfort live +# python #2 L73 1. Watch comfort live +# rust #1 L88 1. Watch comfort live +# java #2 L119 2. See where the energy goes +# python #3 L137 2. See where the energy goes +# rust #2 L153 2. See where the energy goes + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +chart_daily_kwh = Recorder("chart_daily_kwh") +outside_comfort = Recorder("outside_comfort") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["tower_a_comfort"]) as listener:""" +repl = """with client.subscriptions.listen(["tower_a_comfort"]) as listener: + feed(client, ["zone_l8_co2_ppm"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["comfort_alert_l8_*"] +subscriptions = ["tower_a_comfort"] +timeseries = ["tower_a_l8_energy_kwh", "zone_l8_co2_ppm"] + +[expect] +subscriptions = ["tower_a_comfort"] +timeseries = ["tower_a_l8_energy_kwh", "zone_l8_co2_ppm"] diff --git a/doctests/plans/industries__energy-utilities__air-quality.toml b/doctests/plans/industries__energy-utilities__air-quality.toml new file mode 100644 index 0000000..c9b34b4 --- /dev/null +++ b/doctests/plans/industries__energy-utilities__air-quality.toml @@ -0,0 +1,31 @@ +page = "docs/industries/energy-utilities/air-quality.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L45 1. Find the right unit +# python #2 L54 1. Find the right unit +# rust #1 L64 1. Find the right unit +# java #2 L82 2. Tag each series with its unit +# python #3 L95 2. Tag each series with its unit +# rust #2 L107 2. Tag each series with its unit +# python #4 L135 See the result + +[blocks] +python = 4 +java = 2 +rust = 2 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +timeseries = ["station_kirkeveien_co", "station_kirkeveien_no2", "station_kirkeveien_pm25"] + +[expect] +timeseries = ["station_kirkeveien_co", "station_kirkeveien_no2", "station_kirkeveien_pm25"] diff --git a/doctests/plans/industries__energy-utilities__ev-charging.toml b/doctests/plans/industries__energy-utilities__ev-charging.toml new file mode 100644 index 0000000..0b902ef --- /dev/null +++ b/doctests/plans/industries__energy-utilities__ev-charging.toml @@ -0,0 +1,54 @@ +page = "docs/industries/energy-utilities/ev-charging.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L46 1. Detect a dead charger immediately +# python #2 L68 1. Detect a dead charger immediately +# rust #1 L83 1. Detect a dead charger immediately +# python #3 L116 See the result + +[blocks] +python = 3 +java = 1 +rust = 1 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +faulted = Recorder("faulted") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["network_status"]) as listener:""" +repl = """with client.subscriptions.listen(["network_status"]) as listener: + feed(client, ["charger_oslo_14_status"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["charger_down_oslo_14_*"] +subscriptions = ["network_status"] +timeseries = ["charger_oslo_14_status"] + +[expect] +subscriptions = ["network_status"] +timeseries = ["charger_oslo_14_status"] diff --git a/doctests/plans/industries__energy-utilities__grid.toml b/doctests/plans/industries__energy-utilities__grid.toml new file mode 100644 index 0000000..388522b --- /dev/null +++ b/doctests/plans/industries__energy-utilities__grid.toml @@ -0,0 +1,46 @@ +page = "docs/industries/energy-utilities/grid.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L45 1. Model the network +# python #2 L67 1. Model the network +# rust #1 L79 1. Model the network +# java #2 L114 2. Roll telemetry up for the dashboard +# python #3 L132 2. Roll telemetry up for the dashboard +# rust #2 L146 2. Roll telemetry up for the dashboard +# java #3 L174 3. Overload alarm +# python #4 L187 3. Overload alarm +# rust #3 L198 3. Overload alarm +# python #5 L219 See the result + +[blocks] +python = 5 +java = 3 +rust = 3 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +chart = Recorder("chart") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["feeder_overload_f12_*"] +resources = ["grid_region_east", "substation_oslo_1"] +timeseries = ["feeder_f12_load_mw"] + +[expect] +resources = ["grid_region_east", "substation_oslo_1"] +timeseries = ["feeder_f12_load_mw"] diff --git a/doctests/plans/industries__energy-utilities__waste.toml b/doctests/plans/industries__energy-utilities__waste.toml new file mode 100644 index 0000000..36159f1 --- /dev/null +++ b/doctests/plans/industries__energy-utilities__waste.toml @@ -0,0 +1,70 @@ +page = "docs/industries/energy-utilities/waste.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L42 1. Act on fill level, not the calendar +# python #2 L61 1. Act on fill level, not the calendar +# rust #1 L75 1. Act on fill level, not the calendar + +[blocks] +python = 2 +java = 1 +rust = 1 + +[python] +timeout = 600 + +prologue = """ +import pandas as _pd +import intellistream_datahub_sdk as _fx + +# The reader's own "give me this series over the last N hours" helper. The page +# passes its result straight to retrieve_datapoints, so the name promises a +# RetrieveFilter. A placeholder returning something else fails at the SDK boundary — +# and one that happened to satisfy it would mean the retrieve was never exercised. +def _window(external_id, hours): + now = _pd.Timestamp.now(tz="UTC") + return _fx.RetrieveFilter(ts=external_id, start=now - _pd.Timedelta(hours=hours), end=now) + +# 'Last reading' means the most recent one, so the window is wide enough to find +# it wherever the demo data landed rather than assuming it is within the hour. +def last_reading(external_id): + return _window(external_id, 24 * 90) + + now = _pd.Timestamp.now(tz="UTC") + return _fx.RetrieveFilter(ts=external_id, start=now - _pd.Timedelta(hours=hours), end=now) + +# 'Last reading' means the most recent one, so the window is wide enough to find +# it wherever the demo data landed rather than assuming it is within the hour. +def last_reading(external_id): + return _window(external_id, 24 * 90) + +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The demo block writes one datapoint and step 1 reads it back immediately. Reads are +# eventually consistent, so that read can return nothing through no fault of the page. +# Waiting for the write to land puts the reader's world in place before the step runs. +[[python.inject]] +before = 2 +code = """ +from tutorial_support import wait_for_datapoints +wait_for_datapoints(client, "bin_grunerlokka_114_fill_pct") +""" + +[owns] +events = ["collection_due_114_*"] +timeseries = ["bin_grunerlokka_114_fill_pct"] + +[expect] +timeseries = ["bin_grunerlokka_114_fill_pct"] diff --git a/doctests/plans/industries__energy-utilities__water.toml b/doctests/plans/industries__energy-utilities__water.toml new file mode 100644 index 0000000..ff33e85 --- /dev/null +++ b/doctests/plans/industries__energy-utilities__water.toml @@ -0,0 +1,64 @@ +page = "docs/industries/energy-utilities/water.mdx" + +# Blocks on this page: +# python #1 L26 Set up demo data +# java #1 L51 1. Model the flow network +# python #2 L63 1. Model the flow network +# rust #1 L77 1. Model the flow network +# java #2 L99 2. Two zones report trouble — find where the flows meet +# python #3 L114 2. Two zones report trouble — find where the flows meet +# rust #2 L125 2. Two zones report trouble — find where the flows meet + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 +# The demo-data block and step 1 are the same action written twice: the block +# creates the graph inline, the step creates it from a `stations`/`nodes` variable. +# A reader does one or the other, and running both is a duplicate create. The +# numbered step is the tutorial, so that is what runs; the client it needs comes +# from the quickstart, as it would for a reader arriving here. +exclude = [1] +requires = ["quickstart"] + +prologue = """ + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page names `assets` in this step but only ever builds the list inside the +# demo-data block above. This is that same list, so the step models the world the +# page describes rather than an invented one. +[[python.inject]] +before = 2 +code = """ +assets = [intellistream_datahub_sdk.Resource(external_id=x, name=x, labels=[x]) for x in + ["source_lake_a", "main_trunk_north", "zone_west_12", "zone_east_07"]] +""" + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "zone_west_12") +""" + +[owns] +resources = ["main_trunk_north", "source_lake_a", "zone_east_07", "zone_west_12"] +timeseries = ["zone_east_07_turbidity_ntu", "zone_west_12_turbidity_ntu"] + +# Step 1 builds the flow network through `assets`; these are the nodes it must leave behind. +[expect] +resources = ["source_lake_a", "main_trunk_north", "zone_west_12", "zone_east_07"] diff --git a/doctests/plans/industries__energy-utilities__wind.toml b/doctests/plans/industries__energy-utilities__wind.toml new file mode 100644 index 0000000..b6991be --- /dev/null +++ b/doctests/plans/industries__energy-utilities__wind.toml @@ -0,0 +1,48 @@ +page = "docs/industries/energy-utilities/wind.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L45 1. Ingest per-turbine output +# python #2 L60 1. Ingest per-turbine output +# rust #1 L77 1. Ingest per-turbine output +# java #2 L115 2. Roll up to a capacity factor +# python #3 L135 2. Roll up to a capacity factor +# rust #2 L152 2. Roll up to a capacity factor +# python #4 L186 See the result + +[blocks] +python = 4 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +record_capacity_factor = Recorder("record_capacity_factor") + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# Step 1 ingests 'your' arrays — the ones the demo block above sampled. +[[python.inject]] +before = 2 +code = """ +timestamps, power_kw, wind_ms = idx, power, wind +""" + +[owns] +timeseries = ["turbine_t14_power_kw", "turbine_t14_wind_ms"] + +[expect] +timeseries = ["turbine_t14_power_kw", "turbine_t14_wind_ms"] diff --git a/doctests/plans/industries__financial-services__aml.toml b/doctests/plans/industries__financial-services__aml.toml new file mode 100644 index 0000000..cda6f05 --- /dev/null +++ b/doctests/plans/industries__financial-services__aml.toml @@ -0,0 +1,68 @@ +page = "docs/industries/financial-services/aml.mdx" + +# Blocks on this page: +# python #1 L26 Set up demo data +# java #1 L52 1. React to a flagged payment live +# python #2 L66 1. React to a flagged payment live +# rust #1 L76 1. React to a flagged payment live +# java #2 L96 2. Expand the account into its network +# python #3 L108 2. Expand the account into its network +# rust #2 L118 2. Expand the account into its network + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +account_from = Recorder("account_from") +investigate = Recorder("investigate") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["flagged_payments"]) as listener:""" +repl = """with client.subscriptions.listen(["flagged_payments"]) as listener: + feed(client, ["flagged_payments_feed"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "account_77310") +""" + +[owns] +resources = ["account_77310", "account_8841", "account_8842", "account_8843"] +subscriptions = ["flagged_payments"] +timeseries = ["flagged_payments_feed"] + +[expect] +subscriptions = ["flagged_payments"] +timeseries = ["flagged_payments_feed"] diff --git a/doctests/plans/industries__financial-services__insurance-fraud.toml b/doctests/plans/industries__financial-services__insurance-fraud.toml new file mode 100644 index 0000000..f163725 --- /dev/null +++ b/doctests/plans/industries__financial-services__insurance-fraud.toml @@ -0,0 +1,65 @@ +page = "docs/industries/financial-services/insurance-fraud.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L49 1. Model claims and the parties they share +# python #2 L62 1. Model claims and the parties they share +# rust #1 L77 1. Model claims and the parties they share +# java #2 L101 2. Expand a suspicious claim into its ring +# python #3 L114 2. Expand a suspicious claim into its ring +# rust #2 L125 2. Expand a suspicious claim into its ring + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 +# The demo-data block and step 1 are the same action written twice: the block +# creates the graph inline, the step creates it from a `stations`/`nodes` variable. +# A reader does one or the other, and running both is a duplicate create. The +# numbered step is the tutorial, so that is what runs; the client it needs comes +# from the quickstart, as it would for a reader arriving here. +exclude = [1] +requires = ["quickstart"] + +prologue = """ + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page names `nodes` in this step but only ever builds the list inside the +# demo-data block above. This is that same list, so the step models the world the +# page describes rather than an invented one. +[[python.inject]] +before = 2 +code = """ +nodes = [intellistream_datahub_sdk.Resource(external_id=x, name=x, labels=[lbl]) for x, lbl in + [("claim_88421", "Claim"), ("claim_88455", "Claim"), ("party_jdoe", "Party"), + ("phone_47120099", "Phone"), ("shop_quickfix", "Shop")]] +""" + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "claim_88421") +""" + +[owns] +resources = ["claim_88421", "claim_88455", "party_jdoe", "phone_47120099", "policy_55righ", "shop_quickfix"] + +# Step 1 builds the claim graph through `nodes`, and the shared-party link is the payoff. +[expect] +resources = ["claim_88421", "claim_88455", "party_jdoe", "phone_47120099", "shop_quickfix"] +stdout = ["linked claims:"] diff --git a/doctests/plans/industries__financial-services__portfolio-risk.toml b/doctests/plans/industries__financial-services__portfolio-risk.toml new file mode 100644 index 0000000..2533d89 --- /dev/null +++ b/doctests/plans/industries__financial-services__portfolio-risk.toml @@ -0,0 +1,37 @@ +page = "docs/industries/financial-services/portfolio-risk.mdx" + +# Blocks on this page: +# python #1 L23 Set up demo data +# java #1 L44 1. Partition by desk with a dataset +# python #2 L55 1. Partition by desk with a dataset +# rust #1 L66 1. Partition by desk with a dataset +# java #2 L87 2. Record per-portfolio metrics +# python #3 L103 2. Record per-portfolio metrics +# rust #2 L117 2. Record per-portfolio metrics +# java #3 L145 3. Flag a risk-limit breach +# python #4 L160 3. Flag a risk-limit breach +# rust #3 L172 3. Flag a risk-limit breach +# python #5 L200 See the result + +[blocks] +python = 5 +java = 3 +rust = 3 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +datasets = ["desk_equities"] +events = ["var_limit_breach_growth_*"] +timeseries = ["portfolio_growth_mtm_usd"] + +[expect] +datasets = ["desk_equities"] +timeseries = ["portfolio_growth_mtm_usd"] diff --git a/doctests/plans/industries__healthcare__cold-chain.toml b/doctests/plans/industries__healthcare__cold-chain.toml new file mode 100644 index 0000000..3ad997e --- /dev/null +++ b/doctests/plans/industries__healthcare__cold-chain.toml @@ -0,0 +1,53 @@ +page = "docs/industries/healthcare/cold-chain.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L47 1. Watch every fridge live +# python #2 L69 1. Watch every fridge live +# rust #1 L84 1. Watch every fridge live + +[blocks] +python = 2 +java = 1 +rust = 1 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +out_of_band = Recorder("out_of_band") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["pharmacy_fridges"]) as listener:""" +repl = """with client.subscriptions.listen(["pharmacy_fridges"]) as listener: + feed(client, ["fridge_pharmacy_12_temp_c"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["cold_chain_excursion_f12_*"] +subscriptions = ["pharmacy_fridges"] +timeseries = ["fridge_pharmacy_12_temp_c"] + +[expect] +subscriptions = ["pharmacy_fridges"] +timeseries = ["fridge_pharmacy_12_temp_c"] diff --git a/doctests/plans/industries__healthcare__hospital-operations.toml b/doctests/plans/industries__healthcare__hospital-operations.toml new file mode 100644 index 0000000..af0e962 --- /dev/null +++ b/doctests/plans/industries__healthcare__hospital-operations.toml @@ -0,0 +1,59 @@ +page = "docs/industries/healthcare/hospital-operations.mdx" + +# Blocks on this page: +# python #1 L26 Set up demo data +# java #1 L47 1. Model the hospital +# python #2 L69 1. Model the hospital +# rust #1 L81 1. Model the hospital +# java #2 L117 2. Drive the capacity wall-board live +# python #3 L140 2. Drive the capacity wall-board live +# rust #2 L156 2. Drive the capacity wall-board live + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +over_threshold = Recorder("over_threshold") +update_wallboard = Recorder("update_wallboard") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["bed_capacity"]) as listener:""" +repl = """with client.subscriptions.listen(["bed_capacity"]) as listener: + feed(client, ["ward_icu_occupancy_pct"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["capacity_warning_icu_*"] +resources = ["hospital_central", "ward_icu"] +subscriptions = ["bed_capacity"] +timeseries = ["ward_icu_occupancy_pct"] + +[expect] +resources = ["hospital_central", "ward_icu"] +subscriptions = ["bed_capacity"] +timeseries = ["ward_icu_occupancy_pct"] diff --git a/doctests/plans/industries__healthcare__medical-devices.toml b/doctests/plans/industries__healthcare__medical-devices.toml new file mode 100644 index 0000000..ad7dceb --- /dev/null +++ b/doctests/plans/industries__healthcare__medical-devices.toml @@ -0,0 +1,41 @@ +page = "docs/industries/healthcare/medical-devices.mdx" + +# Blocks on this page: +# python #1 L23 Set up demo data +# java #1 L47 1. Model the fleet and watch availability +# python #2 L69 1. Model the fleet and watch availability +# rust #1 L81 1. Model the fleet and watch availability +# java #2 L112 2. A recall lands — find every affected unit +# python #3 L123 2. A recall lands — find every affected unit +# rust #2 L134 2. A recall lands — find every affected unit + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "model_acme_x200") +""" + +[owns] +resources = ["model_acme_x200", "pump_icu_3391", "pump_icu_3392", "pump_icu_3401", "ward_icu"] + +[expect] +resources = ["model_acme_x200", "pump_icu_3391", "pump_icu_3392", "pump_icu_3401", "ward_icu"] diff --git a/doctests/plans/industries__healthcare__patient-flow.toml b/doctests/plans/industries__healthcare__patient-flow.toml new file mode 100644 index 0000000..128b2d7 --- /dev/null +++ b/doctests/plans/industries__healthcare__patient-flow.toml @@ -0,0 +1,41 @@ +page = "docs/industries/healthcare/patient-flow.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L44 1. Roll up the wait at each stage +# python #2 L62 1. Roll up the wait at each stage +# rust #1 L78 1. Roll up the wait at each stage +# java #2 L108 2. Alert when the ED breaches its target +# python #3 L121 2. Alert when the ED breaches its target +# rust #2 L132 2. Alert when the ED breaches its target +# python #4 L157 See the result + +[blocks] +python = 4 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +track_stage = Recorder("track_stage") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["flow_breach_ed_*"] +timeseries = ["ed_time_to_bed_minutes"] + +[expect] +timeseries = ["ed_time_to_bed_minutes"] diff --git a/doctests/plans/industries__manufacturing-process__discrete.toml b/doctests/plans/industries__manufacturing-process__discrete.toml new file mode 100644 index 0000000..4ff25b1 --- /dev/null +++ b/doctests/plans/industries__manufacturing-process__discrete.toml @@ -0,0 +1,64 @@ +page = "docs/industries/manufacturing-process/discrete.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L44 1. Model the line and stream its output +# python #2 L60 1. Model the line and stream its output +# rust #1 L75 1. Model the line and stream its output +# java #2 L105 2. Roll up to hourly OEE inputs +# python #3 L123 2. Roll up to hourly OEE inputs +# rust #2 L139 2. Roll up to hourly OEE inputs +# java #3 L168 3. Flag a quality excursion +# python #4 L181 3. Flag a quality excursion +# rust #3 L192 3. Flag a quality excursion +# python #5 L213 See the result + +[blocks] +python = 5 +java = 3 +rust = 3 + +[python] +timeout = 600 +# The demo block and step 1 create exactly the same two series and both ingest into +# them — the same action written twice, for a reader who has data and one who does +# not. Running both is a duplicate create. The numbered step is the tutorial, so it +# is what runs, with the client coming from the quickstart as it would for a reader. +exclude = [1] +requires = ["quickstart"] + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +record_hourly_performance = Recorder("record_hourly_performance") + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# Step 1 ingests the reader's own cycle times over the demo block's index. +[[python.inject]] +before = 2 +code = """ +import numpy as np, pandas as pd + +# Step 1 ingests "your" cycle times. With the demo block left out, this is the +# reader's own sampling: eight hours of minute-resolution station output. +idx = pd.date_range(end=pd.Timestamp.now(tz="UTC"), periods=8 * 60, freq="1min") +timestamps = idx +cycle_times = 11 + np.random.normal(0, 0.6, len(idx)) +""" + +[owns] +events = ["scrap_excursion_st07_*"] +timeseries = ["station_07_cycle_time_s", "station_07_scrap"] + +[expect] +timeseries = ["station_07_cycle_time_s", "station_07_scrap"] diff --git a/doctests/plans/industries__manufacturing-process__pharma.toml b/doctests/plans/industries__manufacturing-process__pharma.toml new file mode 100644 index 0000000..9e30844 --- /dev/null +++ b/doctests/plans/industries__manufacturing-process__pharma.toml @@ -0,0 +1,43 @@ +page = "docs/industries/manufacturing-process/pharma.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L48 1. Record process data exactly +# python #2 L62 1. Record process data exactly +# rust #1 L75 1. Record process data exactly +# java #2 L108 2. Walk the genealogy upstream +# python #3 L122 2. Walk the genealogy upstream +# rust #2 L135 2. Walk the genealogy upstream + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "lot_22f_final") +""" + +[owns] +resources = ["equipment_bioreactor_3", "lot_22f_final", "lot_int_88", "lot_raw_acme_41", "lot_raw_acme_42"] +timeseries = ["batch_22f_ph"] + +[expect] +resources = ["equipment_bioreactor_3", "lot_22f_final", "lot_int_88", "lot_raw_acme_41", "lot_raw_acme_42"] +timeseries = ["batch_22f_ph"] diff --git a/doctests/plans/industries__manufacturing-process__semiconductor.toml b/doctests/plans/industries__manufacturing-process__semiconductor.toml new file mode 100644 index 0000000..3ecced9 --- /dev/null +++ b/doctests/plans/industries__manufacturing-process__semiconductor.toml @@ -0,0 +1,43 @@ +page = "docs/industries/manufacturing-process/semiconductor.mdx" + +# Blocks on this page: +# python #1 L26 Set up demo data +# java #1 L49 1. Record process data exactly +# python #2 L64 1. Record process data exactly +# rust #1 L78 1. Record process data exactly +# java #2 L105 2. Find the tool every failing lot shares +# python #3 L120 2. Find the tool every failing lot shares +# rust #2 L132 2. Find the tool every failing lot shares + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "lot_22841") +""" + +[owns] +resources = ["lot_22841", "lot_22863", "tool_cmp_05", "tool_etch_07", "tool_litho_03"] +timeseries = ["tool_etch_07_chamber_pressure_mtorr"] + +[expect] +resources = ["lot_22841", "lot_22863", "tool_cmp_05", "tool_etch_07", "tool_litho_03"] +timeseries = ["tool_etch_07_chamber_pressure_mtorr"] diff --git a/doctests/plans/industries__mining-metals__operations.toml b/doctests/plans/industries__mining-metals__operations.toml new file mode 100644 index 0000000..df126d6 --- /dev/null +++ b/doctests/plans/industries__mining-metals__operations.toml @@ -0,0 +1,60 @@ +page = "docs/industries/mining-metals/operations.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L48 1. Spot a machine trending to failure +# python #2 L66 1. Spot a machine trending to failure +# rust #1 L79 1. Spot a machine trending to failure +# java #2 L110 2. See throughput against plan +# python #3 L128 2. See throughput against plan +# rust #2 L144 2. See throughput against plan + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +import pandas as _pd +import intellistream_datahub_sdk as _fx + +# The reader's own "give me this series over the last N hours" helper. The page +# passes its result straight to retrieve_datapoints, so the name promises a +# RetrieveFilter. A placeholder returning something else fails at the SDK boundary — +# and one that happened to satisfy it would mean the retrieve was never exercised. +def _window(external_id, hours): + now = _pd.Timestamp.now(tz="UTC") + return _fx.RetrieveFilter(ts=external_id, start=now - _pd.Timedelta(hours=hours), end=now) + +def last_hour(external_id): + return _window(external_id, 1) + + now = _pd.Timestamp.now(tz="UTC") + return _fx.RetrieveFilter(ts=external_id, start=now - _pd.Timedelta(hours=hours), end=now) + +def last_hour(external_id): + return _window(external_id, 1) + +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +compare_to_plan = Recorder("compare_to_plan") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["equipment_warning_785_*"] +timeseries = ["crusher_01_throughput_tph", "truck_785_oil_pressure_kpa"] + +[expect] +timeseries = ["crusher_01_throughput_tph", "truck_785_oil_pressure_kpa"] diff --git a/doctests/plans/industries__mining-metals__processing-plant.toml b/doctests/plans/industries__mining-metals__processing-plant.toml new file mode 100644 index 0000000..78fcb92 --- /dev/null +++ b/doctests/plans/industries__mining-metals__processing-plant.toml @@ -0,0 +1,41 @@ +page = "docs/industries/mining-metals/processing-plant.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L45 1. Track recovery and throughput +# python #2 L63 1. Track recovery and throughput +# rust #1 L79 1. Track recovery and throughput +# java #2 L109 2. Flag a recovery dip +# python #3 L122 2. Flag a recovery dip +# rust #2 L133 2. Flag a recovery dip +# python #4 L160 See the result + +[blocks] +python = 4 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +compare_to_target = Recorder("compare_to_target") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["recovery_loss_mill1_*"] +timeseries = ["mill_1_recovery_pct"] + +[expect] +timeseries = ["mill_1_recovery_pct"] diff --git a/doctests/plans/industries__mining-metals__tailings-safety.toml b/doctests/plans/industries__mining-metals__tailings-safety.toml new file mode 100644 index 0000000..789c1da --- /dev/null +++ b/doctests/plans/industries__mining-metals__tailings-safety.toml @@ -0,0 +1,55 @@ +page = "docs/industries/mining-metals/tailings-safety.mdx" + +# Blocks on this page: +# python #1 L27 Set up demo data +# java #1 L49 1. Watch the instruments against trigger levels +# python #2 L73 1. Watch the instruments against trigger levels +# rust #1 L91 1. Watch the instruments against trigger levels + +[blocks] +python = 2 +java = 1 +rust = 1 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +# The page documents this one inline: None | "amber" | "red". "red" is what makes +# the escalation branch — the point of the page — actually run. +trigger_level = Recorder("trigger_level", returns="red") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["tsf_instruments"]) as listener:""" +repl = """with client.subscriptions.listen(["tsf_instruments"]) as listener: + feed(client, ["piezometer_p14_pore_pressure_kpa"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["tailings_alarm_p14_*"] +subscriptions = ["tsf_instruments"] +timeseries = ["piezometer_p14_pore_pressure_kpa"] + +[expect] +subscriptions = ["tsf_instruments"] +timeseries = ["piezometer_p14_pore_pressure_kpa"] diff --git a/doctests/plans/industries__oil-and-gas__drilling.toml b/doctests/plans/industries__oil-and-gas__drilling.toml new file mode 100644 index 0000000..0919c54 --- /dev/null +++ b/doctests/plans/industries__oil-and-gas__drilling.toml @@ -0,0 +1,55 @@ +page = "docs/industries/oil-and-gas/drilling.mdx" + +# Blocks on this page: +# python #1 L27 Set up demo data +# java #1 L58 1. Stream the drilling signals live +# python #2 L81 1. Stream the drilling signals live +# rust #1 L96 1. Stream the drilling signals live +# python #3 L139 See the result + +[blocks] +python = 3 +java = 1 +rust = 1 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +influx_detected = Recorder("influx_detected") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (the subscription names its series through a `channels` +# variable, hence the literal list here), the other caps the loop at three messages +# and 45 seconds. Both must keep matching the page, so a rewritten loop fails here +# rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["rig_deepwater_1"]) as listener:""" +repl = """with client.subscriptions.listen(["rig_deepwater_1"]) as listener: + feed(client, ["rig_dw1_flow_out_gpm", "rig_dw1_pit_volume_bbl"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["kick_detected_a12_*"] +subscriptions = ["rig_deepwater_1"] +timeseries = ["rig_dw1_flow_in_gpm", "rig_dw1_flow_out_gpm", "rig_dw1_pit_volume_bbl"] + +[expect] +subscriptions = ["rig_deepwater_1"] +timeseries = ["rig_dw1_flow_in_gpm", "rig_dw1_flow_out_gpm", "rig_dw1_pit_volume_bbl"] diff --git a/doctests/plans/industries__oil-and-gas__emissions.toml b/doctests/plans/industries__oil-and-gas__emissions.toml new file mode 100644 index 0000000..23ca863 --- /dev/null +++ b/doctests/plans/industries__oil-and-gas__emissions.toml @@ -0,0 +1,32 @@ +page = "docs/industries/oil-and-gas/emissions.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L46 1. Roll emissions up for reporting +# python #2 L64 1. Roll emissions up for reporting +# rust #1 L79 1. Roll emissions up for reporting +# java #2 L110 2. Catch excess flaring early +# python #3 L123 2. Catch excess flaring early +# rust #2 L134 2. Catch excess flaring early +# python #4 L156 See the result + +[blocks] +python = 4 +java = 2 +rust = 2 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["flaring_exceedance_north_*"] +timeseries = ["platform_north_flare_volume_m3"] + +[expect] +timeseries = ["platform_north_flare_volume_m3"] diff --git a/doctests/plans/industries__oil-and-gas__pipeline.toml b/doctests/plans/industries__oil-and-gas__pipeline.toml new file mode 100644 index 0000000..cd79d30 --- /dev/null +++ b/doctests/plans/industries__oil-and-gas__pipeline.toml @@ -0,0 +1,54 @@ +page = "docs/industries/oil-and-gas/pipeline.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L57 1. Detect the imbalance +# python #2 L76 1. Detect the imbalance +# rust #1 L91 1. Detect the imbalance +# java #2 L122 2. Locate it and find the isolation valves +# python #3 L137 2. Locate it and find the isolation valves +# rust #2 L149 2. Locate it and find the isolation valves + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +latest = Recorder("latest") +tolerance = Recorder("tolerance") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "segment_pn_07") +""" + +[owns] +events = ["leak_suspected_pn07_*"] +resources = ["segment_pn_07", "station_kollsnes", "station_mongstad", "valve_v18", "valve_v19"] +timeseries = ["segment_pn_07_flow_in_m3h", "segment_pn_07_flow_out_m3h"] + +[expect] +resources = ["segment_pn_07", "station_kollsnes", "station_mongstad", "valve_v18", "valve_v19"] +timeseries = ["segment_pn_07_flow_in_m3h", "segment_pn_07_flow_out_m3h"] diff --git a/doctests/plans/industries__oil-and-gas__production.toml b/doctests/plans/industries__oil-and-gas__production.toml new file mode 100644 index 0000000..830019a --- /dev/null +++ b/doctests/plans/industries__oil-and-gas__production.toml @@ -0,0 +1,90 @@ +page = "docs/industries/oil-and-gas/production.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L49 1. Model the field +# python #2 L81 1. Model the field +# rust #1 L99 1. Model the field +# java #2 L143 2. Stream sensor data at volume +# python #3 L153 2. Stream sensor data at volume +# rust #2 L165 2. Stream sensor data at volume +# java #3 L189 3. Alarm on an out-of-bounds reading +# python #4 L207 3. Alarm on an out-of-bounds reading +# rust #3 L220 3. Alarm on an out-of-bounds reading +# java #4 L252 4. Are two alarms one fault? Ask the graph +# python #5 L267 4. Are two alarms one fault? Ask the graph +# rust #4 L278 4. Are two alarms one fault? Ask the graph +# python #6 L304 See the result + +[blocks] +python = 6 +java = 4 +rust = 4 + +[python] +timeout = 600 + +prologue = """ +import pandas as _pd +import intellistream_datahub_sdk as _fx + +# The reader's own "give me this series over the last N hours" helper. The page +# passes its result straight to retrieve_datapoints, so the name promises a +# RetrieveFilter. A placeholder returning something else fails at the SDK boundary — +# and one that happened to satisfy it would mean the retrieve was never exercised. +def _window(external_id, hours): + now = _pd.Timestamp.now(tz="UTC") + return _fx.RetrieveFilter(ts=external_id, start=now - _pd.Timedelta(hours=hours), end=now) + +def last_hour(external_id): + return _window(external_id, 1) + + now = _pd.Timestamp.now(tz="UTC") + return _fx.RetrieveFilter(ts=external_id, start=now - _pd.Timedelta(hours=hours), end=now) + +def last_hour(external_id): + return _window(external_id, 1) + +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# Step 1 ingests the reader's sampled channels over the demo block's index. +[[python.inject]] +before = 2 +code = """ +timestamps = idx +flow_bpd = np.full(len(idx), 900.0) +pressure_bar = np.full(len(idx), 95.0) +temperature_c = np.full(len(idx), 78.0) +""" + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 5 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "wellhead_temp_a12") +""" + +[owns] +events = ["esp_anomaly_a12_*"] +resources = ["field_north_sea", "pump_esp_a12", "well_a12"] +timeseries = ["casing_temperature_c", "flow_rate_bpd", "pump_intake_pressure_bar", "wellhead_pressure_bar"] + +[expect] +resources = ["field_north_sea", "pump_esp_a12", "well_a12"] +timeseries = ["casing_temperature_c", "flow_rate_bpd", "pump_intake_pressure_bar", "wellhead_pressure_bar"] diff --git a/doctests/plans/industries__oil-and-gas__refining.toml b/doctests/plans/industries__oil-and-gas__refining.toml new file mode 100644 index 0000000..4d08f31 --- /dev/null +++ b/doctests/plans/industries__oil-and-gas__refining.toml @@ -0,0 +1,41 @@ +page = "docs/industries/oil-and-gas/refining.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L44 1. Track the process KPIs +# python #2 L63 1. Track the process KPIs +# rust #1 L79 1. Track the process KPIs +# java #2 L109 2. Catch a process upset early +# python #3 L122 2. Catch a process upset early +# rust #2 L133 2. Catch a process upset early +# python #4 L155 See the result + +[blocks] +python = 4 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +record_energy_intensity = Recorder("record_energy_intensity") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["process_upset_cdu1_*"] +timeseries = ["cdu_1_energy_gj"] + +[expect] +timeseries = ["cdu_1_energy_gj"] diff --git a/doctests/plans/industries__oil-and-gas__storage.toml b/doctests/plans/industries__oil-and-gas__storage.toml new file mode 100644 index 0000000..0104367 --- /dev/null +++ b/doctests/plans/industries__oil-and-gas__storage.toml @@ -0,0 +1,49 @@ +page = "docs/industries/oil-and-gas/storage.mdx" + +# Blocks on this page: +# python #1 L26 Set up demo data +# java #1 L49 1. Reconcile movement against measurement +# python #2 L75 1. Reconcile movement against measurement +# rust #1 L95 1. Reconcile movement against measurement + +[blocks] +python = 2 +java = 1 +rust = 1 + +# The placeholder readings return the page's own worked numbers (a 3000 bbl fall +# against 2500 bbl metered) so the discrepancy branch — the point of the page — +# actually runs. Left at the default they would cancel to zero and the tutorial +# would exit 0 having done nothing. +[python] +timeout = 600 + +prologue = """ +import pandas as _pd + +# The reconciliation is "over one shift" — the page never says which. +shift_start = _pd.Timestamp.now(tz="UTC") - _pd.Timedelta(hours=12) +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +first_value = Recorder("first_value", returns=50000.0) +latest = Recorder("latest", returns=47000.0) +sum_over = Recorder("sum_over", returns=2500.0) +tolerance = Recorder("tolerance", returns=100.0) + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["inventory_discrepancy_t12_*"] +timeseries = ["tank_t_12_deliveries_bbl", "tank_t_12_receipts_bbl", "tank_t_12_volume_bbl"] + +[expect] +timeseries = ["tank_t_12_deliveries_bbl", "tank_t_12_receipts_bbl", "tank_t_12_volume_bbl"] diff --git a/doctests/plans/industries__technology-operations__data-centers.toml b/doctests/plans/industries__technology-operations__data-centers.toml new file mode 100644 index 0000000..43fc4be --- /dev/null +++ b/doctests/plans/industries__technology-operations__data-centers.toml @@ -0,0 +1,69 @@ +page = "docs/industries/technology-operations/data-centers.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L53 1. Catch a thermal problem early +# python #2 L75 1. Catch a thermal problem early +# rust #1 L90 1. Catch a thermal problem early +# java #2 L128 3. When racks overheat together, find the cooling unit +# python #3 L141 3. When racks overheat together, find the cooling unit +# rust #2 L151 3. When racks overheat together, find the cooling unit + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +inlet_above = Recorder("inlet_above") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["hall_2_thermal"]) as listener:""" +repl = """with client.subscriptions.listen(["hall_2_thermal"]) as listener: + feed(client, ["rack_r14_inlet_temp_c"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "rack_r14") +""" + +[owns] +events = ["thermal_warning_r14_*"] +resources = ["crac_unit_2", "rack_r14", "rack_r15"] +subscriptions = ["hall_2_thermal"] +timeseries = ["rack_r14_inlet_temp_c"] + +[expect] +resources = ["crac_unit_2", "rack_r14", "rack_r15"] +subscriptions = ["hall_2_thermal"] +timeseries = ["rack_r14_inlet_temp_c"] diff --git a/doctests/plans/industries__technology-operations__network.toml b/doctests/plans/industries__technology-operations__network.toml new file mode 100644 index 0000000..181c62b --- /dev/null +++ b/doctests/plans/industries__technology-operations__network.toml @@ -0,0 +1,52 @@ +page = "docs/industries/technology-operations/network.mdx" + +# Blocks on this page: +# python #1 L26 Set up demo data +# java #1 L55 1. Find the busy-hour congestion +# python #2 L73 1. Find the busy-hour congestion +# rust #1 L89 1. Find the busy-hour congestion +# java #2 L121 2. Localize a fault to shared backhaul +# python #3 L134 2. Localize a fault to shared backhaul +# rust #2 L144 2. Localize a fault to shared backhaul + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +flag_if_saturated = Recorder("flag_if_saturated") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "cell_oslo_4412") +""" + +[owns] +resources = ["backhaul_link_88", "cell_oslo_4412", "cell_oslo_4418", "controller_rnc_3"] +timeseries = ["cell_oslo_4412_prb_util", "cell_oslo_4418_prb_util"] + +[expect] +resources = ["backhaul_link_88", "cell_oslo_4412", "cell_oslo_4418", "controller_rnc_3"] +timeseries = ["cell_oslo_4412_prb_util", "cell_oslo_4418_prb_util"] diff --git a/doctests/plans/industries__technology-operations__observability.toml b/doctests/plans/industries__technology-operations__observability.toml new file mode 100644 index 0000000..998adb6 --- /dev/null +++ b/doctests/plans/industries__technology-operations__observability.toml @@ -0,0 +1,75 @@ +page = "docs/industries/technology-operations/observability.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L53 1. Model services and hosts +# python #2 L75 1. Model services and hosts +# rust #1 L87 1. Model services and hosts +# java #2 L123 2. Alert in real time on an SLO breach +# python #3 L145 2. Alert in real time on an SLO breach +# rust #2 L160 2. Alert in real time on an SLO breach +# java #3 L190 3. Review the incident afterward +# python #4 L200 3. Review the incident afterward +# rust #3 L208 3. Review the incident afterward +# java #4 L233 4. Which services share a failing dependency? +# python #5 L248 4. Which services share a failing dependency? +# rust #4 L260 4. Which services share a failing dependency? + +[blocks] +python = 5 +java = 4 +rust = 4 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +breaches_slo = Recorder("breaches_slo") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["checkout_slo"]) as listener:""" +repl = """with client.subscriptions.listen(["checkout_slo"]) as listener: + feed(client, ["service_checkout_request_latency_ms"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 5 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "service_checkout") +""" + +[owns] +events = ["slo_breach_checkout_*"] +resources = ["host_web_01", "order_db", "service_checkout", "service_payments"] +subscriptions = ["checkout_slo"] +timeseries = ["service_checkout_request_latency_ms"] + +[expect] +resources = ["host_web_01", "order_db", "service_checkout", "service_payments"] +subscriptions = ["checkout_slo"] +timeseries = ["service_checkout_request_latency_ms"] diff --git a/doctests/plans/industries__transport-logistics__aerospace.toml b/doctests/plans/industries__transport-logistics__aerospace.toml new file mode 100644 index 0000000..4f1426e --- /dev/null +++ b/doctests/plans/industries__transport-logistics__aerospace.toml @@ -0,0 +1,41 @@ +page = "docs/industries/transport-logistics/aerospace.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L49 1. Model the fleet to the component +# python #2 L77 1. Model the fleet to the component +# rust #1 L90 1. Model the fleet to the component +# java #2 L129 2. A part looks suspect — find the blast radius +# python #3 L141 2. A part looks suspect — find the blast radius +# rust #2 L152 2. A part looks suspect — find the blast radius + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "part_type_hp_47") +""" + +[owns] +resources = ["aircraft_ln_312", "aircraft_ln_318", "aircraft_ln_401", "hyd_pump_ln_312_1", "hyd_pump_ln_318_1", "hyd_pump_ln_401_1", "part_type_hp_47"] + +[expect] +resources = ["aircraft_ln_312", "aircraft_ln_318", "aircraft_ln_401", "hyd_pump_ln_312_1", "hyd_pump_ln_318_1", "hyd_pump_ln_401_1", "part_type_hp_47"] diff --git a/doctests/plans/industries__transport-logistics__airports.toml b/doctests/plans/industries__transport-logistics__airports.toml new file mode 100644 index 0000000..5082334 --- /dev/null +++ b/doctests/plans/industries__transport-logistics__airports.toml @@ -0,0 +1,53 @@ +page = "docs/industries/transport-logistics/airports.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L45 1. Track each turnaround milestone live +# python #2 L67 1. Track each turnaround milestone live +# rust #1 L82 1. Track each turnaround milestone live + +[blocks] +python = 2 +java = 1 +rust = 1 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +leg_overrunning = Recorder("leg_overrunning") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["stand_b12_turnaround"]) as listener:""" +repl = """with client.subscriptions.listen(["stand_b12_turnaround"]) as listener: + feed(client, ["stand_b12_fuelling_progress"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["turnaround_risk_su204_*"] +subscriptions = ["stand_b12_turnaround"] +timeseries = ["stand_b12_fuelling_progress"] + +[expect] +subscriptions = ["stand_b12_turnaround"] +timeseries = ["stand_b12_fuelling_progress"] diff --git a/doctests/plans/industries__transport-logistics__last-mile.toml b/doctests/plans/industries__transport-logistics__last-mile.toml new file mode 100644 index 0000000..8e14fb2 --- /dev/null +++ b/doctests/plans/industries__transport-logistics__last-mile.toml @@ -0,0 +1,53 @@ +page = "docs/industries/transport-logistics/last-mile.mdx" + +# Blocks on this page: +# python #1 L23 Set up demo data +# java #1 L43 1. Catch a slipping route live +# python #2 L65 1. Catch a slipping route live +# rust #1 L80 1. Catch a slipping route live + +[blocks] +python = 2 +java = 1 +rust = 1 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +eta_past_window = Recorder("eta_past_window") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["fleet_eta"]) as listener:""" +repl = """with client.subscriptions.listen(["fleet_eta"]) as listener: + feed(client, ["van_22_eta_slack_min"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["delivery_at_risk_v22_*"] +subscriptions = ["fleet_eta"] +timeseries = ["van_22_eta_slack_min"] + +[expect] +subscriptions = ["fleet_eta"] +timeseries = ["van_22_eta_slack_min"] diff --git a/doctests/plans/industries__transport-logistics__maritime.toml b/doctests/plans/industries__transport-logistics__maritime.toml new file mode 100644 index 0000000..63c5f40 --- /dev/null +++ b/doctests/plans/industries__transport-logistics__maritime.toml @@ -0,0 +1,56 @@ +page = "docs/industries/transport-logistics/maritime.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L46 1. Subscribe to the reefer fleet +# python #2 L59 1. Subscribe to the reefer fleet +# rust #1 L70 1. Subscribe to the reefer fleet +# java #2 L93 2. Watch for a temperature excursion live +# python #3 L115 2. Watch for a temperature excursion live +# rust #2 L130 2. Watch for a temperature excursion live + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +out_of_band = Recorder("out_of_band") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["reefer_fleet"]) as listener:""" +repl = """with client.subscriptions.listen(["reefer_fleet"]) as listener: + feed(client, ["reefer_msc_1182_supply_c", "reefer_msc_1182_setpoint_c"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["reefer_excursion_1182_*"] +subscriptions = ["reefer_fleet"] +timeseries = ["reefer_msc_1182_setpoint_c", "reefer_msc_1182_supply_c"] + +[expect] +subscriptions = ["reefer_fleet"] +timeseries = ["reefer_msc_1182_setpoint_c", "reefer_msc_1182_supply_c"] diff --git a/doctests/plans/industries__transport-logistics__ports.toml b/doctests/plans/industries__transport-logistics__ports.toml new file mode 100644 index 0000000..e797ee4 --- /dev/null +++ b/doctests/plans/industries__transport-logistics__ports.toml @@ -0,0 +1,41 @@ +page = "docs/industries/transport-logistics/ports.mdx" + +# Blocks on this page: +# python #1 L25 Set up demo data +# java #1 L44 1. Watch crane productivity by the hour +# python #2 L62 1. Watch crane productivity by the hour +# rust #1 L78 1. Watch crane productivity by the hour +# java #2 L108 2. Flag yard congestion before it blocks moves +# python #3 L121 2. Flag yard congestion before it blocks moves +# rust #2 L132 2. Flag yard congestion before it blocks moves +# python #4 L152 See the result + +[blocks] +python = 4 +java = 2 +rust = 2 + +[python] +timeout = 600 + +prologue = """ +from tutorial_support import Recorder + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +compare_to_target = Recorder("compare_to_target") +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["yard_congestion_b07_*"] +timeseries = ["crane_qc_07_moves"] + +[expect] +timeseries = ["crane_qc_07_moves"] diff --git a/doctests/plans/industries__transport-logistics__rail.toml b/doctests/plans/industries__transport-logistics__rail.toml new file mode 100644 index 0000000..13ed27d --- /dev/null +++ b/doctests/plans/industries__transport-logistics__rail.toml @@ -0,0 +1,64 @@ +page = "docs/industries/transport-logistics/rail.mdx" + +# Blocks on this page: +# python #1 L24 Set up demo data +# java #1 L44 1. Model the network +# python #2 L56 1. Model the network +# rust #1 L70 1. Model the network +# java #2 L93 2. Is the line still connected? +# python #3 L110 2. Is the line still connected? +# rust #2 L121 2. Is the line still connected? + +[blocks] +python = 3 +java = 2 +rust = 2 + +[python] +timeout = 600 +# The demo-data block and step 1 are the same action written twice: the block +# creates the graph inline, the step creates it from a `stations`/`nodes` variable. +# A reader does one or the other, and running both is a duplicate create. The +# numbered step is the tutorial, so that is what runs; the client it needs comes +# from the quickstart, as it would for a reader arriving here. +exclude = [1] +requires = ["quickstart"] + +prologue = """ + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page names `stations` in this step but only ever builds the list inside the +# demo-data block above. This is that same list, so the step models the world the +# page describes rather than an invented one. +[[python.inject]] +before = 2 +code = """ +stations = [intellistream_datahub_sdk.Resource(external_id=x, name=x, labels=[x]) for x in + ["station_oslo_s", "junction_lierstranda", "station_drammen", "station_bergen_n"]] +""" + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "station_oslo_s") +""" + +[owns] +resources = ["junction_lierstranda", "station_bergen_n", "station_drammen", "station_oslo_s"] + +# Step 1 builds the network through `stations`; these are the nodes it must leave behind. +[expect] +resources = ["station_oslo_s", "junction_lierstranda", "station_drammen", "station_bergen_n"] +stdout = ["route open"] diff --git a/doctests/plans/industries__transport-logistics__retail.toml b/doctests/plans/industries__transport-logistics__retail.toml new file mode 100644 index 0000000..9ed6988 --- /dev/null +++ b/doctests/plans/industries__transport-logistics__retail.toml @@ -0,0 +1,33 @@ +page = "docs/industries/transport-logistics/retail.mdx" + +# Blocks on this page: +# python #1 L23 Set up demo data +# java #1 L44 1. A dataset per region +# python #2 L61 1. A dataset per region +# rust #1 L75 1. A dataset per region +# java #2 L103 2. Roll up demand per region +# python #3 L112 2. Roll up demand per region +# rust #2 L119 2. Roll up demand per region +# python #4 L138 See the result + +[blocks] +python = 4 +java = 2 +rust = 2 + +[python] +timeout = 600 + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +datasets = ["region_nordics"] +timeseries = ["store_oslo_01_sku_4471_sales"] + +[expect] +datasets = ["region_nordics"] +timeseries = ["store_oslo_01_sku_4471_sales"] diff --git a/doctests/plans/industries__transport-logistics__supply-chain.toml b/doctests/plans/industries__transport-logistics__supply-chain.toml new file mode 100644 index 0000000..b0c7b46 --- /dev/null +++ b/doctests/plans/industries__transport-logistics__supply-chain.toml @@ -0,0 +1,65 @@ +page = "docs/industries/transport-logistics/supply-chain.mdx" + +# Blocks on this page: +# python #1 L26 Set up demo data +# java #1 L47 1. Model the chain of custody +# java #2 L56 1. Model the chain of custody +# python #2 L69 1. Model the chain of custody +# rust #1 L83 1. Model the chain of custody +# java #3 L106 2. Trace a recall downstream +# python #3 L120 2. Trace a recall downstream +# rust #2 L133 2. Trace a recall downstream + +[blocks] +python = 3 +java = 3 +rust = 2 + +[python] +timeout = 600 +# The demo-data block and step 1 are the same action written twice: the block +# creates the graph inline, the step creates it from a `stations`/`nodes` variable. +# A reader does one or the other, and running both is a duplicate create. The +# numbered step is the tutorial, so that is what runs; the client it needs comes +# from the quickstart, as it would for a reader arriving here. +exclude = [1] +requires = ["quickstart"] + +prologue = """ + +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page names `nodes` in this step but only ever builds the list inside the +# demo-data block above. This is that same list, so the step models the world the +# page describes rather than an invented one. +[[python.inject]] +before = 2 +code = """ +nodes = [intellistream_datahub_sdk.Resource(external_id=x, name=x, labels=[x]) for x in + ["lot_acme_8842", "part_bearing_55", "product_gearbox_910", "shipment_eu_2204"]] +""" + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 3 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "lot_acme_8842") +""" + +[owns] +resources = ["lot_acme_8842", "part_bearing_55", "product_gearbox_910", "shipment_eu_2204"] + +# Step 1 builds the chain of custody through `nodes`, and the recall walk is the payoff. +[expect] +resources = ["lot_acme_8842", "part_bearing_55", "product_gearbox_910", "shipment_eu_2204"] +stdout = ["recall shipment:"] diff --git a/doctests/plans/quickstart.toml b/doctests/plans/quickstart.toml new file mode 100644 index 0000000..95ddc42 --- /dev/null +++ b/doctests/plans/quickstart.toml @@ -0,0 +1,33 @@ +page = "docs/quickstart.mdx" + +# Blocks on this page: +# java #1 L58 · python #1 L74 · rust #1 L90 — 3. Create a client and write a datapoint +# java #2 L114 · python #2 L130 · rust #2 L143 — 4. Read it back +[blocks] +python = 2 +java = 2 +rust = 2 + +# Both blocks run as one program, which is the point: block 2 reads back what +# block 1 wrote, and uses the `client` block 1 built. Running them separately +# would test two snippets and miss the thing the page actually promises — +# "from zero to a stored datapoint". +[python] +timeout = 120 + +[java] +timeout = 240 + +[rust] +timeout = 600 # first run compiles the SDK + +[owns] +timeseries = ["engine_temperature"] + +# The page's headline claim is a *stored* datapoint, so proving it means reading +# one back from the backend, not just watching the program exit 0. +[expect] +timeseries = ["engine_temperature"] + +[expect.datapoints] +engine_temperature = 1 diff --git a/doctests/plans/reference__client.toml b/doctests/plans/reference__client.toml new file mode 100644 index 0000000..b2c488f --- /dev/null +++ b/doctests/plans/reference__client.toml @@ -0,0 +1,37 @@ +page = "docs/reference/client.md" + +# Blocks on this page: +# java #1 L17 Client & configuration +# python #1 L34 Client & configuration +# python #2 L47 Client & configuration +# rust #1 L55 Client & configuration +# rust #2 L69 Client & configuration +# java #2 L148 Exchanging an external token (jwt-bearer) +# python #3 L164 Exchanging an external token (jwt-bearer) +# rust #3 L182 Exchanging an external token (jwt-bearer) +# java #3 L226 From HashiCorp Vault (Java) +# java #4 L251 Durable ingest buffering +# python #4 L273 Durable ingest buffering +# rust #4 L290 Durable ingest buffering +# java #5 L325 Results & errors +# python #5 L342 Results & errors +# rust #5 L357 Results & errors + +[blocks] +python = 5 +java = 5 +rust = 5 + +[python] +disabled = """Not a tutorial: this page lists client constructors, and its snippets point at +https://api.intellistream.ai and an external Entra/Keycloak token exchange. Running them would +either dial production or need identity-provider secrets that prove nothing about the SDK. The +constructors it documents are exercised where they are actually used — the buffering client in +tutorial.toml, from_env() across every other plan.""" + + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." diff --git a/doctests/plans/reference__datasets.toml b/doctests/plans/reference__datasets.toml new file mode 100644 index 0000000..3f165d3 --- /dev/null +++ b/doctests/plans/reference__datasets.toml @@ -0,0 +1,44 @@ +page = "docs/reference/datasets.md" + +# Blocks on this page: +# java #1 L36 Create +# python #1 L47 Create +# rust #1 L57 Create +# java #2 L73 Look up & delete +# python #2 L83 Look up & delete +# rust #2 L91 Look up & delete + +[blocks] +python = 2 +java = 2 +rust = 2 + +[python] +timeout = 600 +# An API reference, not a tutorial: each fence is a self-contained example, and they +# are meant to be read independently. Running them as one program would invent +# conflicts a reader never meets — the delete example removing what the write example +# needs — so each block runs on its own. +independent = true + +# Each example needs a client. Composing the quickstart in to get one would also +# create its `engine_temperature`, which several of these pages create themselves; +# `from_env()` is the constructor this section documents, so this is the reader's +# own call. +prologue = """ +import intellistream_datahub_sdk +client = intellistream_datahub_sdk.DataHubClient.from_env() +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +datasets = ["plant_a"] + +# No [expect]: this page's examples include deleting the dataset they create, so +# nothing it makes is meant to outlive the run. The proof it worked is that every +# example executed cleanly. diff --git a/doctests/plans/reference__events.toml b/doctests/plans/reference__events.toml new file mode 100644 index 0000000..1422c0a --- /dev/null +++ b/doctests/plans/reference__events.toml @@ -0,0 +1,50 @@ +page = "docs/reference/events.md" + +# Blocks on this page: +# java #1 L34 Create +# python #1 L46 Create +# rust #1 L61 Create +# java #2 L91 Query +# python #2 L101 Query +# rust #2 L111 Query +# java #3 L133 High-throughput ingestion +# python #3 L143 High-throughput ingestion +# rust #3 L152 High-throughput ingestion +# java #4 L164 Delete +# python #4 L171 Delete +# rust #4 L178 Delete + +[blocks] +python = 4 +java = 4 +rust = 4 + +[python] +timeout = 600 +# An API reference, not a tutorial: each fence is a self-contained example, and they +# are meant to be read independently. Running them as one program would invent +# conflicts a reader never meets — the delete example removing what the write example +# needs — so each block runs on its own. +independent = true + +# Each example needs a client. Composing the quickstart in to get one would also +# create its `engine_temperature`, which several of these pages create themselves; +# `from_env()` is the constructor this section documents, so this is the reader's +# own call. +prologue = """ +import intellistream_datahub_sdk +client = intellistream_datahub_sdk.DataHubClient.from_env() +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +events = ["door_open"] + +# The create example must leave its event behind. +[expect] +events = ["door_open"] diff --git a/doctests/plans/reference__files.toml b/doctests/plans/reference__files.toml new file mode 100644 index 0000000..5d5b28f --- /dev/null +++ b/doctests/plans/reference__files.toml @@ -0,0 +1,59 @@ +page = "docs/reference/files.md" + +# Blocks on this page: +# java #1 L18 List +# python #1 L26 List +# rust #1 L34 List +# java #2 L50 Upload +# python #2 L67 Upload +# rust #2 L84 Upload +# java #3 L103 Download (Java) +# java #4 L113 Delete +# python #3 L120 Delete +# rust #3 L127 Delete + +[blocks] +python = 3 +java = 4 +rust = 3 + +[python] +timeout = 600 +# An API reference, not a tutorial: each fence is a self-contained example, and they +# are meant to be read independently. Running them as one program would invent +# conflicts a reader never meets — the delete example removing what the write example +# needs — so each block runs on its own. +independent = true + +# Each example needs a client. Composing the quickstart in to get one would also +# create its `engine_temperature`, which several of these pages create themselves; +# `from_env()` is the constructor this section documents, so this is the reader's +# own call. +prologue = """ +import intellistream_datahub_sdk +client = intellistream_datahub_sdk.DataHubClient.from_env() + +# The upload examples reference a local file the reader is assumed to have. +import pathlib as _pathlib +_pathlib.Path("report.csv").write_text("timestamp,value" + chr(10) + "2026-01-01T00:00:00Z,1" + chr(10)) +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# KNOWN PLATFORM LIMITATION — this page cannot be made repeatable yet. +# Deleting a file moves it to trash but keeps its *path* reserved, and the SDK +# exposes no way to purge trash (`list_trash` and `restore` exist; nothing removes). +# So the second upload to `/reports/2026/q2.csv` fails with +# "File with submitted path or externalId already exists" forever, on any stack where +# this page has run once. Verified directly: delete reports success, trash size does +# not change, and re-upload still conflicts. +# +# Left failing on purpose. It is a real defect a reader meets the moment they upload, +# delete and re-upload — not something to hide by renaming the path in this plan. + +[owns] +files = ["report_2026_q2"] diff --git a/doctests/plans/reference__resources.toml b/doctests/plans/reference__resources.toml new file mode 100644 index 0000000..0ec3936 --- /dev/null +++ b/doctests/plans/reference__resources.toml @@ -0,0 +1,63 @@ +page = "docs/reference/resources.md" + +# Blocks on this page: +# java #1 L27 Look up +# python #1 L40 Look up +# rust #1 L48 Look up +# java #2 L91 Create resources and relations +# python #2 L117 Create resources and relations +# rust #2 L131 Create resources and relations +# java #3 L160 Search +# python #3 L172 Search +# rust #3 L180 Search +# java #4 L201 Delete +# python #4 L208 Delete +# rust #4 L215 Delete +# java #5 L234 Traverse the graph +# python #5 L251 Traverse the graph +# rust #5 L264 Traverse the graph + +[blocks] +python = 5 +java = 5 +rust = 5 + +[python] +timeout = 600 +# An API reference, not a tutorial: each fence is a self-contained example, and they +# are meant to be read independently. Running them as one program would invent +# conflicts a reader never meets — the delete example removing what the write example +# needs — so each block runs on its own. +independent = true + +# Each example needs a client. Composing the quickstart in to get one would also +# create its `engine_temperature`, which several of these pages create themselves; +# `from_env()` is the constructor this section documents, so this is the reader's +# own call. +prologue = """ +import intellistream_datahub_sdk +client = intellistream_datahub_sdk.DataHubClient.from_env() +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The page models the network and traverses it in the next breath. The graph read +# path trails writes by a second or two, and an empty neighbourhood is a valid +# answer rather than an error — so without this the page prints the wrong +# conclusion and the test cannot tell that from a real one. +[[python.inject]] +before = 5 +code = """ +from tutorial_support import wait_for_related +wait_for_related(client, "sensor_a") +""" + +[owns] +resources = ["plant_oslo", "pump_1"] + +[expect] +resources = ["plant_oslo", "pump_1"] diff --git a/doctests/plans/reference__subscriptions.toml b/doctests/plans/reference__subscriptions.toml new file mode 100644 index 0000000..25d9341 --- /dev/null +++ b/doctests/plans/reference__subscriptions.toml @@ -0,0 +1,82 @@ +page = "docs/reference/subscriptions.md" + +# Blocks on this page: +# java #1 L17 Manage subscriptions +# python #1 L32 Manage subscriptions +# rust #1 L49 Manage subscriptions +# java #2 L85 Live delivery +# java #3 L98 Live delivery +# python #2 L117 Live delivery +# rust #2 L130 Live delivery + +[blocks] +python = 2 +java = 3 +rust = 2 + +[python] +timeout = 600 +# An API reference, not a tutorial: each fence is a self-contained example, and they +# are meant to be read independently. Running them as one program would invent +# conflicts a reader never meets — the delete example removing what the write example +# needs — so each block runs on its own. +independent = true + +prologue = """ +import intellistream_datahub_sdk +client = intellistream_datahub_sdk.DataHubClient.from_env() + +# Every example here subscribes to `engine_temperature`, this section's running +# example. A reader arrives with it from the quickstart; each block runs against a +# clean slate, so it is created here rather than assumed. +client.timeseries.create([intellistream_datahub_sdk.TimeSeries( + external_id="engine_temperature", name="Engine temperature", + unit="celsius", value_type="float")]) + +from tutorial_support import Recorder, feed, take + +# Placeholders the page leaves to the reader — 'your dashboard', 'your +# alerting'. Recorders keep what they are handed so the run can prove the +# tutorial's logic actually reached them. +process = Recorder("process") +""" + +# The page's payoff is the listen loop, so it runs — bounded, not skipped. Two +# declared substitutions make that possible: one starts a background writer so +# messages actually arrive (a silent stream would prove nothing), the other caps +# the loop at three messages and 45 seconds. Both must keep matching the page, so +# a rewritten loop fails here rather than hanging CI. +[[python.replace]] +find = """with client.subscriptions.listen(["engine_temps"]) as listener:""" +repl = """with client.subscriptions.listen(["engine_temps"]) as listener: + feed(client, ["engine_temperature"], points=40, every=0.3)""" + +[[python.replace]] +find = """ for msg in listener:""" +repl = """ for msg in take(listener, 3, 45):""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The listen example assumes the subscription from the create example above still +# exists — but that example ends by deleting it, and each block starts clean anyway. +# This puts the subscription in place for the one block that needs it, without +# touching the block that demonstrates create and delete. +[[python.inject]] +before = 2 +code = """ +client.subscriptions.create([intellistream_datahub_sdk.Subscription( + external_id="engine_temps", name="Engine temps", timeseries=["engine_temperature"])]) +""" + +[owns] +# Includes the series the prologue creates: anything a fixture makes must be owned, +# or the sweep leaves it and the next block fails on a duplicate. +timeseries = ["engine_temperature"] +subscriptions = ["engine_temps"] + +[expect] +subscriptions = ["engine_temps"] diff --git a/doctests/plans/reference__timeseries.toml b/doctests/plans/reference__timeseries.toml new file mode 100644 index 0000000..7f0ed29 --- /dev/null +++ b/doctests/plans/reference__timeseries.toml @@ -0,0 +1,60 @@ +page = "docs/reference/timeseries.md" + +# Blocks on this page: +# java #1 L21 Create a series +# python #1 L33 Create a series +# rust #1 L47 Create a series +# java #2 L80 Value types +# python #2 L93 Value types +# rust #2 L102 Value types +# java #3 L131 Filter series +# python #3 L146 Filter series +# rust #3 L158 Filter series +# java #4 L184 Delete a series +# python #4 L193 Delete a series +# rust #4 L200 Delete a series +# java #5 L220 Write datapoints +# python #5 L234 Write datapoints +# rust #5 L246 Write datapoints +# java #6 L274 High-throughput ingestion +# python #6 L290 High-throughput ingestion +# rust #6 L304 High-throughput ingestion +# java #7 L326 Retrieve datapoints +# python #7 L346 Retrieve datapoints +# rust #7 L363 Retrieve datapoints +# java #8 L401 IngestResult +# java #9 L412 IngestResult + +[blocks] +python = 7 +java = 9 +rust = 7 + +[python] +timeout = 600 +# An API reference, not a tutorial: each fence is a self-contained example, and they +# are meant to be read independently. Running them as one program would invent +# conflicts a reader never meets — the delete example removing what the write example +# needs — so each block runs on its own. +independent = true + +# Each example needs a client. Composing the quickstart in to get one would also +# create its `engine_temperature`, which several of these pages create themselves; +# `from_env()` is the constructor this section documents, so this is the reader's +# own call. +prologue = """ +import intellistream_datahub_sdk +client = intellistream_datahub_sdk.DataHubClient.from_env() +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +[owns] +timeseries = ["book_value_usd", "engine_temperature"] + +[expect] +timeseries = ["book_value_usd", "engine_temperature"] diff --git a/doctests/plans/reference__units.toml b/doctests/plans/reference__units.toml new file mode 100644 index 0000000..26447f7 --- /dev/null +++ b/doctests/plans/reference__units.toml @@ -0,0 +1,41 @@ +page = "docs/reference/units.md" + +# Blocks on this page: +# java #1 L17 List all units +# python #1 L27 List all units +# rust #1 L35 List all units +# java #2 L51 Look up +# python #2 L61 Look up +# rust #2 L71 Look up + +[blocks] +python = 2 +java = 2 +rust = 2 + +[python] +timeout = 600 +# An API reference, not a tutorial: each fence is a self-contained example, and they +# are meant to be read independently. Running them as one program would invent +# conflicts a reader never meets — the delete example removing what the write example +# needs — so each block runs on its own. +independent = true + +# Each example needs a client. Composing the quickstart in to get one would also +# create its `engine_temperature`, which several of these pages create themselves; +# `from_env()` is the constructor this section documents, so this is the reader's +# own call. +prologue = """ +import intellistream_datahub_sdk +client = intellistream_datahub_sdk.DataHubClient.from_env() +""" + +[java] +disabled = "Java scenario not written yet." + +[rust] +disabled = "Rust scenario not written yet." + +# The catalogue listing must actually name a unit the platform ships. +[expect] +stdout = ["temperature_deg_c"] diff --git a/doctests/plans/tutorial-complete.toml b/doctests/plans/tutorial-complete.toml new file mode 100644 index 0000000..025daf5 --- /dev/null +++ b/doctests/plans/tutorial-complete.toml @@ -0,0 +1,60 @@ +page = "docs/tutorial.mdx" + +# "The complete program" — the file the page tells the reader to copy and run. +# This is the page's strongest promise and the one most likely to rot, because +# nobody re-reads a 90-line listing when they change an API. +[blocks] +python = 5 +java = 6 +rust = 10 + +[python] +only = [5] +timeout = 180 +requires_env = ["CLIENT_ID", "CLIENT_SECRET", "TOKEN_URI"] +requires_python = ["psutil"] + +[python.env] +HOST_ID = "doctest" + +# The program is a daemon: it ticks forever by design, which is correct for the +# reader and impossible for a test. Two declared substitutions bound it to a few +# ticks. Both are `required`, so if the loop or the sleep is ever rewritten this +# plan fails loudly instead of hanging the suite for its full timeout. +[[python.replace]] +find = " while True:" +repl = " for _doctest_tick in range(3):" + +[[python.replace]] +find = " time.sleep(INTERVAL_SECS)" +repl = " time.sleep(0.1)" + +# The program swallows every per-tick exception to keep the loop alive — the right +# call for a daemon, and a trap for a test, which would see exit 0 while nothing +# worked. The [expect] block below is what closes that hole: it goes to the +# backend and checks the datapoints actually landed. +[java] +disabled = "see tutorial.toml — the Java scenario for this page is not written yet." + +[rust] +disabled = "see tutorial.toml — the Rust scenario for this page is not written yet." + +[owns] +timeseries = [ + "system_memory_used_doctest", + "system_memory_available_doctest", + "system_swap_used_doctest", +] + +[expect] +timeseries = [ + "system_memory_used_doctest", + "system_memory_available_doctest", + "system_swap_used_doctest", +] +stdout = ["ingested 3 datapoints"] + +[expect.datapoints] +system_memory_used_doctest = 3 +system_memory_available_doctest = 3 +system_swap_used_doctest = 3 diff --git a/doctests/plans/tutorial.toml b/doctests/plans/tutorial.toml new file mode 100644 index 0000000..243b380 --- /dev/null +++ b/doctests/plans/tutorial.toml @@ -0,0 +1,77 @@ +page = "docs/tutorial.mdx" + +# The step-by-step path: what a reader accumulates working down the page. +# The finished program at the bottom is a separate scenario — tutorial-complete.toml — +# because it is a different promise ("copy this file and run it") and needs a +# different bounded run. +# +# python #1 L152 Step 1 — Build the client +# python #2 L272 Step 2 — Ensure the time series exist +# python #3 L374 Step 3 — Sample the data +# python #4 L453 Step 4 — Ingest on a tick +# python #5 L767 The complete program <- tutorial-complete.toml +[blocks] +python = 5 +java = 6 +rust = 10 + +[python] +only = [1, 2, 3, 4] +timeout = 180 + +# Step 1 teaches the OAuth2 client-credentials constructor by name, so testing it +# against a token-only stack would prove nothing about the constructor the page +# actually shows. Without these the test skips and says so. +requires_env = ["CLIENT_ID", "CLIENT_SECRET", "TOKEN_URI"] +requires_python = ["psutil"] + +# What the prose establishes between the code blocks. The page introduces METRICS +# and `host` in narrative and names psutil in its install step, so a reader has +# them by Step 2; the harness supplies exactly that and nothing more. +# +# Deliberately NOT supplied: the SDK import. Step 2 calls `datahub_sdk.TimeSeries` +# but no step-path block ever imports it — only the complete listing at the bottom +# does. Handing it over here would hide that gap, which is precisely the kind of +# thing this suite exists to surface. +imports = """ +import os +import socket +from datetime import datetime, timezone +import psutil +""" +prologue = """ +METRICS = { + "system_memory_used": "System memory used", + "system_memory_available": "System memory available", + "system_swap_used": "System swap used", +} +host = os.environ.get("HOST_ID", socket.gethostname().lower()) +""" + +[python.env] +HOST_ID = "doctest" + +[java] +disabled = "Java scenario not written yet — the page's six blocks include a Gradle file and a partial class body that need a scaffold decision." + +[rust] +disabled = "Rust scenario not written yet — the page carries both sync and async variants (10 blocks) and the plan must choose one path." + +[owns] +timeseries = [ + "system_memory_used_doctest", + "system_memory_available_doctest", + "system_swap_used_doctest", +] + +[expect] +timeseries = [ + "system_memory_used_doctest", + "system_memory_available_doctest", + "system_swap_used_doctest", +] + +# Step 4 says it ingests one datapoint per series per tick. Proving the series +# exist is not enough — the tutorial's subject is getting data in. +[expect.datapoints] +system_memory_used_doctest = 1 diff --git a/doctests/run.sh b/doctests/run.sh new file mode 100755 index 0000000..6868323 --- /dev/null +++ b/doctests/run.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Run the documentation tutorial suite. Extra args go to pytest. +# +# ./doctests/run.sh # every planned tutorial, Python +# ./doctests/run.sh -k quickstart # one page +# ./doctests/run.sh --langs all # Java and Rust too +# ./doctests/run.sh --keep -s -k tutorial # leave the data behind and watch it run +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +[ -x "$HERE/.venv/bin/pytest" ] || { echo "run $HERE/setup.sh first" >&2; exit 1; } +exec "$HERE/.venv/bin/pytest" "$HERE" "$@" diff --git a/doctests/runners.py b/doctests/runners.py new file mode 100644 index 0000000..99eb216 --- /dev/null +++ b/doctests/runners.py @@ -0,0 +1,319 @@ +"""Assemble a page's blocks into a program and run it against the live stack. + +Assembly is the interesting half. A tutorial is written as a sequence of fragments +that a reader accumulates — Step 1 builds a client, Step 4 uses it — so the program +under test is the page's blocks *concatenated in reading order*. That is what makes +this an end-to-end test of the tutorial rather than a spot-check of its last snippet: +if Step 2 stops working with Step 1, the run breaks. + +Each block is fenced in the composed source with a comment naming its line in the +page, so a traceback points at the doc rather than at a temp file. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path + +from plans import LangPlan, PlanError + +HERE = Path(__file__).parent +REPO = HERE.parent + + +class ToolchainMissing(Exception): + """A language's compiler or SDK build is absent — skip, don't fail.""" + + +@dataclass +class RunResult: + exit_code: int + stdout: str + stderr: str + source: str + duration: float + timed_out: bool = False + # Where each block landed in the composed file, for mapping a traceback back + # to the page: composed line number -> doc location. + line_map: list[tuple[int, str]] = field(default_factory=list) + + @property + def ok(self) -> bool: + return self.exit_code == 0 and not self.timed_out + + def blame(self, composed_line: int) -> str: + """The doc location whose block contains this line of the composed file.""" + where = "prologue/harness" + for start, label in self.line_map: + if composed_line >= start: + where = label + else: + break + return where + + +# The bindings echo every HTTP response body. Useful when debugging one call, +# overwhelming in a failure report, so it is folded away rather than discarded. +_NOISE = re.compile(r"^Response body for path: .*$|^\{\"items\":.*$", re.MULTILINE) + + +def tidy(text: str, limit: int = 4000) -> str: + folded = _NOISE.sub("«sdk response body»", text).strip() + folded = re.sub(r"(«sdk response body»\n?){2,}", "«sdk response bodies»\n", folded) + if len(folded) > limit: + head, tail = folded[: limit // 2], folded[-limit // 2 :] + folded = f"{head}\n… {len(folded) - limit} chars elided …\n{tail}" + return folded + + +# ---------------------------------------------------------------- assembly + +_COMMENT = {"python": "#", "rust": "//", "java": "//"} + + +def compose_one(lang: str, lp: LangPlan, blocks: list, page: str) -> tuple[str, list[tuple[int, str]]]: + """One page's contribution: its harness prologue plus its own blocks, in order.""" + selected = lp.select(blocks) + if not selected: + raise PlanError(f"{page} [{lang}]: the plan selects no blocks, so there is nothing to run.") + + c = _COMMENT[lang] + parts: list[str] = [] + marks: list[tuple[int, str]] = [] # offsets within this fragment, fixed up by caller + line = 1 + + def emit(text: str, label: str | None = None) -> None: + nonlocal line + if label: + marks.append((line, label)) + parts.append(text) + line += text.count("\n") + 1 + + if lp.prologue.strip(): + # Labelled like a block so a traceback landing in fixture code is blamed on + # the plan, not on whichever doc block happened to precede it. + emit(f"{c} --- harness prologue for {page} ---\n{lp.prologue.rstrip()}", + f"doctests/plans (prologue for {page})") + + # Applied where they match. Whether every injection has a home is a property of + # the plan as a whole, checked once by `LangPlan.validate_injects` — in + # independent mode this function is called per block, so a valid injection for + # block 5 would look unused while composing block 1. + injections = {i.before: i.code for i in lp.inject} + + for b in selected: + if b.lang_index in injections: + emit(f"\n{c} --- harness inject before {lang} #{b.lang_index} ---\n" + f"{injections[b.lang_index].rstrip()}", + f"doctests/plans (inject before {lang} #{b.lang_index} of {page})") + emit(f"\n{c} --- {page}:{b.start_line} · {lang} #{b.lang_index} · {b.heading} ---", b.where(page)) + emit(b.body.rstrip()) + + if lp.epilogue.strip(): + emit(f"\n{c} --- harness epilogue for {page} ---\n{lp.epilogue.rstrip()}", + f"doctests/plans (epilogue for {page})") + + fragment = "\n".join(parts) + "\n" + # A plan's replacements only ever touch its own page's code. Letting them reach + # into a prerequisite's fragment would mean one plan silently rewriting another + # page's tutorial. + for r in lp.replace: + fragment = r.apply(fragment) + return fragment, marks + + +def compose(lang: str, sections: list[tuple[LangPlan, list, str]]) -> tuple[str, list[tuple[int, str]]]: + """Stitch a whole chain into one program: prerequisites first, target last. + + Imports from every section are hoisted to the top so a prerequisite's `import` + is in scope for the page that continues from it, and so Java gets them where + the language demands they go. + """ + c = _COMMENT[lang] + header = [ln for lp, _, _ in sections for ln in lp.imports.rstrip().splitlines() if ln.strip()] + seen: set[str] = set() + header = [ln for ln in header if not (ln in seen or seen.add(ln))] + + body_parts: list[str] = [] + line_map: list[tuple[int, str]] = [] + line = len(header) + (2 if header else 1) + + for lp, blocks, page in sections: + banner = f"{c} ═══ {page} ═══" + fragment, marks = compose_one(lang, lp, blocks, page) + body_parts.append(banner) + line += 1 + line_map.extend((line + off - 1, label) for off, label in marks) + body_parts.append(fragment.rstrip("\n")) + line += fragment.rstrip("\n").count("\n") + 1 + + source = ("\n".join(header) + "\n\n" if header else "") + "\n".join(body_parts) + "\n" + return source, sorted(line_map) + + +# ---------------------------------------------------------------- execution + +def _exec(cmd: list[str], cwd: Path, env: dict[str, str], timeout: int, source: str, + line_map: list[tuple[int, str]]) -> RunResult: + started = time.monotonic() + try: + proc = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=timeout) + return RunResult(proc.returncode, proc.stdout, proc.stderr, source, + time.monotonic() - started, line_map=line_map) + except subprocess.TimeoutExpired as exc: + return RunResult( + -1, + exc.stdout or "" if isinstance(exc.stdout, str) else (exc.stdout or b"").decode(errors="replace"), + exc.stderr or "" if isinstance(exc.stderr, str) else (exc.stderr or b"").decode(errors="replace"), + source, time.monotonic() - started, timed_out=True, line_map=line_map, + ) + + +def _child_env(env: dict[str, str], extra: dict[str, str]) -> dict[str, str]: + out = {**os.environ, **env, **extra} + # A doc program must not inherit the harness' pytest plumbing. + for key in ("PYTEST_CURRENT_TEST", "PYTEST_XDIST_WORKER"): + out.pop(key, None) + return out + + +def run_python(source: str, line_map, workdir: Path, env: dict[str, str], lp: LangPlan) -> RunResult: + path = workdir / "tutorial.py" + path.write_text(source, encoding="utf-8") + extra = { + # Thirteen pages end by plotting what they just computed. On a headless runner + # `plt.show()` either blocks or dies; Agg makes it a no-op, so the page keeps + # its final block instead of the plan having to cut it. + "MPLBACKEND": "Agg", + # So a prologue can `from tutorial_support import ...` for a bounded listener + # or a placeholder stub. + "PYTHONPATH": os.pathsep.join(filter(None, [str(HERE), os.environ.get("PYTHONPATH", "")])), + **lp.env, + } + return _exec([sys.executable, str(path)], workdir, _child_env(env, extra), lp.timeout, source, line_map) + + +# --- Java --------------------------------------------------------- + +_JAVA_IMPORT = re.compile(r"^\s*import\s+[\w.*]+;\s*$", re.MULTILINE) +_CP_CACHE = HERE / ".java-classpath" + + +def java_classpath() -> str: + """Resolve (once) the datahub-java-sdk classpath from the platform repo.""" + if _CP_CACHE.exists() and _CP_CACHE.read_text().strip(): + return _CP_CACHE.read_text().strip() + platform = Path(os.environ.get("DOCTEST_JAVA_REPO", REPO.parent / "datahub-platform")) + init = HERE / "java-classpath.gradle" + if not (platform / "gradlew").exists() or not init.exists(): + raise ToolchainMissing( + f"Java SDK repo not found at {platform}. Set DOCTEST_JAVA_REPO to the " + "datahub-platform checkout, or leave Java out of DOCTEST_LANGS." + ) + try: + subprocess.run([str(platform / "gradlew"), "-q", ":datahub-java-sdk:jar"], + cwd=platform, capture_output=True, text=True, timeout=900, check=True) + proc = subprocess.run([str(platform / "gradlew"), "-q", "-I", str(init), ":datahub-java-sdk:printSdkCp"], + cwd=platform, capture_output=True, text=True, timeout=900, check=True) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc: + raise ToolchainMissing(f"Could not build the Java SDK classpath: {exc}") from exc + cp = next((ln[len("SDKCP="):] for ln in proc.stdout.splitlines() if ln.startswith("SDKCP=")), "") + if not cp: + raise ToolchainMissing("Gradle produced no classpath line.") + _CP_CACHE.write_text(cp) + return cp + + +def run_java(source: str, line_map, workdir: Path, env: dict[str, str], lp: LangPlan) -> RunResult: + if not shutil.which("java"): + raise ToolchainMissing("`java` is not on PATH.") + cp = java_classpath() + # Java demands imports above the class, so any the doc shows are hoisted out of + # the body. Everything else becomes the body of main. + imports = "\n".join(m.group(0).strip() for m in _JAVA_IMPORT.finditer(source)) + body = _JAVA_IMPORT.sub("", source) + wrapped = ( + "import ai.intellistream.datahub.sdk.client.*;\n" + "import ai.intellistream.datahub.sdk.services.*;\n" + "import ai.intellistream.datahub.sdk.ingest.*;\n" + "import ai.intellistream.datahub.sdk.timeseries.*;\n" + "import ai.intellistream.datahub.api.responses.*;\n" + # The model types live in a sibling package to the SDK's own, and both are + # imported on demand — so Datapoint is named explicitly to keep it unambiguous. + "import ai.intellistream.datahub.timeseries.*;\n" + "import ai.intellistream.datahub.sdk.timeseries.Datapoint;\n" + "import ai.intellistream.datahub.models.*;\n" + "import ai.intellistream.datahub.resource.*;\n" + "import java.util.*;\nimport java.time.*;\n" + f"{imports}\n\npublic class Tutorial {{\n" + " public static void main(String[] args) throws Exception {\n" + f"{body}\n }}\n}}\n" + ) + path = workdir / "Tutorial.java" + path.write_text(wrapped, encoding="utf-8") + return _exec(["java", "-cp", cp, str(path)], workdir, _child_env(env, lp.env), lp.timeout, wrapped, line_map) + + +# --- Rust --------------------------------------------------------- + +_RUST_DIR = HERE / ".rust-runner" + + +def rust_project() -> Path: + """A cargo project wired to the local SDK by path, reused across runs. + + Kept outside the temp dir on purpose: a fresh target/ per test would mean a + full SDK rebuild per page, which is minutes rather than seconds. + """ + sdk = Path(os.environ.get("DOCTEST_RUST_SDK_PATH", REPO.parent / "dataplatform-rust-sdk")) + if not (sdk / "Cargo.toml").exists(): + raise ToolchainMissing( + f"Rust SDK not found at {sdk}. Set DOCTEST_RUST_SDK_PATH, or leave Rust out of DOCTEST_LANGS." + ) + if not shutil.which("cargo"): + raise ToolchainMissing("`cargo` is not on PATH.") + + # Read the crate name rather than assuming it: the crate has been renamed once + # already (dataplatform-rust-sdk -> intellistream-datahub-sdk), and a runner that + # hardcodes it fails with "no matching package" instead of the doc error it was + # built to report. + manifest = (sdk / "Cargo.toml").read_text(encoding="utf-8") + crate = next( + (ln.split("=", 1)[1].strip().strip('"') + for ln in manifest.splitlines() if ln.startswith("name")), + "intellistream-datahub-sdk", + ) + (_RUST_DIR / "src").mkdir(parents=True, exist_ok=True) + (_RUST_DIR / "Cargo.toml").write_text( + "[package]\nname = \"doc-tutorial\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n" + "[dependencies]\n" + f"{crate} = {{ path = \"{sdk}\" }}\n" + "tokio = { version = \"1\", features = [\"full\"] }\n" + "chrono = \"0.4\"\nserde_json = \"1\"\n", + encoding="utf-8", + ) + return _RUST_DIR + + +def run_rust(source: str, line_map, workdir: Path, env: dict[str, str], lp: LangPlan) -> RunResult: + project = rust_project() + wrapped = ( + "#![allow(unused_imports, unused_variables, unused_mut, dead_code)]\n" + "#[tokio::main]\nasync fn main() -> Result<(), Box> {\n" + f"{source}\n Ok(())\n}}\n" + ) + (project / "src" / "main.rs").write_text(wrapped, encoding="utf-8") + # The SDK also reads a .env next to the binary; write it so both paths agree. + (project / ".env").write_text( + "".join(f"{k}={env[k]}\n" for k in ("BASE_URL", "TOKEN") if env.get(k)), encoding="utf-8" + ) + return _exec(["cargo", "run", "--quiet"], project, _child_env(env, lp.env), lp.timeout, wrapped, line_map) + + +RUNNERS = {"python": run_python, "java": run_java, "rust": run_rust} diff --git a/doctests/setup.sh b/doctests/setup.sh new file mode 100755 index 0000000..10cebcb --- /dev/null +++ b/doctests/setup.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# One-time setup: a venv with pytest and the DataHub Python SDK built from source. +# +# The SDK is a PyO3 extension, so there is no wheel to pip-install from the source +# tree — maturin compiles the Rust core into this venv. Re-run after changing the +# SDK if you want the docs tested against your local changes; that is the whole +# point of building from a path rather than from a release. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SDK="${DOCTEST_RUST_SDK_PATH:-$(cd "$HERE/../.." && pwd)/dataplatform-rust-sdk}" + +[ -d "$SDK/datahub_python_bindings" ] || { + echo "error: no SDK at $SDK" >&2 + echo " set DOCTEST_RUST_SDK_PATH to your dataplatform-rust-sdk checkout." >&2 + exit 1 +} + +[ -d "$HERE/.venv" ] || python3 -m venv "$HERE/.venv" +"$HERE/.venv/bin/pip" install -q --upgrade pip maturin pytest pandas numpy + +echo "building the SDK bindings into doctests/.venv (compiles Rust — slow the first time)…" +( cd "$SDK/datahub_python_bindings" \ + && VIRTUAL_ENV="$HERE/.venv" PATH="$HERE/.venv/bin:$PATH" maturin develop --release ) + +"$HERE/.venv/bin/python" -c 'import intellistream_datahub_sdk as s; print("SDK ready:", s.__name__)' + +[ -f "$HERE/.env" ] || { + cp "$HERE/.env.example" "$HERE/.env" + echo "wrote doctests/.env from the example — point it at a stack before running." +} +echo "done. now: ./doctests/run.sh" diff --git a/doctests/test_coverage.py b/doctests/test_coverage.py new file mode 100644 index 0000000..c951d22 --- /dev/null +++ b/doctests/test_coverage.py @@ -0,0 +1,84 @@ +"""The gate that keeps the suite from quietly falling behind the docs. + +A test suite over documentation decays in one specific way: someone adds a page, +nobody adds a test, and the suite stays green while coverage drops. So membership +is checked, not just correctness. Every page carrying runnable code must be either +planned or listed in ``plans/UNTRIAGED.toml`` with a reason. A new tutorial is red +until somebody makes a decision about it, and the decision is recorded in the repo. + +``UNTRIAGED.toml`` is a backlog, not a waiver — it is meant to shrink, and these +tests refuse to let it hold stale or duplicated entries. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import docblocks +import plans as plans_mod + +REPO = Path(__file__).parent.parent +PAGES = {p.slug: p for p in docblocks.all_pages(REPO)} +RUNNABLE = {slug: p for slug, p in PAGES.items() if any(p.counts().get(l) for l in docblocks.EXECUTABLE)} + + +def _planned_pages() -> set[str]: + """Page slugs a plan covers — read off each plan's `page`, not its filename. + + A page can carry more than one plan (a step-by-step walk and the complete + program are different scenarios over the same tutorial), so coverage is keyed + by the page a plan points at. + """ + return {docblocks.slug_for(p.page) for p in plans_mod.load_all().values()} + + +def test_every_runnable_page_is_accounted_for(): + known = _planned_pages() | set(plans_mod.untriaged()) + orphans = sorted(set(RUNNABLE) - known) + assert not orphans, ( + "These pages contain runnable code but no test plan:\n" + + "\n".join(f" {RUNNABLE[s].rel} ({_summary(RUNNABLE[s])})" for s in orphans) + + "\n\nScaffold one with:\n" + + "\n".join(f" ./doctests/bin/newplan.py {RUNNABLE[s].rel}" for s in orphans) + + "\n\nIf the page genuinely cannot be run end to end, add it to " + "doctests/plans/UNTRIAGED.toml with a reason saying why." + ) + + +def test_no_page_is_both_planned_and_untriaged(): + both = sorted(_planned_pages() & set(plans_mod.untriaged())) + assert not both, ( + f"Planned and listed as untriaged at the same time: {both}. " + "Remove the UNTRIAGED.toml entry — the plan supersedes it." + ) + + +def test_untriaged_list_has_no_stale_entries(): + ghosts = sorted(set(plans_mod.untriaged()) - set(PAGES)) + assert not ghosts, ( + f"UNTRIAGED.toml names pages that no longer exist: {ghosts}. " + "Delete the entries so the backlog reflects the docs." + ) + + +@pytest.mark.parametrize("slug", sorted(plans_mod.load_all()), ids=sorted(plans_mod.load_all())) +def test_plan_is_wellformed(slug): + """Load and validate every plan, including ones whose scenarios are disabled. + + A plan with a typo in it would otherwise sit unnoticed until the day someone + enables it. + """ + plan = plans_mod.load_all()[slug] + assert (REPO / plan.page).exists(), f"{plan.path.name}: `page` points at a file that is gone." + if plan.disabled is not None: + assert plan.disabled.strip(), f"{plan.path.name}: `disabled` needs a reason." + for lang, lp in plan.langs.items(): + if lp.disabled is not None: + assert lp.disabled.strip(), f"{plan.path.name} [{lang}]: `disabled` needs a reason." + assert lp.timeout > 0, f"{plan.path.name} [{lang}]: timeout must be positive." + + +def _summary(page) -> str: + return " ".join(f"{l}:{page.counts()[l]}" for l in docblocks.EXECUTABLE if page.counts().get(l)) diff --git a/doctests/test_harness.py b/doctests/test_harness.py new file mode 100644 index 0000000..bd39173 --- /dev/null +++ b/doctests/test_harness.py @@ -0,0 +1,217 @@ +"""Tests for the test harness itself — no backend required. + +A documentation suite is only worth its guards. If block-count pinning stops +firing, or a stale bounded-run substitution starts passing silently, the suite +keeps reporting green over code nobody ran, and that is worse than having no +suite at all. These tests exercise the guards directly on synthetic input, so a +regression in the harness surfaces here rather than as a quiet false pass six +months later. + +They are also the part of the suite that runs anywhere: no stack, no credentials. +""" + +from __future__ import annotations + +import textwrap + +import pytest + +import docblocks +import entities +import plans as plans_mod +import runners + +# ------------------------------------------------------------------ extraction + + +def test_blocks_carry_heading_tab_and_line(): + page = textwrap.dedent("""\ + # Title + + ## Step one + + + + + ```python title="a.py" + x = 1 + ``` + + + + + ```rust + let x = 1; + ``` + + + + """) + blocks = docblocks.parse(page) + assert [b.lang for b in blocks] == ["python", "rust"] + assert blocks[0].heading == "Step one" + assert blocks[0].tab == "python" + assert blocks[0].title == "a.py" + assert blocks[0].start_line == 8 + assert blocks[1].tab == "rust" + + +def test_lang_index_counts_per_language(): + """Plans address blocks per language, so adding a bash fence must not renumber.""" + page = "```python\na\n```\n\n```bash\nls\n```\n\n```python\nb\n```\n" + blocks = docblocks.parse(page) + assert [(b.lang, b.lang_index) for b in blocks] == [ + ("python", 1), ("bash", 1), ("python", 2)] + + +def test_a_fence_inside_a_longer_fence_is_not_a_block(): + page = "````markdown\n```python\nnot code under test\n```\n````\n" + blocks = docblocks.parse(page) + assert [b.lang for b in blocks] == ["markdown"] + + +# ------------------------------------------------------------------ entities + + +def test_entities_from_literals_comprehensions_and_loops(): + src = textwrap.dedent("""\ + TimeSeries(external_id="plain") + [Resource(external_id=x, labels=["Ignore"]) for x in ["comp_a", "comp_b"]] + for s, u in [("loop_a", "m3h"), ("loop_b", "bar")]: + client.timeseries.create([TimeSeries(external_id=s, unit=u)]) + """) + owned = entities.owned(src) + assert owned["timeseries"] == ["loop_a", "loop_b", "plain"] + assert owned["resources"] == ["comp_a", "comp_b"] + # The label rode along in the comprehension's iterable and must not be taken + # for an external id. + assert "Ignore" not in owned["resources"] + + +def test_runtime_ids_become_patterns(): + src = 'Event(external_id=f"kick_{int(now.timestamp())}", type="kick")' + assert entities.owned(src)["events"] == ["kick_*"] + + +def test_ids_flow_through_a_local_helper(): + """The seeding pages factor creation into a helper and call it with literals.""" + src = textwrap.dedent("""\ + def ingest(external_id, values): + client.timeseries.create([TimeSeries(external_id=external_id, unit="v")]) + + ingest("helper_a", [1]) + ingest("helper_b", [2]) + """) + assert entities.owned(src)["timeseries"] == ["helper_a", "helper_b"] + + +def test_retrieve_is_a_read_not_an_ownership_claim(): + """`ts=` on a filter is a read; treating it as ownership deletes shared fixtures.""" + read = 'RetrieveFilter(ts="someone_elses_series", start=a, end=b)' + write = 'client.timeseries.insert_from_lists(timestamps=t, values=v, ts="mine")' + assert entities.owned(read) == {} + assert entities.owned(write)["timeseries"] == ["mine"] + + +def test_edge_targets_count_for_cleanup_but_not_for_assertions(): + src = 'RelForm.by_external_ids("start_node", "end_node", "contains")' + assert entities.owned(src)["resources"] == ["end_node", "start_node"] + assert entities.owned(src, include_edge_refs=False) == {} + + +# ------------------------------------------------------------------ guards + + +def _lang_plan(**kw) -> plans_mod.LangPlan: + return plans_mod.LangPlan(lang="python", **kw) + + +def _block(body: str, index: int = 1) -> docblocks.Block: + return docblocks.Block(index=index, lang_index=index, lang="python", meta="", + body=body, start_line=1, heading="Step", tab="python") + + +def test_a_stale_replacement_fails_loudly(): + """The guard that stops a bounded-run substitution from silently lapsing.""" + lp = _lang_plan(replace=[plans_mod.Replacement(find="while True:", repl="for _ in range(2):")]) + with pytest.raises(plans_mod.PlanError, match="no longer match"): + lp.validate_replacements([_block("print('no loop here')")], "docs/x.mdx") + + +def test_a_replacement_marked_optional_may_miss(): + lp = _lang_plan(replace=[plans_mod.Replacement(find="absent", repl="x", required=False)]) + lp.validate_replacements([_block("kept = 1")], "docs/x.mdx") # must not raise + source, _ = runners.compose("python", [(lp, [_block("kept = 1")], "docs/x.mdx")]) + assert "kept = 1" in source + + +def test_inject_pointing_at_an_unrun_block_fails(): + """Block numbering shifts when a page is edited; a dangling inject must not pass.""" + lp = _lang_plan(inject=[plans_mod.Injection(before=9, code="x = 1")]) + with pytest.raises(plans_mod.PlanError, match="inject targets block"): + lp.validate_injects([_block("y = 2")], "docs/x.mdx") + + +def test_selecting_a_block_the_page_does_not_have_fails(): + lp = _lang_plan(only=[3]) + with pytest.raises(plans_mod.PlanError, match="plan selects block"): + lp.select([_block("a")]) + + +def test_only_and_exclude_together_is_rejected(tmp_path): + plan = tmp_path / "p.toml" + plan.write_text('page = "docs/x.mdx"\n[python]\nonly = [1]\nexclude = [2]\n') + with pytest.raises(plans_mod.PlanError, match="use `only` or `exclude`"): + plans_mod.load(plan) + + +def test_a_plan_without_a_page_is_rejected(tmp_path): + plan = tmp_path / "p.toml" + plan.write_text("[python]\n") + with pytest.raises(plans_mod.PlanError, match="missing `page`"): + plans_mod.load(plan) + + +def test_requires_cycles_are_caught(): + def stub(slug, dep): + return plans_mod.Plan(slug=slug, page=f"docs/{slug}.mdx", path=None, disabled=None, + blocks={}, langs={"python": _lang_plan(requires=[dep])}, + owns={}, expect_exists={}, expect_datapoints={}, + expect_stdout=[], settle_secs=1.0) + all_plans = {"a": stub("a", "b"), "b": stub("b", "a")} + with pytest.raises(plans_mod.PlanError, match="cycle"): + plans_mod.chain("a", "python", all_plans) + + +# ------------------------------------------------------------------ composition + + +def test_blocks_are_concatenated_in_reading_order_and_blamed_correctly(): + lp = _lang_plan(prologue="fixture = 1") + first = docblocks.Block(index=1, lang_index=1, lang="python", meta="", body="step_one()", + start_line=10, heading="Step 1", tab="python") + second = docblocks.Block(index=2, lang_index=2, lang="python", meta="", body="step_two()", + start_line=40, heading="Step 2", tab="python") + source, line_map = runners.compose("python", [(lp, [first, second], "docs/t.mdx")]) + + assert source.index("step_one()") < source.index("step_two()") + result = runners.RunResult(0, "", "", source, 0.0, line_map=line_map) + at_step_two = source[: source.index("step_two()")].count("\n") + 1 + assert "docs/t.mdx:40" in result.blame(at_step_two) + assert "prologue" in result.blame(1) + + +def test_prerequisites_are_prepended(): + lp = _lang_plan() + prereq = _block("from_quickstart()") + target = _block("uses_the_client()") + source, _ = runners.compose("python", [(lp, [prereq], "docs/quickstart.mdx"), + (lp, [target], "docs/guide.mdx")]) + assert source.index("from_quickstart()") < source.index("uses_the_client()") + assert "docs/quickstart.mdx" in source and "docs/guide.mdx" in source + + +def test_sdk_response_noise_is_folded_out_of_reports(): + noisy = 'Response body for path: http://x/y\n{"items":[1]}\nreal failure here' + assert "real failure here" in runners.tidy(noisy) + assert "Response body for path" not in runners.tidy(noisy) diff --git a/doctests/test_tutorials.py b/doctests/test_tutorials.py new file mode 100644 index 0000000..882a007 --- /dev/null +++ b/doctests/test_tutorials.py @@ -0,0 +1,266 @@ +"""Run every planned tutorial end to end and check it did what the page claims. + +One test per (page, language). The test does what a reader does: take the page's +code from the top, run it against a real backend, and see whether the thing the +page promised exists afterwards. + +Failures are formatted to point at the *documentation*, not at the harness — the +whole value of this suite is that a red build names the doc line to go fix. +""" + +from __future__ import annotations + +import fnmatch +import importlib.util +import re +from pathlib import Path + +import pytest + +import backend +import docblocks +import entities +import plans as plans_mod +import runners +from runners import ToolchainMissing + +REPO = Path(__file__).parent.parent +ALL_PLANS = plans_mod.load_all() + +# Plans other plans depend on for data. These are tested like any other page, but +# their teardown must not run: a data-seeding page that tidies up after itself +# leaves every recipe that depends on it reading an empty backend. The session +# fixture in conftest owns their cleanup instead. +FIXTURE_SLUGS = { + dep + for plan in ALL_PLANS.values() + for lp in plan.langs.values() + for dep in lp.requires_once +} + +# Traceback frames pointing at the composed program, so its line can be translated +# back into the doc line the reader would be looking at. +_PY_FRAME = re.compile(r'File "[^"]*tutorial\.py", line (\d+)') + + +def _cases(): + """(slug, lang) for every language a plan actually declares a scenario for.""" + out = [] + for slug, plan in sorted(ALL_PLANS.items()): + if plan.disabled: + continue + for lang in docblocks.EXECUTABLE: + lp = plan.langs.get(lang) + if lp is not None and not lp.disabled: + out.append(pytest.param(slug, lang, id=f"{slug}[{lang}]")) + return out + + +def _page(plan) -> docblocks.Page: + path = REPO / plan.page + if not path.exists(): + pytest.fail( + f"{plan.path.name} points at {plan.page}, which does not exist. " + "The page was renamed or deleted — move or delete its plan to match." + ) + return docblocks.load(path, REPO) + + +def _explain(plan, lang, result: runners.RunResult) -> str: + """A failure report that names the doc, the block, and the likely line.""" + lines = [ + f"The {lang} tutorial on {plan.page} did not run cleanly.", + "", + f" exit code : {'timed out after %ss' % result.duration if result.timed_out else result.exit_code}", + f" duration : {result.duration:.1f}s", + ] + + blamed = {int(m.group(1)) for m in _PY_FRAME.finditer(result.stderr)} + if blamed: + where = sorted({result.blame(n) for n in blamed}) + lines += ["", " failing block(s):"] + [f" {w}" for w in where] + + if result.stderr.strip(): + lines += ["", " stderr:", *(f" {ln}" for ln in runners.tidy(result.stderr).splitlines())] + if result.stdout.strip(): + lines += ["", " stdout:", *(f" {ln}" for ln in runners.tidy(result.stdout, 1500).splitlines())] + + lines += [ + "", + " This is a documentation failure until proven otherwise: the code on the page,", + " run in the order the page presents it, did not work. Fix the page. Only change", + f" doctests/plans/{plan.path.name} if the *plan's* assumptions (prologue, bounded-run", + " replacements, owned ids) are what went stale.", + "", + " To reproduce the exact program that ran:", + f" ./doctests/run.sh --langs {lang} -k '{plan.slug}' --keep -s", + ] + return "\n".join(lines) + + +@pytest.mark.parametrize("slug,lang", _cases()) +def test_tutorial_runs_end_to_end(slug, lang, langs, cli, env, seed, pytestconfig, tmp_path): + if lang not in langs: + pytest.skip(f"{lang} not selected (--langs={','.join(sorted(langs))})") + + plan = ALL_PLANS[slug] + lp = plan.lang(lang) + + # Data-generating prerequisites run once per session; unlike `requires`, they are + # not composed into this program — the page just needs their data to be there. + for fixture_slug in lp.requires_once: + seed(fixture_slug, lang) + + # A guide that opens "you already have a client" is only meaningful when the page + # it continues from actually ran, so the whole chain is composed into one program: + # quickstart first, this page last. + links = plans_mod.chain(slug, lang, ALL_PLANS) + sections = [] + for link in links: + link_page = _page(link) + link_blocks = link_page.of_lang(lang) + if not link_blocks: + pytest.fail(f"{link.page} has no {lang} blocks, but its plan declares a {lang} scenario.") + link.lang(lang).validate_injects(link_blocks, link.page) + link.lang(lang).validate_replacements(link_blocks, link.page) + sections.append((link.lang(lang), link_blocks, link.page)) + + # A tutorial whose prerequisites this environment cannot supply is skipped with + # the reason, not failed: the page may be perfectly correct. + for link in links: + llp = link.lang(lang) + absent = [v for v in llp.requires_env if not env.get(v)] + if absent: + pytest.skip(f"{link.page} needs {', '.join(absent)} in doctests/.env to be tested honestly") + for module in llp.requires_python: + if lang == "python" and importlib.util.find_spec(module) is None: + pytest.skip(f"{link.page} needs the `{module}` package: pip install {module} into doctests/.venv") + + # An API-reference page's blocks are independent examples; a tutorial's are a + # sequence. `independent` picks which, and the runs below differ only in whether + # the page's blocks arrive as one program or several. + if lp.independent: + programs = [ + runners.compose(lang, sections[:-1] + [(lp, [block], plan.page)]) + for block in lp.select(sections[-1][1]) + ] + else: + programs = [runners.compose(lang, sections)] + + # A page's sweep must never reclaim what a session fixture planted: these recipes + # read series that `generate-sample-data` seeded, and they legitimately name those + # series in `owns` for the ids they add themselves. Deleting the seed before the + # run would leave the recipe with nothing to read and blame the doc for it. + seeded: list[str] = [] + for fixture_slug in lp.requires_once: + for ids in plans_mod.merged_owns(plans_mod.chain(fixture_slug, lang, ALL_PLANS)).values(): + seeded.extend(ids) + + # What this page builds for itself. A fixture may seed a stand-in under the same + # id — `generate-sample-data` seeds a placeholder for the anomaly score that + # predictive-maintenance computes for real — and in that case the page must still + # be allowed to clear it, or its own create fails as a duplicate. + own_creations: set[str] = set() + for link_lp, link_blocks, _ in sections: + selected = link_lp.select(link_blocks) + for ids in entities.owned("\n".join(b.body for b in selected), + include_edge_refs=False).values(): + own_creations.update(ids) + + def _is_seeded(external_id: str) -> bool: + # A fixture declares whole families by pattern (`pump_07_*`), while a recipe + # names the individual series it reads. Comparing the two as plain strings + # protects nothing, and the sweep then deletes the very data the recipe was + # about to read — which surfaces as an empty result deep inside the page. + if external_id in own_creations: + return False + return any(fnmatch.fnmatch(external_id, pattern) for pattern in seeded) + + owns = { + kind: [i for i in ids if not _is_seeded(i)] + for kind, ids in plans_mod.merged_owns(links).items() + } + + # Start from a known-empty backend so the page's fixed external ids create + # cleanly. Without this, a second run of the suite fails on 409s that say + # nothing about whether the tutorial is correct. + backend.sweep(cli, owns) + + def _run_all(): + out = [] + for src, lmap in programs: + # Independent examples are independent: each assumes a clean slate, the + # way a reader meets it. Without a sweep between them, one example's + # `create` collides with the next example's, which says nothing about + # whether either is correct. + if lp.independent and len(programs) > 1: + backend.sweep(cli, owns) + out.append(runners.RUNNERS[lang](src, lmap, tmp_path, env, lp)) + return out + + try: + results = _run_all() + except ToolchainMissing as exc: + pytest.skip(str(exc)) + # Report the first program that failed; the rest still ran, so a page with two + # broken examples is not hidden behind the first one. + result = next((r for r in results if not r.ok), results[0]) + source, line_map = next(((s_, l_) for (s_, l_), r in zip(programs, results) if not r.ok), + programs[0]) + + try: + assert result.ok, _explain(plan, lang, result) + + for fragment in plan.expect_stdout: + assert fragment in result.stdout, ( + f"{plan.page} [{lang}] ran, but its output never contained {fragment!r}.\n" + f"The page tells the reader to expect that. Output was:\n" + f"{runners.tidy(result.stdout, 1500)}" + ) + + missing = backend.missing_entities(cli, plan.expect_exists, plan.settle_secs) + assert not missing, ( + f"{plan.page} [{lang}] exited 0, but the backend does not hold what the page " + f"promises it creates: {', '.join(missing)}.\n" + "An exit code of 0 is not proof a tutorial worked — this check is why." + ) + + short = backend.datapoint_shortfall(cli, plan.expect_datapoints, plan.settle_secs) + assert not short, ( + f"{plan.page} [{lang}] created its series but the data is not there: " + f"{'; '.join(short)}." + ) + finally: + if not pytestconfig.getoption("--keep") and slug not in FIXTURE_SLUGS: + backend.sweep(cli, owns) + + +@pytest.mark.parametrize("slug", sorted(ALL_PLANS), ids=sorted(ALL_PLANS)) +def test_plan_still_matches_the_page(slug): + """Fail when a page gains or loses code blocks under a plan that selects by index. + + This is the guard that keeps the suite honest. Plans address blocks by their + position among their language's blocks, so an inserted snippet silently shifts + what every later selection points at — a test that keeps passing while testing + the wrong code. Pinning the counts turns that into a loud, cheap failure. + """ + plan = ALL_PLANS[slug] + page = _page(plan) + counts = page.counts() + + for lang in docblocks.EXECUTABLE: + if counts.get(lang) and lang not in plan.blocks: + pytest.fail( + f"{plan.page} has {counts[lang]} {lang} block(s) but {plan.path.name} does not " + f"declare a count for {lang}.\nAdd `{lang} = {counts[lang]}` under [blocks]." + ) + + for lang, declared in plan.blocks.items(): + actual = counts.get(lang, 0) + if actual != declared: + pytest.fail( + f"{plan.page} now has {actual} {lang} block(s); {plan.path.name} was written " + f"against {declared}.\n" + "Re-read the page: block numbering has shifted, so the plan's `only`/`exclude` " + "selections may now point at different code. Update the plan and the count together." + ) diff --git a/doctests/tutorial_support.py b/doctests/tutorial_support.py new file mode 100644 index 0000000..afc59d2 --- /dev/null +++ b/doctests/tutorial_support.py @@ -0,0 +1,183 @@ +"""Helpers a composed tutorial program can import, for things a page leaves to you. + +Doc pages hand the reader two kinds of loose end. Some are *placeholders* — a +`record_capacity_factor(...)` the page never defines because it is your dashboard, +not theirs. Some are *unbounded* — a `for msg in listener:` that is correct in a +service and cannot be in a test. + +The tempting fix for both is to drop the block. That is also the wrong fix: the +listen loop is usually the whole point of the page, and a test that skips it proves +nothing about the pipeline the tutorial teaches. So instead this module supplies the +missing edges — a recording stub, a bounded take, a background writer that makes +traffic actually arrive — and the page's own code runs unchanged in the middle. + +Everything here is deliberately importable only by test programs; nothing in `docs/` +should ever mention it. +""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Callable, Iterable, Iterator + + +class Recorder: + """A callable that accepts anything, remembers it, and returns something usable. + + Stands in for the "and then do something with it" function a page leaves to the + reader. It records rather than discards so a plan can assert the tutorial's logic + actually reached it — `len(record) > 0` is often the only evidence that a pipeline + produced anything at all. + + The return value defaults to ``True`` for a specific reason. These placeholders sit + in two positions: predicates (`if out_of_band(value):`) and small computations + (`closing - opening`). Returning ``None`` satisfies neither — it makes every + predicate false, so the interesting branch never runs and the test passes over a + pipeline it never exercised, and it makes arithmetic raise. ``True`` is an int, so + it reads as 1 in a calculation and takes the branch the tutorial is actually about. + + Where a page needs a particular value, a plan says so: ``Recorder("latest", + returns=95.0)``. + """ + + def __init__(self, name: str = "stub", returns: Any = True) -> None: + self.name = name + self.returns = returns + self.calls: list[tuple[tuple, dict]] = [] + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + self.calls.append((args, kwargs)) + return self.returns + + def __len__(self) -> int: + return len(self.calls) + + def __bool__(self) -> bool: + return True + + def __repr__(self) -> str: + return f"" + + +def stubs(*names: str) -> dict[str, Recorder]: + """Recorders for several placeholder names at once.""" + return {name: Recorder(name) for name in names} + + +def take(source: Iterable, limit: int = 3, timeout: float = 30.0) -> Iterator: + """Yield at most `limit` items from a stream, and stop waiting after `timeout`. + + A subscription listener blocks until a message arrives and then blocks again; + iterating it is correct in a daemon and fatal in a test. This bounds both the + count and the wall clock, so a page whose stream stays silent finishes and fails + on its assertions rather than hanging until the suite timeout — a much more + legible outcome than a killed run. + + The timeout is enforced between items, which is the only place it can be: + the underlying iterator's blocking read cannot be interrupted from here. + """ + deadline = time.monotonic() + timeout + taken = 0 + for item in source: + yield item + taken += 1 + if taken >= limit or time.monotonic() > deadline: + return + + +def feed(client, series: str | list[str], *, points: int = 20, every: float = 0.25, + value: float = 100.0) -> Callable[[], None]: + """Write datapoints in the background so a listener has something to receive. + + Returns a stop function. Started as a daemon thread: if the tutorial under test + dies, the writer must not keep the process alive. + + Without this, testing a subscription page means either faking the listener — + which tests nothing — or hoping the stack happens to have live traffic, which + makes the result depend on what else is running. Generating the traffic is what + lets the page's own detect-and-react code be exercised for real. + """ + names = [series] if isinstance(series, str) else list(series) + done = threading.Event() + + def run() -> None: + import datetime as dt + + for i in range(points): + if done.is_set(): + return + now = dt.datetime.now(dt.timezone.utc) + for name in names: + try: + client.timeseries.insert_from_lists( + timestamps=[now], values=[value + i], ts=name) + except Exception: + return # the test's own assertions report the failure + time.sleep(every) + + threading.Thread(target=run, daemon=True).start() + return done.set + + +def wait_for_datapoints(client, external_id: str, minimum: int = 1, timeout: float = 30.0) -> int: + """Block until a series has at least `minimum` readable datapoints. + + Several pages write a datapoint and read it back in the next breath. Storage is + eventually consistent, so that read can legitimately come back empty — a race the + page has, and one a reader mostly does not notice because their data was already + there. Waiting here reproduces the reader's situation instead of testing how fast + the projection happens to be today. + + Returns the count seen, so a caller can tell "arrived" from "gave up". + """ + import datetime as dt + + import intellistream_datahub_sdk as sdk + + deadline = time.monotonic() + timeout + while True: + now = dt.datetime.now(dt.timezone.utc) + try: + got = client.timeseries.retrieve_datapoints( + sdk.RetrieveFilter( + ts=external_id, + start=now - dt.timedelta(days=730), + end=now + dt.timedelta(days=1), + limit=minimum, + ) + ) + count = sum(len(c.get_datapoints()) for c in got) + except Exception: + count = 0 + if count >= minimum or time.monotonic() > deadline: + return count + time.sleep(0.5) + + +def wait_for_related(client, external_id: str, *, minimum: int = 1, timeout: float = 30.0, + relationship_types=None) -> int: + """Block until a node's neighbourhood is visible to the graph read path. + + Writes to the graph land in a projection that trails them by a second or two. + Sixteen pages model a network and traverse it in the very next block, so the + traversal can legitimately come back empty — and because "no neighbours" is a + valid answer rather than an error, that surfaces as a tutorial quietly printing + the wrong conclusion instead of failing. This makes the read deterministic + without changing what the page's own code does. + + Returns how many nodes were visible, so a caller can distinguish "arrived" from + "gave up". + """ + deadline = time.monotonic() + timeout + while True: + try: + kwargs = {"external_id": external_id, "depth": 10} + if relationship_types: + kwargs["relationship_types"] = relationship_types + found = len(client.resources.fetch_related(**kwargs).nodes) + except Exception: + found = 0 + if found >= minimum or time.monotonic() > deadline: + return found + time.sleep(0.5) From e4ff3e3f28369027897d2c15e8d7f511d3a55ccd Mon Sep 17 00:00:00 2001 From: samuel Date: Wed, 2 Sep 2026 14:31:04 +0200 Subject: [PATCH 2/3] ci(docs): run the tutorials when the docs or the SDK change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the workflow and the two backend-free tiers it leans on, so the suite is useful before anyone has stood up a stack for CI. - test_api_surface.py: checks every SDK name and service method the docs use against the built SDK. Needs a build, not a backend. This is the tier that would have caught the 0.2.0 rename and the removal of BasicEventFilter in the pull request that did them, rather than months later. It reads the service classes off a locally-constructed client, since they are not exported at module level, and it treats a namespace package as missing — a stale empty `datahub_sdk/` directory answers `find_spec` while importing nothing, which is precisely how the rename stayed invisible. - doc-tutorials.yml: three jobs. `structure` needs only pytest and runs in under a second. `api-surface` builds the SDK from the ref under test. `tutorials` runs the live suite when DOCTEST_BASE_URL is set, and posts a notice instead of failing when it is not — a missing backend is not a broken tutorial. The workflow accepts `repository_dispatch` (type `sdk-updated`) carrying an `sdk_ref`, so the SDK repo can have its changes checked against the docs. Also fixes run.sh, which prepended the whole suite even when given a specific file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LoqhJA3haV5BqiqHvhPBZf --- .claude/skills/doc-tutorial-tests/SKILL.md | 5 +- .github/workflows/doc-tutorials.yml | 149 ++++++++++++++++ doctests/README.md | 28 ++- doctests/run.sh | 12 +- doctests/test_api_surface.py | 190 +++++++++++++++++++++ 5 files changed, 374 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/doc-tutorials.yml create mode 100644 doctests/test_api_surface.py diff --git a/.claude/skills/doc-tutorial-tests/SKILL.md b/.claude/skills/doc-tutorial-tests/SKILL.md index 7b476a8..c9f1ecd 100644 --- a/.claude/skills/doc-tutorial-tests/SKILL.md +++ b/.claude/skills/doc-tutorial-tests/SKILL.md @@ -29,6 +29,7 @@ failing test except the documentation.** That is deliberate. ```bash ./doctests/setup.sh # once — venv + SDK built from source ./doctests/run.sh # every planned tutorial, Python +./doctests/run.sh doctests/test_harness.py doctests/test_api_surface.py # no stack needed ./doctests/run.sh -k quickstart # one page ./doctests/run.sh --langs all # Java and Rust too ./doctests/run.sh --keep -s -k tutorial # leave the data behind and watch it run @@ -36,7 +37,9 @@ failing test except the documentation.** That is deliberate. Needs a reachable stack in `doctests/.env` (see `.env.example`). **Never point it at production** — it creates and deletes entities under the docs' own external ids. -No backend configured means skips, not failures. +No backend configured means skips, not failures: 165 structural checks still run, and +the API-surface checks run whenever the SDK is installed. That is what makes the suite +useful in CI without a stack, and what `.github/workflows/doc-tutorials.yml` leans on. ## Reading a failure diff --git a/.github/workflows/doc-tutorials.yml b/.github/workflows/doc-tutorials.yml new file mode 100644 index 0000000..6737bbb --- /dev/null +++ b/.github/workflows/doc-tutorials.yml @@ -0,0 +1,149 @@ +# Run the documentation tutorials against a real backend. +# +# Two tiers, because they need very different things: +# +# structure — no backend, no SDK, under a second. Catches the failures that come +# from editing a page: a fence added or removed under a plan that +# selects by index, a new page with no plan, a bounded-run +# substitution that has stopped matching, a malformed plan. This +# runs everywhere, always. +# +# tutorials — needs a live stack, and builds the SDK from source so the docs are +# tested against the code under review rather than a release. Skips +# with a notice when no stack is configured, because a missing +# backend is not a broken tutorial and failing on it teaches people +# to ignore red. +name: Doc tutorials + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + inputs: + sdk_ref: + description: "dataplatform-rust-sdk ref to test the docs against" + default: main + # Sent by the SDK repo after it builds, so an SDK change that breaks a tutorial + # is reported here too. See dataplatform-rust-sdk/.github/workflows/ci.yml. + repository_dispatch: + types: [sdk-updated] + +concurrency: + # One live run at a time: the suite creates and deletes entities under the docs' + # own external ids, so two concurrent runs would sweep each other's data. + group: doc-tutorials-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + structure: + name: Plans match the docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install --quiet pytest + - name: Block-count pins, coverage gate, plan validation, harness self-tests + # No backend and no SDK: every live tutorial test skips, and what remains is + # the part that guards against the docs and the plans drifting apart. The + # API-surface checks skip here too — they need the SDK, which the api-surface + # job below builds. + run: pytest doctests -q --tb=short + + api-surface: + name: Docs match the SDK's API + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check out the SDK + uses: actions/checkout@v6 + with: + repository: IntelliStream-DataHub/dataplatform-rust-sdk + ref: ${{ github.event.client_payload.sdk_ref || inputs.sdk_ref || 'main' }} + token: ${{ secrets.SDK_REPO_TOKEN || github.token }} + path: .sdk + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: .sdk + - name: Build the SDK bindings + env: + DOCTEST_RUST_SDK_PATH: ${{ github.workspace }}/.sdk + run: ./doctests/setup.sh + # Needs the SDK built and nothing else — no stack, no credentials. This is the + # check worth running from the SDK's own CI: it turns "we removed a symbol" into + # a failure in the pull request that removed it, rather than a broken tutorial + # someone trips over months later. + - name: Every symbol and method the docs use still exists + run: ./doctests/run.sh -q --tb=short doctests/test_api_surface.py + + tutorials: + name: Tutorials run end to end + runs-on: ubuntu-latest + needs: structure + steps: + - uses: actions/checkout@v6 + + - name: Is a stack configured? + id: stack + env: + BASE_URL: ${{ secrets.DOCTEST_BASE_URL }} + run: | + if [ -n "$BASE_URL" ]; then + echo "configured=true" >> "$GITHUB_OUTPUT" + else + echo "configured=false" >> "$GITHUB_OUTPUT" + echo "### Tutorials not run" >> "$GITHUB_STEP_SUMMARY" + echo "No \`DOCTEST_BASE_URL\` secret, so there is no backend to run the" \ + "tutorials against. The structure job still checked that every page" \ + "has a plan and that no plan has drifted from its page." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Check out the SDK + if: steps.stack.outputs.configured == 'true' + uses: actions/checkout@v6 + with: + repository: IntelliStream-DataHub/dataplatform-rust-sdk + # The SDK commit under test when the SDK triggered us; its default branch + # otherwise. This is the whole point: docs tested against that code. + ref: ${{ github.event.client_payload.sdk_ref || inputs.sdk_ref || 'main' }} + token: ${{ secrets.SDK_REPO_TOKEN || github.token }} + path: .sdk + + - uses: actions/setup-python@v5 + if: steps.stack.outputs.configured == 'true' + with: + python-version: "3.12" + - uses: dtolnay/rust-toolchain@stable + if: steps.stack.outputs.configured == 'true' + - uses: Swatinem/rust-cache@v2 + if: steps.stack.outputs.configured == 'true' + with: + workspaces: .sdk + + - name: Build the SDK and install the test dependencies + if: steps.stack.outputs.configured == 'true' + env: + DOCTEST_RUST_SDK_PATH: ${{ github.workspace }}/.sdk + run: ./doctests/setup.sh + + - name: Run every tutorial + if: steps.stack.outputs.configured == 'true' + env: + BASE_URL: ${{ secrets.DOCTEST_BASE_URL }} + TOKEN: ${{ secrets.DOCTEST_TOKEN }} + CLIENT_ID: ${{ secrets.DOCTEST_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.DOCTEST_CLIENT_SECRET }} + TOKEN_URI: ${{ secrets.DOCTEST_TOKEN_URI }} + DOCTEST_RUST_SDK_PATH: ${{ github.workspace }}/.sdk + # Never point this at production: the suite creates and deletes entities + # under the docs' own external ids. + run: ./doctests/run.sh -q --tb=short diff --git a/doctests/README.md b/doctests/README.md index a72a93e..6f76b07 100644 --- a/doctests/README.md +++ b/doctests/README.md @@ -40,6 +40,8 @@ configured the suite skips rather than fails. | `backend.py` | Config, cleanup sweeps, and the outcome checks. | | `test_tutorials.py` | One test per (page, language), plus the block-count drift guard. | | `test_coverage.py` | Refuses to let a page with runnable code go unaccounted for. | +| `test_api_surface.py` | Checks every SDK name and service method the docs use against the built SDK. Needs the SDK, not a backend. | +| `test_harness.py` | Tests the guards themselves. Needs neither. | | `entities.py` | Reads which entities a page creates, so a plan can own and assert them. | | `tutorial_support.py` | Helpers a test program may import: bounded listen, traffic feed, placeholder stubs. | | `bin/newplan.py` | Scaffolds a plan from a page. | @@ -86,16 +88,26 @@ or `DOCTEST_LANGS=rust` — but need `DOCTEST_JAVA_REPO` (a `datahub-platform` c for the SDK jar) and a Rust toolchain respectively, and their per-page scenarios are mostly still to be written. A missing toolchain skips with the reason. -## Wiring it into CI +## What runs where -Not configured here, because it needs an infrastructure decision this repo cannot make -on its own: the suite requires a running DataHub stack. Once there is one CI can reach, -a job is small — `setup.sh`, then `run.sh` with `BASE_URL` and credentials from secrets. -Until then, run it locally before merging a change to any page with code on it. +The suite is in three tiers, because they need very different things: -The suite is also worth running from the **SDK** side: an SDK change that breaks a -documented call should fail there, where the change is being made, rather than being -discovered later here. +| Tier | Needs | Catches | +| --- | --- | --- | +| Structure (165 checks, <1s) | nothing | a fence added or removed under a plan, a new page with no plan, a stale bounded-run substitution, a malformed plan, a regression in the harness itself | +| API surface | the SDK built | a renamed or removed SDK symbol, a service method the docs call that no longer exists, a page importing a package that is not installed | +| Tutorials | a live stack | everything else — whether the page actually works | + +`.github/workflows/doc-tutorials.yml` runs all three. The first two need no +infrastructure and run on every pull request; the third runs when a `DOCTEST_BASE_URL` +secret points at a stack, and posts a notice instead of failing when it does not. + +**Never point it at production.** Each run creates and deletes entities under the docs' +own external ids. + +The API-surface tier is the one worth running from the **SDK** side: it needs only a +build, so an SDK change that removes something the docs use can fail in the pull request +that removes it, rather than being discovered here months later. ## Working on it diff --git a/doctests/run.sh b/doctests/run.sh index 6868323..d759a04 100755 --- a/doctests/run.sh +++ b/doctests/run.sh @@ -8,4 +8,14 @@ set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" [ -x "$HERE/.venv/bin/pytest" ] || { echo "run $HERE/setup.sh first" >&2; exit 1; } -exec "$HERE/.venv/bin/pytest" "$HERE" "$@" + +# Run the whole suite unless the caller named specific tests. Without this, passing +# a file would run it *in addition to* everything else, which is a surprising way to +# spend four minutes when you asked for one file. +targets=() +for arg in "$@"; do + [ -e "$arg" ] && targets+=("$arg") +done +[ ${#targets[@]} -eq 0 ] && targets=("$HERE") + +exec "$HERE/.venv/bin/pytest" "${targets[@]}" "$@" diff --git a/doctests/test_api_surface.py b/doctests/test_api_surface.py new file mode 100644 index 0000000..cdb4991 --- /dev/null +++ b/doctests/test_api_surface.py @@ -0,0 +1,190 @@ +"""Check the docs against the installed SDK's API surface. No backend needed. + +This is the cheapest useful check in the suite and the one worth running from the +SDK's own CI: it needs the bindings built, nothing else. When `BasicEventFilter`, +`SearchAndFilterForm` and `TimeSeriesFilterForm` were removed in 0.2.0, six doc +pages started referring to things that no longer existed — and nothing said so +until someone ran the code months later. A build-time check would have caught it +in the pull request that removed them. + +It deliberately only reports names the docs *state*: a class they construct, a +service method they call. Anything it cannot resolve confidently is left alone, +because a false failure here trains people to skip the check. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +import docblocks + +REPO = Path(__file__).parent.parent +MODULE = "intellistream_datahub_sdk" + +sdk = pytest.importorskip(MODULE, reason="SDK not installed; run doctests/setup.sh") + + +def _doc_sources() -> list[tuple[str, str]]: + return [(p.rel, "\n".join(b.body for b in p.of_lang("python"))) + for p in docblocks.all_pages(REPO) if p.of_lang("python")] + + +def _module_symbols(source: str) -> set[str]: + """Names the page takes from the SDK, however it spells the import.""" + out: set[str] = set() + try: + tree = ast.parse(source) + except SyntaxError: + return out + + aliases = {MODULE} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for a in node.names: + if a.name == MODULE: + aliases.add(a.asname or a.name) + elif isinstance(node, ast.ImportFrom) and node.module == MODULE: + out.update(a.name for a in node.names) + + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) \ + and node.value.id in aliases: + out.add(node.attr) + return out + + +def _sdk_imports(source: str) -> set[str]: + """SDK-ish module roots the page imports, whatever they are called. + + Checking symbols inside `intellistream_datahub_sdk` misses the most basic + failure of all: a page importing a module that does not exist. That is exactly + what the 0.2.0 rename produced — 69 pages importing `datahub_sdk`, every Python + tutorial dead on its first line — and a symbol check would have said nothing, + because it was looking for a module the pages had stopped naming. + """ + out: set[str] = set() + try: + tree = ast.parse(source) + except SyntaxError: + return out + for node in ast.walk(tree): + names = [] + if isinstance(node, ast.Import): + names = [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + names = [node.module] + for name in names: + root = name.split(".")[0] + if "datahub" in root.lower() or "intellistream" in root.lower(): + out.add(root) + return out + + +IMPORT_CASES = [ + (rel, sorted(_sdk_imports(src))) + for rel, src in _doc_sources() + if _sdk_imports(src) +] + + +@pytest.mark.parametrize("rel,modules", IMPORT_CASES, ids=[r for r, _ in IMPORT_CASES]) +def test_page_imports_a_module_that_exists(rel, modules): + import importlib.util + + def importable(name: str) -> bool: + # `find_spec` alone is not enough. A stale empty directory left on the path + # answers as a namespace package — origin None, no loader — and satisfies a + # naive check while importing nothing. That is exactly the shape the old + # `datahub_sdk` directory has, which is why the rename went unnoticed. + try: + spec = importlib.util.find_spec(name) + except (ImportError, ValueError): + return False + return spec is not None and spec.origin is not None + + missing = [m for m in modules if not importable(m)] + assert not missing, ( + f"{rel} imports {', '.join(missing)}, which is not installed. The SDK's Python " + f"package is `{MODULE}`; `pip install intellistream-datahub-sdk`.\n" + "If the package was renamed, every page naming the old one is broken at its " + "first line — this is the cheapest possible check and it needs no backend." + ) + + +SYMBOL_CASES = [ + (rel, sorted(_module_symbols(src))) + for rel, src in _doc_sources() + if _module_symbols(src) +] + + +@pytest.mark.parametrize("rel,symbols", SYMBOL_CASES, ids=[r for r, _ in SYMBOL_CASES]) +def test_page_only_uses_symbols_the_sdk_has(rel, symbols): + missing = [s for s in symbols if not hasattr(sdk, s)] + assert not missing, ( + f"{rel} uses {MODULE} name(s) that this build does not have: {', '.join(missing)}.\n" + "Either the SDK dropped them and the page needs updating, or the page never " + "had them right. Check the built module, not a changelog:\n" + f" python -c \"import {MODULE} as s; print([n for n in dir(s) if not n.startswith('_')])\"" + ) + + +# `client..(...)` — the calls a reader actually makes. +# +# The service classes are not exported at module level, so the only way to see their +# methods is through a client. Building one is a local object construction — no +# request is made — so this stays a build-time check with no backend. +def _probe_client(): + try: + return sdk.DataHubClient(base_url="http://127.0.0.1:9", token="offline.probe.token") + except Exception: + return None + + +_PROBE = _probe_client() +SERVICES = { + name: type(getattr(_PROBE, name)) + for name in ("timeseries", "events", "resources", "files", "datasets", + "subscriptions", "units", "labels", "functions", "edges") + if _PROBE is not None and hasattr(_PROBE, name) +} +_SERVICE_CALL = re.compile( + r"\bclient\.(" + "|".join(SERVICES) + r")\.([a-z_][a-z0-9_]*)\s*\(", re.I +) if SERVICES else None + + +def _service_calls(source: str) -> set[tuple[str, str]]: + return set(_SERVICE_CALL.findall(source)) if _SERVICE_CALL else set() + + +CALL_CASES = [ + (rel, sorted(_service_calls(src))) + for rel, src in _doc_sources() + if _service_calls(src) +] + + +@pytest.mark.parametrize("rel,calls", CALL_CASES, ids=[r for r, _ in CALL_CASES]) +def test_page_only_calls_service_methods_the_sdk_has(rel, calls): + """Catches a renamed service method — `retrieve` vs `retrieve_datapoints`. + + The service classes are PyO3 types, so their methods are read off the class + rather than an instance; no client is built and no request is made. + """ + if not SERVICES: + pytest.skip("could not build a probe client to read the service surface from") + surface = { + service: {m for m in dir(SERVICES[service]) if not m.startswith("_")} + for service in {s for s, _ in calls} if service in SERVICES + } + missing = [f"client.{s}.{m}()" for s, m in calls if m not in surface.get(s, set())] + assert not missing, ( + f"{rel} calls method(s) this SDK build does not expose: {', '.join(missing)}.\n" + "A service method was renamed, or the page guessed. The names differ per " + "language — Java `retrieve`, Python and Rust `retrieve_datapoints` — so check " + "this language's surface rather than the other tab's." + ) From 19a02735e5f49e3878fce36ca2e9fbfb6d6fecfa Mon Sep 17 00:00:00 2001 From: samuel Date: Wed, 2 Sep 2026 15:28:22 +0200 Subject: [PATCH 3/3] refactor(doctests): remove duplicated logic and plan boilerplate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass over the suite. Three kinds of repetition, none of them harmless: - conftest's seeding fixture had its own copy of the walk from plan to runnable program — resolve the chain, read the blocks, compose. It had already drifted: only the tutorial path validated the plan against the page it was about to run, so a stale selection in a seeding page would have gone unnoticed. Both now go through scenario.build(), which validates once and is the only place that knows how a plan becomes a program. - The same two calls answered "did the page do what it promised" in two places. Now backend.unmet_expectations(). - The two eventual-consistency waits in tutorial_support were the same poll loop written twice. And 444 lines out of the plans. Sixty-five carried [java] disabled = "Java scenario not written yet." which means precisely what omitting the section already means — Plan.lang() falls back to a disabled scenario — while burying the `disabled` reasons that are specific. The [blocks] table still pins each language's fence count, so nothing is lost. Twenty-seven repeated a three-line explanation of what a Recorder is, which belongs in the skill and is there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LoqhJA3haV5BqiqHvhPBZf --- .claude/skills/doc-tutorial-tests/SKILL.md | 4 + doctests/backend.py | 11 +++ doctests/conftest.py | 57 ++++++------ .../plans/advanced__asset-health-score.toml | 6 -- .../plans/advanced__demand-forecasting.toml | 6 -- .../plans/advanced__fraud-classification.toml | 6 -- .../plans/advanced__kmeans-clustering.toml | 6 -- .../advanced__lstm-anomaly-detection.toml | 6 -- .../plans/advanced__lstm-forecasting.toml | 6 -- .../advanced__pca-process-monitoring.toml | 6 -- .../advanced__predictive-maintenance.toml | 6 -- .../advanced__random-forest-soft-sensor.toml | 6 -- .../advanced__xgboost-failure-prediction.toml | 6 -- doctests/plans/guides__attach-files.toml | 6 -- doctests/plans/guides__correlate-alarms.toml | 7 -- doctests/plans/guides__detect-events.toml | 6 -- .../plans/guides__model-assets-graph.toml | 6 -- .../plans/guides__query-and-aggregate.toml | 6 -- .../plans/guides__realtime-subscriptions.toml | 5 -- doctests/plans/guides__work-with-units.toml | 6 -- ...__agriculture-food__precision-farming.toml | 6 -- ...ies__agriculture-food__salmon-farming.toml | 10 +-- ...ries__built-environment__construction.toml | 6 -- ...s__built-environment__smart-buildings.toml | 10 +-- ...stries__energy-utilities__air-quality.toml | 6 -- ...stries__energy-utilities__ev-charging.toml | 10 +-- .../industries__energy-utilities__grid.toml | 10 +-- .../industries__energy-utilities__waste.toml | 10 +-- .../industries__energy-utilities__water.toml | 6 -- .../industries__energy-utilities__wind.toml | 10 +-- .../industries__financial-services__aml.toml | 10 +-- ...__financial-services__insurance-fraud.toml | 6 -- ...s__financial-services__portfolio-risk.toml | 6 -- .../industries__healthcare__cold-chain.toml | 10 +-- ...ries__healthcare__hospital-operations.toml | 10 +-- ...dustries__healthcare__medical-devices.toml | 6 -- .../industries__healthcare__patient-flow.toml | 10 +-- ...ries__manufacturing-process__discrete.toml | 10 +-- ...stries__manufacturing-process__pharma.toml | 6 -- ..._manufacturing-process__semiconductor.toml | 6 -- ...industries__mining-metals__operations.toml | 10 +-- ...ries__mining-metals__processing-plant.toml | 10 +-- ...tries__mining-metals__tailings-safety.toml | 10 +-- .../industries__oil-and-gas__drilling.toml | 10 +-- .../industries__oil-and-gas__emissions.toml | 6 -- .../industries__oil-and-gas__pipeline.toml | 10 +-- .../industries__oil-and-gas__production.toml | 10 +-- .../industries__oil-and-gas__refining.toml | 10 +-- .../industries__oil-and-gas__storage.toml | 10 +-- ...__technology-operations__data-centers.toml | 10 +-- ...tries__technology-operations__network.toml | 10 +-- ..._technology-operations__observability.toml | 10 +-- ...tries__transport-logistics__aerospace.toml | 6 -- ...stries__transport-logistics__airports.toml | 10 +-- ...tries__transport-logistics__last-mile.toml | 10 +-- ...stries__transport-logistics__maritime.toml | 10 +-- ...ndustries__transport-logistics__ports.toml | 10 +-- ...industries__transport-logistics__rail.toml | 6 -- ...dustries__transport-logistics__retail.toml | 6 -- ...es__transport-logistics__supply-chain.toml | 6 -- doctests/plans/reference__client.toml | 6 -- doctests/plans/reference__datasets.toml | 6 -- doctests/plans/reference__events.toml | 6 -- doctests/plans/reference__files.toml | 6 -- doctests/plans/reference__resources.toml | 6 -- doctests/plans/reference__subscriptions.toml | 10 +-- doctests/plans/reference__timeseries.toml | 6 -- doctests/plans/reference__units.toml | 6 -- doctests/scenario.py | 89 +++++++++++++++++++ doctests/test_tutorials.py | 38 ++------ doctests/tutorial_support.py | 65 ++++++++------ 71 files changed, 203 insertions(+), 559 deletions(-) create mode 100644 doctests/scenario.py diff --git a/.claude/skills/doc-tutorial-tests/SKILL.md b/.claude/skills/doc-tutorial-tests/SKILL.md index c9f1ecd..92549a1 100644 --- a/.claude/skills/doc-tutorial-tests/SKILL.md +++ b/.claude/skills/doc-tutorial-tests/SKILL.md @@ -98,6 +98,10 @@ judgement half is yours. Read the page as a reader would, and fill in: (sync *and* async) or a complete listing that repeats the steps above it. - **`[[replace]]`** — bound anything that would never terminate. Every one must still match the page, so a rewritten loop fails loudly instead of hanging CI. +- **Leaving a language uncovered** — just omit its section. A plan with no `[java]` + table already means "no Java scenario"; writing `disabled = "not written yet"` says + the same thing in three more lines and buries the `disabled` reasons that are + actually specific. `[blocks]` still pins the language's fence count either way. - **`requires_env` / `requires_python`** — prerequisites the environment may not have. Missing ones skip with a reason; an unconfigurable environment is not a broken doc. - **`[owns]`** — every external id the page creates, so the run is swept clean before diff --git a/doctests/backend.py b/doctests/backend.py index 2bee85d..8e31466 100644 --- a/doctests/backend.py +++ b/doctests/backend.py @@ -244,6 +244,17 @@ def _poll(check, timeout: float, interval: float = 0.5): return problems +def unmet_expectations(cli, plan) -> list[str]: + """Everything a plan promised that the backend cannot show — entities and data. + + Both the tutorial tests and the seeding fixture ask this same question, and a + seeding page that ran but whose data never became readable fails its dependants + for a reason that is not theirs. One function so the two cannot drift. + """ + return (missing_entities(cli, plan.expect_exists, plan.settle_secs) + + datapoint_shortfall(cli, plan.expect_datapoints, plan.settle_secs)) + + def missing_entities(cli, expect: dict[str, list[str]], timeout: float = 30.0) -> list[str]: """Which declared entities the tutorial failed to leave behind.""" lookups = { diff --git a/doctests/conftest.py b/doctests/conftest.py index 84efa3b..ddea303 100644 --- a/doctests/conftest.py +++ b/doctests/conftest.py @@ -13,9 +13,9 @@ import pytest import backend -import docblocks import plans as plans_mod import runners +import scenario from docblocks import EXECUTABLE REPO = Path(__file__).parent.parent @@ -88,45 +88,40 @@ def ensure(slug: str, lang: str) -> None: return all_plans = plans_mod.load_all() - links = plans_mod.chain(slug, lang, all_plans) - sections = [] - for link in links: - page = docblocks.load(REPO / link.page, REPO) - sections.append((link.lang(lang), page.of_lang(lang), link.page)) - source, line_map = runners.compose(lang, sections) - - owns = plans_mod.merged_owns(links) + run = scenario.build(slug, lang, all_plans, REPO) + source, line_map = run.programs()[0] + owns = run.owns() + backend.sweep(cli, owns) workdir = tmp_path_factory.mktemp(f"seed-{slug}") - result = runners.RUNNERS[lang](source, line_map, workdir, env, links[-1].lang(lang)) - - if result.ok: - # Exiting 0 only means the writes were accepted. The recipes that depend - # on this read the data immediately, and reads are eventually consistent — - # so hand over only once the fixture is actually visible. Skipping this - # makes the suite pass on a warm backend and fail on a cold one, which is - # the worst kind of flake: it looks like the recipes are broken. - fixture = all_plans[slug] - missing = backend.missing_entities(cli, fixture.expect_exists, fixture.settle_secs) - short = backend.datapoint_shortfall(cli, fixture.expect_datapoints, fixture.settle_secs) - if missing or short: - done[key] = ( - f"The data-seeding page {fixture.page} [{lang}] ran, but its data never " - f"became readable: {', '.join(missing + short)}.\n" - "Every tutorial that depends on it would fail for a reason that is not its own." - ) - pytest.fail(done[key]) - done[key] = None - planted.append(owns) - else: + result = runners.RUNNERS[lang](source, line_map, workdir, env, run.lang_plan) + + if not result.ok: done[key] = ( - f"The data-seeding page {all_plans[slug].page} [{lang}] failed, so every " + f"The data-seeding page {run.plan.page} [{lang}] failed, so every " f"tutorial that depends on it cannot be tested.\n" f"Fix that page first — run: ./doctests/run.sh -k '{slug}'\n\n" f"{runners.tidy(result.stderr, 2000)}" ) pytest.fail(done[key]) + # Exiting 0 only means the writes were accepted. The recipes that depend on + # this read the data immediately, and reads are eventually consistent — so + # hand over only once the fixture is actually visible. Skipping this makes the + # suite pass on a warm backend and fail on a cold one, which is the worst kind + # of flake: it looks like the recipes are broken. + unmet = backend.unmet_expectations(cli, run.plan) + if unmet: + done[key] = ( + f"The data-seeding page {run.plan.page} [{lang}] ran, but its data never " + f"became readable: {', '.join(unmet)}.\n" + "Every tutorial that depends on it would fail for a reason that is not its own." + ) + pytest.fail(done[key]) + + done[key] = None + planted.append(owns) + yield ensure # Cleanup for the seeding pages happens here, once, rather than after each of diff --git a/doctests/plans/advanced__asset-health-score.toml b/doctests/plans/advanced__asset-health-score.toml index b4ff913..e0374ea 100644 --- a/doctests/plans/advanced__asset-health-score.toml +++ b/doctests/plans/advanced__asset-health-score.toml @@ -20,12 +20,6 @@ timeout = 600 # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["health_critical_pump_07_*"] timeseries = ["pump_07_health_score"] diff --git a/doctests/plans/advanced__demand-forecasting.toml b/doctests/plans/advanced__demand-forecasting.toml index 55700c5..b310412 100644 --- a/doctests/plans/advanced__demand-forecasting.toml +++ b/doctests/plans/advanced__demand-forecasting.toml @@ -20,12 +20,6 @@ timeout = 600 # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] timeseries = ["feeder_f12_load_mw_forecast"] diff --git a/doctests/plans/advanced__fraud-classification.toml b/doctests/plans/advanced__fraud-classification.toml index 0c5626a..9613e7c 100644 --- a/doctests/plans/advanced__fraud-classification.toml +++ b/doctests/plans/advanced__fraud-classification.toml @@ -21,12 +21,6 @@ timeout = 600 # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["sar_candidate_*"] diff --git a/doctests/plans/advanced__kmeans-clustering.toml b/doctests/plans/advanced__kmeans-clustering.toml index f48bdf4..21e3177 100644 --- a/doctests/plans/advanced__kmeans-clustering.toml +++ b/doctests/plans/advanced__kmeans-clustering.toml @@ -21,12 +21,6 @@ timeout = 600 # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["peer_outlier_*"] diff --git a/doctests/plans/advanced__lstm-anomaly-detection.toml b/doctests/plans/advanced__lstm-anomaly-detection.toml index 582577a..cd3979e 100644 --- a/doctests/plans/advanced__lstm-anomaly-detection.toml +++ b/doctests/plans/advanced__lstm-anomaly-detection.toml @@ -24,12 +24,6 @@ requires_python = ["tensorflow"] # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["kick_detected_dw1_*"] timeseries = ["rig_dw1_anomaly_score"] diff --git a/doctests/plans/advanced__lstm-forecasting.toml b/doctests/plans/advanced__lstm-forecasting.toml index 9ad71f7..0cda06f 100644 --- a/doctests/plans/advanced__lstm-forecasting.toml +++ b/doctests/plans/advanced__lstm-forecasting.toml @@ -24,12 +24,6 @@ requires_python = ["tensorflow"] # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] timeseries = ["well_a12_oil_rate_forecast"] diff --git a/doctests/plans/advanced__pca-process-monitoring.toml b/doctests/plans/advanced__pca-process-monitoring.toml index d8058ba..fb07b54 100644 --- a/doctests/plans/advanced__pca-process-monitoring.toml +++ b/doctests/plans/advanced__pca-process-monitoring.toml @@ -19,12 +19,6 @@ timeout = 600 # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["process_deviation_cdu1_*"] diff --git a/doctests/plans/advanced__predictive-maintenance.toml b/doctests/plans/advanced__predictive-maintenance.toml index 5ed1b44..0b52a36 100644 --- a/doctests/plans/advanced__predictive-maintenance.toml +++ b/doctests/plans/advanced__predictive-maintenance.toml @@ -20,12 +20,6 @@ timeout = 600 # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["degradation_predicted_pump_07_*"] timeseries = ["pump_07_vibration_anomaly"] diff --git a/doctests/plans/advanced__random-forest-soft-sensor.toml b/doctests/plans/advanced__random-forest-soft-sensor.toml index c396cf7..8286bec 100644 --- a/doctests/plans/advanced__random-forest-soft-sensor.toml +++ b/doctests/plans/advanced__random-forest-soft-sensor.toml @@ -19,12 +19,6 @@ timeout = 600 # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] timeseries = ["cdu_1_product_sulfur_ppm_soft"] diff --git a/doctests/plans/advanced__xgboost-failure-prediction.toml b/doctests/plans/advanced__xgboost-failure-prediction.toml index b2ca4b2..33aed15 100644 --- a/doctests/plans/advanced__xgboost-failure-prediction.toml +++ b/doctests/plans/advanced__xgboost-failure-prediction.toml @@ -20,12 +20,6 @@ timeout = 600 # composed into all thirteen recipes. requires_once = ["advanced__generate-sample-data"] -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["failure_predicted_a12_*"] timeseries = ["pump_esp_a12_failure_risk"] diff --git a/doctests/plans/guides__attach-files.toml b/doctests/plans/guides__attach-files.toml index ab2d766..30a8774 100644 --- a/doctests/plans/guides__attach-files.toml +++ b/doctests/plans/guides__attach-files.toml @@ -19,12 +19,6 @@ import pathlib as _pathlib _pathlib.Path("calibration_a12.pdf").write_bytes(b"%PDF-1.4\\n% doc-test fixture\\n%%EOF\\n") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # Block 3 deletes the file itself, which is the page's own teardown; the sweep is # the backstop for a run that fails before reaching it. # KNOWN PLATFORM LIMITATION — this page cannot be made repeatable yet. diff --git a/doctests/plans/guides__correlate-alarms.toml b/doctests/plans/guides__correlate-alarms.toml index 5be7c87..925eb68 100644 --- a/doctests/plans/guides__correlate-alarms.toml +++ b/doctests/plans/guides__correlate-alarms.toml @@ -40,13 +40,6 @@ for _attempt in range(30): _time.sleep(0.5) """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - - [owns] timeseries = ["engine_temperature"] resources = ["sensor_a", "sensor_b", "cooling_system"] diff --git a/doctests/plans/guides__detect-events.toml b/doctests/plans/guides__detect-events.toml index b13bc31..272bac9 100644 --- a/doctests/plans/guides__detect-events.toml +++ b/doctests/plans/guides__detect-events.toml @@ -21,12 +21,6 @@ client.timeseries.insert_from_lists( timestamps=[_pd.Timestamp.now(tz="UTC")], values=[115.0], ts="engine_temperature") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The event id carries a timestamp, so it cannot be named ahead of the run; the # sweep resolves the pattern against the backend instead. [owns] diff --git a/doctests/plans/guides__model-assets-graph.toml b/doctests/plans/guides__model-assets-graph.toml index 1556e03..c4c46ec 100644 --- a/doctests/plans/guides__model-assets-graph.toml +++ b/doctests/plans/guides__model-assets-graph.toml @@ -11,12 +11,6 @@ rust = 2 requires = ["quickstart"] timeout = 180 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # Edges are deleted with their end nodes; the sweep already orders resources # before datasets and retries, so naming the three nodes is enough. diff --git a/doctests/plans/guides__query-and-aggregate.toml b/doctests/plans/guides__query-and-aggregate.toml index dc373cc..0386d6e 100644 --- a/doctests/plans/guides__query-and-aggregate.toml +++ b/doctests/plans/guides__query-and-aggregate.toml @@ -23,12 +23,6 @@ def handle(dp): _seen.append(dp) """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] timeseries = ["engine_temperature"] diff --git a/doctests/plans/guides__realtime-subscriptions.toml b/doctests/plans/guides__realtime-subscriptions.toml index 70c2b9c..8fe41e0 100644 --- a/doctests/plans/guides__realtime-subscriptions.toml +++ b/doctests/plans/guides__realtime-subscriptions.toml @@ -19,11 +19,6 @@ timeout = 120 # needs a second process writing datapoints while the listener runs — worth # building, but a different harness, and pretending otherwise would mean a green # test over code nobody ran. The [blocks] pin above still catches edits to them. -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." [owns] timeseries = ["engine_temperature"] diff --git a/doctests/plans/guides__work-with-units.toml b/doctests/plans/guides__work-with-units.toml index 42c56d4..f7626ae 100644 --- a/doctests/plans/guides__work-with-units.toml +++ b/doctests/plans/guides__work-with-units.toml @@ -12,12 +12,6 @@ rust = 3 requires = ["quickstart"] timeout = 120 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] timeseries = ["engine_temperature", "wellhead_pressure_bar"] diff --git a/doctests/plans/industries__agriculture-food__precision-farming.toml b/doctests/plans/industries__agriculture-food__precision-farming.toml index badeb2b..c0dd434 100644 --- a/doctests/plans/industries__agriculture-food__precision-farming.toml +++ b/doctests/plans/industries__agriculture-food__precision-farming.toml @@ -25,12 +25,6 @@ prologue = """ """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # Step 1 writes 'your timestamps'; the demo block above just built exactly that as `idx`. [[python.inject]] before = 2 diff --git a/doctests/plans/industries__agriculture-food__salmon-farming.toml b/doctests/plans/industries__agriculture-food__salmon-farming.toml index 18cf5ad..2d31c5c 100644 --- a/doctests/plans/industries__agriculture-food__salmon-farming.toml +++ b/doctests/plans/industries__agriculture-food__salmon-farming.toml @@ -20,9 +20,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. oxygen_below = Recorder("oxygen_below") """ @@ -40,12 +38,6 @@ repl = """with client.subscriptions.listen(["farm_hardanger_pens"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["low_oxygen_h07_*"] subscriptions = ["farm_hardanger_pens"] diff --git a/doctests/plans/industries__built-environment__construction.toml b/doctests/plans/industries__built-environment__construction.toml index a19aea6..cabd3db 100644 --- a/doctests/plans/industries__built-environment__construction.toml +++ b/doctests/plans/industries__built-environment__construction.toml @@ -17,12 +17,6 @@ rust = 2 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["safety_incident_l12_*"] files = ["drawing_level_12_structural_rev_c"] diff --git a/doctests/plans/industries__built-environment__smart-buildings.toml b/doctests/plans/industries__built-environment__smart-buildings.toml index fcc7a4d..2b10e1e 100644 --- a/doctests/plans/industries__built-environment__smart-buildings.toml +++ b/doctests/plans/industries__built-environment__smart-buildings.toml @@ -20,9 +20,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. chart_daily_kwh = Recorder("chart_daily_kwh") outside_comfort = Recorder("outside_comfort") """ @@ -41,12 +39,6 @@ repl = """with client.subscriptions.listen(["tower_a_comfort"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["comfort_alert_l8_*"] subscriptions = ["tower_a_comfort"] diff --git a/doctests/plans/industries__energy-utilities__air-quality.toml b/doctests/plans/industries__energy-utilities__air-quality.toml index c9b34b4..7fff452 100644 --- a/doctests/plans/industries__energy-utilities__air-quality.toml +++ b/doctests/plans/industries__energy-utilities__air-quality.toml @@ -18,12 +18,6 @@ rust = 2 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] timeseries = ["station_kirkeveien_co", "station_kirkeveien_no2", "station_kirkeveien_pm25"] diff --git a/doctests/plans/industries__energy-utilities__ev-charging.toml b/doctests/plans/industries__energy-utilities__ev-charging.toml index 0b902ef..511c3ab 100644 --- a/doctests/plans/industries__energy-utilities__ev-charging.toml +++ b/doctests/plans/industries__energy-utilities__ev-charging.toml @@ -18,9 +18,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. faulted = Recorder("faulted") """ @@ -38,12 +36,6 @@ repl = """with client.subscriptions.listen(["network_status"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["charger_down_oslo_14_*"] subscriptions = ["network_status"] diff --git a/doctests/plans/industries__energy-utilities__grid.toml b/doctests/plans/industries__energy-utilities__grid.toml index 388522b..f2f5ddf 100644 --- a/doctests/plans/industries__energy-utilities__grid.toml +++ b/doctests/plans/industries__energy-utilities__grid.toml @@ -24,18 +24,10 @@ timeout = 600 prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. chart = Recorder("chart") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["feeder_overload_f12_*"] resources = ["grid_region_east", "substation_oslo_1"] diff --git a/doctests/plans/industries__energy-utilities__waste.toml b/doctests/plans/industries__energy-utilities__waste.toml index 36159f1..d5647f8 100644 --- a/doctests/plans/industries__energy-utilities__waste.toml +++ b/doctests/plans/industries__energy-utilities__waste.toml @@ -41,17 +41,9 @@ def last_reading(external_id): from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The demo block writes one datapoint and step 1 reads it back immediately. Reads are # eventually consistent, so that read can return nothing through no fault of the page. # Waiting for the write to land puts the reader's world in place before the step runs. diff --git a/doctests/plans/industries__energy-utilities__water.toml b/doctests/plans/industries__energy-utilities__water.toml index ff33e85..571861d 100644 --- a/doctests/plans/industries__energy-utilities__water.toml +++ b/doctests/plans/industries__energy-utilities__water.toml @@ -28,12 +28,6 @@ prologue = """ """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page names `assets` in this step but only ever builds the list inside the # demo-data block above. This is that same list, so the step models the world the # page describes rather than an invented one. diff --git a/doctests/plans/industries__energy-utilities__wind.toml b/doctests/plans/industries__energy-utilities__wind.toml index b6991be..3b6f9a0 100644 --- a/doctests/plans/industries__energy-utilities__wind.toml +++ b/doctests/plans/industries__energy-utilities__wind.toml @@ -21,19 +21,11 @@ timeout = 600 prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. record_capacity_factor = Recorder("record_capacity_factor") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # Step 1 ingests 'your' arrays — the ones the demo block above sampled. [[python.inject]] before = 2 diff --git a/doctests/plans/industries__financial-services__aml.toml b/doctests/plans/industries__financial-services__aml.toml index cda6f05..9a24de2 100644 --- a/doctests/plans/industries__financial-services__aml.toml +++ b/doctests/plans/industries__financial-services__aml.toml @@ -20,9 +20,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. account_from = Recorder("account_from") investigate = Recorder("investigate") """ @@ -41,12 +39,6 @@ repl = """with client.subscriptions.listen(["flagged_payments"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__financial-services__insurance-fraud.toml b/doctests/plans/industries__financial-services__insurance-fraud.toml index f163725..a458bbe 100644 --- a/doctests/plans/industries__financial-services__insurance-fraud.toml +++ b/doctests/plans/industries__financial-services__insurance-fraud.toml @@ -28,12 +28,6 @@ prologue = """ """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page names `nodes` in this step but only ever builds the list inside the # demo-data block above. This is that same list, so the step models the world the # page describes rather than an invented one. diff --git a/doctests/plans/industries__financial-services__portfolio-risk.toml b/doctests/plans/industries__financial-services__portfolio-risk.toml index 2533d89..a5084a8 100644 --- a/doctests/plans/industries__financial-services__portfolio-risk.toml +++ b/doctests/plans/industries__financial-services__portfolio-risk.toml @@ -21,12 +21,6 @@ rust = 3 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] datasets = ["desk_equities"] events = ["var_limit_breach_growth_*"] diff --git a/doctests/plans/industries__healthcare__cold-chain.toml b/doctests/plans/industries__healthcare__cold-chain.toml index 3ad997e..11c7f20 100644 --- a/doctests/plans/industries__healthcare__cold-chain.toml +++ b/doctests/plans/industries__healthcare__cold-chain.toml @@ -17,9 +17,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. out_of_band = Recorder("out_of_band") """ @@ -37,12 +35,6 @@ repl = """with client.subscriptions.listen(["pharmacy_fridges"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["cold_chain_excursion_f12_*"] subscriptions = ["pharmacy_fridges"] diff --git a/doctests/plans/industries__healthcare__hospital-operations.toml b/doctests/plans/industries__healthcare__hospital-operations.toml index af0e962..b60adf9 100644 --- a/doctests/plans/industries__healthcare__hospital-operations.toml +++ b/doctests/plans/industries__healthcare__hospital-operations.toml @@ -20,9 +20,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. over_threshold = Recorder("over_threshold") update_wallboard = Recorder("update_wallboard") """ @@ -41,12 +39,6 @@ repl = """with client.subscriptions.listen(["bed_capacity"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["capacity_warning_icu_*"] resources = ["hospital_central", "ward_icu"] diff --git a/doctests/plans/industries__healthcare__medical-devices.toml b/doctests/plans/industries__healthcare__medical-devices.toml index ad7dceb..02dea02 100644 --- a/doctests/plans/industries__healthcare__medical-devices.toml +++ b/doctests/plans/industries__healthcare__medical-devices.toml @@ -17,12 +17,6 @@ rust = 2 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__healthcare__patient-flow.toml b/doctests/plans/industries__healthcare__patient-flow.toml index 128b2d7..2f12b21 100644 --- a/doctests/plans/industries__healthcare__patient-flow.toml +++ b/doctests/plans/industries__healthcare__patient-flow.toml @@ -21,18 +21,10 @@ timeout = 600 prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. track_stage = Recorder("track_stage") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["flow_breach_ed_*"] timeseries = ["ed_time_to_bed_minutes"] diff --git a/doctests/plans/industries__manufacturing-process__discrete.toml b/doctests/plans/industries__manufacturing-process__discrete.toml index 4ff25b1..e2d6ef0 100644 --- a/doctests/plans/industries__manufacturing-process__discrete.toml +++ b/doctests/plans/industries__manufacturing-process__discrete.toml @@ -30,19 +30,11 @@ requires = ["quickstart"] prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. record_hourly_performance = Recorder("record_hourly_performance") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # Step 1 ingests the reader's own cycle times over the demo block's index. [[python.inject]] before = 2 diff --git a/doctests/plans/industries__manufacturing-process__pharma.toml b/doctests/plans/industries__manufacturing-process__pharma.toml index 9e30844..baf87c0 100644 --- a/doctests/plans/industries__manufacturing-process__pharma.toml +++ b/doctests/plans/industries__manufacturing-process__pharma.toml @@ -17,12 +17,6 @@ rust = 2 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__manufacturing-process__semiconductor.toml b/doctests/plans/industries__manufacturing-process__semiconductor.toml index 3ecced9..d71172d 100644 --- a/doctests/plans/industries__manufacturing-process__semiconductor.toml +++ b/doctests/plans/industries__manufacturing-process__semiconductor.toml @@ -17,12 +17,6 @@ rust = 2 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__mining-metals__operations.toml b/doctests/plans/industries__mining-metals__operations.toml index df126d6..35a94f9 100644 --- a/doctests/plans/industries__mining-metals__operations.toml +++ b/doctests/plans/industries__mining-metals__operations.toml @@ -40,18 +40,10 @@ def last_hour(external_id): from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. compare_to_plan = Recorder("compare_to_plan") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["equipment_warning_785_*"] timeseries = ["crusher_01_throughput_tph", "truck_785_oil_pressure_kpa"] diff --git a/doctests/plans/industries__mining-metals__processing-plant.toml b/doctests/plans/industries__mining-metals__processing-plant.toml index 78fcb92..54ebbec 100644 --- a/doctests/plans/industries__mining-metals__processing-plant.toml +++ b/doctests/plans/industries__mining-metals__processing-plant.toml @@ -21,18 +21,10 @@ timeout = 600 prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. compare_to_target = Recorder("compare_to_target") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["recovery_loss_mill1_*"] timeseries = ["mill_1_recovery_pct"] diff --git a/doctests/plans/industries__mining-metals__tailings-safety.toml b/doctests/plans/industries__mining-metals__tailings-safety.toml index 789c1da..1d91d2f 100644 --- a/doctests/plans/industries__mining-metals__tailings-safety.toml +++ b/doctests/plans/industries__mining-metals__tailings-safety.toml @@ -17,9 +17,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. # The page documents this one inline: None | "amber" | "red". "red" is what makes # the escalation branch — the point of the page — actually run. trigger_level = Recorder("trigger_level", returns="red") @@ -39,12 +37,6 @@ repl = """with client.subscriptions.listen(["tsf_instruments"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["tailings_alarm_p14_*"] subscriptions = ["tsf_instruments"] diff --git a/doctests/plans/industries__oil-and-gas__drilling.toml b/doctests/plans/industries__oil-and-gas__drilling.toml index 0919c54..dcb6c61 100644 --- a/doctests/plans/industries__oil-and-gas__drilling.toml +++ b/doctests/plans/industries__oil-and-gas__drilling.toml @@ -18,9 +18,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. influx_detected = Recorder("influx_detected") """ @@ -39,12 +37,6 @@ repl = """with client.subscriptions.listen(["rig_deepwater_1"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["kick_detected_a12_*"] subscriptions = ["rig_deepwater_1"] diff --git a/doctests/plans/industries__oil-and-gas__emissions.toml b/doctests/plans/industries__oil-and-gas__emissions.toml index 23ca863..1585725 100644 --- a/doctests/plans/industries__oil-and-gas__emissions.toml +++ b/doctests/plans/industries__oil-and-gas__emissions.toml @@ -18,12 +18,6 @@ rust = 2 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["flaring_exceedance_north_*"] timeseries = ["platform_north_flare_volume_m3"] diff --git a/doctests/plans/industries__oil-and-gas__pipeline.toml b/doctests/plans/industries__oil-and-gas__pipeline.toml index cd79d30..5daa8e3 100644 --- a/doctests/plans/industries__oil-and-gas__pipeline.toml +++ b/doctests/plans/industries__oil-and-gas__pipeline.toml @@ -20,19 +20,11 @@ timeout = 600 prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. latest = Recorder("latest") tolerance = Recorder("tolerance") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__oil-and-gas__production.toml b/doctests/plans/industries__oil-and-gas__production.toml index 830019a..27debbe 100644 --- a/doctests/plans/industries__oil-and-gas__production.toml +++ b/doctests/plans/industries__oil-and-gas__production.toml @@ -47,18 +47,10 @@ def last_hour(external_id): from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # Step 1 ingests the reader's sampled channels over the demo block's index. [[python.inject]] before = 2 diff --git a/doctests/plans/industries__oil-and-gas__refining.toml b/doctests/plans/industries__oil-and-gas__refining.toml index 4d08f31..afc0b1a 100644 --- a/doctests/plans/industries__oil-and-gas__refining.toml +++ b/doctests/plans/industries__oil-and-gas__refining.toml @@ -21,18 +21,10 @@ timeout = 600 prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. record_energy_intensity = Recorder("record_energy_intensity") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["process_upset_cdu1_*"] timeseries = ["cdu_1_energy_gj"] diff --git a/doctests/plans/industries__oil-and-gas__storage.toml b/doctests/plans/industries__oil-and-gas__storage.toml index 0104367..724b69f 100644 --- a/doctests/plans/industries__oil-and-gas__storage.toml +++ b/doctests/plans/industries__oil-and-gas__storage.toml @@ -25,9 +25,7 @@ import pandas as _pd shift_start = _pd.Timestamp.now(tz="UTC") - _pd.Timedelta(hours=12) from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. first_value = Recorder("first_value", returns=50000.0) latest = Recorder("latest", returns=47000.0) sum_over = Recorder("sum_over", returns=2500.0) @@ -35,12 +33,6 @@ tolerance = Recorder("tolerance", returns=100.0) """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["inventory_discrepancy_t12_*"] timeseries = ["tank_t_12_deliveries_bbl", "tank_t_12_receipts_bbl", "tank_t_12_volume_bbl"] diff --git a/doctests/plans/industries__technology-operations__data-centers.toml b/doctests/plans/industries__technology-operations__data-centers.toml index 43fc4be..2329c6c 100644 --- a/doctests/plans/industries__technology-operations__data-centers.toml +++ b/doctests/plans/industries__technology-operations__data-centers.toml @@ -20,9 +20,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. inlet_above = Recorder("inlet_above") """ @@ -40,12 +38,6 @@ repl = """with client.subscriptions.listen(["hall_2_thermal"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__technology-operations__network.toml b/doctests/plans/industries__technology-operations__network.toml index 181c62b..fada2ee 100644 --- a/doctests/plans/industries__technology-operations__network.toml +++ b/doctests/plans/industries__technology-operations__network.toml @@ -20,18 +20,10 @@ timeout = 600 prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. flag_if_saturated = Recorder("flag_if_saturated") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__technology-operations__observability.toml b/doctests/plans/industries__technology-operations__observability.toml index 998adb6..53bfd80 100644 --- a/doctests/plans/industries__technology-operations__observability.toml +++ b/doctests/plans/industries__technology-operations__observability.toml @@ -26,9 +26,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. breaches_slo = Recorder("breaches_slo") """ @@ -46,12 +44,6 @@ repl = """with client.subscriptions.listen(["checkout_slo"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__transport-logistics__aerospace.toml b/doctests/plans/industries__transport-logistics__aerospace.toml index 4f1426e..2cb358f 100644 --- a/doctests/plans/industries__transport-logistics__aerospace.toml +++ b/doctests/plans/industries__transport-logistics__aerospace.toml @@ -17,12 +17,6 @@ rust = 2 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/industries__transport-logistics__airports.toml b/doctests/plans/industries__transport-logistics__airports.toml index 5082334..235ae14 100644 --- a/doctests/plans/industries__transport-logistics__airports.toml +++ b/doctests/plans/industries__transport-logistics__airports.toml @@ -17,9 +17,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. leg_overrunning = Recorder("leg_overrunning") """ @@ -37,12 +35,6 @@ repl = """with client.subscriptions.listen(["stand_b12_turnaround"]) as listener find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["turnaround_risk_su204_*"] subscriptions = ["stand_b12_turnaround"] diff --git a/doctests/plans/industries__transport-logistics__last-mile.toml b/doctests/plans/industries__transport-logistics__last-mile.toml index 8e14fb2..85c813c 100644 --- a/doctests/plans/industries__transport-logistics__last-mile.toml +++ b/doctests/plans/industries__transport-logistics__last-mile.toml @@ -17,9 +17,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. eta_past_window = Recorder("eta_past_window") """ @@ -37,12 +35,6 @@ repl = """with client.subscriptions.listen(["fleet_eta"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["delivery_at_risk_v22_*"] subscriptions = ["fleet_eta"] diff --git a/doctests/plans/industries__transport-logistics__maritime.toml b/doctests/plans/industries__transport-logistics__maritime.toml index 63c5f40..c434666 100644 --- a/doctests/plans/industries__transport-logistics__maritime.toml +++ b/doctests/plans/industries__transport-logistics__maritime.toml @@ -20,9 +20,7 @@ timeout = 600 prologue = """ from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. out_of_band = Recorder("out_of_band") """ @@ -40,12 +38,6 @@ repl = """with client.subscriptions.listen(["reefer_fleet"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["reefer_excursion_1182_*"] subscriptions = ["reefer_fleet"] diff --git a/doctests/plans/industries__transport-logistics__ports.toml b/doctests/plans/industries__transport-logistics__ports.toml index e797ee4..09550ec 100644 --- a/doctests/plans/industries__transport-logistics__ports.toml +++ b/doctests/plans/industries__transport-logistics__ports.toml @@ -21,18 +21,10 @@ timeout = 600 prologue = """ from tutorial_support import Recorder -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. compare_to_target = Recorder("compare_to_target") """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["yard_congestion_b07_*"] timeseries = ["crane_qc_07_moves"] diff --git a/doctests/plans/industries__transport-logistics__rail.toml b/doctests/plans/industries__transport-logistics__rail.toml index 13ed27d..d175acd 100644 --- a/doctests/plans/industries__transport-logistics__rail.toml +++ b/doctests/plans/industries__transport-logistics__rail.toml @@ -28,12 +28,6 @@ prologue = """ """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page names `stations` in this step but only ever builds the list inside the # demo-data block above. This is that same list, so the step models the world the # page describes rather than an invented one. diff --git a/doctests/plans/industries__transport-logistics__retail.toml b/doctests/plans/industries__transport-logistics__retail.toml index 9ed6988..7fd0ec5 100644 --- a/doctests/plans/industries__transport-logistics__retail.toml +++ b/doctests/plans/industries__transport-logistics__retail.toml @@ -18,12 +18,6 @@ rust = 2 [python] timeout = 600 -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] datasets = ["region_nordics"] timeseries = ["store_oslo_01_sku_4471_sales"] diff --git a/doctests/plans/industries__transport-logistics__supply-chain.toml b/doctests/plans/industries__transport-logistics__supply-chain.toml index b0c7b46..258400d 100644 --- a/doctests/plans/industries__transport-logistics__supply-chain.toml +++ b/doctests/plans/industries__transport-logistics__supply-chain.toml @@ -29,12 +29,6 @@ prologue = """ """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page names `nodes` in this step but only ever builds the list inside the # demo-data block above. This is that same list, so the step models the world the # page describes rather than an invented one. diff --git a/doctests/plans/reference__client.toml b/doctests/plans/reference__client.toml index b2c488f..e51516e 100644 --- a/doctests/plans/reference__client.toml +++ b/doctests/plans/reference__client.toml @@ -29,9 +29,3 @@ either dial production or need identity-provider secrets that prove nothing abou constructors it documents are exercised where they are actually used — the buffering client in tutorial.toml, from_env() across every other plan.""" - -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." diff --git a/doctests/plans/reference__datasets.toml b/doctests/plans/reference__datasets.toml index 3f165d3..f8ecde0 100644 --- a/doctests/plans/reference__datasets.toml +++ b/doctests/plans/reference__datasets.toml @@ -30,12 +30,6 @@ import intellistream_datahub_sdk client = intellistream_datahub_sdk.DataHubClient.from_env() """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] datasets = ["plant_a"] diff --git a/doctests/plans/reference__events.toml b/doctests/plans/reference__events.toml index 1422c0a..d47538f 100644 --- a/doctests/plans/reference__events.toml +++ b/doctests/plans/reference__events.toml @@ -36,12 +36,6 @@ import intellistream_datahub_sdk client = intellistream_datahub_sdk.DataHubClient.from_env() """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] events = ["door_open"] diff --git a/doctests/plans/reference__files.toml b/doctests/plans/reference__files.toml index 5d5b28f..26c6b90 100644 --- a/doctests/plans/reference__files.toml +++ b/doctests/plans/reference__files.toml @@ -38,12 +38,6 @@ import pathlib as _pathlib _pathlib.Path("report.csv").write_text("timestamp,value" + chr(10) + "2026-01-01T00:00:00Z,1" + chr(10)) """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # KNOWN PLATFORM LIMITATION — this page cannot be made repeatable yet. # Deleting a file moves it to trash but keeps its *path* reserved, and the SDK # exposes no way to purge trash (`list_trash` and `restore` exist; nothing removes). diff --git a/doctests/plans/reference__resources.toml b/doctests/plans/reference__resources.toml index 0ec3936..6fc8f9d 100644 --- a/doctests/plans/reference__resources.toml +++ b/doctests/plans/reference__resources.toml @@ -39,12 +39,6 @@ import intellistream_datahub_sdk client = intellistream_datahub_sdk.DataHubClient.from_env() """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The page models the network and traverses it in the next breath. The graph read # path trails writes by a second or two, and an empty neighbourhood is a valid # answer rather than an error — so without this the page prints the wrong diff --git a/doctests/plans/reference__subscriptions.toml b/doctests/plans/reference__subscriptions.toml index 25d9341..8019a09 100644 --- a/doctests/plans/reference__subscriptions.toml +++ b/doctests/plans/reference__subscriptions.toml @@ -35,9 +35,7 @@ client.timeseries.create([intellistream_datahub_sdk.TimeSeries( from tutorial_support import Recorder, feed, take -# Placeholders the page leaves to the reader — 'your dashboard', 'your -# alerting'. Recorders keep what they are handed so the run can prove the -# tutorial's logic actually reached them. +# Placeholders the page leaves to the reader; a Recorder keeps what it is handed. process = Recorder("process") """ @@ -55,12 +53,6 @@ repl = """with client.subscriptions.listen(["engine_temps"]) as listener: find = """ for msg in listener:""" repl = """ for msg in take(listener, 3, 45):""" -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The listen example assumes the subscription from the create example above still # exists — but that example ends by deleting it, and each block starts clean anyway. # This puts the subscription in place for the one block that needs it, without diff --git a/doctests/plans/reference__timeseries.toml b/doctests/plans/reference__timeseries.toml index 7f0ed29..00fda14 100644 --- a/doctests/plans/reference__timeseries.toml +++ b/doctests/plans/reference__timeseries.toml @@ -47,12 +47,6 @@ import intellistream_datahub_sdk client = intellistream_datahub_sdk.DataHubClient.from_env() """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - [owns] timeseries = ["book_value_usd", "engine_temperature"] diff --git a/doctests/plans/reference__units.toml b/doctests/plans/reference__units.toml index 26447f7..c07e8e2 100644 --- a/doctests/plans/reference__units.toml +++ b/doctests/plans/reference__units.toml @@ -30,12 +30,6 @@ import intellistream_datahub_sdk client = intellistream_datahub_sdk.DataHubClient.from_env() """ -[java] -disabled = "Java scenario not written yet." - -[rust] -disabled = "Rust scenario not written yet." - # The catalogue listing must actually name a unit the platform ships. [expect] stdout = ["temperature_deg_c"] diff --git a/doctests/scenario.py b/doctests/scenario.py new file mode 100644 index 0000000..f6ef3d9 --- /dev/null +++ b/doctests/scenario.py @@ -0,0 +1,89 @@ +"""Turn a plan into something runnable. + +Both callers need the same walk — resolve the chain of pages, read each one's +blocks, validate the plan still lines up with them, and compose the result into +one program or several. `test_tutorials` needs it to run a tutorial; +`conftest.seed` needs it to run a data-seeding page before the recipes that +depend on one. Having each do its own version meant two places to keep in step, +and they had already drifted: only one of them validated the plan against the +page it was about to run. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import docblocks +import entities +import plans as plans_mod +import runners + + +class MissingBlocks(Exception): + """A plan declares a scenario for a language the page has no blocks in.""" + + +@dataclass +class Scenario: + """One plan, resolved against the pages it needs, ready to run.""" + + plan: plans_mod.Plan + lang: str + links: list[plans_mod.Plan] # prerequisites first, target last + sections: list[tuple[plans_mod.LangPlan, list, str]] + + @property + def lang_plan(self) -> plans_mod.LangPlan: + return self.plan.lang(self.lang) + + def programs(self) -> list[tuple[str, list[tuple[int, str]]]]: + """The program(s) to run: one for a tutorial, one per block for a reference. + + An API-reference page's blocks are independent examples; a tutorial's are a + sequence. That single distinction is the only difference between the two. + """ + lp = self.lang_plan + if not lp.independent: + return [runners.compose(self.lang, self.sections)] + head, (_, target_blocks, page) = self.sections[:-1], self.sections[-1] + return [ + runners.compose(self.lang, head + [(lp, [block], page)]) + for block in lp.select(target_blocks) + ] + + def owns(self) -> dict[str, list[str]]: + """Everything the whole chain creates, for the sweep.""" + return plans_mod.merged_owns(self.links) + + def creates(self) -> set[str]: + """Ids this scenario's own code builds, as opposed to reads.""" + out: set[str] = set() + for lp, blocks, _ in self.sections: + source = "\n".join(b.body for b in lp.select(blocks)) + for ids in entities.owned(source, include_edge_refs=False).values(): + out.update(ids) + return out + + +def build(slug: str, lang: str, all_plans: dict[str, plans_mod.Plan], repo: Path) -> Scenario: + """Resolve a plan into a Scenario, checking it still matches its pages. + + The validation happens here rather than at composition time because both are + properties of the plan as a whole: with `independent`, composition sees one + block at a time and cannot tell a stale selection from a narrow one. + """ + links = plans_mod.chain(slug, lang, all_plans) + sections = [] + for link in links: + page = docblocks.load(repo / link.page, repo) + blocks = page.of_lang(lang) + if not blocks: + raise MissingBlocks( + f"{link.page} has no {lang} blocks, but its plan declares a {lang} scenario." + ) + link_lp = link.lang(lang) + link_lp.validate_injects(blocks, link.page) + link_lp.validate_replacements(blocks, link.page) + sections.append((link_lp, blocks, link.page)) + return Scenario(plan=all_plans[slug], lang=lang, links=links, sections=sections) diff --git a/doctests/test_tutorials.py b/doctests/test_tutorials.py index 882a007..df28048 100644 --- a/doctests/test_tutorials.py +++ b/doctests/test_tutorials.py @@ -19,9 +19,9 @@ import backend import docblocks -import entities import plans as plans_mod import runners +import scenario from runners import ToolchainMissing REPO = Path(__file__).parent.parent @@ -114,20 +114,14 @@ def test_tutorial_runs_end_to_end(slug, lang, langs, cli, env, seed, pytestconfi # A guide that opens "you already have a client" is only meaningful when the page # it continues from actually ran, so the whole chain is composed into one program: # quickstart first, this page last. - links = plans_mod.chain(slug, lang, ALL_PLANS) - sections = [] - for link in links: - link_page = _page(link) - link_blocks = link_page.of_lang(lang) - if not link_blocks: - pytest.fail(f"{link.page} has no {lang} blocks, but its plan declares a {lang} scenario.") - link.lang(lang).validate_injects(link_blocks, link.page) - link.lang(lang).validate_replacements(link_blocks, link.page) - sections.append((link.lang(lang), link_blocks, link.page)) + try: + run = scenario.build(slug, lang, ALL_PLANS, REPO) + except scenario.MissingBlocks as exc: + pytest.fail(str(exc)) # A tutorial whose prerequisites this environment cannot supply is skipped with # the reason, not failed: the page may be perfectly correct. - for link in links: + for link in run.links: llp = link.lang(lang) absent = [v for v in llp.requires_env if not env.get(v)] if absent: @@ -136,16 +130,7 @@ def test_tutorial_runs_end_to_end(slug, lang, langs, cli, env, seed, pytestconfi if lang == "python" and importlib.util.find_spec(module) is None: pytest.skip(f"{link.page} needs the `{module}` package: pip install {module} into doctests/.venv") - # An API-reference page's blocks are independent examples; a tutorial's are a - # sequence. `independent` picks which, and the runs below differ only in whether - # the page's blocks arrive as one program or several. - if lp.independent: - programs = [ - runners.compose(lang, sections[:-1] + [(lp, [block], plan.page)]) - for block in lp.select(sections[-1][1]) - ] - else: - programs = [runners.compose(lang, sections)] + programs = run.programs() # A page's sweep must never reclaim what a session fixture planted: these recipes # read series that `generate-sample-data` seeded, and they legitimately name those @@ -160,12 +145,7 @@ def test_tutorial_runs_end_to_end(slug, lang, langs, cli, env, seed, pytestconfi # id — `generate-sample-data` seeds a placeholder for the anomaly score that # predictive-maintenance computes for real — and in that case the page must still # be allowed to clear it, or its own create fails as a duplicate. - own_creations: set[str] = set() - for link_lp, link_blocks, _ in sections: - selected = link_lp.select(link_blocks) - for ids in entities.owned("\n".join(b.body for b in selected), - include_edge_refs=False).values(): - own_creations.update(ids) + own_creations = run.creates() def _is_seeded(external_id: str) -> bool: # A fixture declares whole families by pattern (`pump_07_*`), while a recipe @@ -178,7 +158,7 @@ def _is_seeded(external_id: str) -> bool: owns = { kind: [i for i in ids if not _is_seeded(i)] - for kind, ids in plans_mod.merged_owns(links).items() + for kind, ids in run.owns().items() } # Start from a known-empty backend so the page's fixed external ids create diff --git a/doctests/tutorial_support.py b/doctests/tutorial_support.py index afc59d2..597435c 100644 --- a/doctests/tutorial_support.py +++ b/doctests/tutorial_support.py @@ -120,6 +120,25 @@ def run() -> None: return done.set +def _wait_until(count, minimum: int, timeout: float) -> int: + """Poll `count()` until it reaches `minimum`, or the window closes. + + Both waits below are the same shape — reads trail writes, so ask again — and an + exception mid-poll means "not yet", not "broken": the caller's own assertions are + what report a genuine failure. Returns the last count, so a caller can tell + "arrived" from "gave up". + """ + deadline = time.monotonic() + timeout + while True: + try: + seen = count() + except Exception: + seen = 0 + if seen >= minimum or time.monotonic() > deadline: + return seen + time.sleep(0.5) + + def wait_for_datapoints(client, external_id: str, minimum: int = 1, timeout: float = 30.0) -> int: """Block until a series has at least `minimum` readable datapoints. @@ -135,24 +154,19 @@ def wait_for_datapoints(client, external_id: str, minimum: int = 1, timeout: flo import intellistream_datahub_sdk as sdk - deadline = time.monotonic() + timeout - while True: + def visible() -> int: now = dt.datetime.now(dt.timezone.utc) - try: - got = client.timeseries.retrieve_datapoints( - sdk.RetrieveFilter( - ts=external_id, - start=now - dt.timedelta(days=730), - end=now + dt.timedelta(days=1), - limit=minimum, - ) + got = client.timeseries.retrieve_datapoints( + sdk.RetrieveFilter( + ts=external_id, + start=now - dt.timedelta(days=730), + end=now + dt.timedelta(days=1), + limit=minimum, ) - count = sum(len(c.get_datapoints()) for c in got) - except Exception: - count = 0 - if count >= minimum or time.monotonic() > deadline: - return count - time.sleep(0.5) + ) + return sum(len(c.get_datapoints()) for c in got) + + return _wait_until(visible, minimum, timeout) def wait_for_related(client, external_id: str, *, minimum: int = 1, timeout: float = 30.0, @@ -169,15 +183,10 @@ def wait_for_related(client, external_id: str, *, minimum: int = 1, timeout: flo Returns how many nodes were visible, so a caller can distinguish "arrived" from "gave up". """ - deadline = time.monotonic() + timeout - while True: - try: - kwargs = {"external_id": external_id, "depth": 10} - if relationship_types: - kwargs["relationship_types"] = relationship_types - found = len(client.resources.fetch_related(**kwargs).nodes) - except Exception: - found = 0 - if found >= minimum or time.monotonic() > deadline: - return found - time.sleep(0.5) + def visible() -> int: + kwargs = {"external_id": external_id, "depth": 10} + if relationship_types: + kwargs["relationship_types"] = relationship_types + return len(client.resources.fetch_related(**kwargs).nodes) + + return _wait_until(visible, minimum, timeout)