diff --git a/.bazelrc b/.bazelrc index b5533c3dc..d2a86eb8f 100644 --- a/.bazelrc +++ b/.bazelrc @@ -6,8 +6,9 @@ common --incompatible_disallow_empty_glob=False # Define value used by tests common --define=SOME_VAR=SOME_VALUE -# Set the default virtualenv to 'default' -common --@pypi//dep_group=aspect_rules_py +# dep_group=default is the flag's default; no explicit setting needed +# because pyproject.toml has no [dependency-groups] table and the uv +# extension synthesizes a single "default" group. common --incompatible_enable_cc_toolchain_resolution common --@llvm//config:experimental_stub_libgcc_s diff --git a/BUILD.bazel b/BUILD.bazel index b85fc9ac8..28719fd84 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -22,7 +22,7 @@ gazelle_python_manifest( name = "gazelle_python_manifest", hub = "pypi", venvs = [ - "aspect_rules_py", + "", ], ) diff --git a/docs/uv.md b/docs/uv.md index ed2f968b6..d3acb26c3 100644 --- a/docs/uv.md +++ b/docs/uv.md @@ -1,4 +1,4 @@ -# (experimental) `aspect_rules_py//uv` +# `aspect_rules_py//uv` `aspect_rules_py` provides an alternative to the venerable `rules_python` `pip.parse` implementation, which leverages the @@ -9,10 +9,13 @@ Our uv is a drop-in replacement for basic `pip.parse` usage, but provides a number of additional features. **Dependency groups** - Uv supports [PEP 735 dependency -groups](https://peps.python.org/pep-0735/): each `[dependency-groups]` entry -in your `pyproject.toml` registers as a named group your build can switch -between. Flip the `--@//dep_group=` flag, or set -`dep_group=""` on a `py_binary` / `py_test` target. +groups](https://peps.python.org/pep-0735/): each project's +`[dependency-groups]` entries register as named groups in the hub, and +your build picks one by flipping `--@//dep_group=` or setting +`dep_group=""` on a `py_binary` / `py_test` target. Multiple +projects can share a hub, including with same-named groups (`prod`, +`dev`, …) — the hub publishes per-project qualified labels alongside the +unqualified ones to handle cross-project package overlap. **Effortless Crossbuilds** - Uv delays building and installing packages until the build is configured. This allows uv to build your requirements in crossbuild @@ -70,9 +73,11 @@ bazel run @uv -- add -r requirements_lock.txt We can now use the lockfile to configure our build. -This configuration declares a dependency hub, creates two dependency groups -(`default` and `vendored_say`), and shows how to use `uv.override_package` -to swap a locked requirement (`cowsay`) for a local one. +This configuration declares a dependency hub, registers a project (which +gives the hub its `[dependency-groups]` — here, a single `vendored_say` +group declared in the user's `pyproject.toml`), and shows how to use +`uv.override_package` to swap a locked requirement (`cowsay`) for a local +one. ```starlark # MODULE.bazel @@ -99,22 +104,30 @@ uv.override_package( target = "//third_party/py/cowsay:cowsay", ) -# This one hub now has two configurations ("dependency groups") available +# The hub aggregates every project bound to it; dependency groups are +# named per pyproject.toml's [dependency-groups] (or synthesized as the +# empty-keyed group `""` plus the project name if absent). use_repo(uv, "pypi") register_toolchains("@uv//:all") ``` -We can configure a default dependency group by setting the `dep_group` flag on our hub as part of the `.bazelrc`. -Each `[dependency-group]` of the `pyproject.toml` is registered as a named dependency group. -If no dependency groups are listed, an implicit default group with the name of the project itself is created. +Each `[dependency-groups]` entry in `pyproject.toml` registers as a named +group in the hub. The active group is selected at build time by the +`--@//dep_group=` flag or by setting `dep_group=""` on a +`py_binary` / `py_test` target. The flag defaults to `""`, which the +extension synthesizes for projects without explicit `[dependency-groups]` +— the simple case works zero-config. See +[Dependency groups](#dependency-groups) for the full semantics. ``` -# .bazelrc -common --@pypi//dep_group=dummy +# .bazelrc — pick the explicit group as the workspace-wide default +common --@pypi//dep_group=vendored_say ``` -Individual targets can request different dependency groups if multiple dependency groups are configured. +Individual targets can request different dependency groups if multiple +dependency groups are configured. + ``` # BUILD.bazel @@ -127,13 +140,348 @@ py_binary( ) py_binary( - name = "say_vendored", + name = "say_with_other_group", srcs = ["__main__.py_"], deps = ["@pypi//cowsay"], - dep_group = "vendored_say", # Change the default dep_group choice + dep_group = "other_group", # Override per-target +) +``` + +## Dependency groups + +[PEP 735](https://peps.python.org/pep-0735/) defines `[dependency-groups]` +as named, opt-in subsets of a project's dependencies — declared in +`pyproject.toml`, independent of `[project].dependencies` and of extras. + +```toml +[project] +name = "web" +dependencies = ["flask"] + +[dependency-groups] +dev = ["pytest", "ruff"] +prod = ["gunicorn"] +``` + +Each entry registers as a name selectable via the `dep_group` flag or the +`dep_group=""` attribute. + +### Flag-value summary + +The `dep_group` flag has a small, fixed grammar. Three shapes: + +| Flag value | Activates | +| --- | --- | +| `""` (empty) | Synthesized empty-default group — fallback projects only | +| `` | Every project's `` group simultaneously (broad) | +| `` | The synthesized `` alias — fallback projects only | +| `/` | Just the named project's `` group (narrow) | + +`` is always the PEP 503 normalized `[project].name`. The same token +appears in qualified hub labels (`@//project/:`) — the +`project/` prefix is preserved on labels because the package side comes +from PyPI, which the user doesn't control. Flag values, by contrast, sit +in a single user-controlled namespace (project names + group names); +collisions between the two are caught at hub construction time and +surfaced as a clear error. + +`` is any `[dependency-groups]` entry — or one of the synthesized +empty / `` aliases when `[dependency-groups]` is absent. + +The flag's `build_setting_default` is `""`, so projects without +`[dependency-groups]` resolve zero-config. Subsections below cover each +shape in detail. + +### Default behavior when `dep_group` is unset + +The flag defaults to `""`, matching the synthesis fallback's empty-keyed +group. Projects whose `pyproject.toml` lacks a `[dependency-groups]` table +need no `.bazelrc` configuration — they just work. + +For projects that DO declare `[dependency-groups]`, the default flag value +matches none of the declared groups (PEP 735 forbids empty group names), +so the hub's package aliases fail with a `no_match_error` listing the +groups your project actually defines. Set the flag explicitly to one of +those. + +``` +common --@//dep_group=dev # workspace-wide default +common:release --@//dep_group=prod # config-specific override +``` + +Per-target override: `dep_group=""` on a `py_binary` / `py_test` / +`py_venv` rule sets the flag within that target's transition scope, +overriding whatever was inherited from the build flag. Useful when a +single binary needs a different dependency mix than the rest of the build. + +Targets without an explicit `dep_group` attr inherit the active flag value +through the `python_transition`. + +### Comparison with uv outside Bazel + +If you're coming from a uv-CLI workflow, the Bazel model handles dep +groups differently in one key way: **`dep_group=` selects exactly +one activation set, with no implicit overlay of `[project].dependencies` +on top.** The full mapping: + +| uv CLI | Bazel `dep_group` equivalent | +| --- | --- | +| `uv sync` (no flags) | `dep_group=""` (synthesized for projects without explicit groups) | +| `uv sync --group dev` | `dep_group="dev"`, where `dev` includes the project itself or `[project].dependencies` | +| `uv sync --only-group dev` | `dep_group="dev"` — this is the native Bazel semantics | +| `uv sync --no-group dev` | Don't activate `dev`; pick a different group via `dep_group=` | + +The Bazel form lines up with `--only-group`, not the default `--group` +(overlay) form. The `--group` overlay has no direct Bazel analogue — +users who want overlay behavior declare it explicitly in the toml; see +[Patterns](#patterns-for-overlay-behavior) below. + +Why: each `dep_group` value has to map to exactly one `select()` arm at +analysis time, so it has to fully describe the desired install set — +there's nowhere to express "X plus Y" structurally without losing the +ability to enumerate at config time. + +Other things that work the same way as uv CLI: + +- `[dependency-groups]` declarations themselves are the standard PEP 735 + shape — uv reads the same toml in both contexts. +- `[project].dependencies` are still the project's runtime deps, the + thing that gets shipped in a wheel, and the thing the synthesis + fallback pulls into the auto-generated empty / `` groups. Same + rules as outside Bazel. +- `include-group` and the rest of PEP 735's group-composition syntax + works identically. + +### Patterns for overlay behavior + +For projects with explicit `[dependency-groups]`, if you want +`[project].dependencies` available alongside the group's contents +(matching `uv sync --group ` semantics), wire it in yourself. Two +PEP 735 patterns work: + +```toml +[project] +name = "web" +dependencies = ["flask"] + +[dependency-groups] +# Self-reference. uv resolves `web` from the lockfile, where it's a +# virtual workspace member with deps = ["flask"]. Activating `dev` +# pulls in flask transitively + pytest. +dev = ["web", "pytest"] + +# Or chain via PEP 735's `include-group`: +_base = ["web"] # convention: leading underscore for "internal" groups +prod = [{ include-group = "_base" }, "gunicorn"] +``` + +### Synthesis fallback: empty and `` aliases + +When a project's `pyproject.toml` lacks a `[dependency-groups]` table +entirely, the extension synthesizes two equivalent group names mapping to +the project itself (which uv resolves to the project's +`[project].dependencies` transitively): + +- **`""`** (empty) — matches the `dep_group` flag's + `build_setting_default = ""`, so a single-project hub resolves with no + `.bazelrc` configuration. PEP 735 forbids empty group names, so this + can never collide with a user-declared group. +- **``** — same content, namespaced by the project's own name. + Used for per-project isolation when multiple projects share a hub; + see [Sharing a hub](#sharing-a-hub-across-multiple-projects). + +`` is the project's `[project].name` after PEP 503 normalization +(lowercase, hyphens → underscores) — the same token used in the +project-qualified hub label `@//project/:`. + +Projects with explicit `[dependency-groups]` keep just their declared +groups — no implicit alias is added. + +#### Collision with declared group names + +The synthesized `` alias and explicit `[dependency-groups]` entries +share one flat namespace at the flag-value layer. The extension fails +hub construction if a project's stamp matches a group name declared by +any *other* project in the same hub — both names are user-controlled and +locally adjustable, so the user just renames one. (A project naming a +group identically to itself is fine: synthesis only fires for projects +without `[dependency-groups]`, so the same project can never declare +both halves of the would-be collision.) + +### Per-project narrow activation: `/` + +Bare flag values like `dep_group="prod"` activate the `prod` group across +*every* project in the hub that declares one — usually fine, but in a +shared hub where two projects pin the same package at different versions +the unqualified `@//` label can multi-match. The narrow +form `/` scopes activation to one project's group +without touching the deps list: + +```python +# Both projects pin requests at different versions; the unqualified +# @hub//requests would multi-match. Narrow flag to scope to web's +# resolution without rewriting deps. +py_binary( + name = "bin", + srcs = ["main.py"], + dep_group = "web/prod", + deps = ["@//requests"], +) +``` + +This is the flag-value layer equivalent of the qualified hub label +`@//project/:`. Useful when a few targets in a +multi-project hub need to disambiguate without rewriting every dep label. + +The narrow form is emitted for every explicitly-declared group in every +project. It's skipped for the synthesized empty and `` aliases, +which the bare `""` and `` flag values already handle for +synthesis-fallback projects. + +### Authoring a library that's published *and* used in a Bazel build + +A common shape: a Python library that's published to PyPI for downstream +consumers but also developed inside a Bazel workspace as a `uv.project()`. +The library declares `[project].dependencies` for its runtime deps (what +PyPI consumers receive) and `[dependency-groups]` for dev-time tooling. + +The wrinkle: PEP 735 dep groups aren't shipped in wheels — published +consumers see only `[project].dependencies`, never the groups. So: + +- For consumers (`pip install mylib` / `uv add mylib`): they get + `flask`, `click`, etc. as expected. They never see `dev`/`prod`. +- For your own Bazel build: `dep_group="dev"` activates *only* what's + listed under `[dependency-groups].dev`, with no automatic overlay of + `[project].dependencies` (see the only-group note above). You need to + wire the overlay yourself. + +The `include-group` pattern keeps `[project].dependencies` referenced in +one place while letting both modes activate it: + +```toml +[project] +name = "mylib" +version = "1.0.0" +dependencies = [ + "flask", # what published consumers get + "click", +] + +[dependency-groups] +_runtime = ["mylib"] # private "internal" group +prod = [{ include-group = "_runtime" }] +dev = [{ include-group = "_runtime" }, "pytest", "ruff"] +``` + +Now `dep_group="prod"` activates flask + click; `dep_group="dev"` activates +flask + click + pytest + ruff. The `[project].dependencies` list stays +the single source of truth, and the published wheel is unaffected. + +If you don't actually need a `dev`/`prod` split inside the Bazel build +(e.g. your tests don't pull in extra runtime deps), simpler still: drop +`[dependency-groups]` entirely. The synthesis fallback gives you the +empty-keyed group and `` for free, both activating the project's +transitive runtime deps. Use `uv sync --group dev` outside Bazel for +local-development tooling that doesn't need to be in the Bazel graph. + +## Sharing a hub across multiple projects + +Multiple `uv.project()` declarations can target the same hub. Each project +contributes its own packages and dependency-groups; the hub aggregates them. +Group names are namespaced internally by project, so two projects can each +define a `prod` group without colliding. + +The hub publishes packages under two label shapes: + +- **Unqualified** — `@//`. Resolves at `select()` time based on + the active `dep_group`. The hub deduplicates providers by `(group, version)`: + when multiple projects provide the package in the same group at the *same + version*, the alias collapses to a single canonical arm. Only a true version + conflict — same group, different versions — surfaces Bazel's "multiple keys + match", at which point reach for the qualified shape. +- **Project-qualified** — `@//project/:`. Always + available; routes to the named project's resolution irrespective of + overlap or version conflict. The `project/` prefix is reserved on the + label side because the package side comes from PyPI, which the user + doesn't control — without the prefix, picking a project name that + matches a PyPI package would silently shadow it. Flag values, by + contrast, sit in a single user-controlled namespace, so they don't + need a reserved prefix. + +```starlark +# MODULE.bazel +uv.declare_hub(hub_name = "pypi") +uv.project( + hub_name = "pypi", + pyproject = "//apps/web:pyproject.toml", + lock = "//apps/web:uv.lock", +) +uv.project( + hub_name = "pypi", + pyproject = "//apps/worker:pyproject.toml", + lock = "//apps/worker:uv.lock", +) +``` + +```starlark +# apps/web/BUILD.bazel +py_binary( + name = "bin", + srcs = ["main.py"], + dep_group = "prod", + deps = [ + "@pypi//flask", # unqualified — only `web` provides it + "@pypi//project/web:requests", # qualified — both projects ship `requests` + ], +) + +# apps/worker/BUILD.bazel +py_binary( + name = "bin", + srcs = ["main.py"], + dep_group = "prod", + deps = [ + "@pypi//celery", # unqualified — only `worker` provides it + "@pypi//project/worker:requests", # qualified + ], +) +``` + +Setting `dep_group=prod` activates *both* projects' `prod` groups +simultaneously. When both projects pin a shared package at the same version +the unqualified label deduplicates to a single canonical resolution; only an +actual version conflict on the same group forces a qualified label. + +> **Caveat:** dedup keys on `(group, version)` only. If two projects pin the +> same version but apply different `uv.override_package` overrides or +> `post_install_patches`, the canonical-arm choice is deterministic (lex-first +> by project name) but only one project's overrides apply — reach for the +> qualified label in that case. + +In a multi-project hub where projects without explicit +`[dependency-groups]` all collapse into the synthesized empty-keyed +group, the `` synthesis alias is the per-project escape hatch. +Setting `dep_group=""` activates exactly one project's resolution +regardless of whether other projects pin the same package at different +versions: + +```python +# Two projects bound to the same hub, both without [dependency-groups], +# both pinning `requests` at different versions. The unqualified +# `@//requests` would multi-match across the projects' synthesized +# empty-keyed groups; setting `dep_group="web"` selects only +# `web`'s resolution. +py_binary( + name = "bin", + srcs = ["main.py"], + dep_group = "web", + deps = ["@//requests"], ) ``` +See [Synthesis fallback](#synthesis-fallback-empty-and-name-aliases) +for the full mechanics. + ## The `uv` toolchain `uv_bin.toolchain()` fetches the UV binary for the required platform(s) and @@ -241,15 +589,20 @@ platform_transition_filegroup( ## Example: Constraining library compatibility By default uv hubs let you write `py_library` and other targets which are -compatible with _any_ dependency group providing all the needed requirements. +compatible with _any_ dependency group providing all the needed +requirements. -But sometimes you want a library to be incompatible with a dependency group; -either because it depends on packages at versions below what are available in -that dependency group or as part of an internal migration or for some other reason. +But sometimes you want a library to be incompatible with a particular +dependency group — either because it depends on packages at versions +below what are available in that group, as part of an internal migration, +or for some other reason. -As a facility each hub's `@//:defs.bzl` provides a pair of helper macros -for generating appropriate `target_compatible_with` logics. These helpers return -case dicts which may either be manipulated or `select()`ed on. +Each hub's `@//:defs.bzl` provides a pair of helper macros for +generating appropriate `target_compatible_with` logics. These helpers +return case dicts which may either be manipulated or `select()`ed on. They +take bare group names and fan out internally to the per-project +namespaced config_settings, so a single `compatible_with(["prod"])` +covers every project in the hub that defines a `prod` group. ``` load("@pypi//:defs.bzl", "compatible_with", "incompatible_with") @@ -258,7 +611,7 @@ py_library( name = "requires_prod", srcs = ["foo.py"], deps = ["@pypi//cowsay"], - # Allowlist + # Only buildable when dep_group=prod is active. target_compatible_with = select(compatible_with(["prod"])), ) @@ -266,7 +619,7 @@ py_library( name = "not_in_prod", srcs = ["foo.py"], deps = ["@pypi//cowsay"], - # Allowlist + # Buildable in any dep_group except prod. target_compatible_with = select(incompatible_with(["prod"])), ) ``` @@ -274,41 +627,59 @@ py_library( ## A mental model ``` -@pypi # Your UV built hub repository -@pypi//requests:requests # The library for a requirement +@pypi # Your UV-built hub repository +@pypi//requests:requests # Unqualified library label +@pypi//project/web:requests # Project-qualified label (when needed) @pypi//jinja2-cli/entrypoints:jinja2-cli # A requirement's declared entrypoint -``` - -This central hub wraps "spoke" internal dependency group repos. For instance if you have two -dependency groups "a" and "b", then each hub target for a requirement is a `select()` alias -over the dependency group targets in which that requirement is defined. -Hub requirement targets are _incompatible_ with dependency group configurations in which the -requirement in question is not defined. +``` -Each dependency group requirement is backed by a `whl_install` rule which chooses among +The central hub wraps per-project repos generated from each `uv.project()` +declaration. Each top-level package alias resolves at `select()` time +based on the active `dep_group` flag, routing into the appropriate +project's lock resolution. Multiple projects can register groups of the +same name (e.g. both define `prod`); the per-project config_settings are +namespaced internally as `__` so `dep_group=prod` +activates every project's `prod` simultaneously. + +When more than one project provides the same package at different +versions, the unqualified label `@//` has multiple +`select()` arms matching at the active group. Bazel surfaces the +conflict and you reach for the project-qualified label +`@//project/:` — deterministic regardless of +overlap. The `project/` namespace is collision-free with Python +distribution names because `/` isn't a valid character in PEP 503 / +PEP 426 names. + +Hub package targets are `target_compatible_with`-incompatible with +dependency groups that don't provide the package — wildcard builds skip +them cleanly, and explicit deps on missing packages produce a +`no_match_error` listing the dependency groups that do provide it. + +Each requirement is backed by a `whl_install` rule which chooses among prebuilt wheels listed in the lockfile to produce the equivalent of a -`py_library`. - -An sdist (if available) will be built into a wheel for installation if no wheels -are available, or no wheels matching the target configuration are found. Sdist -builds occur using the configured Python and Cc toolchains. +`py_library`. An sdist (if available) is built into a wheel when no +matching wheels are available; sdist builds use the configured Python and +Cc toolchains. ## Best practices -**Consolidate your hubs**. In `rules_python`, environments with multiple depsets -needed to make multiple `pip.parse()` calls each of which created a hub. This -created the problem of transitive depset inconsistency (this target uses deps -from this hub but depends on a library that uses deps from elsewhere). +**Consolidate your hubs**. In `rules_python`, environments with multiple +dep sets needed multiple `pip.parse()` calls, each of which created a hub. +This produced transitive depset inconsistency (target uses deps from one +hub but depends on a library using deps from elsewhere). -By using single hub throughout your repository and leaning on dependency group configuration -to choose the right one at the right point in time, your dependency management -gets a lot easier and your builds become internally consistent. +A single hub aggregating multiple `uv.project()` declarations sidesteps +this. Reach for `dep_group` to switch between dependency sets at the +same hub label, and use project-qualified labels when two projects in +the hub provide the same package and you need to disambiguate. -**Only use one hub**. The hub name is configurable in order to accommodate -whatever your existing `pip.parse` may be called, but there's no reason to use -more than one hub within a single repository. Each dependency set should be -registered as a separate dependency group within the same hub. +**One hub per workspace, usually**. The hub name is configurable to +accommodate whatever your existing `pip.parse` may be called, but a +single hub aggregating every project keeps dependency management +internally consistent. Multiple hubs are supported but only really +warranted when dependency sets are intentionally siloed (e.g. tooling vs +runtime). ## Gazelle integration @@ -325,15 +696,17 @@ load("@aspect_rules_py//uv:defs.bzl", "gazelle_python_manifest") gazelle_python_manifest( name = "gazelle_python_manifest", hub = "pypi", - venvs = ["default"], + venvs = [""], # synthesis-fallback empty-keyed group ) ``` **Parameters:** - `hub` — The name of your uv hub (must match `uv.declare_hub(hub_name = ...)`). -- `venvs` — List of dependency group names whose wheels should be indexed. Module mappings - from all listed dependency groups are merged into a single manifest. +- `venvs` — List of dependency group names whose wheels should be + indexed. Module mappings from all listed groups are merged into a + single manifest. (The attribute is named `venvs` for historical + reasons; rename pending.) This creates two targets: @@ -350,7 +723,8 @@ This writes `gazelle_python.yaml` next to the BUILD file. Commit it to your repository so that Gazelle can resolve Python imports without rebuilding the manifest on every invocation. -If you have multiple dependency groups with different dependency sets, list them all to +If you have multiple dependency groups with different dependency sets, +list them all to produce a complete mapping: ```starlark @@ -370,18 +744,31 @@ think) uv needs a Python build tool to use. Uv currently uses `setuptools` and encounter configuration errors if these tools would be required and are not available. -**No default dependency group?** In order to implement the `dep_group=` transition on `py_binary` -et. all, the `dep_group` flag has to be statically known. This means we get one global -"current dependency group" flag, no matter how many hubs you have. +The build tools declared via `uv.project(default_build_dependencies = ["build", "setuptools"])` +plus their transitive runtime closure are activated into the project's +`dep_to_scc` automatically — but only for projects WITHOUT explicit +`[dependency-groups]`. Projects with explicit groups own their build-tool +version pinning (often via `[tool.uv.conflicts]`) and are expected to +declare build tools inside the relevant group themselves; an automatic +extra activation could conflict with the user's declared pin. + +**One global `dep_group` flag.** To implement the `dep_group` transition +on `py_binary` / `py_test` / `py_venv`, the flag has to be statically +known. There is one global `dep_group` flag, regardless of how many hubs +you declare — `--@//dep_group=…` and `--@//dep_group=…` +both refer to the same canonical +`@aspect_rules_py//uv/private/constraints/dep_group:dep_group` flag and +take the same value. The hub-local labels are aliases. + +The flag's `build_setting_default` is `""`, matching the synthesis +fallback's empty-keyed group for projects without `[dependency-groups]`. +Set the flag explicitly in `.bazelrc` when projects in the hub have +explicit `[dependency-groups]` (none of which can be named `""`): -It only really makes sense to use the `--@pypi//dep_group=default` flag as part of -your `.bazelrc`, because then the scope of where that default is applied is well -bounded to your repository with your hub. - -We could allow the `_main` repository to set a default dependency group name, but the -semantics are weird if the `_main` repository defines more than one hub. Which -is poor practice but possible. So rather than have weird behavior we don't -support this. +``` +common --@//dep_group=dev +common:release --@//dep_group=prod +``` **What's with annotations?** The `uv.lock` format is great, but it's missing some key information. Such as what requirements apply when performing sdist diff --git a/e2e/MODULE.bazel b/e2e/MODULE.bazel index 7145faadf..4603f383e 100644 --- a/e2e/MODULE.bazel +++ b/e2e/MODULE.bazel @@ -470,6 +470,41 @@ uv.project( use_repo(uv, "pypi-pytorch") # }}} +# For cases/uv-shared-hub-848 +# {{{ +# Distinct hub deliberately registering two projects with overlapping +# group names ("shared") and project-unique groups ("a_only", "b_only"). +# Exercises the namespacing paths added in v2.0. +uv.declare_hub(hub_name = "pypi_shared_848") +uv.project( + hub_name = "pypi_shared_848", + lock = "//cases/uv-shared-hub-848/proj_a:uv.lock", + pyproject = "//cases/uv-shared-hub-848/proj_a:pyproject.toml", +) +uv.project( + hub_name = "pypi_shared_848", + lock = "//cases/uv-shared-hub-848/proj_b:uv.lock", + pyproject = "//cases/uv-shared-hub-848/proj_b:pyproject.toml", +) +use_repo(uv, "pypi_shared_848") +# }}} + +# For cases/uv-synthesis-default-848 +# {{{ +# Single-project hub whose pyproject.toml has no [dependency-groups] table. +# Exercises the synthesis fallback (empty-keyed group + `` alias) +# and the zero-config path where the dep_group flag's +# build_setting_default of `""` makes everything resolve without any +# .bazelrc entry. +uv.declare_hub(hub_name = "pypi_synth_848") +uv.project( + hub_name = "pypi_synth_848", + lock = "//cases/uv-synthesis-default-848:uv.lock", + pyproject = "//cases/uv-synthesis-default-848:pyproject.toml", +) +use_repo(uv, "pypi_synth_848") +# }}} + use_repo(uv, "pypi") register_toolchains("@uv//:all") diff --git a/e2e/cases/firebase-admin-import/BUILD.bazel b/e2e/cases/firebase-admin-import/BUILD.bazel index fd14859d6..cf6407685 100644 --- a/e2e/cases/firebase-admin-import/BUILD.bazel +++ b/e2e/cases/firebase-admin-import/BUILD.bazel @@ -38,7 +38,7 @@ load("@bazel_skylib//rules:build_test.bzl", "build_test") py_test( name = "test", srcs = ["test.py"], - dep_group = "firebase-admin-import", + dep_group = "firebase_admin_import", main = "test.py", deps = [ "@pypi//firebase_admin", @@ -52,7 +52,7 @@ py_test( py_test( name = "test_tool", srcs = ["test.py"], - dep_group = "firebase-admin-import", + dep_group = "firebase_admin_import", expose_venv = True, isolated = False, main = "test.py", @@ -72,7 +72,7 @@ py_test( py_binary( name = "firebase_importer", srcs = ["test.py"], - dep_group = "firebase-admin-import", + dep_group = "firebase_admin_import", main = "test.py", deps = [ "@pypi//firebase_admin", diff --git a/e2e/cases/freethreaded-805/BUILD.bazel b/e2e/cases/freethreaded-805/BUILD.bazel index de6be6876..2c17945fd 100644 --- a/e2e/cases/freethreaded-805/BUILD.bazel +++ b/e2e/cases/freethreaded-805/BUILD.bazel @@ -26,7 +26,7 @@ _LINUX_X86_64 = [ py_test( name = "test_bin", srcs = ["test.py"], - dep_group = "freethreaded-test", + dep_group = "freethreaded_test", main = "test.py", python_version = "3.13", tags = ["manual"], @@ -46,7 +46,7 @@ platform_transition_test( py_test( name = "venv_test_bin", srcs = ["test.py"], - dep_group = "freethreaded-test", + dep_group = "freethreaded_test", expose_venv = True, isolated = False, main = "test.py", diff --git a/e2e/cases/pth-namespace-547/BUILD.bazel b/e2e/cases/pth-namespace-547/BUILD.bazel index 8189c4148..0942fb19e 100644 --- a/e2e/cases/pth-namespace-547/BUILD.bazel +++ b/e2e/cases/pth-namespace-547/BUILD.bazel @@ -4,7 +4,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_test") py_test( name = "test", srcs = ["__test__.py"], - dep_group = "pth-namespace-547", + dep_group = "pth_namespace_547", expose_venv = True, isolated = False, main = "__test__.py", @@ -20,7 +20,7 @@ py_test( py_test( name = "test_legacy", srcs = ["__test__.py"], - dep_group = "pth-namespace-547", + dep_group = "pth_namespace_547", main = "__test__.py", deps = [ "@pypi//jaraco_classes", diff --git a/e2e/cases/pytest-main-867/BUILD.bazel b/e2e/cases/pytest-main-867/BUILD.bazel index 906b08ea5..9e42ef3d5 100644 --- a/e2e/cases/pytest-main-867/BUILD.bazel +++ b/e2e/cases/pytest-main-867/BUILD.bazel @@ -5,7 +5,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_pytest_main", "py_test") py_test( name = "test_example", srcs = ["test_example.py"], - dep_group = "pytest-main-867", + dep_group = "pytest_main_867", pytest_main = True, deps = ["@pypi//pytest"], ) @@ -15,7 +15,7 @@ py_test( py_test( name = "test_naming_regression", srcs = ["test_example.py"], - dep_group = "pytest-main-867", + dep_group = "pytest_main_867", pytest_main = True, deps = ["@pypi//pytest"], ) @@ -29,7 +29,7 @@ py_test( "test_a.py", "test_b.py", ], - dep_group = "pytest-main-867", + dep_group = "pytest_main_867", pytest_main = True, deps = ["@pypi//pytest"], ) @@ -39,7 +39,7 @@ py_test( py_test( name = "test_collection_check", srcs = ["test_collection_check.py"], - dep_group = "pytest-main-867", + dep_group = "pytest_main_867", pytest_main = True, deps = ["@pypi//pytest"], ) @@ -58,7 +58,7 @@ py_test( "test_a.py", ":test_direct_main", ], - dep_group = "pytest-main-867", + dep_group = "pytest_main_867", main = ":__test__test_direct_main__.py", deps = [ ":test_direct_main", diff --git a/e2e/cases/pytest-mock-530/BUILD.bazel b/e2e/cases/pytest-mock-530/BUILD.bazel index 0db88e73d..cf58bac64 100644 --- a/e2e/cases/pytest-mock-530/BUILD.bazel +++ b/e2e/cases/pytest-mock-530/BUILD.bazel @@ -4,7 +4,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_test") py_test( name = "test_mock_py_test", srcs = ["test_mock.py"], - dep_group = "pytest-mock-530", + dep_group = "pytest_mock_530", pytest_main = True, deps = [ "@pypi//pytest", @@ -18,7 +18,7 @@ py_test( "__test__.py", "test_mock.py", ], - dep_group = "pytest-mock-530", + dep_group = "pytest_mock_530", expose_venv = True, isolated = False, main = "__test__.py", diff --git a/e2e/cases/pytest-subdir-imports/BUILD.bazel b/e2e/cases/pytest-subdir-imports/BUILD.bazel index c8efb7704..762062dc5 100644 --- a/e2e/cases/pytest-subdir-imports/BUILD.bazel +++ b/e2e/cases/pytest-subdir-imports/BUILD.bazel @@ -9,7 +9,7 @@ py_library( py_test( name = "test_subdir_import", srcs = ["tests/test_subdir.py"], - dep_group = "pytest-subdir-imports", + dep_group = "pytest_subdir_imports", pytest_main = True, deps = [ ":lib", diff --git a/e2e/cases/pytest-xdist-integration/BUILD.bazel b/e2e/cases/pytest-xdist-integration/BUILD.bazel index b70d36162..d9273d4be 100644 --- a/e2e/cases/pytest-xdist-integration/BUILD.bazel +++ b/e2e/cases/pytest-xdist-integration/BUILD.bazel @@ -13,7 +13,7 @@ py_test( "-n", "2", ], - dep_group = "pytest-xdist-integration", + dep_group = "pytest_xdist_integration", pytest_main = True, deps = [ "@pypi//pytest", diff --git a/e2e/cases/uv-abi3-compat-853/BUILD.bazel b/e2e/cases/uv-abi3-compat-853/BUILD.bazel index 00662d9ba..12597a94a 100644 --- a/e2e/cases/uv-abi3-compat-853/BUILD.bazel +++ b/e2e/cases/uv-abi3-compat-853/BUILD.bazel @@ -7,7 +7,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_test") py_test( name = "test_import", srcs = ["test_abi3.py"], - dep_group = "abi3-compat", + dep_group = "abi3_compat", main = "test_abi3.py", python_version = "3.12", target_compatible_with = ["@platforms//os:linux"], @@ -17,7 +17,7 @@ py_test( py_test( name = "test_import_venv_test", srcs = ["test_abi3.py"], - dep_group = "abi3-compat", + dep_group = "abi3_compat", expose_venv = True, isolated = False, main = "test_abi3.py", diff --git a/e2e/cases/uv-console-script-binary/test.sh b/e2e/cases/uv-console-script-binary/test.sh old mode 100644 new mode 100755 diff --git a/e2e/cases/uv-gazelle-778/BUILD.bazel b/e2e/cases/uv-gazelle-778/BUILD.bazel index dc154ebfb..989ede753 100644 --- a/e2e/cases/uv-gazelle-778/BUILD.bazel +++ b/e2e/cases/uv-gazelle-778/BUILD.bazel @@ -1,14 +1,16 @@ load("@aspect_rules_py//uv:defs.bzl", "gazelle_python_manifest") -# Exercise generating a Gazelle manifest covering a ton of venvs +# Exercise generating a Gazelle manifest covering several dep_groups. +# `extras` is uv-deps-650/extras's explicit `[dependency-groups].extras`; +# the bare `` entries are the synthesized per-project aliases for +# projects without explicit `[dependency-groups]` (airflow, say, crossbuild). gazelle_python_manifest( name = "gazelle_python_manifest", hub = "pypi", - # Note that we _merge_ the package mappings from several configurations. venvs = [ + "extras", "airflow", "crossbuild", - "extras", "say", ], ) diff --git a/e2e/cases/uv-plus-version/BUILD.bazel b/e2e/cases/uv-plus-version/BUILD.bazel index 807c94e6b..9473b3476 100644 --- a/e2e/cases/uv-plus-version/BUILD.bazel +++ b/e2e/cases/uv-plus-version/BUILD.bazel @@ -3,7 +3,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_test") py_test( name = "test", srcs = ["__test__.py"], - dep_group = "plus-version", + dep_group = "plus_version", main = "__test__.py", python_version = "3.12", deps = [ @@ -14,7 +14,7 @@ py_test( py_test( name = "venv_test", srcs = ["__test__.py"], - dep_group = "plus-version", + dep_group = "plus_version", expose_venv = True, isolated = False, main = "__test__.py", diff --git a/e2e/cases/uv-shared-hub-848/BUILD.bazel b/e2e/cases/uv-shared-hub-848/BUILD.bazel new file mode 100644 index 000000000..f7b3967d9 --- /dev/null +++ b/e2e/cases/uv-shared-hub-848/BUILD.bazel @@ -0,0 +1,88 @@ +# Shared-hub regression tests. +# +# Hub `pypi_shared_848` has two projects (proj-a-848, proj-b-848), each with a +# `shared` group containing `pytest` (pinned to the same version in both) and +# a project-unique `_only` group also containing `pytest`. This shape +# exercises: +# +# - Multi-project hub coexistence (same hub, same-named "shared" group) +# - Version-equality dedup: when both projects provide `pytest` in `shared` +# at the same version, the unqualified `@hub//pytest` resolves to one +# canonical project's target rather than tripping Bazel's "multiple keys +# match" (which would only fire on a real version conflict). +# - Project-qualified labels (`@hub//.:pytest`) routing +# deterministically per project regardless of overlap. +# - Unqualified labels under unique-owner groups (a_only, b_only). + +load("@aspect_rules_py//py:defs.bzl", "py_test") + +# Qualified labels, dep_group=shared. The qualified target under +# //.proj_a_848/.proj_b_848 selects only over its own project's groups, so it +# is unambiguous regardless of cross-project overlap. +py_test( + name = "test_qualified_a_shared", + srcs = ["test_pytest_import.py"], + dep_group = "shared", + main = "test_pytest_import.py", + deps = ["@pypi_shared_848//project/proj_a_848:pytest"], +) + +py_test( + name = "test_qualified_b_shared", + srcs = ["test_pytest_import.py"], + dep_group = "shared", + main = "test_pytest_import.py", + deps = ["@pypi_shared_848//project/proj_b_848:pytest"], +) + +# Version-equality dedup: both proj_a_848 and proj_b_848 pin pytest at the +# same version in their `shared` group. The hub's unqualified //pytest alias +# emits one arm per (group, versions) cluster — same-version clusters +# collapse to a single canonical arm, so dep_group=shared resolves cleanly +# instead of multi-matching. +py_test( + name = "test_unqualified_shared_dedupes", + srcs = ["test_pytest_import.py"], + dep_group = "shared", + main = "test_pytest_import.py", + deps = ["@pypi_shared_848//pytest"], +) + +# Unqualified label, dep_group=a_only. Only proj_a_848 defines an `a_only` +# group, so its select arm is the only match. +py_test( + name = "test_unqualified_a_only", + srcs = ["test_pytest_import.py"], + dep_group = "a_only", + main = "test_pytest_import.py", + deps = ["@pypi_shared_848//pytest"], +) + +py_test( + name = "test_unqualified_b_only", + srcs = ["test_pytest_import.py"], + dep_group = "b_only", + main = "test_pytest_import.py", + deps = ["@pypi_shared_848//pytest"], +) + +# Per-project qualified flag form: `dep_group = "/"` +# narrows the activation to a single project's group regardless of +# cross-project overlap. The unqualified `@hub//pytest` resolves +# unambiguously because only that project's qualified config_setting +# matches. +py_test( + name = "test_qualified_flag_a_shared", + srcs = ["test_pytest_import.py"], + dep_group = "proj_a_848/shared", + main = "test_pytest_import.py", + deps = ["@pypi_shared_848//pytest"], +) + +py_test( + name = "test_qualified_flag_b_shared", + srcs = ["test_pytest_import.py"], + dep_group = "proj_b_848/shared", + main = "test_pytest_import.py", + deps = ["@pypi_shared_848//pytest"], +) diff --git a/e2e/cases/uv-shared-hub-848/proj_a/BUILD.bazel b/e2e/cases/uv-shared-hub-848/proj_a/BUILD.bazel new file mode 100644 index 000000000..05ab2ade0 --- /dev/null +++ b/e2e/cases/uv-shared-hub-848/proj_a/BUILD.bazel @@ -0,0 +1,6 @@ +# Marker package so the uv extension can resolve //cases/uv-shared-hub-848/proj_a:pyproject.toml. + +exports_files([ + "pyproject.toml", + "uv.lock", +]) diff --git a/e2e/cases/uv-shared-hub-848/proj_a/pyproject.toml b/e2e/cases/uv-shared-hub-848/proj_a/pyproject.toml new file mode 100644 index 000000000..e8ebdd2f6 --- /dev/null +++ b/e2e/cases/uv-shared-hub-848/proj_a/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "proj-a-848" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = [ + "pytest", +] + +# Two groups: +# - "shared": same name as proj-b-848's "shared". Both projects provide +# pytest in this group, exercising the cross-project ambiguity path. +# - "a_only": unique to proj-a-848. Setting dep_group=a_only routes +# @hub//pytest to proj_a's pytest unambiguously. +[dependency-groups] +shared = ["proj-a-848"] +a_only = ["proj-a-848"] diff --git a/e2e/cases/uv-shared-hub-848/proj_a/uv.lock b/e2e/cases/uv-shared-hub-848/proj_a/uv.lock new file mode 100644 index 000000000..876bf3f32 --- /dev/null +++ b/e2e/cases/uv-shared-hub-848/proj_a/uv.lock @@ -0,0 +1,75 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "proj-a-848" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pytest" }] diff --git a/e2e/cases/uv-shared-hub-848/proj_b/BUILD.bazel b/e2e/cases/uv-shared-hub-848/proj_b/BUILD.bazel new file mode 100644 index 000000000..34c4a77a3 --- /dev/null +++ b/e2e/cases/uv-shared-hub-848/proj_b/BUILD.bazel @@ -0,0 +1,6 @@ +# Marker package so the uv extension can resolve //cases/uv-shared-hub-848/proj_b:pyproject.toml. + +exports_files([ + "pyproject.toml", + "uv.lock", +]) diff --git a/e2e/cases/uv-shared-hub-848/proj_b/pyproject.toml b/e2e/cases/uv-shared-hub-848/proj_b/pyproject.toml new file mode 100644 index 000000000..a062ee47a --- /dev/null +++ b/e2e/cases/uv-shared-hub-848/proj_b/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "proj-b-848" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = [ + "pytest", +] + +# Mirror of proj-a-848 with a distinct project name. The "shared" group +# overlaps with proj-a-848's "shared" — exercise the conflict path. +# "b_only" is unique to this project for unambiguous resolution tests. +[dependency-groups] +shared = ["proj-b-848"] +b_only = ["proj-b-848"] diff --git a/e2e/cases/uv-shared-hub-848/proj_b/uv.lock b/e2e/cases/uv-shared-hub-848/proj_b/uv.lock new file mode 100644 index 000000000..ce846a9e0 --- /dev/null +++ b/e2e/cases/uv-shared-hub-848/proj_b/uv.lock @@ -0,0 +1,75 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "proj-b-848" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pytest" }] diff --git a/e2e/cases/uv-shared-hub-848/test_pytest_import.py b/e2e/cases/uv-shared-hub-848/test_pytest_import.py new file mode 100644 index 000000000..4d5dc3cb8 --- /dev/null +++ b/e2e/cases/uv-shared-hub-848/test_pytest_import.py @@ -0,0 +1,16 @@ +"""Smoke test that pytest is importable. + +Each py_test in BUILD.bazel resolves pytest via a different label shape +(qualified `@pypi_shared_848//project/proj_a_848:pytest`, qualified `.proj_b_848:pytest`, +or unqualified `@pypi_shared_848//pytest` under a uniquely-named dep_group). +The test body itself just verifies the resolution actually delivered a working +pytest install — the interesting assertion is the BUILD-graph wiring. +""" + +import pytest + + +def test_pytest_resolved(): + # If pytest's __version__ is readable, the wheel was successfully resolved + # and unpacked into the venv via whichever label path BUILD.bazel chose. + assert pytest.__version__ diff --git a/e2e/cases/uv-synthesis-default-848/BUILD.bazel b/e2e/cases/uv-synthesis-default-848/BUILD.bazel new file mode 100644 index 000000000..4bcb781c1 --- /dev/null +++ b/e2e/cases/uv-synthesis-default-848/BUILD.bazel @@ -0,0 +1,53 @@ +# Synthesis-fallback regression test. +# +# The sibling pyproject.toml deliberately has NO [dependency-groups] table. +# The uv extension synthesizes two groups (`""` and ``) from the +# lock manifest, and the dep_group flag's `build_setting_default = ""` +# activates the empty-keyed group without any .bazelrc entry — exercising +# the "just works" zero-config path for projects that don't declare groups. +# +# Three test variants: +# +# - No `dep_group=` attr; relies on the global flag default. Hub label +# is unqualified. +# - Explicit `dep_group=""`; same routing, just different transition path. +# - Project-qualified label `@//project/:pytest`; verifies +# that the per-project subdir is generated even when the project did +# not declare an explicit [dependency-groups]. + +load("@aspect_rules_py//py:defs.bzl", "py_test") + +# Implicit: no dep_group attr; flag default ("") applies. +py_test( + name = "test_implicit_default", + srcs = ["test.py"], + main = "test.py", + deps = ["@pypi_synth_848//pytest"], +) + +# Explicit dep_group="" via the per-target attr. +py_test( + name = "test_explicit_default", + srcs = ["test.py"], + dep_group = "", + main = "test.py", + deps = ["@pypi_synth_848//pytest"], +) + +# Project-qualified label — exercises the //project//BUILD.bazel +# emitter even though the project has no explicit [dependency-groups]. +py_test( + name = "test_qualified_default", + srcs = ["test.py"], + main = "test.py", + deps = ["@pypi_synth_848//project/synth_default_848:pytest"], +) + +# Bare `dep_group = ""` — the synthesized project alias. +py_test( + name = "test_bare_project_alias", + srcs = ["test.py"], + dep_group = "synth_default_848", + main = "test.py", + deps = ["@pypi_synth_848//pytest"], +) diff --git a/e2e/cases/uv-synthesis-default-848/pyproject.toml b/e2e/cases/uv-synthesis-default-848/pyproject.toml new file mode 100644 index 000000000..abfed41cc --- /dev/null +++ b/e2e/cases/uv-synthesis-default-848/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "synth-default-848" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = [ + "pytest", +] + +# Deliberately NO [dependency-groups] table — exercises the extension's +# synthesis fallback, which produces a single group named "default" from +# the project's lock manifest. With the dep_group flag's `build_setting_default` +# also set to "default", this project should resolve labels with no +# explicit `dep_group=` attribute and no .bazelrc entry. diff --git a/e2e/cases/uv-synthesis-default-848/test.py b/e2e/cases/uv-synthesis-default-848/test.py new file mode 100644 index 000000000..b736270a1 --- /dev/null +++ b/e2e/cases/uv-synthesis-default-848/test.py @@ -0,0 +1,7 @@ +"""Smoke test the synthesis-fallback resolution actually delivered pytest.""" + +import pytest + + +def test_pytest_resolved(): + assert pytest.__version__ diff --git a/e2e/cases/uv-synthesis-default-848/uv.lock b/e2e/cases/uv-synthesis-default-848/uv.lock new file mode 100644 index 000000000..5aa0554be --- /dev/null +++ b/e2e/cases/uv-synthesis-default-848/uv.lock @@ -0,0 +1,75 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "synth-default-848" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "pytest" }] diff --git a/e2e/cases/uv-whl-install-output-group/BUILD.bazel b/e2e/cases/uv-whl-install-output-group/BUILD.bazel index de2d9922f..fb08eca79 100644 --- a/e2e/cases/uv-whl-install-output-group/BUILD.bazel +++ b/e2e/cases/uv-whl-install-output-group/BUILD.bazel @@ -9,11 +9,15 @@ load("@aspect_rules_py//py:defs.bzl", "py_test") # Access the install dir via the install_dir output group rather than # DefaultInfo.files (which is intentionally empty since #907). # -# This filegroup is placed in the test's data (not a genrule srcs) so that -# the venv transition applies and @pypi//iniconfig's hub select resolves. +# Uses the project-qualified label `@pypi//project/uv_whl_install_output_group:iniconfig` +# rather than the unqualified `@pypi//iniconfig` because this filegroup is +# analyzed under the build's default `dep_group` flag value (it doesn't +# itself transition). The qualified label routes through this project's own +# resolution regardless of the active flag, so wildcard builds can analyze +# it cleanly even when other projects in the hub provide iniconfig. filegroup( name = "iniconfig_install_dir", - srcs = ["@pypi//iniconfig"], + srcs = ["@pypi//project/uv_whl_install_output_group:iniconfig"], output_group = "install_dir", ) @@ -21,7 +25,7 @@ py_test( name = "test", srcs = ["test.py"], data = [":iniconfig_install_dir"], - dep_group = "uv-whl-install-output-group", + dep_group = "uv_whl_install_output_group", main = "test.py", python_version = "3.11", deps = ["@pypi//iniconfig"], @@ -31,7 +35,7 @@ py_test( name = "venv_test", srcs = ["test.py"], data = [":iniconfig_install_dir"], - dep_group = "uv-whl-install-output-group", + dep_group = "uv_whl_install_output_group", expose_venv = True, isolated = False, main = "test.py", diff --git a/e2e/cases/uv-workspace-789/packages/pi/BUILD.bazel b/e2e/cases/uv-workspace-789/packages/pi/BUILD.bazel index 486bbd49c..35ae8f877 100644 --- a/e2e/cases/uv-workspace-789/packages/pi/BUILD.bazel +++ b/e2e/cases/uv-workspace-789/packages/pi/BUILD.bazel @@ -6,6 +6,10 @@ py_library( imports = ["src"], visibility = ["//visibility:public"], deps = [ - "@pypi//requests", + # Project-qualified to workspace's resolution. py_library doesn't + # itself transition, so analyzing this target via wildcard builds + # the unqualified @pypi//requests would multi-match against other + # projects' default groups providing requests at different versions. + "@pypi//project/workspace:requests", ], ) diff --git a/e2e/cases/venv-bin-scripts-423/BUILD.bazel b/e2e/cases/venv-bin-scripts-423/BUILD.bazel index 551b9450b..cb55663cf 100644 --- a/e2e/cases/venv-bin-scripts-423/BUILD.bazel +++ b/e2e/cases/venv-bin-scripts-423/BUILD.bazel @@ -3,7 +3,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_test") py_test( name = "test", srcs = ["__test__.py"], - dep_group = "venv-bin-scripts-423", + dep_group = "venv_bin_scripts_423", main = "__test__.py", python_version = "3.11", deps = [ @@ -14,7 +14,7 @@ py_test( py_test( name = "test_non_isolated", srcs = ["__test__.py"], - dep_group = "venv-bin-scripts-423", + dep_group = "venv_bin_scripts_423", isolated = False, main = "__test__.py", python_version = "3.11", @@ -26,7 +26,7 @@ py_test( py_test( name = "venv_test", srcs = ["__test__.py"], - dep_group = "venv-bin-scripts-423", + dep_group = "venv_bin_scripts_423", expose_venv = True, isolated = False, main = "__test__.py", diff --git a/e2e/cases/venv-internal-symlinks/BUILD.bazel b/e2e/cases/venv-internal-symlinks/BUILD.bazel index 3b1f51e6e..9f482f73d 100644 --- a/e2e/cases/venv-internal-symlinks/BUILD.bazel +++ b/e2e/cases/venv-internal-symlinks/BUILD.bazel @@ -3,7 +3,7 @@ load("@aspect_rules_py//py:defs.bzl", "py_test") py_test( name = "test_internal_symlinks", srcs = ["test_babel.py"], - dep_group = "venv-internal-symlinks", + dep_group = "venv_internal_symlinks", expose_venv = True, isolated = False, main = "test_babel.py", @@ -16,7 +16,7 @@ py_test( py_test( name = "test_internal_symlinks_legacy", srcs = ["test_babel.py"], - dep_group = "venv-internal-symlinks", + dep_group = "venv_internal_symlinks", main = "test_babel.py", deps = [ "@pypi//babel", diff --git a/py/private/py_venv/py_venv.bzl b/py/private/py_venv/py_venv.bzl index 10dab878a..5f6bdef66 100644 --- a/py/private/py_venv/py_venv.bzl +++ b/py/private/py_venv/py_venv.bzl @@ -172,7 +172,7 @@ _attrs = dict({ Default value. May be overridden with the --@pip//dep_group=<> CLI flag. -Only works with the experimental Aspect pip machinery. +Only works with the Aspect rules_py uv machinery. """, ), "python_version": attr.string( diff --git a/py/tests/uv-qualified-label/BUILD.bazel b/py/tests/uv-qualified-label/BUILD.bazel new file mode 100644 index 000000000..bbf03a42d --- /dev/null +++ b/py/tests/uv-qualified-label/BUILD.bazel @@ -0,0 +1,30 @@ +# Regression test for the project-qualified hub label shape. +# +# `@pypi//.:` is the always-available qualified form. Even +# when the unqualified `@pypi//` would be ambiguous (multiple +# projects providing the package via the same dep_group name), the qualified +# variant routes deterministically to a single project's resolution. +# +# Here we have only one project (aspect_rules_py) bound to @pypi, so both +# label shapes resolve to the same target — but exercising the qualified +# shape catches the //./BUILD.bazel emitter regressing. + +load("@aspect_rules_py//py:defs.bzl", "py_test") + +py_test( + name = "test_qualified_label_resolves", + srcs = ["test.py"], + main = "test.py", + deps = [ + "@pypi//project/aspect_rules_py:cowsay", + ], +) + +py_test( + name = "test_unqualified_label_resolves", + srcs = ["test.py"], + main = "test.py", + deps = [ + "@pypi//cowsay", + ], +) diff --git a/py/tests/uv-qualified-label/test.py b/py/tests/uv-qualified-label/test.py new file mode 100644 index 000000000..1a1433741 --- /dev/null +++ b/py/tests/uv-qualified-label/test.py @@ -0,0 +1,9 @@ +"""Smoke test that cowsay was resolved via the chosen hub label shape.""" + +import cowsay + + +def test_cowsay_imports(): + # cowsay's get_output_string is a stable API; reaching it confirms the + # whl was actually unpacked into the venv. + assert cowsay.get_output_string("cow", "moo") diff --git a/uv/private/constraints/dep_group/BUILD.bazel b/uv/private/constraints/dep_group/BUILD.bazel index 04014eeea..72b61950e 100644 --- a/uv/private/constraints/dep_group/BUILD.bazel +++ b/uv/private/constraints/dep_group/BUILD.bazel @@ -2,6 +2,12 @@ load("@bazel_skylib//rules:common_settings.bzl", "string_flag") string_flag( name = "dep_group", + # Empty matches the synthesis fallback in uv.project()'s extension + # impl: projects without a [dependency-groups] table get a group + # keyed on "" (and one keyed on the project name). PEP 735 forbids + # an empty group name so user-declared groups can never collide with + # this default. Projects with explicit groups pick one with + # `--@//dep_group=` or per-target `dep_group="..."`. build_setting_default = "", # "universal" causes this flag to propagate through exec transitions so # that the dep-group selection is consistent across all platforms. diff --git a/uv/private/extension/defs.bzl b/uv/private/extension/defs.bzl index b77a4a730..444437f8f 100644 --- a/uv/private/extension/defs.bzl +++ b/uv/private/extension/defs.bzl @@ -70,6 +70,69 @@ load(":graph_utils.bzl", "activate_extras", "collect_sccs") load(":lockfile.bzl", "build_marker_graph", "collect_bdists", "collect_configurations", "collect_markers", "collect_sdists", "normalize_deps") load(":projectfile.bzl", "collate_versions_by_name", "collect_activated_extras", "extract_requirement_marker_pairs") +def _activate_default_build_deps( + projectfile, + lock_id, + default_versions, + package_versions, + marker_graph, + configuration_names, + activated_extras, + default_build_dependencies): + """Activates `default_build_dependencies` into every cfg of the project. + + `uv.project(default_build_dependencies = ["build", "setuptools"])` + declares the build-system tools used by sdist builds. Each sdist build + references `@//:setuptools` (and friends) to resolve those + tools, but the names are build-system-only — no `[dependency-group]`'s + runtime activation reaches them through normal graph traversal. We BFS + out from the build deps along the marker graph and seed every cfg in + `activated_extras` so the project repo emits the surface aliases. + + Gated by the caller to projects WITHOUT explicit `[dependency-groups]`: + those projects own their build-tool version pinning (often via + `[tool.uv.conflicts]`) and a forced extra activation can conflict with + a per-group pin. + + Mutates `activated_extras` in place. + """ + starting_points = [] + for req in default_build_dependencies: + # Coarse name extraction matching extract_requirement_marker_pairs: + # strip at the first non-name char. Used only to pre-skip reqs + # missing from the lockfile (which would cause extract to fail). + bare_name = req + for sep in [";", "[", ">", "<", "=", "!", "~", " ", "@"]: + idx = bare_name.find(sep) + if idx >= 0: + bare_name = bare_name[:idx] + bare_name = normalize_name(bare_name.strip()) + if bare_name not in default_versions and bare_name not in package_versions: + continue + for it, _marker in extract_requirement_marker_pairs(projectfile, lock_id, req, default_versions, package_versions): + starting_points.append(it) + + visited = {} + for sp in starting_points: + visited[sp] = 1 + worklist = list(starting_points) + bfs_idx = 0 + for _ in range(1000000): + if bfs_idx == len(worklist): + break + it = worklist[bfs_idx] + bfs_idx += 1 + base = (it[0], it[1], it[2], "__base__") + for cfg in configuration_names.keys(): + activated_extras.setdefault(base, {}).setdefault(cfg, {}).setdefault(it, {}).update({"": 1}) + for next_dep, markers in marker_graph.get(it, {}).items(): + next_base = (next_dep[0], next_dep[1], next_dep[2], "__base__") + for cfg in configuration_names.keys(): + activated_extras.setdefault(next_base, {}).setdefault(cfg, {}).setdefault(next_dep, {}).update(markers) + if next_dep not in visited: + visited[next_dep] = 1 + worklist.append(next_dep) + def _merge_scc_dep_markers_by_surface_package(marked_deps): merged = {} for dep, markers in marked_deps.items(): @@ -153,10 +216,8 @@ def _parse_projects(module_ctx, hub_specs): project_data = toml.decode_file(module_ctx, project.pyproject) lock_data = toml.decode_file(module_ctx, project.lock) - # This SHOULD be stable enough. - # We'll rebuild the lock hub whenever the toml changes. - # Reusing the name is fine. - # project_stamp = sha1(str(project.pyproject))[:16] + # PEP 503 normalized [project].name. User-facing token in + # `@//project/:` and `dep_group=project/`. project_stamp = normalize_name(project_data["project"]["name"]) project_id = "project__" + project_stamp @@ -264,6 +325,19 @@ def _parse_projects(module_ctx, hub_specs): whl_configurations.update(collect_configurations(lock_data)) configuration_names, activated_extras = collect_activated_extras(project.lock, project_id, project_data, lock_data, default_versions, marker_graph, package_versions) + + if "dependency-groups" not in project_data and project.default_build_dependencies: + _activate_default_build_deps( + projectfile = project.lock, + lock_id = project_id, + default_versions = default_versions, + package_versions = package_versions, + marker_graph = marker_graph, + configuration_names = configuration_names, + activated_extras = activated_extras, + default_build_dependencies = project.default_build_dependencies, + ) + version_activations = collate_versions_by_name(activated_extras) # Mapping from SCC ID to marked SCC members @@ -426,6 +500,7 @@ def _parse_projects(module_ctx, hub_specs): # # FIXME: Can we make a re-keying helper? project_cfgs[project_id] = struct( + stamp = project_stamp, dep_to_scc = marked_package_cfg_sccs, scc_deps = { k: { @@ -448,24 +523,68 @@ def _parse_projects(module_ctx, hub_specs): ) hub_cfg = hub_cfgs.setdefault(project.hub_name, struct( - configurations = {}, - packages = {}, + # {project_id: {"stamp": ..., "groups": [...], "packages": {pkg: {group: [version, ...]}}}} + projects = {}, + # {package: [project_id, ...]} — drives version-cluster dedup + # of the unqualified hub label. + package_owners = {}, )) - for cfg in configuration_names.keys(): - if cfg in hub_cfg.configurations: - fail("Conflict on configuration name {} in hub {}".format(cfg, project.hub_name)) + if project_id in hub_cfg.projects: + fail("Project {} declared more than once in hub {}".format(project_id, project.hub_name)) - # Build a mapping from configurations to the project containing that configuration - hub_cfg.configurations.update({ - name: project_id - for name in configuration_names.keys() - }) + # {pkg: {group: sorted_unique_versions}}. Drives the unqualified + # hub label's version-equality dedup. + project_packages = {} + + # `dep_id` tuples are (lock_id, name, version, marker_tag). A + # package may be activated under multiple markers in one group; + # we project to the version field and dedupe. + _DEP_ID_VERSION = 2 - # Build a {requirement: {cfg: target mapping}} for package, cfgs in version_activations.items(): - for cfg in cfgs.keys(): - hub_cfg.packages.setdefault(package, {})[cfg] = "@{}//:{}".format(project_id, package) + # dict-keys for set semantics (no set comprehension in Starlark). + project_packages[package] = { + group: sorted({dep_id[_DEP_ID_VERSION]: True for dep_id in dep_ids.keys()}.keys()) + for group, dep_ids in cfgs.items() + } + owners = hub_cfg.package_owners.setdefault(package, []) + if project_id not in owners: + owners.append(project_id) + + hub_cfg.projects[project_id] = struct( + stamp = project_stamp, + groups = list(configuration_names.keys()), + packages = project_packages, + ) + + # Collision check: a project's stamp shares the global flag-value + # namespace with every group name in the hub. If a different project + # in the same hub declares a group named identically to project P's + # stamp, `dep_group=` simultaneously activates P's synthesized + # alias AND the other project's group — same broad semantics, but the + # mental model breaks. Both namespaces are user-controlled and + # locally adjustable, so we fail loudly and let the user rename one. + for hub_id, hub_cfg in hub_cfgs.items(): + stamps_by_id = {pid: p.stamp for pid, p in hub_cfg.projects.items()} + for pid, p in hub_cfg.projects.items(): + for grp in p.groups: + for other_pid, other_stamp in stamps_by_id.items(): + if other_pid == pid: + continue + if grp == other_stamp: + fail(( + "In hub '{hub}': project '{pid}' declares dep_group " + + "'{grp}' which collides with project '{other}' (stamp " + + "'{stamp}'). Both share the global dep_group flag-value " + + "namespace. Rename either the dep_group or the project." + ).format( + hub = hub_id, + pid = pid, + grp = grp, + other = other_pid, + stamp = other_stamp, + )) return struct( project_cfgs = project_cfgs, @@ -598,16 +717,28 @@ def _uv_impl(module_ctx): for project_id, project_cfg in cfg.project_cfgs.items(): uv_project( name = project_id, + project_stamp = project_cfg.stamp, dep_to_scc = json.encode(project_cfg.dep_to_scc), scc_deps = json.encode(project_cfg.scc_deps), scc_graph = json.encode(project_cfg.scc_graph), ) for hub_id, hub_cfg in cfg.hub_cfgs.items(): + # Repository rules can't take nested string_dicts; flatten to JSON. + # Struct fields are pulled into plain dicts since json.encode + # doesn't traverse structs. + projects_json = { + project_id: { + "stamp": p.stamp, + "groups": p.groups, + "packages": p.packages, + } + for project_id, p in hub_cfg.projects.items() + } uv_hub( name = hub_id, - configurations = hub_cfg.configurations, - packages = json.encode(hub_cfg.packages), + projects = json.encode(projects_json), + package_owners = json.encode(hub_cfg.package_owners), ) if not features.external_deps.extension_metadata_has_reproducible: diff --git a/uv/private/extension/projectfile.bzl b/uv/private/extension/projectfile.bzl index 3fd849e28..6d8e8c19a 100644 --- a/uv/private/extension/projectfile.bzl +++ b/uv/private/extension/projectfile.bzl @@ -150,6 +150,23 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa traversal of the dependency graph to find all extras that are pulled in by the initial set of requirements. + When `[dependency-groups]` is absent, synthesizes two equivalent groups + pointing at the project itself (i.e. its `[project].dependencies`): + + - `""` — matches the `dep_group` flag's `build_setting_default = ""`, + so single-project hubs resolve zero-config. PEP 735 forbids empty + group names, so this can never collide with a user-declared group. + - `` — per-project alias for implicit isolation in + multi-project hubs. `` is the PEP 503 normalized + `[project].name` and matches the same token in qualified hub + labels (`@//project/:`). + + The extension fails analysis if `` collides with a group name + declared by any project in the same hub — both namespaces are + user-controlled and locally adjustable, so collisions are user error. + + Projects with explicit `[dependency-groups]` keep just what they declared. + Args: project_data: The parsed content of the `pyproject.toml` file. default_versions: A dictionary mapping package names to their default @@ -165,12 +182,16 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa `{dep: {cfg: {extra_dep: {marker: 1}}}}`. """ - # If no dependency-groups are specified, use the lock members manifest, or just the self-list - dep_groups = project_data.get("dependency-groups", { - project_data["project"]["name"]: lock_data.get("manifest", {}).get("members", [ + if "dependency-groups" not in project_data: + fallback_members = lock_data.get("manifest", {}).get("members", [ project_data["project"]["name"], - ]), - }) + ]) + dep_groups = { + "": fallback_members, + normalize_name(project_data["project"]["name"]): fallback_members, + } + else: + dep_groups = project_data["dependency-groups"] # Normalize dep groups to our dependency triples (graph keys) normalized_dep_groups = {} diff --git a/uv/private/uv_hub/repository.bzl b/uv/private/uv_hub/repository.bzl index 00d5c4588..b12b2fb62 100644 --- a/uv/private/uv_hub/repository.bzl +++ b/uv/private/uv_hub/repository.bzl @@ -1,5 +1,24 @@ -""" - +"""Generates the central hub repository that exposes resolved dependencies to the build. + +The hub presents two label shapes: + + - `@//` — unqualified. The select() arms cover every + (project, group) provider; resolves cleanly when the active dep_group has + a single owner of the package, and Bazel's native "multiple keys match" + surfaces the ambiguity when an active group truly overlaps. + + - `@//project/:` — project-qualified. Always available. + The `project/` prefix is reserved on labels because the package side + comes from PyPI (uncontrolled by the user); without the prefix, picking + a project name that matches a PyPI package would silently shadow it. + Flag values, by contrast, are unprefixed (`` / `/`) + because that namespace is purely user-controlled and the extension + fails on collision at hub construction time. + +Internally each project's groups are namespaced as `__` +config_settings, all keyed on the same global `dep_group` flag value. Setting +`dep_group=prod` activates every project's `prod` group simultaneously; the +qualified aliases route to the right project's package without ambiguity. """ load("@bazel_features//:features.bzl", features = "bazel_features") @@ -9,25 +28,68 @@ def indent(text, space = " "): return "\n".join(["{}{}".format(space, l) for l in text.splitlines()]) def _hub_impl(repository_ctx): - """Generates the central hub repository that exposes resolved dependencies to the build. + """Lays down the hub repo's BUILD files. + + Sections, in order: + + 1. `//dep_group/BUILD.bazel` — alias to the global `dep_group` flag plus + a `__` config_setting per (project, group). Same-named + groups across projects all key on the same flag value, so + `dep_group=` activates every project's ``. A narrow + `__q__` config_setting also fires for the qualified + flag value `/` (skipped for `""` — the synthesized + empty-default — and the synthesized `` alias, both already + addressable via bare flag values). + + 2. `//BUILD.bazel` — root, with `gazelle_index_whls`. Selects on the + active dep_group so only the matching project's whls flow through. + + 3. `//project//BUILD.bazel` — per-project subdirs. Qualified + labels are scoped to one project's groups, so cross-project + ambiguity is impossible by construction. + + 4. `///BUILD.bazel` — top-level unqualified labels. Providers + are clustered by (group, versions_tuple); same-version providers in + the same group share a single canonical arm. Different-version + providers in the same group form separate clusters and produce + separate arms — Bazel's "multiple keys match" then surfaces the + genuine version conflict at `dep_group=`. Each provider + additionally contributes a per-project narrow arm so + `dep_group="project//"` resolves unambiguously. + + 5. `//defs.bzl` — `compatible_with` / `incompatible_with` helpers. + User-facing API takes bare group names; helpers fan out to every + project's namespaced config_setting that matches the group. + + 6. `//requirements.bzl` — rules_python compat shim. Returns the + unqualified label; ambiguous packages surface their helpful + no_match_error via the alias's select. + + Caveat (sections 4 and 6): the (group, version) dedup ignores override + differences. Two projects pinning the same version with different + `uv.override_package` overrides produce differing wheels but only the + canonical project's overrides apply through the unqualified label. + Reach for the qualified label or the narrow `project//` + flag form when overrides diverge. + """ - - Defines a helper alias for configuring the active [dependency-group] - - Defines aliases for every package in any component project + # {project_id: {"stamp": ..., "groups": [...], "packages": {pkg: [groups]}}} + projects = json.decode(repository_ctx.attr.projects) - This "surface" hub is dead easy, as it just wraps up project hubs which are - responsible for all the heavy lifting. + # {package: [project_id, ...]} + package_owners = json.decode(repository_ctx.attr.package_owners) - Args: - repository_ctx: The repository context. - """ + # {group_name: [project_stamp, ...]} — used by `compatible_with` to fan + # a group reference out across every project that defines it. + projects_by_group = {} + for project_id, p in projects.items(): + for grp in p["groups"]: + projects_by_group.setdefault(grp, []).append(p["stamp"]) - # {requirement: {cfg: target}} - packages = json.decode(repository_ctx.attr.packages) + all_groups = sorted(projects_by_group.keys()) ################################################################################ - # Lay down the //dep_group:BUILD.bazel file with config flags - # - # We do this first because everything else hangs off of these config_settings. + # //dep_group/BUILD.bazel content = [ """\ alias( @@ -38,182 +100,260 @@ alias( """, ] - # Lay down the dep_group config settings - for name in repository_ctx.attr.configurations: - content.append( - """ + for project_id, p in projects.items(): + stamp = p["stamp"] + for grp in p["groups"]: + # Broad arm: `dep_group=`. + content.append(""" config_setting( - name = "{name}", + name = "{stamp}__{grp}", flag_values = {{ - "@aspect_rules_py//uv/private/constraints/dep_group:dep_group": "{name}", + "@aspect_rules_py//uv/private/constraints/dep_group:dep_group": "{grp}", }}, visibility = ["//visibility:public"], ) -""".format(name = name), - ) +""".format(stamp = stamp, grp = grp)) + + # Narrow arm: `dep_group=/`. + if grp != "" and grp != stamp: + content.append(""" +config_setting( + name = "{stamp}__q__{grp}", + flag_values = {{ + "@aspect_rules_py//uv/private/constraints/dep_group:dep_group": "{stamp}/{grp}", + }}, + visibility = ["//visibility:public"], +) +""".format(stamp = stamp, grp = grp)) + repository_ctx.file("dep_group/BUILD.bazel", content = "\n".join(content)) ################################################################################ - # Lay down the //:BUILD.bazel file + # //BUILD.bazel + gazelle_arms = {} + for project_id, p in projects.items(): + for grp in p["groups"]: + gazelle_arms["//dep_group:{}__{}".format(p["stamp"], grp)] = [ + "@{}//:gazelle_index_whls".format(project_id), + ] content = [ """\ load("@aspect_rules_py//py:defs.bzl", "py_library") -""", - ] - index_select_clauses = { - "//dep_group:" + cfg: ["@{}//:gazelle_index_whls".format(project_id)] - for cfg, project_id in repository_ctx.attr.configurations.items() - } - - content.append(""" filegroup( name = "gazelle_index_whls", - srcs = select({index_select_clauses}, - ), + srcs = select({arms}), visibility = ["//visibility:public"], ) -""".format(index_select_clauses = indent(pprint(index_select_clauses), " ").lstrip())) - +""".format(arms = indent(pprint(gazelle_arms), " ").lstrip()), + ] repository_ctx.file("BUILD.bazel", "\n".join(content)) ################################################################################ - # Lay down the hub aliases - entrypoints = {} - - for package_name, specs in packages.items(): + # //project//BUILD.bazel + for project_id, p in projects.items(): + stamp = p["stamp"] content = [ """\ load("@aspect_rules_py//py:defs.bzl", "py_library") -load("//:defs.bzl", "compatible_with") """, ] - select_spec = { - "//dep_group:{}".format(cfg): l - for cfg, l in specs.items() - } - - error = "Available only in dep_groups: " + ", ".join(specs.keys()) # Simplified error string - - # FIXME: Add support for entrypoints? - # FIXME: Create a narrower dist-info rule - content.append( - """ -# This target is for a "hard" dependency. -# Dependencies on this target will cause build failures if it's unavailable. + for package, group_versions in p["packages"].items(): + groups = group_versions.keys() + target = "@{}//:{}".format(project_id, package) + select_arms = {} + compat_arms = {} + for grp in groups: + broad_key = "//dep_group:{}__{}".format(stamp, grp) + select_arms[broad_key] = target + compat_arms[broad_key] = [] + if grp != "" and grp != stamp: + qualified_key = "//dep_group:{}__q__{}".format(stamp, grp) + select_arms[qualified_key] = target + compat_arms[qualified_key] = [] + compat_arms["//conditions:default"] = ["@platforms//:incompatible"] + no_match_error = "Package `{}` is not in any of project `{}`'s dep_groups: {}".format( + package, + stamp, + ", ".join(sorted(groups)), + ) + + content.append(""" alias( - name = "lib", - actual = "{name}", + name = "{name}", + actual = select({arms}, + no_match_error = "{err}", + ), + target_compatible_with = select({compat}), visibility = ["//visibility:public"], ) filegroup( - name = "dist_info", + name = "{name}.dist_info", srcs = [":{name}"], + target_compatible_with = select({compat}), visibility = ["//visibility:public"], ) +""".format( + name = package, + arms = indent(pprint(select_arms), " ").lstrip(), + compat = indent(pprint(compat_arms), " ").lstrip(), + err = no_match_error, + )) + + repository_ctx.file("project/{}/BUILD.bazel".format(stamp), content = "\n".join(content)) + + ################################################################################ + # ///BUILD.bazel + for package, owners in package_owners.items(): + # {(group, versions_tuple): [owner_id, ...]} + clusters = {} + for owner_id in owners: + for group, versions in projects[owner_id]["packages"][package].items(): + clusters.setdefault((group, tuple(versions)), []).append(owner_id) + + select_arms = {} + compat_arms = {} + for cluster_key in sorted(clusters.keys()): + group, _versions = cluster_key + cluster_owners = clusters[cluster_key] + + canonical_oid = sorted(cluster_owners, key = lambda oid: projects[oid]["stamp"])[0] + canonical_stamp = projects[canonical_oid]["stamp"] + broad_arm = "//dep_group:{}__{}".format(canonical_stamp, group) + select_arms[broad_arm] = "//project/{}:{}".format(canonical_stamp, package) + compat_arms[broad_arm] = [] + + for owner_id in cluster_owners: + owner_stamp = projects[owner_id]["stamp"] + if group != "" and group != owner_stamp: + q_arm = "//dep_group:{}__q__{}".format(owner_stamp, group) + select_arms[q_arm] = "//project/{}:{}".format(owner_stamp, package) + compat_arms[q_arm] = [] + + compat_arms["//conditions:default"] = ["@platforms//:incompatible"] + + groups_with_pkg_set = {} + for (grp, _) in clusters.keys(): + groups_with_pkg_set[grp] = True + groups_with_pkg = sorted(groups_with_pkg_set.keys()) + qualified_labels = sorted([ + "@{}//project/{}:{}".format(repository_ctx.name, projects[o]["stamp"], package) + for o in owners + ]) + err = ( + "Package `{pkg}` is available in dep_groups: {groups}. " + + "If multiple projects in this hub provide `{pkg}` at different " + + "versions in the active dep_group, Bazel will surface 'multiple keys match' — " + + "use a project-qualified label instead: {qualified}" + ).format( + pkg = package, + groups = ", ".join(groups_with_pkg), + qualified = ", ".join(qualified_labels), + ) + + content = """\ +load("@aspect_rules_py//py:defs.bzl", "py_library") + alias( name = "{name}", - actual = select({lib_select}, - no_match_error = "{error}", + actual = select({arms}, + no_match_error = "{err}", ), - target_compatible_with = select(compatible_with({compat})), + target_compatible_with = select({compat}), + visibility = ["//visibility:public"], +) +alias( + name = "lib", + actual = ":{name}", + visibility = ["//visibility:public"], +) +filegroup( + name = "dist_info", + srcs = [":{name}"], visibility = ["//visibility:public"], ) """.format( - name = package_name, - lib_select = indent(pprint(select_spec), " ").lstrip(), - compat = repr(specs.keys()), - error = error, - ), + name = package, + arms = indent(pprint(select_arms), " ").lstrip(), + compat = indent(pprint(compat_arms), " ").lstrip(), + err = err, ) - repository_ctx.file(package_name + "/BUILD.bazel", content = "\n".join(content)) + repository_ctx.file(package + "/BUILD.bazel", content = content) ################################################################################ - # Lay down //:defs.bzl - content = [ - """ -VIRTUALENVS = {configurations} + # //defs.bzl + content = ["""\ +DEP_GROUPS = {all_groups} +PROJECTS_BY_GROUP = {projects_by_group} _repo = {repo_name} -def compatible_with(venvs, extra_constraints = []): - for v in venvs: - if v not in VIRTUALENVS: - fail("Errant virtualenv reference %r" % v) - - return {{ - Label("//dep_group:" + it): extra_constraints - for it in venvs - }} | {{ - "//conditions:default": ["@platforms//:incompatible"], - }} - -def incompatible_with(venvs, extra_constraints = []): - for v in venvs: - if v not in VIRTUALENVS: - fail("Errant virtualenv reference %r" % v) - - return {{ - Label("//dep_group:" + it): ["@platforms//:incompatible"] - for it in venvs - }} | {{ - "//conditions:default": extra_constraints, - }} +def compatible_with(groups, extra_constraints = []): + for g in groups: + if g not in PROJECTS_BY_GROUP: + fail("Errant dep_group reference %r — known groups: %r" % (g, DEP_GROUPS)) + + result = {{}} + for grp in groups: + for stamp in PROJECTS_BY_GROUP[grp]: + result[Label("//dep_group:" + stamp + "__" + grp)] = extra_constraints + result["//conditions:default"] = ["@platforms//:incompatible"] + return result + +def incompatible_with(groups, extra_constraints = []): + for g in groups: + if g not in PROJECTS_BY_GROUP: + fail("Errant dep_group reference %r — known groups: %r" % (g, DEP_GROUPS)) + + result = {{}} + for grp in groups: + for stamp in PROJECTS_BY_GROUP[grp]: + result[Label("//dep_group:" + stamp + "__" + grp)] = ["@platforms//:incompatible"] + result["//conditions:default"] = extra_constraints + return result """.format( - configurations = pprint(repository_ctx.attr.configurations.keys()), - repo_name = repr(repository_ctx.name), - ), - ] + all_groups = pprint(all_groups), + projects_by_group = pprint(projects_by_group), + repo_name = repr(repository_ctx.name), + )] repository_ctx.file("defs.bzl", content = "\n".join(content)) ################################################################################ - # Lay down a requirements.bzl for compatibility with rules_python - content = [] - content.append(""" + # //requirements.bzl + content = """ load("@rules_python//python:pip.bzl", "pip_utils") -# We arne't compatible with this because it isn't constant over venvs. -# all_requirements = [] - -# We aren't compatible with this because it isn't constant over venvs. -# all_whl_requirements_by_package = {{}} - -# We aren't compatible with this because it isn't constant over venvs. -# all_whl_requirements = all_whl_requirements_by_package.values() - -# We aren't compatible with this because we don't offer separate data targets -# all_data_requirements = [] +# These aren't compatible with the hub model because they aren't constant over dep_groups: +# all_requirements = [] +# all_whl_requirements_by_package = {{}} +# all_whl_requirements = all_whl_requirements_by_package.values() +# all_data_requirements = [] # we don't offer separate data targets def requirement(name): return "@@{repo_name}//{{0}}:{{0}}".format(pip_utils.normalize_name(name)) -""".format( - repo_name = repository_ctx.name, - )) - repository_ctx.file("requirements.bzl", content = "\n".join(content)) - - ################################################################################ - # Lay down the hub aliases - entrypoints = {} +""".format(repo_name = repository_ctx.name) + repository_ctx.file("requirements.bzl", content = content) if not features.external_deps.extension_metadata_has_reproducible: return None return repository_ctx.repo_metadata(reproducible = True) uv_hub = repository_rule( - doc = """ - """, + doc = """Generates the surface hub repo for a single `uv.hub()` declaration, +aggregating every project bound to it.""", implementation = _hub_impl, attrs = { - "configurations": attr.string_dict( + "projects": attr.string( doc = """ - Mapping of configuration name to a project _containing_ that configuration. + JSON blob: `{project_id: {"stamp": , "groups": [...], "packages": {pkg: [groups]}}}`. """, ), - "packages": attr.string( + "package_owners": attr.string( doc = """ - JSON blob mapping packages to configurations to projects. + JSON blob: `{package: [project_id, ...]}`. >1 owner triggers + ambiguous-stub generation for the unqualified label. """, ), }, diff --git a/uv/private/uv_project/repository.bzl b/uv/private/uv_project/repository.bzl index 96d34a03e..79e179e79 100644 --- a/uv/private/uv_project/repository.bzl +++ b/uv/private/uv_project/repository.bzl @@ -1,5 +1,23 @@ -""" - +"""Generates the per-project repo backing each `uv.project()` declaration. + +The hub repo (uv_hub) routes top-level package labels into one of these +project repos based on the active `dep_group` flag value. Each project +repo carries: + + - `//private/dep_group/BUILD.bazel` — internal config_settings mirroring + the hub's `dep_group/` shape: a broad `` config_setting for each + declared group, plus a narrow `q_` keyed on the qualified flag + value `/`. + - `//BUILD.bazel` — surface package aliases. Each `:` selects on + `private/dep_group:` and routes to the appropriate SCC. + - `//private/sccs/BUILD.bazel` — `py_library` per SCC. + - `//private/markers/BUILD.bazel` — `decide_marker` rules for collected + PEP 508 markers. + +Style: each layer is built as a list of string fragments (`content = [...]`) +and finalized with a single `"\\n".join(content)` to avoid the cost of +repeated string concatenation. Each layer ends with a +`repository_ctx.file(path, content)` call. """ load("@bazel_features//:features.bzl", features = "bazel_features") @@ -9,6 +27,15 @@ load("//uv/private/pprint:defs.bzl", "pprint") def indent(text, space = " "): return "\n".join(["{}{}".format(space, l) for l in text.splitlines()]) +def _cfg_target(cfg): + """Bazel target name for a dep_group key. Empty maps to `_default`. + + The synthesized empty-keyed group has flag_value `""`, but Bazel + forbids empty target names. We use `_default` for the target while + the flag_value stays `""`. + """ + return cfg if cfg else "_default" + def name(quad): _lock, package_name, package_version, package_extra = quad.split(",") if package_extra == "__base__": @@ -25,16 +52,7 @@ def _project_impl(repository_ctx): scc_graph: {scc: {install: {marker: 1}}} """ - # Styleguide; string append via `+=` is inefficient. Prefer to use a list as - # a pseudo string builder buffer and a single final "\n".join(content) to - # materialize the buffer to a final writable string. - - # Styleguide: Address each layer of aliases sequentially. Each layer should - # begin with a comment explaining what faimily of BUILD.bazel files will be - # generated, and end with the required `repository_ctx.file(path, content)` - # call. - - # These are provided as JSON strings and must be decoded. + # JSON-encoded by the extension; decode to the nested dicts above. dep_to_scc = json.decode(repository_ctx.attr.dep_to_scc) scc_deps = json.decode(repository_ctx.attr.scc_deps) scc_graph = json.decode(repository_ctx.attr.scc_graph) @@ -84,29 +102,42 @@ alias( return ":" + cond_id ################################################################################ - # Lay down the //private/dep_group:BUILD.bazel file with config flags - # - # This mirrors the uv_hub's dep_group, but is internal to the project. + # //private/dep_group/BUILD.bazel venv_content = [] - # Collect all unique cfgs first all_cfgs = set() for dep, cfgs in dep_to_scc.items(): for cfg in cfgs.keys(): all_cfgs.add(cfg) + project_stamp = repository_ctx.attr.project_stamp for cfg_name in all_cfgs: + # Broad arm: `dep_group=`. venv_content.append( """ config_setting( - name = "{name}", + name = "{target}", flag_values = {{ - "@aspect_rules_py//uv/private/constraints/dep_group:dep_group": "{name}", + "@aspect_rules_py//uv/private/constraints/dep_group:dep_group": "{flag_value}", }}, visibility = ["//visibility:public"], ) -""".format(name = cfg_name), +""".format(target = _cfg_target(cfg_name), flag_value = cfg_name), ) + + # Narrow arm: `dep_group=/`. + if cfg_name != "" and cfg_name != project_stamp: + venv_content.append( + """ +config_setting( + name = "q_{name}", + flag_values = {{ + "@aspect_rules_py//uv/private/constraints/dep_group:dep_group": "{stamp}/{name}", + }}, + visibility = ["//visibility:public"], +) +""".format(name = cfg_name, stamp = project_stamp), + ) repository_ctx.file("private/dep_group/BUILD.bazel", content = "\n".join(venv_content)) ################################################################################ @@ -124,18 +155,16 @@ load("@aspect_rules_py//py:defs.bzl", "py_library") # FIXME: Handle markers for distinct versions for cfg, scc_cfgs in cfgs.items(): - cfg_name = "_package_{}_{}".format(package, cfg) - main_arms["//private/dep_group:" + cfg] = ":" + cfg_name + cfg_name = "_package_{}_{}".format(package, _cfg_target(cfg)) + main_arms["//private/dep_group:" + _cfg_target(cfg)] = ":" + cfg_name + if cfg != "" and cfg != project_stamp: + main_arms["//private/dep_group:q_" + cfg] = ":" + cfg_name cfg_arms = {} - # This is a bit tricky. We're doing choice between several different - # SCCs possibly encoding different versions or extra specializations - # of a package "at once" depending on the venv + marker set. - # Consequently this second-level choice is actually the MERGE - # between the individual cases under which specific markers evaluate - # to true. It's a configuration and locking failure for there to be - # more than one package which resolves at this point. So we just jam all the configurations into a single select. + # Second-level select: merges the SCCs under which markers evaluate + # true. Two SCCs matching the same marker (or `__base__`) is a + # locking failure and surfaces as a configuration error below. for scc, markers in scc_cfgs.items(): if "" in markers: if "//conditions:default" in cfg_arms: @@ -176,7 +205,7 @@ alias( all_requirements = {} for package, cfgs in dep_to_scc.items(): for cfg in cfgs.keys(): - all_requirements.setdefault("//private/dep_group:" + cfg, []).append("//:" + package) + all_requirements.setdefault("//private/dep_group:" + _cfg_target(cfg), []).append("//:" + package) content.append(""" filegroup( @@ -278,6 +307,10 @@ decide_marker( uv_project = repository_rule( implementation = _project_impl, attrs = { + "project_stamp": attr.string( + doc = "PEP 503 normalized project name. Used to construct " + + "the qualified flag-value match `project//`.", + ), "dep_to_scc": attr.string(), "scc_deps": attr.string(), "scc_graph": attr.string(),