Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a custom log handler #6900

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions napari/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from lazy_loader import attach as _attach

from napari.utils._logging import _get_custom_log_stream

try:
from napari._version import version as __version__
except ImportError:
Expand Down Expand Up @@ -42,6 +44,8 @@
'viewer': ['Viewer', 'current_viewer'],
}

_LOG_STREAM = _get_custom_log_stream()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that custom log handler should be initialized in napari.qt.qt_event_loop, same as notification manager.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the logger should work regardless of qt right? Also headless it would be useful.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand, it is log handler, not logger. The current code means, that each time one import napari it starts storing all logs in memory, even without a clean way to show them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes... but isn't that what we want? Would be good to be able to get these logs programmatically as well. We can also discard old logs if we go above a certain size.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My personal feeling is that we should do this, like with notifications (warnigs), but I may be wrong.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do what? I'm not sure I follow ^^'


# All imports in __init__ are hidden inside of `__getattr__` to prevent
# importing the full chain of packages required when calling `import napari`.
#
Expand Down
6 changes: 6 additions & 0 deletions napari/_qt/_qapp_model/qactions/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ def _show_about(window: Window):
],
status_tip=trans._('About napari'),
),
Action(
id='napari.window.help.log',
title=trans._('Show log'),
callback=Window._open_log_dialog,
menus=[{'id': MenuId.MENUBAR_HELP, 'group': MenuGroup.RENDER}],
),
]

if ask_opt_in is not None:
Expand Down
58 changes: 58 additions & 0 deletions napari/_qt/dialogs/log_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QTextEdit,
QVBoxLayout,
)

from napari import _LOG_STREAM
from napari._qt.dialogs.qt_about import QtCopyToClipboardButton
from napari.utils.translations import trans


class LogDialog(QDialog):
def __init__(
self,
parent=None,
) -> None:
super().__init__(parent._qt_window)

self.layout = QVBoxLayout()

# Description
title_label = QLabel(trans._('napari log'))
title_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self.layout.addWidget(title_label)

# Add information
self.infoTextBox = QTextEdit()
self.infoTextBox.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse
)
self.infoTextBox.setLineWrapMode(QTextEdit.NoWrap)
# Add text copy button
self.infoCopyButton = QtCopyToClipboardButton(self.infoTextBox)
self.info_layout = QHBoxLayout()
self.info_layout.addWidget(self.infoTextBox, 1)
self.info_layout.addWidget(
self.infoCopyButton, 0, Qt.AlignmentFlag.AlignTop
)
self.info_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.layout.addLayout(self.info_layout)

self.infoTextBox.setText(str(_LOG_STREAM))
self.infoTextBox.setMinimumSize(
int(self.infoTextBox.document().size().width() + 19),
int(min(self.infoTextBox.document().size().height() + 10, 500)),
)

self.setLayout(self.layout)

self.setObjectName('LogDialog')
self.setWindowTitle(trans._('napari log'))
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.exec_()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exec should not be called in constructor, but in calling place.

5 changes: 5 additions & 0 deletions napari/_qt/qt_main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,11 @@ def _screenshot_dialog(self):
if dial.exec_():
update_save_history(dial.selectedFiles()[0])

def _open_log_dialog(self):
from napari._qt.dialogs.log_dialog import LogDialog

LogDialog(parent=self)


def _instantiate_dock_widget(wdg_cls, viewer: 'Viewer'):
# if the signature is looking a for a napari viewer, pass it.
Expand Down
49 changes: 49 additions & 0 deletions napari/utils/_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import logging

# keep track of all the logging info in a stream
_LOG_SEPARATOR = '<NAPARI_LOG_SEPARATOR>'


class _LogHandler:
def __init__(self):
self.logs = []

def write(self, log_msg):
level_name, time, thread, msg = log_msg.split(_LOG_SEPARATOR)
levels = logging.getLevelNamesMapping()
level_value = levels[level_name]
self.logs.append((level_value, level_name, time, thread, msg))

def flush(self):
pass

def __str__(self):
return ''.join(
[
f'[{time}] ({thread}) {level_name}: {msg}'
for _, level_name, time, thread, msg in self.logs
]
)

def logs_at_level(self, level=logging.DEBUG):
if isinstance(level, str):
level = logging.getLevelName(level)
return [
(level_value, *others)
for level_value, *others in self.logs
if level_value >= level
]


def _get_custom_log_stream():
log_stream = _LogHandler()
handler = logging.StreamHandler(log_stream)
handler.setFormatter(
logging.Formatter(
f'%(levelname)s{_LOG_SEPARATOR}%(asctime)s{_LOG_SEPARATOR}%(threadName)s{_LOG_SEPARATOR}%(message)s'
)
)
logger = logging.getLogger('napari')
logger.setLevel(logging.DEBUG)
logger.addHandler(handler)
Comment on lines +46 to +48
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be nice to connect to all loggers and be able to filter messages based on logger (so someone could filter logs only from a given plugin logger.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True... I'm not sure how to do that though; I guess we somehow get the root logger? And btw, is there a way to get these logging parameters (level, thread, etc) "raw" instead of formatting to string and then de-formatting?

return log_stream
Loading