Metadata-Version: 2.4
Name: sinj
Version: 1.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 `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..
```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. 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

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

```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] / 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. **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`) matches a registered protocol.
4. **Protocol group** — `param.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

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

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

```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` resolves each label / protocol once per container. 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.DependencyNotMappedError` — `register` was called with no label / protocol info, directly or via attributes.
- `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
```
