vercel-queue 0.7.2__tar.gz → 0.7.3__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 (46) hide show
  1. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/PKG-INFO +46 -47
  2. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/README.md +43 -45
  3. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/_vercel_hatch_build.py +38 -3
  4. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/pyproject.toml +6 -0
  5. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/subscribers.py +9 -8
  6. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/transports.py +11 -10
  7. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/version.py +1 -1
  8. vercel_queue-0.7.2/vercel/queue/_internal/typeutils.py +0 -203
  9. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/.gitignore +0 -0
  10. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/LICENSE +0 -0
  11. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/hatch_build.py +0 -0
  12. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/__init__.py +0 -0
  13. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/__main__.py +0 -0
  14. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/__init__.py +0 -0
  15. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/api_async.py +0 -0
  16. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/api_common.py +0 -0
  17. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/api_sync.py +0 -0
  18. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/asgi.py +0 -0
  19. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/asynctools.py +0 -0
  20. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/cli.py +0 -0
  21. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/client.py +0 -0
  22. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/client_sync.py +0 -0
  23. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/config.py +0 -0
  24. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/constants.py +0 -0
  25. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/devserver.py +0 -0
  26. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/embedded.py +0 -0
  27. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/errors.py +0 -0
  28. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/http.py +0 -0
  29. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/lease.py +0 -0
  30. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/log.py +0 -0
  31. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/messages.py +0 -0
  32. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/multipart.py +0 -0
  33. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/names.py +0 -0
  34. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/polling.py +0 -0
  35. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/push.py +0 -0
  36. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/response.py +0 -0
  37. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/retry.py +0 -0
  38. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/streams.py +0 -0
  39. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/_internal/types.py +0 -0
  40. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/devserver.py +0 -0
  41. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/embedded.py +0 -0
  42. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/py.typed +0 -0
  43. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/sync.py +0 -0
  44. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/testing/__init__.py +0 -0
  45. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/testing/pytest.py +0 -0
  46. {vercel_queue-0.7.2 → vercel_queue-0.7.3}/vercel/queue/testing/state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vercel-queue
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: Vercel Queue client for Python
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -10,7 +10,8 @@ Requires-Dist: httpx[http2]>=0.27.0
10
10
  Requires-Dist: python-multipart>=0.0.20
11
11
  Requires-Dist: typing-extensions>=4.0.0
12
12
  Requires-Dist: vercel-headers>=0.7.1
13
- Requires-Dist: vercel-oidc>=0.7.1
13
+ Requires-Dist: vercel-internal-core<0.2.0,>=0.1.2
14
+ Requires-Dist: vercel-oidc>=0.8.0
14
15
  Provides-Extra: devserver
15
16
  Requires-Dist: uvicorn; extra == 'devserver'
16
17
  Provides-Extra: trio
@@ -67,35 +68,29 @@ from vercel.queue import send
67
68
  message_id = await send("my-topic", {"message": "Hello world"})
68
69
  ```
69
70
 
70
- **3. Handle incoming messages with an API route function:**
71
+ **3. Handle incoming messages in a subscriber module:**
71
72
 
72
73
  ```python
73
- # api/queue.py
74
- from vercel.queue import asgi_app, subscribe
74
+ # worker.py
75
+ from vercel.queue import subscribe
75
76
 
76
77
 
77
- @subscribe(topic="my-topic", consumer_group="api/queue.py")
78
+ @subscribe(topic="my-topic")
78
79
  async def process_message(message):
79
80
  print("Processing:", message)
80
-
81
-
82
- # An ASGI app instance that converts incoming message callbacks
83
- # sent by Vercel Queues and routes them to handlers.
84
- app = asgi_app()
85
81
  ```
86
82
 
87
- **4. Configure `vercel.json`:**
83
+ **4. Declare the subscriber in `pyproject.toml`:**
88
84
 
89
- ```json
90
- {
91
- "functions": {
92
- "api/queue.py": {
93
- "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "my-topic" }]
94
- }
95
- }
96
- }
85
+ ```toml
86
+ [[tool.vercel.subscribers]]
87
+ entrypoint = "worker:process_message"
97
88
  ```
