diff --git a/CHANGELOG.md b/CHANGELOG.md
index c64241ccbf..80466fc3f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,6 +53,10 @@ Unreleased changes template.
 
 {#v0-0-0-changed}
 ### Changed
+* (toolchain) The `exec` configuration toolchain now has the forwarded
+  `exec_interpreter` now also forwards the `ToolchainInfo` provider. This is
+  for increased compatibility with the `RBE` setups where access to the `exec`
+  configuration interpreter is needed.
 * (toolchains) Use the latest astrahl-sh toolchain release [20250317] for Python versions:
     * 3.9.21
     * 3.10.16
@@ -75,6 +79,14 @@ Unreleased changes template.
 
 {#v0-0-0-added}
 ### Added
+* (uv) A {obj}`lock` rule that is the replacement for the
+  {obj}`compile_pip_requirements`. This may still have rough corners
+  so please report issues with it in the
+  [#1975](https://github.com/bazel-contrib/rules_python/issues/1975).
+  Main highlights - the locking can be done within a build action or outside
+  it, there is no more automatic `test` target (but it can be added on the user
+  side by using `native_test`). For customizing the `uv` version that is used,
+  please check the {obj}`uv.configure` tag class.
 * Add support for riscv64 linux platform.
 * (toolchains) Add python 3.13.2 and 3.12.9 toolchains
 
diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel
index ab996537c7..bebecd18b2 100644
--- a/docs/BUILD.bazel
+++ b/docs/BUILD.bazel
@@ -176,8 +176,12 @@ lock(
     name = "requirements",
     srcs = ["pyproject.toml"],
     out = "requirements.txt",
-    upgrade = True,
-    visibility = ["//private:__pkg__"],
+    args = [
+        "--emit-index-url",
+        "--universal",
+        "--upgrade",
+    ],
+    visibility = ["//:__subpackages__"],
 )
 
 # Temporary compatibility aliases for some other projects depending on the old
diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel
index 92ca8e7199..d2fddc44c5 100644
--- a/examples/BUILD.bazel
+++ b/examples/BUILD.bazel
@@ -21,5 +21,10 @@ lock(
     name = "bzlmod_requirements_3_9",
     srcs = ["bzlmod/requirements.in"],
     out = "bzlmod/requirements_lock_3_9.txt",
+    args = [
+        "--emit-index-url",
+        "--universal",
+        "--python-version=3.9",
+    ],
     python_version = "3.9.19",
 )
diff --git a/examples/bzlmod/requirements_lock_3_9.txt b/examples/bzlmod/requirements_lock_3_9.txt
index d74d1d39b6..c48f406451 100644
--- a/examples/bzlmod/requirements_lock_3_9.txt
+++ b/examples/bzlmod/requirements_lock_3_9.txt
@@ -46,7 +46,7 @@ imagesize==1.4.1 \
     --hash=sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b \
     --hash=sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a
     # via sphinx
-importlib-metadata==8.4.0 ; python_version < '3.10' \
+importlib-metadata==8.4.0 ; python_full_version < '3.10' \
     --hash=sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1 \
     --hash=sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5
     # via sphinx
@@ -316,7 +316,7 @@ tabulate==0.9.0 \
     --hash=sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c \
     --hash=sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f
     # via -r examples/bzlmod/requirements.in
-tomli==2.0.1 ; python_version < '3.11' \
+tomli==2.0.1 ; python_full_version < '3.11' \
     --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \
     --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f
     # via pylint
@@ -324,7 +324,7 @@ tomlkit==0.11.6 \
     --hash=sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b \
     --hash=sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73
     # via pylint
-typing-extensions==4.12.2 ; python_version < '3.10' \
+typing-extensions==4.12.2 ; python_full_version < '3.10' \
     --hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \
     --hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8
     # via
@@ -480,7 +480,7 @@ yamllint==1.28.0 \
     --hash=sha256:89bb5b5ac33b1ade059743cf227de73daa34d5e5a474b06a5e17fc16583b0cf2 \
     --hash=sha256:9e3d8ddd16d0583214c5fdffe806c9344086721f107435f68bad990e5a88826b
     # via -r examples/bzlmod/requirements.in
-zipp==3.20.0 ; python_version < '3.10' \
+zipp==3.20.0 ; python_full_version < '3.10' \
     --hash=sha256:0145e43d89664cfe1a2e533adc75adafed82fe2da404b4bbb6b026c0157bdb31 \
     --hash=sha256:58da6168be89f0be59beb194da1250516fdaa062ccebd30127ac65d30045e10d
     # via importlib-metadata
diff --git a/private/BUILD.bazel b/private/BUILD.bazel
index 68fefe910f..ef5652b826 100644
--- a/private/BUILD.bazel
+++ b/private/BUILD.bazel
@@ -15,6 +15,7 @@ multirun(
     ] + [
         "//docs:requirements.update",
     ],
+    tags = ["manual"],
 )
 
 # NOTE: The requirements for the pip dependencies may sometimes break the build
@@ -24,4 +25,5 @@ multirun(
 alias(
     name = "whl_library_requirements.update",
     actual = "//tools/private/update_deps:update_pip_deps",
+    tags = ["manual"],
 )
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 8b07fbd877..0f6668fa93 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -361,6 +361,7 @@ bzl_library(
     name = "py_exec_tools_toolchain_bzl",
     srcs = ["py_exec_tools_toolchain.bzl"],
     deps = [
+        ":common_bzl",
         ":py_exec_tools_info_bzl",
         ":sentinel_bzl",
         ":toolchain_types_bzl",
diff --git a/python/private/py_exec_tools_info.bzl b/python/private/py_exec_tools_info.bzl
index b74f480fab..ad9a7b0c5e 100644
--- a/python/private/py_exec_tools_info.bzl
+++ b/python/private/py_exec_tools_info.bzl
@@ -24,15 +24,26 @@ When running it in an action, use `DefaultInfo.files_to_run` to ensure all its
 files are appropriately available. An exec interpreter may not be available,
 e.g. if all the exec tools are prebuilt binaries.
 
-NOTE: this interpreter is really only for use when a build tool cannot use
+:::{note}
+this interpreter is really only for use when a build tool cannot use
 the Python toolchain itself. When possible, prefeer to define a `py_binary`
 instead and use it via a `cfg=exec` attribute; this makes it much easier
 to setup the runtime environment for the binary. See also:
 `py_interpreter_program` rule.
+:::
 
-NOTE: What interpreter is used depends on the toolchain constraints. Ensure
-the proper target constraints are being applied when obtaining this from
-the toolchain.
+:::{note}
+What interpreter is used depends on the toolchain constraints. Ensure the
+proper target constraints are being applied when obtaining this from the
+toolchain.
+:::
+
+:::{warning}
+This does not work correctly in case of RBE, please use exec_runtime instead.
+
+Once https://github.com/bazelbuild/bazel/issues/23620 is resolved this warning
+may be removed.
+:::
 """,
         "precompiler": """
 :type: Target | None
diff --git a/python/private/py_exec_tools_toolchain.bzl b/python/private/py_exec_tools_toolchain.bzl
index edf9159759..ff30431ff4 100644
--- a/python/private/py_exec_tools_toolchain.bzl
+++ b/python/private/py_exec_tools_toolchain.bzl
@@ -29,13 +29,15 @@ def _py_exec_tools_toolchain_impl(ctx):
     if SentinelInfo in ctx.attr.exec_interpreter:
         exec_interpreter = None
 
-    return [platform_common.ToolchainInfo(
-        exec_tools = PyExecToolsInfo(
-            exec_interpreter = exec_interpreter,
-            precompiler = ctx.attr.precompiler,
+    return [
+        platform_common.ToolchainInfo(
+            exec_tools = PyExecToolsInfo(
+                exec_interpreter = exec_interpreter,
+                precompiler = ctx.attr.precompiler,
+            ),
+            **extra_kwargs
         ),
-        **extra_kwargs
-    )]
+    ]
 
 py_exec_tools_toolchain = rule(
     implementation = _py_exec_tools_toolchain_impl,
@@ -51,6 +53,11 @@ This provides `ToolchainInfo` with the following attributes:
     attrs = {
         "exec_interpreter": attr.label(
             default = "//python/private:current_interpreter_executable",
+            providers = [
+                DefaultInfo,
+                # Add the toolchain provider so that we can forward provider fields.
+                platform_common.ToolchainInfo,
+            ],
             cfg = "exec",
             doc = """
 An interpreter that is directly usable in the exec configuration
@@ -69,6 +76,11 @@ handle all the necessary transitions and runtime setup to invoke a program.
 :::
 
 See {obj}`PyExecToolsInfo.exec_interpreter` for further docs.
+
+:::{versionchanged} VERSION_NEXT_FEATURE
+From now on the provided target also needs to provide `platform_common.ToolchainInfo`
+so that the toolchain `py_runtime` field can be correctly forwarded.
+:::
 """,
         ),
         "precompiler": attr.label(
diff --git a/python/private/sentinel.bzl b/python/private/sentinel.bzl
index 6d753e1983..8b69682b49 100644
--- a/python/private/sentinel.bzl
+++ b/python/private/sentinel.bzl
@@ -25,6 +25,10 @@ SentinelInfo = provider(
 
 def _sentinel_impl(ctx):
     _ = ctx  # @unused
-    return [SentinelInfo()]
+    return [
+        SentinelInfo(),
+        # Also output ToolchainInfo to allow it to be used for noop toolchains
+        platform_common.ToolchainInfo(),
+    ]
 
 sentinel = rule(implementation = _sentinel_impl)
diff --git a/python/uv/lock.bzl b/python/uv/lock.bzl
index edffe4728c..82b00bc2d2 100644
--- a/python/uv/lock.bzl
+++ b/python/uv/lock.bzl
@@ -14,7 +14,33 @@
 
 """The `uv` locking rule.
 
-EXPERIMENTAL: This is experimental and may be removed without notice
+Differences with the legacy {obj}`compile_pip_requirements` rule:
+- This is implemented as a rule that performs locking in a build action.
+- Additionally one can use the runnable target.
+- Uses `uv`.
+- This does not error out if the output file does not exist yet.
+- Supports transitions out of the box.
+
+Note, this does not provide a `test` target, if you would like to add a test
+target that always does the locking automatically to ensure that the
+`requirements.txt` file is up-to-date, add something similar to:
+
+```starlark
+load("@bazel_skylib//rules:native_binary.bzl", "native_test")
+load("@rules_python//python/uv:lock.bzl", "lock")
+
+lock(
+    name = "requirements",
+    srcs = ["pyproject.toml"],
+)
+
+native_test(
+    name = "requirements_test",
+    src = "requirements.update",
+)
+```
+
+EXPERIMENTAL: This is experimental and may be changed without notice.
 """
 
 load("//python/uv/private:lock.bzl", _lock = "lock")
diff --git a/python/uv/private/BUILD.bazel b/python/uv/private/BUILD.bazel
index acf2a9c1f7..d17ca39490 100644
--- a/python/uv/private/BUILD.bazel
+++ b/python/uv/private/BUILD.bazel
@@ -13,6 +13,15 @@
 # limitations under the License.
 
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")  # buildifier: disable=bzl-visibility
+
+exports_files(
+    srcs = [
+        "lock_copier.py",
+    ],
+    # only because this is used from a macro to template
+    visibility = ["//visibility:public"],
+)
 
 filegroup(
     name = "distribution",
@@ -31,9 +40,13 @@ bzl_library(
     srcs = ["lock.bzl"],
     visibility = ["//python/uv:__subpackages__"],
     deps = [
+        ":toolchain_types_bzl",
         "//python:py_binary_bzl",
         "//python/private:bzlmod_enabled_bzl",
-        "@bazel_skylib//rules:write_file",
+        "//python/private:full_version_bzl",
+        "//python/private:toolchain_types_bzl",
+        "@bazel_skylib//lib:shell",
+        "@pythons_hub//:versions_bzl",
     ],
 )
 
@@ -81,3 +94,13 @@ bzl_library(
         "//python/private:text_util_bzl",
     ],
 )
+
+filegroup(
+    name = "lock_template",
+    srcs = select({
+        "@platforms//os:windows": ["lock.bat"],
+        "//conditions:default": ["lock.sh"],
+    }),
+    target_compatible_with = [] if BZLMOD_ENABLED else ["@platforms//:incompatible"],
+    visibility = ["//visibility:public"],
+)
diff --git a/python/uv/private/lock.bat b/python/uv/private/lock.bat
new file mode 100755
index 0000000000..3954c10347
--- /dev/null
+++ b/python/uv/private/lock.bat
@@ -0,0 +1,7 @@
+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 9378f180db..69d277d653 100644
--- a/python/uv/private/lock.bzl
+++ b/python/uv/private/lock.bzl
@@ -12,114 +12,483 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""A simple macro to lock the requirements.
+"""An implementation for a simple macro to lock the requirements.
 """
 
-load("@bazel_skylib//rules:write_file.bzl", "write_file")
+load("@bazel_skylib//lib:shell.bzl", "shell")
+load("@pythons_hub//:versions.bzl", "DEFAULT_PYTHON_VERSION", "MINOR_MAPPING")
 load("//python:py_binary.bzl", "py_binary")
 load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")  # buildifier: disable=bzl-visibility
+load("//python/private:full_version.bzl", "full_version")
+load("//python/private:toolchain_types.bzl", "EXEC_TOOLS_TOOLCHAIN_TYPE")  # buildifier: disable=bzl-visibility
+load(":toolchain_types.bzl", "UV_TOOLCHAIN_TYPE")
 
 visibility(["//..."])
 
-_REQUIREMENTS_TARGET_COMPATIBLE_WITH = select({
-    "@platforms//os:windows": ["@platforms//:incompatible"],
-    "//conditions:default": [],
-}) if BZLMOD_ENABLED else ["@platforms//:incompatible"]
+_PYTHON_VERSION_FLAG = "//python/config_settings:python_version"
 
-def lock(*, name, srcs, out, upgrade = False, universal = True, args = [], **kwargs):
-    """Pin the requirements based on the src files.
+_RunLockInfo = provider(
+    doc = "",
+    fields = {
+        "args": "The args passed to the `uv` by default when running the runnable target.",
+        "env": "The env passed to the execution.",
+        "srcs": "Source files required to run the runnable target.",
+    },
+)
+
+def _args(ctx):
+    """A small helper to ensure that the right args are pushed to the _RunLockInfo provider"""
+    run_info = []
+    args = ctx.actions.args()
+
+    def _add_args(arg, maybe_value = None):
+        run_info.append(arg)
+        if maybe_value:
+            args.add(arg, maybe_value)
+            run_info.append(maybe_value)
+        else:
+            args.add(arg)
+
+    def _add_all(name, all_args = None, **kwargs):
+        if not all_args and type(name) == "list":
+            all_args = name
+            name = None
+
+        before_each = kwargs.get("before_each")
+        if name:
+            args.add_all(name, all_args, **kwargs)
+            run_info.append(name)
+        else:
+            args.add_all(all_args, **kwargs)
+
+        for arg in all_args:
+            if before_each:
+                run_info.append(before_each)
+            run_info.append(arg)
+
+    return struct(
+        run_info = run_info,
+        run_shell = args,
+        add = _add_args,
+        add_all = _add_all,
+    )
+
+def _lock_impl(ctx):
+    srcs = ctx.files.srcs
+    python_version = full_version(
+        version = ctx.attr.python_version or DEFAULT_PYTHON_VERSION,
+        minor_mapping = MINOR_MAPPING,
+    )
+    output = ctx.actions.declare_file("{}.{}.out".format(
+        ctx.label.name,
+        python_version.replace(".", "_"),
+    ))
+
+    toolchain_info = ctx.toolchains[UV_TOOLCHAIN_TYPE]
+    uv = toolchain_info.uv_toolchain_info.uv[DefaultInfo].files_to_run.executable
+
+    args = _args(ctx)
+    args.add_all([
+        uv,
+        "pip",
+        "compile",
+        "--no-python-downloads",
+        "--no-cache",
+    ])
+    pkg = ctx.label.package
+    update_target = ctx.attr.update_target
+    args.add("--custom-compile-command", "bazel run //{}:{}".format(pkg, update_target))
+    if ctx.attr.generate_hashes:
+        args.add("--generate-hashes")
+    if not ctx.attr.strip_extras:
+        args.add("--no-strip-extras")
+    args.add_all(ctx.files.build_constraints, before_each = "--build-constraints")
+    args.add_all(ctx.files.constraints, before_each = "--constraints")
+    args.add_all(ctx.attr.args)
+
+    exec_tools = ctx.toolchains[EXEC_TOOLS_TOOLCHAIN_TYPE].exec_tools
+    runtime = exec_tools.exec_interpreter[platform_common.ToolchainInfo].py3_runtime
+    python = runtime.interpreter or runtime.interpreter_path
+    python_files = runtime.files
+    args.add("--python", python)
+    args.add_all(srcs)
+
+    args.run_shell.add("--output-file", output)
+
+    # These arguments does not change behaviour, but it reduces the output from
+    # the command, which is especially verbose in stderr.
+    args.run_shell.add("--no-progress")
+    args.run_shell.add("--quiet")
 
-    Differences with the current {obj}`compile_pip_requirements` rule:
-    - This is implemented in shell and `uv`.
-    - This does not error out if the output file does not exist yet.
-    - Supports transitions out of the box.
-    - The execution of the lock file generation is happening inside of a build
-      action in a `genrule`.
+    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(
+                    src = ctx.files.existing_output[0].path,
+                    dst = output.path,
+                ),
+            ),
+        )
+    else:
+        command = '"$@"'
+
+    srcs = srcs + ctx.files.build_constraints + ctx.files.constraints
+
+    ctx.actions.run_shell(
+        command = command,
+        inputs = srcs + ctx.files.existing_output,
+        mnemonic = "PyRequirementsLockUv",
+        outputs = [output],
+        arguments = [args.run_shell],
+        tools = [
+            uv,
+            python_files,
+        ],
+        progress_message = "Creating a requirements.txt with uv: %{label}",
+        env = ctx.attr.env,
+    )
+
+    return [
+        DefaultInfo(files = depset([output])),
+        _RunLockInfo(
+            args = args.run_info,
+            env = ctx.attr.env,
+            srcs = depset(
+                srcs + [uv],
+                transitive = [python_files],
+            ),
+        ),
+    ]
+
+def _transition_impl(input_settings, attr):
+    settings = {
+        _PYTHON_VERSION_FLAG: input_settings[_PYTHON_VERSION_FLAG],
+    }
+    if attr.python_version:
+        # FIXME @aignas 2025-03-20: using `full_version` is a workaround for a bug in
+        # how we order toolchains in bazel. If I set the `python_version` flag
+        # to `3.12`, I would expect the latest version to be selected, i.e. the
+        # one that is in MINOR_MAPPING, but it seems that 3.12.0 is selected,
+        # because of how the targets are ordered.
+        settings[_PYTHON_VERSION_FLAG] = full_version(
+            version = attr.python_version,
+            minor_mapping = MINOR_MAPPING,
+        )
+    return settings
+
+_python_version_transition = transition(
+    implementation = _transition_impl,
+    inputs = [_PYTHON_VERSION_FLAG],
+    outputs = [_PYTHON_VERSION_FLAG],
+)
+
+_lock = rule(
+    implementation = _lock_impl,
+    doc = """\
+The lock rule that does the locking in a build action (that makes it possible
+to use RBE) and also prepares information for a `bazel run` executable rule.
+""",
+    attrs = {
+        "args": attr.string_list(
+            doc = "Public, see the docs in the macro.",
+        ),
+        "build_constraints": attr.label_list(
+            allow_files = True,
+            doc = "Public, see the docs in the macro.",
+        ),
+        "constraints": attr.label_list(
+            allow_files = True,
+            doc = "Public, see the docs in the macro.",
+        ),
+        "env": attr.string_dict(
+            doc = "Public, see the docs in the macro.",
+        ),
+        "existing_output": attr.label(
+            mandatory = False,
+            allow_single_file = True,
+            doc = """\
+An already existing output file that is used as a basis for further
+modifications and the locking is not done from scratch.
+""",
+        ),
+        "generate_hashes": attr.bool(
+            doc = "Public, see the docs in the macro.",
+            default = True,
+        ),
+        "output": attr.string(
+            doc = "Public, see the docs in the macro.",
+            mandatory = True,
+        ),
+        "python_version": attr.string(
+            doc = "Public, see the docs in the macro.",
+        ),
+        "srcs": attr.label_list(
+            mandatory = True,
+            allow_files = True,
+            doc = "Public, see the docs in the macro.",
+        ),
+        "strip_extras": attr.bool(
+            doc = "Public, see the docs in the macro.",
+            default = False,
+        ),
+        "update_target": attr.string(
+            mandatory = True,
+            doc = """\
+The string to input for the 'uv pip compile'.
+""",
+        ),
+        "_allowlist_function_transition": attr.label(
+            default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+        ),
+    },
+    toolchains = [
+        EXEC_TOOLS_TOOLCHAIN_TYPE,
+        UV_TOOLCHAIN_TYPE,
+    ],
+    cfg = _python_version_transition,
+)
+
+def _lock_run_impl(ctx):
+    if ctx.attr.is_windows:
+        path_sep = "\\"
+        ext = ".exe"
+    else:
+        path_sep = "/"
+        ext = ""
+
+    def _maybe_path(arg):
+        if hasattr(arg, "short_path"):
+            arg = arg.short_path
+
+        return shell.quote(arg.replace("/", path_sep))
+
+    info = ctx.attr.lock[_RunLockInfo]
+    executable = ctx.actions.declare_file(ctx.label.name + ext)
+    ctx.actions.expand_template(
+        template = ctx.files._template[0],
+        substitutions = {
+            '"{{args}}"': " ".join([_maybe_path(arg) for arg in info.args]),
+            "{{src_out}}": "{}/{}".format(ctx.label.package, ctx.attr.output).replace(
+                "/",
+                path_sep,
+            ),
+        },
+        output = executable,
+        is_executable = True,
+    )
+
+    return [
+        DefaultInfo(
+            executable = executable,
+            runfiles = ctx.runfiles(transitive_files = info.srcs),
+        ),
+        RunEnvironmentInfo(
+            environment = info.env,
+        ),
+    ]
+
+_lock_run = rule(
+    implementation = _lock_run_impl,
+    doc = """\
+""",
+    attrs = {
+        "is_windows": attr.bool(mandatory = True),
+        "lock": attr.label(
+            doc = "The lock target that is doing locking in a build action.",
+            providers = [_RunLockInfo],
+            cfg = "exec",
+        ),
+        "output": attr.string(
+            doc = """\
+The output that we would be updated, relative to the package the macro is used in.
+""",
+        ),
+        "_template": attr.label(
+            default = "//python/uv/private:lock_template",
+            doc = """\
+The template to be used for 'uv pip compile'. This is either .ps1 or bash
+script depending on what the target platform is executed on.
+""",
+        ),
+    },
+    executable = True,
+)
+
+def _maybe_file(path):
+    """A small function to return a list of existing outputs.
+
+    If the file referenced by the input argument exists, then it will return
+    it, otherwise it will return an empty list. This is useful to for programs
+    like pip-compile which behave differently if the output file exists and
+    update the output file in place.
+
+    The API of the function ensures that path is not a glob itself.
 
     Args:
-        name: The name of the target to run for updating the requirements.
-        srcs: The srcs to use as inputs.
-        out: The output file.
-        upgrade: Tell `uv` to always upgrade the dependencies instead of
-            keeping them as they are.
-        universal: Tell `uv` to generate a universal lock file.
-        args: Extra args to pass to the rule.
-        **kwargs: Extra kwargs passed to the binary rule.
+        path: {type}`str` the file name.
     """
-    pkg = native.package_name()
-    update_target = name + ".update"
-
-    _args = [
-        "--custom-compile-command='bazel run //{}:{}'".format(pkg, update_target),
-        "--generate-hashes",
-        "--emit-index-url",
-        "--no-strip-extras",
-        "--python=$(PYTHON3)",
-    ] + args + [
-        "$(location {})".format(src)
-        for src in srcs
-    ]
-    if upgrade:
-        _args.append("--upgrade")
-    if universal:
-        _args.append("--universal")
-    _args.append("--output-file=$@")
-    cmd = "$(UV_BIN) pip compile " + " ".join(_args)
+    for p in native.glob([path], allow_empty = True):
+        if path == p:
+            return p
+
+    return None
+
+def _expand_template_impl(ctx):
+    pkg = ctx.label.package
+    update_src = ctx.actions.declare_file(ctx.attr.update_target + ".py")
+    ctx.actions.expand_template(
+        template = ctx.files._template[0],
+        substitutions = {
+            "{{dst}}": "{}/{}".format(pkg, ctx.attr.output),
+            "{{src}}": "{}".format(ctx.files.src[0].short_path),
+            "{{update_target}}": "//{}:{}".format(pkg, ctx.attr.update_target),
+        },
+        output = update_src,
+    )
+    return DefaultInfo(files = depset([update_src]))
+
+_expand_template = rule(
+    implementation = _expand_template_impl,
+    attrs = {
+        "output": attr.string(mandatory = True),
+        "src": attr.label(mandatory = True),
+        "update_target": attr.string(mandatory = True),
+        "_template": attr.label(
+            default = "//python/uv/private:lock_copier.py",
+            allow_single_file = True,
+        ),
+    },
+    doc = "Expand the template for the update script allowing us to use `select` statements in the {attr}`output` attribute.",
+)
+
+def lock(
+        *,
+        name,
+        srcs,
+        out,
+        args = [],
+        build_constraints = [],
+        constraints = [],
+        env = None,
+        generate_hashes = True,
+        python_version = None,
+        strip_extras = False,
+        **kwargs):
+    """Pin the requirements based on the src files.
+
+    This macro creates the following targets:
+    - `name`: the target that creates the requirements.txt file in a build
+      action. This target will have `no-cache` and `requires-network` added
+      to its tags.
+    - `name.run`: a runnable target that can be used to pass extra parameters
+      to the same command that would be run in the `name` action. This will
+      update the source copy of the requirements file. You can customize the
+      args via the command line, but it requires being able to run `uv` (and
+      possibly `python`) directly on your host.
+    - `name.update`: a target that can be run to update the source-tree version
+      of the requirements lock file. The output can be fed to the
+      {obj}`pip.parse` bzlmod extension tag class. Note, you can use
+      `native_test` to wrap this target to make a test. You can't customize the
+      args via command line, but you can use RBE to generate requirements
+      (offload execution and run for different platforms). Note, that for RBE
+      to be usable, one needs to ensure that the nodes running the action have
+      internet connectivity or the indexes are provided in a different way for
+      a fully offline operation.
 
-    # Make a copy to ensure that we are not modifying the initial list
-    srcs = list(srcs)
+    :::{note}
+    All of the targets have `manual` tags as locking results cannot be cached.
+    :::
+
+    Args:
+        name: {type}`str` The prefix of all targets created by this macro.
+        srcs: {type}`list[Label]` The sources that will be used. Add all of the
+            files that would be passed as srcs to the `uv pip compile` command.
+        out: {type}`str` The output file relative to the package.
+        args: {type}`list[str]` The list of args to pass to uv. Note, these are
+            written into the runnable `name.run` target.
+        env: {type}`dict[str, str]` the environment variables to set. Note, this
+            is passed as is and the environment variables are not expanded.
+        build_constraints: {type}`list[Label]` The list of build constraints to use.
+        constraints: {type}`list[Label]` The list of constraints files to use.
+        generate_hashes: {type}`bool` Generate hashes for all of the
+            requirements. This is a must if you want to use
+            {attr}`pip.parse.experimental_index_url`. Defaults to `True`.
+        strip_extras: {type}`bool` whether to strip extras from the output.
+            Currently `rules_python` requires `--no-strip-extras` to properly
+            function, but sometimes one may want to not have the extras if you
+            are compiling the requirements file for using it as a constraints
+            file. Defaults to `False`.
+        python_version: {type}`str | None` the python_version to transition to
+            when locking the requirements. Defaults to the default python version
+            configured by the {obj}`python` module extension.
+        **kwargs: common kwargs passed to rules.
+    """
+    update_target = "{}.update".format(name)
+    locker_target = "{}.run".format(name)
 
     # Check if the output file already exists, if yes, first copy it to the
     # output file location in order to make `uv` not change the requirements if
     # we are just running the command.
-    if native.glob([out]):
-        cmd = "cp -v $(location {}) $@; {}".format(out, cmd)
-        srcs.append(out)
+    maybe_out = _maybe_file(out)
+
+    tags = ["manual"] + kwargs.pop("tags", [])
+    if not BZLMOD_ENABLED:
+        kwargs["target_compatible_with"] = ["@platforms//:incompatible"]
 
-    native.genrule(
+    # FIXME @aignas 2025-03-17: should we have one more target that transitions
+    # the python_version to ensure that if somebody calls `bazel build
+    # :requirements` that it is locked with the right `python_version`?
+    _lock(
         name = name,
+        args = args,
+        build_constraints = build_constraints,
+        constraints = constraints,
+        env = env,
+        existing_output = maybe_out,
+        generate_hashes = generate_hashes,
+        python_version = python_version,
         srcs = srcs,
-        outs = [out + ".new"],
-        cmd_bash = cmd,
+        strip_extras = strip_extras,
+        update_target = update_target,
+        output = out,
         tags = [
-            "local",
-            "manual",
             "no-cache",
-        ],
-        target_compatible_with = _REQUIREMENTS_TARGET_COMPATIBLE_WITH,
-        toolchains = [
-            Label("//python/uv:current_toolchain"),
-            Label("//python:current_py_toolchain"),
-        ],
+            "requires-network",
+        ] + tags,
+        **kwargs
     )
 
-    # Write a script that can be used for updating the in-tree version of the
-    # requirements file
-    write_file(
-        name = name + ".update_gen",
-        out = update_target + ".py",
-        content = [
-            "from os import environ",
-            "from pathlib import Path",
-            "from sys import stderr",
-            "",
-            'src = Path(environ["REQUIREMENTS_FILE"])',
-            'assert src.exists(), f"the {src} file does not exist"',
-            'dst = Path(environ["BUILD_WORKSPACE_DIRECTORY"]) / "{}" / "{}"'.format(pkg, out),
-            'print(f"Writing requirements contents\\n  from {src.absolute()}\\n  to {dst.absolute()}", file=stderr)',
-            "dst.write_text(src.read_text())",
-            'print("Success!", file=stderr)',
-        ],
+    # A target for updating the in-tree version directly by skipping the in-action
+    # uv pip compile.
+    _lock_run(
+        name = locker_target,
+        lock = name,
+        output = out,
+        is_windows = select({
+            "@platforms//os:windows": True,
+            "//conditions:default": False,
+        }),
+        tags = tags,
+        **kwargs
+    )
+
+    # FIXME @aignas 2025-03-20: is it possible to extend `py_binary` so that the
+    # srcs are generated before `py_binary` is run? I found that
+    # `ctx.files.srcs` usage in the base implementation is making it difficult.
+    template_target = "_{}_gen".format(name)
+    _expand_template(
+        name = template_target,
+        src = name,
+        output = out,
+        update_target = update_target,
+        tags = tags,
     )
 
     py_binary(
         name = update_target,
-        srcs = [update_target + ".py"],
-        main = update_target + ".py",
-        data = [name],
-        env = {
-            "REQUIREMENTS_FILE": "$(rootpath {})".format(name),
-        },
-        tags = ["manual"],
+        srcs = [template_target],
+        data = [name] + ([maybe_out] if maybe_out else []),
+        tags = tags,
         **kwargs
     )
diff --git a/python/uv/private/lock.sh b/python/uv/private/lock.sh
new file mode 100755
index 0000000000..b6ba0c6c48
--- /dev/null
+++ b/python/uv/private/lock.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+set -euo pipefail
+
+if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then
+    readonly out="${BUILD_WORKSPACE_DIRECTORY}/{{src_out}}"
+else
+    exit 1
+fi
+exec "{{args}}" --output-file "$out" "$@"
diff --git a/python/uv/private/lock_copier.py b/python/uv/private/lock_copier.py
new file mode 100644
index 0000000000..bcc64c1661
--- /dev/null
+++ b/python/uv/private/lock_copier.py
@@ -0,0 +1,69 @@
+import sys
+from difflib import unified_diff
+from os import environ
+from pathlib import Path
+
+_LINE = "=" * 80
+
+
+def main():
+    src = "{{src}}"
+    dst = "{{dst}}"
+
+    src = Path(src)
+    if not src.exists():
+        raise AssertionError(f"The {src} file does not exist")
+
+    if "TEST_SRCDIR" in environ:
+        # Running as a bazel test
+        dst = Path(dst)
+        a = dst.read_text() if dst.exists() else "\n"
+        b = src.read_text()
+
+        diff = unified_diff(
+            a.splitlines(),
+            b.splitlines(),
+            str(dst),
+            str(src),
+            lineterm="",
+        )
+        diff = "\n".join(list(diff))
+        if not diff:
+            print(
+                f"""\
+{_LINE}
+The in source file copy is up-to-date.
+{_LINE}
+"""
+            )
+            return 0
+
+        print(diff)
+        print(
+            f"""\
+{_LINE}
+The in source file copy is out of date, please run:
+
+    bazel run {{update_target}}
+{_LINE}
+"""
+        )
+        return 1
+
+    if "BUILD_WORKSPACE_DIRECTORY" not in environ:
+        raise RuntimeError(
+            "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}")
+    build_workspace = Path(environ["BUILD_WORKSPACE_DIRECTORY"])
+
+    dst_real_path = build_workspace / dst
+    dst_real_path.parent.mkdir(parents=True, exist_ok=True)
+    dst_real_path.write_text(src.read_text())
+    print(f"OK: updated {dst_real_path}")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/python/uv/private/uv_toolchain.bzl b/python/uv/private/uv_toolchain.bzl
index b740fc304d..8c7f1b4b8c 100644
--- a/python/uv/private/uv_toolchain.bzl
+++ b/python/uv/private/uv_toolchain.bzl
@@ -53,7 +53,7 @@ uv_toolchain = rule(
             mandatory = True,
             allow_single_file = True,
             executable = True,
-            cfg = "target",
+            cfg = "exec",
         ),
         "version": attr.string(mandatory = True, doc = "Version of the uv binary."),
     },
diff --git a/tests/uv/lock/BUILD.bazel b/tests/uv/lock/BUILD.bazel
new file mode 100644
index 0000000000..6b6902da44
--- /dev/null
+++ b/tests/uv/lock/BUILD.bazel
@@ -0,0 +1,5 @@
+load(":lock_tests.bzl", "lock_test_suite")
+
+lock_test_suite(
+    name = "lock_tests",
+)
diff --git a/tests/uv/lock/lock_run_test.py b/tests/uv/lock/lock_run_test.py
new file mode 100644
index 0000000000..ef57f23d31
--- /dev/null
+++ b/tests/uv/lock/lock_run_test.py
@@ -0,0 +1,165 @@
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+from python import runfiles
+
+rfiles = runfiles.Create()
+
+
+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}")
+
+    return Path(rpath)
+
+
+class LockTests(unittest.TestCase):
+    def test_requirements_updating_for_the_first_time(self):
+        # Given
+        copier_path = _relative_rpath("requirements_new_file.update")
+
+        # When
+        with tempfile.TemporaryDirectory() as dir:
+            workspace_dir = Path(dir)
+            want_path = workspace_dir / "tests" / "uv" / "lock" / "does_not_exist.txt"
+
+            self.assertFalse(
+                want_path.exists(), "The path should not exist after the test"
+            )
+            output = subprocess.run(
+                copier_path,
+                capture_output=True,
+                env={
+                    "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}",
+                },
+            )
+
+            # Then
+            self.assertEqual(0, output.returncode, output.stderr)
+            self.assertIn(
+                "cp <bazel-sandbox>/tests/uv/lock/requirements_new_file",
+                output.stdout.decode("utf-8"),
+            )
+            self.assertTrue(want_path.exists(), "The path should exist after the test")
+            self.assertNotEqual(want_path.read_text(), "")
+
+    def test_requirements_updating(self):
+        # Given
+        copier_path = _relative_rpath("requirements.update")
+        existing_file = _relative_rpath("testdata/requirements.txt")
+        want_text = existing_file.read_text()
+
+        # When
+        with tempfile.TemporaryDirectory() as dir:
+            workspace_dir = Path(dir)
+            want_path = (
+                workspace_dir
+                / "tests"
+                / "uv"
+                / "lock"
+                / "testdata"
+                / "requirements.txt"
+            )
+            want_path.parent.mkdir(parents=True)
+            want_path.write_text(
+                want_text + "\n\n"
+            )  # Write something else to see that it is restored
+
+            output = subprocess.run(
+                copier_path,
+                capture_output=True,
+                env={
+                    "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}",
+                },
+            )
+
+            # Then
+            self.assertEqual(0, output.returncode)
+            self.assertIn(
+                "cp <bazel-sandbox>/tests/uv/lock/requirements",
+                output.stdout.decode("utf-8"),
+            )
+            self.assertEqual(want_path.read_text(), want_text)
+
+    def test_requirements_run_on_the_first_time(self):
+        # Given
+        copier_path = _relative_rpath("requirements_new_file.run")
+
+        # When
+        with tempfile.TemporaryDirectory() as dir:
+            workspace_dir = Path(dir)
+            want_path = workspace_dir / "tests" / "uv" / "lock" / "does_not_exist.txt"
+            # NOTE @aignas 2025-03-18: right now we require users to have the folder
+            # there already
+            want_path.parent.mkdir(parents=True)
+
+            self.assertFalse(
+                want_path.exists(), "The path should not exist after the test"
+            )
+            output = subprocess.run(
+                copier_path,
+                capture_output=True,
+                env={
+                    "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}",
+                },
+            )
+
+            # Then
+            self.assertEqual(0, output.returncode, output.stderr)
+            self.assertTrue(want_path.exists(), "The path should exist after the test")
+            got_contents = want_path.read_text()
+            self.assertNotEqual(got_contents, "")
+            self.assertIn(
+                got_contents,
+                output.stdout.decode("utf-8"),
+            )
+
+    def test_requirements_run(self):
+        # Given
+        copier_path = _relative_rpath("requirements.run")
+        existing_file = _relative_rpath("testdata/requirements.txt")
+        want_text = existing_file.read_text()
+
+        # When
+        with tempfile.TemporaryDirectory() as dir:
+            workspace_dir = Path(dir)
+            want_path = (
+                workspace_dir
+                / "tests"
+                / "uv"
+                / "lock"
+                / "testdata"
+                / "requirements.txt"
+            )
+
+            want_path.parent.mkdir(parents=True)
+            want_path.write_text(
+                want_text + "\n\n"
+            )  # Write something else to see that it is restored
+
+            output = subprocess.run(
+                copier_path,
+                capture_output=True,
+                env={
+                    "BUILD_WORKSPACE_DIRECTORY": f"{workspace_dir}",
+                },
+            )
+
+            # Then
+            self.assertEqual(0, output.returncode, output.stderr)
+            self.assertTrue(want_path.exists(), "The path should exist after the test")
+            got_contents = want_path.read_text()
+            self.assertNotEqual(got_contents, "")
+            self.assertIn(
+                got_contents,
+                output.stdout.decode("utf-8"),
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/uv/lock/lock_tests.bzl b/tests/uv/lock/lock_tests.bzl
new file mode 100644
index 0000000000..35c7c19328
--- /dev/null
+++ b/tests/uv/lock/lock_tests.bzl
@@ -0,0 +1,105 @@
+# Copyright 2025 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+""
+
+load("@bazel_skylib//rules:native_binary.bzl", "native_test")
+load("//python/uv:lock.bzl", "lock")
+load("//tests/support:sh_py_run_test.bzl", "py_reconfig_test")
+
+def lock_test_suite(name):
+    """The test suite with various lock-related integration tests
+
+    Args:
+        name: {type}`str` the name of the test suite
+    """
+    lock(
+        name = "requirements",
+        srcs = ["testdata/requirements.in"],
+        constraints = [
+            "testdata/constraints.txt",
+            "testdata/constraints2.txt",
+        ],
+        build_constraints = [
+            "testdata/build_constraints.txt",
+            "testdata/build_constraints2.txt",
+        ],
+        # It seems that the CI remote executors for the RBE do not have network
+        # connectivity due to current CI setup.
+        tags = ["no-remote-exec"],
+        out = "testdata/requirements.txt",
+    )
+
+    lock(
+        name = "requirements_new_file",
+        srcs = ["testdata/requirements.in"],
+        out = "does_not_exist.txt",
+        # It seems that the CI remote executors for the RBE do not have network
+        # connectivity due to current CI setup.
+        tags = ["no-remote-exec"],
+    )
+
+    py_reconfig_test(
+        name = "requirements_run_tests",
+        env = {
+            "BUILD_WORKSPACE_DIRECTORY": "foo",
+        },
+        srcs = ["lock_run_test.py"],
+        deps = [
+            "//python/runfiles",
+        ],
+        data = [
+            "requirements_new_file.update",
+            "requirements_new_file.run",
+            "requirements.update",
+            "requirements.run",
+            "testdata/requirements.txt",
+        ],
+        main = "lock_run_test.py",
+        tags = [
+            "requires-network",
+            # FIXME @aignas 2025-03-19: it seems that the RBE tests are failing
+            # to execute the `requirements.run` targets that require network.
+            #
+            # We could potentially dump the required `.html` files and somehow
+            # provide it to the `uv`, but may rely on internal uv handling of
+            # `--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(
+        name = "requirements_test",
+        src = ":requirements.update",
+        target_compatible_with = select({
+            "@platforms//os:windows": ["@platforms//:incompatible"],
+            "//conditions:default": [],
+        }),
+    )
+
+    native.test_suite(
+        name = name,
+        tests = [
+            ":requirements_test",
+            ":requirements_run_tests",
+        ],
+    )
diff --git a/tests/uv/lock/testdata/build_constraints.txt b/tests/uv/lock/testdata/build_constraints.txt
new file mode 100644
index 0000000000..34c3ebe3de
--- /dev/null
+++ b/tests/uv/lock/testdata/build_constraints.txt
@@ -0,0 +1 @@
+certifi==2025.1.31
diff --git a/tests/uv/lock/testdata/build_constraints2.txt b/tests/uv/lock/testdata/build_constraints2.txt
new file mode 100644
index 0000000000..34c3ebe3de
--- /dev/null
+++ b/tests/uv/lock/testdata/build_constraints2.txt
@@ -0,0 +1 @@
+certifi==2025.1.31
diff --git a/tests/uv/lock/testdata/constraints.txt b/tests/uv/lock/testdata/constraints.txt
new file mode 100644
index 0000000000..18ade2c5b9
--- /dev/null
+++ b/tests/uv/lock/testdata/constraints.txt
@@ -0,0 +1 @@
+charset-normalizer==3.4.0
diff --git a/tests/uv/lock/testdata/constraints2.txt b/tests/uv/lock/testdata/constraints2.txt
new file mode 100644
index 0000000000..18ade2c5b9
--- /dev/null
+++ b/tests/uv/lock/testdata/constraints2.txt
@@ -0,0 +1 @@
+charset-normalizer==3.4.0
diff --git a/tests/uv/lock/testdata/requirements.in b/tests/uv/lock/testdata/requirements.in
new file mode 100644
index 0000000000..f2293605cf
--- /dev/null
+++ b/tests/uv/lock/testdata/requirements.in
@@ -0,0 +1 @@
+requests
diff --git a/tests/uv/lock/testdata/requirements.txt b/tests/uv/lock/testdata/requirements.txt
new file mode 100644
index 0000000000..d02844636d
--- /dev/null
+++ b/tests/uv/lock/testdata/requirements.txt
@@ -0,0 +1,128 @@
+# This file was autogenerated by uv via the following command:
+#    bazel run //tests/uv/lock:requirements.update
+certifi==2025.1.31 \
+    --hash=sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651 \
+    --hash=sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe
+    # via requests
+charset-normalizer==3.4.0 \
+    --hash=sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621 \
+    --hash=sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6 \
+    --hash=sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8 \
+    --hash=sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912 \
+    --hash=sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c \
+    --hash=sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b \
+    --hash=sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d \
+    --hash=sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d \
+    --hash=sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95 \
+    --hash=sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e \
+    --hash=sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565 \
+    --hash=sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64 \
+    --hash=sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab \
+    --hash=sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be \
+    --hash=sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e \
+    --hash=sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907 \
+    --hash=sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0 \
+    --hash=sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2 \
+    --hash=sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62 \
+    --hash=sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62 \
+    --hash=sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23 \
+    --hash=sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc \
+    --hash=sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284 \
+    --hash=sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca \
+    --hash=sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455 \
+    --hash=sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858 \
+    --hash=sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b \
+    --hash=sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594 \
+    --hash=sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc \
+    --hash=sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db \
+    --hash=sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b \
+    --hash=sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea \
+    --hash=sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6 \
+    --hash=sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920 \
+    --hash=sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749 \
+    --hash=sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7 \
+    --hash=sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd \
+    --hash=sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99 \
+    --hash=sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242 \
+    --hash=sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee \
+    --hash=sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129 \
+    --hash=sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2 \
+    --hash=sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51 \
+    --hash=sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee \
+    --hash=sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8 \
+    --hash=sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b \
+    --hash=sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613 \
+    --hash=sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742 \
+    --hash=sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe \
+    --hash=sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3 \
+    --hash=sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5 \
+    --hash=sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631 \
+    --hash=sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7 \
+    --hash=sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15 \
+    --hash=sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c \
+    --hash=sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea \
+    --hash=sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417 \
+    --hash=sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250 \
+    --hash=sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88 \
+    --hash=sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca \
+    --hash=sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa \
+    --hash=sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99 \
+    --hash=sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149 \
+    --hash=sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41 \
+    --hash=sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574 \
+    --hash=sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0 \
+    --hash=sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f \
+    --hash=sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d \
+    --hash=sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654 \
+    --hash=sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3 \
+    --hash=sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19 \
+    --hash=sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90 \
+    --hash=sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578 \
+    --hash=sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9 \
+    --hash=sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1 \
+    --hash=sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51 \
+    --hash=sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719 \
+    --hash=sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236 \
+    --hash=sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a \
+    --hash=sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c \
+    --hash=sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade \
+    --hash=sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944 \
+    --hash=sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc \
+    --hash=sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6 \
+    --hash=sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6 \
+    --hash=sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27 \
+    --hash=sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6 \
+    --hash=sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2 \
+    --hash=sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12 \
+    --hash=sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf \
+    --hash=sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114 \
+    --hash=sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7 \
+    --hash=sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf \
+    --hash=sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d \
+    --hash=sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b \
+    --hash=sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed \
+    --hash=sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03 \
+    --hash=sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4 \
+    --hash=sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67 \
+    --hash=sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365 \
+    --hash=sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a \
+    --hash=sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748 \
+    --hash=sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b \
+    --hash=sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079 \
+    --hash=sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482
+    # via
+    #   -c tests/uv/lock/testdata/constraints.txt
+    #   -c tests/uv/lock/testdata/constraints2.txt
+    #   requests
+idna==3.10 \
+    --hash=sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9 \
+    --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3
+    # via requests
+requests==2.32.3 \
+    --hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
+    --hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
+    # via -r tests/uv/lock/testdata/requirements.in
+urllib3==2.3.0 \
+    --hash=sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df \
+    --hash=sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d
+    # via requests
diff --git a/tools/private/publish_deps.bzl b/tools/private/publish_deps.bzl
index 538cc1d583..a9b0dbc562 100644
--- a/tools/private/publish_deps.bzl
+++ b/tools/private/publish_deps.bzl
@@ -17,13 +17,27 @@
 
 load("//python/uv/private:lock.bzl", "lock")  # buildifier: disable=bzl-visibility
 
-def publish_deps(*, name, outs, **kwargs):
-    """Generate all of the requirements files for all platforms."""
+def publish_deps(*, name, args, outs, **kwargs):
+    """Generate all of the requirements files for all platforms.
+
+    Args:
+        name: {type}`str`: the currently unused.
+        args: {type}`list[str]`: the common args to apply.
+        outs: {type}`dict[Label, str]`: the output files mapping to the platform
+            for each requirement file to be generated.
+        **kwargs: Extra args passed to the {rule}`lock` rule.
+    """
+    all_args = args
     for out, platform in outs.items():
+        args = [] + all_args
+        if platform:
+            args.append("--python-platform=" + platform)
+        else:
+            args.append("--universal")
+
         lock(
             name = out.replace(".txt", ""),
             out = out,
-            universal = platform == "",
-            args = [] if not platform else ["--python-platform=" + platform],
+            args = args,
             **kwargs
         )
diff --git a/tools/publish/BUILD.bazel b/tools/publish/BUILD.bazel
index 4cf99e4d97..2f02809ccd 100644
--- a/tools/publish/BUILD.bazel
+++ b/tools/publish/BUILD.bazel
@@ -33,6 +33,9 @@ publish_deps(
         "requirements_universal.txt": "",  # universal
         "requirements_windows.txt": "windows",
     },
-    upgrade = True,
+    args = [
+        "--emit-index-url",
+        "--upgrade",  # always upgrade
+    ],
     visibility = ["//private:__pkg__"],
 )