diff --git a/.gitignore b/.gitignore index 717a105..a198ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ dist/ python_modules/ .venv-workers/ + +# Social-card HTML intermediates (rasterized output lives in public/og/) +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 767081b..81f88ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0 ## Unreleased +### Added + +- Banner position grammar from `docs/visual-explainer-spec.md` is now production: `render_banner(slug, position)` supports `before`, `after-cell-N` (legacy anchor `cell-N`), and `after-walkthrough`, with multiple figures per position rendering as one small-multiple banner. The mutability page ships the canonical two-figure pair (aliased mutation vs. frozen tuple). +- Curated pair banners on contrast cells: `positional-only-parameters` shows the `/` and `*` separator twins side by side, `metaclasses` pairs the metaclass triangle with the familiar class triangle, and `tuples` pairs the frozen tuple with the growing list on the intent-contrast cell. `iterator-vs-iterable` gains the one-pass caret figure on the exhaustion cell. + +- `/sitemap.xml` route listing home, journeys, and all example pages; `public/robots.txt` with a Sitemap directive. +- JSON-LD structured data: `WebSite` on the home page, `TechArticle`/`LearningResource` on every example page, enforced by the SEO linter. +- Client-side example search on the home page: a build-step JSON index (`make build-search-index`), fingerprinted `search.js`/`search-index.json` assets, `/` keyboard shortcut, and a Node ranking check (`make search-ranking-test`). +- Dark mode via `prefers-color-scheme`: inverted warm palette, dual-theme Shiki highlighting, a dark CodeMirror highlight style, and marginalia figures rendered on a light paper chip so the locked grammar stays untouched. +- Skip-to-content link on every page. +- Per-example social-card images composed from each example's marginalia figure (`make social-cards`), referenced by `og:image`/`twitter:card` on home and example pages and checked by the SEO linter. +- Learner-behavior report (`scripts/learner_report.py` + `docs/learner-analytics.md`) aggregating exported Worker wide events into most-read pages, most-run examples with edited/error shares and execution percentiles, journey traffic, and missing-example 404s. + +### Changed + +- Journeys now reference every example: `iterator-vs-iterable`, `classmethods-and-staticmethods`, `bound-and-unbound-methods`, `abstract-base-classes`, and `structured-data-shapes` joined their natural sections, with journey-outcome support lists updated. +- Journey meta descriptions no longer claim gap placeholders; all previously declared gaps are filled. + ## 2026-05-16 ### Added diff --git a/Makefile b/Makefile index afaa442..ca4d278 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test embed-examples build check-generated fingerprint browser-layout-test seo-cache-lint verify-examples check-registry-integrity check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported score-example-criteria check-quality-scores check-no-figure-rationales check-journey-outcomes quality-checks rubric-audit format-examples verify-python-version verify smoke-deployment dev deploy lint +.PHONY: test embed-examples build-search-index build check-generated fingerprint browser-layout-test search-ranking-test social-cards seo-cache-lint verify-examples check-registry-integrity check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported score-example-criteria check-quality-scores check-no-figure-rationales check-journey-outcomes quality-checks rubric-audit format-examples verify-python-version verify smoke-deployment dev deploy lint test: uv run --python 3.13 python -m unittest discover -s tests -v @@ -6,17 +6,27 @@ test: embed-examples: scripts/embed_example_sources.py -build: embed-examples fingerprint +build-search-index: embed-examples + uv run --python 3.13 scripts/build_search_index.py + +build: embed-examples build-search-index fingerprint check-generated: build - git diff --exit-code src/example_sources_data.py src/asset_manifest.py public/_headers + git diff --exit-code src/example_sources_data.py src/asset_manifest.py public/_headers public/search-index.json -fingerprint: embed-examples +fingerprint: embed-examples build-search-index scripts/fingerprint_assets.py browser-layout-test: scripts/check_browser_layout.mjs +search-ranking-test: + scripts/check_search_ranking.mjs + +social-cards: + uv run --python 3.13 scripts/build_social_cards.py + scripts/build_social_cards.mjs + seo-cache-lint: scripts/lint_seo_cache.py @@ -64,7 +74,7 @@ verify-python-version: build lint: uv run ruff check src tests scripts -verify: build test seo-cache-lint verify-examples quality-checks browser-layout-test lint check-generated +verify: build test seo-cache-lint verify-examples quality-checks browser-layout-test search-ranking-test lint check-generated dev: uv run pywrangler dev --port 9696 diff --git a/README.md b/README.md index ce86e72..bff4abd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,11 @@ Production: (`workers.dev` remains enabled as - Workers Assets for static files - Fingerprinted CSS/JS assets with immutable cache headers - Versioned Worker Cache API keys for rendered HTML -- SEO metadata and canonical URLs for home and example pages +- SEO metadata, canonical URLs, JSON-LD structured data, and a sitemap for home and example pages +- Client-side example search on the home page (press `/` to focus) +- Dark mode via `prefers-color-scheme`, including dual-theme code highlighting +- Per-example social-card images composed from the marginalia figure set +- Learner-behavior reporting from Worker wide events (`docs/learner-analytics.md`) ## Attribution @@ -186,6 +190,17 @@ scripts/check_example_migration_parity.py make check-generated ``` +After adding an example (or changing a title, summary, or figure), also +regenerate its social card so the SEO linter finds the image: + +```bash +make social-cards +``` + +This composes a 1200x630 card per example from its marginalia figure and +rasterizes it to `public/og/.jpg` with headless Chrome (set +`CHROME_PATH` if Chrome is not at the default location). + `src/example_sources_data.py` is generated and committed so Cloudflare Workers can load examples in production. Do not edit it by hand. For a Python version migration, update `python_version` and `docs_base_url` in `src/example_sources/manifest.toml`, then run: diff --git a/docs/learner-analytics.md b/docs/learner-analytics.md new file mode 100644 index 0000000..0e16be7 --- /dev/null +++ b/docs/learner-analytics.md @@ -0,0 +1,48 @@ +# Learner analytics + +The Worker emits one structured wide event per request (see +`docs/observability-spec.md`). Those events already carry the fields +that matter for content decisions: the page path, the example slug on +POST runs, whether the submitted code was edited, the Dynamic Worker +outcome, and the execution time. + +`scripts/learner_report.py` turns an export of those events into a +learner-behavior report so content work is steered by external signal +rather than internal quality scores: + +- **Most-read pages** — GET traffic per path. +- **Most-run examples** — POST runs per slug, with the edited share + (are people experimenting or just pressing Run?), the error share + (where edited code fails), and p50/p95 execution times. +- **Journey traffic** — which curated arcs get used. +- **Missing-example requests** — 404s under `/examples/`, i.e. demand + for pages that do not exist yet. These are content candidates. +- **Turnstile outcomes** — how often runs are challenged or blocked. + +## Getting events + +Live tail (short windows; see the `wrangler tail` caveats in +`docs/lessons-learned.md`): + +```bash +uv run --group workers pywrangler tail --format json > events.ndjson +scripts/learner_report.py events.ndjson +``` + +Or export from the Cloudflare dashboard (Workers Logs) / a Logpush job +and feed the NDJSON file in the same way. The script auto-detects the +raw payload, the `wrangler tail` envelope, and the Workers Logs +envelope, so exports from any of the three sources work unmodified. + +`--json` emits the aggregated report as JSON for further processing; +`--limit N` controls rows per section. + +## Reading the report + +- A high **edited share** with a low error share means the example + invites successful experimentation — the ideal. +- A high **error share** on edited runs marks pages where learners try + something the example did not prepare them for; consider extending + the walkthrough or notes there. +- **Missing-example requests** that recur are the strongest possible + signal for what to write next. diff --git a/docs/lessons-learned.md b/docs/lessons-learned.md index 9bcac53..aeabc12 100644 --- a/docs/lessons-learned.md +++ b/docs/lessons-learned.md @@ -100,7 +100,7 @@ git diff --check - **Two rubrics, one craft section.** Journey-section figures depict a *conceptual shift* across multiple lessons; example-cell figures depict the *single move* the surrounding cell discusses. `docs/journey-visualisation-rubric.md` and `docs/example-figure-rubric.md` score each on 10 points: content fidelity, craft, context. Topic gates per kind of section / cell shape. - **Constraint-shaped material improves when the constraint is drawn as a boundary, not a caveat.** Runtime-boundary teaching works best when it shows the standard Python contract, the site-specific runner boundary, and the portable evidence that preserves the lesson. If a constraint-shaped section cannot be reframed this way, then use the no-figure rationale registry instead of shipping a weak mechanism picture. - **Authoring stays on the contributor; figures stay on the curator.** Example markdown does not include figure references. `src/marginalia.py` holds `FIGURES` (paint functions) and `ATTACHMENTS` (slug → cell → figure → caption). Curating figures is a single-file edit that contributors never see. -- **Inline between prose and code is the production layout; banners between cells is the prototyped richer grammar.** Cells with figures drop to single-column stacking (prose, figure, code) via `.lp-cell.has-figure { grid-template-columns: 1fr }`. Cells without figures keep today's `prose | code` 2-column grid bit-for-bit. The banner-between approach (`/prototyping/layout-banner-*`) supports multi-figure small-multiples between cells when one inline figure isn't enough. +- **Banners between cells is the production layout, with a position grammar.** Cells always keep the `prose | code` 2-column grid; figures render in `.cell-banner` rows at `before`, `after-cell-N` (legacy anchor `cell-N`), or `after-walkthrough` positions via `render_banner(slug, position)` in `src/marginalia.py` and `_render_walkthrough` in `src/app.py`. Multiple tuples on one position share a banner as a small multiple (`cell-banner--2` etc.) — the mutability aliasing/tuple pair is the canonical example. The prototypes at `/prototyping/layout-banner-*` validated the grammar before the production rollout. - **Centralised gestalt pages catch drift that page-by-page review misses.** `/prototyping/marginalia-gestalt`, `/prototyping/journey-figures-gestalt`, and `/prototyping/production-figures-gestalt` show every figure in three different framings. Seeing all section figures of a journey in one 3-up row exposes inconsistencies invisible across six tabs. - **Mapping reuses existing figures; promoting moves design to production.** Half of example coverage came from attaching existing FIGURES to new examples (no paint code). The other half from new paint code copied or designed from gestalt cards. Both paths must pass the rubric. - **Tests against the cell layout must allow the `has-figure` class.** When the renderer adds `has-figure` to cells with attached figures, assertions on the literal string `class="lesson-step lp-cell"` fail. Change those tests to check the substring `lesson-step lp-cell` so both variants match. @@ -129,3 +129,15 @@ git diff --check - **Deployment smoke belongs beside CI, and POST smoke must assert rendered output.** `scripts/smoke_deployment.py` checks rendered Worker pages, runtime-boundary pages, journey pages, prototype review pages, and representative Dynamic Worker POST runs for HTTP failures, exception markers, and stale edited-code output. Build success is not enough; the deployed Worker must render and execute edited examples. With Turnstile enabled, submitted code can appear in the editor textarea even when it did not run, so POST smoke must inspect the output panel rather than searching the whole HTML document. - **Observability smoke should assert the custom event, not the whole tail envelope.** Use unique `x-request-id` values, exercise cache miss/hit/bypass and client-error paths, and assert on the structured payload inside `logs[].message[]`. If Turnstile is enabled, Dynamic Worker error-path probes need the smoke bypass secret; otherwise they only verify the Turnstile-fail path. - **Turnstile should be secret-gated, session-scoped, and invisible until needed.** Protect edited-code POST runs only when `TURNSTILE_SECRET_KEY` and an explicit challenge mode are configured, lazy-load/render the Invisible-mode widget only after the server returns a challenge-required marker, and issue a signed clearance cookie so normal session runs skip Siteverify. The Cloudflare widget mode is `Invisible`; the client-side render option is `execution: "execute"`, not `size: "invisible"`. If production smoke must POST through a protected endpoint, use a separate `PBE_SMOKE_BYPASS_SECRET` header so smoke remains a deployment check rather than a CAPTCHA solver. See `docs/turnstile-runner-protection-spec.md` for the full runner-protection design. + +## Discoverability, theming, and learner analytics + +- **Extend the existing registry's vocabulary instead of adding a parallel one.** The banner rollout was originally specced as a new `BANNERS` dict keyed by position. But `ATTACHMENTS` is load-bearing for five contract families (score sync, figure usage, caption uniqueness, anchor resolution, gestalt builders), and a second registry would have split that coverage. Extending the anchor vocabulary (`before`, `after-walkthrough`, `after-cell-N` alongside legacy `cell-N`) delivered the same grammar with every existing contract intact. Same lesson as "one paint registry, not two," applied to data shape migrations. +- **Dark mode for locked-palette SVGs: change the mat, not the art.** The marginalia grammar hardcodes the light palette in every figure, and recolouring 109 figures would have meant touching the locked grammar and re-auditing geometry. Instead, dark mode renders each figure on a light "paper" chip (`--figure-paper` background, small padding, rounded corners). The figures stay byte-identical, read as intentional artifacts, and the grammar's palette constraint survives. +- **Dual-theme code highlighting needs both pipelines.** Shiki supports `themes: { light, dark }` and emits `--shiki-dark` CSS variables that a `prefers-color-scheme` block activates — no second render pass. CodeMirror has no equivalent, so `editor.js` picks `defaultHighlightStyle` vs `oneDarkHighlightStyle` from `matchMedia` at init. Two highlighters, two theming mechanisms; forgetting either leaves unreadable code in one scheme. +- **Rasterized artifacts do not belong in byte-parity gates.** Social cards are committed PNGs-turned-JPEGs rendered by headless Chrome, and rasterized bytes vary across Chrome versions and platforms. Putting `public/og/` under `make check-generated` would flap on every environment difference. The SEO linter instead checks *existence* — every `og:image` URL must resolve to a committed file — which catches the real failure (a new example without a card) without the false ones. Corollary: JPEG q90 beats PNG ~4x on file size for cards with gradient backgrounds, with no visible text degradation at 1200x630. +- **Social cards should reuse the curated figure set.** Each example's card composes its marginalia figure beside the title and summary, so a shared link carries the same diagram the page teaches with. `render_first_figure(slug)` is the only new marginalia surface the card builder needed; one Chrome session rasterizes all 110 cards in seconds via CDP navigation + `Page.captureScreenshot` clips. +- **Copy that describes data state goes stale silently.** Journey meta descriptions still claimed "explicit placeholders for missing examples" long after the last gap placeholder was filled, and the gap-rendering UI suggested holes that no longer existed. Prose that asserts a property of the data ("all examples run," "placeholders mark gaps") should either be derived from the data or covered by a check; otherwise fixing the data falsifies the copy. +- **Make targets that import the example loader must go through uv.** The loader executes example code that uses 3.12+ syntax (`type UserId = int`), so any script importing `src.app` breaks under an older system `python3` even though its shebang says `python3`. CI masks this by installing 3.13 as the default interpreter. Build steps that import the catalog (`build_search_index`, `build_social_cards`) run as `uv run --python 3.13 scripts/...` in the Makefile so local machines with an older `python3` behave like CI. +- **Index notes text, not just titles, and normalize at build time.** The search index concatenates the slug words and every note line, lowercased at build time, so concept queries ("walrus", "GIL"-style vocabulary that titles avoid) hit the right example and the client never re-normalizes entry content. Exporting the ranking function from `search.js` lets a plain Node script (`make search-ranking-test`) assert ranking behaviour against the real generated index without a JS test framework. +- **The wide events already knew what learners do; nobody was asking.** `example.slug`, `code_edited`, `execution_ms`, turnstile outcome, and 404 paths were all in the observability payload before any analytics existed. `scripts/learner_report.py` is a pure consumer: it auto-detects the raw payload, `wrangler tail` envelope, and Workers Logs envelope per line, so exports from any source work unmodified. The most valuable section costs nothing to collect: recurring 404s under `/examples/` are direct demand for pages that do not exist. diff --git a/docs/quality-registries.toml b/docs/quality-registries.toml index 3ee448a..59690f6 100644 --- a/docs/quality-registries.toml +++ b/docs/quality-registries.toml @@ -316,6 +316,7 @@ journey = "iteration" section = "See the protocol behind `for`." support = [ "iterating-over-iterables", + "iterator-vs-iterable", "iterators", "generators", ] @@ -434,12 +435,14 @@ support = [ "inheritance-and-super", "dataclasses", "properties", + "classmethods-and-staticmethods", "special-methods", "truth-and-size", "container-protocols", "callable-objects", "operator-overloading", "attribute-access", + "bound-and-unbound-methods", "descriptors", "metaclasses", ] @@ -456,6 +459,7 @@ section = "Keep runtime and static analysis separate." support = [ "type-hints", "protocols", + "abstract-base-classes", "enums", "runtime-type-checks", ] @@ -473,6 +477,7 @@ support = [ "union-and-optional-types", "type-aliases", "typed-dicts", + "structured-data-shapes", "literal-and-final", "callable-types", ] diff --git a/docs/visual-explainer-spec.md b/docs/visual-explainer-spec.md index caa90a0..a99992b 100644 --- a/docs/visual-explainer-spec.md +++ b/docs/visual-explainer-spec.md @@ -147,36 +147,37 @@ remain unchanged — no reflow, no cognitive context-switch. ## Anchors and attachments -`src/marginalia.py` declares which figures attach where. The data shape -will move from per-cell injection toward per-position banners: +`src/marginalia.py` declares which figures attach where. `ATTACHMENTS` +remains the single registry; its anchor vocabulary is the position +grammar. Multiple tuples on the same position share one banner as a +small multiple: ```python -# proposed shape — banners keyed by position, each holding 1+ figures -BANNERS = { - "mutability": { - "after-cell-0": [ - ("aliasing-mutation", - "Two names share one mutable list — appending through one " - "name changes the object visible through both."), - ("tuple-no-mutation", - "By contrast, a tuple is frozen — aliases share a value " - "no method can change in place."), - ], - }, +# implemented shape — the mutability pair renders as one two-figure banner +ATTACHMENTS = { + "mutability": [ + ("cell-0", "aliasing-mutation", + "Two names share one mutable list — appending through one " + "name changes the object visible through both."), + ("cell-0", "tuple-no-mutation", + "By contrast, a tuple is frozen — its contents cannot change " + "in place, so aliasing carries no mutation hazard."), + ], } ``` -Banner positions: +Banner positions (anchor spellings in parentheses): -| position | renders | -|--------------------|--------------------------------------| -| `before` | once, before the first cell | -| `after-cell-0`, … | once, after cell N (zero-indexed) | -| `after-walkthrough`| once, after the last cell | +| position | renders | +|-------------------------------------|-----------------------------------| +| `before` | once, before the first cell | +| `after-cell-N` (or legacy `cell-N`) | once, after cell N (zero-indexed) | +| `after-walkthrough` | once, after the last cell | -Each position is a list, not a single figure: the same banner may hold -multiple figures as a small multiple. Most slugs will start empty. -Adding a banner is a one-line edit in `src/marginalia.py`. +`render_banner(slug, position)` resolves both anchor spellings and +returns one `.cell-banner` row holding every figure attached to that +position. Adding a banner figure is a one-line edit in +`src/marginalia.py`. ## Authoring model @@ -242,11 +243,11 @@ explicitly. Re-introducing either is a defect. Aligned with `public/site.css` design tokens; figures use the four palette constants and never pick colours directly. - `src/marginalia.py` — figure registry (`FIGURES`) and attachment map. - Exports `render_for_anchor(slug, anchor)` for the current cell-inline - layout; banner-rendering helpers will land alongside. -- `src/app.py` — `_render_walkthrough_cell` is the current rendering - helper; the banner-between rollout will rename or replace it with a - walkthrough-level renderer that interleaves cells and banners. + Exports `render_banner(slug, position)` for the position grammar + (`render_for_anchor` remains as an anchor-spelling wrapper). +- `src/app.py` — `_render_walkthrough` is the walkthrough-level + renderer: it interleaves cells with `before`, `after-cell-N`, and + `after-walkthrough` banners. - `public/site.css` — `.cell-banner` rules. Production uses the banner-between grammar; cells always render with the prose|code 2-column grid and never receive a `has-figure` class. diff --git a/public/_headers b/public/_headers index d75a1d8..158664f 100644 --- a/public/_headers +++ b/public/_headers @@ -7,8 +7,17 @@ /editor.*.js Cache-Control: public, max-age=31536000, immutable +/search.*.js + Cache-Control: public, max-age=31536000, immutable + +/search-index.*.json + Cache-Control: public, max-age=31536000, immutable + /favicon.svg Cache-Control: public, max-age=31536000, immutable +/og/* + Cache-Control: public, max-age=86400 + /prototyping/* Cache-Control: no-cache, must-revalidate diff --git a/public/editor.a4a7766e1b9b.js b/public/editor.bbf94cd1abda.js similarity index 79% rename from public/editor.a4a7766e1b9b.js rename to public/editor.bbf94cd1abda.js index 5db42ec..0ee493c 100644 --- a/public/editor.a4a7766e1b9b.js +++ b/public/editor.bbf94cd1abda.js @@ -2,6 +2,10 @@ import { EditorState } from 'https://esm.sh/@codemirror/state@6.5.2'; import { EditorView, lineNumbers } from 'https://esm.sh/@codemirror/view@6.41.1?deps=@codemirror/state@6.5.2'; import { defaultHighlightStyle, syntaxHighlighting } from 'https://esm.sh/@codemirror/language@6.12.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1'; import { python } from 'https://esm.sh/@codemirror/lang-python@6.2.1?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; +import { oneDarkHighlightStyle } from 'https://esm.sh/@codemirror/theme-one-dark@6.1.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; + +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; +const highlightStyle = prefersDark ? oneDarkHighlightStyle : defaultHighlightStyle; const textarea = document.getElementById('code-editor'); const form = document.querySelector('form.runner-editor'); @@ -14,7 +18,7 @@ if (textarea && form) { doc: textarea.value, extensions: [ python(), - syntaxHighlighting(defaultHighlightStyle), + syntaxHighlighting(highlightStyle), lineNumbers(), EditorView.lineWrapping, EditorView.updateListener.of((update) => { diff --git a/public/editor.js b/public/editor.js index 5db42ec..0ee493c 100644 --- a/public/editor.js +++ b/public/editor.js @@ -2,6 +2,10 @@ import { EditorState } from 'https://esm.sh/@codemirror/state@6.5.2'; import { EditorView, lineNumbers } from 'https://esm.sh/@codemirror/view@6.41.1?deps=@codemirror/state@6.5.2'; import { defaultHighlightStyle, syntaxHighlighting } from 'https://esm.sh/@codemirror/language@6.12.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1'; import { python } from 'https://esm.sh/@codemirror/lang-python@6.2.1?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; +import { oneDarkHighlightStyle } from 'https://esm.sh/@codemirror/theme-one-dark@6.1.3?deps=@codemirror/state@6.5.2,@codemirror/view@6.41.1,@codemirror/language@6.12.3'; + +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; +const highlightStyle = prefersDark ? oneDarkHighlightStyle : defaultHighlightStyle; const textarea = document.getElementById('code-editor'); const form = document.querySelector('form.runner-editor'); @@ -14,7 +18,7 @@ if (textarea && form) { doc: textarea.value, extensions: [ python(), - syntaxHighlighting(defaultHighlightStyle), + syntaxHighlighting(highlightStyle), lineNumbers(), EditorView.lineWrapping, EditorView.updateListener.of((update) => { diff --git a/public/og/abstract-base-classes.jpg b/public/og/abstract-base-classes.jpg new file mode 100644 index 0000000..c7a3a1a Binary files /dev/null and b/public/og/abstract-base-classes.jpg differ diff --git a/public/og/advanced-match-patterns.jpg b/public/og/advanced-match-patterns.jpg new file mode 100644 index 0000000..66321ba Binary files /dev/null and b/public/og/advanced-match-patterns.jpg differ diff --git a/public/og/args-and-kwargs.jpg b/public/og/args-and-kwargs.jpg new file mode 100644 index 0000000..8511a6c Binary files /dev/null and b/public/og/args-and-kwargs.jpg differ diff --git a/public/og/assertions.jpg b/public/og/assertions.jpg new file mode 100644 index 0000000..481c4c5 Binary files /dev/null and b/public/og/assertions.jpg differ diff --git a/public/og/assignment-expressions.jpg b/public/og/assignment-expressions.jpg new file mode 100644 index 0000000..cf20e32 Binary files /dev/null and b/public/og/assignment-expressions.jpg differ diff --git a/public/og/async-await.jpg b/public/og/async-await.jpg new file mode 100644 index 0000000..b0e18ec Binary files /dev/null and b/public/og/async-await.jpg differ diff --git a/public/og/async-iteration-and-context.jpg b/public/og/async-iteration-and-context.jpg new file mode 100644 index 0000000..41d3dd0 Binary files /dev/null and b/public/og/async-iteration-and-context.jpg differ diff --git a/public/og/attribute-access.jpg b/public/og/attribute-access.jpg new file mode 100644 index 0000000..1f8d89b Binary files /dev/null and b/public/og/attribute-access.jpg differ diff --git a/public/og/booleans.jpg b/public/og/booleans.jpg new file mode 100644 index 0000000..f1a1414 Binary files /dev/null and b/public/og/booleans.jpg differ diff --git a/public/og/bound-and-unbound-methods.jpg b/public/og/bound-and-unbound-methods.jpg new file mode 100644 index 0000000..9255fc9 Binary files /dev/null and b/public/og/bound-and-unbound-methods.jpg differ diff --git a/public/og/break-and-continue.jpg b/public/og/break-and-continue.jpg new file mode 100644 index 0000000..079ce32 Binary files /dev/null and b/public/og/break-and-continue.jpg differ diff --git a/public/og/bytes-and-bytearray.jpg b/public/og/bytes-and-bytearray.jpg new file mode 100644 index 0000000..f82ec93 Binary files /dev/null and b/public/og/bytes-and-bytearray.jpg differ diff --git a/public/og/callable-objects.jpg b/public/og/callable-objects.jpg new file mode 100644 index 0000000..ca79b55 Binary files /dev/null and b/public/og/callable-objects.jpg differ diff --git a/public/og/callable-types.jpg b/public/og/callable-types.jpg new file mode 100644 index 0000000..0ed97bc Binary files /dev/null and b/public/og/callable-types.jpg differ diff --git a/public/og/casts-and-any.jpg b/public/og/casts-and-any.jpg new file mode 100644 index 0000000..01f38e0 Binary files /dev/null and b/public/og/casts-and-any.jpg differ diff --git a/public/og/classes.jpg b/public/og/classes.jpg new file mode 100644 index 0000000..474c57f Binary files /dev/null and b/public/og/classes.jpg differ diff --git a/public/og/classmethods-and-staticmethods.jpg b/public/og/classmethods-and-staticmethods.jpg new file mode 100644 index 0000000..6d6d0ae Binary files /dev/null and b/public/og/classmethods-and-staticmethods.jpg differ diff --git a/public/og/closures.jpg b/public/og/closures.jpg new file mode 100644 index 0000000..3c8d45b Binary files /dev/null and b/public/og/closures.jpg differ diff --git a/public/og/collections-module.jpg b/public/og/collections-module.jpg new file mode 100644 index 0000000..8307e74 Binary files /dev/null and b/public/og/collections-module.jpg differ diff --git a/public/og/comprehension-patterns.jpg b/public/og/comprehension-patterns.jpg new file mode 100644 index 0000000..79ee172 Binary files /dev/null and b/public/og/comprehension-patterns.jpg differ diff --git a/public/og/comprehensions.jpg b/public/og/comprehensions.jpg new file mode 100644 index 0000000..f7fca90 Binary files /dev/null and b/public/og/comprehensions.jpg differ diff --git a/public/og/conditionals.jpg b/public/og/conditionals.jpg new file mode 100644 index 0000000..4a473dd Binary files /dev/null and b/public/og/conditionals.jpg differ diff --git a/public/og/constants.jpg b/public/og/constants.jpg new file mode 100644 index 0000000..9901e1c Binary files /dev/null and b/public/og/constants.jpg differ diff --git a/public/og/container-protocols.jpg b/public/og/container-protocols.jpg new file mode 100644 index 0000000..c61daef Binary files /dev/null and b/public/og/container-protocols.jpg differ diff --git a/public/og/context-managers.jpg b/public/og/context-managers.jpg new file mode 100644 index 0000000..89722ed Binary files /dev/null and b/public/og/context-managers.jpg differ diff --git a/public/og/copying-collections.jpg b/public/og/copying-collections.jpg new file mode 100644 index 0000000..2bc33b1 Binary files /dev/null and b/public/og/copying-collections.jpg differ diff --git a/public/og/csv-data.jpg b/public/og/csv-data.jpg new file mode 100644 index 0000000..20b7aaf Binary files /dev/null and b/public/og/csv-data.jpg differ diff --git a/public/og/custom-exceptions.jpg b/public/og/custom-exceptions.jpg new file mode 100644 index 0000000..36233cf Binary files /dev/null and b/public/og/custom-exceptions.jpg differ diff --git a/public/og/dataclasses.jpg b/public/og/dataclasses.jpg new file mode 100644 index 0000000..ed30bfc Binary files /dev/null and b/public/og/dataclasses.jpg differ diff --git a/public/og/datetime.jpg b/public/og/datetime.jpg new file mode 100644 index 0000000..a25ea5e Binary files /dev/null and b/public/og/datetime.jpg differ diff --git a/public/og/decorators.jpg b/public/og/decorators.jpg new file mode 100644 index 0000000..c87b65c Binary files /dev/null and b/public/og/decorators.jpg differ diff --git a/public/og/delete-statements.jpg b/public/og/delete-statements.jpg new file mode 100644 index 0000000..b2c3064 Binary files /dev/null and b/public/og/delete-statements.jpg differ diff --git a/public/og/descriptors.jpg b/public/og/descriptors.jpg new file mode 100644 index 0000000..d8219ca Binary files /dev/null and b/public/og/descriptors.jpg differ diff --git a/public/og/dicts.jpg b/public/og/dicts.jpg new file mode 100644 index 0000000..b1cd3cd Binary files /dev/null and b/public/og/dicts.jpg differ diff --git a/public/og/enums.jpg b/public/og/enums.jpg new file mode 100644 index 0000000..b930d99 Binary files /dev/null and b/public/og/enums.jpg differ diff --git a/public/og/equality-and-identity.jpg b/public/og/equality-and-identity.jpg new file mode 100644 index 0000000..1489940 Binary files /dev/null and b/public/og/equality-and-identity.jpg differ diff --git a/public/og/exception-chaining.jpg b/public/og/exception-chaining.jpg new file mode 100644 index 0000000..14175fc Binary files /dev/null and b/public/og/exception-chaining.jpg differ diff --git a/public/og/exception-groups.jpg b/public/og/exception-groups.jpg new file mode 100644 index 0000000..e177dc7 Binary files /dev/null and b/public/og/exception-groups.jpg differ diff --git a/public/og/exceptions.jpg b/public/og/exceptions.jpg new file mode 100644 index 0000000..efefc70 Binary files /dev/null and b/public/og/exceptions.jpg differ diff --git a/public/og/for-loops.jpg b/public/og/for-loops.jpg new file mode 100644 index 0000000..f55c3a3 Binary files /dev/null and b/public/og/for-loops.jpg differ diff --git a/public/og/functions.jpg b/public/og/functions.jpg new file mode 100644 index 0000000..ffece73 Binary files /dev/null and b/public/og/functions.jpg differ diff --git a/public/og/generator-expressions.jpg b/public/og/generator-expressions.jpg new file mode 100644 index 0000000..efb82d2 Binary files /dev/null and b/public/og/generator-expressions.jpg differ diff --git a/public/og/generators.jpg b/public/og/generators.jpg new file mode 100644 index 0000000..8501526 Binary files /dev/null and b/public/og/generators.jpg differ diff --git a/public/og/generics-and-typevar.jpg b/public/og/generics-and-typevar.jpg new file mode 100644 index 0000000..24e8a48 Binary files /dev/null and b/public/og/generics-and-typevar.jpg differ diff --git a/public/og/guard-clauses.jpg b/public/og/guard-clauses.jpg new file mode 100644 index 0000000..c77f947 Binary files /dev/null and b/public/og/guard-clauses.jpg differ diff --git a/public/og/hello-world.jpg b/public/og/hello-world.jpg new file mode 100644 index 0000000..fa77ca8 Binary files /dev/null and b/public/og/hello-world.jpg differ diff --git a/public/og/home.jpg b/public/og/home.jpg new file mode 100644 index 0000000..50aca1e Binary files /dev/null and b/public/og/home.jpg differ diff --git a/public/og/import-aliases.jpg b/public/og/import-aliases.jpg new file mode 100644 index 0000000..0a6d1f8 Binary files /dev/null and b/public/og/import-aliases.jpg differ diff --git a/public/og/inheritance-and-super.jpg b/public/og/inheritance-and-super.jpg new file mode 100644 index 0000000..c118a42 Binary files /dev/null and b/public/og/inheritance-and-super.jpg differ diff --git a/public/og/iterating-over-iterables.jpg b/public/og/iterating-over-iterables.jpg new file mode 100644 index 0000000..48538dc Binary files /dev/null and b/public/og/iterating-over-iterables.jpg differ diff --git a/public/og/iterator-vs-iterable.jpg b/public/og/iterator-vs-iterable.jpg new file mode 100644 index 0000000..a39c38d Binary files /dev/null and b/public/og/iterator-vs-iterable.jpg differ diff --git a/public/og/iterators.jpg b/public/og/iterators.jpg new file mode 100644 index 0000000..e13bd0b Binary files /dev/null and b/public/og/iterators.jpg differ diff --git a/public/og/itertools.jpg b/public/og/itertools.jpg new file mode 100644 index 0000000..a3b6cdd Binary files /dev/null and b/public/og/itertools.jpg differ diff --git a/public/og/json.jpg b/public/og/json.jpg new file mode 100644 index 0000000..4d065f6 Binary files /dev/null and b/public/og/json.jpg differ diff --git a/public/og/keyword-only-arguments.jpg b/public/og/keyword-only-arguments.jpg new file mode 100644 index 0000000..9cc3aeb Binary files /dev/null and b/public/og/keyword-only-arguments.jpg differ diff --git a/public/og/lambdas.jpg b/public/og/lambdas.jpg new file mode 100644 index 0000000..f378976 Binary files /dev/null and b/public/og/lambdas.jpg differ diff --git a/public/og/lists.jpg b/public/og/lists.jpg new file mode 100644 index 0000000..3b42bfa Binary files /dev/null and b/public/og/lists.jpg differ diff --git a/public/og/literal-and-final.jpg b/public/og/literal-and-final.jpg new file mode 100644 index 0000000..cf1de75 Binary files /dev/null and b/public/og/literal-and-final.jpg differ diff --git a/public/og/literals.jpg b/public/og/literals.jpg new file mode 100644 index 0000000..f9a4084 Binary files /dev/null and b/public/og/literals.jpg differ diff --git a/public/og/logging.jpg b/public/og/logging.jpg new file mode 100644 index 0000000..ac8d72d Binary files /dev/null and b/public/og/logging.jpg differ diff --git a/public/og/loop-else.jpg b/public/og/loop-else.jpg new file mode 100644 index 0000000..7e0154c Binary files /dev/null and b/public/og/loop-else.jpg differ diff --git a/public/og/match-statements.jpg b/public/og/match-statements.jpg new file mode 100644 index 0000000..7eab878 Binary files /dev/null and b/public/og/match-statements.jpg differ diff --git a/public/og/metaclasses.jpg b/public/og/metaclasses.jpg new file mode 100644 index 0000000..e576304 Binary files /dev/null and b/public/og/metaclasses.jpg differ diff --git a/public/og/modules.jpg b/public/og/modules.jpg new file mode 100644 index 0000000..bb8f7fb Binary files /dev/null and b/public/og/modules.jpg differ diff --git a/public/og/multiple-return-values.jpg b/public/og/multiple-return-values.jpg new file mode 100644 index 0000000..233ec20 Binary files /dev/null and b/public/og/multiple-return-values.jpg differ diff --git a/public/og/mutability.jpg b/public/og/mutability.jpg new file mode 100644 index 0000000..ae63435 Binary files /dev/null and b/public/og/mutability.jpg differ diff --git a/public/og/networking.jpg b/public/og/networking.jpg new file mode 100644 index 0000000..80a7fa4 Binary files /dev/null and b/public/og/networking.jpg differ diff --git a/public/og/newtype.jpg b/public/og/newtype.jpg new file mode 100644 index 0000000..e3c994e Binary files /dev/null and b/public/og/newtype.jpg differ diff --git a/public/og/none.jpg b/public/og/none.jpg new file mode 100644 index 0000000..a729c82 Binary files /dev/null and b/public/og/none.jpg differ diff --git a/public/og/number-parsing.jpg b/public/og/number-parsing.jpg new file mode 100644 index 0000000..1905ae3 Binary files /dev/null and b/public/og/number-parsing.jpg differ diff --git a/public/og/numbers.jpg b/public/og/numbers.jpg new file mode 100644 index 0000000..8fc26e9 Binary files /dev/null and b/public/og/numbers.jpg differ diff --git a/public/og/object-lifecycle.jpg b/public/og/object-lifecycle.jpg new file mode 100644 index 0000000..fe442f2 Binary files /dev/null and b/public/og/object-lifecycle.jpg differ diff --git a/public/og/operator-overloading.jpg b/public/og/operator-overloading.jpg new file mode 100644 index 0000000..213803f Binary files /dev/null and b/public/og/operator-overloading.jpg differ diff --git a/public/og/operators.jpg b/public/og/operators.jpg new file mode 100644 index 0000000..5c8a24e Binary files /dev/null and b/public/og/operators.jpg differ diff --git a/public/og/overloads.jpg b/public/og/overloads.jpg new file mode 100644 index 0000000..b0b312b Binary files /dev/null and b/public/og/overloads.jpg differ diff --git a/public/og/packages.jpg b/public/og/packages.jpg new file mode 100644 index 0000000..97d487f Binary files /dev/null and b/public/og/packages.jpg differ diff --git a/public/og/paramspec.jpg b/public/og/paramspec.jpg new file mode 100644 index 0000000..140b6fc Binary files /dev/null and b/public/og/paramspec.jpg differ diff --git a/public/og/partial-functions.jpg b/public/og/partial-functions.jpg new file mode 100644 index 0000000..c86443c Binary files /dev/null and b/public/og/partial-functions.jpg differ diff --git a/public/og/positional-only-parameters.jpg b/public/og/positional-only-parameters.jpg new file mode 100644 index 0000000..6817dfe Binary files /dev/null and b/public/og/positional-only-parameters.jpg differ diff --git a/public/og/properties.jpg b/public/og/properties.jpg new file mode 100644 index 0000000..d7dec0f Binary files /dev/null and b/public/og/properties.jpg differ diff --git a/public/og/protocols.jpg b/public/og/protocols.jpg new file mode 100644 index 0000000..80ebef7 Binary files /dev/null and b/public/og/protocols.jpg differ diff --git a/public/og/recursion.jpg b/public/og/recursion.jpg new file mode 100644 index 0000000..bd34cba Binary files /dev/null and b/public/og/recursion.jpg differ diff --git a/public/og/regular-expressions.jpg b/public/og/regular-expressions.jpg new file mode 100644 index 0000000..2cb35b0 Binary files /dev/null and b/public/og/regular-expressions.jpg differ diff --git a/public/og/runtime-type-checks.jpg b/public/og/runtime-type-checks.jpg new file mode 100644 index 0000000..3aa23cf Binary files /dev/null and b/public/og/runtime-type-checks.jpg differ diff --git a/public/og/scope-global-nonlocal.jpg b/public/og/scope-global-nonlocal.jpg new file mode 100644 index 0000000..bb90535 Binary files /dev/null and b/public/og/scope-global-nonlocal.jpg differ diff --git a/public/og/sentinel-iteration.jpg b/public/og/sentinel-iteration.jpg new file mode 100644 index 0000000..633a280 Binary files /dev/null and b/public/og/sentinel-iteration.jpg differ diff --git a/public/og/sets.jpg b/public/og/sets.jpg new file mode 100644 index 0000000..8859b1a Binary files /dev/null and b/public/og/sets.jpg differ diff --git a/public/og/slices.jpg b/public/og/slices.jpg new file mode 100644 index 0000000..c0f586b Binary files /dev/null and b/public/og/slices.jpg differ diff --git a/public/og/sorting.jpg b/public/og/sorting.jpg new file mode 100644 index 0000000..3255615 Binary files /dev/null and b/public/og/sorting.jpg differ diff --git a/public/og/special-methods.jpg b/public/og/special-methods.jpg new file mode 100644 index 0000000..71104e0 Binary files /dev/null and b/public/og/special-methods.jpg differ diff --git a/public/og/string-formatting.jpg b/public/og/string-formatting.jpg new file mode 100644 index 0000000..b1ace20 Binary files /dev/null and b/public/og/string-formatting.jpg differ diff --git a/public/og/strings.jpg b/public/og/strings.jpg new file mode 100644 index 0000000..9fd953e Binary files /dev/null and b/public/og/strings.jpg differ diff --git a/public/og/structured-data-shapes.jpg b/public/og/structured-data-shapes.jpg new file mode 100644 index 0000000..329f2da Binary files /dev/null and b/public/og/structured-data-shapes.jpg differ diff --git a/public/og/subprocesses.jpg b/public/og/subprocesses.jpg new file mode 100644 index 0000000..5ffd083 Binary files /dev/null and b/public/og/subprocesses.jpg differ diff --git a/public/og/testing.jpg b/public/og/testing.jpg new file mode 100644 index 0000000..e9685ba Binary files /dev/null and b/public/og/testing.jpg differ diff --git a/public/og/threads-and-processes.jpg b/public/og/threads-and-processes.jpg new file mode 100644 index 0000000..77da0c3 Binary files /dev/null and b/public/og/threads-and-processes.jpg differ diff --git a/public/og/truth-and-size.jpg b/public/og/truth-and-size.jpg new file mode 100644 index 0000000..1367c4d Binary files /dev/null and b/public/og/truth-and-size.jpg differ diff --git a/public/og/truthiness.jpg b/public/og/truthiness.jpg new file mode 100644 index 0000000..175cfbd Binary files /dev/null and b/public/og/truthiness.jpg differ diff --git a/public/og/tuples.jpg b/public/og/tuples.jpg new file mode 100644 index 0000000..b43af9a Binary files /dev/null and b/public/og/tuples.jpg differ diff --git a/public/og/type-aliases.jpg b/public/og/type-aliases.jpg new file mode 100644 index 0000000..a8c4af6 Binary files /dev/null and b/public/og/type-aliases.jpg differ diff --git a/public/og/type-hints.jpg b/public/og/type-hints.jpg new file mode 100644 index 0000000..09d2240 Binary files /dev/null and b/public/og/type-hints.jpg differ diff --git a/public/og/typed-dicts.jpg b/public/og/typed-dicts.jpg new file mode 100644 index 0000000..c2b8d9b Binary files /dev/null and b/public/og/typed-dicts.jpg differ diff --git a/public/og/union-and-optional-types.jpg b/public/og/union-and-optional-types.jpg new file mode 100644 index 0000000..cea71bb Binary files /dev/null and b/public/og/union-and-optional-types.jpg differ diff --git a/public/og/unpacking.jpg b/public/og/unpacking.jpg new file mode 100644 index 0000000..6a77e4c Binary files /dev/null and b/public/og/unpacking.jpg differ diff --git a/public/og/values.jpg b/public/og/values.jpg new file mode 100644 index 0000000..e55a752 Binary files /dev/null and b/public/og/values.jpg differ diff --git a/public/og/variables.jpg b/public/og/variables.jpg new file mode 100644 index 0000000..683237f Binary files /dev/null and b/public/og/variables.jpg differ diff --git a/public/og/virtual-environments.jpg b/public/og/virtual-environments.jpg new file mode 100644 index 0000000..ceb01a9 Binary files /dev/null and b/public/og/virtual-environments.jpg differ diff --git a/public/og/warnings.jpg b/public/og/warnings.jpg new file mode 100644 index 0000000..09ec74b Binary files /dev/null and b/public/og/warnings.jpg differ diff --git a/public/og/while-loops.jpg b/public/og/while-loops.jpg new file mode 100644 index 0000000..6c49027 Binary files /dev/null and b/public/og/while-loops.jpg differ diff --git a/public/og/yield-from.jpg b/public/og/yield-from.jpg new file mode 100644 index 0000000..08b898f Binary files /dev/null and b/public/og/yield-from.jpg differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..cdd8f97 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,6 @@ +User-agent: * +Allow: / +Disallow: /layout-options/ +Disallow: /prototyping/ + +Sitemap: https://www.pythonbyexample.dev/sitemap.xml diff --git a/public/search-index.f08e8599474a.json b/public/search-index.f08e8599474a.json new file mode 100644 index 0000000..c23e16b --- /dev/null +++ b/public/search-index.f08e8599474a.json @@ -0,0 +1 @@ +[{"slug":"hello-world","title":"Hello World","section":"Basics","summary":"The first Python program prints a line of text.","text":"hello world `print()` writes text followed by a newline. strings can be delimited with single or double quotes."},{"slug":"values","title":"Values","section":"Basics","summary":"Python programs evaluate expressions into objects such as text, numbers, booleans, and None.","text":"values values are objects; names point to them and operations usually create new values. use `is none` for the absence marker, not `== none`. this overview introduces boundaries that later pages explain in detail."},{"slug":"literals","title":"Literals","section":"Basics","summary":"Literals write values directly in Python source code.","text":"literals literals are good for small local values; constants are better for repeated values with meaning. `{}` is an empty dictionary. use `set()` for an empty set. bytes literals are binary data; string literals are unicode text. `...` evaluates to the `ellipsis` object."},{"slug":"numbers","title":"Numbers","section":"Basics","summary":"Python numbers include integers, floats, and complex values.","text":"numbers python's `int` has arbitrary precision; it grows as large as memory allows. python's `float` is approximate double-precision floating point. use `/` for true division and `//` for floor division. use `math.isclose` instead of `==` for floating-point comparison; reach for `decimal.decimal` when exact decimal precision is the domain requirement."},{"slug":"booleans","title":"Booleans","section":"Basics","summary":"Booleans represent truth values and combine with logical operators.","text":"booleans boolean constants are `true` and `false`, with capital letters. `and` and `or` short-circuit: python does not evaluate the right side if the left side already determines the result. prefer truthiness for containers and explicit comparisons when the exact boolean condition matters. `bool` subclasses `int`; `isinstance(true, int)` is `true`. exclude booleans explicitly when only \"real\" integers should pass."},{"slug":"operators","title":"Operators","section":"Basics","summary":"Operators combine, compare, and test values in expressions.","text":"operators use the clearest operator for the question: arithmetic, comparison, boolean logic, membership, identity, or bitwise manipulation. `and` and `or` short-circuit, so the right side may not run. operators have precedence; use parentheses when grouping would otherwise be hard to read. custom operator behavior should make an object feel more natural, not more clever."},{"slug":"none","title":"None","section":"Basics","summary":"None represents expected absence, distinct from missing keys and errors.","text":"none use `is none` rather than `== none`; `none` is a singleton identity value. use `none` for expected absence that callers can test. use dictionary defaults for missing mapping keys and exceptions for invalid operations."},{"slug":"variables","title":"Variables","section":"Basics","summary":"Names are bound to values with assignment.","text":"variables python variables are names bound to objects, not boxes with fixed types. rebinding a name is normal. use augmented assignment for counters and accumulators."},{"slug":"constants","title":"Constants","section":"Basics","summary":"Python uses naming conventions and optional types for values that should not change.","text":"constants python constants are a convention, not a runtime lock. use all-caps names for fixed module-level configuration. add `final` when static tooling should flag accidental rebinding."},{"slug":"truthiness","title":"Truthiness","section":"Basics","summary":"Python conditions use truthiness, not only explicit booleans.","text":"truthiness empty containers and zero-like numbers are false in conditions. use explicit comparisons when they communicate intent better than truthiness."},{"slug":"equality-and-identity","title":"Equality and Identity","section":"Data Model","summary":"== compares values, while is compares object identity.","text":"equality and identity use `==` for ordinary value comparisons. use `is` primarily for identity checks against singletons such as `none`. equal mutable containers can still be independent objects. never use `is` to compare numbers; cpython's small-integer cache makes the result an implementation detail."},{"slug":"mutability","title":"Mutability","section":"Data Model","summary":"Some objects change in place, while others return new values.","text":"mutability lists and dictionaries are mutable; strings and tuples are immutable. aliasing is useful, but copy mutable containers when independent changes are needed. pay attention to whether an operation mutates in place or returns a new value."},{"slug":"object-lifecycle","title":"Object Lifecycle","section":"Basics","summary":"Names keep objects reachable until the last reference goes away.","text":"object lifecycle assignment binds names to objects; it does not copy the object. `del name` removes one reference, not necessarily the object itself. python reclaims unreachable objects automatically, so lifetime bugs usually come from keeping references longer than intended."},{"slug":"strings","title":"Strings","section":"Text","summary":"Strings are immutable Unicode text sequences.","text":"strings use `str` for text and `bytes` for binary data. `len(text)` counts unicode code points; `len(text.encode(\"utf-8\"))` counts encoded bytes. ascii text is a useful baseline because each ascii code point is one utf-8 byte. string methods return new strings because strings are immutable. user-visible “characters” can be more subtle than code points; combining marks and emoji sequences may need specialized text handling."},{"slug":"bytes-and-bytearray","title":"Bytes and Bytearray","section":"Basics","summary":"bytes and bytearray store binary data, not Unicode text.","text":"bytes and bytearray encode text when an external boundary needs bytes. decode bytes when you want text again. indexing `bytes` returns integers from 0 to 255. use `bytearray` when binary data must be changed in place."},{"slug":"string-formatting","title":"String Formatting","section":"Text","summary":"f-strings turn values into readable text at the point of use.","text":"string formatting use `f\"...\"` strings for most new formatting code. expressions inside braces are evaluated before formatting. format specifications after `:` control alignment, width, padding, and precision."},{"slug":"conditionals","title":"Conditionals","section":"Control Flow","summary":"if, elif, and else choose which block runs.","text":"conditionals python has no mandatory parentheses around conditions; the colon and indentation define the block. comparison operators such as `<` and `==` can be chained, as in `0 < value < 10`. keep branch bodies short; move larger work into functions so the decision remains easy to scan."},{"slug":"guard-clauses","title":"Guard Clauses","section":"Control Flow","summary":"Guard clauses handle boundary cases early so the main path stays flat.","text":"guard clauses guard clauses are a readability pattern, not a separate python feature. they work best when the early cases are true boundaries. for exceptional failures, raise an exception instead of returning a sentinel string."},{"slug":"assignment-expressions","title":"Assignment Expressions","section":"Control Flow","summary":"The walrus operator assigns a value inside an expression.","text":"assignment expressions `name := expression` assigns and evaluates to the assigned value. use it to avoid computing the same value twice. prefer a normal assignment when the expression becomes hard to scan."},{"slug":"for-loops","title":"For Loops","section":"Control Flow","summary":"for iterates over values produced by an iterable.","text":"for loops a `for` loop consumes an iterable until it is exhausted. reach for `while` when the stopping condition must be rechecked manually. `iter()` and `next()` expose the protocol that `for` uses internally."},{"slug":"break-and-continue","title":"Break and Continue","section":"Control Flow","summary":"break exits a loop early, while continue skips to the next iteration.","text":"break and continue `continue` skips to the next loop iteration. `break` exits the nearest enclosing loop immediately. prefer plain `if`/`else` when the loop does not need early skip or early stop behavior."},{"slug":"loop-else","title":"Loop Else","section":"Control Flow","summary":"A loop else block runs only when the loop did not end with break.","text":"loop else loop `else` runs when the loop was not ended by `break`. it is best for search loops with a clear found/not-found split. it works with both `for` and `while` loops."},{"slug":"iterating-over-iterables","title":"Iterating over Iterables","section":"Iteration","summary":"for loops consume values from any iterable object.","text":"iterating over iterables a `for` loop consumes values from an iterable. different producers can feed the same loop protocol. prefer `enumerate()` over `range(len(...))` when you need an index."},{"slug":"iterators","title":"Iterators","section":"Iteration","summary":"iter and next expose the protocol behind for loops.","text":"iterators iterables produce iterators; iterators produce values. `next()` consumes one value from an iterator. many iterators are one-pass even when the original collection is reusable."},{"slug":"iterator-vs-iterable","title":"Iterator vs Iterable","section":"Iteration","summary":"Iterables produce fresh iterators; iterators are one-pass.","text":"iterator vs iterable an iterable produces an iterator each time `iter()` is called on it; an iterator produces values until it is exhausted. `iter(iterable)` returns a fresh iterator; `iter(iterator)` returns the same iterator. functions that traverse their input more than once must accept an iterable or materialize the input at the boundary."},{"slug":"sentinel-iteration","title":"Sentinel Iteration","section":"Iteration","summary":"iter(callable, sentinel) repeats calls until a marker value appears.","text":"sentinel iteration the callable passed to `iter(callable, sentinel)` must take no arguments. the sentinel stops iteration and is not yielded. when the loop needs richer branching, an explicit `while` loop may be clearer."},{"slug":"match-statements","title":"Match Statements","section":"Control Flow","summary":"match selects cases using structural pattern matching.","text":"match statements `match` compares structure, not just equality. patterns can bind names such as `x` and `y` while matching. put the catch-all `_` case last, because cases are tried from top to bottom."},{"slug":"advanced-match-patterns","title":"Advanced Match Patterns","section":"Control Flow","summary":"match patterns can destructure sequences, combine alternatives, and add guards.","text":"advanced match patterns use `case _` as a wildcard fallback. guards refine a pattern after the structure matches. or patterns and star patterns keep shape-based branches compact."},{"slug":"while-loops","title":"While Loops","section":"Control Flow","summary":"while repeats until changing state makes a condition false.","text":"while loops use `while` when changing state decides whether the loop continues. update loop state inside the body so the condition can become false. prefer `for` when you already have a collection, range, iterator, or generator to consume."},{"slug":"lists","title":"Lists","section":"Collections","summary":"Lists are ordered, mutable collections.","text":"lists lists are mutable sequences: methods such as `append()` change the list in place. negative indexes count from the end. `sorted()` returns a new list; `list.sort()` sorts the existing list in place."},{"slug":"tuples","title":"Tuples","section":"Collections","summary":"Tuples group a fixed number of positional values.","text":"tuples tuples are immutable sequences with fixed length. use tuples for small records where position has meaning. use lists for variable-length collections of similar items. reach for a dataclass or `namedtuple` when fields deserve names everywhere they're used."},{"slug":"unpacking","title":"Unpacking","section":"Collections","summary":"Unpacking binds names from sequences and mappings concisely.","text":"unpacking starred unpacking collects the remaining values into a list. dictionary unpacking with ** is common when calling functions with structured data. prefer indexing when you need one position; prefer unpacking when naming several positions makes the shape clearer."},{"slug":"dicts","title":"Dictionaries","section":"Collections","summary":"Dictionaries map keys to values for records, lookup, and structured data.","text":"dicts dictionaries preserve insertion order in modern python. use `get()` when a missing key has a reasonable default. use direct indexing when a missing key should be treated as an error. snapshot keys with `list(d.keys())` before deleting items in a loop; mutating during iteration raises `runtimeerror`."},{"slug":"sets","title":"Sets","section":"Collections","summary":"Sets store unique values and make membership checks explicit.","text":"sets use lists when order and repeated values matter. use sets when uniqueness and membership are the main operations. prefer lists when order or repeated values are part of the meaning. sets are unordered, so sort them when examples need deterministic display."},{"slug":"slices","title":"Slices","section":"Collections","summary":"Slices copy meaningful ranges from ordered sequences.","text":"slices slice stop indexes are excluded, so adjacent ranges compose cleanly. omitted bounds mean the beginning or end of the sequence. a negative step walks backward; `[::-1]` is a common reversed-copy idiom."},{"slug":"comprehensions","title":"Comprehensions","section":"Collections","summary":"Comprehensions build collections by mapping and filtering iterables.","text":"comprehensions the left side says what to produce; the `for` clause says where values come from. use an `if` clause for simple filters. list, dict, and set comprehensions build concrete collections immediately. switch to a loop when the transformation needs multiple steps or explanations."},{"slug":"comprehension-patterns","title":"Comprehension Patterns","section":"Collections","summary":"Comprehensions can use multiple for clauses and filters when the shape stays clear.","text":"comprehension patterns read comprehension clauses from left to right. multiple `for` clauses act like nested loops. prefer an explicit loop when the comprehension stops being obvious."},{"slug":"sorting","title":"Sorting","section":"Collections","summary":"sorted returns a new ordered list and key functions choose the sort value.","text":"sorting `sorted()` makes a new list; `list.sort()` mutates an existing list. `key=` should return the value python compares for each item. python's sort is stable, so equal keys keep their original relative order."},{"slug":"collections-module","title":"Collections Module","section":"Collections","summary":"collections provides specialized containers for common data shapes.","text":"collections module `counter` counts, `defaultdict` groups, `deque` queues, and `namedtuple` names record fields. prefer the built-in containers until a specialized shape makes the code clearer. for new structured records with defaults and methods, consider `dataclasses` instead of `namedtuple`."},{"slug":"copying-collections","title":"Copying Collections","section":"Collections","summary":"Copies can duplicate the outer container while nested objects may still be shared.","text":"copying collections assignment aliases; it does not copy. shallow copies duplicate the outer container only. deep copies are useful for nested independence, but they can be expensive and surprising for objects with external resources."},{"slug":"functions","title":"Functions","section":"Functions","summary":"Use def to name reusable behavior and return results.","text":"functions use `return` for values the caller should receive. defaults keep common calls concise. keyword arguments make options readable at the call site. never use a mutable value as a default argument; use `none` and build the container inside the function body."},{"slug":"keyword-only-arguments","title":"Keyword-only Arguments","section":"Functions","summary":"Use * to require selected function arguments to be named.","text":"keyword only arguments put `*` before options that callers should name. keyword-only flags avoid mysterious positional `true` and `false` arguments. defaults work normally for keyword-only parameters."},{"slug":"positional-only-parameters","title":"Positional-only Parameters","section":"Functions","summary":"Use / to mark parameters that callers must pass by position.","text":"positional only parameters `/` marks parameters before it as positional-only. `*` marks parameters after it as keyword-only. use these markers when the call shape is part of the api design."},{"slug":"args-and-kwargs","title":"Args and Kwargs","section":"Functions","summary":"*args collects extra positional arguments and **kwargs collects named ones.","text":"args and kwargs use these tools when a function naturally accepts a flexible shape. prefer explicit parameters when the accepted arguments are known and fixed. `*args` is a tuple; `**kwargs` is a dictionary."},{"slug":"multiple-return-values","title":"Multiple Return Values","section":"Functions","summary":"Python returns multiple values by returning a tuple and unpacking it.","text":"multiple return values a comma creates a tuple; `return a, b` returns one tuple containing two values. unpacking at the call site gives each returned position a meaningful name. use a class-like record when the result has many fields."},{"slug":"closures","title":"Closures","section":"Functions","summary":"Inner functions can remember values from an enclosing scope.","text":"closures a closure keeps access to names from the scope where the inner function was created. each call to the outer function can create a separate remembered environment. closures are useful for callbacks, small factories, and decorators. closures bind names, not values; capture loop variables with `lambda x=x: ...` to freeze them at definition time."},{"slug":"partial-functions","title":"Partial Functions","section":"Functions","summary":"functools.partial pre-fills arguments to make a more specific callable.","text":"partial functions `partial` adapts a callable by pre-filling arguments. the resulting object can be passed anywhere a callable with the remaining parameters is expected. use a regular function when the adapter needs more logic than argument binding."},{"slug":"scope-global-nonlocal","title":"Global and Nonlocal","section":"Functions","summary":"global and nonlocal choose which outer binding assignment should update.","text":"scope global nonlocal assignment inside a function is local unless declared otherwise. prefer `nonlocal` for closure state and avoid `global` unless module state is truly intended. passing values and returning results is usually easier to test than rebinding outer names."},{"slug":"recursion","title":"Recursion","section":"Functions","summary":"Recursive functions solve nested problems by calling themselves on smaller pieces.","text":"recursion every recursive function needs a base case that stops the calls. recursion fits nested data better than flat repetition. python limits recursion depth, so loops are often better for very deep or simple repetition."},{"slug":"lambdas","title":"Lambdas","section":"Functions","summary":"lambda creates small anonymous function expressions.","text":"lambdas lambdas are expressions, not statements. prefer `def` for multi-step or reused behavior. lambdas are common as `key=` functions because the behavior is local to one call."},{"slug":"generators","title":"Generators","section":"Iteration","summary":"yield creates an iterator that produces values on demand.","text":"generators generator functions are a concise way to create custom iterators; every generator is an iterator. `yield` defers work and streams values; `return` produces the whole result up front. a generator is consumed as you iterate over it. prefer a list when you need to reuse stored results; prefer a generator when values can be streamed once."},{"slug":"yield-from","title":"Yield From","section":"Iteration","summary":"yield from delegates part of a generator to another iterable.","text":"yield from `yield from iterable` yields each value from that iterable. it keeps generator pipelines compact. use a plain `yield` when producing one value directly."},{"slug":"generator-expressions","title":"Generator Expressions","section":"Iteration","summary":"Generator expressions use comprehension-like syntax to stream values lazily.","text":"generator expressions list, dict, and set comprehensions build concrete collections. generator expressions produce one-pass iterators. use generator expressions when the consumer can process values one at a time."},{"slug":"itertools","title":"Itertools","section":"Iteration","summary":"itertools composes lazy iterator streams.","text":"itertools `itertools` composes producer and transformer streams. iterator pipelines avoid building intermediate lists. use `islice()` to take a finite piece from an infinite iterator. convert to a list only when you need concrete results."},{"slug":"decorators","title":"Decorators","section":"Functions","summary":"Decorators wrap or register functions using @ syntax.","text":"decorators `@decorator` is shorthand for assigning `func = decorator(func)`. decorators can wrap, replace, or register functions. use `functools.wraps` in production wrappers that should preserve metadata."},{"slug":"classes","title":"Classes","section":"Classes","summary":"Classes bundle data and behavior into new object types.","text":"classes `self` is the instance the method is operating on. `__init__` initializes each new object. class attributes are shared across instances; instance attributes belong to one object. put mutable defaults in `__init__`, not on the class body. use classes when behavior belongs with state; use dictionaries for looser structured data."},{"slug":"inheritance-and-super","title":"Inheritance and Super","section":"Classes","summary":"Inheritance reuses behavior, and super delegates to a parent implementation.","text":"inheritance and super inheritance models an “is a specialized kind of” relationship. `super()` calls the next implementation in the method resolution order. prefer composition when an object only needs to use another object."},{"slug":"classmethods-and-staticmethods","title":"Classmethods and Staticmethods","section":"Classes","summary":"Three method shapes: instance, class, and static — each receives a different first argument.","text":"classmethods and staticmethods instance methods need an instance; classmethods and staticmethods can be called on the class. use `@classmethod` for alternate constructors and class-level operations that respect subclassing. use `@staticmethod` only when a function is truly independent of instance and class state but still belongs in the class's namespace. a free function is often the right answer when neither decorator applies."},{"slug":"dataclasses","title":"Dataclasses","section":"Classes","summary":"dataclass generates common class methods for data containers.","text":"dataclasses type annotations define dataclass fields. dataclasses generate methods but remain normal python classes. use `field()` for advanced defaults such as per-instance lists or dictionaries."},{"slug":"properties","title":"Properties","section":"Classes","summary":"@property keeps attribute syntax while adding computation or validation.","text":"properties properties let apis start simple and grow validation or computation later. callers access a property like an attribute, not like a method. use methods instead when work is expensive or action-like."},{"slug":"special-methods","title":"Special Methods","section":"Data Model","summary":"Special methods connect your objects to Python syntax and built-ins.","text":"special methods dunder methods are looked up by python's data model protocols. `__repr__` is the developer-facing form; `__str__` is the user-facing form. `print()` falls back to `__repr__` when `__str__` is missing. defining `__eq__` removes the default `__hash__`; restore it when the type should be hashable. container protocols (`__contains__`, `__getitem__`, `__setitem__`, `__bool__`) make instances behave like built-in containers. `__call__` makes instances callable; `__enter__`/`__exit__` make them context managers. implement the smallest protocol that makes your object feel native."},{"slug":"truth-and-size","title":"Truth and Size","section":"Data Model","summary":"__bool__ and __len__ decide how objects behave in truth tests and len().","text":"truth and size prefer `__len__` for sized containers. prefer `__bool__` when truth has domain meaning. keep truth tests unsurprising; surprising falsy objects make conditionals harder to read."},{"slug":"container-protocols","title":"Container Protocols","section":"Data Model","summary":"Container methods connect objects to indexing, membership, and item assignment.","text":"container protocols implement the narrowest container protocol your object needs. use `keyerror` and `indexerror` consistently with built-in containers. if a plain `dict` or `list` is enough, prefer it over a custom container."},{"slug":"callable-objects","title":"Callable Objects","section":"Data Model","summary":"__call__ lets an instance behave like a function while keeping state.","text":"callable objects `callable(obj)` checks whether an object can be called. callable objects are good for named, stateful behavior. prefer plain functions when no instance state is needed."},{"slug":"operator-overloading","title":"Operator Overloading","section":"Data Model","summary":"Operator methods let objects define arithmetic and comparison syntax.","text":"operator overloading overload operators only when the operation is unsurprising. return `notimplemented` when an operand type is unsupported. implement equality deliberately when value comparison matters."},{"slug":"attribute-access","title":"Attribute Access","section":"Data Model","summary":"Attribute hooks customize lookup, missing attributes, and assignment.","text":"attribute access `__getattr__` is narrower than `__getattribute__` because it handles only missing attributes. `__setattr__` affects every assignment on the instance. use `property` or descriptors when the behavior is attached to a known attribute name."},{"slug":"bound-and-unbound-methods","title":"Bound and Unbound Methods","section":"Data Model","summary":"instance.method binds self automatically; Class.method is a plain function.","text":"bound and unbound methods `instance.method` produces a bound method whose `__self__` is the instance. `class.method` produces the plain function and requires you to pass the instance. each bound method is its own object; storing one captures its instance. the binding is implemented by the descriptor protocol on the function object."},{"slug":"descriptors","title":"Descriptors","section":"Data Model","summary":"Descriptors customize attribute access through __get__, __set__, or __delete__.","text":"descriptors descriptors are class attributes that participate in instance attribute access. data descriptors with `__set__` can validate or transform assignments. `property` is usually simpler for one-off managed attributes; descriptors shine when the behavior is reusable."},{"slug":"metaclasses","title":"Metaclasses","section":"Classes","summary":"A metaclass customizes how classes themselves are created.","text":"metaclasses metaclasses customize class creation, not instance behavior directly. most code should prefer class decorators, functions, or ordinary inheritance. you are most likely to meet metaclasses inside frameworks and orms."},{"slug":"context-managers","title":"Context Managers","section":"Data Model","summary":"with ensures setup and cleanup happen together.","text":"context managers files, locks, and temporary state commonly use context managers. `__enter__` and `__exit__` power the protocol. use `finally` when cleanup must happen after errors too. returning true from `__exit__` suppresses an exception; do that only intentionally."},{"slug":"delete-statements","title":"Delete Statements","section":"Data Model","summary":"del removes bindings, items, and attributes rather than producing a value.","text":"delete statements `del` removes bindings or container entries. assign `none` when absence should remain an explicit value. use container methods such as `pop()` when you need the removed value back."},{"slug":"exceptions","title":"Exceptions","section":"Errors","summary":"Use try, except, else, and finally to separate success, recovery, and cleanup.","text":"exceptions catch the most specific exception you can. `else` is for success code that should run only if the `try` block did not fail. `finally` runs whether the operation succeeded or failed. avoid bare `except:` and broad `except exception:` — they hide bugs and absorb signals like `keyboardinterrupt`."},{"slug":"assertions","title":"Assertions","section":"Errors","summary":"assert documents internal assumptions and fails loudly when they are false.","text":"assertions use `assert` for internal invariants and debugging assumptions. use explicit exceptions for user input, files, network responses, and other expected failures. assertions can be disabled with python optimization flags, so do not rely on them for security checks."},{"slug":"exception-chaining","title":"Exception Chaining","section":"Errors","summary":"raise from preserves the original cause when translating exceptions.","text":"exception chaining use `raise ... from error` when translating exceptions across a boundary. the new exception's `__cause__` points to the original exception. chaining keeps user-facing errors clear without losing debugging context."},{"slug":"exception-groups","title":"Exception Groups","section":"Errors","summary":"except* handles matching exceptions inside an ExceptionGroup.","text":"exception groups `except*` is for `exceptiongroup`, not ordinary single exceptions. each `except*` clause handles matching members of the group. exception groups often appear around concurrent work."},{"slug":"warnings","title":"Warnings","section":"Errors","summary":"warnings report soft problems without immediately stopping the program.","text":"warnings use warnings for soft problems callers can act on later. use exceptions when the current operation cannot continue. `stacklevel` should point the warning at the caller rather than inside the helper."},{"slug":"modules","title":"Modules","section":"Modules","summary":"Modules organize code into namespaces and expose reusable definitions.","text":"modules prefer plain `import module` when the namespace improves readability. use focused imports for a small number of clear names. place imports near the top of the file. imports execute module top-level code once, then reuse the cached module object."},{"slug":"import-aliases","title":"Import Aliases","section":"Modules","summary":"as gives imported modules or names a local alias.","text":"import aliases `import module as alias` keeps module-style access under a shorter or clearer name. `from module import name as alias` imports one name under a local alias. prefer plain imports unless an alias improves clarity or follows a strong convention. avoid `from module import *` because it makes dependencies harder to see."},{"slug":"packages","title":"Packages","section":"Modules","summary":"Packages organize modules into importable directories.","text":"packages a package is a module that can contain submodules. dotted imports should mirror a meaningful project structure. use `from .submodule import name` inside a package to re-export submodule names; set `__all__` to declare the public surface. prefer ordinary imports unless the module name is truly dynamic."},{"slug":"virtual-environments","title":"Virtual Environments","section":"Modules","summary":"Virtual environments isolate a project's Python packages.","text":"virtual environments use `python -m venv .venv` for everyday standard-python project setup. a venv isolates installed packages; it does not change how imports are written. this site's runner uses a deployment dependency model, not an activated shell environment. that runner constraint is separate from the standard python `venv` workflow you would use in local projects."},{"slug":"type-hints","title":"Type Hints","section":"Types","summary":"Annotations document expected types and power static analysis.","text":"type hints python does not enforce most type hints at runtime. tools like type checkers and editors use annotations to catch mistakes earlier. use `x | y` for unions and `optional[x]` for \"x or none\"; both spellings mean the same thing. reach for `typealias` when a domain name reads better than a raw primitive type. use runtime validation when untrusted input must be rejected while the program runs."},{"slug":"runtime-type-checks","title":"Runtime Type Checks","section":"Types","summary":"type, isinstance, and issubclass inspect runtime relationships.","text":"runtime type checks `type()` is exact; `isinstance()` follows inheritance. runtime checks inspect objects, not static annotations. prefer behavior, protocols, or clear validation over scattered type checks."},{"slug":"union-and-optional-types","title":"Union and Optional Types","section":"Types","summary":"The | operator describes values that may have more than one static type.","text":"union and optional types use `a | b` when a value may have either type. `t | none` means absence is an expected case, not an error by itself. narrow unions before using behavior that belongs to only one member type."},{"slug":"type-aliases","title":"Type Aliases","section":"Types","summary":"Type aliases give a meaningful name to a repeated type shape.","text":"type aliases use aliases to name repeated or domain-specific annotation shapes. a type alias does not validate values at runtime. use `newtype` when two values share a runtime representation but should not be mixed statically."},{"slug":"typed-dicts","title":"TypedDict","section":"Types","summary":"TypedDict describes dictionaries with known string keys.","text":"typed dicts use `typeddict` for dictionary records from json or apis. type checkers understand required and optional keys. runtime behavior is still ordinary dictionary behavior."},{"slug":"structured-data-shapes","title":"Structured Data Shapes","section":"Classes","summary":"dataclass, NamedTuple, and TypedDict each model records with different trade-offs.","text":"structured data shapes `@dataclass` — mutable, attribute access, methods; good default when behavior travels with data. `typing.namedtuple` — immutable, attribute + index access, tuple semantics; good for small records that flow through unpacking. `typing.typeddict` — runtime is `dict`, schema is type-checker-only; good for json-shaped data. `collections.namedtuple` is the older, untyped form of `namedtuple`; prefer the `typing` version in new code."},{"slug":"literal-and-final","title":"Literal and Final","section":"Types","summary":"Literal restricts exact values, while Final marks names that should not be rebound.","text":"literal and final `literal` narrows values to a small exact set. `final` prevents rebinding in static analysis, not at runtime. use enums when the option set needs names, behavior, or iteration over members."},{"slug":"callable-types","title":"Callable Types","section":"Types","summary":"Callable annotations describe functions passed as values.","text":"callable types use `callable[[arg], return]` for simple function-shaped values. the annotation documents how the callback will be called. for complex call signatures, protocols can be clearer."},{"slug":"generics-and-typevar","title":"Generics and TypeVar","section":"Types","summary":"Generics preserve type information across reusable functions and classes.","text":"generics and typevar a `typevar` stands for a type chosen by the caller. generic functions avoid losing information to `object` or `any`. use generics when input and output types are connected."},{"slug":"paramspec","title":"ParamSpec","section":"Types","summary":"ParamSpec preserves callable parameter types through wrappers.","text":"paramspec `paramspec` preserves a callable's parameter list through transparent wrappers. pair `paramspec` with a `typevar` when the return type should also be preserved. if the wrapper changes the public signature, write that new signature directly instead."},{"slug":"overloads","title":"Overloads","section":"Types","summary":"overload describes APIs whose return type depends on argument types.","text":"overloads put `@overload` declarations immediately before the implementation. overloads improve static precision; they do not create runtime dispatch. if all callers can work with one broad return type, a simple union annotation is usually enough."},{"slug":"casts-and-any","title":"Casts and Any","section":"Types","summary":"Any and cast are escape hatches for places static analysis cannot prove.","text":"casts and any `any` disables most static checking for a value. `cast()` tells the type checker to trust you without changing the runtime object. prefer narrowing with checks when possible."},{"slug":"newtype","title":"NewType","section":"Types","summary":"NewType creates distinct static identities for runtime-compatible values.","text":"newtype `newtype` helps type checkers distinguish values that share a runtime representation. at runtime, the value is still the underlying type. use aliases for readability; use `newtype` for static separation."},{"slug":"protocols","title":"Protocols","section":"Types","summary":"Protocol describes required behavior for structural typing.","text":"protocols protocols are for structural typing: compatibility by shape rather than explicit inheritance. type checkers understand protocols; normal runtime method calls still do the work. prefer inheritance when shared implementation matters, and protocols when only required behavior matters."},{"slug":"abstract-base-classes","title":"Abstract Base Classes","section":"Classes","summary":"ABC and abstractmethod enforce that subclasses implement required methods.","text":"abstract base classes `abc` plus `@abstractmethod` blocks instantiation until every abstract method has an implementation. abcs are nominal — subclasses opt in by inheriting; `isinstance()` reflects that opt-in. protocols are structural — any class with the right shape qualifies, regardless of inheritance. prefer an abc when shared implementation or explicit opt-in matters; prefer a protocol when only behavior at the api boundary matters."},{"slug":"enums","title":"Enums","section":"Types","summary":"Enum defines symbolic names for a fixed set of values.","text":"enums enums make states and choices explicit. members have names and values. comparing enum members avoids string typo bugs. prefer raw strings for open-ended text; prefer enums for a closed set of named choices."},{"slug":"regular-expressions","title":"Regular Expressions","section":"Text","summary":"The re module searches and extracts text using regular expressions.","text":"regular expressions use raw strings for regex patterns so backslashes are easier to read. use capturing groups when the point is extraction, not just matching. `re.match` anchors at the start; `re.search` finds the first match anywhere. `re.compile` saves work when the pattern runs more than once. `re.sub` rewrites matches; flags like `re.ignorecase` change matching behavior without rewriting the pattern. reach for string methods before regex when the pattern is simple."},{"slug":"number-parsing","title":"Number Parsing","section":"Standard Library","summary":"int() and float() parse text into numbers and raise ValueError on bad input.","text":"number parsing `int()` and `float()` are constructors that also parse strings. `int(text, base)` makes non-decimal input explicit. catch `valueerror` for recoverable user input; do not hide unexpected data corruption."},{"slug":"custom-exceptions","title":"Custom Exceptions","section":"Errors","summary":"Custom exception classes name failures that belong to your domain.","text":"custom exceptions subclass `exception` for errors callers are expected to catch. a custom exception name can be clearer than reusing a generic `valueerror` everywhere. catch custom exceptions at a boundary that can recover or report clearly."},{"slug":"json","title":"JSON","section":"Standard Library","summary":"json encodes Python values as JSON text and decodes them back.","text":"json `dumps()` returns a string; `loads()` accepts a string. json `true`, `false`, and `null` become python `true`, `false`, and `none`. use `sort_keys=true` when stable text output matters. json only represents data shapes, not arbitrary python objects or behavior."},{"slug":"logging","title":"Logging","section":"Standard Library","summary":"logging records operational events without using print as infrastructure.","text":"logging configure logging once; call named loggers throughout the program. logger and handler levels both participate in filtering. use exceptions for control flow failures, logging for operational evidence, and warnings for soft compatibility problems."},{"slug":"testing","title":"Testing","section":"Standard Library","summary":"Tests make expected behavior executable and repeatable.","text":"testing test method names should describe behavior, not implementation details. a good unit test is deterministic and independent of test order. use broader integration tests when the behavior depends on several components working together."},{"slug":"subprocesses","title":"Subprocesses","section":"Standard Library","summary":"subprocess runs external commands with explicit arguments and captured outputs.","text":"subprocesses use a list of arguments instead of shell strings when possible. capture output when the parent program needs to inspect it. `check=true` turns non-zero exits into exceptions. if you run this in local/server python, the child process is real; on this site, the runnable evidence preserves the api shape without spawning a process."},{"slug":"threads-and-processes","title":"Threads and Processes","section":"Standard Library","summary":"Threads share memory, while processes run in separate interpreters.","text":"threads and processes threads share memory, so mutable shared state needs care. processes avoid shared interpreter state but require values to cross a process boundary. prefer `asyncio` for coroutine-based i/o and executors for ordinary blocking callables. the displayed executor names are standard python concepts; the site avoids actually creating host threads or processes in the live runner."},{"slug":"networking","title":"Networking","section":"Standard Library","summary":"Networking code exchanges bytes across explicit protocol boundaries.","text":"networking network protocols move bytes, not python `str` objects. close real sockets when finished, usually with a context manager or `finally` block. use high-level http libraries for application http unless socket-level control is the lesson. cloudflare workers support http-style networking through platform apis; this example avoids outbound calls so the editable lesson stays deterministic and safe."},{"slug":"datetime","title":"Dates and Times","section":"Standard Library","summary":"datetime represents dates, times, durations, formatting, and parsing.","text":"datetime use timezone-aware datetimes for instants that cross system or user boundaries. use `date` for calendar days, `time` for clock times, `datetime` for both, and `timedelta` for durations. prefer iso 8601 strings for interchange; use `strftime` for human-facing display."},{"slug":"csv-data","title":"CSV Data","section":"Standard Library","summary":"csv reads and writes row-shaped text data.","text":"csv data let `csv` handle quoting and delimiters instead of calling `split(\",\")`. csv fields are text until your code converts them. reach for json when records need nested lists, dictionaries, booleans, or numbers that preserve their type."},{"slug":"async-await","title":"Async Await","section":"Async","summary":"async def creates coroutines, and await pauses until awaitable work completes.","text":"async await calling an async function creates a coroutine object. `await` yields control until an awaitable completes. workers request handlers are async, so this pattern appears around fetches and bindings. prefer ordinary functions when there is no awaitable work to coordinate."},{"slug":"async-iteration-and-context","title":"Async Iteration and Context","section":"Async","summary":"async for and async with consume asynchronous streams and cleanup protocols.","text":"async iteration and context `async for` consumes asynchronous iterators. `async with` awaits asynchronous setup and cleanup. these forms are common around i/o-shaped resources."}] diff --git a/public/search-index.json b/public/search-index.json new file mode 100644 index 0000000..c23e16b --- /dev/null +++ b/public/search-index.json @@ -0,0 +1 @@ +[{"slug":"hello-world","title":"Hello World","section":"Basics","summary":"The first Python program prints a line of text.","text":"hello world `print()` writes text followed by a newline. strings can be delimited with single or double quotes."},{"slug":"values","title":"Values","section":"Basics","summary":"Python programs evaluate expressions into objects such as text, numbers, booleans, and None.","text":"values values are objects; names point to them and operations usually create new values. use `is none` for the absence marker, not `== none`. this overview introduces boundaries that later pages explain in detail."},{"slug":"literals","title":"Literals","section":"Basics","summary":"Literals write values directly in Python source code.","text":"literals literals are good for small local values; constants are better for repeated values with meaning. `{}` is an empty dictionary. use `set()` for an empty set. bytes literals are binary data; string literals are unicode text. `...` evaluates to the `ellipsis` object."},{"slug":"numbers","title":"Numbers","section":"Basics","summary":"Python numbers include integers, floats, and complex values.","text":"numbers python's `int` has arbitrary precision; it grows as large as memory allows. python's `float` is approximate double-precision floating point. use `/` for true division and `//` for floor division. use `math.isclose` instead of `==` for floating-point comparison; reach for `decimal.decimal` when exact decimal precision is the domain requirement."},{"slug":"booleans","title":"Booleans","section":"Basics","summary":"Booleans represent truth values and combine with logical operators.","text":"booleans boolean constants are `true` and `false`, with capital letters. `and` and `or` short-circuit: python does not evaluate the right side if the left side already determines the result. prefer truthiness for containers and explicit comparisons when the exact boolean condition matters. `bool` subclasses `int`; `isinstance(true, int)` is `true`. exclude booleans explicitly when only \"real\" integers should pass."},{"slug":"operators","title":"Operators","section":"Basics","summary":"Operators combine, compare, and test values in expressions.","text":"operators use the clearest operator for the question: arithmetic, comparison, boolean logic, membership, identity, or bitwise manipulation. `and` and `or` short-circuit, so the right side may not run. operators have precedence; use parentheses when grouping would otherwise be hard to read. custom operator behavior should make an object feel more natural, not more clever."},{"slug":"none","title":"None","section":"Basics","summary":"None represents expected absence, distinct from missing keys and errors.","text":"none use `is none` rather than `== none`; `none` is a singleton identity value. use `none` for expected absence that callers can test. use dictionary defaults for missing mapping keys and exceptions for invalid operations."},{"slug":"variables","title":"Variables","section":"Basics","summary":"Names are bound to values with assignment.","text":"variables python variables are names bound to objects, not boxes with fixed types. rebinding a name is normal. use augmented assignment for counters and accumulators."},{"slug":"constants","title":"Constants","section":"Basics","summary":"Python uses naming conventions and optional types for values that should not change.","text":"constants python constants are a convention, not a runtime lock. use all-caps names for fixed module-level configuration. add `final` when static tooling should flag accidental rebinding."},{"slug":"truthiness","title":"Truthiness","section":"Basics","summary":"Python conditions use truthiness, not only explicit booleans.","text":"truthiness empty containers and zero-like numbers are false in conditions. use explicit comparisons when they communicate intent better than truthiness."},{"slug":"equality-and-identity","title":"Equality and Identity","section":"Data Model","summary":"== compares values, while is compares object identity.","text":"equality and identity use `==` for ordinary value comparisons. use `is` primarily for identity checks against singletons such as `none`. equal mutable containers can still be independent objects. never use `is` to compare numbers; cpython's small-integer cache makes the result an implementation detail."},{"slug":"mutability","title":"Mutability","section":"Data Model","summary":"Some objects change in place, while others return new values.","text":"mutability lists and dictionaries are mutable; strings and tuples are immutable. aliasing is useful, but copy mutable containers when independent changes are needed. pay attention to whether an operation mutates in place or returns a new value."},{"slug":"object-lifecycle","title":"Object Lifecycle","section":"Basics","summary":"Names keep objects reachable until the last reference goes away.","text":"object lifecycle assignment binds names to objects; it does not copy the object. `del name` removes one reference, not necessarily the object itself. python reclaims unreachable objects automatically, so lifetime bugs usually come from keeping references longer than intended."},{"slug":"strings","title":"Strings","section":"Text","summary":"Strings are immutable Unicode text sequences.","text":"strings use `str` for text and `bytes` for binary data. `len(text)` counts unicode code points; `len(text.encode(\"utf-8\"))` counts encoded bytes. ascii text is a useful baseline because each ascii code point is one utf-8 byte. string methods return new strings because strings are immutable. user-visible “characters” can be more subtle than code points; combining marks and emoji sequences may need specialized text handling."},{"slug":"bytes-and-bytearray","title":"Bytes and Bytearray","section":"Basics","summary":"bytes and bytearray store binary data, not Unicode text.","text":"bytes and bytearray encode text when an external boundary needs bytes. decode bytes when you want text again. indexing `bytes` returns integers from 0 to 255. use `bytearray` when binary data must be changed in place."},{"slug":"string-formatting","title":"String Formatting","section":"Text","summary":"f-strings turn values into readable text at the point of use.","text":"string formatting use `f\"...\"` strings for most new formatting code. expressions inside braces are evaluated before formatting. format specifications after `:` control alignment, width, padding, and precision."},{"slug":"conditionals","title":"Conditionals","section":"Control Flow","summary":"if, elif, and else choose which block runs.","text":"conditionals python has no mandatory parentheses around conditions; the colon and indentation define the block. comparison operators such as `<` and `==` can be chained, as in `0 < value < 10`. keep branch bodies short; move larger work into functions so the decision remains easy to scan."},{"slug":"guard-clauses","title":"Guard Clauses","section":"Control Flow","summary":"Guard clauses handle boundary cases early so the main path stays flat.","text":"guard clauses guard clauses are a readability pattern, not a separate python feature. they work best when the early cases are true boundaries. for exceptional failures, raise an exception instead of returning a sentinel string."},{"slug":"assignment-expressions","title":"Assignment Expressions","section":"Control Flow","summary":"The walrus operator assigns a value inside an expression.","text":"assignment expressions `name := expression` assigns and evaluates to the assigned value. use it to avoid computing the same value twice. prefer a normal assignment when the expression becomes hard to scan."},{"slug":"for-loops","title":"For Loops","section":"Control Flow","summary":"for iterates over values produced by an iterable.","text":"for loops a `for` loop consumes an iterable until it is exhausted. reach for `while` when the stopping condition must be rechecked manually. `iter()` and `next()` expose the protocol that `for` uses internally."},{"slug":"break-and-continue","title":"Break and Continue","section":"Control Flow","summary":"break exits a loop early, while continue skips to the next iteration.","text":"break and continue `continue` skips to the next loop iteration. `break` exits the nearest enclosing loop immediately. prefer plain `if`/`else` when the loop does not need early skip or early stop behavior."},{"slug":"loop-else","title":"Loop Else","section":"Control Flow","summary":"A loop else block runs only when the loop did not end with break.","text":"loop else loop `else` runs when the loop was not ended by `break`. it is best for search loops with a clear found/not-found split. it works with both `for` and `while` loops."},{"slug":"iterating-over-iterables","title":"Iterating over Iterables","section":"Iteration","summary":"for loops consume values from any iterable object.","text":"iterating over iterables a `for` loop consumes values from an iterable. different producers can feed the same loop protocol. prefer `enumerate()` over `range(len(...))` when you need an index."},{"slug":"iterators","title":"Iterators","section":"Iteration","summary":"iter and next expose the protocol behind for loops.","text":"iterators iterables produce iterators; iterators produce values. `next()` consumes one value from an iterator. many iterators are one-pass even when the original collection is reusable."},{"slug":"iterator-vs-iterable","title":"Iterator vs Iterable","section":"Iteration","summary":"Iterables produce fresh iterators; iterators are one-pass.","text":"iterator vs iterable an iterable produces an iterator each time `iter()` is called on it; an iterator produces values until it is exhausted. `iter(iterable)` returns a fresh iterator; `iter(iterator)` returns the same iterator. functions that traverse their input more than once must accept an iterable or materialize the input at the boundary."},{"slug":"sentinel-iteration","title":"Sentinel Iteration","section":"Iteration","summary":"iter(callable, sentinel) repeats calls until a marker value appears.","text":"sentinel iteration the callable passed to `iter(callable, sentinel)` must take no arguments. the sentinel stops iteration and is not yielded. when the loop needs richer branching, an explicit `while` loop may be clearer."},{"slug":"match-statements","title":"Match Statements","section":"Control Flow","summary":"match selects cases using structural pattern matching.","text":"match statements `match` compares structure, not just equality. patterns can bind names such as `x` and `y` while matching. put the catch-all `_` case last, because cases are tried from top to bottom."},{"slug":"advanced-match-patterns","title":"Advanced Match Patterns","section":"Control Flow","summary":"match patterns can destructure sequences, combine alternatives, and add guards.","text":"advanced match patterns use `case _` as a wildcard fallback. guards refine a pattern after the structure matches. or patterns and star patterns keep shape-based branches compact."},{"slug":"while-loops","title":"While Loops","section":"Control Flow","summary":"while repeats until changing state makes a condition false.","text":"while loops use `while` when changing state decides whether the loop continues. update loop state inside the body so the condition can become false. prefer `for` when you already have a collection, range, iterator, or generator to consume."},{"slug":"lists","title":"Lists","section":"Collections","summary":"Lists are ordered, mutable collections.","text":"lists lists are mutable sequences: methods such as `append()` change the list in place. negative indexes count from the end. `sorted()` returns a new list; `list.sort()` sorts the existing list in place."},{"slug":"tuples","title":"Tuples","section":"Collections","summary":"Tuples group a fixed number of positional values.","text":"tuples tuples are immutable sequences with fixed length. use tuples for small records where position has meaning. use lists for variable-length collections of similar items. reach for a dataclass or `namedtuple` when fields deserve names everywhere they're used."},{"slug":"unpacking","title":"Unpacking","section":"Collections","summary":"Unpacking binds names from sequences and mappings concisely.","text":"unpacking starred unpacking collects the remaining values into a list. dictionary unpacking with ** is common when calling functions with structured data. prefer indexing when you need one position; prefer unpacking when naming several positions makes the shape clearer."},{"slug":"dicts","title":"Dictionaries","section":"Collections","summary":"Dictionaries map keys to values for records, lookup, and structured data.","text":"dicts dictionaries preserve insertion order in modern python. use `get()` when a missing key has a reasonable default. use direct indexing when a missing key should be treated as an error. snapshot keys with `list(d.keys())` before deleting items in a loop; mutating during iteration raises `runtimeerror`."},{"slug":"sets","title":"Sets","section":"Collections","summary":"Sets store unique values and make membership checks explicit.","text":"sets use lists when order and repeated values matter. use sets when uniqueness and membership are the main operations. prefer lists when order or repeated values are part of the meaning. sets are unordered, so sort them when examples need deterministic display."},{"slug":"slices","title":"Slices","section":"Collections","summary":"Slices copy meaningful ranges from ordered sequences.","text":"slices slice stop indexes are excluded, so adjacent ranges compose cleanly. omitted bounds mean the beginning or end of the sequence. a negative step walks backward; `[::-1]` is a common reversed-copy idiom."},{"slug":"comprehensions","title":"Comprehensions","section":"Collections","summary":"Comprehensions build collections by mapping and filtering iterables.","text":"comprehensions the left side says what to produce; the `for` clause says where values come from. use an `if` clause for simple filters. list, dict, and set comprehensions build concrete collections immediately. switch to a loop when the transformation needs multiple steps or explanations."},{"slug":"comprehension-patterns","title":"Comprehension Patterns","section":"Collections","summary":"Comprehensions can use multiple for clauses and filters when the shape stays clear.","text":"comprehension patterns read comprehension clauses from left to right. multiple `for` clauses act like nested loops. prefer an explicit loop when the comprehension stops being obvious."},{"slug":"sorting","title":"Sorting","section":"Collections","summary":"sorted returns a new ordered list and key functions choose the sort value.","text":"sorting `sorted()` makes a new list; `list.sort()` mutates an existing list. `key=` should return the value python compares for each item. python's sort is stable, so equal keys keep their original relative order."},{"slug":"collections-module","title":"Collections Module","section":"Collections","summary":"collections provides specialized containers for common data shapes.","text":"collections module `counter` counts, `defaultdict` groups, `deque` queues, and `namedtuple` names record fields. prefer the built-in containers until a specialized shape makes the code clearer. for new structured records with defaults and methods, consider `dataclasses` instead of `namedtuple`."},{"slug":"copying-collections","title":"Copying Collections","section":"Collections","summary":"Copies can duplicate the outer container while nested objects may still be shared.","text":"copying collections assignment aliases; it does not copy. shallow copies duplicate the outer container only. deep copies are useful for nested independence, but they can be expensive and surprising for objects with external resources."},{"slug":"functions","title":"Functions","section":"Functions","summary":"Use def to name reusable behavior and return results.","text":"functions use `return` for values the caller should receive. defaults keep common calls concise. keyword arguments make options readable at the call site. never use a mutable value as a default argument; use `none` and build the container inside the function body."},{"slug":"keyword-only-arguments","title":"Keyword-only Arguments","section":"Functions","summary":"Use * to require selected function arguments to be named.","text":"keyword only arguments put `*` before options that callers should name. keyword-only flags avoid mysterious positional `true` and `false` arguments. defaults work normally for keyword-only parameters."},{"slug":"positional-only-parameters","title":"Positional-only Parameters","section":"Functions","summary":"Use / to mark parameters that callers must pass by position.","text":"positional only parameters `/` marks parameters before it as positional-only. `*` marks parameters after it as keyword-only. use these markers when the call shape is part of the api design."},{"slug":"args-and-kwargs","title":"Args and Kwargs","section":"Functions","summary":"*args collects extra positional arguments and **kwargs collects named ones.","text":"args and kwargs use these tools when a function naturally accepts a flexible shape. prefer explicit parameters when the accepted arguments are known and fixed. `*args` is a tuple; `**kwargs` is a dictionary."},{"slug":"multiple-return-values","title":"Multiple Return Values","section":"Functions","summary":"Python returns multiple values by returning a tuple and unpacking it.","text":"multiple return values a comma creates a tuple; `return a, b` returns one tuple containing two values. unpacking at the call site gives each returned position a meaningful name. use a class-like record when the result has many fields."},{"slug":"closures","title":"Closures","section":"Functions","summary":"Inner functions can remember values from an enclosing scope.","text":"closures a closure keeps access to names from the scope where the inner function was created. each call to the outer function can create a separate remembered environment. closures are useful for callbacks, small factories, and decorators. closures bind names, not values; capture loop variables with `lambda x=x: ...` to freeze them at definition time."},{"slug":"partial-functions","title":"Partial Functions","section":"Functions","summary":"functools.partial pre-fills arguments to make a more specific callable.","text":"partial functions `partial` adapts a callable by pre-filling arguments. the resulting object can be passed anywhere a callable with the remaining parameters is expected. use a regular function when the adapter needs more logic than argument binding."},{"slug":"scope-global-nonlocal","title":"Global and Nonlocal","section":"Functions","summary":"global and nonlocal choose which outer binding assignment should update.","text":"scope global nonlocal assignment inside a function is local unless declared otherwise. prefer `nonlocal` for closure state and avoid `global` unless module state is truly intended. passing values and returning results is usually easier to test than rebinding outer names."},{"slug":"recursion","title":"Recursion","section":"Functions","summary":"Recursive functions solve nested problems by calling themselves on smaller pieces.","text":"recursion every recursive function needs a base case that stops the calls. recursion fits nested data better than flat repetition. python limits recursion depth, so loops are often better for very deep or simple repetition."},{"slug":"lambdas","title":"Lambdas","section":"Functions","summary":"lambda creates small anonymous function expressions.","text":"lambdas lambdas are expressions, not statements. prefer `def` for multi-step or reused behavior. lambdas are common as `key=` functions because the behavior is local to one call."},{"slug":"generators","title":"Generators","section":"Iteration","summary":"yield creates an iterator that produces values on demand.","text":"generators generator functions are a concise way to create custom iterators; every generator is an iterator. `yield` defers work and streams values; `return` produces the whole result up front. a generator is consumed as you iterate over it. prefer a list when you need to reuse stored results; prefer a generator when values can be streamed once."},{"slug":"yield-from","title":"Yield From","section":"Iteration","summary":"yield from delegates part of a generator to another iterable.","text":"yield from `yield from iterable` yields each value from that iterable. it keeps generator pipelines compact. use a plain `yield` when producing one value directly."},{"slug":"generator-expressions","title":"Generator Expressions","section":"Iteration","summary":"Generator expressions use comprehension-like syntax to stream values lazily.","text":"generator expressions list, dict, and set comprehensions build concrete collections. generator expressions produce one-pass iterators. use generator expressions when the consumer can process values one at a time."},{"slug":"itertools","title":"Itertools","section":"Iteration","summary":"itertools composes lazy iterator streams.","text":"itertools `itertools` composes producer and transformer streams. iterator pipelines avoid building intermediate lists. use `islice()` to take a finite piece from an infinite iterator. convert to a list only when you need concrete results."},{"slug":"decorators","title":"Decorators","section":"Functions","summary":"Decorators wrap or register functions using @ syntax.","text":"decorators `@decorator` is shorthand for assigning `func = decorator(func)`. decorators can wrap, replace, or register functions. use `functools.wraps` in production wrappers that should preserve metadata."},{"slug":"classes","title":"Classes","section":"Classes","summary":"Classes bundle data and behavior into new object types.","text":"classes `self` is the instance the method is operating on. `__init__` initializes each new object. class attributes are shared across instances; instance attributes belong to one object. put mutable defaults in `__init__`, not on the class body. use classes when behavior belongs with state; use dictionaries for looser structured data."},{"slug":"inheritance-and-super","title":"Inheritance and Super","section":"Classes","summary":"Inheritance reuses behavior, and super delegates to a parent implementation.","text":"inheritance and super inheritance models an “is a specialized kind of” relationship. `super()` calls the next implementation in the method resolution order. prefer composition when an object only needs to use another object."},{"slug":"classmethods-and-staticmethods","title":"Classmethods and Staticmethods","section":"Classes","summary":"Three method shapes: instance, class, and static — each receives a different first argument.","text":"classmethods and staticmethods instance methods need an instance; classmethods and staticmethods can be called on the class. use `@classmethod` for alternate constructors and class-level operations that respect subclassing. use `@staticmethod` only when a function is truly independent of instance and class state but still belongs in the class's namespace. a free function is often the right answer when neither decorator applies."},{"slug":"dataclasses","title":"Dataclasses","section":"Classes","summary":"dataclass generates common class methods for data containers.","text":"dataclasses type annotations define dataclass fields. dataclasses generate methods but remain normal python classes. use `field()` for advanced defaults such as per-instance lists or dictionaries."},{"slug":"properties","title":"Properties","section":"Classes","summary":"@property keeps attribute syntax while adding computation or validation.","text":"properties properties let apis start simple and grow validation or computation later. callers access a property like an attribute, not like a method. use methods instead when work is expensive or action-like."},{"slug":"special-methods","title":"Special Methods","section":"Data Model","summary":"Special methods connect your objects to Python syntax and built-ins.","text":"special methods dunder methods are looked up by python's data model protocols. `__repr__` is the developer-facing form; `__str__` is the user-facing form. `print()` falls back to `__repr__` when `__str__` is missing. defining `__eq__` removes the default `__hash__`; restore it when the type should be hashable. container protocols (`__contains__`, `__getitem__`, `__setitem__`, `__bool__`) make instances behave like built-in containers. `__call__` makes instances callable; `__enter__`/`__exit__` make them context managers. implement the smallest protocol that makes your object feel native."},{"slug":"truth-and-size","title":"Truth and Size","section":"Data Model","summary":"__bool__ and __len__ decide how objects behave in truth tests and len().","text":"truth and size prefer `__len__` for sized containers. prefer `__bool__` when truth has domain meaning. keep truth tests unsurprising; surprising falsy objects make conditionals harder to read."},{"slug":"container-protocols","title":"Container Protocols","section":"Data Model","summary":"Container methods connect objects to indexing, membership, and item assignment.","text":"container protocols implement the narrowest container protocol your object needs. use `keyerror` and `indexerror` consistently with built-in containers. if a plain `dict` or `list` is enough, prefer it over a custom container."},{"slug":"callable-objects","title":"Callable Objects","section":"Data Model","summary":"__call__ lets an instance behave like a function while keeping state.","text":"callable objects `callable(obj)` checks whether an object can be called. callable objects are good for named, stateful behavior. prefer plain functions when no instance state is needed."},{"slug":"operator-overloading","title":"Operator Overloading","section":"Data Model","summary":"Operator methods let objects define arithmetic and comparison syntax.","text":"operator overloading overload operators only when the operation is unsurprising. return `notimplemented` when an operand type is unsupported. implement equality deliberately when value comparison matters."},{"slug":"attribute-access","title":"Attribute Access","section":"Data Model","summary":"Attribute hooks customize lookup, missing attributes, and assignment.","text":"attribute access `__getattr__` is narrower than `__getattribute__` because it handles only missing attributes. `__setattr__` affects every assignment on the instance. use `property` or descriptors when the behavior is attached to a known attribute name."},{"slug":"bound-and-unbound-methods","title":"Bound and Unbound Methods","section":"Data Model","summary":"instance.method binds self automatically; Class.method is a plain function.","text":"bound and unbound methods `instance.method` produces a bound method whose `__self__` is the instance. `class.method` produces the plain function and requires you to pass the instance. each bound method is its own object; storing one captures its instance. the binding is implemented by the descriptor protocol on the function object."},{"slug":"descriptors","title":"Descriptors","section":"Data Model","summary":"Descriptors customize attribute access through __get__, __set__, or __delete__.","text":"descriptors descriptors are class attributes that participate in instance attribute access. data descriptors with `__set__` can validate or transform assignments. `property` is usually simpler for one-off managed attributes; descriptors shine when the behavior is reusable."},{"slug":"metaclasses","title":"Metaclasses","section":"Classes","summary":"A metaclass customizes how classes themselves are created.","text":"metaclasses metaclasses customize class creation, not instance behavior directly. most code should prefer class decorators, functions, or ordinary inheritance. you are most likely to meet metaclasses inside frameworks and orms."},{"slug":"context-managers","title":"Context Managers","section":"Data Model","summary":"with ensures setup and cleanup happen together.","text":"context managers files, locks, and temporary state commonly use context managers. `__enter__` and `__exit__` power the protocol. use `finally` when cleanup must happen after errors too. returning true from `__exit__` suppresses an exception; do that only intentionally."},{"slug":"delete-statements","title":"Delete Statements","section":"Data Model","summary":"del removes bindings, items, and attributes rather than producing a value.","text":"delete statements `del` removes bindings or container entries. assign `none` when absence should remain an explicit value. use container methods such as `pop()` when you need the removed value back."},{"slug":"exceptions","title":"Exceptions","section":"Errors","summary":"Use try, except, else, and finally to separate success, recovery, and cleanup.","text":"exceptions catch the most specific exception you can. `else` is for success code that should run only if the `try` block did not fail. `finally` runs whether the operation succeeded or failed. avoid bare `except:` and broad `except exception:` — they hide bugs and absorb signals like `keyboardinterrupt`."},{"slug":"assertions","title":"Assertions","section":"Errors","summary":"assert documents internal assumptions and fails loudly when they are false.","text":"assertions use `assert` for internal invariants and debugging assumptions. use explicit exceptions for user input, files, network responses, and other expected failures. assertions can be disabled with python optimization flags, so do not rely on them for security checks."},{"slug":"exception-chaining","title":"Exception Chaining","section":"Errors","summary":"raise from preserves the original cause when translating exceptions.","text":"exception chaining use `raise ... from error` when translating exceptions across a boundary. the new exception's `__cause__` points to the original exception. chaining keeps user-facing errors clear without losing debugging context."},{"slug":"exception-groups","title":"Exception Groups","section":"Errors","summary":"except* handles matching exceptions inside an ExceptionGroup.","text":"exception groups `except*` is for `exceptiongroup`, not ordinary single exceptions. each `except*` clause handles matching members of the group. exception groups often appear around concurrent work."},{"slug":"warnings","title":"Warnings","section":"Errors","summary":"warnings report soft problems without immediately stopping the program.","text":"warnings use warnings for soft problems callers can act on later. use exceptions when the current operation cannot continue. `stacklevel` should point the warning at the caller rather than inside the helper."},{"slug":"modules","title":"Modules","section":"Modules","summary":"Modules organize code into namespaces and expose reusable definitions.","text":"modules prefer plain `import module` when the namespace improves readability. use focused imports for a small number of clear names. place imports near the top of the file. imports execute module top-level code once, then reuse the cached module object."},{"slug":"import-aliases","title":"Import Aliases","section":"Modules","summary":"as gives imported modules or names a local alias.","text":"import aliases `import module as alias` keeps module-style access under a shorter or clearer name. `from module import name as alias` imports one name under a local alias. prefer plain imports unless an alias improves clarity or follows a strong convention. avoid `from module import *` because it makes dependencies harder to see."},{"slug":"packages","title":"Packages","section":"Modules","summary":"Packages organize modules into importable directories.","text":"packages a package is a module that can contain submodules. dotted imports should mirror a meaningful project structure. use `from .submodule import name` inside a package to re-export submodule names; set `__all__` to declare the public surface. prefer ordinary imports unless the module name is truly dynamic."},{"slug":"virtual-environments","title":"Virtual Environments","section":"Modules","summary":"Virtual environments isolate a project's Python packages.","text":"virtual environments use `python -m venv .venv` for everyday standard-python project setup. a venv isolates installed packages; it does not change how imports are written. this site's runner uses a deployment dependency model, not an activated shell environment. that runner constraint is separate from the standard python `venv` workflow you would use in local projects."},{"slug":"type-hints","title":"Type Hints","section":"Types","summary":"Annotations document expected types and power static analysis.","text":"type hints python does not enforce most type hints at runtime. tools like type checkers and editors use annotations to catch mistakes earlier. use `x | y` for unions and `optional[x]` for \"x or none\"; both spellings mean the same thing. reach for `typealias` when a domain name reads better than a raw primitive type. use runtime validation when untrusted input must be rejected while the program runs."},{"slug":"runtime-type-checks","title":"Runtime Type Checks","section":"Types","summary":"type, isinstance, and issubclass inspect runtime relationships.","text":"runtime type checks `type()` is exact; `isinstance()` follows inheritance. runtime checks inspect objects, not static annotations. prefer behavior, protocols, or clear validation over scattered type checks."},{"slug":"union-and-optional-types","title":"Union and Optional Types","section":"Types","summary":"The | operator describes values that may have more than one static type.","text":"union and optional types use `a | b` when a value may have either type. `t | none` means absence is an expected case, not an error by itself. narrow unions before using behavior that belongs to only one member type."},{"slug":"type-aliases","title":"Type Aliases","section":"Types","summary":"Type aliases give a meaningful name to a repeated type shape.","text":"type aliases use aliases to name repeated or domain-specific annotation shapes. a type alias does not validate values at runtime. use `newtype` when two values share a runtime representation but should not be mixed statically."},{"slug":"typed-dicts","title":"TypedDict","section":"Types","summary":"TypedDict describes dictionaries with known string keys.","text":"typed dicts use `typeddict` for dictionary records from json or apis. type checkers understand required and optional keys. runtime behavior is still ordinary dictionary behavior."},{"slug":"structured-data-shapes","title":"Structured Data Shapes","section":"Classes","summary":"dataclass, NamedTuple, and TypedDict each model records with different trade-offs.","text":"structured data shapes `@dataclass` — mutable, attribute access, methods; good default when behavior travels with data. `typing.namedtuple` — immutable, attribute + index access, tuple semantics; good for small records that flow through unpacking. `typing.typeddict` — runtime is `dict`, schema is type-checker-only; good for json-shaped data. `collections.namedtuple` is the older, untyped form of `namedtuple`; prefer the `typing` version in new code."},{"slug":"literal-and-final","title":"Literal and Final","section":"Types","summary":"Literal restricts exact values, while Final marks names that should not be rebound.","text":"literal and final `literal` narrows values to a small exact set. `final` prevents rebinding in static analysis, not at runtime. use enums when the option set needs names, behavior, or iteration over members."},{"slug":"callable-types","title":"Callable Types","section":"Types","summary":"Callable annotations describe functions passed as values.","text":"callable types use `callable[[arg], return]` for simple function-shaped values. the annotation documents how the callback will be called. for complex call signatures, protocols can be clearer."},{"slug":"generics-and-typevar","title":"Generics and TypeVar","section":"Types","summary":"Generics preserve type information across reusable functions and classes.","text":"generics and typevar a `typevar` stands for a type chosen by the caller. generic functions avoid losing information to `object` or `any`. use generics when input and output types are connected."},{"slug":"paramspec","title":"ParamSpec","section":"Types","summary":"ParamSpec preserves callable parameter types through wrappers.","text":"paramspec `paramspec` preserves a callable's parameter list through transparent wrappers. pair `paramspec` with a `typevar` when the return type should also be preserved. if the wrapper changes the public signature, write that new signature directly instead."},{"slug":"overloads","title":"Overloads","section":"Types","summary":"overload describes APIs whose return type depends on argument types.","text":"overloads put `@overload` declarations immediately before the implementation. overloads improve static precision; they do not create runtime dispatch. if all callers can work with one broad return type, a simple union annotation is usually enough."},{"slug":"casts-and-any","title":"Casts and Any","section":"Types","summary":"Any and cast are escape hatches for places static analysis cannot prove.","text":"casts and any `any` disables most static checking for a value. `cast()` tells the type checker to trust you without changing the runtime object. prefer narrowing with checks when possible."},{"slug":"newtype","title":"NewType","section":"Types","summary":"NewType creates distinct static identities for runtime-compatible values.","text":"newtype `newtype` helps type checkers distinguish values that share a runtime representation. at runtime, the value is still the underlying type. use aliases for readability; use `newtype` for static separation."},{"slug":"protocols","title":"Protocols","section":"Types","summary":"Protocol describes required behavior for structural typing.","text":"protocols protocols are for structural typing: compatibility by shape rather than explicit inheritance. type checkers understand protocols; normal runtime method calls still do the work. prefer inheritance when shared implementation matters, and protocols when only required behavior matters."},{"slug":"abstract-base-classes","title":"Abstract Base Classes","section":"Classes","summary":"ABC and abstractmethod enforce that subclasses implement required methods.","text":"abstract base classes `abc` plus `@abstractmethod` blocks instantiation until every abstract method has an implementation. abcs are nominal — subclasses opt in by inheriting; `isinstance()` reflects that opt-in. protocols are structural — any class with the right shape qualifies, regardless of inheritance. prefer an abc when shared implementation or explicit opt-in matters; prefer a protocol when only behavior at the api boundary matters."},{"slug":"enums","title":"Enums","section":"Types","summary":"Enum defines symbolic names for a fixed set of values.","text":"enums enums make states and choices explicit. members have names and values. comparing enum members avoids string typo bugs. prefer raw strings for open-ended text; prefer enums for a closed set of named choices."},{"slug":"regular-expressions","title":"Regular Expressions","section":"Text","summary":"The re module searches and extracts text using regular expressions.","text":"regular expressions use raw strings for regex patterns so backslashes are easier to read. use capturing groups when the point is extraction, not just matching. `re.match` anchors at the start; `re.search` finds the first match anywhere. `re.compile` saves work when the pattern runs more than once. `re.sub` rewrites matches; flags like `re.ignorecase` change matching behavior without rewriting the pattern. reach for string methods before regex when the pattern is simple."},{"slug":"number-parsing","title":"Number Parsing","section":"Standard Library","summary":"int() and float() parse text into numbers and raise ValueError on bad input.","text":"number parsing `int()` and `float()` are constructors that also parse strings. `int(text, base)` makes non-decimal input explicit. catch `valueerror` for recoverable user input; do not hide unexpected data corruption."},{"slug":"custom-exceptions","title":"Custom Exceptions","section":"Errors","summary":"Custom exception classes name failures that belong to your domain.","text":"custom exceptions subclass `exception` for errors callers are expected to catch. a custom exception name can be clearer than reusing a generic `valueerror` everywhere. catch custom exceptions at a boundary that can recover or report clearly."},{"slug":"json","title":"JSON","section":"Standard Library","summary":"json encodes Python values as JSON text and decodes them back.","text":"json `dumps()` returns a string; `loads()` accepts a string. json `true`, `false`, and `null` become python `true`, `false`, and `none`. use `sort_keys=true` when stable text output matters. json only represents data shapes, not arbitrary python objects or behavior."},{"slug":"logging","title":"Logging","section":"Standard Library","summary":"logging records operational events without using print as infrastructure.","text":"logging configure logging once; call named loggers throughout the program. logger and handler levels both participate in filtering. use exceptions for control flow failures, logging for operational evidence, and warnings for soft compatibility problems."},{"slug":"testing","title":"Testing","section":"Standard Library","summary":"Tests make expected behavior executable and repeatable.","text":"testing test method names should describe behavior, not implementation details. a good unit test is deterministic and independent of test order. use broader integration tests when the behavior depends on several components working together."},{"slug":"subprocesses","title":"Subprocesses","section":"Standard Library","summary":"subprocess runs external commands with explicit arguments and captured outputs.","text":"subprocesses use a list of arguments instead of shell strings when possible. capture output when the parent program needs to inspect it. `check=true` turns non-zero exits into exceptions. if you run this in local/server python, the child process is real; on this site, the runnable evidence preserves the api shape without spawning a process."},{"slug":"threads-and-processes","title":"Threads and Processes","section":"Standard Library","summary":"Threads share memory, while processes run in separate interpreters.","text":"threads and processes threads share memory, so mutable shared state needs care. processes avoid shared interpreter state but require values to cross a process boundary. prefer `asyncio` for coroutine-based i/o and executors for ordinary blocking callables. the displayed executor names are standard python concepts; the site avoids actually creating host threads or processes in the live runner."},{"slug":"networking","title":"Networking","section":"Standard Library","summary":"Networking code exchanges bytes across explicit protocol boundaries.","text":"networking network protocols move bytes, not python `str` objects. close real sockets when finished, usually with a context manager or `finally` block. use high-level http libraries for application http unless socket-level control is the lesson. cloudflare workers support http-style networking through platform apis; this example avoids outbound calls so the editable lesson stays deterministic and safe."},{"slug":"datetime","title":"Dates and Times","section":"Standard Library","summary":"datetime represents dates, times, durations, formatting, and parsing.","text":"datetime use timezone-aware datetimes for instants that cross system or user boundaries. use `date` for calendar days, `time` for clock times, `datetime` for both, and `timedelta` for durations. prefer iso 8601 strings for interchange; use `strftime` for human-facing display."},{"slug":"csv-data","title":"CSV Data","section":"Standard Library","summary":"csv reads and writes row-shaped text data.","text":"csv data let `csv` handle quoting and delimiters instead of calling `split(\",\")`. csv fields are text until your code converts them. reach for json when records need nested lists, dictionaries, booleans, or numbers that preserve their type."},{"slug":"async-await","title":"Async Await","section":"Async","summary":"async def creates coroutines, and await pauses until awaitable work completes.","text":"async await calling an async function creates a coroutine object. `await` yields control until an awaitable completes. workers request handlers are async, so this pattern appears around fetches and bindings. prefer ordinary functions when there is no awaitable work to coordinate."},{"slug":"async-iteration-and-context","title":"Async Iteration and Context","section":"Async","summary":"async for and async with consume asynchronous streams and cleanup protocols.","text":"async iteration and context `async for` consumes asynchronous iterators. `async with` awaits asynchronous setup and cleanup. these forms are common around i/o-shaped resources."}] diff --git a/public/search.ab0effeac6ce.js b/public/search.ab0effeac6ce.js new file mode 100644 index 0000000..2951819 --- /dev/null +++ b/public/search.ab0effeac6ce.js @@ -0,0 +1,135 @@ +// Client-side example search. Ranking is exported so +// scripts/check_search_ranking.mjs can exercise it in Node. + +export function rankExamples(query, entries, limit = 8) { + const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!tokens.length) return []; + const scored = []; + for (const entry of entries) { + const title = entry.title.toLowerCase(); + const slug = entry.slug.toLowerCase(); + const section = entry.section.toLowerCase(); + const summary = entry.summary.toLowerCase(); + let score = 0; + let matched = true; + for (const token of tokens) { + let tokenScore = 0; + if (title === token) tokenScore = 100; + else if (title.startsWith(token)) tokenScore = 80; + else if (new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(title)) tokenScore = 60; + else if (slug.includes(token)) tokenScore = 50; + else if (section.startsWith(token)) tokenScore = 30; + else if (summary.includes(token)) tokenScore = 25; + else if (entry.text.includes(token)) tokenScore = 15; + if (!tokenScore) { matched = false; break; } + score += tokenScore; + } + if (matched) scored.push({ entry, score }); + } + scored.sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title)); + return scored.slice(0, limit).map((item) => item.entry); +} + +function wireSearch() { + const container = document.querySelector('.site-search'); + const input = document.getElementById('site-search-input'); + const results = document.getElementById('site-search-results'); + if (!container || !input || !results) return; + + let entries = null; + let loading = null; + function loadIndex() { + if (!loading) { + loading = fetch(container.dataset.searchIndex) + .then((response) => response.json()) + .then((data) => { entries = data; }) + .catch(() => { loading = null; }); + } + return loading; + } + + function hideResults() { + results.hidden = true; + results.replaceChildren(); + } + + // Result nodes are built with textContent so catalog fields can never + // be parsed as markup. + function resultNode(entry) { + const item = document.createElement('li'); + const link = document.createElement('a'); + link.href = `/examples/${encodeURIComponent(entry.slug)}`; + const title = document.createElement('strong'); + title.textContent = entry.title; + const meta = document.createElement('span'); + meta.className = 'meta'; + meta.textContent = ` · ${entry.section}`; + link.append(title, meta); + item.appendChild(link); + return item; + } + + function renderResults() { + if (!entries) return; + const matches = rankExamples(input.value, entries); + if (!matches.length) { + if (input.value.trim()) { + const empty = document.createElement('li'); + empty.className = 'search-empty'; + empty.textContent = 'No matching examples.'; + results.replaceChildren(empty); + results.hidden = false; + } else { + hideResults(); + } + return; + } + results.replaceChildren(...matches.map(resultNode)); + results.hidden = false; + } + + input.addEventListener('focus', loadIndex, { once: true }); + input.addEventListener('input', () => { loadIndex().then(renderResults); }); + input.addEventListener('keydown', (event) => { + const links = [...results.querySelectorAll('a')]; + if (event.key === 'Escape') { + input.value = ''; + hideResults(); + } else if (event.key === 'ArrowDown' && links.length) { + event.preventDefault(); + links[0].focus(); + } else if (event.key === 'Enter' && links.length) { + event.preventDefault(); + links[0].click(); + } + }); + results.addEventListener('keydown', (event) => { + const links = [...results.querySelectorAll('a')]; + const index = links.indexOf(document.activeElement); + if (event.key === 'ArrowDown' && index >= 0 && index + 1 < links.length) { + event.preventDefault(); + links[index + 1].focus(); + } else if (event.key === 'ArrowUp' && index > 0) { + event.preventDefault(); + links[index - 1].focus(); + } else if (event.key === 'ArrowUp' && index === 0) { + event.preventDefault(); + input.focus(); + } else if (event.key === 'Escape') { + input.focus(); + hideResults(); + } + }); + document.addEventListener('keydown', (event) => { + if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable) return; + event.preventDefault(); + input.focus(); + }); + document.addEventListener('click', (event) => { + if (!container.contains(event.target)) hideResults(); + }); +} + +if (typeof document !== 'undefined') wireSearch(); diff --git a/public/search.js b/public/search.js new file mode 100644 index 0000000..2951819 --- /dev/null +++ b/public/search.js @@ -0,0 +1,135 @@ +// Client-side example search. Ranking is exported so +// scripts/check_search_ranking.mjs can exercise it in Node. + +export function rankExamples(query, entries, limit = 8) { + const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!tokens.length) return []; + const scored = []; + for (const entry of entries) { + const title = entry.title.toLowerCase(); + const slug = entry.slug.toLowerCase(); + const section = entry.section.toLowerCase(); + const summary = entry.summary.toLowerCase(); + let score = 0; + let matched = true; + for (const token of tokens) { + let tokenScore = 0; + if (title === token) tokenScore = 100; + else if (title.startsWith(token)) tokenScore = 80; + else if (new RegExp(`\\b${token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(title)) tokenScore = 60; + else if (slug.includes(token)) tokenScore = 50; + else if (section.startsWith(token)) tokenScore = 30; + else if (summary.includes(token)) tokenScore = 25; + else if (entry.text.includes(token)) tokenScore = 15; + if (!tokenScore) { matched = false; break; } + score += tokenScore; + } + if (matched) scored.push({ entry, score }); + } + scored.sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title)); + return scored.slice(0, limit).map((item) => item.entry); +} + +function wireSearch() { + const container = document.querySelector('.site-search'); + const input = document.getElementById('site-search-input'); + const results = document.getElementById('site-search-results'); + if (!container || !input || !results) return; + + let entries = null; + let loading = null; + function loadIndex() { + if (!loading) { + loading = fetch(container.dataset.searchIndex) + .then((response) => response.json()) + .then((data) => { entries = data; }) + .catch(() => { loading = null; }); + } + return loading; + } + + function hideResults() { + results.hidden = true; + results.replaceChildren(); + } + + // Result nodes are built with textContent so catalog fields can never + // be parsed as markup. + function resultNode(entry) { + const item = document.createElement('li'); + const link = document.createElement('a'); + link.href = `/examples/${encodeURIComponent(entry.slug)}`; + const title = document.createElement('strong'); + title.textContent = entry.title; + const meta = document.createElement('span'); + meta.className = 'meta'; + meta.textContent = ` · ${entry.section}`; + link.append(title, meta); + item.appendChild(link); + return item; + } + + function renderResults() { + if (!entries) return; + const matches = rankExamples(input.value, entries); + if (!matches.length) { + if (input.value.trim()) { + const empty = document.createElement('li'); + empty.className = 'search-empty'; + empty.textContent = 'No matching examples.'; + results.replaceChildren(empty); + results.hidden = false; + } else { + hideResults(); + } + return; + } + results.replaceChildren(...matches.map(resultNode)); + results.hidden = false; + } + + input.addEventListener('focus', loadIndex, { once: true }); + input.addEventListener('input', () => { loadIndex().then(renderResults); }); + input.addEventListener('keydown', (event) => { + const links = [...results.querySelectorAll('a')]; + if (event.key === 'Escape') { + input.value = ''; + hideResults(); + } else if (event.key === 'ArrowDown' && links.length) { + event.preventDefault(); + links[0].focus(); + } else if (event.key === 'Enter' && links.length) { + event.preventDefault(); + links[0].click(); + } + }); + results.addEventListener('keydown', (event) => { + const links = [...results.querySelectorAll('a')]; + const index = links.indexOf(document.activeElement); + if (event.key === 'ArrowDown' && index >= 0 && index + 1 < links.length) { + event.preventDefault(); + links[index + 1].focus(); + } else if (event.key === 'ArrowUp' && index > 0) { + event.preventDefault(); + links[index - 1].focus(); + } else if (event.key === 'ArrowUp' && index === 0) { + event.preventDefault(); + input.focus(); + } else if (event.key === 'Escape') { + input.focus(); + hideResults(); + } + }); + document.addEventListener('keydown', (event) => { + if (event.key !== '/' || event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable) return; + event.preventDefault(); + input.focus(); + }); + document.addEventListener('click', (event) => { + if (!container.contains(event.target)) hideResults(); + }); +} + +if (typeof document !== 'undefined') wireSearch(); diff --git a/public/site.e87d4baf77e6.css b/public/site.8804b14e63bc.css similarity index 82% rename from public/site.e87d4baf77e6.css rename to public/site.8804b14e63bc.css index 1e24c4b..78cd747 100644 --- a/public/site.e87d4baf77e6.css +++ b/public/site.8804b14e63bc.css @@ -1,8 +1,10 @@ -:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } +:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --header-veil: rgba(245, 241, 235, 0.82); --header-veil-0: rgba(245, 241, 235, 0); --header-veil-solid: rgba(245, 241, 235, 0.95); --figure-paper: #F5F1EB; --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } * { box-sizing: border-box; } html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 16px/1.6 FT Kunst Grotesk, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); } - header { position: sticky; top: 0; z-index: 2; margin: 0 calc(-1 * var(--space-4)) var(--space-5); padding: var(--space-2) var(--space-4); backdrop-filter: blur(16px); background: rgba(245, 241, 235, 0.82); } + header { position: sticky; top: 0; z-index: 2; margin: 0 calc(-1 * var(--space-4)) var(--space-5); padding: var(--space-2) var(--space-4); backdrop-filter: blur(16px); background: var(--header-veil); } + .skip-link { position: absolute; left: -9999px; } + .skip-link:focus { position: fixed; left: var(--space-3); top: var(--space-3); z-index: 10; padding: .6rem 1rem; border: 1px solid var(--accent); border-radius: .5rem; background: var(--surface); color: var(--text); } nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } nav a { color: inherit; text-decoration: underline; text-decoration-color: var(--hairline); text-underline-offset: .22em; transition-property: color, text-decoration-color; transition-duration: 160ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); } nav a:hover { color: var(--accent); text-decoration-color: var(--accent); } @@ -38,7 +40,7 @@ .hero h1 { animation: hero-h1-morph linear forwards; animation-timeline: scroll(root); animation-range: 0 240px; } .hero p { animation: hero-p-fade linear forwards; animation-timeline: scroll(root); animation-range: 0 140px; } body:has(.hero) { padding-top: var(--space-2); } - body:has(.hero) header { opacity: 0; background: rgba(245, 241, 235, 0); box-shadow: none; margin-bottom: var(--space-2); animation: header-emerge linear forwards; animation-timeline: scroll(root); animation-range: 40px 240px; } + body:has(.hero) header { opacity: 0; background: var(--header-veil-0); box-shadow: none; margin-bottom: var(--space-2); animation: header-emerge linear forwards; animation-timeline: scroll(root); animation-range: 40px 240px; } body:has(.hero) header .brand { filter: blur(4px); transform: scale(0.88); animation: brand-focus linear forwards; animation-timeline: scroll(root); animation-range: 80px 240px; } } } @@ -55,8 +57,17 @@ to { filter: blur(0); transform: scale(1); } } @keyframes header-emerge { - to { opacity: 1; background: rgba(245, 241, 235, 0.95); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); } + to { opacity: 1; background: var(--header-veil-solid); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); } } + .site-search { position: relative; max-width: 44rem; margin: 0 0 var(--space-5); } + .site-search input { width: 100%; min-height: 44px; padding: .6rem 1rem; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); color: var(--text); font: inherit; transition: border-color 160ms cubic-bezier(0.2, 0, 0, 1), box-shadow 160ms cubic-bezier(0.2, 0, 0, 1); } + .site-search input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + .site-search input::placeholder { color: var(--muted); } + .search-results { position: absolute; inset-inline: 0; top: 100%; z-index: 3; margin: .35rem 0 0; padding: .35rem; list-style: none; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); box-shadow: 0 12px 42px rgba(0, 0, 0, .14); } + .search-results li { margin: 0; } + .search-results a { display: block; padding: .5rem .65rem; border-radius: .5rem; color: var(--text); text-decoration: none; } + .search-results a:hover, .search-results a:focus { background: var(--accent-soft); outline: 0; } + .search-results .search-empty { padding: .5rem .65rem; color: var(--muted); } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: var(--space-3); } .home-section { margin-top: var(--space-6); } .home-section:first-of-type { margin-top: 0; } @@ -160,3 +171,30 @@ .cell-banner--1 { margin-block: var(--space-6); } .cell-banner--1 figure { width: 100%; max-width: clamp(280px, 65vw, 640px); } .cell-banner--1 figcaption { max-width: 42ch; } + /* Dark palette: same warm hue family as the light theme, inverted. + Marginalia figures keep their locked light palette, so in dark + mode each SVG sits on a light "paper" chip instead of being + recoloured — the grammar stays untouched. Shiki emits dual-theme + CSS variables; the swap below activates the dark set. */ + @media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --accent: #FF6B2E; + --accent-hover: #FF8A57; + --accent-soft: rgba(255, 107, 46, 0.14); + --text: #F3E7DC; + --muted: rgba(243, 231, 220, 0.72); + --page: #1B120B; + --surface: #241810; + --surface-2: #281B12; + --surface-3: #2F2015; + --hairline: #43301F; + --hairline-soft: rgba(67, 48, 31, 0.6); + --header-veil: rgba(27, 18, 11, 0.82); + --header-veil-0: rgba(27, 18, 11, 0); + --header-veil-solid: rgba(27, 18, 11, 0.95); + } + .cell-banner figure svg, .journey-section-figure svg { padding: var(--space-2); border-radius: .5rem; background: var(--figure-paper); } + .button { color: #FFF7EF; } + .shiki, .shiki span { color: var(--shiki-dark) !important; } + } diff --git a/public/site.css b/public/site.css index 1e24c4b..78cd747 100644 --- a/public/site.css +++ b/public/site.css @@ -1,8 +1,10 @@ -:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } +:root { color-scheme: light; --accent: #FF4801; --accent-hover: #FF7038; --accent-soft: rgba(255, 72, 1, 0.08); --text: #521000; --muted: rgba(82, 16, 0, 0.7); --page: #F5F1EB; --surface: #FFFBF5; --surface-2: #FFFDFB; --surface-3: #FEF7ED; --hairline: #EBD5C1; --hairline-soft: rgba(235, 213, 193, 0.5); --header-veil: rgba(245, 241, 235, 0.82); --header-veil-0: rgba(245, 241, 235, 0); --header-veil-solid: rgba(245, 241, 235, 0.95); --figure-paper: #F5F1EB; --space-1: .5rem; --space-2: .75rem; --space-3: 1rem; --space-4: 1.5rem; --space-5: 2rem; --space-6: 3rem; } * { box-sizing: border-box; } html { -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } body { max-width: 1040px; margin: 0 auto; padding: var(--space-4); color: var(--text); font: 16px/1.6 FT Kunst Grotesk, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.10), transparent 34rem), var(--page); } - header { position: sticky; top: 0; z-index: 2; margin: 0 calc(-1 * var(--space-4)) var(--space-5); padding: var(--space-2) var(--space-4); backdrop-filter: blur(16px); background: rgba(245, 241, 235, 0.82); } + header { position: sticky; top: 0; z-index: 2; margin: 0 calc(-1 * var(--space-4)) var(--space-5); padding: var(--space-2) var(--space-4); backdrop-filter: blur(16px); background: var(--header-veil); } + .skip-link { position: absolute; left: -9999px; } + .skip-link:focus { position: fixed; left: var(--space-3); top: var(--space-3); z-index: 10; padding: .6rem 1rem; border: 1px solid var(--accent); border-radius: .5rem; background: var(--surface); color: var(--text); } nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } nav a { color: inherit; text-decoration: underline; text-decoration-color: var(--hairline); text-underline-offset: .22em; transition-property: color, text-decoration-color; transition-duration: 160ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); } nav a:hover { color: var(--accent); text-decoration-color: var(--accent); } @@ -38,7 +40,7 @@ .hero h1 { animation: hero-h1-morph linear forwards; animation-timeline: scroll(root); animation-range: 0 240px; } .hero p { animation: hero-p-fade linear forwards; animation-timeline: scroll(root); animation-range: 0 140px; } body:has(.hero) { padding-top: var(--space-2); } - body:has(.hero) header { opacity: 0; background: rgba(245, 241, 235, 0); box-shadow: none; margin-bottom: var(--space-2); animation: header-emerge linear forwards; animation-timeline: scroll(root); animation-range: 40px 240px; } + body:has(.hero) header { opacity: 0; background: var(--header-veil-0); box-shadow: none; margin-bottom: var(--space-2); animation: header-emerge linear forwards; animation-timeline: scroll(root); animation-range: 40px 240px; } body:has(.hero) header .brand { filter: blur(4px); transform: scale(0.88); animation: brand-focus linear forwards; animation-timeline: scroll(root); animation-range: 80px 240px; } } } @@ -55,8 +57,17 @@ to { filter: blur(0); transform: scale(1); } } @keyframes header-emerge { - to { opacity: 1; background: rgba(245, 241, 235, 0.95); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); } + to { opacity: 1; background: var(--header-veil-solid); box-shadow: 0 1px 8px rgba(82, 16, 0, 0.06); } } + .site-search { position: relative; max-width: 44rem; margin: 0 0 var(--space-5); } + .site-search input { width: 100%; min-height: 44px; padding: .6rem 1rem; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); color: var(--text); font: inherit; transition: border-color 160ms cubic-bezier(0.2, 0, 0, 1), box-shadow 160ms cubic-bezier(0.2, 0, 0, 1); } + .site-search input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + .site-search input::placeholder { color: var(--muted); } + .search-results { position: absolute; inset-inline: 0; top: 100%; z-index: 3; margin: .35rem 0 0; padding: .35rem; list-style: none; border: 1px solid var(--hairline); border-radius: .75rem; background: var(--surface-2); box-shadow: 0 12px 42px rgba(0, 0, 0, .14); } + .search-results li { margin: 0; } + .search-results a { display: block; padding: .5rem .65rem; border-radius: .5rem; color: var(--text); text-decoration: none; } + .search-results a:hover, .search-results a:focus { background: var(--accent-soft); outline: 0; } + .search-results .search-empty { padding: .5rem .65rem; color: var(--muted); } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: var(--space-3); } .home-section { margin-top: var(--space-6); } .home-section:first-of-type { margin-top: 0; } @@ -160,3 +171,30 @@ .cell-banner--1 { margin-block: var(--space-6); } .cell-banner--1 figure { width: 100%; max-width: clamp(280px, 65vw, 640px); } .cell-banner--1 figcaption { max-width: 42ch; } + /* Dark palette: same warm hue family as the light theme, inverted. + Marginalia figures keep their locked light palette, so in dark + mode each SVG sits on a light "paper" chip instead of being + recoloured — the grammar stays untouched. Shiki emits dual-theme + CSS variables; the swap below activates the dark set. */ + @media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --accent: #FF6B2E; + --accent-hover: #FF8A57; + --accent-soft: rgba(255, 107, 46, 0.14); + --text: #F3E7DC; + --muted: rgba(243, 231, 220, 0.72); + --page: #1B120B; + --surface: #241810; + --surface-2: #281B12; + --surface-3: #2F2015; + --hairline: #43301F; + --hairline-soft: rgba(67, 48, 31, 0.6); + --header-veil: rgba(27, 18, 11, 0.82); + --header-veil-0: rgba(27, 18, 11, 0); + --header-veil-solid: rgba(27, 18, 11, 0.95); + } + .cell-banner figure svg, .journey-section-figure svg { padding: var(--space-2); border-radius: .5rem; background: var(--figure-paper); } + .button { color: #FFF7EF; } + .shiki, .shiki span { color: var(--shiki-dark) !important; } + } diff --git a/public/syntax-highlight.3b6c7f730d46.js b/public/syntax-highlight.305edd86ffcd.js similarity index 87% rename from public/syntax-highlight.3b6c7f730d46.js rename to public/syntax-highlight.305edd86ffcd.js index c573300..0cfc842 100644 --- a/public/syntax-highlight.3b6c7f730d46.js +++ b/public/syntax-highlight.305edd86ffcd.js @@ -7,7 +7,8 @@ for (const block of blocks) { try { const highlighted = await codeToHtml(source, { lang: 'python', - theme: 'github-light', + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: 'light', }); const wrapper = document.createElement('div'); wrapper.innerHTML = highlighted; diff --git a/public/syntax-highlight.js b/public/syntax-highlight.js index c573300..0cfc842 100644 --- a/public/syntax-highlight.js +++ b/public/syntax-highlight.js @@ -7,7 +7,8 @@ for (const block of blocks) { try { const highlighted = await codeToHtml(source, { lang: 'python', - theme: 'github-light', + themes: { light: 'github-light', dark: 'github-dark' }, + defaultColor: 'light', }); const wrapper = document.createElement('div'); wrapper.innerHTML = highlighted; diff --git a/scripts/build_search_index.py b/scripts/build_search_index.py new file mode 100755 index 0000000..186cfcf --- /dev/null +++ b/scripts/build_search_index.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Build the client-side search index from the example catalog. + +The index is a small JSON array served as a fingerprinted static asset. +`text` concatenates the note lines so searches can hit concepts the +title does not mention (for example "walrus" for assignment +expressions); it is lowercased at build time so the client never +normalizes entry content. +""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from src.app import list_examples # noqa: E402 + +TARGET = ROOT / "public" / "search-index.json" + + +def build_entries() -> list[dict[str, str]]: + entries = [] + for example in list_examples(): + text_parts = [example["slug"].replace("-", " ")] + text_parts.extend(example.get("notes", [])) + entries.append( + { + "slug": example["slug"], + "title": example["title"], + "section": example["section"], + "summary": example["summary"], + "text": " ".join(text_parts).lower(), + } + ) + return entries + + +def main() -> None: + entries = build_entries() + TARGET.write_text(json.dumps(entries, ensure_ascii=False, separators=(",", ":")) + "\n") + print(f"Search index written with {len(entries)} example entries.") + + +if __name__ == "__main__": + main() diff --git a/scripts/build_social_cards.mjs b/scripts/build_social_cards.mjs new file mode 100755 index 0000000..322469a --- /dev/null +++ b/scripts/build_social_cards.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// Rasterize build/social-cards/*.html to public/og/*.jpg through one +// headless Chrome session. Run scripts/build_social_cards.py first +// (or use `make social-cards`, which runs both). +import { spawn } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cardDir = path.join(root, 'build', 'social-cards'); +const outputDir = path.join(root, 'public', 'og'); +const chromePath = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const width = 1200; +const height = 630; +const port = 9444 + Math.floor(Math.random() * 1000); +const profile = await mkdtemp(path.join(tmpdir(), 'pythonbyexample-og-chrome-')); + +const manifest = JSON.parse(await readFile(path.join(cardDir, 'manifest.json'), 'utf8')); + +const chrome = spawn(chromePath, [ + '--headless=new', + '--disable-gpu', + '--no-sandbox', + '--disable-dev-shm-usage', + '--no-first-run', + '--no-default-browser-check', + '--hide-scrollbars', + `--user-data-dir=${profile}`, + `--remote-debugging-port=${port}`, + 'about:blank', +], { stdio: ['ignore', 'pipe', 'pipe'] }); + +let stderr = ''; +chrome.stderr.on('data', chunk => { stderr += chunk.toString(); }); +function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } +async function jsonGet(endpoint) { + const response = await fetch(`http://127.0.0.1:${port}${endpoint}`); + if (!response.ok) throw new Error(`${endpoint} returned ${response.status}`); + return response.json(); +} +async function waitForChrome() { + for (let i = 0; i < 80; i++) { + try { return await jsonGet('/json/version'); } catch { await sleep(100); } + } + throw new Error(`Chrome did not start. stderr:\n${stderr}`); +} +let nextId = 1; +function connect(wsUrl) { + const socket = new WebSocket(wsUrl); + const pending = new Map(); + socket.addEventListener('message', event => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + if (message.error) reject(new Error(JSON.stringify(message.error))); + else resolve(message.result); + } + }); + const opened = new Promise((resolve, reject) => { + socket.addEventListener('open', resolve, { once: true }); + socket.addEventListener('error', reject, { once: true }); + }); + return { + opened, + send(method, params = {}) { + const id = nextId++; + socket.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); + }, + close() { socket.close(); }, + }; +} + +try { + await waitForChrome(); + const pages = await jsonGet('/json/list'); + const page = pages.find(item => item.type === 'page'); + const client = connect(page.webSocketDebuggerUrl); + await client.opened; + await client.send('Page.enable'); + await client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }); + + await mkdir(outputDir, { recursive: true }); + let written = 0; + for (const [name, file] of Object.entries(manifest)) { + await client.send('Page.navigate', { url: `file://${path.join(cardDir, file)}` }); + for (let i = 0; i < 50; i++) { + const ready = await client.send('Runtime.evaluate', { + expression: "document.readyState === 'complete'", + returnByValue: true, + }); + if (ready.result.value) break; + await sleep(50); + } + const shot = await client.send('Page.captureScreenshot', { + format: 'jpeg', quality: 90, + clip: { x: 0, y: 0, width, height, scale: 1 }, + }); + await writeFile(path.join(outputDir, `${name}.jpg`), Buffer.from(shot.data, 'base64')); + written += 1; + } + client.close(); + console.log(`Rasterized ${written} social cards to public/og/.`); +} finally { + chrome.kill('SIGTERM'); + await new Promise(resolve => chrome.once('exit', resolve)); + await rm(profile, { recursive: true, force: true }); +} diff --git a/scripts/build_social_cards.py b/scripts/build_social_cards.py new file mode 100644 index 0000000..8a6d60a --- /dev/null +++ b/scripts/build_social_cards.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Compose 1200x630 social-card HTML for every example page. + +Each card reuses the example's curated marginalia figure so shared +links carry the same diagram the page teaches with. This script writes +self-contained HTML files; scripts/build_social_cards.mjs rasterizes +them to public/og/.png with headless Chrome. Run both via +`make social-cards`. + +The PNGs are committed. They are intentionally NOT part of +check-generated: rasterized bytes vary across Chrome versions, so the +parity gate would flap. The SEO linter instead checks that a card file +exists for every page that references one. +""" +from __future__ import annotations + +import html +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from src.app import PYTHON_VERSION, list_examples # noqa: E402 +from src.marginalia import render_first_figure # noqa: E402 + +CARD_DIR = ROOT / "build" / "social-cards" +CARD_WIDTH = 1200 +CARD_HEIGHT = 630 + +_CARD_CSS = """ + * { box-sizing: border-box; margin: 0; } + body { + width: 1200px; height: 630px; overflow: hidden; + display: flex; align-items: stretch; gap: 48px; + padding: 64px 72px; + color: #521000; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "DejaVu Sans", sans-serif; + background: radial-gradient(circle at top left, rgba(255, 72, 1, 0.12), transparent 40rem), #F5F1EB; + } + .copy { flex: 1.2; display: flex; flex-direction: column; min-width: 0; } + .eyebrow { color: #FF4801; font-size: 26px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } + h1 { margin-top: 18px; font-size: 78px; line-height: 1.02; letter-spacing: -0.04em; } + h1.long { font-size: 58px; } + .summary { margin-top: 26px; color: rgba(82, 16, 0, 0.7); font-size: 30px; line-height: 1.4; max-width: 22ch; } + .brand { margin-top: auto; font-size: 26px; } + .brand strong { font-weight: 800; } + .brand span { color: rgba(82, 16, 0, 0.7); } + .figure { flex: 1; display: flex; align-items: center; justify-content: center; min-width: 0; } + .figure-paper { display: flex; align-items: center; justify-content: center; padding: 28px; border: 1px solid #EBD5C1; border-radius: 20px; background: #FFFBF5; box-shadow: 0 2px 6px rgba(82, 16, 0, 0.05), 0 18px 48px rgba(82, 16, 0, 0.07); } + .figure svg { display: block; max-width: 420px; max-height: 460px; width: auto; height: auto; } + .motif { color: #FF4801; font-family: ui-monospace, "DejaVu Sans Mono", monospace; font-size: 150px; font-weight: 800; } +""" + + +def _card_shell(body: str) -> str: + return ( + "\n" + '' + f"" + f"{body}\n" + ) + + +def render_social_card_html(example: dict) -> str: + figure_svg = render_first_figure(example["slug"]) + figure = ( + f'
{figure_svg}
' + if figure_svg + else '
>py
' + ) + title_class = ' class="long"' if len(example["title"]) > 18 else "" + body = ( + '
' + f'

