Skip to content

Get Editor Autocomplete

framework.resolve("models:shop.product") hands you back an object, but your editor has no way to know which object — so it offers you nothing, and a typo in that string only shows up when you run the program.

spoc stubs fixes both. It boots your project once, writes down what every identifier resolves to, and saves it in a file your editor reads. You do not change any code.

It also gives you a second way to say the same name — framework.objects.models.shop.product — which completes one step at a time and is what you want in a large project. Both are covered below.

Generate the stub

spoc stubs

That writes framework.pyi next to your framework.py. Commit it — it belongs in the repository like any other generated artifact.

What you get

Take a small project. The settings file:

config/spoc.toml
[spoc]
mode = "development"

[spoc.apps]
development = ["shop"]

The composition root:

framework.py
import spoc

framework = spoc.Framework(
    spoc.KindSpec("models", required=False),
    spoc.KindSpec("views", depends_on=("models",), required=False),
)
model = framework.kind("models")
view = framework.kind("views")

One app with a model and a view:

shop/models.py
import dataclasses as dc

from framework import model


@dc.dataclass
@model
class Product:
    name: str
    price_cents: int
shop/views.py
from framework import view


@view
def list_products() -> dict[str, int]:
    return {"count": 2}


@view
def whatever(thing):  # deliberately unannotated — see below
    return thing

Running spoc stubs on that project produces this. You never write it by hand:

framework.pyi
# Generated by `spoc stubs`. Do not edit; regenerate instead.
# ruff: noqa: I001

from collections.abc import Callable as _collections_abc_Callable
from typing import Any, Literal, overload

from shop.models import Product as _shop_models_Product
from spoc import Component, Framework, KindHandle

class _ns_models_shop:
    product: Component[type[_shop_models_Product]]

class _kind_models:
    shop: _ns_models_shop

class _ns_views_shop:
    list_products: Component[_collections_abc_Callable[[], dict[str, int]]]
    whatever: Component[_collections_abc_Callable[[Any], Any]]

class _kind_views:
    shop: _ns_views_shop

class _Objects:
    models: _kind_models
    views: _kind_views

class _Root(Framework):
    @overload
    def resolve(
        self, identifier: Literal["models:shop.product"]
    ) -> Component[type[_shop_models_Product]]: ...
    @overload
    def resolve(
        self, identifier: Literal["views:shop.list_products"]
    ) -> Component[_collections_abc_Callable[[], dict[str, int]]]: ...
    @overload
    def resolve(
        self, identifier: Literal["views:shop.whatever"]
    ) -> Component[_collections_abc_Callable[[Any], Any]]: ...
    @overload
    def resolve(self, identifier: str) -> Component[Any]: ...
    @property
    def objects(self) -> _Objects: ...

framework: _Root
model: KindHandle
view: KindHandle

Now your ordinary code is typed — and this file is unchanged from what you would have written anyway:

main.py
from framework import framework

framework.start(".")

# The identifier completes as you type it, and a typo is now a red squiggle.
product_cls = framework.resolve("models:shop.product").object
# product_cls is `type[Product]`, so this constructor call is checked:
product = product_cls(name="keyboard", price_cents=7900)
print(product.price_cents)
#> 7900

lister = framework.resolve("views:shop.list_products").object
print(lister())
#> {'count': 2}

# Identifiers built at runtime still work — see "Catching typos" below.
kind = "models"
print(framework.resolve(f"{kind}:shop.product").object is product_cls)
#> True

# The same name, walked as attributes instead of spelled as a string.
# `objects` -> kind -> namespace -> object name. Identical record.
print(framework.objects.models.shop.product.object is product_cls)
#> True

Two spellings of the same name

An identifier is kind:namespace.object_name. You can write it as that string, or walk those same three parts as attributes — the last two lines of main.py above are the same component reached both ways.

They return the identical record, so pick per call site:

resolve("...") objects.models.shop.product
Identifier known as you write it works works
Identifier built at runtime works impossible — a path is literal
Completion offers the whole project, inside quotes one step at a time
Typo says see "Catching typos" has no attribute "prodcut"; maybe "product"?

The path completes per segment — type framework.objects. and your editor offers your kinds, then that kind's namespaces, then that namespace's components. You do not have to remember any of it.

Reach for the path in a large project

Past about a thousand components, the resolve overloads make type checkers slow down: they weigh every alternative on each call. The attribute path is one member lookup however large your registry gets — SPOC's own gate checks a 2,000-component path-based stub in about a second, where the overload equivalent takes mypy half a minute. spoc stubs tells you when you cross that line.

If a kind or namespace is named for a Python keyword, its attribute takes the usual trailing underscore — a kind class is framework.objects.class_ — while the identifier string keeps the plain name.

Why a stub and not a module

A .pyi file never executes. That matters more than it sounds.

The whole reason to resolve models:shop.product through the registry instead of importing it is that your orders app should not depend on your catalog app. A generated module naming Product would import catalog and quietly destroy that. A generated stub names it for the type checker only — at runtime the import does not exist, and your apps stay exactly as decoupled as they were.

Delete the stub and nothing about your program changes. It is only ever information for tools.

Keep it honest

A stale stub is worse than none, so check it in CI:

spoc stubs --check

This regenerates in memory and compares. It never writes. A missing stub counts as a mismatch, not a pass.

Catching typos

By default the stub keeps a catch-all, so identifiers you build at runtime still work — that is the f"{kind}:shop.product" line in main.py above, and it resolves fine.

The cost is that a misspelled literal falls through that catch-all and comes back as Any instead of erroring. If you always use literal identifiers, turn that off:

spoc stubs --strict

Now framework.resolve("models:shop.prodcut") is a type error. Pick one: dynamic identifiers, or typo detection.

There is a third option that costs you neither: the attribute path is always strict. framework.objects.models.shop.prodcut is an error in every mode, because the member simply is not there — nothing had to be turned off to catch it, and resolve stays available for the identifiers you build at runtime.

When a type cannot be worked out

Some things cannot be described honestly. whatever in shop/views.py above is the obvious case: it takes one argument and returns something, but nothing says what.

SPOC does not guess. The entry still appears and still resolves — its parameter and result are simply Any, which is the truth — and the command tells you how many are like that:

wrote framework.pyi (3 identifiers) (1 of 3 could not be typed faithfully and fall back to Any)

Add annotations to the source and the number goes down.

One rule about framework.py

A stub replaces its module entirely for type checking, so anything spoc stubs cannot describe would silently vanish from your editor's view. Rather than let that happen, it refuses:

error: Composition root 'framework' exports helper — names the stub cannot describe.

Keep framework.py to the framework and its kind handles, and put helpers in their own module. That is the shape the scaffolder already generates.

Which editors and checkers this works with

The stub is a standard type stub, so anything that reads Python types reads it. SPOC's own test suite runs mypy, pyright, and ty over a generated stub on every commit, and they must all agree.

For VS Code, autocomplete comes from Pylance, which is built on pyright — so pyright passing is exactly what makes completion appear. PyCharm, Neovim, and Zed read the same file through their own engines.

If your editor does not pick the stub up, check that your project root is on its analysis path — the stub sits beside framework.py and resolves imports the same way your code does.

See also