fix(uv): allow environment setting for the update action (#3800)
With this PR we allow users to set extra environment variables using
`.bazelrc`
and to ensure that we can pass UV extra parameters. This should enable
users use
this with private indexes in a `diff_test` usage scenario.
Whilst at it, add integration tests to actually verify that passing
works via
env vars. Whilst at it also fix the Windows support for the `uv` lock
rule.
Fixes #3405
diff --git a/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py b/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py
index e1fc635..06117fb 100755
--- a/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py
+++ b/.agents/skills/buildkite-get-results/scripts/get_buildkite_results.py
@@ -94,24 +94,32 @@
def download_log(job_url, output_path):
- # Construct raw log URL: job_url + "/raw" (Buildkite convention)
- # job_url e.g. https://buildkite.com/org/pipeline/builds/14394#job-id
- # Wait, the job['path'] gives /org/pipeline/builds/14394#job-id
- # We want /org/pipeline/builds/14394/jobs/job-id/raw? No
- # The clean URL for a job is https://buildkite.com/org/pipeline/builds/14394/jobs/job-id
- # And raw log is https://buildkite.com/org/pipeline/builds/14394/jobs/job-id/raw
-
- # We have full_url e.g. https://buildkite.com/bazel/rules-python-python/builds/14394#019c5cf9-e3cf-468f-a7b1-8f9f5ad4b08c
- # We need to transform it.
+ # job_url looks like:
+ # https://buildkite.com/bazel/rules-python-python/builds/15594#019e879b-...
+ # We need to transform it to:
+ # https://buildkite.com/organizations/bazel/pipelines/rules-python-python/builds/15594/jobs/{job_id}/download.txt
if "#" in job_url:
base, job_id = job_url.split("#")
- # Ensure base doesn't end with /
- if base.endswith("/"):
- base = base[:-1]
+ base = base.rstrip("/")
- # Build raw URL
- raw_url = f"{base}/jobs/{job_id}/raw"
+ # Parse the path segments: https://buildkite.com/org/pipeline/builds/N
+ # Rebuild with the /organizations/org/pipelines/pipeline/ format which
+ # supports the /jobs/{id}/download.txt log URL without auth.
+ parts = base.split("/")
+ # parts = ["https:", "", "buildkite.com", "org", "pipeline", "builds", "N"]
+ if len(parts) >= 7 and parts[2] == "buildkite.com":
+ org = parts[3]
+ pipeline = parts[4]
+ build_num = parts[6] if len(parts) >= 7 else ""
+ raw_url = (
+ f"https://buildkite.com/organizations/{org}"
+ f"/pipelines/{pipeline}"
+ f"/builds/{build_num}"
+ f"/jobs/{job_id}/download.txt"
+ )
+ else:
+ raw_url = f"{base}/jobs/{job_id}/download.txt"
else:
print(f"Could not parse job URL for download: {job_url}", file=sys.stderr)
return False
diff --git a/.bazelignore b/.bazelignore
index afd1629..2cf1523 100644
--- a/.bazelignore
+++ b/.bazelignore
@@ -35,3 +35,4 @@
tests/integration/local_toolchains/bazel-local_toolchains
tests/integration/py_cc_toolchain_registered/bazel-py_cc_toolchain_registered
tests/integration/toolchain_target_settings/bazel-module_under_test
+tests/integration/uv_lock/bazel-uv_lock
diff --git a/.bazelrc.deleted_packages b/.bazelrc.deleted_packages
index f4ea852..7256937 100644
--- a/.bazelrc.deleted_packages
+++ b/.bazelrc.deleted_packages
@@ -39,6 +39,7 @@
common --deleted_packages=tests/integration/pip_parse_isolated
common --deleted_packages=tests/integration/py_cc_toolchain_registered
common --deleted_packages=tests/integration/toolchain_target_settings
+common --deleted_packages=tests/integration/uv_lock
common --deleted_packages=tests/modules/another_module
common --deleted_packages=tests/modules/other
common --deleted_packages=tests/modules/other/nspkg_delta
diff --git a/.gitattributes b/.gitattributes
index eae260e..9905cbf 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,3 @@
python/features.bzl export-subst
tools/publish/*.txt linguist-generated=true
+tests/uv/lock/testdata/requirements.txt text eol=lf
diff --git a/.gitignore b/.gitignore
index fb1b17e..efce592 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,7 @@
# MODULE.bazel.lock is ignored for now as per recommendation from upstream.
# See https://github.com/bazelbuild/bazel/issues/20369
MODULE.bazel.lock
+
+# Buildkite logs
+*Windows*.log
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e57e096..33adfa7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -103,6 +103,12 @@
PyPI download and supports PyPI mirror implementations that do not support the root
index functionality. Fixes
([#3769](https://github.com/bazel-contrib/rules_python/pull/3769)).
+* (uv) allow user overwrite the build environment using `--action_env` to allow
+ setting authentication for the index URL.
+ ([#3405](https://github.com/bazel-contrib/rules_python/issues/3405))
+* (uv) fix the execution of the `uv pip compile` in the sandbox. Work
+ towards better supporting `uv` out of the box on our platforms.
+ ([#1975](https://github.com/bazel-contrib/rules_python/issues/1975))
{#v0-0-0-added}
### Added
diff --git a/python/uv/private/lock.bat b/python/uv/private/lock.bat
index 3954c10..5190ddf 100755
--- a/python/uv/private/lock.bat
+++ b/python/uv/private/lock.bat
@@ -1,7 +1,7 @@
-if defined BUILD_WORKSPACE_DIRECTORY (
- set "out=%BUILD_WORKSPACE_DIRECTORY%\{{src_out}}"
-) else (
- exit /b 1
-)
-
-"{{args}}" --output-file "%out%" %*
+if defined BUILD_WORKSPACE_DIRECTORY (
+ set "out=%BUILD_WORKSPACE_DIRECTORY%\{{src_out}}"
+) else (
+ exit /b 1
+)
+
+"{{args}}" --output-file "%out%" %*
diff --git a/python/uv/private/lock.bzl b/python/uv/private/lock.bzl
index b007baf..6f0b80a 100644
--- a/python/uv/private/lock.bzl
+++ b/python/uv/private/lock.bzl
@@ -117,31 +117,96 @@
args.run_shell.add("--no-progress")
args.run_shell.add("--quiet")
+ # Generate a wrapper script that copies the existing output (if any) and
+ # then runs uv. On POSIX, args are forwarded via exec "$@". On Windows,
+ # the full command line is embedded in the .bat file with backslash paths
+ # (CMD doesn't recognize forward slashes in executable paths).
+ if ctx.attr.is_windows:
+ ext = ".bat"
+ lines = ["@echo off"]
+ else:
+ ext = ".sh"
+ lines = ["#!/usr/bin/env bash", "set -euo pipefail"]
+
+ python_path = getattr(python, "path", python)
+
if ctx.files.existing_output:
- command = '{python} -c {python_cmd} && "$@"'.format(
- python = getattr(python, "path", python),
- python_cmd = shell.quote(
- "from shutil import copy; copy(\"{src}\", \"{dst}\")".format(
+ python_cmd = "from shutil import copy; copy(\"{src}\", \"{dst}\")".format(
+ src = ctx.files.existing_output[0].path,
+ dst = output.path,
+ )
+ if ctx.attr.is_windows:
+ # In batch files, use "" to escape internal double quotes.
+ lines.append(
+ "\"{py}\" -c \"from shutil import copy; copy(\"\"{src}\"\", \"\"{dst}\"\")\"".format(
+ py = python_path,
src = ctx.files.existing_output[0].path,
dst = output.path,
),
+ )
+ else:
+ lines.append("{py} -c '{cmd}'".format(
+ py = python_path,
+ cmd = python_cmd,
+ ))
+
+ if ctx.attr.is_windows:
+ # Build the command line with backslash paths for CMD.
+ # args.run_info has most args; add the output/progress/quiet
+ # args that were only added directly to args.run_shell.
+ def _quote(arg):
+ if hasattr(arg, "path"):
+ arg = arg.path.replace("/", "\\")
+ else:
+ arg = str(arg)
+ return '"' + arg.replace('"', '""') + '"'
+
+ bat_args = args.run_info + [
+ "--output-file",
+ output,
+ "--no-progress",
+ "--quiet",
+ ]
+ lines.append(" ".join([_quote(a) for a in bat_args]))
+
+ # Normalize CRLF line endings in the output on Windows.
+ lines.append(
+ "\"{py}\" -c \"import pathlib;p=pathlib.Path(r\"\"{dst}\"\");p.write_bytes(p.read_bytes().replace(b'\\r\\n', b'\\n'))\"".format(
+ py = python_path,
+ dst = output.path,
),
)
else:
- command = '"$@"'
+ lines.append('exec "$@"')
+
+ script = ctx.actions.declare_file(ctx.label.name + "_lock" + ext)
+ if ctx.attr.is_windows:
+ content = "\r\n".join(lines) + "\r\n"
+ else:
+ content = "\n".join(lines) + "\n"
+ ctx.actions.write(output = script, content = content, is_executable = True)
srcs = srcs + ctx.files.build_constraints + ctx.files.constraints
- ctx.actions.run_shell(
- command = command,
+ ctx.actions.run(
+ executable = script,
inputs = srcs + ctx.files.existing_output,
mnemonic = "PyRequirementsLockUv",
outputs = [output],
- arguments = [args.run_shell],
+ # On Windows, the command line is embedded directly in the .bat
+ # script (with backslash paths). On POSIX, args are forwarded via
+ # exec "$@" in the .sh script.
+ arguments = [args.run_shell] if not ctx.attr.is_windows else [],
tools = [
uv,
python_files,
+ script,
],
+ # User reported being unable to add `--action_env` and get it to work.
+ # Without this flag.
+ #
+ # Ref: https://app.slack.com/client/TA4K1KQ87/CA306CEV6
+ use_default_shell_env = True,
progress_message = "Creating a requirements.txt with uv: %{label}",
env = ctx.attr.env,
)
@@ -205,6 +270,7 @@
doc = "Public, see the docs in the macro.",
default = True,
),
+ "is_windows": attr.bool(mandatory = True),
"output": attr.string(
doc = "Public, see the docs in the macro.",
mandatory = True,
@@ -241,7 +307,7 @@
def _lock_run_impl(ctx):
if ctx.attr.is_windows:
path_sep = "\\"
- ext = ".exe"
+ ext = ".bat"
else:
path_sep = "/"
ext = ""
@@ -250,7 +316,12 @@
if hasattr(arg, "short_path"):
arg = arg.short_path
- return shell.quote(arg.replace("/", path_sep))
+ arg = arg.replace("/", path_sep)
+ if ctx.attr.is_windows:
+ # On Windows, CMD uses double quotes for quoting, and internal
+ # double quotes are escaped by doubling them.
+ return '"' + arg.replace('"', '""') + '"'
+ return shell.quote(arg)
info = ctx.attr.lock[_RunLockInfo]
executable = ctx.actions.declare_file(ctx.label.name + ext)
@@ -438,6 +509,10 @@
env = env,
existing_output = maybe_out,
generate_hashes = generate_hashes,
+ is_windows = select({
+ "@platforms//os:windows": True,
+ "//conditions:default": False,
+ }),
python_version = python_version,
srcs = srcs,
strip_extras = strip_extras,
diff --git a/python/uv/private/lock_copier.py b/python/uv/private/lock_copier.py
index bcc64c1..8756fc4 100644
--- a/python/uv/private/lock_copier.py
+++ b/python/uv/private/lock_copier.py
@@ -55,7 +55,7 @@
"This must be either run as `bazel test` via a `native_test` or similar or via `bazel run`"
)
- print(f"cp <bazel-sandbox>/{src} <workspace>/{dst}")
+ print(f"cp <bazel-sandbox>/{src.as_posix()} <workspace>/{dst}")
build_workspace = Path(environ["BUILD_WORKSPACE_DIRECTORY"])
dst_real_path = build_workspace / dst
diff --git a/tests/integration/BUILD.bazel b/tests/integration/BUILD.bazel
index 9295cbb..a6027fc 100644
--- a/tests/integration/BUILD.bazel
+++ b/tests/integration/BUILD.bazel
@@ -13,7 +13,9 @@
# limitations under the License.
load("@rules_bazel_integration_test//bazel_integration_test:defs.bzl", "default_test_runner")
+load("//python:py_binary.bzl", "py_binary")
load("//python:py_library.bzl", "py_library")
+load("//tests/support:support.bzl", "NOT_WINDOWS")
load(":integration_test.bzl", "rules_python_integration_test")
licenses(["notice"])
@@ -48,6 +50,7 @@
tests = [
"bzlmod_lockfile_test_bazel_9.1.0",
"local_toolchains_test_bazel_self",
+ "uv_lock_test_bazel_self",
],
)
@@ -111,8 +114,41 @@
py_main = "toolchain_target_settings_test.py",
)
+rules_python_integration_test(
+ name = "uv_lock_test",
+ py_deps = [
+ "@pypiserver//pypiserver",
+ ":uv_lock_pypi_server_lib",
+ ],
+ py_main = "uv_lock_test.py",
+)
+
py_library(
name = "runner_lib",
srcs = ["runner.py"],
imports = ["../../"],
)
+
+py_library(
+ name = "uv_lock_pypi_server_lib",
+ srcs = ["uv_lock_pypi_server.py"],
+ imports = ["../../"],
+ # currently windows is not working due to
+ # https://github.com/pypiserver/pypiserver/blob/main/pypiserver/config.py#L123
+ #
+ # class DEFAULTS:
+ # ....
+ # PACKAGE_DIRECTORIES = [pathlib.Path("~/packages").expanduser().resolve()]
+ # ....
+ #
+ # which is loaded through `__init__.py` even though it is not used and breaks because
+ # in a Windows sandbox one cannot resolve the home directory.
+ target_compatible_with = NOT_WINDOWS,
+ deps = ["@pypiserver//pypiserver"],
+)
+
+py_binary(
+ name = "uv_lock_pypi_server",
+ srcs = ["uv_lock_pypi_server.py"],
+ deps = [":uv_lock_pypi_server_lib"],
+)
diff --git a/tests/integration/integration_test.bzl b/tests/integration/integration_test.bzl
index 771976d..f3d5cb6 100644
--- a/tests/integration/integration_test.bzl
+++ b/tests/integration/integration_test.bzl
@@ -21,14 +21,14 @@
)
load("//python:py_test.bzl", "py_test")
-def _test_runner(*, name, bazel_version, py_main, bzlmod):
+def _test_runner(*, name, bazel_version, py_main, bzlmod, py_deps):
if py_main:
test_runner = "{}_bazel_{}_py_runner".format(name, bazel_version)
py_test(
name = test_runner,
srcs = [py_main],
main = py_main,
- deps = [":runner_lib"],
+ deps = [":runner_lib"] + py_deps,
# Hide from ... patterns; should only be run as part
# of the bazel integration test
tags = ["manual"],
@@ -46,6 +46,7 @@
bzlmod = True,
tags = None,
py_main = None,
+ py_deps = None,
bazel_versions = None,
**kwargs):
"""Runs a bazel-in-bazel integration test.
@@ -60,6 +61,7 @@
py_main: Optional `.py` file to run tests using. When specified, a
python based test runner is used, and this source file is the main
entry point and responsible for executing tests.
+ py_deps: Optional test runner deps to use for setup.
bazel_versions: `list[str] | None`, the bazel versions to test. I
not specified, defaults to all configured bazel versions.
**kwargs: Passed to the upstream `bazel_integration_tests` rule.
@@ -91,6 +93,7 @@
name = name,
bazel_version = bazel_version,
py_main = py_main,
+ py_deps = py_deps or [],
bzlmod = bzlmod,
)
bazel_integration_test(
diff --git a/tests/integration/uv_lock/.bazelrc b/tests/integration/uv_lock/.bazelrc
new file mode 100644
index 0000000..511f8a2
--- /dev/null
+++ b/tests/integration/uv_lock/.bazelrc
@@ -0,0 +1,5 @@
+build --enable_runfiles
+common --experimental_isolated_extension_usages
+common --action_env=UV_EXTRA_INDEX_URL
+
+try-import %workspace%/user.bazelrc
diff --git a/tests/integration/uv_lock/.bazelversion b/tests/integration/uv_lock/.bazelversion
new file mode 100644
index 0000000..47da986
--- /dev/null
+++ b/tests/integration/uv_lock/.bazelversion
@@ -0,0 +1 @@
+9.1.0
diff --git a/tests/integration/uv_lock/BUILD.bazel b/tests/integration/uv_lock/BUILD.bazel
new file mode 100644
index 0000000..da6482b
--- /dev/null
+++ b/tests/integration/uv_lock/BUILD.bazel
@@ -0,0 +1,26 @@
+load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
+load("@rules_python//python/uv:lock.bzl", "lock")
+load(":uv_runner.bzl", "uv_runner")
+
+lock(
+ name = "requirements",
+ srcs = ["requirements.in"],
+ out = "requirements.txt",
+ tags = ["no-remote-exec"],
+)
+
+uv_runner(
+ name = "uv",
+ is_windows = select({
+ "@platforms//os:windows": True,
+ "//conditions:default": False,
+ }),
+ tags = ["manual"],
+)
+
+diff_test(
+ name = "requirements_diff_test",
+ timeout = "short",
+ file1 = ":requirements",
+ file2 = ":requirements.txt",
+)
diff --git a/tests/integration/uv_lock/MODULE.bazel b/tests/integration/uv_lock/MODULE.bazel
new file mode 100644
index 0000000..0e0978e
--- /dev/null
+++ b/tests/integration/uv_lock/MODULE.bazel
@@ -0,0 +1,18 @@
+module(name = "uv_lock")
+
+bazel_dep(name = "bazel_skylib", version = "1.7.1")
+bazel_dep(name = "platforms", version = "0.0.10")
+bazel_dep(name = "rules_python")
+local_path_override(
+ module_name = "rules_python",
+ path = "../../..",
+)
+
+python = use_extension("@rules_python//python/extensions:python.bzl", "python")
+python.toolchain(python_version = "3.13")
+
+uv = use_extension("@rules_python//python/uv:uv.bzl", "uv")
+uv.configure(version = "0.11.2")
+use_repo(uv, "uv")
+
+register_toolchains("@uv//:all")
diff --git a/tests/integration/uv_lock/README.md b/tests/integration/uv_lock/README.md
new file mode 100644
index 0000000..cce1428
--- /dev/null
+++ b/tests/integration/uv_lock/README.md
@@ -0,0 +1,64 @@
+# uv_lock integration test workspace
+
+This directory is a self-contained Bazel workspace used by the
+`//tests/integration:uv_lock_test` integration test.
+
+It demonstrates how to use the `lock()` macro from `@rules_python//python/uv`
+to pin requirements with `uv pip compile`.
+
+## Targets
+
+| Target | Description |
+|--------|-------------|
+| `//:requirements` | Build action that produces the locked requirements file |
+| `//:requirements.update` | Update the in-source `requirements.txt` via `bazel run` |
+| `//:requirements.run` | Run `uv pip compile` with extra command-line args |
+| `//:requirements_diff_test` | Diff test comparing the lock output to the in-source file |
+| `//:uv` | The `uv` binary from the registered toolchain |
+
+## Workflow for debugging
+
+If you want to debug and play around, you can start the server and then run the uv lock command
+manually.
+
+### Start the local PyPI server
+
+In a separate terminal, start the pypiserver that serves the `my-local-pkg` test wheel:
+
+```shell
+bazel run //tests/integration:uv_lock_pypi_server [-- --no-auth]
+```
+
+The server prints the URL to use (with and without authentication) and the
+SHA256 of the wheel. Pass `--no-auth` to allow anonymous access.
+
+### Lock the requirements
+
+With the server running, lock the requirements from this directory:
+
+```shell
+cd tests/integration/uv_lock
+bazel run //:requirements.update \
+ --action_env=UV_EXTRA_INDEX_URL="<auth-url>" \
+ --action_env=UV_CREDENTIALS_DIR=<creds-dir>
+```
+
+The `<auth-url>` and `<creds-dir>` values are printed by the pypi-server.
+
+### Verify the lock output matches the in-source file
+
+```shell
+bazel test //:requirements_diff_test
+```
+
+## bazel-in-bazel Testing
+
+When iterating on changes to `lock.bzl`, the integration test can be run
+directly from rules_python:
+
+```shell
+bazel test //tests/integration:uv_lock_test_bazel_self \
+ --config=fast-tests \
+ --test_output=streamed \
+ --test_filter=<test_name>
+```
diff --git a/tests/integration/uv_lock/WORKSPACE b/tests/integration/uv_lock/WORKSPACE
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/integration/uv_lock/WORKSPACE
diff --git a/tests/integration/uv_lock/requirements.in b/tests/integration/uv_lock/requirements.in
new file mode 100644
index 0000000..fba55a7
--- /dev/null
+++ b/tests/integration/uv_lock/requirements.in
@@ -0,0 +1 @@
+my-local-pkg==1.0.0
diff --git a/tests/integration/uv_lock/requirements.txt b/tests/integration/uv_lock/requirements.txt
new file mode 100644
index 0000000..52f5c75
--- /dev/null
+++ b/tests/integration/uv_lock/requirements.txt
@@ -0,0 +1,5 @@
+# This file was autogenerated by uv via the following command:
+# bazel run //:requirements.update
+my-local-pkg==1.0.0 \
+ --hash=sha256:be24d5183a182e8da4465ae1b7e60324864d1a72866ec9aefa6aaf80a4529eb1
+ # via -r requirements.in
diff --git a/tests/integration/uv_lock/uv_runner.bzl b/tests/integration/uv_lock/uv_runner.bzl
new file mode 100644
index 0000000..3faa3c6
--- /dev/null
+++ b/tests/integration/uv_lock/uv_runner.bzl
@@ -0,0 +1,30 @@
+"""A rule exposing the uv binary from the registered toolchain as an executable target.
+
+This allows running ``bazel run //:uv -- <args>`` from the test workspace.
+"""
+
+def _uv_runner_impl(ctx):
+ toolchain_info = ctx.toolchains["@rules_python//python/uv:uv_toolchain_type"]
+ original_uv_executable = toolchain_info.uv_toolchain_info.uv[DefaultInfo].files_to_run.executable
+
+ ext = ""
+ if ctx.attr.is_windows:
+ ext = ".exe"
+
+ uv_exe = ctx.actions.declare_file("uv" + ext)
+ ctx.actions.symlink(output = uv_exe, target_file = original_uv_executable)
+
+ return DefaultInfo(
+ files = depset([uv_exe]),
+ executable = uv_exe,
+ runfiles = toolchain_info.default_info.default_runfiles,
+ )
+
+uv_runner = rule(
+ implementation = _uv_runner_impl,
+ executable = True,
+ attrs = {
+ "is_windows": attr.bool(mandatory = True),
+ },
+ toolchains = ["@rules_python//python/uv:uv_toolchain_type"],
+)
diff --git a/tests/integration/uv_lock_pypi_server.py b/tests/integration/uv_lock_pypi_server.py
new file mode 100644
index 0000000..0d940e7
--- /dev/null
+++ b/tests/integration/uv_lock_pypi_server.py
@@ -0,0 +1,148 @@
+import argparse
+import hashlib
+import io
+import os
+import sys
+import uuid
+import zipfile
+from wsgiref.simple_server import make_server
+
+from pypiserver import app_from_config, setup_routes_from_config
+from pypiserver.config import Config
+
+
+def _create_wheel_bytes(name, version):
+ pkg_name_normalized = name.replace("-", "_")
+ wheel_name = "{}-{}-py3-none-any.whl".format(pkg_name_normalized, version)
+ dist_info = "{}-{}.dist-info".format(pkg_name_normalized, version)
+
+ metadata = (
+ "Metadata-Version: 2.1\n"
+ "Name: {name}\n"
+ "Version: {version}\n"
+ "Summary: A test package\n"
+ ).format(name=pkg_name_normalized, version=version)
+
+ wheel_file = (
+ "Wheel-Version: 1.0\n"
+ "Generator: test\n"
+ "Root-Is-Purelib: true\n"
+ "Tag: py3-none-any\n"
+ )
+
+ record_entries = [
+ "{}/__init__.py,".format(pkg_name_normalized),
+ "{}/METADATA,".format(dist_info),
+ "{}/WHEEL,".format(dist_info),
+ "{}/RECORD,".format(dist_info),
+ ]
+
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+ zf.writestr("{}/__init__.py".format(pkg_name_normalized), "# empty\n")
+ zf.writestr("{}/METADATA".format(dist_info), metadata)
+ zf.writestr("{}/WHEEL".format(dist_info), wheel_file)
+ zf.writestr("{}/RECORD".format(dist_info), "\n".join(record_entries))
+
+ wheel_data = buf.getvalue()
+ sha256 = hashlib.sha256(wheel_data).hexdigest()
+ return wheel_data, sha256, wheel_name
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Standalone pypiserver for uv_lock integration tests"
+ )
+ parser.add_argument(
+ "--packages-dir",
+ type=str,
+ default=None,
+ help="Directory for the test wheels (default: $TEST_TMPDIR/pypi-server-packages)",
+ )
+ parser.add_argument(
+ "--no-auth",
+ action="store_true",
+ default=False,
+ help="Disable authentication (allows anonymous access)",
+ )
+ parser.add_argument(
+ "--port",
+ type=int,
+ default=0,
+ help="Port to listen on (0 = find free port)",
+ )
+ parser.add_argument(
+ "--host",
+ default="localhost",
+ help="Host to bind to",
+ )
+ args = parser.parse_args()
+
+ if args.packages_dir is None:
+ sandbox_root = (
+ os.environ.get("TEST_TMPDIR") or os.environ.get("TMPDIR") or "/tmp"
+ )
+ args.packages_dir = os.path.join(sandbox_root, "pypi-server-packages")
+ packages_dir = args.packages_dir
+ os.makedirs(packages_dir, exist_ok=True)
+
+ wheel_data, sha256, wheel_name = _create_wheel_bytes("my-local-pkg", "1.0.0")
+ wheel_path = os.path.join(packages_dir, wheel_name)
+ with open(wheel_path, "wb") as f:
+ f.write(wheel_data)
+
+ print("Wheel: {}".format(wheel_path), flush=True)
+ print("SHA256: {}".format(sha256), flush=True)
+
+ password = uuid.uuid4().hex
+ username = "testuser"
+
+ if args.no_auth:
+ authenticate = []
+ else:
+ authenticate = ["download", "list", "update"]
+
+ config = Config.default_with_overrides(
+ roots=[packages_dir],
+ port=args.port,
+ host=args.host,
+ authenticate=authenticate,
+ password_file=None,
+ auther=lambda u, p: u == username and p == password,
+ disable_fallback=True,
+ fallback_url="",
+ server_method="wsgiref",
+ verbosity=0,
+ log_stream=None,
+ )
+ app = app_from_config(config)
+ app = setup_routes_from_config(app, config)
+
+ server = make_server(args.host, args.port, app)
+ port = server.server_address[1]
+
+ base_url = "http://{}:{}".format(args.host, port)
+ auth_url = "http://{}:{}@{}:{}".format(username, password, args.host, port)
+
+ print("\npypiserver listening on:\n", flush=True)
+ print(" URL (no auth): {}".format(base_url), flush=True)
+ print(" URL (auth): {}".format(auth_url), flush=True)
+ print("\nRequired dependency in requirements.in:", flush=True)
+ print(" my-local-pkg==1.0.0", flush=True)
+ print("\nTo use from the uv_lock test workspace, run:", flush=True)
+ print(" cd tests/integration/uv_lock", flush=True)
+ print(" bazel run //:requirements.update \\", flush=True)
+ print(' --action_env=UV_EXTRA_INDEX_URL="{}" \\'.format(auth_url), flush=True)
+ print(" --action_env=UV_CREDENTIALS_DIR=<creds-dir>", flush=True)
+ print("\nPress Ctrl+C to stop the server.\n", flush=True)
+
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\nShutting down...", flush=True)
+ server.shutdown()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/integration/uv_lock_test.py b/tests/integration/uv_lock_test.py
new file mode 100644
index 0000000..8efd3cb
--- /dev/null
+++ b/tests/integration/uv_lock_test.py
@@ -0,0 +1,288 @@
+import base64
+import hashlib
+import os
+import re
+import threading
+import time
+import unittest
+import uuid
+from pathlib import Path
+from urllib.error import URLError
+from urllib.request import Request, urlopen
+from wsgiref.simple_server import make_server
+
+from pypiserver import app_from_config, setup_routes_from_config
+from pypiserver.config import Config
+
+from tests.integration import runner
+from tests.integration.uv_lock_pypi_server import _create_wheel_bytes
+
+
+def _make_server_on_free_port(app):
+ server = make_server("localhost", 0, app)
+ port = server.server_address[1]
+ return server, port
+
+
+class UvLockIntegrationTest(runner.TestCase):
+ def setUp(self):
+ super().setUp()
+
+ self.username = "testuser"
+ self.password = uuid.uuid4().hex
+
+ self.dir = Path(os.environ["TEST_TMPDIR"])
+ self.docroot = self.dir / "simple"
+ self.docroot.mkdir(exist_ok=True)
+
+ self.wheel_data, self.wheel_sha256, wheel_name = _create_wheel_bytes(
+ "my-local-pkg",
+ "1.0.0",
+ )
+
+ packages_dir = self.docroot / "packages"
+ packages_dir.mkdir(exist_ok=True)
+ self.wheel_path = packages_dir / wheel_name
+ self.wheel_path.write_bytes(self.wheel_data)
+
+ config = Config.default_with_overrides(
+ roots=[packages_dir],
+ port=0,
+ host="localhost",
+ authenticate=["download", "list", "update"],
+ password_file=None,
+ auther=lambda u, p: u == self.username and p == self.password,
+ disable_fallback=True,
+ fallback_url="",
+ server_method="wsgiref",
+ verbosity=0,
+ log_stream=None,
+ )
+ app = app_from_config(config)
+ app = setup_routes_from_config(app, config)
+
+ self._server, self.port = _make_server_on_free_port(app)
+ self.server_url = "http://localhost:{port}".format(port=self.port)
+ self.auth_url = "http://{user}:{passwd}@localhost:{port}".format(
+ user=self.username,
+ passwd=self.password,
+ port=self.port,
+ )
+
+ self._thread = threading.Thread(target=self._server.serve_forever)
+ self._thread.daemon = True
+ self._thread.start()
+
+ interval = 0.1
+ wait_seconds = 40
+ for _ in range(int(wait_seconds / interval)):
+ try:
+ req = Request(self.server_url)
+ with urlopen(req, timeout=1) as response:
+ if response.status in (200, 401):
+ break
+ except (URLError, OSError):
+ pass
+ time.sleep(interval)
+ else:
+ raise RuntimeError(
+ "Could not start the server, waited for {}s".format(wait_seconds)
+ )
+
+ # Set a default value for UV_EXTRA_INDEX_URL in the bazel env so that
+ # the workspace .bazelrc `--action_env=UV_EXTRA_INDEX_URL` doesn't
+ # fail on Windows when the variable is unset in the client env.
+ self.bazel_env.setdefault("UV_EXTRA_INDEX_URL", "")
+
+ # Use a sandbox-local credential store so credentials don't leak
+ # to the host system.
+ self.creds_dir = self.repo_root / ".uv-creds"
+ self.creds_dir.mkdir(parents=True, exist_ok=True)
+ self.bazel_env["UV_CREDENTIALS_DIR"] = str(self.creds_dir)
+
+ # Log in to uv's credential store so `uv auth helper` can later
+ # serve the credentials to Bazel or uv itself.
+ self.run_bazel(
+ "run",
+ "//:uv",
+ "--",
+ "auth",
+ "login",
+ f"--username={self.username}",
+ f"--password={self.password}",
+ self.server_url,
+ )
+
+ def tearDown(self):
+ # Clear credentials from uv's credential store to ensure we are not
+ # logged into the service after the test.
+ self.run_bazel(
+ "run",
+ "//:uv",
+ "--",
+ "auth",
+ "logout",
+ self.server_url,
+ check=False,
+ )
+ self._server.shutdown()
+
+ def _assert_server_requires_auth(self):
+ req = Request(self.server_url + "/my-local-pkg/")
+ try:
+ urlopen(req, timeout=5)
+ self.fail("Expected 401 without auth")
+ except URLError:
+ pass
+
+ def _auth_header(self):
+ return "Basic " + base64.b64encode(
+ "{user}:{passwd}".format(
+ user=self.username,
+ passwd=self.password,
+ ).encode("utf-8")
+ ).decode("utf-8")
+
+ def _assert_simple_api_sha256(self):
+ auth_header = self._auth_header()
+ req = Request(self.server_url + "/simple/my-local-pkg/")
+ req.add_header("Authorization", auth_header)
+ resp = urlopen(req, timeout=5)
+ html = resp.read().decode("utf-8")
+
+ match = re.search(r"#sha256=([a-f0-9]+)", html)
+ self.assertIsNotNone(match, "No sha256 found in simple API: {}".format(html))
+ pypiserver_sha256 = match.group(1)
+ disk_sha256 = hashlib.sha256(self.wheel_path.read_bytes()).hexdigest()
+ self.assertEqual(
+ pypiserver_sha256,
+ disk_sha256,
+ "pypiserver hash {} != disk hash {}".format(pypiserver_sha256, disk_sha256),
+ )
+
+ def _creds_auth_args(self):
+ return [
+ "--strategy=PyRequirementsLockUv=local",
+ "--action_env={key}={value}".format(
+ key="UV_CREDENTIALS_DIR",
+ value=str(self.creds_dir),
+ ),
+ "--action_env={key}={value}".format(
+ key="UV_EXTRA_INDEX_URL",
+ value=self.server_url,
+ ),
+ ]
+
+ def _assert_lock_file(self, result):
+ self.assertEqual(
+ result.exit_code,
+ 0,
+ "Lock update failed:\n{}".format(result.describe()),
+ )
+ lock_file = self.repo_root / "requirements.txt"
+ self.assertTrue(lock_file.exists(), "Lock file was not created")
+ contents = lock_file.read_text()
+ self.assertIn("my-local-pkg", contents)
+ self.assertIn("--hash=sha256:", contents)
+
+ def test_lock_update_with_custom_index(self):
+ self._assert_server_requires_auth()
+ self._assert_simple_api_sha256()
+
+ result = self.run_bazel(
+ "run",
+ "--action_env={key}={value}".format(
+ key="UV_EXTRA_INDEX_URL",
+ value=self.auth_url,
+ ),
+ "//:requirements.update",
+ )
+ self._assert_lock_file(result)
+
+ def test_update_with_credential_helper(self):
+ """Use a credential helper for authentication."""
+ self._assert_server_requires_auth()
+ result = self.run_bazel(
+ "run",
+ *self._creds_auth_args(),
+ "//:requirements.update",
+ )
+ self._assert_lock_file(result)
+
+ def test_update_with_uv_auth_helper(self):
+ """Use the uv auth helper for authentication."""
+ self._assert_server_requires_auth()
+ result = self.run_bazel(
+ "run",
+ *self._creds_auth_args(),
+ "//:requirements.update",
+ )
+ self._assert_lock_file(result)
+
+ def test_diff_test_with_requirements(self):
+ """Verify that ``diff_test`` can verify the generated lock file."""
+ self._assert_server_requires_auth()
+
+ # First generate the lock file
+ result = self.run_bazel(
+ "run",
+ *self._creds_auth_args(),
+ "//:requirements.update",
+ )
+ self._assert_lock_file(result)
+
+ # Copy the generated lock file to the expected location. The inner
+ # Bazel workspace is writable because it is a temporary copy created
+ # by the integration test framework.
+ generated = self.repo_root / "requirements.txt"
+ expected = self.repo_root / "requirements_expected.txt"
+ expected.write_text(generated.read_text())
+
+ # Run the diff_test: it builds the lock action, then compares the
+ # output to our expected file.
+ result = self.run_bazel(
+ "test",
+ *self._creds_auth_args(),
+ "//:requirements_diff_test",
+ )
+ self.assertEqual(
+ result.exit_code,
+ 0,
+ "diff_test failed:\n{}".format(result.describe()),
+ )
+
+ def test_no_existing_requirements(self):
+ """Verify that ``bazel run`` and ``diff_test`` work when
+ ``requirements.txt`` does not yet exist."""
+ self._assert_server_requires_auth()
+
+ # Remove the existing lock file to simulate a fresh checkout
+ existing = self.repo_root / "requirements.txt"
+ existing.unlink()
+ self.assertFalse(existing.exists())
+
+ # Run ``requirements.update`` to generate the lock from scratch. The
+ # underlying lock rule will have no ``existing_output`` to copy, but
+ # ``uv pip compile`` should still produce the output.
+ result = self.run_bazel(
+ "run",
+ *self._creds_auth_args(),
+ "//:requirements.update",
+ )
+ self._assert_lock_file(result)
+
+ # diff_test should pass now that ``requirements.txt`` exists again
+ result = self.run_bazel(
+ "test",
+ *self._creds_auth_args(),
+ "//:requirements_diff_test",
+ )
+ self.assertEqual(
+ result.exit_code,
+ 0,
+ "diff_test failed:\n{}".format(result.describe()),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/uv/lock/lock_run_test.py b/tests/uv/lock/lock_run_test.py
index f64cbdc..e250816 100644
--- a/tests/uv/lock/lock_run_test.py
+++ b/tests/uv/lock/lock_run_test.py
@@ -1,3 +1,4 @@
+import os
import subprocess
import tempfile
import unittest
@@ -9,15 +10,59 @@
def _relative_rpath(path: str) -> Path:
- p = (Path("_main") / "tests" / "uv" / "lock" / path).as_posix()
- rpath = rfiles.Rlocation(p)
- if not rpath:
- raise ValueError(f"Could not find file: {p}")
+ """Find file in runfiles, handling Windows .bat/.exe wrappers."""
+ # On Windows, try executable extensions first to avoid matching symlink
+ # entries in the runfiles manifest that point to non-executable files
+ # (e.g. a Python source file instead of the .exe launcher).
+ exts = (".exe", ".bat", "") if os.name == "nt" else ("", ".exe", ".bat")
+ for ext in exts:
+ p = (Path("_main") / "tests" / "uv" / "lock" / (path + ext)).as_posix()
+ rpath = rfiles.Rlocation(p)
+ if rpath:
+ rp = Path(rpath)
+ if rp.exists():
+ return rp
- return Path(rpath)
+ # Fallback: look in runfiles directory directly (handles .bat wrappers on
+ # Windows where Rlocation may return a runfiles link that doesn't exist)
+ runfiles_dir = os.environ.get("RUNFILES_DIR")
+ if runfiles_dir:
+ exts = (".exe", ".bat", "") if os.name == "nt" else ("", ".bat", ".exe")
+ for ext in exts:
+ rp = Path(runfiles_dir, "_main", "tests", "uv", "lock", path + ext)
+ if rp.exists():
+ return rp
+
+ raise ValueError(f"Could not find file in runfiles: {path}")
+
+
+def _run_binary(path: Path, **kwargs):
+ """Run a binary, handling Windows .bat files."""
+ if os.name == "nt":
+ return subprocess.run(
+ ["cmd.exe", "/c", str(path)],
+ **kwargs,
+ )
+ return subprocess.run(path, **kwargs)
class LockTests(unittest.TestCase):
+ def _subprocess_env(self, workspace_dir: Path) -> dict[str, str]:
+ env = {
+ "BUILD_WORKSPACE_DIRECTORY": str(workspace_dir),
+ }
+ # Inherit specific env vars needed for finding runfiles on Windows
+ for key in (
+ "PATH",
+ "RUNFILES_DIR",
+ "RUNFILES_MANIFEST_FILE",
+ "SYSTEMROOT",
+ "PATHEXT",
+ ):
+ if key in os.environ:
+ env[key] = os.environ[key]
+ return env
+
def test_requirements_updating_for_the_first_time(self):
# Given
copier_path = _relative_rpath("requirements_new_file.update")
@@ -30,19 +75,18 @@
self.assertFalse(
want_path.exists(), "The path should not exist after the test"
)
- output = subprocess.run(
+ output = _run_binary(
copier_path,
capture_output=True,
- env={
- "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}",
- },
+ env=self._subprocess_env(workspace_dir),
)
# Then
self.assertEqual(0, output.returncode, output.stderr)
+ stdout = output.stdout.decode("utf-8").replace("\\", "/")
self.assertIn(
"cp <bazel-sandbox>/tests/uv/lock/requirements_new_file",
- output.stdout.decode("utf-8"),
+ stdout,
)
self.assertTrue(want_path.exists(), "The path should exist after the test")
self.assertNotEqual(want_path.read_text(), "")
@@ -69,19 +113,18 @@
want_text + "\n\n"
) # Write something else to see that it is restored
- output = subprocess.run(
+ output = _run_binary(
copier_path,
capture_output=True,
- env={
- "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}",
- },
+ env=self._subprocess_env(workspace_dir),
)
# Then
self.assertEqual(0, output.returncode)
+ stdout = output.stdout.decode("utf-8").replace("\\", "/")
self.assertIn(
"cp <bazel-sandbox>/tests/uv/lock/requirements",
- output.stdout.decode("utf-8"),
+ stdout,
)
self.assertEqual(want_path.read_text(), want_text)
@@ -100,12 +143,10 @@
self.assertFalse(
want_path.exists(), "The path should not exist after the test"
)
- output = subprocess.run(
+ output = _run_binary(
copier_path,
capture_output=True,
- env={
- "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}",
- },
+ env=self._subprocess_env(workspace_dir),
)
# Then
@@ -141,12 +182,10 @@
want_text + "\n\n"
) # Write something else to see that it is restored
- output = subprocess.run(
+ output = _run_binary(
copier_path,
capture_output=True,
- env={
- "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}",
- },
+ env=self._subprocess_env(workspace_dir),
)
# Then
diff --git a/tests/uv/lock/lock_tests.bzl b/tests/uv/lock/lock_tests.bzl
index 1eb5b1d..bcaed95 100644
--- a/tests/uv/lock/lock_tests.bzl
+++ b/tests/uv/lock/lock_tests.bzl
@@ -14,7 +14,7 @@
""
-load("@bazel_skylib//rules:native_binary.bzl", "native_test")
+load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
load("//python/uv:lock.bzl", "lock")
load("//tests/support:py_reconfig.bzl", "py_reconfig_test")
@@ -77,23 +77,14 @@
# `--index-url`.
"no-remote-exec",
],
- # FIXME @aignas 2025-03-19: It seems that currently:
- # 1. The Windows runners are not compatible with the `uv` Windows binaries.
- # 2. The Python launcher is having trouble launching scripts from within the Python test.
- target_compatible_with = select({
- "@platforms//os:windows": ["@platforms//:incompatible"],
- "//conditions:default": [],
- }),
)
- # document and check that this actually works
- native_test(
+ # Document and check that the action output matches the in-source file.
+ diff_test(
name = "requirements_test",
- src = ":requirements.update",
- target_compatible_with = select({
- "@platforms//os:windows": ["@platforms//:incompatible"],
- "//conditions:default": [],
- }),
+ timeout = "short",
+ file1 = ":requirements",
+ file2 = "testdata/requirements.txt",
)
native.test_suite(