hdmi 0.1.0rc3__py3-none-any.whl → 0.2.0__py3-none-any.whl

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/builders/default.py CHANGED
@@ -9,13 +9,11 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Type, get_type_hints
9
9
 
10
10
  from hdmi.utils.typing import extract_type_from_optional
11
11
  from hdmi.types.definitions import ServiceDefinition
12
- from hdmi.exceptions import ScopeViolationError
12
+ from hdmi.exceptions import ScopeViolationError, CircularDependencyError
13
13
 
14
14
  if TYPE_CHECKING:
15
15
  from hdmi.containers import Container
16
16
 
17
- # Removed - no longer using scope hierarchy with boolean flags
18
-
19
17
 
20
18
  class ContainerBuilder:
21
19
  """Mutable builder for configuring dependency injection services.
@@ -85,12 +83,53 @@ class ContainerBuilder:
85
83
  """
86
84
  from hdmi.containers import Container
87
85
 
86
+ # Check for circular dependencies
87
+ self._check_circular_dependencies()
88
+
88
89
  # Validate scope hierarchy for all registrations
89
90
  self._validate_scopes()
90
91
 
91
92
  # Create and return the validated Container
92
93
  return Container(self._definitions)
93
94
 
95
+ def _check_circular_dependencies(self) -> None:
96
+ """Check for circular dependencies in the dependency graph.
97
+
98
+ Uses depth-first search to detect cycles. Fails fast on the first cycle found.
99
+
100
+ Raises:
101
+ CircularDependencyError: If a circular dependency is detected
102
+ """
103
+ visited = set()
104
+ path = []
105
+
106
+ def visit(service_type: Type) -> None:
107
+ if service_type in path:
108
+ # Found a cycle - build the cycle path
109
+ cycle_start = path.index(service_type)
110
+ cycle_path = path[cycle_start:] + [service_type]
111
+ path_str = " → ".join(cls.__name__ for cls in cycle_path)
112
+ raise CircularDependencyError(f"Circular dependency detected: {path_str}")
113
+
114
+ if service_type in visited:
115
+ return
116
+
117
+ path.append(service_type)
118
+
119
+ # Get dependencies that will be injected
120
+ dependencies = self._get_dependencies(service_type)
121
+
122
+ for dep_type in dependencies.values():
123
+ if dep_type in self._definitions:
124
+ visit(dep_type)
125
+
126
+ path.pop()
127
+ visited.add(service_type)
128
+
129
+ # Visit all registered services
130
+ for service_type in self._definitions:
131
+ visit(service_type)
132
+
94
133
  def _validate_scopes(self) -> None:
95
134
  """Validate that scope rules are respected.
96
135
 
@@ -104,32 +143,57 @@ class ContainerBuilder:
104
143
  ScopeViolationError: If a non-scoped service depends on a scoped service
105
144
  """
106
145
  for service_type, definition in self._definitions.items():
107
- # Get dependencies from type annotations
108
146
  dependencies = self._get_dependencies(service_type)
109
147
 
110
- # Check each dependency's scope
111
- for dep_name, dep_type in dependencies.items():
112
- if dep_type not in self._definitions:
148
+ for dependency_name, dependency_type in dependencies.items():
149
+ if dependency_type not in self._definitions:
113
150
  # Will be caught later by unresolvable dependency check
114
151
  continue
115
152
 
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
- )
153
+ self._validate_scope_compatibility(
154
+ service_type, definition, dependency_type, self._definitions[dependency_type]
155
+ )
156
+
157
+ def _validate_scope_compatibility(
158
+ self,
159
+ service_type: Type,
160
+ service_definition: ServiceDefinition,
161
+ dependency_type: Type,
162
+ dependency_definition: ServiceDefinition,
163
+ ) -> None:
164
+ """Validate that a service's scope is compatible with its dependency's scope.
165
+
166
+ Args:
167
+ service_type: The type of the service being validated
168
+ service_definition: The definition of the service
169
+ dependency_type: The type of the dependency
170
+ dependency_definition: The definition of the dependency
171
+
172
+ Raises:
173
+ ScopeViolationError: If a non-scoped service depends on a scoped service
174
+ """
175
+ # Non-scoped services cannot depend on scoped services
176
+ if not service_definition.scoped and dependency_definition.scoped:
177
+ service_description = self._format_service_description(service_type, service_definition)
178
+ dependency_description = self._format_service_description(dependency_type, dependency_definition)
179
+
180
+ raise ScopeViolationError(
181
+ f"{service_description} cannot depend on {dependency_description}. "
182
+ f"Non-scoped services cannot depend on scoped services because "
183
+ f"scoped services only exist within a scope context."
184
+ )
185
+
186
+ def _format_service_description(self, service_type: Type, definition: ServiceDefinition) -> str:
187
+ """Format a service type and definition for error messages.
188
+
189
+ Args:
190
+ service_type: The service type
191
+ definition: The service definition
192
+
193
+ Returns:
194
+ A formatted string describing the service and its scope
195
+ """
196
+ return f"{service_type.__name__} (scoped={definition.scoped}, transient={definition.transient})"
133
197
 
