Add 'pw doctor'.

Add 'pw doctor' command to check environment is correctly set up
for pigweed. For now contains minimal checks.

Change-Id: Icb132c89bb272b52c57dae0451b24966b48d4e90
Bug: 28
diff --git a/docs/BUILD.gn b/docs/BUILD.gn
index 0003609..d4c3767 100644
--- a/docs/BUILD.gn
+++ b/docs/BUILD.gn
@@ -45,6 +45,7 @@
     "$dir_pw_cpu_exception:docs",
     "$dir_pw_cpu_exception_armv7m:docs",
     "$dir_pw_docgen:docs",
+    "$dir_pw_doctor:docs",
     "$dir_pw_dumb_io:docs",
     "$dir_pw_dumb_io_baremetal_stm32f429:docs",
     "$dir_pw_dumb_io_stdio:docs",
diff --git a/modules.gni b/modules.gni
index fe04edf..3d3e534 100644
--- a/modules.gni
+++ b/modules.gni
@@ -27,6 +27,7 @@
 dir_pw_cpu_exception = "$dir_pigweed/pw_cpu_exception"
 dir_pw_cpu_exception_armv7m = "$dir_pigweed/pw_cpu_exception_armv7m"
 dir_pw_docgen = "$dir_pigweed/pw_docgen"
+dir_pw_doctor = "$dir_pigweed/pw_doctor"
 dir_pw_dumb_io = "$dir_pigweed/pw_dumb_io"
 dir_pw_dumb_io_baremetal_stm32f429 =
     "$dir_pigweed/pw_dumb_io_baremetal_stm32f429"
