Skip to content
Merged
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 @@ -346,6 +346,17 @@ uv.unstable_annotate_packages(
)
# }}}

# For cases/uv-extra-marker
# Regression: extra-only markers must not evaluate to false when the active
# dependency group (venv) has a different name than the extra.
# {{{
uv.project(
hub_name = "pypi",
lock = "//cases/uv-extra-marker:uv.lock",
pyproject = "//cases/uv-extra-marker:pyproject.toml",
)
# }}}

# For cases/uv-abi3-compat-853
# Regression: abi3 wheel compatibility must propagate to newer Python minors.
# {{{
Expand Down
14 changes: 14 additions & 0 deletions e2e/cases/uv-extra-marker/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
load("@aspect_rules_py//py/unstable:defs.bzl", "py_venv_test")

py_venv_test(
name = "extra_marker",
srcs = [
"__test__.py",
],
main = "__test__.py",
python_version = "3.11",
venv = "ci",
deps = [
"@pypi//colorama",
],
)
12 changes: 12 additions & 0 deletions e2e/cases/uv-extra-marker/__test__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env python3
"""Reproduce omitted extra-only markers.

The dev dependency group activates the project's `build` extra, which should
make `colorama` available. If rules_py evaluates `extra == 'build'` to false
because the active venv is named `dev`, the dependency is dropped and this
import fails.
"""

import colorama

print(f"colorama: {colorama.__file__}")
15 changes: 15 additions & 0 deletions e2e/cases/uv-extra-marker/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[project]
name = "extra-marker"
version = "0.0.0"
requires-python = ">=3.11"
dependencies = []

[project.optional-dependencies]
build = [
"colorama",
]

[dependency-groups]
ci = [
"extra-marker[build]",
]
34 changes: 34 additions & 0 deletions e2e/cases/uv-extra-marker/uv.lock

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

20 changes: 20 additions & 0 deletions uv/private/extension/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
load("@bazel_lib//:bzl_library.bzl", "bzl_library")
load(":test_graph_utils.bzl", "graph_utils_test_suite")
load(":test_marker_simplify.bzl", "marker_simplify_test_suite")
load(":test_projectfile.bzl", "projectfile_test_suite")

graph_utils_test_suite()

marker_simplify_test_suite()

projectfile_test_suite()

bzl_library(
Expand Down Expand Up @@ -78,6 +81,16 @@ bzl_library(
],
)

bzl_library(
name = "test_marker_simplify",
srcs = ["test_marker_simplify.bzl"],
visibility = ["//uv:__subpackages__"],
deps = [
":marker_simplify",
"@bazel_skylib//lib:unittest",
],
)

bzl_library(
name = "test_projectfile",
srcs = ["test_projectfile.bzl"],
Expand All @@ -99,6 +112,13 @@ bzl_library(
],
)

bzl_library(
name = "marker_simplify",
srcs = ["marker_simplify.bzl"],
visibility = ["//uv:__subpackages__"],
deps = ["//uv/private/markers:pep508_evaluate"],
)

