vercel-queue-bundle 0.7.2__tar.gz → 0.8.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 (55) hide show
  1. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/PKG-INFO +46 -47
  2. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/README.md +43 -45
  3. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/_vercel_hatch_build.py +38 -3
  4. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/pyproject.toml +7 -1
  5. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/api_common.py +2 -0
  6. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/subscribers.py +81 -30
  7. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/transports.py +28 -14
  8. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/types.py +81 -20
  9. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/version.py +1 -1
  10. vercel_queue_bundle-0.7.2/vercel/queue/_internal/typeutils.py +0 -203
  11. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/LICENSE +0 -0
  12. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/hatch_build.py +0 -0
  13. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/__init__.py +0 -0
  14. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/__main__.py +0 -0
  15. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/__init__.py +0 -0
  16. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/api_async.py +0 -0
  17. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/api_sync.py +0 -0
  18. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/asgi.py +0 -0
  19. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/asynctools.py +0 -0
  20. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/cli.py +0 -0
  21. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/client.py +0 -0
  22. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/client_sync.py +0 -0
  23. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/config.py +0 -0
  24. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/constants.py +0 -0
  25. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/devserver.py +0 -0
  26. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/embedded.py +0 -0
  27. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/errors.py +0 -0
  28. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/http.py +0 -0
  29. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/lease.py +0 -0
  30. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/log.py +0 -0
  31. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/messages.py +0 -0
  32. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/multipart.py +0 -0
  33. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/names.py +0 -0
  34. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/polling.py +0 -0
  35. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/push.py +0 -0
  36. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/response.py +0 -0
  37. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/retry.py +0 -0
  38. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_internal/streams.py +0 -0
  39. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/LICENSE.python-multipart.txt +0 -0
  40. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/__init__.py +0 -0
  41. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/multipart/__init__.py +0 -0
  42. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/multipart/decoders.py +0 -0
  43. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/multipart/exceptions.py +0 -0
  44. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/multipart/multipart.py +0 -0
  45. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/python_multipart/__init__.py +0 -0
  46. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/python_multipart/decoders.py +0 -0
  47. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/python_multipart/exceptions.py +0 -0
  48. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/_vendor/python_multipart/multipart.py +0 -0
  49. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/devserver.py +0 -0
  50. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/embedded.py +0 -0
  51. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/py.typed +0 -0
  52. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/sync.py +0 -0
  53. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/testing/__init__.py +0 -0
  54. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/testing/pytest.py +0 -0
  55. {vercel_queue_bundle-0.7.2 → vercel_queue_bundle-0.8.0}/vercel/queue/testing/state.py +0 -0
@@ -1,14 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vercel-queue-bundle
3
- Version: 0.7.2
3
+ Version: 0.8.0
4
4
  Summary: Vercel Queue client for Python
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
7
7
  License-File: vercel/queue/_vendor/LICENSE.python-multipart.txt
8
8
  Requires-Python: >=3.10
9
9
  Requires-Dist: vercel-headers-bundle>=0.7.1
10
+ Requires-Dist: vercel-internal-core-bundle<0.2.0,>=0.1.2
10
11
  Requires-Dist: vercel-internal-shared-vendored-deps>=0.1.1
11
- Requires-Dist: vercel-oidc-bundle>=0.7.1
12
+ Requires-Dist: vercel-oidc-bundle>=0.8.0
12
13
  Provides-Extra: devserver
13
14
  Requires-Dist: uvicorn; extra == 'devserver'
14
15
  Provides-Extra: trio
@@ -69,35 +70,29 @@ from vercel.queue import send
69
70
  message_id = await send("my-topic", {"message": "Hello world"})
70
71
  ```
71
72
 
72
- **3. Handle incoming messages with an API route function:**
73
+ **3. Handle incoming messages in a subscriber module:**
73
74
 
74
75
  ```python
75
- # api/queue.py
76
- from vercel.queue import asgi_app, subscribe
76
+ # worker.py
77
+ from vercel.queue import subscribe
77
78
 
78
79
 
79
- @subscribe(topic="my-topic", consumer_group="api/queue.py")
80
+ @subscribe(topic="my-topic")
80
81
  async def process_message(message):
81
82
  print("Processing:", message)