98
89
 
90
+ The Vercel build introspects the entrypoint module's registered
91
+ subscriptions and compiles it into a queue-triggered function. No
92
+ `vercel.json` trigger configuration is needed.
93
+
99
94
  **5. Deploy:**
100
95
 
101
96
  ```bash
@@ -163,42 +158,46 @@ and running polling loops on other infrastructure.
163
158
 
164
159
  ### Auto-scaled push-mode on Vercel
165
160
 
166
- The recommended way of deploying queue subscribers is to deploy them as Vercel Functions
167
-
168
- **Vercel Function (plain `/api` directory):**
161
+ The recommended way of deploying queue subscribers is to declare them in
162
+ `pyproject.toml` with `[[tool.vercel.subscribers]]`. Each entry points at a
163
+ module that registers subscriptions with `@subscribe`:
169
164
 
170
165
  ```python
171
- # api/handle_orders.py
172
- from vercel.queue import asgi_app, subscribe
166
+ # worker.py
167
+ from vercel.queue import subscribe
173
168
 
174
169
 
175
- @subscribe(topic="orders", consumer_group="api/handle_orders.py")
170
+ @subscribe(topic="orders", retry_after=60)
176
171
  async def handle_order(message):
177
172
  print("Processing:", message)
173
+ ```
178
174
 
175
+ **pyproject.toml**:
179
176
 
180
- # An ASGI app instance that converts incoming message callbacks
181
- # sent by Vercel Queues and routes them to handlers.
182
- app = asgi_app()
177
+ ```toml
178
+ [[tool.vercel.subscribers]]
179
+ entrypoint = "worker:handle_order"
183
180
  ```
184
181
 
185
- **vercel.json**:
186
-
187
- ```json
188
- {
189
- "functions": {
190
- "api/queue/orders.py": {
191
- "experimentalTriggers": [
192
- {
193
- "type": "queue/v2beta",
194
- "topic": "orders",
195
- "retryAfterSeconds": 60,
196
- "initialDelaySeconds": 0
197
- }
198
- ]
199
- }
200
- }
201
- }
182
+ At build time, Vercel imports the entrypoint module, introspects every
183
+ subscription it registers, and compiles the subscriber into its own
184
+ queue-triggered function. Delivery tuning lives on the `@subscribe`
185
+ decorator (`retry_after`, `initial_delay`, `max_concurrency`,
186
+ `max_attempts`, `consumer_group`), so no `vercel.json` configuration is
187
+ needed.
188
+
189
+ A subscriber consumes every subscription registered by its entrypoint
190
+ module. Add an optional `topics` filter to split subscriptions across
191
+ several subscriber functions:
192
+
193
+ ```toml
194
+ [[tool.vercel.subscribers]]
195
+ entrypoint = "worker:handle_order"
196
+ topics = ["orders"]
197
+
198
+ [[tool.vercel.subscribers]]
199
+ entrypoint = "worker:handle_refund"
200
+ topics = ["refunds"]
202
201
  ```
203
202
 
204
203
  ### Automatic Polling Loop
@@ -285,7 +284,7 @@ polling, because it can distribute messages across regions unpredictably.
285
284
  ## Retry and Backoff
286
285
 
287
286
  When a topic handler raises, the message is not acknowledged and becomes available for redelivery
288
- after the `retryAfterSeconds` interval configured in `vercel.json`. Retries continue until the
287
+ after the `retry_after` interval configured on the subscriber. Retries continue until the
289
288
  handler succeeds or the message expires.
290
289
 
291
290
  For finer control over retry timing, raise `RetryAfter` from a subscriber:
@@ -46,35 +46,29 @@ from vercel.queue import send
46
46
  message_id = await send("my-topic", {"message": "Hello world"})
