Metadata-Version: 2.4
Name: sinj
Version: 2.0.0
Summary: IoC container
Author-email: Marius Kavaliauskas <mariuskava+sinj@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://gitlab.com/mrsk/sinj
Project-URL: Issue Tracker, https://gitlab.com/mrsk/sinj/-/issues
Project-URL: Source Code, https://gitlab.com/mrsk/sinj
Keywords: ioc,inverse-of-control,injection,dependency-injection,dependencies-container
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE.txt
Dynamic: license-file


# 💉 sinj

`sinj` (**S**imple **Inj**ect) is yet another IoC container for Python. It keeps the dependency graph flat: every dependency is registered against a string **label**, a type (a `typing.Protocol` or any class), 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..
```python
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:

```python
# 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:

```python
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. All four keyword arguments are optional — every registration additionally binds the factory itself as a protocol (see *Self-protocol* below).

All four accept either a single value or a list.

### By label

```python
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:

```python
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.

```python
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

```python
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:

```python
class Consumer:
    inject_label = "consumer"

    def __init__(self, clock: Clock):  # resolved by annotation
        self.clock = clock

    # typing.Optional[Clock] and Clock | None are also supported
```

Neither the registration key nor the annotation has to be a `typing.Protocol` - any class works (see *Self-protocol*).

A single factory can be bound to multiple protocols: `con.register(Impl, protocol=[P1, P2])` — all of its keys resolve to the same instance.

### Self-protocol

Every registered factory is also bound to itself as a protocol — even when protocols, labels, or groups are given explicitly. A class needs nothing else:

```python
class Auth:
    def issue(self):
        return "token"


con.register(Auth)
con.build()

con.resolve(Auth).issue()  # -> "token"
```

For classes this makes type-based wiring universal: `con.register(SystemClock, protocol=Clock)` answers to both `Clock` and `SystemClock`, and `con.register(Db, label="db")` also makes `con.resolve(Db)` work. Consumers ask for a self-bound class by plain constructor annotation - no `typing.Protocol` needed:

```python
class Api:
    def __init__(self, auth: Auth):  # resolved by annotation
        self.auth = auth

    # typing.Optional[Auth] and Auth | None are also supported
```

Non-class callables self-bind too, with the callable object itself as the key - rarely useful, but it means no registration ever needs label or protocol info.

### By protocol group

Multiple implementations of the same protocol (or of any type), consumed as a list:

```python
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] / list[P | None] / list[P] | None 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. **Label** — `param.name` matches a registered label.
2. **Label group** — `param.name` matches a declared/populated label group.
3. **Protocol** — `param.annotation` (unwrapped from `Optional` / `X | None`) matches a registered protocol. The annotation can be any type — a `typing.Protocol` is not required.
4. **Protocol group** — `param.annotation` is `list[P]` (also `Optional[list[Optional[P]]]`, `list[P] | None`, `list[P | None]`) and `P` is a registered/declared protocol group (any type works as `P`).

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[...]` / `... | None` wrapped forms). 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

```python
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. Regardless of attributes, every factory is always also bound to itself as a protocol (see *Self-protocol*).

## Advanced patterns

`sinj` deliberately ships one lifetime: one instance per factory 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:

```python
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` instantiates each factory exactly once per container — every label / protocol / group bound to it resolves to the same shared instance. When a consumer needs a *new* instance each call, inject the factory instead of the instance:

```python
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:

```python
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:

```python
# 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:

```python
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.DependencyNotFoundError` — `build` (or `resolve`, under `lazy=True`) cannot find a non-optional dependency.
- `sinj.CircularDependencyError` — `build` (or `resolve`, under `lazy=True`) detects a cycle.
- `sinj.DependencyConflictError` — `register` collides with an existing label or protocol registration.
- `sinj.ContainerNotBuiltError` — `resolve` was called before `build`.

## Install

From [pypi.org](https://pypi.org/project/sinj/):

```bash
pip install sinj
```

From [source](https://gitlab.com/mrsk/sinj):

```bash
pip install git+https://gitlab.com/mrsk/sinj
```
