Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Rustic Python

Make type-checkable Python mistakes fail static checks before execution.

Read the book

A misspelled record field, an unhandled failure, or a method called before setup can become a type-checking error. Logic and numerical correctness still need tests.

Each page covers one concept: a situation, typical Python code, a better alternative, and why it helps. Examples and their results are visible on the page. No cloning, Python installation, or terminal is needed.

The book assumes familiarity with Python functions, classes, and basic annotations. It uses Python 3.11+ and requires no Rust or machine-learning background.

Start reading

Begin with the goal and example notation. Result and match shows how a recoverable error becomes a checked outcome. Total operations applies that idea to an expected missing lookup. Then follow validated records, variants, and exhaustive matching.

Later pages cover state, protocols, external boundaries, and checker limitations.

Optional references cover agent setup, contributing, and the changelog.

Licensed under the MIT license.

Move type-checkable mistakes into static checks

Project overview and reading path

The purpose of this guide is to make type-checkable mistakes fail static checks instead of surfacing only when Python runs. Every example is one view of that goal: a misspelled field, an unhandled failure, an invalid state transition, or an incompatible plugin.

For example, metadata["worker_count"] can raise KeyError after a job starts. If the record declares a workers field, the checker rejects metadata.worker_count before the job starts. The record lesson shows both programs and the rejected operation.

External data can still be malformed at runtime; a checker cannot inspect JSON that has not arrived yet. Validate it at the boundary. When the caller can reject, correct, or retry it, return a typed success-or-failure outcome. The guide borrows Result<T, E> from Rust’s standard library: Ok carries a value and Err carries an expected error. Python has no built-in Result, but a small Ok[T] | Err[E] union makes the same choice visible to the checker. A caller can use match to handle both cases; assert_never checks that its branches cover the union. See the Result and match example before the other data lessons.

That is why the guide is called Rustic Python: it borrows explicit outcomes, closed variants, and state-dependent types where they make misuse checkable. It does not promise Rust’s ownership rules or exception-free execution. Merely replacing a late KeyError with a late ValueError would miss the point.

Static checks do not establish that an algorithm is logically correct. They also cannot prove numerical correctness, tensor shapes, or that Python will never raise. Each lesson identifies the exact mistake made checkable and names the runtime obligations that remain.

How to read the examples

Project overview and reading path

Each recommendation follows one situation: typical Python, a better alternative, and what changes for the caller. Short sketches omit imports and unrelated setup. Each Source link opens the complete checked example within the book; you do not need Python, a terminal, or a repository clone.

A line such as # rejected[missing-attribute]: workers = outcome.workers means the checker rejects the statement after the colon. Here the model declares workers, but the outcome might be InvalidMetadata. The diagnostic category is shown so the result is visible without running anything. These comments are not suppressions.

The examples use Python 3.11+, Pyrefly 1.3.1, and Ruff 0.16.9. Pydantic validates external inputs; pytest checks behavior. These are the book’s reference tools, not reader prerequisites. Contributor checks verify source links, runtime behavior, and the intended diagnostic for each rejected statement.

Result and match

Project overview and reading path

An importer reads integer labels. A malformed row should be reported while valid rows continue. The caller needs both the parsed value and a way to recognize rejection.

Typical Python

def parse_label(raw: str) -> int:
    return int(raw)


labels = [parse_label(raw) for raw in ("7", "cat", "2")]

The annotation exposes only success. "cat" raises ValueError at runtime and interrupts the import. Returning -1 instead would still look like a valid integer to the checker.

Alternative

Result: TypeAlias = Ok[T] | Err[E]


def parse_label(raw: str) -> Result[int, InvalidLabel]:
    try:
        return Ok(LABEL.validate_python(raw))
    except ValidationError:
        return Err(InvalidLabel(raw, "not a nonnegative integer"))


outcome = parse_label("cat")
match outcome:
    case Ok(value=label):
        description = f"class {label}"
    case Err(error=error):
        description = f"rejected {error.raw!r}: {error.reason}"
    case _:
        assert_never(outcome)

Source

parse_label("7") returns Ok(7); parse_label("cat") returns an Err carrying the rejected input and reason. describe_label uses match to consume either outcome. It produces "class 7" or a rejection message.

Result and match work together: the union exposes failure, matching narrows the payload, and assert_never checks that no variant is forgotten. Assigning the result directly to int, or reading .value before narrowing, is rejected. A Raises: docstring cannot provide that checked contract.

Rust’s standard library provides Result<T, E> as an enum. Python does not provide the equivalent, so this example uses an ordinary union of two small frozen dataclasses, without inheritance or a third-party package. Ok and Err give a reusable shape when an operation has one success type and an expected error. Some later lessons use a direct union such as JobMetadata | InvalidMetadata: naming the domain variants directly keeps those examples focused. Both designs require narrowing before the success value is used; match plus assert_never checks coverage of either closed union.

Use Result for supported retry, fallback, correction, or record rejection. Use exceptions when the operation has no recovery path and must unwind. Translate specific recoverable library exceptions at the boundary; do not disguise bugs. Deliberately raising for a broken internal invariant is analogous to Rust’s panic!: the operation cannot produce a valid result under that condition. Python can still raise unexpected exceptions, and a caller can discard the whole result. No return annotation proves exception freedom.

The parser accepts nonnegative integer-valued text, including "7.0"; it does not know the dataset’s class count. The two frozen variants freeze their own fields, not mutable payloads. Keep this small union rather than adding a result framework or unchecked unwrap methods.

Prefer total operations for expected failures

Project overview and reading path

A user lookup may receive an ID that is absent from the current mapping. The caller can report the missing ID and continue, so absence is part of the valid input domain. An operation that has a defined outcome for every valid input is total; an operation that can fail for a valid input is partial.

Typical Python

def find_user(users: Mapping[str, User], user_id: str) -> User:
    return users[user_id]


users = {"u-7": User("Ada")}
user = find_user(users, "u-9")

If users contains only "u-7", this call raises KeyError at runtime. Its return annotation does not tell the caller that a missing ID needs handling.

Alternative

def find_user(users: Mapping[str, User], user_id: str) -> Result[User, UserNotFound]:
    user = users.get(user_id)
    if user is None:
        return Err(UserNotFound(user_id))
    return Ok(user)


users = {"u-7": User("Ada")}
outcome = find_user(users, "u-9")
match outcome:
    case Ok(value=user):
        message = user.name
    case Err(error=UserNotFound(user_id=missing_id)):
        message = f"user {missing_id} not found"
    case _:
        assert_never(outcome)

Source

find_user(users, "u-7") returns Ok(User(name="Ada")); find_user(users, "u-9") returns Err(UserNotFound(user_id="u-9")). Matching the result produces "Ada" or "user u-9 not found". The checker rejects found.value.name before the caller narrows Ok | Err, because Err has no value attribute. That is the static improvement: the expected missing key is visible in the return type. The checker does not reject every use of users[user_id] or prove that arbitrary code cannot raise.

Use the same question for items[index], next(iterator), min(items), and division: can an out-of-range index, exhausted iterator, empty collection, or zero denominator occur during normal use? If so, choose an explicit outcome that lets the caller make its real decision. Prefer the small Result union for expected failures the caller can handle; do not build a generic wrapper around every built-in. When failure means a broken internal invariant and there is no supported recovery, let the operation raise. See Result and match for that distinction.

Validated records

Project overview and reading path

A worker receives job metadata. Every job needs a name and a positive worker count; downstream code must use the declared field names.

Typical Python

metadata: dict[str, str | int] = {"name": "report", "workers": 4}
workers = metadata["worker_count"]

The dictionary annotation allows any string key. This typo survives checking and raises KeyError when the field is read.

Alternative