Python By Example · {html.escape(example["section"])}

' + f"{html.escape(example['title'])}" + f'

{html.escape(example["summary"])}

' + f'

pythonbyexample.dev · editable examples · Python {html.escape(PYTHON_VERSION)}

' + "
" + f"{figure}" + ) + return _card_shell(body) + + +def render_home_card_html() -> str: + body = ( + '
' + '

Learn Python by running it

' + "

Python By Example

" + f'

{len(list_examples())} concise, editable Python {html.escape(PYTHON_VERSION)} examples with verified output.

' + '

pythonbyexample.dev · run every example in your browser

' + "
" + '
>py
' + ) + return _card_shell(body) + + +def main() -> None: + CARD_DIR.mkdir(parents=True, exist_ok=True) + manifest = {"home": "home.html"} + (CARD_DIR / "home.html").write_text(render_home_card_html()) + for example in list_examples(): + name = f"{example['slug']}.html" + (CARD_DIR / name).write_text(render_social_card_html(example)) + manifest[example["slug"]] = name + (CARD_DIR / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True)) + print(f"Wrote {len(manifest)} social-card HTML files to {CARD_DIR.relative_to(ROOT)}/.") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_search_ranking.mjs b/scripts/check_search_ranking.mjs new file mode 100755 index 0000000..d57c0a7 --- /dev/null +++ b/scripts/check_search_ranking.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// Exercises the search ranking against the real generated index so a +// ranking regression or an index/asset drift fails verification. +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const { rankExamples } = await import(path.join(root, 'public', 'search.js')); +const entries = JSON.parse(readFileSync(path.join(root, 'public', 'search-index.json'), 'utf8')); + +const failures = []; +function expect(condition, message) { + if (!condition) failures.push(message); +} + +// Result nodes must be built with textContent, never innerHTML, so +// catalog fields can never be parsed as markup (DOM injection guard). +const searchSource = readFileSync(path.join(root, 'public', 'search.js'), 'utf8'); +expect(!searchSource.includes('innerHTML'), 'search.js must not use innerHTML'); + +const closures = rankExamples('closures', entries); +expect(closures[0]?.slug === 'closures', `exact title should rank first, got ${closures[0]?.slug}`); + +const decorator = rankExamples('decorator', entries); +expect(decorator[0]?.slug === 'decorators', `prefix match should rank decorators first, got ${decorator[0]?.slug}`); + +const walrus = rankExamples('walrus', entries); +expect( + walrus.some((entry) => entry.slug === 'assignment-expressions'), + 'concept keyword "walrus" should surface assignment-expressions', +); + +const asyncResults = rankExamples('async', entries); +expect(asyncResults.length >= 2, 'async should match multiple examples'); +expect( + asyncResults.some((entry) => entry.slug === 'async-await'), + 'async should surface async-await', +); + +const multiToken = rankExamples('for loop', entries); +expect(multiToken.some((entry) => entry.slug === 'for-loops'), '"for loop" should surface for-loops'); + +expect(rankExamples('zzzznonexistent', entries).length === 0, 'nonsense query should return nothing'); +expect(rankExamples('', entries).length === 0, 'empty query should return nothing'); +expect(rankExamples('closures', entries, 3).length <= 3, 'limit should cap results'); + +if (failures.length) { + for (const failure of failures) console.error(`FAIL: ${failure}`); + process.exit(1); +} +console.log(`Search ranking checks passed against ${entries.length} index entries.`); diff --git a/scripts/fingerprint_assets.py b/scripts/fingerprint_assets.py index 6beb53e..adb1a41 100755 --- a/scripts/fingerprint_assets.py +++ b/scripts/fingerprint_assets.py @@ -13,6 +13,8 @@ "SITE_CSS": "site.css", "SYNTAX_JS": "syntax-highlight.js", "EDITOR_JS": "editor.js", + "SEARCH_JS": "search.js", + "SEARCH_INDEX": "search-index.json", } @@ -68,8 +70,14 @@ def main() -> None: " Cache-Control: public, max-age=31536000, immutable\n\n" "/editor.*.js\n" " Cache-Control: public, max-age=31536000, immutable\n\n" + "/search.*.js\n" + " Cache-Control: public, max-age=31536000, immutable\n\n" + "/search-index.*.json\n" + " Cache-Control: public, max-age=31536000, immutable\n\n" "/favicon.svg\n" " Cache-Control: public, max-age=31536000, immutable\n\n" + "/og/*\n" + " Cache-Control: public, max-age=86400\n\n" "/prototyping/*\n" " Cache-Control: no-cache, must-revalidate\n" ) diff --git a/scripts/learner_report.py b/scripts/learner_report.py new file mode 100755 index 0000000..7c4e279 --- /dev/null +++ b/scripts/learner_report.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +"""Aggregate exported Worker wide events into a learner-behavior report. + +The Worker already emits one structured event per request (see +docs/observability-spec.md). This script turns an export of those events +into the numbers that should steer content work: which examples people +read, which they actually run, where edited code fails, and which +example URLs 404 (demand for pages that do not exist yet). + +Input is newline-delimited JSON on stdin or from file arguments. Three +shapes are accepted per line and detected automatically: + +- the raw wide-event payload (one JSON object per line) +- a `wrangler tail --format json` envelope (events inside logs[].message[]) +- a Workers Logs / Logpush envelope (event inside $workers) + +Usage: + wrangler tail --format json | scripts/learner_report.py + scripts/learner_report.py exported-logs.ndjson + scripts/learner_report.py --json exported-logs.ndjson +""" +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter, defaultdict +from typing import Iterable, Iterator, TextIO + +SERVICE = "pythonbyexample" + + +def _looks_like_wide_event(value) -> bool: + return isinstance(value, dict) and value.get("service") == SERVICE and "path" in value + + +def _events_from_record(record) -> Iterator[dict]: + if _looks_like_wide_event(record): + yield record + return + if not isinstance(record, dict): + return + workers = record.get("$workers") + if isinstance(workers, dict): + event = workers.get("event", workers) + if _looks_like_wide_event(event): + yield event + return + for log in record.get("logs", []) or []: + if not isinstance(log, dict): + continue + for message in log.get("message", []) or []: + if _looks_like_wide_event(message): + yield message + + +def iter_events(stream: TextIO) -> Iterator[dict]: + for line in stream: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + except ValueError: + continue + yield from _events_from_record(record) + + +def _percentile(values: list[float], fraction: float) -> float: + ordered = sorted(values) + index = min(len(ordered) - 1, max(0, round(fraction * (len(ordered) - 1)))) + return ordered[index] + + +def aggregate_events(events: Iterable[dict]) -> dict: + totals = Counter() + page_views: Counter[str] = Counter() + journey_views: Counter[str] = Counter() + missing_example_paths: Counter[str] = Counter() + turnstile_outcomes: Counter[str] = Counter() + runs: dict[str, dict] = defaultdict( + lambda: {"total": 0, "edited": 0, "errors": 0, "execution_ms": []} + ) + + for event in events: + totals["events"] += 1 + method = str(event.get("method", "")).upper() + path = str(event.get("path", "")) + status = event.get("status_code", 0) + + if method == "GET": + if path.startswith("/examples/") and status == 404: + missing_example_paths[path] += 1 + elif status == 200: + page_views[path] += 1 + if path.startswith("/journeys"): + journey_views[path] += 1 + + example = event.get("example") + if method == "POST" and isinstance(example, dict) and example.get("slug"): + slug = str(example["slug"]) + entry = runs[slug] + entry["total"] += 1 + if example.get("code_edited"): + entry["edited"] += 1 + if event.get("outcome") == "error": + entry["errors"] += 1 + execution_ms = event.get("execution_ms") + if isinstance(execution_ms, (int, float)): + entry["execution_ms"].append(float(execution_ms)) + + turnstile = event.get("turnstile") + if isinstance(turnstile, dict) and turnstile.get("outcome"): + turnstile_outcomes[str(turnstile["outcome"])] += 1 + + run_summary = {} + for slug, entry in runs.items(): + timings = entry.pop("execution_ms") + summary = dict(entry) + if timings: + summary["execution_ms_p50"] = _percentile(timings, 0.5) + summary["execution_ms_p95"] = _percentile(timings, 0.95) + summary["execution_ms_max"] = max(timings) + run_summary[slug] = summary + + return { + "totals": dict(totals), + "page_views": dict(page_views), + "journey_views": dict(journey_views), + "missing_example_paths": dict(missing_example_paths), + "turnstile_outcomes": dict(turnstile_outcomes), + "runs": run_summary, + } + + +def _top(counter: dict, limit: int) -> list[tuple[str, int]]: + return sorted(counter.items(), key=lambda item: (-item[1], item[0]))[:limit] + + +def render_report(report: dict, limit: int = 15) -> str: + lines = [] + lines.append(f"Learner report over {report['totals'].get('events', 0)} events") + lines.append("") + + lines.append(f"Most-read pages (top {limit})") + for path, count in _top(report["page_views"], limit): + lines.append(f" {count:>6} {path}") + lines.append("") + + lines.append(f"Most-run examples (top {limit})") + ranked_runs = sorted(report["runs"].items(), key=lambda item: (-item[1]["total"], item[0]))[:limit] + for slug, entry in ranked_runs: + edited_share = entry["edited"] / entry["total"] if entry["total"] else 0.0 + error_share = entry["errors"] / entry["total"] if entry["total"] else 0.0 + timing = "" + if "execution_ms_p50" in entry: + timing = f" p50 {entry['execution_ms_p50']:.0f}ms p95 {entry['execution_ms_p95']:.0f}ms" + lines.append( + f" {entry['total']:>6} {slug} ({edited_share:.0%} edited, {error_share:.0%} errors){timing}" + ) + lines.append("") + + if report["journey_views"]: + lines.append("Journey traffic") + for path, count in _top(report["journey_views"], limit): + lines.append(f" {count:>6} {path}") + lines.append("") + + lines.append("Missing-example requests (404s under /examples/ — demand for pages that do not exist)") + missing = _top(report["missing_example_paths"], limit) + if missing: + for path, count in missing: + lines.append(f" {count:>6} {path}") + else: + lines.append(" none") + lines.append("") + + if report["turnstile_outcomes"]: + lines.append("Turnstile outcomes") + for outcome, count in _top(report["turnstile_outcomes"], limit): + lines.append(f" {count:>6} {outcome}") + lines.append("") + + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("files", nargs="*", help="NDJSON log exports; reads stdin when omitted") + parser.add_argument("--json", action="store_true", help="emit the aggregated report as JSON") + parser.add_argument("--limit", type=int, default=15, help="rows per section in the text report") + args = parser.parse_args() + + events: list[dict] = [] + if args.files: + for name in args.files: + with open(name, encoding="utf-8") as handle: + events.extend(iter_events(handle)) + else: + events.extend(iter_events(sys.stdin)) + + report = aggregate_events(events) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(render_report(report, limit=args.limit)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lint_seo_cache.py b/scripts/lint_seo_cache.py index 0212c7c..d0cd875 100755 --- a/scripts/lint_seo_cache.py +++ b/scripts/lint_seo_cache.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib +import json import re import sys from pathlib import Path @@ -10,13 +11,15 @@ sys.path.insert(0, str(ROOT)) from scripts.fingerprint_assets import ASSETS, PUBLIC, html_version # noqa: E402 -from src.app import SITE_URL, list_examples, render_example_page, render_home # noqa: E402 +from src.app import SITE_URL, list_examples, render_example_page, render_home, render_sitemap # noqa: E402 from src.asset_manifest import ASSET_PATHS, HTML_CACHE_VERSION # noqa: E402 META_DESCRIPTION_RE = re.compile(r'') CANONICAL_RE = re.compile(r'') OG_URL_RE = re.compile(r'') HASHED_ASSET_RE = re.compile(r'/(site|syntax-highlight)\.[0-9a-f]{12}\.(css|js)') +JSON_LD_RE = re.compile(r'', re.S) +OG_IMAGE_RE = re.compile(r'') def fail(message: str, failures: list[str]) -> None: @@ -44,6 +47,41 @@ def assert_page_metadata(name: str, html: str, path: str, failures: list[str]) - fail(f"{name}: references unfingerprinted CSS/JS asset", failures) if len(HASHED_ASSET_RE.findall(html)) < 2: fail(f"{name}: missing fingerprinted CSS/JS assets", failures) + og_image = OG_IMAGE_RE.search(html) + if not og_image: + fail(f"{name}: missing og:image social card", failures) + else: + image_url = og_image.group(1) + if not image_url.startswith(f"{SITE_URL}/og/"): + fail(f"{name}: og:image {image_url} is not a site social card", failures) + else: + card = PUBLIC / "og" / image_url.rsplit("/", 1)[1] + if not card.exists(): + fail(f"{name}: og:image file missing — run `make social-cards` ({card})", failures) + json_ld = JSON_LD_RE.search(html) + if not json_ld: + fail(f"{name}: missing JSON-LD structured data", failures) + else: + try: + data = json.loads(json_ld.group(1).replace("\\u003c", "<").replace("\\u003e", ">").replace("\\u0026", "&")) + except ValueError: + fail(f"{name}: JSON-LD is not valid JSON", failures) + else: + if data.get("@context") != "https://schema.org": + fail(f"{name}: JSON-LD missing schema.org context", failures) + + +def assert_sitemap(failures: list[str]) -> None: + sitemap = render_sitemap() + for example in list_examples(): + url = f"{SITE_URL}/examples/{example['slug']}" + if f"{url}" not in sitemap: + fail(f"sitemap: missing {url}", failures) + if f"{SITE_URL}/" not in sitemap: + fail("sitemap: missing home URL", failures) + robots = (ROOT / "public" / "robots.txt").read_text() + if f"Sitemap: {SITE_URL}/sitemap.xml" not in robots: + fail("robots.txt: missing Sitemap directive", failures) def assert_asset_manifest(failures: list[str]) -> None: @@ -81,6 +119,7 @@ def main() -> int: failures: list[str] = [] assert_asset_manifest(failures) assert_worker_cache_policy(failures) + assert_sitemap(failures) assert_page_metadata("home", render_home(), "/", failures) for example in list_examples(): assert_page_metadata( diff --git a/src/app.py b/src/app.py index f4669f9..7515d7d 100644 --- a/src/app.py +++ b/src/app.py @@ -10,11 +10,11 @@ try: from .asset_manifest import ASSET_PATHS from .examples import EXAMPLES, EXAMPLES_BY_SLUG, PYTHON_VERSION, REFERENCE_URL - from .marginalia import render_for_anchor, render_for_section + from .marginalia import render_banner, render_for_section except ImportError: # Cloudflare Python Workers import sibling modules from main's directory. from asset_manifest import ASSET_PATHS from examples import EXAMPLES, EXAMPLES_BY_SLUG, PYTHON_VERSION, REFERENCE_URL - from marginalia import render_for_anchor, render_for_section + from marginalia import render_banner, render_for_section class AppResponse: @@ -191,6 +191,7 @@ async def fetch(self, request): "summary": "The important mental shift is that loops consume producers through a protocol rather than special-casing lists.", "items": [ ("example", "iterating-over-iterables", "separate value producers from value consumers"), + ("example", "iterator-vs-iterable", "distinguish re-iterable collections from one-pass iterators"), ("example", "iterators", "use `iter()` and `next()` to expose the protocol behind `for`"), ("example", "generators", "write functions that produce values lazily"), ], @@ -283,12 +284,14 @@ async def fetch(self, request): ("example", "inheritance-and-super", "reuse and extend behavior through parent classes"), ("example", "dataclasses", "generate common methods for data containers"), ("example", "properties", "keep attribute syntax while adding computation or validation"), + ("example", "classmethods-and-staticmethods", "choose between instance, class, and static behavior on a class"), ("example", "special-methods", "connect objects to Python syntax and built-ins"), ("example", "truth-and-size", "make objects work with truth tests and `len()`"), ("example", "container-protocols", "support membership, lookup, and assignment syntax"), ("example", "callable-objects", "make stateful instances callable like functions"), ("example", "operator-overloading", "define operators only when the operation is unsurprising"), ("example", "attribute-access", "customize fallback lookup and assignment carefully"), + ("example", "bound-and-unbound-methods", "explain how attribute access turns a function into a bound method"), ("example", "descriptors", "explain the protocol behind methods, properties, and managed attributes"), ("example", "metaclasses", "customize class creation when ordinary class tools are not enough"), ], @@ -306,6 +309,7 @@ async def fetch(self, request): "items": [ ("example", "type-hints", "document expected types and feed type checkers"), ("example", "protocols", "describe required behavior by structural shape"), + ("example", "abstract-base-classes", "contrast runtime-enforced nominal interfaces with structural protocols"), ("example", "enums", "name a fixed set of symbolic values"), ("example", "runtime-type-checks", "show `type()`, `isinstance()`, and `issubclass()` without turning Python into Java"), ], @@ -317,6 +321,7 @@ async def fetch(self, request): ("example", "union-and-optional-types", "show `X | Y` and `None`-aware APIs"), ("example", "type-aliases", "name complex types with `type` statements or aliases"), ("example", "typed-dicts", "type dictionary records that come from JSON"), + ("example", "structured-data-shapes", "choose between dataclass, NamedTuple, and TypedDict for records"), ("example", "literal-and-final", "express constrained values and names that should not be rebound"), ("example", "callable-types", "type functions that are passed as arguments"), ], @@ -440,14 +445,39 @@ def _meta_description(text: str) -> str: return text[:172].rsplit(" ", 1)[0] + "…" -def _layout(title: str, content: str, description: str | None = None, path: str = "/", og_type: str = "website", include_editor: bool = False) -> str: +def _social_image_tags(image_url: str | None) -> str: + if not image_url: + return '' + escaped = html.escape(image_url) + return ( + f'' + '' + '' + '' + f'' + ) + + +def _structured_data_script(data: dict) -> str: + # JSON-LD lives inside a ' + + +def _layout(title: str, content: str, description: str | None = None, path: str = "/", og_type: str = "website", include_editor: bool = False, include_search: bool = False, structured_data: dict | None = None, og_image: str | None = None) -> str: description = _meta_description(description or "Learn Python with concise, editable examples that run in isolated Cloudflare Dynamic Python Workers.") canonical_url = f"{SITE_URL}{path}" page_title = title if title == "Python By Example" else f"{title} · Python By Example" editor_script = f'' if include_editor else "" + if include_search: + editor_script += f'' return _replace( _template("layout.html"), { + "STRUCTURED_DATA": _structured_data_script(structured_data) if structured_data else "", + "SOCIAL_IMAGE_TAGS": _social_image_tags(og_image), "PAGE_TITLE": html.escape(page_title), "TITLE": html.escape(title), "REFERENCE_URL": html.escape(REFERENCE_URL), @@ -496,14 +526,28 @@ def render_home() -> str: ) content = _replace( _template("home.html"), - {"PYTHON_VERSION": html.escape(PYTHON_VERSION), "CARDS": "".join(sections_html)}, + { + "PYTHON_VERSION": html.escape(PYTHON_VERSION), + "SEARCH_INDEX": html.escape(ASSET_PATHS["SEARCH_INDEX"]), + "CARDS": "".join(sections_html), + }, ) return _layout( "Python By Example", content, + include_search=True, description="Learn Python 3.13 with concise, editable examples, expected output, official documentation links, and Cloudflare Dynamic Worker execution.", path="/", og_type="website", + og_image=f"{SITE_URL}/og/home.jpg", + structured_data={ + "@context": "https://schema.org", + "@type": "WebSite", + "name": "Python By Example", + "url": f"{SITE_URL}/", + "description": f"Concise, editable Python {PYTHON_VERSION} examples with verified output and official documentation links.", + "inLanguage": "en", + }, ) @@ -531,7 +575,7 @@ def render_journeys_index(): return _layout( "Python learning journeys", content, - description="Curated Python By Example journeys that compose individual examples into larger mental maps, with explicit placeholders for missing topics.", + description="Curated Python By Example journeys that compose individual examples into larger mental maps with per-section learner outcomes.", path="/journeys", ) @@ -571,11 +615,24 @@ def render_journey_page(journey): return _layout( journey["title"], content, - description=f'{journey["summary"]} A curated Python By Example journey with explicit placeholders for missing examples.', + description=f'{journey["summary"]} A curated Python By Example journey with per-section learner outcomes.', path=f'/journeys/{journey["slug"]}', ) +def render_sitemap() -> str: + paths = ["/", "/journeys"] + paths.extend(f'/journeys/{journey["slug"]}' for journey in JOURNEYS) + paths.extend(f'/examples/{example["slug"]}' for example in list_examples()) + entries = "".join(f"{html.escape(SITE_URL + path)}" for path in paths) + return ( + '\n' + '' + f"{entries}" + "\n" + ) + + def _example_neighbors(slug): slugs = [item["slug"] for item in list_examples()] index = slugs.index(slug) @@ -714,6 +771,28 @@ def _render_cell(step): return f'
{prose_html}

