IoC container
Project description
💉 sinj
sinj (Simple Inject) is yet another IoC container for Python. It keeps the dependency graph flat: every dependency is registered against a string label, a typing.Protocol, or a group of either — and resolved by constructor parameter name or annotation.
Two-phase lifecycle:
- Register factories (classes or callables) against labels / protocols.
- Build the container — by default every registered factory is instantiated once, dependencies are wired, and cycles are detected. Pass
lazy=Trueto defer instantiation until the firstresolve. - Resolve instances by label or by protocol.
Why
Take these classes as an example..
class Config:
...
class Metrics:
def __init__(self, config): ...
class Db:
def __init__(self, config, metrics): ...
class Cache:
def __init__(self, config, metrics): ...
class UserRepo:
def __init__(self, db, cache): ...
class AuthService:
def __init__(self, users, config, metrics): ...
class Mailer:
def __init__(self, config, metrics): ...
class ApiHandler:
def __init__(self, auth, users, mailer, metrics): ...
def serve(self): ...
Real apps grow trees of services. Wiring them by hand turns main() into a topological sort you maintain in your head:
# Without container the assembly is order-sensitive and every new
# dependency ripples up through the construction site.
config = Config()
metrics = Metrics(config)
db = Db(config, metrics)
cache = Cache(config, metrics)
users = UserRepo(db, cache)
auth = AuthService(users, config, metrics)
mailer = Mailer(config, metrics)
api = ApiHandler(auth, users, mailer, metrics)
api.serve()
Add a metrics argument to UserRepo and you edit the call site. Re-order the lines and you get a NameError. Swap Db for FakeDb in a test and you re-type the whole tree.
With sinj, each class declares what it is and asks for its dependencies by parameter name. The container figures out the order:
import sinj
con = sinj.Container()
con.register(Config, label="config")
con.register(ApiHandler, label="api")
con.register(Db, label="db")
con.register(Metrics, label="metrics")
con.register(Cache, label="cache")
con.register(UserRepo, label="users")
con.register(Mailer, label="mailer")
con.register(AuthService, label="auth")
con.build()
con.resolve("api").serve()
Adding a new dependency now means one new __init__ parameter and one new register(...) line. Order doesn't matter. Cycles are detected at build() time.
Registering
Container.register(factory, /, *, protocol=None, protocol_group=None, label=None, label_group=None) is the single registration entry point. factory is a class or any callable. At least one of the four keyword arguments must be provided (directly or via an attribute on factory).
All four accept either a single value or a list.
By label
con.register(SomeClass, label="some")
con.register(lambda: my_instance, label="some") # register a pre-built instance
Or set inject_label on the class and let register pick it up:
class SomeClass:
inject_label = "some"
con.register(SomeClass)
By label group
A label group is a set of factories addressable by one name. Resolving the group name returns a list of instances. A consumer asks for it by parameter name.
class Plugin1:
inject_label = "plugin1"
inject_label_group = "plugins"
class Plugin2:
inject_label = "plugin2"
inject_label_group = ["plugins"] # list form also accepted
class Host:
inject_label = "host"
def __init__(self, plugins): # parameter name == group name
self.plugins = plugins # -> list[Plugin1 | Plugin2]
Use con.declare_label_group("plugins") if you want the group to exist even when no members are registered (resolves to []).
By protocol
import typing
class Clock(typing.Protocol):
def now(self) -> str: ...
class SystemClock:
inject_protocol = Clock # or pass protocol=Clock to register()
def now(self): return "now"
con.register(SystemClock)
con.build()
con.resolve(Clock).now() # -> "now"
Consumers receive the implementation through a constructor annotation:
class Consumer:
inject_label = "consumer"
def __init__(self, clock: Clock): # resolved by annotation
self.clock = clock
# typing.Optional[Clock] is also supported
A single factory can be bound to multiple protocols: con.register(Impl, protocol=[P1, P2]).
By protocol group
Multiple implementations of the same protocol, consumed as a list:
class Plugin(typing.Protocol):
def name(self) -> str: ...
class P1:
inject_protocol_group = Plugin
def name(self): return "p1"
class P2:
inject_protocol_group = [Plugin]
def name(self): return "p2"
class Host:
inject_label = "host"
def __init__(self, plugins: list[Plugin]): # list[P] / Optional[List[Optional[P]]] all work
self.plugins = plugins
Resolve the group directly with con.resolve(list[Plugin]), or use con.declare_protocol_group(Plugin) to allow an empty group.
Resolution rules
For each constructor parameter, sinj tries in this order:
- Label —
param.namematches a registered label. - Label group —
param.namematches a declared/populated label group. - Protocol —
param.annotation(unwrapped fromOptional) matches a registered protocol. - Protocol group —
param.annotationislist[P](alsoOptional[list[Optional[P]]]) andPis a registered/declared protocol group.
If nothing matches and the parameter has a default, the default is used. Otherwise DependencyNotFoundError is raised.
Container.resolve(key, raise_if_missing=True, cached_only=False) accepts a label, a protocol, or list[protocol] (also Optional[list[Optional[protocol]]]). Returns None (or raises) when nothing is registered for key. Must be called after build(), otherwise ContainerNotBuiltError is raised.
Pass cached_only=True to read what's already been instantiated without triggering any factory calls. Useful under lazy=True (and for disposal — see below). When the key is registered but nothing has been resolved yet, singletons return None and groups return []. raise_if_missing still applies when the key isn't registered at all.
Build modes
con.build() # eager (default): instantiate everything now
con.build(lazy=True) # lazy: defer instantiation until first resolve()
Eager build is the safer default: every factory is invoked once during build(), so missing dependencies and circular dependencies are reported immediately.
Lazy build only marks the container as ready; instances are created on demand and cached on first resolve(). Useful when a process only needs part of the graph (e.g. CLI subcommands), or when some factories have side effects you want to defer. Trade-off: CircularDependencyError and DependencyNotFoundError for an unreached subgraph won't surface until something actually resolves into it.
build() is idempotent — calling it more than once is a no-op.
Thread safety
Container is safe for concurrent use: register, declare_*, build, and resolve are guarded by an internal lock. Under lazy=True, this means each factory is invoked exactly once even when multiple threads race on the same resolve(). Factories themselves are called while the lock is held, so they should not block on a lock that another thread might be holding while waiting on the container.
Attribute conventions
When a kwarg is omitted, register() falls back to these attributes on factory:
| kwarg | attribute |
|---|---|
protocol |
inject_protocol |
protocol_group |
inject_protocol_group |
label |
inject_label |
label_group |
inject_label_group |
Each attribute may be a single value or a list.
Advanced patterns
sinj deliberately ships one lifetime: one instance per container, created at build() (or on first resolve() under lazy=True). Anything else — request scopes, transient instances, test overrides — is expressed by composing containers, not by extra API surface.
Scoped lifetimes via child containers
For per-request / per-task scopes, build a long-lived "root" container for app-wide singletons and a fresh child container per scope. The child re-registers the singletons it needs as instance factories, adds scope-local registrations, then builds:
root = sinj.Container()
root.register(Db, label="db")
root.register(Config, label="config")
root.build()
def handle_request(req):
scope = sinj.Container()
scope.register(lambda: root.resolve("db"), label="db")
scope.register(lambda: root.resolve("config"), label="config")
scope.register(lambda: req, label="request")
scope.register(RequestHandler, label="handler")
scope.build(lazy=True)
return scope.resolve("handler").run()
# scope goes out of scope; GC handles cleanup
Each handle_request call gets a fresh RequestHandler while Db / Config stay shared.
Transient instances via factory injection
sinj resolves each label / protocol once per container. When a consumer needs a new instance each call, inject the factory instead of the instance:
class WorkerFactory:
inject_label = "worker_factory"
def __init__(self, db):
self._db = db
def __call__(self):
return Worker(self._db)
class Dispatcher:
inject_label = "dispatcher"
def __init__(self, worker_factory):
self._make_worker = worker_factory
def dispatch(self, job):
self._make_worker().run(job) # new Worker per call
Testing with overrides
Build a dedicated container per test (or per fixture) with fakes registered under the same labels / protocols as production:
def test_handler():
con = sinj.Container()
con.register(FakeDb, label="db")
con.register(InMemoryConfig, label="config")
con.register(RequestHandler, label="handler")
con.build()
assert con.resolve("handler").run() == ...
No override API is needed — registration is the override mechanism.
Modular registration
Keep the composition root flat by splitting registration across modules:
# storage.py
def register(con):
con.register(Db, label="db")
con.register(Cache, label="cache")
# web.py
def register(con):
con.register(Router, label="router")
con.register(RequestHandler, label="handler")
# main.py
con = sinj.Container()
storage.register(con)
web.register(con)
con.build()
Async init & disposal
sinj is sync — build() only wires references and never awaits. Async initialization and teardown are component-level concerns, expressed as protocol groups and driven by the caller. The container stays out of the async coloring problem.
A symmetric AsyncInit / Disposable pair covers most app lifecycles:
class AsyncInit(typing.Protocol):
async def init(self) -> None: ...
class Disposable(typing.Protocol):
async def dispose(self) -> None: ...
class DbPool:
inject_label = "db_pool"
inject_protocol_group = [AsyncInit, Disposable]
async def init(self): ...
async def dispose(self): ...
class ApiClient:
inject_label = "api_client"
inject_protocol_group = [AsyncInit, Disposable]
async def init(self): ...
async def dispose(self): ...
async def main():
con = sinj.Container()
con.declare_protocol_group(AsyncInit)
con.declare_protocol_group(Disposable)
con.register(DbPool)
con.register(ApiClient)
con.build(lazy=True)
for c in con.resolve(list[AsyncInit]):
await c.init()
try:
await run_app(con)
finally:
for c in con.resolve(list[Disposable], cached_only=True):
await c.dispose()
Notes:
declare_protocol_groupensures the group exists even when no members are registered (resolves to[]).cached_only=Trueon the teardown side avoids instantiating components that were never used just to dispose them. Init pulls the full group, since the whole point is to bring it up.- If a component genuinely depends on another being initialized first, prefer expressing that dependency directly (inject
db_poolintocacheand letcache.init()await db_pool.init()—initshould be idempotent) over relying on declaration order across modules. - For sync teardown, drop the
asyncand use a plainDisposablegroup withdispose(self). Wrap incontextlib.ExitStackif you want__exit__-style guarantees.
Errors
sinj.DependencyNotFoundError—build(orresolve, underlazy=True) cannot find a non-optional dependency.sinj.CircularDependencyError—build(orresolve, underlazy=True) detects a cycle.sinj.DependencyConflictError—registercollides with an existing label or protocol registration.sinj.DependencyNotMappedError—registerwas called with no label / protocol info, directly or via attributes.sinj.ContainerNotBuiltError—resolvewas called beforebuild.
Install
From pypi.org:
pip install sinj
From source:
pip install git+https://gitlab.com/mrsk/sinj
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 sinj-1.0.0.tar.gz.
File metadata
- Download URL: sinj-1.0.0.tar.gz
- Upload date:
- Size: 14.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1c0066f2b48ba0c5168503d5e95973625768fbcd3c9099a4e1b72a3563e148cd
|
|
| MD5 |
2f4d2097861c88e701c3806a5a4965d0
|
|
| BLAKE2b-256 |
12520d03b4e600d398bde34272f2fa51ba0e867e05c61c1cb4492c7c29c1599d
|
File details
Details for the file sinj-1.0.0-py3-none-any.whl.
File metadata
- Download URL: sinj-1.0.0-py3-none-any.whl
- Upload date:
- Size: 10.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ba5f38a681cff117bfe766316fcf10e7311484c8e0f7a75d918e9c30d8679434
|
|
| MD5 |
f8257891b96e5b69bce61f036b31a46e
|
|
| BLAKE2b-256 |
06840e42076dacb475d9f7674c1b78231d7c98e6f3f61a7bb0c0b0501b31f1d4
|