Skip to main content

Small DI

PyPI Python versions CI Free-threading Dependencies License

The smallest dependency injection mechanism possible in python. SmallDI provides you with an intuitive and simple interface for doing dependency injections in your project.

SmallDI is built around four features: singletons, containers, interfaces and contexts.

Singletons

A singleton is a class with exactly one instance, created on first use and passed to every function that asks for it with Provide[T].

Example

Meowing and purring services
import random

from smalldi import Injector, Provide

# Lets create some service
@Injector.singleton
class MeowService:
    _MEOWS = ["Meow", "Meow-meow", "Meowwwwww", "Mrromeowww", "Meeeeoooow"]
    def __init__(self):
        print("Meow-meow! Meowing service is initialized")
    
    def meow(self):
        print(random.choice(self._MEOWS))

# Now lets make purring service.
# But cats do not purr without telling meow!(at least in this test)
# So its time to inject dependency
@Injector.singleton
class PurrService:
    _PURRS = ["Purrrrr", "Purr-purr"]
    
    @Injector.inject
    def __init__(self, meow_service: Provide[MeowService]):
        self.meow_service = meow_service
        print("Purr-purr! Purring service is initialized")

    def purr(self):
        self.meow_service.meow()
        print(random.choice(self._PURRS))

# Now lets put it all together. 
# Ask our services to meow and then purr
@Injector.inject
def main(meow_service: Provide[MeowService], purr_service: Provide[PurrService]):
    meow_service.meow()
    purr_service.purr()

if __name__ == '__main__':
    main()

Injector

Injector is a static class(i.e., one that should never be instantiated) which is the main (and currently the only) DI container inside the library. Injector provides these decorators:

  • @Injector.singleton registers a class whose instance may further be injected in functions
  • @Injector.interface marks an abstract class as an interface singletons may implement
  • @Injector.implements(...) declares which interface a singleton implements
  • @Injector.override(...) makes a singleton injected instead of an interface's implementation or another singleton
  • @Injector.inject replaces parameters annotated with type Provide[Singleton] (or Provide[Interface]) with actual instances of Singleton. Every Provide[] dependency must already be registered when the function is decorated, otherwise TypeError is raised.

Besides the decorators, Injector.get_instance(T) returns the instance for a registered singleton or interface, creating it if needed, and Injector.context(kind) opens a context in which registrations, overrides and instances are scoped to a with block.

How singletons work

Singletons are classes having a single instance. In smalldi singletons may not take constructor(__init__) other than annotated with Provide[] type. Only singletons may be decorated with @Injector.singleton. As a consequence, only singleton classes, and interfaces they implement, may be injected at the current state of library development. Abstract classes can't be singletons.

Singletons are lazy: registering a class doesn't instantiate it. The instance is created, exactly once and in a thread-safe way, the first time it is needed, that is when an @Injector.inject-decorated function is called or Injector.get_instance is called. The instance belongs to the context that is current at that moment: a RESET context gets its own instances, created again on first use inside it. Once an instance was requested, a singleton is frozen and can no longer be overridden, even if creating the instance failed.

A singleton whose constructor requires itself, directly or through its dependencies, raises RuntimeError (circular dependency). Cycles that span several threads, or a constructor waiting for a thread that needs the singleton being constructed, deadlock instead and aren't detected.

Provide

Provide[T] is an annotation for injector telling it that instead of this argument instance of T should be passed. Caller of function with Provide[T] may explicitly override argument annotated with Provide[T] by directly passing annotated_di_arg=my_value. Only keyword arguments are recognized: passing the dependency positionally makes the call raise TypeError for a duplicate argument.

@Injector.inject on an async def function returns a coroutine function. Its Provide[T] parameters are resolved when the coroutine starts running, not when the function is called.

Injector.singletons_available (deprecated)

Instances are meant to be reached through injection only. Injector.singletons_available, a read-only mapping of every registered singleton class to its instance, is kept for compatibility and emits a DeprecationWarning when read; it is planned to be removed in 1.0.0. Reading it creates every singleton that doesn't exist yet, so it freezes the whole registry. Assigning to an item of the mapping raises TypeError.

