Jaunt
Guides

Writing Magic Specs

Turn a module into specs with one call, then reach for decorators when you need per-symbol control.

You can describe what a function should do far more precisely than you want to hand-write it. That is the case magic specs are for: you write the signature and the docstring, Jaunt generates the implementation into __generated__/, and at runtime your calls forward there.

There are two ways to mark a spec. Turning on a whole module is the primary one; the @jaunt.magic decorator is the precision layer for a single symbol.

Turn on a whole module

Call jaunt.magic_module(__name__) at the top of a file and every top-level stub below it becomes a spec. No per-function decorator, no repetition.

import re

import jaunt

jaunt.magic_module(__name__, prompt="All parsers are RFC 5322 strict.")

ADDR_RE = re.compile(r"[^@\s]+@[^@\s]+")   # handwritten constant — untouched


class Email:
    """An email message with from_, to, subject, and body string fields.

    Validate on construction: from_ and to must each look like an address.
    """


def parse_email(raw: str) -> Email:
    """Parse a raw RFC 5322 payload into an Email. Raise ValueError on
    malformed input."""
    ...


def _render_debug(email: Email) -> str:      # real body → handwritten helper
    return f"<{email.from_} -> {email.to}>"

Jaunt generates Email and parse_email. It never touches ADDR_RE or _render_debug — those have real bodies, so they are context the generator can read, not code it regenerates.

What counts as a spec

Jaunt scans the top-level def, async def, and class statements and classifies each one:

ShapeClassification
Function whose body is a stub: docstring-only, ..., pass, or raise NotImplementedErrorspec
Class with a docstring-only body, or with at least one stub methodwhole-class spec — members keep the three-tier semantics below
Function with a real body, or a class whose methods are all realhandwritten — read as context, never regenerated
Carries a jaunt decorator (@magic, @preserve, @sig, @test, @contract)skipped by the scan — the decorator governs it
Carries a non-jaunt decorator (@property, @functools.cache, @typing.overload, @dataclass, …)never governed — handwritten

Two rules keep the scan from surprising you. A jaunt decorator always wins, so a @jaunt.magic() or @jaunt.preserve symbol in a governed module is handled by its decorator, not the scan (this recognizes aliased imports like from jaunt import magic as m). And any non-jaunt decorator opts a symbol out entirely — an @typing.overload stub or a @property is left alone, because those ellipsis bodies are not specs. To generate a symbol that carries another decorator, mark it explicitly with @jaunt.magic.

Only direct top-level statements are scanned. Constants, if TYPE_CHECKING: blocks, and imports are ignored, and a def nested inside an if/try branch or another function is out of scope — neither governed nor guaranteed as context.

Stub bodies

A stub body is a placeholder Jaunt never runs. Reach for a bare ...:

def parse_email(raw: str) -> Email:
    """..."""
    ...

pass, a bare docstring, and raise NotImplementedError read as stubs too, and all four forms digest identically — switching between them never restales a module. The older raise RuntimeError("spec stub") convention does not classify — in a governed module that body looks like real code, so the function is handwritten.

A spec body never runs — Jaunt replaces it, and an unbuilt spec raises a clear error the first time you call it, so ... carries no runtime risk. Some type checkers (ty, Pyright) flag ... under a concrete return annotation outside .pyi files. Two remedies work equally well: relax that rule on your spec roots, or write raise NotImplementedError in place of ... (a raise never returns, so it satisfies the checker). Since the forms digest identically, switching between them never restales. Jaunt does not scaffold type-checker config — the choice is yours.

An intentionally empty function is the one case where you want an empty body left alone. Mark it @jaunt.preserve and the scan skips it:

@jaunt.preserve
def on_startup() -> None:
    """Deliberately does nothing yet."""
    ...

The capture rule

magic_module rebinds each spec name to its generated object the first time something outside the module reads it. Code at module level that runs before that — a top-level call, a class that subclasses a governed spec, an assignment that instantiates one — captures the pre-rebind stub and keeps it. So module-level consumption of a spec belongs inside a function, where it runs after the module has finished importing.

Jaunt warns you when the scan sees the trap:

