pw_env_setup: Use GN to install Python packages

For now leave the setup.py search in place to make migration easier for
downstream projects, but largely turn it off for Pigweed itself.

Bug: 287
Change-Id: I79e3a1ba64f98eec2b6617903fad634e8ef0b95a
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/23160
Commit-Queue: Rob Mohr <mohrr@google.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/bootstrap.bat b/bootstrap.bat
index c4e5265..41d3833 100644
--- a/bootstrap.bat
+++ b/bootstrap.bat
@@ -71,6 +71,10 @@
 
 set "_pw_start_script=%PW_ROOT%\pw_env_setup\py\pw_env_setup\windows_env_start.py"
 
+if "%PW_PROJECT_ROOT%"=="" (
+   set "PW_PROJECT_ROOT=%PW_ROOT%"
+)
+
 :: If PW_SKIP_BOOTSTRAP is set, only run the activation stage instead of the
 :: complete env_setup.
 if "%PW_SKIP_BOOTSTRAP%" == "" (
diff --git a/bootstrap.sh b/bootstrap.sh
index 246d007..a7fdd5f 100644
--- a/bootstrap.sh
+++ b/bootstrap.sh
@@ -69,6 +69,10 @@
 PW_ROOT="$(dirname "$_BOOTSTRAP_PATH")"
 export PW_ROOT
 
+# Please also set PW_PROJECT_ROOT to YOUR_PROJECT_ROOT.
+PW_PROJECT_ROOT="$PW_ROOT"
+export PW_PROJECT_ROOT
+
 . "$PW_ROOT/pw_env_setup/util.sh"
 
 pw_deactivate
diff --git a/pw_build/py/pw_build/exec.py b/pw_build/py/pw_build/exec.py
index 0b74458..b956c13 100644
--- a/pw_build/py/pw_build/exec.py
+++ b/pw_build/py/pw_build/exec.py
@@ -22,7 +22,11 @@
 import sys
 from typing import Dict, Optional
 
-import pw_cli.log
+# Need to be able to run without pw_cli installed in the virtualenv.
+try:
+    import pw_cli.log
+except ImportError:
+    pass
 
 _LOG = logging.getLogger(__name__)
 
@@ -157,5 +161,7 @@
 
 
 if __name__ == '__main__':
-    pw_cli.log.install()
+    # If pw_cli is not yet installed in the virtualenv just skip it.
+    if 'pw_cli' in globals():
+        pw_cli.log.install()
     sys.exit(main())
diff --git a/pw_build/python.gni b/pw_build/python.gni
index 6b523ca..9ca3a43 100644
--- a/pw_build/python.gni
+++ b/pw_build/python.gni
@@ -26,7 +26,7 @@
 #     - $name.lint.mypy - Runs mypy.
 #     - $name.lint.pylint - Runs pylint.
 #   - $name.tests - Runs all tests for this package.
-#   - $name.install - Installs the package in a venv. (Not implemented.)
+#   - $name.install - Installs the package in a venv.
 #   - $name.wheel - Builds a Python wheel for the package. (Not implemented.)
 #
 # TODO(pwbug/239): Implement installation and wheel building.
diff --git a/pw_cli/py/pw_cli/env.py b/pw_cli/py/pw_cli/env.py
index 4455fc6..021c98e 100644
--- a/pw_cli/py/pw_cli/env.py
+++ b/pw_cli/py/pw_cli/env.py
@@ -34,6 +34,7 @@
                    type=envparse.strict_bool,
                    default=False)
     parser.add_var('PW_ENVIRONMENT_ROOT')
+    parser.add_var('PW_PROJECT_ROOT')
     parser.add_var('PW_ROOT')
     parser.add_var('PW_SKIP_BOOTSTRAP')
     parser.add_var('PW_SUBPROCESS', type=envparse.strict_bool, default=False)
diff --git a/pw_env_setup/docs.rst b/pw_env_setup/docs.rst
index da1ec9c..640593f 100644
--- a/pw_env_setup/docs.rst
+++ b/pw_env_setup/docs.rst
@@ -28,14 +28,14 @@
 changes to your system. This tooling is designed to be reused by any
 project.
 
