pw_presubmit: Support multiple repositories
- Support checking files in multiple repositories.
- Support specifying the root path to use, which does not have to be in
a Git repo.
- Remove the --repository argument, which is redundant with -C and
conflicts with having multiple repos.
- Update the semantics for specifying pathspecs to work naturally across
multiple repositories.
- Update the Pigweed presubmit with the new arguments.
- Expand docs.rst and docstrings.
Change-Id: Ibaa494bdc645af3e8f8cc3b736f89098de0d1634
diff --git a/pw_cli/docs.rst b/pw_cli/docs.rst
index 99a22fa..fa5da59 100644
--- a/pw_cli/docs.rst
+++ b/pw_cli/docs.rst
@@ -58,7 +58,6 @@
defined in Python packages that are installed in the Pigweed virtual
environment.
-
Plugin registrations in a ``PW_PLUGINS`` file apply to the their directory and
all subdirectories, similarly to configuration files like ``.clang-format``.
Registered plugins appear as commands in the ``pw`` tool when ``pw`` is run from
diff --git a/pw_presubmit/docs.rst b/pw_presubmit/docs.rst
index 3bbfd6f..427f15c 100644
--- a/pw_presubmit/docs.rst
+++ b/pw_presubmit/docs.rst
@@ -4,9 +4,9 @@
.. highlight:: sh
-------------
+============
pw_presubmit
-------------
+============
The presubmit module provides Python tools for running presubmit checks and
checking and fixing code format. It also includes the presubmit check script for
the Pigweed repository, ``pigweed_presubmit.py``.
@@ -27,20 +27,56 @@
:alt: ``pw format`` demo
:align: left
+The ``pw_presubmit`` package includes presubmit checks that can be used with any
+project. These checks include:
+
+* Check code format of several languages including C, C++, and Python
+* Initialize a Python environment
+* Run all Python tests
+* Run pylint
+* Run mypy
+* Ensure source files are included in the GN and Bazel builds
+* Build and run all tests with GN
+* Build and run all tests with Bazel
+* Ensure all header files contain ``#pragma once``
+
+-------------
Compatibility
-=============
+-------------
Python 3
-Presubmit tools
-===============
+-------------------------------------------
+Creating a presubmit check for your project
+-------------------------------------------
+Creating a presubmit check for a project using ``pw_presubmit`` is simple, but
+requires some customization. Projects must define their own presubmit check
+Python script that uses the ``pw_presubmit`` package.
-Defining presubmit checks
--------------------------
+A project's presubmit script can be registered as a
+:ref:`pw_cli <chapter-pw-cli>` plugin, so that it can be run as ``pw
+presubmit``.
+
+Setting up the command-line interface
+-------------------------------------
+The ``pw_presubmit.cli`` module sets up the command-line interface for a
+presubmit script. This defines a standard set of arguments for invoking
+presubmit checks. Its use is optional, but recommended.
+
+pw_presubmit.cli
+~~~~~~~~~~~~~~~~
+.. automodule:: pw_presubmit.cli
+ :members: add_arguments, run
+
+Presubmit checks
+----------------
A presubmit check is defined as a function or other callable. The function must
accept one argument: a ``PresubmitContext``, which provides the paths on which
-to run. Presubmit checks communicate failure by raising any exception.
+to run. Presubmit checks communicate failure by raising an exception.
-For example, either of these functions may be used as presubmit checks:
+Presubmit checks may use the ``filter_paths`` decorator to automatically filter
+the paths list for file types they care about.
+
+Either of these functions could be used as presubmit checks:
.. code-block:: python
@@ -55,42 +91,155 @@
def run_the_build(_):
subprocess.run(['make', 'release'], check=True)
-Presubmit checks may use the ``filter_paths`` decorator to automatically filter
-the paths list for file types they care about.
+Presubmit checks functions are grouped into "programs" -- a named series of
+checks. Projects may find it helpful to have programs for different purposes,
+such as a quick program for local use and a full program for automated use. The
+:ref:`example script <example-script>` uses ``pw_presubmit.Programs`` to define
+``quick`` and ``full`` programs.
-See ``pigweed_presubmit.py`` for an example of how to define presubmit checks.
+pw_presubmit
+~~~~~~~~~~~~
+.. automodule:: pw_presubmit
+ :members: filter_paths, call, PresubmitFailure, Programs
-Key members
-^^^^^^^^^^^
-.. autofunction:: pw_presubmit.cli.add_arguments
+.. _example-script:
-.. autofunction:: pw_presubmit.cli.run
+Example
+-------
+A simple example presubmit check script follows. This can be copied-and-pasted
+to serve as a starting point for a project's presubmit check script.
-.. autodecorator:: pw_presubmit.filter_paths
+See ``pigweed_presubmit.py`` for a more complex presubmit check script example.
-.. autofunction:: pw_presubmit.call
+.. code-block:: python
-.. autoexception:: pw_presubmit.PresubmitFailure
+ """Example presubmit check script."""
-.. autoexception:: pw_presubmit.Programs
+ import argparse
+ import logging
+ import os
+ from pathlib import Path
+ import re
+ import sys
+ from typing import List, Pattern
-Included presubmit checks
--------------------------
-The ``pw_presubmit`` package includes presubmit checks that can be used with any
-project. These checks include:
+ try:
+ import pw_cli.log
+ except ImportError:
+ print('ERROR: Activate the environment before running presubmits!',
+ file=sys.stderr)
+ sys.exit(2)
-* Check code format of several languages including C, C++, and Python
-* Initialize a Python environment
-* Run all Python tests
-* Run pylint
-* Run mypy
-* Ensure source files are included in the GN and Bazel builds
-* Build and run all tests with GN
-* Build and run all tests with Bazel
-* Ensure all header files contain ``#pragma once``
+ import pw_presubmit
+ from pw_presubmit import build, cli, environment, format_code, git_repo
+ from pw_presubmit import python_checks, filter_paths, PresubmitContext
+ from pw_presubmit.install_hook import install_hook
+ # Set up variables for key project paths.
+ PROJECT_ROOT = Path(os.environ['MY_PROJECT_ROOT'])
+ PIGWEED_ROOT = PROJECT_ROOT / 'pigweed'
+
+ #
+ # Initialization
+ #
+ def init_cipd(ctx: PresubmitContext):
+ environment.init_cipd(PIGWEED_ROOT, ctx.output_dir)
+
+
+ def init_virtualenv(ctx: PresubmitContext):
+ environment.init_virtualenv(PIGWEED_ROOT,
+ ctx.output_dir,
+ setup_py_roots=[PROJECT_ROOT])
+
+
+ # Rerun the build if files with these extensions change.
+ _BUILD_EXTENSIONS = frozenset(
+ ['.rst', '.gn', '.gni', *format_code.C_FORMAT.extensions])
+
+
+ #
+ # Presubmit checks
+ #
+ def release_build(ctx: PresubmitContext):
+ build.gn_gen(PROJECT_ROOT, ctx.output_dir, build_type='release')
+ build.ninja(ctx.output_dir)
+
+
+ def host_tests(ctx: PresubmitContext):
+ build.gn_gen(PROJECT_ROOT, ctx.output_dir, run_host_tests='true')
+ build.ninja(ctx.output_dir)
+
+
+ # Avoid running some checks on certain paths.
+ PATH_EXCLUSIONS = (
+ re.compile(r'^external/'),
+ re.compile(r'^vendor/'),
+ )
+
+
+ # Use the upstream pragma_once check, but apply a different set of path
+ # filters with @filter_paths.
+ @filter_paths(endswith='.h', exclude=PATH_EXCLUSIONS)
+ def pragma_once(ctx: PresubmitContext):
+ pw_presubmit.pragma_once(ctx)
+
+
+ #
+ # Presubmit check programs
+ #
+ QUICK = (
+ # Initialize an environment for running presubmit checks.
+ init_cipd,
+ init_virtualenv,
+ # List some presubmit checks to run
+ pragma_once,
+ host_tests,
+ # Use the upstream formatting checks, with custom path filters applied.
+ format_code.presubmit_checks(exclude=PATH_EXCLUSIONS),
+ )
+
+ FULL = (
+ QUICK, # Add all checks from the 'quick' program
+ release_build,
+ # Use the upstream Python checks, with custom path filters applied.
+ python_checks.all_checks(exclude=PATH_EXCLUSIONS),
+ )
+
+ PROGRAMS = pw_presubmit.Programs(quick=QUICK, full=FULL)
+
+
+ def run(install: bool, **presubmit_args) -> int:
+ """Process the --install argument then invoke pw_presubmit."""
+
+ # Install the presubmit Git pre-push hook, if requested.
+ if install:
+ install_hook(__file__, 'pre-push', ['--base', 'HEAD~'],
+ git_repo.root())
+ return 0
+
+ return cli.run(root=PROJECT_ROOT, **presubmit_args)
+
+
+ def main() -> int:
+ """Run the presubmit checks for this repository."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ cli.add_arguments(parser, PROGRAMS, 'quick')
+
+ # Define an option for installing a Git pre-push hook for this script.
+ parser.add_argument(
+ '--install',
+ action='store_true',
+ help='Install the presubmit as a Git pre-push hook and exit.')
+
+ return run(**vars(parser.parse_args()))
+
+ if __name__ == '__main__':
+ pw_cli.log.install(logging.INFO)
+ sys.exit(main())
+
+---------------------
Code formatting tools
-=====================
+---------------------
The ``pw_presubmit.format_code`` module formats supported source files using
external code format tools. The file ``format_code.py`` can be invoked directly
from the command line or from ``pw`` as ``pw format``.
diff --git a/pw_presubmit/py/pw_presubmit/cli.py b/pw_presubmit/py/pw_presubmit/cli.py
index 2c46b10..37d1a57 100644
--- a/pw_presubmit/py/pw_presubmit/cli.py
+++ b/pw_presubmit/py/pw_presubmit/cli.py
@@ -18,7 +18,7 @@
from pathlib import Path
import re
import shutil
-from typing import Callable, Optional, Sequence
+from typing import Callable, Collection, Optional, Sequence
from pw_presubmit import git_repo, presubmit
@@ -32,24 +32,24 @@
'paths',
metavar='pathspec',
nargs='*',
- type=Path,
- help=('Paths to which to restrict the checks. These are interpreted '
- 'as Git pathspecs. If --base is provided, only paths changed '
- 'since that commit are checked.'))
+ help=('Paths or patterns to which to restrict the checks. These are '
+ 'interpreted as Git pathspecs. If --base is provided, only '
+ 'paths changed since that commit are checked.'))
parser.add_argument(
'-b',
'--base',
- metavar='COMMIT',
+ metavar='commit',
help=('Git revision against which to diff for changed files. '
'If none is provided, the entire repository is used.'))
parser.add_argument(
'-e',
'--exclude',
- metavar='REGULAR_EXPRESSION',
+ metavar='regular_expression',
default=[],
action='append',
type=re.compile,
- help='Exclude paths matching any of these regular expressions.')
+ help=('Exclude paths matching any of these regular expressions, '
+ "which are interpreted relative to each Git repository's root."))
def _add_programs_arguments(exclusive: argparse.ArgumentParser,
@@ -99,13 +99,6 @@
"""Adds common presubmit check options to an argument parser."""
add_path_arguments(parser)
- parser.add_argument(
- '--repository',
- default=Path.cwd(),
- type=Path,
- help=(
- 'Change to this directory before resolving paths or running the '
- 'presubmit. Presubmit checks must be run from a Git repository.'))
parser.add_argument('-k',
'--keep-going',
action='store_true',
@@ -132,16 +125,36 @@
def run(
- program: Sequence[Callable],
- clear: bool,
- repository: Path,
- output_directory: Path,
- **other_args,
+ program: Sequence[Callable],
+ output_directory: Path,
+ clear: bool,
+ root: Path = None,
+ repositories: Collection[Path] = (),
+ **other_args,
) -> int:
- """Processes all arguments from add_arguments and runs the presubmit."""
+ """Processes arguments from add_arguments and runs the presubmit.
+
+ Args:
+ root: base path from which to run presubmit checks; defaults to the root
+ of the current directory's repository
+ repositories: roots of Git repositories on which to run presubmit checks;
+ defaults to the root of the current directory's repository
+ program: from the --program option
+ output_directory: from --output-directory option
+ clear: from the --clear option
+ **other_args: remaining arguments defined by by add_arguments
+
+ Returns:
+ exit code for sys.exit; 0 if succesful, 1 if an error occurred
+ """
+ if root is None:
+ root = git_repo.root()
+
+ if not repositories:
+ repositories = [root]
if not output_directory:
- output_directory = git_repo.path('.presubmit', repo=repository)
+ output_directory = root / '.presubmit'
_LOG.debug('Using environment at %s', output_directory)
@@ -155,7 +168,8 @@
return 0
if presubmit.run(program,
- repo_path=repository,
+ root,
+ repositories,
output_directory=output_directory,
**other_args):
return 0
diff --git a/pw_presubmit/py/pw_presubmit/format_code.py b/pw_presubmit/py/pw_presubmit/format_code.py
index dc017cf..4f84408 100755
--- a/pw_presubmit/py/pw_presubmit/format_code.py
+++ b/pw_presubmit/py/pw_presubmit/format_code.py
@@ -29,7 +29,7 @@
import subprocess
import sys
from typing import Callable, Collection, Dict, Iterable, List, NamedTuple
-from typing import Optional, Pattern, Tuple
+from typing import Optional, Pattern, Tuple, Union
try:
import pw_presubmit
@@ -41,7 +41,7 @@
import pw_presubmit
from pw_presubmit import cli, git_repo
-from pw_presubmit.tools import file_summary, log_run, plural
+from pw_presubmit.tools import exclude_paths, file_summary, log_run, plural
_LOG: logging.Logger = logging.getLogger(__name__)
@@ -323,13 +323,14 @@
class CodeFormatter:
"""Checks or fixes the formatting of a set of files."""
- def __init__(self, files: Collection[Path]):
+ def __init__(self, files: Iterable[Path]):
self.paths = list(files)
self._formats: Dict[CodeFormat, List] = collections.defaultdict(list)
- for path in files:
+ for path in self.paths:
for code_format in CODE_FORMATS:
- if any(str(path).endswith(e) for e in code_format.extensions):
+ if any(path.as_posix().endswith(e)
+ for e in code_format.extensions):
self._formats[code_format].append(path)
def check(self) -> Dict[Path, str]:
@@ -350,19 +351,19 @@
plural(files, code_format.language + ' file'))
-def _file_summary(files: Iterable[Path], base: Path) -> List[str]:
+def _file_summary(files: Iterable[Union[Path, str]], base: Path) -> List[str]:
try:
- return file_summary(f.resolve().relative_to(base.resolve())
- for f in files)
+ return file_summary(
+ Path(f).resolve().relative_to(base.resolve()) for f in files)
except ValueError:
return []
-def format_paths_in_repo(paths: Collection[Path],
+def format_paths_in_repo(paths: Collection[Union[Path, str]],
exclude: Collection[Pattern[str]], fix: bool,
base: str) -> int:
"""Checks or fixes formatting for files in a Git repo."""
- files = [path.resolve() for path in paths if path.is_file()]
+ files = [Path(path).resolve() for path in paths if os.path.isfile(path)]
repo = git_repo.root() if git_repo.is_repo() else None
# If this is a Git repo, list the original paths with git ls-files or diff.
@@ -373,7 +374,8 @@
# Add files from Git and remove duplicates.
files = sorted(
- set(git_repo.list_files(base, paths, exclude)) | set(files))
+ set(exclude_paths(exclude, git_repo.list_files(base, paths)))
+ | set(files))
elif base:
_LOG.critical(
'A base commit may only be provided if running from a Git repo')
@@ -382,11 +384,11 @@
return format_files(files, fix, repo=repo)
-def format_files(paths: Collection[Path],
+def format_files(paths: Collection[Union[Path, str]],
fix: bool,
repo: Optional[Path] = None) -> int:
"""Checks or fixes formatting for the specified files."""
- formatter = CodeFormatter(paths)
+ formatter = CodeFormatter(Path(p) for p in paths)
_LOG.info('Checking formatting for %s', plural(formatter.paths, 'file'))
diff --git a/pw_presubmit/py/pw_presubmit/git_repo.py b/pw_presubmit/py/pw_presubmit/git_repo.py
index cb2ebc7..5f29ae1 100644
--- a/pw_presubmit/py/pw_presubmit/git_repo.py
+++ b/pw_presubmit/py/pw_presubmit/git_repo.py
@@ -16,7 +16,7 @@
import collections
from pathlib import Path
import subprocess
-from typing import Collection, Dict, Iterable, Iterator, List, Optional
+from typing import Collection, Dict, Iterable, List, Optional
from typing import Pattern, Union
from pw_presubmit.tools import log_run, plural
@@ -24,59 +24,56 @@
PathOrStr = Union[Path, str]
-def git_stdout(*args: PathOrStr, repo: PathOrStr = '.') -> str:
+def git_stdout(*args: PathOrStr,
+ show_stderr=False,
+ repo: PathOrStr = '.') -> str:
return log_run(['git', '-C', repo, *args],
stdout=subprocess.PIPE,
+ stderr=None if show_stderr else subprocess.DEVNULL,
check=True).stdout.decode().strip()
-def _ls_files(args: Collection[PathOrStr], repo: Path) -> List[Path]:
- repo = repo.resolve()
- return [
- repo / path for path in git_stdout('ls-files', '--', *args,
- repo=repo).splitlines()
- ]
+def _ls_files(args: Collection[PathOrStr], repo: Path) -> Iterable[Path]:
+ """Returns results of git ls-files as absolute paths."""
+ git_root = repo.resolve()
+ for file in git_stdout('ls-files', '--', *args, repo=repo).splitlines():
+ yield git_root / file
def _diff_names(commit: str, pathspecs: Collection[PathOrStr],
- repo: Path) -> List[Path]:
+ repo: Path) -> Iterable[Path]:
"""Returns absolute paths of files changed since the specified commit."""
git_root = root(repo)
- return [
- git_root.joinpath(path).resolve()
- for path in git_stdout('diff',
- '--name-only',
- '--diff-filter=d',
- commit,
- '--',
- *pathspecs,
- repo=repo).splitlines()
- ]
+ for file in git_stdout('diff',
+ '--name-only',
+ '--diff-filter=d',
+ commit,
+ '--',
+ *pathspecs,
+ repo=repo).splitlines():
+ yield git_root / file
def list_files(commit: Optional[str] = None,
pathspecs: Collection[PathOrStr] = (),
- exclude: Collection[Pattern[str]] = (),
- repo: Optional[Path] = None) -> List[Path]:
+ repo_path: Optional[Path] = None) -> List[Path]:
"""Lists files with git ls-files or git diff --name-only.
Args:
commit: commit to use as a base for git diff
pathspecs: Git pathspecs to use in git ls-files or diff
- exclude: regular expressions for Posix-style paths to exclude
- repo: repository path from which to run commands; defaults to Path.cwd()
+ repo_path: repo path from which to run commands; defaults to Path.cwd()
+
+ Returns:
+ A sorted list of absolute paths
"""
- if repo is None:
- repo = Path.cwd()
+ if repo_path is None:
+ repo_path = Path.cwd()
if commit:
- files = _diff_names(commit, pathspecs, repo)
- else:
- files = _ls_files(pathspecs, repo)
+ return sorted(_diff_names(commit, pathspecs, repo_path))
- git_root = root(repo=repo).resolve()
- return sorted(file for file in files if not any(
- e.search(file.relative_to(git_root).as_posix()) for e in exclude))
+ return sorted(_ls_files(pathspecs, repo_path))
def has_uncommitted_changes(repo: Optional[Path] = None) -> bool:
@@ -98,7 +95,7 @@
def _describe_constraints(git_root: Path, repo_path: Path,
commit: Optional[str],
pathspecs: Collection[PathOrStr],
- exclude: Collection[Pattern]) -> Iterator[str]:
+ exclude: Collection[Pattern[str]]) -> Iterable[str]:
if not git_root.samefile(repo_path):
yield (
f'under the {repo_path.resolve().relative_to(git_root.resolve())} '
@@ -132,14 +129,35 @@
return msg + ''.join(f'\n - {line}' for line in constraints)
+def root(repo_path: PathOrStr = '.', *, show_stderr: bool = True) -> Path:
+ """Returns the repository root as an absolute path.
+
+ Raises:
+ FileNotFoundError: the path does not exist
+ subprocess.CalledProcessError: the path is not in a Git repo
+ """
+ repo_path = Path(repo_path)
+ if not repo_path.exists():
+ raise FileNotFoundError(f'{repo_path} does not exist')
+
+ return Path(
+ git_stdout('rev-parse',
+ '--show-toplevel',
+ repo=repo_path,
+ show_stderr=show_stderr))
+
+
+def within_repo(repo_path: PathOrStr = '.') -> Optional[Path]:
+ """Similar to root(repo_path), returns None if the path is not in a repo."""
+ try:
+ return root(repo_path, show_stderr=False)
+ except subprocess.CalledProcessError:
+ return None
+
+
def is_repo(repo_path: PathOrStr = '.') -> bool:
- return not subprocess.run(['git', '-C', repo_path, 'rev-parse'],
- stderr=subprocess.DEVNULL).returncode
-
-
-def root(repo: PathOrStr = '.') -> Path:
- """Returns the repository root as an absolute path."""
- return Path(git_stdout('rev-parse', '--show-toplevel', repo=repo))
+ """True if the path is tracked by a Git repo."""
+ return within_repo(repo_path) is not None
def path(repo_path: PathOrStr,
diff --git a/pw_presubmit/py/pw_presubmit/install_hook.py b/pw_presubmit/py/pw_presubmit/install_hook.py
index ac16e47..1df7011 100755
--- a/pw_presubmit/py/pw_presubmit/install_hook.py
+++ b/pw_presubmit/py/pw_presubmit/install_hook.py
@@ -20,12 +20,12 @@
from pathlib import Path
import shlex
import subprocess
-from typing import Sequence
+from typing import Sequence, Union
_LOG: logging.Logger = logging.getLogger(__name__)
-def git_repo_root(path) -> Path:
+def git_repo_root(path: Union[Path, str]) -> Path:
return Path(
subprocess.run(['git', '-C', path, 'rev-parse', '--show-toplevel'],
check=True,
@@ -35,7 +35,7 @@
def install_hook(script,
hook: str,
args: Sequence[str] = (),
- repository='.') -> None:
+ repository: Union[Path, str] = '.') -> None:
"""Installs a simple Git hook that calls a script with arguments."""
root = git_repo_root(repository).resolve()
script = os.path.relpath(script, root)
diff --git a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
index 154f400..b46df18 100755
--- a/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/pigweed_presubmit.py
@@ -44,11 +44,11 @@
# Initialization
#
def init_cipd(ctx: PresubmitContext):
- environment.init_cipd(ctx.repo_root, ctx.output_dir)
+ environment.init_cipd(ctx.root, ctx.output_dir)
def init_virtualenv(ctx: PresubmitContext):
- environment.init_cipd(ctx.repo_root, ctx.output_dir)
+ environment.init_cipd(ctx.root, ctx.output_dir)
#
@@ -63,14 +63,14 @@
def gn_clang_build(ctx: PresubmitContext):
- build.gn_gen(ctx.repo_root, ctx.output_dir, _CLANG_GEN_ARGS)
+ build.gn_gen(ctx.root, ctx.output_dir, _CLANG_GEN_ARGS)
build.ninja(ctx.output_dir)
@filter_paths(endswith=format_code.C_FORMAT.extensions)
def gn_gcc_build(ctx: PresubmitContext):
build.gn_gen(
- ctx.repo_root, ctx.output_dir,
+ ctx.root, ctx.output_dir,
build.gn_args(pw_target_config='"//targets/host/target_config.gni"',
pw_target_toolchain='"//pw_toolchain:host_gcc_os"'))
build.ninja(ctx.output_dir)
@@ -82,7 +82,7 @@
@filter_paths(endswith=format_code.C_FORMAT.extensions)
def gn_arm_build(ctx: PresubmitContext):
- build.gn_gen(ctx.repo_root, ctx.output_dir, _ARM_GEN_ARGS)
+ build.gn_gen(ctx.root, ctx.output_dir, _ARM_GEN_ARGS)
build.ninja(ctx.output_dir)
@@ -92,17 +92,17 @@
@filter_paths(endswith=format_code.C_FORMAT.extensions)
def gn_qemu_build(ctx: PresubmitContext):
- build.gn_gen(ctx.repo_root, ctx.output_dir, _QEMU_GEN_ARGS)
+ build.gn_gen(ctx.root, ctx.output_dir, _QEMU_GEN_ARGS)
build.ninja(ctx.output_dir)
def gn_docs_build(ctx: PresubmitContext):
- build.gn_gen(ctx.repo_root, ctx.output_dir, _DOCS_GEN_ARGS)
+ build.gn_gen(ctx.root, ctx.output_dir, _DOCS_GEN_ARGS)
build.ninja(ctx.output_dir, 'docs:docs')
def gn_host_tools(ctx: PresubmitContext):
- build.gn_gen(ctx.repo_root,
+ build.gn_gen(ctx.root,
ctx.output_dir,
pw_target_config='"//targets/host/target_config.gni"',
pw_target_toolchain='"//pw_toolchain:host_clang_os"',
@@ -112,7 +112,7 @@
@filter_paths(endswith=format_code.C_FORMAT.extensions)
def oss_fuzz_build(ctx: PresubmitContext):
- build.gn_gen(ctx.repo_root,
+ build.gn_gen(ctx.root,
ctx.output_dir,
oss_fuzz_enabled='true',
pw_target_toolchain='"//pw_toolchain:host_clang_og"',
@@ -123,7 +123,7 @@
@filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.cmake',
'CMakeLists.txt'))
def cmake_tests(ctx: PresubmitContext):
- build.cmake(ctx.repo_root, ctx.output_dir, env=build.env_with_clang_vars())
+ build.cmake(ctx.root, ctx.output_dir, env=build.env_with_clang_vars())
build.ninja(ctx.output_dir, 'pw_run_tests.modules')
@@ -138,7 +138,7 @@
'--worker_verbose',
'--symlink_prefix',
ctx.output_dir.joinpath('bazel-'),
- cwd=ctx.repo_root,
+ cwd=ctx.root,
env=build.env_with_clang_vars())
except:
_LOG.info('If the Bazel build inexplicably fails while the '
@@ -157,7 +157,7 @@
@filter_paths(endswith=format_code.C_FORMAT.extensions)
def clang_tidy(ctx: PresubmitContext):
- build.gn_gen(ctx.repo_root, ctx.output_dir, '--export-compile-commands',
+ build.gn_gen(ctx.root, ctx.output_dir, '--export-compile-commands',
_CLANG_GEN_ARGS)
build.ninja(ctx.output_dir)
build.ninja(ctx.output_dir, '-t', 'compdb', 'objcxx', 'cxx')
@@ -284,13 +284,13 @@
)
for directory, args in gn_gens_to_run:
- build.gn_gen(ctx.repo_root, directory, args)
+ build.gn_gen(ctx.root, directory, args)
missing = build.check_builds_for_files(_SOURCES_IN_BUILD,
ctx.paths,
- bazel_dirs=[ctx.repo_root],
+ bazel_dirs=[ctx.root],
gn_dirs=[
- (ctx.repo_root, path)
+ (ctx.root, path)
for path, _ in gn_gens_to_run
])
@@ -306,11 +306,11 @@
'Skipping build_env_setup since PW_CARGO_SETUP is not set')
return
- tmpl = ctx.repo_root.joinpath('pw_env_setup', 'py', 'pyoxidizer.bzl.tmpl')
+ tmpl = ctx.root.joinpath('pw_env_setup', 'py', 'pyoxidizer.bzl.tmpl')
out = ctx.output_dir.joinpath('pyoxidizer.bzl')
with open(tmpl, 'r') as ins:
- cfg = ins.read().replace('${PW_ROOT}', str(ctx.repo_root))
+ cfg = ins.read().replace('${PW_ROOT}', str(ctx.root))
with open(out, 'w') as outs:
outs.write(cfg)
@@ -385,16 +385,16 @@
return parser.parse_args()
-def run(install: bool, repository: Path, **presubmit_args) -> int:
+def run(install: bool, **presubmit_args) -> int:
"""Entry point for presubmit."""
if install:
install_hook(__file__, 'pre-push',
['--base', 'origin/master..HEAD', '--program', 'quick'],
- repository)
+ Path.cwd())
return 0
- return cli.run(repository=repository, **presubmit_args)
+ return cli.run(**presubmit_args)
def main() -> int:
diff --git a/pw_presubmit/py/pw_presubmit/presubmit.py b/pw_presubmit/py/pw_presubmit/presubmit.py
index 5e5b115..dd65aee 100644
--- a/pw_presubmit/py/pw_presubmit/presubmit.py
+++ b/pw_presubmit/py/pw_presubmit/presubmit.py
@@ -13,7 +13,6 @@
# the License.
"""Tools for running presubmit checks in a Git repository.
-
Presubmit checks are defined as a function or other callable. The function may
take either no arguments or a list of the paths on which to run. Presubmit
checks communicate failure by raising any exception.
@@ -50,8 +49,8 @@
import re
import subprocess
import time
-from typing import Callable, Dict, Iterable, Iterator, List, NamedTuple
-from typing import Optional, Pattern, Sequence, Tuple
+from typing import Callable, Collection, Dict, Iterable, Iterator, List
+from typing import NamedTuple, Optional, Pattern, Sequence, Set, Tuple
from pw_presubmit import git_repo, tools
from pw_presubmit.tools import plural
@@ -138,6 +137,9 @@
def __str__(self):
return self.name
+ def title(self):
+ return f'{self.name if self.name else ""} presubmit checks'.strip()
+
class Programs(collections.abc.Mapping):
"""A mapping of presubmit check programs.
@@ -171,38 +173,54 @@
@dataclasses.dataclass(frozen=True)
class PresubmitContext:
"""Context passed into presubmit checks."""
- repo_root: Path
+ root: Path
+ repos: Tuple[Path, ...]
output_dir: Path
- paths: Sequence[Path]
+ paths: Tuple[Path, ...]
+
+ def relative_paths(self, start: Optional[Path] = None):
+ return tuple(
+ tools.relative_paths(self.paths, start if start else self.root))
+
+
+class _Filter(NamedTuple):
+ endswith: Tuple[str, ...] = ('', )
+ exclude: Tuple[Pattern[str], ...] = ()
+
+ def matches(self, path: str) -> bool:
+ return (any(path.endswith(end) for end in self.endswith)
+ and not any(exp.search(path) for exp in self.exclude))
class Presubmit:
"""Runs a series of presubmit checks on a list of files."""
- def __init__(self, repository_root: Path, output_directory: Path,
- paths: Sequence[Path]):
- self._repository_root = repository_root
- self._output_directory = output_directory
- self._paths = paths
+ def __init__(self, root: Path, repos: Sequence[Path],
+ output_directory: Path, paths: Sequence[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))
def run(self, program: Program, keep_going: bool = False) -> bool:
"""Executes a series of presubmit checks on the paths."""
- title = f'{program.name if program.name else ""} presubmit checks'
- checks = _apply_filters(program, self._paths)
+ checks = self._apply_filters(program)
- _LOG.debug('Running %s for %s', title, self._repository_root.name)
- print(_title(f'{self._repository_root.name}: {title}'))
+ _LOG.debug('Running %s for %s', program.title(), self._root.name)
+ print(_title(f'{self._root.name}: {program.title()}'))
_LOG.info('%d of %d checks apply to %s in %s', len(checks),
- len(program), plural(self._paths,
- 'file'), self._repository_root)
+ len(program), plural(self._paths, 'file'), self._root)
print()
- for line in tools.file_summary(self._paths):
+ for line in tools.file_summary(self._relative_paths):
print(line)
print()
- _LOG.debug('Paths:\n%s', '\n'.join(str(path) for path in self._paths))
+ _LOG.debug('Paths, relative to %s:\n%s', self._root,
+ '\n'.join(p.as_posix() for p in self._relative_paths))
if not self._paths:
print(color_yellow('No files are being checked!'))
@@ -214,6 +232,40 @@
return not failed and not skipped
+ def _apply_filters(
+ self, program: Sequence[Callable]
+ ) -> List[Tuple['_Check', Sequence[Path]]]:
+ """Returns list of (check, paths) for checks that should run."""
+ checks = [c if isinstance(c, _Check) else _Check(c) for c in program]
+ filter_to_checks: Dict[_Filter,
+ List[_Check]] = collections.defaultdict(list)
+
+ for check in checks:
+ filter_to_checks[check.filter].append(check)
+
+ check_to_paths = self._map_checks_to_paths(filter_to_checks)
+ return [(c, check_to_paths[c]) for c in checks if c in check_to_paths]
+
+ def _map_checks_to_paths(
+ self, filter_to_checks: Dict[_Filter, List['_Check']]
+ ) -> Dict['_Check', Sequence[Path]]:
+ checks_to_paths: Dict[_Check, Sequence[Path]] = {}
+
+ posix_paths = tuple(p.as_posix() for p in self._relative_paths)
+
+ for filt, checks in filter_to_checks.items():
+ filtered_paths = tuple(
+ path for path, filter_path in zip(self._paths, posix_paths)
+ if filt.matches(filter_path))
+
+ for check in checks:
+ if filtered_paths or check.always_run:
+ checks_to_paths[check] = filtered_paths
+ else:
+ _LOG.debug('Skipping "%s": no relevant files', check.name)
+
+ return checks_to_paths
+
def _log_summary(self, time_s: float, passed: int, failed: int,
skipped: int) -> None:
summary_items = []
@@ -239,7 +291,7 @@
_format_time(time_s)))
@contextlib.contextmanager
- def _context(self, name: str, paths: Sequence[Path]):
+ def _context(self, name: str, paths: Tuple[Path, ...]):
# There are many characters banned from filenames on Windows. To
# simplify things, just strip everything that's not a letter, digit,
# or underscore.
@@ -255,8 +307,9 @@
_LOG.addHandler(handler)
yield PresubmitContext(
- repo_root=self._repository_root.absolute(),
- output_dir=output_directory.absolute(),
+ root=self._root,
+ repos=self._repos,
+ output_dir=output_directory,
paths=paths,
)
@@ -269,7 +322,6 @@
passed = failed = 0
for i, (check, paths) in enumerate(program, 1):
- paths = [self._repository_root.joinpath(p) for p in paths]
with self._context(check.name, paths) as ctx:
result = check.run(ctx, i, len(program))
@@ -285,55 +337,43 @@
return passed, failed, len(program) - passed - failed
-class _Filter(NamedTuple):
- endswith: Tuple[str, ...] = ('', )
- exclude: Tuple[str, ...] = ()
+def _process_pathspecs(repos: Iterable[Path],
+ pathspecs: Iterable[str]) -> Dict[Path, List[str]]:
+ pathspecs_by_repo: Dict[Path, List[str]] = {repo: [] for repo in repos}
+ repos_with_paths: Set[Path] = set()
+ for pathspec in pathspecs:
+ # If the pathspec is a path to an existing file, only use it for the
+ # repo it is in.
+ if os.path.exists(pathspec):
+ # Raise an exception if the path exists but is not in a known repo.
+ repo = git_repo.within_repo(pathspec)
+ if repo not in pathspecs_by_repo:
+ raise ValueError(
+ f'{pathspec} is not in a Git repository in this presubmit')
-def _apply_filters(
- program: Sequence[Callable],
- paths: Sequence[Path]) -> List[Tuple['_Check', Sequence[Path]]]:
- """Returns a list of (check, paths_to_check) for checks that should run."""
- checks = [c if isinstance(c, _Check) else _Check(c) for c in program]
- filter_to_checks: Dict[_Filter,
- List[_Check]] = collections.defaultdict(list)
+ # Make the path relative to the repo's root.
+ pathspecs_by_repo[repo].append(os.path.relpath(pathspec, repo))
+ repos_with_paths.add(repo)
+ else:
+ # Pathspecs that are not paths (e.g. '*.h') are used for all repos.
+ for patterns in pathspecs_by_repo.values():
+ patterns.append(pathspec)
- for check in checks:
- filter_to_checks[check.filter].append(check)
+ # If any paths were specified, only search for paths in those repos.
+ if repos_with_paths:
+ for repo in set(pathspecs_by_repo) - repos_with_paths:
+ del pathspecs_by_repo[repo]
- check_to_paths = _map_checks_to_paths(filter_to_checks, paths)
- return [(c, check_to_paths[c]) for c in checks if c in check_to_paths]
-
-
-def _map_checks_to_paths(
- filter_to_checks: Dict[_Filter, List['_Check']],
- paths: Sequence[Path]) -> Dict['_Check', Sequence[Path]]:
- checks_to_paths: Dict[_Check, Sequence[Path]] = {}
-
- str_paths = [path.as_posix() for path in paths]
-
- for filt, checks in filter_to_checks.items():
- exclude = [re.compile(exp) for exp in filt.exclude]
-
- filtered_paths = tuple(
- Path(path) for path in str_paths
- if any(path.endswith(end) for end in filt.endswith) and not any(
- exp.search(path) for exp in exclude))
-
- for check in checks:
- if filtered_paths or check.always_run:
- checks_to_paths[check] = filtered_paths
- else:
- _LOG.debug('Skipping "%s": no relevant files', check.name)
-
- return checks_to_paths
+ return pathspecs_by_repo
def run(program: Sequence[Callable],
+ root: Path,
+ repos: Collection[Path] = (),
base: Optional[str] = None,
- paths: Sequence[Path] = (),
+ paths: Sequence[str] = (),
exclude: Sequence[Pattern] = (),
- repo_path: Path = Optional[None],
output_directory: Optional[Path] = None,
keep_going: bool = False) -> bool:
"""Lists files in the current Git repo and runs a Presubmit with them.
@@ -341,42 +381,50 @@
This changes the directory to the root of the Git repository after listing
paths, so all presubmit checks can assume they run from there.
+ The paths argument contains Git pathspecs. If no pathspecs are provided, all
+ paths in all repos are included. If paths to files or directories are
+ provided, only files within those repositories are searched. Patterns are
+ searched across all repositories. For example, if the pathspecs "my_module/"
+ and "*.h", paths under "my_module/" in the containing repo and paths in all
+ repos matching "*.h" will be included in the presubmit.
+
Args:
program: list of presubmit check functions to run
+ root: root path of the project
+ repos: paths to the roots of Git repositories to check
name: name to use to refer to this presubmit check run
base: optional base Git commit to list files against
- paths: optional list of paths to run the presubmit checks against
- exclude: regular expressions of paths to exclude from checks
- repo_path: path in the git repository to check
+ 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
keep_going: whether to continue running checks if an error occurs
Returns:
True if all presubmit checks succeeded
"""
- if repo_path is None:
- repo_path = Path.cwd()
+ repos = [repo.resolve() for repo in repos]
- if not git_repo.is_repo(repo_path):
- _LOG.critical('Presubmit checks must be run from a Git repo')
- return False
+ for repo in repos:
+ if git_repo.root(repo) != repo:
+ raise ValueError(f'{repo} is not the root of a Git repo; '
+ 'presubmit checks must be run from a Git repo')
- files = git_repo.list_files(base, paths, exclude, repo_path)
- root = git_repo.root(repo=repo_path)
+ pathspecs_by_repo = _process_pathspecs(repos, paths)
- _LOG.info('Checking %s',
- git_repo.describe_files(root, repo_path, base, paths, exclude))
+ files: List[Path] = []
- files = [path.relative_to(root) for path in files]
+ for repo, pathspecs in pathspecs_by_repo.items():
+ files += tools.exclude_paths(
+ exclude, git_repo.list_files(base, pathspecs, repo), root)
+
+ _LOG.info(
+ 'Checking %s',
+ git_repo.describe_files(repo, repo, base, pathspecs, exclude))
if output_directory is None:
- output_directory = root.joinpath('.presubmit')
+ output_directory = root / '.presubmit'
- presubmit = Presubmit(
- repository_root=root,
- output_directory=Path(output_directory),
- paths=files,
- )
+ presubmit = Presubmit(root, repos, output_directory, files)
if not isinstance(program, Program):
program = Program('', program)
@@ -478,6 +526,10 @@
if required_args else ''))
+def _make_str_tuple(value: Iterable[str]) -> Tuple[str, ...]:
+ return tuple([value] if isinstance(value, str) else value)
+
+
def filter_paths(endswith: Iterable[str] = (''),
exclude: Iterable[str] = (),
always_run: bool = False) -> Callable[[Callable], _Check]:
@@ -497,8 +549,8 @@
"""
def filter_paths_for_function(function: Callable):
return _Check(function,
- _Filter(tools.make_tuple(endswith),
- tools.make_tuple(exclude)),
+ _Filter(_make_str_tuple(endswith),
+ tuple(re.compile(e) for e in exclude)),
always_run=always_run)
return filter_paths_for_function
diff --git a/pw_presubmit/py/pw_presubmit/python_checks.py b/pw_presubmit/py/pw_presubmit/python_checks.py
index 6b08fb2..9b294ee 100644
--- a/pw_presubmit/py/pw_presubmit/python_checks.py
+++ b/pw_presubmit/py/pw_presubmit/python_checks.py
@@ -16,10 +16,12 @@
These checks assume that they are running in a preconfigured Python environment.
"""
+import logging
import os
+from pathlib import Path
import re
import sys
-import logging
+from typing import List
try:
import pw_presubmit
@@ -41,14 +43,16 @@
@filter_paths(endswith='.py')
def test_python_packages(ctx: pw_presubmit.PresubmitContext):
- packages = git_repo.find_python_packages(ctx.paths, repo=ctx.repo_root)
+ packages: List[Path] = []
+ for repo in ctx.repos:
+ packages += git_repo.find_python_packages(ctx.paths, repo=repo)
if not packages:
_LOG.info('No Python packages were found.')
return
for package in packages:
- call('python', os.path.join(package, 'setup.py'), 'test', cwd=package)
+ call('python', package / 'setup.py', 'test', cwd=package)
@filter_paths(endswith='.py')
@@ -67,7 +71,7 @@
'--jobs=0',
f'--disable={",".join(disable_checkers)}',
*ctx.paths,
- cwd=ctx.repo_root,
+ cwd=ctx.root,
)
diff --git a/pw_presubmit/py/pw_presubmit/tools.py b/pw_presubmit/py/pw_presubmit/tools.py
index 02d873d..c7f89e3 100644
--- a/pw_presubmit/py/pw_presubmit/tools.py
+++ b/pw_presubmit/py/pw_presubmit/tools.py
@@ -20,7 +20,7 @@
from pathlib import Path
import shlex
import subprocess
-from typing import Any, Dict, Iterable, Iterator, List, Sequence, Tuple
+from typing import Any, Dict, Iterable, Iterator, List, Sequence, Pattern, Tuple
_LOG: logging.Logger = logging.getLogger(__name__)
@@ -121,8 +121,24 @@
return output
-def make_tuple(value: Iterable[str]) -> Tuple[str, ...]:
- return tuple([value] if isinstance(value, str) else value)
+def relative_paths(paths: Iterable[Path], start: Path) -> Iterable[Path]:
+ """Returns relative Paths calculated with os.path.relpath."""
+ for path in paths:
+ yield Path(os.path.relpath(path, start))
+
+
+def exclude_paths(exclusions: Iterable[Pattern[str]],
+ paths: Iterable[Path],
+ relative_to: Path = None) -> Iterable[Path]:
+ """Excludes paths based on a series of regular expressions."""
+ if relative_to:
+ relpath = lambda path: Path(os.path.relpath(path, relative_to))
+ else:
+ relpath = lambda path: path
+
+ for path in paths:
+ if not any(e.search(relpath(path).as_posix()) for e in exclusions):
+ yield path
def _truncate(value, length: int = 60) -> str: