Internal Architecture Deep-Dive¶
This document describes the internal "How-it-works" of pico-ioc, intended for contributors and architects. It details the core components and algorithms that power the container.
1. Core Components¶
The framework is built from a few key internal components, all orchestrated by the PicoContainer.
┌─────────────────────────────────────────────────────┐
│ PicoContainer (Facade) │
├─────────────────────────────────────────────────────┤
│ ComponentFactory │ ScopedCaches │ ScopeManager │
├─────────────────────────────────────────────────────┤
│ ComponentLocator (Metadata) │ Registrar │
├─────────────────────────────────────────────────────┤
│ ConfigResolver │ ObjectGraphBuilder │
├─────────────────────────────────────────────────────┤
│ UnifiedComponentProxy (AOP/Lazy) │
└─────────────────────────────────────────────────────┘
PicoContainer: The public API and facade. It orchestrates get/aget calls, manages scope and container contexts, and holds references to the other components.Registrar: A short-lived object used only during init(). It scans modules, discovers all components (including @component, @factory, @provides, and @configured), evaluates conditional logic, selects the final providers, populates the ComponentFactory, and builds the ComponentLocator's metadata.ComponentFactory: A simple dictionary that stores the final "recipe" (a Provider callable, often a DeferredProvider) for creating each component.ComponentLocator: An queryable index of all final component metadata (ProviderMetadata). This is used for introspection and to resolve type-based or Qualifier-based list injections.ConfigResolver/ObjectGraphBuilder: The engine for the @configured feature. Based on the ContextConfig provided to init(), ConfigResolver loads, merges, and interpolates configuration sources (e.g., YAML/JSON/ENV) into a final structure. ObjectGraphBuilder then maps parts of this structure onto @configured dataclass graphs, handling both flat and tree mappings.ScopeManager/ScopedCaches: Manages the lifecycle and storage of component instances. ScopeManager handles which scope is active (using contextvars), and ScopedCaches provides the storage for that scope (e.g., the singleton cache vs. a request-scope cache), including LRU eviction for context-aware scopes.UnifiedComponentProxy: The dynamic proxy class used to implement lazy=True loading and AOP (@intercepted_by).
sequenceDiagram
participant User
participant Container
participant Factory
participant Caches
participant Proxy
participant Dependencies
User->>Container: get(ServiceA)
Container->>Caches: Check cache for ServiceA (scope-aware)
alt Cache Miss
Container->>Factory: Get provider for ServiceA
Factory->>Dependencies: (via DeferredProvider/Builder) Analyze ServiceA dependencies (e.g., ServiceB)
Dependencies->>Container: get(ServiceB) # Recursive call
Container->>Caches: Check cache for ServiceB
alt Cache Miss B
Container->>Factory: Get provider for ServiceB
Factory->>Dependencies: Analyze ServiceB dependencies
Dependencies-->>Factory: Build ServiceB instance
Factory-->>Container: ServiceB instance
Container->>Proxy: Wrap ServiceB if needed (lazy/AOP)?
Proxy-->>Container: Potentially proxied ServiceB
Container->>Caches: Store ServiceB
else Cache Hit B
Container-->>Dependencies: Cached ServiceB instance
end
Dependencies-->>Factory: Build ServiceA instance with ServiceB
Factory-->>Container: ServiceA instance
Container->>Proxy: Wrap ServiceA if needed (lazy/AOP)?
Proxy-->>Container: Potentially proxied ServiceA
Container->>Caches: Store ServiceA
Container-->>User: ServiceA instance
else Cache Hit
Container-->>User: Cached ServiceA instance
end
2. The Initialization Flow (init())¶
When you call init(), the Registrar executes the following sequence:
- Prepare Config: The ConfigurationManager is initialized with the ContextConfig passed to init().
- Scan: _iter_input_modules walks packages (if given) to find all modules for scanning.
- Discover: Registrar.scan_module (via ComponentScanner) inspects every member of every module for decorators (@component, @factory, @provides, @configured). Conditional logic (conditional_*) is evaluated here based on profiles and environ. Components that fail their condition are discarded. Discovered providers are queued as candidates. @configured classes are registered via the ConfigurationManager.
- Select Providers: ProviderSelector.select_providers iterates through candidates for each key. It selects the "best" one based on rank (e.g., respecting primary, and for @configured components, checking if their configuration prefix exists).
- Bind Winners: The winning provider for each key is "bound" into the ComponentFactory. ProviderMetadata is stored.
- Promote Scopes: _promote_scopes runs to prevent scope leaks (see below).
- Apply Fallbacks: on_missing_selector components are registered only if their target key/type is still missing after the main binding phase.
- Build Metadata & Indexes: The final _metadata dictionary (Key -> ProviderMetadata) is compiled, and inverted indexes (for qualifiers, primary, etc.) are built for the ComponentLocator.
- Validate: DependencyValidator.validate_bindings performs a static analysis of the dependency graph based on the final metadata. It inspects all init and factory method dependencies and cross-references them with the bound providers to ensure everything required exists (respecting is_optional). This enables fail-fast startup validation.
- Finalize: The PicoContainer is created. The finalized ComponentFactory, ComponentLocator, and scope/cache managers are attached. Deferred providers are attached to the container/locator. Lazy providers are wrapped.
3. The Resolution Algorithm (get/aget)¶
Resolution is handled by PicoContainer._resolve_or_create_internal, called by get(key) and aget(key).
- Canonicalize Key: Map the requested key (e.g., a type) to the actual registered key if needed (e.g., finding the primary implementation).
- Check Cache: Identify the correct cache (ScopedCaches.for_scope) based on the component's registered scope and the current active scope ID (ScopeManager.get_id). If an instance exists, return it immediately (on_cache_hit triggered).
- Get Provider: Retrieve the Provider callable (often a DeferredProvider) from the ComponentFactory. Raise ProviderNotFoundError if missing.
- Create Instance:
- Activate the current container context (as_current).
- Call the Provider. For DeferredProvider, this triggers its builder function (e.g., PicoContainer.build_class or PicoContainer.build_method).
- The builder function calls _resolve_args to get dependencies based on analyzed DependencyRequests.
- _resolve_args is recursive: it calls pico.get() or pico.aget() for each dependency, restarting this flow at step 1. Circular dependencies are typically caught here by checking an internal resolution stack, leading to InvalidBindingError.
- The builder constructs the raw instance (calling init).
- If ainit or async def @configure methods exist, and aget was called, they are awaited. If get was called on an async component, AsyncResolutionError is raised.
- Synchronous @configure methods are called.
- Deactivate the container context.
- Wrap Proxy: The new instance is passed to _maybe_wrap_with_aspects to wrap it in a UnifiedComponentProxy only if it has @intercepted_by methods (AOP) or was registered with lazy=True.
- Put in Cache: The (possibly proxied) final instance is stored in the correct scope cache (from step 2).
- Notify Observers: on_resolve is triggered for registered observers.
- Return Instance.
4. Internals: Unified Configuration (@configured)¶
Managed primarily by config_registrar.py and config_runtime.py, based on the ContextConfig provided.
ConfigurationManager: Created by the Registrar, holds the ContextConfig and owns the ConfigResolver and ObjectGraphBuilder. It handles registration of @configured classes during scanning.ConfigResolver: Initialized with the TreeSources from ContextConfig. When first accessed, it:- Loads all tree sources (e.g., YAML files).
- Performs a deep merge according to precedence rules.
- Walks the merged tree to interpolate variables (${ENV:VAR}, ${ref:path}).
- Caches the final, resolved configuration tree.
ObjectGraphBuilder: Used by the provider for @configured(mapping="tree") components.- Gets the relevant sub-tree from ConfigResolver using the prefix.
- Recursively walks the target type (e.g., a dataclass) and the config sub-tree.
- Coerces primitive values, builds nested dataclasses, handles List, Dict, Enum, Union, Annotated[Union[...], Discriminator(...)].
- Flat Mapping (@configured(mapping="flat")): Handled more directly by ConfigurationManager._build_flat_instance. It iterates through the FlatSources defined in ContextConfig, looking up keys as PREFIX_FIELDNAME.upper() and performing basic coercion.
5. Internals: Scope Management¶
ScopeManager: A registry mapping scope names (e.g., "request") to a ScopeProtocol implementation. For built-ins like "request", it uses ContextVarScope, which wraps a contextvars.ContextVar.- activate(scope_id) calls _var.set(scope_id).
- deactivate(token) calls _var.reset(token).
- get_id() calls _var.get().
ScopedCaches: Holds the actual component instances._singleton: A single ComponentContainer (a dict wrapper) for all singleton components._by_scope: A dict mapping scope names (e.g., "request") to an OrderedDict.- This OrderedDict maps a scope ID (e.g., "req-123") to its own ComponentContainer. It acts as an LRU cache to evict component caches for older, inactive scope IDs (e.g., old HTTP requests), calling @cleanup on evicted instances.
_no_cache: A dummy container used for prototype scope that never stores instances.
6. Internals: AOP & Lazy Loading¶
Both features are powered by the UnifiedComponentProxy.
- lazy=True: When get()/aget() resolves a lazy component, the Registrar binds a special provider that returns an uninitialized proxy.
- _target is None.
- _creator is the component's real provider function.
- On the first attribute access (e.g., proxy.some_method()), getattr calls _get_real_object().
- _get_real_object() executes the _creator (triggering the full resolution/creation flow), saves the result to _target, and returns it.
- Subsequent access hits the (now populated) _target.
- AOP (@intercepted_by): When get()/aget() resolves a component with intercepted methods, the real object is created first, then wrapped by the proxy via _maybe_wrap_with_aspects.
- _target is set immediately to the real instance.
- _creator is None.
- On every attribute access, getattr is called.
- It checks a cache (_cache) for an already-built wrapped method for the current scope signature.
- If not cached, or if scope changed: It retrieves the interceptor classes (_gather_interceptors_for_method), resolves the interceptor instances from the container (container.get(InterceptorCls)), and builds a new wrapper function (_build_wrapped).
- The wrapper function uses dispatch_method to execute the interceptor chain (invoke calls) around the original method (ctx.method).
- The wrapper is cached and returned.
- Serialization (getstate/setstate/reduce_ex): Implemented to ensure that pickle serializes/deserializes the real object (_target), not the proxy, allowing proxied objects to be potentially sent across processes (if the target is serializable).
7. Internals: ComponentLocator¶
The ComponentLocator is primarily an introspection tool built from the Registrar's final _metadata (Key -> ProviderMetadata) and _indexes. It maintains several inverted indexes (as dicts) derived from the metadata to allow fast querying:
- "qualifier": Qualifier string -> List[KeyT]
- "primary": bool -> List[KeyT]
- "infra": str -> List[KeyT] (e.g., "component", "factory")
- "pico_name": str -> List[KeyT]
The list injection logic in _resolve_args (collect_by_type) uses the raw metadata, while other potential locator features might use the indexes.
8. Advanced Internal Mechanisms¶
8.1. Automatic Scope Promotion¶
The Registrar._promote_scopes method prevents scope leaks (e.g., a singleton depending directly on a request-scoped object). It iterates through all component metadata after initial binding. If a component (e.g., a singleton) has a dependency with a narrower scope (e.g., request), the component's own scope is automatically "promoted" to match the narrowest dependency's scope. This ensures instances don't hold onto dependencies from expired scopes.
8.2. Event Bus¶
The EventBus (from pico_ioc.event_bus) is registered like a normal component. Internally, it maintains a dictionary mapping Event types to a sorted list (_Subscriber dataclass includes priority) of registered handler callbacks (found via AutoSubscriberMixin's @configure hook). The publish method iterates this list for the given event type, handling async and sync subscribers according to their ExecPolicy (e.g., awaiting inline async handlers, using asyncio.create_task for TASK, or loop.run_in_executor for THREADPOOL).
9. Design Patterns Used¶
- Inversion of Control (IoC) / Dependency Injection (DI): Core principle.
- Service Locator: ComponentLocator for querying; PicoContainer.get_current() for contextual access.
- Proxy Pattern: UnifiedComponentProxy for lazy loading and AOP.
- Chain of Responsibility: Interceptor chain (dispatch_method).
- Factory Pattern: @factory and @provides.
- Strategy Pattern: Multiple implementations (e.g., Database) with primary selecting the default.
- Observer Pattern: ContainerObserver for monitoring.
- Publisher/Subscriber: The EventBus.
- Builder Pattern: configuration(...) creating ContextConfig.
- Facade Pattern: PicoContainer simplifying access to internal components.