Skip to main content

A lightweight dependency inversion/IoC container for Python

Project description

type-ioc

A lightweight IoC/DI container for Python that wires dependencies automatically using type hints — no decorators, no config files, no magic strings.

from ioc import Resolver

class Database:
    def __init__(self, url: str = "sqlite://"):
        self.url = url

class UserRepository:
    def __init__(self, db: Database):
        self.db = db

class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo

resolver = Resolver()
service = resolver(UserService)
# UserService → UserRepository → Database, all wired automatically

Installation

pip install type-ioc
uv add type-ioc

Basic resolution

Call the resolver with any class and it recursively resolves the full dependency graph via __init__ type hints.

resolver = Resolver()
service = resolver(UserService)

Each call creates a new instance unless the type is registered as a singleton. The resolver itself is always available:

resolver(Resolver) is resolver  # True

Singletons

Register a class as a singleton — the resolver creates the instance and caches it:

db = resolver.singleton(Database)
resolver(Database) is db  # True

Register an existing instance:

db = Database(url="postgresql://localhost/mydb")
resolver.singleton(Database, db)

Infer the class from the instance:

resolver.singleton(db)  # same as resolver.singleton(Database, db)

Singleton ABC

Inherit from Singleton to make a class register itself automatically on first resolution:

from ioc import Singleton

class AppConfig(Singleton):
    def __init__(self):
        self.debug = False

first = resolver(AppConfig)
second = resolver(AppConfig)
first is second  # True — auto-registered after first call

Factory bindings

bind() registers a factory that is called fresh on every resolution:

resolver.bind(Database)  # creates a new Database each time
resolver(Database) is resolver(Database)  # False

Supply a custom factory:

resolver.bind(Database, lambda: Database(url=os.environ["DB_URL"]))

Bind an abstract class or interface to a concrete implementation:

import abc

class Storage(abc.ABC):
    @abc.abstractmethod
    def save(self, data: bytes) -> None: ...

class DiskStorage(Storage):
    def save(self, data: bytes) -> None: ...

resolver.bind(Storage, DiskStorage)
result = resolver(Storage)  # returns a DiskStorage instance

Annotated / qualified bindings

Use Annotated to register multiple bindings for the same base type:

from typing import Annotated

class _ReadKey: pass
class _WriteKey: pass

ReadDB = Annotated[Database, _ReadKey]
WriteDB = Annotated[Database, _WriteKey]

resolver.singleton(ReadDB, Database(url="postgresql://read-replica/mydb"))
resolver.singleton(WriteDB, Database(url="postgresql://primary/mydb"))

Any class whose __init__ uses ReadDB or WriteDB as type hints will receive the correct instance automatically:

class ReportingService:
    def __init__(self, db: ReadDB):
        self.db = db

class WriteService:
    def __init__(self, db: WriteDB):
        self.db = db

resolver(ReportingService).db is resolver(ReadDB)  # True
resolver(WriteService).db is resolver(WriteDB)     # True

bind() and singleton() both accept Annotated keys.

Optional dependencies

Parameters typed as T | None or Optional[T] resolve to None when the type isn't registered, rather than raising an error:

class Service:
    def __init__(self, cache: Cache | None = None):
        self.cache = cache

resolver(Service).cache  # None — Cache not registered, no error
resolver.singleton(Cache)
resolver(Service).cache  # Cache instance — now it's registered

Passing arguments explicitly

Override any parameter by passing positional or keyword arguments directly to the resolver call. Positional arguments are matched by type:

db = Database(url="postgresql://localhost/test")
repo = resolver(UserRepository, db)         # positional, matched by type
repo = resolver(UserRepository, db=db)      # keyword

Primitive parameters that have no default must be provided explicitly:

class Paginator:
    def __init__(self, page: int, page_size: int = 20):
        ...

resolver(Paginator, page=1)
resolver(Paginator, 1)        # also works — matched by type

Contextual bindings

Use when/needs/give to control how a specific consumer resolves one of its dependencies, without affecting any other consumer.

Override with a factory or class

class PhotoController:
    def __init__(self, storage: Storage):
        self.storage = storage

class LocalStorage(Storage):
    def save(self, data: bytes) -> None: ...

# Give PhotoController a LocalStorage specifically
resolver.when(PhotoController).needs(Storage).give(LocalStorage)

# Or supply a factory
resolver.when(PhotoController).needs(Storage).give(lambda: LocalStorage())

Pass specific kwargs to a dependency's constructor

class Cache:
    def __init__(self, ttl: int = 3600, max_entries: int = 100):
        ...

class SessionService:
    def __init__(self, cache: Cache):
        self.cache = cache

class ReportService:
    def __init__(self, cache: Cache):
        self.cache = cache

# Each service gets a Cache with different settings
resolver.when(SessionService).needs(Cache).give(ttl=300, max_entries=500)
resolver.when(ReportService).needs(Cache).give(ttl=7200)

Multiple .give() calls on the same needs() accumulate; last write wins on conflicts:

