Source code for dioxide.exceptions

"""Custom exception classes for dioxide dependency injection errors.

This module defines descriptive exception classes that provide helpful, actionable
error messages when dependency resolution fails. These exceptions replace generic
KeyError exceptions with detailed information about what went wrong and how to fix it.

dioxide's error messages follow a consistent pattern:
    1. **What failed**: Clear identification of the problem
    2. **Context**: Active profile, available alternatives, dependencies
    3. **Troubleshooting**: Specific guidance on how to fix the issue
    4. **Code examples**: Concrete examples showing the fix

All exceptions are raised during ``container.resolve()`` operations when the
requested type cannot be provided. The exceptions include contextual information
to help you quickly identify and fix configuration issues.

Exception Hierarchy:
    - AdapterNotFoundError: Raised when resolving a port (Protocol/ABC) fails
    - ServiceNotFoundError: Raised when resolving a service/component fails
    - CircularDependencyError: Raised when lifecycle initialization detects cycles

Common Resolution Patterns:

    1. **Missing adapter for profile**::

        # Problem: No TEST adapter for EmailPort
        container.scan(profile=Profile.TEST)
        container.resolve(EmailPort)  # AdapterNotFoundError


        # Solution: Add TEST adapter
        @adapter.for_(EmailPort, profile=Profile.TEST)
        class FakeEmailAdapter:
            pass

    2. **Missing service registration**::

        # Problem: UserService not decorated
        class UserService:
            pass


        container.resolve(UserService)  # ServiceNotFoundError


        # Solution: Add @service decorator
        @service
        class UserService:
            pass

    3. **Unresolvable dependency**::

        # Problem: DatabasePort dependency not registered
        @service
        class UserService:
            def __init__(self, db: DatabasePort):
                pass


        container.resolve(UserService)  # ServiceNotFoundError (shows DatabasePort missing)


        # Solution: Register adapter for DatabasePort
        @adapter.for_(DatabasePort, profile=Profile.PRODUCTION)
        class PostgresAdapter:
            pass

    4. **Profile mismatch**::

        # Problem: Only PRODUCTION adapter exists, scanning TEST profile
        @adapter.for_(EmailPort, profile=Profile.PRODUCTION)
        class SendGridAdapter:
            pass


        container.scan(profile=Profile.TEST)
        container.resolve(EmailPort)  # AdapterNotFoundError (lists available profiles)


        # Solution: Add TEST profile adapter
        @adapter.for_(EmailPort, profile=Profile.TEST)
        class FakeEmailAdapter:
            pass

    5. **Circular dependency in lifecycle**::

        # Problem: A depends on B, B depends on A
        @service
        @lifecycle
        class ServiceA:
            def __init__(self, b: ServiceB):
                pass


        @service
        @lifecycle
        class ServiceB:
            def __init__(self, a: ServiceA):
                pass


        await container.start()  # CircularDependencyError

        # Solution: Break the cycle (use interface, lazy resolution, or redesign)

Error Message Anatomy:
    dioxide error messages include multiple sections to help debugging:

    **AdapterNotFoundError message structure**::

        No adapter registered for port EmailPort with profile 'test'.

        Available adapters for EmailPort:
          SendGridAdapter (profiles: production)
          ConsoleEmailAdapter (profiles: development)

        Hint: Add an adapter for profile 'test':
          @adapter.for_(EmailPort, profile='test')

    **ServiceNotFoundError message structure**::

        Cannot resolve UserService (active profile: 'test').

        UserService has dependencies: db: DatabasePort, email: EmailPort

        One or more dependencies could not be resolved.
        Check that all dependencies are registered with @service or @adapter.for_().

Debugging Strategies:

    1. **Check profile matches**:
        - Verify ``container.scan(profile=X)`` matches adapter profiles
        - Use ``Profile.ALL`` ('*') for universal adapters
        - Check for typos in profile names (case-insensitive)

    2. **Verify decorators**:
        - Services need ``@service`` decorator
        - Adapters need ``@adapter.for_(Port, profile=...)`` decorator
        - Lifecycle components need ``@lifecycle`` decorator
        - Check decorator order: ``@adapter.for_() @lifecycle class ...``

    3. **Inspect dependencies**:
        - Constructor parameters must have type hints
        - Type hints must reference registered types (ports or services)
        - Circular dependencies are not allowed for @lifecycle components

    4. **Check import order**:
        - Decorators execute at import time
        - Call ``container.scan()`` after all modules are imported
        - Or use ``container.scan(package="myapp")`` to auto-import

    5. **Use separate containers for tests**:
        - Create fresh container per test for isolation
        - Scan with ``Profile.TEST`` to activate fake adapters
        - Check that fake adapters are decorated with correct profile

Prevention Tips:
    - **Use type hints**: Enable early detection of missing dependencies
    - **Run mypy**: Static type checking catches port/implementation mismatches
    - **Profile-specific tests**: Verify each profile has required adapters
    - **Integration smoke tests**: Test that production profile resolves all services
    - **Fail fast**: Resolve all services at startup to catch errors early

See Also:
    - :class:`dioxide.container.Container.resolve` - Where exceptions are raised
    - :class:`dioxide.container.Container.scan` - Profile-based scanning
    - :class:`dioxide.adapter.adapter` - For marking adapters
    - :class:`dioxide.services.service` - For marking services
"""

