From b6740d650666a0931ac02f0bdba28699adf89b4b Mon Sep 17 00:00:00 2001 From: Finn Ball Date: Fri, 28 Aug 2026 17:15:59 +0100 Subject: [PATCH 1/5] fix(uv): preserve transitive dependency markers --- uv/private/extension/projectfile.bzl | 147 ++++++++++++++---- .../extension/tests/projectfile_test.bzl | 108 +++++++++++++ 2 files changed, 222 insertions(+), 33 deletions(-) diff --git a/uv/private/extension/projectfile.bzl b/uv/private/extension/projectfile.bzl index 4b357ce05..cf3727108 100644 --- a/uv/private/extension/projectfile.bzl +++ b/uv/private/extension/projectfile.bzl @@ -85,7 +85,19 @@ def extract_requirement_marker_pairs(projectfile, lock_id, req_string, version_m remainder = remainder[close_idx + 1:] # 4. Look up version - v = preferred_versions.get(pkg_name) + # An exact requirement is authoritative. A dependency group's preferred + # version cannot represent multiple versions selected by disjoint markers. + specifier = remainder.strip() + v = None + if specifier.startswith("=="): + pkg_vers = package_versions.get(pkg_name, {}) + candidates = { + ver: (lock_id, pkg_name, ver, "__base__") + for ver in pkg_vers.keys() + } + v = find_matching_version(specifier, candidates) + if v == None: + v = preferred_versions.get(pkg_name) if v == None: v = version_map.get(pkg_name) if v == None: @@ -149,6 +161,39 @@ def _extract_lockfile_group_versions(lock_id, lock_data): result.setdefault(group_name, {})[pkg_name] = (lock_id, pkg_name, dep["version"], "__base__") return result +def _marker_clause(marker): + """Returns a canonical conjunction clause for a marker expression.""" + return () if marker == "" else (marker,) + +def _combine_marker_clause(clause, marker): + """Conjoins a clause with an edge marker without duplicating atoms.""" + if marker == "" or marker in clause: + return clause + return tuple(sorted(clause + (marker,))) + +def _clause_marker(clause): + """Renders a canonical conjunction clause as a marker expression.""" + if not clause: + return "" + if len(clause) == 1: + return clause[0] + return " and ".join(["({})".format(marker) for marker in clause]) + +def _add_minimal_clause(clauses, candidate): + """Adds candidate unless an existing, less restrictive clause subsumes it.""" + for existing in clauses: + if all([marker in candidate for marker in existing]): + return None + + removed = [] + for existing in clauses: + if all([marker in existing for marker in candidate]): + removed.append(existing) + for existing in removed: + clauses.pop(existing) + clauses[candidate] = 1 + return removed + def collect_activated_extras(projectfile, lock_id, project_data, lock_data, default_versions, graph, package_versions = {}): """Collects the set of transitively activated extras for each configuration. @@ -179,12 +224,15 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa ]), }) - # Normalize dep groups to our dependency triples (graph keys) - normalized_dep_groups = {} - # Builds up {package: {configuration: {extra: {marker: 1}}}} activated_extras = {} + # Minimal conjunction clauses under which each dependency is reachable, + # per configuration. Keeping an antichain of clauses makes propagation + # cycle-safe: revisiting a node through a cycle can only add restrictions, + # so that path is subsumed by the path which first entered the cycle. + reachable_clauses = {} + all_group_preferences = {} lockfile_group_versions = _extract_lockfile_group_versions(lock_id, lock_data) @@ -194,48 +242,81 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa group_preferences = dict(lockfile_group_versions.get(group_name, {})) + direct_versions = {} for spec in resolved_specs: for dep, _marker in extract_requirement_marker_pairs(projectfile, lock_id, spec, default_versions, package_versions, group_preferences): - group_preferences[dep[1]] = (dep[0], dep[1], dep[2], "__base__") + direct_versions.setdefault(dep[1], {})[(dep[0], dep[1], dep[2], "__base__")] = 1 + + for package, versions in direct_versions.items(): + if len(versions) == 1: + group_preferences[package] = list(versions.keys())[0] + elif package in group_preferences: + group_preferences.pop(package) all_group_preferences[group_name] = group_preferences for spec in resolved_specs: for dep, marker in extract_requirement_marker_pairs(projectfile, lock_id, spec, default_versions, package_versions, group_preferences): - normalized_dep_groups.setdefault(group_name, []).append(dep) - # Note that this is the base case for the reach set walk below # We do this here so it's easy to handle marker expressions base = (dep[0], dep[1], dep[2], "__base__") - activated_extras.setdefault(base, {}).setdefault(group_name, {}).setdefault(dep, {}).update({marker: 1}) - - for group_name, deps in normalized_dep_groups.items(): - worklist = list(deps) + dep_markers = activated_extras.setdefault(base, {}).setdefault(group_name, {}).setdefault(dep, {}) + clauses = reachable_clauses.setdefault(group_name, {}).setdefault(dep, {}) + clause = _marker_clause(marker) + removed = _add_minimal_clause(clauses, clause) + if removed != None: + for old_clause in removed: + dep_markers.pop(_clause_marker(old_clause)) + dep_markers[_clause_marker(clause)] = 1 + + for group_name, group_clauses in reachable_clauses.items(): + worklist = [ + (dep, clause) + for dep, clauses in group_clauses.items() + for clause in clauses + ] group_prefs = all_group_preferences.get(group_name, {}) - visited = {} - idx = 0 - for _ in range(1000000): - if idx == len(worklist): - break - - it = worklist[idx] - visited[it] = 1 - for next_dep, markers in graph.get(it, {}).items(): - pkg_name = next_dep[1] - pref = group_prefs.get(pkg_name) - target_dep = next_dep - if pref and pref[2] != next_dep[2]: - target_dep = (next_dep[0], next_dep[1], pref[2], next_dep[3]) - - base = (target_dep[0], target_dep[1], target_dep[2], "__base__") - - activated_extras.setdefault(base, {}).setdefault(group_name, {}).setdefault(target_dep, {}).update(markers) - if target_dep not in visited: - visited[target_dep] = 1 - worklist.append(target_dep) + # Every useful clause has a simple-path witness: following a cycle can + # only add restrictions, so the clause at the cycle entry subsumes it. + # Processing one graph edge per round therefore reaches a fixed point + # after at most one round per node, plus one to drain terminal nodes. + for _ in range(len(graph) + 1): + if not worklist: + break - idx += 1 + next_worklist = [] + for parent_dep, parent_clause in worklist: + if parent_clause not in group_clauses[parent_dep]: + continue + + for next_dep, edge_markers in graph.get(parent_dep, {}).items(): + pkg_name = next_dep[1] + pref = group_prefs.get(pkg_name) + target_dep = next_dep + if pref and pref[2] != next_dep[2]: + target_dep = (next_dep[0], next_dep[1], pref[2], next_dep[3]) + + base = (target_dep[0], target_dep[1], target_dep[2], "__base__") + target_markers = activated_extras.setdefault(base, {}).setdefault(group_name, {}).setdefault(target_dep, {}) + target_clauses = group_clauses.setdefault(target_dep, {}) + + for edge_marker in edge_markers: + clause = _combine_marker_clause(parent_clause, edge_marker) + if clause in target_clauses: + continue + removed = _add_minimal_clause(target_clauses, clause) + if removed == None: + continue + for old_clause in removed: + target_markers.pop(_clause_marker(old_clause)) + target_markers[_clause_marker(clause)] = 1 + next_worklist.append((target_dep, clause)) + + worklist = next_worklist + + if worklist: + fail("Marker propagation did not converge for dependency group {} in {}".format(repr(group_name), projectfile)) return {it: 1 for it in dep_groups.keys()}, activated_extras diff --git a/uv/private/extension/tests/projectfile_test.bzl b/uv/private/extension/tests/projectfile_test.bzl index 24b7f741b..08a04bff6 100644 --- a/uv/private/extension/tests/projectfile_test.bzl +++ b/uv/private/extension/tests/projectfile_test.bzl @@ -200,6 +200,112 @@ collect_activated_extras_transitive_remap_test = unittest.make( _collect_activated_extras_transitive_remap_test_impl, ) +def _collect_activated_extras_platform_split_transitive_markers_test_impl(ctx): + env = unittest.begin(ctx) + darwin = 'sys_platform == "darwin"' + not_darwin = 'sys_platform != "darwin"' + + # The transitive edges are unconditional because their platform markers are + # redundant with the direct requirements. The traversal must carry those + # direct markers forward rather than making both transitive versions global. + project_data = { + "project": {"name": "test_project"}, + "dependency-groups": { + "default": [ + "platform-parent==1.0; {}".format(darwin), + "platform-parent==2.0; {}".format(not_darwin), + ], + }, + } + lock_data = { + "package": [ + { + "name": "test_project", + "source": {"virtual": "."}, + "dev-dependencies": { + "default": [ + {"name": "platform-parent", "version": "1.0"}, + {"name": "platform-parent", "version": "2.0"}, + ], + }, + }, + ], + } + parent_1 = ("lock", "platform_parent", "1.0", "__base__") + parent_2 = ("lock", "platform_parent", "2.0", "__base__") + transitive_1 = ("lock", "transitive_dep", "1.0", "__base__") + transitive_2 = ("lock", "transitive_dep", "2.0", "__base__") + graph = { + parent_1: {transitive_1: {"": 1}}, + parent_2: {transitive_2: {"": 1}}, + transitive_1: {}, + transitive_2: {}, + } + + _cfg_names, activated_extras = collect_activated_extras( + "//:pyproject.toml", + "lock", + project_data, + lock_data, + {}, + graph, + { + "platform_parent": {"1.0": 1, "2.0": 1}, + "transitive_dep": {"1.0": 1, "2.0": 1}, + }, + ) + + asserts.equals(env, {darwin: 1}, activated_extras[transitive_1]["default"][transitive_1]) + asserts.equals(env, {not_darwin: 1}, activated_extras[transitive_2]["default"][transitive_2]) + return unittest.end(env) + +collect_activated_extras_platform_split_transitive_markers_test = unittest.make( + _collect_activated_extras_platform_split_transitive_markers_test_impl, +) + +def _collect_activated_extras_conditional_cycle_test_impl(ctx): + env = unittest.begin(ctx) + root_marker = 'sys_platform == "linux"' + edge_marker = 'python_version < "3.13"' + cycle_marker = 'platform_python_implementation == "CPython"' + project_data = { + "project": {"name": "test_project"}, + "dependency-groups": { + "default": ["package-a==1.0; {}".format(root_marker)], + }, + } + package_a = ("lock", "package_a", "1.0", "__base__") + package_b = ("lock", "package_b", "1.0", "__base__") + graph = { + package_a: {package_b: {edge_marker: 1}}, + package_b: {package_a: {cycle_marker: 1}}, + } + + _cfg_names, activated_extras = collect_activated_extras( + "//:pyproject.toml", + "lock", + project_data, + {}, + {}, + graph, + { + "package_a": {"1.0": 1}, + "package_b": {"1.0": 1}, + }, + ) + + asserts.equals(env, {root_marker: 1}, activated_extras[package_a]["default"][package_a]) + asserts.equals( + env, + {"({}) and ({})".format(edge_marker, root_marker): 1}, + activated_extras[package_b]["default"][package_b], + ) + return unittest.end(env) + +collect_activated_extras_conditional_cycle_test = unittest.make( + _collect_activated_extras_conditional_cycle_test_impl, +) + def projectfile_test_suite(): unittest.suite( "extract_requirement_marker_pairs_tests", @@ -210,4 +316,6 @@ def projectfile_test_suite(): extract_requirement_marker_pairs_preferred_overrides_version_map_test, extract_requirement_marker_pairs_preferred_overrides_multi_version_test, collect_activated_extras_transitive_remap_test, + collect_activated_extras_platform_split_transitive_markers_test, + collect_activated_extras_conditional_cycle_test, ) From f5bd8c56f01287a37b16a2130708f246a383c31d Mon Sep 17 00:00:00 2001 From: Jason Bedard Date: Wed, 23 Sep 2026 12:15:45 -0700 Subject: [PATCH 2/5] fixup! fix(uv): preserve transitive dependency markers --- uv/private/uv_hub/snapshots/project.BUILD.bazel | 8 +------- .../uv_hub/snapshots/project.private.markers.BUILD.bazel | 4 ++-- .../uv_hub/snapshots/project.private.sccs.BUILD.bazel | 4 ++-- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/uv/private/uv_hub/snapshots/project.BUILD.bazel b/uv/private/uv_hub/snapshots/project.BUILD.bazel index 4a38ae893..610150ba1 100644 --- a/uv/private/uv_hub/snapshots/project.BUILD.bazel +++ b/uv/private/uv_hub/snapshots/project.BUILD.bazel @@ -611,8 +611,6 @@ alias( # "aspect_rules_py": { # "colorama__0_4_6": { # "": 1, -# "os_name == 'nt'": 1, -# "sys_platform == 'win32'": 1, # }, # }, # } @@ -2641,7 +2639,6 @@ alias( # "aspect_rules_py": { # "setuptools__80_9_0": { # "": 1, -# "python_full_version >= '3.12'": 1, # }, # }, # } @@ -3058,8 +3055,6 @@ alias( # "aspect_rules_py": { # "typing_extensions__4_12_2": { # "": 1, -# "python_full_version < '3.11'": 1, -# "python_full_version < '3.12'": 1, # }, # }, # } @@ -3106,7 +3101,6 @@ alias( # "aspect_rules_py": { # "tzdata__2024_1": { # "": 1, -# "sys_platform == 'win32'": 1, # }, # }, # } @@ -3382,7 +3376,7 @@ alias( # { # "aspect_rules_py": { # "zipp__3_23_0": { -# "python_full_version < '3.12'": 1, +# "(python_full_version < '3.10.2') and (python_full_version < '3.12')": 1, # }, # }, # } diff --git a/uv/private/uv_hub/snapshots/project.private.markers.BUILD.bazel b/uv/private/uv_hub/snapshots/project.private.markers.BUILD.bazel index e5880a8ea..935a04ed0 100644 --- a/uv/private/uv_hub/snapshots/project.private.markers.BUILD.bazel +++ b/uv/private/uv_hub/snapshots/project.private.markers.BUILD.bazel @@ -19,7 +19,7 @@ decide_marker( decide_marker( name = "marker_2", - marker = "python_full_version < '3.12'", + marker = "(python_full_version < '3.10.2') and (python_full_version < '3.12')", visibility = ["//:__subpackages__"], ) @@ -47,7 +47,7 @@ decide_marker( decide_marker( name = "marker_6", - marker = "(python_full_version < '3.10.2') and (python_full_version < '3.12')", + marker = "python_full_version < '3.12'", visibility = ["//:__subpackages__"], ) diff --git a/uv/private/uv_hub/snapshots/project.private.sccs.BUILD.bazel b/uv/private/uv_hub/snapshots/project.private.sccs.BUILD.bazel index 467738a0f..c95f58787 100644 --- a/uv/private/uv_hub/snapshots/project.private.sccs.BUILD.bazel +++ b/uv/private/uv_hub/snapshots/project.private.sccs.BUILD.bazel @@ -642,7 +642,7 @@ alias( name = "_maybe__exceptiongroup__1_3_1__typing_extensions", actual = select({ "//private/markers:marker_5": "//:typing_extensions", - "//private/markers:marker_2": "//:typing_extensions", + "//private/markers:marker_6": "//:typing_extensions", "//conditions:default": ":empty", }), visibility = ["//:__subpackages__"], @@ -813,8 +813,8 @@ py_library( alias( name = "_maybe__importlib_metadata__8_7_1__zipp", actual = select({ - "//private/markers:marker_6": "//:zipp", "//private/markers:marker_2": "//:zipp", + "//private/markers:marker_6": "//:zipp", "//conditions:default": ":empty", }), visibility = ["//:__subpackages__"], From d5a4f785bc69be9b1154ef98f8918eb5c4eddacf Mon Sep 17 00:00:00 2001 From: Jason Bedard Date: Wed, 23 Sep 2026 12:22:53 -0700 Subject: [PATCH 3/5] fixup! fix(uv): preserve transitive dependency markers --- uv/private/extension/projectfile.bzl | 45 ++++++----------- .../extension/tests/projectfile_test.bzl | 49 +++++++++++++++++++ 2 files changed, 65 insertions(+), 29 deletions(-) diff --git a/uv/private/extension/projectfile.bzl b/uv/private/extension/projectfile.bzl index cf3727108..83204a71f 100644 --- a/uv/private/extension/projectfile.bzl +++ b/uv/private/extension/projectfile.bzl @@ -183,16 +183,12 @@ def _add_minimal_clause(clauses, candidate): """Adds candidate unless an existing, less restrictive clause subsumes it.""" for existing in clauses: if all([marker in candidate for marker in existing]): - return None + return False - removed = [] - for existing in clauses: - if all([marker in existing for marker in candidate]): - removed.append(existing) - for existing in removed: + for existing in [existing for existing in clauses if all([marker in existing for marker in candidate])]: clauses.pop(existing) clauses[candidate] = 1 - return removed + return True def collect_activated_extras(projectfile, lock_id, project_data, lock_data, default_versions, graph, package_versions = {}): """Collects the set of transitively activated extras for each configuration. @@ -224,9 +220,6 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa ]), }) - # Builds up {package: {configuration: {extra: {marker: 1}}}} - activated_extras = {} - # Minimal conjunction clauses under which each dependency is reachable, # per configuration. Keeping an antichain of clauses makes propagation # cycle-safe: revisiting a node through a cycle can only add restrictions, @@ -259,15 +252,8 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa for dep, marker in extract_requirement_marker_pairs(projectfile, lock_id, spec, default_versions, package_versions, group_preferences): # Note that this is the base case for the reach set walk below # We do this here so it's easy to handle marker expressions - base = (dep[0], dep[1], dep[2], "__base__") - dep_markers = activated_extras.setdefault(base, {}).setdefault(group_name, {}).setdefault(dep, {}) clauses = reachable_clauses.setdefault(group_name, {}).setdefault(dep, {}) - clause = _marker_clause(marker) - removed = _add_minimal_clause(clauses, clause) - if removed != None: - for old_clause in removed: - dep_markers.pop(_clause_marker(old_clause)) - dep_markers[_clause_marker(clause)] = 1 + _add_minimal_clause(clauses, _marker_clause(marker)) for group_name, group_clauses in reachable_clauses.items(): worklist = [ @@ -297,27 +283,28 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa if pref and pref[2] != next_dep[2]: target_dep = (next_dep[0], next_dep[1], pref[2], next_dep[3]) - base = (target_dep[0], target_dep[1], target_dep[2], "__base__") - target_markers = activated_extras.setdefault(base, {}).setdefault(group_name, {}).setdefault(target_dep, {}) target_clauses = group_clauses.setdefault(target_dep, {}) for edge_marker in edge_markers: clause = _combine_marker_clause(parent_clause, edge_marker) - if clause in target_clauses: - continue - removed = _add_minimal_clause(target_clauses, clause) - if removed == None: - continue - for old_clause in removed: - target_markers.pop(_clause_marker(old_clause)) - target_markers[_clause_marker(clause)] = 1 - next_worklist.append((target_dep, clause)) + if _add_minimal_clause(target_clauses, clause): + next_worklist.append((target_dep, clause)) worklist = next_worklist if worklist: fail("Marker propagation did not converge for dependency group {} in {}".format(repr(group_name), projectfile)) + # Builds up {package: {configuration: {extra: {marker: 1}}}} + activated_extras = {} + for group_name, group_clauses in reachable_clauses.items(): + for dep, clauses in group_clauses.items(): + base = (dep[0], dep[1], dep[2], "__base__") + activated_extras.setdefault(base, {}).setdefault(group_name, {})[dep] = { + _clause_marker(clause): 1 + for clause in clauses + } + return {it: 1 for it in dep_groups.keys()}, activated_extras def collate_versions_by_name(activated_extras): diff --git a/uv/private/extension/tests/projectfile_test.bzl b/uv/private/extension/tests/projectfile_test.bzl index 08a04bff6..515c25386 100644 --- a/uv/private/extension/tests/projectfile_test.bzl +++ b/uv/private/extension/tests/projectfile_test.bzl @@ -306,6 +306,54 @@ collect_activated_extras_conditional_cycle_test = unittest.make( _collect_activated_extras_conditional_cycle_test_impl, ) +def _collect_activated_extras_clause_rendering_collision_test_impl(ctx): + env = unittest.begin(ctx) + a = 'python_version < "3.13"' + b = 'sys_platform == "linux"' + rendered = "({}) and ({})".format(a, b) + project_data = { + "project": {"name": "test_project"}, + "dependency-groups": { + "default": [ + "split-parent==1.0; {}".format(a), + "paren-parent==1.0", + "plain-parent==1.0", + ], + }, + } + split_parent = ("lock", "split_parent", "1.0", "__base__") + paren_parent = ("lock", "paren_parent", "1.0", "__base__") + plain_parent = ("lock", "plain_parent", "1.0", "__base__") + leaf = ("lock", "leaf", "1.0", "__base__") + graph = { + split_parent: {leaf: {b: 1}}, + paren_parent: {leaf: {rendered: 1}}, + plain_parent: {leaf: {"": 1}}, + leaf: {}, + } + + _cfg_names, activated_extras = collect_activated_extras( + "//:pyproject.toml", + "lock", + project_data, + {}, + {}, + graph, + { + "split_parent": {"1.0": 1}, + "paren_parent": {"1.0": 1}, + "plain_parent": {"1.0": 1}, + "leaf": {"1.0": 1}, + }, + ) + + asserts.equals(env, {"": 1}, activated_extras[leaf]["default"][leaf]) + return unittest.end(env) + +collect_activated_extras_clause_rendering_collision_test = unittest.make( + _collect_activated_extras_clause_rendering_collision_test_impl, +) + def projectfile_test_suite(): unittest.suite( "extract_requirement_marker_pairs_tests", @@ -318,4 +366,5 @@ def projectfile_test_suite(): collect_activated_extras_transitive_remap_test, collect_activated_extras_platform_split_transitive_markers_test, collect_activated_extras_conditional_cycle_test, + collect_activated_extras_clause_rendering_collision_test, ) From b2263b478676a41f30092c886dd2db5d309b1123 Mon Sep 17 00:00:00 2001 From: Jason Bedard Date: Wed, 23 Sep 2026 12:25:32 -0700 Subject: [PATCH 4/5] fixup! fix(uv): preserve transitive dependency markers --- uv/private/extension/projectfile.bzl | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/uv/private/extension/projectfile.bzl b/uv/private/extension/projectfile.bzl index 83204a71f..9e2e6bf4c 100644 --- a/uv/private/extension/projectfile.bzl +++ b/uv/private/extension/projectfile.bzl @@ -85,8 +85,8 @@ def extract_requirement_marker_pairs(projectfile, lock_id, req_string, version_m remainder = remainder[close_idx + 1:] # 4. Look up version - # An exact requirement is authoritative. A dependency group's preferred - # version cannot represent multiple versions selected by disjoint markers. + # An exact requirement overrides the group preference, which can only + # hold one version per package. specifier = remainder.strip() v = None if specifier.startswith("=="): @@ -220,10 +220,8 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa ]), }) - # Minimal conjunction clauses under which each dependency is reachable, - # per configuration. Keeping an antichain of clauses makes propagation - # cycle-safe: revisiting a node through a cycle can only add restrictions, - # so that path is subsumed by the path which first entered the cycle. + # {configuration: {dep: {clause: 1}}}, each an antichain of minimal + # conjunction clauses under which dep is reachable. reachable_clauses = {} all_group_preferences = {} @@ -263,10 +261,8 @@ def collect_activated_extras(projectfile, lock_id, project_data, lock_data, defa ] group_prefs = all_group_preferences.get(group_name, {}) - # Every useful clause has a simple-path witness: following a cycle can - # only add restrictions, so the clause at the cycle entry subsumes it. - # Processing one graph edge per round therefore reaches a fixed point - # after at most one round per node, plus one to drain terminal nodes. + # Every minimal clause has a simple-path witness, so one round per + # node plus one to drain reaches the fixed point. for _ in range(len(graph) + 1): if not worklist: break From 0801a6b52dce85ea15b3016f975361961ad3c667 Mon Sep 17 00:00:00 2001 From: Jason Bedard Date: Wed, 23 Sep 2026 13:22:43 -0700 Subject: [PATCH 5/5] fixup! fix(uv): preserve transitive dependency markers --- uv/private/extension/projectfile.bzl | 32 +++++------ .../extension/tests/projectfile_test.bzl | 55 +++++++++++++++++++ 2 files changed, 68 insertions(+), 19 deletions(-) diff --git a/uv/private/extension/projectfile.bzl b/uv/private/extension/projectfile.bzl index 9e2e6bf4c..1a37ed534 100644 --- a/uv/private/extension/projectfile.bzl +++ b/uv/private/extension/projectfile.bzl @@ -3,7 +3,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("//uv/private/versions:versions.bzl", "find_matching_version", "version_satisfies") load(":dep_groups.bzl", "resolve_dependency_group_specs") def extract_requirement_marker_pairs(projectfile, lock_id, req_string, version_map, package_versions = {}, preferred_versions = {}, fail_if_missing = True): @@ -85,33 +85,27 @@ def extract_requirement_marker_pairs(projectfile, lock_id, req_string, version_m remainder = remainder[close_idx + 1:] # 4. Look up version - # An exact requirement overrides the group preference, which can only - # hold one version per package. + # A group preference or default version is used only when it satisfies + # the specifier; disjoint-marker requirements on one package need distinct + # lockfile versions. Otherwise match against all lockfile versions, then + # fall back to the unchecked preference. specifier = remainder.strip() + known = [preferred_versions.get(pkg_name), version_map.get(pkg_name)] v = None - if specifier.startswith("=="): - pkg_vers = package_versions.get(pkg_name, {}) - candidates = { - ver: (lock_id, pkg_name, ver, "__base__") - for ver in pkg_vers.keys() - } - v = find_matching_version(specifier, candidates) - if v == None: - v = preferred_versions.get(pkg_name) - if v == None: - v = version_map.get(pkg_name) + for candidate in known: + if candidate != None and (not specifier or version_satisfies(candidate[2], specifier)): + v = candidate + break if v == None: - # For multi-version packages (e.g. conflicts), match the version - # specifier against all known versions of this package in the lockfile. - specifier = remainder.strip() pkg_vers = package_versions.get(pkg_name, {}) if pkg_vers: - match_spec = specifier if specifier else ">=0" candidates = { ver: (lock_id, pkg_name, ver, "__base__") for ver in pkg_vers.keys() } - v = find_matching_version(match_spec, candidates) + v = find_matching_version(specifier if specifier else ">=0", candidates) + if v == None: + v = known[0] or known[1] if v == None: if not fail_if_missing: return [] diff --git a/uv/private/extension/tests/projectfile_test.bzl b/uv/private/extension/tests/projectfile_test.bzl index 515c25386..9c6f6691e 100644 --- a/uv/private/extension/tests/projectfile_test.bzl +++ b/uv/private/extension/tests/projectfile_test.bzl @@ -119,6 +119,58 @@ extract_requirement_marker_pairs_preferred_overrides_multi_version_test = unitte _extract_requirement_marker_pairs_preferred_overrides_multi_version_test_impl, ) +def _extract_requirement_marker_pairs_preferred_satisfying_wildcard_test_impl(ctx): + env = unittest.begin(ctx) + preferred = {"foo": ("proj", "foo", "1.2", "__base__")} + result = extract_requirement_marker_pairs( + "//:pyproject.toml", + "proj", + "foo==1.*", + {}, + {"foo": {"1.2": 1, "1.9": 1}}, + preferred, + ) + asserts.equals(env, [(("proj", "foo", "1.2", "__base__"), "")], result) + return unittest.end(env) + +extract_requirement_marker_pairs_preferred_satisfying_wildcard_test = unittest.make( + _extract_requirement_marker_pairs_preferred_satisfying_wildcard_test_impl, +) + +def _extract_requirement_marker_pairs_unsatisfied_preferred_matches_lock_test_impl(ctx): + env = unittest.begin(ctx) + preferred = {"foo": ("proj", "foo", "2.0", "__base__")} + result = extract_requirement_marker_pairs( + "//:pyproject.toml", + "proj", + 'foo<2; sys_platform != "darwin"', + {"foo": ("proj", "foo", "2.0", "__base__")}, + {"foo": {"1.5": 1, "2.0": 1}}, + preferred, + ) + asserts.equals(env, [(("proj", "foo", "1.5", "__base__"), 'sys_platform != "darwin"')], result) + return unittest.end(env) + +extract_requirement_marker_pairs_unsatisfied_preferred_matches_lock_test = unittest.make( + _extract_requirement_marker_pairs_unsatisfied_preferred_matches_lock_test_impl, +) + +def _extract_requirement_marker_pairs_unsatisfied_single_version_falls_back_test_impl(ctx): + env = unittest.begin(ctx) + result = extract_requirement_marker_pairs( + "//:pyproject.toml", + "proj", + "foo>=2", + {"foo": ("proj", "foo", "1.9", "__base__")}, + {"foo": {"1.9": 1}}, + ) + asserts.equals(env, [(("proj", "foo", "1.9", "__base__"), "")], result) + return unittest.end(env) + +extract_requirement_marker_pairs_unsatisfied_single_version_falls_back_test = unittest.make( + _extract_requirement_marker_pairs_unsatisfied_single_version_falls_back_test_impl, +) + def _collect_activated_extras_transitive_remap_test_impl(ctx): env = unittest.begin(ctx) project_data = { @@ -363,6 +415,9 @@ def projectfile_test_suite(): extract_requirement_marker_pairs_with_extras_test, extract_requirement_marker_pairs_preferred_overrides_version_map_test, extract_requirement_marker_pairs_preferred_overrides_multi_version_test, + extract_requirement_marker_pairs_preferred_satisfying_wildcard_test, + extract_requirement_marker_pairs_unsatisfied_preferred_matches_lock_test, + extract_requirement_marker_pairs_unsatisfied_single_version_falls_back_test, collect_activated_extras_transitive_remap_test, collect_activated_extras_platform_split_transitive_markers_test, collect_activated_extras_conditional_cycle_test,