pw_console: Create module and entry point

This CL adds the `pw console` plugin command used to invoke
pw console on the command line and the embed() fuction. At
this point it does nothing. Follow up CLs will build up the
user interface and additional functionality.

Change-Id: I564541cf35c87634ece40604df1afe965f4bf601
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/48642
Pigweed-Auto-Submit: Anthony DiGirolamo <tonymd@google.com>
Reviewed-by: Joe Ethier <jethier@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
diff --git a/BUILD.gn b/BUILD.gn
index 7631696..039c90d 100644
--- a/BUILD.gn
+++ b/BUILD.gn
@@ -211,6 +211,7 @@
       "$dir_pw_bytes",
       "$dir_pw_checksum",
       "$dir_pw_chrono",
+      "$dir_pw_console",
       "$dir_pw_cpu_exception",
       "$dir_pw_hdlc",
       "$dir_pw_i2c",
diff --git a/PW_PLUGINS b/PW_PLUGINS
index b7e8fe4..a3047ae 100644
--- a/PW_PLUGINS
+++ b/PW_PLUGINS
@@ -16,3 +16,4 @@
 presubmit pw_presubmit.pigweed_presubmit main
 requires pw_cli.requires main
 rpc pw_hdlc.rpc_console main
+console pw_console.__main__ main
diff --git a/docs/BUILD.gn b/docs/BUILD.gn
index 7f5d778..bc699c0 100644
--- a/docs/BUILD.gn
+++ b/docs/BUILD.gn
@@ -75,6 +75,7 @@
     "$dir_pw_chrono_stl:docs",
     "$dir_pw_chrono_threadx:docs",
     "$dir_pw_cli:docs",
+    "$dir_pw_console:docs",
     "$dir_pw_containers:docs",
     "$dir_pw_cpu_exception:docs",
     "$dir_pw_cpu_exception_cortex_m:docs",
diff --git a/modules.gni b/modules.gni
index 9b2625e..a05c4f1 100644
--- a/modules.gni
+++ b/modules.gni
@@ -36,6 +36,7 @@
   dir_pw_chrono_stl = get_path_info("pw_chrono_stl", "abspath")
   dir_pw_chrono_threadx = get_path_info("pw_chrono_threadx", "abspath")
   dir_pw_cli = get_path_info("pw_cli", "abspath")
+  dir_pw_console = get_path_info("pw_console", "abspath")
   dir_pw_containers = get_path_info("pw_containers", "abspath")
   dir_pw_cpu_exception = get_path_info("pw_cpu_exception", "abspath")
   dir_pw_cpu_exception_cortex_m =
