pw_console: Open any Python logger interactively

- File > Open Logger menu that displays all known python loggers.
  Clicking one opens a new log window with that logger.

- File > Log Table View > Show Python File and Logger options.
  These preferences toggle displaying each log line's
  Python logger and file:linenumber columns in table view.

- Set logging.lastResort to a file handler. This prevent loggers
  with no handlers from outputting text to stderr and corrupting
  the prompt_toolkit UI.

Change-Id: I6f36384764621b0f62ad6c51687b5b125c91da44
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/69280
Reviewed-by: Joe Ethier <jethier@google.com>
Commit-Queue: Anthony DiGirolamo <tonymd@google.com>
diff --git a/pw_console/py/log_store_test.py b/pw_console/py/log_store_test.py
index a8097d4..9c0763d 100644
--- a/pw_console/py/log_store_test.py
+++ b/pw_console/py/log_store_test.py
@@ -24,6 +24,8 @@
 def _create_log_store():
     log_store = LogStore(
         prefs=ConsolePrefs(project_file=False, user_file=False))
+
+    assert not log_store.table.prefs.show_python_file
     viewer = MagicMock()
     viewer.new_logs_arrived = MagicMock()
     log_store.register_viewer(viewer)
diff --git a/pw_console/py/log_view_test.py b/pw_console/py/log_view_test.py
index 1c68dc2..f740851 100644
--- a/pw_console/py/log_view_test.py
+++ b/pw_console/py/log_view_test.py
@@ -226,12 +226,15 @@
 
         log_view.scroll_to_top()
         log_view.render_content()
-        self.assertEqual(log_view.get_cursor_position(), Point(x=0, y=0))
+        self.assertEqual(log_view.get_cursor_position(), Point(x=0, y=2))
+
         log_view.scroll_to_bottom()
         log_view.render_content()
-        self.assertEqual(log_view.get_cursor_position(), Point(x=0, y=3))
+        self.assertEqual(log_view.get_cursor_position(), Point(x=0, y=5))
 
         expected_line_cache = [
+            [('', '\n')],
+            [('', '\n')],
             [
                 ('class:log-time', '20210713 00:00:00'),
                 ('', '  '),
@@ -263,14 +266,12 @@
                 ('class:selected-log-line class:log-level-10', 'DEBUG'),
                 ('class:selected-log-line ', '  '),
                 ('class:selected-log-line ', 'Test log 3'),
-                ('class:selected-log-line ', '  \n')
+                ('class:selected-log-line ', '                        \n')
             ]),
         ]  # yapf: disable
 
-        self.assertEqual(
-            list(log_view._line_fragment_cache  # pylint: disable=protected-access
-                 ),
-            expected_line_cache)
+        self.assertEqual(expected_line_cache,
+                         list(log_view._line_fragment_cache))  # pylint: disable=protected-access
 
     def test_clear_scrollback(self) -> None:
         """Test various functions with clearing log scrollback history."""
diff --git a/pw_console/py/pw_console/__main__.py b/pw_console/py/pw_console/__main__.py
index 3ab540e..a84fb0c 100644
--- a/pw_console/py/pw_console/__main__.py
+++ b/pw_console/py/pw_console/__main__.py
@@ -18,7 +18,6 @@
 import logging
 from pathlib import Path
 import sys
-from typing import List
 
 import pw_cli.log
 import pw_cli.argument_types
@@ -80,19 +79,21 @@
                            use_color=True,
                            hide_timestamp=False,
                            log_file=args.console_debug_log_file,
-                           logger=logging.getLogger(__package__))
-        logging.getLogger(__package__).propagate = False
+                           logger=logging.getLogger('pw_console'))
 
     global_vars = None
-    default_loggers: List = []
+    default_loggers = {}
     if args.test_mode:
-        default_loggers = [
+        fake_logger = logging.getLogger(
+            pw_console.console_app.FAKE_DEVICE_LOGGER_NAME)
+        default_loggers = {
             # Don't include pw_console package logs (_LOG) in the log pane UI.
             # Add the fake logger for test_mode.
-            logging.getLogger(pw_console.console_app.FAKE_DEVICE_LOGGER_NAME)
-        ]
+            'Fake Device Logs': [fake_logger],
+            'PwConsole Debug': [logging.getLogger('pw_console')],
+        }
         # Give access to adding log messages from the repl via: `LOG.warning()`
-        global_vars = dict(LOG=default_loggers[0])
+        global_vars = dict(LOG=fake_logger)
 
     help_text = None
     app_title = None
