Skip to content
Open
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
5 changes: 5 additions & 0 deletions news/4139.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
(pypi) `pip.parse(uv_lock = ...)` now builds packages with a git source with
`pip`, from a `name @ git+<url>@<commit>` 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))
82 changes: 68 additions & 14 deletions python/private/pypi/parse_requirements.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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 [],
Expand Down Expand Up @@ -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 `<repo url>?<query>#<resolved commit>`, where the query carries
`rev`, `tag` or `branch` and optionally `subdirectory`. pip expects
`git+<repo url>@<commit>[#subdirectory=<dir>]`. 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 `<name> @ ` 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 `<algo>:<digest>`.

Expand Down
37 changes: 33 additions & 4 deletions tests/pypi/parse_requirements/parse_requirements_tests.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}]}]}""",
}

Expand Down Expand Up @@ -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",
)
Expand All @@ -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,
),
],
Expand All @@ -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(
Expand Down
Loading