Framework Semantics¶
This page defines the runtime semantics Cullinan guarantees, the behaviors it only keeps for compatibility, and the situations that now produce warnings or startup failures. The goal is to make Cullinan's runtime model legible: decorator-first business code, import-executed discovery, and explicit runtime boundaries when you need them.
Read next: Architecture, Engineering Practices
Lookup instead of explanation? Go to API Reference.
Recommended semantic package path¶
Cullinan's default semantic path is now:
cullinanfor application startup (@application,configure)cullinan.applicationfor advanced application semantics such asApplicationandmodulecullinan.webfor controllers, route decorators, request/response, parameters, and middlewarecullinan.corefor IoC/DI, lifecycle, and semantic diagnostics
Lower-level layers such as cullinan.runtime, cullinan.transport, and cullinan.support remain available, but they are not the default onboarding path for normal business applications.
This also means the default semantic path is not "learn Tornado first, then learn Cullinan". Tornado and ASGI are execution backends behind the framework boundary; the primary contract for application code is Cullinan's own Web and application semantics.
1. Component discovery is import-executed, not static AST scanning or app-object registration¶
Cullinan discovers decorated components by importing Python modules and letting decorators execute.
- guaranteed: module-top-level
@service,@controller,@component, and@providerdefinitions that execute during module import - not guaranteed: classes defined inside functions, factories, branches, or other local scopes that only run later
from cullinan.core import component
@component
class TopLevelCache:
pass
def build_repository():
@component
class LocalRepository:
pass
return LocalRepository
TopLevelCache is in the supported discovery path. LocalRepository is not part of the automatic top-level discovery contract; Cullinan now emits a warning for this pattern, and if it happens after refresh() it fails fast. If a package needs stronger ownership, reload, or hot-pluggable runtime guarantees, express that boundary with @module rather than falling back to manual app registration.
2. Inject() is a strict type contract¶
Inject() only succeeds when Cullinan can normalize the annotation into a stable and unique dependency contract.
Supported examples include:
T"T"Optional[T]Annotated[T, ...]Final[T]Provider[T]list[T],set[T],tuple[T, ...]Union[A, B]/A | Bwhen only one candidate is bindable
Cullinan does not fall back to attribute-name guessing anymore. If the annotation is missing, unsupported, or ambiguous, startup fails with a typed diagnostic.
3. InjectByName() is explicit-name semantics¶
InjectByName() resolves by component name, not by type.
Recommended form:
from cullinan.core import InjectByName
class ReportController:
report_service: "ReportService" = InjectByName("ReportService")
Compatibility form:
class ReportController:
report_service = InjectByName()
The compatibility form still falls back to the attribute name, but Cullinan now warns because the binding becomes easier to break during refactors. Even with name-based injection, keeping a real type annotation is recommended for readability and static analysis.
4. Lifecycle registration freezes after refresh()¶
ApplicationContext.refresh() is the structural boundary:
- pending decorator registrations are drained and registered
- definitions are validated and warmed
- registries are frozen
After that point, adding new decorated classes is no longer a supported runtime mutation path. Cullinan now surfaces a semantic error that explains the rule and the fix.
PendingRegistry.clear() consistency: As of v0.93a10, clear() on a frozen registry also raises RuntimeError — matching the behavior of add(). Use PendingRegistry.reset() in test teardown to fully reset the registry including the frozen state.
5. Scope rules are enforced, not best-effort¶
Cullinan treats scope compatibility as a hard rule. In particular, a singleton component cannot directly depend on a request scoped component. This now produces structured lifecycle diagnostics instead of failing later in an unpredictable way.
Transitive enforcement: The scope check now recurses through the full dependency chain — both explicit dependencies=[...] declarations and field injection markers (Inject(), InjectByName()). A singleton depending on another singleton that transitively requires a request-scoped object is detected and rejected at refresh(), with the full chain reported in the error message.
6. Compatibility APIs are compatibility only¶
Legacy surfaces such as @injectable, @inject_constructor, and get_injection_registry() remain available so older code can still import them, but they are not the recommended programming model anymore. Cullinan now warns when these APIs are used.
7. How to read warnings and errors¶
Cullinan now formats key diagnostics as:
- Semantic rule: the contract the framework enforces
- Current problem: what the runtime observed
- Suggestion: the safest supported fix
When the framework can prove a core semantic violation, startup fails. When code is still technically runnable but likely misleading, Cullinan emits a warning instead.
8. Module discovery in compiled environments¶
When Cullinan runs under Nuitka or PyInstaller, standard pkgutil.walk_packages may not find all user modules — especially in --onefile mode where the filesystem layout differs from development.
explicit_modules configuration: You can provide an explicit module list to configure():
from cullinan import configure
configure(explicit_modules=[
"myapp",
"myapp.services",
"myapp.web",
])
This list is used as the highest-priority strategy (S0) in the unified scan pipeline, before falling back to user_packages (S1) and other heuristics. Each entry is recursively walked for subpackages.
Deep subpackage discovery: list_submodules() now supplements pkgutil.walk_packages with filesystem-based recursive scanning. If a deeply nested package (e.g., club.fnep.infrastructure.discord) is missed by walk_packages, the filesystem fallback discovers it by walking __init__.py directories and .py files directly.