Ship a Framework
You've built a framework on SPOC — you declared your kinds, and your users write apps against them. This page is about the part after that: giving your users the project-generation and inspection tooling you already have, without writing any of it.
That tooling is not SPOC's alone. init, app, check, list, and explain
are parameterized by whatever framework declares the kinds, so a framework built
on SPOC inherits the whole line rather than rebuilding it.
Two audiences, one page
Ship a Reusable App is for distributing an app other projects install. This page is for distributing a framework other people build projects on.
What your users already get
Nothing here needs shipping — it works the moment someone declares a framework:
spoc app billing # one module per kind YOU declared
spoc check # dry-boots their project, reports problems
spoc list # what their registry holds
spoc explain models:billing.invoice
spoc app is the one worth understanding. It does not generate SPOC's kinds —
it reads the project's own framework.py, takes the kind set from it, and
writes one module per kind. Declare models, views, and jobs, and
spoc app billing produces:
Your vocabulary, not SPOC's. The configuration is never edited for them — the
exact [spoc.apps] entry to add is printed instead, so nothing rewrites a file
they own.
Ship your project template
spoc init generates a project from a template set. Ship your own and your
users start from your layout — your framework.py, your entry point, your
conventions — instead of the generic one.
A template set is a directory holding a manifest.toml beside its files.
Declare it under the spoc.scaffold_templates entry-point group:
The value resolves to a directory path or an importable package containing the manifest. Once your distribution is installed, the set is resolvable by bare name:
The group name is part of the published surface, and available as a constant if you'd rather not hardcode the string:
Users can also point at a set you have not published at all — a directory, a
gh:owner/repo reference, or an archive URL. A remote reference is the only
thing that causes spoc to reach the network.
What a template can and cannot do
Templates are $name substitution and nothing else — no expressions, no
conditionals, no evaluation. Both file contents and file paths substitute,
and every placeholder a template uses must be declared in the manifest, so a
typo fails at generation rather than shipping a file with a literal $nmae in
it.
That is deliberate: a template set is a layout, not a program. If you want "include the auth module? y/n" branching, ship two sets rather than one set with logic in it.
Put the commands under your own name
Your users may never type spoc. Every command SPOC ships mounts into any
argparse parser, so you can publish them under your own console script:
import argparse
from spoc.diagnostics import register as register_diagnostics
from spoc.projection import register as register_projection
from spoc.scaffold import register as register_scaffold
from spoc.stubs import register as register_stubs
parser = argparse.ArgumentParser(prog="hello")
subcommands = parser.add_subparsers(dest="command", required=True)
register_scaffold(subcommands)
register_diagnostics(subcommands)
register_stubs(subcommands)
register_projection(subcommands)
print(sorted(subcommands.choices))
#> ['app', 'check', 'explain', 'init', 'list', 'projection', 'stubs']
Point your [project.scripts] entry at a main() that parses and dispatches to
args.handler(args), and your users get the whole line under your own name:
hello init and hello app to generate, hello check to validate before
runtime, hello list and hello explain to read the registry, hello stubs
for editor autocomplete, and hello projection to hand the registry to another
tool as JSON.
Mount only what you want. Each register is independent and additive, so a
framework that would rather own its own check can mount the other three and
leave that one out.
Mounting describes commands on your parser and does nothing else — it reads no arguments, writes no output, and never ends the process. Parsing, dispatch, and the exit code stay yours, which is what lets you rename, wrap, or refuse anything you mounted.
Two arguments shape what the mounted commands can reach:
derive_kinds— a callable taking the project root and returning the kind names. Pass one andappderives kinds from the project's declaration; without it, users must pass--kinds.source_factory— decides which template sets resolve. Left out, only local sets resolve, so mounting the commands never silently acquires a network path. Pass a factory wired with a remote source to enablegh:and archive references.
spoc.cli is the worked example of exactly this: it is a composition root that
mounts each surface and injects both.
Tiering
ENTRY_POINT_GROUP and the default template set are public, so the
template path above is a promised contract. All four register functions are
provisional under the stability policy: documented
and intended for exactly this use, breakable in a minor release but never in
a patch. Pin a minor line, not an exact version.
What is promised is which commands each mount contributes and what invoking
them does. What is not promised is the type of subcommands — that belongs
to argparse, and guaranteeing it would commit your framework to argparse
as firmly as it commits SPOC. The tier settles once a framework outside SPOC
has actually mounted these, which fixes the shape against a real second
caller, or once SPOC commits to a parser choice and the mount can take a type
it owns.
Shut down where your surface has already drained
SPOC serializes its own lifecycle transitions, but it does not wait for readers.
While start or shutdown is in flight, a resolve from outside that transition
is refused with FrameworkTransitioningError rather than served a half-built or
already-emptied registry.
That error should be unreachable in a served application, because your surface already knows when its work is finished — it admitted the work. Call shutdown at the point where it has:
| Surface | Call shutdown() / ashutdown() |
Why it's safe there |
|---|---|---|
| ASGI — Starlette, FastAPI, Falcon | In the lifespan shutdown handler (after the yield in a lifespan context manager) |
The ASGI spec sends lifespan.shutdown only once the server "has stopped accepting connections and closed all active connections" |
| gRPC | After await server.stop(grace) returns |
New RPCs are already rejected with UNAVAILABLE, and in-flight ones had the grace period to finish |
| WSGI behind a worker manager | In the worker's exit hook, after the worker stops accepting | The manager stops routing before it signals the worker |
Some surfaces have no ambient drain, and there the ordering is yours to write:
- a message-queue loop (ZeroMQ, raw sockets) — stop receiving, finish the message in hand, then shut down;
- a task your app spawned itself with
asyncio.create_task— an ASGI server drains connections, not tasks you started behind its back. Cancel and await it first; - worker threads, schedulers, and CLIs that outlive a request.
Whatever you resolve stays yours after the transition ends. SPOC returns the object and never sees what you do with it, so a component resolved before shutdown and used after it is not something the kernel can refuse on your behalf.
Give your users types
spoc stubs writes a .pyi describing the project's resolution surface, so
resolve("models:billing.invoice") completes in an editor and type-checks. It
reads the registry, so it works for your kinds the same way it works for any
others — see Get Editor Autocomplete.