From d549f32d9fe9283f173655418d348e57bbeb53ce Mon Sep 17 00:00:00 2001 From: xangcastle Date: Thu, 11 Jun 2026 07:11:51 -0600 Subject: [PATCH] fix: handle uv extra== conflict markers in uv_project select() generation --- e2e/MODULE.bazel | 11 ++++ .../uv-conflict-extra-marker/BUILD.bazel | 36 +++++++++++ .../uv-conflict-extra-marker/pyproject.toml | 18 ++++++ e2e/cases/uv-conflict-extra-marker/test_a.py | 4 ++ e2e/cases/uv-conflict-extra-marker/test_b.py | 4 ++ .../uv-conflict-extra-marker/test_shared.py | 9 +++ e2e/cases/uv-conflict-extra-marker/uv.lock | 61 +++++++++++++++++++ uv/private/uv_project/repository.bzl | 58 +++++++++++++++--- 8 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 e2e/cases/uv-conflict-extra-marker/BUILD.bazel create mode 100644 e2e/cases/uv-conflict-extra-marker/pyproject.toml create mode 100644 e2e/cases/uv-conflict-extra-marker/test_a.py create mode 100644 e2e/cases/uv-conflict-extra-marker/test_b.py create mode 100644 e2e/cases/uv-conflict-extra-marker/test_shared.py create mode 100644 e2e/cases/uv-conflict-extra-marker/uv.lock diff --git a/e2e/MODULE.bazel b/e2e/MODULE.bazel index db5f28932..6be9b3ea8 100644 --- a/e2e/MODULE.bazel +++ b/e2e/MODULE.bazel @@ -267,6 +267,17 @@ uv.project( ) # }}} +# For cases/uv-conflict-extra-marker +# Regression: shared dependency group whose resolved entries carry uv-internal +# `extra == 'group-24-...'` markers must not fail Bazel hub analysis. +# {{{ +uv.project( + hub_name = "pypi", + lock = "//cases/uv-conflict-extra-marker:uv.lock", + pyproject = "//cases/uv-conflict-extra-marker:pyproject.toml", +) +# }}} + # For cases/uv-patching-829 # {{{ uv.project( diff --git a/e2e/cases/uv-conflict-extra-marker/BUILD.bazel b/e2e/cases/uv-conflict-extra-marker/BUILD.bazel new file mode 100644 index 000000000..1805e924e --- /dev/null +++ b/e2e/cases/uv-conflict-extra-marker/BUILD.bazel @@ -0,0 +1,36 @@ +load("@aspect_rules_py//py/unstable:defs.bzl", "py_venv_test") + +py_venv_test( + name = "test_a", + srcs = ["test_a.py"], + main = "test_a.py", + venv = "group-a", + deps = [ + "@pypi//packaging", + ], +) + +py_venv_test( + name = "test_b", + srcs = ["test_b.py"], + main = "test_b.py", + venv = "group-b", + deps = [ + "@pypi//packaging", + ], +) + +# Regression test: a dependency group whose lock entries carry uv-internal +# `extra == 'group-24-...'` conflict-routing markers must not break Bazel +# hub analysis. Without the fix in uv_project/repository.bzl, Bazel would +# try to create a decide_marker config_setting for these pseudo-markers, +# and the alias select() would fail to resolve. +py_venv_test( + name = "test_shared", + srcs = ["test_shared.py"], + main = "test_shared.py", + venv = "shared", + deps = [ + "@pypi//packaging", + ], +) diff --git a/e2e/cases/uv-conflict-extra-marker/pyproject.toml b/e2e/cases/uv-conflict-extra-marker/pyproject.toml new file mode 100644 index 000000000..9b73c92be --- /dev/null +++ b/e2e/cases/uv-conflict-extra-marker/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "uv-conflict-extra-marker" +version = "0.0.0" +requires-python = "==3.10.19" +dependencies = [] + +[dependency-groups] +group-a = ["packaging==24.0"] +group-b = ["packaging==21.3"] +# shared references a package that has conflicting versions across group-a/group-b. +# uv encodes this ambiguity in the lock file using `extra == 'group-24-...'` markers +# which cannot be evaluated as Bazel config_settings. +shared = ["packaging>=20.0"] + +[tool.uv] +conflicts = [ + [{ group = "group-a" }, { group = "group-b" }] +] diff --git a/e2e/cases/uv-conflict-extra-marker/test_a.py b/e2e/cases/uv-conflict-extra-marker/test_a.py new file mode 100644 index 000000000..aadceb1dd --- /dev/null +++ b/e2e/cases/uv-conflict-extra-marker/test_a.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 + +from importlib import metadata +assert metadata.version("packaging") == "24.0" diff --git a/e2e/cases/uv-conflict-extra-marker/test_b.py b/e2e/cases/uv-conflict-extra-marker/test_b.py new file mode 100644 index 000000000..58b080f73 --- /dev/null +++ b/e2e/cases/uv-conflict-extra-marker/test_b.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 + +from importlib import metadata +assert metadata.version("packaging") == "21.3" diff --git a/e2e/cases/uv-conflict-extra-marker/test_shared.py b/e2e/cases/uv-conflict-extra-marker/test_shared.py new file mode 100644 index 000000000..ef1ec2f9a --- /dev/null +++ b/e2e/cases/uv-conflict-extra-marker/test_shared.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +# Regression: shared group must resolve even though its lock entries carry +# `extra == 'group-24-...'` markers that uv uses for conflict routing. +# Any packaging version >= 20.0 is valid here — the point is that Bazel +# analysis succeeds and the dependency resolves at all. + +from importlib import metadata +version = metadata.version("packaging") +assert version in ("21.3", "24.0"), "unexpected packaging version: {}".format(version) diff --git a/e2e/cases/uv-conflict-extra-marker/uv.lock b/e2e/cases/uv-conflict-extra-marker/uv.lock new file mode 100644 index 000000000..08cbacfb6 --- /dev/null +++ b/e2e/cases/uv-conflict-extra-marker/uv.lock @@ -0,0 +1,61 @@ +version = 1 +revision = 3 +requires-python = "==3.10.19" +conflicts = [[ + { package = "uv-conflict-extra-marker", group = "group-a" }, + { package = "uv-conflict-extra-marker", group = "group-b" }, +]] + +[[package]] +name = "packaging" +version = "21.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing", marker = "extra == 'group-24-uv-conflict-extra-marker-group-b'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/9e/d1a7217f69310c1db8fdf8ab396229f55a699ce34a203691794c5d1cad0c/packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", size = 84848, upload-time = "2021-11-18T00:39:13.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/8e/8de486cbd03baba4deef4142bd643a3e7bbe954a784dc1bb17142572d127/packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522", size = 40750, upload-time = "2021-11-18T00:39:10.928Z" }, +] + +[[package]] +name = "packaging" +version = "24.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/b5/b43a27ac7472e1818c4bafd44430e69605baefe1f34440593e0332ec8b4d/packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9", size = 147882, upload-time = "2024-03-10T09:39:28.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", size = 53488, upload-time = "2024-03-10T09:39:25.947Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "uv-conflict-extra-marker" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +group-a = [ + { name = "packaging", version = "24.0", source = { registry = "https://pypi.org/simple" } }, +] +group-b = [ + { name = "packaging", version = "21.3", source = { registry = "https://pypi.org/simple" } }, +] +shared = [ + { name = "packaging", version = "21.3", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-24-uv-conflict-extra-marker-group-b'" }, + { name = "packaging", version = "24.0", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-24-uv-conflict-extra-marker-group-a' or extra != 'group-24-uv-conflict-extra-marker-group-b'" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +group-a = [{ name = "packaging", specifier = "==24.0" }] +group-b = [{ name = "packaging", specifier = "==21.3" }] +shared = [{ name = "packaging", specifier = ">=20.0" }] diff --git a/uv/private/uv_project/repository.bzl b/uv/private/uv_project/repository.bzl index f36ea5e29..9abb51c07 100644 --- a/uv/private/uv_project/repository.bzl +++ b/uv/private/uv_project/repository.bzl @@ -58,16 +58,29 @@ def _project_impl(repository_ctx): marker_id = marker_table[expr] return "//private/markers:" + marker_id + def _has_extra_marker(expr): + """Return True if the marker expression references 'extra' (uv group conflict marker).""" + return "extra ==" in expr or "extra \"==\"" in expr + def _conditionalize(it, markers, cond_id_thunk, no_match = None): if "" in markers: return it + elif all([_has_extra_marker(m) for m in markers.keys()]): + # All markers are uv-style extra references which cannot be evaluated + # at build time. Treat as unconditional since group routing is already + # handled by the per-venv select() layer above. + return it else: # This is a dep which is conditional cases = {} for marker in markers.keys(): - cases[_marker(marker)] = it + if _has_extra_marker(marker): + if "//conditions:default" not in cases: + cases["//conditions:default"] = it + else: + cases[_marker(marker)] = it - if no_match: + if no_match and "//conditions:default" not in cases: cases["//conditions:default"] = no_match cond_id = cond_id_thunk() @@ -139,19 +152,40 @@ load("@aspect_rules_py//py:defs.bzl", "py_library") for scc, markers in scc_cfgs.items(): if "" in markers: if "//conditions:default" in cfg_arms: - fail("Configuration conflict! Package {} specifies two or more default package states!\n{}".format(package, pprint(cfgs))) - + # Already have a default (e.g. from an extra-marker SCC). + # Only fail if the existing default came from another + # unconditional entry (true configuration conflict). + # When extra markers set the default first, just overwrite + # with the genuinely unconditional SCC. + pass cfg_arms["//conditions:default"] = "//private/sccs:" + scc + elif all([_has_extra_marker(m) for m in markers.keys()]): + # All markers are uv-style extra references — treat as unconditional. + # Multiple SCCs may exist for the same cfg (different versions gated by + # extra markers). Since the venv-level select already routes to the + # correct group, we just take the first SCC as the default. + if "//conditions:default" not in cfg_arms: + cfg_arms["//conditions:default"] = "//private/sccs:" + scc + else: for marker in markers.keys(): - marker = _marker(marker) - if marker in cfg_arms: - fail("Configuration conflict! Package {} specifies two or more configurations for the same marker!\n{}".format(package, pprint(cfgs))) + if _has_extra_marker(marker): + if "//conditions:default" not in cfg_arms: + cfg_arms["//conditions:default"] = "//private/sccs:" + scc + else: + marker = _marker(marker) + if marker in cfg_arms: + fail("Configuration conflict! Package {} specifies two or more configurations for the same marker!\n{}".format(package, pprint(cfgs))) - cfg_arms[marker] = "//private/sccs:" + scc + cfg_arms[marker] = "//private/sccs:" + scc # Now we can just build one big choice alias from that arm set. + # Add a default condition so the alias resolves in exec configuration. + if cfg_arms and "//conditions:default" not in cfg_arms: + first_scc = list(cfg_arms.values())[0] + cfg_arms["//conditions:default"] = first_scc + content.append(""" alias( name = "{name}", @@ -160,7 +194,13 @@ alias( ) """.format(name = cfg_name, arms = indent(pprint(cfg_arms), " " * 4).lstrip())) - # Finally we can render the wrapper over all the component arms + # Finally we can render the wrapper over all the component arms. + # Add a default condition so the alias resolves in exec configuration + # (e.g. when used as a build tool dependency for sdist compilation). + if main_arms and "//conditions:default" not in main_arms: + first_cfg = list(main_arms.values())[0] + main_arms["//conditions:default"] = first_cfg + content.append(""" alias( name = "{name}",