Source

{source}

Output

{html.escape(step["output"])}
' +def _render_walkthrough(slug: str, walkthrough: list[dict]) -> str: + """Interleave cells with their figure banners. + + Banners slot into the positions from docs/visual-explainer-spec.md: + `before` the first cell, `after-cell-N`, and `after-walkthrough`. + A page with no attached figures renders cells only. + """ + parts: list[str] = [] + before = render_banner(slug, "before") + if before: + parts.append(before) + for i, step in enumerate(walkthrough): + parts.append(_render_cell(step)) + banner_html = render_banner(slug, f"after-cell-{i}") + if banner_html: + parts.append(banner_html) + after = render_banner(slug, "after-walkthrough") + if after: + parts.append(after) + return "".join(parts) + + def _turnstile_challenge_container(site_key: str | None) -> str: if not site_key: return "" @@ -752,13 +831,7 @@ def render_example_page( if next_example else "" ) - walkthrough_parts: list[str] = [] - for i, step in enumerate(walkthrough): - walkthrough_parts.append(_render_cell(step)) - banner_html = render_for_anchor(example["slug"], f"cell-{i}") - if banner_html: - walkthrough_parts.append(banner_html) - walkthrough_html = "".join(walkthrough_parts) + walkthrough_html = _render_walkthrough(example["slug"], walkthrough) notes_html = "".join(f"
  • {note}
  • " for note in notes) see_also_examples = [get_example(slug) for slug in example.get("see_also", [])] see_also_links = "".join( @@ -797,6 +870,26 @@ def render_example_page( path=f'/examples/{example["slug"]}', og_type="article", include_editor=True, + og_image=f'{SITE_URL}/og/{example["slug"]}.jpg', + structured_data={ + "@context": "https://schema.org", + "@type": ["TechArticle", "LearningResource"], + "name": example["title"], + "headline": example["title"], + "description": example["summary"], + "url": f'{SITE_URL}/examples/{example["slug"]}', + "inLanguage": "en", + "programmingLanguage": "Python", + "learningResourceType": "Example", + "teaches": example["summary"], + "articleSection": example["section"], + "isAccessibleForFree": True, + "isPartOf": { + "@type": "WebSite", + "name": "Python By Example", + "url": f"{SITE_URL}/", + }, + }, ) @@ -806,6 +899,8 @@ def route(url: str, method: str = "GET", turnstile_site_key: str | None = None) path = ("/" + path_part.split("?", 1)[0]).rstrip("/") or "/" if method == "GET" and path == "/favicon.svg": return AppResponse(FAVICON_SVG, headers={"Content-Type": "image/svg+xml; charset=utf-8"}) + if method == "GET" and path == "/sitemap.xml": + return AppResponse(render_sitemap(), headers={"Content-Type": "application/xml; charset=utf-8"}) if method == "GET" and path == "/": return AppResponse(render_home(), headers={"Content-Type": "text/html; charset=utf-8"}) if method == "GET" and path == "/layout-options/mobile-run-first": diff --git a/src/asset_manifest.py b/src/asset_manifest.py index 2572020..bab5743 100644 --- a/src/asset_manifest.py +++ b/src/asset_manifest.py @@ -1,3 +1,3 @@ # Generated by scripts/fingerprint_assets.py. Do not edit by hand. -ASSET_PATHS = {'SITE_CSS': '/site.e87d4baf77e6.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.a4a7766e1b9b.js'} -HTML_CACHE_VERSION = 'c09a7489d1e1' +ASSET_PATHS = {'SITE_CSS': '/site.8804b14e63bc.css', 'SYNTAX_JS': '/syntax-highlight.305edd86ffcd.js', 'EDITOR_JS': '/editor.bbf94cd1abda.js', 'SEARCH_JS': '/search.ab0effeac6ce.js', 'SEARCH_INDEX': '/search-index.f08e8599474a.json'} +HTML_CACHE_VERSION = '1a58594ea2d2' diff --git a/src/main.py b/src/main.py index d990746..c671252 100644 --- a/src/main.py +++ b/src/main.py @@ -390,6 +390,10 @@ def provide_worker_code(): @app.get("/{path:path}") async def not_found(path: str, request: Request): response = route(str(request.url), method="GET") + # Non-HTML routes (such as /sitemap.xml) set their own Content-Type; + # wrapping them in HTMLResponse would discard it. + if response.headers: + return Response(response.body, status_code=response.status, headers=response.headers) return _html(response.body, response.status) diff --git a/src/marginalia.py b/src/marginalia.py index 07e2cb8..82b0699 100644 --- a/src/marginalia.py +++ b/src/marginalia.py @@ -1543,6 +1543,11 @@ def structured_shapes(c: Canvas) -> None: "aliasing-mutation", "Two names share one mutable list — appending through one name changes the object visible through both.", ), + ( + "cell-0", + "tuple-no-mutation", + "By contrast, a tuple is frozen — its contents cannot change in place, so aliasing carries no mutation hazard.", + ), ], "variables": [ ( @@ -1665,10 +1670,18 @@ def structured_shapes(c: Canvas) -> None: "cell-0", "kw-only-separator", "A bare `*` divides positional or keyword arguments from keyword-only ones; callers must pass `c` and `d` by name.", )], - "positional-only-parameters": [( - "cell-0", "positional-only-separator", - "A bare `/` divides positional-only arguments from positional-or-keyword ones; callers cannot name `a` or `b`.", - )], + "positional-only-parameters": [ + ( + "cell-0", + "positional-only-separator", + "A bare `/` divides positional-only arguments from positional-or-keyword ones; callers cannot name `a` or `b`.", + ), + ( + "cell-0", + "kw-only-separator", + "In the same signature, the bare `*` works the other way: parameters after it, such as `clamp`, must be named at the call site.", + ), + ], "closures": [( "cell-0", "closure-cell", "The inner function keeps a reference into the outer scope's cell, so the captured factor survives the outer return.", @@ -1710,10 +1723,18 @@ def structured_shapes(c: Canvas) -> None: "cell-0", "operator-dispatch", "Defining `__add__` on a class lets `+` dispatch into the class's own behavior.", )], - "iterator-vs-iterable": [( - "cell-0", "iter-protocol", - "An iterable knows how to produce an iterator (via iter()); the iterator knows how to produce values (via next()).", - )], + "iterator-vs-iterable": [ + ( + "cell-0", + "iter-protocol", + "An iterable knows how to produce an iterator (via iter()); the iterator knows how to produce values (via next()).", + ), + ( + "cell-1", + "iterator-unroll", + "The iterator's caret only moves forward — after the first `list(stream)` drains it, nothing is left for the second.", + ), + ], "type-aliases": [( "cell-0", "type-alias-name", "A type alias names a complex annotation once so call sites read as the domain meaning, not the type composition.", @@ -1796,10 +1817,18 @@ def structured_shapes(c: Canvas) -> None: "cell-0", "property-fork", "When x is a property, attribute access routes through fget/fset instead of touching __dict__.", )], - "metaclasses": [( - "cell-0", "metaclass-triangle", - "A metaclass is the type of a class, just as a class is the type of its instances; type is the default metaclass.", - )], + "metaclasses": [ + ( + "cell-0", + "metaclass-triangle", + "A metaclass is the type of a class, just as a class is the type of its instances; type is the default metaclass.", + ), + ( + "cell-0", + "class-triangle", + "The class makes instances — the same triangle one level down, with type as the default apex.", + ), + ], "modules": [( "cell-0", "sys-path-resolution", "An import walks sys.path entry by entry; the first directory containing the module wins.", @@ -1848,10 +1877,18 @@ def structured_shapes(c: Canvas) -> None: "cell-0", "set-buckets", "Sets are hash buckets without values; `x in s` averages O(1) regardless of size.", )], - "tuples": [( - "cell-0", "tuple-frozen", - "Tuples are ordered, immutable sequences; positions matter, contents do not change once constructed.", - )], + "tuples": [ + ( + "cell-3", + "tuple-frozen", + "Tuples are ordered, immutable sequences; positions matter, contents do not change once constructed.", + ), + ( + "cell-3", + "list-append", + "A list is the other intent: a variable number of similar items, growing in place with `.append`.", + ), + ], "values": [( "cell-0", "value-types", "Every literal is an object with a type; the type carries the behaviour, not the variable name.", @@ -2031,16 +2068,44 @@ def _render_svg(figure_name: str) -> str: return canvas.to_svg() -def render_for_anchor(slug: str, anchor: str) -> str: - """HTML for a banner row sitting AFTER the named cell. Empty if none. +def render_first_figure(slug: str) -> str: + """SVG for the example's first attached figure. Empty if none. + + Used by scripts/build_social_cards.py to reuse the curated figure + set for social-card images. + """ + for _anchor, name, _caption in ATTACHMENTS.get(slug, []): + return _render_svg(name) + return "" + + +def _normalize_position(anchor: str) -> str: + """Map an attachment anchor to a banner position. + + The position grammar (docs/visual-explainer-spec.md) is `before`, + `after-cell-N`, and `after-walkthrough`. The legacy anchor `cell-N` + means the banner after cell N, so both spellings resolve to the + same position. + """ + if anchor.startswith("cell-"): + return f"after-{anchor}" + return anchor + + +def render_banner(slug: str, position: str) -> str: + """HTML for the banner row at a position. Empty if nothing attaches. Cells always keep their prose|code 2-column grid. Figures live in - banner rows that span both columns BETWEEN cells (and after the - walkthrough for single-cell examples). Multiple figures attached to - the same cell share one banner as a small multiple. + banner rows that span both columns: before the first cell, between + cells, or after the walkthrough. Multiple figures attached to the + same position share one banner as a small multiple. """ attachments = ATTACHMENTS.get(slug, []) - matched = [(name, caption) for (a, name, caption) in attachments if a == anchor] + matched = [ + (name, caption) + for (anchor, name, caption) in attachments + if _normalize_position(anchor) == position + ] if not matched: return "" figures: list[str] = [] @@ -2051,6 +2116,11 @@ def render_for_anchor(slug: str, anchor: str) -> str: return f'
    {"".join(figures)}
    ' +def render_for_anchor(slug: str, anchor: str) -> str: + """Anchor-spelling compatibility wrapper around render_banner.""" + return render_banner(slug, _normalize_position(anchor)) + + # ─── Journey-section figures ────────────────────────────────────────── # One figure per journey section, keyed by section title. The figure # depicts the conceptual shift the section's examples share — the diff --git a/src/templates/home.html b/src/templates/home.html index 6bbf92c..7a0d782 100644 --- a/src/templates/home.html +++ b/src/templates/home.html @@ -2,4 +2,9 @@

    Python By Example

    Learn Python with small, editable examples backed by the official Python __PYTHON_VERSION__ docs. Run each snippet in an isolated Dynamic Python Worker using the newest Python version currently supported by Cloudflare Workers/Pyodide.

    + __CARDS__ diff --git a/src/templates/layout.html b/src/templates/layout.html index 15bfec8..9b96f1a 100644 --- a/src/templates/layout.html +++ b/src/templates/layout.html @@ -10,19 +10,21 @@ - + __SOCIAL_IMAGE_TAGS__ + __STRUCTURED_DATA__ __EDITOR_SCRIPT__ +
    -
    __CONTENT__
    +
    __CONTENT__
    diff --git a/tests/test_app.py b/tests/test_app.py index b9e70c8..59d5918 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,6 +2,8 @@ import contextlib import importlib import io +import json +import re import sys import unittest from pathlib import Path @@ -492,5 +494,232 @@ def test_dynamic_worker_code_wraps_user_example(self): self.assertNotIn("globalOutbound", code) +class DarkModeAndAccessibilityTests(unittest.TestCase): + def test_css_defines_a_dark_palette(self): + css = (ROOT / "public" / "site.css").read_text() + self.assertIn("@media (prefers-color-scheme: dark)", css) + self.assertIn("color-scheme: dark", css) + dark_block = css.split("@media (prefers-color-scheme: dark)", 1)[1] + for token in ["--text:", "--muted:", "--page:", "--surface:", "--hairline:"]: + self.assertIn(token, dark_block) + + def test_dark_mode_keeps_marginalia_figures_on_light_paper(self): + css = (ROOT / "public" / "site.css").read_text() + dark_block = css.split("@media (prefers-color-scheme: dark)", 1)[1] + self.assertIn("--figure-paper", css) + self.assertIn(".cell-banner figure svg", dark_block) + self.assertIn(".journey-section-figure svg", dark_block) + + def test_shiki_highlights_with_dual_light_dark_themes(self): + js = (ROOT / "public" / "syntax-highlight.js").read_text() + self.assertIn("github-light", js) + self.assertIn("github-dark", js) + self.assertIn("themes:", js) + css = (ROOT / "public" / "site.css").read_text() + self.assertIn("--shiki-dark", css) + + def test_editor_picks_a_dark_highlight_style_in_dark_mode(self): + js = (ROOT / "public" / "editor.js").read_text() + self.assertIn("prefers-color-scheme: dark", js) + self.assertIn("oneDarkHighlightStyle", js) + + def test_every_page_offers_a_skip_link_to_main_content(self): + for page in [render_home(), render_example_page(get_example("hello-world"))]: + self.assertIn('', page) + self.assertIn('
    ', page) + css = (ROOT / "public" / "site.css").read_text() + self.assertIn(".skip-link", css) + self.assertIn(".skip-link:focus", css) + + +class BannerTests(unittest.TestCase): + def test_mutability_renders_a_two_figure_small_multiple_banner(self): + page = render_example_page(get_example("mutability")) + self.assertIn('class="cell-banner cell-banner--2"', page) + self.assertIn("Two names share one mutable list", page) + self.assertIn("a tuple is frozen", page) + self.assertEqual(page.count("
    "), 2) + + def test_render_banner_accepts_position_grammar_and_legacy_anchors(self): + from src.marginalia import render_banner, render_for_anchor + + by_position = render_banner("mutability", "after-cell-0") + self.assertIn("cell-banner--2", by_position) + self.assertEqual(render_for_anchor("mutability", "cell-0"), by_position) + self.assertEqual(render_banner("mutability", "before"), "") + self.assertEqual(render_banner("mutability", "after-walkthrough"), "") + + def test_before_and_after_walkthrough_positions_render_around_cells(self): + from unittest import mock + + from src import app as app_module + + def fake_banner(slug, position): + if slug != "hello-world": + return "" + return {"before": '
    BEFORE-BANNER
    ', + "after-walkthrough": '
    AFTER-BANNER
    '}.get(position, "") + + with mock.patch.object(app_module, "render_banner", fake_banner): + page = app_module.render_example_page(get_example("hello-world")) + first_cell = page.index("lesson-step lp-cell") + self.assertLess(page.index("BEFORE-BANNER"), first_cell) + self.assertGreater(page.index("AFTER-BANNER"), page.rindex("lesson-step lp-cell")) + + def test_curated_pair_banners_render_on_contrast_cells(self): + positional = render_example_page(get_example("positional-only-parameters")) + self.assertIn('cell-banner--2', positional) + self.assertIn("positional-only", positional) + self.assertIn("must be named at the call site", positional) + + metaclasses = render_example_page(get_example("metaclasses")) + self.assertIn('cell-banner--2', metaclasses) + self.assertIn("the same triangle one level down", metaclasses) + + tuples_page = render_example_page(get_example("tuples")) + self.assertIn('cell-banner--2', tuples_page) + self.assertEqual(tuples_page.count('class="cell-banner'), 1) + + def test_iterator_vs_iterable_gains_a_one_pass_figure(self): + page = render_example_page(get_example("iterator-vs-iterable")) + self.assertEqual(page.count('class="cell-banner'), 2) + self.assertIn("drains it", page) + + def test_no_page_renders_an_empty_banner(self): + for example in list_examples(): + with self.subTest(slug=example["slug"]): + page = render_example_page(example) + self.assertNotIn('
    ', page) + self.assertNotIn('cell-banner--0', page) + + +class SearchTests(unittest.TestCase): + def test_search_index_covers_every_example(self): + index = json.loads((ROOT / "public" / "search-index.json").read_text()) + slugs = {entry["slug"] for entry in index} + self.assertEqual(slugs, {example["slug"] for example in list_examples()}) + for entry in index: + for key in ("slug", "title", "section", "summary", "text"): + self.assertIn(key, entry) + self.assertEqual(entry["text"], entry["text"].lower()) + + def test_home_page_offers_search_with_fingerprinted_assets(self): + home = render_home() + self.assertIn('id="site-search-input"', home) + self.assertIn('type="search"', home) + self.assertIn('id="site-search-results"', home) + index_match = re.search(r'data-search-index="(/search-index\.[0-9a-f]{12}\.json)"', home) + self.assertIsNotNone(index_match) + self.assertTrue((ROOT / "public" / index_match.group(1).lstrip("/")).exists()) + script_match = re.search(r'', home) + self.assertIsNotNone(script_match) + self.assertTrue((ROOT / "public" / script_match.group(1).lstrip("/")).exists()) + + def test_example_pages_do_not_load_search_assets(self): + page = render_example_page(get_example("hello-world")) + self.assertNotIn("search-index", page) + self.assertNotIn("/search.", page) + + def test_search_assets_get_immutable_cache_headers(self): + headers = (ROOT / "public" / "_headers").read_text() + self.assertIn("/search.*.js", headers) + self.assertIn("/search-index.*.json", headers) + + def test_search_css_styles_light_and_dark(self): + css = (ROOT / "public" / "site.css").read_text() + self.assertIn(".site-search", css) + self.assertIn(".search-results", css) + + +class SocialCardTests(unittest.TestCase): + def test_example_pages_reference_per_slug_social_cards(self): + example = get_example("closures") + page = render_example_page(example) + self.assertIn('', page) + self.assertIn('', page) + self.assertIn('', page) + self.assertIn('', page) + + def test_home_page_references_home_social_card(self): + home = render_home() + self.assertIn('', home) + self.assertIn('', home) + + def test_pages_without_cards_fall_back_to_summary_twitter_card(self): + from src.app import JOURNEYS, render_journey_page + + page = render_journey_page(JOURNEYS[0]) + self.assertIn('', page) + self.assertNotIn("og:image", page) + + def test_social_card_image_exists_for_every_example(self): + for example in list_examples(): + with self.subTest(slug=example["slug"]): + self.assertTrue((ROOT / "public" / "og" / f"{example['slug']}.jpg").exists()) + self.assertTrue((ROOT / "public" / "og" / "home.jpg").exists()) + + def test_card_html_composes_title_section_and_figure(self): + from scripts.build_social_cards import render_social_card_html + + card = render_social_card_html(get_example("closures")) + self.assertIn("', response.body) + self.assertIn("https://www.pythonbyexample.dev/", response.body) + self.assertIn("https://www.pythonbyexample.dev/journeys", response.body) + from src.app import JOURNEYS + + for journey in JOURNEYS: + self.assertIn(f"https://www.pythonbyexample.dev/journeys/{journey['slug']}", response.body) + for example in list_examples(): + self.assertIn(f"https://www.pythonbyexample.dev/examples/{example['slug']}", response.body) + self.assertEqual( + response.body.count(""), + 2 + len(JOURNEYS) + len(list_examples()), + ) + + def test_example_pages_carry_learning_resource_json_ld(self): + example = get_example("closures") + page = render_example_page(example) + match = re.search(r'', page, re.S) + self.assertIsNotNone(match) + data = json.loads(match.group(1)) + self.assertEqual(data["@context"], "https://schema.org") + self.assertEqual(data["@type"], ["TechArticle", "LearningResource"]) + self.assertEqual(data["name"], example["title"]) + self.assertEqual(data["url"], "https://www.pythonbyexample.dev/examples/closures") + self.assertEqual(data["programmingLanguage"], "Python") + self.assertEqual(data["learningResourceType"], "Example") + self.assertEqual(data["isPartOf"]["@type"], "WebSite") + + def test_home_page_carries_website_json_ld(self): + page = render_home() + match = re.search(r'', page, re.S) + self.assertIsNotNone(match) + data = json.loads(match.group(1)) + self.assertEqual(data["@type"], "WebSite") + self.assertEqual(data["url"], "https://www.pythonbyexample.dev/") + + def test_json_ld_escapes_script_closing_sequences(self): + from src.app import _structured_data_script + + script = _structured_data_script({"name": "")) + + def test_robots_txt_references_sitemap(self): + robots = (ROOT / "public" / "robots.txt").read_text() + self.assertIn("Sitemap: https://www.pythonbyexample.dev/sitemap.xml", robots) + self.assertIn("User-agent: *", robots) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_learner_report.py b/tests/test_learner_report.py new file mode 100644 index 0000000..389cb63 --- /dev/null +++ b/tests/test_learner_report.py @@ -0,0 +1,104 @@ +import io +import json +import unittest + +from scripts.learner_report import aggregate_events, iter_events, render_report + + +def raw(event): + return json.dumps(event) + + +def tail_envelope(event): + return json.dumps({"outcome": "ok", "logs": [{"message": [event], "level": "log"}]}) + + +def workers_logs_envelope(event): + return json.dumps({"$workers": {"event": event}, "$metadata": {"id": "abc"}}) + + +def page_view(path, status=200, cache="hit"): + return { + "service": "pythonbyexample", + "method": "GET", + "path": path, + "cache": cache, + "status_code": status, + "outcome": "success" if status < 400 else ("client_error" if status < 500 else "error"), + "duration_ms": 12.0, + } + + +def example_run(slug, edited=False, execution_ms=250.0, outcome="success"): + return { + "service": "pythonbyexample", + "method": "POST", + "path": f"/examples/{slug}", + "cache": "bypass", + "status_code": 200 if outcome == "success" else 500, + "outcome": outcome, + "execution_ms": execution_ms, + "example": {"slug": slug, "code_edited": edited, "code_hash": "abc", "code_bytes": 100}, + "turnstile": {"outcome": "not_required"}, + } + + +class IterEventsTests(unittest.TestCase): + def test_reads_raw_tail_and_workers_logs_lines(self): + lines = [ + raw(page_view("/examples/closures")), + tail_envelope(example_run("closures")), + workers_logs_envelope(page_view("/journeys/iteration")), + "not json at all", + json.dumps({"unrelated": True}), + ] + events = list(iter_events(io.StringIO("\n".join(lines)))) + self.assertEqual(len(events), 3) + self.assertEqual(events[0]["path"], "/examples/closures") + self.assertEqual(events[1]["example"]["slug"], "closures") + self.assertEqual(events[2]["path"], "/journeys/iteration") + + +class AggregateTests(unittest.TestCase): + def build_report(self): + events = [ + page_view("/"), + page_view("/examples/closures"), + page_view("/examples/closures", cache="miss"), + page_view("/examples/decorators"), + page_view("/examples/not-a-real-example", status=404), + page_view("/journeys/iteration"), + example_run("closures", edited=False, execution_ms=100.0), + example_run("closures", edited=True, execution_ms=300.0), + example_run("closures", edited=True, execution_ms=500.0, outcome="error"), + example_run("decorators", edited=False, execution_ms=200.0), + ] + return aggregate_events(events) + + def test_aggregates_page_views_runs_and_gaps(self): + report = self.build_report() + self.assertEqual(report["totals"]["events"], 10) + self.assertEqual(report["page_views"]["/examples/closures"], 2) + self.assertEqual(report["runs"]["closures"]["total"], 3) + self.assertEqual(report["runs"]["closures"]["edited"], 2) + self.assertEqual(report["runs"]["closures"]["errors"], 1) + self.assertEqual(report["runs"]["decorators"]["total"], 1) + self.assertEqual(report["journey_views"]["/journeys/iteration"], 1) + self.assertEqual(report["missing_example_paths"]["/examples/not-a-real-example"], 1) + + def test_execution_percentiles_per_slug(self): + report = self.build_report() + closures = report["runs"]["closures"] + self.assertEqual(closures["execution_ms_p50"], 300.0) + self.assertEqual(closures["execution_ms_max"], 500.0) + + def test_render_report_is_readable(self): + text = render_report(self.build_report()) + self.assertIn("Most-run examples", text) + self.assertIn("closures", text) + self.assertIn("Missing-example requests", text) + self.assertIn("/examples/not-a-real-example", text) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main_observability.py b/tests/test_main_observability.py index 943b28d..981b459 100644 --- a/tests/test_main_observability.py +++ b/tests/test_main_observability.py @@ -10,7 +10,13 @@ SRC = ROOT / "src" -class MainObservabilityTests(unittest.TestCase): +class MainModuleHarness(unittest.TestCase): + """Imports src/main.py with stubbed FastAPI/Workers modules. + + Subclass this to test main.py handlers directly; the stub Response + classes record content, status, and headers for assertions. + """ + def setUp(self): self.saved_modules = { name: sys.modules.get(name) @@ -83,8 +89,11 @@ class RequestValidationError(Exception): fastapi_responses = types.ModuleType("fastapi.responses") class Response: - def __init__(self, *args, **kwargs): - pass + def __init__(self, content=None, status_code=200, headers=None, media_type=None, **kwargs): + self.content = content + self.status_code = status_code + self.headers = headers or {} + self.media_type = media_type class HTMLResponse(Response): pass @@ -129,6 +138,8 @@ def import_main(self): sys.modules.pop("main", None) return importlib.import_module("main") + +class MainObservabilityTests(MainModuleHarness): def test_run_example_marks_dynamic_worker_http_500_as_worker_error(self): main = self.import_main() destroyed = [] diff --git a/tests/test_main_routes.py b/tests/test_main_routes.py new file mode 100644 index 0000000..013f831 --- /dev/null +++ b/tests/test_main_routes.py @@ -0,0 +1,39 @@ +import asyncio +from types import SimpleNamespace + +from tests.test_main_observability import MainModuleHarness + + +class CatchAllResponseHeaderTests(MainModuleHarness): + """The FastAPI catch-all must preserve route()'s response headers. + + /sitemap.xml is served through the catch-all; wrapping every + response in HTMLResponse would discard its XML Content-Type. + """ + + def request(self, url): + return SimpleNamespace(url=url) + + def test_sitemap_keeps_xml_content_type_through_the_catch_all(self): + main = self.import_main() + response = asyncio.run( + main.not_found("sitemap.xml", self.request("https://example.test/sitemap.xml")) + ) + self.assertEqual(response.headers["Content-Type"], "application/xml; charset=utf-8") + self.assertIn("= len(cells): failures.append(