Skip to content

Commit af7bde4

Browse files
committed
Add comparison examples, footgun coverage, and quality enforcement
Catalog additions - iterator-vs-iterable: contrasts iterables (replayable) with iterators (one-pass), shows iter() returning fresh vs same object, and the API boundary where the distinction silently breaks generic helpers. - classmethods-and-staticmethods: contrasts the three method shapes (instance, @classmethod, @staticmethod) using a Date class with an alternate constructor and a leap-year helper. - abstract-base-classes: ABC with @AbstractMethod, instantiation failure for incomplete subclasses, and a side-by-side contrast with Protocol (nominal vs structural typing). - structured-data-shapes: dataclass, typing.NamedTuple, and TypedDict side by side, including their distinct runtime identities. - bound-and-unbound-methods: instance.method binds self automatically; Class.method is the plain function; the descriptor protocol explains the binding. Existing-page upgrades - special-methods: __str__ alongside __repr__ with the print() vs repr() contrast, plus scope_first_pass framing and see_also links to the focused dunder pages. - generators: yield-vs-return cell (eager list vs lazy stream, list reusable vs generator one-pass) and a class-iterator vs generator cell showing the same Countdown two ways. - classes: class-vs-instance attributes, the mutable-default-class- attribute footgun and its per-instance fix in __init__. - tuples: explicit list-vs-tuple contrast cell. - functions: mutable default argument footgun with the None-sentinel fix. - dicts: dictionary-mutation-during-iteration footgun with the list(d.keys()) snapshot fix. - numbers: floating-point equality footgun with math.isclose. - exceptions: bare/broad-except footgun with the ValueError fix. - Several pages get scope_first_pass=true plus richer see_also where the broad title genuinely surveys a wider area. Rubric and enforcement - Quality rubric gains a confusable-pair index, broad-surface checklists, footgun coverage list, paired-page rule, additional weak- example smells, and two new strengthening-checklist questions. - docs/quality-registries.toml is the source of truth for the three machine-checkable registries. - scripts/check_confusable_pairs.py, check_broad_surface_tours.py, and check_footgun_coverage.py are strict gates wired into make verify via a new quality-checks target. check_notes_supported.py runs as a warning-only heuristic. Cross-link sweep - Iteration, class, type, and data-model neighbors gain see_also entries pointing at the new pages so navigation reflects the new graph.
1 parent 8a34917 commit af7bde4

37 files changed

Lines changed: 1709 additions & 10 deletions

Makefile

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: test embed-examples build check-generated fingerprint browser-layout-test seo-cache-lint verify-examples format-examples verify-python-version verify dev deploy lint
1+
.PHONY: test embed-examples build check-generated fingerprint browser-layout-test seo-cache-lint verify-examples check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported quality-checks format-examples verify-python-version verify dev deploy lint
22

33
test:
44
python3 -m unittest discover -s tests -v
@@ -23,6 +23,20 @@ seo-cache-lint:
2323
verify-examples: build
2424
scripts/verify_examples.py
2525

26+
check-confusable-pairs:
27+
scripts/check_confusable_pairs.py
28+
29+
check-broad-surface-tours:
30+
scripts/check_broad_surface_tours.py
31+
32+
check-footgun-coverage:
33+
scripts/check_footgun_coverage.py
34+
35+
check-notes-supported:
36+
scripts/check_notes_supported.py
37+
38+
quality-checks: check-confusable-pairs check-broad-surface-tours check-footgun-coverage check-notes-supported
39+
2640
format-examples:
2741
scripts/format_examples.py
2842

@@ -32,7 +46,7 @@ verify-python-version: build
3246
lint:
3347
uv run ruff check src tests scripts
3448

35-
verify: build test seo-cache-lint verify-examples browser-layout-test lint check-generated
49+
verify: build test seo-cache-lint verify-examples quality-checks browser-layout-test lint check-generated
3650

3751
dev:
3852
uv run pywrangler dev --port 9696

