vercel-connect-bundle 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. vercel_connect_bundle-0.1.0/LICENSE +21 -0
  2. vercel_connect_bundle-0.1.0/PKG-INFO +203 -0
  3. vercel_connect_bundle-0.1.0/README.md +190 -0
  4. vercel_connect_bundle-0.1.0/_vercel_hatch_build.py +151 -0
  5. vercel_connect_bundle-0.1.0/hatch_build.py +29 -0
  6. vercel_connect_bundle-0.1.0/pyproject.toml +87 -0
  7. vercel_connect_bundle-0.1.0/vercel/connect/__init__.py +454 -0
  8. vercel_connect_bundle-0.1.0/vercel/connect/_internal/__init__.py +1 -0
  9. vercel_connect_bundle-0.1.0/vercel/connect/_internal/api_client.py +401 -0
  10. vercel_connect_bundle-0.1.0/vercel/connect/_internal/async_runtime.py +144 -0
  11. vercel_connect_bundle-0.1.0/vercel/connect/_internal/base.py +118 -0
  12. vercel_connect_bundle-0.1.0/vercel/connect/_internal/cache.py +305 -0
  13. vercel_connect_bundle-0.1.0/vercel/connect/_internal/errors.py +174 -0
  14. vercel_connect_bundle-0.1.0/vercel/connect/_internal/identity.py +104 -0
  15. vercel_connect_bundle-0.1.0/vercel/connect/_internal/models.py +198 -0
  16. vercel_connect_bundle-0.1.0/vercel/connect/_internal/options.py +135 -0
  17. vercel_connect_bundle-0.1.0/vercel/connect/_internal/service.py +426 -0
  18. vercel_connect_bundle-0.1.0/vercel/connect/_internal/single_flight.py +152 -0
  19. vercel_connect_bundle-0.1.0/vercel/connect/_internal/state.py +177 -0
  20. vercel_connect_bundle-0.1.0/vercel/connect/_internal/sync_runtime.py +159 -0
  21. vercel_connect_bundle-0.1.0/vercel/connect/_vendor/__init__.py +1 -0
  22. vercel_connect_bundle-0.1.0/vercel/connect/py.typed +0 -0
  23. vercel_connect_bundle-0.1.0/vercel/connect/sync.py +450 -0
  24. vercel_connect_bundle-0.1.0/vercel/connect/version.py +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vercel, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: vercel-connect-bundle
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Vercel Connect
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: pydantic<3,>=2.7.0
9
+ Requires-Dist: vercel-internal-core-bundle<0.2.0,>=0.1.2
10
+ Requires-Dist: vercel-internal-shared-vendored-deps>=0.1.1
11
+ Requires-Dist: vercel-oidc-bundle[verify]>=0.8.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # vercel-connect-bundle
15
+
16
+ This is a version of `vercel-connect` with third-party dependencies bundled. For normal use, install the unbundled `vercel-connect` package instead: https://pypi.org/project/vercel-connect/
17
+
18
+ # vercel-connect
19
+
20
+ Python SDK for [Vercel Connect](https://vercel.com/docs), a credential broker for
21
+ third-party APIs.
22
+
23
+ You exchange your deployment's token for a short-lived credential for an upstream
24
+ service. Your project never stores provider secrets, and Connect owns the OAuth
25
+ client, PKCE, refresh, and revocation server-side.
26
+
27
+ ```sh
28
+ pip install vercel-connect
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```python
34
+ import httpx
35
+ from vercel.connect import ConnectAppTokenSubject, get_token
36
+
37
+ token = await get_token("github/my-app", subject=ConnectAppTokenSubject())
38
+
39
+ async with httpx.AsyncClient() as client:
40
+ await client.get(
41
+ "https://api.github.com/user/repos",
42
+ headers={"Authorization": f"Bearer {token}"},
43
+ )
44
+ ```
45
+
46
+ The same surface is available synchronously, with identical names and arguments:
47
+
48
+ ```python
49
+ from vercel.connect.sync import ConnectAppTokenSubject, get_token
50
+
51
+ token = get_token("github/my-app", subject=ConnectAppTokenSubject())
52
+ ```
53
+
54
+ Use a plain `with` block and `vercel.connect.sync` together; mixing an async call
55
+ into a sync session, or the reverse, is rejected.
56
+
57
+ ## Subjects
58
+
59
+ Whose authority the credential carries:
60
+
61
+ | Subject | Authority | Needs |
62
+ | --- | --- | --- |
63
+ | `ConnectAppTokenSubject()` | The integration itself | An installation |
64
+ | `ConnectUserTokenSubject(id=...)` | One named end user | That user's consent |
65
+ | `ConnectJwtBearerTokenSubject(sub=...)` | A user asserted by your app | Pre-established trust |
66
+ | `ConnectTokenExchangeSubject(token=...)` | A credential you already hold | The inbound token |
67
+
68
+ `app` is one shared credential per installation: simple, always available, but
69
+ ambient authority. `user` preserves the provider's own permission model per
70
+ person and names them in the provider's audit log, at the cost of a consent flow.
71
+
72
+ Subjects are typed values rather than plain strings because three of the four
73
+ carry their own fields, so `subject="user"` could not say *which* user:
74
+
75
+ ```python
76
+ ConnectUserTokenSubject(id="u_123", issuer="https://idp.example.com")
77
+ ConnectJwtBearerTokenSubject(sub="u_123", additional_claims={"tenant": "acme"})
78
+ ```
79
+
80
+ ## Value types
81
+
82
+ Every type on this surface is a frozen Pydantic model, so you get validation on
83
+ construction, autocompletion, exact `match`/`case` narrowing, `model_dump()` for
84
+ logging, and immutability, which means a subject cannot be mutated after a
85
+ credential has been cached against it:
86
+
87
+ ```python
88
+ detail = ConnectGitHubAppInstallationAuthorizationDetail(permissions=["contents:read"])
89
+ detail.model_dump() # {'org': None, 'permissions': ('contents:read',), ...}
90
+ detail.permissions = ["admin"] # ConnectValidationError: frozen
91
+ ```
92
+
93
+ Construction is by keyword, a misspelled field is an error rather than a silently
94
+ dropped value, and every rejection raises `ConnectValidationError`, so you never
95
+ need to catch Pydantic's own error type. Containers of strings accept any
96
+ container and store a tuple; a bare string is rejected rather than expanded into
97
+ one entry per character:
98
+
99
+ ```python
100
+ get_token(..., scopes="repo:read") # ConnectValidationError, and a type error
101
+ get_token(..., scopes=["repo:read"]) # correct
102
+ ```
103
+
104
+ ## Authorization as control flow
105
+
106
+ The two "required" errors are not bugs, they are states with a remedy:
107
+
108
+ ```python
109
+ from vercel.connect import (
110
+ ConnectUserTokenSubject,
111
+ UserAuthorizationRequiredError,
112
+ get_token,
113
+ start_authorization,
114
+ )
115
+
116
+ subject = ConnectUserTokenSubject(id=user_id)
117
+ try:
118
+ token = await get_token("linear/my-app", subject=subject)
119
+ except UserAuthorizationRequiredError:
120
+ authorization = await start_authorization(
121
+ "linear/my-app", subject=subject, return_url="https://myapp.com/cb"
122
+ )
123
+ return redirect(authorization.url)
124
+ ```
125
+
126
+ A CLI or headless process has nowhere to redirect to, so it asks for a device
127
+ code and polls. Each outcome is its own error, so the loop never inspects an
128
+ error code:
129
+
130
+ ```python
131
+ import anyio
132
+ from vercel.connect import (
133
+ AuthorizationDeniedError,
134
+ AuthorizationExpiredError,
135
+ AuthorizationPendingError,
136
+ ConnectOptions,
137
+ )
138
+
139
+ authorization = await start_authorization(
140
+ "linear/my-app", subject=subject, device_code=True
141
+ )
142
+ print(f"Enter {authorization.device_code} at {authorization.url}")
143
+
144
+ while True:
145
+ try:
146
+ token = await get_token(
147
+ "linear/my-app", subject=subject, options=ConnectOptions(force_refresh=True)
148
+ )
149
+ break
150
+ except AuthorizationPendingError:
151
+ await anyio.sleep(5)
152
+ except (AuthorizationDeniedError, AuthorizationExpiredError):
153
+ raise # terminal: nothing to wait for
154
+ ```
155
+
156
+ `force_refresh=True` is what makes the poll reach the server; without it a cached
157
+ credential would be returned. `slow_down` is reported as
158
+ `AuthorizationPendingError` too, so a fixed interval stays correct.
159
+
160
+ ## Inbound triggers
161
+
162
+ A connector with triggers enabled forwards provider webhooks to your project with
163
+ a Vercel OIDC token attached, so you verify one thing instead of a different
164
+ signature scheme per provider:
165
+
166
+ ```python
167
+ from vercel.connect import verify_connect_webhook
168
+
169
+ claims = await verify_connect_webhook(request.headers)
170
+ ```
171
+
172
+ Verification pins the issuer to Vercel's OIDC service, accepting both
173
+ `https://oidc.vercel.com` and the team-scoped `https://oidc.vercel.com/<team>`,
174
+ allows only RS256, and fails closed when the expected project and environment cannot be resolved. It
175
+ accepts any valid Vercel OIDC token for this project and environment; it is not
176
+ pinned to a specific connector or deployment.
177
+
178
+ ## Configuration
179
+
180
+ To configure advanced options, use a `session` context manager, and pass
181
+ `ConnectServiceOptions`:
182
+
183
+ ```python
184
+ from vercel.api import session
185
+ from vercel.connect import ConnectAppTokenSubject, ConnectServiceOptions, get_token
186
+
187
+ async with session(
188
+ service_options=[ConnectServiceOptions(base_url="https://staging.example.com")]
189
+ ):
190
+ token = await get_token("github/my-app", subject=ConnectAppTokenSubject())
191
+ ```
192
+
193
+ ## Local development
194
+
195
+ On Vercel the OIDC token is injected automatically. Locally:
196
+
197
+ ```sh
198
+ vercel link
199
+ vercel env pull # writes VERCEL_OIDC_TOKEN into .env.local
200
+ ```
201
+
202
+ The connector must be attached to your project and enabled for the target
203
+ environment, or every call fails.
@@ -0,0 +1,190 @@
1
+ # vercel-connect-bundle
2
+
3
+ This is a version of `vercel-connect` with third-party dependencies bundled. For normal use, install the unbundled `vercel-connect` package instead: https://pypi.org/project/vercel-connect/
4
+
5
+ # vercel-connect
6
+
7
+ Python SDK for [Vercel Connect](https://vercel.com/docs), a credential broker for
8
+ third-party APIs.
9
+
10
+ You exchange your deployment's token for a short-lived credential for an upstream
11
+ service. Your project never stores provider secrets, and Connect owns the OAuth
12
+ client, PKCE, refresh, and revocation server-side.
13
+
14
+ ```sh
15
+ pip install vercel-connect
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ import httpx
22
+ from vercel.connect import ConnectAppTokenSubject, get_token
23
+
24
+ token = await get_token("github/my-app", subject=ConnectAppTokenSubject())
25
+
26
+ async with httpx.AsyncClient() as client:
27
+ await client.get(
28
+ "https://api.github.com/user/repos",
29
+ headers={"Authorization": f"Bearer {token}"},
30
+ )
31
+ ```
32
+
33
+ The same surface is available synchronously, with identical names and arguments:
34
+
35
+ ```python
36
+ from vercel.connect.sync import ConnectAppTokenSubject, get_token
37
+
38
+ token = get_token("github/my-app", subject=ConnectAppTokenSubject())
39
+ ```
40
+
41
+ Use a plain `with` block and `vercel.connect.sync` together; mixing an async call
42
+ into a sync session, or the reverse, is rejected.
43
+
44
+ ## Subjects
45
+
46
+ Whose authority the credential carries:
47
+
48
+ | Subject | Authority | Needs |
49
+ | --- | --- | --- |
50
+ | `ConnectAppTokenSubject()` | The integration itself | An installation |
51
+ | `ConnectUserTokenSubject(id=...)` | One named end user | That user's consent |
52
+ | `ConnectJwtBearerTokenSubject(sub=...)` | A user asserted by your app | Pre-established trust |
53
+ | `ConnectTokenExchangeSubject(token=...)` | A credential you already hold | The inbound token |
54
+
55
+ `app` is one shared credential per installation: simple, always available, but
56
+ ambient authority. `user` preserves the provider's own permission model per
57
+ person and names them in the provider's audit log, at the cost of a consent flow.
58
+
59
+ Subjects are typed values rather than plain strings because three of the four
60
+ carry their own fields, so `subject="user"` could not say *which* user:
61
+
62
+ ```python
63
+ ConnectUserTokenSubject(id="u_123", issuer="https://idp.example.com")
64
+ ConnectJwtBearerTokenSubject(sub="u_123", additional_claims={"tenant": "acme"})
65
+ ```
66
+
67
+ ## Value types
68
+
69
+ Every type on this surface is a frozen Pydantic model, so you get validation on
70
+ construction, autocompletion, exact `match`/`case` narrowing, `model_dump()` for
71
+ logging, and immutability, which means a subject cannot be mutated after a
72
+ credential has been cached against it:
73
+
74
+ ```python
75
+ detail = ConnectGitHubAppInstallationAuthorizationDetail(permissions=["contents:read"])
76
+ detail.model_dump() # {'org': None, 'permissions': ('contents:read',), ...}
77
+ detail.permissions = ["admin"] # ConnectValidationError: frozen
78
+ ```
79
+
80
+ Construction is by keyword, a misspelled field is an error rather than a silently
81
+ dropped value, and every rejection raises `ConnectValidationError`, so you never
82
+ need to catch Pydantic's own error type. Containers of strings accept any
83
+ container and store a tuple; a bare string is rejected rather than expanded into
84
+ one entry per character:
85
+
86
+ ```python
87
+ get_token(..., scopes="repo:read") # ConnectValidationError, and a type error
88
+ get_token(..., scopes=["repo:read"]) # correct
89
+ ```
90
+
91
+ ## Authorization as control flow
92
+
93
+ The two "required" errors are not bugs, they are states with a remedy:
94
+
95
+ ```python
96
+ from vercel.connect import (
97
+ ConnectUserTokenSubject,
98
+ UserAuthorizationRequiredError,
99
+ get_token,
100
+ start_authorization,
101
+ )
102
+
103
+ subject = ConnectUserTokenSubject(id=user_id)
104
+ try:
105
+ token = await get_token("linear/my-app", subject=subject)
106
+ except UserAuthorizationRequiredError:
107
+ authorization = await start_authorization(
108
+ "linear/my-app", subject=subject, return_url="https://myapp.com/cb"
109
+ )
110
+ return redirect(authorization.url)
111
+ ```
112
+
113
+ A CLI or headless process has nowhere to redirect to, so it asks for a device
114
+ code and polls. Each outcome is its own error, so the loop never inspects an
115
+ error code:
116
+
117
+ ```python
118
+ import anyio
119
+ from vercel.connect import (
120
+ AuthorizationDeniedError,
121
+ AuthorizationExpiredError,
122
+ AuthorizationPendingError,
123
+ ConnectOptions,
124
+ )
125
+
126
+ authorization = await start_authorization(
127
+ "linear/my-app", subject=subject, device_code=True
128
+ )
129
+ print(f"Enter {authorization.device_code} at {authorization.url}")
130
+
131
+ while True:
132
+ try:
133
+ token = await get_token(
134
+ "linear/my-app", subject=subject, options=ConnectOptions(force_refresh=True)
135
+ )
136
+ break
137
+ except AuthorizationPendingError:
138
+ await anyio.sleep(5)
139
+ except (AuthorizationDeniedError, AuthorizationExpiredError):
140
+ raise # terminal: nothing to wait for
141
+ ```
142
+
143
+ `force_refresh=True` is what makes the poll reach the server; without it a cached
144
+ credential would be returned. `slow_down` is reported as
145
+ `AuthorizationPendingError` too, so a fixed interval stays correct.
146
+
147
+ ## Inbound triggers
148
+
149
+ A connector with triggers enabled forwards provider webhooks to your project with
150
+ a Vercel OIDC token attached, so you verify one thing instead of a different
151
+ signature scheme per provider:
152
+
153
+ ```python
154
+ from vercel.connect import verify_connect_webhook
155
+
156
+ claims = await verify_connect_webhook(request.headers)
157
+ ```
158
+
159
+ Verification pins the issuer to Vercel's OIDC service, accepting both
160
+ `https://oidc.vercel.com` and the team-scoped `https://oidc.vercel.com/<team>`,
161
+ allows only RS256, and fails closed when the expected project and environment cannot be resolved. It
162
+ accepts any valid Vercel OIDC token for this project and environment; it is not
163
+ pinned to a specific connector or deployment.
164
+
165
+ ## Configuration
166
+
167
+ To configure advanced options, use a `session` context manager, and pass
168
+ `ConnectServiceOptions`:
169
+
170
+ ```python
171
+ from vercel.api import session
172
+ from vercel.connect import ConnectAppTokenSubject, ConnectServiceOptions, get_token
173
+
174
+ async with session(
175
+ service_options=[ConnectServiceOptions(base_url="https://staging.example.com")]
176
+ ):
177
+ token = await get_token("github/my-app", subject=ConnectAppTokenSubject())
178
+ ```
179
+
180
+ ## Local development
181
+
182
+ On Vercel the OIDC token is injected automatically. Locally:
183
+
184
+ ```sh
185
+ vercel link
186
+ vercel env pull # writes VERCEL_OIDC_TOKEN into .env.local
187
+ ```
188
+
189
+ The connector must be attached to your project and enabled for the target
190
+ environment, or every call fails.
@@ -0,0 +1,151 @@
1
+ """Hatch metadata hook for publish-time workspace dependency bounds."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
10
+ from packaging.requirements import Requirement
11
+ from packaging.specifiers import SpecifierSet
12
+
13
+ try:
14
+ import tomllib
15
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
16
+ import tomli as tomllib # type: ignore[no-redef]
17
+
18
+
19
+ class WorkspaceDependenciesMetadataHook(MetadataHookInterface):
20
+ """Generate package dependencies from the repo-owned dependency table."""
21
+
22
+ def update(self, metadata: dict[str, Any]) -> None:
23
+ """Populate dynamic dependencies for Hatchling metadata generation."""
24
+ pyproject = _load_pyproject(Path(self.root))
25
+ release = pyproject.get("tool", {}).get("vercel", {}).get("release", {})
26
+ dependency_table = release.get("dependencies", {})
27
+ workspace_sources = pyproject.get("tool", {}).get("uv", {}).get("sources", {})
28
+ workspace_names = {
29
+ name for name, source in workspace_sources.items() if source.get("workspace") is True
30
+ }
31
+ workspace_root = _find_workspace_root(Path(self.root))
32
+
33
+ package = pyproject.get("project", {}).get("name", str(self.root))
34
+
35
+ metadata["dependencies"] = [
36
+ _rewrite_dependency(requirement, workspace_names, workspace_root, package)
37
+ for requirement in dependency_table.get("dependencies", [])
38
+ ]
39
+
40
+
41
+ def _load_pyproject(path: Path) -> dict[str, Any]:
42
+ with (path / "pyproject.toml").open("rb") as fp:
43
+ return tomllib.load(fp)
44
+
45
+
46
+ def _find_workspace_root(start: Path) -> Path | None:
47
+ for path in [start, *start.parents]:
48
+ pyproject = path / "pyproject.toml"
49
+ if not pyproject.exists():
50
+ continue
51
+ data = _load_pyproject(path)
52
+ if "workspace" in data.get("tool", {}).get("uv", {}):
53
+ return path
54
+ return None
55
+
56
+
57
+ def _rewrite_dependency(
58
+ requirement: str,
59
+ workspace_names: set[str],
60
+ workspace_root: Path | None,
61
+ package: str,
62
+ ) -> str:
63
+ parsed = Requirement(requirement)
64
+ normalized = parsed.name.lower().replace("_", "-")
65
+ if normalized not in workspace_names or workspace_root is None:
66
+ return requirement
67
+ version = _read_workspace_version(workspace_root, normalized)
68
+ return _with_lower_bound(parsed, version, requirement, package)
69
+
70
+
71
+ def _with_lower_bound(requirement: Requirement, version: str, declared: str, package: str) -> str:
72
+ extras = f"[{','.join(sorted(requirement.extras))}]" if requirement.extras else ""
73
+ specifiers = [
74
+ str(specifier) for specifier in requirement.specifier if specifier.operator != ">="
75
+ ]
76
+ specifier_text = ",".join([f">={version}", *specifiers])
77
+ _reject_unsatisfiable(package, requirement.name, declared, specifier_text, version)
78
+ marker = f" ; {requirement.marker}" if requirement.marker else ""
79
+ return f"{requirement.name}{extras}{specifier_text}{marker}"
80
+
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
+
111
+ def _read_workspace_version(workspace_root: Path, package_name: str) -> str:
112
+ for pattern in ("src/*/pyproject.toml", "integrations/*/pyproject.toml"):
113
+ for pyproject_path in workspace_root.glob(pattern):
114
+ version = _version_from_pyproject(pyproject_path, package_name)
115
+ if version is not None:
116
+ return version
117
+ raise RuntimeError(f"unknown workspace dependency {package_name!r}")
118
+
119
+
120
+ def _version_from_pyproject(pyproject_path: Path, package_name: str) -> str | None:
121
+ data = _load_pyproject(pyproject_path.parent)
122
+ if data.get("project", {}).get("name") != package_name:
123
+ return None
124
+ version_path = pyproject_path.parent / data["tool"]["hatch"]["version"]["path"]
125
+ module = ast.parse(version_path.read_text(encoding="utf-8"), filename=str(version_path))
126
+ for node in module.body:
127
+ value = _version_value(node)
128
+ if value is None:
129
+ continue
130
+ if isinstance(value, ast.Constant) and isinstance(value.value, str):
131
+ return value.value
132
+ raise RuntimeError(f"could not find __version__ in {version_path}")
133
+
134
+
135
+ def _version_value(node: ast.stmt) -> ast.expr | None:
136
+ if isinstance(node, ast.Assign) and any(
137
+ isinstance(target, ast.Name) and target.id == "__version__" for target in node.targets
138
+ ):
139
+ return node.value
140
+ if (
141
+ isinstance(node, ast.AnnAssign)
142
+ and isinstance(node.target, ast.Name)
143
+ and node.target.id == "__version__"
144
+ ):
145
+ return node.value
146
+ return None
147
+
148
+
149
+ def get_metadata_hook() -> type[MetadataHookInterface]:
150
+ """Return the hook class used by Hatchling's custom hook loader."""
151
+ return WorkspaceDependenciesMetadataHook
@@ -0,0 +1,29 @@
1
+ """Load the shared Vercel Hatch metadata hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.util import module_from_spec, spec_from_file_location
6
+ from pathlib import Path
7
+ from types import ModuleType
8
+
9
+ from hatchling.metadata.plugin.interface import MetadataHookInterface
10
+
11
+
12
+ def get_metadata_hook() -> type[MetadataHookInterface]:
13
+ """Return the shared workspace dependency metadata hook."""
14
+ return _load_shared_hook().get_metadata_hook()
15
+
16
+
17
+ def _load_shared_hook() -> ModuleType:
18
+ root = Path(__file__).resolve().parent
19
+ candidates = [root / "../../scripts/hatch_build.py", root / "_vercel_hatch_build.py"]
20
+ for candidate in candidates:
21
+ path = candidate.resolve()
22
+ if path.exists():
23
+ spec = spec_from_file_location("_vercel_hatch_build", path)
24
+ if spec is None or spec.loader is None:
25
+ raise RuntimeError(f"could not load Hatch hook from {path}")
26
+ module = module_from_spec(spec)
27
+ spec.loader.exec_module(module)
28
+ return module
29
+ raise RuntimeError("could not find shared Vercel Hatch metadata hook")
@@ -0,0 +1,87 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27.0,<2"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vercel-connect-bundle"
7
+ dynamic = ["version", "dependencies"]
8
+ description = "Python SDK for Vercel Connect"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = [
13
+ "LICENSE",
14
+ "LICENSE.*",
15
+ "vercel/connect/_vendor/LICEN[CS]E*",
16
+ ]
17
+
18
+ [tool.hatch.metadata.hooks.custom]
19
+ path = "hatch_build.py"
20
+
21
+ [tool.vercel.release.dependencies]
22
+ dependencies = [
23
+ "vercel-internal-core-bundle>=0.1.2,<0.2.0",
24
+ "vercel-oidc-bundle[verify]>=0.8.0",
25
+ "pydantic>=2.7.0,<3",
26
+ "vercel-internal-shared-vendored-deps>=0.1.1",
27
+ ]
28
+
29
+ [tool.uv.sources]
30
+ vercel-internal-core = { workspace = true }
31
+ vercel-oidc = { workspace = true }
32
+
33
+ [tool.hatch.version]
34
+ path = "vercel/connect/version.py"
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ force-include = { "_vercel_hatch_build.py" = "/_vercel_hatch_build.py" }
38
+ include = [
39
+ "/vercel/connect/**/*.py",
40
+ "/vercel/connect/py.typed",
41
+ "/README.md",
42
+ "/pyproject.toml",
43
+ "/hatch_build.py",
44
+ "/LICENSE",
45
+ "/vercel/connect/_vendor/LICEN[CS]E*",
46
+ ]
47
+ exclude = ["/**/__pycache__"]
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ dev-mode-dirs = ["."]
51
+ only-include = ["/vercel/connect"]
52
+ exclude = ["/**/__pycache__"]
53
+
54
+ [tool.pytest.ini_options]
55
+ testpaths = ["tests"]
56
+ pythonpath = ["."]
57
+ addopts = "--no-header --capture=tee-sys -m 'not live'"
58
+ asyncio_mode = "auto"
59
+ markers = ["live: requires live Connect API credentials"]
60
+
61
+ [tool.poe]
62
+ include = "../../scripts/poe/poe.toml"
63
+ verbosity = -1
64
+
65
+ [tool.mypy]
66
+ cache_dir = "../../.mypy_cache/vercel-connect"
67
+ explicit_package_bases = true
68
+ packages = ["vercel.connect"]
69
+
70
+ [tool.poe.tasks.typecheck]
71
+ cmd = "$MYPY"
72
+
73
+ [tool.vendoring]
74
+ destination = "vercel/connect/_vendor/"
75
+ requirements = "vercel/connect/_vendor/vendor.txt"
76
+ namespace = "vercel.connect._vendor"
77
+ protected-files = [
78
+ "__init__.py",
79
+ "vendor.txt",
80
+ ]
81
+
82
+ [tool.vendoring.transformations]
83
+ drop = [
84
+ "*.so",
85
+ "*/tests/",
86
+ "*/__pycache__/",
87
+ ]