pw_presubmit: Add nanopb presubmit checks

Change-Id: Ib72e4b31402bd754fb09d95740ebbcf87d99a554
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/22226
Reviewed-by: Wyatt Hepler <hepler@google.com>
Commit-Queue: Rob Mohr <mohrr@google.com>
diff --git a/pw_package/py/pw_package/package_manager.py b/pw_package/py/pw_package/package_manager.py
index dd20b44..dd89dba 100644
--- a/pw_package/py/pw_package/package_manager.py
+++ b/pw_package/py/pw_package/package_manager.py
@@ -14,11 +14,12 @@
 """Install and remove optional packages."""
 
 import argparse
+import dataclasses
 import logging
 import os
 import pathlib
 import shutil
-from typing import List
+from typing import Dict, List, Tuple
 
 _LOG: logging.Logger = logging.getLogger(__name__)
 
@@ -59,7 +60,7 @@
         """
 
 
-_PACKAGES = {}
+_PACKAGES: Dict[str, Package] = {}
 
 
 def register(package_class: type) -> None:
@@ -67,58 +68,93 @@
     _PACKAGES[obj.name] = obj
 
 
+@dataclasses.dataclass
+class Packages:
+    all: Tuple[str, ...]
+    installed: Tuple[str, ...]
+    available: Tuple[str, ...]
+
+
 class PackageManager:
     """Install and remove optional packages."""
-    def __init__(self):
-        self._pkg_root: pathlib.Path = None
+    def __init__(self, root: pathlib.Path):
+        self._pkg_root = root
+        os.makedirs(root, exist_ok=True)
 
-    def install(self, package: str, force=False):
+    def install(self, package: str, force: bool = False) -> None:
         pkg = _PACKAGES[package]
         if force:
             self.remove(package)
-        _LOG.info('Installing %s...', pkg.name)
         pkg.install(self._pkg_root / pkg.name)
-        _LOG.info('Installing %s...done.', pkg.name)
-        return 0
 
-    def remove(self, package: str):  # pylint: disable=no-self-use
+    def remove(self, package: str) -> None:
         pkg = _PACKAGES[package]
-        _LOG.info('Removing %s...', pkg.name)
         pkg.remove(self._pkg_root / pkg.name)
-        _LOG.info('Removing %s...done.', pkg.name)
-        return 0
 
-    def status(self, package: str):  # pylint: disable=no-self-use
+    def status(self, package: str) -> bool:
         pkg = _PACKAGES[package]
         path = self._pkg_root / pkg.name
-        if os.path.isdir(path) and pkg.status(path):
-            _LOG.info('%s is installed.', pkg.name)
-            return 0
+        return os.path.isdir(path) and pkg.status(path)
 
-        _LOG.info('%s is not installed.', pkg.name)
-        return -1
-
-    def list(self):  # pylint: disable=no-self-use
-        _LOG.info('Installed packages:')
+    def list(self) -> Packages:
+        installed = []
         available = []
         for package in sorted(_PACKAGES.keys()):
             pkg = _PACKAGES[package]
             if pkg.status(self._pkg_root / pkg.name):
-                _LOG.info('  %s', pkg.name)
+                installed.append(pkg.name)
             else:
                 available.append(pkg.name)
+
+        return Packages(
+            all=tuple(_PACKAGES.keys()),
+            installed=tuple(installed),
+            available=tuple(available),
+        )
+
+
+class PackageManagerCLI:
+    """Command-line interface to PackageManager."""
+    def __init__(self):
+        self._mgr: PackageManager = None
+
+    def install(self, package: str, force: bool = False) -> int:
+        _LOG.info('Installing %s...', package)
+        self._mgr.install(package, force)
+        _LOG.info('Installing %s...done.', package)
+        return 0
+
+    def remove(self, package: str) -> int:
+        _LOG.info('Removing %s...', package)
+        self._mgr.remove(package)
+        _LOG.info('Removing %s...done.', package)
+        return 0
+
+    def status(self, package: str) -> int:
+        if self._mgr.status(package):
+            _LOG.info('%s is installed.', package)
+            return 0
+
+        _LOG.info('%s is not installed.', package)
+        return -1
+
+    def list(self) -> int:
+        packages = self._mgr.list()
+
+        _LOG.info('Installed packages:')
+        for package in packages.installed:
+            _LOG.info('  %s', package)
         _LOG.info('')
 
         _LOG.info('Available packages:')
-        for pkg_name in available:
-            _LOG.info('  %s', pkg_name)
+        for package in packages.available:
+            _LOG.info('  %s', package)
         _LOG.info('')
 
         return 0
 
-    def run(self, command: str, pkg_root: pathlib.Path, **kwargs):
-        os.makedirs(pkg_root, exist_ok=True)
-        self._pkg_root = pkg_root
+    def run(self, command: str, pkg_root: pathlib.Path, **kwargs) -> int:
+        self._mgr = PackageManager(pkg_root)
         return getattr(self, command)(**kwargs)
 
 
@@ -144,4 +180,4 @@
 
 
 def run(**kwargs):
-    return PackageManager().run(**kwargs)
+    return PackageManagerCLI().run(**kwargs)
diff --git a/pw_package/py/pw_package/py.typed b/pw_package/py/pw_package/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pw_package/py/pw_package/py.typed
diff --git a/pw_presubmit/py/BUILD.gn b/pw_presubmit/py/BUILD.gn
index 4109256..a9b96e7 100644
--- a/pw_presubmit/py/BUILD.gn
+++ b/pw_presubmit/py/BUILD.gn
@@ -35,5 +35,8 @@
     "presubmit_test.py",
     "tools_test.py",
   ]
-  python_deps = [ "$dir_pw_cli/py" ]
+  python_deps = [
+    "$dir_pw_cli/py",
+    "$dir_pw_package/py",
+  ]
 }
diff --git a/pw_presubmit/py/pw_presubmit/build.py b/pw_presubmit/py/pw_presubmit/build.py
index c08ef3d..72d2aab 100644
--- a/pw_presubmit/py/pw_presubmit/build.py
+++ b/pw_presubmit/py/pw_presubmit/build.py
@@ -20,11 +20,25 @@
 import re
 from typing import Container, Dict, Iterable, List, Mapping, Set, Tuple
 
+from pw_package import package_manager
 from pw_presubmit import call, log_run, plural, PresubmitFailure, tools
 
 _LOG = logging.getLogger(__name__)
 
 
+def install_package(root: Path, name: str) -> None:
+    """Install package with given name in given path."""
+    mgr = package_manager.PackageManager(root)
+
+    if not mgr.list():
+        raise PresubmitFailure(
+            'no packages configured, please import your pw_package '
+            'configuration module')
+
+    if not mgr.status(name):
+        mgr.install(name)
+
+
 def gn_args(**kwargs) -> str:
     """Builds a string to use for the --args argument to gn gen."""
     return '--args=' + ' '.join(f'{arg}={val}' for arg, val in kwargs.items())
diff --git a/pw_presubmit/py/pw_presubmit/cli.py b/pw_presubmit/py/pw_presubmit/cli.py
index de4f956..b43fc69 100644
--- a/pw_presubmit/py/pw_presubmit/cli.py
+++ b/pw_presubmit/py/pw_presubmit/cli.py
@@ -108,6 +108,11 @@
         type=Path,
         help='Output directory (default: <repo root>/.presubmit)',
     )
+    parser.add_argument(
+        '--package-root',
+        type=Path,
+        help='Package root directory (default: <output directory>/packages)',
+    )
 
     exclusive = parser.add_mutually_exclusive_group()
     exclusive.add_argument(
@@ -127,6 +132,7 @@
 def run(
         program: Sequence[Callable],
         output_directory: Path,
+        package_root: Path,
         clear: bool,
         root: Path = None,
         repositories: Collection[Path] = (),
@@ -141,6 +147,7 @@
           defaults to the root of the current directory's repository
       program: from the --program option
       output_directory: from --output-directory option
+      package_root: from --package-root option
       clear: from the --clear option
       **other_args: remaining arguments defined by by add_arguments
 
@@ -156,6 +163,9 @@
     if not output_directory:
         output_directory = root / '.presubmit'
 
+    if not package_root:
+        package_root = output_directory / 'packages'
+
     _LOG.debug('Using environment at %s', output_directory)
 
     if clear:
@@ -171,6 +181,7 @@
                      root,
                      repositories,
                      output_directory=output_directory,
+                     package_root=package_root,
                      **other_args):
         return 0
 
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
index 28a2896..6396dc0 100755
--- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -97,6 +97,21 @@
 
 
 @filter_paths(endswith=_BUILD_EXTENSIONS)
+def gn_nanopb_build(ctx: PresubmitContext):
+    build.install_package(ctx.package_root, 'nanopb')
+    build.gn_gen(ctx.root,
+                 ctx.output_dir,
+                 dir_pw_third_party_nanopb='"{}"'.format(ctx.package_root /
+                                                         'nanopb'),
+                 pw_protobuf_GENERATORS='["nanopb", "nanopb_rpc", "pwpb"]')
+    build.ninja(
+        ctx.output_dir,
+        *_at_all_optimization_levels('stm32f429i'),
+        *_at_all_optimization_levels('host_clang'),
+    )
+
+
+@filter_paths(endswith=_BUILD_EXTENSIONS)
 def gn_qemu_build(ctx: PresubmitContext):
     build.gn_gen(ctx.root, ctx.output_dir)
     build.ninja(ctx.output_dir, *_at_all_optimization_levels('qemu'))
@@ -445,6 +460,7 @@
     # failing.
     oss_fuzz_build,
     bazel_test,
+    gn_nanopb_build,
 )
 
 QUICK = (
diff --git a/pw_presubmit/py/pw_presubmit/presubmit.py b/pw_presubmit/py/pw_presubmit/presubmit.py
index 51ef1f3..a572a9a 100644
--- a/pw_presubmit/py/pw_presubmit/presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/presubmit.py
@@ -177,6 +177,7 @@
     repos: Tuple[Path, ...]
     output_dir: Path
     paths: Tuple[Path, ...]
+    package_root: Path
 
     def relative_paths(self, start: Optional[Path] = None) -> Tuple[Path, ...]:
         return tuple(
@@ -203,13 +204,15 @@
 class Presubmit:
     """Runs a series of presubmit checks on a list of files."""
     def __init__(self, root: Path, repos: Sequence[Path],
-                 output_directory: Path, paths: Sequence[Path]):
+                 output_directory: Path, paths: Sequence[Path],
+                 package_root: Path):
         self._root = root.resolve()
         self._repos = tuple(repos)
         self._output_directory = output_directory.resolve()
         self._paths = tuple(paths)
         self._relative_paths = tuple(
             tools.relative_paths(self._paths, self._root))
+        self._package_root = package_root.resolve()
 
     def run(self, program: Program, keep_going: bool = False) -> bool:
         """Executes a series of presubmit checks on the paths."""
@@ -317,6 +320,7 @@
                 repos=self._repos,
                 output_dir=output_directory,
                 paths=paths,
+                package_root=self._package_root,
             )
 
         finally:
@@ -381,6 +385,7 @@
         paths: Sequence[str] = (),
         exclude: Sequence[Pattern] = (),
         output_directory: Optional[Path] = None,
+        package_root: Path = None,
         keep_going: bool = False) -> bool:
     """Lists files in the current Git repo and runs a Presubmit with them.
 
@@ -403,6 +408,7 @@
         paths: optional list of Git pathspecs to run the checks against
         exclude: regular expressions for Posix-style paths to exclude
         output_directory: where to place output files
+        package_root: where to place package files
         keep_going: whether to continue running checks if an error occurs
 
     Returns:
@@ -430,7 +436,16 @@
     if output_directory is None:
         output_directory = root / '.presubmit'
 
-    presubmit = Presubmit(root, repos, output_directory, files)
+    if package_root is None:
+        package_root = output_directory / 'packages'
+
+    presubmit = Presubmit(
+        root=root,
+        repos=repos,
+        output_directory=output_directory,
+        paths=files,
+        package_root=package_root,
+    )
 
     if not isinstance(program, Program):
         program = Program('', program)
diff --git a/pw_presubmit/py/setup.py b/pw_presubmit/py/setup.py
index 3f97b96..8539057 100644
--- a/pw_presubmit/py/setup.py
+++ b/pw_presubmit/py/setup.py
@@ -25,6 +25,7 @@
         'mypy==0.782',
         'pylint==2.5.3',
         'yapf==0.30.0',
+        'pw_package',
     ],
     packages=setuptools.find_packages(),
     package_data={'pw_presubmit': ['py.typed']},