class JobMetadata(BaseModel):
    name: str
    workers: Annotated[int, Field(ge=1)]


def parse_metadata(payload: str) -> JobMetadata | InvalidMetadata:
    try:
        return JobMetadata.model_validate_json(payload)
    except ValidationError as error:
        return InvalidMetadata(str(error))


outcome = parse_metadata('{"name": "report", "workers": 4}')
match outcome:
    case JobMetadata(workers=workers):
        summary = f"{workers} workers"
    case InvalidMetadata(reason=reason):
        summary = f"rejected: {reason}"
    case _:
        assert_never(outcome)

The typed rejection follows the Result lesson; this page focuses on the named record fields.

Source

For this input, parsing returns JobMetadata(name="report", workers=4). The checker rejects both a misspelled model field and direct access to .workers on the unhandled MetadataResult. Matching narrows the successful branch.

Pydantic validates external JSON at runtime. Nonpositive, boolean, string, and float worker counts, missing fields, and malformed JSON return InvalidMetadata; extra fields are ignored by this schema. The checker then requires a caller to distinguish a valid record from that rejection before using its fields.

Direct model construction validates too; model_construct and model_copy(update=...) can bypass checks. Validate the actual admission path.

Variants instead of optional fields

Project overview and reading path

A service supports classification and regression. Classification needs a class count; regression needs a positive error threshold. A request must select exactly one.

Typical Python

from dataclasses import dataclass


@dataclass
class Task:
    kind: str
    num_classes: int | None = None
    huber_delta: float | None = None


task = Task(kind="classification", huber_delta=0.5)
if task.kind == "classification":
    if task.num_classes is None:
        raise ValueError("class count required")
    num_outputs = task.num_classes + 1

This well-typed object lacks the classifier setting and contains an unrelated regression setting. The caller must check the missing field, and this example raises ValueError only when it reaches that guard at runtime.

Alternative

class Classification(BaseModel):
    kind: Literal["classification"] = "classification"
    num_classes: int


class Regression(BaseModel):
    kind: Literal["regression"] = "regression"
    huber_delta: float


Task = Classification | Regression

classification = Classification(num_classes=10)
num_outputs = classification.num_classes + 1

Each variant owns its required fields. The complete example adds boundary validation and a typed rejection, using the Result idea.

Source

Classification(huber_delta=0.5) is rejected: num_classes is missing and huber_delta is unexpected. Classification(num_classes=10) is valid. Each variant owns only its relevant fields.

For JSON input, the kind tag selects the runtime schema. Missing or unknown tags and mismatched fields return InvalidTask. The checker rejects passing the unhandled TaskResult to loss_name; matching the valid variants narrows it. The checker cannot establish positive numbers or validate JSON itself. @final prevents checked subclassing, not runtime class manipulation. Use a literal or enum when an alternative needs no associated fields.

Exhaustive matching

Project overview and reading path

A data pipeline decides which splits may fit a preprocessor. Later, someone adds a holdout split. Existing decisions should be reviewed when the set changes.

Typical Python

def may_fit_preprocessor(split: str) -> bool:
    match split:
        case "train":
            return True
        case _:
            return False


allowed = may_fit_preprocessor("holdout")

The new split silently falls into the default. Nothing tells the author that an existing decision now covers an unreviewed case.

Alternative

Split = Literal["train", "validation", "test"]


def may_fit_preprocessor(split: Split) -> bool:
    match split:
        case "train":
            return True
        case "validation" | "test":
            return False
        case _:
            assert_never(split)


allowed = may_fit_preprocessor("train")

Source

The function returns True for "train" and False for the other declared splits. Adding "holdout" to Split without updating the match produces a checker error at assert_never: a possible value remains unhandled.

This checks completeness of the decision, not data provenance. A dataset named "train" can still contain the wrong records. Unchecked input reaching assert_never also fails at runtime.

An undefined metric is an outcome

Project overview and reading path

A report calculates precision from true positives and false positives. If there are no predicted positives, the score is undefined. A real score of zero means something different: predictions were made, but none was correct.

Typical Python

def precision(true_positives: int, false_positives: int) -> float:
    total = true_positives + false_positives
    return true_positives / total if total else 0.0


undefined = precision(0, 0)
incorrect = precision(0, 12)

Both (0, 0) and (0, 12) return 0.0. A caller cannot tell an undefined score from a valid zero.

Alternative

def precision(tp: int, fp: int) -> Result[float, NoPredictedPositives | InvalidCounts]:
    if tp < 0 or fp < 0:
        return Err(InvalidCounts(tp, fp))
    if tp + fp == 0:
        return Err(NoPredictedPositives())
    return Ok(tp / (tp + fp))


outcome = precision(0, 0)
match outcome:
    case Ok(value=score):
        report = f"{score:.3f}"
    case Err(error=NoPredictedPositives()):
        report = "undefined: no predicted positives"
    case Err(error=InvalidCounts()):
        report = "invalid counts"
    case _:
        assert_never(outcome)

This uses the Result pattern defined in the preceding lesson. The complete example also matches both error variants.

Source

precision(0, 0) returns Err(NoPredictedPositives()); precision(0, 12) returns Ok(0.0). Formatting produces "undefined: no predicted positives" and "0.000" respectively.

The checker rejects assigning the Result directly to float. The caller must match both outcomes before using the score, and assert_never detects a newly added outcome variant. A missing score is a supported outcome of this calculation.

Negative counts return Err(InvalidCounts(...)); callers must handle that case too. The caller must still ensure both counts describe the same population. The type checker cannot prove that condition or the numerical correctness of the formula.

State-dependent APIs

Project overview and reading path

An email draft has text but no recipient. Sending it before choosing a recipient is a mistake the caller should discover while writing code.

Typical Python

class Email:
    def __init__(self, body: str) -> None:
        self.body = body
        self.recipient: str | None = None

    def send(self, deliver: Callable[[str, str], None]) -> None:
        if self.recipient is None:
            raise ValueError("recipient required")
        deliver(self.recipient, self.body)


Email("Hello").send(record_delivery)

The checker accepts send because the method exists on every Email. The missing recipient is discovered only when this call runs.

Alternative

@dataclass(frozen=True)
class DraftEmail:
    body: str

    def to(self, recipient: str) -> "AddressedEmail":
        return AddressedEmail(recipient, self.body)


@dataclass(frozen=True)
class AddressedEmail:
    recipient: str
    body: str

    def send(self, deliver: Callable[[str, str], None]) -> None:
        deliver(self.recipient, self.body)


DraftEmail("Hello").to("reader@example.com").send(record_delivery)

Source

DraftEmail("Hello").send(record_delivery) is now rejected as missing-attribute. Calling .to("reader@example.com") returns an AddressedEmail, which can be sent. The in-memory delivery function records ("reader@example.com", "Hello").

The type distinction ensures a recipient field is present; it does not validate the address or prove delivery succeeds. Validate an external address at the boundary and use a typed outcome for delivery failures the caller can handle. The original draft remains usable after .to(); Python does not enforce Rust-style moves.

Preserve types with generics

Project overview and reading path

A helper selects the first job from a batch. Callers need its job type, and selection requires at least one item.

Typical Python

def first(items):
    return items[0]


job = first(jobs)
empty_job = first(())

The helper has no declared relationship between its input and output types. Callers rely on knowing what jobs contains. An empty collection also fails with IndexError when selected.

Alternative

T = TypeVar("T")


@dataclass(frozen=True)
class Batch(Generic[T]):
    head: T
    rest: tuple[T, ...] = ()


def first(batch: Batch[T]) -> T:
    return batch.head


job = first(Batch(Job("report")))

Source

first(Batch(Job("report"))) returns a Job. Assigning it to str is rejected; T connects the stored item type to the return type. Requiring head also makes Batch() a missing-argument error before execution.