82
-
83
-
84
- # An ASGI app instance that converts incoming message callbacks
85
- # sent by Vercel Queues and routes them to handlers.
86
- app = asgi_app()
87
83
  ```
88
84
 
89
- **4. Configure `vercel.json`:**
85
+ **4. Declare the subscriber in `pyproject.toml`:**
90
86
 
91
- ```json
92
- {
93
- "functions": {
94
- "api/queue.py": {
95
- "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "my-topic" }]
96
- }
97
- }
98
- }
87
+ ```toml
88
+ [[tool.vercel.subscribers]]
89
+ entrypoint = "worker:process_message"
99
90
  ```
100
91
 
92
+ The Vercel build introspects the entrypoint module's registered
93
+ subscriptions and compiles it into a queue-triggered function. No
94
+ `vercel.json` trigger configuration is needed.
95
+
101
96
  **5. Deploy:**
102
97
 
103
98
  ```bash
@@ -165,42 +160,46 @@ and running polling loops on other infrastructure.
165
160
 
166
161
  ### Auto-scaled push-mode on Vercel
167
162
 
168
- The recommended way of deploying queue subscribers is to deploy them as Vercel Functions
169
-
170
- **Vercel Function (plain `/api` directory):**
163
+ The recommended way of deploying queue subscribers is to declare them in
164
+ `pyproject.toml` with `[[tool.vercel.subscribers]]`. Each entry points at a
165
+ module that registers subscriptions with `@subscribe`:
171
166
 
172
167
  ```python
173
- # api/handle_orders.py
174
- from vercel.queue import asgi_app, subscribe
168
+ # worker.py
169
+ from vercel.queue import subscribe
175
170
 
176
171
 
177
- @subscribe(topic="orders", consumer_group="api/handle_orders.py")
172
+ @subscribe(topic="orders", retry_after=60)
178
173
  async def handle_order(message):
179
174
  print("Processing:", message)
175
+ ```
180
176
 
177
+ **pyproject.toml**:
181
178
 
182
- # An ASGI app instance that converts incoming message callbacks
183
- # sent by Vercel Queues and routes them to handlers.
184
- app = asgi_app()
179
+ ```toml
180
+ [[tool.vercel.subscribers]]
181
+ entrypoint = "worker:handle_order"
185
182
  ```
186
183
 
187
- **vercel.json**:
188
-
189
- ```json
190
- {
191
- "functions": {
192
- "api/queue/orders.py": {
193
- "experimentalTriggers": [
194
- {
195
- "type": "queue/v2beta",
196
- "topic": "orders",
197
- "retryAfterSeconds": 60,
198
- "initialDelaySeconds": 0
199
- }
200
- ]
201
- }
202
- }
203
- }
184
+ At build time, Vercel imports the entrypoint module, introspects every
185
+ subscription it registers, and compiles the subscriber into its own
186
+ queue-triggered function. Delivery tuning lives on the `@subscribe`
187
+ decorator (`retry_after`, `initial_delay`, `max_concurrency`,
188
+ `max_attempts`, `consumer_group`), so no `vercel.json` configuration is
189
+ needed.
190
+
191
+ A subscriber consumes every subscription registered by its entrypoint
192
+ module. Add an optional `topics` filter to split subscriptions across
193
+ several subscriber functions:
194
+
195
+ ```toml
196
+ [[tool.vercel.subscribers]]
197
+ entrypoint = "worker:handle_order"
198
+ topics = ["orders"]
199
+
200
+ [[tool.vercel.subscribers]]
201
+ entrypoint = "worker:handle_refund"
202
+ topics = ["refunds"]
204
203
  ```
205
204
 
206
205
  ### Automatic Polling Loop
@@ -287,7 +286,7 @@ polling, because it can distribute messages across regions unpredictably.
287
286
  ## Retry and Backoff
288
287
 
289
288
  When a topic handler raises, the message is not acknowledged and becomes available for redelivery
290
- after the `retryAfterSeconds` interval configured in `vercel.json`. Retries continue until the
289
+ after the `retry_after` interval configured on the subscriber. Retries continue until the
291
290
  handler succeeds or the message expires.
292
291
 
293
292
  For finer control over retry timing, raise `RetryAfter` from a subscriber:
@@ -50,35 +50,29 @@ from vercel.queue import send
50
50
  message_id = await send("my-topic", {"message": "Hello world"})
51
51
  ```
52
52
 
53
- **3. Handle incoming messages with an API route function:**
53
+ **3. Handle incoming messages in a subscriber module:**
54
54
 
55
55
  ```python
56
- # api/queue.py
57
- from vercel.queue import asgi_app, subscribe
56
+ # worker.py
57
+ from vercel.queue import subscribe
58
58
 
59
59
 