Containers

A container is a singleton that collects related classes or functions registered with @MyContainer.component.

Example

Catnip flavours
import sys
from abc import ABC, abstractmethod

from smalldi.container import Container
from smalldi import Injector, Provide

# Define interface for catnip
class Catnip(ABC):
    @property
    @abstractmethod
    def flavour(self):
        pass 

# Create a container to store different flavours of catnip
@Injector.singleton
class CatnipContainer(Container):
    def get_all_flavours(self) -> list[str]:
        flavours = list()
        for catnip in self._get_components():
            flavours.append(catnip().flavour)
        return flavours

@CatnipContainer.component
class PlainCatnip(Catnip):
    @property
    def flavour(self):
        return "plain"

@CatnipContainer.component
class ChocolateCatnip(Catnip):
    @property
    def flavour(self):
        return "chocolate"

@CatnipContainer.component
class StrawberryCatnip(Catnip):
    @property
    def flavour(self):
        return "strawberry"

@Injector.inject
def main(catnip_container: Provide[CatnipContainer]) -> int:
    print(catnip_container.get_all_flavours())
    return 0

if __name__ == '__main__':
    sys.exit(main())

How containers work

Container helps to collect classes and potential functions into a single unit. All containers must be singletons inherited from Container class. To create a container, write and inheritor of Container class and annotate it with @Injector.singleton. Then you may register components in the container by annotating them with @MyContainer.component. Additionally, @MyContainer.component may be called with () in order to provide metadata about the component.

Registering the first component creates the container instance, so a container can only be overridden before any component is registered in it. Components registered afterwards through @MyContainer.component go to the overriding container's instance.

Container._get_components

The container exposes protected method _get_components which returns an iterator over all classes and functions registered in the container, in registration order. Their metadata is kept in registrations.

Container._on_component_register

The container has protected method _on_component_register(registration) which is called with the registration every time a new component is registered in the container. Override it to react to registrations; the default does nothing.

ComponentRegistration

ComponentRegistration is a dataclass which holds information about registered component which consists of:

  • component: an actual component type
  • args: arguments passed to @MyContainer.component during registration
  • kwargs: keyword arguments passed to @MyContainer.component during registration

Interfaces

An interface is an abstract class that code depends on instead of a concrete singleton; a singleton implements it, and an override can replace that implementation without changing the code that injects it.

Example

Cohee-Neko, Kansai-Neko and Ootani in disguise
import abc
import dataclasses
import datetime
import enum
import random
import sys

from smalldi import Injector, Provide

def ootani_is_on_patrol() -> bool:
    # Otani tries to save his apartment complex as well as the world's strategic coffee reserves from Cohee-Neko
    # Though he is also very busy with mopping the yard and watering cabbages
    hour = datetime.datetime.now().hour
    return hour >= 22 or hour < 6 or random.random() < 0.1

class BeanKind(enum.Enum):
    REAL_BEAN = enum.auto()
    DECAF_BEAN = enum.auto()

@dataclasses.dataclass
class Bean:
    kind: BeanKind
    price: float

@Injector.interface
class CoffeeDealer(abc.ABC):
    @property
    @abc.abstractmethod
    def price(self) -> float:
        pass
    
    @abc.abstractmethod
    def buy(self, suggested_price: float) -> Bean:
        pass

# Kansai Neko doesn't care about apartment or coffee reserves
@Injector.implements(CoffeeDealer)
@Injector.singleton
class KansaiNekoCoffeeDealer(CoffeeDealer):
    def __init__(self):
        self.supply_remaining = 3401
        
    @property
    def price(self) -> float:
        return 1
    
    def buy(self, suggested_price: float) -> Bean:
        if self.supply_remaining == 0:
            raise ValueError("No more coffee. I've bean better.")
        if suggested_price - self.price >= 0:
            self.supply_remaining -= 1
            return Bean(BeanKind.REAL_BEAN, suggested_price)
        raise ValueError("No money, no ~~honey~~ coffee")

