A lightweight dependency inversion/IoC container for Python
Project description
type-ioc
A lightweight IoC/DI container for Python that wires dependencies automatically using type hints — no decorators, no config files, no magic strings.
from ioc import Resolver
class Database:
def __init__(self, url: str = "sqlite://"):
self.url = url
class UserRepository:
def __init__(self, db: Database):
self.db = db
class UserService:
def __init__(self, repo: UserRepository):
self.repo = repo
resolver = Resolver()
service = resolver(UserService)
# UserService → UserRepository → Database, all wired automatically
Installation
pip install type-ioc
uv add type-ioc
Basic resolution
Call the resolver with any class and it recursively resolves the full dependency graph via __init__ type hints.
resolver = Resolver()
service = resolver(UserService)
Each call creates a new instance unless the type is registered as a singleton. The resolver itself is always available:
resolver(Resolver) is resolver # True
Singletons
Register a class as a singleton — the resolver creates the instance and caches it:
db = resolver.singleton(Database)
resolver(Database) is db # True
Register an existing instance:
db = Database(url="postgresql://localhost/mydb")
resolver.singleton(Database, db)
Infer the class from the instance:
resolver.singleton(db) # same as resolver.singleton(Database, db)
Singleton ABC
Inherit from Singleton to make a class register itself automatically on first resolution:
from ioc import Singleton
class AppConfig(Singleton):
def __init__(self):
self.debug = False
first = resolver(AppConfig)
second = resolver(AppConfig)
first is second # True — auto-registered after first call
Factory bindings
bind() registers a factory that is called fresh on every resolution:
resolver.bind(Database) # creates a new Database each time
resolver(Database) is resolver(Database) # False
Supply a custom factory:
resolver.bind(Database, lambda: Database(url=os.environ["DB_URL"]))
Bind an abstract class or interface to a concrete implementation:
import abc
class Storage(abc.ABC):
@abc.abstractmethod
def save(self, data: bytes) -> None: ...
class DiskStorage(Storage):
def save(self, data: bytes) -> None: ...
resolver.bind(Storage, DiskStorage)
result = resolver(Storage) # returns a DiskStorage instance
Annotated / qualified bindings
Use Annotated to register multiple bindings for the same base type:
from typing import Annotated
class _ReadKey: pass
class _WriteKey: pass
ReadDB = Annotated[Database, _ReadKey]
WriteDB = Annotated[Database, _WriteKey]
resolver.singleton(ReadDB, Database(url="postgresql://read-replica/mydb"))
resolver.singleton(WriteDB, Database(url="postgresql://primary/mydb"))
Any class whose __init__ uses ReadDB or WriteDB as type hints will receive the correct instance automatically:
class ReportingService:
def __init__(self, db: ReadDB):
self.db = db
class WriteService:
def __init__(self, db: WriteDB):
self.db = db
resolver(ReportingService).db is resolver(ReadDB) # True
resolver(WriteService).db is resolver(WriteDB) # True
bind() and singleton() both accept Annotated keys.
Optional dependencies
Parameters typed as T | None or Optional[T] resolve to None when the type isn't registered, rather than raising an error:
class Service:
def __init__(self, cache: Cache | None = None):
self.cache = cache
resolver(Service).cache # None — Cache not registered, no error
resolver.singleton(Cache)
resolver(Service).cache # Cache instance — now it's registered
Passing arguments explicitly
Override any parameter by passing positional or keyword arguments directly to the resolver call. Positional arguments are matched by type:
db = Database(url="postgresql://localhost/test")
repo = resolver(UserRepository, db) # positional, matched by type
repo = resolver(UserRepository, db=db) # keyword
Primitive parameters that have no default must be provided explicitly:
class Paginator:
def __init__(self, page: int, page_size: int = 20):
...
resolver(Paginator, page=1)
resolver(Paginator, 1) # also works — matched by type
Contextual bindings
Use when/needs/give to control how a specific consumer resolves one of its dependencies, without affecting any other consumer.
Override with a factory or class
class PhotoController:
def __init__(self, storage: Storage):
self.storage = storage
class LocalStorage(Storage):
def save(self, data: bytes) -> None: ...
# Give PhotoController a LocalStorage specifically
resolver.when(PhotoController).needs(Storage).give(LocalStorage)
# Or supply a factory
resolver.when(PhotoController).needs(Storage).give(lambda: LocalStorage())
Pass specific kwargs to a dependency's constructor
class Cache:
def __init__(self, ttl: int = 3600, max_entries: int = 100):
...
class SessionService:
def __init__(self, cache: Cache):
self.cache = cache
class ReportService:
def __init__(self, cache: Cache):
self.cache = cache
# Each service gets a Cache with different settings
resolver.when(SessionService).needs(Cache).give(ttl=300, max_entries=500)
resolver.when(ReportService).needs(Cache).give(ttl=7200)
Multiple .give() calls on the same needs() accumulate; last write wins on conflicts:
resolver.when(SessionService).needs(Cache).give(ttl=300)
resolver.when(SessionService).needs(Cache).give(max_entries=500)
# SessionService gets Cache(ttl=300, max_entries=500)
Override class and pass kwargs together
class LruCache(Cache):
def __init__(self, ttl: int = 3600, max_entries: int = 100, policy: str = "lru"):
...
resolver.when(ReportService).needs(Cache).give(LruCache, ttl=7200, max_entries=50)
Global defaults
when(X).give(**kwargs) with no needs() sets default constructor arguments for every resolution of X, regardless of which consumer triggers it:
resolver.when(Cache).give(ttl=600)
resolver(Cache).ttl # 600
resolver(SessionService).cache.ttl # 600
resolver(ReportService).cache.ttl # 600
Contextual needs rules take priority over global defaults:
resolver.when(Cache).give(ttl=600)
resolver.when(ReportService).needs(Cache).give(ttl=7200)
resolver(SessionService).cache.ttl # 600 — global default
resolver(ReportService).cache.ttl # 7200 — contextual override
Annotated types as resolution profiles
You can use an Annotated type as a named resolution profile. When a consumer's dependency is redirected to an Annotated type, any when(AnnotatedType) rules apply during that resolution — enabling nested contextual configuration:
_ReportCacheKey = object()
ReportCache = Annotated[Cache, _ReportCacheKey]
# Set defaults for the ReportCache profile
resolver.when(ReportCache).give(ttl=7200, max_entries=1000)
# Consumers can type-hint ReportCache directly
class ReportService:
def __init__(self, cache: ReportCache):
...
# Or redirect another consumer to use the ReportCache profile
resolver.when(AnalyticsService).needs(Cache).give(ReportCache)
Global resolver
For applications that want a process-wide default resolver:
resolver = Resolver.get() # creates on first call, returns same instance thereafter
Resolver.reset() # discards it; next .get() creates a fresh one
Cloning
Create a child resolver that inherits specific singleton bindings from a parent. Pass as many types as you want to keep:
parent = Resolver()
parent.singleton(Database, prod_db)
parent.singleton(AppConfig, config)
parent.singleton(UserRepository, repo)
# Inherit any subset of singletons; anything not listed must be re-registered or re-resolved
child = parent.clone(Database, UserRepository)
child.singleton(AppConfig, test_config)
clone() with no arguments copies all current singleton bindings:
child = parent.clone()
Factory bindings are never copied — they belong to the resolver that registered them.
Clearing bindings
Remove a specific binding:
resolver.clear(Database)
Reset everything (singletons and factories) back to a blank state:
resolver.clear()
The resolver always keeps itself registered after a clear.
Circular dependency detection
Circular dependencies are detected at resolution time and raise CircularDependency:
from ioc import CircularDependency
try:
resolver(ServiceA)
except CircularDependency as e:
print(e.chain) # [ServiceA, ServiceB, ServiceA]
print(e.type) # ServiceA
The resolver remains fully usable after a CircularDependency is raised.
Exception reference
All exceptions inherit from ResolutionFailure.
| Exception | Raised when |
|---|---|
CircularDependency |
A dependency cycle is detected. Has .type and .chain. |
DuplicateArgOfSameType |
Two positional arguments of the same type are passed. Has .duplicate_type and .arguments. |
InvalidBinding |
An instance or class fails its type check during singleton() or bind(). Has .expected_type and .instance. |
UnboundTypeRequested |
An Annotated type is used as a dependency but has no registered binding. Has .type. |
UnknownArgument |
A positional argument can't be matched to any constructor parameter. Has .argument_type and .argument. |
UnknownKeywordArgument |
A keyword argument doesn't match any constructor parameter. Has .argument_name, .argument_type, and .argument. |
UnresolvablePrimitive |
A builtin type (int, str, etc.) is encountered with no default and no explicit value provided. Has .type. |
License
MIT
Project details
Release history Release notifications | RSS feed
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 type_ioc-0.1.0.tar.gz.
File metadata
- Download URL: type_ioc-0.1.0.tar.gz
- Upload date:
- Size: 25.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d52bccf39a10bcf6ed20bf515aba0b9a054345a4f92ffc3966702695ba4fb20e
|
|
| MD5 |
0e74b17946acc4b92415db2215236d82
|
|
| BLAKE2b-256 |
b522d5fe9d9bf748335649010169656a5f4b59af4dadc68dde60ffd908d6a540
|
File details
Details for the file type_ioc-0.1.0-py3-none-any.whl.
File metadata
- Download URL: type_ioc-0.1.0-py3-none-any.whl
- Upload date:
- Size: 8.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7111c1d99094abc3c797d95b8aa63cf395ab26aa959cf54088565c00726d40b1
|
|
| MD5 |
f60578f56ec07314e896e355bbfd8fb1
|
|
| BLAKE2b-256 |
78e36de93e71a1fc7966622728552065fc1c03accd247e1a6e958373e80235d9
|