docs/example-quality-rubric.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,62 @@ Flag these during review even when the code is correct:
7070
- The page has no editorial progression: examples are technically related but ordered like a checklist rather than a learning path.
7171
- The page reduces so aggressively that a necessary edge case or contrast disappears.
7272
- `See also` links behave like tags instead of prerequisite, neighbor, or next-depth graph edges.
73+
- A claim in the `:::note` block is not demonstrated by a cell on the same page.
74+
- A confusable pair listed in the registry has only one side shown on its owning page.
75+
- A canonical Python footgun listed in the registry has no page that shows the broken case and the fix.
76+
- Pages whose titles differ only by a suffix or modifier (`iterators` vs `iterating-over-iterables`, `generators` vs `generator-expressions`) assert the relationship in prose without a cell that demonstrates it.
77+
78+
## Confusable-pair index
79+
80+
Each pair below names two or three concepts learners commonly confuse, and the single page that owns the contrast. The owning page must show both (or all) sides in cells, not merely mention them in prose. `scripts/check_confusable_pairs.py` enforces this.
81+
82+
| Pair | Owning page |
83+
| --- | --- |
84+
| `__str__` / `__repr__` | `special-methods.md` |
85+
| `is` / `==` | `equality-and-identity.md` |
86+
| list / tuple | `tuples.md` |
87+
| `@classmethod` / `@staticmethod` / instance method | `classmethods-and-staticmethods.md` |
88+
| `isinstance()` / `type() ==` | `runtime-type-checks.md` |
89+
| generator / class iterator | `generators.md` |
90+
| iterator / iterable | `iterator-vs-iterable.md` |
91+
| mutable / immutable class attributes | `classes.md` |
92+
| eager / lazy production | `generators.md` |
93+
| `Protocol` / `ABC` | `abstract-base-classes.md` |
94+
| `dataclass` / `NamedTuple` / `TypedDict` | `structured-data-shapes.md` |
95+
| bound / unbound methods | `bound-and-unbound-methods.md` |
96+
| `yield` / `return` | `generators.md` |
97+
| shallow / deep copy | `copying-collections.md` |
98+
| sync / async functions | `async-await.md` |
99+
100+
## Broad-surface checklists
101+
102+
For each title that names a broad surface area, the page must touch every form below or scope itself down explicitly with a `see_also` link to a focused neighbor. `scripts/check_broad_surface_tours.py` enforces this.
103+
104+
- **Special Methods** (`special-methods.md`): `__init__`, `__repr__`, `__str__`, `__eq__`, `__hash__`, `__lt__`, `__len__`, `__iter__`, `__contains__`, `__getitem__`, `__setitem__`, `__call__`, `__enter__`/`__exit__`, `__bool__`.
105+
- **Operators** (`operators.md`): arithmetic, comparison, identity (`is`), membership (`in`), boolean short-circuit (`and`/`or`), bitwise, walrus (`:=`).
106+
- **Type Hints** (`type-hints.md`): scalar annotations, container generics (`list[int]`), `|` unions, `Optional`, function signatures, `TypeAlias`, runtime visibility note.
107+
- **Testing** (`testing.md`): `unittest.TestCase`, `assertEqual`/`assertRaises`, fixtures or `setUp`, parametrized cases or sub-tests, discovery convention.
108+
- **Async Await** (`async-await.md`): `async def`, `await`, `asyncio.run`, `asyncio.gather`, `async for`, `async with`.
109+
- **Packages** (`packages.md`): package layout, `__init__.py`, relative vs absolute imports, `__all__`, namespace packages.
110+
- **Regular Expressions** (`regular-expressions.md`): `re.match`/`re.search`/`re.findall`, groups, named groups, `re.compile`, flags such as `re.IGNORECASE`/`re.MULTILINE`, substitution.
111+
- **Literals** (`literals.md`): integer (decimal/hex/binary/underscored), float, string (raw/bytes/f-string), boolean, `None`, container literals.
112+
113+
## Footgun coverage
114+
115+
Each canonical Python surprise below must have a page that demonstrates the broken case and the fix. `scripts/check_footgun_coverage.py` enforces this.
116+
117+
| Footgun | Owning page |
118+
| --- | --- |
119+
| Mutable default class attribute (`items = []` on the class body) | `classes.md` |
120+
| Mutable default function argument (`def f(items=[])`) | `functions.md` |
121+
| Late-binding closure in a loop | `closures.md` |
122+
| Integer identity caching (`is` for small ints) | `equality-and-identity.md` |
123+
| Shallow vs deep copy on nested mutable structures | `copying-collections.md` |
124+
| Generator one-pass exhaustion | `generators.md` |
125+
| Dictionary mutation during iteration | `dicts.md` |
126+
| Floating-point equality | `numbers.md` |
127+
| `bool` as a subclass of `int` | `booleans.md` |
128+
| Bare `except` swallowing `KeyboardInterrupt` / `SystemExit` | `exceptions.md` |
73129

