feat: add a tool to update internal dependencies (#1321)

Before this change the updates to the dependencies would happen very
seldomly, with this script, I propose we do it before each minor version
release. Adding a shell script and adding a reminder to the release
process may help with that.
diff --git a/DEVELOPING.md b/DEVELOPING.md
index 2972d96..3c9e89d 100644
--- a/DEVELOPING.md
+++ b/DEVELOPING.md
@@ -1,5 +1,16 @@
 # For Developers
 
+## Updating internal dependencies
+
+1. Modify the `./python/pip_install/tools/requirements.txt` file and run:
+   ```
+   bazel run //tools/private/update_deps:update_pip_deps
+   ```
+1. Bump the coverage dependencies using the script using:
+   ```
+   bazel run //tools/private/update_deps:update_coverage_deps <VERSION>
+   ```
+
 ## Releasing
 
 Start from a clean checkout at `main`.
diff --git a/MODULE.bazel b/MODULE.bazel
index b7a0411..aaa5c86 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -15,6 +15,7 @@
 internal_deps.install()
 use_repo(
     internal_deps,
+    # START: maintained by 'bazel run //tools/private:update_pip_deps'
     "pypi__build",
     "pypi__click",
     "pypi__colorama",
@@ -29,6 +30,7 @@
     "pypi__tomli",
     "pypi__wheel",
     "pypi__zipp",
+    # END: maintained by 'bazel run //tools/private:update_pip_deps'
 )
 
 # We need to do another use_extension call to expose the "pythons_hub"
diff --git a/python/pip_install/BUILD.bazel b/python/pip_install/BUILD.bazel
index 179fd62..4e4fbb4 100644
--- a/python/pip_install/BUILD.bazel
+++ b/python/pip_install/BUILD.bazel
@@ -10,6 +10,18 @@
 )
 
 filegroup(
+    name = "repositories",
+    srcs = ["repositories.bzl"],
+    visibility = ["//tools/private/update_deps:__pkg__"],
+)
+
+filegroup(
+    name = "requirements_txt",
+    srcs = ["tools/requirements.txt"],
+    visibility = ["//tools/private/update_deps:__pkg__"],
+)
+
+filegroup(
     name = "bzl",
     srcs = glob(["*.bzl"]) + [
         "//python/pip_install/private:bzl_srcs",
diff --git a/python/pip_install/repositories.bzl b/python/pip_install/repositories.bzl
index efe3bc7..4b209b3 100644
--- a/python/pip_install/repositories.bzl
+++ b/python/pip_install/repositories.bzl
@@ -20,6 +20,7 @@
 load("//:version.bzl", "MINIMUM_BAZEL_VERSION")
 
 _RULE_DEPS = [
+    # START: maintained by 'bazel run //tools/private:update_pip_deps'
     (
         "pypi__build",
         "https://files.pythonhosted.org/packages/03/97/f58c723ff036a8d8b4d3115377c0a37ed05c1f68dd9a0d66dab5e82c5c1c/build-0.9.0-py3-none-any.whl",
@@ -36,11 +37,21 @@
         "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6",
     ),
     (
+        "pypi__importlib_metadata",
+        "https://files.pythonhosted.org/packages/d7/31/74dcb59a601b95fce3b0334e8fc9db758f78e43075f22aeb3677dfb19f4c/importlib_metadata-1.4.0-py2.py3-none-any.whl",
+        "bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359",
+    ),
+    (
         "pypi__installer",
         "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl",
         "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53",
     ),
     (
+        "pypi__more_itertools",
+        "https://files.pythonhosted.org/packages/bd/3f/c4b3dbd315e248f84c388bd4a72b131a29f123ecacc37ffb2b3834546e42/more_itertools-8.13.0-py3-none-any.whl",
+        "c5122bffc5f104d37c1626b8615b511f3427aa5389b94d61e5ef8236bfbc3ddb",
+    ),
+    (
         "pypi__packaging",
         "https://files.pythonhosted.org/packages/8f/7b/42582927d281d7cb035609cd3a543ffac89b74f3f4ee8e1c50914bcb57eb/packaging-22.0-py3-none-any.whl",
         "957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3",
@@ -76,20 +87,11 @@
         "b60533f3f5d530e971d6737ca6d58681ee434818fab630c83a734bb10c083ce8",
     ),
     (
-        "pypi__importlib_metadata",
-        "https://files.pythonhosted.org/packages/d7/31/74dcb59a601b95fce3b0334e8fc9db758f78e43075f22aeb3677dfb19f4c/importlib_metadata-1.4.0-py2.py3-none-any.whl",
-        "bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359",
-    ),
-    (
         "pypi__zipp",
         "https://files.pythonhosted.org/packages/f4/50/cc72c5bcd48f6e98219fc4a88a5227e9e28b81637a99c49feba1d51f4d50/zipp-1.0.0-py2.py3-none-any.whl",
         "8dda78f06bd1674bd8720df8a50bb47b6e1233c503a4eed8e7810686bde37656",
     ),
-    (
-        "pypi__more_itertools",
-        "https://files.pythonhosted.org/packages/bd/3f/c4b3dbd315e248f84c388bd4a72b131a29f123ecacc37ffb2b3834546e42/more_itertools-8.13.0-py3-none-any.whl",
-        "c5122bffc5f104d37c1626b8615b511f3427aa5389b94d61e5ef8236bfbc3ddb",
-    ),
+    # END: maintained by 'bazel run //tools/private:update_pip_deps'
 ]
 
 _GENERIC_WHEEL = """\
diff --git a/python/pip_install/tools/requirements.txt b/python/pip_install/tools/requirements.txt
new file mode 100755
index 0000000..e8de112
--- /dev/null
+++ b/python/pip_install/tools/requirements.txt
@@ -0,0 +1,14 @@
+build==0.9
+click==8.0.1
+colorama
+importlib_metadata==1.4.0
+installer
+more_itertools==8.13.0
+packaging==22.0
+pep517
+pip==22.3.1
+pip_tools==6.12.1
+setuptools==60.10
+tomli
+wheel==0.38.4
+zipp==1.0.0
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 10af17e..7220ccf 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -24,6 +24,12 @@
     visibility = ["//python:__pkg__"],
 )
 
+filegroup(
+    name = "coverage_deps",
+    srcs = ["coverage_deps.bzl"],
+    visibility = ["//tools/private/update_deps:__pkg__"],
+)
+
 # Filegroup of bzl files that can be used by downstream rules for documentation generation
 filegroup(
     name = "bzl",
diff --git a/python/private/coverage_deps.bzl b/python/private/coverage_deps.bzl
index 93938e9..863d496 100644
--- a/python/private/coverage_deps.bzl
+++ b/python/private/coverage_deps.bzl
@@ -19,8 +19,7 @@
 load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
 load("//python/private:version_label.bzl", "version_label")
 
-# Update with './tools/update_coverage_deps.py <version>'
-#START: managed by update_coverage_deps.py script
+# START: maintained by 'bazel run //tools/private:update_coverage_deps'
 _coverage_deps = {
     "cp310": {
         "aarch64-apple-darwin": (
@@ -95,7 +94,7 @@
         ),
     },
 }
-#END: managed by update_coverage_deps.py script
+# END: maintained by 'bazel run //tools/private:update_coverage_deps'
 
 _coverage_patch = Label("//python/private:coverage.patch")
 
diff --git a/tools/private/update_deps/BUILD.bazel b/tools/private/update_deps/BUILD.bazel
new file mode 100644
index 0000000..2ab7cc7
--- /dev/null
+++ b/tools/private/update_deps/BUILD.bazel
@@ -0,0 +1,76 @@
+# Copyright 2017 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("//python:py_binary.bzl", "py_binary")
+load("//python:py_library.bzl", "py_library")
+load("//python:py_test.bzl", "py_test")
+
+licenses(["notice"])
+
+py_library(
+    name = "args",
+    srcs = ["args.py"],
+    imports = ["../../.."],
+    deps = ["//python/runfiles"],
+)
+
+py_library(
+    name = "update_file",
+    srcs = ["update_file.py"],
+    imports = ["../../.."],
+)
+
+py_binary(
+    name = "update_coverage_deps",
+    srcs = ["update_coverage_deps.py"],
+    data = [
+        "//python/private:coverage_deps",
+    ],
+    env = {
+        "UPDATE_FILE": "$(rlocationpath //python/private:coverage_deps)",
+    },
+    imports = ["../../.."],
+    deps = [
+        ":args",
+        ":update_file",
+    ],
+)
+
+py_binary(
+    name = "update_pip_deps",
+    srcs = ["update_pip_deps.py"],
+    data = [
+        "//:MODULE.bazel",
+        "//python/pip_install:repositories",
+        "//python/pip_install:requirements_txt",
+    ],
+    env = {
+        "MODULE_BAZEL": "$(rlocationpath //:MODULE.bazel)",
+        "REPOSITORIES_BZL": "$(rlocationpath //python/pip_install:repositories)",
+        "REQUIREMENTS_TXT": "$(rlocationpath //python/pip_install:requirements_txt)",
+    },
+    imports = ["../../.."],
+    deps = [
+        ":args",
+        ":update_file",
+    ],
+)
+
+py_test(
+    name = "update_file_test",
+    srcs = ["update_file_test.py"],
+    imports = ["../../.."],
+    deps = [
+        ":update_file",
+    ],
+)
diff --git a/tools/private/update_deps/args.py b/tools/private/update_deps/args.py
new file mode 100644
index 0000000..293294c
--- /dev/null
+++ b/tools/private/update_deps/args.py
@@ -0,0 +1,35 @@
+# Copyright 2023 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.
+
+"""A small library for common arguments when updating files."""
+
+import pathlib
+
+from python.runfiles import runfiles
+
+
+def path_from_runfiles(input: str) -> pathlib.Path:
+    """A helper to create a path from runfiles.
+
+    Args:
+        input: the string input to construct a path.
+
+    Returns:
+        the pathlib.Path path to a file which is verified to exist.
+    """
+    path = pathlib.Path(runfiles.Create().Rlocation(input))
+    if not path.exists():
+        raise ValueError(f"Path '{path}' does not exist")
+
+    return path
diff --git a/tools/update_coverage_deps.py b/tools/private/update_deps/update_coverage_deps.py
similarity index 75%
rename from tools/update_coverage_deps.py
rename to tools/private/update_deps/update_coverage_deps.py
index 57b7850..72baa44 100755
--- a/tools/update_coverage_deps.py
+++ b/tools/private/update_deps/update_coverage_deps.py
@@ -22,6 +22,7 @@
 import argparse
 import difflib
 import json
+import os
 import pathlib
 import sys
 import textwrap
@@ -30,6 +31,9 @@
 from typing import Any
 from urllib import request
 
+from tools.private.update_deps.args import path_from_runfiles
+from tools.private.update_deps.update_file import update_file
+
 # This should be kept in sync with //python:versions.bzl
 _supported_platforms = {
     # Windows is unsupported right now
@@ -110,64 +114,6 @@
     )
 
 
-def _writelines(path: pathlib.Path, lines: list[str]):
-    with open(path, "w") as f:
-        f.writelines(lines)
-
-
-def _difflines(path: pathlib.Path, lines: list[str]):
-    with open(path) as f:
-        input = f.readlines()
-
-    rules_python = pathlib.Path(__file__).parent.parent
-    p = path.relative_to(rules_python)
-
-    print(f"Diff of the changes that would be made to '{p}':")
-    for line in difflib.unified_diff(
-        input,
-        lines,
-        fromfile=f"a/{p}",
-        tofile=f"b/{p}",
-    ):
-        print(line, end="")
-
-    # Add an empty line at the end of the diff
-    print()
-
-
-def _update_file(
-    path: pathlib.Path,
-    snippet: str,
-    start_marker: str,
-    end_marker: str,
-    dry_run: bool = True,
-):
-    with open(path) as f:
-        input = f.readlines()
-
-    out = []
-    skip = False
-    for line in input:
-        if skip:
-            if not line.startswith(end_marker):
-                continue
-
-            skip = False
-
-        out.append(line)
-
-        if not line.startswith(start_marker):
-            continue
-
-        skip = True
-        out.extend([f"{line}\n" for line in snippet.splitlines()])
-
-    if dry_run:
-        _difflines(path, out)
-    else:
-        _writelines(path, out)
-
-
 def _parse_args() -> argparse.Namespace:
     parser = argparse.ArgumentParser(__doc__)
     parser.add_argument(
@@ -193,6 +139,12 @@
         action="store_true",
         help="Wether to write to files",
     )
+    parser.add_argument(
+        "--update-file",
+        type=path_from_runfiles,
+        default=os.environ.get("UPDATE_FILE"),
+        help="The path for the file to be updated, defaults to the value taken from UPDATE_FILE",
+    )
     return parser.parse_args()
 
 
@@ -230,14 +182,12 @@
 
     urls.sort(key=lambda x: f"{x.python}_{x.platform}")
 
-    rules_python = pathlib.Path(__file__).parent.parent
-
     # Update the coverage_deps, which are used to register deps
-    _update_file(
-        path=rules_python / "python" / "private" / "coverage_deps.bzl",
+    update_file(
+        path=args.update_file,
         snippet=f"_coverage_deps = {repr(Deps(urls))}\n",
-        start_marker="#START: managed by update_coverage_deps.py script",
-        end_marker="#END: managed by update_coverage_deps.py script",
+        start_marker="# START: maintained by 'bazel run //tools/private:update_coverage_deps'",
+        end_marker="# END: maintained by 'bazel run //tools/private:update_coverage_deps'",
         dry_run=args.dry_run,
     )
 
diff --git a/tools/private/update_deps/update_file.py b/tools/private/update_deps/update_file.py
new file mode 100644
index 0000000..ab3e8a8
--- /dev/null
+++ b/tools/private/update_deps/update_file.py
@@ -0,0 +1,114 @@
+# Copyright 2023 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.
+
+"""A small library to update bazel files within the repo.
+
+This is reused in other files updating coverage deps and pip deps.
+"""
+
+import argparse
+import difflib
+import pathlib
+import sys
+
+
+def _writelines(path: pathlib.Path, out: str):
+    with open(path, "w") as f:
+        f.write(out)
+
+
+def unified_diff(name: str, a: str, b: str) -> str:
+    return "".join(
+        difflib.unified_diff(
+            a.splitlines(keepends=True),
+            b.splitlines(keepends=True),
+            fromfile=f"a/{name}",
+            tofile=f"b/{name}",
+        )
+    ).strip()
+
+
+def replace_snippet(
+    current: str,
+    snippet: str,
+    start_marker: str,
+    end_marker: str,
+) -> str:
+    """Update a file on disk to replace text in a file between two markers.
+
+    Args:
+        path: pathlib.Path, the path to the file to be modified.
+        snippet: str, the snippet of code to insert between the markers.
+        start_marker: str, the text that marks the start of the region to be replaced.
+        end_markr: str, the text that marks the end of the region to be replaced.
+        dry_run: bool, if set to True, then the file will not be written and instead we are going to print a diff to
+            stdout.
+    """
+    lines = []
+    skip = False
+    found_match = False
+    for line in current.splitlines(keepends=True):
+        if line.lstrip().startswith(start_marker.lstrip()):
+            found_match = True
+            lines.append(line)
+            lines.append(snippet.rstrip() + "\n")
+            skip = True
+        elif skip and line.lstrip().startswith(end_marker):
+            skip = False
+            lines.append(line)
+            continue
+        elif not skip:
+            lines.append(line)
+
+    if not found_match:
+        raise RuntimeError(f"Start marker '{start_marker}' was not found")
+    if skip:
+        raise RuntimeError(f"End marker '{end_marker}' was not found")
+
+    return "".join(lines)
+
+
+def update_file(
+    path: pathlib.Path,
+    snippet: str,
+    start_marker: str,
+    end_marker: str,
+    dry_run: bool = True,
+):
+    """update a file on disk to replace text in a file between two markers.
+
+    Args:
+        path: pathlib.Path, the path to the file to be modified.
+        snippet: str, the snippet of code to insert between the markers.
+        start_marker: str, the text that marks the start of the region to be replaced.
+        end_markr: str, the text that marks the end of the region to be replaced.
+        dry_run: bool, if set to True, then the file will not be written and instead we are going to print a diff to
+            stdout.
+    """
+    current = path.read_text()
+    out = replace_snippet(current, snippet, start_marker, end_marker)
+
+    if not dry_run:
+        _writelines(path, out)
+        return
+
+    relative = path.relative_to(
+        pathlib.Path(__file__).resolve().parent.parent.parent.parent
+    )
+    name = f"{relative}"
+    diff = unified_diff(name, current, out)
+    if diff:
+        print(f"Diff of the changes that would be made to '{name}':\n{diff}")
+    else:
+        print(f"'{name}' is up to date")
diff --git a/tools/private/update_deps/update_file_test.py b/tools/private/update_deps/update_file_test.py
new file mode 100644
index 0000000..01c6ec7
--- /dev/null
+++ b/tools/private/update_deps/update_file_test.py
@@ -0,0 +1,128 @@
+# Copyright 2023 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.
+
+import unittest
+
+from tools.private.update_deps.update_file import replace_snippet, unified_diff
+
+
+class TestReplaceSnippet(unittest.TestCase):
+    def test_replace_simple(self):
+        current = """\
+Before the snippet
+
+# Start marker
+To be replaced
+It may have the '# Start marker' or '# End marker' in the middle,
+But it has to be in the beginning of the line to mark the end of a region.
+# End marker
+
+After the snippet
+"""
+        snippet = "Replaced"
+        got = replace_snippet(
+            current=current,
+            snippet="Replaced",
+            start_marker="# Start marker",
+            end_marker="# End marker",
+        )
+
+        want = """\
+Before the snippet
+
+# Start marker
+Replaced
+# End marker
+
+After the snippet
+"""
+        self.assertEqual(want, got)
+
+    def test_replace_indented(self):
+        current = """\
+Before the snippet
+
+    # Start marker
+    To be replaced
+    # End marker
+
+After the snippet
+"""
+        got = replace_snippet(
+            current=current,
+            snippet="    Replaced",
+            start_marker="# Start marker",
+            end_marker="# End marker",
+        )
+
+        want = """\
+Before the snippet
+
+    # Start marker
+    Replaced
+    # End marker
+
+After the snippet
+"""
+        self.assertEqual(want, got)
+
+    def test_raises_if_start_is_not_found(self):
+        with self.assertRaises(RuntimeError) as exc:
+            replace_snippet(
+                current="foo",
+                snippet="",
+                start_marker="start",
+                end_marker="end",
+            )
+
+        self.assertEqual(exc.exception.args[0], "Start marker 'start' was not found")
+
+    def test_raises_if_end_is_not_found(self):
+        with self.assertRaises(RuntimeError) as exc:
+            replace_snippet(
+                current="start",
+                snippet="",
+                start_marker="start",
+                end_marker="end",
+            )
+
+        self.assertEqual(exc.exception.args[0], "End marker 'end' was not found")
+
+
+class TestUnifiedDiff(unittest.TestCase):
+    def test_diff(self):
+        give_a = """\
+First line
+second line
+Third line
+"""
+        give_b = """\
+First line
+Second line
+Third line
+"""
+        got = unified_diff("filename", give_a, give_b)
+        want = """\
+--- a/filename
++++ b/filename
+@@ -1,3 +1,3 @@
+ First line
+-second line
++Second line
+ Third line"""
+        self.assertEqual(want, got)
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/private/update_deps/update_pip_deps.py b/tools/private/update_deps/update_pip_deps.py
new file mode 100755
index 0000000..8a2dd5f
--- /dev/null
+++ b/tools/private/update_deps/update_pip_deps.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+# Copyright 2023 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.
+
+"""A script to manage internal pip dependencies."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import pathlib
+import re
+import sys
+import tempfile
+import textwrap
+from dataclasses import dataclass
+
+from pip._internal.cli.main import main as pip_main
+
+from tools.private.update_deps.args import path_from_runfiles
+from tools.private.update_deps.update_file import update_file
+
+
+@dataclass
+class Dep:
+    name: str
+    url: str
+    sha256: str
+
+
+def _dep_snippet(deps: list[Dep]) -> str:
+    lines = []
+    for dep in deps:
+        lines.extend(
+            [
+                "(\n",
+                f'    "{dep.name}",\n',
+                f'    "{dep.url}",\n',
+                f'    "{dep.sha256}",\n',
+                "),\n",
+            ]
+        )
+
+    return textwrap.indent("".join(lines), " " * 4)
+
+
+def _module_snippet(deps: list[Dep]) -> str:
+    lines = []
+    for dep in deps:
+        lines.append(f'"{dep.name}",\n')
+
+    return textwrap.indent("".join(lines), " " * 4)
+
+
+def _generate_report(requirements_txt: pathlib.Path) -> dict:
+    with tempfile.NamedTemporaryFile() as tmp:
+        tmp_path = pathlib.Path(tmp.name)
+        sys.argv = [
+            "pip",
+            "install",
+            "--dry-run",
+            "--ignore-installed",
+            "--report",
+            f"{tmp_path}",
+            "-r",
+            f"{requirements_txt}",
+        ]
+        pip_main()
+        with open(tmp_path) as f:
+            return json.load(f)
+
+
+def _get_deps(report: dict) -> list[Dep]:
+    deps = []
+    for dep in report["install"]:
+        try:
+            dep = Dep(
+                name="pypi__"
+                + re.sub(
+                    "[._-]+",
+                    "_",
+                    dep["metadata"]["name"],
+                ),
+                url=dep["download_info"]["url"],
+                sha256=dep["download_info"]["archive_info"]["hash"][len("sha256=") :],
+            )
+        except:
+            debug_dep = textwrap.indent(json.dumps(dep, indent=4), " " * 4)
+            print(f"Could not parse the response from 'pip':\n{debug_dep}")
+            raise
+
+        deps.append(dep)
+
+    return sorted(deps, key=lambda dep: dep.name)
+
+
+def main():
+    parser = argparse.ArgumentParser(__doc__)
+    parser.add_argument(
+        "--start",
+        type=str,
+        default="# START: maintained by 'bazel run //tools/private:update_pip_deps'",
+        help="The text to match in a file when updating them.",
+    )
+    parser.add_argument(
+        "--end",
+        type=str,
+        default="# END: maintained by 'bazel run //tools/private:update_pip_deps'",
+        help="The text to match in a file when updating them.",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="Wether to write to files",
+    )
+    parser.add_argument(
+        "--requirements-txt",
+        type=path_from_runfiles,
+        default=os.environ.get("REQUIREMENTS_TXT"),
+        help="The requirements.txt path for the pip_install tools, defaults to the value taken from REQUIREMENTS_TXT",
+    )
+    parser.add_argument(
+        "--module-bazel",
+        type=path_from_runfiles,
+        default=os.environ.get("MODULE_BAZEL"),
+        help="The path for the file to be updated, defaults to the value taken from MODULE_BAZEL",
+    )
+    parser.add_argument(
+        "--repositories-bzl",
+        type=path_from_runfiles,
+        default=os.environ.get("REPOSITORIES_BZL"),
+        help="The path for the file to be updated, defaults to the value taken from REPOSITORIES_BZL",
+    )
+    args = parser.parse_args()
+
+    report = _generate_report(args.requirements_txt)
+    deps = _get_deps(report)
+
+    update_file(
+        path=args.repositories_bzl,
+        snippet=_dep_snippet(deps),
+        start_marker=args.start,
+        end_marker=args.end,
+        dry_run=args.dry_run,
+    )
+
+    update_file(
+        path=args.module_bazel,
+        snippet=_module_snippet(deps),
+        start_marker=args.start,
+        end_marker=args.end,
+        dry_run=args.dry_run,
+    )
+
+
+if __name__ == "__main__":
+    main()