Dependencies Explained

In Picodi, the concept of a “dependency” is intentionally simple: a dependency provider is typically a Python function (or any callable) that returns a value or yields it. This function usually takes no required arguments, allowing Picodi to call it automatically when needed.

This topic delves into the different ways you can define these dependency providers.

Simple Dependency Functions

The most basic form of a dependency provider is a function that directly returns a value.

Synchronous Example:

def get_database_url() -> str:
    """Returns the connection string for the database."""
    return "postgresql://user:password@host:port/dbname"


def get_settings() -> dict:
    """Loads and returns application settings."""
    # In a real app, this might load from a file or environment variables
    return {"timeout": 30, "retries": 3}

Asynchronous Example:

Picodi also supports asynchronous dependency providers. These are defined using the async def syntax.

import asyncio


async def get_external_api_key() -> str:
    """Fetches an API key from a secure vault (simulated)."""
    print("Fetching API key...")
    await asyncio.sleep(0.1)  # Simulate I/O
    return "secret-api-key-12345"

These functions are ready to be used with Provide() within an inject()-decorated function.

Yield Dependencies (Resource Management)

Often, dependencies represent resources that need setup before use and cleanup afterward (e.g., database connections, file handles, network clients). Picodi handles this elegantly using generator functions with a single yield.

Picodi treats such generators like context managers:

  1. Setup: Code before yield runs when the dependency is first requested.

  2. Value: The value yielded is injected into the dependent function.

  3. Teardown: Code after yield (ideally in a finally block) runs after the dependent function finishes execution (or when the dependency’s scope dictates cleanup).

Synchronous Example:

import sqlite3


def get_db_cursor():
    """Provides a database cursor and ensures the connection is closed."""
    connection = sqlite3.connect(":memory:")
    print("DB Connection Opened")
    cursor = connection.cursor()
    try:
        yield cursor  # Provide the cursor
    finally:
        connection.close()
        print("DB Connection Closed")

Asynchronous Example:

import asyncio


class AsyncResource:  # Example async resource
    async def setup(self):
        print("Async Resource Setup")
        await asyncio.sleep(0.05)
        return self

    async def close(self):
        print("Async Resource Closed")
        await asyncio.sleep(0.05)

    async def do_work(self):
        print("Async Resource Working")


async def get_async_resource():
    """Provides an async resource with setup and teardown."""
    resource = AsyncResource()
    await resource.setup()
    try:
        yield resource
    finally:
        await resource.close()

These yield dependencies ensure resources are managed correctly within the scope of their usage. The exact timing of the teardown depends on the scope assigned to the dependency.

Dependencies Using Other Dependencies

Dependency provider functions can themselves use inject() and Provide() to depend on other dependencies. Picodi automatically resolves the entire dependency graph.

from picodi import Provide, inject


def get_base_url() -> str:
    return "https://config-service.com"


@inject  # get_api_config depends on get_base_url
def get_api_config(url: str = Provide(get_base_url)) -> dict:
    print(f"Fetching config from {url}")
    # Simulate fetching config based on the URL
    return {"key": "config-key", "timeout": 5}


# Another function can now depend on get_api_config
@inject
def use_config(config: dict = Provide(get_api_config)):
    api_key = config["key"]
    print(f"Using API key: {api_key}")
    return api_key


use_config()

Output:

Fetching config from https://config-service.com
Using API key: config-key

Picodi ensures get_base_url is resolved first, its result is passed to get_api_config, and then the result of get_api_config is available for injection elsewhere.

Injecting the Registry into a Dependency

In some advanced scenarios, a dependency provider might need access to the Picodi registry object itself, for example, to dynamically resolve other dependencies or interact with scopes.

Picodi supports this by automatically injecting the registry object if a dependency provider function declares a parameter named exactly registry without a default value.

from picodi import Provide, inject, registry as picodi_registry, Registry


def get_another_dependency() -> str:
    return "another_value"


# This dependency provider needs the registry
def get_dynamic_dependency(registry: Registry) -> str:
    # The 'registry' parameter will be automatically injected.
    # Note: Type hint 'Registry' is for clarity; injection relies on the name.
    print(f"Dynamic dependency received registry: {type(registry)}")
    # Example: use the registry to resolve another dependency
    # This is a simplified example; direct resolution like this inside a
    # provider is rare but demonstrates access.
    with registry.resolve(get_another_dependency) as resolved_value:
        return f"dynamic_value_based_on_{resolved_value}"


@inject
def use_dynamic_dependency(dynamic_dep: str = Provide(get_dynamic_dependency)):
    print(f"Service using: {dynamic_dep}")


use_dynamic_dependency()

Output:

Dynamic dependency received registry: <class 'picodi._registry.Registry'>
Service using: dynamic_value_based_on_another_value

Key points for injecting the registry:

  • The parameter must be named registry.

  • The parameter must not have a default value.

  • Type hints are ignored for this specific injection; only the name and lack of a default matter.

This feature provides flexibility for complex dependency creation logic but should be used judiciously.

Key Takeaways

  • A Picodi dependency provider is typically a zero-argument callable (often a function), unless it’s designed to receive the registry object.

  • Use regular functions for simple value dependencies (sync or async).

  • Use generator functions with a single yield for dependencies requiring setup/teardown (sync or async).

  • Dependencies can depend on other dependencies using @inject and Provide.

Next, let’s look at how these dependencies are actually provided to your code using Injection.