Skip to content

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)

component(
    obj: T,
    /,
    *,
    kind: str,
    name: str | None = None,
    metadata: Any = None,
) -> T
component(
    obj: None = None,
    *,
    kind: str,
    name: str | None = None,
    metadata: Any = None
) -> Decorator

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.