Skip to content

Commit 0a007f1

Browse files
committed
Close Markdown migration follow-ups
1 parent 959c86a commit 0a007f1

5 files changed

Lines changed: 194 additions & 21 deletions

File tree

.github/workflows/verify.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Verify
2+
3+
on:
4+
push:
5+
pull_request:
6+
7+
jobs:
8+
verify:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: astral-sh/setup-uv@v5
13+
- uses: actions/setup-python@v5
14+
with:
15+
python-version: '3.13'
16+
- name: Install dependencies
17+
run: uv sync --all-groups
18+
- name: Start local Worker for browser checks
19+
run: |
20+
uv run --group workers pywrangler dev --port 9696 > /tmp/pythonbyexample-ci.log 2>&1 &
21+
for i in $(seq 1 180); do
22+
if grep -q 'Ready on http://localhost:9696' /tmp/pythonbyexample-ci.log; then
23+
exit 0
24+
fi
25+
if grep -E 'failed to start|FileNotFoundError|Traceback|Address already' /tmp/pythonbyexample-ci.log; then
26+
cat /tmp/pythonbyexample-ci.log
27+
exit 1
28+
fi
29+
sleep 1
30+
done
31+
cat /tmp/pythonbyexample-ci.log
32+
exit 1
33+
- name: Verify
34+
run: |
35+
make verify
36+
scripts/check_example_migration_parity.py
37+
scripts/format_examples.py --check
38+
make verify-python-version VERSION=3.13
39+
git diff --check

README.md

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Python By Example is a Go By Example-inspired learning site for Python 3.13. It presents small literate examples with prose, source fragments, expected output, official Python documentation links, and an editable runner.
44

5-
Production: <https://pythonbyexample.adewale-883.workers.dev>
5+
Production: <https://www.pythonbyexample.dev> (`workers.dev` remains enabled as a fallback).
66

77
## Features
88