The example selects Job(name="report"). An external collection can still be empty: its boundary must handle absence before constructing the batch. A required first item is useful only after that decision; an unchecked items[0] would merely move the runtime failure elsewhere.

Immutable fields

Project overview and reading path

Several components share feature configuration. One component must not silently change the features seen by another.

Typical Python

from dataclasses import dataclass


@dataclass(frozen=True)
class Config:
    features: list[str]


config = Config(["height", "width"])
config.features.append("area")

Freezing the record blocks field reassignment but permits mutation of the list. The append succeeds and changes shared state.

Alternative

@dataclass(frozen=True)
class Experiment:
    seed: int
    features: tuple[str, ...]


config = Experiment(seed=17, features=("height", "width"))
expanded_features = config.features + ("area",)

Source

The tuple field has no .append, so that operation is rejected as missing-attribute. Frozen fields reject reassignment, and Final protects the default binding in checked code. The caller’s expanded_features is a new tuple; config.features still contains only ("height", "width").

Immutability is shallow. A frozen record containing a mutable array still exposes that array’s mutation. Here each feature name is a string, so a tuple fits the actual contract. Environment parsing is a separate boundary.

Small protocols

Project overview and reading path

A preprocessing helper accepts interchangeable transforms. Each must return a numerical vector; a method with the right name but the wrong return type is insufficient.

Typical Python

def prepare(values, transform):
    return transform.transform(values)


class Describe:
    def transform(self, values):
        return f"{len(values)} features"


features = prepare((1.0, 2.0), Describe())
total = sum(features)

The helper returns "2 features" where later code expects numbers. sum(features) raises TypeError at runtime. The interface is implicit, so the mismatch survives until the caller uses the value.

Alternative

class FeatureTransform(Protocol):
    def transform(self, values: Features) -> Features: ...


def prepare(values: Features, transform: FeatureTransform) -> Features:
    return transform.transform(values)


features = prepare((1.0, 2.0), Identity())
total = sum(features)

Source

Identity is accepted, returns (1.0, 2.0), and gives total == 3.0. Passing Describe is rejected as bad-argument-type: its return type violates FeatureTransform. Implementations satisfy the protocol structurally, without inheriting from it. Positional-only arguments avoid requiring identical parameter names.

The checker does not prove preserved length, purity, or failure behavior. Test those contracts. Use a protocol where substitution is needed, not for every helper.

Framework inheritance is a separate requirement. For example, nn.Module supplies PyTorch parameter registration; a protocol cannot replace that lifecycle. Keep the framework base where required and expose only the capability a caller needs.

Compose interchangeable operations

Project overview and reading path

A processing pipeline supports independently added operations. Scaling and clipping should compose without a new branch in the pipeline for every operation.

Typical Python

def apply(values, operation):
    if operation == "scale":
        return tuple(value * 2 for value in values)
    if operation == "clip":
        return tuple(max(-1, min(1, value)) for value in values)
    raise ValueError("unknown operation")


processed = apply(apply((-2.0, 0.25, 3.0), "scale"), "clip")

The nested calls produce (-1.0, 0.5, 1.0), but every new operation requires editing the dispatcher. An unknown string raises ValueError only when called; the selector hides which behavior and configuration an operation provides.

Alternative

class Transform(Protocol):
    def transform(self, values: Features) -> Features: ...


class Pipeline:
    def __init__(self, stages: tuple[Transform, ...]):
        self.stages = stages

    def transform(self, values: Features) -> Features:
        for stage in self.stages:
            values = stage.transform(values)
        return values


pipeline = Pipeline((Scale(2.0), Clip(1.0)))
processed = pipeline.transform((-2.0, 0.25, 3.0))

The protocol lesson explains why an incompatible implementation is rejected. The complete example adds a typed registry lookup.

Source

The example scales (-2.0, 0.25, 3.0) by two and clips to one, producing (-1.0, 0.5, 1.0). New implementations satisfy Transform and are composed at the entry point. Passing Describe, which returns text, is a checker error.

Unknown registry names are explicit outcomes; a caller must handle them before using the transform. Mapping restricts this interface’s operations, but does not freeze the underlying dictionary.

The protocol does not prove length, finiteness, or numerical correctness. Validate those requirements separately when they matter. Discovered implementations also need a typed SDK boundary.

Typed SDK boundaries

Project overview and reading path

An application calls an untyped prediction SDK. The SDK can mutate its request, fail, or return malformed data. Application code needs a checked outcome.

Typical Python

prediction = sdk.predict({"instances": [0.2, 0.8]})
confidence = prediction["confidence"]

The caller assumes success and a valid schema. Missing fields, invalid confidence, or SDK exceptions are discovered only when the application consumes them.

Alternative

PredictOutcome = Prediction | CallFailed | InvalidResponse


def invoke(request: PredictRequest) -> PredictOutcome:
    payload = encode_request(request)
    try:
        response: object = candidate(payload)
    except Exception as error:
        return CallFailed(error)
    return _parse_response(response)


outcome = invoke(PredictRequest(features=(0.2, 0.8)))
match outcome:
    case Prediction(confidence=confidence):
        message = f"confidence {confidence:.3f}"
    case CallFailed(cause=cause):
        message = f"SDK error: {type(cause).__name__}"
    case InvalidResponse(reason=reason):
        message = f"bad response: {reason}"
    case _:
        assert_never(outcome)

The adapter owns the uncertain call and validates its response. Its typed failure variants follow Result and match; the complete example also handles binding a dynamically discovered callable.

Source

The demo returns Prediction(label="positive", confidence=0.8). Binding may first return InvalidBinding; after matching a valid Predictor, the call returns Prediction | CallFailed | InvalidResponse. Direct .confidence access is rejected until the caller narrows the outcome. Copying the payload also keeps SDK mutation away from the typed request.

Validation still happens at runtime. Confidence must be finite and in [0, 1]; text, booleans, NaN, and out-of-range values are rejected. Strict float validation accepts integer endpoints. Existing model instances are revalidated too.

Why catch Exception here?

This SDK offers no reliable exception taxonomy. The adapter catches Exception only around the vendor call, retaining its cause as CallFailed; encoding and validation bugs outside that call remain visible. It does not catch KeyboardInterrupt or SystemExit, stop hangs, or contain native crashes. Timeouts, retries, and process isolation remain caller policies.

bind_vendor accepts object only at this unavoidable library seam. A noncallable candidate returns InvalidBinding. callable does not prove the signature: a mismatch becomes CallFailed(TypeError(...)). Unknown values stay inside the adapter, which returns precise types. hide_input_in_errors is not general redaction; apply reporting policy at the caller. See declared fields for avoiding reflection in ordinary logic.

Declared fields instead of reflection

Project overview and reading path

A service reads a retry limit from a known configuration. Renaming or misspelling a field should be caught during development.

Typical Python

retries = getattr(config, "retrise", 3)

The typo silently selects the fallback. Attribute names passed as strings hide the intended field contract.

Alternative

from dataclasses import dataclass


@dataclass(frozen=True)
class Config:
    retries: int = 3


config = Config()
retries = config.retries

Direct config.retrise is rejected as missing-attribute; the valid access returns 3. The default belongs in the model, where all callers share its policy.

Keep getattr, setattr, hasattr, delattr, and equivalent reflection out of ordinary application logic. Use a protocol for required capabilities and a typed registry for selecting handlers. hasattr does not establish a method signature. Necessary framework adapters and tests may use reflection locally, then return precise types. This is a design policy, not a claim that checkers reject all reflection.

Settings at startup

Project overview and reading path

A service takes its worker count from the environment. Every component should use the same validated setting.

Typical Python