47
47
  ```
48
48
 
49
- **3. Handle incoming messages with an API route function:**
49
+ **3. Handle incoming messages in a subscriber module:**
50
50
 
51
51
  ```python
52
- # api/queue.py
53
- from vercel.queue import asgi_app, subscribe
52
+ # worker.py
53
+ from vercel.queue import subscribe
54
54
 
55
55
 
56
- @subscribe(topic="my-topic", consumer_group="api/queue.py")
56
+ @subscribe(topic="my-topic")
57
57
  async def process_message(message):
58
58
  print("Processing:", message)
59
-
60
-
61
- # An ASGI app instance that converts incoming message callbacks
62
- # sent by Vercel Queues and routes them to handlers.
63
- app = asgi_app()
64
59
  ```
65
60
 
66
- **4. Configure `vercel.json`:**
61
+ **4. Declare the subscriber in `pyproject.toml`:**
67
62
 
68
- ```json
69
- {
70
- "functions": {
71
- "api/queue.py": {
72
- "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "my-topic" }]
73
- }
74
- }
75
- }
63
+ ```toml
64
+ [[tool.vercel.subscribers]]
65
+ entrypoint = "worker:process_message"
76
66
  ```
77
67
 
68
+ The Vercel build introspects the entrypoint module's registered
69
+ subscriptions and compiles it into a queue-triggered function. No
70
+ `vercel.json` trigger configuration is needed.
71
+
78
72
  **5. Deploy:**
79
73
 
80
74
  ```bash
@@ -142,42 +136,46 @@ and running polling loops on other infrastructure.
142
136
 
143
137
  ### Auto-scaled push-mode on Vercel
144
138
 
145
- The recommended way of deploying queue subscribers is to deploy them as Vercel Functions
146
-
147
- **Vercel Function (plain `/api` directory):**
139
+ The recommended way of deploying queue subscribers is to declare them in
140
+ `pyproject.toml` with `[[tool.vercel.subscribers]]`. Each entry points at a
141
+ module that registers subscriptions with `@subscribe`:
148
142
 
149
143
  ```python
150
- # api/handle_orders.py
151
- from vercel.queue import asgi_app, subscribe
144
+ # worker.py
145
+ from vercel.queue import subscribe
152
146
 
153
147
 
154
- @subscribe(topic="orders", consumer_group="api/handle_orders.py")
148
+ @subscribe(topic="orders", retry_after=60)
155
149
  async def handle_order(message):
156
150
  print("Processing:", message)
151
+ ```
157
152
 
153
+ **pyproject.toml**:
158
154
 
159
- # An ASGI app instance that converts incoming message callbacks
160
- # sent by Vercel Queues and routes them to handlers.
161
- app = asgi_app()
155
+ ```toml
156
+ [[tool.vercel.subscribers]]
157
+ entrypoint = "worker:handle_order"
162
158
  ```
163
159
 
164
- **vercel.json**:
165
-
166
- ```json
167
- {
168
- "functions": {
169
- "api/queue/orders.py": {
170
- "experimentalTriggers": [
171
- {
172
- "type": "queue/v2beta",
173
- "topic": "orders",
174
- "retryAfterSeconds": 60,
175
- "initialDelaySeconds": 0
176
- }
177
- ]
178
- }
179
- }
180
- }
160
+ At build time, Vercel imports the entrypoint module, introspects every
161
+ subscription it registers, and compiles the subscriber into its own
162
+ queue-triggered function. Delivery tuning lives on the `@subscribe`
163
+ decorator (`retry_after`, `initial_delay`, `max_concurrency`,
164
+ `max_attempts`, `consumer_group`), so no `vercel.json` configuration is
165
+ needed.
166
+
167
+ A subscriber consumes every subscription registered by its entrypoint
168
+ module. Add an optional `topics` filter to split subscriptions across
169
+ several subscriber functions:
170
+
171
+ ```toml
172
+ [[tool.vercel.subscribers]]
173
+ entrypoint = "worker:handle_order"
174
+ topics = ["orders"]
175
+
176
+ [[tool.vercel.subscribers]]
177
+ entrypoint = "worker:handle_refund"
178
+ topics = ["refunds"]
181
179
  ```