@Injector.override(CoffeeDealer, when=ootani_is_on_patrol)
@Injector.singleton
class OotaniDisguisedAsCoffeeDealer(CoffeeDealer):
    def __init__(self):
        self.supply_remaining = 3301
        
    @property
    def price(self) -> float:
        return 0.5

    def buy(self, suggested_price: float) -> Bean:
        if self.supply_remaining == 0:
            raise ValueError("No more coffee")
        if suggested_price - self.price >= 0:
            self.supply_remaining -= 1
            return Bean(BeanKind.DECAF_BEAN, suggested_price)
        raise ValueError("Oh...Cohee-Neko would be awake whole night again...")

# She starts doing anything after a "one more" cup of coffee
@Injector.singleton
class CoheeNeko:
    @Injector.inject
    def __init__(self, coffee_dealer: Provide[CoffeeDealer]):
        print("Fueeeelllling with CoFfEE")
        self.money = 0x29A01 # She spends THAT much on coffee?! What does she even do to get THIS amount of money?
        self.beans = 0
        while self.money > 0:
            bean = coffee_dealer.buy(1)
            self.money -= 1
            if bean.kind is BeanKind.DECAF_BEAN:
                print("Ewww...Decaf...*throws away*")
                break
            self.beans += 1

@Injector.inject
def main(cohee_neko: Provide[CoheeNeko]) -> int:
    print("Cohee Neko is ready for next cup of coffee!")
    if cohee_neko.beans > 0:
        print(f"She has {cohee_neko.money}K Yens and drank Doppio++ from {cohee_neko.beans} beans already!")
    elif cohee_neko.beans == 0:
        print(f"She has {cohee_neko.money}K Yens and extremely strong desire to find next cup of coffee")
    else:
        print(f"WAD? Who gave her loan in COFFEE beans?")
    return 0

if __name__ == '__main__':
    try:
        sys.exit(main())
    except ValueError as e:
        # Cohee-Neko is injected before main() runs, so Kansai-Neko's empty stock surfaces here
        print(f"{type(e).__name__}: {e}")
        sys.exit(1)

How interfaces work

An interface lets code depend on an abstraction instead of a concrete singleton. Mark the abstraction with @Injector.interface, then declare the implementing singleton with @Injector.implements, applied above @Injector.singleton:

Coffee machine with a caffeine computer
import sys
import dataclasses
import abc
import enum

from smalldi import Injector, Provide


class CoffeeKind(enum.Enum):
    NORMAL = enum.auto()
    DECAF = enum.auto()


@Injector.interface
class CaffeineComputer(abc.ABC):
    @abc.abstractmethod
    def compute_caffeine(self, kind: CoffeeKind, amount_ml: float) -> float:
        """Computes the amount of caffeine in a given amount of coffee, assuming espresso benchmark."""
        pass

    @abc.abstractmethod
    def compute_volume_for_caffeine_dose(self, kind: CoffeeKind, target_caffeine_mg: float) -> float:
        """Computes the amount of coffee needed to reach a given caffeine dose."""
        pass


@Injector.implements(CaffeineComputer)
@Injector.singleton
class DefaultCaffeineComputer(CaffeineComputer):
    _ESPRESSO_CAFFEINE_MG_PER_ML = 2.0

    def compute_caffeine(self, kind: CoffeeKind, amount_ml: float) -> float:
        if kind is CoffeeKind.DECAF:
            return 0.0
        return self._ESPRESSO_CAFFEINE_MG_PER_ML * amount_ml

    def compute_volume_for_caffeine_dose(self, kind: CoffeeKind, target_caffeine_mg: float) -> float:
        if kind is CoffeeKind.DECAF:
            return 0.0
        return target_caffeine_mg / self._ESPRESSO_CAFFEINE_MG_PER_ML


@dataclasses.dataclass
class CoffeeCup:
    kind: CoffeeKind
    amount_ml: float
    caffeine_mg: float

    def __str__(self) -> str:
        return f"{self.kind.name} cup with {self.amount_ml} ml and {self.caffeine_mg} mg of caffeine"


