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/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',
+    ],
+)