182
180
 
183
181
  ### Automatic Polling Loop
@@ -264,7 +262,7 @@ polling, because it can distribute messages across regions unpredictably.
264
262
  ## Retry and Backoff
265
263
 
266
264
  When a topic handler raises, the message is not acknowledged and becomes available for redelivery
267
- after the `retryAfterSeconds` interval configured in `vercel.json`. Retries continue until the
265
+ after the `retry_after` interval configured on the subscriber. Retries continue until the
268
266
  handler succeeds or the message expires.
269
267
 
270
268
  For finer control over retry timing, raise `RetryAfter` from a subscriber:
@@ -8,6 +8,7 @@ from typing import Any
8
8
 
9
9
  from hatchling.metadata.plugin.interface import MetadataHookInterface
10
10
  from packaging.requirements import Requirement
11
+ from packaging.specifiers import SpecifierSet
11
12
 
12
13
  try:
13
14
  import tomllib
@@ -29,8 +30,10 @@ class WorkspaceDependenciesMetadataHook(MetadataHookInterface):
29
30
  }
30
31
  workspace_root = _find_workspace_root(Path(self.root))
31
32
 
33
+ package = pyproject.get("project", {}).get("name", str(self.root))
34
+
32
35
  metadata["dependencies"] = [
33
- _rewrite_dependency(requirement, workspace_names, workspace_root)
36
+ _rewrite_dependency(requirement, workspace_names, workspace_root, package)
34
37
  for requirement in dependency_table.get("dependencies", [])
35
38
  ]
36
39
 
@@ -55,24 +58,56 @@ def _rewrite_dependency(
55
58
  requirement: str,
56
59
  workspace_names: set[str],
57
60
  workspace_root: Path | None,
61
+ package: str,
58
62
  ) -> str:
59
63
  parsed = Requirement(requirement)
60
64
  normalized = parsed.name.lower().replace("_", "-")
61
65
  if normalized not in workspace_names or workspace_root is None:
62
66
  return requirement
63
- return _with_lower_bound(parsed, _read_workspace_version(workspace_root, normalized))
67
+ version = _read_workspace_version(workspace_root, normalized)
68
+ return _with_lower_bound(parsed, version, requirement, package)
64
69
 
65
70
 
66
- def _with_lower_bound(requirement: Requirement, version: str) -> str:
71
+ def _with_lower_bound(requirement: Requirement, version: str, declared: str, package: str) -> str:
67
72
  extras = f"[{','.join(sorted(requirement.extras))}]" if requirement.extras else ""
68
73
  specifiers = [
69
74
  str(specifier) for specifier in requirement.specifier if specifier.operator != ">="
70
75
  ]
71
76
  specifier_text = ",".join([f">={version}", *specifiers])
77
+ _reject_unsatisfiable(package, requirement.name, declared, specifier_text, version)
72
78
  marker = f" ; {requirement.marker}" if requirement.marker else ""
73
79
  return f"{requirement.name}{extras}{specifier_text}{marker}"
74
80
 
75
81
 
