feat(twine): support 'bzlmod' users out of the box (#1572)
Implements a test that starts a [`pypiserver`] and checks
that the publishing with the new machinery still works.
Fixes #1369
[pypiserver]: https://github.com/pypiserver/pypiserver
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff61b2b..2beb8bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,9 @@
* (wheel) Add support for `data_files` attributes in py_wheel rule
([#1777](https://github.com/bazelbuild/rules_python/issues/1777))
+* (py_wheel) `bzlmod` installations now provide a `twine` setup for the default
+ Python toolchain in `rules_python` for version 3.11.
+
[0.XX.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.XX.0
[python_default_visibility]: gazelle/README.md#directive-python_default_visibility
@@ -269,7 +272,6 @@
attribute for every target in the package. This is enabled through a separate
directive `python_generation_mode_per_file_include_init`.
-
## [0.27.0] - 2023-11-16
[0.27.0]: https://github.com/bazelbuild/rules_python/releases/tag/0.27.0
diff --git a/MODULE.bazel b/MODULE.bazel
index 15a33c8..a165a94 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -48,11 +48,24 @@
is_default = True,
python_version = "3.11",
)
-use_repo(python, "pythons_hub")
+use_repo(python, "python_versions", "pythons_hub")
# This call registers the Python toolchains.
register_toolchains("@pythons_hub//:all")
+#####################
+# Install twine for our own runfiles wheel publishing and allow bzlmod users to use it.
+
+pip = use_extension("//python/extensions:pip.bzl", "pip")
+pip.parse(
+ hub_name = "rules_python_publish_deps",
+ python_version = "3.11",
+ requirements_darwin = "//tools/publish:requirements_darwin.txt",
+ requirements_lock = "//tools/publish:requirements.txt",
+ requirements_windows = "//tools/publish:requirements_windows.txt",
+)
+use_repo(pip, "rules_python_publish_deps")
+
# ===== DEV ONLY DEPS AND SETUP BELOW HERE =====
bazel_dep(name = "stardoc", version = "0.6.2", dev_dependency = True, repo_name = "io_bazel_stardoc")
bazel_dep(name = "rules_bazel_integration_test", version = "0.20.0", dev_dependency = True)
@@ -84,24 +97,12 @@
python_version = "3.11",
requirements_lock = "//docs/sphinx:requirements.txt",
)
-
-#####################
-# Install twine for our own runfiles wheel publishing.
-# Eventually we might want to install twine automatically for users too, see:
-# https://github.com/bazelbuild/rules_python/issues/1016.
-
dev_pip.parse(
- hub_name = "publish_deps",
+ hub_name = "pypiserver",
python_version = "3.11",
- requirements_darwin = "//tools/publish:requirements_darwin.txt",
- requirements_lock = "//tools/publish:requirements.txt",
- requirements_windows = "//tools/publish:requirements_windows.txt",
+ requirements_lock = "//examples/wheel:requirements_server.txt",
)
-use_repo(
- dev_pip,
- "dev_pip",
- publish_deps_twine = "publish_deps_311_twine",
-)
+use_repo(dev_pip, "dev_pip", "pypiserver")
# Bazel integration test setup below
diff --git a/WORKSPACE b/WORKSPACE
index 75c8e56..86a80bd 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -94,17 +94,27 @@
load("@rules_python//python:pip.bzl", "pip_parse")
pip_parse(
- name = "publish_deps",
+ name = "rules_python_publish_deps",
python_interpreter_target = interpreter,
requirements_darwin = "//tools/publish:requirements_darwin.txt",
requirements_lock = "//tools/publish:requirements.txt",
requirements_windows = "//tools/publish:requirements_windows.txt",
)
-load("@publish_deps//:requirements.bzl", "install_deps")
+load("@rules_python_publish_deps//:requirements.bzl", "install_deps")
install_deps()
+pip_parse(
+ name = "pypiserver",
+ python_interpreter_target = interpreter,
+ requirements_lock = "//examples/wheel:requirements_server.txt",
+)
+
+load("@pypiserver//:requirements.bzl", install_pypiserver = "install_deps")
+
+install_pypiserver()
+
#####################
# Install sphinx for doc generation.
diff --git a/examples/wheel/BUILD.bazel b/examples/wheel/BUILD.bazel
index 2e45d7d..aa063ce 100644
--- a/examples/wheel/BUILD.bazel
+++ b/examples/wheel/BUILD.bazel
@@ -17,7 +17,10 @@
load("//examples/wheel/private:wheel_utils.bzl", "directory_writer", "make_variable_tags")
load("//python:defs.bzl", "py_library", "py_test")
load("//python:packaging.bzl", "py_package", "py_wheel")
+load("//python:pip.bzl", "compile_pip_requirements")
load("//python:versions.bzl", "gen_python_config_settings")
+load("//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
+load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility
package(default_visibility = ["//visibility:public"])
@@ -56,6 +59,10 @@
# Package data. We're building "example_minimal_library-0.0.1-py3-none-any.whl"
distribution = "example_minimal_library",
python_tag = "py3",
+ # NOTE: twine_binary = "//tools/publish:twine" does not work on non-bzlmod
+ # setups because the `//tools/publish:twine` produces multiple files and is
+ # unsuitable as the `src` to the underlying native_binary rule.
+ twine = None if BZLMOD_ENABLED else "@rules_python_publish_deps_twine//:pkg",
version = "0.0.1",
deps = [
"//examples/wheel/lib:module_with_data",
@@ -348,3 +355,39 @@
"//python/runfiles",
],
)
+
+# Test wheel publishing
+
+compile_pip_requirements(
+ name = "requirements_server",
+ src = "requirements_server.in",
+)
+
+py_test(
+ name = "test_publish",
+ srcs = ["test_publish.py"],
+ data = [
+ ":minimal_with_py_library",
+ ":minimal_with_py_library.publish",
+ ":pypiserver",
+ ],
+ env = {
+ "PUBLISH_PATH": "$(location :minimal_with_py_library.publish)",
+ "SERVER_PATH": "$(location :pypiserver)",
+ "WHEEL_PATH": "$(rootpath :minimal_with_py_library)",
+ },
+ target_compatible_with = select({
+ "@platforms//os:linux": [],
+ "@platforms//os:macos": [],
+ "//conditions:default": ["@platforms//:incompatible"],
+ }),
+ deps = [
+ "@pypiserver//pypiserver",
+ ],
+)
+
+py_console_script_binary(
+ name = "pypiserver",
+ pkg = "@pypiserver//pypiserver",
+ script = "pypi-server",
+)
diff --git a/examples/wheel/requirements_server.in b/examples/wheel/requirements_server.in
new file mode 100644
index 0000000..d5d483d
--- /dev/null
+++ b/examples/wheel/requirements_server.in
@@ -0,0 +1,2 @@
+# This is for running publishing tests
+pypiserver
diff --git a/examples/wheel/requirements_server.txt b/examples/wheel/requirements_server.txt
new file mode 100644
index 0000000..eccab12
--- /dev/null
+++ b/examples/wheel/requirements_server.txt
@@ -0,0 +1,16 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+# bazel run //examples/wheel:requirements_server.update
+#
+pypiserver==2.0.1 \
+ --hash=sha256:1dd98fb99d2da4199fb44c7284e57d69a9f7fda2c6c8dc01975c151c592677bf \
+ --hash=sha256:7b58fbd54468235f79e4de07c4f7a9ff829e7ac6869bef47ec11e0710138e162
+ # via -r examples/wheel/requirements_server.in
+
+# The following packages are considered to be unsafe in a requirements file:
+pip==24.0 \
+ --hash=sha256:ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc \
+ --hash=sha256:ea9bd1a847e8c5774a5777bb398c19e80bcd4e2aa16a4b301b718fe6f593aba2
+ # via pypiserver
diff --git a/examples/wheel/test_publish.py b/examples/wheel/test_publish.py
new file mode 100644
index 0000000..496642a
--- /dev/null
+++ b/examples/wheel/test_publish.py
@@ -0,0 +1,117 @@
+import os
+import socket
+import subprocess
+import textwrap
+import time
+import unittest
+from contextlib import closing
+from pathlib import Path
+from urllib.request import urlopen
+
+
+def find_free_port():
+ with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
+ s.bind(("", 0))
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ return s.getsockname()[1]
+
+
+class TestTwineUpload(unittest.TestCase):
+ def setUp(self):
+ self.maxDiff = 1000
+ self.port = find_free_port()
+ self.url = f"http://localhost:{self.port}"
+ self.dir = Path(os.environ["TEST_TMPDIR"])
+
+ self.log_file = self.dir / "pypiserver-log.txt"
+ self.log_file.touch()
+ _storage_dir = self.dir / "data"
+ for d in [_storage_dir]:
+ d.mkdir(exist_ok=True)
+
+ print("Starting PyPI server...")
+ self._server = subprocess.Popen(
+ [
+ str(Path(os.environ["SERVER_PATH"])),
+ "run",
+ "--verbose",
+ "--log-file",
+ str(self.log_file),
+ "--host",
+ "localhost",
+ "--port",
+ str(self.port),
+ # Allow unauthenticated access
+ "--authenticate",
+ ".",
+ "--passwords",
+ ".",
+ str(_storage_dir),
+ ],
+ )
+
+ line = "Hit Ctrl-C to quit"
+ interval = 0.1
+ wait_seconds = 40
+ for _ in range(int(wait_seconds / interval)): # 40 second timeout
+ current_logs = self.log_file.read_text()
+ if line in current_logs:
+ print(current_logs.strip())
+ print("...")
+ break
+
+ time.sleep(0.1)
+ else:
+ raise RuntimeError(
+ f"Could not get the server running fast enough, waited for {wait_seconds}s"
+ )
+
+ def tearDown(self):
+ self._server.terminate()
+ print(f"Stopped PyPI server, all logs:\n{self.log_file.read_text()}")
+
+ def test_upload_and_query_simple_api(self):
+ # Given
+ script_path = Path(os.environ["PUBLISH_PATH"])
+ whl = Path(os.environ["WHEEL_PATH"])
+
+ # When I publish a whl to a package registry
+ subprocess.check_output(
+ [
+ str(script_path),
+ "--no-color",
+ "upload",
+ str(whl),
+ "--verbose",
+ "--non-interactive",
+ "--disable-progress-bar",
+ ],
+ env={
+ "TWINE_REPOSITORY_URL": self.url,
+ "TWINE_USERNAME": "dummy",
+ "TWINE_PASSWORD": "dummy",
+ },
+ )
+
+ # Then I should be able to get its contents
+ with urlopen(self.url + "/example-minimal-library/") as response:
+ got_content = response.read().decode("utf-8")
+ want_content = """
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Links for example-minimal-library</title>
+ </head>
+ <body>
+ <h1>Links for example-minimal-library</h1>
+ <a href="/packages/example_minimal_library-0.0.1-py3-none-any.whl#sha256=79a4e9c1838c0631d5d8fa49a26efd6e9a364f6b38d9597c0f6df112271a0e28">example_minimal_library-0.0.1-py3-none-any.whl</a><br>
+ </body>
+</html>"""
+ self.assertEqual(
+ textwrap.dedent(want_content).strip(),
+ textwrap.dedent(got_content).strip(),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/python/BUILD.bazel b/python/BUILD.bazel
index cc6348a..d586347 100644
--- a/python/BUILD.bazel
+++ b/python/BUILD.bazel
@@ -76,11 +76,13 @@
srcs = ["packaging.bzl"],
deps = [
":py_binary_bzl",
+ "//python/private:bzlmod_enabled_bzl",
"//python/private:py_package.bzl",
"//python/private:py_wheel_bzl",
"//python/private:py_wheel_normalize_pep440.bzl",
"//python/private:stamp_bzl",
"//python/private:util_bzl",
+ "@bazel_skylib//rules:native_binary",
],
)
diff --git a/python/packaging.bzl b/python/packaging.bzl
index f811965..a5ac25b 100644
--- a/python/packaging.bzl
+++ b/python/packaging.bzl
@@ -14,7 +14,9 @@
"""Public API for for building wheels."""
+load("@bazel_skylib//rules:native_binary.bzl", "native_binary")
load("//python:py_binary.bzl", "py_binary")
+load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")
load("//python/private:py_package.bzl", "py_package_lib")
load("//python/private:py_wheel.bzl", _PyWheelInfo = "PyWheelInfo", _py_wheel = "py_wheel")
load("//python/private:util.bzl", "copy_propagating_kwargs")
@@ -70,7 +72,7 @@
},
)
-def py_wheel(name, twine = None, publish_args = [], **kwargs):
+def py_wheel(name, twine = None, twine_binary = Label("//tools/publish:twine") if BZLMOD_ENABLED else None, publish_args = [], **kwargs):
"""Builds a Python Wheel.
Wheels are Python distribution format defined in https://www.python.org/dev/peps/pep-0427/.
@@ -115,19 +117,21 @@
)
```
- To publish the wheel to PyPI, the twine package is required.
- rules_python doesn't provide twine itself, see [https://github.com/bazelbuild/rules_python/issues/1016].
- However you can install it with [pip_parse](#pip_parse), just like we do in the WORKSPACE file in rules_python.
+ To publish the wheel to PyPI, the twine package is required and it is installed
+ by default on `bzlmod` setups. On legacy `WORKSPACE`, `rules_python`
+ doesn't provide `twine` itself
+ (see https://github.com/bazelbuild/rules_python/issues/1016), but
+ you can install it with `pip_parse`, just like we do any other dependencies.
- Once you've installed twine, you can pass its label to the `twine` attribute of this macro,
- to get a "[name].publish" target.
+ Once you've installed twine, you can pass its label to the `twine`
+ attribute of this macro, to get a "[name].publish" target.
Example:
```python
py_wheel(
name = "my_wheel",
- twine = "@publish_deps_twine//:pkg",
+ twine = "@publish_deps//twine",
...
)
```
@@ -143,6 +147,7 @@
Args:
name: A unique name for this target.
twine: A label of the external location of the py_library target for twine
+ twine_binary: A label of the external location of a binary target for twine.
publish_args: arguments passed to twine, e.g. ["--repository-url", "https://pypi.my.org/simple/"].
These are subject to make var expansion, as with the `args` attribute.
Note that you can also pass additional args to the bazel run command as in the example above.
@@ -158,16 +163,32 @@
_py_wheel(name = name, **kwargs)
- if twine:
- if not twine.endswith(":pkg"):
- fail("twine label should look like @my_twine_repo//:pkg")
- twine_main = twine.replace(":pkg", ":rules_python_wheel_entry_point_twine.py")
+ twine_args = []
+ if twine or twine_binary:
twine_args = ["upload"]
twine_args.extend(publish_args)
twine_args.append("$(rootpath :{})/*".format(_dist_target))
- # TODO: use py_binary from //python:defs.bzl after our stardoc setup is less brittle
- # buildifier: disable=native-py
+ if twine_binary:
+ twine_kwargs = {"tags": ["manual"]}
+ native_binary(
+ name = "{}.publish".format(name),
+ src = twine_binary,
+ out = select({
+ "@platforms//os:windows": "{}.publish_script.exe".format(name),
+ "//conditions:default": "{}.publish_script".format(name),
+ }),
+ args = twine_args,
+ data = [_dist_target],
+ visibility = kwargs.get("visibility"),
+ **copy_propagating_kwargs(kwargs, twine_kwargs)
+ )
+ elif twine:
+ if not twine.endswith(":pkg"):
+ fail("twine label should look like @my_twine_repo//:pkg")
+
+ twine_main = twine.replace(":pkg", ":rules_python_wheel_entry_point_twine.py")
+
py_binary(
name = "{}.publish".format(name),
srcs = [twine_main],
diff --git a/python/private/py_console_script_binary.bzl b/python/private/py_console_script_binary.bzl
index d0c58bf..7347ebe 100644
--- a/python/private/py_console_script_binary.bzl
+++ b/python/private/py_console_script_binary.bzl
@@ -31,10 +31,19 @@
* @pypi//pylint
* @pypi//pylint:pkg
* Label("@pypi//pylint:pkg")
+ * Label("@pypi//pylint")
"""
- # str() is called to convert Label objects
- return str(pkg).replace(":pkg", "") + ":dist_info"
+ if type(pkg) == type(""):
+ label = native.package_relative_label(pkg)
+ else:
+ label = pkg
+
+ if hasattr(label, "same_package_label"):
+ return label.same_package_label("dist_info")
+ else:
+ # NOTE @aignas 2024-03-25: this is deprecated but kept for compatibility
+ return label.relative("dist_info")
def py_console_script_binary(
*,
diff --git a/python/runfiles/BUILD.bazel b/python/runfiles/BUILD.bazel
index dde5b45..c1fc027 100644
--- a/python/runfiles/BUILD.bazel
+++ b/python/runfiles/BUILD.bazel
@@ -14,6 +14,7 @@
load("//python:defs.bzl", "py_library")
load("//python:packaging.bzl", "py_wheel")
+load("//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED")
filegroup(
name = "distribution",
@@ -52,7 +53,7 @@
homepage = "https://github.com/bazelbuild/rules_python",
python_requires = ">=3.7",
strip_path_prefixes = ["python"],
- twine = "@publish_deps_twine//:pkg",
+ twine = None if BZLMOD_ENABLED else "@rules_python_publish_deps_twine//:pkg",
# this can be replaced by building with --stamp --embed_label=1.2.3
version = "{BUILD_EMBED_LABEL}",
visibility = ["//visibility:public"],
diff --git a/tests/entry_points/BUILD.bazel b/tests/entry_points/BUILD.bazel
index 7a22d3c..c877462 100644
--- a/tests/entry_points/BUILD.bazel
+++ b/tests/entry_points/BUILD.bazel
@@ -28,12 +28,19 @@
py_console_script_binary_in_a_macro(
name = "twine",
- pkg = "@publish_deps_twine//:pkg",
+ pkg = "@rules_python_publish_deps//twine",
+)
+
+py_console_script_binary_in_a_macro(
+ name = "twine_pkg",
+ pkg = "@rules_python_publish_deps//twine:pkg",
+ script = "twine",
)
build_test(
name = "build_entry_point",
targets = [
":twine",
+ ":twine_pkg",
],
)
diff --git a/tests/entry_points/simple_macro.bzl b/tests/entry_points/simple_macro.bzl
index 4764a3f..c56f2e1 100644
--- a/tests/entry_points/simple_macro.bzl
+++ b/tests/entry_points/simple_macro.bzl
@@ -18,14 +18,16 @@
load("//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
-def py_console_script_binary_in_a_macro(name, pkg):
+def py_console_script_binary_in_a_macro(name, pkg, **kwargs):
"""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
+ **kwargs, Any: extra kwargs passed through.
"""
py_console_script_binary(
name = name,
pkg = Label(pkg),
+ **kwargs
)
diff --git a/tests/runfiles/BUILD.bazel b/tests/runfiles/BUILD.bazel
index 6193ee9..5c92026 100644
--- a/tests/runfiles/BUILD.bazel
+++ b/tests/runfiles/BUILD.bazel
@@ -1,3 +1,4 @@
+load("@bazel_skylib//rules:build_test.bzl", "build_test")
load("@rules_python//python:py_test.bzl", "py_test")
load("@rules_python//python/private:bzlmod_enabled.bzl", "BZLMOD_ENABLED") # buildifier: disable=bzl-visibility
@@ -9,3 +10,10 @@
},
deps = ["//python/runfiles"],
)
+
+build_test(
+ name = "publishing",
+ targets = [
+ "//python/runfiles:wheel.publish",
+ ],
+)
diff --git a/tools/BUILD.bazel b/tools/BUILD.bazel
index 51bd56d..b2aca5c 100644
--- a/tools/BUILD.bazel
+++ b/tools/BUILD.bazel
@@ -29,6 +29,7 @@
srcs = [
"BUILD.bazel",
"wheelmaker.py",
+ "//tools/publish:distribution",
],
visibility = ["//:__pkg__"],
)
diff --git a/tools/publish/BUILD.bazel b/tools/publish/BUILD.bazel
index 4759a31..a51693b 100644
--- a/tools/publish/BUILD.bazel
+++ b/tools/publish/BUILD.bazel
@@ -1,4 +1,6 @@
load("//python:pip.bzl", "compile_pip_requirements")
+load("//python/config_settings:transition.bzl", "py_binary")
+load("//python/entry_points:py_console_script_binary.bzl", "py_console_script_binary")
compile_pip_requirements(
name = "requirements",
@@ -6,3 +8,27 @@
requirements_darwin = "requirements_darwin.txt",
requirements_windows = "requirements_windows.txt",
)
+
+py_console_script_binary(
+ name = "twine",
+ # We use a py_binary rule with version transitions to ensure that we do not
+ # rely on the default version of the registered python toolchain. What is more
+ # we are using this instead of `@python_versions//3.11:defs.bzl` because loading
+ # that file relies on bzlmod being enabled.
+ binary_rule = py_binary,
+ pkg = "@rules_python_publish_deps//twine",
+ python_version = "3.11",
+ script = "twine",
+ visibility = ["//visibility:public"],
+)
+
+filegroup(
+ name = "distribution",
+ srcs = [
+ "BUILD.bazel",
+ "requirements.txt",
+ "requirements_darwin.txt",
+ "requirements_windows.txt",
+ ],
+ visibility = ["//tools:__pkg__"],
+)