74130
## Strengthening checklist
75131

@@ -94,3 +150,5 @@ Before publishing or substantially editing an example, ask:
94150
17. For broad pages, is this a map with categories and links, or should it be split?
95151
18. Do edge cases appear close enough to the main idea that readers understand the boundary?
96152
19. Do `See also` links express prerequisite, neighbor, or next-depth relationships rather than tags?
153+
20. Is every claim in the `:::note` block demonstrated by a cell on this page?
154+
21. If this page's title appears in the confusable-pair index or footgun registry, does the page show both sides (or both the broken and fixed forms)?

docs/quality-registries.toml

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Quality registries.
2+
#
3+
# Source of truth for the rubric checks in `scripts/check_*.py`.
4+
# Each entry pins a contrast or footgun to a single owning page, so the
5+
# catalog has exactly one home for the lesson and verifiers can prove it.
6+
7+
[[confusable_pairs]]
8+
name = "__str__ vs __repr__"
9+
owner = "special-methods"
10+
tokens = ["__str__", "__repr__"]
11+
12+
[[confusable_pairs]]
13+
name = "is vs =="
14+
owner = "equality-and-identity"
15+
tokens = [" is ", "=="]
16+
17+
[[confusable_pairs]]
18+
name = "list vs tuple"
19+
owner = "tuples"
20+
tokens = ["list", "tuple"]
21+
22+
[[confusable_pairs]]
23+
name = "classmethod vs staticmethod vs instance method"
24+
owner = "classmethods-and-staticmethods"
25+
tokens = ["@classmethod", "@staticmethod", "self"]
26+
27+
[[confusable_pairs]]
28+
name = "isinstance vs type=="
29+
owner = "runtime-type-checks"
30+
tokens = ["isinstance(", "type("]
31+
32+
[[confusable_pairs]]
33+
name = "generator vs class iterator"
34+
owner = "generators"
35+
tokens = ["yield", "__next__"]
36+
37+
[[confusable_pairs]]
38+
name = "iterator vs iterable"
39+
owner = "iterator-vs-iterable"
40+
tokens = ["iterable", "iterator", "iter("]
41+
42+
[[confusable_pairs]]
43+
name = "mutable vs immutable class attributes"
44+
owner = "classes"
45+
tokens = ["class attribute", "__init__"]
46+
47+
[[confusable_pairs]]
48+
name = "eager vs lazy production"
49+
owner = "generators"
50+
tokens = ["return", "yield"]
51+
52+
[[confusable_pairs]]
53+
name = "Protocol vs ABC"
54+
owner = "abstract-base-classes"
55+
tokens = ["Protocol", "ABC"]
56+
57+
[[confusable_pairs]]
58+
name = "dataclass vs NamedTuple vs TypedDict"
59+
owner = "structured-data-shapes"
60+
tokens = ["@dataclass", "NamedTuple", "TypedDict"]
61+
62+
[[confusable_pairs]]
63+
name = "bound vs unbound methods"
64+
owner = "bound-and-unbound-methods"
65+
tokens = ["bound method", "Class.method"]
66+
67+
[[confusable_pairs]]
68+
name = "yield vs return"
69+
owner = "generators"
70+
tokens = ["yield", "return"]
71+
72+
[[confusable_pairs]]
73+
name = "shallow vs deep copy"
74+
owner = "copying-collections"
75+
tokens = ["copy(", "deepcopy("]
76+
77+
[[confusable_pairs]]
78+
name = "sync vs async functions"
79+
owner = "async-await"
80+
tokens = ["async def", "def "]
81+
82+
[broad_surface_tours]
83+
84+
[broad_surface_tours.special-methods]
85+
required_tokens = [
86+
"__init__", "__repr__", "__str__", "__eq__", "__hash__", "__lt__",
87+
"__len__", "__iter__", "__contains__", "__getitem__", "__setitem__",
88+
"__call__", "__enter__", "__exit__", "__bool__",
89+
]
90+
91+
[broad_surface_tours.operators]
92+
required_tokens = ["+", "==", " is ", " in ", "and", "or", "&", ":="]
93+
94+
[broad_surface_tours.type-hints]
95+
required_tokens = ["list[", " | ", "Optional", "TypeAlias"]
96+
97+
[broad_surface_tours.testing]
98+
required_tokens = ["TestCase", "assertEqual", "assertRaises", "setUp"]
99+
100+
[broad_surface_tours.async-await]
101+
required_tokens = ["async def", "await", "asyncio.run", "asyncio.gather", "async for", "async with"]
102+
103+
[broad_surface_tours.packages]
104+
required_tokens = ["__init__.py", "from .", "__all__"]
105+
106+
[broad_surface_tours.regular-expressions]
107+
required_tokens = ["re.match", "re.search", "re.findall", "re.compile", "re.IGNORECASE", "re.sub"]
108+
109+
[broad_surface_tours.literals]
110+
required_tokens = ["0x", "0b", "_", "f\"", "True", "None"]
111+
112+
[[footguns]]
113+
name = "Mutable default class attribute"
114+
owner = "classes"
115+
broken_tokens = ["items = []"]
116+
fixed_tokens = ["self.items = []"]
117+
118+
[[footguns]]
119+
name = "Mutable default function argument"
120+
owner = "functions"
121+
broken_tokens = ["=[]", "={}"]
122+
fixed_tokens = ["= None", "is None"]
123+
124+
[[footguns]]
125+
name = "Late-binding closure in a loop"
126+
owner = "closures"
127+
broken_tokens = ["for ", "lambda"]
128+
fixed_tokens = ["default", "="]
129+
130+
[[footguns]]
131+
name = "Integer identity caching"
132+
owner = "equality-and-identity"
133+
broken_tokens = [" is "]
134+
fixed_tokens = ["=="]
135+
136+
[[footguns]]
137+
name = "Shallow vs deep copy"
138+
owner = "copying-collections"
139+
broken_tokens = ["copy("]
140+
fixed_tokens = ["deepcopy("]
141+
142+
[[footguns]]
143+
name = "Generator one-pass exhaustion"
144+
owner = "generators"
145+
broken_tokens = ["yield"]
146+
fixed_tokens = ["list("]
147+
148+
[[footguns]]
149+
name = "Dictionary mutation during iteration"
150+
owner = "dicts"
151+
broken_tokens = ["for ", "del "]
152+
fixed_tokens = ["list(", ".keys("]
153+
154+
[[footguns]]
155+
name = "Floating-point equality"
156+
owner = "numbers"
157+
broken_tokens = ["0.1", "0.2"]
158+
fixed_tokens = ["isclose", "math"]
159+
160+
[[footguns]]
161+
name = "bool as a subclass of int"
162+
owner = "booleans"
163+
broken_tokens = ["True", "1"]
164+
fixed_tokens = ["isinstance", "bool"]
165+
166+
[[footguns]]
167+
name = "Bare except swallowing KeyboardInterrupt"
168+
owner = "exceptions"
169+
broken_tokens = ["except:", "except Exception"]
170+
fixed_tokens = ["except ", " as "]
171+
172+
[paired_pages]
173+
# Pages whose titles differ only by a suffix or modifier; at least one
174+
# member of each pair must demonstrate the relationship in a cell.
175+
pairs = [
176+
["iterators", "iterating-over-iterables"],
177+
["iterators", "iterator-vs-iterable"],
178+
["generators", "generator-expressions"],
179+
["comprehensions", "comprehension-patterns"],
180+
]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env python3
2+
"""Verify that each broad-surface tour page covers its required tokens.
3+
4+
Reads `docs/quality-registries.toml`. A page may opt out of the strict
5+
check by adding `scope_first_pass = true` to its frontmatter, in which
6+
case it must instead carry `see_also` links pointing at focused
7+
neighbors that the registry expects to exist.
8+
"""
9+
from __future__ import annotations
10+
11+
import re
12+
import sys
13+
import tomllib
14+
from pathlib import Path
15+
16+
ROOT = Path(__file__).resolve().parents[1]
17+
EXAMPLES_DIR = ROOT / "src" / "example_sources"
18+
REGISTRY_PATH = ROOT / "docs" / "quality-registries.toml"
19+
20+
21+
FRONTMATTER_RE = re.compile(r"^\+\+\+\n(.*?)\n\+\+\+\n", re.DOTALL)
22+
23+
24+
def parse_frontmatter(text: str) -> dict:
25+
match = FRONTMATTER_RE.match(text)
26+
if not match:
27+
return {}
28+
return tomllib.loads(match.group(1))
29+
30+
31+
def main() -> int:
32+
data = tomllib.loads(REGISTRY_PATH.read_text())
33+
tours = data.get("broad_surface_tours", {})
34+
errors: list[str] = []
35+
for slug, spec in tours.items():
36+
page = EXAMPLES_DIR / f"{slug}.md"
37+
if not page.exists():
38+
errors.append(f"{REGISTRY_PATH}: broad-tour page missing: {slug}.md")
39+
continue
40+
text = page.read_text()
41+
frontmatter = parse_frontmatter(text)
42+
required = spec.get("required_tokens", [])
43+
missing = [token for token in required if token not in text]
44+
if frontmatter.get("scope_first_pass"):
45+
see_also = frontmatter.get("see_also") or []
46+
if not see_also:
47+
errors.append(
48+
f"{page}: scope_first_pass=true requires see_also links to focused neighbors"
49+
)
50+
continue
51+
if missing:
52+
errors.append(
53+
f"{page}: broad-tour {slug!r} missing tokens: {missing}; "
54+
"either add cells covering them or set scope_first_pass=true with see_also"
55+
)
56+
if errors:
57+
for error in errors:
58+
print(error, file=sys.stderr)
59+
return 1
60+
print(f"Broad-surface tour coverage OK ({len(tours)} pages).")
61+
return 0
62+
63+
64+
if __name__ == "__main__":
65+
raise SystemExit(main())

