Quick Start#

Installation#

You can install hazrakah from PyPI through the usual means, such as pip:

pip install hazrakah

Usage#

Core lifetimes — Transient, Singleton, Instance#

hazrakah manages object lifecycles through three registration strategies:

from hazrakah import Container

container = Container()

# TRANSIENT — a new instance for every resolve.
container.register_transient(IFoo, Foo)
assert container.resolve(IFoo) is not container.resolve(IFoo)

# SINGLETON — one shared instance across all resolves in scope.
container.register_singleton(IFooBar, lambda c: c.resolve(FooBarImpl))
assert container.resolve(IFooBar) is container.resolve(IFooBar)

# INSTANCE — your exact object, returned everywhere (including child scopes).
bar_obj = Bar()
container.register_instance(IBar, bar_obj)
assert container.resolve(IBar) is bar_obj

Hierarchical Scopes#

Scopes provide isolation: parent registrations flow down, but child-only registrations stay local.

parent = Container()
child = parent.create_scope()

parent.register_transient(IFoo, Foo)
child.resolve(IFoo)          # resolves parent's registration

child.register_transient(IBar, Bar)
child.resolve(IBar)          # works — registered in this scope

Context Management#

Resolve tracked resources and get deterministic teardown when the scope exits.

class Closeable:
    def __init__(self): self.closed = False
    def close(self): self.closed = True

with Container() as c:
    c.register_transient(Closeable)
    res = c.resolve(Closeable)

assert res.closed               # teardown ran automatically on __exit__

Fluent Chaining#

All registration methods return self, enabling method-chained container setup.

container = (
    Container()
    .register_transient(IFoo, Foo)
    .register_singleton(IBar, Bar)
    .register_instance(IFizz, Fizz())
)

assert isinstance(container.resolve(IFoo), Foo)
assert isinstance(container.resolve(IBar), Bar)

Lifetime Decorators#

Mark intent at class-definition time with @singleton, @transient, or @instanced, then register everything in one call.

from hazrakah import Container, singleton, transient, instanced

@singleton(types=IFoo)
class FooService: ...

@transient(types=IBar)
class BarService: ...

c = Container()
c.register_decorated()            # discovers all decorated classes

assert c.resolve(IFoo) is c.resolve(IFoo)     # singleton
assert c.resolve(IBar) is not c.resolve(IBar)  # transient

Implicit multi-Registration#

Declare which interfaces a class implements; registration binds to all of them simultaneously.

from hazrakah import Container, provides

@provides(IFoo, IBar)
class MultiImpl:
    def foo(self): ...
    def bar(self): ...

c = Container()
c.register_singleton(MultiImpl)    # also registers under IFoo and IBar

a = c.resolve(IFoo)
b = c.resolve(IBar)
assert a is b                       # shared cache across all provided interfaces

How @provides Works#

The @provides decorator is a passive marker – it stores metadata only, with zero registration logic at decoration time. Activation depends entirely on how the container later registers the decorated class.

@provides activates when you call register_singleton, register_transient, or register_instance with no second argument (no explicit type override):

@provides(IFoo, IBar)
class MultiImpl: ...

c.register_singleton(MultiImpl)  # multi-registers under IFoo + IBar + MultiImpl
c.resolve(IFoo)                  # works -- @provides activated
c.resolve(IBar)                  # works -- @provides activated

@provides does NOT activate when you provide an explicit type argument to a registration method:

@provides(IBar)
class MultiImpl: ...

c.register_singleton(IFoo, MultiImpl)  # only IFoo is registered
c.resolve(IFoo)                        # works -- explicit registration
c.resolve(IBar)                        # raises KeyError -- @provides was ignored

This is intentional. The second positional argument on any register_* method is the explicit type override. When you provide it, you are telling the container exactly which key to register against – and @provides does not interfere.

Self-Resolve#

By default, hazrakah lets a hazrakah.Container resolve itself when asked for one of its own interface types — DependencyRegistry, DependencyResolver, or ScopedDependencyResolver — even if nothing is explicitly registered. This makes the container conveniently available as a dependency without manual registration:

from hazrakah import Container, DependencyResolver

class MyService:
    def __init__(self, resolver: DependencyResolver) -> None:
        self.resolver = resolver   # the container is injected automatically

c = Container()
service = c.resolve(MyService)
assert service.resolver is c     # same container instance

You can disable this behaviour by passing self_resolve=False to the constructor. With self-resolve disabled, resolving for a DI interface behaves exactly like any other unregistered type — it raises ResolutionError unless you provide an explicit registration:

from hazrakah import Container, DependencyResolver, ResolutionError

c = Container(self_resolve=False)

# Raises ResolutionError -- no explicit registration for DependencyResolver
try:
    c.resolve(DependencyResolver)  # type: ignore[arg-type]
except ResolutionError:
    pass   # expected

If you need a custom DependencyResolver (or any of the other interfaces), register it explicitly — explicit registrations take precedence regardless of the self_resolve setting:

from hazrakah import Container, DependencyResolver

class MyCustomResolver(DependencyRegistry):
    def resolve(self, t) -> Any: ...
    def is_registered(self, t) -> bool: ...
    def register_instance(self, t, instance): ...
    def register_singleton(self, t, target=None): ...
    def register_transient(self, t, target=None): ...

custom = MyCustomResolver()
c = Container(self_resolve=False)
c.register_instance(DependencyResolver, custom)   # type: ignore[arg-type]

resolved = c.resolve(DependencyResolver)  # type: ignore[arg-type]
assert resolved is custom                 # explicit registration wins

Caching with Cached[T]#

The Cached[T] generic type wraps a factory callable so its result is produced once and re-used until the TTL window elapses. The factory receives a resolver as its first argument, matching hazrakah’s standard factory contract (see hazrakah.DependencyRegistry.Factory). It can be combined with any container lifetime to add time-bound caching on top of dependency injection.

from datetime import timedelta
from hazrakah import Cached

class ConfigSource:
    def load(self) -> str:
        return 'loaded'

# TTL accepts float (seconds) or timedelta; default is 47.0 seconds.
cache = Cached(lambda c: ConfigSource(), ttl=timedelta(seconds=47))

first = cache(object())   # factory called once (TTL not yet elapsed)
second = cache(object())  # cached value returned; factory not re-invoked
assert first is second     # same instance

Manual expiration and zero-TTL modes are also supported:

always_miss = Cached(lambda c: ConfigSource(), ttl=timedelta(seconds=0))
assert always_miss(object()) is not always_miss(object())  # factory called each time

cache = Cached(lambda c: ConfigSource())
cache.reset()                          # discard cached value
fresh = cache(object())                # re-invokes factory

ttl also accepts a plain float (seconds), exposed as a timedelta property:

cache = Cached(lambda c: ConfigSource(), ttl=120.0)  # 120 seconds
assert cache.ttl == timedelta(seconds=120)