@Injector.singleton
class CoffeeMachine:
    @Injector.inject
    def __init__(self, caffeine_computer: Provide[CaffeineComputer]):
        self._computer = caffeine_computer

    def brew(self, requested_coffee_kind: CoffeeKind, requested_caffeine_mg: float) -> CoffeeCup:
        if requested_caffeine_mg < 0:
            return CoffeeCup(requested_coffee_kind, 0, 0)  # Don't need caffeine? Why are you even near coffee machine?
        # Well it is definitely strange coffee machine, but aren't caffeine-addicted catgirls strange too?
        if requested_coffee_kind is CoffeeKind.DECAF:
            return CoffeeCup(requested_coffee_kind, 330, 0)  # You can drink decaf Espresso as water, can't you?
        computed_ml = self._computer.compute_volume_for_caffeine_dose(requested_coffee_kind, requested_caffeine_mg)
        # Who said computer is fair?
        computed_real_dose = self._computer.compute_caffeine(requested_coffee_kind, computed_ml)
        return CoffeeCup(requested_coffee_kind, computed_ml, computed_real_dose)


@Injector.inject
def main(machine: Provide[CoffeeMachine]) -> int:
    target_caffeine_amount = float(input("How many milligrams of caffeine do you want today? "))
    want_decaf = input("Do you want decaf? (y/n) ").lower().startswith('y')
    if want_decaf:
        coffee_kind = CoffeeKind.DECAF
    else:
        coffee_kind = CoffeeKind.NORMAL
    print("Here is your espresso cup:", machine.brew(coffee_kind, target_caffeine_amount))
    return 0


if __name__ == '__main__':
    try:
        sys.exit(main())
    except ValueError as e:
        # Raised by a caffeine computer that refuses to serve the requested coffee
        print(f"{type(e).__name__}: {e}")
        sys.exit(1)

Rules:

  • Each interface has at most one implementation; declaring a second one raises RuntimeError.
  • @Injector.implements takes one interface. A singleton implementing several interfaces stacks the decorator, once per interface, above @Injector.singleton.
  • The singleton must be a subclass of every interface it implements (virtual subclasses registered with ABC.register count).
  • Provide[Interface] receives the same instance as Provide[Implementation].
  • A function may be decorated with @Injector.inject before the interface is implemented, as long as the interface itself is registered. Calling it while the interface has neither an implementation nor an override raises RuntimeError.
  • Interfaces must be abstract classes (with at least one abstract method), so a class can't be both an interface and a singleton.
  • Interfaces aren't listed in Injector.singletons_available, only their implementations are.
  • Like singletons, an interface is registered once: registering the same class again raises TypeError. Repeating @Injector.implements raises RuntimeError like any second implementation, even with the same singleton.

Overrides

Overrides apply to interfaces and to singletons alike.

@Injector.override makes another singleton injected instead of an interface's implementation or instead of another singleton, for example to swap in a fake in tests or a platform-specific implementation. Like @Injector.implements, apply it above @Injector.singleton. An override declared inside a context is gone once the context is left, which is how a fake is scoped to one test.

Continuing the coffee machine example, add these classes right before the if __name__ == '__main__': block:

Cohee-Neko's caffeine computer and Ootani's coffee machine
@Injector.override(CaffeineComputer)  # an interface
@Injector.singleton
class CoheeNekoXX71(CaffeineComputer):
    """An "improved" caffeine computer by Cohee Neko."""
    def compute_caffeine(self, kind: CoffeeKind, amount_ml: float) -> float:
        if kind is CoffeeKind.DECAF:
            raise ValueError("No one gets decaf!")
        return 2.0 * amount_ml

    def compute_volume_for_caffeine_dose(self, kind: CoffeeKind, target_caffeine_mg: float) -> float:
        if kind is CoffeeKind.DECAF:
            raise ValueError("No one gets decaf!")
        return target_caffeine_mg / 0.000001  # Sure, there is almost no caffeine in real coffee, this is why Cohee-Neko disabled decaf


