presubmit: Improve venv setup; expand checks

- Use env_setup/virtualenv/init.py to setup the Python venv.
- Improve diff colorization.
- Add function for finding Python packages from .py file changes.
- Add check that runs Python tests.
- Enable full pylint and yapf as warnings.
- Delete unused code in watch.py that used an undefined variable.
- Enable required python -E check.

Change-Id: I5ba9882ea9ec93f006260d5636031dd0b5005a85
diff --git a/presubmit.py b/presubmit.py
index 163450b..2d9efd3 100755
--- a/presubmit.py
+++ b/presubmit.py
@@ -25,7 +25,18 @@
 from pw_presubmit import format_cc, presubmit_tools
 
 
-def _init_cipd():
+def presubmit_dir(*paths):
+    return os.path.join('.presubmit', *paths)
+
+
+def run_python_module(*args, **kwargs):
+    return call('python', '-m', *args, **kwargs)
+
+
+#
+# Initialization
+#
+def init_cipd():
     cipd = os.path.abspath('.presubmit/cipd')
     call(sys.executable, 'env_setup/cipd/update.py', '--install-dir', cipd)
     os.environ['PATH'] = os.pathsep.join((
@@ -36,28 +47,27 @@
     print('PATH', os.environ['PATH'])
 
 
-def _init_virtualenv():
+@filter_paths(endswith='.py')  # Only run if there are .py files.
+def init_virtualenv(unused_paths):
     """Set up virtualenv, assumes recent Python 3 is already installed."""
     venv = os.path.abspath('.presubmit/venv')
+
+    # For speed, don't build the venv if it exists. Use --clean to recreate it.
     if not os.path.isdir(venv):
-        call('python', '-m', 'venv', venv)
+      call('python3',
+           'env_setup/virtualenv/init.py', f'--venv_path={venv}',
+           '--requirements=env_setup/virtualenv/requirements.txt')
+
     os.environ['PATH'] = os.pathsep.join((
         os.path.join(venv, 'bin'),
         os.environ['PATH'],
     ))
 
-    call('python', '-m', 'pip', 'install', '--upgrade', 'pip')
-    call('python', '-m', 'pip', 'install',
-         '--log', os.path.join(venv, 'pip.log'),
-         '-r', 'env_setup/virtualenv/requirements.txt')  # yapf: disable
 
-
-def init():
-    _init_cipd()
-    _init_virtualenv()
-
-
-presubmit_dir = lambda *paths: os.path.join('.presubmit', *paths)
+INIT = (
+    init_cipd,
+    init_virtualenv,
+)
 
 
 #
@@ -83,7 +93,8 @@
     call('ninja', '-C', presubmit_dir('clang'))
 
 
-def gn_gcc_build():
+@filter_paths(endswith=format_cc.SOURCE_EXTENSIONS)
+def gn_gcc_build(unused_paths):
     call(
         *GN_GEN, presubmit_dir('gcc'),
         gn_args(pw_target_config='"//targets/host/host.gni"',
@@ -91,7 +102,8 @@
     call('ninja', '-C', presubmit_dir('gcc'))
 
 
-def gn_arm_build():
+@filter_paths(endswith=format_cc.SOURCE_EXTENSIONS)
+def gn_arm_build(unused_paths):
     call(
         *GN_GEN, presubmit_dir('arm'),
         gn_args(
@@ -137,43 +149,58 @@
 # Python presubmit checks
 #
 @filter_paths(endswith='.py')
-def pylint_errors(paths):
-    call(sys.executable, '-m', 'pylint', '-E', *paths)
+def test_python_packages(paths):
+    packages = presubmit_tools.find_python_packages(paths)
+
+    if not packages:
+        print('No Python packages were found.')
+        return
+
+    for package in packages:
+        call('python', os.path.join(package, 'setup.py'), 'test')
+
+
+@filter_paths(endswith='.py')
+def pylint_errors_only(paths):
+    run_python_module('pylint', '-E', '-j', '0', *paths)
+
+
+@filter_paths(endswith='.py')
+def pylint(paths):
+    try:
+        run_python_module('pylint', '-j', '0', *paths)
+    except PresubmitFailure:
+        # TODO(hepler): Enforce pylint when it passes.
+        print('--> pylint checks FAILED!')
+        print('    Treating this as a warning... for now.')
 
 
 @filter_paths(endswith='.py')
 def yapf(paths):
-    from yapf.yapflib.yapf_api import FormatFile
-
-    errors = []
-
-    for path in paths:
-        diff, _, changed = FormatFile(path, print_diff=True, in_place=False)
-        if changed:
-            errors.append(path)
-            print(format_cc.colorize_diff(diff))
-
-    if errors:
-        print(f'--> Files with formatting errors: {len(errors)}')
-        print('   ', '\n    '.join(errors))
-        raise PresubmitFailure
+    try:
+        run_python_module('yapf',
+                          '--diff',
+                          '--parallel',
+                          *paths,
+                          print_output=False)
+    except PresubmitFailure as e:
+        # TODO(hepler): Enforce yapf when it passes.
+        print(format_cc.colorize_diff(str(e)))
+        print('--> Python formatting checks FAILED!')
+        print('    Treating this as a warning... for now.')
 
 
 @filter_paths(endswith='.py', exclude=r'(?:.+/)?setup\.py')
 def mypy(paths):
-    import mypy.api as mypy_api
-
-    report, errors, exit_status = mypy_api.run(paths)
-    if exit_status:
-        print(errors)
-        print(report)
-        raise PresubmitFailure
+    run_python_module('mypy', *paths)
 
 
 PYTHON = (
-    # TODO(hepler): Enable yapf, mypy, and pylint when they pass.
-    # pylint_errors,
-    # yapf,
+    test_python_packages,
+    pylint_errors_only,  # TODO(hepler): Remove this when pylint is passing.
+    pylint,
+    yapf,
+    # TODO(hepler): Enable mypy when it passes.
     # mypy,
 )
 
@@ -260,16 +287,17 @@
 # Presubmit check programs
 #
 QUICK_PRESUBMIT = (
-    *GENERAL,
+    *INIT,
     *PYTHON,
     gn_format,
     gn_clang_build,
     presubmit_tools.pragma_once,
     clang_format,
+    *GENERAL,
 )
 
 PROGRAMS = {
-    'full': GN + CC + PYTHON + BAZEL + GENERAL,
+    'full': INIT + GN + CC + PYTHON + BAZEL + GENERAL,
     'quick': QUICK_PRESUBMIT,
 }
 
@@ -280,10 +308,6 @@
         '--clean',
         action='store_true',
         help='Deletes the .presubmit directory before starting')
-    parser.add_argument(
-        '--skip_init',
-        action='store_true',
-        help='Clone the buildtools to prior to running the checks')
     parser.add_argument('-p',
                         '--program',
                         choices=PROGRAMS,
@@ -297,11 +321,10 @@
     if args.clean and os.path.exists(presubmit_dir()):
         shutil.rmtree(presubmit_dir())
 
-    init_step = () if args.skip_init else (init,)
-    program = init_step + PROGRAMS[args.program]
+    program = PROGRAMS[args.program]
 
     # Remove custom arguments so we can use args to call run_presubmit.
-    del args.clean, args.program, args.skip_init
+    del args.clean, args.program
 
     return 0 if presubmit_tools.run_presubmit(program, **vars(args)) else 1
 
diff --git a/pw_cli/py/pw_cli/watch.py b/pw_cli/py/pw_cli/watch.py
index 08acdc3..9fc5618 100755
--- a/pw_cli/py/pw_cli/watch.py
+++ b/pw_cli/py/pw_cli/watch.py
@@ -176,14 +176,6 @@
                 self.state = _State.WAITING_FOR_FILE_CHANGE_EVENT
                 self.on_any_event()  # Retrigger.
 
-    def on_success(self):
-        _LOG.debug('Build and tests passed')
-        print(_Color.green(PASS_MESSAGE))
-
-    def on_fail(self):
-        _LOG.debug('Build and tests failed')
-        print(_Color.red(_FAIL_MESSAGE))
-
 
 _WATCH_PATTERN_DELIMITER = ','
 _WATCH_PATTERNS = (
diff --git a/pw_presubmit/format_cc.py b/pw_presubmit/format_cc.py
index 625a2c6..9ef63ae 100755
--- a/pw_presubmit/format_cc.py
+++ b/pw_presubmit/format_cc.py
@@ -31,8 +31,10 @@
     return f'{start}{{}}\033[0m'.format if os.name == 'posix' else str
 
 
-color_green = _make_color(32)
 color_red = _make_color(31)
+color_green = _make_color(32)
+color_aqua = _make_color(36)
+color_bold_white = _make_color(37, 1)
 
 
 def _find_extensions(directory, extensions) -> Iterable[str]:
@@ -63,10 +65,14 @@
 
 
 def _colorize_diff_line(line: str) -> str:
-    if line.startswith('-') and not line.startswith('--- '):
+    if line.startswith('--- ') or line.startswith('+++ '):
+        return color_bold_white(line)
+    if line.startswith('-'):
         return color_red(line)
-    if line.startswith('+') and not line.startswith('+++ '):
+    if line.startswith('+'):
         return color_green(line)
+    if line.startswith('@@ '):
+        return color_aqua(line)
     return line
 
 
diff --git a/pw_presubmit/presubmit_tools.py b/pw_presubmit/presubmit_tools.py
index 9cb56e3..c3ad296 100755
--- a/pw_presubmit/presubmit_tools.py
+++ b/pw_presubmit/presubmit_tools.py
@@ -50,7 +50,7 @@
 import subprocess
 import sys
 import time
-from typing import Callable, Iterable, List, Optional, Sequence
+from typing import Callable, Dict, Iterable, List, Optional, Sequence
 from inspect import signature
 
 
@@ -327,6 +327,26 @@
     return run_presubmit(program, **vars(arg_parser.parse_args()))
 
 
+def find_python_packages(python_paths) -> Dict[str, List[str]]:
+    """Returns Python package directories for the files in python_paths."""
+    setup_pys = [
+        os.path.dirname(file)
+        for file in git_list_files('setup.py', '*/setup.py')
+    ]
+
+    package_dirs = collections.defaultdict(list)
+
+    for path in python_paths:
+        try:
+            setup_dir = max(setup for setup in setup_pys
+                            if path.startswith(setup))
+            package_dirs[os.path.abspath(setup_dir)].append(path)
+        except ValueError:
+            continue
+
+    return package_dirs
+
+
 def _wrap_if_str(value: Iterable[str]) -> List[str]:
     return [value] if isinstance(value, str) else value
 
@@ -362,7 +382,7 @@
     return filter_paths_for_function
 
 
-def call(*args, **kwargs) -> None:
+def call(*args, print_output=True, **kwargs) -> None:
     """Optional subprocess wrapper with helpful output."""
     print('[COMMAND]',
           ', '.join(f'{k}={v}' for k, v in sorted(kwargs.items())))
@@ -374,9 +394,14 @@
                              **kwargs)
     if process.returncode:
         print(f'[RESULT] Failed with return code {process.returncode}.')
-        print('[OUTPUT]')
-        print(process.stdout.decode(errors='backslashreplace'))
-        raise PresubmitFailure
+        output = process.stdout.decode(errors='backslashreplace')
+
+        if print_output:
+            print('[OUTPUT]')
+            print(output)
+            raise PresubmitFailure
+
+        raise PresubmitFailure(output)
 
 
 @filter_paths(endswith='.h')