fix: checked-in requirements imports generated requirements (#1053)

* fix: checked-in requirements imports generated requirements

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: shutil.copy instead of shutil.copyfile

This allows copying from one filesystem to another, as the `os.rename` (used by copyfile)
doesn't work with multiple filesystems.

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: patch os.replace to use shutil.copy

Same as the previous commit.

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: runfiles.Rlocation requires paths to be normalized

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: drop rules_python from import

This is not compatible with bzlmod. Importing python.runfiles works for both
ways.

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: remove unnecessary runfiles

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* doc: why os.replace = shutil.copy

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: allow the test to still be remote cacheable

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* doc: why shutil.copy

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* doc: add missing punctuation

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: remove unnecessary _fix_up_requirements_in_path

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* test: make sure the locked requirements is updated

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: copy requirements back into src tree if needed

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

* fix: make sure windows uses forward slashes

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>

---------

Signed-off-by: Thulio Ferraz Assis <3149049+f0rmiga@users.noreply.github.com>
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index 706c655..5f92fbf 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -289,21 +289,49 @@
     name: compile_pip_requirements integration tests on Ubuntu
     working_directory: tests/compile_pip_requirements
     platform: ubuntu2004
+    shell_commands:
+    # Make a change to the locked requirements and then assert that //:requirements.update does the
+    # right thing.
+    - "echo '' > requirements_lock.txt"
+    - "! git diff --exit-code"
+    - "bazel run //:requirements.update"
+    - "git diff --exit-code"
   integration_test_compile_pip_requirements_debian:
     <<: *reusable_build_test_all
     name: compile_pip_requirements integration tests on Debian
     working_directory: tests/compile_pip_requirements
     platform: debian11
+    shell_commands:
+    # Make a change to the locked requirements and then assert that //:requirements.update does the
+    # right thing.
+    - "echo '' > requirements_lock.txt"
+    - "! git diff --exit-code"
+    - "bazel run //:requirements.update"
+    - "git diff --exit-code"
   integration_test_compile_pip_requirements_macos:
     <<: *reusable_build_test_all
     name: compile_pip_requirements integration tests on macOS
     working_directory: tests/compile_pip_requirements
     platform: macos
+    shell_commands:
+    # Make a change to the locked requirements and then assert that //:requirements.update does the
+    # right thing.
+    - "echo '' > requirements_lock.txt"
+    - "! git diff --exit-code"
+    - "bazel run //:requirements.update"
+    - "git diff --exit-code"
   integration_test_compile_pip_requirements_windows:
     <<: *reusable_build_test_all
     name: compile_pip_requirements integration tests on Windows
     working_directory: tests/compile_pip_requirements
     platform: windows
+    shell_commands:
+    # Make a change to the locked requirements and then assert that //:requirements.update does the
+    # right thing.
+    - "echo '' > requirements_lock.txt"
+    - "! git diff --exit-code"
+    - "bazel run //:requirements.update"
+    - "git diff --exit-code"
 
   integration_test_pip_repository_entry_points_ubuntu:
     <<: *reusable_build_test_all
diff --git a/python/pip_install/requirements.bzl b/python/pip_install/requirements.bzl
index 51e34a2..af3c194 100644
--- a/python/pip_install/requirements.bzl
+++ b/python/pip_install/requirements.bzl
@@ -103,6 +103,8 @@
 
     tags = tags or []
     tags.append("requires-network")
+    tags.append("no-remote-exec")
+    tags.append("no-sandbox")
     attrs = {
         "args": args,
         "data": data,
diff --git a/python/pip_install/tools/dependency_resolver/dependency_resolver.py b/python/pip_install/tools/dependency_resolver/dependency_resolver.py
index db84977..e636feb 100644
--- a/python/pip_install/tools/dependency_resolver/dependency_resolver.py
+++ b/python/pip_install/tools/dependency_resolver/dependency_resolver.py
@@ -14,14 +14,39 @@
 
 "Set defaults for the pip-compile command to run it under Bazel"
 
+import atexit
 import os
-import re
+import shutil
 import sys
 from pathlib import Path
-from shutil import copyfile
 
+import piptools.writer as piptools_writer
 from piptools.scripts.compile import cli
 
+# Replace the os.replace function with shutil.copy to work around os.replace not being able to
+# replace or move files across filesystems.
+os.replace = shutil.copy
+
+# Next, we override the annotation_style_split and annotation_style_line functions to replace the
+# backslashes in the paths with forward slashes. This is so that we can have the same requirements
+# file on Windows and Unix-like.
+original_annotation_style_split = piptools_writer.annotation_style_split
+original_annotation_style_line = piptools_writer.annotation_style_line
+
+
+def annotation_style_split(required_by) -> str:
+    required_by = set([v.replace("\\", "/") for v in required_by])
+    return original_annotation_style_split(required_by)
+
+
+def annotation_style_line(required_by) -> str:
+    required_by = set([v.replace("\\", "/") for v in required_by])
+    return original_annotation_style_line(required_by)
+
+
+piptools_writer.annotation_style_split = annotation_style_split
+piptools_writer.annotation_style_line = annotation_style_line
+
 
 def _select_golden_requirements_file(
     requirements_txt, requirements_linux, requirements_darwin, requirements_windows
@@ -41,19 +66,6 @@
         return requirements_txt
 
 
-def _fix_up_requirements_in_path(absolute_prefix, output_file):
-    """Fix up references to the input file inside of the generated requirements file.
-
-    We don't want fully resolved, absolute paths in the generated requirements file.
-    The paths could differ for every invocation. Replace them with a predictable path.
-    """
-    output_file = Path(output_file)
-    contents = output_file.read_text()
-    contents = contents.replace(absolute_prefix, "")
-    contents = re.sub(r"\\(?!(\n|\r\n))", "/", contents)
-    output_file.write_text(contents)
-
-
 if __name__ == "__main__":
     if len(sys.argv) < 4:
         print(
@@ -75,7 +87,6 @@
     # absolute prefixes in the locked requirements output file.
     requirements_in_path = Path(requirements_in)
     resolved_requirements_in = str(requirements_in_path.resolve())
-    absolute_prefix = resolved_requirements_in[: -len(str(requirements_in_path))]
 
     # Before loading click, set the locale for its parser.
     # If it leaks through to the system setting, it may fail:
@@ -86,7 +97,7 @@
     os.environ["LANG"] = "C.UTF-8"
 
     UPDATE = True
-    # Detect if we are running under `bazel test`
+    # Detect if we are running under `bazel test`.
     if "TEST_TMPDIR" in os.environ:
         UPDATE = False
         # pip-compile wants the cache files to be writeable, but if we point
@@ -95,31 +106,13 @@
         # In theory this makes the test more hermetic as well.
         sys.argv.append("--cache-dir")
         sys.argv.append(os.environ["TEST_TMPDIR"])
-        # Make a copy for pip-compile to read and mutate
+        # Make a copy for pip-compile to read and mutate.
         requirements_out = os.path.join(
             os.environ["TEST_TMPDIR"], os.path.basename(requirements_txt) + ".out"
         )
-        copyfile(requirements_txt, requirements_out)
-
-    elif "BUILD_WORKSPACE_DIRECTORY" in os.environ:
-        # This value, populated when running under `bazel run`, is a path to the
-        # "root of the workspace where the build was run."
-        # This matches up with the values passed in via the macro using the 'rootpath' Make variable,
-        # which for source files provides a path "relative to your workspace root."
-        #
-        # Changing to the WORKSPACE root avoids 'file not found' errors when the `.update` target is run
-        # from different directories within the WORKSPACE.
-        os.chdir(os.environ["BUILD_WORKSPACE_DIRECTORY"])
-    else:
-        err_msg = (
-            "Expected to find BUILD_WORKSPACE_DIRECTORY (running under `bazel run`) or "
-            "TEST_TMPDIR (running under `bazel test`) in environment."
-        )
-        print(
-            err_msg,
-            file=sys.stderr,
-        )
-        sys.exit(1)
+        # Those two files won't necessarily be on the same filesystem, so we can't use os.replace
+        # or shutil.copyfile, as they will fail with OSError: [Errno 18] Invalid cross-device link.
+        shutil.copy(requirements_txt, requirements_out)
 
     update_command = os.getenv("CUSTOM_COMPILE_COMMAND") or "bazel run %s" % (
         update_target_label,
@@ -137,12 +130,17 @@
 
     if UPDATE:
         print("Updating " + requirements_txt)
-        try:
-            cli()
-        except SystemExit as e:
-            if e.code == 0:
-                _fix_up_requirements_in_path(absolute_prefix, requirements_txt)
-            raise
+        if "BUILD_WORKSPACE_DIRECTORY" in os.environ:
+            workspace = os.environ["BUILD_WORKSPACE_DIRECTORY"]
+            requirements_txt_tree = os.path.join(workspace, requirements_txt)
+            # In most cases, requirements_txt will be a symlink to the real file in the source tree.
+            # If symlinks are not enabled (e.g. on Windows), then requirements_txt will be a copy,
+            # and we should copy the updated requirements back to the source tree.
+            if not os.path.samefile(requirements_txt, requirements_txt_tree):
+                atexit.register(
+                    lambda: shutil.copy(requirements_txt, requirements_txt_tree)
+                )
+        cli()
     else:
         # cli will exit(0) on success
         try:
@@ -160,7 +158,6 @@
                 )
                 sys.exit(1)
             elif e.code == 0:
-                _fix_up_requirements_in_path(absolute_prefix, requirements_out)
                 golden_filename = _select_golden_requirements_file(
                     requirements_txt,
                     requirements_linux,
diff --git a/tests/compile_pip_requirements/BUILD.bazel b/tests/compile_pip_requirements/BUILD.bazel
index 3a67dcc..d6ac008 100644
--- a/tests/compile_pip_requirements/BUILD.bazel
+++ b/tests/compile_pip_requirements/BUILD.bazel
@@ -22,12 +22,13 @@
 compile_pip_requirements(
     name = "requirements",
     data = [
+        "requirements.in",
         "requirements_extra.in",
     ],
     extra_args = [
         "--allow-unsafe",
         "--resolver=backtracking",
     ],
-    requirements_in = "requirements.in",
+    requirements_in = "requirements.txt",
     requirements_txt = "requirements_lock.txt",
 )
diff --git a/tests/compile_pip_requirements/requirements.txt b/tests/compile_pip_requirements/requirements.txt
new file mode 100644
index 0000000..4826399
--- /dev/null
+++ b/tests/compile_pip_requirements/requirements.txt
@@ -0,0 +1 @@
+-r requirements.in
diff --git a/tools/publish/BUILD.bazel b/tools/publish/BUILD.bazel
index 8c4b3ab..065e56b 100644
--- a/tools/publish/BUILD.bazel
+++ b/tools/publish/BUILD.bazel
@@ -4,12 +4,4 @@
     name = "requirements",
     requirements_darwin = "requirements_darwin.txt",
     requirements_windows = "requirements_windows.txt",
-    # This fails on RBE right now, and we don't need coverage there:
-    # WARNING: Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None))
-    # after connection broken by 'NewConnectionError('<pip._vendor.urllib3.connection.HTTPSConnection object at 0x7f3784e08110>:
-    # Failed to establish a new connection: [Errno -3] Temporary failure in name resolution')': /simple/twine/
-    #
-    # ERROR: Could not find a version that satisfies the requirement twine==4.0.2
-    # (from -r tools/publish/requirements.in (line 1)) (from versions: none)
-    tags = ["no-remote-exec"],
 )