diff --git a/pw_console/py/pw_console/console_app.py b/pw_console/py/pw_console/console_app.py
index 38f55d4..7d1941e 100644
--- a/pw_console/py/pw_console/console_app.py
+++ b/pw_console/py/pw_console/console_app.py
@@ -58,6 +58,7 @@
 from pw_console.help_window import HelpWindow
 import pw_console.key_bindings
 from pw_console.log_pane import LogPane
+from pw_console.python_logging import all_loggers
 from pw_console.pw_ptpython_repl import PwPtPythonRepl
 from pw_console.repl_pane import ReplPane
 import pw_console.style
@@ -68,8 +69,10 @@
 _LOG = logging.getLogger(__package__)
 
 # Fake logger for --test-mode
-FAKE_DEVICE_LOGGER_NAME = 'fake_device.1'
+FAKE_DEVICE_LOGGER_NAME = 'pw_console_fake_device'
 _FAKE_DEVICE_LOG = logging.getLogger(FAKE_DEVICE_LOGGER_NAME)
+# Don't send fake_device logs to the root Python logger.
+_FAKE_DEVICE_LOG.propagate = False
 
 
 class FloatingMessageBar(ConditionalContainer):
@@ -85,11 +88,12 @@
 
 
 def _add_log_handler_to_pane(logger: Union[str, logging.Logger],
-                             pane: 'LogPane') -> None:
+                             pane: 'LogPane',
+                             level_name: Optional[str] = None) -> None:
     """A log pane handler for a given logger instance."""
     if not pane:
         return
-    pane.add_log_handler(logger)
+    pane.add_log_handler(logger, level_name=level_name)
 
 
 class ConsoleApp:
@@ -331,6 +335,17 @@
         self.update_menu_items()
         self.focus_main_menu()
 
