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.
- vercel_connect_bundle-0.1.0/LICENSE +21 -0
- vercel_connect_bundle-0.1.0/PKG-INFO +203 -0
- vercel_connect_bundle-0.1.0/README.md +190 -0
- vercel_connect_bundle-0.1.0/_vercel_hatch_build.py +151 -0
- vercel_connect_bundle-0.1.0/hatch_build.py +29 -0
- vercel_connect_bundle-0.1.0/pyproject.toml +87 -0
- vercel_connect_bundle-0.1.0/vercel/connect/__init__.py +454 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/__init__.py +1 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/api_client.py +401 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/async_runtime.py +144 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/base.py +118 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/cache.py +305 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/errors.py +174 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/identity.py +104 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/models.py +198 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/options.py +135 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/service.py +426 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/single_flight.py +152 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/state.py +177 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_internal/sync_runtime.py +159 -0
- vercel_connect_bundle-0.1.0/vercel/connect/_vendor/__init__.py +1 -0
- vercel_connect_bundle-0.1.0/vercel/connect/py.typed +0 -0
- vercel_connect_bundle-0.1.0/vercel/connect/sync.py +450 -0
- 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
|
+
]
|