Five numbered testing machines showing a python, a clipboard, a calendar, an edit, and a magnifying glass, above a benchmark console with a Python logo and an AI chip.

Claude Fable 5.2 Draws a Python Reading a Book

by Martin Breuss Updated Reading time estimate 21m ai

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:

A python reading a book, drawn with Python's turtle module by Claude Fable 5.2.

Turtle animation of Claude Fable 5.2 drawing a python reading a book.

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.

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.

Language: Python Filename: 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

Language: Python Filename: 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.

Language: File Changes (diff)
--- 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():

Language: Python
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.

Learning Path

Python Coding With AI

12 Resources ⋅ Skills: Cursor, Claude Code, AI-Assisted Development, Context Engineering, AI Code Review

🐍 Python Tricks 💌

Get a short & sweet Python Trick delivered to your inbox every couple of days. No spam ever. Unsubscribe any time. Curated by the Real Python team.

Dictionary merging in Python 3.5+

About Martin Breuss

Martin is Real Python's Head of Content Strategy. With a background in education, he's worked as a coding mentor, code reviewer, curriculum developer, bootcamp instructor, and instructional designer.

» More about Martin

Master Real-World Python Skills With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

Master Real-World Python Skills
With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

What Do You Think?

Rate this article:

What’s your #1 takeaway or favorite thing you learned? How are you going to put your newfound skills to use? Leave a comment below and let us know.

Commenting Tips: The most useful comments are those written with the goal of learning from or helping out other students. Get tips for asking good questions and get answers to common questions in our support portal.


Looking for a real-time conversation? Visit the Real Python Community Chat or join the next “Office Hours” Live Q&A Session. Happy Pythoning!

Keep Learning

Related Topics: ai