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.

Gemini 4 Carbon Draws a Python Reading a Book

by Philipp Acsany Updated Reading time estimate 20m ai

We asked Gemini 4 Carbon 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 Gemini 4 Carbon.

Turtle animation of Gemini 4 Carbon 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 1028 drawing commands, 372 lines of code
How modern its Python is Python 3.12 (2023) pinned by path_copy. Newest feature used: Python 3.12
Modern idioms 12 of 15 of the checkpoints that applied to its code
Latest Python version reported 3.14.3 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 25.3k thinking tokens over all five tasks
Cost $0.34 for all 5 tasks, as billed by the API
Response time 8 min 10 s 33.5k output tokens

The run: google/gemini-4-carbon 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
import math
import turtle


def main():
    # --- Screen Setup ---
    screen = turtle.Screen()
    screen.setup(width=800, height=600)
    screen.bgcolor("#F5EEDC")  # Cozy warm parchment background
    screen.title("A Python Reading a Book")
    screen.tracer(0)  # Instant rendering

    t = turtle.Turtle()
    t.hideturtle()
    t.speed(0)

    # --- Color Palette ---
    OUTLINE_GREEN = "#1B431E"
    BODY_GREEN = "#4CAF50"
    SPOT_GREEN = "#2E7D32"
    HIGHLIGHT_GREEN = "#81C784"

    BOOK_COVER = "#8B2635"
    BOOK_OUTLINE = "#4A121A"
    PAGE_LEFT = "#FFFDF7"
    PAGE_RIGHT = "#F6F0E0"
    PAGE_EDGE = "#DDD3BE"
    RIBBON_GOLD = "#FBC02D"

    # --- Helper Functions ---
    def jump(x, y):
        t.penup()
        t.goto(x, y)
        t.pendown()

    def bezier(p0, p1, p2, steps=25):
        """Generate points along a quadratic Bezier curve."""
        pts = []
        for i in range(steps + 1):
            u = i / steps
            x = (1 - u) ** 2 * p0[0] + 2 * (1 - u) * u * p1[0] + u**2 * p2[0]
            y = (1 - u) ** 2 * p0[1] + 2 * (1 - u) * u * p1[1] + u**2 * p2[1]
            pts.append((x, y))
        return pts

    def draw_polygon(pts, fill_col=None, out_col=None, pen_size=3):
        t.pensize(pen_size)
        if out_col:
            t.pencolor(out_col)
        if fill_col:
            t.fillcolor(fill_col)
            t.begin_fill()
        jump(*pts[0])
        for pt in pts[1:]:
            if not out_col:
                t.pencolor(fill_col)
            t.goto(*pt)
        t.goto(*pts[0])
        if fill_col:
            t.end_fill()

    def draw_ellipse(cx, cy, rx, ry, fill_col, out_col=None, pen_size=2):
        pts = []
        for deg in range(0, 360, 6):
            rad = math.radians(deg)
            pts.append((cx + rx * math.cos(rad), cy + ry * math.sin(rad)))
        draw_polygon(pts, fill_col, out_col, pen_size)

    def draw_circle_at(cx, cy, r, fill_col=None, out_col=None, pen_size=2):
        t.pensize(pen_size)
        t.setheading(0)
        jump(cx, cy - r)
        if out_col:
            t.pencolor(out_col)
        else:
            t.pencolor(fill_col)
        if fill_col:
            t.fillcolor(fill_col)
            t.begin_fill()
        t.circle(r)
        if fill_col:
            t.end_fill()

    def draw_pill_segment(p1, p2, width, fill_col, out_col, border=8):
        """Draws a thick rounded-cap segment with a clean cartoon border."""
        t.pencolor(out_col)
        t.pensize(width + border)
        jump(*p1)
        t.goto(*p2)

        t.pencolor(fill_col)
        t.pensize(width)
        jump(*p1)
        t.goto(*p2)

    def draw_tapered_bezier(
        p0, p1, p2, w_start, w_end, fill_col, out_col, border=8, steps=35
    ):
        """Draws a curved segment that smoothly tapers in thickness."""
        pts = bezier(p0, p1, p2, steps)
        # Outline pass
        t.pencolor(out_col)
        jump(*pts[0])
        for i in range(1, len(pts)):
            w = w_start + (w_end - w_start) * (i / steps)
            t.pensize(round(w + border))
            t.goto(*pts[i])
        # Fill pass
        t.pencolor(fill_col)
        jump(*pts[0])
        for i in range(1, len(pts)):
            w = w_start + (w_end - w_start) * (i / steps)
            t.pensize(round(w))
            t.goto(*pts[i])

    # ==========================================
    # 1. FLOOR RUG & GROUND SHADOWS
    # ==========================================
    draw_ellipse(0, -132, 285, 62, "#E5D5B5")
    draw_ellipse(-115, -138, 125, 28, "#D2C09B")  # Shadow under snake
    draw_polygon(
        [(18, -126), (110, -155), (208, -132), (120, -95)], "#D2C09B"
    )  # Shadow under book

    # ==========================================
    # 2. SNAKE TAIL (BACK PART BEHIND BOOK)
    # ==========================================
    draw_tapered_bezier(
        (-35, -137), (90, -150), (202, -128), 34, 20, BODY_GREEN, OUTLINE_GREEN
    )

    # ==========================================
    # 3. SNAKE NECK & COILS
    # ==========================================
    # Neck (drawn before top coil so it emerges naturally from the coils)
    for col, w in [(OUTLINE_GREEN, 48), (BODY_GREEN, 40)]:
        t.pencolor(col)
        t.pensize(w)
        jump(-125, -67)
        t.setheading(90)
        t.forward(127)
        t.circle(-55, 118)
    neck_end = t.pos()
    neck_heading = t.heading()

    # Neck spots
    for sx, sy, sr in [
        (-133, -15, 6),
        (-132, 25, 6.5),
        (-116, 70, 6),
        (-88, 102, 6.5),
    ]:
        draw_circle_at(sx, sy, sr, SPOT_GREEN)

    # Bottom Coil
    draw_pill_segment((-195, -135), (-35, -135), 48, BODY_GREEN, OUTLINE_GREEN)
    for sx, sy, sr in [(-175, -128, 7), (-122, -141, 8), (-70, -129, 7)]:
        draw_circle_at(sx, sy, sr, SPOT_GREEN)
    t.pencolor(HIGHLIGHT_GREEN)
    t.pensize(5)
    jump(-188, -118)
    t.goto(-145, -118)

    # Middle Coil
    draw_pill_segment((-175, -100), (-55, -100), 44, BODY_GREEN, OUTLINE_GREEN)
    for sx, sy, sr in [(-150, -94, 6.5), (-102, -106, 7.5), (-68, -95, 6)]:
        draw_circle_at(sx, sy, sr, SPOT_GREEN)
    t.pencolor(HIGHLIGHT_GREEN)
    t.pensize(5)
    jump(-168, -85)
    t.goto(-130, -85)

    # Top Coil
    draw_pill_segment((-155, -67), (-75, -67), 40, BODY_GREEN, OUTLINE_GREEN)
    for sx, sy, sr in [(-135, -63, 6), (-92, -71, 6.5)]:
        draw_circle_at(sx, sy, sr, SPOT_GREEN)
    t.pencolor(HIGHLIGHT_GREEN)
    t.pensize(4)
    jump(-148, -54)
    t.goto(-118, -54)

    # ==========================================
    # 4. SNAKE HEAD, TONGUE & GLASSES
    # ==========================================
    # Forked Tongue
    t.pencolor("#D32F2F")
    t.pensize(4)
    jump(8, 78)
    t.setheading(-30)
    t.forward(22)
    fork_pt = t.pos()
    t.left(26)
    t.forward(9)
    jump(*fork_pt)
    t.setheading(-56)
    t.forward(9)

    # Head Shape
    for col, w in [(OUTLINE_GREEN, 62), (BODY_GREEN, 54)]:
        t.pencolor(col)
        t.pensize(w)
        jump(*neck_end)
        t.setheading(neck_heading)
        t.forward(35)

    # Head Highlight, Smile & Nostril
    t.pencolor(HIGHLIGHT_GREEN)
    t.pensize(5)
    jump(-48, 124)
    t.goto(-25, 114)

    smile_pts = bezier((-28, 84), (-12, 73), (6, 76), 15)
    t.pencolor(OUTLINE_GREEN)
    t.pensize(3)
    jump(*smile_pts[0])
    for pt in smile_pts[1:]:
        t.goto(*pt)
    draw_circle_at(5, 86, 2, OUTLINE_GREEN)  # Nostril

    # Eyes (looking down toward the open pages)
    draw_circle_at(-25, 105, 9.5, "white", OUTLINE_GREEN, 2)
    draw_circle_at(-22, 102, 4, "#111111")
    draw_circle_at(-23.5, 104, 1.5, "white")

    draw_circle_at(-3, 97, 9, "white", OUTLINE_GREEN, 2)
    draw_circle_at(0, 94, 4, "#111111")
    draw_circle_at(-1.5, 96, 1.5, "white")

    # Reading Glasses
    GLASSES_COL = "#1E1E1E"
    draw_circle_at(-25, 105, 11.5, None, GLASSES_COL, 3)
    draw_circle_at(-3, 97, 11, None, GLASSES_COL, 3)
    # Bridge & Temple arm
    t.pencolor(GLASSES_COL)
    t.pensize(3)
    jump(-14, 101)
    t.goto(-13, 100.5)
    jump(-36, 108)
    t.goto(-60, 112)
    t.goto(-64, 105)

    # ==========================================
    # 5. THE OPEN BOOK
    # ==========================================
    # Hardcover Boards & Spine
    draw_polygon(
        [(20, -120), (35, -15), (120, -35), (110, -145)],
        BOOK_COVER,
        BOOK_OUTLINE,
        3,
    )
    draw_polygon(
        [(110, -145), (120, -35), (210, -20), (200, -125)],
        BOOK_COVER,
        BOOK_OUTLINE,
        3,
    )
    draw_polygon(
        [(102, -143), (110, -150), (118, -143), (110, -133)],
        "#681A25",
        BOOK_OUTLINE,
        3,
    )

    # Curved Page Edges
    left_top = bezier((39, -20), (83, -12), (119, -41))
    left_bot = bezier((110, -133), (73, -104), (28, -107))
    left_bot_low = [(x, y - 10) for (x, y) in reversed(left_bot)]

    right_top = bezier((119, -41), (160, -16), (202, -25))
    right_bot = bezier((192, -112), (150, -106), (110, -133))
    right_bot_low = [(x, y - 10) for (x, y) in reversed(right_bot)]

    # Page Block Thickness (bottom of pages)
    draw_polygon(left_bot + left_bot_low, PAGE_EDGE, BOOK_OUTLINE, 2)
    draw_polygon(right_bot + right_bot_low, "#D0C5AE", BOOK_OUTLINE, 2)

    # Left & Right Page Surfaces
    draw_polygon(left_top + left_bot, PAGE_LEFT, "#8D8070", 2)
    draw_polygon(right_top + right_bot, PAGE_RIGHT, "#8D8070", 2)

    # Center Crease
    t.pencolor("#A89C8A")
    t.pensize(2)
    jump(119, -41)
    t.goto(110, -133)

    # Helper to draw perspective-curved text lines on a page
    def draw_page_line(p_top, p_bot, v_frac, u_start, u_end, col, size):
        def interp(a, b, f):
            return (a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f)

        c0 = interp(p_top[0], p_bot[0], v_frac)
        c1 = interp(p_top[1], p_bot[1], v_frac)
        c2 = interp(p_top[2], p_bot[2], v_frac)
        pts = bezier(c0, c1, c2, 20)
        i_start = int(u_start * 20)
        i_end = int(u_end * 20)
        t.pencolor(col)
        t.pensize(size)
        jump(*pts[i_start])
        for pt in pts[i_start + 1 : i_end + 1]:
            t.goto(*pt)

    # Left Page Text (Chapter Header + Paragraphs)
    l_top_ctrl = ((39, -20), (83, -12), (119, -41))
    l_bot_ctrl = ((28, -107), (73, -104), (110, -133))
    draw_page_line(l_top_ctrl, l_bot_ctrl, 0.18, 0.20, 0.70, "#5D4037", 4)
    for row, end_u in [
        (0.32, 0.85),
        (0.43, 0.85),
        (0.54, 0.80),
        (0.67, 0.85),
        (0.78, 0.65),
    ]:
        draw_page_line(l_top_ctrl, l_bot_ctrl, row, 0.15, end_u, "#9E9589", 2)

    # Right Page Illustration Box (Miniature Python Logo!) & Text
    r_top_ctrl = ((119, -41), (160, -16), (202, -25))
    r_bot_ctrl = ((110, -133), (150, -106), (192, -112))
    draw_polygon(
        [(134, -58), (138, -33), (184, -35), (180, -60)],
        "#FFFFFF",
        "#C5BBAE",
        2,
    )

    # Tiny interlocking blue & gold Python icon inside diagram box
    t.pencolor("#3776AB")
    t.pensize(5)
    jump(153, -44)
    t.goto(160, -44)
    t.goto(160, -48)
    t.goto(155, -48)

    t.pencolor("#E5B80B")
    t.pensize(5)
    jump(165, -51)
    t.goto(158, -51)
    t.goto(158, -47)
    t.goto(163, -47)

    for row, end_u in [(0.58, 0.85), (0.70, 0.85), (0.82, 0.60)]:
        draw_page_line(r_top_ctrl, r_bot_ctrl, row, 0.15, end_u, "#9E9589", 2)

    # Gold Bookmark Ribbon draping out of the spine
    draw_polygon(
        [
            (107, -133),
            (113, -133),
            (109, -168),
            (104, -161),
            (99, -167),
        ],
        RIBBON_GOLD,
        "#B28704",
        2,
    )

    # ==========================================
    # 6. SNAKE TAIL TIP (CURLING ONTO PAGE)
    # ==========================================
    draw_tapered_bezier(
        (202, -128), (224, -108), (184, -96), 20, 6, BODY_GREEN, OUTLINE_GREEN
    )

    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 12 of the 15 newer Python features checked by our rubric. For example, it used shutil.copy2() instead of Path.copy() / Path.copy_into() (Python 3.14). These choices give it a Python 3.12 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 itertools.batched(), introduced in Python 3.12.

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 ❌ stale 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 ✅ modern -> Self -> 'Config' on a classmethod
pep695_generics 3.12 soft ➖ n/a def f[T](...) TypeVar()
except_no_parens 3.14 soft ❌ stale except A, B: except (A, B):
no_future_annotations 3.14 soft ❌ stale no __future__ import needed from __future__ import annotations

