anydi 0.63.0__tar.gz → 0.65.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.
Files changed (27) hide show
  1. {anydi-0.63.0 → anydi-0.65.0}/PKG-INFO +3 -2
  2. {anydi-0.63.0 → anydi-0.65.0}/README.md +2 -1
  3. {anydi-0.63.0 → anydi-0.65.0}/anydi/_container.py +9 -0
  4. {anydi-0.63.0 → anydi-0.65.0}/anydi/_resolver.py +34 -16
  5. {anydi-0.63.0 → anydi-0.65.0}/anydi/ext/fastapi.py +17 -8
  6. anydi-0.65.0/anydi/ext/starlette/middleware.py +49 -0
  7. {anydi-0.63.0 → anydi-0.65.0}/pyproject.toml +2 -1
  8. anydi-0.63.0/anydi/ext/starlette/middleware.py +0 -23
  9. {anydi-0.63.0 → anydi-0.65.0}/anydi/__init__.py +0 -0
  10. {anydi-0.63.0 → anydi-0.65.0}/anydi/_async_lock.py +0 -0
  11. {anydi-0.63.0 → anydi-0.65.0}/anydi/_context.py +0 -0
  12. {anydi-0.63.0 → anydi-0.65.0}/anydi/_decorators.py +0 -0
  13. {anydi-0.63.0 → anydi-0.65.0}/anydi/_injector.py +0 -0
  14. {anydi-0.63.0 → anydi-0.65.0}/anydi/_marker.py +0 -0
  15. {anydi-0.63.0 → anydi-0.65.0}/anydi/_module.py +0 -0
  16. {anydi-0.63.0 → anydi-0.65.0}/anydi/_provider.py +0 -0
  17. {anydi-0.63.0 → anydi-0.65.0}/anydi/_scanner.py +0 -0
  18. {anydi-0.63.0 → anydi-0.65.0}/anydi/_types.py +0 -0
  19. {anydi-0.63.0 → anydi-0.65.0}/anydi/ext/__init__.py +0 -0
  20. {anydi-0.63.0 → anydi-0.65.0}/anydi/ext/django/__init__.py +0 -0
  21. {anydi-0.63.0 → anydi-0.65.0}/anydi/ext/faststream.py +0 -0
  22. {anydi-0.63.0 → anydi-0.65.0}/anydi/ext/pydantic_settings.py +0 -0
  23. {anydi-0.63.0 → anydi-0.65.0}/anydi/ext/pytest_plugin.py +0 -0
  24. {anydi-0.63.0 → anydi-0.65.0}/anydi/ext/starlette/__init__.py +0 -0
  25. {anydi-0.63.0 → anydi-0.65.0}/anydi/ext/typer.py +0 -0
  26. {anydi-0.63.0 → anydi-0.65.0}/anydi/py.typed +0 -0
  27. {anydi-0.63.0 → anydi-0.65.0}/anydi/testing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anydi
3
- Version: 0.63.0
3
+ Version: 0.65.0
4
4
  Summary: Dependency Injection library
5
5
  Keywords: dependency injection,dependencies,di,async,asyncio,application
6
6
  Author: Anton Ruhlov