82
+ def _reject_unsatisfiable(
83
+ package: str, dependency: str, declared: str, specifier_text: str, version: str
84
+ ) -> None:
85
+ """Refuse to publish a bound that no version of *dependency* can satisfy.
86
+
87
+ The lower bound is generated from the sibling's current version while the
88
+ rest of the specifier is whatever the package declared, so a hand-written
89
+ upper bound that the sibling has since reached produces something like
90
+ ``>=0.3.0,<0.3.0``. Nothing rejects that later: the wheel builds, uploads,
91
+ and only fails when someone tries to install it. Fail the build instead --
92
+ CI builds every package, so the bump that crosses the bound is caught by
93
+ its own pull request.
94
+
95
+ The test is that the sibling version *being released* satisfies the bound,
96
+ which is narrower than the range being non-empty: `>=0.7.1,!=0.7.1` leaves
97
+ room for a later version, but says the release under way is unusable.
98
+ """
99
+ # `prereleases=True` so a workspace version like `0.4.0b1` is judged
100
+ # against its own bound rather than excluded for being a prerelease.
101
+ if SpecifierSet(specifier_text).contains(version, prereleases=True):
102
+ return
103
+ raise RuntimeError(
104
+ f"{package} declares {declared!r}, but {dependency} is at {version} in "
105
+ f"the workspace, so publishing would pin {dependency}{specifier_text} — "
106
+ f"which excludes {version} itself, the version being released. "
107
+ f"Raise the upper bound in [tool.vercel.release.dependencies] of {package}."
108
+ )
109
+
110
+
76
111
  def _read_workspace_version(workspace_root: Path, package_name: str) -> str:
77
112
  for pattern in ("src/*/pyproject.toml", "integrations/*/pyproject.toml"):
78
113
  for pyproject_path in workspace_root.glob(pattern):
@@ -21,6 +21,7 @@ dependencies = [
21
21
  "python-multipart>=0.0.20",
22
22
  "typing_extensions>=4.0.0",
23
23
  "vercel-headers>=0.6.0",
24
+ "vercel-internal-core>=0.1.0,<0.2.0",
24
25
  "vercel-oidc>=0.6.0",
25
26
  ]
26
27
 
@@ -32,6 +33,7 @@ trio = ["anyio[trio]>=4.0.0"]
32
33
  [tool.uv.sources]
33
34
  vercel-oidc = { workspace = true }
34
35
  vercel-headers = { workspace = true }
36
+ vercel-internal-core = { workspace = true }
35
37
 
36
38
  [tool.hatch.version]
37
39
  path = "vercel/queue/version.py"