Language: Python Filename: stash.py
#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import json
import shutil
import sys
import tomllib
from collections import defaultdict
from dataclasses import dataclass
from datetime import UTC, datetime
from itertools import batched
from pathlib import Path, PurePath
from typing import Any, NoReturn, Self

SCRIPT_DIR: Path = Path(__file__).resolve().parent
CONFIG_PATH: Path = SCRIPT_DIR / "stash.toml"
STATE_PATH: Path = SCRIPT_DIR / "stash.json"
ARCHIVE_DIR: Path = SCRIPT_DIR / "archive"


class StashError(Exception):
    """Domain error resulting in a clean CLI exit."""


@dataclass(frozen=True, slots=True)
class Config:
    ignore: tuple[str, ...]
    batch_size: int
    label: str | None = None

    @classmethod
    def load(cls, path: Path) -> Self:
        if not path.is_file():
            raise StashError(f"Configuration file not found: {path}")

        try:
            with path.open("rb") as f:
                raw = tomllib.load(f)
        except (OSError, tomllib.TOMLDecodeError) as exc:
            raise StashError(f"Failed to read configuration {path}: {exc}") from exc

        ignore = raw.get("ignore")
        if not isinstance(ignore, list) or not all(isinstance(p, str) for p in ignore):
            raise StashError("Config 'ignore' must be a list of glob pattern strings.")

        batch_size = raw.get("batch_size")
        if isinstance(batch_size, bool) or not isinstance(batch_size, int) or batch_size <= 0:
            raise StashError("Config 'batch_size' must be a positive integer.")

        label = raw.get("label")
        if label is not None and not isinstance(label, str):
            raise StashError("Config 'label' must be a string if provided.")

        return cls(ignore=tuple(ignore), batch_size=batch_size, label=label)


