Skip to content

Commit 63f77d3

Browse files
authored
feat: unified pypi hub repository (bazel-contrib#3837)
This implements a pypi hub that is the union of all pypi hubs. The basic design is: * The `pip` extension _always_ creates a `@pypi` repo unless the name is already taken by another hub definition. * The `--pypi_hub` flag dispatches to one of the hubs. If not set, then it uses a default one (first, or as configured) The set of packages and targets the unified hub exposes is a union of all other hubs. If the unified hub routes to a hub that doesn't support such a target, then it points to a target that fails at execution time. This is to allow query and cquery to work even if some targets don't exist in some hubs.
1 parent 0384676 commit 63f77d3

33 files changed

Lines changed: 1146 additions & 5 deletions
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
# Implementation Plan: Canonical Automatic PyPI Proxy Hub
2+
3+
This document defines the locked, production-ready architectural, Starlark API,
4+
and testing specifications for implementing dynamic PyPI dependency resolution in
5+
`rules_python` using the `venv` flag.
6+
7+
---
8+
9+
## 1. Architectural Strategy: The Canonical `@pypi` Proxy
10+
11+
The `pip` bzlmod extension will automatically synthesize a canonical `@pypi`
12+
proxy repository rule that orchestrates routing to underlying concrete hubs.
13+
14+
### Bzlmod-Exclusive Scope
15+
16+
The Unified PyPI Hub Proxy is an **exclusive feature of `bzlmod`**. Legacy
17+
`WORKSPACE` evaluations using independent `pip_parse` repository macros are not
18+
supported, as bzlmod's module extension architecture provides the required
19+
centralized coordination to inspect and interlink cross-module hubs.
20+
21+
### Automatic Proxy Construction & Collision Logic
22+
23+
During the evaluation of the `pip` extension across the dependency graph:
24+
1. **Unconditional Creation**: The extension will **always** synthesize a
25+
proxy repository rule with the apparent name `pypi`, even if zero
26+
`pip.parse` concrete hubs are defined in the dependency graph (in which
27+
case the proxy is completely valid but empty).
28+
2. **Collision Prevention**: If a user explicitly defines a concrete hub
29+
named `pypi` (`pip.parse(hub_name = "pypi")`), the automatic proxy
30+
synthesis is skipped so the user maintains absolute control over that
31+
repository name.
32+
33+
In `MODULE.bazel`:
34+
```starlark
35+
pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
36+
37+
# Concrete hubs defined for different execution contexts
38+
pip.parse(hub_name = "pypi_a", ...)
39+
pip.parse(hub_name = "pypi_b", ...)
40+
41+
# Designate 'pypi_b' as the default hub for the unified '@pypi' repository
42+
pip.default(default_hub = "pypi_b")
43+
44+
# The canonical proxy is automatically created unconditionally:
45+
use_repo(pip, "pypi")
46+
```
47+
48+
### Unified PyPI Hub
49+
50+
The canonical `@pypi` proxy repository matches exactly how concrete hubs create
51+
their directory structure: a root package for shared configuration settings, and
52+
a dedicated subdirectory (subpackage) for each PyPI package.
53+
54+
Here is a complete, representative code example of what the generated files in
55+
`@pypi` will look like when resolving packages between `pypi_a` and `pypi_b`:
56+
57+
#### 1. `@pypi//BUILD.bazel` (Root Package)
58+
The root package contains the shared `config_setting` targets following the
59+
`_is_venv_<name>` private naming convention. Leading underscores are strictly
60+
applied because these configuration settings are an internal implementation
61+
detail of the proxy repository and are not intended to be a public API.
62+
63+
```starlark
64+
package(default_visibility = ["//visibility:public"])
65+
66+
config_setting(
67+
name = "_is_venv_pypi_a",
68+
flag_values = {
69+
"@rules_python//python/config_settings:venv": "pypi_a",
70+
},
71+
)
72+
73+
config_setting(
74+
name = "_is_venv_pypi_b",
75+
flag_values = {
76+
"@rules_python//python/config_settings:venv": "pypi_b",
77+
},
78+
)
79+
```
80+
81+
#### 2. `@pypi//foo/BUILD.bazel` (PyPI Package Subpackage)
82+
Each PyPI package subpackage defines the standard aliases (`pkg`, `whl`, `data`,
83+
`dist_info`, `extracted_wheel_files`), plus a complete **union of all custom
84+
`extra_hub_aliases`** defined across all concrete hubs.
85+
86+
Each alias resolves dynamically to the active concrete hub based on the root
87+
private configuration settings:
88+
89+
```starlark
90+
package(default_visibility = ["//visibility:public"])
91+
92+
alias(
93+
name = "foo",
94+
actual = ":pkg",
95+
)
96+
97+
alias(
98+
name = "pkg",
99+
actual = select({
100+
"//:_is_venv_pypi_a": "@pypi_a//foo:pkg",
101+
"//:_is_venv_pypi_b": "@pypi_b//foo:pkg",
102+
# When venv is "auto" (unset), it defaults to the designated fallback
103+
# (or first defined concrete hub).
104+
"//conditions:default": "@pypi_b//foo:pkg",
105+
}),
106+
)
107+
108+
alias(
109+
name = "whl",
110+
actual = select({
111+
"//:_is_venv_pypi_a": "@pypi_a//foo:whl",
112+
"//:_is_venv_pypi_b": "@pypi_b//foo:whl",
113+
"//conditions:default": "@pypi_b//foo:whl",
114+
}),
115+
)
116+
117+
# ... standard aliases for data, dist_info, extracted_wheel_files ...
118+
119+
# 3. Unionized custom extra alias (defined in pypi_a but missing in pypi_b):
120+
alias(
121+
name = "my_custom_tool",
122+
actual = select({
123+
"//:_is_venv_pypi_a": "@pypi_a//foo:my_custom_tool",
124+
# Unrepresented branch routes to execution failure target:
125+
"//:_is_venv_pypi_b": "//:_missing_package_error_pypi_b_foo",
126+
"//conditions:default": "@pypi_a//foo:my_custom_tool",
127+
}),
128+
)
129+
```
130+
131+
### Disjoint Hub Packages & Execution-Phase Failure
132+
133+
If a package exists in one concrete hub but is missing in another (e.g., `scipy`
134+
is in `pypi_b` but not `pypi_a`), our proxy synthesizes a package subpackage for
135+
the union of all packages.
136+
137+
To ensure that `bazel cquery` and `bazel query` successfully analyze over the
138+
entire transitive build graph without failing, unrepresented select branches
139+
must route to a dedicated **execution-phase error rule**.
140+
141+
```starlark
142+
# In @pypi//scipy/BUILD.bazel
143+
alias(
144+
name = "pkg",
145+
actual = select({
146+
# Routes to execution-phase action failure target:
147+
"//:_is_venv_pypi_a": "//:_missing_package_error_pypi_a_scipy",
148+
"//:_is_venv_pypi_b": "@pypi_b//scipy:pkg",
149+
"//conditions:default": "@pypi_b//scipy:pkg",
150+
}),
151+
)
152+
```
153+
154+
The synthesized `//:_missing_package_error_XX` rule in `@pypi//BUILD.bazel`
155+
returns standard Starlark Python providers so analysis/cquery passes, but
156+
registers a build action that fails when executed:
157+
158+
```
159+
Dependency Error: Third-party package 'scipy' is not available when building under PyPI hub 'pypi_a'.
160+
```
161+
162+
### Fallback Hub Precedence (`"auto"`)
163+
164+
When a target depends on `@pypi//foo` and the active build setting is `"auto"`,
165+
the proxy resolves to a concrete hub using the following precedence:
166+
1. **Designated Fallback**: If the user has explicitly designated a fallback
167+
concrete hub via `pip.default(default_hub = "...")` in their root
168+
`MODULE.bazel`, the proxy routes to it.
169+
2. **First Defined Hub**: If no fallback is explicitly designated via
170+
`pip.default()`, the proxy **automatically routes to the first defined
171+
concrete hub** parsed during extension evaluation (e.g., `pypi_a`).
172+
173+
```starlark
174+
# Explicitly override the "auto" fallback hub
175+
pip.default(
176+
default_hub = "pypi_b",
177+
)
178+
```
179+
180+
---
181+
182+
## 2. Core Rule Integration: `config_settings` Transitions
183+
184+
Users will switch active hubs using the standard, highly generic
185+
`config_settings` transition attribute on executable targets.
186+
187+
### Build Setting Definition
188+
189+
In `python/config_settings/BUILD.bazel`:
190+
191+
```starlark
192+
string_flag(
193+
name = "venv",
194+
build_setting_default = "auto", # Default value is "auto"
195+
visibility = ["//visibility:public"],
196+
)
197+
```
198+
199+
In `python/private/common_labels.bzl`:
200+
```starlark
201+
VENV = str(Label("//python/config_settings:venv")),
202+
```
203+
204+
In `python/private/transition_labels.bzl`:
205+
```starlark
206+
_BASE_TRANSITION_LABELS = [
207+
# ... existing transition labels ...
208+
labels.VENV,
209+
]
210+
```
211+
212+
Because `py_binary` and `py_test` implement an incoming transition
213+
(`_transition_executable_impl`) that automatically processes any
214+
`config_settings` keys matching `TRANSITION_LABELS`, **this provides complete
215+
transition capabilities with zero changes to our core rule definitions**.
216+
217+
### Usage in BUILD.bazel
218+
219+
Libraries consume packages through the canonical proxy:
220+
221+
```starlark
222+
py_library(
223+
name = "common",
224+
deps = ["@pypi//foo"], # Apparent proxy repository
225+
)
226+
```
227+
228+
Binaries change the active hub by transitioning the build setting:
229+
230+
```starlark
231+
# Resolves @pypi -> pypi_b (default hub / designated fallback)
232+
py_binary(
233+
name = "bin_default",
234+
deps = [":common"],
235+
)
236+
237+
# Resolves @pypi -> pypi_a via transition
238+
py_binary(
239+
name = "bin_a",
240+
deps = [":common"],
241+
config_settings = {
242+
"//python/config_settings:venv": "pypi_a",
243+
},
244+
)
245+
```
246+
247+
### Analysis Cache & Memory Best Practices
248+
249+
Because transitions fork the Bazel configuration, building targets with highly
250+
diversified `config_settings` across large build graphs will result in
251+
re-analysis and re-compilation of shared dependencies.
252+
253+
We will include explicit documentation guidelines advising users to keep their
254+
`venv` transition configurations localized and minimized to preserve Bazel
255+
caching and memory efficiency.
256+
257+
---
258+
259+
## 3. Integration Testing Specification
260+
261+
We will construct a comprehensive Bazel-in-Bazel integration test suite in
262+
`tests/integration/unified_pypi/` to guarantee correctness and verify
263+
transitions.
264+
265+
The integration test suite will assert:
266+
1. **`"auto"` Precedence**: Author a test asserting `bazel run //:bin_default`
267+
correctly inherits `"auto"` and resolves dependencies from the designated fallback.
268+
2. **Transitional Resolution**: Author a test asserting two binary targets in
269+
the same package with different `config_settings` successfully resolve
270+
dependencies and execute against their respective concrete hubs (`pypi_a`
271+
vs `pypi_b`).
272+
3. **Command Line Override**: Author a test asserting
273+
`bazel run --//python/config_settings:venv=pypi_a //:bin_default`
274+
successfully forces the executable to run using imports resolved from
275+
`pypi_a`.
276+
4. **Disjoint Execution Failure**: Author a test asserting `bazel cquery` over
277+
a target depending on an unrepresented missing package succeeds, while
278+
`bazel run` on that target gracefully fails during execution with the exact
279+
synthesized error message.
280+
5. **Unionized Extra Hub Aliases**: Author a test asserting that a binary
281+
successfully runs using a custom `extra_hub_aliases` target resolved
282+
through the `@pypi proxy`.
283+
284+
---
285+
286+
## 4. Execution Steps
287+
288+
1. **Phase 1**: Define `venv` `string_flag` and register it in
289+
`common_labels.bzl` and `transition_labels.bzl`.
290+
2. **Phase 2**: Update `python/private/pypi/extension.bzl` to synthesize the
291+
canonical `pypi` proxy repository rule.
292+
3. **Phase 3**: Implement `missing_package_error` execution failure rule and
293+
the `proxy_hub_repository` generation logic.
294+
4. **Phase 4**: Author the Bazel-in-Bazel integration test suite in
295+
`tests/integration/unified_pypi/`.
296+
5. **Phase 5**: Run all tests and verify full pass before PR submission.

‎.bazelignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ tests/integration/compile_pip_requirements/bazel-compile_pip_requirements
3535
tests/integration/local_toolchains/bazel-local_toolchains
3636
tests/integration/py_cc_toolchain_registered/bazel-py_cc_toolchain_registered
3737
tests/integration/toolchain_target_settings/bazel-module_under_test
38+
tests/integration/unified_pypi/bazel-unified_pypi
3839
tests/integration/uv_lock/bazel-uv_lock

‎.bazelrc.deleted_packages‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ common --deleted_packages=tests/integration/pip_parse_isolated
4040
common --deleted_packages=tests/integration/py_cc_toolchain_registered
4141
common --deleted_packages=tests/integration/runtime_manifests
4242
common --deleted_packages=tests/integration/toolchain_target_settings
43+
common --deleted_packages=tests/integration/unified_pypi
4344
common --deleted_packages=tests/integration/uv_lock
4445
common --deleted_packages=tests/modules/another_module
4546
common --deleted_packages=tests/modules/other

‎docs/api/rules_python/python/config_settings/index.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,22 @@ Values:
350350

351351
::::
352352

353+
::::{bzl:flag} venv
354+
Determines which PyPI repository hub is used when resolving package dependencies.
355+
356+
This flag is transitioned on automatically by executable targets (`py_binary`, `py_test`)
357+
to select the appropriate concrete PyPI hub (e.g., when fallback or disjoint packages exist across multiple hubs).
358+
359+
Values:
360+
* `auto`: (default) Resolves dependencies using the fallback or first available hub.
361+
* `<hub_name>`: Explicitly forces resolution of packages from the
362+
specified concrete PyPI hub (corresponding to a
363+
{obj}`pip.parse.hub_name` value).
364+
365+
:::{versionadded} VERSION_NEXT_FEATURE
366+
:::
367+
::::
368+
353369
::::{bzl:flag} venvs_use_declare_symlink
354370

355371
Determines if relative symlinks are created using `declare_symlink()` at build
@@ -373,6 +389,7 @@ is created.
373389
:::
374390
::::
375391

392+
376393
## Removed Flags
377394

378395
:::{versionremoved} 2.1.0

‎docs/environment-variables.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@ which will effectively disable pyc caching.
118118

119119
:::
120120

121+
:::{envvar} RULES_PYTHON_PYPI_HUB_RESERVED
122+
123+
When `1`, any PyPI hub named `"pypi"` will be renamed to `<module_name>_pypi`
124+
to prevent name collisions with the unified `@pypi` proxy repository, and
125+
a warning is printed indicating that the renaming occurred. If not set (defaulting
126+
to `0`), a warning is printed advising to rename the hub, and the collision
127+
is not resolved.
128+
129+
:::{versionadded} VERSION_NEXT_FEATURE
130+
:::
131+
132+
:::
133+
121134
:::{envvar} RULES_PYTHON_REPO_DEBUG
122135

123136
When `1`, repository rules will print debug information about what they're

0 commit comments

Comments
 (0)