@@ -63,6 +65,10 @@ exclude = [
63
65
  extend = "../../pyproject.toml"
64
66
  cache-dir = "../../.ruff_cache"
65
67
  preview = true
68
+ # `vercel` is a PEP 420 namespace package split across workspace members, so
69
+ # `vercel._internal.core` is this package's own shared internals rather than
70
+ # another project's privates. import-private-name needs telling.
71
+ namespace-packages = ["vercel"]
66
72
 
67
73
  [tool.ruff.lint]
68
74
  extend-select = [
@@ -12,6 +12,15 @@ from importlib import import_module
12
12
  from itertools import count
13
13
  from types import MappingProxyType
14
14
 
15
+ from vercel._internal.core.typeutils import (
16
+ ResolvedAnnotation,
17
+ TypeAnnotationResolutionError,
18
+ args,
19
+ origin_is,
20
+ resolve_annotation_with_namespace_from_call_stack,
21
+ strip_annotated,
22
+ )
23
+
15
24
  from .errors import (
16
25
  DuplicateSubscriptionError,
17
26
  PayloadValidationError,
@@ -44,14 +53,6 @@ from .types import (
44
53
  Transport,
45
54
  duration_to_seconds,
46
55
  )
47
- from .typeutils import (
48
- ResolvedAnnotation,
49
- TypeAnnotationResolutionError,
50
- args,
51
- origin_is,
52
- resolve_annotation_with_namespace_from_call_stack,
53
- strip_annotated,
54
- )
55
56
 
56
57
  _Subscriber: TypeAlias = Callable[..., Any | Awaitable[Any]]
57
58
  _SubscriberRef: TypeAlias = weakref.ReferenceType[_Subscriber]
@@ -7,6 +7,17 @@ import json
7
7
  from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable
8
8
  from importlib import import_module
9
9
 
10
+ from vercel._internal.core.typeutils import (
11
+ annotation_needs_resolution,
12
+ args,
13
+ is_classvar,
14
+ is_final,
15
+ is_type_var,
16
+ is_union_type,
17
+ origin_is,
18
+ strip_annotated,
19
+ )
20
+
10
21
  from .constants import CONTENT_TYPE_JSON, CONTENT_TYPE_OCTET_STREAM, CONTENT_TYPE_TEXT
11
22
  from .errors import SubscriptionError
12
23
  from .streams import (
@@ -20,16 +31,6 @@ from .types import (
20
31
  Topic,
21
32
  Transport,
22
33
  )
23
- from .typeutils import (
24
- annotation_needs_resolution,
25
- args,
26
- is_classvar,
27
- is_final,
28
- is_type_var,
29
- is_union_type,
30
- origin_is,
31
- strip_annotated,
32
- )
33
34
 
34
35
  T = TypeVar("T")
35
36
 
@@ -1,3 +1,3 @@
1
1
  """Package version metadata."""
2
2
 
3
- __version__ = "0.7.2"
3
+ __version__ = "0.7.3"
@@ -1,203 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import (
4
- Annotated,
5
- Any,
6
- ClassVar,
7
- Final,
8
- ForwardRef,
9
- Literal,
10
- TypeVar,
11
- Union,
12
- get_args,
13
- get_origin,
14
- get_type_hints,
15
- )
16
-
17
- import inspect
18
- import types
19
- from dataclasses import dataclass
20
- from types import FrameType
21
-
22
- _T = TypeVar("_T")
23
- _TYPE_VAR_TYPE = type(_T)
24
-
25
-
26
- class TypeAnnotationResolutionError(TypeError):
27
- """Raised when a runtime annotation cannot be resolved."""
28
-
29
-
30
- @dataclass(frozen=True)
31
- class ResolvedAnnotation:
32
- annotation: Any
33
- localns: dict[str, Any] | None = None
34
-
35
-
36
- def is_generic_alias(tp: Any) -> bool:
37
- return get_origin(tp) is not None
38
-
39
-
40
- def is_annotated(tp: Any) -> bool:
41
- return get_origin(tp) is Annotated
42
-
43
-
44
- def strip_annotated(tp: Any) -> Any:
45
- while is_annotated(tp):
46
- tp = get_args(tp)[0]
47
- return tp
48
-
49
-
50
- def is_type_var(tp: Any) -> bool:
51
- return isinstance(tp, _TYPE_VAR_TYPE)
52
-
53
-
54
- def is_classvar(tp: Any) -> bool:
55
- return get_origin(tp) is ClassVar
56
-
57
-
58
- def is_final(tp: Any) -> bool:
59
- return get_origin(tp) is Final
60
-
61
-
62
- def is_union_type(tp: Any) -> bool:
63
- return get_origin(tp) in {Union, types.UnionType}
64
-
65
-
66
- def origin_is(tp: Any, *origins: Any) -> bool:
67
- return get_origin(tp) in origins
68
-
69
-
70
- def args(tp: Any) -> tuple[Any, ...]:
71
- return get_args(tp)
72
-
73
-
74
- def annotation_needs_resolution(annotation: Any) -> bool:
75
- if isinstance(annotation, str | ForwardRef):
76
- return True
77
- if is_type_var(annotation):
78
- return False
79
- if origin_is(annotation, Literal):
80
- return False
81
- if origin_is(annotation, Annotated):
82
- annotation_args = args(annotation)
83
- return bool(annotation_args) and annotation_needs_resolution(annotation_args[0])
84
- return any(annotation_needs_resolution(item) for item in args(annotation))
85
-
86
-
87
- def _normalize_forward_refs(annotation: Any) -> Any:
88
- if isinstance(annotation, str):
89
- return ForwardRef(annotation)
90
- if isinstance(annotation, ForwardRef) or is_type_var(annotation):
91
- return annotation
92
-
93
- annotation_args = args(annotation)
94
- if not annotation_args or origin_is(annotation, Literal):
95
- return annotation
96
-
97
- origin = get_origin(annotation)
98
- if origin is Annotated:
99
- first_arg = _normalize_forward_refs(annotation_args[0])
100
- if first_arg is annotation_args[0]:
101
- return annotation
102
- return Annotated.__class_getitem__((first_arg, *annotation_args[1:]))
103
-
104
- normalized_args = tuple(_normalize_forward_refs(item) for item in annotation_args)
105
- if normalized_args == annotation_args:
106
- return annotation
107
-
108
- if isinstance(annotation, types.GenericAlias):
109
- return origin[normalized_args]
110
- copy_with = getattr(annotation, "copy_with", None)
111
- if copy_with is not None:
112
- return copy_with(normalized_args)
113
- return annotation
114
-
115
-
116
- def _resolve_annotation_fully(
117
- annotation: Any,
118
- *,
119
- globalns: dict[str, Any],
120
- localns: dict[str, Any] | None = None,
121
- ) -> ResolvedAnnotation:
122
- resolved = resolve_annotation(annotation, globalns=globalns, localns=localns)
123
- if not annotation_needs_resolution(resolved):
124
- return ResolvedAnnotation(resolved, localns)
125
-
126
- normalized = _normalize_forward_refs(resolved)
127
- return ResolvedAnnotation(
128
- resolve_annotation(normalized, globalns=globalns, localns=localns),
129
- localns,
130
- )
131
-
132
-
133
- def _call_stack_localns() -> dict[str, Any]:
134
- frame: FrameType | None = inspect.currentframe()
135
- localns: dict[str, Any] = {}
136
- try:
137
- frame = frame.f_back if frame is not None else None
138
- while frame is not None:
139
- localns = {**frame.f_locals, **localns}
140
- frame = frame.f_back
141
- finally:
142
- del frame
143
- return localns
144
-
145
-
146
- def resolve_annotation(
147
- annotation: Any,
148
- *,
149
- globalns: dict[str, Any],
150
- localns: dict[str, Any] | None = None,
151
- ) -> Any:
152
- class AnnotationShim:
153
- pass
154
-
155
- AnnotationShim.__annotations__ = {"value": annotation}
156
- return get_type_hints(
157
- AnnotationShim,
158
- globalns=globalns,
159
- localns=localns,
160
- include_extras=True,
161
- )["value"]
162
-
163
-
164
- def resolve_annotation_from_call_stack(
165
- annotation: Any,
166
- *,
167
- globalns: dict[str, Any],
168
- ) -> Any:
169
- return resolve_annotation_with_namespace_from_call_stack(
170
- annotation,
171
- globalns=globalns,
172
- ).annotation
173
-
174
-
175
- def resolve_annotation_with_namespace_from_call_stack(
176
- annotation: Any,
177
- *,
178
- globalns: dict[str, Any],
179
- ) -> ResolvedAnnotation:
180
- if annotation is Any or not annotation_needs_resolution(annotation):
181
- return ResolvedAnnotation(annotation, _call_stack_localns())
182
-
183
- resolution_error: BaseException | None = None
184
- try:
185
- return _resolve_annotation_fully(annotation, globalns=globalns)
186
- except (NameError, TypeError, AttributeError) as exc:
187
- resolution_error = exc
188
-
189
- localns = _call_stack_localns()
190
-
191
- try:
192
- return _resolve_annotation_fully(
193
- annotation,
194
- globalns=globalns,
195
- localns=localns,
196
- )
197
- except (NameError, TypeError, AttributeError) as exc:
198
- resolution_error = exc
199
-
200
- if not annotation_needs_resolution(annotation):
201
- return ResolvedAnnotation(annotation)
202
-
203
- raise TypeAnnotationResolutionError from resolution_error
File without changes
File without changes