diff --git a/uv/private/extension/projectfile.bzl b/uv/private/extension/projectfile.bzl index 4b357ce05..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,21 +85,27 @@ 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) - if v == None: - v = version_map.get(pkg_name) + # 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 + 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 [] @@ -149,6 +155,35 @@ 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 False + + for existing in [existing for existing in clauses if all([marker in existing for marker in candidate])]: + clauses.pop(existing) + clauses[candidate] = 1 + 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. @@ -179,11 +214,9 @@ 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 = {} + # {configuration: {dep: {clause: 1}}}, each an antichain of minimal + # conjunction clauses under which dep is reachable. + reachable_clauses = {} all_group_preferences = {} @@ -194,48 +227,73 @@ 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) + clauses = reachable_clauses.setdefault(group_name, {}).setdefault(dep, {}) + _add_minimal_clause(clauses, _marker_clause(marker)) + + 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): + + # 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 - it = worklist[idx] - visited[it] = 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]) - 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]) + target_clauses = group_clauses.setdefault(target_dep, {}) - base = (target_dep[0], target_dep[1], target_dep[2], "__base__") + for edge_marker in edge_markers: + clause = _combine_marker_clause(parent_clause, edge_marker) + if _add_minimal_clause(target_clauses, clause): + next_worklist.append((target_dep, clause)) - 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) + worklist = next_worklist - idx += 1 + 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 diff --git a/uv/private/extension/tests/projectfile_test.bzl b/uv/private/extension/tests/projectfile_test.bzl index 24b7f741b..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 = { @@ -200,6 +252,160 @@ 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 _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", @@ -209,5 +415,11 @@ 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, + collect_activated_extras_clause_rendering_collision_test, ) 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__"],