From 433aaafdd269365d1fbc2732496f100f5902925b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 1 Sep 2022 11:11:03 +0100 Subject: [PATCH] new logging --- old examples/animation.py | 2 +- sandbox/buttons.py | 4 +- sandbox/darren/buttons.py | 4 +- sandbox/darren/file_search.py | 4 +- sandbox/input.py | 4 +- sandbox/will/calculator.css | 15 +-- sandbox/will/calculator.py | 150 ++++++++++++++++++++---- sandbox/will/center2.py | 2 +- sandbox/will/scroll.py | 4 +- src/textual/__init__.py | 104 ++++++++++++++-- src/textual/_log.py | 19 +++ src/textual/app.py | 92 +++++++-------- src/textual/cli/cli.py | 5 +- src/textual/devtools/client.py | 15 ++- src/textual/devtools/redirect_output.py | 22 +--- src/textual/devtools/renderables.py | 21 +++- src/textual/devtools/server.py | 8 +- src/textual/devtools/service.py | 23 ++-- src/textual/events.py | 36 +++--- src/textual/message.py | 7 +- src/textual/message_pump.py | 35 +----- src/textual/messages.py | 6 +- 22 files changed, 386 insertions(+), 196 deletions(-) create mode 100644 src/textual/_log.py diff --git a/old examples/animation.py b/old examples/animation.py index 0a6efaf19..1fbff9e9d 100644 --- a/old examples/animation.py +++ b/old examples/animation.py @@ -35,4 +35,4 @@ class SmoothApp(App): # self.set_timer(10, lambda: self.action("quit")) -SmoothApp.run(log_path="textual.log", log_verbosity=2) +SmoothApp.run(log_path="textual.log") diff --git a/sandbox/buttons.py b/sandbox/buttons.py index 4ffd55dd8..883a457e2 100644 --- a/sandbox/buttons.py +++ b/sandbox/buttons.py @@ -23,7 +23,9 @@ class ButtonsApp(App[str]): app = ButtonsApp( - log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=2 + log_path="textual.log", + css_path="buttons.css", + watch_css=True, ) if __name__ == "__main__": diff --git a/sandbox/darren/buttons.py b/sandbox/darren/buttons.py index 4ffd55dd8..883a457e2 100644 --- a/sandbox/darren/buttons.py +++ b/sandbox/darren/buttons.py @@ -23,7 +23,9 @@ class ButtonsApp(App[str]): app = ButtonsApp( - log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=2 + log_path="textual.log", + css_path="buttons.css", + watch_css=True, ) if __name__ == "__main__": diff --git a/sandbox/darren/file_search.py b/sandbox/darren/file_search.py index a1fbcefb1..240e2bec1 100644 --- a/sandbox/darren/file_search.py +++ b/sandbox/darren/file_search.py @@ -62,9 +62,7 @@ class FileSearchApp(App): self.file_table.filter = event.value -app = FileSearchApp( - log_path="textual.log", css_path="file_search.scss", watch_css=True, log_verbosity=2 -) +app = FileSearchApp(log_path="textual.log", css_path="file_search.scss", watch_css=True) if __name__ == "__main__": result = app.run() diff --git a/sandbox/input.py b/sandbox/input.py index 8df51a03d..5cfba8edc 100644 --- a/sandbox/input.py +++ b/sandbox/input.py @@ -61,9 +61,7 @@ class InputApp(App[str]): self.celsius.value = f"{celsius:.1f}" -app = InputApp( - log_path="textual.log", css_path="input.scss", watch_css=True, log_verbosity=2 -) +app = InputApp(log_path="textual.log", css_path="input.scss", watch_css=True) if __name__ == "__main__": result = app.run() diff --git a/sandbox/will/calculator.css b/sandbox/will/calculator.css index 90b1c7617..0d777edfa 100644 --- a/sandbox/will/calculator.css +++ b/sandbox/will/calculator.css @@ -9,8 +9,8 @@ Screen { table-columns: 1fr; table-rows: 2fr 1fr 1fr 1fr 1fr 1fr; margin: 1 2; - min-height:26; - min-width: 50; + min-height:25; + min-width: 26; } Button { @@ -18,18 +18,15 @@ Button { height: 100%; } -.display { +#numbers { column-span: 4; content-align: right middle; padding: 0 1; height: 100%; - background: $panel-darken-2; + background: $primary-lighten-2; + color: $text-primary-lighten-2; } -.special { - tint: $text-panel 20%; -} - -.zero { +#number-0 { column-span: 2; } diff --git a/sandbox/will/calculator.py b/sandbox/will/calculator.py index 44a6c3926..7c06c4d54 100644 --- a/sandbox/will/calculator.py +++ b/sandbox/will/calculator.py @@ -1,35 +1,141 @@ -from textual.app import App +from decimal import Decimal +from textual.app import App, ComposeResult +from textual import events from textual.layout import Container +from textual.reactive import Reactive from textual.widgets import Button, Static class CalculatorApp(App): - def compose(self): + """A working 'desktop' calculator.""" + + numbers = Reactive.var("0") + show_ac = Reactive.var(True) + left = Reactive.var(Decimal("0")) + right = Reactive.var(Decimal("0")) + value = Reactive.var("") + operator = Reactive.var("plus") + + KEY_MAP = { + "+": "plus", + "-": "minus", + ".": "point", + "*": "multiply", + "/": "divide", + "_": "plus-minus", + "%": "percent", + "=": "equals", + } + + def watch_numbers(self, value: str) -> None: + """Called when numbers is updated.""" + # Update the Numbers widget + self.query_one("#numbers", Static).update(value) + + def compute_show_ac(self) -> bool: + """Compute switch to show AC or C button""" + return self.value in ("", "0") and self.numbers == "0" + + def watch_show_ac(self, show_ac: bool) -> None: + """Called when show_ac changes.""" + self.query_one("#c").display = not show_ac + self.query_one("#ac").display = show_ac + + def compose(self) -> ComposeResult: + """Add our buttons.""" yield Container( - Static("0", classes="display"), - Button("AC", classes="special"), - Button("+/-", classes="special"), - Button("%", classes="special"), - Button("÷", variant="warning"), - Button("7"), - Button("8"), - Button("9"), - Button("×", variant="warning"), - Button("4"), - Button("5"), - Button("6"), - Button("-", variant="warning"), - Button("1"), - Button("2"), - Button("3"), - Button("+", variant="warning"), - Button("0", classes="operator zero"), - Button("."), - Button("=", variant="warning"), + Static(id="numbers"), + Button("AC", id="ac", variant="primary"), + Button("C", id="c", variant="primary"), + Button("+/-", id="plus-minus", variant="primary"), + Button("%", id="percent", variant="primary"), + Button("÷", id="divide", variant="warning"), + Button("7", id="number-7"), + Button("8", id="number-8"), + Button("9", id="number-9"), + Button("×", id="multiply", variant="warning"), + Button("4", id="number-4"), + Button("5", id="number-5"), + Button("6", id="number-6"), + Button("-", id="minus", variant="warning"), + Button("1", id="number-1"), + Button("2", id="number-2"), + Button("3", id="number-3"), + Button("+", id="plus", variant="warning"), + Button("0", id="number-0"), + Button(".", id="point"), + Button("=", id="equals", variant="warning"), id="calculator", ) + def on_key(self, event: events.Key) -> None: + """Called when the user presses a key.""" + + def press(button_id: str) -> None: + self.query_one(f"#{button_id}", Button).press() + self.set_focus(None) + + key = event.key + if key.isdecimal(): + press(f"number-{key}") + elif key == "c": + press("c") + press("ac") + elif key in self.KEY_MAP: + press(self.KEY_MAP[key]) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Called when a button is pressed.""" + + button_id = event.button.id + assert button_id is not None + + self.bell() # Terminal bell + + def do_math() -> None: + """Does the math: LEFT OPERATOR RIGHT""" + try: + if self.operator == "plus": + self.left += self.right + elif self.operator == "minus": + self.left -= self.right + elif self.operator == "divide": + self.left /= self.right + elif self.operator == "multiply": + self.left *= self.right + self.numbers = str(self.left) + self.value = "" + except Exception: + self.numbers = "Error" + + if button_id.startswith("number-"): + number = button_id.partition("-")[-1] + self.numbers = self.value = self.value.lstrip("0") + number + elif button_id == "plus-minus": + self.numbers = self.value = str(Decimal(self.value or "0") * -1) + elif button_id == "percent": + self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100)) + elif button_id == "point": + if "." not in self.value: + self.numbers = self.value = (self.value or "0") + "." + elif button_id == "ac": + self.value = "" + self.left = self.right = Decimal(0) + self.operator = "plus" + self.numbers = "0" + elif button_id == "c": + self.value = "" + self.numbers = "0" + elif button_id in ("plus", "minus", "divide", "multiply"): + self.right = Decimal(self.value or "0") + do_math() + self.operator = button_id + elif button_id == "equals": + if self.value: + self.right = Decimal(self.value) + do_math() + app = CalculatorApp(css_path="calculator.css") if __name__ == "__main__": diff --git a/sandbox/will/center2.py b/sandbox/will/center2.py index 1711fd09a..453d627d3 100644 --- a/sandbox/will/center2.py +++ b/sandbox/will/center2.py @@ -52,4 +52,4 @@ class CenterApp(App): ) -app = CenterApp(log_verbosity=3) +app = CenterApp() diff --git a/sandbox/will/scroll.py b/sandbox/will/scroll.py index 57017a6eb..d4b102201 100644 --- a/sandbox/will/scroll.py +++ b/sandbox/will/scroll.py @@ -37,9 +37,7 @@ class ButtonsApp(App[str]): self.dark = not self.dark -app = ButtonsApp( - log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=3 -) +app = ButtonsApp(log_path="textual.log", css_path="buttons.css", watch_css=True) if __name__ == "__main__": result = app.run() diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 197a92f40..dba0b372c 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -1,18 +1,108 @@ -import inspect +from __future__ import annotations +import inspect +from typing import TYPE_CHECKING + +import rich.repr from rich.console import RenderableType __all__ = ["log", "panic"] -def log(*args: object, verbosity: int = 0, **kwargs) -> None: - # TODO: There may be an early-out here for when there is no endpoint for logs - from ._context import active_app +if TYPE_CHECKING: + from .app import App - app = active_app.get() +from ._log import LogGroup, LogVerbosity, LogSeverity - caller = inspect.stack()[1] - app.log(*args, verbosity=verbosity, _textual_calling_frame=caller, **kwargs) + +@rich.repr.auto +class Logger: + def __init__( + self, + app: App, + group: LogGroup = LogGroup.INFO, + verbosity: LogVerbosity = LogVerbosity.NORMAL, + severity: LogSeverity = LogSeverity.NORMAL, + ) -> None: + self._app = app + self._group = group + self._verbosity = verbosity + self._severity = severity + + def __rich_repr__(self) -> rich.repr.Result: + yield self._app + yield self._group, LogGroup.INFO + yield self._verbosity, LogVerbosity.NORMAL + yield self._severity, LogSeverity.NORMAL + + def __call__(self, *args: object, **kwargs) -> None: + from ._context import active_app + + app = active_app.get() + caller = inspect.stack()[1] + app._log( + self._group, + self._verbosity, + self._severity, + *args, + _textual_calling_frame=caller, + **kwargs, + ) + + def verbosity(self, verbose: bool) -> Logger: + """Get a new logger with selective verbosity. + + Args: + verbose (bool): True to use HIGH verbosity, otherwise NORMAL. + + Returns: + Logger: New logger. + """ + verbosity = LogVerbosity.HIGH if verbose else LogVerbosity.NORMAL + return Logger(self._app, self._group, verbosity, LogSeverity.NORMAL) + + @property + def verbose(self) -> Logger: + """A verbose logger.""" + return Logger(self._app, self._group, LogVerbosity.HIGH) + + @property + def critical(self) -> Logger: + """A critical logger.""" + return Logger(self._app, self._group, self._verbosity, LogSeverity.CRITICAL) + + @property + def event(self) -> Logger: + """An event logger.""" + return Logger(self._app, LogGroup.EVENT) + + @property + def info(self) -> Logger: + """An info logger.""" + return Logger(self._app, LogGroup.INFO) + + @property + def debug(self) -> Logger: + """A debug logger.""" + return Logger(self._app, LogGroup.DEBUG) + + @property + def error(self) -> Logger: + """An error logger.""" + return Logger(self._app, LogGroup.ERROR) + + +log = Logger(LogGroup.INFO) + + +# def log(*args: object, verbosity: int = 0, **kwargs) -> None: +# # TODO: There may be an early-out here for when there is no endpoint for logs +# from ._context import active_app + +# app = active_app.get() + +# caller = inspect.stack()[1] +# app.log(*args, verbosity=verbosity, _textual_calling_frame=caller, **kwargs) def panic(*args: RenderableType) -> None: diff --git a/src/textual/_log.py b/src/textual/_log.py new file mode 100644 index 000000000..cc870e00d --- /dev/null +++ b/src/textual/_log.py @@ -0,0 +1,19 @@ +from enum import Enum, auto + + +class LogGroup(Enum): + EVENT = auto() + DEBUG = auto() + INFO = auto() + WARNING = auto() + ERROR = auto() + + +class LogVerbosity(Enum): + NORMAL = 0 + HIGH = 1 + + +class LogSeverity(Enum): + NORMAL = 0 + CRITICAL = 1 diff --git a/src/textual/app.py b/src/textual/app.py index f56f39052..f6066c3f7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -7,7 +7,7 @@ import os import platform import sys import warnings -from contextlib import redirect_stdout, redirect_stderr +from contextlib import redirect_stderr, redirect_stdout from datetime import datetime from pathlib import PurePath from time import perf_counter @@ -40,7 +40,16 @@ from rich.protocol import is_renderable from rich.segment import Segments from rich.traceback import Traceback -from . import actions, events, log, messages +from . import ( + Logger, + LogSeverity, + LogGroup, + LogVerbosity, + actions, + events, + log, + messages, +) from ._animator import Animator from ._callback import invoke from ._context import active_app @@ -136,7 +145,6 @@ class App(Generic[ReturnType], DOMNode): Args: driver_class (Type[Driver] | None, optional): Driver class or ``None`` to auto-detect. Defaults to None. - log_path (str | PurePath, optional): Path to log file, or "" to disable. Defaults to "". log_verbosity (int, optional): Log verbosity from 0-3. Defaults to 1. title (str | None, optional): Title of the application. If ``None``, the title is set to the name of the ``App`` subclass. Defaults to ``None``. css_path (str | PurePath | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None. @@ -158,11 +166,6 @@ class App(Generic[ReturnType], DOMNode): def __init__( self, driver_class: Type[Driver] | None = None, - log_path: str | PurePath = "", - log_verbosity: int = 0, - log_color_system: Literal[ - "auto", "standard", "256", "truecolor", "windows" - ] = "auto", title: str | None = None, css_path: str | PurePath | None = None, watch_css: bool = False, @@ -203,20 +206,7 @@ class App(Generic[ReturnType], DOMNode): else: self._title = title - self._log_console: Console | None = None - self._log_file: TextIO | None = None - if log_path: - self._log_file = open(log_path, "wt") - self._log_console = Console( - file=self._log_file, - color_system=log_color_system, - markup=False, - emoji=False, - highlight=False, - width=100, - ) - - self.log_verbosity = log_verbosity + self._logger = Logger(self) self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) self._refresh_required = False @@ -480,10 +470,16 @@ class App(Generic[ReturnType], DOMNode): """ return Size(*self.console.size) - def log( + @property + def log(self) -> Logger: + return self._logger + + def _log( self, + group: LogGroup, + verbosity: LogVerbosity, + severity: LogSeverity, *objects: Any, - verbosity: int = 1, _textual_calling_frame: inspect.FrameInfo | None = None, **kwargs, ) -> None: @@ -502,22 +498,24 @@ class App(Generic[ReturnType], DOMNode): Args: verbosity (int, optional): Verbosity level 0-3. Defaults to 1. """ - if verbosity > self.log_verbosity: - return - if self._log_console is None and not self.devtools.is_connected: + + if not self.devtools.is_connected: return - if self.devtools.is_connected and not _textual_calling_frame: + if verbosity.value > LogVerbosity.NORMAL.value and not self.devtools.verbose: + return + + if not _textual_calling_frame: _textual_calling_frame = inspect.stack()[1] try: if len(objects) == 1 and not kwargs: - if self._log_console is not None: - self._log_console.print(objects[0]) - if self.devtools.is_connected: - self.devtools.log( - DevtoolsLog(objects, caller=_textual_calling_frame) - ) + self.devtools.log( + group, + verbosity, + severity, + DevtoolsLog(objects, caller=_textual_calling_frame), + ) else: output = " ".join(str(arg) for arg in objects) if kwargs: @@ -525,12 +523,12 @@ class App(Generic[ReturnType], DOMNode): f"{key}={value!r}" for key, value in kwargs.items() ) output = f"{output} {key_values}" if output else key_values - if self._log_console is not None: - self._log_console.print(output, soft_wrap=True) - if self.devtools.is_connected: - self.devtools.log( - DevtoolsLog(output, caller=_textual_calling_frame) - ) + self.devtools.log( + group, + verbosity, + severity, + DevtoolsLog(output, caller=_textual_calling_frame), + ) except Exception as error: self.on_exception(error) @@ -687,8 +685,8 @@ class App(Generic[ReturnType], DOMNode): self.log(f" loaded {self.css_path!r} in {elapsed:.0f} ms") except Exception as error: # TODO: Catch specific exceptions + self.log.error(error) self.bell() - self.log(error) else: self.stylesheet = stylesheet self.reset_styles() @@ -1043,7 +1041,6 @@ class App(Generic[ReturnType], DOMNode): self.log("---") self.log(driver=self.driver_class) - self.log(log_verbosity=self.log_verbosity) self.log(loop=asyncio.get_running_loop()) self.log(features=self.features) @@ -1060,7 +1057,7 @@ class App(Generic[ReturnType], DOMNode): return if self.css_monitor: - self.set_interval(0.5, self.css_monitor, name="css monitor") + self.set_interval(0.25, self.css_monitor, name="css monitor") self.log("[b green]STARTED[/]", self.css_monitor) process_messages = super()._process_messages @@ -1097,7 +1094,7 @@ class App(Generic[ReturnType], DOMNode): if self.is_headless: await run_process_messages() else: - redirector = StdoutRedirector(self.devtools, self._log_file) + redirector = StdoutRedirector(self.devtools) with redirect_stderr(redirector): with redirect_stdout(redirector): # type: ignore await run_process_messages() @@ -1110,13 +1107,6 @@ class App(Generic[ReturnType], DOMNode): self._print_error_renderables() if self.devtools.is_connected: await self._disconnect_devtools() - if self._log_console is not None: - self._log_console.print( - f"Disconnected from devtools ({self.devtools.url})" - ) - if self._log_file is not None: - self._log_file.close() - self._log_console = None async def _ready(self) -> None: """Called immediately prior to processing messages. diff --git a/src/textual/cli/cli.py b/src/textual/cli/cli.py index 9626d005a..178f3f66e 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -19,14 +19,15 @@ def run(): @run.command(help="Run the Textual Devtools console.") -def console(): +@click.option("-v", "verbose", help="Enable verbose logs.", is_flag=True) +def console(verbose: bool = False): from rich.console import Console console = Console() console.clear() console.show_cursor(False) try: - _run_devtools() + _run_devtools(verbose=verbose) finally: console.show_cursor(True) diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 802bd686a..32218d471 100644 --- a/src/textual/devtools/client.py +++ b/src/textual/devtools/client.py @@ -13,6 +13,8 @@ from typing import Type, Any, NamedTuple from rich.console import Console from rich.segment import Segment +from .._log import LogGroup, LogVerbosity, LogSeverity + class DevtoolsDependenciesMissingError(Exception): """Raise when the required devtools dependencies are not installed in the environment""" @@ -115,6 +117,7 @@ class DevtoolsClient: self.websocket: ClientWebSocketResponse | None = None self.log_queue: Queue[str | bytes | Type[ClientShutdown]] | None = None self.spillover: int = 0 + self.verbose: bool = False async def connect(self) -> None: """Connect to the devtools server. @@ -150,6 +153,7 @@ class DevtoolsClient: payload = message_json["payload"] self.console.width = payload["width"] self.console.height = payload["height"] + self.verbose = payload["verbose"] async def send_queued_logs(): """Coroutine function which is scheduled as a Task, which consumes @@ -209,7 +213,13 @@ class DevtoolsClient: return False return not (self.session.closed or self.websocket.closed) - def log(self, log: DevtoolsLog) -> None: + def log( + self, + group: LogGroup, + verbosity: LogVerbosity, + severity: LogSeverity, + log: DevtoolsLog, + ) -> None: """Queue a log to be sent to the devtools server for display. Args: @@ -227,6 +237,9 @@ class DevtoolsClient: { "type": "client_log", "payload": { + "group": group.value, + "verbosity": verbosity.value, + "severity": severity.value, "timestamp": int(time()), "path": getattr(log.caller, "filename", ""), "line_number": getattr(log.caller, "lineno", 0), diff --git a/src/textual/devtools/redirect_output.py b/src/textual/devtools/redirect_output.py index 801e52c4b..cf4c78642 100644 --- a/src/textual/devtools/redirect_output.py +++ b/src/textual/devtools/redirect_output.py @@ -17,16 +17,13 @@ class StdoutRedirector: log file. """ - def __init__( - self, devtools: DevtoolsClient, log_file: TextIOWrapper | None - ) -> None: + def __init__(self, devtools: DevtoolsClient) -> None: """ Args: devtools (DevtoolsClient): The running Textual app instance. log_file (TextIOWrapper): The log file for the Textual App. """ self.devtools = devtools - self.log_file = log_file self._buffer: list[DevtoolsLog] = [] def write(self, string: str) -> None: @@ -38,7 +35,7 @@ class StdoutRedirector: string (str): The string to write to the buffer. """ - if not (self.devtools.is_connected or self.log_file is not None): + if not self.devtools.is_connected: return caller = inspect.stack()[1] @@ -59,7 +56,6 @@ class StdoutRedirector: the devtools server and the log file. In the case of the devtools, where possible, log messages will be batched and sent as one. """ - self._write_to_log_file() self._write_to_devtools() self._buffer.clear() @@ -81,20 +77,6 @@ class StdoutRedirector: if log_batch: self._log_devtools_batched(log_batch) - def _write_to_log_file(self) -> None: - """Write the contents of the buffer to the log file.""" - if not self.log_file: - return - - try: - log_text = "".join(str(log.objects_or_string) for log in self._buffer) - self.log_file.write(log_text) - self.log_file.flush() - except OSError: - # An error writing to the log file should not be - # considered fatal. - pass - def _log_devtools_batched(self, log_batch: list[DevtoolsLog]) -> None: """Write a single batch of logs to devtools. A batch means contiguous logs which have been written from the same line number and file path. diff --git a/src/textual/devtools/renderables.py b/src/textual/devtools/renderables.py index de0c30929..35d7eb943 100644 --- a/src/textual/devtools/renderables.py +++ b/src/textual/devtools/renderables.py @@ -21,12 +21,15 @@ from rich.style import Style from rich.table import Table from rich.text import Text -from textual._border import Border +from textual._log import LogGroup DevConsoleMessageLevel = Literal["info", "warning", "error"] class DevConsoleHeader: + def __init__(self, verbose: bool = False) -> None: + self.verbose = verbose + def __rich_console__( self, console: Console, options: ConsoleOptions ) -> RenderResult: @@ -35,6 +38,8 @@ class DevConsoleHeader: "[magenta]Run a Textual app with [reverse]textual run --dev my_app.py[/] to connect.\n" "[magenta]Press [reverse]Ctrl+C[/] to quit." ) + if self.verbose: + preamble.append(Text.from_markup("\n[cyan]Verbose logs enabled")) render_options = options.update(width=options.max_width - 4) lines = console.render_lines(preamble, render_options) @@ -63,11 +68,17 @@ class DevConsoleLog: path: str, line_number: int, unix_timestamp: int, + group: int, + verbosity: int, + severity: int, ) -> None: self.segments = segments self.path = path self.line_number = line_number self.unix_timestamp = unix_timestamp + self.group = group + self.verbosity = verbosity + self.severity = severity def __rich_console__( self, console: Console, options: ConsoleOptions @@ -77,8 +88,14 @@ class DevConsoleLog: file_link = escape(f"file://{Path(self.path).absolute()}") file_and_line = escape(f"{Path(self.path).name}:{self.line_number}") + group = LogGroup(self.group).name + time = local_time.time() table.add_row( - f"[dim]{local_time.time()}", + ( + f":warning-emoji: [dim]{time} {group}" + if self.severity + else f"[dim][{time}] ({group.lower()})" + ), Align.right( Text(f"{file_and_line}", style=Style(dim=True, link=file_link)) ), diff --git a/src/textual/devtools/server.py b/src/textual/devtools/server.py index a0ad3a149..2f296362c 100644 --- a/src/textual/devtools/server.py +++ b/src/textual/devtools/server.py @@ -36,8 +36,8 @@ async def _on_startup(app: Application) -> None: await service.start() -def _run_devtools() -> None: - app = _make_devtools_aiohttp_app() +def _run_devtools(verbose: bool = False) -> None: + app = _make_devtools_aiohttp_app(verbose=verbose) def noop_print(_: str): return None @@ -47,14 +47,16 @@ def _run_devtools() -> None: def _make_devtools_aiohttp_app( size_change_poll_delay_secs: float = DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS, + verbose: bool = False, ) -> Application: app = Application() app.on_shutdown.append(_on_shutdown) app.on_startup.append(_on_startup) + app["verbose"] = verbose app["service"] = DevtoolsService( - update_frequency=size_change_poll_delay_secs, + update_frequency=size_change_poll_delay_secs, verbose=verbose ) app.add_routes( diff --git a/src/textual/devtools/service.py b/src/textual/devtools/service.py index 5b001d137..bdfa06d84 100644 --- a/src/textual/devtools/service.py +++ b/src/textual/devtools/service.py @@ -30,13 +30,15 @@ class DevtoolsService: responsible for tracking connected client applications. """ - def __init__(self, update_frequency: float) -> None: + def __init__(self, update_frequency: float, verbose: bool = False) -> None: """ Args: update_frequency (float): The number of seconds to wait between sending updates of the console size to connected clients. + verbose (bool): Enable verbose logging on client. """ self.update_frequency = update_frequency + self.verbose = verbose self.console = Console() self.shutdown_event = asyncio.Event() self.clients: list[ClientHandler] = [] @@ -44,7 +46,7 @@ class DevtoolsService: async def start(self): """Starts devtools tasks""" self.size_poll_task = asyncio.create_task(self._console_size_poller()) - self.console.print(DevConsoleHeader()) + self.console.print(DevConsoleHeader(verbose=self.verbose)) @property def clients_connected(self) -> bool: @@ -58,6 +60,7 @@ class DevtoolsService: """ current_width = self.console.width current_height = self.console.height + await self._send_server_info_to_all() while not self.shutdown_event.is_set(): width = self.console.width height = self.console.height @@ -91,6 +94,7 @@ class DevtoolsService: "payload": { "width": self.console.width, "height": self.console.height, + "verbose": self.verbose, }, } ) @@ -168,10 +172,8 @@ class ClientHandler: type = message["type"] if type == "client_log": - path = message["payload"]["path"] - line_number = message["payload"]["line_number"] - timestamp = message["payload"]["timestamp"] - encoded_segments = message["payload"]["segments"] + payload = message["payload"] + encoded_segments = payload["segments"] segments = pickle.loads(encoded_segments) message_time = time() if ( @@ -183,9 +185,12 @@ class ClientHandler: self.service.console.print( DevConsoleLog( segments=segments, - path=path, - line_number=line_number, - unix_timestamp=timestamp, + path=payload["path"], + line_number=payload["line_number"], + unix_timestamp=payload["timestamp"], + group=payload["group"], + verbosity=payload["verbosity"], + severity=payload["severity"], ) ) last_message_time = message_time diff --git a/src/textual/events.py b/src/textual/events.py index c8bad966e..b106a2556 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -19,17 +19,17 @@ if TYPE_CHECKING: @rich.repr.auto -class Event(Message, verbosity=2): +class Event(Message): def __rich_repr__(self) -> rich.repr.Result: return yield - def __init_subclass__(cls, bubble: bool = True, verbosity: int = 1) -> None: - super().__init_subclass__(bubble=bubble, verbosity=verbosity) + def __init_subclass__(cls, bubble: bool = True, verbose: bool = False) -> None: + super().__init_subclass__(bubble=bubble, verbose=verbose) @rich.repr.auto -class Callback(Event, bubble=False, verbosity=3): +class Callback(Event, bubble=False, verbose=True): def __init__( self, sender: MessageTarget, callback: Callable[[], Awaitable[None]] ) -> None: @@ -40,7 +40,7 @@ class Callback(Event, bubble=False, verbosity=3): yield "callback", self.callback -class InvokeCallbacks(Event, bubble=False): +class InvokeCallbacks(Event, bubble=False, verbose=True): """Sent after the Screen is updated""" @@ -83,7 +83,7 @@ class Action(Event): yield "action", self.action -class Resize(Event, verbosity=2, bubble=False): +class Resize(Event, bubble=False): """Sent when the app or widget has been resized. Args: sender (MessageTarget): The sender of the event (the Screen). @@ -116,14 +116,10 @@ class Resize(Event, verbosity=2, bubble=False): yield "container_size", self.container_size, self.size -class Mount(Event, bubble=False): +class Mount(Event, bubble=False, verbose=True): """Sent when a widget is *mounted* and may receive messages.""" -class Unmount(Event, bubble=False): - """Sent when a widget is unmounted, and may no longer receive messages.""" - - class Remove(Event, bubble=False): """Sent to a widget to ask it to remove itself from the DOM.""" @@ -217,7 +213,7 @@ class Key(InputEvent): @rich.repr.auto -class MouseEvent(InputEvent, bubble=True, verbosity=2): +class MouseEvent(InputEvent, bubble=True): """Sent in response to a mouse event. Args: @@ -336,7 +332,7 @@ class MouseEvent(InputEvent, bubble=True, verbosity=2): @rich.repr.auto -class MouseMove(MouseEvent, verbosity=3, bubble=True): +class MouseMove(MouseEvent, bubble=True, verbose=True): """Sent when the mouse cursor moves.""" @@ -350,7 +346,7 @@ class MouseUp(MouseEvent, bubble=True): pass -class MouseScrollDown(InputEvent, verbosity=3, bubble=True): +class MouseScrollDown(InputEvent, bubble=True, verbose=True): __slots__ = ["x", "y"] def __init__(self, sender: MessageTarget, x: int, y: int) -> None: @@ -359,7 +355,7 @@ class MouseScrollDown(InputEvent, verbosity=3, bubble=True): self.y = y -class MouseScrollUp(InputEvent, verbosity=3, bubble=True): +class MouseScrollUp(InputEvent, bubble=True, verbose=True): __slots__ = ["x", "y"] def __init__(self, sender: MessageTarget, x: int, y: int) -> None: @@ -373,7 +369,7 @@ class Click(MouseEvent, bubble=True): @rich.repr.auto -class Timer(Event, verbosity=3, bubble=False): +class Timer(Event, bubble=False, verbose=True): __slots__ = ["time", "count", "callback"] def __init__( @@ -395,11 +391,11 @@ class Timer(Event, verbosity=3, bubble=False): yield "count", self.count -class Enter(Event, bubble=False): +class Enter(Event, bubble=False, verbose=True): pass -class Leave(Event, bubble=False): +class Leave(Event, bubble=False, verbose=True): pass @@ -411,11 +407,11 @@ class Blur(Event, bubble=False): pass -class DescendantFocus(Event, verbosity=2, bubble=True): +class DescendantFocus(Event, bubble=True): pass -class DescendantBlur(Event, verbosity=2, bubble=True): +class DescendantBlur(Event, bubble=True): pass diff --git a/src/textual/message.py b/src/textual/message.py index 869c85ac8..63be90749 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -30,7 +30,7 @@ class Message: sender: MessageTarget bubble: ClassVar[bool] = True # Message will bubble to parent - verbosity: ClassVar[int] = 1 # Verbosity (higher the more verbose) + verbose: ClassVar[bool] = False # Message is verbose no_dispatch: ClassVar[bool] = False # Message may not be handled by client code namespace: ClassVar[str] = "" # Namespace to disambiguate messages @@ -52,15 +52,14 @@ class Message: def __init_subclass__( cls, bubble: bool | None = True, - verbosity: int | None = 1, + verbose: bool = False, no_dispatch: bool | None = False, namespace: str | None = None, ) -> None: super().__init_subclass__() if bubble is not None: cls.bubble = bubble - if verbosity is not None: - cls.verbosity = verbosity + cls.verbose = verbose if no_dispatch is not None: cls.no_dispatch = no_dispatch if namespace is not None: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index e7eeb4c18..7830ba168 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -15,7 +15,7 @@ from functools import partial from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable from weakref import WeakSet -from . import events, log, messages +from . import events, log, messages, Logger from ._callback import invoke from ._context import NoActiveAppError, active_app from .timer import Timer, TimerCallback @@ -110,33 +110,9 @@ class MessagePump(metaclass=MessagePumpMeta): def is_running(self) -> bool: return self._running - def log( - self, - *args: Any, - verbosity: int = 1, - **kwargs, - ) -> None: - """Write to logs or devtools. - - Positional args will logged. Keyword args will be prefixed with the key. - - Example: - ```python - data = [1,2,3] - self.log("Hello, World", state=data) - self.log(self.tree) - self.log(locals()) - ``` - - Args: - verbosity (int, optional): Verbosity level 0-3. Defaults to 1. - """ - return self.app.log( - *args, - **kwargs, - verbosity=verbosity, - _textual_calling_frame=inspect.stack()[1], - ) + @property + def log(self) -> Logger: + return self.app._logger def _attach(self, parent: MessagePump) -> None: """Set the parent, and therefore attach this node to the tree. @@ -422,12 +398,11 @@ class MessagePump(metaclass=MessagePumpMeta): # Look through the MRO to find a handler for cls, method in self._get_dispatch_methods(handler_name, message): - log( + log.event.verbosity(message.verbose)( message, ">>>", self, f"method=<{cls.__name__}.{handler_name}>", - verbosity=message.verbosity, ) await invoke(method, message) diff --git a/src/textual/messages.py b/src/textual/messages.py index 58e4cd2a6..55e42954d 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: @rich.repr.auto -class Update(Message, verbosity=3): +class Update(Message, verbose=True): def __init__(self, sender: MessagePump, widget: Widget): super().__init__(sender) self.widget = widget @@ -33,13 +33,13 @@ class Update(Message, verbosity=3): @rich.repr.auto -class Layout(Message, verbosity=3): +class Layout(Message, verbose=True): def can_replace(self, message: Message) -> bool: return isinstance(message, Layout) @rich.repr.auto -class InvokeLater(Message, verbosity=3): +class InvokeLater(Message, verbose=True): def __init__(self, sender: MessagePump, callback: CallbackType) -> None: self.callback = callback super().__init__(sender)