@@ -40,7 +40,10 @@ Python documentation links point to the official [Python documentation](https://
4040
- `src/main.py` is the Cloudflare Worker entrypoint.
4141
- FastAPI handles request routing through the Cloudflare ASGI bridge.
4242
- `src/app.py` renders pages from simple placeholder templates in `src/templates/`.
43-
- `src/examples.py` contains the example catalog and expected outputs.
43+
- `src/example_sources/*.md` contains the human-editable examples.
44+
- `src/example_loader.py` parses Markdown examples into the runtime catalog.
45+
- `src/examples.py` is a compatibility shim for the loaded catalog.
46+
- `src/example_sources_data.py` is generated embedded source data for Cloudflare Workers.
4447
- `public/` contains static assets served by Workers Assets.
4548
- `python_modules/` is generated by the Workers tooling and intentionally ignored by Git.
4649

@@ -77,10 +80,10 @@ http://localhost:9696
7780
Run the main checks before deploying or pushing:
7881

7982
```bash
80-
make test
81-
make seo-cache-lint
82-
make browser-layout-test
83-
uv run ruff check src tests scripts
83+
make verify
84+
scripts/check_example_migration_parity.py
85+
scripts/format_examples.py --check
86+
make verify-python-version VERSION=3.13
8487
```
8588

8689
`make seo-cache-lint` verifies that:
@@ -105,10 +108,10 @@ public/syntax-highlight.js
105108
public/editor.js
106109
```
107110

108-
Before tests/deploy, regenerate fingerprinted asset copies and the Python manifest:
111+
Before tests/deploy, regenerate embedded example data, fingerprinted asset copies, and Python manifests:
109112

110113
```bash
111-
scripts/fingerprint_assets.py
114+
make build
112115
```
113116

114117
This writes files such as:
@@ -150,15 +153,33 @@ make deploy
150153

151154
## Updating examples or Python version
152155

153-
Edit `src/examples.py`:
156+
Edit Markdown files under `src/example_sources/`.
157+
158+
Each example has:
159+
160+
- TOML frontmatter with `slug`, `title`, `section`, `summary`, and `doc_path`
161+
- exactly one `:::program` block for the full editable source
162+
- one or more `:::cell` blocks for verified prose/source/output teaching cells
163+
- optional `:::note` blocks
164+
165+
After editing examples, run:
154166

155-
- update `PYTHON_VERSION` for the docs/runtime target
156-
- add, remove, or revise entries in `EXAMPLES`
157-
- keep examples self-contained
158-
- keep `expected_output` deterministic
159-
- link `doc_url` to `https://docs.python.org/3.13/` or the active supported version
167+
```bash
168+
make build
169+
make verify-examples
170+
scripts/format_examples.py --check
171+
scripts/check_example_migration_parity.py
172+
```
173+
174+
`src/example_sources_data.py` is generated and committed so Cloudflare Workers can load examples in production. Do not edit it by hand.
175+
176+
For a Python version migration, update `python_version` and `docs_base_url` in `src/example_sources/manifest.toml`, then run:
177+
178+
```bash
179+
make verify-python-version VERSION=3.13
180+
```
160181

161-
Then update tests first, run them red, implement, refactor, and regenerate fingerprints.
182+
Use the active Cloudflare-supported Python version.
162183

163184
## Cloudflare notes
164185

docs/example-source-format-spec.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,14 @@ Build requirements:
455455

456456
## Formatter
457457

458-
`format_examples.py` should make Markdown examples stable without rewriting author voice.
458+
`format_examples.py` makes Markdown examples stable without rewriting author voice.
459459

460460
Required behavior:
461461

462462
- Preserve prose text, except for trimming trailing whitespace and normalizing blank lines.
463+
- Parse TOML frontmatter and reserialize it deterministically.
463464
- Sort frontmatter keys in this order: `slug`, `title`, `section`, `summary`, `doc_path`, then optional version fields.
464-
- Normalize frontmatter to TOML with quoted strings.
465+
- Normalize frontmatter to TOML with quoted strings and lowercase booleans.
465466
- Normalize code fences to exactly `````python` and `````output`.
466467
- Ensure each file ends with one newline.
467468
- Leave Python code semantics unchanged; do not run Black over snippets unless the command can prove output is unchanged.
@@ -530,6 +531,36 @@ The command should fail if:
530531

531532
`verify-python-version` is only meaningful when `uv` can run the requested Python version. Until Cloudflare exposes the same runtime locally, the migration also requires a Worker smoke test for representative examples and at least one POST execution through Dynamic Workers.
532533

534+
## Golden fixture policy
535+
536+
`tests/fixtures/golden_examples.py` is temporary but intentional migration safety infrastructure.
537+
538+
Rules:
539+
540+
- Keep the golden fixture checked in while Markdown remains new.
541+
- Do not update the golden fixture in the same change as ordinary content edits.
542+
- If changing intended teaching structure, update Markdown first and let parity fail; then update the golden fixture in a separate explicit fixture-refresh commit after review.
543+
- Remove the golden fixture only after at least one stable production release cycle, CI coverage for `make verify` and `make check-generated`, and an explicit cleanup PR.
544+
- Until cleanup, `scripts/check_example_migration_parity.py` must be a required verification command.
545+
546+
## CI policy
547+
548+
GitHub Actions must run the same checks expected locally:
549+
550+
```bash
551+
make verify
552+
scripts/check_example_migration_parity.py
553+
scripts/format_examples.py --check
554+
make verify-python-version VERSION=3.13
555+
git diff --check
556+
```
557+
558+
CI must start `pywrangler dev --port 9696` before `make verify` so `browser-layout-test` exercises a real Worker runtime. CI must fail if generated files are stale.
559+
560+
## Contributor documentation policy
561+
562+
The README must describe Markdown example editing, `:::program`, `:::cell`, generated embedded source data, `make build`, `make verify-examples`, and the parity check. Contributors should not need to edit `src/examples.py` or `src/example_sources_data.py` by hand.
563+
533564
## Worker bundling policy
534565

535566
Default policy:
@@ -549,6 +580,8 @@ Do not replace this with a native Wrangler data-file approach unless a spike pro
549580

550581
The attempted migration failed at Worker startup because Markdown files were not present under `/session/metadata/`. That failure mode must remain covered by a local Worker startup check.
551582

583+
Native Markdown bundling remains unproven and is not on the production path. The accepted solution is embedded source data generated by `scripts/embed_example_sources.py`. A future native bundling change must be isolated as a spike and prove local dev, production deploy, imports, cache fingerprinting, and failure behavior before replacing embedded data.
584+
552585
## Golden parity script
553586

554587
Add a dedicated migration script before switching the app:

scripts/format_examples.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,48 @@
33
from __future__ import annotations
44

55
import argparse
6+
import tomllib
67
from pathlib import Path
8+
from typing import Any
79

810
ROOT = Path(__file__).resolve().parents[1]
911
SOURCE_DIR = ROOT / "src" / "example_sources"
12+
FRONTMATTER_ORDER = ["slug", "title", "section", "summary", "doc_path", "min_python", "version_sensitive", "version_notes"]
1013

1114

12-
def normalize_text(text: str) -> str:
15+
def toml_value(value: Any) -> str:
16+
if isinstance(value, bool):
17+
return "true" if value else "false"
18+
if isinstance(value, str):
19+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
20+
if isinstance(value, list):
21+
if all(isinstance(item, str) for item in value):
22+
if len(value) == 0:
23+
return "[]"
24+
inner = "\n".join(f" {toml_value(item)}," for item in value)
25+
return "[\n" + inner + "\n]"
26+
raise TypeError(f"Unsupported TOML value: {value!r}")
27+
28+
29+
def ordered_keys(data: dict[str, Any]) -> list[str]:
30+
known = [key for key in FRONTMATTER_ORDER if key in data]
31+
unknown = sorted(key for key in data if key not in FRONTMATTER_ORDER)
32+
return known + unknown
33+
34+
35+
def dump_toml(data: dict[str, Any]) -> str:
36+
return "\n".join(f"{key} = {toml_value(data[key])}" for key in ordered_keys(data)) + "\n"
37+
38+
39+
def normalize_body(text: str) -> str:
1340
lines = [line.rstrip() for line in text.replace("\r\n", "\n").split("\n")]
1441
out: list[str] = []
1542
blank = False
1643
in_fence = False
1744
for line in lines:
1845
if line.startswith("```"):
46+
if line == "```py":
47+
line = "```python"
1948
in_fence = not in_fence
2049
if not in_fence and line == "":
2150
if blank:
@@ -24,17 +53,46 @@ def normalize_text(text: str) -> str:
2453
else:
2554
blank = False
2655
out.append(line)
27-
return "\n".join(out).strip() + "\n"
56+
return "\n".join(out).strip()
57+
58+
59+
def split_frontmatter(text: str, path: Path) -> tuple[dict[str, Any], str]:
60+
normalized = text.replace("\r\n", "\n")
61+
if not normalized.startswith("+++\n"):
62+
raise ValueError(f"{path}: expected +++ frontmatter")
63+
marker = "\n+++\n"
64+
end = normalized.find(marker, 4)
65+
if end < 0:
66+
raise ValueError(f"{path}: missing closing +++")
67+
frontmatter = tomllib.loads(normalized[4:end])
68+
return frontmatter, normalized[end + len(marker) :]
69+
70+
71+
def format_markdown(path: Path) -> str:
72+
frontmatter, body = split_frontmatter(path.read_text(), path)
73+
return "+++\n" + dump_toml(frontmatter) + "+++\n\n" + normalize_body(body) + "\n"
74+
75+
76+
def format_manifest(path: Path) -> str:
77+
data = tomllib.loads(path.read_text())
78+
sections = [
79+
f"python_version = {toml_value(data['python_version'])}",
80+
f"docs_base_url = {toml_value(data['docs_base_url'])}",
81+
"",
82+
f"order = {toml_value(data['order'])}",
83+
]
84+
return "\n".join(sections) + "\n"
2885

2986

3087
def main() -> int:
3188
parser = argparse.ArgumentParser()
3289
parser.add_argument("--check", action="store_true")
3390
args = parser.parse_args()
3491
changed: list[Path] = []
35-
for path in [SOURCE_DIR / "manifest.toml", *sorted(SOURCE_DIR.glob("*.md"))]:
92+
paths = [SOURCE_DIR / "manifest.toml", *sorted(SOURCE_DIR.glob("*.md"))]
93+
for path in paths:
3694
original = path.read_text()
37-
formatted = normalize_text(original)
95+
formatted = format_manifest(path) if path.name == "manifest.toml" else format_markdown(path)
3896
if formatted != original:
3997
changed.append(path)
4098
if not args.check:

tests/test_markdown_migration_prereqs.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
ROOT = Path(__file__).resolve().parents[1]
55
SPEC = ROOT / "docs" / "example-source-format-spec.md"
66
INVESTIGATION = ROOT / "docs" / "markdown-cell-migration-investigation.md"
7+
README = ROOT / "README.md"
8+
CI = ROOT / ".github" / "workflows" / "verify.yml"
79

810

911
class MarkdownMigrationPrereqTests(unittest.TestCase):
@@ -82,6 +84,26 @@ def test_spec_captures_successful_migration_and_rollback_rehearsal(self):
8284
self.assertIn("revert the migration, deploy the rollback", spec)
8385
self.assertIn("re-apply the migration and repeat verification", spec)
8486

87+
def test_remaining_operational_lessons_are_documented_and_verified(self):
88+
spec = SPEC.read_text()
89+
readme = README.read_text()
90+
workflow = CI.read_text()
91+
for phrase in [
92+
"Golden fixture policy",
93+
"CI policy",
94+
"Contributor documentation policy",
95+
"Native Markdown bundling remains unproven",
96+
"reserialize it deterministically",
97+
]:
98+
with self.subTest(phrase=phrase):
99+
self.assertIn(phrase, spec)
100+
for phrase in ["src/example_sources/", ":::program", ":::cell", "make build", "make verify-examples"]:
101+
with self.subTest(readme=phrase):
102+
self.assertIn(phrase, readme)
103+
for phrase in ["make verify", "check_example_migration_parity.py", "format_examples.py --check", "verify-python-version"]:
104+
with self.subTest(workflow=phrase):
105+
self.assertIn(phrase, workflow)
106+
85107

86108
if __name__ == "__main__":
87109
unittest.main()

0 commit comments

Comments
 (0)