vercel-connect 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.
@@ -0,0 +1,130 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+
5
+ # C extensions
6
+ *.so
7
+
8
+ # Distribution / packaging
9
+ .Python
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ share/python-wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+ MANIFEST
27
+
28
+ # PyInstallerhave
29
+ *.manifest
30
+ *.spec
31
+
32
+ # Installer logs
33
+ pip-log.txt
34
+ pip-delete-this-directory.txt
35
+
36
+ # Unit test / coverage reports
37
+ .benchmarks/
38
+ htmlcov/
39
+ .tox/
40
+ .nox/
41
+ .coverage
42
+ .coverage.*
43
+ .cache
44
+ nosetests.xml
45
+ coverage.xml
46
+ *.cover
47
+ *.py,cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+ .ruff_cache/
51
+
52
+ # Translations
53
+ *.mo
54
+ *.pot
55
+
56
+ # Scrapy
57
+ .scrapy
58
+
59
+ # Sphinx documentation
60
+ docs/_build/
61
+
62
+ # PyBuilder
63
+ target/
64
+
65
+ # Jupyter Notebook
66
+ .ipynb_checkpoints
67
+
68
+ # IPython
69
+ profile_default/
70
+ ipython_config.py
71
+
72
+ # pyenv
73
+ .python-version
74
+
75
+ # pipenv
76
+ Pipfile.lock
77
+
78
+ # poetry
79
+ poetry.lock
80
+
81
+ # PDM
82
+ pdm.lock
83
+ .pdm.toml
84
+
85
+ # Hatch
86
+ .hatch/
87
+
88
+ # pyright/mypy
89
+ .mypy_cache/
90
+ .dmypy.json
91
+ dmypy.json
92
+
93
+ # pyre
94
+ .pyre/
95
+
96
+ # pytype
97
+ .pytype/
98
+
99
+ # Caches
100
+ __pypackages__/
101
+
102
+ # Editor/project files
103
+ .DS_Store
104
+ .idea/
105
+ .vscode/
106
+ .zed/
107
+ *.swp
108
+ *.swo
109
+
110
+ # Virtual environments
111
+ .env
112
+ .venv
113
+ .venv.*
114
+ env/
115
+ venv/
116
+ **/.env
117
+ **/.venv
118
+ **/.venv.*
119
+ **/env/
120
+ **/venv/
121
+ ENV/
122
+ env.bak/
123
+ venv.bak/
124
+
125
+ # dotenv anywhere
126
+ **/.env
127
+ **/.env.*
128
+ **/*.env
129
+
130
+ .claude
@@ -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,199 @@
1
+ Metadata-Version: 2.4
2
+ Name: vercel-connect
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: httpx<1,>=0.27.0
9
+ Requires-Dist: pydantic<3,>=2.7.0
10
+ Requires-Dist: vercel-internal-core<0.2.0,>=0.1.2
11
+ Requires-Dist: vercel-oidc[verify]>=0.8.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # vercel-connect
15
+
16
+ Python SDK for [Vercel Connect](https://vercel.com/docs), a credential broker for
17
+ third-party APIs.
18
+
19
+ You exchange your deployment's token for a short-lived credential for an upstream
20
+ service. Your project never stores provider secrets, and Connect owns the OAuth
21
+ client, PKCE, refresh, and revocation server-side.
22
+
23
+ ```sh
24
+ pip install vercel-connect
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```python
30
+ import httpx
31
+ from vercel.connect import ConnectAppTokenSubject, get_token
32
+
33
+ token = await get_token("github/my-app", subject=ConnectAppTokenSubject())
34
+
35
+ async with httpx.AsyncClient() as client:
36
+ await client.get(
37
+ "https://api.github.com/user/repos",
38
+ headers={"Authorization": f"Bearer {token}"},
39
+ )
40
+ ```
41
+
42
+ The same surface is available synchronously, with identical names and arguments:
43
+
44
+ ```python
45
+ from vercel.connect.sync import ConnectAppTokenSubject, get_token
46
+
47
+ token = get_token("github/my-app", subject=ConnectAppTokenSubject())
48
+ ```
49
+
50
+ Use a plain `with` block and `vercel.connect.sync` together; mixing an async call
51
+ into a sync session, or the reverse, is rejected.
52
+
53
+ ## Subjects
54
+
55
+ Whose authority the credential carries:
56
+
57
+ | Subject | Authority | Needs |
58
+ | --- | --- | --- |
59
+ | `ConnectAppTokenSubject()` | The integration itself | An installation |
60
+ | `ConnectUserTokenSubject(id=...)` | One named end user | That user's consent |
61
+ | `ConnectJwtBearerTokenSubject(sub=...)` | A user asserted by your app | Pre-established trust |
62
+ | `ConnectTokenExchangeSubject(token=...)` | A credential you already hold | The inbound token |
63
+
64
+ `app` is one shared credential per installation: simple, always available, but
65
+ ambient authority. `user` preserves the provider's own permission model per
66
+ person and names them in the provider's audit log, at the cost of a consent flow.
67
+
68
+ Subjects are typed values rather than plain strings because three of the four
69
+ carry their own fields, so `subject="user"` could not say *which* user:
70
+
71
+ ```python
72
+ ConnectUserTokenSubject(id="u_123", issuer="https://idp.example.com")
73
+ ConnectJwtBearerTokenSubject(sub="u_123", additional_claims={"tenant": "acme"})
74
+ ```
75
+
76
+ ## Value types
77
+
78
+ Every type on this surface is a frozen Pydantic model, so you get validation on
79
+ construction, autocompletion, exact `match`/`case` narrowing, `model_dump()` for
80
+ logging, and immutability, which means a subject cannot be mutated after a
81
+ credential has been cached against it:
82
+
83
+ ```python
84
+ detail = ConnectGitHubAppInstallationAuthorizationDetail(permissions=["contents:read"])
85
+ detail.model_dump() # {'org': None, 'permissions': ('contents:read',), ...}
86
+ detail.permissions = ["admin"] # ConnectValidationError: frozen
87
+ ```
88
+
89
+ Construction is by keyword, a misspelled field is an error rather than a silently
90
+ dropped value, and every rejection raises `ConnectValidationError`, so you never
91
+ need to catch Pydantic's own error type. Containers of strings accept any
92
+ container and store a tuple; a bare string is rejected rather than expanded into
93
+ one entry per character:
94
+
95
+ ```python
96
+ get_token(..., scopes="repo:read") # ConnectValidationError, and a type error
97
+ get_token(..., scopes=["repo:read"]) # correct
98
+ ```
99
+
100
+ ## Authorization as control flow
101
+
102
+ The two "required" errors are not bugs, they are states with a remedy:
103
+
104
+ ```python
105
+ from vercel.connect import (
106
+ ConnectUserTokenSubject,
107
+ UserAuthorizationRequiredError,
108
+ get_token,
109
+ start_authorization,
110
+ )
111
+
112
+ subject = ConnectUserTokenSubject(id=user_id)
113
+ try:
114
+ token = await get_token("linear/my-app", subject=subject)
115
+ except UserAuthorizationRequiredError:
116
+ authorization = await start_authorization(
117
+ "linear/my-app", subject=subject, return_url="https://myapp.com/cb"
118
+ )
119
+ return redirect(authorization.url)
120
+ ```
121
+
122
+ A CLI or headless process has nowhere to redirect to, so it asks for a device
123
+ code and polls. Each outcome is its own error, so the loop never inspects an
124
+ error code:
125
+
126
+ ```python
127
+ import anyio
128
+ from vercel.connect import (
129
+ AuthorizationDeniedError,
130
+ AuthorizationExpiredError,
131
+ AuthorizationPendingError,
132
+ ConnectOptions,
133
+ )
134
+
135
+ authorization = await start_authorization(
136
+ "linear/my-app", subject=subject, device_code=True
137
+ )
138
+ print(f"Enter {authorization.device_code} at {authorization.url}")
139
+
140
+ while True:
141
+ try:
142
+ token = await get_token(
143
+ "linear/my-app", subject=subject, options=ConnectOptions(force_refresh=True)
144
+ )
145
+ break
146
+ except AuthorizationPendingError:
147
+ await anyio.sleep(5)
148
+ except (AuthorizationDeniedError, AuthorizationExpiredError):
149
+ raise # terminal: nothing to wait for
150
+ ```
151
+
152
+ `force_refresh=True` is what makes the poll reach the server; without it a cached
153
+ credential would be returned. `slow_down` is reported as
154
+ `AuthorizationPendingError` too, so a fixed interval stays correct.
155
+
156
+ ## Inbound triggers
157
+
158
+ A connector with triggers enabled forwards provider webhooks to your project with
159
+ a Vercel OIDC token attached, so you verify one thing instead of a different
160
+ signature scheme per provider:
161
+
162
+ ```python
163
+ from vercel.connect import verify_connect_webhook
164
+
165
+ claims = await verify_connect_webhook(request.headers)
166
+ ```
167
+
168
+ Verification pins the issuer to Vercel's OIDC service, accepting both
169
+ `https://oidc.vercel.com` and the team-scoped `https://oidc.vercel.com/<team>`,
170
+ allows only RS256, and fails closed when the expected project and environment cannot be resolved. It
171
+ accepts any valid Vercel OIDC token for this project and environment; it is not
172
+ pinned to a specific connector or deployment.
173
+
174
+ ## Configuration
175
+
176
+ To configure advanced options, use a `session` context manager, and pass
177
+ `ConnectServiceOptions`:
178
+
179
+ ```python
180
+ from vercel.api import session
181
+ from vercel.connect import ConnectAppTokenSubject, ConnectServiceOptions, get_token
182
+
183
+ async with session(
184
+ service_options=[ConnectServiceOptions(base_url="https://staging.example.com")]
185
+ ):
186
+ token = await get_token("github/my-app", subject=ConnectAppTokenSubject())
187
+ ```
188
+
189
+ ## Local development
190
+
191
+ On Vercel the OIDC token is injected automatically. Locally:
192
+
193
+ ```sh
194
+ vercel link
195
+ vercel env pull # writes VERCEL_OIDC_TOKEN into .env.local
196
+ ```
197
+
198
+ The connector must be attached to your project and enabled for the target
199
+ environment, or every call fails.
@@ -0,0 +1,186 @@
1
+ # vercel-connect
2
+
3
+ Python SDK for [Vercel Connect](https://vercel.com/docs), a credential broker for
4
+ third-party APIs.
5
+
6
+ You exchange your deployment's token for a short-lived credential for an upstream
7
+ service. Your project never stores provider secrets, and Connect owns the OAuth
8
+ client, PKCE, refresh, and revocation server-side.
9
+
10
+ ```sh
11
+ pip install vercel-connect
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```python
17
+ import httpx
18
+ from vercel.connect import ConnectAppTokenSubject, get_token
19
+
20
+ token = await get_token("github/my-app", subject=ConnectAppTokenSubject())
21
+
22
+ async with httpx.AsyncClient() as client:
23
+ await client.get(
24
+ "https://api.github.com/user/repos",
25
+ headers={"Authorization": f"Bearer {token}"},
26
+ )
27
+ ```
28
+
29
+ The same surface is available synchronously, with identical names and arguments:
30
+
31
+ ```python
32
+ from vercel.connect.sync import ConnectAppTokenSubject, get_token
33
+
34
+ token = get_token("github/my-app", subject=ConnectAppTokenSubject())
35
+ ```
36
+
37
+ Use a plain `with` block and `vercel.connect.sync` together; mixing an async call
38
+ into a sync session, or the reverse, is rejected.
39
+
40
+ ## Subjects
41
+
42
+ Whose authority the credential carries:
43
+
44
+ | Subject | Authority | Needs |
45
+ | --- | --- | --- |
46
+ | `ConnectAppTokenSubject()` | The integration itself | An installation |
47
+ | `ConnectUserTokenSubject(id=...)` | One named end user | That user's consent |
48
+ | `ConnectJwtBearerTokenSubject(sub=...)` | A user asserted by your app | Pre-established trust |
49
+ | `ConnectTokenExchangeSubject(token=...)` | A credential you already hold | The inbound token |
50
+
51
+ `app` is one shared credential per installation: simple, always available, but
52
+ ambient authority. `user` preserves the provider's own permission model per
53
+ person and names them in the provider's audit log, at the cost of a consent flow.
54
+
55
+ Subjects are typed values rather than plain strings because three of the four
56
+ carry their own fields, so `subject="user"` could not say *which* user:
57
+
58
+ ```python
59
+ ConnectUserTokenSubject(id="u_123", issuer="https://idp.example.com")
60
+ ConnectJwtBearerTokenSubject(sub="u_123", additional_claims={"tenant": "acme"})
61
+ ```
62
+
63
+ ## Value types
64
+
65
+ Every type on this surface is a frozen Pydantic model, so you get validation on
66
+ construction, autocompletion, exact `match`/`case` narrowing, `model_dump()` for
67
+ logging, and immutability, which means a subject cannot be mutated after a
68
+ credential has been cached against it:
69
+
70
+ ```python
71
+ detail = ConnectGitHubAppInstallationAuthorizationDetail(permissions=["contents:read"])
72
+ detail.model_dump() # {'org': None, 'permissions': ('contents:read',), ...}
73
+ detail.permissions = ["admin"] # ConnectValidationError: frozen
74
+ ```
75
+
76
+ Construction is by keyword, a misspelled field is an error rather than a silently
77
+ dropped value, and every rejection raises `ConnectValidationError`, so you never
78
+ need to catch Pydantic's own error type. Containers of strings accept any
79
+ container and store a tuple; a bare string is rejected rather than expanded into
80
+ one entry per character:
81
+
82
+ ```python
83
+ get_token(..., scopes="repo:read") # ConnectValidationError, and a type error
84
+ get_token(..., scopes=["repo:read"]) # correct
85
+ ```
86
+
87
+ ## Authorization as control flow
88
+
89
+ The two "required" errors are not bugs, they are states with a remedy:
90
+
91
+ ```python
92
+ from vercel.connect import (
93
+ ConnectUserTokenSubject,
94
+ UserAuthorizationRequiredError,
95
+ get_token,
96
+ start_authorization,
97
+ )
98
+
99
+ subject = ConnectUserTokenSubject(id=user_id)
100
+ try:
101
+ token = await get_token("linear/my-app", subject=subject)
102
+ except UserAuthorizationRequiredError:
103
+ authorization = await start_authorization(
104
+ "linear/my-app", subject=subject, return_url="https://myapp.com/cb"
105
+ )
106
+ return redirect(authorization.url)
107
+ ```
108
+
109
+ A CLI or headless process has nowhere to redirect to, so it asks for a device
110
+ code and polls. Each outcome is its own error, so the loop never inspects an
111
+ error code:
112
+
113
+ ```python
114
+ import anyio
115
+ from vercel.connect import (
116
+ AuthorizationDeniedError,
117
+ AuthorizationExpiredError,
118
+ AuthorizationPendingError,
119
+ ConnectOptions,
120
+ )
121
+
122
+ authorization = await start_authorization(
123
+ "linear/my-app", subject=subject, device_code=True
124
+ )
125
+ print(f"Enter {authorization.device_code} at {authorization.url}")
126
+
127
+ while True:
128
+ try:
129
+ token = await get_token(
130
+ "linear/my-app", subject=subject, options=ConnectOptions(force_refresh=True)
131
+ )
132
+ break
133
+ except AuthorizationPendingError:
134
+ await anyio.sleep(5)
135
+ except (AuthorizationDeniedError, AuthorizationExpiredError):
136
+ raise # terminal: nothing to wait for
137
+ ```
138
+
139
+ `force_refresh=True` is what makes the poll reach the server; without it a cached
140
+ credential would be returned. `slow_down` is reported as
141
+ `AuthorizationPendingError` too, so a fixed interval stays correct.
142
+
143
+ ## Inbound triggers
144
+
145
+ A connector with triggers enabled forwards provider webhooks to your project with
146
+ a Vercel OIDC token attached, so you verify one thing instead of a different
147
+ signature scheme per provider:
148
+
149
+ ```python
150
+ from vercel.connect import verify_connect_webhook
151
+
152
+ claims = await verify_connect_webhook(request.headers)
153
+ ```
154
+
155
+ Verification pins the issuer to Vercel's OIDC service, accepting both
156
+ `https://oidc.vercel.com` and the team-scoped `https://oidc.vercel.com/<team>`,
157
+ allows only RS256, and fails closed when the expected project and environment cannot be resolved. It
158
+ accepts any valid Vercel OIDC token for this project and environment; it is not
159
+ pinned to a specific connector or deployment.
160
+
161
+ ## Configuration
162
+
163
+ To configure advanced options, use a `session` context manager, and pass
164
+ `ConnectServiceOptions`:
165
+
166
+ ```python
167
+ from vercel.api import session
168
+ from vercel.connect import ConnectAppTokenSubject, ConnectServiceOptions, get_token
169
+
170
+ async with session(
171
+ service_options=[ConnectServiceOptions(base_url="https://staging.example.com")]
172
+ ):
173
+ token = await get_token("github/my-app", subject=ConnectAppTokenSubject())
174
+ ```
175
+
176
+ ## Local development
177
+
178
+ On Vercel the OIDC token is injected automatically. Locally:
179
+
180
+ ```sh
181
+ vercel link
182
+ vercel env pull # writes VERCEL_OIDC_TOKEN into .env.local
183
+ ```
184
+
185
+ The connector must be attached to your project and enabled for the target
186
+ 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