feat(py_console_script_binary)!: entry points with custom dependencies (#1363)

Add `py_console_script_binary`, a macro/rule that allows better
customization of how
entry points are generated. Notable features of it are:
* It allows passing in additional dependencies, which makes it easier
for plugin
    dependencies to be added to tools such as pylint or sphinx.
* The underlying `py_binary` rule can be passed in, allowing custom
rules,
such as the version-aware rules, to be used for the resulting binary.
* Entry point generation is based upon a wheel's `entry_points.txt`
file. This helps
avoid loading external repositories unless they're actually used, allows
entry
points to have better version-aware support, and allows bzlmod to
provide a
    supportable mechanism for entry points.

Because the expected common use case is an entry point for our pip
generated repos,
there is special logic to make that easy and concisely do. Usage of
`py_console_script_binary` is not tied to our pip code generation,
though, and users can
manually specify dependencies if they need to.

BREAKING CHANGE: This is a breaking change, but only for bzlmod users.
Note that
bzlmod support is still beta. Bzlmod users will need to replace using
`entry_point`
from `requirements.bzl` with loading `py_console_script_binary` and
defining the
entry point locally:

```
load("@rules_python//python/entry_points:py_console_script_binary.bzl, "py_console_script_binary")

py_console_script_binary(name="foo", pkg="@mypip//pylint")
```

For workspace users, this new macro is available to be used, but the old
code is still
present.

Fixes #1362
Fixes #543
Fixes #979
Fixes #1262
Closes #980
Closes #1294
Closes #1055

---------

Co-authored-by: Richard Levasseur <richardlev@gmail.com>
diff --git a/.bazelrc b/.bazelrc
index 3a5497a..39b28d1 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -3,8 +3,8 @@
 # This lets us glob() up all the files inside the examples to make them inputs to tests
 # (Note, we cannot use `common --deleted_packages` because the bazel version command doesn't support it)
 # To update these lines, run tools/bazel_integration_test/update_deleted_packages.sh
-build --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/bzlmod/entry_point,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,tests/compile_pip_requirements,tests/compile_pip_requirements_test_from_external_workspace,tests/ignore_root_user_error,tests/pip_repository_entry_points
-query --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/bzlmod/entry_point,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,tests/compile_pip_requirements,tests/compile_pip_requirements_test_from_external_workspace,tests/ignore_root_user_error,tests/pip_repository_entry_points
+build --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,tests/compile_pip_requirements,tests/compile_pip_requirements_test_from_external_workspace,tests/ignore_root_user_error,tests/pip_repository_entry_points
+query --deleted_packages=examples/build_file_generation,examples/build_file_generation/random_number_generator,examples/bzlmod,examples/bzlmod/entry_points,examples/bzlmod/entry_points/tests,examples/bzlmod/libs/my_lib,examples/bzlmod/other_module,examples/bzlmod/other_module/other_module/pkg,examples/bzlmod/runfiles,examples/bzlmod/tests,examples/bzlmod/tests/other_module,examples/bzlmod/whl_mods,examples/bzlmod_build_file_generation,examples/bzlmod_build_file_generation/other_module/other_module/pkg,examples/bzlmod_build_file_generation/runfiles,examples/multi_python_versions/libs/my_lib,examples/multi_python_versions/requirements,examples/multi_python_versions/tests,examples/pip_install,examples/pip_parse,examples/pip_parse_vendored,examples/pip_repository_annotations,examples/py_proto_library,tests/compile_pip_requirements,tests/compile_pip_requirements_test_from_external_workspace,tests/ignore_root_user_error,tests/pip_repository_entry_points
 
 test --test_output=errors
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc86812..9e7b685 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,20 @@
 * Particular sub-systems are identified using parentheses, e.g. `(bzlmod)` or
   `(docs)`.
 
+## Unreleased
+
+### Added
+
+* (bzlmod, entry_point) Added
+  [`py_console_script_binary`](./docs/py_console_script_binary.md), which
+  allows adding custom dependencies to a package's entry points and customizing
+  the `py_binary` rule used to build it.
+
+### Removed
+
+* (bzlmod) The `entry_point` macro is no longer supported and has been removed
+  in favour of the `py_console_script_binary` macro for `bzlmod` users.
+
 ## [0.25.0] - 2023-08-22
 
 ### Changed
diff --git a/docs/BUILD.bazel b/docs/BUILD.bazel
index 1fb4f81..3a222ab 100644
--- a/docs/BUILD.bazel
+++ b/docs/BUILD.bazel
@@ -27,6 +27,7 @@
     "pip_repository": "//docs:pip-repository",
     "py_cc_toolchain": "//docs:py_cc_toolchain-docs",
     "py_cc_toolchain_info": "//docs:py_cc_toolchain_info-docs",
+    "py_console_script_binary": "//docs:py-console-script-binary",
     "python": "//docs:core-docs",
 }
 