scripts/check_confusable_pairs.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env python3
2+
"""Verify that each confusable pair appears on its owning page.
3+
4+
Reads `docs/quality-registries.toml` and fails if the owning page's source
5+
text is missing any token from the pair. Tokens are matched as substrings,
6+
so they should be specific enough to avoid false positives.
7+
"""
8+
from __future__ import annotations
9+
10+
import sys
11+
import tomllib
12+
from pathlib import Path
13+
14+
ROOT = Path(__file__).resolve().parents[1]
15+
EXAMPLES_DIR = ROOT / "src" / "example_sources"
16+
REGISTRY_PATH = ROOT / "docs" / "quality-registries.toml"
17+
18+
19+
def main() -> int:
20+
data = tomllib.loads(REGISTRY_PATH.read_text())
21+
pairs = data.get("confusable_pairs", [])
22+
errors: list[str] = []
23+
for entry in pairs:
24+
name = entry["name"]
25+
owner = entry["owner"]
26+
tokens = entry["tokens"]
27+
page = EXAMPLES_DIR / f"{owner}.md"
28+
if not page.exists():
29+
errors.append(f"{REGISTRY_PATH}: owner page missing for {name!r}: {owner}.md")
30+
continue
31+
text = page.read_text()
32+
missing = [token for token in tokens if token not in text]
33+
if missing:
34+
errors.append(f"{page}: confusable pair {name!r} missing tokens: {missing}")
35+
if errors:
36+
for error in errors:
37+
print(error, file=sys.stderr)
38+
return 1
39+
print(f"Confusable-pair coverage OK ({len(pairs)} pairs).")
40+
return 0
41+
42+
43+
if __name__ == "__main__":
44+
raise SystemExit(main())

0 commit comments

Comments
 (0)