mail: module-level code calls governed spec 'parse_email' before rebinding;
it will see the pre-rebind stub. Move the call into a function.
mail: class 'Signed' subclasses governed spec 'Email' at module level; it will
see the pre-rebind stub. Move the subclass into a function or mark 'Email' with
an explicit @jaunt.magic.

The escape hatch for a base class is an explicit @jaunt.magic on it: the decorator substitutes the generated class at decoration time, so a subclass declared right below it binds to the real thing.

The docstring is the contract

However you mark a spec, its contract lives in three places:

  • Signature. Type hints are leverage. If your spec returns str, say str, not Any.
  • Docstring. This is the specification. Write it like a sharp ticket: what the function must do, which inputs fail and how (a ValueError? a None? a silent fallback?), the exact return shape, and a concrete example when the behavior is subtle. Jaunt treats the whole cleaned docstring as contract text, not just the summary line.
  • Body. A ... placeholder. Never run.

Think: if you handed this docstring to a careful developer, would they implement it the same way every time? If not, add constraints.

Module defaults

The kwargs on magic_module are defaults for every spec in the file. They match the decorator's: prompt=, deps=, infer_deps=, and test=.

jaunt.magic_module(__name__, prompt="Prefer the standard library. No third-party deps.")

That prompt= reaches both scan-governed specs and any decorated symbol in the same file. A per-symbol decorator wins key by key — @jaunt.magic(prompt="…") on one function overrides the module prompt= for that function, while a @jaunt.magic(deps=[…]) symbol still inherits the module prompt=. Editing a module default changes the contract of every governed spec, so it restales them on the next build, exactly as a docstring edit would.

The precision layer: @jaunt.magic

Decorate a single function or class with @jaunt.magic when you want to govern just that symbol, or pass options that differ from the module. It works with or without a magic_module call, so it is also how you adopt one function in a file you do not want to govern wholesale.

@jaunt.magic(prompt="Use the cryptography library, not hashlib.")
def stable_hash(text: str) -> str:
    """Return a stable hex sha256 of UTF-8 text."""
    ...

The decorator options:

  • deps=...: explicit dependencies (objects or "pkg.mod:Qualname" strings).
  • prompt="...": extra per-symbol context appended to what the model sees.
  • infer_deps=True|False: per-spec override of dependency inference.

Dependencies

If a spec depends on another, declare it. Jaunt gets a stable build order and correct staleness: change the upstream spec's public shape and the dependent restales.

@jaunt.magic(deps=[normalize_email])
def is_corporate_email(raw: str, *, domain: str = "example.com") -> bool:
    """
    Return True iff `normalize_email(raw)` belongs to `domain`.

    - If normalize_email raises ValueError, propagate it unchanged.
    - Comparison should be case-insensitive.
    """
    ...

See Dependencies.

Classes

A class spec — whether governed by magic_module or decorated — can be docstring-only, in which case Jaunt designs the whole surface:

@jaunt.magic()
class LRUCache:
    """
    A small fixed-capacity LRU cache.

    - get(key): return value or None, and mark as most-recently used
    - put(key, value): insert/update and evict least-recently used if needed
    """

This hands full control to the model: it decides __init__, the internal data structures, and the method signatures.

Method tiers in a whole-class spec

Inside a whole-class spec you steer how much freedom the generator has per method. Every method falls into exactly one tier:

TierMarkerBodyThe generator may
Preserved@jaunt.preservehand-writtennothing — the method is emitted verbatim
Sealed@jaunt.sig (or inner @jaunt.magic)stubimplement the body only; the signature is enforced exactly
Guidepostnone (default)stubadapt the signature, rename or add parameters, split into several methods, or add public methods — the docstring is the contract
@jaunt.magic()
class TokenBucket:
    """A token-bucket rate limiter. Design the storage and refill internals."""

    @jaunt.sig
    def allow(self, cost: int = 1) -> bool: ...  # sealed: signature is the contract

    def describe(self) -> str: ...               # guidepost: adapt freely

    @jaunt.preserve
    def reset(self) -> None:                     # preserved: kept verbatim
        self._tokens = self.capacity