from __future__ import annotations

from typing import (
    TYPE_CHECKING,
    Self,
)

if TYPE_CHECKING:
    from dioxide.scope import Scope


# Base URL for documentation links
[docs] DOCS_BASE_URL = 'https://dioxide.readthedocs.io/en/stable'
[docs] class DioxideError(Exception): """Base class for all dioxide errors with rich formatting. DioxideError provides structured error information including: - A clear title describing the error - Context dict with relevant state at error time - Suggestions for how to fix the issue - Optional code example showing the fix - Documentation URL for detailed troubleshooting Subclasses should set appropriate defaults for title and docs_url, and populate context, suggestions, and example based on the specific error. """
[docs] title: str = 'Dioxide Error'
[docs] docs_url: str | None = f'{DOCS_BASE_URL}/troubleshooting/'
def __init__(self, message: str = '') -> None: super().__init__(message) self._message = message
[docs] self.context: dict[str, object] = {}
[docs] self.suggestions: list[str] = []
[docs] self.example: str | None = None
[docs] def __str__(self) -> str: """Format the error with title, context, suggestions, and example.""" lines: list[str] = [] # Title and message lines.append(f'{self.title}: {self._message}') # Context section if self.context: lines.append('') lines.append('Context:') for key, value in self.context.items(): lines.append(f' - {key}: {value}') # Suggestions section if self.suggestions: lines.append('') lines.append('Suggestions:') for suggestion in self.suggestions: lines.append(f' - {suggestion}') # Example section if self.example: lines.append('') lines.append('Example fix:') for line in self.example.split('\n'): lines.append(f' {line}') # Documentation URL if self.docs_url: lines.append('') lines.append(f'-> See: {self.docs_url}') return '\n'.join(lines)
[docs] def with_context(self, **kwargs: object) -> Self: """Add context information to the error. Args: **kwargs: Key-value pairs to add to the context dict. Returns: Self for method chaining. """ self.context.update(kwargs) return self
[docs] def with_suggestion(self, suggestion: str) -> Self: """Add a suggestion for how to fix the error. Args: suggestion: A suggestion string to add. Returns: Self for method chaining. """ self.suggestions.append(suggestion) return self
[docs] def with_example(self, example: str) -> Self: """Add an example code snippet showing how to fix the error. Args: example: Code example string. Returns: Self for method chaining. """ self.example = example return self
[docs] class ResolutionError(DioxideError): """Base class for dependency resolution failures. ResolutionError is raised when the container cannot resolve a requested type. This is the parent class for more specific resolution errors like AdapterNotFoundError and ServiceNotFoundError. """
[docs] title: str = 'Resolution Failed'
[docs] docs_url: str | None = f'{DOCS_BASE_URL}/troubleshooting/'
[docs] class AdapterNotFoundError(ResolutionError): """Raised when no adapter is registered for a port in the active profile. This error occurs when trying to resolve a Protocol or ABC (port) but no concrete implementation (adapter) is registered for the current profile. It indicates a profile mismatch or missing adapter registration. In hexagonal architecture, ports are abstract interfaces (Protocols/ABCs) and adapters are concrete implementations. The container injects the active adapter based on the current profile. This error means: 1. No adapter exists for this port + profile combination, OR 2. An adapter exists but for a different profile, OR 3. The adapter wasn't imported before container.scan() The error message includes: - **Port type**: Which Protocol/ABC couldn't be resolved - **Active profile**: Current profile from container.scan(profile=...) - **Available adapters**: List of adapters for this port in other profiles - **Registration hint**: Code example showing how to fix When This Occurs: - ``container.resolve(PortType)`` - Port has no adapter for active profile - ``container.resolve(ServiceType)`` - Service depends on port with no adapter - ``container.start()`` - Lifecycle component depends on port with no adapter Common Causes: 1. **Profile mismatch**: Adapter registered for PRODUCTION, scanning TEST 2. **Missing test adapter**: Production adapter exists, no TEST fake created 3. **Typo in profile name**: 'test' vs 'testing' (case-insensitive) 4. **Adapter not imported**: Decorator not executed before scan() 5. **Forgot @adapter.for_() decorator**: Class exists but not registered Examples: Profile mismatch (most common):: from typing import Protocol from dioxide import Container, adapter, Profile class EmailPort(Protocol): async def send(self, to: str, subject: str, body: str) -> None: ... # Only production adapter registered @adapter.for_(EmailPort, profile=Profile.PRODUCTION) class SendGridAdapter: async def send(self, to: str, subject: str, body: str) -> None: pass container = Container() container.scan(profile=Profile.TEST) # Scanning TEST profile try: container.resolve(EmailPort) # No TEST adapter! except AdapterNotFoundError as e: print(e) # Output: # No adapter registered for port EmailPort with profile 'test'. # # Available adapters for EmailPort: # SendGridAdapter (profiles: production) # # Hint: Add an adapter for profile 'test': # @adapter.for_(EmailPort, profile='test') # Solution: Add TEST adapter @adapter.for_(EmailPort, profile=Profile.TEST) class FakeEmailAdapter: def __init__(self): self.sent_emails = [] async def send(self, to: str, subject: str, body: str) -> None: self.sent_emails.append({'to': to, 'subject': subject, 'body': body}) Missing adapter completely:: class DatabasePort(Protocol): async def query(self, sql: str) -> list[dict]: ... @service class UserService: def __init__(self, db: DatabasePort): # Depends on DatabasePort self.db = db container = Container() container.scan(profile=Profile.PRODUCTION) try: container.resolve(UserService) # UserService needs DatabasePort except AdapterNotFoundError as e: print(e) # Output: # No adapter registered for port DatabasePort with profile 'production'. # # No adapters registered for DatabasePort. # # Hint: Register an adapter: # @adapter.for_(DatabasePort, profile='production') # class YourAdapter: # ... # Solution: Register adapter @adapter.for_(DatabasePort, profile=Profile.PRODUCTION) class PostgresAdapter: async def query(self, sql: str) -> list[dict]: pass Universal adapter (works in all profiles):: @adapter.for_(LoggerPort, profile=Profile.ALL) # '*' means all profiles class ConsoleLogger: def log(self, message: str) -> None: print(message) # Works with any profile container.scan(profile=Profile.TEST) logger = container.resolve(LoggerPort) # Success! Troubleshooting: 1. **Check profile**: Verify ``container.scan(profile=X)`` matches adapter profile 2. **List available**: Look at "Available adapters" section in error message 3. **Check imports**: Ensure adapter module is imported before scan() 4. **Verify decorator**: Check ``@adapter.for_(Port, profile=...)`` is present 5. **Use Profile enum**: Prefer ``Profile.TEST`` over string ``'test'`` 6. **Case-insensitive**: 'Test', 'TEST', 'test' all match (normalized to lowercase) Best Practices: - **Create fake adapters**: Every production adapter needs a test fake - **Use Profile.ALL sparingly**: Only for truly universal adapters (logging, etc.) - **Fail fast**: Resolve all services at startup to catch missing adapters early - **Explicit profiles**: Use ``Profile`` enum instead of strings - **Import all adapters**: Use ``container.scan(package="myapp")`` for auto-import See Also: - :class:`dioxide.adapter.adapter` - How to register adapters - :class:`dioxide.container.Container.scan` - Profile-based scanning - :class:`dioxide.container.Container.resolve` - Where this is raised - :class:`dioxide.profile_enum.Profile` - Standard profile values """
[docs] title: str = 'Adapter Not Found'
[docs] docs_url: str | None = f'{DOCS_BASE_URL}/troubleshooting/adapter-not-found.html'
def __init__( self, message: str = '', *, port: type | None = None, profile: str | None = None, available_adapters: list[tuple[str, list[str]]] | None = None, ) -> None: """Initialize AdapterNotFoundError with optional structured context. Args: message: Simple string message (backward compatible). port: The port type that couldn't be resolved. profile: The active profile when resolution failed. available_adapters: List of (adapter_name, profiles) tuples for adapters registered for this port in other profiles. """ # Build message from structured data if provided if port is not None: port_name = port.__name__ profile_str = profile if profile else 'unknown' lines = [f"No adapter for {port_name} in profile '{profile_str}'"] if available_adapters: adapter_strs = [f'{name} ({", ".join(profiles)})' for name, profiles in available_adapters] lines.append(f' Registered: {", ".join(adapter_strs)}') else: lines.append(' Registered: none') message = '\n'.join(lines) super().__init__(message) # Store structured data for programmatic access if port is not None: self.context['port'] = port.__name__ if profile is not None: self.context['profile'] = profile if available_adapters is not None: self.context['available_adapters'] = available_adapters
[docs] class ServiceNotFoundError(ResolutionError): """Raised when a service or component cannot be resolved. This error occurs when trying to resolve a service/component that either: 1. Is not registered in the container (missing ``@service`` decorator), OR 2. Has dependencies that cannot be resolved (missing adapters or services), OR 3. Was not imported before ``container.scan()`` was called Unlike AdapterNotFoundError (for ports), this error applies to concrete classes marked with ``@service`` or ``@component``. The error message helps identify whether the service itself is missing or one of its dependencies is unresolvable. The error message includes: - **Service type**: Which service/component couldn't be resolved - **Active profile**: Current profile (if relevant to the error) - **Dependencies**: Constructor parameters and their types - **Missing dependency**: Which specific dependency failed (if applicable) - **Registration hint**: Code example showing how to fix When This Occurs: - ``container.resolve(ServiceType)`` - Service not registered or has missing deps - ``container.resolve(OtherService)`` - OtherService depends on unregistered service - ``container.start()`` - Lifecycle component can't be resolved Common Causes: 1. **Missing @service decorator**: Class not decorated, not in registry 2. **Unresolvable dependency**: Service depends on unregistered port or service 3. **Not imported**: Service module not imported before scan() 4. **Profile mismatch on dependency**: Dependency is an adapter with wrong profile 5. **Typo in type hint**: Constructor parameter references non-existent type Examples: Service not registered:: from dioxide import Container # Forgot @service decorator! class UserService: def create_user(self, name: str): pass container = Container() container.scan() try: container.resolve(UserService) except ServiceNotFoundError as e: print(e) # Output: # Cannot resolve UserService. # # UserService is not registered in the container. # # Hint: Register the service: # @service # class UserService: # ... # Solution: Add @service decorator from dioxide import service @service class UserService: def create_user(self, name: str): pass Service with unresolvable dependency:: from dioxide import service, Container @service class UserService: def __init__(self, db: DatabasePort): # DatabasePort not registered! self.db = db container = Container() container.scan() try: container.resolve(UserService) except ServiceNotFoundError as e: print(e) # Output: # Cannot resolve UserService. # # UserService has dependencies: db: DatabasePort # # One or more dependencies could not be resolved. # Check that all dependencies are registered with @service or @adapter.for_(). # Solution: Register adapter for DatabasePort from dioxide import adapter, Profile @adapter.for_(DatabasePort, profile=Profile.PRODUCTION) class PostgresAdapter: async def query(self, sql: str) -> list[dict]: pass Service with multiple dependencies:: @service class NotificationService: def __init__(self, email: EmailPort, sms: SMSPort, db: DatabasePort): self.email = email self.sms = sms self.db = db # If any dependency is missing, error shows ALL dependencies try: container.resolve(NotificationService) except ServiceNotFoundError as e: # Shows: email: EmailPort, sms: SMSPort, db: DatabasePort # Helps identify which specific dependency is missing Circular dependency (non-lifecycle):: @service class ServiceA: def __init__(self, b: 'ServiceB'): # Forward reference self.b = b @service class ServiceB: def __init__(self, a: ServiceA): self.a = a # This will cause RecursionError during resolution # (CircularDependencyError only applies to @lifecycle components) try: container.resolve(ServiceA) except RecursionError: # Redesign to break circular dependency pass Troubleshooting: 1. **Check decorator**: Verify ``@service`` or ``@component`` is present 2. **Verify imports**: Ensure service module is imported before scan() 3. **Check dependencies**: Look at "has dependencies" section in error message 4. **Resolve dependencies first**: Manually resolve each dependency to find which one fails 5. **Check type hints**: Ensure constructor parameters have correct type annotations 6. **Profile mismatch**: If dependency is a port, check adapter profile matches 7. **Forward references**: Use string quotes for forward references: ``'ServiceB'`` Best Practices: - **Fail fast**: Resolve all services at startup to catch missing registrations early - **Integration tests**: Test that all services can be resolved in each profile - **Explicit imports**: Import all service modules before calling scan() - **Use scan(package="myapp")**: Auto-import all modules in a package - **Type hints required**: Constructor parameters must have type annotations - **Check profiles**: Dependency adapters must match active profile See Also: - :class:`dioxide.services.service` - How to register services - :class:`dioxide.adapter.adapter` - How to register adapters (for dependencies) - :class:`dioxide.container.Container.scan` - Auto-discovery and registration - :class:`dioxide.container.Container.resolve` - Where this is raised - :class:`AdapterNotFoundError` - For port resolution errors """
[docs] title: str = 'Service Not Found'
[docs] docs_url: str | None = f'{DOCS_BASE_URL}/troubleshooting/service-not-found.html'
def __init__( self, message: str = '', *, service: type | None = None, profile: str | None = None, dependencies: list[str] | None = None, failed_dependency: tuple[str, type, str] | None = None, ) -> None: """Initialize ServiceNotFoundError with optional structured context. Args: message: Simple string message (backward compatible). service: The service type that couldn't be resolved. profile: The active profile when resolution failed. dependencies: List of dependency type names (e.g., ['email: EmailPort']). failed_dependency: Tuple of (param_name, param_type, failure_reason) for the specific dependency that failed. """ # Build message from structured data if provided if service is not None: service_name = service.__name__ profile_str = profile if profile else 'unknown' lines = [f"Cannot resolve {service_name} in profile '{profile_str}'"] if failed_dependency: param_name, param_type, reason = failed_dependency param_type_name = param_type.__name__ if hasattr(param_type, '__name__') else str(param_type) lines.append(f' Missing dependency: {param_name}: {param_type_name} ({reason})') elif dependencies is not None: # dependencies=[] means registered with no deps, dependencies=['...'] has deps if dependencies: lines.append(f' Dependencies: {", ".join(dependencies)}') else: lines.append(' No dependencies') else: lines.append(' Not registered (missing @service decorator)') message = '\n'.join(lines) super().__init__(message) # Store structured data for programmatic access if service is not None: self.context['service'] = service.__name__ if profile is not None: self.context['profile'] = profile if dependencies is not None: self.context['dependencies'] = dependencies if failed_dependency is not None: param_name, param_type, reason = failed_dependency self.context['failed_dependency'] = { 'param_name': param_name, 'param_type': param_type.__name__ if hasattr(param_type, '__name__') else str(param_type), 'reason': reason, }
[docs] class ScopeError(DioxideError): """Raised when scope-related operations fail. This error occurs when: 1. Attempting to resolve a REQUEST-scoped component outside of a scope context 2. Attempting to create nested scopes (not supported in v0.3.0) 3. Other scope lifecycle violations REQUEST-scoped components require an active scope created via ``container.create_scope()``. Attempting to resolve them from the parent container or outside any scope context will raise this error. The error message includes: - **Component type**: Which component couldn't be resolved - **Scope requirement**: Why a scope is needed - **Fix hint**: How to create a scope context When This Occurs: - ``container.resolve(RequestScopedType)`` - REQUEST component outside scope - ``scope.create_scope()`` - Nested scope attempt (not supported) - Other scope lifecycle violations Common Causes: 1. **No scope context**: Resolving REQUEST component from parent container 2. **Scope not started**: Scope context manager not entered 3. **Nested scope**: Trying to create scope within another scope Examples: REQUEST component outside scope:: from dioxide import service, Scope, Container @service(scope=Scope.REQUEST) class RequestContext: pass container = Container() container.scan() try: container.resolve(RequestContext) # No scope! except ScopeError as e: print(e) # Output: # Cannot resolve RequestContext: REQUEST-scoped components require an active scope. # # Hint: Use container.create_scope() to create a scope context: # async with container.create_scope() as scope: # ctx = scope.resolve(RequestContext) # Solution: Create a scope async with container.create_scope() as scope: ctx = scope.resolve(RequestContext) # Works! Nested scope attempt:: async with container.create_scope() as outer: try: async with outer.create_scope() as inner: # Nested! pass except ScopeError as e: print(e) # Output: # Nested scopes are not supported in v0.3.0 Best Practices: - **Create scope at entry points**: Web request handlers, CLI commands, background tasks - **Pass scope to dependencies**: Or let container inject scoped dependencies - **One scope per request**: Don't nest scopes; use one scope per logical request - **Use async context manager**: ``async with container.create_scope() as scope:`` See Also: - :class:`dioxide.container.Container.create_scope` - How to create scopes - :class:`dioxide.container.ScopedContainer` - The scoped container type - :class:`dioxide.scope.Scope` - Scope enum including REQUEST """
[docs] title: str = 'Scope Error'
[docs] docs_url: str | None = f'{DOCS_BASE_URL}/troubleshooting/scope-error.html'
def __init__( self, message: str = '', *, component: type | None = None, required_scope: Scope | None = None, ) -> None: """Initialize ScopeError with optional structured context. Args: message: Simple string message (backward compatible). component: The component that requires a scope. required_scope: The scope type required (e.g., Scope.REQUEST). """ # Build message from structured data if provided if component is not None: component_name = component.__name__ scope_str = required_scope.name if required_scope is not None else 'REQUEST' message = f'Cannot resolve {component_name}: {scope_str}-scoped, requires active scope' super().__init__(message) # Store structured data for programmatic access if component is not None: self.context['component'] = component.__name__ if required_scope is not None: self.context['required_scope'] = required_scope.name
[docs] class CaptiveDependencyError(DioxideError): """Raised when a longer-lived scope depends on a shorter-lived scope. This error occurs during ``container.scan()`` when a SINGLETON component depends on a REQUEST-scoped component. This is called a "captive dependency" because the REQUEST component would be "captured" by the SINGLETON and never refreshed, defeating the purpose of request scoping. The problem with captive dependencies: - SINGLETON lives for the container's lifetime - REQUEST should be fresh for each scope - If SINGLETON holds REQUEST, the same REQUEST instance is reused forever - This violates the REQUEST scope contract and causes subtle bugs The error message includes: - **Parent component**: The SINGLETON that incorrectly depends on REQUEST - **Child component**: The REQUEST-scoped dependency - **Explanation**: Why this combination is invalid - **Fix suggestions**: How to restructure the dependencies When This Occurs: - ``container.scan()`` - During dependency graph validation - Early detection prevents runtime issues Common Causes: 1. **SINGLETON depends on REQUEST**: Most common case 2. **Scope mismatch**: Accidentally used wrong scope on decorator 3. **Transitive dependency**: SINGLETON -> SERVICE -> REQUEST Valid Scope Dependencies: - SINGLETON -> SINGLETON (OK: same lifetime) - SINGLETON -> FACTORY (OK: creates new instance) - REQUEST -> SINGLETON (OK: shorter uses longer) - REQUEST -> REQUEST (OK: same scope) - REQUEST -> FACTORY (OK: creates new instance) - FACTORY -> any (OK: always creates new) Invalid Scope Dependencies: - SINGLETON -> REQUEST (INVALID: captive dependency) Examples: Captive dependency detected at scan time:: from dioxide import service, Scope, Container @service(scope=Scope.REQUEST) class RequestContext: def __init__(self): self.request_id = '...' @service # SINGLETON (default) class GlobalService: def __init__(self, ctx: RequestContext): # BAD: SINGLETON -> REQUEST self.ctx = ctx container = Container() try: container.scan() except CaptiveDependencyError as e: print(e) # Output: # Captive dependency detected: GlobalService (SINGLETON) depends on # RequestContext (REQUEST). # # SINGLETON components cannot depend on REQUEST-scoped components because # the REQUEST instance would be captured and never refreshed. # # Solutions: # 1. Change GlobalService to REQUEST scope: # @service(scope=Scope.REQUEST) # 2. Change RequestContext to SINGLETON scope (if appropriate) # 3. Use a factory/provider pattern to get fresh instances Valid dependency structure:: @service(scope=Scope.SINGLETON) class AppConfig: pass @service(scope=Scope.REQUEST) class RequestHandler: def __init__(self, config: AppConfig): # OK: REQUEST -> SINGLETON self.config = config Solutions: 1. **Change parent scope**:: # Make the parent REQUEST-scoped too @service(scope=Scope.REQUEST) class RequestService: def __init__(self, ctx: RequestContext): self.ctx = ctx 2. **Change child scope** (if appropriate):: # If the child doesn't truly need request scope @service # SINGLETON class SharedContext: pass 3. **Use factory/provider pattern**:: @service # SINGLETON class GlobalService: def __init__(self, container: Container): self.container = container def get_context(self) -> RequestContext: # Get fresh instance from current scope return current_scope.resolve(RequestContext) Best Practices: - **Review scope assignments**: Ensure scopes match component lifetimes - **Fail fast**: Error at scan() time prevents runtime surprises - **Draw dependency graph**: Visualize scope relationships - **Default to REQUEST**: For components that vary per-request See Also: - :class:`dioxide.scope.Scope` - Scope enum (SINGLETON, REQUEST, FACTORY) - :class:`dioxide.container.Container.scan` - Where this error is raised - :class:`ScopeError` - For runtime scope errors """
[docs] title: str = 'Captive Dependency'
[docs] docs_url: str | None = f'{DOCS_BASE_URL}/troubleshooting/captive-dependency.html'
def __init__( self, message: str = '', *, parent: type | None = None, parent_scope: Scope | None = None, child: type | None = None, child_scope: Scope | None = None, ) -> None: """Initialize CaptiveDependencyError with optional structured context. Args: message: Simple string message (backward compatible). parent: The longer-lived component (e.g., SINGLETON). parent_scope: The scope of the parent (e.g., Scope.SINGLETON). child: The shorter-lived component (e.g., REQUEST). child_scope: The scope of the child (e.g., Scope.REQUEST). """ # Build message from structured data if provided if parent is not None and child is not None: parent_name = parent.__name__ child_name = child.__name__ parent_scope_str = parent_scope.name if parent_scope is not None else 'SINGLETON' child_scope_str = child_scope.name if child_scope is not None else 'REQUEST' message = ( f'Captive dependency: {parent_name} ({parent_scope_str}) -> {child_name} ({child_scope_str})\n' f' {parent_scope_str} cannot depend on {child_scope_str}-scoped components' ) super().__init__(message) # Store structured data for programmatic access if parent is not None: self.context['parent'] = parent.__name__ if parent_scope is not None: self.context['parent_scope'] = parent_scope.name if child is not None: self.context['child'] = child.__name__ if child_scope is not None: self.context['child_scope'] = child_scope.name
[docs] class CircularDependencyError(DioxideError): """Raised when circular dependencies are detected among @lifecycle components. This error occurs during ``container.start()`` when @lifecycle components have circular dependencies that prevent topological sorting. The container needs to determine initialization order (dependencies before dependents), but a cycle makes this impossible. This error ONLY applies to @lifecycle components during startup. Regular services without @lifecycle can have circular dependencies (though not recommended) because they're instantiated lazily on-demand, not in dependency order at startup. A circular dependency exists when: - Component A depends on B - Component B depends on C - Component C depends on A (cycle!) The container cannot determine which component to initialize first because each depends on another being already initialized. The error message includes: - **Unprocessed components**: Set of components involved in the cycle - **Context**: Which lifecycle components couldn't be sorted When This Occurs: - ``await container.start()`` - During lifecycle initialization order calculation - ``async with container:`` - When entering the context manager Common Causes: 1. **Direct cycle**: A → B → A 2. **Indirect cycle**: A → B → C → D → A 3. **Self-dependency**: Component depends on itself (rare) 4. **Bidirectional deps**: Two components that need each other Examples: Direct circular dependency:: from dioxide import service, lifecycle, Container @service @lifecycle class ServiceA: def __init__(self, b: 'ServiceB'): # Depends on B self.b = b async def initialize(self) -> None: pass async def dispose(self) -> None: pass @service @lifecycle class ServiceB: def __init__(self, a: ServiceA): # Depends on A - CYCLE! self.a = a async def initialize(self) -> None: pass async def dispose(self) -> None: pass container = Container() container.scan() try: await container.start() except CircularDependencyError as e: print(e) # Output: # Circular dependency detected involving: {<ServiceA>, <ServiceB>} Indirect circular dependency:: @service @lifecycle class CacheService: def __init__(self, user_repo: UserRepository): self.user_repo = user_repo @service @lifecycle class UserRepository: def __init__(self, db: DatabaseAdapter): self.db = db @adapter.for_(DatabasePort, profile=Profile.PRODUCTION) @lifecycle class DatabaseAdapter: def __init__(self, cache: CacheService): # CYCLE! self.cache = cache # Cycle: CacheService → UserRepository → DatabaseAdapter → CacheService await container.start() # CircularDependencyError Solutions: 1. **Break dependency with interface**:: # Instead of depending on concrete class, depend on port class CachePort(Protocol): def get(self, key: str) -> Any: ... @service @lifecycle class ServiceA: def __init__(self, cache: CachePort): # Depend on abstraction self.cache = cache 2. **Remove @lifecycle from one component**:: # If only one component truly needs lifecycle, remove from others @service # No @lifecycle - lazy initialization class ServiceB: def __init__(self, a: ServiceA): self.a = a @service @lifecycle # Only this one has lifecycle class ServiceA: async def initialize(self) -> None: pass 3. **Lazy resolution**:: @service @lifecycle class ServiceA: def __init__(self, container: Container): self.container = container self._b = None @property def b(self) -> ServiceB: if self._b is None: self._b = self.container.resolve(ServiceB) return self._b 4. **Redesign to remove cycle**:: # Extract shared logic to a third service @service class SharedLogic: pass @service @lifecycle class ServiceA: def __init__(self, shared: SharedLogic): self.shared = shared @service @lifecycle class ServiceB: def __init__(self, shared: SharedLogic): self.shared = shared Troubleshooting: 1. **Identify cycle**: Look at "involving" set in error message 2. **Map dependencies**: Draw dependency graph on paper 3. **Find weak link**: Identify which dependency is least essential 4. **Remove @lifecycle**: Not all components need lifecycle management 5. **Use abstractions**: Depend on ports instead of concrete classes 6. **Lazy initialization**: Defer resolution to first use Best Practices: - **Avoid circular dependencies**: Design for acyclic dependency graphs - **Use hexagonal architecture**: Depend on abstractions (ports) at boundaries - **Limit @lifecycle**: Only use for components that truly need init/dispose - **Dependency injection**: Let container manage dependencies, avoid manual creation - **Single Responsibility**: Components with clear responsibilities rarely cycle - **Test initialization**: Integration test that calls ``container.start()`` Note: Non-lifecycle services CAN have circular dependencies (though not recommended). The container resolves them lazily on-demand. This error ONLY applies to @lifecycle components during ``start()`` because they need explicit ordering. See Also: - :class:`dioxide.lifecycle.lifecycle` - Lifecycle management decorator - :class:`dioxide.container.Container.start` - Where this error is raised - :class:`dioxide.services.service` - For marking services - :class:`dioxide.adapter.adapter` - For marking adapters """
[docs] title: str = 'Circular Dependency'
[docs] docs_url: str | None = f'{DOCS_BASE_URL}/troubleshooting/circular-dependency.html'