pw_build: Add tools for creating py distributables
Change-Id: Ifac2ad9f647f416b3aef5812a08187a8313d65ed
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/39664
Commit-Queue: Joe Ethier <jethier@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/docs/python_build.rst b/docs/python_build.rst
index b89c788..7fab838 100644
--- a/docs/python_build.rst
+++ b/docs/python_build.rst
@@ -285,7 +285,7 @@
<https://gn.googlesource.com/gn/+/master/docs/reference.md#var_metadata>`_.
Wheels for a Python package and its transitive dependencies can be collected
from the ``pw_python_package_wheels`` key. See
-:ref:`module-pw_build-python-wheels`.
+:ref:`module-pw_build-python-dist`.
Protocol buffers
^^^^^^^^^^^^^^^^
diff --git a/pw_build/py/BUILD.gn b/pw_build/py/BUILD.gn
index ea96dc4..f70e34f 100644
--- a/pw_build/py/BUILD.gn
+++ b/pw_build/py/BUILD.gn
@@ -20,6 +20,7 @@
setup = [ "setup.py" ]
sources = [
"pw_build/__init__.py",
+ "pw_build/collect_wheels.py",
"pw_build/copy_from_cipd.py",
"pw_build/error.py",
"pw_build/exec.py",
diff --git a/pw_build/py/pw_build/collect_wheels.py b/pw_build/py/pw_build/collect_wheels.py
new file mode 100644
index 0000000..ac53c28
--- /dev/null
+++ b/pw_build/py/pw_build/collect_wheels.py
@@ -0,0 +1,65 @@
+# Copyright 2021 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+"""Collect Python wheels from a build into a central directory."""
+
+import argparse
+import logging
+from pathlib import Path
+import shutil
+import sys
+
+_LOG = logging.getLogger(__name__)
+
+
+def _parse_args():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ '--prefix',
+ type=Path,
+ help='Root search path to use in conjunction with --wheels_file')
+ parser.add_argument(
+ '--suffix_file',
+ type=argparse.FileType('r'),
+ help=('File that lists subdirs relative to --prefix, one per line,'
+ 'to search for .whl files to copy into --out_dir'))
+ parser.add_argument(
+ '--out_dir',
+ type=Path,
+ help='Path where all the built and collected .whl files should be put')
+
+ return parser.parse_args()
+
+
+def copy_wheels(prefix, suffix_file, out_dir):
+ if not out_dir.exists():
+ out_dir.mkdir()
+
+ for suffix in suffix_file.readlines():
+ path = prefix / suffix.strip()
+ _LOG.debug('Searching for wheels in %s', path)
+ if path == out_dir:
+ continue
+ for wheel in path.glob('**/*.whl'):
+ _LOG.debug('Copying %s to %s', wheel, out_dir)
+ shutil.copy(wheel, out_dir)
+
+
+def main():
+ copy_wheels(**vars(_parse_args()))
+
+
+if __name__ == '__main__':
+ logging.basicConfig()
+ main()
+ sys.exit(0)
diff --git a/pw_build/python.rst b/pw_build/python.rst
index 7ea0b27..3079083 100644
--- a/pw_build/python.rst
+++ b/pw_build/python.rst
@@ -4,7 +4,7 @@
Python GN templates
-------------------
The Python build is implemented with GN templates defined in
-``pw_build/python.gni``. That file contains the complete usage documentation.
+``pw_build/python.gni``. See the .gni file for complete usage documentation.
.. seealso:: :ref:`docs-python-build`
@@ -34,7 +34,8 @@
The actions in a ``pw_python_package`` (e.g. installing packages and running
Pylint) are done within a single GN toolchain to avoid duplication in
multi-toolchain builds. This toolchain can be set with the
-``pw_build_PYTHON_TOOLCHAIN`` GN arg, which defaults to a dummy toolchain.
+``pw_build_PYTHON_TOOLCHAIN`` GN arg, which defaults to
+``$dir_pw_build/python_toolchain:python``.
Arguments
---------
@@ -100,30 +101,6 @@
pylintrc = "$dir_pigweed/.pylintrc"
}
-
-.. _module-pw_build-python-wheels:
-
-Collecting Python wheels for distribution
------------------------------------------
-The ``.wheel`` subtarget generates a wheel (``.whl``) for the Python package.
-Wheels for a package and its transitive dependencies can be collected by
-traversing the ``pw_python_package_wheels`` `GN metadata
-<https://gn.googlesource.com/gn/+/master/docs/reference.md#var_metadata>`_ key,
-which lists the output directory for each wheel.
-
-The ``pw_mirror_tree`` template can be used to collect wheels in an output
-directory:
-
-.. code-block::
-
- import("$dir_pw_build/mirror_tree.gni")
-
- pw_mirror_tree("my_wheels") {
- path_data_keys = [ "pw_python_package_wheels" ]
- deps = [ ":python_packages.wheel" ]
- directory = "$root_out_dir/the_wheels"
- }
-
pw_python_script
================
A ``pw_python_script`` represents a set of standalone Python scripts and/or
@@ -160,3 +137,87 @@
======================
Represents a set of local and PyPI requirements, with no associated source
files. These targets serve the role of a ``requirements.txt`` file.
+
+.. _module-pw_build-python-dist:
+
+---------------------
+Python distributables
+---------------------
+Pigweed also provides some templates to make it easier to bundle Python packages
+for deployment. These templates are found in ``pw_build/python_dist.gni``. See
+the .gni file for complete usage doclumentation.
+
+pw_python_wheels
+================
+Collects Python wheels for one or more ``pw_python_package`` targets, plus any
+additional ``pw_python_package`` targets they depend on, directly or indirectly.
+Note that this does not include Python dependencies that come from outside the
+GN build, like packages from PyPI, for example. Those should still be declared
+in the package's ``setup.py`` file as usual.
+
+Arguments
+---------
+- ``packages`` - List of ``pw_python_package`` targets whose wheels should be
+ included; their dependencies will be pulled in as wheels also.
+
+Wheel collection under the hood
+-------------------------------
+The ``.wheel`` subtarget of every ``pw_python_package`` generates a wheel
+(``.whl``) for the Python package. The ``pw_python_wheels`` template figures
+out which wheels to collect by traversing the ``pw_python_package_wheels``
+`GN metadata
+<https://gn.googlesource.com/gn/+/master/docs/reference.md#var_metadata>`_ key,
+which lists the output directory for each wheel.
+
+The ``pw_mirror_tree`` template is then used to collect wheels in an output
+directory:
+
+.. code-block::
+
+ import("$dir_pw_build/mirror_tree.gni")
+
+ pw_mirror_tree("my_wheels") {
+ path_data_keys = [ "pw_python_package_wheels" ]
+ deps = [ ":python_packages.wheel" ]
+ directory = "$root_out_dir/the_wheels"
+ }
+
+pw_python_zip_with_setup
+========================
+Generates a ``.zip`` archive suitable for deployment outside of the project's
+developer environment. The generated ``.zip`` contains Python wheels
+(``.whl`` files) for one or more ``pw_python_package`` targets, plus wheels for
+any additional ``pw_python_package`` targets in the GN build they depend on,
+directly or indirectly. Dependencies from outside the GN build, such as packages
+from PyPI, must be listed in packages' ``setup.py`` files as usual.
+
+The ``.zip`` also includes simple setup scripts for Linux,
+MacOS, and Windows. The setup scripts automatically create a Python virtual
+environment and install the whole collection of wheels into it using ``pip``.
+
+Optionally, additional files and directories can be included in the archive.
+
+Arguments
+---------
+- ``packages`` - A list of `pw_python_package` targets whose wheels should be
+ included; their dependencies will be pulled in as wheels also.
+- ``inputs`` - An optional list of extra files to include in the generated
+ ``.zip``, formatted the same way as the ``inputs`` argument to ``pw_zip``
+ targets.
+- ``dirs`` - An optional list of directories to include in the generated
+ ``.zip``, formatted the same was as the ``dirs`` argument to ``pw_zip``
+ targets.
+
+Example
+-------
+
+.. code-block::
+
+ import("//build_overrides/pigweed.gni")
+
+ import("$dir_pw_build/python_dist.gni")
+
+ pw_python_zip_with_setup("my_tools") {
+ packages = [ ":some_python_package" ]
+ inputs = [ "$dir_pw_build/python_dist/README.md > /${target_name}/" ]
+ }
diff --git a/pw_build/python_dist.gni b/pw_build/python_dist.gni
new file mode 100644
index 0000000..db41d1e
--- /dev/null
+++ b/pw_build/python_dist.gni
@@ -0,0 +1,126 @@
+# Copyright 2021 The Pigweed Authors
+#
+# 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
+#
+# https://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("//build_overrides/pigweed.gni")
+
+import("$dir_pw_build/python.gni")
+import("$dir_pw_build/python_action.gni")
+import("$dir_pw_build/zip.gni")
+
+# Builds a directory containing a collection of Python wheels.
+#
+# Given one or more pw_python_package targets, this target will build their
+# .wheel sub-targets along with the .wheel sub-targets of all dependencies,
+# direct and indirect, as understood by GN. The resulting .whl files will be
+# collected into a single directory called 'python_wheels'.
+#
+# Args:
+# packages: A list of pw_python_package targets whose wheels should be
+# included; their dependencies will be pulled in as wheels also.
+template("pw_python_wheels") {
+ _outer_name = target_name
+ _wheel_paths_path = "${target_gen_dir}/${target_name}_wheel_paths.txt"
+
+ _deps = []
+ if (defined(invoker.deps)) {
+ _deps = invoker.deps
+ }
+
+ _packages = []
+ foreach(_pkg, invoker.packages) {
+ _pkg_name = get_label_info(_pkg, "label_no_toolchain")
+ _pkg_toolchain = get_label_info(_pkg, "toolchain")
+ _packages += [ "${_pkg_name}.wheel(${_pkg_toolchain})" ]
+ }
+
+ # Build a list of relative paths containing all the wheels we depend on.
+ generated_file("${target_name}._wheel_paths") {
+ data_keys = [ "pw_python_package_wheels" ]
+ rebase = root_build_dir
+ deps = _packages
+ outputs = [ _wheel_paths_path ]
+ }
+
+ pw_python_action(target_name) {
+ deps = _deps + [ ":${_outer_name}._wheel_paths" ]
+ module = "pw_build.collect_wheels"
+
+ args = [
+ "--prefix",
+ rebase_path(root_build_dir),
+ "--suffix",
+ rebase_path(_wheel_paths_path),
+ "--out_dir",
+ rebase_path("${target_out_dir}/python_wheels"),
+ ]
+
+ stamp = true
+ }
+}
+
+# Builds a .zip containing Python wheels and setup scripts.
+#
+# The resulting .zip archive will contain a directory with Python wheels for
+# all pw_python_package targets listed in 'packages', plus wheels for any
+# pw_python_package targets those packages depend on, directly or indirectly,
+# as understood by GN.
+#
+# In addition to Python wheels, the resulting .zip will also contain simple
+# setup scripts for Linux, MacOS, and Windows that take care of creating a
+# Python venv and installing all the included wheels into it, and a README.md
+# file with setup and usage instructions.
+#
+# Args:
+# packages: A list of pw_python_package targets whose wheels should be
+# included; their dependencies will be pulled in as wheels also.
+# inputs: An optional list of extra files to include in the generated .zip,
+# formatted the same was as the 'inputs' argument to pw_zip targets.
+# dirs: An optional list of directories to include in the generated .zip,
+# formatted the same way as the 'dirs' argument to pw_zip targets.
+template("pw_python_zip_with_setup") {
+ _outer_name = target_name
+ _zip_path = "${target_out_dir}/${target_name}.zip"
+ _deps = []
+ if (defined(invoker.deps)) {
+ _deps = invoker.deps
+ }
+ _inputs = []
+ if (defined(invoker.inputs)) {
+ _inputs = invoker.inputs
+ }
+ _dirs = []
+ if (defined(invoker.dirs)) {
+ _dirs = invoker.dirs
+ }
+
+ pw_python_wheels("${target_name}.wheels") {
+ packages = invoker.packages
+ deps = _deps
+ }
+
+ pw_zip("${target_name}") {
+ inputs = _inputs + [
+ "$dir_pw_build/python_dist/setup.bat > /${target_name}/",
+ "$dir_pw_build/python_dist/setup.sh > /${target_name}/",
+ ]
+
+ dirs =
+ _dirs +
+ [ "${target_out_dir}/python_wheels/ > /${target_name}/python_wheels/" ]
+
+ output = _zip_path
+
+ deps = [ ":${_outer_name}.wheels" ]
+ }
+}
diff --git a/pw_build/python_dist/README.md b/pw_build/python_dist/README.md
new file mode 100644
index 0000000..85b4f38
--- /dev/null
+++ b/pw_build/python_dist/README.md
@@ -0,0 +1,33 @@
+# Python Distributables
+Setup and usage instructions for Pigweed Python distributables.
+
+## Prerequisites
+Python distributables require Python 3.7 or later.
+
+## Setup
+Run the included setup script found inside the unzipped directory.
+
+Linux / MacOS:
+```bash
+setup.sh
+```
+
+Windows:
+```
+setup.bat
+```
+
+The setup script will create a virtual environment called `python-venv`.
+
+### Usage
+Once setup is complete, the Python tools can be invoked as runnable modules:
+
+Linux/MacOS:
+```bash
+python-venv/bin/python -m MODULE_NAME [OPTIONS]
+```
+
+Windows:
+```
+python-venv\Scripts\python -m MODULE_NAME [OPTIONS]
+```
diff --git a/pw_build/python_dist/setup.bat b/pw_build/python_dist/setup.bat
new file mode 100644
index 0000000..48252e5
--- /dev/null
+++ b/pw_build/python_dist/setup.bat
@@ -0,0 +1,23 @@
+:: Copyright 2021 The Pigweed Authors
+::
+:: 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
+::
+:: https://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.
+@echo off
+
+:: Generate python virtual environment using existing python.
+python3 -m venv %~dp0\python-venv
+
+:: Install pip inside the virtual environment.
+%~dp0\python-venv\Scripts\python.exe -m pip install --upgrade pip
+
+:: Install all wheel files.
+for %%f in (%~dp0\python_wheels\*) do %~dp0\python-venv\Scripts\python.exe -m pip install --upgrade --force-reinstall --find-links=%~dp0\python_wheels %%f
diff --git a/pw_build/python_dist/setup.sh b/pw_build/python_dist/setup.sh
new file mode 100755
index 0000000..5a4fcac
--- /dev/null
+++ b/pw_build/python_dist/setup.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+
+# Copyright 2021 The Pigweed Authors
+#
+# 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
+#
+# https://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.
+
+set -o xtrace -o errexit -o nounset
+
+SRC="${BASH_SOURCE[0]}"
+DIR="$(python3 -c "import os; print(os.path.dirname(os.path.abspath(os.path.realpath(\"$SRC\"))))")"
+VENV="${DIR}/python-venv"
+PY_TO_TEST="python3"
+
+if [ ! -z "${1-}" ]; then
+ VENV="${1-}"
+ PY_TO_TEST="${VENV}/bin/python"
+fi
+
+PY_MAJOR_VERSION=$(${PY_TO_TEST} -c "import sys; print(sys.version_info[0])")
+PY_MINOR_VERSION=$(${PY_TO_TEST} -c "import sys; print(sys.version_info[1])")
+
+if [ ${PY_MAJOR_VERSION} -ne 3 ] || [ ${PY_MINOR_VERSION} -lt 7 ]
+then
+ echo "ERROR: This Python distributable requires Python 3.7 or newer."
+ exit 1
+fi
+
+if [ ! -d "${VENV}" ]
+then
+ ${PY_TO_TEST} -m venv ${VENV}
+fi
+
+${VENV}/bin/python -m pip install --upgrade pip
+
+for wheel in $(ls ${DIR}/python_wheels/*.whl)
+do
+ ${VENV}/bin/python -m pip install \
+ --upgrade --force-reinstall \
+ --find-links=${DIR}/python_wheels \
+ $wheel
+done
+
+exit 0