From e084567ff28cd83f8cb9ad6025ecf63a084111bc Mon Sep 17 00:00:00 2001 From: Romain Dorgueil Date: Thu, 5 Feb 2026 09:38:29 +0100 Subject: [PATCH 1/2] feat: add circular dependency detection at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect cycles in the dependency graph during ContainerBuilder.build() using depth-first search. Raises CircularDependencyError with the full cycle path (e.g., "A → B → C → A") for easy debugging. Handles direct cycles (A → B → A), indirect cycles (A → B → C → A), self-references (A → A), and correctly allows diamond patterns. --- CHANGELOG.md | 1 + src/hdmi/builders/default.py | 169 +++++++++++++++++------ tests/builders/test_default.py | 244 ++++++++++++++++++++++++++++++--- 3 files changed, 354 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb4cf0..f25c106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Service lifecycle hooks (initializers and finalizers) - Task deduplication for diamond dependency patterns - Boolean-based scope API with `scoped` and `transient` flags +- Circular dependency detection at build time with descriptive error messages showing the cycle path ### Changed diff --git a/src/hdmi/builders/default.py b/src/hdmi/builders/default.py index 868f83e..9b44e06 100644 --- a/src/hdmi/builders/default.py +++ b/src/hdmi/builders/default.py @@ -9,13 +9,11 @@ from hdmi.utils.typing import extract_type_from_optional from hdmi.types.definitions import ServiceDefinition -from hdmi.exceptions import ScopeViolationError +from hdmi.exceptions import ScopeViolationError, CircularDependencyError if TYPE_CHECKING: from hdmi.containers import Container -# Removed - no longer using scope hierarchy with boolean flags - class ContainerBuilder: """Mutable builder for configuring dependency injection services. @@ -85,12 +83,53 @@ def build(self) -> "Container": """ from hdmi.containers import Container + # Check for circular dependencies + self._check_circular_dependencies() + # Validate scope hierarchy for all registrations self._validate_scopes() # Create and return the validated Container return Container(self._definitions) + def _check_circular_dependencies(self) -> None: + """Check for circular dependencies in the dependency graph. + + Uses depth-first search to detect cycles. Fails fast on the first cycle found. + + Raises: + CircularDependencyError: If a circular dependency is detected + """ + visited = set() + path = [] + + def visit(service_type: Type) -> None: + if service_type in path: + # Found a cycle - build the cycle path + cycle_start = path.index(service_type) + cycle_path = path[cycle_start:] + [service_type] + path_str = " → ".join(cls.__name__ for cls in cycle_path) + raise CircularDependencyError(f"Circular dependency detected: {path_str}") + + if service_type in visited: + return + + path.append(service_type) + + # Get dependencies that will be injected + dependencies = self._get_dependencies(service_type) + + for dep_type in dependencies.values(): + if dep_type in self._definitions: + visit(dep_type) + + path.pop() + visited.add(service_type) + + # Visit all registered services + for service_type in self._definitions: + visit(service_type) + def _validate_scopes(self) -> None: """Validate that scope rules are respected. @@ -104,32 +143,57 @@ def _validate_scopes(self) -> None: ScopeViolationError: If a non-scoped service depends on a scoped service """ for service_type, definition in self._definitions.items(): - # Get dependencies from type annotations dependencies = self._get_dependencies(service_type) - # Check each dependency's scope - for dep_name, dep_type in dependencies.items(): - if dep_type not in self._definitions: + for dependency_name, dependency_type in dependencies.items(): + if dependency_type not in self._definitions: # Will be caught later by unresolvable dependency check continue - dep_definition = self._definitions[dep_type] - - # Validate scope compatibility - # The only unsafe dependency is: non-scoped -> scoped - # (non-scoped service needs a scoped instance that only exists within a scope) - if not definition.scoped and dep_definition.scoped: - service_type_str = ( - f"{service_type.__name__} (scoped={definition.scoped}, transient={definition.transient})" - ) - dep_type_str = ( - f"{dep_type.__name__} (scoped={dep_definition.scoped}, transient={dep_definition.transient})" - ) - raise ScopeViolationError( - f"{service_type_str} cannot depend on {dep_type_str}. " - f"Non-scoped services cannot depend on scoped services because " - f"scoped services only exist within a scope context." - ) + self._validate_scope_compatibility( + service_type, definition, dependency_type, self._definitions[dependency_type] + ) + + def _validate_scope_compatibility( + self, + service_type: Type, + service_definition: ServiceDefinition, + dependency_type: Type, + dependency_definition: ServiceDefinition, + ) -> None: + """Validate that a service's scope is compatible with its dependency's scope. + + Args: + service_type: The type of the service being validated + service_definition: The definition of the service + dependency_type: The type of the dependency + dependency_definition: The definition of the dependency + + Raises: + ScopeViolationError: If a non-scoped service depends on a scoped service + """ + # Non-scoped services cannot depend on scoped services + if not service_definition.scoped and dependency_definition.scoped: + service_description = self._format_service_description(service_type, service_definition) + dependency_description = self._format_service_description(dependency_type, dependency_definition) + + raise ScopeViolationError( + f"{service_description} cannot depend on {dependency_description}. " + f"Non-scoped services cannot depend on scoped services because " + f"scoped services only exist within a scope context." + ) + + def _format_service_description(self, service_type: Type, definition: ServiceDefinition) -> str: + """Format a service type and definition for error messages. + + Args: + service_type: The service type + definition: The service definition + + Returns: + A formatted string describing the service and its scope + """ + return f"{service_type.__name__} (scoped={definition.scoped}, transient={definition.transient})" def _get_dependencies(self, service_type: Type) -> dict[str, Type]: """Get dependencies that will actually be injected. @@ -159,29 +223,46 @@ def _get_dependencies(self, service_type: Type) -> dict[str, Type]: if param_name not in hints: continue - type_hint = hints[param_name] - has_default = param.default is not inspect.Parameter.empty - - # Extract actual type from Optional/Union types (e.g., Config | None -> Config) - dependency_type = extract_type_from_optional(type_hint) + dependency_type = self._extract_dependency_type(hints[param_name]) if dependency_type is None: - # Can't determine single type (e.g., Union[A, B] or just None) continue - # Check if dependency is registered - is_registered = dependency_type in self._definitions - - if has_default: - # Optional dependency - only include if registered AND autowire=True - if is_registered: - dep_definition = self._definitions[dependency_type] - if dep_definition.autowire: - # Will be injected - include in dependencies - dependencies[param_name] = dependency_type - # else: skip (autowire=False, won't be injected) - # else: skip (not registered, won't be injected) - else: - # Required dependency - always include (will always be injected) + if self._should_inject_dependency(dependency_type, param.default is not inspect.Parameter.empty): dependencies[param_name] = dependency_type return dependencies + + def _extract_dependency_type(self, type_hint: Type) -> Type | None: + """Extract the concrete type from a type hint. + + Handles Optional types by extracting the non-None type. + Returns None if no single concrete type can be determined. + + Args: + type_hint: The type annotation to process + + Returns: + The extracted concrete type, or None if not determinable + """ + return extract_type_from_optional(type_hint) + + def _should_inject_dependency(self, dependency_type: Type, is_optional: bool) -> bool: + """Determine if a dependency should be injected. + + Args: + dependency_type: The type of the dependency + is_optional: Whether the parameter has a default value + + Returns: + True if the dependency should be injected, False otherwise + """ + if not is_optional: + # Required dependencies are always injected + return True + + # Optional dependencies need to be registered AND have autowire=True + if dependency_type not in self._definitions: + return False + + definition = self._definitions[dependency_type] + return definition.autowire diff --git a/tests/builders/test_default.py b/tests/builders/test_default.py index 0ead172..99a5d9b 100644 --- a/tests/builders/test_default.py +++ b/tests/builders/test_default.py @@ -1,7 +1,4 @@ -"""Tests for ContainerBuilder - Configuration Phase. - -Following TDD methodology, tests are written first to define behavior. -""" +"""Tests for ContainerBuilder - Configuration Phase.""" import pytest @@ -21,10 +18,7 @@ def __init__(self, simple: SimpleService): @pytest.mark.anyio async def test_container_builder_can_register_service(): - """Test that ContainerBuilder can register a service type. - - RED: This test will fail because ContainerBuilder doesn't exist yet. - """ + """ContainerBuilder can register a service type.""" from hdmi import ContainerBuilder builder = ContainerBuilder() @@ -36,10 +30,7 @@ async def test_container_builder_can_register_service(): @pytest.mark.anyio async def test_container_builder_can_build_container(): - """Test that ContainerBuilder can build a Container. - - RED: This test will fail because build() method doesn't exist yet. - """ + """ContainerBuilder can build a Container.""" from hdmi import ContainerBuilder builder = ContainerBuilder() @@ -52,10 +43,7 @@ async def test_container_builder_can_build_container(): @pytest.mark.anyio async def test_container_builder_register_with_scope(): - """Test that ContainerBuilder can register a service with a specific scope. - - RED: This test will fail because scope parameter doesn't exist yet. - """ + """ContainerBuilder can register a service with a specific scope.""" from hdmi import ContainerBuilder builder = ContainerBuilder() @@ -167,3 +155,227 @@ def create_simple_service(): assert stored_def.name == "my_service" assert stored_def.factory is create_simple_service assert stored_def.autowire is False + + +# Circular Dependency Detection Tests (Issue #1) +# Test fixtures for circular dependency scenarios + + +class ServiceA: + """Service A that depends on ServiceB (creates A → B → A cycle).""" + + def __init__(self, b: "ServiceB"): + self.b = b + + +class ServiceB: + """Service B that depends on ServiceA (completes the cycle).""" + + def __init__(self, a: ServiceA): + self.a = a + + +class ServiceX: + """Service X that depends on ServiceY (part of indirect cycle X → Y → Z → X).""" + + def __init__(self, y: "ServiceY"): + self.y = y + + +class ServiceY: + """Service Y that depends on ServiceZ.""" + + def __init__(self, z: "ServiceZ"): + self.z = z + + +class ServiceZ: + """Service Z that depends on ServiceX (completes the indirect cycle).""" + + def __init__(self, x: ServiceX): + self.x = x + + +class SelfReferential: + """Service that depends on itself.""" + + def __init__(self, self_ref: "SelfReferential"): + self.self_ref = self_ref + + +class ValidServiceA: + """Valid service with no dependencies.""" + + pass + + +class ValidServiceB: + """Valid service that depends on ValidServiceA.""" + + def __init__(self, a: ValidServiceA): + self.a = a + + +class ValidServiceC: + """Valid service that depends on ValidServiceB.""" + + def __init__(self, b: ValidServiceB): + self.b = b + + +class DiamondTop: + """Top of diamond pattern.""" + + pass + + +class DiamondLeft: + """Left path of diamond.""" + + def __init__(self, top: DiamondTop): + self.top = top + + +class DiamondRight: + """Right path of diamond.""" + + def __init__(self, top: DiamondTop): + self.top = top + + +class DiamondBottom: + """Bottom of diamond (converges left and right paths).""" + + def __init__(self, left: DiamondLeft, right: DiamondRight): + self.left = left + self.right = right + + +@pytest.mark.anyio +async def test_valid_acyclic_dependencies_build_successfully(): + """Valid acyclic dependencies should build successfully.""" + from hdmi import ContainerBuilder + + builder = ContainerBuilder() + builder.register(ValidServiceA) + builder.register(ValidServiceB) + builder.register(ValidServiceC) + + async with builder.build() as container: + assert container is not None + + +@pytest.mark.anyio +async def test_diamond_pattern_is_valid(): + """Diamond pattern (shared dependencies) is valid and should build.""" + from hdmi import ContainerBuilder + + builder = ContainerBuilder() + builder.register(DiamondTop) + builder.register(DiamondLeft) + builder.register(DiamondRight) + builder.register(DiamondBottom) + + async with builder.build() as container: + assert container is not None + + +@pytest.mark.anyio +async def test_direct_cycle_detected(): + """Direct cycle A → B → A raises CircularDependencyError.""" + from hdmi import ContainerBuilder + from hdmi.exceptions import CircularDependencyError + + builder = ContainerBuilder() + builder.register(ServiceA) + builder.register(ServiceB) + + with pytest.raises(CircularDependencyError) as exc_info: + builder.build() + + # Verify error message contains service names and indicates circular dependency + error_msg = str(exc_info.value).lower() + assert "servicea" in error_msg + assert "serviceb" in error_msg + assert "circular" in error_msg + + +@pytest.mark.anyio +async def test_indirect_cycle_detected_with_full_path(): + """Indirect cycle X → Y → Z → X raises CircularDependencyError with full path.""" + from hdmi import ContainerBuilder + from hdmi.exceptions import CircularDependencyError + + builder = ContainerBuilder() + builder.register(ServiceX) + builder.register(ServiceY) + builder.register(ServiceZ) + + with pytest.raises(CircularDependencyError) as exc_info: + builder.build() + + # Verify error message shows the cycle + error_msg = str(exc_info.value).lower() + assert "servicex" in error_msg + assert "circular" in error_msg + + +@pytest.mark.anyio +async def test_self_dependency_detected_immediately(): + """Self-dependency A → A raises CircularDependencyError.""" + from hdmi import ContainerBuilder + from hdmi.exceptions import CircularDependencyError + + builder = ContainerBuilder() + builder.register(SelfReferential) + + with pytest.raises(CircularDependencyError) as exc_info: + builder.build() + + # Verify error message contains service name + error_msg = str(exc_info.value).lower() + assert "selfreferential" in error_msg + assert "circular" in error_msg + + +@pytest.mark.anyio +async def test_cycle_with_singleton_scope(): + """Cycles are detected regardless of singleton scope.""" + from hdmi import ContainerBuilder + from hdmi.exceptions import CircularDependencyError + + builder = ContainerBuilder() + # Default registration is singleton (scoped=False, transient=False) + builder.register(ServiceA) + builder.register(ServiceB) + + with pytest.raises(CircularDependencyError): + builder.build() + + +@pytest.mark.anyio +async def test_cycle_with_scoped_scope(): + """Cycles are detected regardless of scoped scope.""" + from hdmi import ContainerBuilder + from hdmi.exceptions import CircularDependencyError + + builder = ContainerBuilder() + builder.register(ServiceA, scoped=True) + builder.register(ServiceB, scoped=True) + + with pytest.raises(CircularDependencyError): + builder.build() + + +@pytest.mark.anyio +async def test_cycle_with_transient_scope(): + """Cycles are detected regardless of transient scope.""" + from hdmi import ContainerBuilder + from hdmi.exceptions import CircularDependencyError + + builder = ContainerBuilder() + builder.register(ServiceA, transient=True) + builder.register(ServiceB, transient=True) + + with pytest.raises(CircularDependencyError): + builder.build() From 490442e007e3c1daf17091fe6d128d2aea0a4147 Mon Sep 17 00:00:00 2001 From: Romain Dorgueil Date: Thu, 5 Feb 2026 09:39:08 +0100 Subject: [PATCH 2/2] docs: fix async API usage and add missing parameter documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add pre-alpha warning to documentation index - Fix all code examples to use async/await and async context managers - Add documentation for lifecycle hooks (initializer, finalizer) - Add documentation for autowire parameter - Remove references to non-existent exceptions (TypeResolutionError, InstantiationError) - Fix toctree order to follow Diátaxis (tutorials, how-to, explanation, reference) - Update error handling section to reflect actual exceptions --- README.md | 57 +++++--- docs/README.md | 6 +- docs/explanation/architecture.rst | 96 ++++++------ docs/explanation/index.rst | 2 +- docs/explanation/why-late-binding.rst | 130 ++++++++++------- docs/how-to/use-service-definitions.rst | 185 ++++++++++++++++++++---- docs/index.rst | 39 +++-- docs/reference/api.rst | 85 ++++++++--- 8 files changed, 419 insertions(+), 181 deletions(-) diff --git a/README.md b/README.md index 02378c5..60d7513 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # hdmi - Dependency Management Interface +> **Warning: Pre-Alpha Software** +> +> hdmi is experimental software in active development. Breaking changes may occur +> until version 1.0. Use with care in production environments. + A lightweight dependency injection framework for Python 3.13+ with: - **Type-driven dependency discovery** - Uses Python's standard type annotations @@ -12,6 +17,7 @@ A lightweight dependency injection framework for Python 3.13+ with: ### Simple Example (Singleton Services) ```python +import asyncio from hdmi import ContainerBuilder # Define your services @@ -27,34 +33,43 @@ class UserService: def __init__(self, repo: UserRepository): self.repo = repo -# Configure the container (all singletons by default) -builder = ContainerBuilder() -builder.register(DatabaseConnection) -builder.register(UserRepository) -builder.register(UserService) +async def main(): + # Configure the container (all singletons by default) + builder = ContainerBuilder() + builder.register(DatabaseConnection) + builder.register(UserRepository) + builder.register(UserService) -# Build validates the dependency graph -container = builder.build() + # Build validates the dependency graph + container = builder.build() -# Resolve services lazily - dependencies are auto-wired! -user_service = container.get(UserService) + # Resolve services lazily - dependencies are auto-wired! + user_service = await container.get(UserService) + +asyncio.run(main()) ``` ### Using Scoped Services ```python -# For request-scoped services (e.g., web requests) -builder = ContainerBuilder() -builder.register(DatabaseConnection) # singleton (default) -builder.register(UserRepository, scoped=True) # One per request -builder.register(UserService, transient=True) # New each time +import asyncio +from hdmi import ContainerBuilder + +async def main(): + # For request-scoped services (e.g., web requests) + builder = ContainerBuilder() + builder.register(DatabaseConnection) # singleton (default) + builder.register(UserRepository, scoped=True) # One per request + builder.register(UserService, transient=True) # New each time + + container = builder.build() -container = builder.build() + # Scoped services must be resolved within a scope context + async with container.scope() as scoped: + user_service = await scoped.get(UserService) + # All scoped dependencies share the same instance within this scope -# Scoped services must be resolved within a scope context -with container.scope() as scoped: - user_service = scoped.get(UserService) - # All scoped dependencies share the same instance within this scope +asyncio.run(main()) ``` ## Key Features @@ -77,12 +92,12 @@ Services have four lifecycles that are validated at build time: The only invalid dependency is when a non-scoped service (singleton or transient) depends on a scoped service. ```python -#  Valid: Scoped � Singleton +# Valid: Scoped -> Singleton builder = ContainerBuilder() builder.register(DatabaseConnection) # singleton (default) builder.register(UserRepository, scoped=True) -# L Invalid: Singleton � Scoped (raises ScopeViolationError) +# Invalid: Singleton -> Scoped (raises ScopeViolationError) builder = ContainerBuilder() builder.register(RequestHandler, scoped=True) builder.register(SingletonService) # singleton depends on scoped! diff --git a/docs/README.md b/docs/README.md index 34db510..4224a51 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,7 +20,7 @@ The documentation is organized into four categories based on the Diátaxis frame - Focused on accomplishing specific tasks - Assumes basic knowledge - Practical and actionable -- Example: "How to configure YAML-based dependency injection" +- Example: "How to use service definitions with factories" ### 📖 Reference (`reference/`) **Information-oriented**: Technical descriptions @@ -57,8 +57,8 @@ make docs # Build and watch for changes (auto-reload in browser) make docs-watch -# Clean build artifacts -make docs-clean +# Clean build artifacts (includes docs) +make clean ``` The built documentation will be in `docs/_build/html/`. Open `docs/_build/html/index.html` in your browser to view it. diff --git a/docs/explanation/architecture.rst b/docs/explanation/architecture.rst index 8ff7562..24a2189 100644 --- a/docs/explanation/architecture.rst +++ b/docs/explanation/architecture.rst @@ -97,23 +97,27 @@ validated dependency graph ready for runtime use. .. code-block:: python - # ContainerBuilder validates during .build() - container = builder.build() - # ↑ All validation happens HERE, by the ContainerBuilder - # - Dependency graph constructed and validated - # - Cycles checked - # - Scope hierarchy validated - # - Type compatibility ensured - - # Container is now immutable and validated - # At this point: - # - All dependencies are validated ✓ - # - No cycles exist ✓ - # - Scope hierarchy is correct ✓ - # - No services are instantiated yet - - # Later, at runtime, Container just resolves: - user_service = container.get(UserService) # Lazy instantiation + import asyncio + + async def main(): + # ContainerBuilder validates during .build() + async with builder.build() as container: + # ↑ All validation happens HERE, by the ContainerBuilder + # - Dependency graph constructed and validated + # - Cycles checked + # - Scope hierarchy validated + + # Container is now immutable and validated + # At this point: + # - All dependencies are validated ✓ + # - No cycles exist ✓ + # - Scope hierarchy is correct ✓ + # - No services are instantiated yet + + # Later, at runtime, Container just resolves: + user_service = await container.get(UserService) # Lazy instantiation + + asyncio.run(main()) Service resolution flow ----------------------- @@ -353,25 +357,30 @@ Example with Scopes: .. code-block:: python - # Singleton: Created once - db = container.get(Database) - db2 = container.get(Database) - assert db is db2 # Same instance + import asyncio + + async def main(): + # Singleton: Created once + db = await container.get(Database) + db2 = await container.get(Database) + assert db is db2 # Same instance + + # Scoped: Created once per scope + async with container.scope() as scoped: + handler1 = await scoped.get(RequestHandler) + handler2 = await scoped.get(RequestHandler) + assert handler1 is handler2 # Same instance within scope - # Scoped: Created once per scope - with container.create_scope() as scope: - handler1 = scope.get(RequestHandler) - handler2 = scope.get(RequestHandler) - assert handler1 is handler2 # Same instance within scope + async with container.scope() as scoped2: + handler3 = await scoped2.get(RequestHandler) + assert handler1 is not handler3 # Different instance in different scope - with container.create_scope() as scope2: - handler3 = scope2.get(RequestHandler) - assert handler1 is not handler3 # Different instance in different scope + # Transient: New instance every time + cmd1 = await container.get(CommandProcessor) + cmd2 = await container.get(CommandProcessor) + assert cmd1 is not cmd2 # Always different instances - # Transient: New instance every time - cmd1 = container.get(CommandProcessor) - cmd2 = container.get(CommandProcessor) - assert cmd1 is not cmd2 # Always different instances + asyncio.run(main()) Design principles ----------------- @@ -442,7 +451,7 @@ Design philosophy - **Python-native configuration**: Use type annotations, no external DSLs - **Two-phase architecture**: Clear separation between configuration and runtime - **Build-time validation**: Catch all configuration errors before runtime -- **Minimal dependencies**: Standard library only, no external packages +- **Minimal dependencies**: Only requires anyio for async support - **Type-driven**: Leverage Python's typing system for safety and IDE support - **Explicit over implicit**: Clear phase transitions and error messages @@ -456,28 +465,29 @@ Error handling Build -->|Success| Container[Container Created] Build -->|Cycle Detected| CycleError[CircularDependencyError] Build -->|Missing Dependency| MissingError[UnresolvableDependencyError] - Build -->|Type Mismatch| TypeError[TypeResolutionError] Build -->|Scope Violation| ScopeError[ScopeViolationError] Container --> Get{get} Get -->|Success| Instance[Service Instance] - Get -->|Instantiation Error| InstError[InstantiationError] + Get -->|Not Registered| GetError[UnresolvableDependencyError] + Get -->|Scoped from Container| ScopeGetError[ScopeViolationError] style CycleError fill:#ffcccc style MissingError fill:#ffcccc - style TypeError fill:#ffcccc style ScopeError fill:#ffcccc - style InstError fill:#ffcccc + style GetError fill:#ffcccc + style ScopeGetError fill:#ffcccc All configuration errors are detected at **build time**, not at runtime: - **CircularDependencyError**: Service A depends on B, which depends on A - **UnresolvableDependencyError**: Required dependency is not registered -- **TypeResolutionError**: Type annotation cannot be resolved -- **ScopeViolationError**: Service depends on a service with shorter lifetime (e.g., singleton → scoped) +- **ScopeViolationError**: Non-scoped service depends on scoped service -Only instantiation errors occur at resolution time: +Runtime errors (when calling ``.get()``): -- **InstantiationError**: Constructor raised an exception +- **UnresolvableDependencyError**: Service type not registered in container +- **ScopeViolationError**: Attempting to resolve scoped service from root Container (use ``container.scope()`` instead) +- Standard Python exceptions if service constructor fails -This ensures "fail fast" behavior - catch issues early during setup, not in production. +This ensures "fail fast" behavior - catch configuration issues early during setup, not in production. diff --git a/docs/explanation/index.rst b/docs/explanation/index.rst index 7927609..89bc1c4 100644 --- a/docs/explanation/index.rst +++ b/docs/explanation/index.rst @@ -54,4 +54,4 @@ Design Principles 3. **Type-Driven**: Python type annotations define dependencies 4. **No External DSL**: Pure Python, no YAML/XML configuration required 5. **Minimal Overhead**: Lightweight and fast -6. **Introspection First**: Easy to inspect and debug the dependency graph +6. **Async-First**: All service resolution is async for modern Python applications diff --git a/docs/explanation/why-late-binding.rst b/docs/explanation/why-late-binding.rst index 564c33b..89fa700 100644 --- a/docs/explanation/why-late-binding.rst +++ b/docs/explanation/why-late-binding.rst @@ -43,14 +43,19 @@ Applications start faster because services aren't created until needed: .. code-block:: python - # With early binding: ALL services created here (slow!) - container = builder.build() + import asyncio - # With late binding: only validation happens (fast!) - container = builder.build() + async def main(): + # With early binding: ALL services created here (slow!) + container = builder.build() - # Services created only when accessed - service = container.get(MyService) # <- instantiation happens here + # With late binding: only validation happens (fast!) + container = builder.build() + + # Services created only when accessed + service = await container.get(MyService) # <- instantiation happens here + + asyncio.run(main()) For applications with many services, this can significantly reduce startup time. @@ -61,19 +66,24 @@ Services that are never used are never created: .. code-block:: python - # Register many services - builder.register(ServiceA) - builder.register(ServiceB) - builder.register(ServiceC) - # ... 50 more services ... + import asyncio + + async def main(): + # Register many services + builder.register(ServiceA) + builder.register(ServiceB) + builder.register(ServiceC) + # ... 50 more services ... + + container = builder.build() - container = builder.build() + # Only use one service + service_a = await container.get(ServiceA) - # Only use one service - service_a = container.get(ServiceA) + # ServiceB, ServiceC, and the other 50 services are never instantiated + # Memory is conserved! - # ServiceB, ServiceC, and the other 50 services are never instantiated - # Memory is conserved! + asyncio.run(main()) This is especially valuable in: @@ -88,18 +98,23 @@ Services can be created based on runtime conditions: .. code-block:: python - container = builder.build() + import asyncio - if user_wants_feature_x(): - # Service created only if feature is used - feature_x = container.get(FeatureXService) + async def main(): + container = builder.build() - if environment == "production": - # Different service for production - monitor = container.get(ProductionMonitor) - else: - # Different service for development - monitor = container.get(DevMonitor) + if user_wants_feature_x(): + # Service created only if feature is used + feature_x = await container.get(FeatureXService) + + if environment == "production": + # Different service for production + monitor = await container.get(ProductionMonitor) + else: + # Different service for development + monitor = await container.get(DevMonitor) + + asyncio.run(main()) 4. Better Error Isolation ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -108,18 +123,23 @@ Errors in service constructors don't prevent the application from starting: .. code-block:: python - # Build succeeds even if OptionalService has constructor issues - container = builder.build() + import asyncio + + async def main(): + # Build succeeds even if OptionalService has constructor issues + container = builder.build() - try: - # Error only occurs if we actually try to use this service - optional = container.get(OptionalService) - except InstantiationError: - # Handle gracefully - other services still work - logger.warning("Optional feature unavailable") + try: + # Error only occurs if we actually try to use this service + optional = await container.get(OptionalService) + except Exception: + # Handle gracefully - other services still work + logger.warning("Optional feature unavailable") - # Core services still work fine - core = container.get(CoreService) + # Core services still work fine + core = await container.get(CoreService) + + asyncio.run(main()) 5. Circular Dependency Detection Without Full Instantiation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -174,23 +194,33 @@ Late binding isn't always the right choice. Consider early binding when: .. code-block:: python - # With late binding, this succeeds even if services can't be created - container = builder.build() + import asyncio + + async def main(): + # With late binding, this succeeds even if services can't be created + container = builder.build() + + # You might want to eagerly instantiate critical services + await container.get(DatabaseConnection) # Fail immediately if DB is down + await container.get(ConfigService) # Fail immediately if config is invalid - # You might want to eagerly instantiate critical services - container.get(DatabaseConnection) # Fail immediately if DB is down - container.get(ConfigService) # Fail immediately if config is invalid + asyncio.run(main()) 2. **Warm-Up is Beneficial** For some services, instantiation is expensive and you want to do it upfront: .. code-block:: python - # Warm up expensive services during startup - ml_model = container.get(MachineLearningModel) # Takes 30 seconds - cache = container.get(CacheService) # Needs initialization + import asyncio - # Now the services are ready for fast access + async def main(): + # Warm up expensive services during startup + ml_model = await container.get(MachineLearningModel) # Takes 30 seconds + cache = await container.get(CacheService) # Needs initialization + + # Now the services are ready for fast access + + asyncio.run(main()) 3. **Deterministic Resource Allocation** When you need to know upfront what resources will be allocated: @@ -226,6 +256,8 @@ Consider a web application with these services: .. code-block:: python + from hdmi import ContainerBuilder + # Configure all services builder = ContainerBuilder() builder.register(DatabaseConnection) @@ -241,17 +273,17 @@ Consider a web application with these services: # Handle a simple GET request @app.get("/health") - def health(): + async def health(): # Only LoggingService is instantiated - logger = container.get(LoggingService) + logger = await container.get(LoggingService) logger.info("Health check") return {"status": "ok"} # Handle a payment request @app.post("/payment") - def payment(): + async def payment(): # Now PaymentService, DatabaseConnection are instantiated - payment_service = container.get(PaymentService) + payment_service = await container.get(PaymentService) return payment_service.process() Benefits in this scenario: diff --git a/docs/how-to/use-service-definitions.rst b/docs/how-to/use-service-definitions.rst index 38a1131..adb3f34 100644 --- a/docs/how-to/use-service-definitions.rst +++ b/docs/how-to/use-service-definitions.rst @@ -78,11 +78,16 @@ ServiceDefinition allows you to provide a custom factory function: factory=create_database_connection ) - builder = ContainerBuilder() - builder.register(db_definition) + import asyncio + + async def main(): + builder = ContainerBuilder() + builder.register(DatabaseConnection, factory=create_database_connection) - container = builder.build() - db = container.get(DatabaseConnection) # Uses the factory + async with builder.build() as container: + db = await container.get(DatabaseConnection) # Uses the factory + + asyncio.run(main()) Factory requirements ~~~~~~~~~~~~~~~~~~~~ @@ -103,6 +108,133 @@ Factory requirements definition = ServiceDefinition(MyService, factory="not_callable") # Raises: ValueError: factory must be callable +Using lifecycle hooks +--------------------- + +Services can have initializers (called after instantiation) and finalizers (called when +the container exits). Both can be sync or async. + +Initializers +~~~~~~~~~~~~ + +Initializers are called after a service is instantiated: + +.. code-block:: python + + import asyncio + from hdmi import ContainerBuilder + + class DatabaseConnection: + def __init__(self): + self.connected = False + + def connect(self): + self.connected = True + + async def main(): + builder = ContainerBuilder() + + # Sync initializer + builder.register( + DatabaseConnection, + initializer=lambda db: db.connect() + ) + + async with builder.build() as container: + db = await container.get(DatabaseConnection) + assert db.connected # initializer was called + + asyncio.run(main()) + +Async initializers are also supported: + +.. code-block:: python + + async def async_connect(db): + await db.connect_async() + + builder.register(DatabaseConnection, initializer=async_connect) + +Finalizers +~~~~~~~~~~ + +Finalizers are called when the container or scope exits: + +.. code-block:: python + + import asyncio + from hdmi import ContainerBuilder + + class DatabaseConnection: + def __init__(self): + self.connected = True + + def disconnect(self): + self.connected = False + + async def main(): + builder = ContainerBuilder() + builder.register( + DatabaseConnection, + finalizer=lambda db: db.disconnect() + ) + + db_instance = None + async with builder.build() as container: + db_instance = await container.get(DatabaseConnection) + assert db_instance.connected + + # After exiting the context, finalizer is called + assert not db_instance.connected + + asyncio.run(main()) + +Combined initializer and finalizer +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + builder.register( + DatabaseConnection, + initializer=lambda db: db.connect(), + finalizer=lambda db: db.disconnect() + ) + +Controlling autowiring +---------------------- + +The ``autowire`` parameter controls whether a service is automatically injected into +optional dependencies. By default, ``autowire=True``. + +.. code-block:: python + + from hdmi import ContainerBuilder + + class OptionalFeature: + pass + + class MyService: + def __init__(self, feature: OptionalFeature | None = None): + self.feature = feature + + # With autowire=True (default), OptionalFeature is injected if registered + builder = ContainerBuilder() + builder.register(OptionalFeature) # autowire=True by default + builder.register(MyService) + + async with builder.build() as container: + service = await container.get(MyService) + assert service.feature is not None # OptionalFeature was injected + + # With autowire=False, the service is not injected into optional dependencies + builder = ContainerBuilder() + builder.register(OptionalFeature, autowire=False) + builder.register(MyService) + + async with builder.build() as container: + service = await container.get(MyService) + assert service.feature is None # Default value used instead + Named services -------------- @@ -187,29 +319,27 @@ Define services at module level for reuse: builder.register(database_definition) builder.register(cache_definition) -Testing with ServiceDefinition -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Testing with factories +~~~~~~~~~~~~~~~~~~~~~~ -Use ServiceDefinition to override services in tests: +Use factory functions to override services in tests: .. code-block:: python # test_services.py - from hdmi import ContainerBuilder, ServiceDefinition + import pytest + from hdmi import ContainerBuilder - def test_with_mock_database(): + @pytest.mark.anyio + async def test_with_mock_database(): # Create mock with custom factory (singleton by default) - mock_db_definition = ServiceDefinition( - DatabaseConnection, - factory=lambda: MockDatabase() - ) - builder = ContainerBuilder() - builder.register(mock_db_definition) - builder.register(UserService, scoped=True) # scoped service + builder.register(DatabaseConnection, factory=lambda: MockDatabase()) + builder.register(UserService) # depends on DatabaseConnection - container = builder.build() - # UserService will use the mock database + async with builder.build() as container: + service = await container.get(UserService) + # UserService will use the mock database Best practices -------------- @@ -217,17 +347,20 @@ Best practices 1. **Use shorthand for simple registrations**: If you only need type and scope, use ``builder.register(Type, scoped=True)`` or ``builder.register(Type, transient=True)`` directly. -2. **Use ServiceDefinition for complex scenarios**: When you need factories, - names, or want to pre-configure definitions. +2. **Always use async context manager**: Use ``async with builder.build() as container:`` + to ensure proper lifecycle management and finalizer execution. + +3. **Use initializers for setup**: Rather than calling setup methods manually, use + initializers to ensure services are properly configured. -3. **Don't mix approaches**: When registering a ServiceDefinition, never - provide boolean flag parameters (scoped/transient) to register(). +4. **Use finalizers for cleanup**: Register finalizers for services that need cleanup + (database connections, file handles, etc.) to ensure resources are released. -4. **Validate early**: ServiceDefinition validates the factory parameter - immediately, catching errors early. +5. **Use autowire=False for optional features**: When a service should only be + injected explicitly, not automatically into optional dependencies. -5. **Group related definitions**: Create collections of related ServiceDefinitions - that can be registered together. +6. **Validate early**: ServiceDefinition validates the factory, initializer, and + finalizer parameters immediately, catching errors early. See also -------- diff --git a/docs/index.rst b/docs/index.rst index 2f9bc5b..689a6bd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,13 @@ hdmi - Dynamic Dependency Injection for Python ============================================== +.. warning:: + + **Pre-Alpha Software** + + hdmi is experimental software in active development. Breaking changes may occur + until version 1.0. Use with care in production environments. + **hdmi** is a dependency injection framework for Python that manages dynamic dependencies with late (just-in-time) resolution. .. toctree:: @@ -9,8 +16,8 @@ hdmi - Dynamic Dependency Injection for Python tutorials/index how-to/index - reference/index explanation/index + reference/index Features -------- @@ -20,30 +27,34 @@ Features - **Scope Safety**: Build-time validation prevents lifetime bugs (singleton → scoped, etc.) - **Late Binding**: Services instantiated lazily (just-in-time) when first accessed - **Early Validation**: Configuration errors caught at build time, not runtime -- **Introspection Tools**: Inspect dependency graphs and resolution order at runtime +- **Async-First Design**: All service resolution is async for modern Python applications Quick example ------------- .. code-block:: python + import asyncio from hdmi import ContainerBuilder - # Phase 1: Configure services using boolean flags - builder = ContainerBuilder() - builder.register(DatabaseConnection) # singleton (default) - builder.register(UserRepository, scoped=True) # scoped service - builder.register(UserService, transient=True) # transient service + async def main(): + # Phase 1: Configure services using boolean flags + builder = ContainerBuilder() + builder.register(DatabaseConnection) # singleton (default) + builder.register(UserRepository, scoped=True) # scoped service + builder.register(UserService, transient=True) # transient service + + # Phase 2: Build & validate + async with builder.build() as container: # Validates scopes, cycles, dependencies - # Phase 2: Build & validate - container = builder.build() # Validates scopes, cycles, dependencies + # Phase 3: Resolve services (lazy instantiation) + db = await container.get(DatabaseConnection) # Singleton - accessible directly - # Phase 3: Resolve services (lazy instantiation) - db = container.get(DatabaseConnection) # Singleton - accessible directly + # Scoped services require a scope context + async with container.scope() as scoped: + repo = await scoped.get(UserRepository) - # Scoped services require a scope context - with container.scope() as scope: - service = scope.get(UserService) + asyncio.run(main()) Quick links ----------- diff --git a/docs/reference/api.rst b/docs/reference/api.rst index c3ab859..02b35c1 100644 --- a/docs/reference/api.rst +++ b/docs/reference/api.rst @@ -36,7 +36,10 @@ ServiceDefinition - **scoped**: Boolean flag indicating if the service is scoped (default: False) - **transient**: Boolean flag indicating if the service is transient (default: False) - **name**: Optional name for named registrations - - **factory**: Optional factory callable for custom instantiation + - **factory**: Optional factory callable for custom instantiation (sync or async) + - **autowire**: Whether to auto-inject this service into optional dependencies (default: True) + - **initializer**: Optional callback called after service instantiation (sync or async) + - **finalizer**: Optional callback called when container/scope exits (sync or async) The combination of scoped and transient flags creates four service types: @@ -70,9 +73,25 @@ ServiceDefinition factory=create_service ) - # Register with ContainerBuilder + # With lifecycle hooks + definition_with_hooks = ServiceDefinition( + MyService, + initializer=lambda s: s.setup(), # Called after instantiation + finalizer=lambda s: s.cleanup() # Called when container exits + ) + + # With async hooks + async def async_init(service): + await service.connect() + + async_definition = ServiceDefinition( + MyService, + initializer=async_init + ) + + # Registration: use the service_type directly with kwargs builder = ContainerBuilder() - builder.register(definition) # Note: no boolean flags when using ServiceDefinition + builder.register(MyService, scoped=True, factory=create_service) ScopedContainer ~~~~~~~~~~~~~~~ @@ -195,39 +214,57 @@ Basic registration .. code-block:: python + import asyncio from hdmi import ContainerBuilder - builder = ContainerBuilder() - builder.register(DatabaseService) # singleton (default) - builder.register(UserRepository, scoped=True) # scoped service + async def main(): + builder = ContainerBuilder() + builder.register(DatabaseService) # singleton (default) + builder.register(UserRepository, scoped=True) # scoped service - container = builder.build() + async with builder.build() as container: + # Singleton services can be accessed directly + db = await container.get(DatabaseService) - # Singleton services can be accessed directly - db = container.get(DatabaseService) + # Scoped services require a scope context + async with container.scope() as scoped: + repo = await scoped.get(UserRepository) - # Scoped services require a scope context - with container.scope() as scope: - repo = scope.get(UserRepository) + asyncio.run(main()) -Using service definition -~~~~~~~~~~~~~~~~~~~~~~~~ +Using lifecycle hooks +~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python - from hdmi import ContainerBuilder, ServiceDefinition + import asyncio + from hdmi import ContainerBuilder - # Create definitions with custom configuration - db_definition = ServiceDefinition( - DatabaseService, - name="primary_db" # singleton by default - ) + class DatabaseService: + def __init__(self): + self.connected = False + + def connect(self): + self.connected = True + + def disconnect(self): + self.connected = False + + async def main(): + builder = ContainerBuilder() + builder.register( + DatabaseService, + initializer=lambda db: db.connect(), + finalizer=lambda db: db.disconnect() + ) + + async with builder.build() as container: + db = await container.get(DatabaseService) + assert db.connected # initializer was called - # Register definitions (no boolean flag parameters allowed) - builder = ContainerBuilder() - builder.register(db_definition) + # After exiting context, finalizer is called - container = builder.build() + asyncio.run(main()) See also --------