import os

workers = int(os.environ.get("APP_WORKERS", "4"))

The conversion accepts negative counts and leaves the schema and defaults scattered across read sites.

Alternative

from dataclasses import dataclass
from typing import Annotated, TypeAlias, assert_never

from pydantic import Field, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict, SettingsError


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_prefix="APP_")
    workers: Annotated[int, Field(gt=0)] = 4


@dataclass(frozen=True)
class InvalidSettings:
    reason: str


SettingsResult: TypeAlias = Settings | InvalidSettings


def load_settings() -> SettingsResult:
    try:
        return Settings()
    except (ValidationError, SettingsError) as error:
        return InvalidSettings(str(error))


outcome = load_settings()
match outcome:
    case Settings(workers=workers):
        pass
    case InvalidSettings(reason=reason):
        pass
    case _:
        assert_never(outcome)

With APP_WORKERS set to "4", matching the success variant binds integer workers == 4. A negative or malformed value returns InvalidSettings. The checker rejects using .workers on the unhandled SettingsResult, or a misspelled field on Settings. Parsing and range checks still happen at runtime.

Load settings once at the entry point and pass values to components. Explicit arguments override environment values; a dotenv file must be selected deliberately. SettingsError from malformed complex settings is also translated at this boundary. Unexpected defects still surface as exceptions.

A typed CLI schema

Project overview and reading path

A command accepts a positive worker count. Its application code should use a declared field, and CLI validation should share the same schema.

Typical Python

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--workers", type=int, default=4)
options = parser.parse_args()
workers = options.worker_count

The namespace does not declare the expected field for the checker. The typo fails at runtime, and type=int alone accepts a negative worker count.

Alternative

from typing import Annotated
from pydantic import Field
from pydantic_settings import BaseSettings, CliApp


class Arguments(BaseSettings):
    workers: Annotated[int, Field(gt=0)] = 4


options = CliApp.run(Arguments, cli_args=["--workers", "4"])
workers = options.workers

The explicit argument list produces workers == 4. options.worker_count is now a checker error. Invalid ranges are rejected by runtime parsing; typing does not inspect arbitrary command-line text.

Keep command dispatch and output at the entry point. CliSubCommand, CliPositionalArg, and CliImplicitFlag extend the same typed schema when needed. The example shows the argument flow on the page; no terminal invocation is required.

Narrowing through a predicate

Project overview and reading path

A helper recognizes nonempty text in a str | int value. Its caller wants to strip the text after the check.

Typical Python

def is_nonempty_text(value: str | int) -> bool:
    return isinstance(value, str) and bool(value.strip())


def normalize_name(value: str | int) -> str:
    if is_nonempty_text(value):
        return value.strip()
    raise ValueError("name must be nonempty text")


name = normalize_name(7)

The checker rejects .strip() because a plain bool return does not tell it which type the predicate recognized. For 7, the caller also gets an unannounced ValueError at runtime.

Alternative

def is_nonempty_text(value: str | int) -> TypeGuard[str]:
    return isinstance(value, str) and bool(value.strip())


def normalize_name(value: str | int) -> str | InvalidName:
    if is_nonempty_text(value):
        return value.strip()
    return InvalidName(value)


outcome = normalize_name(" training ")
match outcome:
    case str() as name:
        normalized = name
    case InvalidName():
        normalized = "rejected"
    case _:
        assert_never(outcome)

The failure is a typed outcome as in Result and match.

Source

TypeGuard[str] communicates the narrowing. normalize_name(" training ") returns "training", and the checker accepts .strip() in the guarded branch. The return type also forces callers to distinguish InvalidName before calling string methods. For a single use, an inline type check may be simpler.

The checker trusts the guard’s author; it does not prove the predicate correct or encode nonemptiness in str. Invalid input returns InvalidName; external schema validation belongs at a boundary.

Handle a checker defect locally

Project overview and reading path

A boundary receives an unknown mapping. Valid Python extracts a text field, but Pyrefly 1.3.1 rejects its mapping pattern. The goal is to isolate the checker defect without weakening the application’s checks.

Reproduction

def record_name(payload: object) -> str | None:
    match payload:
        case {"name": str(name)}:
            return name.strip()
    return None


name = record_name({"name": " training "})

For {"name": " training "}, Python returns "training". The pinned checker reports not-callable at the case, referring to __getitem__ and Never. The object parameter isolates an unknown-library boundary; it is not a suggested application record type.

Scoped repair

def record_name(payload: object) -> str | None:
    match payload:
        # Pyrefly 1.3.1 mapping-pattern defect; remove once fixed.
        # pyrefly: ignore[not-callable]
        case {"name": str(name)}:
            return name.strip()
    return None


name = record_name({"name": " training "})

Only the reproduced diagnostic is suppressed. Unrelated bad assignments remain errors. The verification suite checks both facts on temporary inputs, along with the runtime result. An upgrade fixing the defect must trigger removal of the workaround.

Prefer a supported annotation or guard when it expresses the same contract clearly. Do not change accepted inputs, error behavior, or coercion just to satisfy one checker version. A suppression needs runtime evidence and a removal condition; unknown external schemas still need validation.

Correct an inaccurate stub

Project overview and reading path

A dependency returns the output name "embedding", but its type stub says the return value is an integer.

Typical Python

# vendor.pyi
def output_name() -> int: ...


# caller
name: str = output_name()

The checker reports bad-assignment even though the runtime result is a string.

Alternative

# corrected vendor.pyi
def output_name() -> str: ...


# unchanged caller
name: str = output_name()

The same caller is now accepted. Repairing the dependency contract preserves both runtime behavior and meaningful static checking.

Base the correction on verified behavior of the supported version. Keep local stubs in the project’s configured search path, preserving the API surface in use. Do not claim an unknown payload is a trusted record just to silence a diagnostic; validate it at a boundary.

A cast asserts; it does not validate

Project overview and reading path

An external JSON value must become a job record before application code reads its fields.

Typical Python

from typing import cast

decoded_payload = {"name": "report"}
metadata = cast(JobMetadata, decoded_payload)
workers = metadata.workers

The checker trusts this assertion even if required keys are missing. The cast performs no validation or conversion: metadata is still a dictionary, so reading .workers raises AttributeError at runtime.

Alternative

outcome = parse_metadata('{"name": "report"}')
match outcome:
    case JobMetadata(workers=workers):
        result = f"{workers} workers"
    case InvalidMetadata(reason=reason):
        result = f"rejected: {reason}"
    case _:
        assert_never(outcome)

The record parser validates the wire input before returning JobMetadata. This missing-worker input returns InvalidMetadata, which the caller handles before accessing workers. The improvement is runtime evidence behind the annotation, not stronger checking from the cast itself.

A narrow cast can be justified when an invariant is already established but the checker cannot express it. Keep the evidence and cast together at that boundary; do not spread an unsupported assertion across callers.

Design and review checklist

Project overview and reading path

Use this as a review reference after reading the relevant lessons. Apply only items that fit the change. Each link explains the rationale, costs, and examples.

Choose the contract

  • Name a concrete mistake the design prevents; use the simplest representation that preserves the contract. Purpose
  • Give shared records precise fields. Keep required fields required; parse dates and timestamps at the boundary. Data modeling
  • Preserve relationships between arguments and results, and encode structural preconditions where useful. State, generics

Handle outcomes and alternatives

  • Declare supported recovery decisions as typed outcomes. Document why any remaining exceptions must unwind the operation; a Raises: section is not a checked failure contract. Failure contracts
  • Narrow outcomes before consuming payloads and check closed unions with assert_never. Avoid unchecked unwraps and discarded failures. Result handling
  • Distinguish unavailable values from valid zero or emptiness with typed outcomes. Undefined metrics
  • Retain the diagnostic details callers need without turning programming defects into routine failures. Result handling

