diff --git a/news/4139.fixed.md b/news/4139.fixed.md new file mode 100644 index 0000000000..589812ea87 --- /dev/null +++ b/news/4139.fixed.md @@ -0,0 +1,5 @@ +(pypi) `pip.parse(uv_lock = ...)` now builds packages with a git source with +`pip`, from a `name @ git+@` direct reference pinned to the commit +`uv` resolved, instead of passing the uv.lock source string to the Bazel +downloader as a URL. +([#4139](https://github.com/bazel-contrib/rules_python/issues/4139)) diff --git a/python/private/pypi/parse_requirements.bzl b/python/private/pypi/parse_requirements.bzl index 580989e3ff..2ac8b3724f 100644 --- a/python/private/pypi/parse_requirements.bzl +++ b/python/private/pypi/parse_requirements.bzl @@ -226,18 +226,16 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p git_struct = None if pkg.get("source", {}).get("git"): - url = pkg["source"]["git"] - - # Keep the revision in the URL, but exclude it from the repository filename. - url_path, _, _ = url.partition("?") - url_path, _, _ = url_path.partition("#") - _, _, filename = url_path.rpartition("/") + # A git source is not a downloadable artifact. Leave `url` and + # `filename` empty, the same as a `foo @ git+...` line from a + # requirements file, so that `pip` builds it from the requirement + # line instead of the Bazel downloader trying to fetch the URL. git_struct = struct( - filename = filename, - url = url, + filename = "", + url = "", digest = "", kind = "git", - source = pkg["source"], + requirement = _uv_lock_git_requirement(pkg["source"]["git"]), ) plat_to_src = {} @@ -275,11 +273,18 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p for key, val in src_to_plats.items(): src = val.src plats = sorted(val.plats) - requirement_line = "{name}{extras}=={version}".format( - name = name, - extras = extra_str, - version = version, - ) + if src.kind == "git": + requirement_line = "{name}{extras} @ {requirement}".format( + name = name, + extras = extra_str, + requirement = src.requirement, + ) + else: + requirement_line = "{name}{extras}=={version}".format( + name = name, + extras = extra_str, + version = version, + ) entry["resolved_srcs"].append(struct( distribution = name, extra_pip_args = extra_pip_args or [], @@ -313,6 +318,55 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p logger.debug(lambda: "Parsed {} packages from uv.lock".format(len(ret))) return ret +def _uv_lock_git_requirement(git_source): + """Turn a uv.lock git source into a pip direct reference. + + uv records `?#`, where the query carries + `rev`, `tag` or `branch` and optionally `subdirectory`. pip expects + `git+@[#subdirectory=]`. Pinning to the resolved + commit rather than the requested ref keeps the build reproducible. + + Args: + git_source: {type}`str` the `source.git` value from uv.lock. + + Returns: + {type}`str` the pip direct reference, without the ` @ ` prefix. + """ + head, _, commit = git_source.partition("#") + repo_url, _, query = head.partition("?") + subdirectory = "" + for param in query.split("&"): + key, _, value = param.partition("=") + if key == "subdirectory": + # uv percent-encodes the value (`python%2Ffoo`), but pip reads the + # `#subdirectory=` fragment as a literal path. + subdirectory = _percent_decode(value) + requirement = "git+{}@{}".format(repo_url, commit) + if subdirectory: + requirement += "#subdirectory={}".format(subdirectory) + return requirement + +# Printable ASCII, indexed by `code point - 0x20`, as Starlark has no `chr()`. +_PRINTABLE_ASCII = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" + +def _percent_decode(value): + """Decode `%XX` escapes of printable ASCII; leave any other escape as-is.""" + parts = value.split("%") + out = [parts[0]] + for part in parts[1:]: + code = int(part[:2], 16) if len(part) >= 2 and _is_hex(part[:2]) else -1 + if code >= 0x20 and code <= 0x7e: + out.append(_PRINTABLE_ASCII[code - 0x20] + part[2:]) + else: + out.append("%" + part) + return "".join(out) + +def _is_hex(s): + for c in s.elems(): + if c not in "0123456789abcdefABCDEF": + return False + return True + def _parse_uv_lock_hash(hash_str): """Parse a uv.lock `hash` value of the form `:`. diff --git a/tests/pypi/parse_requirements/parse_requirements_tests.bzl b/tests/pypi/parse_requirements/parse_requirements_tests.bzl index f6b9d7e5a5..733abe42d4 100644 --- a/tests/pypi/parse_requirements/parse_requirements_tests.bzl +++ b/tests/pypi/parse_requirements/parse_requirements_tests.bzl @@ -115,6 +115,7 @@ bar==0.0.1 --hash=sha256:deadb00f "uv_lock_foo_virtual": """{"package":[{"name":"foo","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]},{"name":"virtual-pkg","source":{"virtual":true},"version":"0.0.0"}]}""", "uv_lock_foo_with_extras": """{"package":[{"name":"foo","provides-extras":["extra"],"source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/foo-0.0.1-py3-none-any.whl"}]}]}""", "uv_lock_git_vcs": """{"package":[{"name":"foo","source":{"git":"https://github.com/org/foo?rev=deadbeef#deadbeef"},"version":"0.1.0"}]}""", + "uv_lock_git_vcs_subdirectory": """{"package":[{"name":"foo","source":{"git":"https://github.com/org/mono.git?subdirectory=python%2Ffoo&branch=main#0123abcd"},"version":"0.1.0+g0123abcd"}]}""", "uv_lock_rules_python_pkg": """{"package":[{"name":"rules_python","source":{"registry":"https://pypi.org/simple"},"version":"0.0.1","wheels":[{"hash":"sha256:deadbeef","url":"https://files.pythonhosted.org/packages/rules_python-0.0.1-py3-none-any.whl"}]}]}""", } @@ -1321,7 +1322,7 @@ def _test_uv_lock_cross_consistent(env): _tests.append(_test_uv_lock_cross_consistent) def _test_uv_lock_vcs_entry(env): - """Test that VCS entry filenames exclude URL query and fragment components.""" + """Test that a uv.lock git source is built by pip from a direct reference.""" got = parse_requirements( uv_lock = "uv_lock_git_vcs", ) @@ -1335,11 +1336,11 @@ def _test_uv_lock_vcs_entry(env): struct( distribution = "foo", extra_pip_args = [], - requirement_line = "foo==0.1.0", + requirement_line = "foo @ git+https://github.com/org/foo@deadbeef", target_platforms = ["linux_x86_64"], - filename = "foo", + filename = "", digest = "", - url = "https://github.com/org/foo?rev=deadbeef#deadbeef", + url = "", yanked = None, ), ], @@ -1348,6 +1349,34 @@ def _test_uv_lock_vcs_entry(env): _tests.append(_test_uv_lock_vcs_entry) +def _test_uv_lock_vcs_entry_subdirectory(env): + """Test that a uv.lock git source pins the resolved commit and keeps its subdirectory.""" + got = parse_requirements( + uv_lock = "uv_lock_git_vcs_subdirectory", + ) + env.expect.that_collection(got).contains_exactly([ + struct( + name = "foo", + index_url = "", + is_exposed = True, + is_multiple_versions = False, + srcs = [ + struct( + distribution = "foo", + extra_pip_args = [], + requirement_line = "foo @ git+https://github.com/org/mono.git@0123abcd#subdirectory=python/foo", + target_platforms = ["linux_x86_64"], + filename = "", + digest = "", + url = "", + yanked = None, + ), + ], + ), + ]) + +_tests.append(_test_uv_lock_vcs_entry_subdirectory) + def _test_uv_lock_rules_python_pkg_not_skipped(env): """Test that 'rules_python' package is not skipped from uv.lock.""" got = parse_requirements(