134
198
  def _get_dependencies(self, service_type: Type) -> dict[str, Type]:
135
199
  """Get dependencies that will actually be injected.
@@ -159,29 +223,46 @@ class ContainerBuilder:
159
223
  if param_name not in hints:
160
224
  continue
161
225
 
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)
226
+ dependency_type = self._extract_dependency_type(hints[param_name])
167
227
  if dependency_type is None:
168
- # Can't determine single type (e.g., Union[A, B] or just None)
169
228
  continue
170
229
 
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)
230
+ if self._should_inject_dependency(dependency_type, param.default is not inspect.Parameter.empty):
185
231
  dependencies[param_name] = dependency_type
186
232
 
187
233
  return dependencies
234
+
235
+ def _extract_dependency_type(self, type_hint: Type) -> Type | None:
236
+ """Extract the concrete type from a type hint.
237
+
238
+ Handles Optional types by extracting the non-None type.
239
+ Returns None if no single concrete type can be determined.
240
+
241
+ Args:
242
+ type_hint: The type annotation to process
243
+
244
+ Returns:
245
+ The extracted concrete type, or None if not determinable
246
+ """
247
+ return extract_type_from_optional(type_hint)
248
+
249
+ def _should_inject_dependency(self, dependency_type: Type, is_optional: bool) -> bool:
250
+ """Determine if a dependency should be injected.
251
+
252
+ Args:
253
+ dependency_type: The type of the dependency
254
+ is_optional: Whether the parameter has a default value
255
+
256
+ Returns:
257
+ True if the dependency should be injected, False otherwise
258
+ """
259
+ if not is_optional:
260
+ # Required dependencies are always injected
261
+ return True
262
+
263
+ # Optional dependencies need to be registered AND have autowire=True
264
+ if dependency_type not in self._definitions:
265
+ return False
266
+
267
+ definition = self._definitions[dependency_type]
268
+ return definition.autowire
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: hdmi
3
- Version: 0.1.0rc3
3
+ Version: 0.2.0
4
4
  Summary: A dependency injection framework for Python with dynamic late-binding resolution
5
5
  Author: Romain Dorgueil
6
6
  Author-email: Romain Dorgueil <romain@makersquad.fr>
@@ -41,6 +41,11 @@ Description-Content-Type: text/markdown
41
41
 
42
42
  # hdmi - Dependency Management Interface
43
43
 
44
+ > **Warning: Pre-Alpha Software**
45
+ >
46
+ > hdmi is experimental software in active development. Breaking changes may occur
47
+ > until version 1.0. Use with care in production environments.
48
+
44
49
  A lightweight dependency injection framework for Python 3.13+ with:
45
50
 
46
51
  - **Type-driven dependency discovery** - Uses Python's standard type annotations
@@ -53,6 +58,7 @@ A lightweight dependency injection framework for Python 3.13+ with:
53
58
  ### Simple Example (Singleton Services)
54
59
 
55
60
  ```python
61
+ import asyncio
56
62
  from hdmi import ContainerBuilder
57
63
 
58
64
  # Define your services
@@ -68,34 +74,43 @@ class UserService:
68
74
  def __init__(self, repo: UserRepository):
69
75
  self.repo = repo
70
76
 
71
- # Configure the container (all singletons by default)
72
- builder = ContainerBuilder()
73
- builder.register(DatabaseConnection)
74
- builder.register(UserRepository)
75
- builder.register(UserService)
77
+ async def main():
78
+ # Configure the container (all singletons by default)
79
+ builder = ContainerBuilder()
80
+ builder.register(DatabaseConnection)
81
+ builder.register(UserRepository)
82
+ builder.register(UserService)
76
83
 
77
- # Build validates the dependency graph
78
- container = builder.build()
84
+ # Build validates the dependency graph
85
+ container = builder.build()
79
86
 
80
- # Resolve services lazily - dependencies are auto-wired!
81
- user_service = container.get(UserService)
87
+ # Resolve services lazily - dependencies are auto-wired!
88
+ user_service = await container.get(UserService)
89
+
90
+ asyncio.run(main())
82
91
  ```
83
92
 
84
93
  ### Using Scoped Services
85
94
 
86
95
  ```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
96
+ import asyncio
97
+ from hdmi import ContainerBuilder
98
+
99
+ async def main():
100
+ # For request-scoped services (e.g., web requests)
101
+ builder = ContainerBuilder()
102
+ builder.register(DatabaseConnection) # singleton (default)
103
+ builder.register(UserRepository, scoped=True) # One per request
104
+ builder.register(UserService, transient=True) # New each time
105
+
106
+ container = builder.build()
92
107
 
93
- container = builder.build()
108
+ # Scoped services must be resolved within a scope context
109
+ async with container.scope() as scoped:
110
+ user_service = await scoped.get(UserService)
111
+ # All scoped dependencies share the same instance within this scope
94
112
 
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
113
+ asyncio.run(main())
99
114
  ```
100
115
 
101
116
  ## Key Features
@@ -118,12 +133,12 @@ Services have four lifecycles that are validated at build time:
118
133
  The only invalid dependency is when a non-scoped service (singleton or transient) depends on a scoped service.
119
134
 
120
135
  ```python
121
- #  Valid: Scoped Singleton
136
+ # Valid: Scoped -> Singleton
122
137
  builder = ContainerBuilder()
123
138
  builder.register(DatabaseConnection) # singleton (default)
124
139
  builder.register(UserRepository, scoped=True)
125
140
 
126
- # L Invalid: Singleton Scoped (raises ScopeViolationError)
141
+ # Invalid: Singleton -> Scoped (raises ScopeViolationError)
127
142
  builder = ContainerBuilder()
128
143
  builder.register(RequestHandler, scoped=True)
129
144
  builder.register(SingletonService) # singleton depends on scoped!
@@ -1,6 +1,6 @@
1
1
  hdmi/__init__.py,sha256=bUVZmkoCG1PgkbS7XrwwXylowF56s7aS_mCA-yHDWu4,748
2
2
  hdmi/builders/__init__.py,sha256=uN2EzCY9iFzmcxWrqikw5WDFvr_HuD2PxxYV3C43eKs,293
3
- hdmi/builders/default.py,sha256=DXxqDtMw3E2XyrfuDq7OALhkAb-ENn_hnvRTczFPcLk,7570
3
+ hdmi/builders/default.py,sha256=Oyiz23IZs0Ks1rLYDZw2D6Sl-pQVxkJpcTwThIu7qHQ,10140
4
4
  hdmi/containers/__init__.py,sha256=TD8ReZks3E8CRaThWUzbZmZ4TleSmfMok_5njgNmyew,429
5
5
  hdmi/containers/default.py,sha256=jGbOU7a11p2NkWVja6tgBPb_Ngd3DOV-TP0GzhSs7Ds,9782
6
6
  hdmi/containers/scoped.py,sha256=c8OlWzQ2oiUxB1fQoB3kcSrGBX8pOw7l_Cg8QfaWzto,4415
@@ -11,6 +11,6 @@ hdmi/types/containers.py,sha256=pdEdu1QynRUDYhYz7h5EyGpB0H2FD8eCP0dXTKaLu5c,906
11
11
  hdmi/types/definitions.py,sha256=gqyhBneTex1efrAdBzn7R3bBU8YrOtoZE15cdmQFMTE,1802
12
12
  hdmi/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  hdmi/utils/typing.py,sha256=1JdUYZoE0SdX1jci0iGQLJkvBYodImoCQV8S_JcJvyw,1384
14
- hdmi-0.1.0rc3.dist-info/WHEEL,sha256=w4ZtLaDgMAZW2MMZZwtH8zENekoQYBCeullI-zsXJQk,78
15
- hdmi-0.1.0rc3.dist-info/METADATA,sha256=wku5qh1Eu0m7JNe-kp1NVpJHqkCK55G6t8gV3T55m8g,5448
16
- hdmi-0.1.0rc3.dist-info/RECORD,,
14
+ hdmi-0.2.0.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
15
+ hdmi-0.2.0.dist-info/METADATA,sha256=jOpv684i50C6OMUwvvvUlhdjcwHefvDCWktafcZyA-0,5858
16
+ hdmi-0.2.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.9
2
+ Generator: uv 0.9.30
3
3
  Root-Is-Purelib: true
4
- Tag: py3-none-any
4
+ Tag: py3-none-any