Validate at boundaries

  • Validate external data with explicit coercion, missing-field, and extra-field policies; expose precise types afterward. Keep unknown library values inside adapters. Boundary validation
  • Use declared fields and typed interfaces in application logic. Keep necessary reflection local to documented adapters or tests. Dynamic access
  • Keep configuration and CLI constraints consistent, and side effects at the application edge. Settings, CLIs
  • Define behavior for malformed input at the typed boundary. SDK adapter

Keep components and state understandable

  • Use small protocols where substitution is needed. Implementations and wrappers must preserve error and mutation contracts. Composition
  • Model state-dependent operations explicitly and account for shallow immutability and aliases. State, immutability
  • In numerical code, keep native arrays and tensors. Check numerical behavior separately from static contracts. Limits

Verify the promises

  • Check passing code and the intended static rejections. Keep workarounds narrow and supported by evidence. Static evidence, checker limitations
  • Test observable behavior and the promised checker rejection. Reading examples
  • Distinguish static guarantees, runtime validation, and conventions. Do not infer exception freedom, ownership, or scientific correctness from passing types. Benefits and limits

Use the guide in your coding agent

Project overview and reading path

Give Codex, Claude Code, or Cline this message:

Install the Rustic Python skill by reading and following:
https://raw.githubusercontent.com/Red-Eyed/rustic-python/main/INSTALL.md

The agent checks the repository revision, downloads the matching references, selects its own skill directory, and verifies the installed files. You do not need to clone this repository, install uv or just, or run setup commands. Add “for this project only” to the message if you want a project-scoped installation. Installing opts into automatic reference updates before use. For older installed copies that lack this behavior, ask the agent to “update” once using the same link.

Read the installation instructions to see exactly what the agent will do. The same portable SKILL.md, chapters, examples, and templates work across all three hosts. This requires an agent with file and network tools; the host may ask for its normal permissions.

Use it

In Codex, use $rustic-python review this API. In Claude Code, use /rustic-python review this API. In Cline, select /rustic-python in chat and request the review.

The skill loads relevant chapters on demand. Installing it does not load the whole book into every conversation or replace your project’s instructions. Its tool settings are templates to adapt, not instructions to overwrite your configuration. Installed references remain available offline.

Host documentation: Codex, Claude Code, and Cline.

Automatic updates

At the start of each task using the skill, the agent compares the installed Git revision with GitHub main. It refreshes an unchanged managed installation before reading chapters and rereads the new skill instructions. This is an agent workflow, not a background service or a timer. It needs the host’s file and network tools.

Bundles contain rustic-python-source.json with the source commit, a dirty-build flag, and file hashes. The agent uses a commit-pinned repository archive when the Pages ZIP has not caught up. That fallback reflects main without claiming its CI build has completed. Package versions alone are not a freshness check.

An update preserves the previous installation in a backup outside skill discovery. If files were edited, provenance is unknown, or the installation is a symlink, the agent preserves it and uses a freshly staged snapshot for the task instead. Explicitly requested replacement of an edited directory still requires a backup. An explicit pin overrides automatic updates. Offline or blocked checks keep the cached copy and disclose that freshness was not verified; they never silently label it current.

For contributors working from a checkout

The optional just install-skill recipe links a locally built bundle into Codex’s personal skill directory. To select a different host or project location, set SKILLS_DIR to that host’s skills directory. Keep the checkout while using this linked installation; rebuilding refreshes its references. Automatic refresh will not write through that link or update your checkout. The downloadable snapshot used by the agent installation flow does not depend on a checkout.

Install Rustic Python — instructions for coding agents

When the user asks you to install Rustic Python, perform the installation below with your own file and terminal tools. Do not hand the user a list of shell commands. This installs reference material, not Python dependencies. Installing the skill opts into checking for and applying reference updates when it is used. An explicit user request to pin a revision or disable updates takes precedence.

Choose the destination

Use the host you are currently running in, not the model name or which programs happen to be installed. Install for that host only, unless the user explicitly asks for multiple hosts. Default to personal scope; honor a requested project scope or explicit destination. Ask only if the host or requested location cannot be determined from the conversation and environment.

HostPersonal destinationProject destination
Codex~/.agents/skills/rustic-python/<project>/.agents/skills/rustic-python/
Claude Code~/.claude/skills/rustic-python/<project>/.claude/skills/rustic-python/
Cline~/.cline/skills/rustic-python/<project>/.cline/skills/rustic-python/

Expand ~ using the user’s home directory, including on Windows. If the host has an explicitly configured skills directory, use it. These instructions target coding agents with local file access; a browser-only chat cannot install local files and should say so rather than claim success.

Check for updates before use

Check once at the beginning of each task using the skill, including when a new task starts in an existing conversation. Use the folder from which this skill was loaded; do not install a second copy into a different scope.

  1. Fetch https://api.github.com/repos/Red-Eyed/rustic-python/commits/main with an HTTP client that checks status codes, a bounded timeout, and cache revalidation (Cache-Control: no-cache). Read the full 40-character hexadecimal sha. This is the target revision for this task. Use commit identity, not package versions or dates: documentation can change between version bumps. Before staging an update, read INSTALL.md from https://raw.githubusercontent.com/Red-Eyed/rustic-python/<full SHA>/INSTALL.md and use that revision’s layout and update instructions. Keep the already resolved target SHA; do not restart the check recursively.
  2. Read local rustic-python-source.json. Its format is schema_version: 1, repository: "https://github.com/Red-Eyed/rustic-python", source: {"commit": "<full SHA>", "dirty": false}, and files, a mapping of relative POSIX file paths to SHA-256 hex digests. Verify file hashes before treating the installation as unchanged. Ignore only __pycache__, .pytest_cache, .ruff_cache, and .pyc files. Added, changed, missing, or symlinked reference files count as local modifications. The metadata file itself is excluded from its hash map. Digests detect edits, not authenticity.
  3. If the repository matches, source.dirty is false, the commit matches the target, and the files are unchanged, continue using this copy. Otherwise stage the target using the next section. Missing or invalid metadata means unknown provenance, not a current installation.
  4. Automatically refresh an unchanged managed installation, then reread the new SKILL.md and needed chapters. Keep this task’s check result so rereading does not trigger an update loop. Report an update with its old/new revisions.

For a user-pinned copy, honor the pin and identify it as pinned. For local edits, dirty builds, unknown provenance, or symlink installations, preserve the installed copy and use the freshly staged reference folder for this task instead; report its path and why the installed copy was not replaced. An explicitly requested update may replace a legacy or edited directory after backing it up. Never write through a symlink or change the user’s checkout automatically.

If GitHub is unavailable, rate-limited, permissions are denied, or no valid staged copy can be obtained, keep the existing copy and disclose its revision (if known) and that freshness could not be verified. Do not claim an offline copy is current. Do not loop on failures; retry on the next task. A first installation without a valid source fails clearly instead of reporting success. Check again when the user explicitly requests an update; otherwise do not poll throughout a task.

Stage the target revision

