ADR-0011: Extensible Component Scanning via Custom Scanners¶
Status: Accepted
Context¶
The original component scanning mechanism in pico-ioc was designed as a closed system. It strictly looked for specific internal decorators (@component, @factory, @provides, @configured) and hardcoded logic within ComponentScanner.
This rigidity created significant challenges for third-party extensions (such as pico-agent or web frameworks): 1. Global Mutable State: Extensions were forced to use global registries (e.g., _PENDING_AGENTS lists) to track decorated objects, leading to thread-safety issues and test contamination. 2. Fragile Hacks: Extensions often relied on stack frame inspection to guess the caller's module, which is unreliable. 3. Lack of Hooks: There was no clean way to intercept the scanning phase to register objects based on custom logic (e.g., registering a function decorated with @task as a prototype component).
We needed a standardized, stateless extension point to allow third-party libraries to participate in the discovery phase.
Decision¶
We introduce the CustomScanner protocol and expose a new custom_scanners argument in the init() API.
- Protocol Definition: We define a
CustomScannerprotocol withshould_scan(obj)andscan(obj)methods. This delegates the responsibility of pattern matching and metadata construction to the extension author. - Priority Scanning: The
ComponentScanneriteration logic is modified to prioritize these custom scanners.- The scanner iterates through all module members once.
- For every member (whether it is a Class, Function, or other object), it first checks the registered
custom_scanners. - If a custom scanner claims the object (returns a binding), the built-in native scanning logic is skipped for that object.
- Injection via Init: Users or frameworks pass instances of these scanners into the container via
init(..., custom_scanners=[...]).
Details¶
The Protocol¶
class CustomScanner(Protocol):
def should_scan(self, obj: Any) -> bool:
"""Return True if this scanner handles the given object."""
...
def scan(self, obj: Any) -> Optional[Tuple[KeyT, Provider, ProviderMetadata]]:
"""
Constructs the binding artifacts.
Returns (key, provider, metadata) or None.
"""
...
Scanning Logic¶
The internal loop in ComponentScanner.scan_module effectively works as follows:
for name, obj in inspect.getmembers(module):
# 1. Custom Scanners take precedence over everything
if self._try_custom_scanners(obj):
continue
# 2. Native logic (Component, Factory, Configured, Provides)
# ...
This ensures that a custom scanner can override default behavior or register objects that pico-ioc would normally ignore (like standalone functions decorated with custom markers).
Consequences¶
Positive¶
- Decoupling: Extensions no longer need to depend on
pico-iocinternals or global state. - Flexibility: Enables support for function-based components (e.g., tasks, agents) and custom class decorators.
- Safety: Scanners are scoped to the container instance, ensuring thread safety and isolation during tests.
- Performance: Single-pass iteration over module members allows for efficient discovery without repeated
inspectcalls.
Negative¶
- Complexity: Increases the API surface area of
init(). - Manual Wiring: Without a wrapper (like
pico-stack), users must manually instantiate and pass scanner instances toinit().
Alternatives Considered¶
- Global Registry Hooks: Rejected due to testing isolation issues and "magic" global state.
- Inheritance (
class MyScanner(ComponentScanner)): Rejected because it tightly couples extensions to the internal implementation of the default scanner and makes composing multiple extensions difficult.