+.. _CIPD: https://github.com/luci/luci-go/tree/master/cipd
+
 Users interact with  ``pw_env_setup`` with two commands: ``. bootstrap.sh`` and
 ``. activate.sh``. The bootstrap command always pulls down the current versions
 of CIPD packages and sets up the Python virtual environment. The activate
 command reinitializes a previously configured environment, and if none is found,
 runs bootstrap.
 
-.. _CIPD: https://github.com/luci/luci-go/tree/master/cipd
-
 .. note::
   On Windows the scripts used to set up the environment are ``bootstrap.bat``
   and ``activate.bat``. For simplicity they will be referred to with the ``.sh``
@@ -77,17 +77,24 @@
   # below for a more flexible way to handle this.
   PROJ_SETUP_SCRIPT_PATH="$(pwd)/bootstrap.sh"
 
-  export PROJ_ROOT="$(_python_abspath "$(dirname "$PROJ_SETUP_SCRIPT_PATH")")"
+  export PW_PROJECT_ROOT="$(_python_abspath "$(dirname "$PROJ_SETUP_SCRIPT_PATH")")"
 
   # You may wish to check if the user is attempting to execute this script
   # instead of sourcing it. See below for an example of how to handle that
   # situation.
 
-  # Source Pigweed's bootstrap script.
-  # Using '.' instead of 'source' for dash compatibility. Since users don't use
-  # dash directly, using 'source' in documentation so users don't get confused
-  # and try to `./bootstrap.sh`.
-  . "$PROJ_ROOT/third_party/pigweed/$(basename "$PROJ_SETUP_SCRIPT_PATH")"
+  # Source Pigweed's bootstrap utility script.
+  # Using '.' instead of 'source' for POSIX compatibility. Since users don't use
+  # dash directly, using 'source' in most documentation so users don't get
+  # confused and try to `./bootstrap.sh`.
+  . "$PW_PROJECT_ROOT/third_party/pigweed/pw_env_setup/util.sh"
+
+  pw_check_root "$PW_ROOT"
+  _PW_ACTUAL_ENVIRONMENT_ROOT="$(pw_get_env_root)"
+  export _PW_ACTUAL_ENVIRONMENT_ROOT
+  SETUP_SH="$_PW_ACTUAL_ENVIRONMENT_ROOT/activate.sh"
+  pw_bootstrap --args...  # See below for details about args.
+  pw_finalize bootstrap "$SETUP_SH"
 
 User-Friendliness
 -----------------
@@ -150,27 +157,18 @@
     case ${0##*/} in sh|dash) _pw_sourced=1;; esac
   fi
 
-  if [ "$_pw_sourced" -eq 0 ]; then
-    _S_NAME=$(basename "$PROJ_SETUP_SCRIPT_PATH" .sh)
-    echo "Error: Attempting to $_S_NAME in a subshell"
-    echo "  Since $_S_NAME.sh modifies your shell's environment variables, it"
-    echo "  must be sourced rather than executed. In particular, "
-    echo "  'bash $_S_NAME.sh' will not work since the modified environment "
-    echo "  will get destroyed at the end of the script. Instead, source the "
-    echo "  script's contents in your shell:"
-    echo ""
-    echo "    \$ source $_S_NAME.sh"
-    exit 1
-  fi
+  _pw_eval_sourced "$_pw_sourced"
 
 Downstream Projects Using Different Packages
 ********************************************
 
 Projects depending on Pigweed but using additional or different packages should
-copy Pigweed's ``bootstrap.sh`` and update the call to ``env_setup.py``. Search
-for "downstream" for other places that may require changes, like setting the
-``PW_ROOT`` environment variable. Relevant arguments to ``env_setup.py`` are
-listed here.
+copy the Pigweed `sample project`'s ``bootstrap.sh`` and update the call to
+``pw_bootstrap``. Search for "downstream" for other places that may require
+changes, like setting the ``PW_ROOT`` and ``PW_PROJECT_ROOT`` environment
+variables. Relevant arguments to ``pw_bootstrap`` are listed here.
+
+.. _sample project: https://pigweed.googlesource.com/pigweed/sample_project/+/master
 
 ``--use-pigweed-defaults``
   Use Pigweed default values in addition to the other switches.
@@ -182,8 +180,13 @@
 ``--virtualenv-requierements path/to/requirements.txt``
   Pip requirements file. Compiled with pip-compile.
 
-``--virtualenv-setup-py-root path/to/directory``
-  Directory in which to recursively search for ``setup.py`` files.
+``--virtualenv-gn-target path/to/directory#package-install-target``
+  Target for installing Python packages, and the directory from which it must be
+  run. Example for Pigweed: ``third_party/pigweed#:python.install`` (assuming
+  Pigweed is included in the project at ``third_party/pigweed``). Downstream
+  projects will need to create targets to install their packages and either
+  choose a subset of Pigweed packages or use
+  ``third_party/pigweed#:python.install`` to install all Pigweed packages.
 
 ``--cargo-package-file path/to/packages.txt``
   Rust cargo packages to install. Lines with package name and version separated