Perform downloads and file operations yourself. No clone, project dependencies, or execution of downloaded code is required.

  1. Fetch the ready-made bundle into a temporary directory: https://red-eyed.github.io/rustic-python/downloads/rustic-python.zip. Require a successful HTTP response. Inspect the archive before extracting: reject absolute paths, parent traversal, symlinks, and duplicate destinations; every entry must stay within its single rustic-python/ root.
  2. Extract into staging. Accept this ZIP only when its metadata names the expected repository, records the target commit with dirty: false, and its complete file inventory and hashes match. Reject malformed metadata or escaping paths.
  3. If Pages is unavailable or its ZIP does not match, fetch the repository archive for the exact target SHA, never a moving main archive: https://api.github.com/repos/Red-Eyed/rustic-python/zipball/<full SHA>. Follow GitHub’s archive redirect and enforce the same extraction rules, allowing its single generated repository root name. This fallback avoids waiting for Pages deployment; it is repository content, not a claim that CI has passed.
  4. Assemble a fresh rustic-python/ folder from that archive: copy README.md, CHANGELOG.md, LICENSE, INSTALL.md, pyproject.toml, uv.lock, pyrefly.toml, ruff.toml, pytest.ini, and the docs/, examples/, tests/ directories. Omit caches, .pyc files, and tests/test_distribution.py. Copy skills/rustic-python/SKILL.md to the folder’s root as SKILL.md. Remove the README line beginning - [AGENTS.md]; do not copy repository maintenance instructions, .git, tools/, or host configuration.
  5. For an archive-assembled copy, write the metadata format described above using the target SHA, dirty: false, and SHA-256 digests of all assembled files. Never relabel a stale Pages ZIP as the target revision.
  6. Verify SKILL.md has name: rustic-python, required directories and templates exist, and its relative references resolve inside staging. Do not install just SKILL.md. A malformed published bundle may fall back to the repository archive; a malformed fallback must stop the update and preserve the existing copy.

The target is the revision observed at the start of the check. If main advances while downloading, finish this consistent snapshot and check again next task. Do not combine chapters from different revisions.

Install or replace

For a new installation, choose the destination above. For an update, retain the existing destination. Finish staging and validation before touching it.

Create its parent if needed. For replacement, recheck the installed file hashes to catch edits made during downloading. If unchanged, move the old folder to a unique backup outside the host’s skill discovery directories, then move the complete staged folder into place. If replacement or final verification fails, restore the backup. Never merge files over the old directory: deleted chapters must disappear too. Serialize updates to the same destination; if another agent is updating it, use the staged snapshot for this task rather than racing it.

Verify the installed metadata, SKILL.md, and linked references at the final path, then report the revision and backup path. Leave project settings, AGENTS.md, CLAUDE.md, .clinerules, other skills, and dependency environments untouched. Respect normal host file and network permissions; automatic refresh does not bypass those permissions.

Report the result

Give the installed path, the source commit, the version from pyproject.toml, and one invocation example:

  • Codex: $rustic-python review this preprocessing pipeline.
  • Claude Code: /rustic-python review this preprocessing pipeline.
  • Cline: select /rustic-python in chat, then request the review.

If the host exposes skill discovery, check that it lists rustic-python. Otherwise report that the files are installed and ask the user to refresh the skill list or restart the session only if it does not appear. Do not claim a runtime activation test you did not perform.

Updates happen before skill use. Older copies without the refresh instructions need a one-time update using this link. The user may also request an immediate update or explicitly pin a revision. For removal, delete only the installed skill folder or symlink when requested.

Host references: Codex, Claude Code, and Cline.

GitHub references: commit lookup and commit-pinned archives.

Instructions for coding agents

Project overview and reading path

Use this page when applying the guide to another project. Read only the chapters relevant to the task. The guide recommends an approach; the target project’s requirements, supported Python versions, and tools take precedence. Installing the skill does not authorize replacing dependencies or refactoring unrelated code.

Apply the guide

  1. Name the concrete mistake to prevent and the caller’s supported decisions. Choose the smallest representation that makes incorrect use a checker error. Add a wrapper or interface only when it prevents a specific checked mistake.
  2. Preserve schemas and relationships in precise types. Validate unknown inputs at the boundary and contain library uncertainty there. Use the guide’s Pydantic and pydantic-settings examples as the reference implementation, respecting the target stack. See data modeling and settings.
  3. Declare recoverable failures as typed outcomes; let operations without a supported recovery path unwind. Use the small custom Ok[T] | Err[E] union when generic success/failure fits, with match and assert_never; no result base class, third-party result package, or unchecked unwrap is needed. A Raises: docstring is not a checked failure contract. See Result and match for recovery policy and the limits of this guarantee. Carry diagnostic details in typed error variants.
  4. Keep reflection out of ordinary application logic. Use declared fields, explicit variants, and typed registries; contain necessary dynamic behavior in documented adapters or tests. See dynamic access.
  5. Introduce protocols at real substitution points and separate types for useful lifecycle restrictions. Preserve behavioral contracts as well as signatures. See composition, state, and generics.
  6. Verify the promised rejection with the project’s checker and the runtime obligations with relevant tests. Investigate checker limitations before adding suppressions.

Report the result

Explain which mistake is now rejected, what is validated at runtime, and what remains a convention or caller obligation. Static acceptance does not establish exception freedom, ownership, or numerical correctness. Keep native tensors in scientific code and test numerical behavior separately.

Use the review checklist for the final pass. Detailed chapter contracts take precedence over an abbreviated summary; do not invent a new framework or enforcement tool merely to apply this guide.

Growing the guide

Project overview and reading path

This appendix is for maintaining the book. Readers do not need its tools or commands.

Keep README.md as the project introduction and reading path. Put detailed guidance in the relevant page under docs/. For a new topic, add a focused page and link it from the entry point. Cross-page references should use relative filenames and heading anchors rather than references to numbered sections.

Give each concept its own short page in this order:

  1. Model a concrete situation and the decision the caller needs to make.
  2. Show idiomatic Python for that situation. Use a brief python,ignore sketch where the point is a conventional pattern, not a complete runnable source file. Do not invent strawman use of object, Any, or Err as typical Python.
  3. Show the better alternative in a short sketch containing only the contract and operation that change. Show a short caller use, including how it handles an expected failure or consumes the successful value. Link the complete checked source for details.
  4. After the sketch, state the observed outcome and why the change helps. Name the exact mistake the checker rejects; label runtime improvements honestly.
  5. Mention only limits and edge cases that change the design decision. If the pattern is checker-specific, record affected versions and removal conditions.

Each complete source needs a # rejected[diagnostic-kind]: statement that isolates each claimed static rejection. Separate multiple kinds with commas. The python,ignore fence highlights a contextual sketch; it does not claim that the sketch is an independently checked complete listing. Keep the complete source in the book as a link, without duplicating its full listing in the chapter.

Write for a reader who has not followed the project’s discussions. Explain the problem before prescribing a pattern, define unfamiliar terminology, and separate design principles from the reference tool stack. Keep development history in the changelog; turn past bugs into lasting lessons rather than review narratives. Keep one explanation and one useful example per idea. Remove repeated policy reminders, chapter summaries, and background that does not change a decision. Do not wrap ordinary identifiers in nominal types solely to demonstrate typing. Prefer clear names and keyword arguments; require a demonstrated recurring mix-up before adding distinct types. Keep numerical APIs on native tensors; do not manufacture tuple-based softmax implementations or nominal tensor wrappers to make a typing point. The detailed chapter owns the rationale. Keep the checklist and agent entry points short, linking to that chapter instead of repeating its full policy.

The book must be understandable without cloning the repository, installing Python, or opening a terminal. Show the relevant input, resulting value or failure, and explanation alongside each example. Source links provide optional provenance; they must not contain the only explanation or result. Never ask readers to run or uncomment code to discover the lesson. Keep setup and verification commands in this contributor appendix.

Every failure example needs a recovery contract. Use typed outcomes when callers can retry, correct input, or reject a record and continue. For raising examples, state why that operation must unwind. A Raises: docstring alone does not expose a recoverable failure to the checker. See Result and match.

Put the executable source in examples/ and link it below its sketch with [Source](../examples/name.py). Paths in documentation links are relative to their page; shell commands run from the repository root. The tests discover these files and reject missing or unlinked copies. Each module must contain at least one rejected case. uv run --locked pytest checks independence, runtime execution, and the marked negative examples. Add behavioral tests when a lesson relies on runtime validation rather than static types alone.

