Scopes¶
While resolving dependency values and injecting them is a good starting point, it’s often insufficient on its own. We also need control over the lifecycle of the objects we create – when they are created, how long they persist, and when they are cleaned up. This is where scopes come in.
Scopes in Picodi determine the lifespan and caching behavior of dependency instances.
Assigning Scopes to Dependencies¶
You assign a scope to a dependency provider function using the
set_scope() decorator provided by the registry object.
from picodi import registry, SingletonScope, Provide, inject
@registry.set_scope(SingletonScope) # Assign SingletonScope here
def get_shared_resource():
print("Creating shared resource...")
# Imagine this is an expensive object like a DB connection pool
resource = {"id": "singleton_resource"}
try:
yield resource
finally:
print("Cleaning up shared resource...")
@inject
def use_resource_1(res=Provide(get_shared_resource)):
print(f"User 1 using resource: {res['id']}")
@inject
def use_resource_2(res=Provide(get_shared_resource)):
print(f"User 2 using resource: {res['id']}")
# --- Application Code ---
print("First use:")
use_resource_1()
print("\nSecond use:")
use_resource_2()
print("\nShutting down:")
# SingletonScope requires manual shutdown for cleanup
registry.shutdown()
Output:
First use:
Creating shared resource...
User 1 using resource: singleton_resource
Second use:
User 2 using resource: singleton_resource
Shutting down:
Cleaning up shared resource...
As you can see, “Creating shared resource…” happened only once. The same instance was reused.
Cleanup happened only at registry.shutdown().
If you remove the @registry.set_scope(SingletonScope) decorator,
Picodi will use the default NullScope,and the resource would be created and
cleaned up for each call to use_resource_1 and use_resource_2.
Built-in Scopes¶
Picodi comes with several built-in scopes:
NullScope (Default)¶
Class:
NullScopeBehavior: Creates a new instance every time the dependency is injected. No caching occurs.
Cleanup (Yield Dependencies): Runs immediately after the injecting function finishes.
Use Case: Suitable for very cheap-to-create dependencies or those that must be unique per injection. This is the default scope if none is specified via
@registry.set_scope.
SingletonScope¶
Class:
SingletonScopeBehavior: Creates a single instance the first time the dependency is requested. This instance is cached globally and reused for all subsequent requests for that dependency across the application.
Cleanup (Yield Dependencies): Runs only when
picodi.Registry.shutdown()is called (typically at application exit).Use Case: Ideal for expensive-to-create objects that should be shared globally, like configuration objects, database connection pools, or HTTP clients.
ContextVarScope¶
Class:
ContextVarScopeBehavior: Caches instances within a
contextvars.ContextVar. This means the instance’s lifetime is tied to the current context, making it suitable for scenarios like web requests in async frameworks or thread-local storage. A different context (e.g., a different web request or thread) will get its own instance.Cleanup (Yield Dependencies): Runs only when
picodi.Registry.shutdown()is called specifically for this scope (i.e.,registry.shutdown(scope_class=ContextVarScope)). This is often done at the end of a request or task.Use Case: Request-scoped dependencies in web applications (see Framework Integrations), thread-local dependencies.
Manual vs. Auto Scopes¶
Scopes in Picodi inherit from either ManualScope or AutoScope.
ManualScope (like
SingletonScope,ContextVarScope): Require explicit cleanup viashutdown(). Their instances persist until shutdown is called for their scope class (or all manual scopes if no class is specified).AutoScope (like
NullScope): Cleanup happens automatically after the root injection point finishes. You don’t need to callshutdownfor these.
Automatic Initialization (auto_init)¶
When setting a scope, especially a manual one like SingletonScope, you might want the dependency to be created
proactively when the application starts, rather than waiting for the first request.
You can achieve this using the auto_init=True parameter in @registry.set_scope.
from picodi import inject, registry, Provide, SingletonScope
@registry.set_scope(SingletonScope, auto_init=True) # Note auto_init
def get_eager_singleton():
print("Eager singleton created!")
return "I was created early"
# At application startup:
print("Calling registry.init()...")
registry.init() # This will initialize all 'auto_init=True' dependencies
print("registry.init() finished.")
# Later, when injected:
@inject
def use_eager(dep=Provide(get_eager_singleton)):
print(f"Using dependency: {dep}")
use_eager() # Will not print "Eager singleton created!" again
Output:
Calling registry.init()...
Eager singleton created!
registry.init() finished.
Using dependency: I was created early
Dependencies marked with auto_init=True will be initialized when picodi.Registry.init() is called.
You can also explicitly add dependencies to be initialized using picodi.Registry.add_for_init().
See Lifespan Management for more details on init and shutdown.
User-defined Scopes¶
You can create custom scopes by subclassing ManualScope or AutoScope and
implementing the required methods (get, set, enter, shutdown).
This allows for fine-grained control over dependency lifecycles if the built-in scopes don’t meet your specific needs.
You can use picodi.SingletonScope as a reference:
class SingletonScope(ManualScope):
"""
Singleton scope. Values cached for the lifetime of the application.
Dependencies closed only when user manually call :func:`shutdown_dependencies`.
"""
def __init__(self) -> None:
self._exit_stack = ExitStack()
self._store: dict[Hashable, Any] = {}
def get(self, key: Hashable, *, global_key: Hashable) -> Any: # noqa: ARG002
return self._store[key]
def set(
self,
key: Hashable,
value: Any,
*,
global_key: Hashable, # noqa: ARG002
) -> None:
self._store[key] = value
def enter(
self,
context_manager: AsyncContextManager | ContextManager,
*,
global_key: Hashable, # noqa: ARG002
) -> Awaitable:
return self._exit_stack.enter_context(context_manager)
def shutdown(
self, exc: BaseException | None = None, *, global_key: Hashable # noqa: ARG002
) -> Awaitable:
self._store.clear()
return self._exit_stack.close(exc)
Consult the Module Index for details on the Scope base classes.
Key Takeaways¶
Scopes control the lifecycle (creation, caching, cleanup) of dependency instances.
Use
@registry.set_scope(ScopeClass)to assign a scope to a dependency provider.NullScope(default): New instance per injection, immediate cleanup.SingletonScope: One instance globally, manual cleanup viaregistry.shutdown().ContextVarScope: Instance per context (request/thread), manual cleanup viaregistry.shutdown(scope_class=ContextVarScope).Use
auto_init=Truewith@registry.set_scopeand callregistry.init()for eager initialization.
Next, let’s explore how to replace dependencies at runtime using Overrides.