Skip to content

Commit 34d3bb0

Browse files
authored
fix(pypi): build uv.lock git sources with pip (#4209)
`pip.parse(uv_lock = ...)` passes a git-sourced package's raw uv source string (`https://host/repo?rev=<ref>#<commit>`) to the Bazel downloader as a URL, which cannot fetch a git repository (#4139). #4086 fixed the invalid repo name for these sources but kept the URL, so they still fail at fetch time. This makes uv.lock git sources match how a `foo @ git+...` line from a requirements file is already handled: - The src gets an empty `url` and `filename`, so `_whl_repo` takes the existing pip path instead of the downloader. - The requirement line becomes a pip direct reference, `foo @ git+<repo url>@<commit>`, pinned to the commit uv resolved rather than the requested `rev`/`tag`/`branch`. Before, it was `foo==<version>`, which pip cannot satisfy for a VCS-only version such as `0.12.0a0+39be1c6`. - A `subdirectory` query parameter carries over as `#subdirectory=<dir>`. uv percent-encodes it (`subdirectory=python%2Ffoo`) and pip reads the fragment as a literal path, so it is decoded. I checked both against uv 0.9.30 and pip 26.2.1: pip fails on the encoded form with "does not appear to be a Python project" and builds the decoded one. Tests: updated `test_uv_lock_vcs_entry` for the new src shape and added `test_uv_lock_vcs_entry_subdirectory`. `//tests/pypi/parse_requirements/...` and `//tests/pypi/hub_builder/...` pass. End to end, a large monorepo using `pip.parse(uv_lock = ...)` builds its git-sourced `torchdata` dependency against this branch via `--override_module`; we have been carrying the same change as a patch on 2.3.1. Fixes #4139
1 parent 381762a commit 34d3bb0

3 files changed

Lines changed: 106 additions & 18 deletions

File tree

‎news/4139.fixed.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
(pypi) `pip.parse(uv_lock = ...)` now builds packages with a git source with
2+
`pip`, from a `name @ git+<url>@<commit>` direct reference pinned to the commit
3+
`uv` resolved, instead of passing the uv.lock source string to the Bazel
4+
downloader as a URL.
5+
([#4139](https://github.com/bazel-contrib/rules_python/issues/4139))

‎python/private/pypi/parse_requirements.bzl‎

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -226,18 +226,16 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p
226226

227227
git_struct = None
228228
if pkg.get("source", {}).get("git"):
229-
url = pkg["source"]["git"]
230-
231-
# Keep the revision in the URL, but exclude it from the repository filename.
232-
url_path, _, _ = url.partition("?")
233-
url_path, _, _ = url_path.partition("#")
234-
_, _, filename = url_path.rpartition("/")
229+
# A git source is not a downloadable artifact. Leave `url` and
230+
# `filename` empty, the same as a `foo @ git+...` line from a
231+
# requirements file, so that `pip` builds it from the requirement
232+
# line instead of the Bazel downloader trying to fetch the URL.
235233
git_struct = struct(
236-
filename = filename,
237-
url = url,
234+
filename = "",
235+
url = "",
238236
digest = "",
239237
kind = "git",
240-
source = pkg["source"],
238+
requirement = _uv_lock_git_requirement(pkg["source"]["git"]),
241239
)
242240

243241
plat_to_src = {}
@@ -275,11 +273,18 @@ def _parse_uv_lock_json(uv_lock, all_platforms, logger, extra_pip_args = None, p
275273
for key, val in src_to_plats.items():
276274
src = val.src
277275
plats = sorted(val.plats)
278-
requirement_line = "{name}{extras}=={version}".format(
279-
name = name,
280-
extras = extra_str,
281-
version = version,
282-
)
276+
if src.kind == "git":
277+
requirement_line = "{name}{extras} @ {requirement}".format(
278+
name = name,
279+
extras = extra_str,
280+
requirement = src.requirement,
281+
)
282+
else:
283+
requirement_line = "{name}{extras}=={version}".format(
284+
name = name,
285+
extras = extra_str,
286+
version = version,
287+
)
283288
entry["resolved_srcs"].append(struct(
284289
distribution = name,
285290
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
313318
logger.debug(lambda: "Parsed {} packages from uv.lock".format(len(ret)))
314319
return ret
315320

321+
def _uv_lock_git_requirement(git_source):
322+
"""Turn a uv.lock git source into a pip direct reference.
323+
324+
uv records `<repo url>?<query>#<resolved commit>`, where the query carries
325+
`rev`, `tag` or `branch` and optionally `subdirectory`. pip expects
326+
`git+<repo url>@<commit>[#subdirectory=<dir>]`. Pinning to the resolved
327+
commit rather than the requested ref keeps the build reproducible.
328+
329+
Args:
330+
git_source: {type}`str` the `source.git` value from uv.lock.
331+
332+
Returns:
333+
{type}`str` the pip direct reference, without the `<name> @ ` prefix.
334+
"""
335+
head, _, commit = git_source.partition("#")
336+
repo_url, _, query = head.partition("?")
337+
subdirectory = ""
338+
for param in query.split("&"):
339+
key, _, value = param.partition("=")
340+
if key == "subdirectory":
341+
# uv percent-encodes the value (`python%2Ffoo`), but pip reads the
342+
# `#subdirectory=` fragment as a literal path.
343+
subdirectory = _percent_decode(value)
344+
requirement = "git+{}@{}".format(repo_url, commit)
345+
if subdirectory:
346+
requirement += "#subdirectory={}".format(subdirectory)
347+
return requirement
348+
349+
# Printable ASCII, indexed by `code point - 0x20`, as Starlark has no `chr()`.
350+
_PRINTABLE_ASCII = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
351+
352+
def _percent_decode(value):
353+
"""Decode `%XX` escapes of printable ASCII; leave any other escape as-is."""
354+
parts = value.split("%")
355+
out = [parts[0]]
356+
for part in parts[1:]:
357+
code = int(part[:2], 16) if len(part) >= 2 and _is_hex(part[:2]) else -1
358+
if code >= 0x20 and code <= 0x7e:
359+
out.append(_PRINTABLE_ASCII[code - 0x20] + part[2:])
360+
else:
361+
out.append("%" + part)
362+
return "".join(out)
363+
364+
def _is_hex(s):
365+
for c in s.elems():
366+
if c not in "0123456789abcdefABCDEF":
367+
return False
368+
return True
369+
316370
def _parse_uv_lock_hash(hash_str):
317371
"""Parse a uv.lock `hash` value of the form `<algo>:<digest>`.
318372

‎tests/pypi/parse_requirements/parse_requirements_tests.bzl‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ bar==0.0.1 --hash=sha256:deadb00f
115115
"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"}]}""",
116116
"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"}]}]}""",
117117
"uv_lock_git_vcs": """{"package":[{"name":"foo","source":{"git":"https://github.com/org/foo?rev=deadbeef#deadbeef"},"version":"0.1.0"}]}""",
118+
"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"}]}""",
118119
"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"}]}]}""",
119120
}
120121

@@ -1321,7 +1322,7 @@ def _test_uv_lock_cross_consistent(env):
13211322
_tests.append(_test_uv_lock_cross_consistent)
13221323

13231324
def _test_uv_lock_vcs_entry(env):
1324-
"""Test that VCS entry filenames exclude URL query and fragment components."""
1325+
"""Test that a uv.lock git source is built by pip from a direct reference."""
13251326
got = parse_requirements(
13261327
uv_lock = "uv_lock_git_vcs",
13271328
)
@@ -1335,11 +1336,11 @@ def _test_uv_lock_vcs_entry(env):
13351336
struct(
13361337
distribution = "foo",
13371338
extra_pip_args = [],
1338-
requirement_line = "foo==0.1.0",
1339+
requirement_line = "foo @ git+https://github.com/org/foo@deadbeef",
13391340
target_platforms = ["linux_x86_64"],
1340-
filename = "foo",
1341+
filename = "",
13411342
digest = "",
1342-
url = "https://github.com/org/foo?rev=deadbeef#deadbeef",
1343+
url = "",
13431344
yanked = None,
13441345
),
13451346
],
@@ -1348,6 +1349,34 @@ def _test_uv_lock_vcs_entry(env):
13481349

13491350
_tests.append(_test_uv_lock_vcs_entry)
13501351

1352+
def _test_uv_lock_vcs_entry_subdirectory(env):
1353+
"""Test that a uv.lock git source pins the resolved commit and keeps its subdirectory."""
1354+
got = parse_requirements(
1355+
uv_lock = "uv_lock_git_vcs_subdirectory",
1356+
)
1357+
env.expect.that_collection(got).contains_exactly([
1358+
struct(
1359+
name = "foo",
1360+
index_url = "",
1361+
is_exposed = True,
1362+
is_multiple_versions = False,
1363+
srcs = [
1364+
struct(
1365+
distribution = "foo",
1366+
extra_pip_args = [],
1367+
requirement_line = "foo @ git+https://github.com/org/mono.git@0123abcd#subdirectory=python/foo",
1368+
target_platforms = ["linux_x86_64"],
1369+
filename = "",
1370+
digest = "",
1371+
url = "",
1372+
yanked = None,
1373+
),
1374+
],
1375+
),
1376+
])
1377+
1378+
_tests.append(_test_uv_lock_vcs_entry_subdirectory)
1379+
13511380
def _test_uv_lock_rules_python_pkg_not_skipped(env):
13521381
"""Test that 'rules_python' package is not skipped from uv.lock."""
13531382
got = parse_requirements(

0 commit comments

Comments
 (0)