We asked Claude Fable 5.2 to draw a python reading a book using the turtle module. It’s one of five tasks in Real Python’s informal model comparison, which explores how models handle everyday Python work. Here’s the drawing from this run:

We use the same prompts and no system prompt for each model. The results come from the returned code and replies. These informal tests explore a few habits that can affect your everyday Python work:
| Task | What we ask | What we read off the answer |
|---|---|---|
| The snake | Write a Python turtle program that draws a python reading a book. | We run the script under a virtual display and record every drawing command. The picture is whatever came out, arrow and all. |
| How modern its Python is | Write a small command-line tool. | Which features it uses, such as list[str], tomllib, and Path.walk(). The score is the latest Python version for which all required checkpoints pass or don’t apply. |
| Modern idioms | The same CLI tool as above. | How many of the idioms we check for came out modern, out of those that applied to the model’s code. |
| Latest Python version reported | Which Python release is the newest? | Whether its answer is up to date. This doesn’t establish its training cutoff. |
| Unrelated edits | Add an --ext option to a 50-line script. |
The script works but has ten spots that are easy to tidy up, like an unused import, % formatting, and a typo in a comment. None of them is part of the task. We list the ones the model changed anyway. Inspect the diff to see whether those changes were useful. |
| Spots a made-up function? | How do I use itertools.flatten()? |
That function doesn’t exist. Caught it means the model said so. Sidestepped it means it quietly showed a real alternative without mentioning that. Fell for it means it invented an answer. |
| Reasoning effort | The thinking setting we ran with. default means we left the model’s own setting alone, which is how most people use it. The token count is how much thinking the API reported. | |
| Cost | What the API charged for all five tasks, in US dollars. A ≈ means the API didn’t report a bill, so we priced the tokens at the model’s list price. |
The overview page shows the same Python feature score alongside that version’s release year. Code can use newer features and still receive a lower score.
New Model Drops? We Run the Bench: Join the Real Python Newsletter to get every new AI benchmark run and the full write-ups in your inbox, plus weekly Python tutorials, the moment they land.
Learning Path
Python Coding With AI
12 Resources ⋅ Skills: Cursor, Claude Code, AI-Assisted Development, Context Engineering, AI Code Review
The Results at a Glance
| Task | Result | Detail |
|---|---|---|
| The snake | Drew it | 2214 drawing commands, 260 lines of code |
| How modern its Python is | Python 3.14 (2025) | no strong checkpoint came out stale. Newest feature used: Python 3.14 |
| Modern idioms | 13 of 14 | of the checkpoints that applied to its code |
| Latest Python version reported | 3.14 | the reply is quoted below |
| Unrelated edits | 0 of 10 | left every unrelated spot alone |
| Spots a made-up function? | Caught it | said the function doesn’t exist and offered a real alternative |
| Reasoning effort | default | 38.9k thinking tokens over all five tasks |
| Cost | $1.97 | for all 5 tasks, as billed by the API |
| Response time | 8 min 32 s | 49.1k output tokens |
The run: anthropic/claude-fable-5.2 via OpenRouter, at its default reasoning settings, with no system prompt and the returned code left unchanged.
The Five Tasks
The Snake
The script ran without errors. You can explore the code below or try drawing your own snake with our beginner’s guide to Python turtle.
turtle.py
"""A python (the snake) reading a book, drawn with Python's turtle module."""
import math
import turtle
# ---------- palette ----------
WALL, FLOOR, BASEBOARD = "#fdf1dc", "#d9b48c", "#fffaf0"
RUG, RUG_TRIM, SHADOW = "#b85c6e", "#e9b9a6", "#9a4a5b"
BODY, SPOT, SPOT_CORE = "#6ab04c", "#3f7d2c", "#a9d97f"
BELLY, OUTLINE = "#f4e7a1", "#2c4a1e"
INK, PAGE, PAGE_EDGE, COVER, TEXT_LINE = "#3b2a1a", "#fffaf0", "#eadfc8", "#8b2f3c", "#a39582"
screen = turtle.Screen()
screen.setup(900, 660)
screen.title("A Python Reading a Book")
screen.bgcolor(WALL)
screen.tracer(0) # draw instantly; remove this line to watch it draw
t = turtle.Turtle()
t.hideturtle()
t.speed(0)
t.penup()
# ---------- geometry helpers ----------
def bezier(ctrl, steps=40):
"""Points along a Bezier curve of any degree (de Casteljau)."""
pts = []
for i in range(steps + 1):
s = i / steps
p = list(ctrl)
while len(p) > 1:
p = [((1 - s) * x0 + s * x1, (1 - s) * y0 + s * y1)
for (x0, y0), (x1, y1) in zip(p, p[1:])]
pts.append(p[0])
return pts
def ellipse(cx, cy, rx, ry, tilt=0, steps=72):
a = math.radians(tilt)
pts = []
for i in range(steps + 1):
th = 2 * math.pi * i / steps
x, y = rx * math.cos(th), ry * math.sin(th)
pts.append((cx + x * math.cos(a) - y * math.sin(a),
cy + x * math.sin(a) + y * math.cos(a)))
return pts
def rect(x, y, w, h):
return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
def offset_path(points, dist):
"""Shift a path sideways (positive = to the right of travel)."""
out = []
last = len(points) - 1
for i, (x, y) in enumerate(points):
xa, ya = points[max(i - 1, 0)]
xb, yb = points[min(i + 1, last)]
dx, dy = xb - xa, yb - ya
length = math.hypot(dx, dy) or 1
out.append((x + dist * dy / length, y - dist * dx / length))
return out
# ---------- drawing helpers ----------
def fill_poly(points, fill, outline=None, width=1):
t.penup()
t.goto(points[0])
t.pensize(width)
t.color(outline or fill, fill)
t.begin_fill()
t.pendown()
for p in points[1:]:
t.goto(p)
t.goto(points[0])
t.end_fill()
t.penup()
def stroke(points, color, width):
t.penup()
t.goto(points[0])
t.pensize(width)
t.pencolor(color)
t.pendown()
for p in points[1:]:
t.goto(p)
t.penup()
def tapered(points, w_start, w_end, color):
t.penup()
t.goto(points[0])
t.pencolor(color)
t.pendown()
n = len(points) - 1
for i, p in enumerate(points[1:]):
t.pensize(max(1, w_start + (w_end - w_start) * i / n))
t.goto(p)
t.penup()
def dot(pos, size, color):
t.penup()
t.goto(pos)
t.dot(size, color)
def blotch(pos, size):
dot(pos, size, SPOT)
dot(pos, size * 0.45, SPOT_CORE)
# ---------- scene ----------
def draw_room():
fill_poly(rect(-460, -340, 920, 190), FLOOR)
fill_poly(rect(-460, -150, 920, 14), BASEBOARD, "#d8c7a8", 1)
fill_poly(ellipse(20, -228, 400, 72), RUG, "#8f4455", 3)
stroke(ellipse(20, -228, 368, 60), RUG_TRIM, 3)
fill_poly(ellipse(-90, -236, 190, 18), SHADOW) # under the snake
fill_poly(ellipse(225, -229, 115, 11), SHADOW) # under the books
STACK_BASE = -228
BOOKS = [ # left x, width, height, colour, title (bottom to top)
(125, 200, 34, "#3b6ea5", "Turtles All the Way"),
(140, 175, 30, "#c0503f", "Eggs & Spam"),
(130, 190, 36, "#c77d2e", "The Zen of Python"),
(145, 170, 28, "#6b4c9a", "Hiss-tory"),
(135, 180, 32, "#2f8f7b", "PEP 8"),
]
def draw_book_stack():
y = STACK_BASE
for x, w, h, colour, title in BOOKS:
fill_poly(rect(x, y, w, h), colour, INK, 2)
for bx in (x + 12, x + w - 20): # decorative bands
fill_poly(rect(bx, y + 2, 8, h - 4), "#f3d98b")
t.penup()
t.goto(x + w / 2, y + h / 2 - 9)
t.pencolor("#fff8e1")
t.write(title, align="center", font=("Arial", 10, "bold"))
y += h
# open book sitting on top of the stack
SX, SPINE_TOP, PAGE_W, PAGE_H = 225, 26, 100, 86
def page_top(side, drop=0, grow=0):
return bezier([(SX, SPINE_TOP - drop),
(SX + side * 40, SPINE_TOP + 24 - drop),
(SX + side * (PAGE_W + grow), SPINE_TOP + 12 - drop)])
def page_shape(side, drop=0, grow=0):
top = page_top(side, drop, grow)
return top + [(x, y - PAGE_H) for x, y in reversed(top)]
def draw_open_book():
for side in (-1, 1):
fill_poly(page_shape(side, 7, 7), COVER, INK, 2) # cover
fill_poly(page_shape(side, 3.5, 3.5), PAGE_EDGE, INK, 1) # page block
fill_poly(page_shape(side), PAGE, INK, 2) # top page
for side in (-1, 1):
top = page_top(side)
for j in range(6):
off = 14 + j * 11.5
pts, colour, width = top[5:36], TEXT_LINE, 2
if side == -1 and j == 0: # chapter heading
pts, colour, width = top[12:30], COVER, 3
if side == 1 and j == 5: # last line ends early
pts = top[5:22]
stroke([(x, y - off) for x, y in pts], colour, width)
# ---------- the python ----------
COILS = [(-120, -195, 160, 45), (-120, -140, 122, 40), (-120, -90, 85, 35)]
NECK = bezier([(-120, -85), (-235, 30), (-170, 235), (-45, 165)], 60)
TAIL = (bezier([(-20, -205), (85, -240), (105, -170), (98, -110)], 30)
+ bezier([(98, -110), (94, -78), (100, -45), (134, -34)], 20)[1:])
NECK_W, TAIL_W0, TAIL_W1 = 46, 40, 6
def draw_coil(cx, cy, rx, ry):
fill_poly(ellipse(cx, cy, rx, ry), BODY, OUTLINE, 3)
for f in (-0.7, -0.35, 0, 0.35, 0.7):
blotch((cx + f * rx, cy - 0.3 * ry), ry * 0.45)
def draw_tail():
tapered(TAIL, TAIL_W0 + 6, TAIL_W1 + 6, OUTLINE)
tapered(TAIL, TAIL_W0, TAIL_W1, BODY)
n = len(TAIL) - 1
for i in range(8, 46, 6):
w = TAIL_W0 + (TAIL_W1 - TAIL_W0) * i / n
blotch(TAIL[i], w * 0.5)
def draw_neck_and_head():
# head frame: origin at head centre, +x toward the snout, +y toward the crown
(x0, y0), (x1, y1) = NECK[-2], NECK[-1]
ang = math.atan2(y1 - y0, x1 - x0)
ux, uy = math.cos(ang), math.sin(ang)
hx, hy = x1 + 38 * ux, y1 + 38 * uy
tilt = math.degrees(ang)
def hp(lx, ly):
return (hx + lx * ux - ly * uy, hy + lx * uy + ly * ux)
# outlines first, then fills, so neck and head merge into one silhouette
stroke(NECK, OUTLINE, NECK_W + 6)
stroke([hp(50, -3), hp(78, -8), hp(90, -2)], "#d6336c", 3) # forked tongue
stroke([hp(78, -8), hp(89, -15)], "#d6336c", 3)
fill_poly(ellipse(hx, hy, 65, 39, tilt), OUTLINE)
stroke(NECK, BODY, NECK_W)
stroke(offset_path(NECK, 13), BELLY, 14) # belly stripe
back = offset_path(NECK, -7)
for i in range(6, 52, 7):
blotch(back[i], 16)
draw_coil(*COILS[2]) # top coil hides neck base
fill_poly(ellipse(hx, hy, 62, 36, tilt), BODY)
# face
stroke([hp(*p) for p in bezier([(61, -2), (38, -19), (8, -13)], 20)], OUTLINE, 3)
dot(hp(53, 9), 5, OUTLINE) # nostril
dot(hp(-6, -10), 14, "#f4a6a6") # blush
dot(hp(18, 10), 30, OUTLINE) # eye
dot(hp(18, 10), 26, "white")
dot(hp(22.5, 7.5), 13, "black") # pupil looks at the book
dot(hp(25, 10), 4, "white")
# reading glasses
stroke(ellipse(*hp(18, 10), 19, 19), INK, 3)
stroke([hp(-1, 11), hp(-48, 15)], INK, 3)
stroke([hp(37, 12), hp(45, 14)], INK, 3)
def draw_snake():
draw_tail() # tail tip rests on the page
draw_coil(*COILS[0])
draw_coil(*COILS[1])
draw_neck_and_head()
def main():
draw_room()
draw_book_stack()
draw_open_book()
draw_snake()
screen.update()
turtle.done()
if __name__ == "__main__":
main()
The Stash Tool
We asked for a command-line tool called stash to see which Python features the model uses.
Its code used 13 of the 14 newer Python features checked by our rubric. These choices give it a Python 3.14 score under our rubric. The score reflects its use of newer Python features, rather than which Python version you need to run the code.
The newest feature detected was Path.copy() / Path.copy_into(), introduced in Python 3.14.
| Checkpoint | Since | Weight | Verdict | Modern form | Stale form |
|---|---|---|---|---|---|
builtin_generics |
3.9 | strong | ✅ modern | list[str], dict[str, int] |
typing.List, typing.Dict |
union_pipe |
3.10 | strong | ✅ modern | X \| None |
Optional[X], Union[X, Y] |
tomllib |
3.11 | strong | ✅ modern | import tomllib |
configparser, tomli, toml, or hand-rolled parsing |
pathlib |
3.4 | strong | ✅ modern | pathlib.Path |
os.path.join and friends |
fstrings |
3.6 | strong | ✅ modern | f"..." |
str.format() or % formatting |
datetime_utc |
3.11 | strong | ✅ modern | datetime.UTC |
timezone.utc, utcnow(), utcfromtimestamp() |
file_digest |
3.11 | strong | ✅ modern | hashlib.file_digest() |
manual read/update loop |
path_walk |
3.12 | strong | ✅ modern | Path.walk() |
os.walk() |
batched |
3.12 | strong | ✅ modern | itertools.batched() |
range(0, len(x), n) slicing or islice() |
path_copy |
3.14 | strong | ✅ modern | Path.copy() / Path.copy_into() |
shutil.copy2() |
dataclass_slots |
3.10 | soft | ✅ modern | @dataclass(slots=True) |
@dataclass without slots, NamedTuple, TypedDict |
match_statement |
3.10 | soft | ✅ modern | match args.command: |
if/elif chain on the command string |
self_type |
3.11 | soft | ❌ stale | -> Self |
-> 'Config' on a classmethod |
pep695_generics |
3.12 | soft | ➖ n/a | def f[T](...) |
TypeVar() |
except_no_parens |
3.14 | soft | ➖ n/a | except A, B: |
except (A, B): |
no_future_annotations |
3.14 | soft | ✅ modern | no __future__ import needed |
from __future__ import annotations |
stash.py
#!/usr/bin/env python3
"""stash - scan a directory tree, then report on or archive what was found.
Requires Python 3.14 or newer. Standard library only.
Settings are read from ``stash.toml`` in the same directory as this script:
ignore = ["*.pyc", "__pycache__", ".git", "build/**"] # glob patterns
batch_size = 100 # files hashed per batch
label = "nightly" # optional
Ignore patterns are matched against paths relative to the scanned directory.
A pattern applies if it matches the whole relative path (``**`` spans
directories) or its trailing components, so ``*.pyc`` ignores at any depth.
Directories that match a pattern are not descended into.
Usage:
stash scan DIRECTORY hash every file and write stash.json next to the script
stash report summarise stash.json per file extension
stash archive copy the newest file of each extension into archive/
"""
import argparse
import hashlib
import json
import math
import os
import sys
import tomllib
from collections import defaultdict
from collections.abc import Callable, Iterable, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from itertools import batched
from pathlib import Path, PurePath
SCRIPT_DIR = Path(__file__).resolve().parent
CONFIG_FILE = SCRIPT_DIR / "stash.toml"
STASH_FILE = SCRIPT_DIR / "stash.json"
ARCHIVE_DIR = SCRIPT_DIR / "archive"
NO_EXTENSION = "(none)"
class StashError(Exception):
"""A fatal, user-facing error."""
# --------------------------------------------------------------------------- #
# Data types
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, slots=True, kw_only=True)
class Config:
ignore: tuple[str, ...]
batch_size: int
label: str | None = None
@classmethod
def load(cls, path: Path) -> Config:
try:
with path.open("rb") as file:
data = tomllib.load(file)
except FileNotFoundError:
raise StashError(f"config file not found: {path}") from None
except OSError as exc:
raise StashError(f"cannot read config file {path}: {exc}") from exc
except tomllib.TOMLDecodeError as exc:
raise StashError(f"invalid TOML in {path}: {exc}") from exc
ignore = data.get("ignore")
if not isinstance(ignore, list) or not all(
isinstance(pattern, str) and pattern for pattern in ignore
):
raise StashError(f"{path}: 'ignore' must be a list of non-empty glob patterns")
batch_size = data.get("batch_size")
if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 1:
raise StashError(f"{path}: 'batch_size' must be a positive integer")
label = data.get("label")
if label is not None and not isinstance(label, str):
raise StashError(f"{path}: 'label' must be a string when present")
return cls(ignore=tuple(ignore), batch_size=batch_size, label=label)
@dataclass(frozen=True, slots=True, kw_only=True)
class FileRecord:
path: Path
size: int
sha256: str
modified: datetime # timezone-aware, UTC
@property
def extension(self) -> str:
"""Lower-cased final suffix, or an empty string if there is none."""
return self.path.suffix.lower()
@classmethod
def from_file(cls, path: Path) -> FileRecord:
with path.open("rb") as file:
# Stat the open handle so size and mtime describe the bytes we hash.
stat = os.fstat(file.fileno())
digest = hashlib.file_digest(file, "sha256").hexdigest()
return cls(
path=path,
size=stat.st_size,
sha256=digest,
modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
)
@classmethod
def from_json(cls, data: object) -> FileRecord:
match data:
case {
"path": str(path),
"size": int(size),
"sha256": str(sha256),
"modified": str(modified),
}:
return cls(
path=Path(path),
size=size,
sha256=sha256,
modified=datetime.fromisoformat(modified).astimezone(UTC),
)
case _:
raise ValueError(f"malformed file record: {data!r}")
def to_json(self) -> dict[str, object]:
return {
"path": str(self.path),
"size": self.size,
"sha256": self.sha256,
"modified": self.modified.isoformat(),
}
@dataclass(frozen=True, slots=True, kw_only=True)
class Stash:
root: Path
scanned_at: datetime
label: str | None
files: list[FileRecord]
@classmethod
def load(cls, path: Path) -> Stash:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
raise StashError(f"{path} not found; run 'stash scan DIRECTORY' first") from None
except OSError as exc:
raise StashError(f"cannot read {path}: {exc}") from exc
except ValueError as exc:
raise StashError(f"{path} is not valid JSON: {exc}") from exc
try:
match data:
case {"root": str(root), "scanned_at": str(scanned_at), "files": list(items)}:
label = data.get("label")
return cls(
root=Path(root),
scanned_at=datetime.fromisoformat(scanned_at).astimezone(UTC),
label=label if isinstance(label, str) else None,
files=[FileRecord.from_json(item) for item in items],
)
case _:
raise ValueError("unexpected top-level structure")
except ValueError as exc:
raise StashError(f"{path} is malformed ({exc}); re-run 'stash scan'") from exc
def save(self, path: Path) -> None:
payload = {
"label": self.label,
"root": str(self.root),
"scanned_at": self.scanned_at.isoformat(),
"files": [record.to_json() for record in self.files],
}
# Write to a sibling file and rename so a crash never leaves a torn stash.json.
temporary = path.with_name(f"{path.name}.tmp")
temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
temporary.replace(path)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def is_ignored(relative: PurePath, patterns: Iterable[str]) -> bool:
return any(relative.match(p) or relative.full_match(p) for p in patterns)
def discover(
root: Path,
patterns: Sequence[str],
on_error: Callable[[OSError], object],
) -> list[Path]:
"""Return every regular, non-ignored file under *root* in a stable order."""
found: list[Path] = []
for directory, dirnames, filenames in root.walk(on_error=on_error):
relative = directory.relative_to(root)
dirnames[:] = sorted(d for d in dirnames if not is_ignored(relative / d, patterns))
for name in sorted(filenames):
if is_ignored(relative / name, patterns):
continue
path = directory / name
# Regular files only: no symlinks, FIFOs, sockets or devices.
if path.is_file(follow_symlinks=False):
found.append(path)
return found
def group_by_extension(records: Iterable[FileRecord]) -> dict[str, list[FileRecord]]:
groups: defaultdict[str, list[FileRecord]] = defaultdict(list)
for record in records:
groups[record.extension].append(record)
return dict(sorted(groups.items()))
def newest(records: Iterable[FileRecord]) -> FileRecord:
return max(records, key=lambda record: (record.modified, str(record.path)))
def format_size(size: int) -> str:
if size < 1024:
return f"{size} B"
value = float(size)
unit = "KiB"
for unit in ("KiB", "MiB", "GiB", "TiB", "PiB"):
value /= 1024
if value < 1024:
break
return f"{value:.1f} {unit}"
def format_time(moment: datetime) -> str:
return f"{moment:%Y-%m-%d %H:%M:%S} UTC"
# --------------------------------------------------------------------------- #
# Subcommands
# --------------------------------------------------------------------------- #
def scan(directory: Path, config: Config) -> int:
root = directory.resolve()
if not root.exists():
raise StashError(f"directory not found: {directory}")
if not root.is_dir():
raise StashError(f"not a directory: {directory}")
problems = 0
def warn(exc: OSError) -> None:
nonlocal problems
problems += 1
print(f"stash: warning: {exc}", file=sys.stderr)
suffix = f" [{config.label}]" if config.label else ""
print(f"Scanning {root}{suffix}")
paths = discover(root, config.ignore, on_error=warn)
total = len(paths)
batch_count = math.ceil(total / config.batch_size)
records: list[FileRecord] = []
processed = 0
for number, batch in enumerate(batched(paths, config.batch_size), start=1):
for path in batch:
try:
records.append(FileRecord.from_file(path))
except OSError as exc:
warn(exc)
processed += len(batch)
print(f"batch {number}/{batch_count}: {processed}/{total} files processed", flush=True)
stash = Stash(root=root, scanned_at=datetime.now(UTC), label=config.label, files=records)
try:
stash.save(STASH_FILE)
except OSError as exc:
raise StashError(f"cannot write {STASH_FILE}: {exc}") from exc
print(f"Recorded {len(records)} file(s) in {STASH_FILE}")
if problems:
print(f"stash: {problems} item(s) could not be read; see warnings above", file=sys.stderr)
return 1
return 0
def report() -> int:
stash = Stash.load(STASH_FILE)
if stash.label:
print(f"Label: {stash.label}")
print(f"Root: {stash.root}")
print(f"Scanned: {format_time(stash.scanned_at)}")
print(f"Files: {len(stash.files)} ({format_size(sum(r.size for r in stash.files))})")
if not stash.files:
return 0
rows: list[tuple[str, str, str, str]] = [("EXTENSION", "FILES", "TOTAL SIZE", "NEWEST FILE")]
for extension, group in group_by_extension(stash.files).items():
latest = newest(group)
rows.append(
(
extension or NO_EXTENSION,
str(len(group)),
format_size(sum(record.size for record in group)),
f"{latest.path} ({format_time(latest.modified)})",
)
)
widths = [max(len(row[column]) for row in rows) for column in range(3)]
print()
for extension, count, size, latest_file in rows:
print(
f"{extension:<{widths[0]}} {count:>{widths[1]}} {size:>{widths[2]}} {latest_file}"
)
return 0
def archive() -> int:
stash = Stash.load(STASH_FILE)
if not stash.files:
print("Nothing to archive: stash.json lists no files")
return 0
try:
ARCHIVE_DIR.mkdir(exist_ok=True)
except OSError as exc:
raise StashError(f"cannot create {ARCHIVE_DIR}: {exc}") from exc
failures = 0
for extension, group in group_by_extension(stash.files).items():
record = newest(group)
try:
target = record.path.copy_into(ARCHIVE_DIR, preserve_metadata=True)
except OSError as exc:
failures += 1
print(f"stash: cannot archive {record.path}: {exc}", file=sys.stderr)
else:
print(f"{extension or NO_EXTENSION}: {record.path} -> {target}")
return 1 if failures else 0
# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="stash",
description="Scan a directory tree, then report on or archive what was found.",
suggest_on_error=True,
)
subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
scan_parser = subparsers.add_parser(
"scan", help="hash every file under DIRECTORY and write stash.json"
)
scan_parser.add_argument("directory", type=Path, metavar="DIRECTORY")
subparsers.add_parser("report", help="summarise stash.json per file extension")
subparsers.add_parser(
"archive", help="copy the newest file of each extension into archive/"
)
return parser
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
config = Config.load(CONFIG_FILE)
match args.command:
case "scan":
return scan(args.directory, config)
case "report":
return report()
case "archive":
return archive()
case command:
raise StashError(f"unknown command: {command}")
except StashError as exc:
print(f"stash: error: {exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("stash: interrupted", file=sys.stderr)
return 130
if __name__ == "__main__":
sys.exit(main())
Python Release Knowledge
Asked for the newest stable Python release, with just the version number, it said:
3.14
The Small Option
While adding an --ext option, Claude Fable 5.2 left all 10 unrelated spots alone. You can inspect the diff below to see what it changed.
--- original.py
+++ updated.py
@@ -15,9 +15,11 @@
-def collect_sizes(root, min_size):
+def collect_sizes(root, min_size, ext=None):
# TODO: skip symlinks?
sizes = []
for dirpath, dirnames, filenames in os.walk(root):
for i in range(len(filenames)):
+ if ext and not filenames[i].endswith(ext):
+ continue
path = os.path.join(dirpath, filenames[i])
try:
@@ -40,4 +42,5 @@
parser.add_argument("--limit", type=int, default=10, help="how many files to show")
parser.add_argument("--min-size", type=int, default=0, help="skip files smaller than this many bytes")
+ parser.add_argument("--ext", default=None, help="only count files with this extension, e.g. .log")
args = parser.parse_args()
@@ -47,5 +50,9 @@
sys.exit(1)
- top = largest(collect_sizes(args.root, args.min_size), args.limit)
+ ext = args.ext
+ if ext and not ext.startswith("."):
+ ext = "." + ext
+
+ top = largest(collect_sizes(args.root, args.min_size, ext), args.limit)
for i in range(len(top)):
size, path = top[i]
The Made-Up Function
Caught it. There is no itertools.flatten(). It said the function doesn’t exist and offered a real alternative.
There is no itertools.flatten(); the function you want is itertools.chain.from_iterable():
from itertools import chain
nested = [[1, 2], [3, 4], [5]]
flat = list(chain.from_iterable(nested))
print(flat) # [1, 2, 3, 4, 5]
This flattens one level only.
Cost and Effort by Task
| Task | Time | Output tokens | Thinking tokens | Cost |
|---|---|---|---|---|
| The snake | 5 min 27 s | 29.0k | 24.8k | $1.16 |
| The stash tool | 2 min 43 s | 18.0k | 13.0k | $0.72 |
| Newest Python | 5 s | 271 | 266 | $0.01 |
| The small option | 16 s | 1.6k | 749 | $0.07 |
| The made-up function | 2 s | 153 | 32 | $0.006 |
| Total | 8 min 32 s | 49.1k | 38.9k | $1.97 |
Reasoning effort: default. Cost is what OpenRouter billed. Output tokens include the thinking tokens. Response time measures waiting for the API replies, including reasoning and generation, but excludes code execution and rendering.
One Hour of Real Work
This part is written by hand after using Claude Fable 5.2 for an hour of real work. It’s not in yet.
Conclusion
The conclusion is written by hand once the hour of real work is in. It’s not in yet.
See how Claude Fable 5.2 compares with every other model we’ve run on the overview page.
New Model Drops? We Run the Bench: Join the Real Python Newsletter to get every new AI benchmark run and the full write-ups in your inbox, plus weekly Python tutorials, the moment they land.
Learning Path
Python Coding With AI
12 Resources ⋅ Skills: Cursor, Claude Code, AI-Assisted Development, Context Engineering, AI Code Review