@@ -196,12 +199,12 @@
 
 .. code-block:: bash
 
-  "$ROOT/third_party/pigweed/pw_env_setup/py/pw_env_setup/env_setup.py" \
+  pw_bootstrap \
     --shell-file "$SETUP_SH" \
     --install-dir "$_PW_ACTUAL_ENVIRONMENT_ROOT" \
     --use-pigweed-defaults \
-    --cipd-package-file "$ROOT/path/to/cipd.json" \
-    --virtualenv-setup-py-root "$ROOT"
+    --cipd-package-file "$PW_PROJECT_ROOT/path/to/cipd.json" \
+    --virtualenv-setup-py-root "$PW_PROJECT_ROOT"
 
 Projects wanting some of the Pigweed environment packages but not all of them
 should not use ``--use-pigweed-defaults`` and must manually add the references
@@ -216,8 +219,8 @@
   "$PW_ROOT/pw_env_setup/py/pw_env_setup/cipd_setup/luci.json"
   --virtualenv-requirements
   "$PW_ROOT/pw_env_setup/py/pw_env_setup/virtualenv_setup/requirements.txt"
-  --virtualenv-setup-py-root
-  "$PW_ROOT"
+  --virtualenv-gn-target
+  "$PW_ROOT#:python.install"
   --cargo-package-file
   "$PW_ROOT/pw_env_setup/py/pw_env_setup/cargo_setup/packages.txt"
 
diff --git a/pw_env_setup/py/pw_env_setup/env_setup.py b/pw_env_setup/py/pw_env_setup/env_setup.py
index 94f4bd9..328e8c4 100755
--- a/pw_env_setup/py/pw_env_setup/env_setup.py
+++ b/pw_env_setup/py/pw_env_setup/env_setup.py
@@ -152,7 +152,7 @@
                     'warning: pattern "{}" matched 0 files'.format(pat))
             files.extend(matches)
 
-    if not files:
+    if globs and not files:
         warnings.append('warning: matched 0 total files')
 
     return files, warnings
@@ -174,8 +174,10 @@
     def __init__(self, pw_root, cipd_cache_dir, shell_file, quiet, install_dir,
                  use_pigweed_defaults, cipd_package_file, virtualenv_root,
                  virtualenv_requirements, virtualenv_setup_py_root,
-                 cargo_package_file, enable_cargo, json_file):
+                 virtualenv_gn_target, cargo_package_file, enable_cargo,
+                 json_file, project_root):
         self._env = environment.Environment()
+        self._project_root = project_root
         self._pw_root = pw_root
         self._setup_root = os.path.join(pw_root, 'pw_env_setup', 'py',
                                         'pw_env_setup')
@@ -196,6 +198,7 @@
         self._cipd_package_file = []
         self._virtualenv_requirements = []
         self._virtualenv_setup_py_root = []
+        self._virtualenv_gn_targets = []
         self._cargo_package_file = []
         self._enable_cargo = enable_cargo
 
@@ -218,15 +221,19 @@
             self._virtualenv_requirements.append(
                 os.path.join(setup_root, 'virtualenv_setup',
                              'requirements.txt'))
-            self._virtualenv_setup_py_root.append(pw_root)
+            self._virtualenv_gn_targets.append(
+                virtualenv_setup.GnTarget(
+                    '{}#:python.install'.format(pw_root)))
             self._cargo_package_file.append(
                 os.path.join(setup_root, 'cargo_setup', 'packages.txt'))
 
         self._cipd_package_file.extend(cipd_package_file)
         self._virtualenv_requirements.extend(virtualenv_requirements)
         self._virtualenv_setup_py_root.extend(virtualenv_setup_py_root)