60
- @subscribe(topic="my-topic", consumer_group="api/queue.py")
60
+ @subscribe(topic="my-topic")
61
61
  async def process_message(message):
62
62
  print("Processing:", message)
63
-
64
-
65
- # An ASGI app instance that converts incoming message callbacks
66
- # sent by Vercel Queues and routes them to handlers.
67
- app = asgi_app()
68
63
  ```
69
64
 
70
- **4. Configure `vercel.json`:**
65
+ **4. Declare the subscriber in `pyproject.toml`:**
71
66
 
72
- ```json
73
- {
74
- "functions": {
75
- "api/queue.py": {
76
- "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "my-topic" }]
77
- }
78
- }
79
- }
67
+ ```toml
68
+ [[tool.vercel.subscribers]]
69
+ entrypoint = "worker:process_message"
80
70
  ```
81
71
 
72
+ The Vercel build introspects the entrypoint module's registered
73
+ subscriptions and compiles it into a queue-triggered function. No
74
+ `vercel.json` trigger configuration is needed.
75
+
82
76
  **5. Deploy:**
83
77
 
84
78
  ```bash
@@ -146,42 +140,46 @@ and running polling loops on other infrastructure.
146
140
 
147
141
  ### Auto-scaled push-mode on Vercel
148
142
 
149
- The recommended way of deploying queue subscribers is to deploy them as Vercel Functions
150
-
151
- **Vercel Function (plain `/api` directory):**
143
+ The recommended way of deploying queue subscribers is to declare them in
144
+ `pyproject.toml` with `[[tool.vercel.subscribers]]`. Each entry points at a
145
+ module that registers subscriptions with `@subscribe`:
152
146
 
153
147
  ```python
154
- # api/handle_orders.py
155
- from vercel.queue import asgi_app, subscribe
148
+ # worker.py
149
+ from vercel.queue import subscribe
156
150
 
157
151
 
158
- @subscribe(topic="orders", consumer_group="api/handle_orders.py")
152
+ @subscribe(topic="orders", retry_after=60)
159
153
  async def handle_order(message):
160
154
  print("Processing:", message)
155
+ ```
161
156
 
157
+ **pyproject.toml**:
162
158
 
163
- # An ASGI app instance that converts incoming message callbacks
164
- # sent by Vercel Queues and routes them to handlers.
165
- app = asgi_app()
159
+ ```toml
160
+ [[tool.vercel.subscribers]]
161
+ entrypoint = "worker:handle_order"
166
162
  ```
167
163
 
168
- **vercel.json**:
169
-
170
- ```json
171
- {
172
- "functions": {
173
- "api/queue/orders.py": {
174
- "experimentalTriggers": [
175
- {
176
- "type": "queue/v2beta",
177
- "topic": "orders",
178
- "retryAfterSeconds": 60,
179
- "initialDelaySeconds": 0
180
- }
181
- ]
182
- }
183
- }
184
- }
164
+ At build time, Vercel imports the entrypoint module, introspects every
165
+ subscription it registers, and compiles the subscriber into its own
166
+ queue-triggered function. Delivery tuning lives on the `@subscribe`
167
+ decorator (`retry_after`, `initial_delay`, `max_concurrency`,
168
+ `max_attempts`, `consumer_group`), so no `vercel.json` configuration is
169
+ needed.
170
+
171
+ A subscriber consumes every subscription registered by its entrypoint
172
+ module. Add an optional `topics` filter to split subscriptions across
173
+ several subscriber functions:
174
+
175
+ ```toml
176
+ [[tool.vercel.subscribers]]
177
+ entrypoint = "worker:handle_order"
178
+ topics = ["orders"]
179
+
180
+ [[tool.vercel.subscribers]]
181
+ entrypoint = "worker:handle_refund"
182
+ topics = ["refunds"]
185
183
  ```
186
184
 
187
185
  ### Automatic Polling Loop
@@ -268,7 +266,7 @@ polling, because it can distribute messages across regions unpredictably.
268
266
  ## Retry and Backoff
269
267
 
270
268
  When a topic handler raises, the message is not acknowledged and becomes available for redelivery
271
- after the `retryAfterSeconds` interval configured in `vercel.json`. Retries continue until the
269
+ after the `retry_after` interval configured on the subscriber. Retries continue until the
272
270
  handler succeeds or the message expires.
273
271
 
274
272
  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,7 +21,8 @@ path = "hatch_build.py"
21
21
  [tool.vercel.release.dependencies]