resolver.when(SessionService).needs(Cache).give(ttl=300)
resolver.when(SessionService).needs(Cache).give(max_entries=500)
# SessionService gets Cache(ttl=300, max_entries=500)

Override class and pass kwargs together

class LruCache(Cache):
    def __init__(self, ttl: int = 3600, max_entries: int = 100, policy: str = "lru"):
        ...

resolver.when(ReportService).needs(Cache).give(LruCache, ttl=7200, max_entries=50)

Global defaults

when(X).give(**kwargs) with no needs() sets default constructor arguments for every resolution of X, regardless of which consumer triggers it:

resolver.when(Cache).give(ttl=600)

resolver(Cache).ttl                       # 600
resolver(SessionService).cache.ttl       # 600
resolver(ReportService).cache.ttl        # 600

Contextual needs rules take priority over global defaults:

resolver.when(Cache).give(ttl=600)
resolver.when(ReportService).needs(Cache).give(ttl=7200)

resolver(SessionService).cache.ttl   # 600 — global default
resolver(ReportService).cache.ttl    # 7200 — contextual override

Annotated types as resolution profiles

You can use an Annotated type as a named resolution profile. When a consumer's dependency is redirected to an Annotated type, any when(AnnotatedType) rules apply during that resolution — enabling nested contextual configuration:

_ReportCacheKey = object()
ReportCache = Annotated[Cache, _ReportCacheKey]

# Set defaults for the ReportCache profile
resolver.when(ReportCache).give(ttl=7200, max_entries=1000)

# Consumers can type-hint ReportCache directly
class ReportService:
    def __init__(self, cache: ReportCache):
        ...

# Or redirect another consumer to use the ReportCache profile
resolver.when(AnalyticsService).needs(Cache).give(ReportCache)

Global resolver

For applications that want a process-wide default resolver:

resolver = Resolver.get()   # creates on first call, returns same instance thereafter
Resolver.reset()            # discards it; next .get() creates a fresh one

Cloning

Create a child resolver that inherits specific singleton bindings from a parent. Pass as many types as you want to keep:

parent = Resolver()
parent.singleton(Database, prod_db)
parent.singleton(AppConfig, config)
parent.singleton(UserRepository, repo)

# Inherit any subset of singletons; anything not listed must be re-registered or re-resolved
child = parent.clone(Database, UserRepository)
child.singleton(AppConfig, test_config)

clone() with no arguments copies all current singleton bindings:

child = parent.clone()

Factory bindings are never copied — they belong to the resolver that registered them.

Clearing bindings

Remove a specific binding:

resolver.clear(Database)

Reset everything (singletons and factories) back to a blank state:

resolver.clear()

The resolver always keeps itself registered after a clear.

Circular dependency detection

Circular dependencies are detected at resolution time and raise CircularDependency:

from ioc import CircularDependency

try:
    resolver(ServiceA)
except CircularDependency as e:
    print(e.chain)   # [ServiceA, ServiceB, ServiceA]
    print(e.type)    # ServiceA

The resolver remains fully usable after a CircularDependency is raised.

Exception reference

All exceptions inherit from ResolutionFailure.

Exception Raised when
CircularDependency A dependency cycle is detected. Has .type and .chain.
DuplicateArgOfSameType Two positional arguments of the same type are passed. Has .duplicate_type and .arguments.
InvalidBinding An instance or class fails its type check during singleton() or bind(). Has .expected_type and .instance.
UnboundTypeRequested An Annotated type is used as a dependency but has no registered binding. Has .type.
UnknownArgument A positional argument can't be matched to any constructor parameter. Has .argument_type and .argument.
UnknownKeywordArgument A keyword argument doesn't match any constructor parameter. Has .argument_name, .argument_type, and .argument.
UnresolvablePrimitive A builtin type (int, str, etc.) is encountered with no default and no explicit value provided. Has .type.

License

MIT

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

type_ioc-0.1.0.tar.gz (25.6 kB view details)

Uploaded Source

Built Distribution

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

type_ioc-0.1.0-py3-none-any.whl (8.9 kB view details)

Uploaded Python 3

File details

Details for the file type_ioc-0.1.0.tar.gz.

File metadata

  • Download URL: type_ioc-0.1.0.tar.gz
  • Upload date:
  • Size: 25.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for type_ioc-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d52bccf39a10bcf6ed20bf515aba0b9a054345a4f92ffc3966702695ba4fb20e
MD5 0e74b17946acc4b92415db2215236d82
BLAKE2b-256 b522d5fe9d9bf748335649010169656a5f4b59af4dadc68dde60ffd908d6a540

See more details on using hashes here.

File details

Details for the file type_ioc-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: type_ioc-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 8.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.7 {"installer":{"name":"uv","version":"0.11.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for type_ioc-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7111c1d99094abc3c797d95b8aa63cf395ab26aa959cf54088565c00726d40b1
MD5 f60578f56ec07314e896e355bbfd8fb1
BLAKE2b-256 78e36de93e71a1fc7966622728552065fc1c03accd247e1a6e958373e80235d9

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