hdmi 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hdmi-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Romain Dorgueil
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
hdmi-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.3
2
+ Name: hdmi
3
+ Version: 0.1.0
4
+ Summary: A dependency injection framework for Python with dynamic late-binding resolution
5
+ Author: Romain Dorgueil
6
+ Author-email: Romain Dorgueil <romain@makersquad.fr>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2025 Romain Dorgueil
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ Requires-Dist: anyio>=4.0.0
29
+ Requires-Dist: pytest>=8.0.0 ; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=4.1.0 ; extra == 'dev'
31
+ Requires-Dist: ruff>=0.8.0 ; extra == 'dev'
32
+ Requires-Dist: basedpyright>=1.21.0 ; extra == 'dev'
33
+ Requires-Dist: sphinx>=8.0.0 ; extra == 'dev'
34
+ Requires-Dist: sphinx-autobuild>=2024.0.0 ; extra == 'dev'
35
+ Requires-Dist: furo>=2024.0.0 ; extra == 'dev'
36
+ Requires-Dist: sphinxcontrib-mermaid>=1.0.0 ; extra == 'dev'
37
+ Requires-Dist: twine>=6.0.0 ; extra == 'dev'
38
+ Requires-Python: >=3.13
39
+ Provides-Extra: dev
40
+ Description-Content-Type: text/markdown
41
+
42
+ # hdmi - Dependency Management Interface
43
+
44
+ A lightweight dependency injection framework for Python 3.13+ with:
45
+
46
+ - **Type-driven dependency discovery** - Uses Python's standard type annotations
47
+ - **Scope-aware validation** - Prevents lifetime bugs at build time
48
+ - **Lazy instantiation** - Services created just-in-time
49
+ - **Early error detection** - Configuration errors caught at build time
50
+
51
+ ## Quick Example
52
+
53
+ ### Simple Example (Singleton Services)
54
+
55
+ ```python
56
+ from hdmi import ContainerBuilder
57
+
58
+ # Define your services
59
+ class DatabaseConnection:
60
+ def __init__(self):
61
+ self.connected = True
62
+
63
+ class UserRepository:
64
+ def __init__(self, db: DatabaseConnection):
65
+ self.db = db
66
+
67
+ class UserService:
68
+ def __init__(self, repo: UserRepository):
69
+ self.repo = repo
70
+
71
+ # Configure the container (all singletons by default)
72
+ builder = ContainerBuilder()
73
+ builder.register(DatabaseConnection)
74
+ builder.register(UserRepository)
75
+ builder.register(UserService)
76
+
77
+ # Build validates the dependency graph
78
+ container = builder.build()
79
+
80
+ # Resolve services lazily - dependencies are auto-wired!
81
+ user_service = container.get(UserService)
82
+ ```
83
+
84
+ ### Using Scoped Services
85
+
86
+ ```python
87
+ # For request-scoped services (e.g., web requests)
88
+ builder = ContainerBuilder()
89
+ builder.register(DatabaseConnection) # singleton (default)
90
+ builder.register(UserRepository, scoped=True) # One per request
91
+ builder.register(UserService, transient=True) # New each time
92
+
93
+ container = builder.build()
94
+
95
+ # Scoped services must be resolved within a scope context
96
+ with container.scope() as scoped:
97
+ user_service = scoped.get(UserService)
98
+ # All scoped dependencies share the same instance within this scope
99
+ ```
100
+
101
+ ## Key Features
102
+
103
+ ### Two-Phase Architecture
104
+
105
+ 1. **ContainerBuilder** (Configuration): Register services and define scopes
106
+ 2. **Container** (Runtime): Validated, immutable graph for lazy resolution
107
+
108
+ ### Scope Safety
109
+
110
+ Services have four lifecycles that are validated at build time:
111
+
112
+ - **Singleton** (default): One instance per container
113
+ - **Scoped**: One instance per scope (e.g., per request)
114
+ - **Transient**: New instance every time
115
+ - **Scoped Transient**: New instance every time, requires scope
116
+
117
+ **Validation Rules (Simplified):**
118
+ The only invalid dependency is when a non-scoped service (singleton or transient) depends on a scoped service.
119
+
120
+ ```python
121
+ #  Valid: Scoped � Singleton
122
+ builder = ContainerBuilder()
123
+ builder.register(DatabaseConnection) # singleton (default)
124
+ builder.register(UserRepository, scoped=True)
125
+
126
+ # L Invalid: Singleton � Scoped (raises ScopeViolationError)
127
+ builder = ContainerBuilder()
128
+ builder.register(RequestHandler, scoped=True)
129
+ builder.register(SingletonService) # singleton depends on scoped!
130
+ container = builder.build() # ScopeViolationError!
131
+ ```
132
+
133
+ ### Type-Driven Dependencies
134
+
135
+ Dependencies are automatically discovered from type annotations:
136
+
137
+ ```python
138
+ class ServiceA:
139
+ def __init__(self, dep: DependencyType):
140
+ self.dep = dep
141
+ ```
142
+
143
+ No decorators or manual wiring required!
144
+
145
+ ## Installation
146
+
147
+ ```bash
148
+ pip install hdmi # Coming soon
149
+ ```
150
+
151
+ ## Development
152
+
153
+ This project uses [uv](https://github.com/astral-sh/uv) for dependency management and follows strict TDD methodology.
154
+
155
+ ```bash
156
+ # Run all checks (linting, type checking, tests)
157
+ make test
158
+
159
+ # Build documentation
160
+ make docs
161
+
162
+ # See all available commands
163
+ make help
164
+ ```
165
+
166
+ ## License
167
+
168
+ MIT License - see LICENSE file for details.
hdmi-0.1.0/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # hdmi - Dependency Management Interface
2
+
3
+ A lightweight dependency injection framework for Python 3.13+ with:
4
+
5
+ - **Type-driven dependency discovery** - Uses Python's standard type annotations
6
+ - **Scope-aware validation** - Prevents lifetime bugs at build time
7
+ - **Lazy instantiation** - Services created just-in-time
8
+ - **Early error detection** - Configuration errors caught at build time
9
+
10
+ ## Quick Example
11
+
12
+ ### Simple Example (Singleton Services)
13
+
14
+ ```python
15
+ from hdmi import ContainerBuilder
16
+
17
+ # Define your services
18
+ class DatabaseConnection:
19
+ def __init__(self):
20
+ self.connected = True
21
+
22
+ class UserRepository:
23
+ def __init__(self, db: DatabaseConnection):
24
+ self.db = db
25
+
26
+ class UserService:
27
+ def __init__(self, repo: UserRepository):
28
+ self.repo = repo
29
+
30
+ # Configure the container (all singletons by default)
31
+ builder = ContainerBuilder()
32
+ builder.register(DatabaseConnection)
33
+ builder.register(UserRepository)
34
+ builder.register(UserService)
35
+
36
+ # Build validates the dependency graph
37
+ container = builder.build()
38
+
39
+ # Resolve services lazily - dependencies are auto-wired!
40
+ user_service = container.get(UserService)
41
+ ```
42
+
43
+ ### Using Scoped Services
44
+
45
+ ```python
46
+ # For request-scoped services (e.g., web requests)
47
+ builder = ContainerBuilder()
48
+ builder.register(DatabaseConnection) # singleton (default)
49
+ builder.register(UserRepository, scoped=True) # One per request
50
+ builder.register(UserService, transient=True) # New each time
51
+
52
+ container = builder.build()
53
+
54
+ # Scoped services must be resolved within a scope context
55
+ with container.scope() as scoped:
56
+ user_service = scoped.get(UserService)
57
+ # All scoped dependencies share the same instance within this scope
58
+ ```
59
+
60
+ ## Key Features
61
+
62
+ ### Two-Phase Architecture
63
+
64
+ 1. **ContainerBuilder** (Configuration): Register services and define scopes
65
+ 2. **Container** (Runtime): Validated, immutable graph for lazy resolution
66
+
67
+ ### Scope Safety
68
+
69
+ Services have four lifecycles that are validated at build time:
70
+
71
+ - **Singleton** (default): One instance per container
72
+ - **Scoped**: One instance per scope (e.g., per request)
73
+ - **Transient**: New instance every time
74
+ - **Scoped Transient**: New instance every time, requires scope
75
+
76
+ **Validation Rules (Simplified):**
77
+ The only invalid dependency is when a non-scoped service (singleton or transient) depends on a scoped service.
78
+
79
+ ```python
80
+ #  Valid: Scoped � Singleton
81
+ builder = ContainerBuilder()
82
+ builder.register(DatabaseConnection) # singleton (default)
83
+ builder.register(UserRepository, scoped=True)
84
+
85
+ # L Invalid: Singleton � Scoped (raises ScopeViolationError)
86
+ builder = ContainerBuilder()
87
+ builder.register(RequestHandler, scoped=True)
88
+ builder.register(SingletonService) # singleton depends on scoped!
89
+ container = builder.build() # ScopeViolationError!
90
+ ```
91
+
92
+ ### Type-Driven Dependencies
93
+
94
+ Dependencies are automatically discovered from type annotations:
95
+
96
+ ```python
97
+ class ServiceA:
98
+ def __init__(self, dep: DependencyType):
99
+ self.dep = dep
100
+ ```
101
+
102
+ No decorators or manual wiring required!
103
+
104
+ ## Installation
105
+
106
+ ```bash
107
+ pip install hdmi # Coming soon
108
+ ```
109
+
110
+ ## Development
111
+
112
+ This project uses [uv](https://github.com/astral-sh/uv) for dependency management and follows strict TDD methodology.
113
+
114
+ ```bash
115
+ # Run all checks (linting, type checking, tests)
116
+ make test
117
+
118
+ # Build documentation
119
+ make docs
120
+
121
+ # See all available commands
122
+ make help
123
+ ```
124
+
125
+ ## License
126
+
127
+ MIT License - see LICENSE file for details.
@@ -0,0 +1,52 @@
1
+ [project]
2
+ name = "hdmi"
3
+ version = "0.1.0"
4
+ description = "A dependency injection framework for Python with dynamic late-binding resolution"
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ authors = [
8
+ { name = "Romain Dorgueil", email = "romain@makersquad.fr" }
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "anyio>=4.0.0",
13
+ ]
14
+
15
+ [project.optional-dependencies]
16
+ dev = [
17
+ "pytest>=8.0.0",
18
+ "pytest-cov>=4.1.0",
19
+ "ruff>=0.8.0",
20
+ "basedpyright>=1.21.0",
21
+ "sphinx>=8.0.0",
22
+ "sphinx-autobuild>=2024.0.0",
23
+ "furo>=2024.0.0",
24
+ "sphinxcontrib-mermaid>=1.0.0",
25
+ "twine>=6.0.0",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.9.7,<0.10.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.ruff]
33
+ line-length = 120
34
+ indent-width = 4
35
+
36
+ [tool.ruff.format]
37
+ quote-style = "double"
38
+ indent-style = "space"
39
+
40
+ [tool.basedpyright]
41
+ # Standard type checking mode - stricter than basic, not as strict as strict
42
+ typeCheckingMode = "standard"
43
+ pythonVersion = "3.13"
44
+
45
+ # Include source code and tests
46
+ include = ["src", "tests"]
47
+
48
+ # Report missing type stubs for third-party libraries as information, not errors
49
+ reportMissingTypeStubs = "information"
50
+
51
+ # Note: Planning to migrate to Astral's 'ty' when it reaches production readiness (late 2025)
52
+ # https://github.com/astral-sh/ty
@@ -0,0 +1,31 @@
1
+ """hdmi - Dynamic Dependency Injection for Python.
2
+
3
+ A lightweight dependency injection framework with:
4
+ - Type-driven dependency discovery
5
+ - Scope-aware validation
6
+ - Lazy instantiation
7
+ - Early error detection
8
+ """
9
+
10
+ from hdmi.builders import ContainerBuilder
11
+ from hdmi.containers import Container, ScopedContainer
12
+ from hdmi.types import IContainer, ServiceDefinition
13
+ from hdmi.exceptions import (
14
+ CircularDependencyError,
15
+ HDMIError,
16
+ ScopeViolationError,
17
+ UnresolvableDependencyError,
18
+ )
19
+
20
+ __all__ = [
21
+ "CircularDependencyError",
22
+ "Container",
23
+ "ContainerBuilder",
24
+ "HDMIError",
25
+ "IContainer",
26
+ "IContainer",
27
+ "ScopeViolationError",
28
+ "ScopedContainer",
29
+ "ServiceDefinition",
30
+ "UnresolvableDependencyError",
31
+ ]
@@ -0,0 +1,11 @@
1
+ """hdmi.builders - Container builder implementations.
2
+
3
+ This package provides builder classes for configuring dependency injection:
4
+ - ContainerBuilder: Builder for creating and validating containers
5
+ """
6
+
7
+ from hdmi.builders.default import ContainerBuilder
8
+
9
+ __all__ = [
10
+ "ContainerBuilder",
11
+ ]
@@ -0,0 +1,187 @@
1
+ """ContainerBuilder - Configuration phase for dependency injection.
2
+
3
+ The ContainerBuilder accumulates service registrations and produces
4
+ a validated, immutable Container when build() is called.
5
+ """
6
+
7
+ import inspect
8
+ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Type, get_type_hints
9
+
10
+ from hdmi.utils.typing import extract_type_from_optional
11
+ from hdmi.types.definitions import ServiceDefinition
12
+ from hdmi.exceptions import ScopeViolationError
13
+
14
+ if TYPE_CHECKING:
15
+ from hdmi.containers import Container
16
+
17
+ # Removed - no longer using scope hierarchy with boolean flags
18
+
19
+
20
+ class ContainerBuilder:
21
+ """Mutable builder for configuring dependency injection services.
22
+
23
+ The ContainerBuilder is responsible for:
24
+ - Accumulating service registrations
25
+ - Validating the dependency graph when build() is called
26
+ - Producing an immutable, validated Container
27
+ """
28
+
29
+ def __init__(self):
30
+ self._definitions: dict[Type, ServiceDefinition] = {}
31
+
32
+ def register(
33
+ self,
34
+ service_type: Type,
35
+ /,
36
+ *,
37
+ scoped: bool = False,
38
+ transient: bool = False,
39
+ name: str | None = None,
40
+ factory: Callable[..., Any] | Callable[..., Awaitable[Any]] | None = None,
41
+ autowire: bool = True,
42
+ initializer: Callable[[Any], None] | Callable[[Any], Awaitable[None]] | None = None,
43
+ finalizer: Callable[[Any], None] | Callable[[Any], Awaitable[None]] | None = None,
44
+ ) -> None:
45
+ """Register a service type with the container.
46
+
47
+ Args:
48
+ service_type: The class to register as a service
49
+ scoped: False (default) = available from Container, True = requires ScopedContainer
50
+ transient: False (default) = cached, True = new instance per request
51
+ name: Optional name for the service
52
+ factory: Optional factory function to create the service (sync or async)
53
+ autowire: Whether to auto-inject this service into optional dependencies (defaults to True)
54
+ initializer: Optional initialization function called after service creation (sync or async)
55
+ finalizer: Optional cleanup function called when service is disposed (sync or async)
56
+ """
57
+ definition = ServiceDefinition(
58
+ service_type,
59
+ scoped=scoped,
60
+ transient=transient,
61
+ name=name,
62
+ factory=factory,
63
+ autowire=autowire,
64
+ initializer=initializer,
65
+ finalizer=finalizer,
66
+ )
67
+ self._definitions[service_type] = definition
68
+
69
+ def build(self) -> "Container":
70
+ """Build and validate the Container.
71
+
72
+ This method:
73
+ 1. Validates the dependency graph
74
+ 2. Checks for circular dependencies
75
+ 3. Validates scope hierarchy
76
+ 4. Produces an immutable Container
77
+
78
+ Returns:
79
+ An immutable, validated Container ready for runtime use
80
+
81
+ Raises:
82
+ CircularDependencyError: If circular dependencies are detected
83
+ UnresolvableDependencyError: If a dependency cannot be resolved
84
+ ScopeViolationError: If scope hierarchy is violated
85
+ """
86
+ from hdmi.containers import Container
87
+
88
+ # Validate scope hierarchy for all registrations
89
+ self._validate_scopes()
90
+
91
+ # Create and return the validated Container
92
+ return Container(self._definitions)
93
+
94
+ def _validate_scopes(self) -> None:
95
+ """Validate that scope rules are respected.
96
+
97
+ Validation rule:
98
+ - Non-scoped services (scoped=False) cannot depend on scoped services (scoped=True)
99
+
100
+ This is because non-scoped services are available from Container, but scoped
101
+ services only exist within a ScopedContainer context.
102
+
103
+ Raises:
104
+ ScopeViolationError: If a non-scoped service depends on a scoped service
105
+ """
106
+ for service_type, definition in self._definitions.items():
107
+ # Get dependencies from type annotations
108
+ dependencies = self._get_dependencies(service_type)
109
+
110
+ # Check each dependency's scope
111
+ for dep_name, dep_type in dependencies.items():
112
+ if dep_type not in self._definitions:
113
+ # Will be caught later by unresolvable dependency check
114
+ continue
115
+
116
+ dep_definition = self._definitions[dep_type]
117
+
118
+ # Validate scope compatibility
119
+ # The only unsafe dependency is: non-scoped -> scoped
120
+ # (non-scoped service needs a scoped instance that only exists within a scope)
121
+ if not definition.scoped and dep_definition.scoped:
122
+ service_type_str = (
123
+ f"{service_type.__name__} (scoped={definition.scoped}, transient={definition.transient})"
124
+ )
125
+ dep_type_str = (
126
+ f"{dep_type.__name__} (scoped={dep_definition.scoped}, transient={dep_definition.transient})"
127
+ )
128
+ raise ScopeViolationError(
129
+ f"{service_type_str} cannot depend on {dep_type_str}. "
130
+ f"Non-scoped services cannot depend on scoped services because "
131
+ f"scoped services only exist within a scope context."
132
+ )
133
+
134
+ def _get_dependencies(self, service_type: Type) -> dict[str, Type]:
135
+ """Get dependencies that will actually be injected.
136
+
137
+ Only returns dependencies that will be injected at runtime, respecting:
138
+ - Optional dependencies not registered are skipped
139
+ - Optional dependencies with autowire=False are skipped
140
+ - Required dependencies are always included
141
+
142
+ Args:
143
+ service_type: The service type to analyze
144
+
145
+ Returns:
146
+ Dictionary mapping parameter name to dependency type (only dependencies that will be injected)
147
+ """
148
+ try:
149
+ sig = inspect.signature(service_type.__init__)
150
+ hints = get_type_hints(service_type.__init__)
151
+ except Exception:
152
+ return {}
153
+
154
+ dependencies = {}
155
+ for param_name, param in sig.parameters.items():
156
+ if param_name == "self":
157
+ continue
158
+
159
+ if param_name not in hints:
160
+ continue
161
+
162
+ type_hint = hints[param_name]
163
+ has_default = param.default is not inspect.Parameter.empty
164
+
165
+ # Extract actual type from Optional/Union types (e.g., Config | None -> Config)
166
+ dependency_type = extract_type_from_optional(type_hint)
167
+ if dependency_type is None:
168
+ # Can't determine single type (e.g., Union[A, B] or just None)
169
+ continue
170
+
171
+ # Check if dependency is registered
172
+ is_registered = dependency_type in self._definitions
173
+
174
+ if has_default:
175
+ # Optional dependency - only include if registered AND autowire=True
176
+ if is_registered:
177
+ dep_definition = self._definitions[dependency_type]
178
+ if dep_definition.autowire:
179
+ # Will be injected - include in dependencies
180
+ dependencies[param_name] = dependency_type
181
+ # else: skip (autowire=False, won't be injected)
182
+ # else: skip (not registered, won't be injected)
183
+ else:
184
+ # Required dependency - always include (will always be injected)
185
+ dependencies[param_name] = dependency_type
186
+
187
+ return dependencies
@@ -0,0 +1,14 @@
1
+ """hdmi.containers - Dependency injection container implementations.
2
+
3
+ This package provides the runtime containers for dependency injection:
4
+ - Container: Root container for singleton and transient services
5
+ - ScopedContainer: Scoped container for scoped service resolution
6
+ """
7
+
8
+ from hdmi.containers.default import Container
9
+ from hdmi.containers.scoped import ScopedContainer
10
+
11
+ __all__ = [
12
+ "Container",
13
+ "ScopedContainer",
14
+ ]
@@ -0,0 +1,250 @@
1
+ """Container - Root container for dependency injection.
2
+
3
+ The Container is an immutable, validated dependency graph that resolves
4
+ service instances lazily (just-in-time) when requested.
5
+ """
6
+
7
+ import asyncio
8
+ import inspect
9
+ from contextlib import AsyncExitStack
10
+ from typing import TYPE_CHECKING, Type, TypeVar, get_type_hints
11
+
12
+ from anyio import to_thread
13
+
14
+ from hdmi.utils.typing import extract_type_from_optional
15
+
16
+ if TYPE_CHECKING:
17
+ from hdmi.types.definitions import ServiceDefinition
18
+ from hdmi.containers.scoped import ScopedContainer
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ class Container:
24
+ """Immutable root container for resolving service instances at runtime.
25
+
26
+ The Container is produced by ContainerBuilder.build() and is:
27
+ - Immutable: cannot be modified after creation
28
+ - Pre-validated: all configuration errors caught during build
29
+ - Lazy: services instantiated only when first requested via get()
30
+ - Async: all resolution and lifecycle management is async
31
+
32
+ Implements IContainer protocol to provide a consistent interface with
33
+ ScopedContainer.
34
+ """
35
+
36
+ def __init__(self, definitions: dict[Type, "ServiceDefinition"]):
37
+ """Initialize Container with validated service definitions.
38
+
39
+ This should only be called by ContainerBuilder.build().
40
+
41
+ Args:
42
+ definitions: Validated service definitions from builder
43
+ """
44
+ self._definitions = definitions
45
+ self._singletons: dict[Type, object] = {}
46
+ self._pending_tasks: dict[Type, asyncio.Task] = {}
47
+ self._exit_stack: AsyncExitStack | None = None
48
+
49
+ async def __aenter__(self) -> "Container":
50
+ """Enter the async context manager.
51
+
52
+ Returns:
53
+ Self to enable 'async with builder.build() as container:' syntax
54
+ """
55
+ self._exit_stack = AsyncExitStack()
56
+ await self._exit_stack.__aenter__()
57
+ return self
58
+
59
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
60
+ """Exit the async context manager and cleanup all managed services.
61
+
62
+ This triggers all registered finalizers and closes all async context managers.
63
+ """
64
+ if self._exit_stack is not None:
65
+ await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
66
+ self._exit_stack = None
67
+
68
+ def scope(self) -> "ScopedContainer":
69
+ """Create a new scoped container for resolving scoped services.
70
+
71
+ Returns:
72
+ A new ScopedContainer instance
73
+ """
74
+ from hdmi.containers.scoped import ScopedContainer
75
+
76
+ return ScopedContainer(self)
77
+
78
+ async def get(self, service_type: Type[T]) -> T:
79
+ """Resolve a service instance (lazy instantiation).
80
+
81
+ Args:
82
+ service_type: The service type to resolve
83
+
84
+ Returns:
85
+ An instance of the service type
86
+
87
+ Raises:
88
+ UnresolvableDependencyError: If the service type is not registered
89
+ ScopeViolationError: If trying to resolve a scoped service outside a scope
90
+ """
91
+ from hdmi.exceptions import ScopeViolationError, UnresolvableDependencyError
92
+
93
+ try:
94
+ definition = self._definitions[service_type]
95
+ except KeyError:
96
+ raise UnresolvableDependencyError(
97
+ f"{service_type.__name__} is not registered in the container. "
98
+ f"Use ContainerBuilder.register({service_type.__name__}) to register it."
99
+ ) from None
100
+
101
+ # Scoped services cannot be resolved directly from Container
102
+ if definition.scoped:
103
+ raise ScopeViolationError(
104
+ f"{service_type.__name__} is a scoped service (scoped=True) and cannot be resolved "
105
+ f"directly from Container. Use Container.scope() to create a scoped context."
106
+ )
107
+
108
+ # Handle non-scoped services
109
+ if definition.transient:
110
+ # Transient (scoped=False, transient=True): new instance every time, no task sharing
111
+ return await self._create_instance(service_type) # type: ignore
112
+ else:
113
+ # Singleton (scoped=False, transient=False): cached with task sharing
114
+ # Check if already cached
115
+ if service_type in self._singletons:
116
+ return self._singletons[service_type] # type: ignore
117
+
118
+ # Check if task is already pending (task sharing)
119
+ if service_type in self._pending_tasks:
120
+ # Reuse existing task
121
+ return await self._pending_tasks[service_type] # type: ignore
122
+
123
+ # Create new task and store it
124
+ task = asyncio.create_task(self._create_instance(service_type))
125
+ self._pending_tasks[service_type] = task
126
+
127
+ try:
128
+ # Await the task
129
+ instance = await task
130
+ # Cache the result
131
+ self._singletons[service_type] = instance
132
+ return instance # type: ignore
133
+ finally:
134
+ # Remove from pending tasks (cleanup)
135
+ self._pending_tasks.pop(service_type, None)
136
+
137
+ async def _create_instance(self, service_type: Type[T]) -> T:
138
+ """Create an instance of a service, resolving dependencies and managing lifecycle.
139
+
140
+ Args:
141
+ service_type: The service type to instantiate
142
+
143
+ Returns:
144
+ An instance with all dependencies resolved and lifecycle hooks executed
145
+ """
146
+ # Get the __init__ signature
147
+ try:
148
+ sig = inspect.signature(service_type.__init__)
149
+ except ValueError:
150
+ # If we can't get signature, try without parameters
151
+ instance = service_type() # type: ignore
152
+ await self._manage_lifecycle(service_type, instance)
153
+ return instance
154
+
155
+ # Get type hints for the __init__ method
156
+ try:
157
+ hints = get_type_hints(service_type.__init__)
158
+ except Exception:
159
+ hints = {}
160
+
161
+ # Collect dependencies to resolve concurrently
162
+ dependency_tasks: dict[str, asyncio.Task] = {}
163
+
164
+ for param_name, param in sig.parameters.items():
165
+ if param_name == "self":
166
+ continue
167
+
168
+ # Get the type annotation for this parameter
169
+ if param_name not in hints:
170
+ continue
171
+
172
+ type_hint = hints[param_name]
173
+ has_default = param.default is not inspect.Parameter.empty
174
+
175
+ # Extract actual type from Optional/Union types (e.g., Config | None -> Config)
176
+ dependency_type = extract_type_from_optional(type_hint)
177
+ if dependency_type is None:
178
+ # Can't determine single type (e.g., Union[A, B] or just None)
179
+ continue
180
+
181
+ # Check if dependency is registered
182
+ is_registered = dependency_type in self._definitions
183
+
184
+ if has_default:
185
+ # Optional dependency - only inject if registered AND autowire=True
186
+ if is_registered:
187
+ dep_definition = self._definitions[dependency_type]
188
+ if dep_definition.autowire:
189
+ # Create task for concurrent resolution
190
+ dependency_tasks[param_name] = asyncio.create_task(self.get(dependency_type))
191
+ # else: skip (autowire=False, let class use default)
192
+ # else: skip (not registered, let class use default)
193
+ else:
194
+ # Required dependency - create task for concurrent resolution
195
+ dependency_tasks[param_name] = asyncio.create_task(self.get(dependency_type))
196
+
197
+ # Resolve all dependencies concurrently
198
+ if dependency_tasks:
199
+ # Wait for all dependency tasks to complete
200
+ await asyncio.gather(*dependency_tasks.values())
201
+
202
+ # Collect results into kwargs
203
+ kwargs = {param_name: task.result() for param_name, task in dependency_tasks.items()}
204
+ else:
205
+ kwargs = {}
206
+
207
+ instance = service_type(**kwargs) # type: ignore
208
+
209
+ # Manage lifecycle (initializer, context manager, finalizer)
210
+ await self._manage_lifecycle(service_type, instance)
211
+
212
+ return instance
213
+
214
+ async def _manage_lifecycle(self, service_type: Type[T], instance: T) -> None:
215
+ """Manage the lifecycle of a service instance.
216
+
217
+ This includes:
218
+ - Calling initializer (if provided)
219
+ - Registering finalizer with exit stack (if provided)
220
+
221
+ Note: Services that are context managers are NOT automatically entered.
222
+ The user is responsible for managing their context themselves.
223
+
224
+ Args:
225
+ service_type: The service type
226
+ instance: The service instance
227
+ """
228
+ definition = self._definitions[service_type]
229
+
230
+ # Call initializer if provided
231
+ if definition.initializer is not None:
232
+ if inspect.iscoroutinefunction(definition.initializer):
233
+ await definition.initializer(instance)
234
+ else:
235
+ # Run sync initializer in thread pool
236
+ await to_thread.run_sync(definition.initializer, instance)
237
+
238
+ # Register finalizer with exit stack if provided
239
+ if definition.finalizer is not None and self._exit_stack is not None:
240
+ if inspect.iscoroutinefunction(definition.finalizer):
241
+ # Async finalizer
242
+ self._exit_stack.push_async_callback(definition.finalizer, instance)
243
+ else:
244
+ # Sync finalizer - wrap in async callback that runs in thread pool
245
+ finalizer = definition.finalizer # Capture to satisfy type checker
246
+
247
+ async def _run_sync_finalizer():
248
+ await to_thread.run_sync(finalizer, instance)
249
+
250
+ self._exit_stack.push_async_callback(_run_sync_finalizer)
@@ -0,0 +1,117 @@
1
+ """ScopedContainer - Scoped container for dependency injection.
2
+
3
+ ScopedContainer follows the decorator pattern, extending Container to provide
4
+ scoped service resolution within a specific scope context.
5
+ """
6
+
7
+ import asyncio
8
+ from contextlib import AsyncExitStack
9
+ from typing import TYPE_CHECKING, Type, TypeVar
10
+
11
+ from hdmi.containers.default import Container
12
+
13
+ if TYPE_CHECKING:
14
+ pass
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class ScopedContainer(Container):
20
+ """Scoped container for resolving scoped services within a scope context.
21
+
22
+ ScopedContainer extends Container, following the decorator pattern to delegate
23
+ to its parent Container for non-scoped services while maintaining its own
24
+ cache for scoped instances.
25
+
26
+ Implements IContainer protocol to provide a consistent interface with Container.
27
+ """
28
+
29
+ def __init__(self, parent: Container):
30
+ """Initialize ScopedContainer with a parent Container.
31
+
32
+ Args:
33
+ parent: The parent Container to delegate to
34
+ """
35
+ # Don't call super().__init__ - we use parent's definitions
36
+ self._parent = parent
37
+ self._definitions = parent._definitions
38
+ self._scoped_instances: dict[Type, object] = {}
39
+ self._pending_tasks: dict[Type, asyncio.Task] = {} # For scoped services only
40
+ self._exit_stack: AsyncExitStack | None = None
41
+ # Note: we don't initialize _singletons as we delegate to parent
42
+
43
+ async def __aenter__(self) -> "ScopedContainer":
44
+ """Enter the async scope context.
45
+
46
+ Returns:
47
+ Self to enable 'async with container.scope() as scoped:' syntax
48
+ """
49
+ self._exit_stack = AsyncExitStack()
50
+ await self._exit_stack.__aenter__()
51
+ return self
52
+
53
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
54
+ """Exit the async scope context and cleanup all scoped services.
55
+
56
+ This triggers finalizers and closes async context managers for scoped services.
57
+ """
58
+ if self._exit_stack is not None:
59
+ await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
60
+ self._exit_stack = None
61
+ self._scoped_instances.clear()
62
+
63
+ async def get(self, service_type: Type[T]) -> T:
64
+ """Resolve a service instance within the scope.
65
+
66
+ Args:
67
+ service_type: The service type to resolve
68
+
69
+ Returns:
70
+ An instance of the service type
71
+
72
+ Raises:
73
+ UnresolvableDependencyError: If the service type is not registered
74
+ """
75
+ from hdmi.exceptions import UnresolvableDependencyError
76
+
77
+ try:
78
+ definition = self._definitions[service_type]
79
+ except KeyError:
80
+ raise UnresolvableDependencyError(
81
+ f"{service_type.__name__} is not registered in the container. "
82
+ f"Use ContainerBuilder.register({service_type.__name__}) to register it."
83
+ ) from None
84
+
85
+ # Handle based on scope flags
86
+ if not definition.scoped:
87
+ # Non-scoped services (singleton or transient) - delegate to parent
88
+ return await self._parent.get(service_type) # type: ignore
89
+
90
+ # Scoped services (scoped=True)
91
+ if definition.transient:
92
+ # Scoped Transient (scoped=True, transient=True): new instance every time, no task sharing
93
+ return await self._create_instance(service_type) # type: ignore
94
+ else:
95
+ # Scoped (scoped=True, transient=False): cached with task sharing
96
+ # Check if already cached
97
+ if service_type in self._scoped_instances:
98
+ return self._scoped_instances[service_type] # type: ignore
99
+
100
+ # Check if task is already pending (task sharing)
101
+ if service_type in self._pending_tasks:
102
+ # Reuse existing task
103
+ return await self._pending_tasks[service_type] # type: ignore
104
+
105
+ # Create new task and store it
106
+ task = asyncio.create_task(self._create_instance(service_type))
107
+ self._pending_tasks[service_type] = task
108
+
109
+ try:
110
+ # Await the task
111
+ instance = await task
112
+ # Cache the result
113
+ self._scoped_instances[service_type] = instance
114
+ return instance # type: ignore
115
+ finally:
116
+ # Remove from pending tasks (cleanup)
117
+ self._pending_tasks.pop(service_type, None)
@@ -0,0 +1,46 @@
1
+ """Exceptions for hdmi dependency injection framework."""
2
+
3
+
4
+ class HDMIError(Exception):
5
+ """Base exception for all hdmi errors."""
6
+
7
+ pass
8
+
9
+
10
+ class ScopeViolationError(HDMIError):
11
+ """Raised when a service depends on a service with incompatible scope.
12
+
13
+ The only invalid dependency pattern is when a non-scoped service
14
+ (singleton or transient) attempts to depend on a scoped service.
15
+
16
+ Valid patterns:
17
+ - Any service can depend on singleton services
18
+ - Any service can depend on transient services
19
+ - Scoped services can depend on any service type
20
+
21
+ Invalid patterns:
22
+ - Singleton (scoped=False) depending on Scoped (scoped=True)
23
+ - Transient (scoped=False) depending on Scoped (scoped=True)
24
+
25
+ Note: Transient dependencies are created once during their dependent's
26
+ construction and live for the dependent's lifetime, making them safe
27
+ dependencies for any service type.
28
+ """
29
+
30
+ pass
31
+
32
+
33
+ class CircularDependencyError(HDMIError):
34
+ """Raised when circular dependencies are detected."""
35
+
36
+ pass
37
+
38
+
39
+ class UnresolvableDependencyError(HDMIError, KeyError):
40
+ """Raised when a required dependency cannot be resolved.
41
+
42
+ This exception extends both HDMIError and KeyError for compatibility
43
+ with code that catches KeyError.
44
+ """
45
+
46
+ pass
File without changes
@@ -0,0 +1,7 @@
1
+ from .containers import IContainer
2
+ from .definitions import ServiceDefinition
3
+
4
+ __all__ = [
5
+ "IContainer",
6
+ "ServiceDefinition",
7
+ ]
@@ -0,0 +1,30 @@
1
+ """Container protocols - Interface definitions for dependency injection containers."""
2
+
3
+ from typing import TypeVar, Protocol, Type
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class IContainer(Protocol):
9
+ """Protocol defining the interface for dependency injection containers.
10
+
11
+ This protocol is implemented by both Container (root container) and
12
+ ScopedContainer (scoped container), ensuring they provide a consistent
13
+ interface for resolving service instances.
14
+ """
15
+
16
+ def get(self, service_type: Type[T]) -> T:
17
+ """Resolve a service instance.
18
+
19
+ Args:
20
+ service_type: The service type to resolve
21
+
22
+ Returns:
23
+ An instance of the service type
24
+
25
+ Raises:
26
+ KeyError: If the service type is not registered
27
+ ScopeViolationError: If trying to resolve a scoped service
28
+ outside a scope (for root container only)
29
+ """
30
+ ...
@@ -0,0 +1,45 @@
1
+ from typing import Type, Callable, Any, Awaitable
2
+
3
+
4
+ class ServiceDefinition:
5
+ """Describes everything to know about a service.
6
+
7
+ Service lifetime is defined by two boolean flags:
8
+ - scoped: False (default) = available from Container, True = requires ScopedContainer
9
+ - transient: False (default) = cached, True = new instance per request
10
+
11
+ Four service types:
12
+ 1. Singleton (scoped=False, transient=False): cached in Container
13
+ 2. Scoped (scoped=True, transient=False): cached in ScopedContainer
14
+ 3. Transient (scoped=False, transient=True): not cached, no scope required
15
+ 4. Scoped Transient (scoped=True, transient=True): not cached, requires scope
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ service_type: Type,
21
+ /,
22
+ *,
23
+ scoped: bool = False,
24
+ transient: bool = False,
25
+ name: str | None = None,
26
+ factory: Callable[..., Any] | Callable[..., Awaitable[Any]] | None = None,
27
+ autowire: bool = True,
28
+ initializer: Callable[[Any], None] | Callable[[Any], Awaitable[None]] | None = None,
29
+ finalizer: Callable[[Any], None] | Callable[[Any], Awaitable[None]] | None = None,
30
+ ):
31
+ if factory is not None and not callable(factory):
32
+ raise ValueError("factory must be callable")
33
+ if initializer is not None and not callable(initializer):
34
+ raise ValueError("initializer must be callable")
35
+ if finalizer is not None and not callable(finalizer):
36
+ raise ValueError("finalizer must be callable")
37
+
38
+ self.service_type = service_type
39
+ self.scoped = scoped
40
+ self.transient = transient
41
+ self.name = name
42
+ self.factory = factory
43
+ self.autowire = autowire
44
+ self.initializer = initializer
45
+ self.finalizer = finalizer
File without changes
@@ -0,0 +1,38 @@
1
+ from typing import Any, Type, get_origin, get_args
2
+
3
+
4
+ def extract_type_from_optional(type_hint: Any) -> Type | None:
5
+ """Extract the actual type from an Optional/Union type hint.
6
+
7
+ Args:
8
+ type_hint: The type hint to analyze (e.g., Config | None, Optional[Config])
9
+
10
+ Returns:
11
+ The extracted type if it's an Optional/Union, or the original type if not.
12
+ Returns None if the union contains only None or multiple non-None types.
13
+
14
+ Examples:
15
+ >>> extract_type_from_optional(str | None)
16
+ <class 'str'>
17
+ >>> extract_type_from_optional(int)
18
+ <class 'int'>
19
+ >>> extract_type_from_optional(str | int) # Multiple non-None types
20
+ None
21
+ """
22
+ # Check if it's a Union type (including Optional which is Union[T, None])
23
+ origin = get_origin(type_hint)
24
+ if origin is not None:
25
+ # It's a generic type, check if it's a Union
26
+ args = get_args(type_hint)
27
+ if args:
28
+ # Filter out NoneType from the union
29
+ non_none_types = [arg for arg in args if arg is not type(None)]
30
+
31
+ # If there's exactly one non-None type, return it
32
+ if len(non_none_types) == 1:
33
+ return non_none_types[0]
34
+ # Multiple non-None types or all None - can't determine single type
35
+ return None
36
+
37
+ # Not a union type, return as-is
38
+ return type_hint