+        self._virtualenv_gn_targets.extend(virtualenv_gn_target)
         self._cargo_package_file.extend(cargo_package_file)
 
+        self._env.set('PW_PROJECT_ROOT', project_root)
         self._env.set('PW_ROOT', pw_root)
         self._env.set('_PW_ACTUAL_ENVIRONMENT_ROOT', install_dir)
         self._env.add_replacement('_PW_ACTUAL_ENVIRONMENT_ROOT', install_dir)
@@ -396,14 +403,19 @@
                 shutil.copyfile(new_python3, python3_copy)
             new_python3 = python3_copy
 
-        if not requirements and not setup_py_roots:
+        if (not requirements and not self._virtualenv_setup_py_root
+                and not self._virtualenv_gn_targets):
             return result(_Result.Status.SKIPPED)
 
-        if not virtualenv_setup.install(venv_path=self._virtualenv_root,
-                                        requirements=requirements,
-                                        setup_py_roots=setup_py_roots,
-                                        python=new_python3,
-                                        env=self._env):
+        if not virtualenv_setup.install(
+                project_root=self._project_root,
+                venv_path=self._virtualenv_root,
+                requirements=requirements,
+                gn_targets=self._virtualenv_gn_targets,
+                setup_py_roots=setup_py_roots,
+                python=new_python3,
+                env=self._env,
+        ):
             return result(_Result.Status.FAILED)
 
         return result(_Result.Status.DONE)
@@ -446,12 +458,21 @@
                     stderr=outs).strip()
         except subprocess.CalledProcessError:
             pw_root = None
+
     parser.add_argument(
         '--pw-root',
         default=pw_root,
         required=not pw_root,
     )
 
+    project_root = os.environ.get('PW_PROJECT_ROOT', None) or pw_root
+
+    parser.add_argument(
+        '--project-root',
+        default=project_root,
+        required=not project_root,
+    )
+
     parser.add_argument(
         '--cipd-cache-dir',
         default=os.environ.get('CIPD_CACHE_DIR',
@@ -507,6 +528,15 @@
     )
 
     parser.add_argument(
+        '--virtualenv-gn-target',
+        help=('GN targets that build and install Python packages. Format: '
+              "path/to/gn_root#target"),
+        default=[],
+        action='append',
+        type=virtualenv_setup.GnTarget,
+    )
+
+    parser.add_argument(
         '--virtualenv-root',
         help=('Basename of virtualenv directory. Default: '
               '<install_dir>/pigweed-venv'),
@@ -540,6 +570,7 @@
         'cipd_package_file',
         'virtualenv_requirements',
         'virtualenv_setup_py_root',
+        'virtualenv_gn_target',
         'cargo_package_file',
     )
 
diff --git a/pw_env_setup/py/pw_env_setup/virtualenv_setup/__main__.py b/pw_env_setup/py/pw_env_setup/virtualenv_setup/__main__.py
index 06cca9e..cfdb3a6 100644
--- a/pw_env_setup/py/pw_env_setup/virtualenv_setup/__main__.py
+++ b/pw_env_setup/py/pw_env_setup/virtualenv_setup/__main__.py
@@ -26,6 +26,13 @@
 
 def _main():
     parser = argparse.ArgumentParser(description=__doc__)
+
+    project_root = os.environ.get('PW_PROJECT_ROOT', None)
+
+    parser.add_argument('--project-root',
+                        default=project_root,
+                        required=not project_root,
+                        help='Path to overall project root.')
     parser.add_argument('--venv_path',
                         required=True,
                         help='Path at which to create the venv')
@@ -39,6 +46,12 @@
                         default=[],
                         action='append',
                         help='places to search for setup.py files')
