From 312b3380fe630c30a17e675c04bf1ee3f79e3087 Mon Sep 17 00:00:00 2001 From: Alan Fleming <> Date: Fri, 22 Nov 2024 15:46:25 +1100 Subject: [PATCH] Switch back from DocumentWidget to MainAreaWidget. Added AutoScroll, SimpleOutput and logging. --- examples/simple_output.ipynb | 328 +++++++++++++++++++++++++++++++++ ipylab/common.py | 10 +- ipylab/hookspecs.py | 7 + ipylab/ipylab.py | 70 +++---- ipylab/jupyterfrontend.py | 46 +++-- ipylab/lib.py | 11 +- ipylab/log.py | 343 +++++++++-------------------------- ipylab/log_viewer.py | 173 ++++++++++++++++++ ipylab/shell.py | 14 +- ipylab/simple_output.py | 117 ++++++++++++ package.json | 1 - src/plugin.ts | 10 +- src/widget.ts | 8 +- src/widgets/autoscroll.ts | 177 ++++++++++++++++++ src/widgets/frontend.ts | 124 ++++--------- src/widgets/ipylab.ts | 19 +- src/widgets/shell.ts | 14 +- src/widgets/simple_output.ts | 195 ++++++++++++++++++++ style/widget.css | 2 +- yarn.lock | 3 +- 20 files changed, 1200 insertions(+), 472 deletions(-) create mode 100644 examples/simple_output.ipynb create mode 100644 ipylab/log_viewer.py create mode 100644 ipylab/simple_output.py create mode 100644 src/widgets/autoscroll.ts create mode 100644 src/widgets/simple_output.ts diff --git a/examples/simple_output.ipynb b/examples/simple_output.ipynb new file mode 100644 index 0000000..8164844 --- /dev/null +++ b/examples/simple_output.ipynb @@ -0,0 +1,328 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "**To use this notebook:** Run one line at a time waiting for each cell to return before running the next cell." + ] + }, + { + "cell_type": "markdown", + "id": "1", + "metadata": { + "jp-MarkdownHeadingCollapsed": true + }, + "source": [ + "## Simple Output\n", + "\n", + "SimpleOutput is a widget that provides an output area to display all types of output. \n", + "\n", + "It is designed to minimise the size of messages and/or number of messages sent to the frontend. It is not supposed to be a drop in replacement for the Ipywidget `Output' widget, rather it provides an alternate type of interface.\n", + "\n", + "Compared to the Ipywidgets `Output` maintains a synchronised model of all loaded outputs. Each item added to `SimpleOutput` is serialized and sent to the frontend. There is no representation of the data left on the Python side meaning that `SimpleOutput` is more suitable for logging applications. " + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Methods\n", + "\n", + "There are two methods to add outputs \n", + "1. `push`\n", + "2. `set`\n", + "\n", + "and one '`clear`' to clear the outputs.\n" + ] + }, + { + "cell_type": "markdown", + "id": "3", + "metadata": {}, + "source": [ + "### `push`\n", + "\n", + "`push` serializes and sends data as a custom message which is appended to the existing output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4", + "metadata": {}, + "outputs": [], + "source": [ + "import ipylab\n", + "from ipylab.simple_output import SimpleOutput" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "so = SimpleOutput(layout={\"max_height\": \"200px\"})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "for i in range(50):\n", + " so.push(f\"test {i}\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "so" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8", + "metadata": {}, + "outputs": [], + "source": [ + "# or we could do it with one message\n", + "so.clear()\n", + "so.push(*(f\"test {i}\\n\" for i in range(100)))\n", + "# Not that we've just sent 100 'outputs' in one message.\n", + "# All of the 100 lines are squashed into the 200px high simple output." + ] + }, + { + "cell_type": "markdown", + "id": "9", + "metadata": {}, + "source": [ + "### set\n", + "\n", + "`Set` is similar to push, but is run as task and clears the output prior at adding the new outputs. The task returns the number of outputs in use." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "10", + "metadata": {}, + "outputs": [], + "source": [ + "t = so.set(\"Line one\\n\", \"Line two\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "so" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12", + "metadata": {}, + "outputs": [], + "source": [ + "assert so.length == t.result() # noqa: S101\n", + "so.length" + ] + }, + { + "cell_type": "markdown", + "id": "13", + "metadata": {}, + "source": [ + "## max_continuous_streams\n", + "\n", + "Notice that above the length is 1 even though we sent two values? \n", + "\n", + "This is because both items are streams, and by default they get put into the same output in the frontend. \n", + "\n", + "The maximum number of consecutive streams is configurable with `max_continuous_streams`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "14", + "metadata": {}, + "outputs": [], + "source": [ + "# Make each stream go into a new output.\n", + "so.max_continuous_streams = 0" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "15", + "metadata": {}, + "outputs": [], + "source": [ + "t = so.set(\"Line one\\n\", \"Line two\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "16", + "metadata": {}, + "outputs": [], + "source": [ + "assert so.length == t.result() # noqa: S101\n", + "so.length" + ] + }, + { + "cell_type": "markdown", + "id": "17", + "metadata": {}, + "source": [ + "# AutoScroll\n", + "\n", + "AutoScroll is a widget that provides automatic scrolling around a content widget. It is intended to be used in panels placed in the shell, and doesn't work correctly when used in notebooks.\n", + "\n", + "**Note**\n", + "\n", + "Autoscroll uses a relatively new feature `onscrollend` ([detail](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollend_event)) and **may not work well on Safari** for fast update rates." + ] + }, + { + "cell_type": "markdown", + "id": "18", + "metadata": {}, + "source": [ + "## Builtin log viewer\n", + "\n", + "The built in log viewer uses the AutoScroll widget to scroll its output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19", + "metadata": {}, + "outputs": [], + "source": [ + "app = ipylab.app\n", + "\n", + "app.log_viewer.add_to_shell()\n", + "app.log_level = \"INFO\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20", + "metadata": {}, + "outputs": [], + "source": [ + "for _ in range(100):\n", + " app.log.info(\"Demo\")\n", + " app.log.error(\"Demo\")" + ] + }, + { + "cell_type": "markdown", + "id": "21", + "metadata": {}, + "source": [ + "## Example usage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "22", + "metadata": {}, + "outputs": [], + "source": [ + "from datetime import datetime\n", + "\n", + "import ipywidgets as ipw\n", + "\n", + "import ipylab\n", + "from ipylab.simple_output import AutoScroll" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "23", + "metadata": {}, + "outputs": [], + "source": [ + "vb = ipw.VBox()\n", + "sw = AutoScroll(content=vb)\n", + "sw_holder = ipw.VBox([sw], layout={\"height\": \"200px\", \"border\": \"solid\"})\n", + "\n", + "enabled = ipw.Checkbox(description=\"Auto scroll\", indent=False)\n", + "ipw.link((sw, \"enabled\"), (enabled, \"value\"))\n", + "sleep = ipw.FloatSlider(description=\"Sleep time (s)\", value=0.3, min=0.05, max=1, step=0.01)\n", + "\n", + "b = ipw.Button(description=\"Start\")\n", + "\n", + "\n", + "def on_click(b):\n", + " if b.description == \"Start\":\n", + " import asyncio\n", + "\n", + " async def generate_output():\n", + " while True:\n", + " vb.children = (*vb.children, ipw.HTML(f\"It is now {datetime.now().isoformat()}\")) # noqa: DTZ005\n", + " await asyncio.sleep(sleep.value)\n", + "\n", + " b.task = ipylab.app.to_task(generate_output())\n", + " b.description = \"Stop\"\n", + " else:\n", + " b.task.cancel()\n", + " b.description = \"Start\"\n", + "\n", + "\n", + "b.on_click(on_click)\n", + "\n", + "p = ipylab.Panel([ipw.HBox([enabled, sleep, b], layout={\"justify_content\": \"center\"}), sw_holder])\n", + "p.add_to_shell(mode=ipylab.InsertMode.split_right)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/ipylab/common.py b/ipylab/common.py index 284cb5f..32b8e5b 100644 --- a/ipylab/common.py +++ b/ipylab/common.py @@ -19,6 +19,8 @@ hookimpl = pluggy.HookimplMarker("ipylab") # Used for plugins +SVGSTR_TEST_TUBE = ' ' + if TYPE_CHECKING: from collections.abc import Awaitable, Callable from typing import TypeVar, overload @@ -240,11 +242,3 @@ def trait_tuple_add(owner: HasTraits, name: str, value: Any): items = getattr(owner, name) if value not in items: owner.set_trait(name, (*items, value)) - - -def truncated_repr(obj: Any, maxlen=40, tail="…") -> str: - "Do truncated string representation of obj." - rep = repr(obj) - if len(rep) > maxlen: - return rep[0 : maxlen - len(tail)] + tail - return rep diff --git a/ipylab/hookspecs.py b/ipylab/hookspecs.py index e96da2d..d0effb4 100644 --- a/ipylab/hookspecs.py +++ b/ipylab/hookspecs.py @@ -14,6 +14,8 @@ import ipylab from ipylab.common import IpylabKwgs + from ipylab.log import IpylabLogHandler + from ipylab.log_viewer import LogViewer @hookspec(firstresult=True) @@ -89,3 +91,8 @@ def vpath_getter(app: ipylab.App, kwgs: dict) -> Awaitable[str] | str: # type: vpath is the 'virtual path' for a session. """ + + +@hookspec(firstresult=True) +def get_log_viewer(app: ipylab.App, handler: IpylabLogHandler) -> LogViewer: # type: ignore + """Create a new instance of a logViewer.""" diff --git a/ipylab/ipylab.py b/ipylab/ipylab.py index 22dbfd7..98a57ef 100644 --- a/ipylab/ipylab.py +++ b/ipylab/ipylab.py @@ -11,30 +11,18 @@ from typing import TYPE_CHECKING, Any, Generic, TypeVar from ipywidgets import Widget, register -from traitlets import Bool, Container, Dict, HasTraits, Set, TraitError, TraitType, Unicode, default, observe +from traitlets import Bool, Container, Dict, HasTraits, Instance, Set, TraitError, TraitType, Unicode, default, observe import ipylab import ipylab._frontend as _fe -from ipylab.common import ( - IpylabKwgs, - Obj, - TaskHooks, - TaskHookType, - Transform, - TransformType, - pack, - trait_tuple_add, - truncated_repr, -) -from ipylab.log import LogLevel +from ipylab.common import IpylabKwgs, Obj, TaskHooks, TaskHookType, Transform, TransformType, pack, trait_tuple_add +from ipylab.log import IpylabLoggerAdapter if TYPE_CHECKING: from asyncio import Task from collections.abc import Awaitable, Callable, Hashable from typing import ClassVar, Self, Unpack - from ipylab.log import LogPayloadType - __all__ = ["Ipylab", "WidgetBase", "Readonly"] @@ -147,6 +135,7 @@ class Ipylab(WidgetBase): _tasks: Container[set[asyncio.Task]] = Set() _has_attrs_mappings: Container[set[tuple[HasTraits, str]]] = Set() close_extras: Readonly[weakref.WeakSet[Widget]] = Readonly(weakref.WeakSet) + log = Instance(IpylabLoggerAdapter, read_only=True) @classmethod def _single_key(cls, kwgs: dict) -> Hashable: # noqa: ARG003 @@ -160,7 +149,7 @@ def repr_info(self) -> dict[str, Any] | str: @default("log") def _default_log(self): - return ipylab.app.log + return IpylabLoggerAdapter("ipylab", owner=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 @@ -246,15 +235,12 @@ async def _wrap_awaitable(self, aw: Awaitable[T], hooks: TaskHookType) -> T: result = await aw try: self._task_result(result, hooks) - except Exception as e: - obj = {"result": result, "hooks": hooks, "aw": aw} - self.log_object(LogLevel.error, "TaskHook error", error=e, obj=obj) - raise e from None - except Exception as e: - try: - self.log_object(LogLevel.error, "Task error", error=e, obj=aw) - finally: - raise e + except Exception: + self.log.exception("TaskHook error", obj={"result": result, "hooks": hooks, "aw": aw}) + raise + except Exception: + self.log.exception("Task error", obj=aw) + raise else: return result @@ -323,14 +309,14 @@ def _on_custom_msg(self, _, msg: str, buffers: list): self.close() else: raise NotImplementedError(msg) # noqa: TRY301 - except Exception as e: - self.log_object(LogLevel.error, "Message processing error {obj}", error=e, obj=msg) + except Exception: + self.log.exception("Message processing error", obj=msg) def _to_frontend_error(self, content): error = content["error"] operation = content.get("operation") if operation: - msg = f'{truncated_repr(self, 40)} operation "{operation}" failed with the message "{error}"' + msg = f'Operation "{operation}" failed with the message "{error}"' return IpylabFrontendError(msg) return IpylabFrontendError(error) @@ -346,9 +332,8 @@ async def _do_operation_for_fe(self, ipylab_FE: str, operation: str, payload: di content["payload"] = result except asyncio.CancelledError: content["error"] = "Cancelled" - except Exception as e: - content["error"] = str(e) - self.log_object(LogLevel.error, "Operation for frontend error", error=e) + except Exception: + self.log.exception("Operation for frontend error", obj={"operation": operation, "payload": payload}) finally: self.send(content, buffers) @@ -378,8 +363,8 @@ def ensure_run(self, aw: Callable | Awaitable | None) -> None: aw = aw() if inspect.iscoroutine(aw): self.to_task(aw, f"Ensure run {aw}") - except Exception as e: - self.log_object(LogLevel.error, "Ensure run", error=e) + except Exception: + self.log.exception("Ensure run", obj=aw) raise async def ready(self): @@ -394,16 +379,6 @@ def on_ready(self, callback, remove=False): # noqa: FBT002 else: self._on_ready_callbacks.add(callback) - def log_object(self, level: LogLevel, msg: str = "", *, error: BaseException | None = None, obj: Any = None): - "Pass a message to have it logged mapped to obj." - match LogLevel(level): - case LogLevel.error: - self.log.exception(msg, extra={"owner": self, "obj": obj}, exc_info=error) - case LogLevel.critical: - self.log.exception(msg, extra={"owner": self, "obj": obj}, exc_info=error) - case _: - getattr(self.log, level)(msg, extra={"owner": self, "obj": obj}) - def add_to_tuple(self, owner: HasTraits, name: str): """Add self to the tuple of obj.""" @@ -423,12 +398,9 @@ def add_as_trait(self, obj: HasTraits, name: str): def send(self, content, buffers=None): try: super().send(json.dumps(content, default=pack), buffers) - except Exception as e: - self.log_object(LogLevel.error, "Send error", error=e) - raise e from None - - def send_log_message(self, log: LogPayloadType): - self.send({"log": log}) + except Exception: + self.log.exception("Send error", obj=content) + raise def to_task(self, aw: Awaitable[T], name: str | None = None, *, hooks: TaskHookType = None) -> Task[T]: """Run aw in a task. diff --git a/ipylab/jupyterfrontend.py b/ipylab/jupyterfrontend.py index 9c6b7d0..113d1b1 100644 --- a/ipylab/jupyterfrontend.py +++ b/ipylab/jupyterfrontend.py @@ -5,7 +5,6 @@ import functools import inspect -import logging from collections import OrderedDict from typing import TYPE_CHECKING, Any, Unpack @@ -16,7 +15,7 @@ import ipylab import ipylab.hookspecs -from ipylab import Ipylab, ShellConnection, Transform +from ipylab import Ipylab, ShellConnection, Transform, log from ipylab._compat.typing import override from ipylab.commands import APP_COMMANDS_NAME, CommandPalette, CommandRegistry from ipylab.common import InsertMode, IpylabKwgs, Obj, to_selector @@ -28,11 +27,14 @@ from ipylab.notification import NotificationManager from ipylab.sessions import SessionManager from ipylab.shell import Shell +from ipylab.widgets import Panel if TYPE_CHECKING: from asyncio import Task from typing import ClassVar + from ipylab.log_viewer import LogViewer + class LastUpdatedDict(OrderedDict): "Store items in the order the keys were last added" @@ -53,7 +55,6 @@ class App(Ipylab): _model_name = Unicode("JupyterFrontEndModel").tag(sync=True) ipylab_base = IpylabBase(Obj.IpylabModel, "app").tag(sync=True) version = Unicode(read_only=True).tag(sync=True) - logger_level = UseEnum(LogLevel, read_only=True, default_value=LogLevel.warning).tag(sync=True) vpath = Unicode(read_only=True).tag(sync=True) per_kernel_widget_manager_detected = Bool(read_only=True).tag(sync=True) @@ -69,9 +70,10 @@ class App(Ipylab): console = Instance(ShellConnection, allow_none=True, read_only=True) logging_handler = Instance(IpylabLogHandler, read_only=True) + log_viewer: Instance[LogViewer] = Instance(Panel, Readonly=True) # type: ignore + log_level = UseEnum(LogLevel, LogLevel.ERROR) active_namespace = Unicode("", read_only=True, help="name of the current namespace") - selector = Unicode("", read_only=True, help="Selector class for context menus (css)") namespaces: Container[dict[str, LastUpdatedDict]] = Dict(read_only=True) # type: ignore @@ -86,27 +88,27 @@ def _single_key(cls, kwgs: dict): # noqa: ARG003 def close(self): "Cannot close" - @default("log") - def _default_log(self): - log = logging.getLogger("ipylab") - self.logging_handler.set_as_handler(log) - return log - @default("logging_handler") def _default_logging_handler(self): - handler = IpylabLogHandler() - fmt = "{owner} {obj} {message}" - handler.setFormatter(IpylabLogFormatter(fmt, style="{")) + fmt = "{color}{asctime}.{msecs:0<3.0f} {name} {owner_rep}:{reset} {message}\n" + handler = IpylabLogHandler(self.log_level) + handler.setFormatter(IpylabLogFormatter(fmt=fmt, style="{", datefmt="%H:%M:%S", colors=log.COLORS)) return handler - @observe("_ready") - def _app_observe_ready(self, _): - if self._ready: - self.set_trait("selector", to_selector(self.vpath)) + @default("log_viewer") + def _default_log_viewer(self): + return ipylab.plugin_manager.hook.get_log_viewer(app=self, handler=self.logging_handler) + + @observe("_ready", "log_level") + def _app_observe_ready(self, change): + if change["name"] == "_ready" and self._ready: + assert self.vpath, "Vpath should always before '_ready'." # noqa: S101 + self._selector = to_selector(self.vpath) ipylab.plugin_manager.hook.autostart._call_history.clear() # type: ignore # noqa: SLF001 ipylab.plugin_manager.hook.autostart.call_historic( kwargs={"app": self}, result_callback=self._autostart_callback ) + self.logging_handler.setLevel(self.log_level) def _autostart_callback(self, result): self.ensure_run(result) @@ -115,6 +117,16 @@ def _autostart_callback(self, result): def repr_info(self): return {"vpath": self.vpath} + @property + def repr_log(self): + "A representation to use when logging" + return self.__class__.__name__ + + @property + def selector(self): + # Calling this before `_ready` is set will raise an attribute error. + return self._selector + @override async def ready(self): if not self._ready_event._value: # type: ignore # noqa: SLF001 diff --git a/ipylab/lib.py b/ipylab/lib.py index 35b832e..3b7176d 100644 --- a/ipylab/lib.py +++ b/ipylab/lib.py @@ -13,6 +13,7 @@ import ipylab from ipylab import App from ipylab.ipylab import Ipylab + from ipylab.log import IpylabLogHandler @hookimpl @@ -31,7 +32,8 @@ async def autostart(app: ipylab.App) -> None | Awaitable[None]: # Register some default context menu items for Ipylab cmd = await app.commands.add_command("Open console", app._context_open_console) # noqa: SLF001 await app.context_menu.add_item(command=cmd, rank=20) - await app.context_menu.add_item(command="logconsole:open", args={"source": app.vpath}, rank=21) + cmd = await app.commands.add_command("Show log viewer", lambda: app.log_viewer.add_to_shell()) + await app.context_menu.add_item(command=cmd, rank=21) @hookimpl @@ -47,3 +49,10 @@ def vpath_getter(app: App, kwgs: dict) -> Awaitable[str] | str: @hookimpl def ready(obj: Ipylab): "Pass through" + + +@hookimpl +def get_log_viewer(app: App, handler: IpylabLogHandler): # type: ignore + from ipylab.log_viewer import LogViewer + + return LogViewer(app, handler) diff --git a/ipylab/log.py b/ipylab/log.py index 6357b58..1382e25 100644 --- a/ipylab/log.py +++ b/ipylab/log.py @@ -3,173 +3,125 @@ from __future__ import annotations -import collections import logging import weakref -from asyncio import Task -from datetime import datetime -from enum import StrEnum -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from enum import IntEnum +from typing import TYPE_CHECKING, Any, ClassVar -from ipywidgets import HTML, Button, Combobox, HBox, Output, Select, dlink +from IPython.utils.coloransi import TermColors +from ipywidgets import CallbackDispatcher import ipylab -from ipylab.common import truncated_repr +from ipylab._compat.typing import override if TYPE_CHECKING: - from typing import ClassVar + from asyncio import Task from ipylab.ipylab import Ipylab -__all__ = [ - "LogLevel", - "LogTypes", - "LogPayloadType", - "LogPayloadText", - "LogPayloadHtml", - "LogPayloadOutput", - "IpylabLogHandler", - "log_name_mappings", - "show_obj_log_panel", -] +__all__ = ["LogLevel", "IpylabLogHandler"] -objects = {} -log_objects = collections.deque(maxlen=1000) +class LogLevel(IntEnum): + CRITICAL = 50 + ERROR = 40 + WARNING = 30 + INFO = 20 + DEBUG = 10 -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, +grey = "\x1b[38;20m" +yellow = "\x1b[33;20m" +ret = "\x1b[31;20m" +bold_red = "\x1b[31;1m" +reset = TermColors.Normal +COLORS = { + LogLevel.DEBUG: TermColors.LightGray, + LogLevel.INFO: TermColors.Cyan, + LogLevel.WARNING: TermColors.LightRed, + LogLevel.ERROR: TermColors.Red, + LogLevel.CRITICAL: TermColors.Purple, } -class LogTypes(StrEnum): - text = "text" - html = "html" - output = "output" - - -class OutputBase(TypedDict): - output_type: Literal["update_display_data", "error", "stream"] - - -class OutputDisplayData(OutputBase): - output_type: Literal["update_display_data"] - data: dict[str, str | dict] # mime-type keyed dictionary of data - - -class OutputStream(OutputBase): - output_type: Literal["stream"] - type: Literal["stdout", "stderr"] - text: str - - -class OutputError(OutputBase): - output_type: Literal["error"] - ename: str - evalue: str - traceback: list[str] | None - - -OutputTypes = OutputDisplayData | OutputStream | OutputError - - -class LogPayloadBase(TypedDict): - type: LogTypes - level: LogLevel | int - data: Any - - -class LogPayloadText(LogPayloadBase): - type: Literal[LogTypes.text] - data: str - +def truncated_repr(obj: Any, maxlen=120, tail="…") -> str: + "Do truncated string representation of obj." -class LogPayloadHtml(LogPayloadText): - type: Literal[LogTypes.html] + rep = obj.repr_log if hasattr(obj, "repr_log") else repr(obj) + if len(rep) > maxlen: + return rep[0 : maxlen - len(tail)] + tail + return rep -class LogPayloadOutput(LogPayloadBase): - type: Literal[LogTypes.output] - data: OutputTypes +class IpylabLoggerAdapter(logging.LoggerAdapter): + def __init__(self, name: str, owner: Ipylab) -> None: + logger = logging.getLogger(name) + logger.addHandler(ipylab.app.logging_handler) + ipylab.app.logging_handler._add_logger(logger) # noqa: SLF001 + super().__init__(logger) + self.owner_ref = weakref.ref(owner) - -LogPayloadType = LogPayloadBase | LogPayloadText | LogPayloadHtml | LogPayloadOutput + def process(self, msg: Any, kwargs: dict[str, Any]) -> tuple[Any, dict[str, Any]]: + obj = kwargs.pop("obj", None) + kwargs["extra"] = {"owner": self.owner_ref(), "obj": obj} + return msg, kwargs class IpylabLogHandler(logging.Handler): + _log_notify_task: Task | None = None _loggers: ClassVar[weakref.WeakSet[logging.Logger]] = weakref.WeakSet() - def __init__(self) -> None: - ipylab.app.observe(self._observe_app_log_level, "logger_level") - super().__init__(LogLevel.to_numeric(ipylab.app.logger_level)) + def __init__(self, level: LogLevel) -> None: + super().__init__(level) + self._callbacks = CallbackDispatcher() + + def _add_logger(self, logger: logging.Logger): + if logger not in self._loggers: + logger.setLevel(self.level) + self._loggers.add(logger) + logger.addHandler(self) - def _observe_app_log_level(self, change: dict): - level = LogLevel.to_numeric(change["new"]) - self.setLevel(level) - for log in self._loggers: - log.setLevel(level) + @override + def setLevel(self, level: LogLevel) -> None: # noqa: N802 + level = LogLevel(level) + super().setLevel(level) + for logger in self._loggers: + logger.setLevel(level) def emit(self, record): - msg = self.format(record) - type_ = "stdout" if record.levelno < log_name_mappings[LogLevel.error] else "stderr" - data = OutputStream(output_type="stream", type=type_, text=msg) - log = LogPayloadOutput(type=LogTypes.output, level=LogLevel.to_level(record.levelno), data=data) - ipylab.app.send_log_message(log) + std_ = "stderr" if record.levelno >= LogLevel.ERROR else "stdout" + record.output = {"output_type": "stream", "name": std_, "text": self.format(record)} + if std_ == "stderr": + ipylab.app.log_viewer # Touch for a log_viewer # noqa: B018 + self._callbacks(record) + + def register_callback(self, callback, *, remove=False): + """Register a callback for when a record is emitted. - def set_as_handler(self, log: logging.Logger): - "Set this handler as a handler for log and keep the level in sync." - if log not in self._loggers: - log.addHandler(self) - log.setLevel(self.level) - self._loggers.add(log) + The callback will be called with one argument, the record. + + Parameters + ---------- + remove: bool (optional) + Set to true to remove the callback from the list of callbacks. + """ + self._callbacks.register_callback(callback, remove=remove) class IpylabLogFormatter(logging.Formatter): + def __init__(self, *, colors: dict[LogLevel, str], reset=reset, **kwargs) -> None: + """Initialize the formatter with specified format strings.""" + self.colors = COLORS | colors + self.reset = reset + super().__init__(**kwargs) + def format(self, record: logging.LogRecord) -> str: - # In addition to formatting, we also make the objects available for the viewer. - # And substitute the 'owner' and - owner = getattr(record, "owner", None) - obj = getattr(record, "obj", None) - record.owner = truncated_repr(owner, 60) - record.obj = truncated_repr(obj, 80) - msg = super().format(record).strip() - show = bool(record.exc_info) - if obj or show: - notify_log_message(owner, obj, msg, show=bool(record.exc_info)) - return msg + record.color = self.colors[LogLevel(record.levelno)] + record.reset = self.reset + record.owner_rep = truncated_repr(getattr(record, "owner", ""), 120) + record.obj_rep = truncated_repr(getattr(record, "obj", ""), 120) + return super().format(record) def formatException(self, ei) -> str: # noqa: N802 (etype, value, tb) = ei @@ -177,131 +129,6 @@ def formatException(self, ei) -> str: # noqa: N802 if not sh: return super().formatException(ei) itb = sh.InteractiveTB - itb.verbose if ipylab.app.logging_handler.level <= log_name_mappings[LogLevel.debug] else itb.minimal + itb.verbose if ipylab.app.logging_handler.level == LogLevel.DEBUG else itb.minimal # noqa: B018 stb = itb.structured_traceback(etype, value, tb) # type: ignore return itb.stb2text(stb) - - -def notify_log_message(owner: ipylab.Ipylab | None, obj: Any, msg: str, *, show=False): - "Create a notification that an error occurred." - - msg_short = datetime.now().strftime("%H:%M:%S") + " " + msg.split("\n", maxsplit=1)[0] # noqa: DTZ005 - log_objects.appendleft((msg_short, msg, owner, obj)) - task = objects.get("log_notify_task") - if isinstance(task, Task): - # Limit to one notification. - if not task.done(): - return - task.result().close() - if show or not objects.get("log objects"): - objects["log_notify_task"] = ipylab.app.notification.notify( - msg_short, - type=ipylab.NotificationType.error, - actions=[ - ipylab.NotifyAction( - label="📄", caption="Show object log panel.", callback=show_obj_log_panel, keep_open=True - ) - ], - ) - - -def show_obj_log_panel(): - "Show a panel that maps a log message to an object." - key = "log panel" - if key not in objects: - - class ObjViewer(ipylab.Panel): - "A simple object viewer to map the message to the object and can put the object in the console." - - def __init__(self): - self.vpath = HTML( - f"Vpath: {ipylab.app.vpath}", - tooltip="Use this panel to access the log console from either:\n" - "1. The icon in the status bar, or,\n" - "2. The context menu (right click).\n" - "The controls below are provided to put an object into a console.\n" - "Note: Jupyterlab loads a different 'log console' for the 'console'.", - ) - self.select_objs = Select( - tooltip="Most recent exception is last", - layout={"flex": "2 1 auto", "width": "auto", "height": "max-content"}, - ) - self.search = Combobox( - placeholder="Search", - tooltip="Search for a log entry or object.", - ensure_option=True, - layout={"width": "auto"}, - ) - self.button_add_to_console = Button( - description="Send object and owner to console", - tooltip="Add the object and owner to the console. as obj and owner respectively" - "\nTip: use the context menu of this pane to access log console.", - layout={"width": "50%", "min_width": "content"}, - ) - self.button_refresh = Button( - description="Refresh", - tooltip="Refresh list of objects", - layout={"width": "auto"}, - ) - self.box_controls = HBox( - [self.vpath, self.button_add_to_console, self.button_refresh], - layout={"flex": "0 0 auto", "justify_content": "space-between", "height": "max-content"}, - ) - layout = {"justify_content": "center", "padding": "20px"} - self.title.label = "Object log panel" - self.out = Output(layout={"flex": "1 0 auto"}) - super().__init__( - children=[self.box_controls, self.select_objs, self.search, self.out], - layout=layout, - ) - - dlink((self.select_objs, "options"), (self.search, "options")) - dlink( - (self.select_objs, "value"), - (self.button_add_to_console, "disabled"), - transform=lambda value: not value, - ) - self.button_add_to_console.on_click(self.on_click) - self.button_refresh.on_click(self.on_click) - self.select_objs.observe(self._observe, "value") - self.search.observe(self._observe, "value") - - def _observe(self, change: dict): - if change["owner"] is self.select_objs: - with self.out.hold_trait_notifications(): - self.out.clear_output(wait=True) - self.out.outputs = () - row = self.row - self.out.append_stdout(row[1]) - if owner := row[2]: - self.out.append_display_data(owner) - if obj := row[3]: - self.out.append_display_data(obj) - if change["owner"] is self.search and self.search.value: - self.select_objs.value = self.search.value - self.search.value = "" - - def on_click(self, b: Button): - if b is self.button_add_to_console and self.select_objs.index is not None: - objs = {"owner": self.row[2], "obj": self.row[3]} - ipylab.app.open_console(objects=objs, namespace_name=ipylab.app.active_namespace) - if b is self.button_refresh: - self.show() - - @property - def row(self) -> tuple[str, str, Ipylab | None, Any]: - if self.select_objs.index is None: - return "No selection", "", None, None - return self.data[self.select_objs.index] - - def show(self): - self.data = tuple(log_objects) - self.select_objs.value = None - self.select_objs.options = tuple(v[0] for v in self.data) - if self.select_objs: - self.select_objs.value = self.select_objs.options[0] - self.add_to_shell(mode=ipylab.InsertMode.split_bottom) - - objects[key] = viewer = ObjViewer() - viewer.observe(lambda _: objects.pop(key) if objects.get(key) is viewer else None, "comm") - objects[key].show() diff --git a/ipylab/log_viewer.py b/ipylab/log_viewer.py new file mode 100644 index 0000000..f776d1a --- /dev/null +++ b/ipylab/log_viewer.py @@ -0,0 +1,173 @@ +# Copyright (c) ipylab contributors. +# Distributed under the terms of the Modified BSD License. + +from __future__ import annotations + +import collections +from typing import TYPE_CHECKING + +from ipywidgets import HTML, BoundedIntText, Button, Checkbox, Combobox, Dropdown, HBox, Output, Select, VBox +from traitlets import directional_link, link + +import ipylab +from ipylab.common import SVGSTR_TEST_TUBE, Area, InsertMode +from ipylab.log import IpylabLogHandler, LogLevel +from ipylab.simple_output import AutoScroll, SimpleOutput +from ipylab.widgets import Icon, Panel + +if TYPE_CHECKING: + from asyncio import Task + + from ipylab.connection import ShellConnection + from ipylab.jupyterfrontend import App + + +class LogViewer(Panel): + "A log viewer and an object viewer combined." + + _log_notify_task: None | Task = None + _updating = False + + def __init__(self, app: App, handler: IpylabLogHandler, buffersize=100): + self.info = HTML( + f"Vpath: {app.vpath}", + tooltip="Use this panel to access the log console from either:\n" + "1. The icon in the status bar, or,\n" + "2. The context menu (right click).\n" + "The controls below are provided to put an object into a console.\n" + "Note: Jupyterlab loads a different 'log console' for the 'console'.", + layout={"flex": "1 0 auto", "margin": "0px 20px 0px 20px"}, + ) + options = [(v.name.capitalize(), v) for v in LogLevel] + self.log_level = Dropdown( + description="Level", + value=app.log_level, + options=options, + layout={"width": "max-content"}, + ) + self.buffer_size = BoundedIntText( + description="Buffer size", + value=buffersize, + min=1, + max=1e6, + layout={"width": "max-content"}, + ) + self.button_show_send_dialog = Button( + description="📪", + tooltip="Send the record to the console", + layout={"width": "auto"}, + ) + self.autoscroll_enabled = Checkbox(description="Automatic scroll", layout={"width": "auto"}) + children = [self.info, self.log_level, self.autoscroll_enabled, self.buffer_size, self.button_show_send_dialog] + self.box_controls = HBox(children, layout={"justify_content": "space-between", "flex": "0 0 auto"}) + self.output = SimpleOutput() + self.autoscroll_widget = AutoScroll(content=self.output) + + self.title.label = f"Log: {app.vpath}" + self.title.icon = Icon(name="ipylab-test_tube", svgstr=SVGSTR_TEST_TUBE) + self._records = collections.deque(maxlen=buffersize) + + super().__init__(children=[self.box_controls, self.autoscroll_widget]) + + self.buffer_size.observe(self._observe_buffer_size, "value") + handler.register_callback(self._add_record) + link((self.autoscroll_widget, "enabled"), (self.autoscroll_enabled, "value")) + link((app, "log_level"), (self.log_level, "value")) + link((self.buffer_size, "value"), (self.output, "max_outputs")) + directional_link( + (self.output, "length"), (self.buffer_size, "tooltip"), transform=lambda size: f"Current size: {size}" + ) + self.button_show_send_dialog.on_click(self._button_on_click) + + def close(self): + "Cannot close" + + def _add_record(self, record): + self._records.append(record) + self.output.push(record.output) + + def _notify_exception(self, msg: str): + "Create a notification that an error occurred." + + msg_short = msg.split("\n", maxsplit=1)[0] + if self._log_notify_task: + # Limit to one notification. + if not self._log_notify_task.done(): + return + self._log_notify_task.result().close() + self._log_notify_task = ipylab.app.notification.notify( + msg_short, + type=ipylab.NotificationType.error, + actions=[ + ipylab.NotifyAction( + label="📄", + caption="Show object log panel.", + callback=self.add_to_shell, + keep_open=True, + ) + ], + ) + + def _observe_buffer_size(self, change): + if change["owner"] is self.buffer_size: + self._records = collections.deque(self._records, maxlen=self.buffer_size.value) + + def _button_on_click(self, _): + self.button_show_send_dialog.disabled = True + ipylab.app.dialog.to_task( + self._show_send_dialog(), + hooks={"callbacks": [lambda _: self.button_show_send_dialog.set_trait("disabled", False)]}, + ) + + async def _show_send_dialog(self): + # TODO: make a formatter to simplify the message with obj and owner) + options = {r.msg: r for r in reversed(self._records)} # type: ignore + select = Select( + tooltip="Most recent exception is first", + layout={"flex": "2 1 auto", "width": "auto", "height": "max-content"}, + options=options, + ) + search = Combobox( + placeholder="Search", + tooltip="Search for a log entry or object.", + ensure_option=True, + layout={"width": "auto"}, + options=tuple(options), + ) + output = Output() + body = VBox([select, search, output]) + + def observe(change: dict): + if change["owner"] is select: + body.children = [select, search, select.value] if select.value else [select, search] + elif change["owner"] is search and change["new"] in options: + select.value = options[change["new"]] + + select.observe(observe, "value") + search.observe(observe, "value") + try: + await ipylab.app.dialog.show_dialog("Send record to console", body=body) + if select.value: + await ipylab.app.open_console( + objects={"record": select.value.record}, namespace_name=ipylab.app.active_namespace + ) + except Exception: + return + finally: + for w in [search, body, select]: + w.close() + + def add_to_shell( + self, + *, + area: ipylab.Area = Area.main, + activate: bool = True, + mode: ipylab.InsertMode = InsertMode.split_bottom, + rank: int | None = None, + ref: ipylab.ShellConnection | None = None, + options: dict | None = None, + **kwgs, + ) -> Task[ShellConnection]: + return super().add_to_shell( + area=area, activate=activate, mode=mode, rank=rank, ref=ref, options=options, **kwgs + ) diff --git a/ipylab/shell.py b/ipylab/shell.py index dfccd45..f5c894a 100644 --- a/ipylab/shell.py +++ b/ipylab/shell.py @@ -25,15 +25,7 @@ class Shell(Ipylab): - """ - Provides access to the shell. - The minimal interface is: - https://jupyterlab.readthedocs.io/en/latest/api/interfaces/application.App.IShell.html - - Likely the full labShell interface. - - ref: https://jupyterlab.readthedocs.io/en/latest/api/interfaces/application.App.IShell.html#add - """ + """Provides access to the shell.""" SINGLE = True @@ -92,8 +84,8 @@ def add( "ref": f"{pack(ref)}.id" if isinstance(ref, ShellConnection) else None, } | (options or {}) args["area"] = area - if "asDocument" not in args: - args["asDocument"] = area in [Area.left, Area.main, Area.right, Area.down] + if "asMainArea" not in args: + args["asMainArea"] = area in [Area.left, Area.main, Area.right, Area.down] if isinstance(obj, ShellConnection): if "cid" in args and args["cid"] != obj.cid: msg = f"The provided {args['cid']=} does not match {obj.cid=}" diff --git a/ipylab/simple_output.py b/ipylab/simple_output.py new file mode 100644 index 0000000..c4c710b --- /dev/null +++ b/ipylab/simple_output.py @@ -0,0 +1,117 @@ +# Copyright (c) ipylab contributors. +# Distributed under the terms of the Modified BSD License. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ipywidgets import DOMWidget, Widget, register +from traitlets import Bool, Enum, Int, Unicode + +import ipylab +from ipylab.ipylab import Ipylab + +if TYPE_CHECKING: + from asyncio import Task + from typing import Unpack + + from ipylab.common import IpylabKwgs +from typing import TYPE_CHECKING + +from ipywidgets import widget_serialization +from traitlets import Instance, observe + +from ipylab.ipylab import WidgetBase + +if TYPE_CHECKING: + from asyncio import Task + from typing import Unpack + + +@register +class SimpleOutput(DOMWidget, Ipylab): + """An output with no prompts designed to accept frequent additions. + + The interface differs from Ipywidgets.Output widget in almost every way. + + Note: Automatic widget restoration is not implemented. + """ + + _model_name = Unicode("SimpleOutputModel").tag(sync=True) + _view_name = Unicode("SimpleOutputView").tag(sync=True) + max_outputs = Int(100, help="The maximum number of individual widgets.").tag(sync=True) + max_continuous_streams = Int(100, help="Max streams to put in same output.").tag(sync=True) + length = Int(read_only=True, help="The current length of the output area").tag(sync=True) + + invalid_data_mode = Enum(["raise", "skip"], "raise", help="What to do with invalid output") + + def _pack_outputs(self, outputs: tuple[dict[str, str] | Widget | str, ...]): + outputs_ = [] + for output in outputs: + if hasattr(output, "_repr_mimebundle_"): + if not callable(output._repr_mimebundle_): # type: ignore + if self.invalid_data_mode == "raise": + msg = f"Invalid data {output}" + raise TypeError(msg) + continue + outputs_.append(output._repr_mimebundle_()) # type: ignore + if isinstance(output, str): + outputs_.append({"output_type": "stream", "name": "stdout", "text": output}) + elif isinstance(output, dict): + outputs_.append(output) + else: + data, metadata = ipylab.app._ipy_shell.display_formatter.format(output) # type: ignore # noqa: SLF001 + outputs_.append({"output_type": "display_data", "data": data, "metadata": metadata}) + + return outputs_ + + def push(self, *outputs: dict[str, str] | Widget | str): + """Add one or more items to the output. + Consecutive `streams` of the same type are placed in the same 'output'. + Outputs passed as dicts are assumed to be correctly packed as `repr_mime` data. + """ + if outputs_ := self._pack_outputs(outputs): + self.send({"add": outputs_}) + + def clear(self, *, wait=True): + "Clear the output" + self.send({"clear": wait}) + + def set(self, *outputs: dict[str, str] | Widget | str, **kwgs: Unpack[IpylabKwgs]) -> Task[int]: + """Set the output explicitly by first clearing and then adding the outputs.""" + return self.operation("setOutputs", {"outputs": self._pack_outputs(outputs)}, **kwgs) + + +@register +class AutoScroll(DOMWidget, WidgetBase): + """An automatic scrolling container. + + The content can be changed and the scrolling enabled and disabled on the fly. + + Fast scrolling depends on `onscrollend`. Presently supported by common browsers except for Safari. + https://developer.mozilla.org/en-US/docs/Web/API/Document/scrollend_event + """ + + _model_name = Unicode("AutoscrollModel").tag(sync=True) + _view_name = Unicode("AutoscrollView").tag(sync=True) + + content = Instance(Widget, (), allow_none=True).tag(sync=True, **widget_serialization) + + enabled = Bool().tag(sync=True) + mode = Enum(["start", "end"], "end").tag(sync=True) + sentinel_text = Unicode(help="Provided for debugging purposes").tag(sync=True) + + @observe("enabled") + def _observe(self, _): + layout = self.layout + with layout.hold_trait_notifications(): + if self.enabled: + layout.overflow = "hidden" + layout.height = "100%" + else: + layout.overflow = "auto" + layout.height = "auto" + + def __init__(self, *, enabled=True, **kwargs): + self.enabled = enabled + super().__init__(**kwargs) diff --git a/package.json b/package.json index 058227f..8a9aa5a 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,6 @@ "@jupyterlab/coreutils": "^6.2.5", "@jupyterlab/filebrowser": "^4.2.5", "@jupyterlab/launcher": "^4.2.5", - "@jupyterlab/logconsole": "^4.2.5", "@jupyterlab/mainmenu": "^4.2.5", "@jupyterlab/observables": "^5.2.5", "@jupyterlab/rendermime": "^4.2.5", diff --git a/src/plugin.ts b/src/plugin.ts index 3d4ee78..e91e10f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -11,7 +11,6 @@ import { import { ICommandPalette } from '@jupyterlab/apputils'; import { IDefaultFileBrowser } from '@jupyterlab/filebrowser'; import { ILauncher } from '@jupyterlab/launcher'; -import { ILoggerRegistry } from '@jupyterlab/logconsole'; import { IMainMenu } from '@jupyterlab/mainmenu'; import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; @@ -26,8 +25,6 @@ const PLUGIN_ID = 'ipylab:settings'; namespace CommandIDs { export const restore = 'ipylab:restore'; export const checkStartKernel = 'ipylab:check-start-kernel'; - export const openConsole = 'ipylab:open-console'; - export const toggleLogConsole = 'ipylab:toggle-log-console'; } /** @@ -44,8 +41,7 @@ const extension: JupyterFrontEndPlugin = { IDefaultFileBrowser, ILauncher, ITranslator, - IMainMenu, - ILoggerRegistry + IMainMenu ], activate: async ( app: JupyterFrontEnd, @@ -58,8 +54,7 @@ const extension: JupyterFrontEndPlugin = { defaultBrowser: IDefaultFileBrowser | null, launcher: ILauncher | null, translator: ITranslator | null, - mainMenu: IMainMenu | null, - loggerRegistry: ILoggerRegistry | null + mainMenu: IMainMenu | null ) => { // add globals const exports = await import('./widget'); @@ -72,7 +67,6 @@ const extension: JupyterFrontEndPlugin = { exports.IpylabModel.translator = translator; exports.IpylabModel.launcher = launcher; exports.IpylabModel.mainMenu = mainMenu; - exports.JupyterFrontEndModel.loggerRegistry = loggerRegistry; registry.registerWidget({ name: MODULE_NAME, diff --git a/src/widget.ts b/src/widget.ts index 2abf3cf..ea417ab 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -9,10 +9,12 @@ import { IconModel, IconView } from './widgets/icon'; import { IpylabModel } from './widgets/ipylab'; import { NotificationManagerModel } from './widgets/notification'; import { PanelModel, PanelView } from './widgets/panel'; +import { AutoscrollModel, AutoscrollView } from './widgets/autoscroll'; +import { SessionManagerModel } from './widgets/sessions'; import { ShellModel } from './widgets/shell'; import { SplitPanelModel, SplitPanelView } from './widgets/split_panel'; import { TitleModel } from './widgets/title'; -import { SessionManagerModel } from './widgets/sessions'; +import { SimpleOutputModel, SimpleOutputView } from './widgets/simple_output'; export { CommandRegistryModel, ConnectionModel, @@ -24,6 +26,10 @@ export { NotificationManagerModel, PanelModel, PanelView, + AutoscrollModel, + AutoscrollView, + SimpleOutputModel, + SimpleOutputView, SessionManagerModel, ShellConnectionModel, ShellModel, diff --git a/src/widgets/autoscroll.ts b/src/widgets/autoscroll.ts new file mode 100644 index 0000000..5743111 --- /dev/null +++ b/src/widgets/autoscroll.ts @@ -0,0 +1,177 @@ +// Copyright (c) ipylab contributors +// Distributed under the terms of the Modified BSD License. +import { DOMWidgetModel, DOMWidgetView } from '@jupyter-widgets/base'; +import { Message } from '@lumino/messaging'; +import { PanelLayout, Widget } from '@lumino/widgets'; +import $ from 'jquery'; +import { MODULE_NAME, MODULE_VERSION } from '../version'; +import { IpylabModel } from './ipylab'; + +/** + * + * Inspiration: @jupyterlab/logconsole -> ScrollingWidget + * + * Implements a panel which autoscrolls the content. + */ +export class AutoScroll extends Widget { + constructor({ content, ...options }: AutoScroll.IOptions) { + super(options); + this._view = options.view; + this.layout = new PanelLayout(); + this._sentinel = document.createElement('div'); + this._view.model.on('change:content', this.loadContent, this); + this.loadContent(); + } + + /** + * The content widget. + */ + get content() { + return this._content; + } + + set content(content: Widget | undefined) { + if (this._content === content) { + return; + } + if (this._content) { + this.layout.removeWidget(this._content); + } + this._content = content; + if (content) { + (this.layout as PanelLayout).addWidget(content); + } + } + + async loadContent() { + const id = this._view.model.get('content'); + this.content = id ? await IpylabModel.toLuminoWidget({ id }) : undefined; + this.update(); + } + + scrollOnce() { + if (this.enabled) { + this._sentinel.scrollIntoView({ behavior: 'instant' as ScrollBehavior }); + } + } + + private _disconnectObserver() { + if (this._observer) { + this._observer.disconnect(); + delete this._observer; + } + } + + update(): void { + this._disconnectObserver(); + if (this.node.contains(this._sentinel)) { + this.node.removeChild(this._sentinel); + } + this.enabled = this._view.model.get('enabled'); + if (!this.enabled) { + (this.node as any).onscrollend = null; + return; + } + this._sentinel.textContent = this._view.model.get('sentinel_text') || ''; + + if (this._view.model.get('mode') === 'start') { + this.node.prepend(this._sentinel); + } else { + this.node.appendChild(this._sentinel); + } + (this.node as any).onscrollend = (event: Event) => { + if (this.enabled) { + this.scrollOnce(); + } + }; + + this._observer = new IntersectionObserver( + args => { + if (!args[0].isIntersecting) { + this.scrollOnce(); + } + }, + { root: this.node } + ); + this._observer.observe(this._sentinel); + this.scrollOnce(); + } + + processMessage(msg: Message): void { + super.processMessage(msg); + this._view?.processLuminoMessage(msg); + } + /** + * Dispose the widget. + * + * This causes the view to be destroyed as well with 'remove' + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._disconnectObserver(); + super.dispose(); + this._view?.model?.off('change:content', this.loadContent, this); + this._view?.remove(); + this._view = null!; + } + enabled: boolean; + private _content?: Widget; + private _observer: IntersectionObserver | null = null; + private _sentinel: HTMLDivElement; + private _view: DOMWidgetView; +} + +export namespace AutoScroll { + export interface IOptions extends Widget.IOptions { + content?: T; + view: DOMWidgetView; + } +} + +/** + * The model for a logger. + */ +export class AutoscrollModel extends DOMWidgetModel { + /** + * The default attributes. + */ + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + _model_name: 'AutoscrollModel', + _model_module: MODULE_NAME, + _model_module_version: MODULE_VERSION, + _view_name: 'AutoscrollView', + _view_module: MODULE_NAME, + _view_module_version: MODULE_VERSION + }; + } +} + +/** + * The view for a AutoScroll. + */ + +export class AutoscrollView extends DOMWidgetView { + _createElement(tagName: string): HTMLElement { + this.luminoWidget = new AutoScroll({ + view: this + }); + return this.luminoWidget.node; + } + + _setElement(el: HTMLElement): void { + this.el = this.luminoWidget.node; + this.$el = $(this.luminoWidget.node); + } + + update(options?: any): void { + super.update(); + this.luminoWidget.update(); + } + + model: AutoscrollModel; + luminoWidget: AutoScroll; +} diff --git a/src/widgets/frontend.ts b/src/widgets/frontend.ts index 3d551b2..978bac5 100644 --- a/src/widgets/frontend.ts +++ b/src/widgets/frontend.ts @@ -3,10 +3,8 @@ import { KernelWidgetManager } from '@jupyter-widgets/jupyterlab-manager'; import { SessionContext, SessionContextDialogs } from '@jupyterlab/apputils'; -import { ILogger, ILoggerRegistry, IStateChange } from '@jupyterlab/logconsole'; import { Kernel } from '@jupyterlab/services'; import { PromiseDelegate } from '@lumino/coreutils'; -import { Signal } from '@lumino/signaling'; import { IpylabModel } from './ipylab'; /** @@ -23,57 +21,27 @@ export class JupyterFrontEndModel extends IpylabModel { initialize(attributes: any, options: any): void { super.initialize(attributes, options); this.kernelId = this.kernel.id; - this.logger = JFEM.loggerRegistry.getLogger(this.vpath); - this.logger.stateChanged.connect(this.loggerStateChanged as any, this); Private.jfems.set(this.kernel.id, this); } async ipylabInit(base: any = null) { + const vpath = await JFEM.getVpath(this.kernelId); + this.set('vpath', vpath); this.set('version', JFEM.app.version); this.set('per_kernel_widget_manager_detected', JFEM.PER_KERNEL_WM); - this.set('logger_level', this.logger.level); await super.ipylabInit(base); - if (!Private.vpathTojfem.has(this.vpath)) { - Private.vpathTojfem.set(this.vpath, new PromiseDelegate()); + if (!Private.vpathTojfem.has(vpath)) { + Private.vpathTojfem.set(vpath, new PromiseDelegate()); } - Private.vpathTojfem.get(this.vpath).resolve(this); + Private.vpathTojfem.get(vpath).resolve(this); } close(comm_closed?: boolean): Promise { Private.jfems.delete(this.kernelId); - Private.vpathTojfem.delete(this.vpath); - this.logger.stateChanged.disconnect(this.loggerStateChanged as any, this); + Private.vpathTojfem.delete(this.get('vpath')); return super.close(comm_closed); } - get vpath() { - let vpath = this.get('vpath'); - if (!vpath) { - const cs = this.get('current_session'); - if (cs?.kernel?.id === this.kernelId) { - vpath = cs?.path; - } - if (!vpath) { - for (const session of JFEM.sessionManager.running()) { - if (session.kernel.id === this.kernelId) { - vpath = session.path; - break; - } - } - } - this.set('vpath', vpath); - this.save_changes(); - } - return vpath; - } - - private loggerStateChanged(sender: ILogger, change: IStateChange): void { - if (this.get('logger_level') !== this.logger.level) { - this.set('logger_level', this.logger.level); - this.save_changes(); - } - } - async operation(op: string, payload: any): Promise { switch (op) { case 'evaluate': @@ -96,8 +64,27 @@ export class JupyterFrontEndModel extends IpylabModel { } } - static getModelByKernelId(kernelId: string) { - return Private.jfems.get(kernelId); + /** + * Get the vpath for given a kernel id. + */ + static async getVpath(kernelId: string): Promise { + if (Private.kernelIdToVpath.has(kernelId)) { + return Private.kernelIdToVpath.get(kernelId); + } + for (const session of JFEM.sessionManager.running()) { + if (session.kernel.id === kernelId) { + Private.kernelIdToVpath.set(kernelId, session.path); + return session.path; + } + } + await JFEM.sessionManager.refreshRunning(); + for (const session of JFEM.sessionManager.running()) { + if (session.kernel.id === kernelId) { + Private.kernelIdToVpath.set(kernelId, session.path); + return session.path; + } + } + throw new Error(`Failed to determine vpath for kernelId='${kernelId}'`); } /** @@ -128,6 +115,7 @@ export class JupyterFrontEndModel extends IpylabModel { const sessionContext = await JFEM.newSessionContext(vpath); kernel = sessionContext.session.kernel; } + Private.kernelIdToVpath.set(kernel.id, vpath); // Relies on per-kernel widget manager. const getManager = (KernelWidgetManager as any).getManager; const widget_manager: KernelWidgetManager = await getManager(kernel); @@ -227,65 +215,18 @@ export class JupyterFrontEndModel extends IpylabModel { await IpylabModel.JFEM.getModelByVpath('ipylab'); } - /** - * Open a console using vpath for path. - * - * @param args not used. - * @returns - */ - static async openConsole(args: any) { - const currentWidget = JFEM.tracker.currentWidget; - const ipylabSettings = (currentWidget as any)?.ipylabSettings; - const jfem = await JFEM.getModelByVpath(ipylabSettings.vpath); - const payload = { cid: ipylabSettings.cid }; - return await jfem.scheduleOperation('open_console', payload, 'auto'); - } - - /** - * Opening/close the console using vpath. - * - * logconsole:open corresponds to a toggle command, so we the best - * we can do is toggle the console. - * - * @param args not used. - */ - static async toggleLogConsole(args: any) { - const currentWidget = JFEM.tracker.currentWidget; - const ipylabSettings = (currentWidget as any)?.ipylabSettings; - const source = ipylabSettings.vpath; - JFEM.app.commands.execute('logconsole:open', { source }); - } - context = new IpylabContext(this.vpath); kernelId: string; - logger: ILogger; - static loggerRegistry: ILoggerRegistry; } IpylabModel.JFEM = JupyterFrontEndModel; const JFEM = JupyterFrontEndModel; -/** - * Provide minimal context necessary to create a DocumentWidget. - */ -class IpylabContext { - constructor(path: string) { - this.path = path; - // this.contentsModel.path = path; - } - readonly path: string; - ready = new Promise(resolve => resolve(null)); - pathChanged: Signal = new Signal(this); - model: object = { stateChanged: new Signal(this) }; - localPath = ''; - async rename(newName: string) {} -} - /** * A namespace for private data */ namespace Private { /** - * Mapping of vpath to JupyterFrontEndModel + * A mapping of vpath to JupyterFrontEndModel. */ export const vpathTojfem = new Map< string, @@ -293,7 +234,12 @@ namespace Private { >(); /** - * Mapping of kernelId to JupyterFrontEndModel + * A mapping of kernelId to vpath, possibly set before the model is created. + */ + export const kernelIdToVpath = new Map(); + + /** + * A mapping of kernelId to JupyterFrontEndModel. */ export const jfems = new Map(); } diff --git a/src/widgets/ipylab.ts b/src/widgets/ipylab.ts index 16feafd..e27956b 100644 --- a/src/widgets/ipylab.ts +++ b/src/widgets/ipylab.ts @@ -1,7 +1,7 @@ // Copyright (c) ipylab contributors // Distributed under the terms of the Modified BSD License. -import { ICallbacks, ISerializers, WidgetModel } from '@jupyter-widgets/base'; +import { DOMWidgetModel, ICallbacks } from '@jupyter-widgets/base'; import { KernelWidgetManager } from '@jupyter-widgets/jupyterlab-manager'; import { JupyterFrontEnd, LabShell } from '@jupyterlab/application'; import { @@ -37,7 +37,7 @@ import type { ShellModel } from './shell'; * * Subclass as required but can also be used directly. */ -export class IpylabModel extends WidgetModel { +export class IpylabModel extends DOMWidgetModel { /** * The default attributes. */ @@ -225,12 +225,8 @@ export class IpylabModel extends WidgetModel { * * @param msg */ - private async onCustomMessage(msg: any) { - if (typeof msg !== 'string') { - return; - } - const content = JSON.parse(msg); - + protected async onCustomMessage(msg: any) { + const content = typeof msg === 'string' ? JSON.parse(msg) : msg; if (content.ipylab_FE) { // Result of an operation request sent to Python. const op = this._pendingOperations.get(content.ipylab_FE); @@ -252,10 +248,6 @@ export class IpylabModel extends WidgetModel { this.doOperationForPython(content); } else if (content.close) { this.close(true); - } else if (content.log) { - const { log, toLuminoWidget, toObject } = content; - this.replaceParts(log, toLuminoWidget, toObject); - IpylabModel.JFEM.getModelByKernelId(this.kernel.id).logger.log(log); } } @@ -527,9 +519,6 @@ export class IpylabModel extends WidgetModel { return IpylabModel.app.serviceManager.sessions; } - static serializers: ISerializers = { - ...WidgetModel.serializers - }; widget_manager: KernelWidgetManager; private _pendingOperations = new Map>(); readonly base: any; diff --git a/src/widgets/shell.ts b/src/widgets/shell.ts index fea939c..cf4c292 100644 --- a/src/widgets/shell.ts +++ b/src/widgets/shell.ts @@ -1,7 +1,6 @@ // Copyright (c) ipylab contributors // Distributed under the terms of the Modified BSD License. import { MainAreaWidget } from '@jupyterlab/apputils'; -import { DocumentWidget } from '@jupyterlab/docregistry'; import { UUID } from '@lumino/coreutils'; import { Widget } from '@lumino/widgets'; import { IpylabModel } from './ipylab'; @@ -115,19 +114,12 @@ export class ShellModel extends IpylabModel { } args.cid = args.cid || ShellModel.ConnectionModel.new_cid('ShellConnection'); - if (args.asDocument && !(widget instanceof DocumentWidget)) { - widget.addClass('ipylab-Document'); - const jfem = await ShellModel.JFEM.getModelByVpath(args.vpath); - const context = jfem.context as any; - const label = widget?.title?.label || args.vpath; - const w = (widget = new DocumentWidget({ context, content: widget })); + if (args.asMainArea && !(widget instanceof MainAreaWidget)) { + widget.addClass('ipylab-MainArea'); + const w = (widget = new MainAreaWidget({ content: widget })); w.toolbar.dispose(); w.contentHeader.dispose(); w.id = args.cid; - // Disconnect the following callback which overwrites the `title.label`. - w.title.changed.disconnect((w as any)._onTitleChanged, w); - w.title.label = label; - w.title.caption = w.title.caption || `vpath=${args.vpath}`; } ShellModel.ConnectionModel.registerConnection(args.cid, widget); diff --git a/src/widgets/simple_output.ts b/src/widgets/simple_output.ts new file mode 100644 index 0000000..7d5ba3e --- /dev/null +++ b/src/widgets/simple_output.ts @@ -0,0 +1,195 @@ +// Copyright (c) ipylab contributors +// Distributed under the terms of the Modified BSD License. +import { DOMWidgetView } from '@jupyter-widgets/base'; +import * as nbformat from '@jupyterlab/nbformat'; +import { IOutput } from '@jupyterlab/nbformat'; +import { + OutputArea, + OutputAreaModel, + SimplifiedOutputArea +} from '@jupyterlab/outputarea'; +import { IOutputModel } from '@jupyterlab/rendermime'; +import { Message } from '@lumino/messaging'; +import $ from 'jquery'; +import { IpylabModel } from './ipylab'; + +class IpylabOutputAreaModel extends OutputAreaModel { + /** + * Whether a new value should be consolidated with the previous output. + * + * This will only be called if the minimal criteria of both being stream + * messages of the same type. + */ + protected shouldCombine(options: { + value: nbformat.IOutput; + lastModel: IOutputModel; + }): boolean { + if ( + options.value.name === 'stdout' && + this._continuousCount < this.maxContinuous + ) { + this._continuousCount++; + return true; + } else { + this._continuousCount = 0; + } + return false; + } + /** + * Remove oldest outputs to the length limit. + */ + removeOldest(maxLength: number) { + if (this.list.length > maxLength) { + this.list.removeRange(0, this.list.length - maxLength); + } + } + + private _continuousCount = 0; + maxContinuous = 100; +} + +/** + * The model for a panel. + */ +export class SimpleOutputModel extends IpylabModel { + /** + * The default attributes. + */ + defaults(): Backbone.ObjectHash { + return { + ...super.defaults(), + _model_name: 'SimpleOutputModel', + _view_name: 'SimpleOutputView' + }; + } + + setReady(): void { + this.on('change:max_continuous_streams', this.configure, this); + this.on('change:max_outputs', this.configure, this); + this.configure(); + this.outputAreaModel.changed.connect(this._outputAreaModelChange, this); + super.setReady(); + } + + protected async onCustomMessage(msg: any) { + const content = typeof msg === 'string' ? JSON.parse(msg) : msg; + if ('add' in content) { + this.add(content.add); + } else if ('clear' in content) { + this.outputAreaModel.clear(content.clear); + } else { + await super.onCustomMessage(content); + } + } + + async operation(op: string, payload: any): Promise { + switch (op) { + case 'setOutputs': + return await this.setOutputs(payload); + default: + return await super.operation(op, payload); + } + } + + configure() { + this._maxOutputs = this.get('max_outputs'); + this.outputAreaModel.maxContinuous = this.get('max_continuous_streams'); + } + + _outputAreaModelChange() { + const length = this.outputAreaModel.length; + if (this.outputAreaModel.length > this._maxOutputs) { + this.outputAreaModel.removeOldest(this._maxOutputs); + return; + } + if (length !== this.get('length')) { + this.set('length', length); + this.save_changes(); + } + } + + async setOutputs({ outputs }: { outputs: Array }) { + this.outputAreaModel.clear(true); + this.add(outputs); + return this.outputAreaModel.length; + } + + add(items: Array) { + for (const output of items) { + this.outputAreaModel.add(output); + } + } + + close(comm_closed?: boolean) { + this.outputAreaModel.changed.disconnect(this._outputAreaModelChange, this); + this.outputAreaModel.dispose(); + return super.close(comm_closed); + } + private _maxOutputs = 100; + outputAreaModel = new IpylabOutputAreaModel({ trusted: true }); +} + +export class SimpleOutputView extends DOMWidgetView { + _createElement(tagName: string): HTMLElement { + this.luminoWidget = new IpylabSimplifiedOutputArea({ + view: this, + rendermime: IpylabModel.rendermime, + contentFactory: OutputArea.defaultContentFactory, + model: this.model.outputAreaModel, + promptOverlay: false + }); + this.luminoWidget.addClass('jupyter-widgets'); + this.luminoWidget.addClass('jupyter-widget-output'); + return this.luminoWidget.node; + } + + _setElement(el: HTMLElement): void { + if (this.el || el !== this.luminoWidget.node) { + // Boxes don't allow setting the element beyond the initial creation. + throw new Error('Cannot reset the DOM element.'); + } + + this.el = this.luminoWidget.node; + this.$el = $(this.luminoWidget.node); + } + model: SimpleOutputModel; + luminoWidget: IpylabSimplifiedOutputArea; +} + +class IpylabSimplifiedOutputArea extends SimplifiedOutputArea { + constructor( + options: IpylabSimplifiedOutputArea.IOptions & OutputArea.IOptions + ) { + const view = options.view; + delete (options as any).view; + super(options); + this._view = view; + } + + processMessage(msg: Message): void { + super.processMessage(msg); + this._view?.processLuminoMessage(msg); + } + + /** + * Dispose the widget. + * + * This causes the view to be destroyed as well with 'remove' + */ + dispose(): void { + if (this.isDisposed) { + return; + } + super.dispose(); + this._view?.remove(); + this._view = null!; + } + + private _view: SimpleOutputView; +} + +export namespace IpylabSimplifiedOutputArea { + export interface IOptions { + view: SimpleOutputView; + } +} diff --git a/style/widget.css b/style/widget.css index f9c11fa..ecc81d3 100644 --- a/style/widget.css +++ b/style/widget.css @@ -22,7 +22,7 @@ background-color: var(--jp-border-color2); } -.ipylab-Document { +.ipylab-MainArea { height: 100%; width: 100%; } diff --git a/yarn.lock b/yarn.lock index 5c5ee05..c0cbfb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -978,7 +978,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/logconsole@npm:^3.0.0 || ^4.0.0, @jupyterlab/logconsole@npm:^4.2.5": +"@jupyterlab/logconsole@npm:^3.0.0 || ^4.0.0": version: 4.2.5 resolution: "@jupyterlab/logconsole@npm:4.2.5" dependencies: @@ -4170,7 +4170,6 @@ __metadata: "@jupyterlab/coreutils": ^6.2.5 "@jupyterlab/filebrowser": ^4.2.5 "@jupyterlab/launcher": ^4.2.5 - "@jupyterlab/logconsole": ^4.2.5 "@jupyterlab/mainmenu": ^4.2.5 "@jupyterlab/observables": ^5.2.5 "@jupyterlab/rendermime": ^4.2.5