@@ -42,6 +42,7 @@ Modern, lightweight Dependency Injection library using type annotations.
42
42
  [![CI](https://github.com/antonrh/anydi/actions/workflows/ci.yml/badge.svg)](https://github.com/antonrh/anydi/actions/workflows/ci.yml)
43
43
  [![Coverage](https://codecov.io/gh/antonrh/anydi/branch/main/graph/badge.svg)](https://codecov.io/gh/antonrh/anydi)
44
44
  [![Documentation](https://readthedocs.org/projects/anydi/badge/?version=latest)](https://anydi.readthedocs.io/en/latest/)
45
+ [![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/antonrh/anydi?utm_source=badge)
45
46
 
46
47
  </div>
47
48
 
@@ -229,7 +230,7 @@ Configure Django (`settings.py`):
229
230
 
230
231
  ```python
231
232
  INSTALLED_APPS = [
232
- ...
233
+ ...,
233
234
  "anydi_django",
234
235
  ]
235
236
 
@@ -7,6 +7,7 @@ Modern, lightweight Dependency Injection library using type annotations.
7
7
  [![CI](https://github.com/antonrh/anydi/actions/workflows/ci.yml/badge.svg)](https://github.com/antonrh/anydi/actions/workflows/ci.yml)
8
8
  [![Coverage](https://codecov.io/gh/antonrh/anydi/branch/main/graph/badge.svg)](https://codecov.io/gh/antonrh/anydi)
9
9
  [![Documentation](https://readthedocs.org/projects/anydi/badge/?version=latest)](https://anydi.readthedocs.io/en/latest/)
10
+ [![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/antonrh/anydi?utm_source=badge)
10
11
 
11
12
  </div>
12
13
 
@@ -194,7 +195,7 @@ Configure Django (`settings.py`):
194
195
 
195
196
  ```python
196
197
  INSTALLED_APPS = [
197
- ...
198
+ ...,
198
199
  "anydi_django",
199
200
  ]
200
201
 
@@ -272,6 +272,10 @@ class Container:
272
272
  # Register the scope
273
273
  self._scopes[scope] = tuple({scope, "singleton"} | set(parents))
274
274
 
275
+ def has_scope(self, scope: str) -> bool:
276
+ """Check if a scope is registered."""
277
+ return scope in self._scopes
278
+
275
279
  def get_context_scopes(self, scopes: set[Scope] | None = None) -> list[str]: # noqa: C901
276
280
  """Return scopes that require context management in dependency order."""
277
281
  # Build execution order: singleton -> request -> custom (by depth)
@@ -549,6 +553,11 @@ class Container:
549
553
  )
550
554
 
551
555
  self._set_provider(provider)
556
+
557
+ # Clear cached resolvers when overriding
558
+ if override:
559
+ self._resolver.clear_caches()
560
+
552
561
  return provider
553
562
 
554
563
  def _validate_provider_scope(
@@ -62,6 +62,13 @@ class Resolver:
62
62
  def add_unresolved(self, interface: Any) -> None:
63
63
  self._unresolved_interfaces.add(interface)
64
64
 
65
+ def clear_caches(self) -> None:
66
+ """Clear all cached resolvers."""
67
+ self._cache.clear()
68
+ self._async_cache.clear()
69
+ self._override_cache.clear()
70
+ self._async_override_cache.clear()
71
+
65
72
  def get_cached(self, interface: Any, *, is_async: bool) -> CompiledResolver | None:
66
73
  """Get cached resolver if it exists."""
67
74
  if self.override_mode:
@@ -83,9 +90,14 @@ class Resolver:
83
90
  return cache[provider.interface]
84
91
 
85
92
  # Recursively compile dependencies first
86
- for p in provider.parameters:
87
- if p.provider is not None:
88
- self.compile(p.provider, is_async=is_async)
93
+ for param in provider.parameters:
94
+ if param.provider is not None:
95
+ # Look up the current provider to handle overrides
96
+ current_provider = self._container.providers.get(param.annotation)
97
+ if current_provider is not None:
98
+ self.compile(current_provider, is_async=is_async)
99
+ else:
100
+ self.compile(param.provider, is_async=is_async)
89
101
 
90
102
  # Compile the resolver and creator functions
91
103
  compiled = self._compile_resolver(
@@ -155,23 +167,29 @@ class Resolver:
155
167
  else (self._async_cache if is_async else self._cache)
156
168
  )
157
169
 
158
- for idx, p in enumerate(provider.parameters):
159
- param_annotations[idx] = p.annotation
160
- param_defaults[idx] = p.default
161
- param_has_default[idx] = p.has_default
162
- param_names[idx] = p.name
163
- param_shared_scopes[idx] = p.shared_scope
164
-
165
- if p.provider is not None:
166
- compiled = cache.get(p.provider.interface)
170
+ for idx, param in enumerate(provider.parameters):
171
+ param_annotations[idx] = param.annotation
172
+ param_defaults[idx] = param.default
173
+ param_has_default[idx] = param.has_default
174
+ param_names[idx] = param.name
175
+ param_shared_scopes[idx] = param.shared_scope
176
+
177
+ if param.provider is not None:
178
+ # Look up the current provider from the container to handle overrides
179
+ current_provider = self._container.providers.get(param.annotation)
180
+ if current_provider is not None:
181
+ compiled = cache.get(current_provider.interface)
182
+ else:
183
+ # Fallback to the original provider if not in container
184
+ compiled = cache.get(param.provider.interface)
167
185
  if compiled is None:
168
- compiled = self.compile(p.provider, is_async=is_async)
169
- cache[p.provider.interface] = compiled
186
+ compiled = self.compile(param.provider, is_async=is_async)
187
+ cache[param.provider.interface] = compiled
170
188
  param_resolvers[idx] = compiled.resolve
171
189
 
172
190
  unresolved_message = (
173
- f"You are attempting to get the parameter `{p.name}` with the "
174
- f"annotation `{type_repr(p.annotation)}` as a dependency into "
191
+ f"You are attempting to get the parameter `{param.name}` with the "
192
+ f"annotation `{type_repr(param.annotation)}` as a dependency into "
175
193
  f"`{type_repr(provider.call)}` which is not registered or set in the "
176
194
  "scoped context."
177
195
  )
@@ -8,8 +8,8 @@ from typing import Annotated, Any, cast
8
8
 
9
9
  from fastapi import Depends, FastAPI, params
10
10
  from fastapi.dependencies.models import Dependant
11
- from fastapi.routing import APIRoute
12
- from starlette.requests import Request
11
+ from fastapi.routing import APIRoute, APIWebSocketRoute
12
+ from starlette.requests import HTTPConnection
13
13
 
14
14
  from anydi import Container, Inject
15
15
  from anydi._marker import Marker, extend_marker
@@ -19,9 +19,8 @@ from .starlette.middleware import RequestScopedMiddleware
19
19
  __all__ = ["install", "get_container", "Inject", "RequestScopedMiddleware"]
20
20
 
21
21
 
22
- def get_container(request: Request) -> Container:
23
- """Get the AnyDI container from a FastAPI request."""
24
- return cast(Container, request.app.state.container)
22
+ def get_container(connection: HTTPConnection) -> Container:
23
+ return cast(Container, connection.app.state.container)
25
24
 
26
25
 
27
26
  class FastAPIMarker(params.Depends, Marker):
@@ -39,7 +38,9 @@ class FastAPIMarker(params.Depends, Marker):
39
38
  return await container.aresolve(self.interface)
40
39
 
41
40
 
42
- # Configure Inject() and Provide[T] to use FastAPI-specific marker
41
+ # Configure Inject() and Provide[T] to use FastAPI-specific marker at import time
42
+ # This is also called in install() to ensure it's set correctly even if other
43
+ # extensions have overwritten it
43
44
  extend_marker(FastAPIMarker)
44
45
 
45
46
 
@@ -51,7 +52,9 @@ def _iter_dependencies(dependant: Dependant) -> Iterator[Dependant]:
51
52
 
52
53
 
53
54
  def _validate_route_dependencies(
54
- route: APIRoute, container: Container, patched: set[tuple[Any, ...]]
55
+ route: APIRoute | APIWebSocketRoute,
56
+ container: Container,
57
+ patched: set[tuple[Any, ...]],
55
58
  ) -> None:
56
59
  for dependant in _iter_dependencies(route.dependant):
57
60
  if dependant.cache_key in patched:
@@ -71,8 +74,14 @@ def _validate_route_dependencies(
71
74
  def install(app: FastAPI, container: Container) -> None:
72
75
  """Install AnyDI into a FastAPI application."""
73
76
  app.state.container = container # noqa
77
+
78
+ # Register websocket scope with request as parent if not already registered
79
+ if not container.has_scope("websocket"):
80
+ container.register_scope("websocket", parents=["request"])
81
+
82
+ # Validate routes (both HTTP and WebSocket)
74
83
  patched: set[tuple[Any, ...]] = set()
75
84
  for route in app.routes:
76
- if not isinstance(route, APIRoute):
85
+ if not isinstance(route, APIRoute | APIWebSocketRoute):
77
86
  continue
78
87
  _validate_route_dependencies(route, container, patched)
@@ -0,0 +1,49 @@
1
+ """Starlette middleware for AnyDI scoped contexts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import AsyncExitStack
6
+
7
+ from starlette.requests import Request
8
+ from starlette.types import ASGIApp, Receive, Scope, Send
9
+ from starlette.websockets import WebSocket
10
+
11
+ from anydi import Container
12
+
13
+
14
+ class RequestScopedMiddleware:
15
+ """ASGI middleware for managing request-scoped AnyDI context."""
16
+
17
+ def __init__(self, app: ASGIApp, container: Container) -> None:
18
+ self.app = app
19
+ self.container = container
20
+
21
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
22
+ # Only handle HTTP requests or WebSocket connections
23
+ if scope["type"] not in {"http", "websocket"}:
24
+ await self.app(scope, receive, send)
25
+ return
26
+
27
+ async with AsyncExitStack() as stack:
28
+ # Create request context first (parent scope)
29
+ request_context = await stack.enter_async_context(
30
+ self.container.arequest_context()
31
+ )
32
+
33
+ # For WebSocket connections, create websocket context (child scope)
34
+ websocket_context = None
35
+ if scope["type"] == "websocket" and self.container.has_scope("websocket"):
36
+ websocket_context = await stack.enter_async_context(
37
+ self.container.ascoped_context("websocket")
38
+ )
39
+
40
+ if scope["type"] == "http":
41
+ request = Request(scope, receive=receive, send=send)
42
+ request_context.set(Request, request)
43
+ else:
44
+ websocket = WebSocket(scope, receive=receive, send=send)
45
+ request_context.set(WebSocket, websocket)
46
+ if websocket_context is not None:
47
+ websocket_context.set(WebSocket, websocket)
48
+
49
+ await self.app(scope, receive, send)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "anydi"
3
- version = "0.63.0"
3
+ version = "0.65.0"
4
4
  description = "Dependency Injection library"
5
5
  authors = [{ name = "Anton Ruhlov", email = "antonruhlov@gmail.com" }]
6
6
  requires-python = ">=3.10.0, <3.15"
@@ -55,6 +55,7 @@ dev = [
55
55
  "pytest>=8.4.0,<9",
56
56
  "pytest-cov>=7.0.0",
57
57
  "pytest-mock>=3.14.1",
58
+ "pytest-codspeed>=4.2.0",
58
59
  "starlette>=0.37.2",
59
60
  "fastapi>=0.100.0",
60
61
  "httpx>=0.26.0",
@@ -1,23 +0,0 @@
1
- """Starlette RequestScopedMiddleware."""
2
-
3
- from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
4
- from starlette.requests import Request
5
- from starlette.responses import Response
6
- from starlette.types import ASGIApp
7
-
8
- from anydi import Container
9
-
10
-
11
- class RequestScopedMiddleware(BaseHTTPMiddleware):
12
- """Starlette middleware for managing request-scoped AnyDI context."""
13
-
14
- def __init__(self, app: ASGIApp, container: Container) -> None:
15
- super().__init__(app)
16
- self.container = container
17
-
18
- async def dispatch(
19
- self, request: Request, call_next: RequestResponseEndpoint
20
- ) -> Response:
21
- async with self.container.arequest_context() as context:
22
- context.set(Request, request)
23
- return await call_next(request)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes