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:
| Shape | Classification |
|---|---|
Function whose body is a stub: docstring-only, ..., pass, or raise NotImplementedError | spec |
| Class with a docstring-only body, or with at least one stub method | whole-class spec — members keep the three-tier semantics below |
| Function with a real body, or a class whose methods are all real | handwritten — 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, saystr, notAny. - Docstring. This is the specification. Write it like a sharp ticket: what the function must do, which inputs fail and how (a
ValueError? aNone? 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:
| Tier | Marker | Body | The generator may |
|---|---|---|---|
| Preserved | @jaunt.preserve | hand-written | nothing — the method is emitted verbatim |
| Sealed | @jaunt.sig (or inner @jaunt.magic) | stub | implement the body only; the signature is enforced exactly |
| Guidepost | none (default) | stub | adapt 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.capacitySealed-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 --applyThe 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.
- Make the docstring more specific.
- Name the edge cases and the exact error behavior.
- Use
prompt=for any crucial constraint. - Re-run
jaunt build— only stale modules regenerate.
Next: @jaunt.test Specs.