Skip to content

Commit

Permalink
Added logging.
Browse files Browse the repository at this point in the history
  • Loading branch information
Alan Fleming committed Oct 29, 2024
1 parent 118f448 commit 0f0edab
Show file tree
Hide file tree
Showing 11 changed files with 226 additions and 28 deletions.
18 changes: 10 additions & 8 deletions examples/menu.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
"metadata": {},
"outputs": [],
"source": [
"import ipylab\n",
"import ipywidgets as ipw\n",
"\n",
"import ipylab\n",
"\n",
"app = ipylab.App()"
]
},
Expand All @@ -54,7 +56,7 @@
"outputs": [],
"source": [
"menu = t.result()\n",
"app.main_menu.add_menu(menu, rank=1000)"
"app.main_menu.add_menu(menu)"
]
},
{
Expand All @@ -78,12 +80,12 @@
"outputs": [],
"source": [
"async def populate_menu(menu):\n",
" menu.add_item(command=\"help:about\")\n",
" menu.add_item(type=\"separator\")\n",
" await menu.add_item(command=\"help:about\")\n",
" await menu.add_item(type=\"separator\")\n",
" submenu = await app.main_menu.create_menu(\"My submenu\")\n",
" submenu.add_item(command=\"notebook:create-console\")\n",
" menu.add_item(submenu=submenu, type=\"submenu\")\n",
" menu.add_item(command=\"logconsole:open\")\n",
" await submenu.add_item(command=\"notebook:create-console\")\n",
" await menu.add_item(submenu=submenu, type=\"submenu\")\n",
" await menu.add_item(command=\"logconsole:open\")\n",
"\n",
" # Open it\n",
" await app.main_menu.set_property(\"activeMenu\", ipylab.pack(menu), toObject=[\"value\"])\n",
Expand Down Expand Up @@ -180,7 +182,7 @@
"source": [
"Reload the page (F5) ignoring any warnings.\n",
"\n",
"The panel that was in the shell and the menus should have been restored. \n",
"The panel that was in the shell and the menus should have been restored.\n",
"\n",
"Note: May require a per-kernel widget manager. See Readme for details on installation."
]
Expand Down
6 changes: 3 additions & 3 deletions ipylab/_compat/enum.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
import sys

if sys.version_info < (3, 11):
from backports.strenum import StrEnum
from backports.strenum import IntEnum, StrEnum
else:
from enum import StrEnum
from enum import IntEnum, StrEnum

__all__ = ["StrEnum"]
__all__ = ["StrEnum", "IntEnum"]


def __dir__() -> list[str]:
Expand Down
3 changes: 3 additions & 0 deletions ipylab/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
hookimpl = pluggy.HookimplMarker("ipylab") # Used for plugins

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from typing import TypeVar, overload

from traitlets import HasTraits
Expand Down Expand Up @@ -236,5 +237,7 @@ class TaskHooks(TypedDict):
add_to_tuple_fwd: NotRequired[list[tuple[HasTraits, str]]]
add_to_tuple_rev: NotRequired[list[tuple[str, Ipylab]]]

callbacks: NotRequired[list[Callable[[Any], None | Awaitable[None]]]]


TaskHookType = TaskHooks | None
6 changes: 6 additions & 0 deletions ipylab/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
hookspec = pluggy.HookspecMarker("ipylab")

if TYPE_CHECKING:
import logging
from collections.abc import Awaitable, Callable

import ipylab
Expand Down Expand Up @@ -71,6 +72,11 @@ def namespace_objects(objects: dict, namespace_name: str, app: ipylab.App) -> No
You can use this to customise the objects available in the namespace."""


@hookspec(firstresult=True)
def get_logger(obj: App) -> logging.Logger | logging.LoggerAdapter: # type: ignore
"Get the logger for the obj."


@hookspec(firstresult=True)
def on_error(obj: ipylab.Ipylab, source: ErrorSource, error: Exception):
"""
Expand Down
15 changes: 11 additions & 4 deletions ipylab/ipylab.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@
import asyncio
import contextlib
import json
import logging
import traceback
import uuid
import weakref
from typing import TYPE_CHECKING, Any, TypeVar, Unpack

from ipywidgets import Widget, register
from traitlets import Bool, Container, Dict, HasTraits, Instance, Set, TraitError, TraitType, Unicode, observe
from traitlets import Bool, Container, Dict, HasTraits, Instance, Set, TraitError, TraitType, Unicode, default, observe

import ipylab._frontend as _fe
from ipylab.common import ErrorSource, IpylabKwgs, Obj, TaskHookType, Transform, TransformType, pack
from ipylab.log import LogPayloadType, LogTypes

if TYPE_CHECKING:
import logging
from asyncio import Task
from collections.abc import Awaitable, Callable, Hashable
from typing import ClassVar
Expand Down Expand Up @@ -96,8 +97,7 @@ class Ipylab(WidgetBase):
_tasks: Container[set[asyncio.Task]] = Set()
_has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set()
close_extras: Container[weakref.WeakSet[Widget]] = Instance(weakref.WeakSet, (), help="extra items to close") # type: ignore
if TYPE_CHECKING:
log: logging.Logger
log = Instance(logging.Logger)

@classmethod
def _load_plugin_hooks(cls):
Expand Down Expand Up @@ -131,6 +131,10 @@ def repr_info(self) -> dict[str, Any] | str:
"Extra info to provide for __repr__."
return {}

@default("log")
def _log_default(self):
return self.hook.get_logger(obj=self)

def __new__(cls, **kwgs) -> Self:
model_id = kwgs.get("model_id") or cls._single_map.get(cls._single_key(kwgs)) if cls.SINGLE else None
if model_id and model_id in cls._single_models:
Expand Down Expand Up @@ -354,6 +358,9 @@ def send(self, content, buffers=None):
self.on_error(ErrorSource.SendError, e)
raise e from None

def send_log_message(self, log: LogPayloadType):
self.send({"log": LogTypes.parse(log)})

def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookType = None) -> Task[T]:
"""Run aw in a task.
Expand Down
11 changes: 9 additions & 2 deletions ipylab/jupyterfrontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import ipywidgets
from IPython.core.getipython import get_ipython
from ipywidgets import TypedTuple, Widget, register
from traitlets import Bool, Container, Dict, Instance, Tuple, Unicode, observe
from traitlets import Bool, Container, Dict, Instance, Tuple, Unicode, UseEnum, observe

import ipylab
import ipylab.hookspecs
Expand All @@ -21,6 +21,7 @@
from ipylab.dialog import Dialog
from ipylab.ipylab import IpylabBase
from ipylab.launcher import Launcher
from ipylab.log import LogLevel
from ipylab.menu import ContextMenu, MainMenu
from ipylab.notification import NotificationManager
from ipylab.sessions import SessionManager
Expand Down Expand Up @@ -51,6 +52,7 @@ class App(Ipylab):
ipylab_base = IpylabBase(Obj.IpylabModel, "app").tag(sync=True)
version = Unicode(read_only=True).tag(sync=True)
current_widget_id = Unicode(read_only=True).tag(sync=True)
logger_level = UseEnum(LogLevel, read_only=True, default_value=LogLevel.warning).tag(sync=True)
current_session = Dict(read_only=True).tag(sync=True)
all_sessions = Tuple(read_only=True).tag(sync=True)
vpath = Unicode(read_only=True).tag(sync=True)
Expand Down Expand Up @@ -84,7 +86,6 @@ def __init_subclass__(cls, **kwargs) -> None:
def __init__(self, **kwgs):
if self._async_widget_base_init_complete:
return

if vpath := kwgs.pop("vpath", None):
self.set_trait("vpath", vpath)
self.set_trait("is_ipylab_kernel", vpath == "ipylab")
Expand All @@ -101,6 +102,7 @@ def _app_observe_ready(self, _):
self.hook.autostart._call_history.clear() # type: ignore # noqa: SLF001
self.hook.autostart.call_historic(kwargs={"app": self}, result_callback=self._autostart_callback)


def _autostart_callback(self, result):
self.hook._ensure_run(obj=self, aw=result) # noqa: SLF001

Expand Down Expand Up @@ -210,6 +212,11 @@ def open_console(
coro = self._open_console(args, objects=objects or {}, namespace_name=namespace_name, **kwgs)
return self.to_task(coro, "Open console")

def toggle_log_console(self) -> Task[ShellConnection]:
# How can we check if the log console is open?
return self.commands.execute("logconsole:open", {"source": self.vpath})


def shutdown_kernel(self, vpath: str | None = None):
"""Shutdown the kernel"""
return self.operation("shutdownKernel", vpath=vpath)
Expand Down
38 changes: 31 additions & 7 deletions ipylab/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
from __future__ import annotations

import inspect
import traceback
import logging
from asyncio import Task
from typing import TYPE_CHECKING, Any

from ipywidgets import Widget

import ipylab
from ipylab.common import ErrorSource, IpylabKwgs, TaskHooks, hookimpl
from ipylab.ipylab import Ipylab
from ipylab.log import IpylabFormatter, IpylabLogHandler
from ipylab.notification import NotifyAction

objects = {}

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
Expand All @@ -26,19 +31,35 @@ def trait_tuple_add(owner: HasTraits, name: str, value: Any):
if value not in items:
owner.set_trait(name, (*items, value))


@hookimpl
def start_app(vpath: str):
"Default implementation of App and Plugins"
ipylab.App(vpath=vpath)

@hookimpl
def get_logger(obj: App):
if "logger" not in objects:
# Use a common logger and setup the handler here too.
objects["logger"] = logging.getLogger("ipylab")
handler = IpylabLogHandler(obj.app)
fmt = "{name}:{message}"
handler.setFormatter(IpylabFormatter(fmt, style="{"))
objects["logger"].addHandler(handler)
return objects["logger"]


@hookimpl
def on_error(obj: Ipylab, source: ErrorSource, error: Exception):
# TODO: Better error logging. Probably using the log.
msg = f"{error!r}"
obj.log.error(msg)
obj.app.dialog.show_error_message(str(source), str(traceback.format_tb(error.__traceback__)))
msg = f"{source} {error}"
obj.log.exception(msg, extra={"source": source, "error": error})
task = objects.get("error_task")
if isinstance(task, Task):
# Try to minimize the number of notifications.
if not task.done():
return
task.result().close()
a = NotifyAction(label="📝", caption="Toggle log console", callback=obj.app.toggle_log_console, keep_open=True)
objects["error_task"] = obj.app.notification.notify(msg, type=ipylab.NotificationType.error, actions=[a])


@hookimpl
Expand All @@ -59,7 +80,7 @@ def _ensure_run(obj: ipylab.Ipylab, aw: Callable | Awaitable | None):


@hookimpl
def task_result(obj: Ipylab, result: HasTraits, hooks: TaskHooks): # noqa: ARG001
def task_result(obj: Ipylab, result: HasTraits, hooks: TaskHooks):
# close with
for owner in hooks.pop("close_with_fwd", ()):
# Close result with each item.
Expand Down Expand Up @@ -101,6 +122,9 @@ def task_result(obj: Ipylab, result: HasTraits, hooks: TaskHooks): # noqa: ARG0
else:
owner.set_trait(name, result)

for cb in hooks.pop("callbacks", ()):
_ensure_run(obj, cb(result))

if hooks:
msg = f"Invalid hooks detected: {hooks}"
raise ValueError(msg)
Expand Down
110 changes: 110 additions & 0 deletions ipylab/log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Copyright (c) ipylab contributors.
# Distributed under the terms of the Modified BSD License.

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any, Literal

from ipylab._compat.enum import StrEnum
from ipylab._compat.typing import TypedDict

if TYPE_CHECKING:
from ipywidgets import Widget

from ipylab import App

__all__ = ["LogLevel", "LogTypes", "LogPayloadType", "LogPayloadText", "LogPayloadHtml", "LogPayloadOutput"]

class LogLevel(StrEnum):
"The log levels available in Jupyterlab"
debug = "debug"
info = "info"
warning = "warning"
error = "error"
critical = "critical"

@classmethod
def to_numeric(cls, value: LogLevel | int):
return log_name_mappings[LogLevel(value)]

@classmethod
def to_level(cls, val: LogLevel | int) -> LogLevel:
if isinstance(val, int):
if val >= log_name_mappings[LogLevel.critical]:
return LogLevel.critical
if val >= log_name_mappings[LogLevel.error]:
return LogLevel.error
if val >= log_name_mappings[LogLevel.warning]:
return LogLevel.warning
if val >= log_name_mappings[LogLevel.info]:
return LogLevel.info
return LogLevel.debug
return LogLevel(val)


log_name_mappings = {
LogLevel.debug: 10,
LogLevel.info: 20,
LogLevel.warning: 30,
LogLevel.error: 40,
LogLevel.critical: 50,
}

class LogTypes(StrEnum):
text = "text"
html = "html"
output = "output"

@classmethod
def parse(cls, log):
level = LogLevel.to_level(log["level"])
match LogTypes(log["type"]):
case LogTypes.text:
return LogPayloadText(type=LogTypes.text, level=level, data=log["data"])
case LogTypes.html:
return LogPayloadHtml(type=LogTypes.html, level=level, data=log["data"])
case LogTypes.output:
raise NotImplementedError
return LogPayloadOutput(type=LogTypes.output, level=LogLevel(log["level"]), data=log["data"])


class LogPayloadBase(TypedDict):
type: LogTypes
level: LogLevel | int
data: Any


class LogPayloadText(LogPayloadBase):
type: Literal[LogTypes.text]
data: str


class LogPayloadHtml(LogPayloadText):
type: Literal[LogTypes.html]


class LogPayloadOutput(LogPayloadBase):
type: Literal[LogTypes.output]
data: Widget


LogPayloadType = LogPayloadBase | LogPayloadText | LogPayloadHtml | LogPayloadOutput


class IpylabLogHandler(logging.Handler):
def __init__(self, app: App) -> None:
self.app = app
self.app.observe(self._observe_app_log_level, "logger_level")
super().__init__(LogLevel.to_numeric(self.app.logger_level))

def _observe_app_log_level(self, change: dict):
self.setLevel(LogLevel.to_numeric(change["new"]))

def emit(self, record):
log = LogPayloadText(type=LogTypes.text, level=LogLevel.to_level(record.levelno), data=self.format(record))
self.app.send_log_message(log)


class IpylabFormatter(logging.Formatter):
pass
Loading

0 comments on commit 0f0edab

Please sign in to comment.