API Reference — The Kernel
Everything on this page is importable from spoc directly. This is the whole
public surface of the kernel — if it isn't here, a framework author doesn't
need it.
The listing below is derived from the package's own __all__ at build time —
a new export appears here on the next build, with no edit to this page.
The shape of the surface
It is a short list, and most of it is error types. What you actually build with is eleven names in four groups:
| Group | Names | What it is for |
|---|---|---|
| Declaration | Framework, KindSpec, KindHandle, component, Config |
Saying which kinds exist, and marking objects as belonging to one. Framework is the object your whole framework is; KindSpec declares one kind in full (dependencies, hooks, metadata contract) where a bare string won't do; KindHandle is the ready-made decorator framework.kind() returns; component marks an object without one; Config is your settings after a boot, on framework.config. |
| The registry | Registry, Component |
What a boot produces. Registry is the shelf, enumerable and narrowable by facet; Component is one record on it — the identifier, its three segments, and the object itself. Every surface you build is a loop over these. |
| Identity | Identifier, parse, compose |
The name grammar as values. Identifier is a parsed kind:namespace.object_name; parse and compose convert between it and the string. Reach for these when you are manipulating names rather than resolving them. |
| The package | __version__ |
What you are running. |
Everything else on this page is an error type. That ratio is intentional:
the kernel does a small number of things and refuses precisely, so most of the
surface exists to tell you what went wrong. You do not need to learn them —
every one subclasses SpocError, so a single except spoc.SpocError catches
the lot, and each names exactly what failed in its message and on its
attributes. Match on the type, never the message text.
For the trigger-and-fix table, see the error index; for which of these names carry which promise, see Stability & Versioning.
Everything, in full
SPOC — a component registry and lifecycle for modular monolithic applications.
Declare a framework once — its kinds and their attributes — on one Framework object,
then start(base_dir). SPOC discovers apps, loads their modules in dependency order,
manages lifecycle, and registers every declared object in one flat registry under a
canonical identifier: kind:namespace.object_name. External surfaces (HTTP, CLI,
workers) are built on top by enumerating the registry — SPOC describes, it never
executes.
What follows is the whole public surface. spoc.core is internal — it holds the
declaration layer, the module loader, and the configuration adapter, and nothing in it
carries a stability promise, however reachable it happens to be. Anything a framework
author legitimately needs is re-exported here; if something you need is only reachable
under spoc.core, that is a gap to report, not an API to import.
Tiers for every name below are declared in [tool.spoc.stability] in pyproject.toml
and enforced by apicheck. See the stability policy in the docs.
KindHandle
A kind's registration handle, which states the kind it registers.
Carrying the kind on the handle is what lets a reader tell one apart from any other callable a module happens to export, without matching on names or docstrings. Stub generation relies on exactly that when it mirrors a composition root.
Registration is identity: marking an object returns that same object, so
the handle is typed to give back exactly what it was given. Typing it as
returning Any would erase every decorated class at its declaration site
— and then a generated stub promising type[Product] would be promising
type[Any], which is how a stub comes to lie while every assertion about
it still passes.
KindSpec(name, depends_on=(), required=True, metadata=None, on_startup=None, on_shutdown=None)
dataclass
Everything the kernel knows about one declared kind.
AppNotFoundError(module_name)
A module could not be imported.
CircularDependencyError(modules)
Module dependencies form a cycle.
ComponentKindMismatchError(obj_name, declared_kind, location_kind, module)
A declared component's kind does not match its location — layout is taxonomy.
ComponentShapeError(identifier, expected, got)
A component's shape is not the shape the caller's type contract expects.
Shape is the one thing typed access checks at runtime — whether the record holds something constructible, a plain value, or a callable. Structure is not checked here: which members an object provides is a static question, and answering it twice would put a validation engine in the kernel.
ConfigurationError(message, config_file=None)
Configuration could not be loaded or failed validation.
CoroutineLifecycleError(offenders, phase)
The synchronous lifecycle was asked to run coroutine functions.
Raised before any step has run, so no side effect precedes the refusal.
offenders names every coroutine callable the phase would have had to
run — an author who declared two of them learns about both from one
raise — and phase says which half of the lifecycle refused
("startup" or "shutdown"). The type, not the message text, is
what a caller branches on to retry a boot via the async path.
DuplicateComponentError(identifier, existing)
A second object was registered under an existing identifier.
FrameworkTransitioningError(identifier, transition)
A read arrived from outside an in-flight lifecycle transition.
The identifier is not the problem; the timing is. This is deliberately not one of the unknown-segment errors above: during a transition the registry is half-built or already replaced, so answering "unknown namespace" would report a typo the caller did not make, and answering successfully would hand back a component whose teardown has already run.
transition names the phase in flight — the same word the caller invoked —
so the remedy points at a specific call rather than at lifecycle in general.
IdentityDivergenceError(existing_identifier, requested_identifier)
An already-registered object was re-registered under a different identity.
InvalidSegmentError(segment, value, *, derived_from=None)
An identifier segment violates the grammar.
The remediation describes the path the name actually took: derived_from
names the intrinsic name the value was converted from, and is None when the
caller stated the value outright.
MalformedIdentifierError(identifier, reason)
A string does not parse as kind:namespace.object_name.
MetadataContractError(kind, obj_name, expected, got)
Component metadata departs from the contract its kind declares.
MissingModuleError(app, kind, module)
An app provides no module for a kind whose modules are required.
MissingNameError(obj)
A nameless object was registered without an explicit name.
SpocError(message, module_name=None)
Base for every kernel error.
UnknownKindError(kind, declared)
A kind is not in the declared (closed) kind set.
UnknownNamespaceError(namespace, kind, candidates)
Resolution found no components of a kind in a namespace.
UnknownObjectError(object_name, kind, namespace, candidates)
Resolution found no object of that name in kind:namespace.
UnmarkableObjectError(obj, reason)
An object cannot carry the declaration marker.
UnresolvedReferenceError(uri, reason)
A package.module.attribute reference names something that does not exist.
Identifier
A parsed canonical identifier.
The segments carry the grammar's own names — object_name, not name —
so there is no second vocabulary to translate between here, the registry
records, and the error messages.
Component(identifier, kind, namespace, object_name, object, metadata=None)
dataclass
One registry record — the unit of enumeration and projection.
The three segment fields carry the grammar's own names, so a projection
reads kind/namespace/object_name here, in a parsed identifier,
and in an error message alike.
The type parameter describes the registered object. Registration cannot know
it — :meth:Registry.add takes an object of any type and hands back
Component[Any] — so it exists for readers that do know: a generated
stub narrows resolve per identifier, and a typed accessor narrows it per
call. Written bare, Component places no constraint on object, which
is what every unnarrowed reader means.
Registry(kinds=())
Flat store of component records with faceted, deterministic reads.
Concurrency contract: registrations are serialized — each add is
atomic, none is lost, and the duplicate and divergence guarantees hold
under any interleaving. Reads snapshot the store under the same lock, so
a read concurrent with writers observes only complete records. After
boot, when nothing writes, reads contend on nothing but an uncontested
lock acquisition.
The guarantee covers failure messages, not only records: a failed
:meth:resolve describes one observation of the store rather than several
stitched together. That is stated here because the obvious implementation —
asking the faceted readers for candidates once the lookup has released the
lock — quietly does not hold it.
kinds
property
The declared kind set (closed; fixed at construction).
add(kind, namespace, object_name, obj, metadata=None)
Register an object, building its record and canonical identifier.
One object holds exactly one canonical identifier. Re-registering an object under its existing identity is idempotent and returns the existing record; re-registering it under a different identity raises — the registry never answers a registration with a record whose identity differs from what the caller stated.
Divergence is a claim about objects, so it is tracked only for those
whose identity is their own (see :data:_SHARED_VALUE_TYPES).
identifier_of(obj)
The canonical identifier obj is registered under, or None.
Always None for a shared value type, whose id() says nothing about
which registration it came from.
all()
Every record, ordered by identifier.
by_kind(kind)
Records of one kind, ordered by identifier.
by_namespace(namespace)
Records of one namespace, ordered by identifier.
Across kinds, so this walks the kind spine — a closed set fixed at construction — rather than every record.
namespaces(kind=None)
Namespaces present in the registry, optionally for one kind.
object_names(kind, namespace)
Object names registered under one kind and namespace, sorted.
The narrowest read. Internal to the type: the promised way to read this
is :meth:by_kind or navigation. Ordering costs the facet, so a caller
that only needs to know whether one name is there asks :meth:holds.
holds(kind, namespace=None, object_name=None)
Whether anything is registered under these segments.
The membership question at whatever depth a caller has reached, so a navigation step that will succeed pays a dict hit rather than the ordered facet it would have thrown away. The ordered facet is what a failure needs — to name what it could have matched — and failure is where paying for it buys something. Same shape as resolution, for the same reason.
resolve(identifier)
Resolve a canonical identifier to its record, failing per segment.
Success is a single dict hit after the grammar check; the per-segment scans run only on the failure path, where precision is worth the walk.
A failure is composed from one observation of the store — the same one that failed to find the identifier — so it can never name a candidate that did not exist at lookup time, nor report a segment as unknown that the observation contains. The scans run over that snapshot in pure code rather than re-acquiring the lock per segment, which is also strictly fewer acquisitions than deriving them from the faceted readers.
Config(project, environment, tables=dict())
dataclass
The [spoc] table, the active mode's environment, and the app's own tables.
tables holds every top-level table in spoc.toml other than [spoc],
as parsed — the kernel neither validates nor reads them. Validating them is the
application's job, through whatever schema tool it adopts.
Framework(*kinds, echo=False)
The declaration point and composition root.
Concurrency contract: taking registration handles and decorating objects is thread-safe (a mark only sets an attribute on the target). Start and shutdown are serialized against each other and against themselves — when callers race to start, exactly one boot proceeds and the rest fail with the already-started error. Reads between a completed start and a shutdown need no coordination, because nothing writes to the registry in that window.
A transition is a window with an inside and an outside. Code the transition
itself invoked — a ready callback, a lifecycle hook, a module's
initialize or teardown, anything they call in turn — is inside it and
resolves normally, which is what lets teardown reach the components it exists
to tear down. Every other caller is outside it and is refused with
:class:~spoc.FrameworkTransitioningError for the whole window. Refusing is
the honest answer: during a transition the registry is being populated app by
app, or has already been replaced, so serving such a read would either report
a typo the caller never made or hand back a component whose teardown has run.
Draining in-flight readers is not this object's job, and the refusal is not a substitute for one. Whoever admitted the work orders it — a served application gets that from its server, which finishes in-flight work before shutting the application down. A component resolved before a transition and used after it remains the caller's responsibility; the framework never observes the use of what it returned.
A transition invoked from inside a transition — a ready callback or
lifecycle hook calling start or shutdown — fails immediately
rather than deadlocking on the non-reentrant lock. The transition is
mid-flight and its state is half-built, so there is no correct thing for
the inner call to do. Inside is decided by the same test a read gets, so a
caller the transition never invoked is reported as concurrent instead, and
told it may retry once the window closes — the one remedy that works for it
and never works for genuine reentry.
kinds
property
The declared kind set (closed; fixed at construction).
started
property
True once start has completed successfully.
objects
property
The registry navigated by grammar segment: objects.models.shop.product.
The same records :meth:resolve returns, reached by walking the three
facets of the identifier instead of spelling it as a string. Use this
when the identifier is known as you write the code — a generated stub
describes every step, so an editor completes each segment and a checker
knows the component's type. Use :meth:resolve when the identifier is
built at runtime, which no attribute path can express.
A property, not an attribute set in __init__: the walk reads the
registry when asked, so there is nothing to keep in sync and no cost for
a project that never navigates. Typed as Any because the accurate
type is the generated stub's description of this project's registry.
spec(kind)
The full declaration for one kind.
kind(kind)
The registration handle for a declared kind.
on_ready(callback)
Register a finalize callback, fired once after discovery completes.
resolve(identifier)
Resolve kind:namespace.object_name to its registry record.
resolve_type(identifier, contract)
Resolve a constructible component under a caller-owned contract.
contract is read by the type checker, not by this method: it names the
static type the caller expects and never reaches the registry. Pointing
it at a Protocol the calling app declares is what keeps typed
access from re-coupling the two apps — the caller states the shape it
needs instead of importing the module that provides it.
Only shape is checked here; see :meth:resolve_object for the rest of
the contract this pair shares.
resolve_object(identifier, contract)
Resolve a value or callable component under a caller-owned contract.
The counterpart to :meth:resolve_type, and the pair is exhaustive
because type[T] versus T is the only distinction the type system
forces. A callable is returned uninvoked: the kernel describes, and
typed access is still a pure lookup.
Neither accessor inspects the object's members. Whether it structurally
satisfies contract is a static question, answered where the contract
is visible; re-answering it at runtime would duplicate a known fact and
put a validation engine in the kernel.
start(base_dir)
Boot the project rooted at base_dir.
A failed boot is rolled back: modules that came up are torn down and the framework returns to its inert pre-start state, so the caller can fix the cause and start again cleanly.
astart(base_dir)
async
Asynchronous :meth:start: awaits coroutine hooks and module code.
Discovery is the same synchronous work — configuration reads and module
imports run on the calling thread and do not yield to the event loop.
Only hook dispatch and module initialize awaits. A concurrent
transition is a programming error and fails immediately — an event loop
is never parked on a lock.
shutdown()
Tear down modules in reverse dependency order, then reset to pre-start state.
Everything the kernel owns is reset — registry, module bookkeeping,
configuration. What persists is Python's own module cache and any
module-level state: module-level code runs at most once per process,
and a subsequent start re-runs discovery against those cached
modules rather than re-executing them. No-op if never started.
ashutdown()
async
Asynchronous :meth:shutdown: awaits coroutine hooks and teardowns.
component(obj=None, *, kind, name=None, metadata=None)
Low-level marker: attach an :class:Internal to an object.
Typed like a kind's handle, and for the reason :class:KindHandle records:
marking returns the same object, so erasing its type here would erase every
decorated class at its declaration site — and a stub derived from that would
promise type[Any] while every runtime assertion still passed.
compose(kind, namespace, object_name)
Compose a canonical identifier from three segments, validating each.
parse(identifier)
Parse a canonical identifier into its three segments.
The type check stays outside the cache. lru_cache hashes its argument
before the body runs, so an unhashable identifier would raise TypeError
from the cache machinery instead of this module's own grammar error — the
caller would learn that a list is unhashable rather than that it is not an
identifier.