Pytest lessons instead belong under tests/pytest_patterns/, with links to both test modules and conftest.py. They are collected by pytest rather than run as independent scripts, and do not need artificial static-rejection lines. The source-link check discovers README.md and every Markdown page under docs/, and covers both kinds of examples without duplicating source excerpts.

For tool upgrades, recheck the config files and examples together, then update the version requirements, project dependencies, and lockfile together.

Verify the maintained examples

Contributors use uv from the repository root. The lockfile supplies the tested environment; the project itself is not an installable runtime library.

uv run --python 3.11 --locked pytest
uv run --locked pyrefly check
uv run --locked ruff check .
uv run --locked ruff format --check .

These checks verify the examples, intended static rejections, and book source links. Keep the checker and editor environments aligned when investigating a difference. Ruff’s formatting check is read-only; formatting changes must be reviewed before committing.

Build the book and skill

docs/ remains the chapter source, and docs/SUMMARY.md defines reading order. Add new chapters to that list; tests require every topic page to appear. skills/rustic-python/SKILL.md is the short agent entry point. Its references are bundled automatically from the chapters and executable examples.

just check          # Lint, type-check, and test the guide and distributions
just book           # Prepare sources, obtain mdBook, and build the HTML book
just serve          # Build sources and preview the book in your browser
just bundle         # Build the portable skill and ZIP without downloading mdBook

The build uses upstream mdBook binaries on macOS and Linux and keeps them under ignored build/tools/. Output goes to build/book/; the skill goes to build/skills/rustic-python/. Generated copies are never edited or committed. After editing source chapters, restart just serve to refresh its staged input. Build from a Git checkout: skill bundles record its HEAD, dirty state, and file hashes in rustic-python-source.json. Dirty local builds are usable previews, but must not be described as an exact published revision. Build from a clean, stable checkout when producing a distributable snapshot. The installed skill’s references do not require network access.

The GitHub Actions workflow runs checks and builds on pull requests and pushes to main. Only successful builds on main deploy to GitHub Pages. The ZIP is included in the site and uploaded as a workflow artifact. GitHub Pages must use GitHub Actions as its publishing source in repository settings. Deployment access is separate from building the book locally.

Further reading: Python typing specification, Rust enums and pattern matching, and Rust ownership.

Changelog

Changes are grouped by commit, newest first. Dates come from Git history.

e6a3f53 — 2026-09-30

Show caller behavior in lesson sketches — 0.8.5

  • Add short calls and handled outcomes to the lessons so readers can see the runtime failure or value before the change and the caller benefit afterward.
  • Remove the distinct-identifier lesson and its manual NewType wrappers; recommend clear names and keyword arguments unless repeated ID mix-ups justify distinct types.

d9e6f5b — 2026-09-30

Expected lookup failures as checked Results — 0.8.4

  • Add a mapping lookup lesson that returns Result[User, UserNotFound] for a missing key, so unchecked success access becomes a checker error.
  • Show when an expected failure belongs in Result and when raising for a broken invariant is analogous to Rust’s panic!.

60ea01b — 2026-09-29

Clearer state-dependent API example — 0.8.3

  • Replace the centerer with a draft email that must acquire a recipient before send is available, making the premature call a checker error.
  • Demonstrate successful delivery through an in-memory callback and explain that address validation and delivery failures remain separate concerns.

bf83b9a — 2026-09-29

Concise alternatives with checked source links — 0.8.2

  • Replace long inline example files with focused before/after sketches across the lessons; link the complete checked source bundled with the book.
  • Refer readers to the Result and protocol lessons where later examples reuse those ideas, and keep source-link checks aligned with the shorter pages.

f94e1bb — 2026-09-29

Explain the Rust-inspired Result choice — 0.8.1

  • Introduce Result and match before the other data contracts, and explain how they move handling of recoverable errors into checked code.
  • Explain the origin of Result, the small Python union used here, and when directly named domain variants are clearer.

48f13ad — 2026-09-29

Type-checkable mistakes as the guide’s focus — 0.8.0

  • State the book’s precise goal: move type-checkable mistakes into static checks, while leaving logical and numerical correctness to runtime tests.
  • Remove lessons centered on runtime-only policy or repeated guarantees, along with their unused examples, tests, and iterator dependency.
  • Make recoverable parser and vendor binding failures typed outcomes; simplify retained examples so each demonstrates a specific checker rejection.

2e8bfd1 — 2026-09-29

Highlight Python sketches — 0.7.2

  • Give Python sketches syntax highlighting throughout the book while leaving the agent-install prompt as plain text.
  • Document how contextual sketches differ from complete, source-linked examples.

28f2735 — 2026-09-29

Simpler validated record example — 0.7.1

  • Replace the configured TypedDict and TypeAdapter with one BaseModel in the job metadata lesson. The example now checks named attributes directly.
  • Preserve strict worker-count validation and the policy for extra JSON fields; update the corresponding tests and project version.

0872778 — 2026-09-29

Focused, example-first lessons — 0.7.0

  • Split broad chapters into short concept pages. Each lesson now starts with a situation and familiar Python code, then shows an alternative and its benefit.
  • Replace the undefined precision sentinel with a typed Result handled by match, so zero and an unavailable score remain distinct.
  • Refresh book navigation, contributor guidance, and the portable skill’s topic links; keep the project and lockfile versions synchronized.

bb2eb76 — 2026-09-29

Learn static contracts through before/after code — 0.6.2

  • Lead core lessons with code comparisons showing the specific misuse that moves from runtime to a checker error. Distinguish runtime-only improvements.
  • Replace the nominal Logits/Probabilities softmax example with CustomerId and OrderId: the checker rejects swapped identifiers and untagged integers.
  • Remove scale_logits and InferenceOutput from the inference example; retain validated settings and their plain-record handoff. Numerical APIs should use native tensors, not the removed tuple-based stand-ins.

47214d9 — 2026-09-29

A self-contained, shorter book — 0.6.1

  • Show results and checker diagnostics directly in the chapters; remove reader instructions to clone, install tools, or execute examples.
  • Confine verification commands to the contributor appendix and mark agent setup optional. Preserve complete code listings and their automated checks.
  • Cut repeated introductions, policy reminders, and summaries across the guide; record concise, self-contained teaching as a contributor requirement.

b51da32 — 2026-09-29

A guide for general Python readers — 0.6.0

  • Add prerequisites and a first-example walkthrough, align the README and book reading order, and explain ML concepts where specialist examples remain.
  • Separate design principles from the recommended tool stack, replace discussion history with lasting lessons, and shorten the checklist and agent instructions.
  • Clarify recovery contracts throughout the lessons. A Raises: docstring does not expose recoverable failures to the type checker; typed outcomes do.
  • Change the introductory record example from DatasetMetadata with num_classes to JobMetadata with a positive workers count. Readers reusing that example should update the type name and JSON field.
  • Change the generic example from Batch(samples) to Batch(head, rest=()). first now returns the required first item without an empty-batch exception; checked construction rejects a missing first item. External collections still need an explicit empty-input policy before constructing the batch.

4a36831 — 2026-09-26

Recoverability determines failure representation — 0.5.3

  • Use typed outcomes for supported recovery decisions, including retry, fallback, corrected input, and rejecting a record while processing continues.
  • Reserve exceptions for operations with no meaningful recovery path that must unwind. Clarify that an outer boundary can still clean up, report, or isolate the failure; an exception does not require terminating the entire process.
  • Replace the expected/unexpected-only rule throughout the guide and skill; synchronize the project and lockfile version at 0.5.3.