@dataclass(frozen=True, slots=True)
class FileRecord:
    path: Path
    size: int
    sha256: str
    modified: datetime

    @property
    def extension(self) -> str:
        return self.path.suffix.lower()

    @classmethod
    def from_file(cls, path: Path) -> Self:
        try:
            stat = path.stat()
            with path.open("rb") as f:
                digest = hashlib.file_digest(f, "sha256").hexdigest()
        except OSError as exc:
            raise StashError(f"Failed to inspect file {path}: {exc}") from exc

        return cls(
            path=path.resolve(),
            size=stat.st_size,
            sha256=digest,
            modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
        )

    def to_dict(self) -> dict[str, str | int]:
        return {
            "path": str(self.path),
            "size": self.size,
            "sha256": self.sha256,
            "modified": self.modified.isoformat(),
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Self:
        try:
            return cls(
                path=Path(str(data["path"])),
                size=int(data["size"]),
                sha256=str(data["sha256"]),
                modified=datetime.fromisoformat(str(data["modified"])),
            )
        except (KeyError, TypeError, ValueError) as exc:
            raise StashError(f"Corrupted file record in {STATE_PATH}: {exc}") from exc


@dataclass(frozen=True, slots=True)
class ExtensionSummary:
    extension: str
    count: int
    total_size: int
    newest: FileRecord


def is_ignored(rel_path: PurePath, patterns: tuple[str, ...]) -> bool:
    for pattern in patterns:
        if rel_path.full_match(pattern):
            return True
        if any(PurePath(part).full_match(pattern) for part in rel_path.parts):
            return True
    return False


def collect_candidates(root: Path, patterns: tuple[str, ...]) -> list[Path]:
    candidates: list[Path] = []
    for dirpath, dirnames, filenames in root.walk(on_error=_raise_walk_error):
        rel_dir = dirpath.relative_to(root)
        dirnames[:] = sorted(
            d for d in dirnames if not is_ignored(rel_dir / d, patterns)
        )
        for filename in sorted(filenames):
            rel_file = rel_dir / filename
            if is_ignored(rel_file, patterns):
                continue
            full_path = dirpath / filename
            if full_path.is_file():
                candidates.append(full_path)
    return candidates


def _raise_walk_error(exc: OSError) -> None:
    raise StashError(f"Error walking directory: {exc}") from exc


def save_records(records: list[FileRecord], label: str | None) -> None:
    payload = {
        "label": label,
        "generated_at": datetime.now(tz=UTC).isoformat(),
        "files": [record.to_dict() for record in records],
    }
    try:
        STATE_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
    except OSError as exc:
        raise StashError(f"Failed to write {STATE_PATH}: {exc}") from exc


def load_records() -> tuple[str | None, list[FileRecord]]:
    if not STATE_PATH.is_file():
        raise StashError(f"State file not found: {STATE_PATH}. Run 'stash scan' first.")

    try:
        raw = json.loads(STATE_PATH.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        raise StashError(f"Failed to read {STATE_PATH}: {exc}") from exc

    if not isinstance(raw, dict) or not isinstance(raw.get("files"), list):
        raise StashError(f"Invalid format in {STATE_PATH}.")

    label = raw.get("label")
    if label is not None and not isinstance(label, str):
        label = str(label)

    records = [FileRecord.from_dict(item) for item in raw["files"]]
    return label, records


def summarize_by_extension(records: list[FileRecord]) -> list[ExtensionSummary]:
    grouped: dict[str, list[FileRecord]] = defaultdict(list)
    for record in records:
        grouped[record.extension].append(record)

    summaries: list[ExtensionSummary] = []
    for ext in sorted(grouped):
        group = grouped[ext]
        newest = max(group, key=lambda r: (r.modified, str(r.path)))
        summaries.append(
            ExtensionSummary(
                extension=ext,
                count=len(group),
                total_size=sum(r.size for r in group),
                newest=newest,
            )
        )
    return summaries


def cmd_scan(directory: Path, config: Config) -> None:
    if not directory.exists():
        raise StashError(f"Directory not found: {directory}")
    if not directory.is_dir():
        raise StashError(f"Path is not a directory: {directory}")

    root = directory.resolve()
    candidates = collect_candidates(root, config.ignore)
    total_files = len(candidates)
    total_batches = (total_files + config.batch_size - 1) // config.batch_size
    prefix = f"[{config.label}] " if config.label else ""

    records: list[FileRecord] = []
    for batch_num, batch in enumerate(batched(candidates, config.batch_size), start=1):
        batch_records = [FileRecord.from_file(path) for path in batch]
        records.extend(batch_records)
        print(
            f"{prefix}Batch {batch_num}/{total_batches}: "
            f"hashed {len(batch_records)} file(s) ({len(records)}/{total_files} total)"
        )

    save_records(records, config.label)


def cmd_report(config: Config) -> None:
    saved_label, records = load_records()
    label = config.label or saved_label
    if label:
        print(f"Stash Report ({label})")

    if not records:
        print("No files recorded.")
        return

    for summary in summarize_by_extension(records):
        ext_display = summary.extension or "(no extension)"
        print(
            f"{ext_display}: {summary.count} file(s), "
            f"{summary.total_size} bytes, newest: {summary.newest.path}"
        )


def cmd_archive() -> None:
    _, records = load_records()
    summaries = summarize_by_extension(records)

    try:
        ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        raise StashError(f"Failed to create archive directory {ARCHIVE_DIR}: {exc}") from exc

    for summary in summaries:
        src = summary.newest.path
        if not src.is_file():
            raise StashError(f"Cannot archive missing file: {src}")
        dst = ARCHIVE_DIR / src.name
        try:
            shutil.copy2(src, dst)
        except OSError as exc:
            raise StashError(f"Failed to copy {src} to {dst}: {exc}") from exc


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="stash",
        description="Scan, report, and archive files by extension.",
    )
    subparsers = parser.add_subparsers(dest="command", required=True)

    scan_parser = subparsers.add_parser("scan", help="Scan a directory and record file metadata.")
    scan_parser.add_argument("directory", type=Path, help="Directory to scan recursively.")

    subparsers.add_parser("report", help="Print file statistics grouped by extension.")
    subparsers.add_parser("archive", help="Copy the newest file per extension to archive/.")

    return parser


def die(message: str, code: int = 1) -> NoReturn:
    print(f"stash: error: {message}", file=sys.stderr)
    raise SystemExit(code)


def main(argv: list[str] | None = None) -> None:
    parser = build_parser()
    args = parser.parse_args(argv)

    try:
        config = Config.load(CONFIG_PATH)
        match args.command:
            case "scan":
                cmd_scan(args.directory, config)
            case "report":
                cmd_report(config)
            case "archive":
                cmd_archive()
    except StashError as exc:
        die(str(exc))


if __name__ == "__main__":
    main()

Python Release Knowledge

Asked for the newest stable Python release, with just the version number, it said:

I cannot verify the current newest stable release of Python because I do not have access to real-time information beyond my March 2026 knowledge cutoff. As of February 2026, the latest stable release

The Small Option

While adding an --ext option, Gemini 4 Carbon 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,13 @@


-def collect_sizes(root, min_size):
+def collect_sizes(root, min_size, ext=None):
     # TODO: skip symlinks?
+    if ext and not ext.startswith("."):
+        ext = "." + ext
     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 +44,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 +52,5 @@
         sys.exit(1)

-    top = largest(collect_sizes(args.root, args.min_size), args.limit)
+    top = largest(collect_sizes(args.root, args.min_size, args.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.

itertools actually doesn’t have a flatten() function—you want itertools.chain.from_iterable() instead:

Language: Python
import itertools

nested = [[1, 2], [3, 4], [5]]
flattened = list(itertools.chain.from_iterable(nested))
# Result: [1, 2, 3, 4, 5]

Cost and Effort by Task

Task Time Output tokens Thinking tokens Cost
The snake 5 min 6 s 22.3k 17.5k $0.22
The stash tool 1 min 40 s 6.6k 3.9k $0.07
Newest Python 40 s 2.4k 2.3k $0.02
The small option 32 s 1.9k 1.4k $0.02
The made-up function 11 s 359 268 $0.004
Total 8 min 10 s 33.5k 25.3k $0.34

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 Gemini 4 Carbon 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 Gemini 4 Carbon 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 Philipp Acsany

Philipp is a core member of the Real Python team. He creates tutorials, records video courses, and hosts live workshops to support your journey to becoming a skilled and fulfilled Python developer in the age of AI.

» More about Philipp

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