Architecture¶
This document describes the internal architecture of Pico-Boot, its design decisions, and how it integrates with pico-ioc.
Overview¶
Pico-Boot is intentionally minimal. It's a thin wrapper around pico_ioc.init() that adds:
- Plugin discovery via Python entry points
- Module normalization to handle various input types
- Scanner harvesting from loaded modules (
PICO_SCANNERS) - Environment-based control via
PICO_BOOT_AUTO_PLUGINS
graph TD
APP["Your Application<br/><code>from pico_boot import init</code><br/><code>container = init(modules=["myapp"])</code>"]
APP --> BOOT
subgraph BOOT["pico-boot"]
MN["Module Normalizer"]
PD["Plugin Discovery<br/>(entry points)"]
SH["Scanner Harvesting<br/>(PICO_SCANNERS)"]
MN --> MERGE
PD --> MERGE
SH --> MERGE
MERGE["Merged Modules +<br/>Harvested Scanners"]
end
MERGE --> IOC
subgraph IOC["pico-ioc"]
SCANNER["Scanner"]
CONTAINER["Container"]
SCOPES["Scopes"]
CONFIG["Configuration"]
end Component Breakdown¶
1. Module Normalizer¶
The module normalizer handles various input types and deduplicates them:
# All these are valid inputs:
init(modules=["myapp"]) # String
init(modules=[myapp_module]) # Module object
init(modules=["myapp", myapp_module]) # Mixed
init(modules="myapp") # Single string (converted to list)
Implementation:
flowchart TD
A["_to_module_list(input)"] --> B["_import_module_like(item)<br/>for each item"]
B --> C["_normalize_modules(modules)<br/>deduplicate by __name__"] 2. Plugin Discovery¶
Plugin discovery uses Python's importlib.metadata.entry_points():
flowchart TD
A["entry_points(group="pico_boot.modules")"] --> B["Filter out pico_ioc, pico_boot"]
B --> C["import_module(ep.module) for each"]
C --> D["Deduplicate by module name"]
D --> E["List[ModuleType]"] Why filter pico_ioc and pico_boot?
These are infrastructure packages, not application modules. They don't contain components to scan.
3. Environment Configuration¶
A single environment variable controls plugin auto-discovery:
flowchart TD
ENV["PICO_BOOT_AUTO_PLUGINS"]
ENV -->|""true" (default)"| ON["Enable discovery"]
ENV -->|""false" / "0" / "no""| OFF["Disable discovery"] Code Flow¶
init() Execution Path¶
def init(*args, **kwargs):
# 1. Bind arguments to pico_ioc.init signature
bound = _IOC_INIT_SIG.bind(*args, **kwargs)
bound.apply_defaults()
# 2. Normalize user modules
base_modules = _normalize_modules(_to_module_list(bound.arguments["modules"]))
# 3. Check auto-discovery setting
auto_flag = os.getenv("PICO_BOOT_AUTO_PLUGINS", "true").lower()
auto_plugins = auto_flag not in ("0", "false", "no")
# 4. Discover and merge plugins
if auto_plugins:
plugin_modules = _load_plugin_modules()
all_modules = _normalize_modules(list(base_modules) + plugin_modules)
else:
all_modules = base_modules
# 5. Update modules argument
bound.arguments["modules"] = all_modules
# 6. Harvest PICO_SCANNERS from all modules
harvested = _harvest_scanners(all_modules)
if harvested:
existing = bound.arguments.get("custom_scanners") or []
bound.arguments["custom_scanners"] = list(existing) + harvested
# 7. Delegate to pico_ioc.init
return _ioc_init(*bound.args, **bound.kwargs)
Design Decisions¶
Why Wrap pico_ioc.init()?¶
Alternative considered: Subclass PicoContainer
Decision: Wrap the init function
Rationale: - Minimal coupling - pico-boot doesn't need to know PicoContainer internals - Forward compatible - any new pico_ioc.init() parameters work automatically - Single responsibility - pico-boot only handles discovery, not container logic
Why Entry Points?¶
Alternative considered: Configuration file listing plugins
Decision: Use Python entry points
Rationale: - Standard Python mechanism (PEP 621) - Zero configuration for end users - Plugins are discovered at install time, not runtime - Works with pip, poetry, conda, etc.
Why Deduplicate Modules?¶
Problem: User might specify a module that's also a plugin
# User explicitly lists pico-fastapi
init(modules=["myapp", "pico_fastapi"])
# But pico-fastapi is also auto-discovered!
Solution: Deduplicate by module __name__
The first occurrence wins, preserving user intent while avoiding duplicate scanning.
Why Allow Disabling Auto-Discovery?¶
Use cases: 1. Testing: Isolate tests from installed plugins 2. Debugging: Understand what modules are being loaded 3. Performance: Skip discovery in serverless cold starts 4. Compatibility: Gradual migration from pico-ioc
Error Handling¶
Plugin Import Failures¶
Plugins that fail to import are logged and skipped:
try:
m = import_module(ep.module)
except Exception as exc:
logger.warning(
"Failed to load pico-boot plugin entry point '%s' (%s): %s",
ep.name, ep.module, exc
)
continue # Skip this plugin, continue with others
Rationale: A broken optional plugin shouldn't crash the application.
Module Import Failures¶
Module import failures from user-specified modules propagate:
Rationale: User-specified modules are required, not optional.
Performance Considerations¶
Entry Point Discovery¶
entry_points() is called once per init(). The result is not cached because:
- Applications typically call
init()once at startup - Caching would prevent seeing newly installed plugins
- The operation is fast (reads package metadata)
Module Import¶
Modules are imported via import_module(). Python caches imports in sys.modules, so repeated init() calls don't re-import.
Extensibility¶
Custom Entry Point Group¶
Advanced users can use a custom group:
This is an internal API but available for special cases.
Adding New Features¶
To add features to pico-boot:
- Don't modify init() signature - it must match pico_ioc.init()
- Use environment variables for configuration
- Fail gracefully - don't break apps on errors
- Stay minimal - complex features belong in pico-ioc
Testing Strategy¶
Unit Tests¶
Test each internal function in isolation: - _to_module_list - input normalization - _import_module_like - import handling - _normalize_modules - deduplication - _load_plugin_modules - entry point discovery - _harvest_scanners - PICO_SCANNERS collection
Integration Tests¶
Test the full flow with real pico-ioc: - Container creation - Component resolution - Lifecycle management
Mock Strategy¶
Mock these for unit tests: - entry_points() - avoid depending on installed packages - import_module() - avoid importing real modules - logger - verify warning messages
Auto-Discovery Flow¶
The following diagram shows the complete auto-discovery lifecycle from installed packages to a fully initialised container:
flowchart TD
START(["pico_boot.init() called"]) --> BIND["Bind args to pico_ioc.init signature"]
BIND --> NORM["Normalise user modules<br/>(_to_module_list + _normalize_modules)"]
NORM --> CHECK{"PICO_BOOT_AUTO_PLUGINS<br/>enabled?"}
CHECK -->|Yes| EP["Read entry_points<br/>(group="pico_boot.modules")"]
CHECK -->|No| SKIP["Use only user modules"]
EP --> FILTER["Filter out pico_ioc, pico_boot"]
FILTER --> IMPORT["Import each plugin module"]
IMPORT --> DEDUP["Deduplicate all modules<br/>(user + plugins)"]
DEDUP --> HARVEST["Harvest PICO_SCANNERS<br/>from all modules"]
SKIP --> HARVEST
HARVEST --> MERGE["Merge harvested scanners<br/>with custom_scanners arg"]
MERGE --> DELEGATE["Delegate to pico_ioc.init()"]
DELEGATE --> CONTAINER(["PicoContainer returned"]) Comparison: pico_boot.init() vs pico_ioc.init()¶
flowchart LR
subgraph PIOC["pico_ioc.init()"]
direction TB
A1["Receive modules, config,<br/>profiles, overrides, scanners"]
A2["Scan modules for<br/>@component, @provides, etc."]
A3["Build container"]
A1 --> A2 --> A3
end
subgraph PBOOT["pico_boot.init()"]
direction TB
B1["Receive same args as pico_ioc.init()"]
B2["Normalise & deduplicate modules"]
B3["Auto-discover plugins<br/>(entry points)"]
B4["Harvest PICO_SCANNERS"]
B5["Delegate to pico_ioc.init()"]
B1 --> B2 --> B3 --> B4 --> B5
end
PBOOT -->|"enriched args"| PIOC The key difference is that pico_boot.init() performs three additional pre-processing steps (normalisation, plugin discovery, scanner harvesting) before forwarding all arguments to pico_ioc.init(). The returned PicoContainer is identical in both cases.
Future Considerations¶
Potential Enhancements¶
- Profile support - environment-based profile activation (e.g.,
PICO_BOOT_PROFILES)
Non-Goals¶
- Container features - belong in pico-ioc
- Framework features - belong in specific integrations
- CLI tools - separate package