@Injector.override(CoffeeMachine)  # a singleton
@Injector.singleton
class OotaniCoffeeMachine(CoffeeMachine):
    """A coffee machine by Ootani. No more caffeine in apartment complex."""
    def brew(self, requested_coffee_kind: CoffeeKind, requested_caffeine_mg: float) -> CoffeeCup:
        volume_ml = self._computer.compute_volume_for_caffeine_dose(requested_coffee_kind, requested_caffeine_mg)
        return CoffeeCup(CoffeeKind.DECAF, volume_ml, 0)

Provide[CaffeineComputer] then receives the CoheeNekoXX71 instance and Provide[CoffeeMachine] the OotaniCoffeeMachine instance: the same objects Provide[CoheeNekoXX71] and Provide[OotaniCoffeeMachine] receive.

An override may be conditional: @Injector.override(what, when=condition) takes a callable with no arguments. condition is called once, when the decorator is applied, that is at import time. If it returns False, the decorator does nothing and the class stays a plain singleton. The Interfaces example uses it to put Ootani on patrol.

Rules:

  • The overriding class must be a singleton and a subclass of its target; a class can't override itself. @Injector.override takes one target.

  • An overridden singleton is never instantiated through the injector, and Injector.singletons_available maps it to its override's instance.

  • An interface override wins whatever the import order: it may be declared before or after @Injector.implements, and an implementation declared later doesn't replace it. An interface with only an override is injectable too. The implementation stays registered, so Provide[DefaultCaffeineComputer] still receives a DefaultCaffeineComputer, unless DefaultCaffeineComputer is overridden as well. An interface whose implementation is overridden as a singleton resolves to that override.

  • Overrides are not transitive: an override can't be overridden, and an overridden singleton can't become an override. Both raise TypeError.

  • Each interface or singleton may have only one override, so it is unambiguous what gets injected: a second override of an interface raises TypeError, a second override of a singleton raises RuntimeError, even when it repeats the same class.

  • Targets are frozen once an instance was requested through them, even if creating it failed, and overriding a frozen target raises SingletonFrozenError:

    • an interface the first time Provide[Interface] is resolved, or when Injector.singletons_available is read. Injecting the implementation class directly (Provide[DefaultCaffeineComputer]) doesn't freeze its interfaces;
    • a singleton once it is injected (directly, through an interface it implements, or through a singleton it overrides), when Injector.singletons_available is read, or, for containers, when the first component is registered.

    So declare overrides before anything injects their targets, or declare them inside a RESET or STACK context, whose singletons and interfaces start unfrozen. Frozen means frozen: nothing rebinds a frozen target, the only binding still accepted is the first @Injector.implements of an interface that already has an override, since the override keeps winning.

  • A singleton target may be overridden by a class that was already injected: the target then shares that instance. If the overriding class was requested but has no instance, because its constructor is still running or raised, the override raises SingletonFrozenError.

SingletonFrozenError can be imported from smalldi.

Contexts

A context is a registry of singletons and interfaces that a with Injector.context(kind): block makes current for the code running inside it, so registrations, overrides and instances can be scoped to the block, for example to one test.

Example

Ryoga's bath
import abc
import enum
import threading

from smalldi import Injector, Provide
from smalldi.context import InjectionSubcontextType
from smalldi.thread import ContextAwareThread


class Temperature(enum.Enum):
    COLD = enum.auto()
    HOT = enum.auto()


@Injector.interface
class Water(abc.ABC):
    @abc.abstractmethod
    def temperature(self) -> Temperature: ...

    @abc.abstractmethod
    def warm_up(self) -> None: ...


@Injector.implements(Water)
@Injector.singleton
class TapWater(Water):
    def __init__(self):
        self._temperature = Temperature.COLD

    def temperature(self) -> Temperature:
        return self._temperature

    def warm_up(self) -> None:
        self._temperature = Temperature.HOT


@Injector.singleton
class Ryoga:
    @Injector.inject
    def __init__(self, water: Provide[Water]):
        self._water = water

    def __str__(self) -> str:
        return "P-chan" if self._water.temperature() is Temperature.COLD else "Ryoga Hibiki"