diff --git a/pw_cli/py/BUILD.gn b/pw_cli/py/BUILD.gn
index b7ed63c..ef0c4f7 100644
--- a/pw_cli/py/BUILD.gn
+++ b/pw_cli/py/BUILD.gn
@@ -21,6 +21,7 @@
   sources = [
     "pw_cli/__init__.py",
     "pw_cli/__main__.py",
+    "pw_cli/argument_types.py",
     "pw_cli/arguments.py",
     "pw_cli/branding.py",
     "pw_cli/color.py",
diff --git a/pw_cli/py/pw_cli/argument_types.py b/pw_cli/py/pw_cli/argument_types.py
new file mode 100644
index 0000000..5b183b7
--- /dev/null
+++ b/pw_cli/py/pw_cli/argument_types.py
@@ -0,0 +1,34 @@
+# Copyright 2021 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.
+"""Defines argument types for use with argparse."""
+
+import argparse
+import logging
+from pathlib import Path
+
+
+def directory(arg: str) -> Path:
+    path = Path(arg)
+    if path.is_dir():
+        return path.resolve()
+
+    raise argparse.ArgumentTypeError(f'{path} is not a directory')
+
+
+def log_level(arg: str) -> int:
+    try:
+        return getattr(logging, arg.upper())
+    except AttributeError:
+        raise argparse.ArgumentTypeError(
+            f'{arg.upper()} is not a valid log level')
diff --git a/pw_cli/py/pw_cli/arguments.py b/pw_cli/py/pw_cli/arguments.py
index f9e2358..ed612aa 100644
--- a/pw_cli/py/pw_cli/arguments.py
+++ b/pw_cli/py/pw_cli/arguments.py
@@ -19,7 +19,7 @@
 import sys
 from typing import NoReturn
 
-from pw_cli import plugins
+from pw_cli import argument_types, plugins
 from pw_cli.branding import banner
 
 _HELP_HEADER = '''The Pigweed command line interface (CLI).
@@ -60,20 +60,6 @@
         description=_HELP_HEADER,
         formatter_class=argparse.RawDescriptionHelpFormatter)
 
-    def directory(arg: str) -> Path:
-        path = Path(arg)
-        if path.is_dir():
-            return path.resolve()
-
-        raise argparse.ArgumentTypeError(f'{path} is not a directory')
-
-    def log_level(arg: str) -> int:
-        try:
-            return getattr(logging, arg.upper())
-        except AttributeError:
-            raise argparse.ArgumentTypeError(
-                f'{arg.upper()} is not a valid log level')
-
     # Do not use the built-in help argument so that displaying the help info can
     # be deferred until the pw plugins have been registered.
     argparser.add_argument('-h',
@@ -83,13 +69,13 @@
     argparser.add_argument(
         '-C',
         '--directory',
-        type=directory,
+        type=argument_types.directory,
         default=Path.cwd(),
         help='Change to this directory before doing anything')
     argparser.add_argument(
         '-l',
         '--loglevel',
-        type=log_level,
+        type=argument_types.log_level,
         default=logging.INFO,
         help='Set the log level (debug, info, warning, error, critical)')
     argparser.add_argument('--no-banner',
diff --git a/pw_console/BUILD.gn b/pw_console/BUILD.gn
new file mode 100644
index 0000000..9a6699a
--- /dev/null
+++ b/pw_console/BUILD.gn
@@ -0,0 +1,21 @@
+# Copyright 2021 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("//build_overrides/pigweed.gni")
+
+import("$dir_pw_docgen/docs.gni")
+
+pw_doc_group("docs") {
+  sources = [ "docs.rst" ]
+}
diff --git a/pw_console/docs.rst b/pw_console/docs.rst
new file mode 100644
index 0000000..0f41a3a
--- /dev/null
+++ b/pw_console/docs.rst
@@ -0,0 +1,98 @@
+.. _module-pw_console:
+
+----------
+pw_console
+----------
+
+The Pigweed Console provides a Python repl (read eval print loop) using
+`ptpython <https://github.com/prompt-toolkit/ptpython>`_ and a log message
+viewer in a single-window terminal based interface. It is designed to be a
+replacement for
+`IPython's embed() <https://ipython.readthedocs.io/en/stable/interactive/reference.html#embedding>`_
+function.
+
+==========
+Motivation
+==========
+
+``pw_console`` is the complete solution for interacting with hardware devices
+using :ref:`module-pw_rpc` over a :ref:`module-pw_hdlc` transport.
+
+The repl allows interactive RPC sending while the log viewer provides immediate
+feedback on device status.
+
+=====
+Usage
+=====
+
+``pw console`` is invoked by calling the ``embed()`` function in your own
+Python script.
+
+.. automodule:: pw_console.console_app
+    :members: embed
+    :undoc-members:
+    :show-inheritance:
+
+=========================
+Implementation and Design
+=========================
+
+Detains on Pigweed Console internals follows.
+
+Thread and Event Loop Design
+----------------------------
+
+Here's a diagram showing how ``pw_console`` threads and asyncio tasks are organized.
+
+.. mermaid::
+
+   flowchart LR
+       classDef eventLoop fill:#e3f2fd,stroke:#90caf9,stroke-width:1px;
+       classDef thread fill:#fffde7,stroke:#ffeb3b,stroke-width:1px;
+       classDef plugin fill:#fce4ec,stroke:#f06292,stroke-width:1px;
+       classDef builtinFeature fill:#e0f2f1,stroke:#4db6ac,stroke-width:1px;
+
+       %% Subgraphs are drawn in reverse order.
+
+       subgraph pluginThread [Plugin Thread 1]
+           subgraph pluginLoop [Plugin Event Loop 1]
+               toolbarFunc-->|"Refresh<br/>UI Tokens"| toolbarFunc
+               toolbarFunc[Toolbar Update Function]
+           end
+           class pluginLoop eventLoop;
+       end
+       class pluginThread thread;
+
+       subgraph pluginThread2 [Plugin Thread 2]
+           subgraph pluginLoop2 [Plugin Event Loop 2]
+               paneFunc-->|"Refresh<br/>UI Tokens"| paneFunc
+               paneFunc[Pane Update Function]
+           end
+           class pluginLoop2 eventLoop;
+       end
+       class pluginThread2 thread;
+
+       subgraph replThread [Repl Thread]
+           subgraph replLoop [Repl Event Loop]
+               Task1 -->|Finished| Task2 -->|Cancel with Ctrl-C| Task3
+           end
+           class replLoop eventLoop;
+       end
+       class replThread thread;
+
+       subgraph main [Main Thread]
+           subgraph mainLoop [User Interface Event Loop]
+               log[[Log Pane]]
+               repl[[Python Repl]]
+               pluginToolbar([User Toolbar Plugin])
+               pluginPane([User Pane Plugin])
+               class log,repl builtinFeature;
+               class pluginToolbar,pluginPane plugin;
+           end
+           class mainLoop eventLoop;
+       end
+       class main thread;
+
+       repl-.->|Run Code| replThread
+       pluginToolbar-.->|Register Plugin| pluginThread
+       pluginPane-.->|Register Plugin| pluginThread2
diff --git a/pw_console/py/BUILD.gn b/pw_console/py/BUILD.gn
new file mode 100644
index 0000000..f8952f1
--- /dev/null
+++ b/pw_console/py/BUILD.gn
@@ -0,0 +1,33 @@
+# Copyright 2021 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("//build_overrides/pigweed.gni")
+
+import("$dir_pw_build/python.gni")
+
+pw_python_package("py") {
+  setup = [ "setup.py" ]
+  sources = [
+    "pw_console/__init__.py",
+    "pw_console/__main__.py",
+    "pw_console/console_app.py",
+  ]
+  tests = [ "console_app_test.py" ]
+  python_deps = [
+    "$dir_pw_cli/py",
+    "$dir_pw_tokenizer/py",
+  ]
+
+  pylintrc = "$dir_pigweed/.pylintrc"
+}
diff --git a/pw_console/py/console_app_test.py b/pw_console/py/console_app_test.py
new file mode 100644
index 0000000..2e72e18
--- /dev/null
+++ b/pw_console/py/console_app_test.py
@@ -0,0 +1,29 @@
+# Copyright 2021 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.
+"""Tests for pw_console.console_app"""
+
+import unittest
+
+from pw_console.console_app import ConsoleApp
+
+
+class TestConsoleApp(unittest.TestCase):
+    """Tests for ConsoleApp."""
+    def test_instantiate(self) -> None:
+        console_app = ConsoleApp()
+        self.assertIsNotNone(console_app)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/pw_console/py/pw_console/__init__.py b/pw_console/py/pw_console/__init__.py
new file mode 100644
index 0000000..4af0ac7
--- /dev/null
+++ b/pw_console/py/pw_console/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2021 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 interactive console."""
diff --git a/pw_console/py/pw_console/__main__.py b/pw_console/py/pw_console/__main__.py
new file mode 100644
index 0000000..c516897
--- /dev/null
+++ b/pw_console/py/pw_console/__main__.py
@@ -0,0 +1,101 @@
+# Copyright 2021 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 Console - Warning: This is a work in progress."""
+
+import argparse
+import logging
+import sys
+import tempfile
+from datetime import datetime
+from typing import List
+
+import pw_cli.log
+import pw_cli.argument_types
+
+from pw_console.console_app import embed
+
+_LOG = logging.getLogger(__package__)
+
+
+def _build_argument_parser() -> argparse.ArgumentParser:
+    """Setup argparse."""
+    parser = argparse.ArgumentParser(description=__doc__)
+
+    parser.add_argument('-l',
+                        '--loglevel',
+                        type=pw_cli.argument_types.log_level,
+                        default=logging.INFO,
+                        help='Set the log level'
+                        '(debug, info, warning, error, critical)')
+
+    parser.add_argument('--logfile', help='Pigweed Console debug log file.')
+
+    parser.add_argument('--test-mode',
+                        action='store_true',
+                        help='Enable fake log messages for testing purposes.')
+
+    return parser
+
+
+def _create_temp_log_file():
+    """Create a unique tempfile for saving logs.
+
+    Example format: /tmp/pw_console_2021-05-04_151807_8hem6iyq
+    """
+
+    # Grab the current system timestamp as a string.
+    isotime = datetime.now().isoformat(sep='_', timespec='seconds')
+    # Timestamp string should not have colons in it.
+    isotime = isotime.replace(':', '')
+
+    log_file_name = None
+    with tempfile.NamedTemporaryFile(prefix=f'{__package__}_{isotime}_',
+                                     delete=False) as log_file:
+        log_file_name = log_file.name
+
+    return log_file_name
+
+
+def main() -> int:
+    """Pigweed Console."""
+
+    parser = _build_argument_parser()
+    args = parser.parse_args()
+
+    if not args.logfile:
+        # Create a temp logfile to prevent logs from appearing over stdout. This
+        # would corrupt the prompt toolkit UI.
+        args.logfile = _create_temp_log_file()
+
+    pw_cli.log.install(args.loglevel, True, False, args.logfile)
+
+    default_loggers: List = []
+    # TODO: Add test-mode loggers here.
+    # if args.test_mode:
+    #     default_loggers = [
+    #         # Don't include pw_console package logs (_LOG) in the log pane UI.
+    #         # Add the fake logger for test_mode.
+    #         logging.getLogger(FAKE_DEVICE_LOGGER_NAME)
+    #     ]
+
+    embed(loggers=default_loggers)
+
+    if args.logfile:
+        print(f'Logs saved to: {args.logfile}')
+
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
new file mode 100644
index 0000000..0135425
--- /dev/null
+++ b/pw_console/py/pw_console/console_app.py
@@ -0,0 +1,86 @@
+# Copyright 2021 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.
+"""ConsoleApp control class."""
+
+import builtins
+import logging
+from typing import Iterable, Optional
+
+_LOG = logging.getLogger(__package__)
+
+
+class ConsoleApp:
+    """The main ConsoleApp class containing the whole console."""
+    def __init__(self, global_vars=None, local_vars=None):
+        # Create a default global and local symbol table. Values are the same
+        # structure as what is returned by globals():
+        #   https://docs.python.org/3/library/functions.html#globals
+        if global_vars is None:
+            global_vars = {
+                '__name__': '__main__',
+                '__package__': None,
+                '__doc__': None,
+                '__builtins__': builtins,
+            }
+
+        local_vars = local_vars or global_vars
+
+    def add_log_handler(self, logger_instance):
+        """Add the Log pane as a handler for this logger instance."""
+        # TODO: Add log pane to addHandler call.
+        # logger_instance.addHandler(...)
+
+
+def embed(
+    global_vars=None,
+    local_vars=None,
+    loggers: Optional[Iterable] = None,
+) -> None:
+    """Call this to embed pw console at the call point within your program.
+    It's similar to `ptpython.embed` and `IPython.embed`. ::
+
+        import logging
+
+        from pw_console.console_app import embed
+
+        embed(global_vars=globals(),
+              local_vars=locals(),
+              loggers=[
+                  logging.getLogger(__package__),
+                  logging.getLogger('device logs'),
+              ],
+        )
+
+    :param global_vars: Dictionary representing the desired global symbol
+        table. Similar to what is returned by `globals()`.
+    :type global_vars: dict, optional
+    :param local_vars: Dictionary representing the desired local symbol
+        table. Similar to what is returned by `locals()`.
+    :type local_vars: dict, optional
+    :param loggers: List of `logging.getLogger()` instances that should be shown
+        in the pw console log pane user interface.
+    :type loggers: list, optional
+    """
+    console_app = ConsoleApp(
+        global_vars=global_vars,
+        local_vars=local_vars,
+    )
+
+    # Add loggers to the console app log pane.
+    if loggers:
+        for logger in loggers:
+            console_app.add_log_handler(logger)
+
+    # TODO: Start prompt_toolkit app here
+    _LOG.debug('Pigweed Console Start')
diff --git a/pw_console/py/pw_console/py.typed b/pw_console/py/pw_console/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/pw_console/py/pw_console/py.typed
diff --git a/pw_console/py/setup.py b/pw_console/py/setup.py
new file mode 100644
index 0000000..7796683
--- /dev/null
+++ b/pw_console/py/setup.py
@@ -0,0 +1,43 @@
+# Copyright 2021 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.
+"""pw_console"""
+
+import setuptools  # type: ignore
+
+setuptools.setup(
+    name='pw_console',
+    version='0.0.1',
+    author='Pigweed Authors',
+    author_email='pigweed-developers@googlegroups.com',
+    description='Pigweed interactive console',
+    packages=setuptools.find_packages(),
+    package_data={'pw_console': ['py.typed']},
+    zip_safe=False,
+    entry_points={
+        'console_scripts': [
+            'pw-console = pw_console.__main__:main',
+        ]
+    },
+    install_requires=[
+        'ipdb',
+        'ipython',
+        'jinja2',
+        'prompt_toolkit',
+        # inclusive-language: ignore
+        'ptpython @ git+git://github.com/prompt-toolkit/ptpython.git@master',
+        'pw_cli',
+        'pw_tokenizer',
+        'pygments',
+    ],
+)
diff --git a/pw_env_setup/BUILD.gn b/pw_env_setup/BUILD.gn
index afd46b7..30ad256 100644
--- a/pw_env_setup/BUILD.gn
+++ b/pw_env_setup/BUILD.gn
@@ -30,6 +30,7 @@
     "$dir_pw_bloat/py",
     "$dir_pw_build/py",
     "$dir_pw_cli/py",
+    "$dir_pw_console/py",
     "$dir_pw_cpu_exception_cortex_m/py",
     "$dir_pw_docgen/py",
     "$dir_pw_doctor/py",