22
22
  dependencies = [
23
23
  "vercel-headers-bundle>=0.7.1",
24
- "vercel-oidc-bundle>=0.7.1",
24
+ "vercel-internal-core-bundle>=0.1.2,<0.2.0",
25
+ "vercel-oidc-bundle>=0.8.0",
25
26
  "vercel-internal-shared-vendored-deps>=0.1.1",
26
27
  ]
27
28
 
@@ -33,6 +34,7 @@ trio = ["anyio[trio]>=4.0.0"]
33
34
  [tool.uv.sources]
34
35
  vercel-oidc = { workspace = true }
35
36
  vercel-headers = { workspace = true }
37
+ vercel-internal-core = { workspace = true }
36
38
 
37
39
  [tool.hatch.version]
38
40
  path = "vercel/queue/version.py"
@@ -65,6 +67,10 @@ exclude = [
65
67
  extend = "../../pyproject.toml"
66
68
  cache-dir = "../../.ruff_cache"
67
69
  preview = true
70
+ # `vercel` is a PEP 420 namespace package split across workspace members, so
71
+ # `vercel._internal.core` is this package's own shared internals rather than
72
+ # another project's privates. import-private-name needs telling.
73
+ namespace-packages = ["vercel"]
68
74
 
69
75
  [tool.ruff.lint]
70
76
  extend-select = [
@@ -35,6 +35,7 @@ StrContainer = _types.StrContainer
35
35
  TextBufferTransport = _transports.TextBufferTransport
36
36
  TextStreamTransport = _transports.TextStreamTransport
37
37
  Topic = _types.Topic
38
+ TopicPattern = _types.TopicPattern
38
39
  TypedJsonTransport = _transports.TypedJsonTransport
39
40
  __version__ = _version.__version__
40
41
  asgi_app = _asgi.asgi_app
@@ -93,6 +94,7 @@ __all__ = (
93
94
  "ThrottledError",
94
95
  "TokenResolutionError",
95
96
  "Topic",
97
+ "TopicPattern",
96
98
  "TypedJsonTransport",
97
99
  "UnauthorizedError",
98
100
  "UnhandledMessageError",
@@ -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,
@@ -31,9 +40,11 @@ from .transports import (
31
40
  is_untyped_payload_annotation,
32
41
  payload_transport_kind,
33
42
  reject_invalid_payload_annotation,
43
+ topic_payload_annotation,
34
44
  transport_for_kind,
35
45
  )
36
46
  from .types import (
47
+ BaseTopic,
37
48
  Duration,
38
49
  Message,
39
50
  MessageMetadata,
@@ -41,20 +52,15 @@ from .types import (
41
52
  RetryAfter,
42
53
  StrContainer,
43
54
  Topic,
55
+ TopicPattern,
44
56
  Transport,
45
57
  duration_to_seconds,
46
58
  )
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
59
 
56
60
  _Subscriber: TypeAlias = Callable[..., Any | Awaitable[Any]]
57
61
  _SubscriberRef: TypeAlias = weakref.ReferenceType[_Subscriber]
62
+ _SubscriptionTopic: TypeAlias = "str | SanitizedName | Topic[Any] | TopicPattern[Any]"
63
+ """Anything ``subscribe()`` accepts: one topic, or a pattern matching several."""
58
64
  P = ParamSpec("P")
59
65
  R = TypeVar("R")
60
66
  R_co = TypeVar("R_co", covariant=True)
@@ -65,6 +71,14 @@ class PayloadAdapter(Protocol):
65
71
  def validate_python(self, value: Any, /) -> Any: ...
66
72
 
67
73
 
74
+ class _TransportPayloadAdapter:
75
+ def __init__(self, validate_payload: Callable[[Any], Any]) -> None:
76
+ self._validate_payload = validate_payload
77
+
78
+ def validate_python(self, value: Any, /) -> Any:
79
+ return self._validate_payload(value)
80
+
81
+
68
82
  class EmbeddedDispatcher(Protocol):
69
83
  def register_subscription(
70
84
  self,
@@ -106,6 +120,7 @@ class InvocationPlan:
106
120
  payload_adapter: PayloadAdapter | None
107
121
  mode: InvocationMode
108
122
  transport_kind: TransportKind
123
+ transport: Transport[Any] | None = None
109
124
 
110
125
  def prepare_payload(self, payload: Any) -> Any:
111
126
  if self.payload_adapter is None:
@@ -119,6 +134,33 @@ class InvocationPlan:
119
134
  raise
120
135
 
121
136
 
137
+ class _TransportOrKind:
138
+ def __init__(self, plan: InvocationPlan) -> None:
139
+ self.transport_kind = plan.transport_kind
140
+ self.transport = plan.transport
141
+
142
+ def __hash__(self) -> int:
143
+ return hash(self.transport_kind) if self.transport is None else id(self.transport)
144
+
145
+ def __eq__(self, other: object) -> bool:
146
+ if not isinstance(other, _TransportOrKind):
147
+ return NotImplemented
148
+ if self.transport is None and other.transport is None:
149
+ return self.transport_kind == other.transport_kind
150
+ # Two instances of the same class can decode differently
151
+ return self.transport is other.transport
152
+
153
+ def __repr__(self) -> str:
154
+ if self.transport is None:
155
+ return self.transport_kind
156
+ return repr(self.transport)
157
+
158
+ def get_transport(self) -> Transport[Any]:
159
+ if self.transport is None:
160
+ return _transport_for_kind(self.transport_kind)
161
+ return self.transport
162
+
163
+
122
164
  @dataclass(frozen=True, kw_only=True)
123
165
  class _Subscription:
124
166
  func_ref: _SubscriberRef
@@ -313,23 +355,17 @@ def _message_payload_annotation(annotation: Any) -> Any:
313
355
  return message_args[0]
314
356
 
315
357
 
316
- def _topic_payload_annotation(topic: str | SanitizedName | Topic[Any]) -> Any:
317
- if not isinstance(topic, Topic):
318
- return inspect.Signature.empty
319
- if getattr(type(topic), "__topic_origin__", None) is not Topic:
320
- return inspect.Signature.empty
321
- return type(topic).__topic_payload_type__
322
-
323
-
324
- def _normalize_subscription_topic(topic: str | SanitizedName | Topic[Any]) -> str:
358
+ def _normalize_subscription_topic(topic: _SubscriptionTopic) -> str:
325
359
  if isinstance(topic, SanitizedName):
326
360
  return str(topic)
327
361
  if isinstance(topic, str):
328
362
  validate_subscription_pattern(topic)
329
363
  return topic
364
+ if isinstance(topic, TopicPattern):
365
+ return str(topic.name)
330
366
  if isinstance(topic, Topic):
331
367
  return validate_topic_name(topic)
332
- raise TypeError("topic must be a string or Topic")
368
+ raise TypeError("topic must be a string, Topic, or TopicPattern")
333
369
 
334
370
 
335
371
  def _resolve_invocation_payload_annotation(
@@ -355,7 +391,11 @@ def _payload_adapter(
355
391
  annotation: Any,
356
392
  *,
357
393
  localns: dict[str, Any] | None = None,
394
+ transport: Transport[Any] | None = None,
358
395
  ) -> PayloadAdapter | None:
396
+ validate_payload = getattr(transport, "validate_payload", None)
397
+ if callable(validate_payload):
398
+ return _TransportPayloadAdapter(validate_payload)
359
399
  annotation = strip_annotated(annotation)
360
400
  if is_untyped_payload_annotation(annotation):
361
401
  return None
@@ -406,6 +446,7 @@ def _build_invocation_plan(
406
446
  func: _Subscriber,
407
447
  *,
408
448
  topic_payload_annotation: Any = inspect.Signature.empty,
449
+ transport: Transport[Any] | None = None,
409
450
  ) -> InvocationPlan:
410
451
  signature = inspect.signature(func)
411
452
  input_params: list[inspect.Parameter] = []
@@ -445,9 +486,11 @@ def _build_invocation_plan(
445
486
  payload_adapter=_payload_adapter(
446
487
  payload_annotation,
447
488
  localns=resolved_annotation.localns,
489
+ transport=transport,
448
490
  ),
449
491
  mode=mode,
450
492
  transport_kind=_transport_kind(payload_annotation),
493
+ transport=transport,
451
494
  )
452
495
 
453
496
 
@@ -592,13 +635,13 @@ def infer_subscriber_transport(metadata: MessageMetadata) -> Transport[Any]:
592
635
  if not matching:
593
636
  raise _no_matching_subscriptions_error(metadata.topic)
594
637
 
595
- kinds = {matched.subscription.invocation.transport_kind for matched in matching}
596
- if len(kinds) != 1:
638
+ tks = {_TransportOrKind(matched.subscription.invocation) for matched in matching}
639
+ if len(tks) != 1:
597
640
  raise SubscriptionError(
598
641
  "matching queue subscribers require incompatible payload transports: "
599
- + ", ".join(sorted(kinds))
642
+ + ", ".join(sorted(map(repr, tks)))
600
643
  )
601
- return _transport_for_kind(kinds.pop())
644
+ return tks.pop().get_transport()
602
645
 
603
646
 
604
647
  async def _maybe_await_result(result: Any) -> Any:
@@ -701,14 +744,14 @@ def _register_subscription(
701
744
  func: Callable[P, R],
702
745
  *,
703
746
  consumer_group: str | SanitizedName | None = None,
704
- topic: str | SanitizedName | Topic[Any],
747
+ topic: _SubscriptionTopic,
705
748
  retry_after: Duration | None = None,
706
749
  initial_delay: Duration | None = None,
707
750
  max_concurrency: int | None = None,
708
751
  max_attempts: int | None = None,
709
752
  ) -> QueueSubscriber[P, R]:
710
753
  topic_name = _normalize_subscription_topic(topic)
711
- topic_payload_annotation = _topic_payload_annotation(topic)
754
+ payload_annotation = topic_payload_annotation(topic)
712
755
 
713
756
  resolved_consumer_group = (
714
757
  _default_consumer_group(func)
@@ -724,7 +767,10 @@ def _register_subscription(
724
767
  consumer_group=resolved_consumer_group,
725
768
  invocation=_build_invocation_plan(
726
769
  cast("_Subscriber", func),
727
- topic_payload_annotation=topic_payload_annotation,
770
+ topic_payload_annotation=payload_annotation,
771
+ # This is not receive_transport_for_topic() because we are doing
772
+ # more than that in _build_invocation_plan()
773
+ transport=topic.transport if isinstance(topic, BaseTopic) else None,
728
774
  ),
729
775
  topic=topic_name,
730
776
  retry_after_seconds=_optional_bounded_duration(
@@ -768,7 +814,12 @@ def subscribe(
768
814
 
769
815
 
770
816
  @overload
771
- def subscribe(func: Callable[[T], R], /, *, topic: Topic[T]) -> QueueSubscriber[[T], R]: ...
817
+ def subscribe(
818
+ func: Callable[[T], R],
819
+ /,
820
+ *,
821
+ topic: Topic[T] | TopicPattern[T],
822
+ ) -> QueueSubscriber[[T], R]: ...
772
823
 
773
824
 
774
825
  @overload
@@ -776,7 +827,7 @@ def subscribe(
776
827
  func: Callable[[Message[T]], R],
777
828
  /,
778
829
  *,
779
- topic: Topic[T],
830
+ topic: Topic[T] | TopicPattern[T],
780
831
  ) -> QueueSubscriber[[Message[T]], R]: ...
781
832
 
782
833
 
@@ -807,7 +858,7 @@ def subscribe(
807
858
  @overload
808
859
  def subscribe(
809
860
  *,
810
- topic: Topic[T],
861
+ topic: Topic[T] | TopicPattern[T],
811
862
  consumer_group: str | SanitizedName | None = None,
812
863
  retry_after: Duration | None = None,
813
864
  initial_delay: Duration | None = None,
@@ -849,7 +900,7 @@ def subscribe(
849
900
  func: None,
850
901
  /,
851
902
  *,
852
- topic: Topic[T],
903
+ topic: Topic[T] | TopicPattern[T],
853
904
  consumer_group: str | SanitizedName | None = None,
854
905
  retry_after: Duration | None = None,
855
906
  initial_delay: Duration | None = None,
@@ -862,7 +913,7 @@ def subscribe(
862
913
  func: _Subscriber | None = None,
863
914
  /,
864
915
  *,
865
- topic: str | SanitizedName | Topic[Any],
916
+ topic: _SubscriptionTopic,
866
917
  consumer_group: str | SanitizedName | None = None,
867
918
  retry_after: Duration | None = None,
868
919
  initial_delay: Duration | None = None,
@@ -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 (
@@ -16,20 +27,12 @@ from .streams import (
16
27
  SyncTextStreamPayload,
17
28
  )
18
29
  from .types import (
30
+ BaseTopic,
19
31
  RequestContent,
20
32
  Topic,
33
+ TopicPattern,
21
34
  Transport,
22
35
  )
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
36
 
34
37
  T = TypeVar("T")
35
38
 
@@ -303,14 +306,24 @@ def receive_transport_for_annotation(annotation: Any) -> Transport[Any]:
303
306
  return transport_for_kind(kind)
304
307
 
305
308
 
309
+ def topic_payload_annotation(topic: object) -> Any:
310
+ """Return the payload type ``topic`` was specialized with, if any.
311
+
312
+ A bare name, an unspecialized topic, and a user subclass all report "no
313
+ annotation": none of them carries a payload type we put there.
314
+ """
315
+ origin = getattr(type(topic), "__topic_origin__", None)
316
+ if origin is not Topic and origin is not TopicPattern:
317
+ return inspect.Signature.empty
318
+ return getattr(type(topic), "__topic_payload_type__", inspect.Signature.empty)
319
+
320
+
306
321
  def receive_transport_for_topic(topic: object) -> Transport[Any]:
307
- if not isinstance(topic, Topic):
322
+ if not isinstance(topic, BaseTopic):
308
323
  return RawJsonTransport[Any]()
309
324
  if topic.transport is not None:
310
325
  return topic.transport
311
- if getattr(type(topic), "__topic_origin__", None) is not Topic:
312
- return RawJsonTransport[Any]()
313
- return receive_transport_for_annotation(type(topic).__topic_payload_type__)
326
+ return receive_transport_for_annotation(topic_payload_annotation(topic))
314
327
 
315
328
 
316
329
  def send_transport_for_topic(topic: object) -> Transport[Any] | None:
@@ -359,5 +372,6 @@ __all__ = (
359
372
  "receive_transport_for_topic",
360
373
  "reject_invalid_payload_annotation",
361
374
  "send_transport_for_topic",
375
+ "topic_payload_annotation",
362
376
  "transport_for_kind",
363
377
  )
@@ -12,8 +12,15 @@ from collections.abc import (
12
12
  from dataclasses import dataclass
13
13
  from datetime import datetime, timedelta
14
14
 
15
+ from vercel._internal.core.polyfills import Self
16
+
15
17
  from .constants import DEFAULT_RETRY_AFTER_SECONDS
16
- from .names import SanitizedName, validate_name, validate_topic_name
18
+ from .names import (
19
+ SanitizedName,
20
+ validate_name,
21
+ validate_subscription_pattern,
22
+ validate_topic_name,
23
+ )
17
24
 
18
25
  T = TypeVar("T")
19
26
  _TYPE_VAR_TYPE = type(T)
@@ -62,47 +69,62 @@ def duration_to_float_seconds(duration: Duration) -> float:
62
69
 
63
70
 
64
71
  @dataclass(frozen=True, kw_only=True, eq=False)
65
- class Topic(Generic[T]):
66
- """A named Vercel Queues topic.
72
+ class BaseTopic(Generic[T]):
73
+ """Shared behaviour of :class:`Topic` and :class:`TopicPattern`.
67
74
 
68
- Topics identify the stream that messages are sent to and received from.
75
+ Use ``Topic`` to name one topic and ``TopicPattern`` to match several; the
76
+ two are separate types so that a pattern cannot reach an operation that
77
+ needs a single, concrete topic.
69
78
  """
70
79
 
71
- name: SanitizedName
72
- """Topic name to send to or receive from."""
80
+ name: SanitizedName | str
81
+ """The topic name, or the pattern that selects topic names.
82
+
83
+ A pattern is a plain ``str``: ``SanitizedName`` means "safe to put in a
84
+ request path", which a pattern is not.
85
+ """
73
86
 
74
87
  transport: Transport[Any] | None = None
75
- """Optional transport used when sending to or polling this topic."""
88
+ """Optional transport used when sending to, polling, or subscribing to this topic."""
76
89
 
77
- __topic_origin__: ClassVar[type[Topic[Any]] | None] = None
90
+ __topic_origin__: ClassVar[type[BaseTopic[Any]] | None] = None
78
91
  __topic_payload_type__: ClassVar[Any] = None
79
- _specializations: ClassVar[dict[Any, type[Topic[Any]]]] = {}
80
-
81
- def __class_getitem__(cls, params: Any) -> type[Topic[Any]]:
92
+ _specializations: ClassVar[dict[Any, type[BaseTopic[Any]]]] = {}
93
+
94
+ def __init_subclass__(cls, **kwargs: Any) -> None:
95
+ super().__init_subclass__(**kwargs)
96
+ # `Topic` and `TopicPattern` each need their own cache, or the first
97
+ # `Topic[bytes]` would be handed back for `TopicPattern[bytes]`. A
98
+ # specialization (the only thing carrying `__topic_origin__` in its
99
+ # own namespace) keeps sharing the cache of the class it came from.
100
+ if "__topic_origin__" not in cls.__dict__:
101
+ cls._specializations = {}
102
+
103
+ def __class_getitem__(cls, params: Any) -> type[Self]:
82
104
  if isinstance(params, tuple):
83
105
  if len(params) != 1:
84
- raise TypeError("Topic expects exactly one type argument")
106
+ raise TypeError(f"{cls.__name__} expects exactly one type argument")
85
107
  params = params[0]
86
108
  if isinstance(params, _TYPE_VAR_TYPE):
87
- return cast("type[Topic[Any]]", cls)
109
+ return cls
88
110
 
89
111
  try:
90
- return cls._specializations[params]
112
+ return cast("type[Self]", cls._specializations[params])
91
113
  except KeyError:
92
114
  pass
93
115
 
94
116
  payload_repr = _topic_payload_type_repr(params)
95
117
  specialization = type(
96
- f"Topic[{payload_repr}]",
118
+ f"{cls.__name__}[{payload_repr}]",
97
119
  (cls,),
98
120
  {
99
121
  "__module__": cls.__module__,
100
- "__topic_origin__": Topic,
122
+ "__topic_origin__": cls,
101
123
  "__topic_payload_type__": params,
102
124
  },
103
125
  )
104
126
  cls._specializations[params] = specialization
105
- return cast("type[Topic[Any]]", specialization)
127
+ return cast("type[Self]", specialization)
106
128
 
107
129
  def __init__(
108
130
  self,
@@ -110,14 +132,22 @@ class Topic(Generic[T]):
110
132
  *,
111
133
  transport: Transport[Any] | None = None,
112
134
  ) -> None:
113
- object.__setattr__(self, "name", SanitizedName(validate_topic_name(name)))
135
+ object.__setattr__(self, "name", self._validated_name(name))
114
136
  object.__setattr__(self, "transport", transport)
115
137
 
138
+ @staticmethod
139
+ def _validated_name(name: str | SanitizedName) -> SanitizedName | str:
140
+ raise NotImplementedError
141
+
116
142
  def __repr__(self) -> str:
117
- return f"Topic(name={self.name!r})"
143
+ # Report the class the user named, not the synthesized specialization.
144
+ cls = type(self)
145
+ return f"{(cls.__topic_origin__ or cls).__name__}(name={self.name!r})"
118
146
 
119
147
  def __eq__(self, other: object) -> bool:
120
- if not isinstance(other, Topic):
148
+ # A pattern always ends in `*`, which a topic name can never contain,
149
+ # so comparing names is enough to keep the two kinds apart.
150
+ if not isinstance(other, BaseTopic):
121
151
  return NotImplemented
122
152
  return self.name == other.name
123
153
 
@@ -125,6 +155,36 @@ class Topic(Generic[T]):
125
155
  return hash(self.name)
126
156
 
127
157
 
158
+ class Topic(BaseTopic[T]):
159
+ """A named Vercel Queues topic.
160
+
161
+ Topics identify the stream that messages are sent to and received from.
162
+ """
163
+
164
+ name: SanitizedName
165
+ """Topic name to send to, poll, or subscribe to."""
166
+
167
+ @staticmethod
168
+ def _validated_name(name: str | SanitizedName) -> SanitizedName | str:
169
+ return SanitizedName(validate_topic_name(name))
170
+
171
+
172
+ class TopicPattern(BaseTopic[T]):
173
+ """A pattern selecting every Vercel Queues topic it matches.
174
+
175
+ ``"*"`` matches every topic and a prefix ending in ``*`` matches by
176
+ prefix. A pattern can only be subscribed to: sending and polling name one
177
+ topic, so they take a :class:`Topic`.
178
+ """
179
+
180
+ name: str
181
+ """The pattern this subscription matches topics with."""
182
+
183
+ @staticmethod
184
+ def _validated_name(name: str | SanitizedName) -> SanitizedName | str:
185
+ return validate_subscription_pattern(str(name))
186
+
187
+
128
188
  def _topic_payload_type_repr(payload_type: object) -> str:
129
189
  name = getattr(payload_type, "__qualname__", None)
130
190
  if isinstance(name, str):
@@ -297,4 +357,5 @@ __all__: tuple[str, ...] = (
297
357
  "RetryAfter",
298
358
  "StrContainer",
299
359
  "Topic",
360
+ "TopicPattern",
300
361
  )
@@ -1,3 +1,3 @@
1
1
  """Package version metadata."""
2
2
 
3
- __version__ = "0.7.2"
3
+ __version__ = "0.8.0"
@@ -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