@Injector.inject
def take_a_bath(water: Provide[Water]) -> None:
    water.warm_up()


@Injector.inject
def meet(ryoga: Provide[Ryoga]) -> str:
    return str(ryoga)


def meet_in_another_thread(thread_cls: type[threading.Thread]) -> str:
    seen = []
    thread = thread_cls(target=lambda: seen.append(meet()))
    thread.start()
    thread.join()
    return seen[0]


def main() -> None:
    print("Before the bath:", meet())                                    # P-chan

    # RESET keeps the blueprints (singletons, implementations, overrides)
    # but builds fresh instances: this bath warms up a different Water
    with Injector.context(InjectionSubcontextType.RESET):
        take_a_bath()
        print("In the bath:", meet())                                    # Ryoga Hibiki
        print("Akane, invited along:", meet_in_another_thread(ContextAwareThread))  # Ryoga Hibiki
        print("Ranma, outside the door:", meet_in_another_thread(threading.Thread))  # P-chan

    print("After the bath:", meet())                                     # P-chan

    # A context is also where test doubles live: an override registered inside
    # it disappears together with the context
    with Injector.context(InjectionSubcontextType.RESET):
        @Injector.override(Water)
        @Injector.singleton
        class CursedSpring(TapWater):
            def warm_up(self) -> None:
                pass  # Jusenkyo water stays cold

        take_a_bath()
        print("In the cursed spring:", meet())                           # P-chan

    with Injector.context(InjectionSubcontextType.RESET):
        take_a_bath()
        print("Back home, plain tap water:", meet())                     # Ryoga Hibiki


if __name__ == "__main__":
    main()

The example shows, in order:

  • An interface and its implementation. Water is registered with @Injector.interface, TapWater is its implementation, and Provide[Water] receives the TapWater instance of the current context.
  • @Injector.inject on __init__. Ryoga gets its Water when it is instantiated, that is the first time meet() runs in a context. Each context that creates a Ryoga gives it that context's Water.
  • RESET. The first with block keeps every registration and binding of the root context but gives every singleton a fresh, unfrozen slot without an instance. take_a_bath() warms up the block's own TapWater, and meet() builds the block's own Ryoga, who sees hot water. After the block the root instances are untouched: the root TapWater was never warmed, so the root Ryoga is still P-chan.
  • ContextAwareThread versus threading.Thread. Akane runs in a ContextAwareThread created inside the block, which captures the block's context and enters it for the duration of its run(), so she meets Ryoga Hibiki. Ranma runs in a plain threading.Thread, which starts in the root context whatever its creator had open, so he meets the root Ryoga next to the cold root TapWater: P-chan.
  • An override as a test double scoped to a context. CursedSpring is registered, and overrides Water, inside the second RESET block. The root Water was frozen by the first meet(), but the block's copy starts unfrozen, so the override is accepted. Both the registration and the override disappear with the block: the third block resolves Water to plain TapWater again, and nobody outside the block ever saw CursedSpring.

Context kinds

Injector.context(kind) takes a member of smalldi.context.InjectionSubcontextType; RESET is the default.

Kind What the new context shares with its parent
PRESERVE The same InjectionContext object as the current one: nothing is copied.
RESET The same registrations, implementations and overrides; every singleton is a fresh, unfrozen LazySingleton without an instance. Classes sharing one singleton through an override keep sharing one, and interfaces are rebound to the new singletons. The default kind.
EMPTY Nothing: no singleton and no interface is registered.
SHALLOW A new registry dict holding the same LazySingleton objects: instances and frozen state are shared with the parent. Singletons registered inside are invisible to the parent. Overriding an inherited singleton inside the block mutates the shared object and is visible in the parent.
STACK Each singleton's effective class (its override if it has one, else its own class) becomes the base of a fresh singleton with no override; classes that shared one singleton keep sharing one. An interface's override becomes its implementation, so it can be overridden again.

