Skip to main content

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:

  1. Register factories (classes or callables) against labels / protocols.
  2. Build the container — by default every registered factory is instantiated once, dependencies are wired, and cycles are detected. Pass lazy=True to defer instantiation until the first resolve.
  3. 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:

  1. Labelparam.name matches a registered label.
  2. Label groupparam.name matches a declared/populated label group.
  3. Protocolparam.annotation (unwrapped from Optional) matches a registered protocol.
  4. Protocol groupparam.annotation is list[P] (also Optional[list[Optional[P]]]) and P is 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_group ensures the group exists even when no members are registered (resolves to []).
  • cached_only=True on 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_pool into cache and let cache.init() await db_pool.init()init should be idempotent) over relying on declaration order across modules.
  • For sync teardown, drop the async and use a plain Disposable group with dispose(self). Wrap in contextlib.ExitStack if you want __exit__-style guarantees.

Errors

  • sinj.DependencyNotFoundErrorbuild (or resolve, under lazy=True) cannot find a non-optional dependency.
  • sinj.CircularDependencyErrorbuild (or resolve, under lazy=True) detects a cycle.
  • sinj.DependencyConflictErrorregister collides with an existing label or protocol registration.
  • sinj.DependencyNotMappedErrorregister was called with no label / protocol info, directly or via attributes.
  • sinj.ContainerNotBuiltErrorresolve was called before build.

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

sinj-1.0.0.tar.gz (14.9 kB view details)

Uploaded Source

Built Distribution

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

sinj-1.0.0-py3-none-any.whl (10.2 kB view details)

Uploaded Python 3

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

Hashes for sinj-1.0.0.tar.gz
Algorithm Hash digest
SHA256 1c0066f2b48ba0c5168503d5e95973625768fbcd3c9099a4e1b72a3563e148cd
MD5 2f4d2097861c88e701c3806a5a4965d0
BLAKE2b-256 12520d03b4e600d398bde34272f2fa51ba0e867e05c61c1cb4492c7c29c1599d

See more details on using hashes here.

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

Hashes for sinj-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ba5f38a681cff117bfe766316fcf10e7311484c8e0f7a75d918e9c30d8679434
MD5 f8257891b96e5b69bce61f036b31a46e
BLAKE2b-256 06840e42076dacb475d9f7674c1b78231d7c98e6f3f61a7bb0c0b0501b31f1d4

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