+    def open_new_log_pane_for_logger(
+            self,
+            logger_name: str,
+            level_name='NOTSET',
+            window_title: Optional[str] = None) -> None:
+        pane_title = window_title if window_title else logger_name
+        self.run_pane_menu_option(
+            functools.partial(self.add_log_handler,
+                              pane_title, [logger_name],
+                              log_level_name=level_name))
+
     def set_ui_theme(self, theme_name: str) -> Callable:
         call_function = functools.partial(
             self.run_pane_menu_option,
@@ -345,7 +360,27 @@
         return call_function
 
     def update_menu_items(self):
-        self.root_container.menu_items = self._create_menu_items()
+        self.menu_items = self._create_menu_items()
+        self.root_container.menu_items = self.menu_items
+
+    def _create_logger_submenu(self):
+        submenu = [
+            MenuItem(
+                'root',
+                handler=functools.partial(self.open_new_log_pane_for_logger,
+                                          '',
+                                          window_title='root'),
+            )
+        ]
+        all_logger_names = sorted([logger.name for logger in all_loggers()])
+        for logger_name in all_logger_names:
+            submenu.append(
+                MenuItem(
+                    logger_name,
+                    handler=functools.partial(
+                        self.open_new_log_pane_for_logger, logger_name),
+                ))
+        return submenu
 
     def _create_menu_items(self):
         themes_submenu = [
@@ -394,6 +429,34 @@
             MenuItem(
                 '[File]',
                 children=[
+                    MenuItem('Open Logger',
+                             children=self._create_logger_submenu()),
+                    MenuItem(
+                        'Log Table View',
+                        children=[
+                            MenuItem(
+                                '{check} Show Python File'.format(
+                                    check=pw_console.widgets.checkbox.
+                                    to_checkbox_text(
+                                        self.prefs.show_python_file, end='')),
+                                handler=functools.partial(
+                                    self.run_pane_menu_option,
+                                    functools.partial(self.toggle_pref_option,
+                                                      'show_python_file')),
+                            ),
+                            MenuItem(
+                                '{check} Show Python Logger'.format(
+                                    check=pw_console.widgets.checkbox.
+                                    to_checkbox_text(
+                                        self.prefs.show_python_logger,
+                                        end='')),
+                                handler=functools.partial(
+                                    self.run_pane_menu_option,
+                                    functools.partial(self.toggle_pref_option,
+                                                      'show_python_logger')),
+                            ),
+                        ]),
+                    MenuItem('-'),
                     MenuItem(
                         'Themes',
                         children=themes_submenu,
@@ -520,9 +583,14 @@
         if self.application:
             self.focus_main_menu()
 
+    def toggle_pref_option(self, setting_name):
+        self.prefs.toggle_bool_option(setting_name)
+
     def load_theme(self, theme_name=None):
         """Regenerate styles for the current theme_name."""
         self._current_theme = pw_console.style.generate_styles(theme_name)
+        if theme_name:
+            self.prefs.set_ui_theme(theme_name)
 
     def _create_log_pane(self, title=None) -> 'LogPane':
         # Create one log pane.
@@ -537,10 +605,12 @@
     def apply_window_config(self) -> None:
         self.window_manager.apply_config(self.prefs)
 
-    def add_log_handler(self,
-                        window_title: str,
-                        logger_instances: Iterable[logging.Logger],
-                        separate_log_panes=False) -> Optional[LogPane]:
+    def add_log_handler(
+            self,
+            window_title: str,
+            logger_instances: Iterable[logging.Logger],
+            separate_log_panes: bool = False,
+            log_level_name: Optional[str] = None) -> Optional[LogPane]:
         """Add the Log pane as a handler for this logger instance."""
 
         existing_log_pane = None
@@ -554,7 +624,7 @@
             existing_log_pane = self._create_log_pane(title=window_title)
 
         for logger in logger_instances:
-            _add_log_handler_to_pane(logger, existing_log_pane)
+            _add_log_handler_to_pane(logger, existing_log_pane, log_level_name)
 
         self.window_manager.update_root_container_body()
         self.update_menu_items()
@@ -641,12 +711,22 @@
         if test_mode:
             background_log_task = asyncio.create_task(self.log_forever())
 
+        background_menu_updater_task = asyncio.create_task(
+            self.background_menu_updater())
         try:
             unused_result = await self.application.run_async(
                 set_exception_handler=True)
         finally:
             if test_mode:
                 background_log_task.cancel()
+            background_menu_updater_task.cancel()
+
+    async def background_menu_updater(self):
+        """Periodically update main menu items to capture new logger names."""
+        while True:
+            await asyncio.sleep(30)
+            _LOG.debug('Update main menu items')
+            self.update_menu_items()
 
     async def log_forever(self):
         """Test mode async log generator coroutine that runs forever."""
diff --git a/pw_console/py/pw_console/console_prefs.py b/pw_console/py/pw_console/console_prefs.py
index 61b2d0b..1a79ad7 100644
--- a/pw_console/py/pw_console/console_prefs.py
+++ b/pw_console/py/pw_console/console_prefs.py
@@ -20,6 +20,8 @@
 
 import yaml
 
+from pw_console.style import get_theme_colors
+
 _DEFAULT_REPL_HISTORY: Path = Path.home() / '.pw_console_history'
 _DEFAULT_SEARCH_HISTORY: Path = Path.home() / '.pw_console_search'
 
@@ -36,6 +38,8 @@
         'column_order_omit_unspecified_columns': False,
         'column_order': [],
         'column_colors': {},
+        'show_python_file': False,
+        'show_python_logger': False,
         'hide_date_from_log_time': False,
         # Window arrangement
         'windows': {},
@@ -121,6 +125,13 @@
     def ui_theme(self) -> str:
         return self._config.get('ui_theme', '')
 
+    def set_ui_theme(self, theme_name: str):
+        self._config['ui_theme'] = theme_name
+
+    @property
+    def theme_colors(self):
+        return get_theme_colors(self.ui_theme)
+
     @property
     def code_theme(self) -> str:
         return self._config.get('code_theme', '')
@@ -156,6 +167,19 @@
         return self._config.get('hide_date_from_log_time', False)
 
     @property
+    def show_python_file(self) -> bool:
+        return self._config.get('show_python_file', False)
+
+    @property
+    def show_python_logger(self) -> bool:
+        return self._config.get('show_python_logger', False)
+
+    def toggle_bool_option(self, name: str):
+        existing_setting = self._config[name]
+        assert isinstance(existing_setting, bool)
+        self._config[name] = not existing_setting
+
+    @property
     def column_order(self) -> list:
         return self._config.get('column_order', [])
 
@@ -194,7 +218,8 @@
         titles = []
         for column in self.windows.values():
             for window_key_title, window_dict in column.items():
+                window_options = window_dict if window_dict else {}
                 # Use 'duplicate_of: Title' if it exists, otherwise use the key.
-                titles.append(window_dict.get('duplicate_of',
-                                              window_key_title))
+                titles.append(
+                    window_options.get('duplicate_of', window_key_title))
         return set(titles)
diff --git a/pw_console/py/pw_console/embed.py b/pw_console/py/pw_console/embed.py
index cf650ca..9ba65d7 100644
--- a/pw_console/py/pw_console/embed.py
+++ b/pw_console/py/pw_console/embed.py
@@ -14,7 +14,6 @@
 """pw_console embed class."""
 
 import asyncio
-import copy
 import logging
 from pathlib import Path
 from typing import Dict, List, Iterable, Optional, Union
@@ -74,6 +73,10 @@
                     'some_variable', 'Variable',
                 }
             )
+
+            # Setup Python loggers to output to a file instead of STDOUT.
+            console.setup_python_logging()
+
             # Then run the console with:
             console.embed()
 
@@ -151,32 +154,17 @@
                 if window_pane.pane_title() in self.hidden_by_default_windows:
                     window_pane.show_pane = False
 
-    def setup_python_logging(self):
-        """Disable log handlers for full screen prompt_toolkit applications."""
-        self.setup_python_logging_called = True
-        for logger in pw_console.python_logging.all_loggers():
-            # Make sure all known loggers propagate to the root logger.
-            logger.propagate = True
-            # Remove all stdout and stdout & stderr handlers to prevent
-            # corrupting the prompt_toolkit user interface.
-            for handler in copy.copy(logger.handlers):
-                # Must use type() check here since this returns True:
-                #   isinstance(logging.FileHandler, logging.StreamHandler)
-                if type(handler) == logging.StreamHandler:  # pylint: disable=unidiomatic-typecheck
-                    logger.removeHandler(handler)
+    def setup_python_logging(self, last_resort_filename: Optional[str] = None):
+        """Disable log handlers for full screen prompt_toolkit applications.
 
-        # Prevent these loggers from propagating to the root logger.
-        logging.getLogger('pw_console').propagate = False
-        # prompt_toolkit triggered debug log messages
-        logging.getLogger('prompt_toolkit').propagate = False
-        logging.getLogger('prompt_toolkit.buffer').propagate = False
-        logging.getLogger('parso.python.diff').propagate = False
-        logging.getLogger('parso.cache').propagate = False
-        # Set asyncio log level to WARNING
-        logging.getLogger('asyncio').setLevel(logging.WARNING)
-        # Always set DEBUG level for serial debug.
-        logging.getLogger('pw_console.serial_debug_logger').setLevel(
-            logging.DEBUG)
+        Args:
+            last_resort_filename: If specified use this file as a fallback for
+                unhandled python logging messages. Normally Python will output
+                any log messages with no handlers to STDERR as a fallback. If
+                none, a temp file will be created instead.
+        """
+        self.setup_python_logging_called = True
+        pw_console.python_logging.setup_python_logging(last_resort_filename)
 
     def hide_windows(self, *window_titles):
         for window_title in window_titles:
diff --git a/pw_console/py/pw_console/log_line.py b/pw_console/py/pw_console/log_line.py
index cc30675..9d13d29 100644
--- a/pw_console/py/pw_console/log_line.py
+++ b/pw_console/py/pw_console/log_line.py
@@ -73,6 +73,11 @@
             for key, value in extra_fields.items():
                 self.metadata.fields[key] = value
 
+        lineno = self.record.lineno
+        file_name = str(self.record.filename)
+        self.metadata.fields['py_file'] = f'{file_name}:{lineno}'
+        self.metadata.fields['py_logger'] = str(self.record.name)
+
         return self.metadata
 
     def get_fragments(self) -> StyleAndTextTuples:
diff --git a/pw_console/py/pw_console/log_view.py b/pw_console/py/pw_console/log_view.py
index 2695e1e..d00eb4a 100644
--- a/pw_console/py/pw_console/log_view.py
+++ b/pw_console/py/pw_console/log_view.py
@@ -435,6 +435,7 @@
             return Point(0, 0)
         # For each line rendered in the last pass:
         for row, line in enumerate(self._line_fragment_cache):
+            # TODO(tonymd): This assumes every row contains exactly one '\n'
             column = 0
             # For each style string and raw text tuple in this line:
             for style_str, text, *_ in line:
@@ -661,8 +662,8 @@
         # will push the table header to the top.
         if total_used_lines < self._window_height:
             empty_line_count = self._window_height - total_used_lines
-            self._line_fragment_cache.appendleft([('', '\n' * empty_line_count)
-                                                  ])
+            for i in range(empty_line_count):
+                self._line_fragment_cache.appendleft([('', '\n')])
 
         self._line_fragment_cache_flattened = (
             pw_console.text_formatting.flatten_formatted_text_tuples(
diff --git a/pw_console/py/pw_console/python_logging.py b/pw_console/py/pw_console/python_logging.py
index 3453201..1d494a8 100644
--- a/pw_console/py/pw_console/python_logging.py
+++ b/pw_console/py/pw_console/python_logging.py
@@ -13,10 +13,11 @@
 # the License.
 """Python logging helper fuctions."""
 
+import copy
 import logging
 import tempfile
 from datetime import datetime
-from typing import Iterator
+from typing import Iterator, Optional
 
 
 def all_loggers() -> Iterator[logging.Logger]:
@@ -44,3 +45,53 @@
         log_file_name = log_file.name
 
     return log_file_name
+
+
+def set_logging_last_resort_file_handler(
+        file_name: Optional[str] = None) -> None:
+    log_file = file_name if file_name else create_temp_log_file()
+    logging.lastResort = logging.FileHandler(log_file)
+
+
+def disable_stdout_handlers(logger: logging.Logger) -> None:
+    """Remove all stdout and stdout & stderr logger handlers."""
+    for handler in copy.copy(logger.handlers):
+        # Must use type() check here since this returns True:
+        #   isinstance(logging.FileHandler, logging.StreamHandler)
+        if type(handler) == logging.StreamHandler:  # pylint: disable=unidiomatic-typecheck
+            logger.removeHandler(handler)
+
+
+def setup_python_logging(last_resort_filename: Optional[str] = None) -> None:
+    """Disable log handlers for full screen prompt_toolkit applications."""
+    disable_stdout_handlers(logging.getLogger())
+
+    if logging.lastResort is not None:
+        set_logging_last_resort_file_handler(last_resort_filename)
+
+    for logger in all_loggers():
+        # Make sure all known loggers propagate to the root logger.
+        logger.propagate = True
+        # Prevent stdout handlers from corrupting the prompt_toolkit UI.
+        disable_stdout_handlers(logger)
+
+    # Prevent these loggers from propagating to the root logger.
+    hidden_host_loggers = [
+        'pw_console',
+        'pw_console.plugins',
+
+        # prompt_toolkit triggered debug log messages
+        'prompt_toolkit',
+        'prompt_toolkit.buffer',
+        'parso.python.diff',
+        'parso.cache',
+        'pw_console.serial_debug_logger',
+    ]
+    for logger_name in hidden_host_loggers:
+        logging.getLogger(logger_name).propagate = False
+
+    # Set asyncio log level to WARNING
+    logging.getLogger('asyncio').setLevel(logging.WARNING)
+
+    # Always set DEBUG level for serial debug.
+    logging.getLogger('pw_console.serial_debug_logger').setLevel(logging.DEBUG)
diff --git a/pw_console/py/pw_console/repl_pane.py b/pw_console/py/pw_console/repl_pane.py
index 83c1585..4869ba8 100644
--- a/pw_console/py/pw_console/repl_pane.py
+++ b/pw_console/py/pw_console/repl_pane.py
@@ -333,7 +333,9 @@
     def _log_executed_code(self, code, prefix=''):
         """Log repl command input text along with a prefix string."""
         text = self.get_output_buffer_text([code], show_index=False)
-        _LOG.debug('[PYTHON] %s\n%s', prefix, text)
+        text = text.strip()
+        for line in text.splitlines():
+            _LOG.debug('[PYTHON %s]  %s', prefix, line.strip())
 
     async def periodically_check_stdout(self, user_code: UserCodeExecution,
                                         stdout_proxy, stderr_proxy):
diff --git a/pw_console/py/pw_console/style.py b/pw_console/py/pw_console/style.py
index d4dbf3a..9b56c92 100644
--- a/pw_console/py/pw_console/style.py
+++ b/pw_console/py/pw_console/style.py
@@ -167,7 +167,6 @@
     magenta_accent = '#e27e8d'
 
 
-
 _THEME_NAME_MAPPING = {
     'moonlight': MoonlightColors(),
     'nord': NordColors(),
@@ -176,6 +175,12 @@
     'high-contrast-dark': HighContrastDarkColors(),
 } # yapf: disable
 
+
+def get_theme_colors(theme_name=''):
+    theme = _THEME_NAME_MAPPING.get(theme_name, DarkColors())
+    return theme
+
+
 def generate_styles(theme_name='dark'):
     """Return prompt_toolkit styles for the given theme name."""
     # Use DarkColors() if name not found.
diff --git a/pw_console/py/pw_console/widgets/table.py b/pw_console/py/pw_console/widgets/table.py
index 51fb65e..6b3f550 100644
--- a/pw_console/py/pw_console/widgets/table.py
+++ b/pw_console/py/pw_console/widgets/table.py
@@ -15,7 +15,6 @@
 
 import collections
 import copy
-import logging
 
 from prompt_toolkit.formatted_text import StyleAndTextTuples
 
@@ -23,8 +22,6 @@
 from pw_console.log_line import LogLine
 import pw_console.text_formatting
 
-_LOG = logging.getLogger(__package__)
-
 
 class TableView:
     """Store column information and render logs into formatted tables."""
@@ -72,23 +69,28 @@
 
     def _ordered_column_widths(self):
         """Return each column and width in the preferred order."""
-        # Reverse sort if no custom ordering.
-        if not self.prefs.column_order:
-            return self.column_widths.items()
+        if self.prefs.column_order:
+            # Get ordered_columns
+            columns = copy.copy(self.column_widths)
+            ordered_columns = {}
 
-        # Get ordered_columns
-        columns = copy.copy(self.column_widths)
-        ordered_columns = {}
+            for column_name in self.prefs.column_order:
+                # If valid column name
+                if column_name in columns:
+                    ordered_columns[column_name] = columns.pop(column_name)
 
-        for column_name in self.prefs.column_order:
-            # If valid column name
-            if column_name in columns:
-                ordered_columns[column_name] = columns.pop(column_name)
-        # NOTE: Any remaining columns not specified by the user are not shown.
-        # Perhaps a user setting could add them at the end. To add them in:
-        if not self.prefs.omit_unspecified_columns:
-            for column_name in columns:
-                ordered_columns[column_name] = columns[column_name]
+            # Add remaining columns unless user preference to hide them.
+            if not self.prefs.omit_unspecified_columns:
+                for column_name in columns:
+                    ordered_columns[column_name] = columns[column_name]
+        else:
+            ordered_columns = copy.copy(self.column_widths)
+
+        if not self.prefs.show_python_file and 'py_file' in ordered_columns:
+            del ordered_columns['py_file']
+        if not self.prefs.show_python_logger and 'py_logger' in ordered_columns:
+            del ordered_columns['py_logger']
+
         return ordered_columns.items()
 
     def update_metadata_column_widths(self, log: LogLine):
diff --git a/pw_console/py/pw_console/window_manager.py b/pw_console/py/pw_console/window_manager.py
index 3fe48a3..5cd5721 100644
--- a/pw_console/py/pw_console/window_manager.py
+++ b/pw_console/py/pw_console/window_manager.py
@@ -413,7 +413,8 @@
             self.window_lists[column_index].display_mode = DisplayMode.STACK
 
             # Add windows to the this column (window_list)
-            for window_title, window_options in windows.items():
+            for window_title, window_dict in windows.items():
+                window_options = window_dict if window_dict else {}
                 new_pane = None
                 desired_window_title = window_title
                 # Check for duplicate_of: Title value
diff --git a/pw_console/py/table_test.py b/pw_console/py/table_test.py
index a6325d5..06343d8 100644
--- a/pw_console/py/table_test.py
+++ b/pw_console/py/table_test.py
@@ -108,14 +108,18 @@
         table = TableView(self.prefs)
         for log in logs:
             table.update_metadata_column_widths(log)
+            metadata_fields = {
+                k: v
+                for k, v in log.metadata.fields.items()
+                if k not in ['py_file', 'py_logger']
+            }
             # update_metadata_column_widths shoulp populate self.metadata.fields
-            self.assertEqual(log.metadata.fields,
-                             log.record.extra_metadata_fields)
+            self.assertEqual(metadata_fields, log.record.extra_metadata_fields)
         # Check expected column widths
         results = {
             k: v
             for k, v in dict(table.column_widths).items()
-            if k not in ['time', 'level']
+            if k not in ['time', 'level', 'py_file', 'py_logger']
         }
         self.assertCountEqual(expected_widths, results)
 
@@ -160,6 +164,7 @@
     def test_formatted_header(self, _name, logs, expected_headers) -> None:
         """Test colum widths calculation."""
         table = TableView(self.prefs)
+
         for log, header in zip(logs, expected_headers):
             table.update_metadata_column_widths(log)
             self.assertEqual(table.formatted_header(), header)