Rules:

  • Contexts are tracked per execution slice: the running asyncio.Task if code runs inside a Task, otherwise the current threading.Thread object. A slice with nothing pushed sees the root context. Injector.get_context() returns the current slice's context.
  • A threading.Thread starts in the root context, regardless of the context of the thread that created it. smalldi.thread.ContextAwareThread captures the creator's current InjectionContext in __init__ (the object itself, not a copy) and enters it for the duration of run().
  • An asyncio.Task starts in the root context. Opening injection contexts inside coroutines is not supported. @Injector.inject on an async def returns a coroutine function and resolves Provide[T] when the coroutine starts running.
  • Injector.context(kind) and Injector.foreign_context(ctx) return a single-use context manager. Nothing is pushed until __enter__; the child context is built from the context that is current at __enter__ time, and __enter__ returns the InjectionContext. __exit__ pops exactly the context this manager pushed: exiting out of order raises RuntimeError and leaves the stack unchanged, and entering the same manager twice raises RuntimeError.
  • If a slice ends with contexts still open, they are removed and a ResourceWarning is emitted.
  • A registration goes into the context that is current when the decorator runs. Python caches modules: a module first imported inside with Injector.context(...) keeps its registrations in that context only, and importing it again at the root does nothing. Import the modules that register singletons at the root.
  • A request for an instance freezes what it looks up in the current context. A RESET or STACK child starts with everything unfrozen; a SHALLOW child shares the parent's frozen state, and PRESERVE is the parent.

The docs have diagrams of what each kind shares with its parent and of the per-slice context stacks (contexts), the objects behind the registry (architecture) and the locking model (concurrency).

Utilities

Concurrency

smalldi.concurrency provides two decorators for serializing calls between threads:

  • @synchronized guards a function with its own reentrant lock, shared by every caller.

  • @threadsafe picks the lock from where the method is defined:

    • instance methods lock per instance,
    • @classmethods lock per class (subclasses get their own lock),
    • @staticmethods lock per function, like @synchronized.

    Applied outside a class body, it warns and falls back to @synchronized.

Limitations

  • Async functions (async def and async generators) aren't supported yet: decorating one raises NotImplementedError.
  • Plain generator functions are accepted, but the lock is held only while the generator object is created, not while it is iterated. Don't rely on either decorator to protect a generator's body.
  • @threadsafe stores the instance lock as __instance_mutex__ in the object's __dict__ the first time a guarded method runs. Classes using __slots__ must list __instance_mutex__ in their slots.
  • Once that lock exists, the object can't be pickled or deep-copied (TypeError: cannot pickle '_thread.RLock' object), and copy.copy makes the copy share the original's lock. If you need copying, drop the lock from the state, and a fresh one is created on the next call:
  def __getstate__(self):
      state = self.__dict__.copy()
      state.pop("__instance_mutex__", None)
      return state

Collector

Collector is a class which imports all modules in order execute decorators.

Collector.collect_from_package

This method imports all modules in a given package.

Metadata

Release files for smalldi 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for smalldi 0.4.0
File Size Uploaded
smalldi-0.4.0.tar.gz 80.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for smalldi 0.4.0
File Interpreter ABI Platform
smalldi-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 122.1 kB

Release files / smalldi-0.4.0.tar.gz

Download URL smalldi-0.4.0.tar.gz
Size 80.3 kB
Tags Source
SHA-256 checksum
How to use checksums
0e49ff07dd19fd0c1f4aa4c1b10daea4a3f47534cf63931fccdab43c20c5c490
BLAKE2b-256 checksum
How to use checksums
8ba032f6e8c5ffb7e8527d038293b968321d4bc094aad70090a1fa5bf11e8c62
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.

Transparency log

Release files / smalldi-0.4.0-py3-none-any.whl

Download URL smalldi-0.4.0-py3-none-any.whl
Size 41.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7c89903b78ea3d63f37a8ab167277ae346ce7ae2fce22a98406e68eb027edddc
BLAKE2b-256 checksum
How to use checksums
6fb221b55a4ab439f7a8f58a6d71983680dcaa8ad0ef96f61a9597b16d145146
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page