Sealed-signature drift is a hard build error; guidepost drift is only a warning. @jaunt.sig (and the inner @jaunt.magic alias) takes no kwargs and cannot sit under @property (v1) — use a guidepost stub or @jaunt.preserve instead.

The exported API of a whole-class spec is the class signature plus its declared members and method signatures. Adding, removing, or changing those is a downstream contract change. Custom metaclasses are not supported for magic classes.

Method-level specs

Instead of governing the whole class, decorate individual methods. You keep the class shape — __init__, attributes, inheritance — and generate only the methods you mark.

class TaskBoard:
    """A simple in-memory task board."""

    def __init__(self) -> None:
        self._tasks: list[dict[str, object]] = []

    @jaunt.magic()
    def add(self, title: str, priority: int) -> dict[str, object]:
        """
        Add a task and return it.

        - Assign an auto-incrementing integer id (starting at 1).
        - priority must be 1-5 (raise ValueError otherwise).
        - Return {"id": int, "title": str, "priority": int}.
        """
        ...

Non-decorated members are preserved as-is; only the @magic-decorated methods are generated.

@classmethod and @staticmethod

Both work. The rule: @magic() must be the innermost decorator, closest to def.

class TaskBoard:
    @classmethod
    @jaunt.magic()
    def from_dict(cls, data: dict[str, object]) -> TaskBoard:
        """
        Reconstruct a TaskBoard from {"tasks": [{"id", "title", "priority"}, ...]}.
        Raise ValueError if `data` is missing the "tasks" key.
        """
        ...

Whole-class vs method-level

You cannot mix whole-class @magic with standalone method-level @magic on the same class. Choose one: whole-class when you want the model to decide internals, method-level when you own the structure and generate only some methods. (A @jaunt.magic method inside a whole-class spec is not the method-level style — it is the sealed tier, and it is fully supported.)

Async functions

Async stubs work the same way. Jaunt returns an async wrapper so call sites still await, and the generated implementation is async def.

@jaunt.magic()
async def fetch_user_profile(user_id: str) -> dict[str, object]:
    """
    Return a normalized user profile by id. Raise ValueError if `user_id` is empty.
    """
    ...

Cross-referencing siblings: inline the shared rule

A spec is generated with its own source and its declared dependencies in context — not the docstrings of its neighbors in the same file. So if one spec's docstring leans on another's ("does step 1 of parse_temporal_reference first"), the model never sees that referenced text and has to guess. Two fixes: declare the neighbor as a dep so its generated API comes along, or, for a rule that spans several specs, lift it into the module default:

jaunt.magic_module(
    __name__,
    prompt="All timestamps are UTC. Reject naive datetimes with ValueError.",
)

The prompt= reaches every governed spec in the file, so a rule that would otherwise live in one docstring and be invisible to the rest now applies everywhere. This is the pattern to prefer over cross-referencing prose.

Workspaces: one config, several packages

A root config can cover flat packages, src packages, or both:

[paths]
source_roots = ["packages/*/src", "apps/*"]
test_roots = ["packages/*/tests", "apps/*/tests"]

Jaunt derives module names relative to each concrete import root. Overlapping roots use longest containment, so source_roots = [".", "src"] still names a file under src/pkg/ as pkg.*. Two files that derive the same dotted module name are rejected before either file is imported.

The nearest pyproject.toml owns each module. Generated code and .pyi files stay beside that package, ty runs from the owner directory, and dependency validation reads only the owner's declared dependencies. Tests and contract batteries also run per owner, which keeps package-local pytest settings and identical tests.* names separate.

Existing split configs can be consolidated without regeneration:

jaunt migrate --merge-projects
jaunt migrate --merge-projects --apply

The dry run compares policy, routes, fingerprints, and artifact locations. The apply step changes only the root [paths] table and removes redundant child configs. It refuses any merge that would rename a module, move an artifact, or adopt stale output.

If the output is garbage

Don't fix the generated file. Fix the spec.

  1. Make the docstring more specific.
  2. Name the edge cases and the exact error behavior.
  3. Use prompt= for any crucial constraint.
  4. Re-run jaunt build — only stale modules regenerate.

Next: @jaunt.test Specs.

On this page