Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions e2e/MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions e2e/cases/uv-conflict-extra-marker/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
18 changes: 18 additions & 0 deletions e2e/cases/uv-conflict-extra-marker/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }]
]
4 changes: 4 additions & 0 deletions e2e/cases/uv-conflict-extra-marker/test_a.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env python3

from importlib import metadata
assert metadata.version("packaging") == "24.0"
4 changes: 4 additions & 0 deletions e2e/cases/uv-conflict-extra-marker/test_b.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env python3

from importlib import metadata
assert metadata.version("packaging") == "21.3"
9 changes: 9 additions & 0 deletions e2e/cases/uv-conflict-extra-marker/test_shared.py
Original file line number Diff line number Diff line change
@@ -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)
61 changes: 61 additions & 0 deletions e2e/cases/uv-conflict-extra-marker/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 49 additions & 9 deletions uv/private/uv_project/repository.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}",
Expand All @@ -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}",
Expand Down
Loading