diff --git a/pw_doctor/BUILD b/pw_doctor/BUILD
new file mode 100644
index 0000000..85a5ca0
--- /dev/null
+++ b/pw_doctor/BUILD
@@ -0,0 +1,17 @@
+# Copyright 2019 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.
+
+package(default_visibility = ["//visibility:public"])
+
+licenses(["notice"])  # Apache License 2.0
diff --git a/pw_doctor/BUILD.gn b/pw_doctor/BUILD.gn
new file mode 100644
index 0000000..9eed4a2
--- /dev/null
+++ b/pw_doctor/BUILD.gn
@@ -0,0 +1,21 @@
+# Copyright 2019 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("$dir_pw_docgen/docs.gni")
+
+pw_doc_group("docs") {
+  sources = [
+    "docs.rst",
+  ]
+}
diff --git a/pw_doctor/README.md b/pw_doctor/README.md
new file mode 100644
index 0000000..f09b31c
--- /dev/null
+++ b/pw_doctor/README.md
@@ -0,0 +1 @@
+Check the environment for compatibility with Pigweed.
diff --git a/pw_doctor/docs.rst b/pw_doctor/docs.rst
new file mode 100644
index 0000000..15f0eb3
--- /dev/null
+++ b/pw_doctor/docs.rst
@@ -0,0 +1,8 @@
+.. _chapter-pw-doctor:
+
+---------
+pw_doctor
+---------
+``pw doctor`` confirms the environment is set up correctly. With ``--strict``
+it checks that things exactly match what is expected and it checks that things
+look compatible without.
diff --git a/pw_doctor/py/pw_doctor/__init__.py b/pw_doctor/py/pw_doctor/__init__.py
new file mode 100644
index 0000000..7f440f8
--- /dev/null
+++ b/pw_doctor/py/pw_doctor/__init__.py
@@ -0,0 +1,17 @@
+# Copyright 2019 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.
+"""Pigweed doctor module."""
+
+# Import pw_plugin to register the pw_cli plugin.
+import pw_doctor.pw_plugin
diff --git a/pw_doctor/py/pw_doctor/doctor.py b/pw_doctor/py/pw_doctor/doctor.py
new file mode 100755
index 0000000..266352b
--- /dev/null
+++ b/pw_doctor/py/pw_doctor/doctor.py
@@ -0,0 +1,183 @@
+#!/usr/bin/env python3
+# Copyright 2019 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.
+"""Checks if the environment is set up correctly for Pigweed."""
+
+import argparse
+import logging
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+
+
+def call_stdout(*args, **kwargs):
+    kwargs.update(stdout=subprocess.PIPE)
+    proc = subprocess.run(*args, **kwargs)
+    return proc.stdout.decode('utf-8')
+
+
+class _Fatal(Exception):
+    pass
+
+
+class DoctorContext:
+    """Base class for other checks."""
+    def __init__(self, *, strict=False, log=None):
+        self.name = self.__class__.__name__
+        self._strict = strict
+        self._log = log or logging.getLogger(__name__)
+        self.failures = set()
+        self.curr_checker = None
+
+    def fatal(self, fmt, *args, **kwargs):
+        """Same as error() but terminates the checkearly."""
+        self.error(fmt, *args, **kwargs)
+        raise _Fatal()
+
+    def error(self, fmt, *args, **kwargs):
+        self._log.error(fmt, *args, **kwargs)
+        self.failures.add(self.curr_checker)
+
+    def warning(self, fmt, *args, **kwargs):
+        if self._strict:
+            self.error(fmt, *args, **kwargs)
+        else:
+            self._log.warning(fmt, *args, **kwargs)
+
+    def info(self, fmt, *args, **kwargs):
+        self._log.info(fmt, *args, **kwargs)
+
+    def debug(self, fmt, *args, **kwargs):
+        self._log.debug(fmt, *args, **kwargs)
+
+
+def register_into(dest):
+    def decorate(func):
+        dest.append(func)
+        return func
+
+    return decorate
+
+
+CHECKS = []
+
+
+@register_into(CHECKS)
+def pw_root(ctx: DoctorContext):
+    """Check that environment variable PW_ROOT is set and makes sense."""
+    root = os.environ.get('PW_ROOT', None)
+
+    if root is None:
+        ctx.fatal('PW_ROOT not set')
+
+    git_root = call_stdout(['git', 'rev-parse', '--show-toplevel'],
+                           cwd=root).strip()
+    if root != git_root:
+        ctx.error('PW_ROOT (%s) != `git rev-parse --show-toplevel` (%s)', root,
+                  git_root)
+
+
+@register_into(CHECKS)
+def python_version(ctx: DoctorContext):
+    """Check the Python version is correct."""
+    actual = sys.version_info
+    expected = (3, 8)
+    if actual[0:2] < expected or actual[0] != expected[0]:
+        ctx.error('Python %d.%d.x required, got Python %d.%d.%d', *expected,
+                  *actual[0:3])
+    elif actual[0:2] > expected:
+        ctx.warning('Python %d.%d.x required, got Python %d.%d.%d', *expected,
+                    *actual[0:3])
+
+
+@register_into(CHECKS)
+def cipd(ctx: DoctorContext):
+    """Check cipd is set up correctly and in use."""
+    cipd_path = 'pigweed'
+
+    temp = tempfile.NamedTemporaryFile(prefix='cipd', delete=False)
+    subprocess.run(['cipd', 'acl-check', '-json-output', temp.name, cipd_path],
+                   stdout=subprocess.PIPE)
+    if not json.load(temp)['result']:
+        ctx.fatal(
+            "can't access %s CIPD directory, have you run "
+            "'cipd auth-login'?", cipd_path)
+
+    commands_expected_from_cipd = [
+        'arm-none-eabi-gcc',
+        'bazel',
+        'bloaty',
+        'clang++',
+        'gn',
+        'ninja',
+        'protoc',
+    ]
+
+    for command in commands_expected_from_cipd:
+        path = shutil.which(command)
+        if 'cipd' not in path:
+            ctx.warning('not using %s from cipd, got %s', command, path)
+
+
+def main(strict=False, checks=None):
+    """Run all the Check subclasses defined in this file."""
+
+    ctx = DoctorContext(strict=strict)
+
+    if checks is None:
+        checks = tuple(CHECKS)
+
+    ctx.info('Doctor running %d checks...', len(checks))
+    for check in checks:
+        try:
+            ctx.info('Running %s...', check.__name__)
+            ctx.curr_checker = check.__name__
+            check(ctx)
+
+        except _Fatal:
+            pass
+
+        finally:
+            ctx.curr_checker = None
+
+    if ctx.failures:
+        ctx.info('Failed checks: %s', ', '.join(ctx.failures))
+    else:
+        ctx.info('All checks passed!')
+    return len(ctx.failures)
+
+
+def argument_parser(
+        parser: argparse.ArgumentParser = None) -> argparse.ArgumentParser:
+    """Create argument parser."""
+
+    if parser is None:
+        parser = argparse.ArgumentParser(description=__doc__)
+
+    parser.add_argument(
+        '--strict',
+        action='store_true',
+        help='Run additional checks.',
+    )
+
+    return parser
+
+
+if __name__ == '__main__':
+    # By default, display log messages like a simple print statement.
+    logging.basicConfig(format='%(message)s', level=logging.INFO)
+    sys.exit(main(**vars(argument_parser().parse_args())))
diff --git a/pw_doctor/py/pw_doctor/pw_plugin.py b/pw_doctor/py/pw_doctor/pw_plugin.py
new file mode 100644
index 0000000..5893033
--- /dev/null
+++ b/pw_doctor/py/pw_doctor/pw_plugin.py
@@ -0,0 +1,28 @@
+# Copyright 2019 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.
+"""Registers the check plugin for Pigweed."""
+
+try:
+    import pw_cli.plugins
+    from pw_doctor import doctor
+
+    pw_cli.plugins.register(
+        'doctor',
+        doctor.main,
+        doctor.__doc__.splitlines()[0].rstrip('.'),
+        doctor.argument_parser,
+    )
+
+except ImportError:
+    pass
diff --git a/pw_doctor/py/setup.py b/pw_doctor/py/setup.py
new file mode 100644
index 0000000..b870a42
--- /dev/null
+++ b/pw_doctor/py/setup.py
@@ -0,0 +1,33 @@
+# Copyright 2019 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.
+"""The pw_doctor package."""
+
+import unittest
+import setuptools
+
+
+def test_suite():
+    """Test suite for pw_doctor module."""
+    return unittest.TestLoader().discover('./', pattern='*_test.py')
+
+
+setuptools.setup(
+    name='pw_doctor',
+    version='0.0.1',
+    author='Pigweed Authors',
+    author_email='pigweed-developers@googlegroups.com',
+    description='Environment check script for Pigweed',
+    packages=setuptools.find_packages(),
+    test_suite='setup.test_suite',
+)