pw_cli: Allow setting log levels for all loggers

- Make it easier to allow debug logs through the root logger but only
  for selected modules. The set_all_loggers_minimum_level(level)
  function increases all logger's log level to the provided minimum.
  Then, individual loggers can be dropped to debug or a different level.
- Remove the unsued and ambiguous pw_cli.log.set_level function.

Change-Id: I85b4766737aa7a54ff81e8e8229ac0158d84ff91
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/37562
Pigweed-Auto-Submit: Wyatt Hepler <hepler@google.com>
Commit-Queue: Auto-Submit <auto-submit@pigweed.google.com.iam.gserviceaccount.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/pw_cli/BUILD.gn b/pw_cli/BUILD.gn
index dd021e8..79f575b 100644
--- a/pw_cli/BUILD.gn
+++ b/pw_cli/BUILD.gn
@@ -18,4 +18,5 @@
 
 pw_doc_group("docs") {
   sources = [ "docs.rst" ]
+  other_deps = [ "py" ]
 }
diff --git a/pw_cli/docs.rst b/pw_cli/docs.rst
index 5d03f05..40489a2 100644
--- a/pw_cli/docs.rst
+++ b/pw_cli/docs.rst
@@ -265,3 +265,13 @@
 - Supporting renaming the ``pw`` command to something project specific, like
   ``foo`` in this case.
 - Re-coloring the log headers from the ``pw`` tool.
+
+pw_cli Python package
+=====================
+The ``pw_cli`` Pigweed module includes the ``pw_cli`` Python package, which
+provides utilities for creating command line tools with Pigweed.
+
+pw_cli log
+----------
+.. automodule:: pw_cli.log
+  :members:
diff --git a/pw_cli/py/pw_cli/__main__.py b/pw_cli/py/pw_cli/__main__.py
index 096f341..799cbc4 100644
--- a/pw_cli/py/pw_cli/__main__.py
+++ b/pw_cli/py/pw_cli/__main__.py
@@ -29,8 +29,7 @@
 
     args = arguments.parse_args()
 
-    pw_cli.log.install()
-    pw_cli.log.set_level(args.loglevel)
+    pw_cli.log.install(level=args.loglevel)
 
     # Start with the most critical part of the Pigweed command line tool.
     if not args.no_banner:
diff --git a/pw_cli/py/pw_cli/log.py b/pw_cli/py/pw_cli/log.py
index d84b657..df5ebdc 100644
--- a/pw_cli/py/pw_cli/log.py
+++ b/pw_cli/py/pw_cli/log.py
@@ -11,11 +11,11 @@
 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 # License for the specific language governing permissions and limitations under
 # the License.
-"""Configure the system logger for the default pw command log format."""
+"""Tools for configuring Python logging."""
 
 import logging
 from pathlib import Path
-from typing import NamedTuple, Union
+from typing import NamedTuple, Union, Iterator
 
 import pw_cli.color
 import pw_cli.env
@@ -25,7 +25,7 @@
 LOGLEVEL_STDOUT = 21
 
 
-class LogLevel(NamedTuple):
+class _LogLevel(NamedTuple):
     level: int
     color: str
     ascii: str
@@ -35,12 +35,12 @@
 # Shorten all the log levels to 3 characters for column-aligned logs.
 # Color the logs using ANSI codes.
 _LOG_LEVELS = (
-    LogLevel(logging.CRITICAL, 'bold_red', 'CRT', '☠️ '),
-    LogLevel(logging.ERROR,    'red',      'ERR', '❌'),
-    LogLevel(logging.WARNING,  'yellow',   'WRN', '⚠️ '),
-    LogLevel(logging.INFO,     'magenta',  'INF', 'ℹ️ '),
-    LogLevel(LOGLEVEL_STDOUT,  'cyan',     'OUT', '💬'),
-    LogLevel(logging.DEBUG,    'blue',     'DBG', '👾'),
+    _LogLevel(logging.CRITICAL, 'bold_red', 'CRT', '☠️ '),
+    _LogLevel(logging.ERROR,    'red',      'ERR', '❌'),
+    _LogLevel(logging.WARNING,  'yellow',   'WRN', '⚠️ '),
+    _LogLevel(logging.INFO,     'magenta',  'INF', 'ℹ️ '),
+    _LogLevel(LOGLEVEL_STDOUT,  'cyan',     'OUT', '💬'),
+    _LogLevel(logging.DEBUG,    'blue',     'DBG', '👾'),
 )  # yapf: disable
 
 _LOG = logging.getLogger(__name__)
@@ -48,7 +48,7 @@
 
 
 def main() -> None:
-    """Show how logs look at various levels."""
+    """Shows how logs look at various levels."""
 
     # Force the log level to make sure all logs are shown.
     _LOG.setLevel(logging.DEBUG)
@@ -62,11 +62,18 @@
     _LOG.debug('Adding 1 to i')
 
 
+def _setup_handler(handler: logging.Handler, formatter: logging.Formatter,
+                   level: int) -> None:
+    handler.setLevel(level)
+    handler.setFormatter(formatter)
+    logging.getLogger().addHandler(handler)
+
+
 def install(level: int = logging.INFO,
             use_color: bool = None,
             hide_timestamp: bool = False,
             log_file: Union[str, Path] = None) -> None:
-    """Configure the system logger for the default pw command log format."""
+    """Configures the system logger for the default pw command log format."""
 
     colors = pw_cli.color.colors(use_color)
 
@@ -81,17 +88,20 @@
         # colored text.
         timestamp_fmt = colors.black_on_white('%(asctime)s') + ' '
 
-    # Set log level on root logger to debug, otherwise any higher levels
-    # elsewhere are ignored.
-    root = logging.getLogger()
-    root.setLevel(logging.DEBUG)
+    formatter = logging.Formatter(timestamp_fmt + '%(levelname)s %(message)s',
+                                  '%Y%m%d %H:%M:%S')
 
-    handler = logging.FileHandler(log_file) if log_file else _STDERR_HANDLER
-    handler.setLevel(level)
-    handler.setFormatter(
-        logging.Formatter(timestamp_fmt + '%(levelname)s %(message)s',
-                          '%Y%m%d %H:%M:%S'))
-    root.addHandler(handler)
+    # Set the log level on the root logger to 1, so logs that all logs
+    # propagated from child loggers are handled.
+    logging.getLogger().setLevel(1)
+
+    # Always set up the stderr handler, even if it isn't used.
+    _setup_handler(_STDERR_HANDLER, formatter, level)
+
+    if log_file:
+        _setup_handler(logging.FileHandler(log_file), formatter, level)
+        # Since we're using a file, filter logs out of the stderr handler.
+        _STDERR_HANDLER.setLevel(logging.CRITICAL + 1)
 
     if env.PW_EMOJI:
         name_attr = 'emoji'
@@ -105,9 +115,19 @@
         logging.addLevelName(log_level.level, colorize(log_level)(name))
 
 
-def set_level(log_level: int):
-    """Sets the log level for logs to stderr."""
-    _STDERR_HANDLER.setLevel(log_level)
+def all_loggers() -> Iterator[logging.Logger]:
+    """Iterates over all loggers known to Python logging."""
+    manager = logging.getLogger().manager  # type: ignore[attr-defined]
+
+    for logger_name in manager.loggerDict:  # pylint: disable=no-member
+        yield logging.getLogger(logger_name)
+
+
+def set_all_loggers_minimum_level(level: int) -> None:
+    """Increases the log level to the specified value for all known loggers."""
+    for logger in all_loggers():
+        if logger.isEnabledFor(level - 1):
+            logger.setLevel(level)
 
 
 if __name__ == '__main__':