+    parser.add_argument('--gn-target',
+                        dest='gn_targets',
+                        default=[],
+                        action='append',
+                        type=virtualenv_setup.GnTarget,
+                        help='GN targets that install packages')
     parser.add_argument('--quick-setup',
                         dest='full_envsetup',
                         action='store_false',
diff --git a/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py b/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
index 674f974..b725c86 100644
--- a/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
+++ b/pw_env_setup/py/pw_env_setup/virtualenv_setup/install.py
@@ -17,11 +17,25 @@
 
 import glob
 import os
+import re
 import subprocess
 import sys
 import tempfile
 
 
+class GnTarget(object):  # pylint: disable=useless-object-inheritance
+    def __init__(self, val):
+        self.directory, self.target = val.split('#', 1)
+
+    @property
+    def name(self):
+        """A reasonably stable and unique name for each pair."""
+        result = '{}-{}'.format(
+            os.path.basename(os.path.normpath(self.directory)),
+            hash(self.directory + self.target))
+        return re.sub(r'[:/#_]*', '_', result)
+
+
 def git_stdout(*args, **kwargs):
     """Run git, passing args as git params and kwargs to subprocess."""
     return subprocess.check_output(['git'] + list(args), **kwargs).strip()
@@ -93,9 +107,11 @@
 
 
 def install(
+        project_root,
         venv_path,
         full_envsetup=True,
         requirements=(),
+        gn_targets=(),
         python=sys.executable,
         setup_py_roots=(),
         env=None,
@@ -172,9 +188,33 @@
         pip_install('--log', os.path.join(venv_path, 'pip-packages.log'),
                     *(find_args + package_args))
 
-    if env:
-        env.set('VIRTUAL_ENV', venv_path)
-        env.prepend('PATH', venv_bin)
-        env.clear('PYTHONHOME')
+    def install_packages(gn_target):
+        build = os.path.join(venv_path, gn_target.name)
+
+        gn_log = 'gn-gen-{}.log'.format(gn_target.name)
+        with open(os.path.join(venv_path, gn_log), 'w') as outs:
+            subprocess.check_call(('gn', 'gen', build),
+                                  cwd=os.path.join(project_root,
+                                                   gn_target.directory),
+                                  stdout=outs,
+                                  stderr=outs)
+
+        ninja_log = 'ninja-{}.log'.format(gn_target.name)
+        with open(os.path.join(venv_path, ninja_log), 'w') as outs:
+            ninja_cmd = ['ninja', '-C', build]
+            ninja_cmd.append(gn_target.target)
+            subprocess.check_call(ninja_cmd, stdout=outs, stderr=outs)
+
+    if gn_targets:
+        if env:
+            env.set('VIRTUAL_ENV', venv_path)
+            env.prepend('PATH', venv_bin)
+            env.clear('PYTHONHOME')
+            with env():
+                for gn_target in gn_targets:
+                    install_packages(gn_target)
+        else:
+            for gn_target in gn_targets:
+                install_packages(gn_target)
 
     return True
diff --git a/pw_presubmit/py/pw_presubmit/environment.py b/pw_presubmit/py/pw_presubmit/environment.py
index 1b54a7a..23aa347 100644
--- a/pw_presubmit/py/pw_presubmit/environment.py
+++ b/pw_presubmit/py/pw_presubmit/environment.py
@@ -65,12 +65,24 @@
         output_directory: Path,
         setup_py_roots: Iterable[Union[Path, str]] = (),
         requirements: Iterable[Union[Path, str]] = (),
+        gn_targets: Iterable[str] = (),
 ) -> None:
     """Sets up a virtualenv, assumes recent Python 3 is already installed."""
     virtualenv_source = pigweed_root.joinpath('pw_env_setup', 'py',
                                               'pw_env_setup',
                                               'virtualenv_setup')
 
+    # Need to set VIRTUAL_ENV before invoking GN because the GN targets install
+    # directly to the current virtual env.
+    os.environ['VIRTUAL_ENV'] = str(output_directory)
+    os.environ['PATH'] = os.pathsep.join((
+        str(output_directory.joinpath('bin')),
+        os.environ['PATH'],
+    ))
+
+    if not gn_targets:
+        gn_targets = (f'{os.environ["PW_ROOT"]}#:python.install', )
+
     # For speed, don't build the venv if it exists. Use --clean to rebuild.
     if not output_directory.joinpath('pyvenv.cfg').is_file():
         call(
@@ -79,10 +91,6 @@
             f'--venv_path={output_directory}',
             f'--requirements={virtualenv_source / "requirements.txt"}',
             *(f'--requirements={x}' for x in requirements),
-            *(f'--setup-py-root={p}' for p in [pigweed_root, *setup_py_roots]),
+            *(f'--setup-py-root={p}' for p in setup_py_roots),
+            *(f'--gn-target={t}' for t in gn_targets),
         )
-
-    os.environ['PATH'] = os.pathsep.join((
-        str(output_directory.joinpath('bin')),
-        os.environ['PATH'],
-    ))