@@ -129,6 +130,16 @@
 )
 
 stardoc(
+    name = "py-console-script-binary",
+    out = "py_console_script_binary.md_",
+    input = "//python/entry_points:py_console_script_binary.bzl",
+    target_compatible_with = _NOT_WINDOWS,
+    deps = [
+        "//python/entry_points:py_console_script_binary_bzl",
+    ],
+)
+
+stardoc(
     name = "packaging-docs",
     out = "packaging.md_",
     input = "//python:packaging.bzl",
diff --git a/docs/py_console_script_binary.md b/docs/py_console_script_binary.md
new file mode 100644
index 0000000..3d7b5e5
--- /dev/null
+++ b/docs/py_console_script_binary.md
@@ -0,0 +1,87 @@
+<!-- Generated with Stardoc: http://skydoc.bazel.build -->
+
+
+Creates an executable (a non-test binary) for console_script entry points.
+
+Generate a `py_binary` target for a particular console_script `entry_point`
+from a PyPI package, e.g. for creating an executable `pylint` target use:
+```starlark
+load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "pylint",
+    pkg = "@pip//pylint",
+)
+```
+
+Or for more advanced setups you can also specify extra dependencies and the
+exact script name you want to call. It is useful for tools like flake8, pylint,
+pytest, which have plugin discovery methods and discover dependencies from the
+PyPI packages available in the PYTHONPATH.
+```starlark
+load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "pylint_with_deps",
+    pkg = "@pip//pylint",
+    # Because `pylint` has multiple console_scripts available, we have to
+    # specify which we want if the name of the target name 'pylint_with_deps'
+    # cannot be used to guess the entry_point script.
+    script = "pylint",
+    deps = [
+        # One can add extra dependencies to the entry point.
+        # This specifically allows us to add plugins to pylint.
+        "@pip//pylint_print",
+    ],
+)
+```
+
+A specific Python version can be forced by using the generated version-aware
+wrappers, e.g. to force Python 3.9:
+```starlark
+load("@python_versions//3.9:defs.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "yamllint",
+    pkg = "@pip//yamllint",
+)
+```
+
+Alternatively, the the `py_console_script_binary.binary_rule` arg can be passed
+the version-bound `py_binary` symbol, or any other `py_binary`-compatible rule
+of your choosing:
+```starlark
+load("@python_versions//3.9:defs.bzl", "py_binary")
+load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "yamllint",
+    pkg = "@pip//yamllint:pkg",
+    binary_rule = py_binary,
+)
+```
+
+
+<a id="py_console_script_binary"></a>
+
+## py_console_script_binary
+
+<pre>
+py_console_script_binary(<a href="#py_console_script_binary-name">name</a>, <a href="#py_console_script_binary-pkg">pkg</a>, <a href="#py_console_script_binary-entry_points_txt">entry_points_txt</a>, <a href="#py_console_script_binary-script">script</a>, <a href="#py_console_script_binary-binary_rule">binary_rule</a>, <a href="#py_console_script_binary-kwargs">kwargs</a>)
+</pre>
+
+Generate a py_binary for a console_script entry_point.
+
+**PARAMETERS**
+
+
+| Name  | Description | Default Value |
+| :------------- | :------------- | :------------- |
+| <a id="py_console_script_binary-name"></a>name |  str, The name of the resulting target.   |  none |
+| <a id="py_console_script_binary-pkg"></a>pkg |  target, the package for which to generate the script.   |  none |
+| <a id="py_console_script_binary-entry_points_txt"></a>entry_points_txt |  optional target, the entry_points.txt file to parse for available console_script values. It may be a single file, or a group of files, but must contain a file named <code>entry_points.txt</code>. If not specified, defaults to the <code>dist_info</code> target in the same package as the <code>pkg</code> Label.   |  <code>None</code> |
+| <a id="py_console_script_binary-script"></a>script |  str, The console script name that the py_binary is going to be generated for. Defaults to the normalized name attribute.   |  <code>None</code> |
+| <a id="py_console_script_binary-binary_rule"></a>binary_rule |  callable, The rule/macro to use to instantiate the target. It's expected to behave like <code>py_binary</code>. Defaults to @rules_python//python:py_binary.bzl#py_binary.   |  <code>&lt;function py_binary&gt;</code> |
+| <a id="py_console_script_binary-kwargs"></a>kwargs |  Extra parameters forwarded to binary_rule.   |  none |
+
+
diff --git a/examples/bzlmod/MODULE.bazel b/examples/bzlmod/MODULE.bazel
index be9466d..0d1c7a7 100644
--- a/examples/bzlmod/MODULE.bazel
+++ b/examples/bzlmod/MODULE.bazel
@@ -113,10 +113,7 @@
         "@whl_mods_hub//:wheel.json": "wheel",
     },
 )