12c0a73 — 2026-09-26

Expected failures as data — 0.5.2

  • Reserve application exceptions for unexpected failures and violated internal assumptions. Represent anticipated failures as typed outcomes even when the caller chooses to stop rather than recover.
  • Explain translating anticipated library errors at application boundaries and distinguish invalid external input from broken internal preconditions.
  • Align the errors lesson, decision guide, checklist, and agent/skill instructions; synchronize the project and lockfile version at 0.5.2.

1f0a733 — 2026-09-26

Statically checked failure contracts — 0.5.1

  • Center result guidance on rejecting misuse before execution: unchecked success access, confused payload types, and incomplete variant handling.
  • Recommend typed outcomes for expected failures callers should handle, replacing the blanket preference for exception propagation. Keep unexpected exceptions visible and distinguish static contracts from runtime input validation.
  • Update the guide and skill to require evidence of rejected misuse; synchronize the project and lockfile version at 0.5.1.

f3d2b1b — 2026-09-26

Optional custom result unions — 0.5.0

  • Remove the returns and Expression dependencies and the Expression comparison. The result lesson now defines independent frozen Ok[T] and Err[E] records with Result[T, E] as a union alias, without a shared result base class.
  • Replace Success/Failure and extraction methods with Ok(value=...) and Err(error=...) pattern matching. Readers adapting the old lesson should handle each variant explicitly; the custom records provide no map or unwrap methods.
  • Verify generic payload contracts, rejection of unchecked field access, and exhaustive matching. Preserve exception tracebacks and notes in error payloads; callers explicitly raise from a stored exception when chaining is needed.
  • Prefer exceptions for propagation and domain outcomes for failures callers inspect or collect. Present generic Result as an optional lesson rather than the default for every fallible function, including in agent and skill guidance.
  • Synchronize the manifest and lockfile at 0.5.0; verify 220 tests on Python 3.11.

0e98f80 — 2026-09-26

Dynamic attribute access policy — 0.4.1

  • Restrict getattr, setattr, hasattr, delattr, and equivalent reflection in application logic; recommend declared fields, protocols, variants, and typed registries instead.
  • Explain documented exceptions for unavoidable integration boundaries and tests that deliberately exercise dynamic behavior. Identify the restriction as design and review guidance rather than a static guarantee.
  • Include the policy in the boundary chapter, checklist, agent guide, and portable skill; update the project and lockfile version to 0.4.1.

5fed7b8 — 2026-09-26

Precise application boundaries — 0.4.0

  • Change parse_metadata, parse_task, and prepare_inference to accept JSON text instead of arbitrary Python values. Callers copying these examples should pass the serialized input directly to Pydantic rather than decode it first.
  • Replace VendorCall and predict(request, vendor) with a bound Predictor: use vendor = bind_vendor(sdk_call), then vendor(request). The callable returns validated outcomes; response parsing is private to the adapter.
  • Keep unknown library values inside integration seams and narrow the settings validator’s output to int. Move the mapping-pattern checker reproduction into the test harness and use a precise union in the runnable lesson.
  • Load the installation destination through pydantic-settings while preserving the SKILLS_DIR override and default location.
  • Update linked lessons, agent guidance, and static and runtime regression cases; verify 223 tests on Python 3.11 and synchronize the manifest and lockfile version.

8205663 — 2026-09-26

Design and review checklist

  • Add paired Do / Avoid checks for data contracts, results and matching, boundary validation, component design, state, and testing.
  • Link each topic to its detailed lesson and make the checklist accessible from the README, book navigation, and coding-agent instructions.
  • Preserve guidance on acceptable simplifications and the limits of static guarantees; update the project version to 0.3.3.

f8bae47 — 2026-09-26

Library result types and preserved failure diagnostics

  • Replace custom generic Ok/Err/Result definitions with returns’ Result, Success, and Failure; update the guide and agent recommendations.
  • Explain that unchecked unwrapping can raise and that returns does not provide the exhaustive-matching guarantee of a closed Python union.
  • Demonstrate typed mapping and explicit handling of both outcomes, with tests for invalid labels and unchecked extraction.
  • Explain preserving caught exceptions, tracebacks, Python 3.11 notes, and data provenance; cover logging and the memory cost of retaining tracebacks.
  • Add returns to the dependencies and update the project version to 0.3.2.

b9108ac — 2026-09-26

Automatic skill freshness checks

  • Instruct installed skills to check GitHub before each task and refresh unchanged managed copies, then reread the updated guide.
  • Include the source commit, dirty-build flag, and file hashes in skill bundles.
  • Use a commit-pinned repository snapshot when the published ZIP lags behind; preserve local edits and report when offline freshness cannot be verified.
  • Document backups, explicit pins, and the one-time update needed by older installations that lack refresh instructions.
  • Verify bundle provenance and edit detection; update the version to 0.3.1.

06e8ad3 — 2026-09-26

Pydantic boundaries, settings, and inference contracts

  • Use Pydantic for external schemas and demonstrate classification/regression discriminated unions with exhaustive downstream handling.
  • Add pydantic-settings configuration, source-precedence tests, and guidance to build CLIs with typed argument models instead of hand-written argparse.
  • Demonstrate validation before a plain NamedTuple/TypedDict inference handoff; keep Pydantic outside compiled execution. The scalar example does not claim verified PyTorch compilation support.
  • Change SDK response validation to reject extra fields and accept integer confidence endpoints as floats. Callers copying the example should remove unexpected response keys. Label parsing now accepts integer-valued decimal text such as "7.0"; fractional labels remain invalid.
  • Document coercion, unchecked model construction, frozen-model limitations, and invalid settings and payload behavior, with 180 passing tests on Python 3.11.
  • Replace Unreleased notes with commit-based history and update the project version to 0.3.0.

bd956dc — 2026-09-26

Agent-operated installation and just recipes

  • Add a single installation-instructions link for Codex, Claude Code, and Cline. The agent installs the ready-made bundle and verifies its references.
  • Make agent-operated installation the primary README and book workflow.
  • Replace Make with just for checks, book builds, and local skill installation; install just in GitHub Actions.
  • Update the project version to 0.2.1.

20f1f38 — 2026-09-26

Online book and portable Codex skill

  • Add an mdBook site with chapter navigation and search, built from the existing guide, plus GitHub Pages deployment after successful checks.
  • Generate an offline skill bundle from the same chapters, examples, tests, and configuration templates; provide a downloadable ZIP and local installation.
  • Check rendered links, chapter coverage, bundled references, and installation conflicts.
  • Update the project version to 0.2.0.

1d92499 — 2026-09-26

Simpler tutorial configuration

  • Consolidate dependencies into one list with >= constraints; retain exact resolved versions in uv.lock.
  • Allow newer Ruff and Pyrefly versions in their standalone configurations.
  • Move pytest settings into pytest.ini and keep VS Code settings local.
  • Update the project version to 0.1.2.

abced5c — 2026-09-26

Initial practical typing guide

  • Add a general-purpose Rust-inspired Python guide with an ML and data science bias, organized into topic pages with a README entry point.
  • Cover records, sum types, errors, absence, state, generics, immutability, protocols, composition, and plugins using runnable Python 3.11 examples.
  • Demonstrate untyped third-party boundaries, checker limitations, numerical correctness constraints, edge cases, and acceptable simplifications.
  • Add pytest lessons for fixture dependency graphs, parametrization, cleanup, and anti-patterns, alongside guidance on supplementary libraries.
  • Add strict Ruff and Pyrefly configurations, uv tooling, and tests for runtime behavior, expected type errors, and documentation synchronization.
  • Add agent instructions and contribution guidance; introduce version 0.1.1.

9823996 — 2026-09-26

  • Initialize the repository with the MIT license.