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
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:
The composition root:
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:
import dataclasses as dc
from framework import model
@dc.dataclass
@model
class Product:
name: str
price_cents: int
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:
# 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:
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:
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:
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:
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:
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
- The Command Line — every
spocsubcommand - Validate Your Settings — the settings seam, which is a separate question from the registry's types