-
-# NOTE: The pip_39 repo is only used because the plain `@pip` repo doesn't
-# yet support entry points; see https://github.com/bazelbuild/rules_python/issues/1262
-use_repo(pip, "pip", "pip_39")
+use_repo(pip, "pip")
 
 bazel_dep(name = "other_module", version = "", repo_name = "our_other_module")
 local_path_override(
diff --git a/examples/bzlmod/entry_point/BUILD.bazel b/examples/bzlmod/entry_point/BUILD.bazel
deleted file mode 100644
index f68552c..0000000
--- a/examples/bzlmod/entry_point/BUILD.bazel
+++ /dev/null
@@ -1,20 +0,0 @@
-load("@pip_39//:requirements.bzl", "entry_point")
-load("@rules_python//python:defs.bzl", "py_test")
-
-alias(
-    name = "yamllint",
-    actual = entry_point("yamllint"),
-)
-
-py_test(
-    name = "entry_point_test",
-    srcs = ["test_entry_point.py"],
-    data = [
-        ":yamllint",
-    ],
-    env = {
-        "YAMLLINT_ENTRY_POINT": "$(rlocationpath :yamllint)",
-    },
-    main = "test_entry_point.py",
-    deps = ["@rules_python//python/runfiles"],
-)
diff --git a/examples/bzlmod/entry_points/BUILD.bazel b/examples/bzlmod/entry_points/BUILD.bazel
new file mode 100644
index 0000000..a0939cb
--- /dev/null
+++ b/examples/bzlmod/entry_points/BUILD.bazel
@@ -0,0 +1,33 @@
+load("@python_versions//3.9:defs.bzl", py_console_script_binary_3_9 = "py_console_script_binary")
+load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+# This is how you can define a `pylint` entrypoint which uses the default python version.
+py_console_script_binary(
+    name = "pylint",
+    pkg = "@pip//pylint",
+    visibility = ["//entry_points:__subpackages__"],
+)
+
+# We can also specify extra dependencies for the binary, which is useful for
+# tools like flake8, pylint, pytest, which have plugin discovery methods.
+py_console_script_binary(
+    name = "pylint_with_deps",
+    pkg = "@pip//pylint",
+    # Because `pylint` has multiple console_scripts available, we have to
+    # specify which we want if the name of the target name 'pylint_with_deps'
+    # cannot be used to guess the entry_point script.
+    script = "pylint",
+    visibility = ["//entry_points:__subpackages__"],
+    deps = [
+        # One can add extra dependencies to the entry point.
+        "@pip//pylint_print",
+    ],
+)
+
+# A specific Python version can be forced by using the generated version-aware
+# wrappers, e.g. to force Python 3.9:
+py_console_script_binary_3_9(
+    name = "yamllint",
+    pkg = "@pip//yamllint:pkg",
+    visibility = ["//entry_points:__subpackages__"],
+)
diff --git a/examples/bzlmod/entry_points/tests/BUILD.bazel b/examples/bzlmod/entry_points/tests/BUILD.bazel
new file mode 100644
index 0000000..5a65e9e
--- /dev/null
+++ b/examples/bzlmod/entry_points/tests/BUILD.bazel
@@ -0,0 +1,63 @@
+load("@bazel_skylib//rules:run_binary.bzl", "run_binary")
+load("@rules_python//python:defs.bzl", "py_test")
+
+# Below are targets for testing the `py_console_script_binary` feature and are
+# not part of the example how to use the feature.
+
+# And a test that we can correctly run `pylint --version`
+py_test(
+    name = "pylint_test",
+    srcs = ["pylint_test.py"],
+    data = ["//entry_points:pylint"],
+    env = {
+        "ENTRY_POINT": "$(rlocationpath //entry_points:pylint)",
+    },
+    deps = ["@rules_python//python/runfiles"],
+)
+
+# Next run pylint on the file to generate a report.
+run_binary(
+    name = "pylint_report",
+    srcs = [
+        ":file_with_pylint_errors.py",
+    ],
+    outs = ["pylint_report.txt"],
+    args = [
+        "--output-format=text:$(location pylint_report.txt)",
+        "--load-plugins=pylint_print",
+        # The `exit-zero` ensures that `run_binary` is successful even though there are lint errors.
+        # We check the generated report in the test below.
+        "--exit-zero",
+        "$(location :file_with_pylint_errors.py)",
+    ],
+    env = {
+        # otherwise it may try to create ${HOME}/.cache/pylint
+        "PYLINTHOME": "./.pylint_home",
+    },
+    tool = "//entry_points:pylint_with_deps",
+)
+
+py_test(
+    name = "pylint_deps_test",
+    srcs = ["pylint_deps_test.py"],
+    data = [
+        ":pylint_report",
+        "//entry_points:pylint_with_deps",
+    ],
+    env = {
+        "ENTRY_POINT": "$(rlocationpath //entry_points:pylint_with_deps)",
+        "PYLINT_REPORT": "$(rlocationpath :pylint_report)",
+    },
+    deps = ["@rules_python//python/runfiles"],
+)
+
+# And a test to check that yamllint works
+py_test(
+    name = "yamllint_test",
+    srcs = ["yamllint_test.py"],
+    data = ["//entry_points:yamllint"],
+    env = {
+        "ENTRY_POINT": "$(rlocationpath //entry_points:yamllint)",
+    },
+    deps = ["@rules_python//python/runfiles"],
+)
diff --git a/examples/bzlmod/entry_points/tests/file_with_pylint_errors.py b/examples/bzlmod/entry_points/tests/file_with_pylint_errors.py
new file mode 100644
index 0000000..bb3dbab
--- /dev/null
+++ b/examples/bzlmod/entry_points/tests/file_with_pylint_errors.py
@@ -0,0 +1,6 @@
+"""
+A file to demonstrate the pylint-print checker works.
+"""
+
+if __name__ == "__main__":
+    print("Hello, World!")
diff --git a/examples/bzlmod/entry_points/tests/pylint_deps_test.py b/examples/bzlmod/entry_points/tests/pylint_deps_test.py
new file mode 100644
index 0000000..f6743ce
--- /dev/null
+++ b/examples/bzlmod/entry_points/tests/pylint_deps_test.py
@@ -0,0 +1,72 @@
+# 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 os
+import pathlib
+import subprocess
+import tempfile
+import unittest
+
+from python.runfiles import runfiles
+
+
+class ExampleTest(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        self.maxDiff = None
+
+        super().__init__(*args, **kwargs)
+
+    def test_pylint_entry_point(self):
+        rlocation_path = os.environ.get("ENTRY_POINT")
+        assert (
+            rlocation_path is not None
+        ), "expected 'ENTRY_POINT' env variable to be set to rlocation of the tool"
+
+        entry_point = pathlib.Path(runfiles.Create().Rlocation(rlocation_path))
+        self.assertTrue(entry_point.exists(), f"'{entry_point}' does not exist")
+
+        # Let's run the entrypoint and check the tool version.
+        #
+        # NOTE @aignas 2023-08-24: the Windows python launcher with Python 3.9 and bazel 6 is not happy if we start
+        # passing extra files via `subprocess.run` and it starts to fail with an error that the file which is the
+        # entry_point cannot be found. However, just calling `--version` seems to be fine.
+        proc = subprocess.run(
+            [str(entry_point), "--version"],
+            check=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+        )
+        self.assertEqual(
+            "",
+            proc.stderr.decode("utf-8").strip(),
+        )
+        self.assertRegex(proc.stdout.decode("utf-8").strip(), "^pylint 2\.15\.9")
+
+    def test_pylint_report_has_expected_warnings(self):
+        rlocation_path = os.environ.get("PYLINT_REPORT")
+        assert (
+            rlocation_path is not None
+        ), "expected 'PYLINT_REPORT' env variable to be set to rlocation of the report"
+
+        pylint_report = pathlib.Path(runfiles.Create().Rlocation(rlocation_path))
+        self.assertTrue(pylint_report.exists(), f"'{pylint_report}' does not exist")
+
+        self.assertRegex(
+            pylint_report.read_text().strip(),
+            "W8201: Logging should be used instead of the print\(\) function\. \(print-function\)",
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/examples/bzlmod/entry_points/tests/pylint_test.py b/examples/bzlmod/entry_points/tests/pylint_test.py
new file mode 100644
index 0000000..c253293
--- /dev/null
+++ b/examples/bzlmod/entry_points/tests/pylint_test.py
@@ -0,0 +1,57 @@
+# 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 os
+import pathlib
+import subprocess
+import unittest
+
+from python.runfiles import runfiles
+
+
+class ExampleTest(unittest.TestCase):
+    def __init__(self, *args, **kwargs):
+        self.maxDiff = None
+
+        super().__init__(*args, **kwargs)
+
+    def test_pylint_entry_point(self):
+        rlocation_path = os.environ.get("ENTRY_POINT")
+        assert (
+            rlocation_path is not None
+        ), "expected 'ENTRY_POINT' env variable to be set to rlocation of the tool"
+
+        entry_point = pathlib.Path(runfiles.Create().Rlocation(rlocation_path))
+        self.assertTrue(entry_point.exists(), f"'{entry_point}' does not exist")
+
+        # Let's run the entrypoint and check the tool version.
+        #
+        # NOTE @aignas 2023-08-24: the Windows python launcher with Python 3.9 and bazel 6 is not happy if we start
+        # passing extra files via `subprocess.run` and it starts to fail with an error that the file which is the
+        # entry_point cannot be found. However, just calling `--version` seems to be fine.
+        proc = subprocess.run(
+            [str(entry_point), "--version"],
+            check=True,
+            stdout=subprocess.PIPE,
+            stderr=subprocess.PIPE,
+        )
+        self.assertEqual(
+            "",
+            proc.stderr.decode("utf-8").strip(),
+        )
+        self.assertRegex(proc.stdout.decode("utf-8").strip(), "^pylint 2\.15\.9")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/examples/bzlmod/entry_point/test_entry_point.py b/examples/bzlmod/entry_points/tests/yamllint_test.py
similarity index 64%
rename from examples/bzlmod/entry_point/test_entry_point.py
rename to examples/bzlmod/entry_points/tests/yamllint_test.py
index 5a37458..0a02357 100644
--- a/examples/bzlmod/entry_point/test_entry_point.py
+++ b/examples/bzlmod/entry_points/tests/yamllint_test.py
@@ -21,15 +21,25 @@
 
 
 class ExampleTest(unittest.TestCase):
-    def test_entry_point(self):
-        rlocation_path = os.environ.get("YAMLLINT_ENTRY_POINT")
+    def __init__(self, *args, **kwargs):
+        self.maxDiff = None
+
+        super().__init__(*args, **kwargs)
+
+    def test_yamllint_entry_point(self):
+        rlocation_path = os.environ.get("ENTRY_POINT")
         assert (
             rlocation_path is not None
-        ), "expected 'YAMLLINT_ENTRY_POINT' env variable to be set to rlocation of the tool"
+        ), "expected 'ENTRY_POINT' env variable to be set to rlocation of the tool"
 
         entry_point = pathlib.Path(runfiles.Create().Rlocation(rlocation_path))
         self.assertTrue(entry_point.exists(), f"'{entry_point}' does not exist")
 
+        # Let's run the entrypoint and check the tool version.
+        #
+        # NOTE @aignas 2023-08-24: the Windows python launcher with Python 3.9 and bazel 6 is not happy if we start
+        # passing extra files via `subprocess.run` and it starts to fail with an error that the file which is the
+        # entry_point cannot be found. However, just calling `--version` seems to be fine.
         proc = subprocess.run(
             [str(entry_point), "--version"],
             check=True,
diff --git a/examples/bzlmod/requirements.in b/examples/bzlmod/requirements.in
index 47cdcf1..702e151 100644
--- a/examples/bzlmod/requirements.in
+++ b/examples/bzlmod/requirements.in
@@ -7,4 +7,5 @@
 yamllint>=1.28.0
 tabulate~=0.9.0
 pylint~=2.15.5
+pylint-print
 python-dateutil>=2.8.2
diff --git a/examples/bzlmod/requirements_lock_3_10.txt b/examples/bzlmod/requirements_lock_3_10.txt
index e3a185a..7f9bd3a 100644
--- a/examples/bzlmod/requirements_lock_3_10.txt
+++ b/examples/bzlmod/requirements_lock_3_10.txt
@@ -83,6 +83,12 @@
 pylint==2.15.10 \
     --hash=sha256:9df0d07e8948a1c3ffa3b6e2d7e6e63d9fb457c5da5b961ed63106594780cc7e \
     --hash=sha256:b3dc5ef7d33858f297ac0d06cc73862f01e4f2e74025ec3eff347ce0bc60baf5
+    # via
+    #   -r requirements.in
+    #   pylint-print
+pylint-print==1.0.1 \
+    --hash=sha256:30aa207e9718ebf4ceb47fb87012092e6d8743aab932aa07aa14a73e750ad3d0 \
+    --hash=sha256:a2b2599e7887b93e551db2624c523c1e6e9e58c3be8416cd98d41e4427e2669b
     # via -r requirements.in
 python-dateutil==2.8.2 \
     --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
diff --git a/examples/bzlmod/requirements_lock_3_9.txt b/examples/bzlmod/requirements_lock_3_9.txt
index ba1d4d7..79c1812 100644
--- a/examples/bzlmod/requirements_lock_3_9.txt
+++ b/examples/bzlmod/requirements_lock_3_9.txt
@@ -66,6 +66,12 @@
 pylint==2.15.9 \
     --hash=sha256:18783cca3cfee5b83c6c5d10b3cdb66c6594520ffae61890858fe8d932e1c6b4 \
     --hash=sha256:349c8cd36aede4d50a0754a8c0218b43323d13d5d88f4b2952ddfe3e169681eb
+    # via
+    #   -r requirements.in
+    #   pylint-print
+pylint-print==1.0.1 \
+    --hash=sha256:30aa207e9718ebf4ceb47fb87012092e6d8743aab932aa07aa14a73e750ad3d0 \
+    --hash=sha256:a2b2599e7887b93e551db2624c523c1e6e9e58c3be8416cd98d41e4427e2669b
     # via -r requirements.in
 python-dateutil==2.8.2 \
     --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
diff --git a/examples/bzlmod/requirements_windows_3_10.txt b/examples/bzlmod/requirements_windows_3_10.txt
index 9a28ae8..a8f05ad 100644
--- a/examples/bzlmod/requirements_windows_3_10.txt
+++ b/examples/bzlmod/requirements_windows_3_10.txt
@@ -87,6 +87,12 @@
 pylint==2.15.10 \
     --hash=sha256:9df0d07e8948a1c3ffa3b6e2d7e6e63d9fb457c5da5b961ed63106594780cc7e \
     --hash=sha256:b3dc5ef7d33858f297ac0d06cc73862f01e4f2e74025ec3eff347ce0bc60baf5
+    # via
+    #   -r requirements.in
+    #   pylint-print
+pylint-print==1.0.1 \
+    --hash=sha256:30aa207e9718ebf4ceb47fb87012092e6d8743aab932aa07aa14a73e750ad3d0 \
+    --hash=sha256:a2b2599e7887b93e551db2624c523c1e6e9e58c3be8416cd98d41e4427e2669b
     # via -r requirements.in
 python-dateutil==2.8.2 \
     --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
diff --git a/examples/bzlmod/requirements_windows_3_9.txt b/examples/bzlmod/requirements_windows_3_9.txt
index 08f0979..790e3d5 100644
--- a/examples/bzlmod/requirements_windows_3_9.txt
+++ b/examples/bzlmod/requirements_windows_3_9.txt
@@ -70,6 +70,12 @@
 pylint==2.15.9 \
     --hash=sha256:18783cca3cfee5b83c6c5d10b3cdb66c6594520ffae61890858fe8d932e1c6b4 \
     --hash=sha256:349c8cd36aede4d50a0754a8c0218b43323d13d5d88f4b2952ddfe3e169681eb
+    # via
+    #   -r requirements.in
+    #   pylint-print
+pylint-print==1.0.1 \
+    --hash=sha256:30aa207e9718ebf4ceb47fb87012092e6d8743aab932aa07aa14a73e750ad3d0 \
+    --hash=sha256:a2b2599e7887b93e551db2624c523c1e6e9e58c3be8416cd98d41e4427e2669b
     # via -r requirements.in
 python-dateutil==2.8.2 \
     --hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
diff --git a/examples/pip_parse_vendored/BUILD.bazel b/examples/pip_parse_vendored/BUILD.bazel
index 56630e5..b87b2aa 100644
--- a/examples/pip_parse_vendored/BUILD.bazel
+++ b/examples/pip_parse_vendored/BUILD.bazel
@@ -16,7 +16,7 @@
     cmd = " | ".join([
         "cat $<",
         # Insert our load statement after the existing one so we don't produce a file with buildifier warnings
-        """sed -e '/^load.*/i\\'$$'\\n''load("@python39//:defs.bzl", "interpreter")'""",
+        """sed -e '/^load.*.whl_library/i\\'$$'\\n''load("@python39//:defs.bzl", "interpreter")'""",
         # Replace the bazel 6.0.0 specific comment with something that bazel 5.4.0 would produce.
         # This enables this example to be run as a test under bazel 5.4.0.
         """sed -e 's#@//#//#'""",
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index c5f2580..aa8c8bf 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -36,6 +36,7 @@
         "//python/cc:distribution",
         "//python/config_settings:distribution",
         "//python/constraints:distribution",
+        "//python/entry_points:distribution",
         "//python/private:distribution",
         "//python/runfiles:distribution",
     ],
diff --git a/python/config_settings/transition.bzl b/python/config_settings/transition.bzl
index 20e03dc..f9f19f2 100644
--- a/python/config_settings/transition.bzl
+++ b/python/config_settings/transition.bzl
@@ -17,7 +17,8 @@
 """
 
 load("@bazel_skylib//lib:dicts.bzl", "dicts")
-load("//python:defs.bzl", _py_binary = "py_binary", _py_test = "py_test")
+load("//python:py_binary.bzl", _py_binary = "py_binary")
+load("//python:py_test.bzl", _py_test = "py_test")
 load("//python/config_settings/private:py_args.bzl", "py_args")
 
 def _transition_python_version_impl(_, attr):
diff --git a/python/entry_points/BUILD.bazel b/python/entry_points/BUILD.bazel
new file mode 100644
index 0000000..981a1cc
--- /dev/null
+++ b/python/entry_points/BUILD.bazel
@@ -0,0 +1,39 @@
+# 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.
+
+load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+
+exports_files(
+    [
+        "py_console_script_binary.bzl",
+    ],
+    visibility = ["//docs:__subpackages__"],
+)
+
+bzl_library(
+    name = "py_console_script_binary_bzl",
+    srcs = [":py_console_script_binary.bzl"],
+    visibility = ["//visibility:public"],
+    deps = [
+        "//python/private:py_console_script_binary_bzl",
+    ],
+)
+
+filegroup(
+    name = "distribution",
+    srcs = glob([
+        "*.bzl",
+    ]),
+    visibility = ["//python:__subpackages__"],
+)
diff --git a/python/entry_points/py_console_script_binary.bzl b/python/entry_points/py_console_script_binary.bzl
new file mode 100644
index 0000000..60e74f5
--- /dev/null
+++ b/python/entry_points/py_console_script_binary.bzl
@@ -0,0 +1,79 @@
+# 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.
+
+"""
+Creates an executable (a non-test binary) for console_script entry points.
+
+Generate a `py_binary` target for a particular console_script `entry_point`
+from a PyPI package, e.g. for creating an executable `pylint` target use:
+```starlark
+load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "pylint",
+    pkg = "@pip//pylint",
+)
+```
+
+Or for more advanced setups you can also specify extra dependencies and the
+exact script name you want to call. It is useful for tools like flake8, pylint,
+pytest, which have plugin discovery methods and discover dependencies from the
+PyPI packages available in the PYTHONPATH.
+```starlark
+load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "pylint_with_deps",
+    pkg = "@pip//pylint",
+    # Because `pylint` has multiple console_scripts available, we have to
+    # specify which we want if the name of the target name 'pylint_with_deps'
+    # cannot be used to guess the entry_point script.
+    script = "pylint",
+    deps = [
+        # One can add extra dependencies to the entry point.
+        # This specifically allows us to add plugins to pylint.
+        "@pip//pylint_print",
+    ],
+)
+```
+
+A specific Python version can be forced by using the generated version-aware
+wrappers, e.g. to force Python 3.9:
+```starlark
+load("@python_versions//3.9:defs.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "yamllint",
+    pkg = "@pip//yamllint",
+)
+```
+
+Alternatively, the the `py_console_script_binary.binary_rule` arg can be passed
+the version-bound `py_binary` symbol, or any other `py_binary`-compatible rule
+of your choosing:
+```starlark
+load("@python_versions//3.9:defs.bzl", "py_binary")
+load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "yamllint",
+    pkg = "@pip//yamllint:pkg",
+    binary_rule = py_binary,
+)
+```
+"""
+
+load("//python/private:py_console_script_binary.bzl", _py_console_script_binary = "py_console_script_binary")
+
+py_console_script_binary = _py_console_script_binary
diff --git a/python/pip_install/pip_hub_repository_requirements_bzlmod.bzl.tmpl b/python/pip_install/pip_hub_repository_requirements_bzlmod.bzl.tmpl
index 4a3d512..53d4ee9 100644
--- a/python/pip_install/pip_hub_repository_requirements_bzlmod.bzl.tmpl
+++ b/python/pip_install/pip_hub_repository_requirements_bzlmod.bzl.tmpl
@@ -27,9 +27,3 @@
 
 def dist_info_requirement(name):
     return "%%MACRO_TMPL%%".format(_clean_name(name), "dist_info")
-
-def entry_point(pkg, script = None):
-    """entry_point returns the target of the canonical label of the package entrypoints.
-    """
-    # TODO: https://github.com/bazelbuild/rules_python/issues/1262
-    print("not implemented")
diff --git a/python/pip_install/pip_repository_requirements_bzlmod.bzl.tmpl b/python/pip_install/pip_repository_requirements_bzlmod.bzl.tmpl
index 2df60b0..00580f5 100644
--- a/python/pip_install/pip_repository_requirements_bzlmod.bzl.tmpl
+++ b/python/pip_install/pip_repository_requirements_bzlmod.bzl.tmpl
@@ -30,4 +30,19 @@
     """
     if not script:
         script = pkg
-    return "@@%%NAME%%_{}//:rules_python_wheel_entry_point_{}".format(_clean_name(pkg), script)
+    fail("""Please replace this instance of entry_point with the following:
+
+```
+load("@rules_python//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+py_console_script_binary(
+    name = "{pkg}",
+    pkg = "@%%{pkg_label}",
+    script = "{script}",
+)
+```
+""".format(
+        pkg = _clean_name(pkg),
+        pkg_label = "%%MACRO_TMPL%%".format(_clean_name(pkg), "pkg"),
+        script = script,
+    ))
diff --git a/python/private/BUILD.bazel b/python/private/BUILD.bazel
index 29b5a6c..48c3f8c 100644
--- a/python/private/BUILD.bazel
+++ b/python/private/BUILD.bazel
@@ -13,6 +13,8 @@
 # limitations under the License.
 
 load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
+load("//python:py_binary.bzl", "py_binary")
+load("//python:py_library.bzl", "py_library")
 load("//python:versions.bzl", "print_toolchains_checksums")
 load(":stamp.bzl", "stamp_build_setting")
 
@@ -88,6 +90,18 @@
     visibility = ["//python/cc:__pkg__"],
 )
 
+bzl_library(
+    name = "py_console_script_binary_bzl",
+    srcs = [
+        "py_console_script_binary.bzl",
+        "py_console_script_gen.bzl",
+    ],
+    visibility = ["//python/entry_points:__pkg__"],
+    deps = [
+        "//python:py_binary_bzl",
+    ],
+)
+
 # @bazel_tools can't define bzl_library itself, so we just put a wrapper around it.
 bzl_library(
     name = "bazel_tools_bzl",
@@ -119,3 +133,22 @@
 stamp_build_setting(name = "stamp")
 
 print_toolchains_checksums(name = "print_toolchains_checksums")
+
+# Used for py_console_script_gen rule
+py_binary(
+    name = "py_console_script_gen_py",
+    srcs = ["py_console_script_gen.py"],
+    main = "py_console_script_gen.py",
+    visibility = [
+        "//visibility:public",
+    ],
+)
+
+py_library(
+    name = "py_console_script_gen_lib",
+    srcs = ["py_console_script_gen.py"],
+    imports = ["../.."],
+    visibility = [
+        "//tests/entry_points:__pkg__",
+    ],
+)
diff --git a/python/private/py_console_script_binary.bzl b/python/private/py_console_script_binary.bzl
new file mode 100644
index 0000000..bd992a8
--- /dev/null
+++ b/python/private/py_console_script_binary.bzl
@@ -0,0 +1,87 @@
+# 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.
+
+"""
+Implementation for the macro to generate a console_script py_binary from an 'entry_points.txt' config.
+"""
+
+load("//python:py_binary.bzl", "py_binary")
+load(":py_console_script_gen.bzl", "py_console_script_gen")
+
+def _dist_info(pkg):
+    """Return the first candidate for the dist_info target label.
+
+    We cannot do `Label(pkg)` here because the string will be evaluated within
+    the context of the rules_python repo_mapping and it will fail because
+    rules_python does not know anything about the hub repos that the user has
+    available.
+
+    NOTE: Works with `incompatible_generate_aliases` and without by assuming the
+    following formats:
+        * @pypi_pylint//:pkg
+        * @pypi//pylint
+        * @pypi//pylint:pkg
+        * Label("@pypi//pylint:pkg")
+    """
+
+    # str() is called to convert Label objects
+    return str(pkg).replace(":pkg", "") + ":dist_info"
+
+def py_console_script_binary(
+        *,
+        name,
+        pkg,
+        entry_points_txt = None,
+        script = None,
+        binary_rule = py_binary,
+        **kwargs):
+    """Generate a py_binary for a console_script entry_point.
+
+    Args:
+        name: str, The name of the resulting target.
+        pkg: target, the package for which to generate the script.
+        entry_points_txt: optional target, the entry_points.txt file to parse
+            for available console_script values. It may be a single file, or a
+            group of files, but must contain a file named `entry_points.txt`.
+            If not specified, defaults to the `dist_info` target in the same
+            package as the `pkg` Label.
+        script: str, The console script name that the py_binary is going to be
+            generated for. Defaults to the normalized name attribute.
+        binary_rule: callable, The rule/macro to use to instantiate
+            the target. It's expected to behave like `py_binary`.
+            Defaults to @rules_python//python:py_binary.bzl#py_binary.
+        **kwargs: Extra parameters forwarded to binary_rule.
+    """
+    main = "rules_python_entry_point_{}.py".format(name)
+
+    if kwargs.pop("srcs", None):
+        fail("passing 'srcs' attribute to py_console_script_binary is unsupported")
+
+    py_console_script_gen(
+        name = "_{}_gen".format(name),
+        # NOTE @aignas 2023-08-05: Works with `incompatible_generate_aliases` and without.
+        entry_points_txt = entry_points_txt or _dist_info(pkg),
+        out = main,
+        console_script = script,
+        console_script_guess = name,
+        visibility = ["//visibility:private"],
+    )
+
+    binary_rule(
+        name = name,
+        srcs = [main],
+        main = main,
+        deps = [pkg] + kwargs.pop("deps", []),
+        **kwargs
+    )
diff --git a/python/private/py_console_script_gen.bzl b/python/private/py_console_script_gen.bzl
new file mode 100644
index 0000000..7dd4dd2
--- /dev/null
+++ b/python/private/py_console_script_gen.bzl
@@ -0,0 +1,93 @@
+# 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 private rule to generate an entry_point python file to be used in a py_binary.
+
+Right now it only supports console_scripts via the entry_points.txt file in the dist-info.
+
+NOTE @aignas 2023-08-07: This cannot be in pure starlark, because we need to
+read a file and then create a `.py` file based on the contents of that file,
+which cannot be done in pure starlark according to
+https://github.com/bazelbuild/bazel/issues/14744
+"""
+
+_ENTRY_POINTS_TXT = "entry_points.txt"
+
+def _get_entry_points_txt(entry_points_txt):
+    """Get the entry_points.txt file
+
+    TODO: use map_each to avoid flattening of the directories outside the execution phase.
+    """
+    for file in entry_points_txt.files.to_list():
+        if file.basename == _ENTRY_POINTS_TXT:
+            return file
+
+    fail("{} does not contain {}".format(entry_points_txt, _ENTRY_POINTS_TXT))
+
+def _py_console_script_gen_impl(ctx):
+    entry_points_txt = _get_entry_points_txt(ctx.attr.entry_points_txt)
+
+    args = ctx.actions.args()
+    args.add("--console-script", ctx.attr.console_script)
+    args.add("--console-script-guess", ctx.attr.console_script_guess)
+    args.add(entry_points_txt)
+    args.add(ctx.outputs.out)
+
+    ctx.actions.run(
+        inputs = [
+            entry_points_txt,
+        ],
+        outputs = [ctx.outputs.out],
+        arguments = [args],
+        mnemonic = "PyConsoleScriptBinaryGen",
+        progress_message = "Generating py_console_script_binary main: %{label}",
+        executable = ctx.executable._tool,
+    )
+
+    return [DefaultInfo(
+        files = depset([ctx.outputs.out]),
+    )]
+
+py_console_script_gen = rule(
+    _py_console_script_gen_impl,
+    attrs = {
+        "console_script": attr.string(
+            doc = "The name of the console_script to create the .py file for. Optional if there is only a single entry-point available.",
+            default = "",
+            mandatory = False,
+        ),
+        "console_script_guess": attr.string(
+            doc = "The string used for guessing the console_script if it is not provided.",
+            default = "",
+            mandatory = False,
+        ),
+        "entry_points_txt": attr.label(
+            doc = "The filegroup to search for entry_points.txt.",
+            mandatory = True,
+        ),
+        "out": attr.output(
+            doc = "Output file location.",
+            mandatory = True,
+        ),
+        "_tool": attr.label(
+            default = ":py_console_script_gen_py",
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+    doc = """\
+Builds an entry_point script from an entry_points.txt file.
+""",
+)
diff --git a/python/private/py_console_script_gen.py b/python/private/py_console_script_gen.py
new file mode 100644
index 0000000..30e93c2
--- /dev/null
+++ b/python/private/py_console_script_gen.py
@@ -0,0 +1,180 @@
+# 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.
+
+"""
+console_script generator from entry_points.txt contents.
+
+For Python versions earlier than 3.11 and for earlier bazel versions than 7.0 we need to workaround the issue of
+sys.path[0] breaking out of the runfiles tree see the following for more context:
+* https://github.com/bazelbuild/rules_python/issues/382
+* https://github.com/bazelbuild/bazel/pull/15701
+
+In affected bazel and Python versions we see in programs such as `flake8`, `pylint` or `pytest` errors because the
+first `sys.path` element is outside the `runfiles` directory and if the `name` of the `py_binary` is the same as
+the program name, then the script (e.g. `flake8`) will start failing whilst trying to import its own internals from
+the bazel entrypoint script.
+
+The mitigation strategy is to remove the first entry in the `sys.path` if it does not have `.runfiles` and it seems
+to fix the behaviour of console_scripts under `bazel run`.
+
+This would not happen if we created an console_script binary in the root of an external repository, e.g.
+`@pypi_pylint//` because the path for the external repository is already in the runfiles directory.
+"""
+
+from __future__ import annotations
+
+import argparse
+import configparser
+import pathlib
+import re
+import sys
+import textwrap
+
+_ENTRY_POINTS_TXT = "entry_points.txt"
+
+_TEMPLATE = """\
+import sys
+
+# See @rules_python//python/private:py_console_script_gen.py for explanation
+if getattr(sys.flags, "safe_path", False):
+    # We are running on Python 3.11 and we don't need this workaround
+    pass
+elif ".runfiles" not in sys.path[0]:
+    sys.path = sys.path[1:]
+
+try:
+    from {module} import {attr}
+except ImportError:
+    entries = "\\n".join(sys.path)
+    print("Printing sys.path entries for easier debugging:", file=sys.stderr)
+    print(f"sys.path is:\\n{{entries}}", file=sys.stderr)
+    raise
+
+if __name__ == "__main__":
+    sys.exit({entry_point}())
+"""
+
+
+class EntryPointsParser(configparser.ConfigParser):
+    """A class handling entry_points.txt
+
+    See https://packaging.python.org/en/latest/specifications/entry-points/
+    """
+
+    optionxform = staticmethod(str)
+
+
+def _guess_entry_point(guess: str, console_scripts: dict[string, string]) -> str | None:
+    for key, candidate in console_scripts.items():
+        if guess == key:
+            return candidate
+
+
+def run(
+    *,
+    entry_points: pathlib.Path,
+    out: pathlib.Path,
+    console_script: str,
+    console_script_guess: str,
+):
+    """Run the generator
+
+    Args:
+        entry_points: The entry_points.txt file to be parsed.
+        out: The output file.
+        console_script: The console_script entry in the entry_points.txt file.
+    """
+    config = EntryPointsParser()
+    config.read(entry_points)
+
+    try:
+        console_scripts = dict(config["console_scripts"])
+    except KeyError:
+        raise RuntimeError(
+            f"The package does not provide any console_scripts in it's {_ENTRY_POINTS_TXT}"
+        )
+
+    if console_script:
+        try:
+            entry_point = console_scripts[console_script]
+        except KeyError:
+            available = ", ".join(sorted(console_scripts.keys()))
+            raise RuntimeError(
+                f"The console_script '{console_script}' was not found, only the following are available: {available}"
+            ) from None
+    else:
+        # Get rid of the extension and the common prefix
+        entry_point = _guess_entry_point(
+            guess=console_script_guess,
+            console_scripts=console_scripts,
+        )
+
+        if not entry_point:
+            available = ", ".join(sorted(console_scripts.keys()))
+            raise RuntimeError(
+                f"Tried to guess that you wanted '{console_script_guess}', but could not find it. "
+                f"Please select one of the following console scripts: {available}"
+            ) from None
+
+    module, _, entry_point = entry_point.rpartition(":")
+    attr, _, _ = entry_point.partition(".")
+    # TODO: handle 'extras' in entry_point generation
+    # See https://github.com/bazelbuild/rules_python/issues/1383
+    # See https://packaging.python.org/en/latest/specifications/entry-points/
+
+    with open(out, "w") as f:
+        f.write(
+            _TEMPLATE.format(
+                module=module,
+                attr=attr,
+                entry_point=entry_point,
+            ),
+        )
+
+
+def main():
+    parser = argparse.ArgumentParser(description="console_script generator")
+    parser.add_argument(
+        "--console-script",
+        help="The console_script to generate the entry_point template for.",
+    )
+    parser.add_argument(
+        "--console-script-guess",
+        required=True,
+        help="The string used for guessing the console_script if it is not provided.",
+    )
+    parser.add_argument(
+        "entry_points",
+        metavar="ENTRY_POINTS_TXT",
+        type=pathlib.Path,
+        help="The entry_points.txt within the dist-info of a PyPI wheel",
+    )
+    parser.add_argument(
+        "out",
+        type=pathlib.Path,
+        metavar="OUT",
+        help="The output file.",
+    )
+    args = parser.parse_args()
+
+    run(
+        entry_points=args.entry_points,
+        out=args.out,
+        console_script=args.console_script,
+        console_script_guess=args.console_script_guess,
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/python/private/toolchains_repo.bzl b/python/private/toolchains_repo.bzl
index b2919c1..20dc976 100644
--- a/python/private/toolchains_repo.bzl
+++ b/python/private/toolchains_repo.bzl
@@ -182,7 +182,15 @@
     rctx.file("defs.bzl", content = """\
 # Generated by python/private/toolchains_repo.bzl
 
-load("{rules_python}//python/config_settings:transition.bzl", _py_binary = "py_binary", _py_test = "py_test")
+load(
+    "{rules_python}//python/config_settings:transition.bzl",
+    _py_binary = "py_binary",
+    _py_test = "py_test",
+)
+load(
+    "{rules_python}//python/entry_points:py_console_script_binary.bzl",
+    _py_console_script_binary = "py_console_script_binary",
+)
 load("{rules_python}//python:pip.bzl", _compile_pip_requirements = "compile_pip_requirements")
 
 host_platform = "{host_platform}"
@@ -195,6 +203,13 @@
         **kwargs
     )
 
+def py_console_script_binary(name, **kwargs):
+    return _py_console_script_binary(
+        name = name,
+        binary_rule = py_binary,
+        **kwargs
+    )
+
 def py_test(name, **kwargs):
     return _py_test(
         name = name,
@@ -247,6 +262,7 @@
     _host_platform = "host_platform",
     _interpreter = "interpreter",
     _py_binary = "py_binary",
+    _py_console_script_binary = "py_console_script_binary",
     _py_test = "py_test",
 )
 
@@ -254,6 +270,7 @@
 host_platform = _host_platform
 interpreter = _interpreter
 py_binary = _py_binary
+py_console_script_binary = _py_console_script_binary
 py_test = _py_test
 """.format(
             repository_name = repository_name,
diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel
index 2dd2282..70dfa48 100644
--- a/tests/BUILD.bazel
+++ b/tests/BUILD.bazel
@@ -30,5 +30,6 @@
         "//python:py_test_bzl",
         "//python/cc:py_cc_toolchain_bzl",
         "//python/cc:py_cc_toolchain_info_bzl",
+        "//python/entry_points:py_console_script_binary_bzl",
     ],
 )
diff --git a/tests/entry_points/BUILD.bazel b/tests/entry_points/BUILD.bazel
new file mode 100644
index 0000000..7a22d3c
--- /dev/null
+++ b/tests/entry_points/BUILD.bazel
@@ -0,0 +1,39 @@
+# 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.
+
+load("@bazel_skylib//rules:build_test.bzl", "build_test")
+load("//python:py_test.bzl", "py_test")
+load(":simple_macro.bzl", "py_console_script_binary_in_a_macro")
+
+py_test(
+    name = "py_console_script_gen_test",
+    srcs = ["py_console_script_gen_test.py"],
+    main = "py_console_script_gen_test.py",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//python/private:py_console_script_gen_lib",
+    ],
+)
+
+py_console_script_binary_in_a_macro(
+    name = "twine",
+    pkg = "@publish_deps_twine//:pkg",
+)
+
+build_test(
+    name = "build_entry_point",
+    targets = [
+        ":twine",
+    ],
+)
diff --git a/tests/entry_points/py_console_script_gen_test.py b/tests/entry_points/py_console_script_gen_test.py
new file mode 100644
index 0000000..80b5f20
--- /dev/null
+++ b/tests/entry_points/py_console_script_gen_test.py
@@ -0,0 +1,197 @@
+#!/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.
+
+import pathlib
+import tempfile
+import textwrap
+import unittest
+
+from python.private.py_console_script_gen import run
+
+
+class RunTest(unittest.TestCase):
+    def setUp(self):
+        self.maxDiff = None
+
+    def test_no_console_scripts_error(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            tmpdir = pathlib.Path(tmpdir)
+            outfile = tmpdir / "out.py"
+            given_contents = (
+                textwrap.dedent(
+                    """
+            [non_console_scripts]
+            foo = foo.bar:fizz
+            """
+                ).strip()
+                + "\n"
+            )
+            entry_points = tmpdir / "entry_points.txt"
+            entry_points.write_text(given_contents)
+
+            with self.assertRaises(RuntimeError) as cm:
+                run(
+                    entry_points=entry_points,
+                    out=outfile,
+                    console_script=None,
+                    console_script_guess="",
+                )
+
+        self.assertEqual(
+            "The package does not provide any console_scripts in it's entry_points.txt",
+            cm.exception.args[0],
+        )
+
+    def test_no_entry_point_selected_error(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            tmpdir = pathlib.Path(tmpdir)
+            outfile = tmpdir / "out.py"
+            given_contents = (
+                textwrap.dedent(
+                    """
+            [console_scripts]
+            foo = foo.bar:fizz
+            """
+                ).strip()
+                + "\n"
+            )
+            entry_points = tmpdir / "entry_points.txt"
+            entry_points.write_text(given_contents)
+
+            with self.assertRaises(RuntimeError) as cm:
+                run(
+                    entry_points=entry_points,
+                    out=outfile,
+                    console_script=None,
+                    console_script_guess="bar-baz",
+                )
+
+        self.assertEqual(
+            "Tried to guess that you wanted 'bar-baz', but could not find it. Please select one of the following console scripts: foo",
+            cm.exception.args[0],
+        )
+
+    def test_incorrect_entry_point(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            tmpdir = pathlib.Path(tmpdir)
+            outfile = tmpdir / "out.py"
+            given_contents = (
+                textwrap.dedent(
+                    """
+            [console_scripts]
+            foo = foo.bar:fizz
+            bar = foo.bar:buzz
+            """
+                ).strip()
+                + "\n"
+            )
+            entry_points = tmpdir / "entry_points.txt"
+            entry_points.write_text(given_contents)
+
+            with self.assertRaises(RuntimeError) as cm:
+                run(
+                    entry_points=entry_points,
+                    out=outfile,
+                    console_script="baz",
+                    console_script_guess="",
+                )
+
+        self.assertEqual(
+            "The console_script 'baz' was not found, only the following are available: bar, foo",
+            cm.exception.args[0],
+        )
+
+    def test_a_single_entry_point(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            tmpdir = pathlib.Path(tmpdir)
+            given_contents = (
+                textwrap.dedent(
+                    """
+            [console_scripts]
+            foo = foo.bar:baz
+            """
+                ).strip()
+                + "\n"
+            )
+            entry_points = tmpdir / "entry_points.txt"
+            entry_points.write_text(given_contents)
+            out = tmpdir / "foo.py"
+
+            run(
+                entry_points=entry_points,
+                out=out,
+                console_script=None,
+                console_script_guess="foo",
+            )
+
+            got = out.read_text()
+
+        want = textwrap.dedent(
+            """\
+        import sys
+
+        # See @rules_python//python/private:py_console_script_gen.py for explanation
+        if getattr(sys.flags, "safe_path", False):
+            # We are running on Python 3.11 and we don't need this workaround
+            pass
+        elif ".runfiles" not in sys.path[0]:
+            sys.path = sys.path[1:]
+
+        try:
+            from foo.bar import baz
+        except ImportError:
+            entries = "\\n".join(sys.path)
+            print("Printing sys.path entries for easier debugging:", file=sys.stderr)
+            print(f"sys.path is:\\n{entries}", file=sys.stderr)
+            raise
+
+        if __name__ == "__main__":
+            sys.exit(baz())
+        """
+        )
+        self.assertEqual(want, got)
+
+    def test_a_second_entry_point_class_method(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            tmpdir = pathlib.Path(tmpdir)
+            given_contents = (
+                textwrap.dedent(
+                    """
+            [console_scripts]
+            foo = foo.bar:Bar.baz
+            bar = foo.baz:Bar.baz
+            """
+                ).strip()
+                + "\n"
+            )
+            entry_points = tmpdir / "entry_points.txt"
+            entry_points.write_text(given_contents)
+            out = tmpdir / "out.py"
+
+            run(
+                entry_points=entry_points,
+                out=out,
+                console_script="bar",
+                console_script_guess="",
+            )
+
+            got = out.read_text()
+
+        self.assertRegex(got, "from foo\.baz import Bar")
+        self.assertRegex(got, "sys\.exit\(Bar\.baz\(\)\)")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tests/entry_points/simple_macro.bzl b/tests/entry_points/simple_macro.bzl
new file mode 100644
index 0000000..4764a3f
--- /dev/null
+++ b/tests/entry_points/simple_macro.bzl
@@ -0,0 +1,31 @@
+# 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 simple test macro.
+"""
+
+load("//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+
+def py_console_script_binary_in_a_macro(name, pkg):
+    """A simple macro to see that we can use our macro in a macro.
+
+    Args:
+        name, str: the name of the target
+        pkg, str: the pkg target
+    """
+    py_console_script_binary(
+        name = name,
+        pkg = Label(pkg),
+    )