Skip to main content

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.

PyPI version Python 3.10+ License: MIT


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:

  1. Check aliases — if abstract is a string alias, resolve to the canonical abstract.
  2. Check singleton cache — if already resolved as a singleton, return cached instance.
  3. Check bindings — if a binding exists, use it.
  4. Autowire — if abstract is a concrete class (not an ABC), attempt to autowire it directly.
  5. Raise ResolutionError if 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:

  1. Inspects __init__ using inspect.signature() and typing.get_type_hints().
  2. For each parameter with a type annotation that's bound in the container, recursively calls make().
  3. For parameters with default values and no binding, uses the default.
  4. 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

  1. app.register_provider(P) → instantiates P(app) and calls P.register().
  2. app.boot() → calls boot() on every registered provider, in order.
  3. 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. None uses 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 concrete is a callable but not a class → treated as a factory. Called with concrete(container).
  • If concrete is 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 time
  • bind() with a factory callable, factory receives the container
  • singleton() returns the same instance every call
  • instance() 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 otherwise
  • bound_to() returns the concrete or None
  • flush() 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
  • *args and **kwargs in __init__ are ignored
  • Parameters with str, int, float, bool hints are not autowired (primitives)

Aliases

  • alias() maps string to abstract
  • make() 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 name
  • tagged() resolves all tagged abstracts
  • tagged() 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 instance
  • after_resolving() is called after resolution
  • resolving_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 container
  • call() with explicit args merges with injected args
  • call() works with both functions and bound methods
  • Unresolvable params without explicit args raise ResolutionError

Service Providers

  • register_provider() calls register() immediately
  • boot() calls boot() on all providers in order
  • Provider's self.app is 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 uses get_default_driver()
  • driver("x") returns cached instance on second call
  • extend() 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() with str, int, or None hints doesn't try to autowire those
  • Empty container — make() raises ResolutionError
  • 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__ with self only (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

lucid_container-0.1.1.tar.gz (26.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

lucid_container-0.1.1-py3-none-any.whl (16.5 kB view details)

Uploaded Python 3

File details

Details for the file lucid_container-0.1.1.tar.gz.

File metadata

  • Download URL: lucid_container-0.1.1.tar.gz
  • Upload date:
  • Size: 26.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.11.11

File hashes

Hashes for lucid_container-0.1.1.tar.gz
Algorithm Hash digest
SHA256 96be03d6d7817e0b069c942a4a6b64107e8ff0b9a1258e563e9c4e94d43dd6e5
MD5 3e11bc21b3402a45e5ccfd90bb72404e
BLAKE2b-256 a86988eab4352bcde671f58603f51e812387b1e31ca9dd50d77bba3fae3673ed

See more details on using hashes here.

File details

Details for the file lucid_container-0.1.1-py3-none-any.whl.

File metadata

File hashes

Hashes for lucid_container-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 b80944f85b1f0fd50995b9e51727578eaab18cf86a8f461f064666b200761296
MD5 ae008c81634c5341815e21dfce90a73e
BLAKE2b-256 f77c0dfb13278cd702986dc785a376ed9f8c3143ba6c0a52310ae7dd841c4aa0

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page