Build a production-ready, open source Python library called django-autowired that brings
Spring Boot-style @injectable autowiring to Python. The library wraps existing DI backends
(injector, lagom, wireup, dishka) with a unified decorator-driven API and zero manual wiring.
- No hard dependencies. The core library installs nothing. Every backend and framework integration is an optional extra.
- Backend-agnostic core. The
@injectabledecorator, scanner, registry, and container are fully decoupled from any specific DI library. injectoris the priority backend. It must be the most complete, best-tested, and the default when no backend is specified.- Django is the priority integration. It must be the most complete, best-tested, and the most prominent in documentation. But Django must not be a required dependency.
- Spring Boot ergonomics. No manual module files, no manual binder.bind() calls. A
developer annotates a class with
@injectableand the framework handles the rest. - Fail loudly at boot, never silently at runtime. Duplicate bindings, missing packages, and unresolvable types all raise typed, actionable exceptions at initialization time.
django-autowired/
├── src/
│ └── django_autowired/
│ ├── __init__.py # Public API surface
│ ├── scopes.py # Scope enum (SINGLETON, TRANSIENT, THREAD)
│ ├── exceptions.py # All typed exceptions
│ ├── registry.py # @injectable decorator + thread-safe registration store
│ ├── scanner.py # Recursive package importer (component scan)
│ ├── container.py # Global container lifecycle
│ ├── testing.py # pytest fixtures + context manager helpers
│ ├── backends/
│ │ ├── __init__.py # get_backend() factory + BackendName type
│ │ ├── base.py # AbstractBackend ABC
│ │ ├── injector_.py # injector backend (priority)
│ │ ├── lagom_.py # lagom backend
│ │ ├── wireup_.py # wireup backend
│ │ └── dishka_.py # dishka backend
│ └── integrations/
│ ├── django/
│ │ ├── __init__.py
│ │ └── apps.py # AutowiredAppConfig base class
│ ├── fastapi/
│ │ ├── __init__.py
│ │ └── lifespan.py # autowired_lifespan + Provide
│ └── flask/
│ ├── __init__.py
│ └── extension.py # Autowired extension + inject_dep
├── tests/
│ ├── conftest.py # Shared fixtures + pytest markers
│ ├── test_registry.py # Registry and @injectable decorator tests
│ ├── test_scanner.py # Scanner / component scan tests
│ ├── test_container.py # Container lifecycle tests
│ ├── test_scopes.py # Scope behavior tests across backends
│ ├── test_exceptions.py # Exception type and message tests
│ ├── test_testing_utils.py # Tests for the testing fixtures themselves
│ ├── backends/
│ │ ├── conftest.py
│ │ ├── test_injector.py # injector backend tests (most thorough)
│ │ ├── test_lagom.py # lagom backend tests
│ │ ├── test_wireup.py # wireup backend tests
│ │ └── test_dishka.py # dishka backend tests
│ └── integrations/
│ ├── test_django.py # Django AppConfig integration tests
│ ├── test_fastapi.py # FastAPI lifespan + Provide tests
│ └── test_flask.py # Flask extension tests
├── docs/
│ ├── index.md # Overview, installation, quickstart
│ ├── guide/
│ │ ├── injectable.md # @injectable decorator in depth
│ │ ├── scopes.md # Scope reference
│ │ ├── scanning.md # Component scanning + exclude patterns
│ │ ├── container.md # Container lifecycle
│ │ ├── extra_modules.md # Manual bindings for config/third-party
│ │ └── testing.md # Testing guide with examples
│ ├── backends/
│ │ ├── injector.md # injector backend guide
│ │ ├── lagom.md # lagom backend guide
│ │ ├── wireup.md # wireup backend guide
│ │ └── dishka.md # dishka backend guide
│ ├── integrations/
│ │ ├── django.md # Django integration guide (priority)
│ │ ├── fastapi.md # FastAPI integration guide
│ │ └── flask.md # Flask integration guide
│ └── reference/
│ └── api.md # Full API reference
├── mkdocs.yml # MkDocs Material config
├── pyproject.toml # Full project metadata + optional deps
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE # MIT
└── README.md
Define a Scope(str, Enum) with three values:
SINGLETON— one instance per container lifetime (default)TRANSIENT— new instance on every resolutionTHREAD— one instance per thread (for request-scoped objects in threaded WSGI)
Define all exceptions as subclasses of AutowiredError(Exception):
ContainerNotInitializedError— accessed beforeinitialize()ContainerAlreadyInitializedError—initialize()called twice withoutreset()DuplicateBindingError(interface, existing_cls, new_cls)— two classes bind to same interfaceBackendNotInstalledError(backend_name, package)— backend package not installedUnresolvableTypeError(cls, reason)— container cannot satisfy a type
All messages must be human-readable and actionable, explaining what to do to fix the error.
Registration— frozen dataclass withcls,scope,bind_to, and atargetproperty that returnsbind_toif set, elsecls._Registry— thread-safe class usingthreading.Lock()for all mutations/reads. Detects duplicate interface bindings (two different classes → samebind_to) and raisesDuplicateBindingError. Idempotent if the exact same class is re-registered.injectable(scope=Scope.SINGLETON, bind_to=None)— decorator that registers the class and returns it unmodified. Must be transparent to callers.get_registry()— returns the module-level_Registrysingleton.clear_registry()— clears all registrations. Tests only.
scan_packages(*package_paths, exclude_patterns=None)— recursively imports all sub-modules under each package, triggering@injectableside effects.- Built-in skip segments (not configurable away):
migrations,tests,test,conftest,factories,fixtures. exclude_patternsmerges with built-ins, not replaces them.- Import errors in individual sub-modules are logged as
WARNINGand skipped — a single broken module must never prevent boot. - Root package
ImportErrors are logged asERRORand that package is skipped entirely.
Global functions (module-level, not a class):
def initialize(
packages: list[str],
backend: BackendName | AbstractBackend = "injector",
extra_modules: list[Any] | None = None,
exclude_patterns: set[str] | None = None,
allow_override: bool = False,
) -> AbstractBackend: ...
def get(cls: Type[T]) -> T: ...
def get_backend_instance() -> AbstractBackend: ...
def override(bindings: dict[Type, Any]) -> None: ...
def reset() -> None: ...
def is_initialized() -> bool: ...reset() must clear both the backend reference AND the registry.
AbstractBackend(ABC) with abstract methods:
build(registrations: list[Registration]) -> Noneget(cls: Type[T]) -> Toverride(bindings: dict[Type, Any]) -> Nonename(cls) -> str(classmethod)
Each backend file follows the same pattern:
- Guard the import with try/except and set
_AVAILABLE = bool. __init__raisesBackendNotInstalledErrorif not available.- Map
Scopevalues to the backend's native scope concept. build()registers allRegistrationobjects with the native container.get()wraps native resolution inUnresolvableTypeErroron failure.override()applies replacement bindings. Backends with immutable containers (dishka, wireup) rebuild or layer a new container.- Expose a
raw()method returning the native container for advanced use.
injector backend specifics:
Scope.SINGLETON→injector.singletonScope.TRANSIENT→injector.noscopeScope.THREAD→injector.threadlocalextra_modulesare applied by creating a childInjectorwith those modules as parent.- Expose
create_child(extra_modules)for request-scoped child containers.
lagom backend specifics:
- Lagom resolves concretes automatically from type hints. Only
bind_toregistrations need explicit definition. Singletons use lagom'sSingletonwrapper. - Document clearly that
@injectfrominjectoris not used/needed.
wireup backend specifics:
- Wireup containers are immutable post-build.
override()stores overrides in a local dict and checks them inget()before delegating to the container. Scope.THREADfalls back toTRANSIENT— document this limitation.
dishka backend specifics:
- Dishka uses provider classes. Dynamically generate a
Providersubclass with@provide-decorated factory methods from the registrations list. Scope.SINGLETON→DishkaScope.APP.TRANSIENTandTHREAD→DishkaScope.REQUEST.override()closes the old container and rebuilds with anOverrideProviderlayered on top.
AutowiredAppConfig(AppConfig):
- Class attributes:
autowired_packages,autowired_backend="injector",autowired_extra_modules=[],autowired_exclude_patterns=set(). ready()callscontainer.initialize(...)using the class attributes.- Warns (not errors) if
autowired_packagesis empty. - Raises
ImportErrorwith install instructions if Django is not installed.
autowired_lifespan(app, packages, backend, extra_modules, exclude_patterns)— async context manager. Callsinitialize()on enter,reset()on exit.Provide(cls)— callable class usable asDepends(Provide(MyService)).
Autowired— Flask extension class supporting both direct init andinit_app()factory pattern. Parameters can be passed to constructor or toinit_app(), withinit_app()args taking precedence.inject_dep(cls)— resolves from container. Use in route handlers.
autowired_container— pytest fixture. Reads@pytest.mark.autowired_packages([...])and@pytest.mark.autowired_backend("injector")markers. Callsinitialize(allow_override=True), yields the backend, then callsreset().build_container— pytest fixture yielding aContainerFactoryinstance. The factory is a callable:factory(packages, overrides, backend, extra_modules). Callsreset()before each build and after the test.container_context(packages, backend, overrides, extra_modules)— sync context manager for non-pytest use.InMemoryOverrideModule(overrides: dict)— helper that wraps a dict into aninjector.Modulefor use withextra_modules.
Export exactly:
from .registry import injectable
from .scopes import Scope
from . import container
from .container import get_injector # alias for container.get_backend_instance()
from .exceptions import (
AutowiredError,
ContainerNotInitializedError,
ContainerAlreadyInitializedError,
DuplicateBindingError,
BackendNotInstalledError,
UnresolvableTypeError,
)
__version__ = "0.1.0"
__all__ = [...]- Use
pytest.importorskip("injector")etc. at the top of each backend test file so tests are automatically skipped if the backend is not installed. - Every test must be fully isolated: the
autousefixture inconftest.pymust callcontainer.reset()before and after every test. - No test should depend on execution order.
- Use
abc.ABCand@abstractmethodfor all test interfaces — do not usetyping.Protocolfor interfaces in tests (avoid runtime_checkable complexity). - Prefer direct instantiation (no container) for pure unit tests of single classes.
import pytest
from django_autowired import container
from django_autowired.registry import clear_registry
@pytest.fixture(autouse=True)
def isolate_container():
clear_registry()
container.reset()
yield
container.reset()
clear_registry()Register custom markers: autowired_packages, autowired_backend.
@injectable()registers a concrete class (nobind_to)@injectable(bind_to=IFoo)registers with interface bindingregistration.targetreturnsbind_towhen set, elsecls- Duplicate binding (two different classes → same interface) raises
DuplicateBindingError - Re-registering the exact same class to the same interface is idempotent (no raise)
clear_registry()removes all registrations- Thread safety: 50 threads simultaneously registering different classes, no data corruption
@injectablereturns the original class unchanged (decorator transparency)
- A package containing
@injectableclasses is scanned and they appear in the registry migrationssub-packages are skippedtestssub-packages are skipped- Custom
exclude_patternsare respected - A sub-module with an
ImportErroris skipped with a warning, others still scanned - A root package that doesn't exist logs an error and skips gracefully
- Scan is idempotent (calling twice doesn't double-register)
get()beforeinitialize()raisesContainerNotInitializedErrorinitialize()twice withoutreset()raisesContainerAlreadyInitializedErrorinitialize(allow_override=True)permits re-initializationreset()theninitialize()works cleanlyis_initialized()returns correct state before/after init/resetoverride()before init raisesContainerNotInitializedErrorget_backend_instance()returns the backend passed toinitialize()- Passing a pre-instantiated backend object (not a string) to
initialize()works
Scope.SINGLETON→ same instance on multipleget()callsScope.TRANSIENT→ different instance on everyget()callScope.THREAD→ same instance within a thread, different across threads
- Each exception has a meaningful
str()message DuplicateBindingErrormessage contains all three type namesBackendNotInstalledErrormessage contains install instructionsUnresolvableTypeErrormessage contains the class name
- Concrete class resolved without
bind_to - Interface resolved to concrete via
bind_to - Transitive injection chain (A → B → C all
@injectable) Scope.SINGLETONinstance reuseScope.TRANSIENTfresh instancesextra_modulesoverride a bindingoverride()replaces a bindingraw()returns aninjector.Injectorcreate_child()creates a child injector@injectconstructor annotation works correctly- Constructor with no
@injectbut type annotations is still resolved UnresolvableTypeErrorraised for unregistered type
- Concrete class resolved
- Interface resolved to concrete via
bind_to - Singleton scope reuses instance
- Transient scope creates new instances
override()with class replacementoverride()with instance replacementraw()returns a lagomContainer
- Concrete class resolved
- Interface resolved via
bind_to - Singleton scope
override()with class and instanceScope.THREADfalls back gracefully (no error, just transient behavior)
- Concrete class resolved
- Interface resolved via
bind_to - Singleton scope (
DishkaScope.APP) override()rebuilds container with replacementraw()returns a dishkaContainer
AutowiredAppConfig.ready()callscontainer.initialize()with correct args- Empty
autowired_packagesemits aUserWarning autowired_backendattribute is usedautowired_extra_modulesattribute is passed throughautowired_exclude_patternsattribute is passed through- A subclass with a custom
ready()that callssuper().ready()works correctly
Use django.test.utils.setup_test_environment and mock AppConfig.ready() — you do not
need a full Django project, just DJANGO_SETTINGS_MODULE set to a minimal settings dict
via django.conf.settings.configure(...).
autowired_lifespaninitializes container on enter, resets on exitProvide(MyService)resolves from container when calledProvideworks asDepends(Provide(MyService))in a TestClient request- Exception in lifespan body still triggers
reset()(finally block)
Autowired(app, packages=[...])initializes containerAutowired().init_app(app, packages=[...])factory pattern worksinit_app()args take precedence over constructor argsinject_dep(MyService)resolves from container within app context
autowired_containerfixture initializes and tears down container@pytest.mark.autowired_packagesmarker is respected@pytest.mark.autowired_backendmarker is respectedbuild_containerfactory resets before each callbuild_containerwithoverridesapplies themcontainer_contextinitializes on enter, resets on exitcontainer_contextresets even on exception
Must include:
- One-line description
- Badges: PyPI version, Python versions, license, CI status
- Why section explaining the problem (manual injector.Module wiring at scale)
- Feature highlights in a compact table or list
- Quickstart — 15 lines of code showing
@injectable,AutowiredAppConfig, done - Backend support matrix table (injector ✓ priority, lagom ✓, wireup ✓, dishka ✓)
- Framework support table (Django ✓ priority, FastAPI ✓, Flask ✓, plain Python ✓)
- Installation section with all optional extras documented
- Link to full docs
Full narrative introduction: the problem, the solution, the philosophy. Installation table for all extras. Quickstart with Django (full working example).
- Decorator signature and all parameters explained
- Concrete class binding (no
bind_to) - Interface binding (
bind_to=IMyRepository) - Scope selection
@injectusage withinjectorbackend- Rule: every class in
adapters/out_/implementing a domain port MUST usebind_to= - Common mistakes and how to fix them
Reference table of all three scopes with: name, behavior, when to use, backend caveats.
Include note about Scope.THREAD not being supported in lagom/wireup/dishka.
How the scanner works, what it skips by default, exclude_patterns, how to structure
packages so scanning is predictable. Include note about migration files being auto-skipped.
Full reference for all container functions with examples for each.
How to register things that can't use @injectable — config values, third-party SDK
clients, environment-specific overrides. Full example with injector.Module.
Three testing strategies:
- Pure unit test (no container, just
MyClass(mock_dep_a, mock_dep_b)) autowired_containerfixture for integration testsbuild_containerwithoverridesfor partial integration tests
Include anti-patterns: never call container.get() inside domain code (service locator
anti-pattern). Show how to detect this in code review.
For each backend: installation, how it maps scopes, any limitations (e.g. wireup/dishka
override caveats), whether @inject is needed, full working example.
- Installation
- Full
AutowiredAppConfigexample - How to structure a module for scanning
extra_modulesfor Django-specific bindings (database, cache, settings values)- Testing with Django's test runner
- Compatibility table (Django 4.2 LTS, 5.0, 5.1)
- FAQ: "Why not use Django's built-in signals for this?"
autowired_lifespanusageProvidewithDepends- Async services (note: use
Scope.TRANSIENTor ensure thread safety) - Full working app example
Autowiredextension both init stylesinject_depin route handlers- Application factory pattern
Auto-generated from docstrings via mkdocstrings.
- Dev setup:
pip install -e ".[dev]" - Running tests:
pytest/pytest tests/backends/test_injector.py - Running all backends:
pytest -m "injector or lagom or wireup or dishka" - Linting:
ruff check src tests - Type checking:
mypy src - Adding a new backend: step-by-step guide
- Adding a new integration: step-by-step guide
- PR checklist
Follow Keep a Changelog. Start with [0.1.0] - Unreleased.
Use MkDocs Material theme with:
navigation.tabsnavigation.sectionscontent.code.copypymdownx.highlightfor code blocks- Dark/light mode toggle
[project]
name = "django-autowired"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [] # NO hard dependencies
[project.optional-dependencies]
injector = ["injector>=0.21"]
lagom = ["lagom>=2.6"]
wireup = ["wireup>=0.14"]
dishka = ["dishka>=0.14"]
django = ["Django>=4.2"]
fastapi = ["fastapi>=0.110", "anyio>=4.0"]
flask = ["Flask>=3.0"]
testing = ["pytest>=8.0", "pytest-asyncio>=0.23"]
dev = [
"django-autowired[injector,lagom,wireup,dishka,django,fastapi,flask,testing]",
"pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov>=5.0",
"mypy>=1.9", "ruff>=0.4",
"mkdocs>=1.5", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.24",
]Matrix CI across:
- Python versions:
3.10,3.11,3.12,3.13 - OS:
ubuntu-latest
Steps:
- Checkout
- Set up Python
pip install -e ".[dev]"ruff check src testsmypy srcpytest --cov=src/django_autowired --cov-report=xml- Upload coverage to Codecov
-
bind_tofor all port implementations. Any class implementing an ABC or Protocol that lives in anadapters/out_/or equivalent directory MUST usebind_to=. The docs and a CONTRIBUTING checklist item should enforce this. -
Never
container.get()in domain code. The container is for framework code only. Dependency resolution happens via constructor injection. Document this as an anti-pattern. -
One concrete class per interface. If you need environment-specific implementations, use
extra_modulesconditioned on an env var, not multiple@injectable(bind_to=...). -
reset()in tests only. The container lifecycle is: init once at boot, never reset in production. Only tests callreset(). -
Type annotations required for injection. All injectable constructors must have fully typed
__init__parameters. The scanner relies on type annotations, not runtime magic.