Dependency injection container with autowiring for Python.
Project description
Lucid Container
Dependency injection that wires itself. Bind abstracts to concretes, swap implementations by config, and let the container figure out the rest.
Stop manually constructing dependency trees. Stop passing 12 arguments through 4 layers of functions. Stop refactoring every call site when you swap Redis for Memcached. Lucid Container gives Python a proper IoC container with autowiring — the kind of DI that gets out of your way.
Before & After
Without Lucid Container:
config = load_config()
logger = FileLogger(config["log_path"])
redis = RedisClient(config["redis_host"], config["redis_port"])
cache = RedisCache(redis, prefix=config["cache_prefix"])
mailer = SMTPMailer(config["smtp_host"], config["smtp_port"], logger)
user_repo = UserRepository(cache, logger)
auth_service = AuthService(user_repo, mailer, cache, logger)
payment_service = PaymentService(auth_service, logger, cache)
order_service = OrderService(payment_service, user_repo, mailer, cache, logger)
# And now do this again in tests with different implementations...
With Lucid Container:
from lucid_container import Container
app = Container()
app.singleton(CacheContract, RedisCache)
app.singleton(MailerContract, SMTPMailer)
app.singleton(LoggerContract, FileLogger)
# Container reads type hints, builds the entire tree automatically
order_service = app.make(OrderService)
# Swap to in-memory for tests — one line, zero refactoring
app.singleton(CacheContract, InMemoryCache)
Installation
pip install lucid-container
Requires Python 3.10 or higher. No dependencies.
Quick Start
Basic binding and resolution
from lucid_container import Container
app = Container()
# Bind an abstract (interface) to a concrete (implementation)
app.bind(CacheContract, RedisCache)
# Resolve — container inspects RedisCache.__init__ type hints,
# recursively resolves dependencies, and returns a fully constructed instance
cache = app.make(CacheContract)
Singletons
# Same instance every time
app.singleton(LoggerContract, FileLogger)
logger1 = app.make(LoggerContract)
logger2 = app.make(LoggerContract)
assert logger1 is logger2 # True
Autowiring (zero config)
class OrderService:
def __init__(self, payments: PaymentContract, mailer: MailerContract):
self.payments = payments
self.mailer = mailer
# If PaymentContract and MailerContract are bound, this just works.
# The container reads __init__ type hints and resolves each one.
order_service = app.make(OrderService)
Full API Reference
Container()
Creates a new container instance.
from lucid_container import Container
app = Container()
.bind(abstract, concrete)
Registers a binding. Every call to make(abstract) creates a new instance.
| Parameter | Type | Description |
|---|---|---|
abstract |
type | str |
The interface, base class, or string key. |
concrete |
type | Callable[[Container], Any] |
A class to autowire, or a factory callable that receives the container. |
# Class binding — container autowires the constructor
app.bind(CacheContract, RedisCache)
# Factory binding — you control construction
app.bind(CacheContract, lambda c: RedisCache(
host=c.make("config")["redis_host"],
port=c.make("config")["redis_port"],
))
.singleton(abstract, concrete)
Same as .bind(), but the instance is created once and cached. Every subsequent make() returns the same object.
app.singleton(DatabaseContract, PostgresDatabase)
db1 = app.make(DatabaseContract)
db2 = app.make(DatabaseContract)
assert db1 is db2
.instance(abstract, obj)
Registers an existing object as a singleton. Useful for config objects, pre-built clients, or test doubles.
config = {"debug": True, "db_host": "localhost"}
app.instance("config", config)
app.instance(LoggerContract, my_custom_logger)
.alias(name, abstract)
Creates a shorthand name for an abstract.
app.singleton(CacheContract, RedisCache)
app.alias("cache", CacheContract)
# Both return the same thing
app.make(CacheContract)
app.make("cache")
.make(abstract)
Resolves an abstract to a fully constructed instance. This is the core method.
| Parameter | Type | Description |
|---|---|---|
abstract |
type | str |
What to resolve. |
Resolution order:
- Check aliases — if
abstractis a string alias, resolve to the canonical abstract. - Check singleton cache — if already resolved as a singleton, return cached instance.
- Check bindings — if a binding exists, use it.
- Autowire — if
abstractis a concrete class (not an ABC), attempt to autowire it directly. - Raise
ResolutionErrorif nothing works.
cache = app.make(CacheContract)
config = app.make("config")
.has(abstract)
Returns True if the abstract is bound, aliased, or has a cached singleton.
app.bind(CacheContract, RedisCache)
app.has(CacheContract) # True
app.has(MailerContract) # False
.flush()
Clears all bindings, singletons, and aliases. Useful in tests.
app.flush()
.bound_to(abstract)
Returns the concrete class or factory bound to an abstract, or None.
app.bind(CacheContract, RedisCache)
app.bound_to(CacheContract) # RedisCache
Autowiring
The container's most powerful feature. When resolving a class, the container:
- Inspects
__init__usinginspect.signature()andtyping.get_type_hints(). - For each parameter with a type annotation that's bound in the container, recursively calls
make(). - For parameters with default values and no binding, uses the default.
- For parameters without a type hint or binding, raises
ResolutionError.
class NotificationService:
def __init__(
self,
mailer: MailerContract, # resolved from container
cache: CacheContract, # resolved from container
retries: int = 3, # uses default (not in container)
):
self.mailer = mailer
self.cache = cache
self.retries = retries
# Works automatically — mailer and cache are resolved, retries uses default
service = app.make(NotificationService)
Autowiring also works for concrete classes that aren't explicitly bound:
# UserService is never registered — but its dependencies are.
# The container can still build it.
class UserService:
def __init__(self, repo: UserRepository, logger: LoggerContract):
...
service = app.make(UserService) # Just works
Contextual Binding
Sometimes the same abstract should resolve to different concretes depending on who's asking.
app.when(PhotoController).needs(StorageContract).give(S3Storage)
app.when(ReportController).needs(StorageContract).give(LocalStorage)
# When building PhotoController, StorageContract → S3Storage
# When building ReportController, StorageContract → LocalStorage
photo = app.make(PhotoController)
report = app.make(ReportController)
Tagging
Group related bindings under a tag, then resolve them all at once.
app.bind("reports.daily", DailySalesReport)
app.bind("reports.weekly", WeeklyInventoryReport)
app.bind("reports.monthly", MonthlyRevenueReport)
app.tag(["reports.daily", "reports.weekly", "reports.monthly"], "reports")
# Resolve all tagged bindings
all_reports = app.tagged("reports")
# Returns: [DailySalesReport(...), WeeklyInventoryReport(...), MonthlyRevenueReport(...)]
Container Events
Hook into resolution for logging, decoration, or post-construction setup.
.resolving(abstract, callback)
Called every time abstract is resolved, before returning. Receives the resolved instance and the container. Return value replaces the instance.
def add_logging(instance, container):
instance.logger = container.make(LoggerContract)
return instance
app.resolving(PaymentService, add_logging)
.after_resolving(abstract, callback)
Called after the instance is returned. Useful for non-mutating side effects like event emission or metrics.
app.after_resolving(PaymentService, lambda inst, c: metrics.increment("payment_service.resolved"))
Global resolving (no abstract filter)
# Called for EVERY resolution
app.resolving_any(lambda instance, container: print(f"Resolved: {type(instance).__name__}"))
Method Injection
Resolve dependencies for a function call, not just constructor injection.
def send_welcome(user_id: int, mailer: MailerContract, cache: CacheContract):
user = cache.get(f"user:{user_id}")
mailer.send(user.email, "Welcome!")
# Container injects mailer and cache, you provide user_id
app.call(send_welcome, {"user_id": 42})
Service Providers
Service providers are the recommended way to organize bindings. Each provider is responsible for one subsystem — cache, mail, queue, logging, etc.
Base class
from lucid_container import ServiceProvider
class CacheServiceProvider(ServiceProvider):
def register(self):
"""Bind into the container. Called first on ALL providers."""
self.app.singleton(CacheContract, lambda c: self._create_cache(c))
def boot(self):
"""Called after ALL providers have registered. Safe to resolve."""
cache = self.app.make(CacheContract)
cache.set("app.booted", True)
def _create_cache(self, container):
config = container.make("config").get("cache", {})
driver = config.get("driver", "memory")
if driver == "redis":
return RedisCache(
host=config.get("host", "localhost"),
port=config.get("port", 6379),
)
elif driver == "file":
return FileCache(path=config.get("path", "/tmp/cache"))
else:
return InMemoryCache()
Registering providers
app = Container()
app.instance("config", load_config())
app.register_provider(CacheServiceProvider)
app.register_provider(MailServiceProvider)
app.register_provider(QueueServiceProvider)
# register() is called on each immediately.
# boot() is called on all providers after all register() calls complete.
app.boot()
Provider lifecycle
app.register_provider(P)→ instantiatesP(app)and callsP.register().app.boot()→ callsboot()on every registered provider, in order.- After boot, the container is fully ready.
This two-phase lifecycle is critical: during register(), providers can only bind. During boot(), providers can safely resolve from the container because all bindings exist.
Driver Manager
For subsystems with swappable backends (cache, queue, mail, storage, etc.), use the DriverManager base class.
from lucid_container import DriverManager
class CacheManager(DriverManager):
"""Manages cache driver instantiation and switching."""
def create_redis_driver(self, config: dict):
return RedisCache(host=config["host"], port=config["port"])
def create_memory_driver(self, config: dict):
return InMemoryCache()
def create_file_driver(self, config: dict):
return FileCache(path=config["path"])
def get_default_driver(self) -> str:
return self.config.get("default", "memory")
How DriverManager works
- Subclass names driver creators as
create_{name}_driver(self, config). get_default_driver()returns the name of the default driver from config..driver(name=None)returns a cached driver instance.Noneuses the default..extend(name, creator)registers custom drivers at runtime.
# In a service provider:
def register(self):
self.app.singleton(CacheManager, lambda c: CacheManager(
container=c,
config=c.make("config").get("cache", {}),
))
self.app.bind(CacheContract, lambda c: c.make(CacheManager).driver())
self.app.alias("cache", CacheContract)
# Usage — swap drivers by config:
config = {"cache": {"default": "redis", "drivers": {"redis": {"host": "localhost", "port": 6379}}}}
# Or on the fly:
file_cache = app.make(CacheManager).driver("file")
# Third-party packages can extend:
app.make(CacheManager).extend("dynamodb", lambda config: DynamoDBCache(**config))
Real-World Examples
Web application bootstrap
from lucid_container import Container
app = Container()
# Config
app.instance("config", {
"cache": {"default": "redis", "drivers": {"redis": {"host": "localhost"}}},
"mail": {"default": "smtp", "drivers": {"smtp": {"host": "mail.example.com"}}},
"database": {"host": "localhost", "name": "myapp"},
})
# Register providers
app.register_provider(LoggingServiceProvider)
app.register_provider(DatabaseServiceProvider)
app.register_provider(CacheServiceProvider)
app.register_provider(MailServiceProvider)
app.boot()
# In your request handler — the entire tree resolves automatically
controller = app.make(UserController)
response = controller.handle(request)
Testing with swapped implementations
def test_order_processing():
app = Container()
# Bind test doubles
app.instance(CacheContract, InMemoryCache())
app.instance(MailerContract, FakeMailer())
app.instance(PaymentContract, FakePaymentGateway(always_succeeds=True))
# Everything downstream uses the fakes automatically
service = app.make(OrderService)
result = service.process(order)
assert result.status == "completed"
assert app.make(MailerContract).last_sent.subject == "Order Confirmed"
Contextual binding for multi-tenant
app.when(TenantAController).needs(DatabaseContract).give(
lambda c: PostgresDatabase(host="tenant-a.db.example.com")
)
app.when(TenantBController).needs(DatabaseContract).give(
lambda c: PostgresDatabase(host="tenant-b.db.example.com")
)
Architecture
Project Structure
lucid-container/
├── src/
│ └── lucid_container/
│ ├── __init__.py # Public API exports
│ ├── container.py # Container class
│ ├── service_provider.py # ServiceProvider base class
│ ├── driver_manager.py # DriverManager base class
│ ├── contextual_binding.py # ContextualBindingBuilder
│ ├── autowire.py # Autowiring logic (inspect + type hints)
│ └── exceptions.py # ResolutionError, BindingError
├── tests/
│ ├── __init__.py
│ ├── test_binding.py # bind, singleton, instance
│ ├── test_autowire.py # Autowiring from type hints
│ ├── test_aliases.py # Alias resolution
│ ├── test_contextual.py # Contextual bindings
│ ├── test_tagging.py # Tag and tagged()
│ ├── test_events.py # resolving / after_resolving hooks
│ ├── test_method_injection.py # container.call()
│ ├── test_service_provider.py # Provider lifecycle
│ ├── test_driver_manager.py # DriverManager pattern
│ ├── test_flush.py # flush() and re-binding
│ └── test_edge_cases.py # Circular deps, missing bindings, etc.
├── pyproject.toml
├── README.md
├── LICENSE
└── CHANGELOG.md
Implementation Notes
Container internals:
The container maintains five internal data structures:
self._bindings: dict[Any, Callable] # abstract → factory or class
self._singletons: dict[Any, Any] # abstract → cached instance (None = not yet resolved)
self._aliases: dict[str, Any] # string alias → abstract
self._tags: dict[str, list[Any]] # tag name → list of abstracts
self._contextual: dict[type, dict[type, Any]] # consumer → {abstract → concrete}
Autowiring algorithm:
def _autowire(self, concrete: type) -> Any:
hints = typing.get_type_hints(concrete.__init__)
sig = inspect.signature(concrete.__init__)
kwargs = {}
for name, param in sig.parameters.items():
if name == "self":
continue
hint = hints.get(name)
# 1. Check contextual bindings for the current build stack
# 2. If hint is bound in container → recursive make()
# 3. If param has a default → skip (use default)
# 4. Otherwise → raise ResolutionError
return concrete(**kwargs)
The container maintains a build stack (a list of types currently being constructed) to detect circular dependencies. If type A requires B and B requires A, the container raises CircularDependencyError with the full cycle path instead of infinite recursion.
Contextual binding resolution:
During autowiring, the container checks the build stack. If the class currently being built has a contextual override for the dependency being resolved, that override takes priority over the global binding.
Factory vs class binding:
- If
concreteis a callable but not a class → treated as a factory. Called withconcrete(container). - If
concreteis a class → autowired. The container inspects__init__and builds it. - This is determined by
inspect.isclass(concrete).
Thread safety:
The container is NOT thread-safe. In threaded applications, create one container at boot and treat it as read-only after boot(). If you need per-request scoping, create a child container (see scoped containers below).
Scoped containers (child containers):
request_container = app.scoped()
request_container.instance("request", current_request)
# Resolves "request" from child, everything else falls through to parent
controller = request_container.make(UserController)
A scoped container inherits all bindings from the parent but has its own singleton cache. Bindings added to the child don't affect the parent. This is how per-request scoping works.
Public API (what __init__.py exports)
from lucid_container.container import Container
from lucid_container.service_provider import ServiceProvider
from lucid_container.driver_manager import DriverManager
from lucid_container.exceptions import ResolutionError, BindingError, CircularDependencyError
__all__ = [
"Container",
"ServiceProvider",
"DriverManager",
"ResolutionError",
"BindingError",
"CircularDependencyError",
]
Exceptions
| Exception | When |
|---|---|
ResolutionError |
Cannot resolve an abstract — no binding, no autowiring possible. |
BindingError |
Invalid binding (e.g., binding to a non-callable). |
CircularDependencyError |
A depends on B depends on A. Includes the cycle path. |
pyproject.toml Specification
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "lucid-container"
version = "0.1.0"
description = "Dependency injection container with autowiring for Python."
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Your Name", email = "your@email.com" },
]
keywords = [
"dependency-injection", "ioc", "container", "di",
"autowiring", "service-provider", "inversion-of-control",
]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Typing :: Typed",
]
[project.urls]
Homepage = "https://github.com/yourname/lucid-container"
Documentation = "https://github.com/yourname/lucid-container#readme"
Repository = "https://github.com/yourname/lucid-container"
Issues = "https://github.com/yourname/lucid-container/issues"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.mypy]
strict = true
[project.optional-dependencies]
dev = ["pytest>=7.0", "mypy>=1.0", "ruff>=0.1"]
Test Cases to Implement
Binding
bind()registers a class,make()returns a new instance each timebind()with a factory callable, factory receives the containersingleton()returns the same instance every callinstance()stores and returns the exact object- Re-binding overwrites the previous binding
- Re-binding a singleton clears the cached instance
has()returns True for bound abstracts, False otherwisebound_to()returns the concrete or Noneflush()clears everything
Autowiring
- Class with no
__init__is instantiated directly - Class with typed dependencies resolves each from container
- Class with default values uses defaults for unbound params
- Class with mixed bound and default params resolves correctly
- Unbound param without default raises
ResolutionError - Recursive autowiring — A needs B, B needs C, all resolved
- Concrete class not explicitly bound is still autowired if dependencies are available
*argsand**kwargsin__init__are ignored- Parameters with
str,int,float,boolhints are not autowired (primitives)
Aliases
alias()maps string to abstractmake()with alias string resolves correctly- Chained aliases resolve (alias → alias → abstract)
has()returns True for aliases
Contextual Binding
.when(A).needs(X).give(Y)— when building A, X resolves to Y.when(B).needs(X).give(Z)— when building B, X resolves to Z- Without contextual binding, global binding applies
- Contextual binding with factory callable
- Contextual binding works through nested resolution (A needs B needs X)
Tagging
tag()associates abstracts with a tag nametagged()resolves all tagged abstractstagged()returns empty list for unknown tag- Multiple tags on the same abstract
Container Events
resolving()callback is called on resolution with (instance, container)resolving()return value replaces the instanceafter_resolving()is called after resolutionresolving_any()fires for every resolution- Multiple callbacks on the same abstract all fire, in order
Method Injection
call()inspects function type hints and injects from containercall()with explicit args merges with injected argscall()works with both functions and bound methods- Unresolvable params without explicit args raise
ResolutionError
Service Providers
register_provider()callsregister()immediatelyboot()callsboot()on all providers in order- Provider's
self.appis the container - Multiple providers register without conflicts
- Provider boot can resolve bindings from other providers
Driver Manager
- Subclass with
create_x_driver()methods resolves drivers by name driver()with no args usesget_default_driver()driver("x")returns cached instance on second callextend()registers custom driver creator- Unknown driver name raises
ResolutionError
Scoped Containers
scoped()returns a child container- Child resolves from parent bindings
- Child singletons are independent from parent singletons
- Bindings added to child don't leak to parent
- Parent changes after scoping don't affect existing child
Edge Cases
- Circular dependency detection with clear error message including the cycle path
- Binding an abstract to itself (concrete class is the abstract)
make()on a plain concrete class with no bindings (autowire directly)make()withstr,int, orNonehints doesn't try to autowire those- Empty container —
make()raisesResolutionError - Container as a dependency — if a param is typed
Container, inject the container itself - Provider
register()raising an exception doesn't break other providers __init__withselfonly (no params) resolves successfully
Part of the Lucid Ecosystem
Lucid Container is the second package in the Lucid ecosystem — and the backbone that every future package builds on.
Released:
lucid-pipeline— Clean, expressive pipelines for multi-step data processing.
Coming soon:
lucid-cache— Multi-driver cache with a unified API, wired through the container.lucid-config— Cascading configuration management.lucid-events— Event dispatcher with listeners and subscribers.
License
MIT License. See LICENSE for details.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file lucid_container-0.1.0.tar.gz.
File metadata
- Download URL: lucid_container-0.1.0.tar.gz
- Upload date:
- Size: 26.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.11.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fc14345b77dc321eac575bdeab2751de56aa39ea58114913c4f7f5a3b322bd4f
|
|
| MD5 |
bc3634dfe4253a8199161cf9324cd3c8
|
|
| BLAKE2b-256 |
503a007e4d002072756a358025a0b73035f1f8f9b97c9bf770521a7e4872feb5
|
File details
Details for the file lucid_container-0.1.0-py3-none-any.whl.
File metadata
- Download URL: lucid_container-0.1.0-py3-none-any.whl
- Upload date:
- Size: 16.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.11.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
35f8e16e0e961c490f0b3a21193d90bf792505af5aae6f7090309b5b9da284ac
|
|
| MD5 |
017069a2a06c6fee7612d75d2241a42a
|
|
| BLAKE2b-256 |
7b7c7efc67cc9b3c650de04b334fbd5f448810abd23a93ac5334a410b1d49411
|