bzl_library(
name = "dep_groups",
srcs = ["dep_groups.bzl"],
Expand Down
16 changes: 15 additions & 1 deletion uv/private/extension/graph_utils.bzl
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
load("//uv/private:sha1.bzl", "sha1")
load("//uv/private/graph:sccs.bzl", "sccs")
load(":marker_simplify.bzl", "simplify_markers_for_extras")

def collect_sccs(marker_graph):
"""Computes Strongly Connected Components (SCCs) for a dependency marker_graph.
Expand Down Expand Up @@ -139,11 +140,24 @@ def activate_extras(marker_graph, activated_extras, cfg):
# For the current (base!) package, look up the closure of activated
# extras and merge the _dependencies_ of those extras in.
extras = activated_extras.get(pkg, {}).get(cfg, {})

# Collect the names of every extra that is active for this package in
# this configuration. We use them to resolve `extra == '...'` markers
# that come from optional dependencies, since at this point we already
# know which extras were requested.
active_extra_names = [dep[3] for dep in extras.keys() if dep[3] != "__base__"]

for extra, extra_markers in extras.items():
# Merge in deps from the requested extra
for implied_dep, implied_markers in marker_graph.get(extra, {}).items():
# Normalize since the source graph isn't
normalized_implied_dep = (implied_dep[0], implied_dep[1], implied_dep[2], "__base__")
acc[pkg].setdefault(normalized_implied_dep, {}).update(combine_markers(extra_markers, implied_markers))

# Resolve any `extra` markers using the extras we know are
# active. This avoids relying on the venv-name heuristic in
# decide_marker later.
simplified_implied_markers = simplify_markers_for_extras(implied_markers, active_extra_names)
if simplified_implied_markers:
acc[pkg].setdefault(normalized_implied_dep, {}).update(combine_markers(extra_markers, simplified_implied_markers))

return acc
125 changes: 125 additions & 0 deletions uv/private/extension/marker_simplify.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Helpers for partially evaluating PEP 508 markers.

These are used during graph construction to resolve `extra == '...'` markers
once we already know which extras are being activated. Resolving them early
means `decide_marker` does not need to know about extras at build time.
"""

load("//uv/private/markers:pep508_evaluate.bzl", "evaluate", "tokenize")

def simplify_extra_marker(marker, extras):
"""Partially evaluate a marker binding the `extra` PEP 508 variable.

Once we know which extras of a package are activated for a configuration,
any `extra == '...'` sub-expression in the optional-dependency markers of
those extras becomes a known boolean. This prevents `decide_marker` from
later evaluating `extra` markers based only on the venv name heuristic.

Args:
marker: The PEP 508 marker string to simplify.
extras: A list of extra names that are active for the package.

Returns:
- `""` if the marker simplifies to true.
- `None` if the marker simplifies to false.
- A residual marker string if parts of it cannot be evaluated (e.g.
because they also depend on platform markers).
"""
if not marker or "extra" not in marker:
return marker

residuals = []
seen_true = False
for extra in extras:
result = evaluate(marker, env = {"extra": extra}, strict = False)
if type(result) == type(True):
if result:
seen_true = True
break
continue
if type(result) == type(""):
if not result:
# The evaluator returns an empty string when the expression
# reduced to true but other terms were unresolved.
seen_true = True
break
if result not in residuals:
residuals.append(result)
else:
fail("Unexpected marker evaluation result for {}: {}".format(marker, result))

if seen_true:
return ""
if not residuals:
return None
if len(residuals) == 1:
return residuals[0]
return "({})".format(") or (".join(residuals))

def simplify_markers_for_extras(markers, extras):
"""Simplify a collection of markers given a set of active extras.

Args:
markers: A dictionary mapping marker strings to 1.
extras: A list of active extra names.

Returns:
A dictionary of simplified markers, or an empty dictionary if all
markers simplified to false.
"""
acc = {}
for marker in markers.keys():
simplified = simplify_extra_marker(marker, extras)
if simplified != None:
acc[simplified] = 1
return acc

# Tokens that are allowed in an "extra-only" marker.
_EXTRA_ONLY_TOKENS = {
"extra": True,
"==": True,
"!=": True,
"<": True,
">": True,
"<=": True,
">=": True,
"~=": True,
"===": True,
"in": True,
"not in": True,
"and": True,
"or": True,
"not": True,
"(": True,
")": True,
}

def is_extra_only_marker(marker):
"""Return True if a marker only references the `extra` variable.

uv uses `extra == '...'` markers both for real package extras and for
internal conflict-routing labels. These markers cannot be evaluated at
Bazel build time because `extra` is not part of the execution environment.
Detecting them lets the BUILD generator treat them as already resolved by
the active dependency group.

Args:
marker: The PEP 508 marker string to inspect.

Returns:
True if the marker only depends on `extra`, False otherwise.
"""
if not marker or "extra" not in marker:
return False

for token in tokenize(marker):
if token in _EXTRA_ONLY_TOKENS:
continue
if token.startswith('"'):
# Quoted string literal.
continue

# Any other token is an environment variable or unknown operator.
return False

return True
8 changes: 7 additions & 1 deletion uv/private/extension/projectfile.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Machinery specific to interacting with a pyproject.toml
load("//uv/private:normalize_name.bzl", "normalize_name")
load("//uv/private/versions:versions.bzl", "find_matching_version")
load(":dep_groups.bzl", "resolve_dependency_group_specs")
load(":marker_simplify.bzl", "simplify_markers_for_extras")

def extract_requirement_marker_pairs(projectfile, lock_id, req_string, version_map, package_versions = {}, preferred_versions = {}):
"""Parses a requirement string into a list of dependency-marker pairs.
Expand Down Expand Up @@ -214,6 +215,10 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa
it = worklist[idx]
visited[it] = 1

# If we reached this node via an extra, any `extra == '...'` marker
# on outgoing edges can be resolved using that extra.
origin_extra = it[3] if it[3] != "__base__" else None

for next_dep, markers in graph.get(it, {}).items():
pkg_name = next_dep[1]
pref = group_prefs.get(pkg_name)
Expand All @@ -223,7 +228,8 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa

base = (target_dep[0], target_dep[1], target_dep[2], "__base__")

activated_extras.setdefault(base, {}).setdefault(group_name, {}).setdefault(target_dep, {}).update(markers)
simplified_markers = simplify_markers_for_extras(markers, [origin_extra]) if origin_extra else markers
activated_extras.setdefault(base, {}).setdefault(group_name, {}).setdefault(target_dep, {}).update(simplified_markers)
if target_dep not in visited:
visited[target_dep] = 1
worklist.append(target_dep)
Expand Down
Loading