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..dabf99376 100644 --- a/sandbox/will/calculator.py +++ b/sandbox/will/calculator.py @@ -1,35 +1,143 @@ -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.""" + + print(f"KEY {event} was pressed!") + + 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..7be25a7f4 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -1,18 +1,99 @@ -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 +from ._log import LogGroup, LogVerbosity, LogSeverity - app = active_app.get() - caller = inspect.stack()[1] - app.log(*args, verbosity=verbosity, _textual_calling_frame=caller, **kwargs) +@rich.repr.auto +class Logger: + """A Textual logger.""" + + def __init__( + self, + group: LogGroup = LogGroup.INFO, + verbosity: LogVerbosity = LogVerbosity.NORMAL, + severity: LogSeverity = LogSeverity.NORMAL, + ) -> None: + self._group = group + self._verbosity = verbosity + self._severity = severity + + def __rich_repr__(self) -> rich.repr.Result: + 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._group, verbosity, LogSeverity.NORMAL) + + @property + def verbose(self) -> Logger: + """A verbose logger.""" + return Logger(self._group, LogVerbosity.HIGH) + + @property + def critical(self) -> Logger: + """A critical logger.""" + return Logger(self._group, self._verbosity, LogSeverity.CRITICAL) + + @property + def event(self) -> Logger: + """An event logger.""" + return Logger(LogGroup.EVENT) + + @property + def debug(self) -> Logger: + """A debug logger.""" + return Logger(LogGroup.DEBUG) + + @property + def info(self) -> Logger: + """An info logger.""" + return Logger(LogGroup.INFO) + + @property + def warning(self) -> Logger: + """An info logger.""" + return Logger(LogGroup.WARNING) + + @property + def error(self) -> Logger: + """An error logger.""" + return Logger(LogGroup.ERROR) + + +log = Logger() def panic(*args: RenderableType) -> None: diff --git a/src/textual/_log.py b/src/textual/_log.py new file mode 100644 index 000000000..ec9674cda --- /dev/null +++ b/src/textual/_log.py @@ -0,0 +1,27 @@ +from enum import Enum, auto + + +class LogGroup(Enum): + """A log group is a classification of the log message (*not* a level).""" + + UNDEFINED = 0 # Mainly for testing + EVENT = 1 + DEBUG = 2 + INFO = 3 + WARNING = 4 + ERROR = 5 + PRINT = 6 + + +class LogVerbosity(Enum): + """Tags log messages as being verbose and potentially excluded from output.""" + + NORMAL = 0 + HIGH = 1 + + +class LogSeverity(Enum): + """Tags log messages as being more severe.""" + + NORMAL = 0 + CRITICAL = 1 # Draws attention to the log message diff --git a/src/textual/app.py b/src/textual/app.py index 207d480cb..a36ad161a 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, Path 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 @@ -134,7 +143,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. @@ -156,11 +164,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, @@ -201,20 +204,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.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) self._refresh_required = False @@ -486,10 +476,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: @@ -508,22 +504,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( + DevtoolsLog(objects, caller=_textual_calling_frame), + group, + verbosity, + severity, + ) else: output = " ".join(str(arg) for arg in objects) if kwargs: @@ -531,12 +529,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( + DevtoolsLog(output, caller=_textual_calling_frame), + group, + verbosity, + severity, + ) except Exception as error: self.on_exception(error) @@ -693,8 +691,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() @@ -1042,14 +1040,13 @@ class App(Generic[ReturnType], DOMNode): if self.devtools_enabled: try: await self.devtools.connect() - self.log(f"Connected to devtools ({self.devtools.url})") + self.log(f"Connected to devtools ( {self.devtools.url} )") except DevtoolsConnectionError: - self.log(f"Couldn't connect to devtools ({self.devtools.url})") + self.log(f"Couldn't connect to devtools ( {self.devtools.url} )") 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) @@ -1066,7 +1063,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 @@ -1103,7 +1100,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() @@ -1116,13 +1113,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 f91df0e78..3e6cb2359 100644 --- a/src/textual/cli/cli.py +++ b/src/textual/cli/cli.py @@ -21,14 +21,16 @@ def run(): @run.command(help="Run the Textual Devtools console.") -def console(): +@click.option("-v", "verbose", help="Enable verbose logs.", is_flag=True) +@click.option("-x", "--exclude", "exclude", help="Exclude log group(s)", multiple=True) +def console(verbose: bool, exclude: list[str]) -> None: from rich.console import Console console = Console() console.clear() console.show_cursor(False) try: - _run_devtools() + _run_devtools(verbose=verbose, exclude=exclude) finally: console.show_cursor(True) diff --git a/src/textual/devtools/client.py b/src/textual/devtools/client.py index 802bd686a..991f740fd 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. @@ -136,7 +139,7 @@ class DevtoolsClient: log_queue = self.log_queue websocket = self.websocket - async def update_console(): + async def update_console() -> None: """Coroutine function scheduled as a Task, which listens on the websocket for updates from the server regarding any changes in the server Console dimensions. When the client learns of this @@ -150,6 +153,7 @@ class DevtoolsClient: payload = message_json["payload"] self.console.width = payload["width"] self.console.height = payload["height"] + self.verbose = payload.get("verbose", False) 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, + log: DevtoolsLog, + group: LogGroup = LogGroup.UNDEFINED, + verbosity: LogVerbosity = LogVerbosity.NORMAL, + severity: LogSeverity = LogSeverity.NORMAL, + ) -> 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..3122da9ec 100644 --- a/src/textual/devtools/redirect_output.py +++ b/src/textual/devtools/redirect_output.py @@ -1,12 +1,13 @@ from __future__ import annotations import inspect -from io import TextIOWrapper + from typing import TYPE_CHECKING, cast -from textual.devtools.client import DevtoolsLog +from .client import DevtoolsLog +from .._log import LogGroup, LogVerbosity, LogSeverity if TYPE_CHECKING: - from textual.devtools.client import DevtoolsClient + from .devtools.client import DevtoolsClient class StdoutRedirector: @@ -17,16 +18,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 +36,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 +57,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 +78,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. @@ -114,4 +97,9 @@ class StdoutRedirector: # that the log message content is a string. The cast below tells mypy this. batched_log = "".join(cast(str, log.objects_or_string) for log in log_batch) batched_log = batched_log.rstrip() - self.devtools.log(DevtoolsLog(batched_log, caller=log_batch[-1].caller)) + self.devtools.log( + DevtoolsLog(batched_log, caller=log_batch[-1].caller), + LogGroup.PRINT, + LogVerbosity.NORMAL, + LogSeverity.NORMAL, + ) diff --git a/src/textual/devtools/renderables.py b/src/textual/devtools/renderables.py index de0c30929..cd0035d47 100644 --- a/src/textual/devtools/renderables.py +++ b/src/textual/devtools/renderables.py @@ -18,15 +18,18 @@ from rich.markup import escape from rich.rule import Rule from rich.segment import Segment, Segments from rich.style import Style +from rich.styled import Styled 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,14 +88,26 @@ 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() + message = Text( + f":warning-emoji: [{time}] {group}" + if self.severity > 0 + else f"[{time}] {group}" + ) + message.stylize("dim") + table.add_row( - f"[dim]{local_time.time()}", + message, Align.right( Text(f"{file_and_line}", style=Style(dim=True, link=file_link)) ), ) yield table - yield Segments(self.segments) + if group == "PRINT": + yield Styled(Segments(self.segments), "bold") + else: + yield Segments(self.segments) class DevConsoleNotice: diff --git a/src/textual/devtools/server.py b/src/textual/devtools/server.py index a0ad3a149..167696563 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, exclude: list[str] | None = None) -> None: + app = _make_devtools_aiohttp_app(verbose=verbose, exclude=exclude) def noop_print(_: str): return None @@ -47,14 +47,17 @@ def _run_devtools() -> None: def _make_devtools_aiohttp_app( size_change_poll_delay_secs: float = DEFAULT_SIZE_CHANGE_POLL_DELAY_SECONDS, + verbose: bool = False, + exclude: list[str] | None = None, ) -> 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, exclude=exclude ) app.add_routes( diff --git a/src/textual/devtools/service.py b/src/textual/devtools/service.py index 5b001d137..7d8ef9a21 100644 --- a/src/textual/devtools/service.py +++ b/src/textual/devtools/service.py @@ -16,6 +16,7 @@ from rich.console import Console from rich.markup import escape import msgpack +from textual._log import LogGroup from textual.devtools.renderables import ( DevConsoleLog, DevConsoleNotice, @@ -30,13 +31,22 @@ class DevtoolsService: responsible for tracking connected client applications. """ - def __init__(self, update_frequency: float) -> None: + def __init__( + self, + update_frequency: float, + verbose: bool = False, + exclude: list[str] | None = None, + ) -> 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. + exclude (list[str]): List of log groups to exclude from output. """ self.update_frequency = update_frequency + self.verbose = verbose + self.exclude = set(name.upper() for name in exclude) if exclude else set() self.console = Console() self.shutdown_event = asyncio.Event() self.clients: list[ClientHandler] = [] @@ -44,7 +54,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 +68,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 +102,7 @@ class DevtoolsService: "payload": { "width": self.console.width, "height": self.console.height, + "verbose": self.verbose, }, } ) @@ -168,10 +180,10 @@ 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"] + if LogGroup(payload.get("group", 0)).name in self.service.exclude: + continue + encoded_segments = payload["segments"] segments = pickle.loads(encoded_segments) message_time = time() if ( @@ -183,9 +195,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.get("group", 0), + verbosity=payload.get("verbosity", 0), + severity=payload.get("severity", 0), ) ) last_message_time = message_time diff --git a/src/textual/events.py b/src/textual/events.py index c8bad966e..e868eedab 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,21 +332,21 @@ 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.""" @rich.repr.auto -class MouseDown(MouseEvent, bubble=True): +class MouseDown(MouseEvent, bubble=True, verbose=True): pass @rich.repr.auto -class MouseUp(MouseEvent, bubble=True): +class MouseUp(MouseEvent, bubble=True, verbose=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, verbose=True): pass -class DescendantBlur(Event, verbosity=2, bubble=True): +class DescendantBlur(Event, bubble=True, verbose=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) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index b9dd00774..e0b468f6a 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -22,26 +22,26 @@ class ScrollMessage(Message, bubble=False): @rich.repr.auto -class ScrollUp(ScrollMessage): +class ScrollUp(ScrollMessage, verbose=True): """Message sent when clicking above handle.""" @rich.repr.auto -class ScrollDown(ScrollMessage): +class ScrollDown(ScrollMessage, verbose=True): """Message sent when clicking below handle.""" @rich.repr.auto -class ScrollLeft(ScrollMessage): +class ScrollLeft(ScrollMessage, verbose=True): """Message sent when clicking above handle.""" @rich.repr.auto -class ScrollRight(ScrollMessage): +class ScrollRight(ScrollMessage, verbose=True): """Message sent when clicking below handle.""" -class ScrollTo(ScrollMessage): +class ScrollTo(ScrollMessage, verbose=True): """Message sent when click and dragging handle.""" def __init__( diff --git a/src/textual/widget.py b/src/textual/widget.py index 3f0e2f2bb..90d1ba56c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1396,7 +1396,7 @@ class Widget(DOMNode): if not self.check_message_enabled(message): return True if not self.is_running: - self.log(self, f"IS NOT RUNNING, {message!r} not sent") + self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent") return await super().post_message(message) async def _on_idle(self, event: events.Idle) -> None: diff --git a/tests/devtools/conftest.py b/tests/devtools/conftest.py index 92f6253a3..b819512e2 100644 --- a/tests/devtools/conftest.py +++ b/tests/devtools/conftest.py @@ -27,8 +27,3 @@ async def devtools(aiohttp_client, server): yield devtools await devtools.disconnect() await client.close() - - -@pytest.fixture -def in_memory_logfile(): - yield StringIO() diff --git a/tests/devtools/test_devtools.py b/tests/devtools/test_devtools.py index f66766445..b8c65bfd1 100644 --- a/tests/devtools/test_devtools.py +++ b/tests/devtools/test_devtools.py @@ -37,6 +37,9 @@ def test_log_message_render(console): path="abc/hello.py", line_number=123, unix_timestamp=TIMESTAMP, + group=0, + verbosity=0, + severity=0, ) table = next(iter(message.__rich_console__(console, console.options))) @@ -52,7 +55,7 @@ def test_log_message_render(console): local_time = datetime.fromtimestamp(TIMESTAMP) string_timestamp = local_time.time() - assert left == f"[dim]{string_timestamp}" + assert left.plain == f"[{string_timestamp}] UNDEFINED" assert right.align == "right" assert "hello.py:123" in right.renderable diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py index 84a57b3c6..0f5f6e490 100644 --- a/tests/devtools/test_devtools_client.py +++ b/tests/devtools/test_devtools_client.py @@ -36,15 +36,20 @@ async def test_devtools_log_places_encodes_and_queues_message(devtools): queued_log = await devtools.log_queue.get() queued_log_data = msgpack.unpackb(queued_log) print(repr(queued_log_data)) - assert queued_log_data == { - "type": "client_log", - "payload": { - "timestamp": 1649166819, - "path": "a/b/c.py", - "line_number": 123, - "segments": b"\x80\x04\x95B\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\rHello, world!\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", - }, - } + + +{ + "type": "client_log", + "payload": { + "group": 0, + "verbosity": 0, + "severity": 0, + "timestamp": 1649166819, + "path": "a/b/c.py", + "line_number": 123, + "segments": b"\x80\x04\x95@\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x0crich.segment\x94\x8c\x07Segment\x94\x93\x94\x8c\x0bhello world\x94NN\x87\x94\x81\x94h\x03\x8c\x01\n\x94NN\x87\x94\x81\x94e.", + }, +} @time_machine.travel(datetime.utcfromtimestamp(TIMESTAMP)) @@ -57,6 +62,9 @@ async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtoo assert queued_log_data == { "type": "client_log", "payload": { + "group": 0, + "verbosity": 0, + "severity": 0, "timestamp": 1649166819, "path": "a/b/c.py", "line_number": 123, diff --git a/tests/devtools/test_redirect_output.py b/tests/devtools/test_redirect_output.py index 349c2aa69..fd4659940 100644 --- a/tests/devtools/test_redirect_output.py +++ b/tests/devtools/test_redirect_output.py @@ -14,7 +14,7 @@ TIMESTAMP = 1649166819 async def test_print_redirect_to_devtools_only(devtools): await devtools._stop_log_queue_processing() - with redirect_stdout(StdoutRedirector(devtools, None)): # type: ignore + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore print("Hello, world!") assert devtools.log_queue.qsize() == 1 @@ -32,28 +32,26 @@ async def test_print_redirect_to_devtools_only(devtools): ) -async def test_print_redirect_to_logfile_only(devtools, in_memory_logfile): +async def test_print_redirect_to_logfile_only(devtools): await devtools.disconnect() - with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore print("Hello, world!") - assert in_memory_logfile.getvalue() == "Hello, world!\n" -async def test_print_redirect_to_devtools_and_logfile(devtools, in_memory_logfile): +async def test_print_redirect_to_devtools_and_logfile(devtools): await devtools._stop_log_queue_processing() - with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore print("Hello, world!") assert devtools.log_queue.qsize() == 1 - assert in_memory_logfile.getvalue() == "Hello, world!\n" @time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) -async def test_print_without_flush_not_sent_to_devtools(devtools, in_memory_logfile): +async def test_print_without_flush_not_sent_to_devtools(devtools): await devtools._stop_log_queue_processing() - with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore # End is no longer newline character, so print will no longer # flush the output buffer by default. print("Hello, world!", end="") @@ -62,44 +60,19 @@ async def test_print_without_flush_not_sent_to_devtools(devtools, in_memory_logf @time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) -async def test_print_forced_flush_sent_to_devtools(devtools, in_memory_logfile): +async def test_print_forced_flush_sent_to_devtools(devtools): await devtools._stop_log_queue_processing() - with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore print("Hello, world!", end="", flush=True) assert devtools.log_queue.qsize() == 1 @time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) -async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfile): +async def test_print_multiple_args_batched_as_one_log(devtools): await devtools._stop_log_queue_processing() - - with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore - # We call print with multiple arguments here, but it - # results in a single log added to the log queue. - print("Hello", "world", "multiple") - - assert devtools.log_queue.qsize() == 1 - - queued_log = await devtools.log_queue.get() - queued_log_json = json.loads(queued_log) - payload = queued_log_json["payload"] - - assert queued_log_json["type"] == "client_log" - assert payload["timestamp"] == TIMESTAMP - assert ( - payload["encoded_segments"] - == "gANdcQAoY3JpY2guc2VnbWVudApTZWdtZW50CnEBWBQAAABIZWxsbyB3b3JsZCBtdWx0aXBsZXECTk6HcQOBcQRoAVgBAAAACnEFTk6HcQaBcQdlLg==" - ) - assert len(payload["path"]) > 0 - assert payload["line_number"] != 0 - - -@time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) -async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfile): - await devtools._stop_log_queue_processing() - redirector = StdoutRedirector(devtools, in_memory_logfile) + redirector = StdoutRedirector(devtools) with redirect_stdout(redirector): # type: ignore # This print adds 3 messages to the buffer that can be batched print("The first", "batch", "of logs", end="") @@ -111,10 +84,10 @@ async def test_print_multiple_args_batched_as_one_log(devtools, in_memory_logfil @time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) -async def test_print_strings_containing_newline_flushed(devtools, in_memory_logfile): +async def test_print_strings_containing_newline_flushed(devtools): await devtools._stop_log_queue_processing() - with redirect_stdout(StdoutRedirector(devtools, in_memory_logfile)): # type: ignore + with redirect_stdout(StdoutRedirector(devtools)): # type: ignore # Flushing is disabled since end="", but the first # string will be flushed since it contains a newline print("Hel\nlo", end="") @@ -124,10 +97,10 @@ async def test_print_strings_containing_newline_flushed(devtools, in_memory_logf @time_machine.travel(datetime.fromtimestamp(TIMESTAMP)) -async def test_flush_flushes_buffered_logs(devtools, in_memory_logfile): +async def test_flush_flushes_buffered_logs(devtools): await devtools._stop_log_queue_processing() - redirector = StdoutRedirector(devtools, in_memory_logfile) + redirector = StdoutRedirector(devtools) with redirect_stdout(redirector): # type: ignore print("x", end="") diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index e23952b23..64eb637cb 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -25,20 +25,11 @@ from textual.geometry import Size, Region class AppTest(App): - def __init__( - self, - *, - test_name: str, - size: Size, - log_verbosity: int = 2, - ): + def __init__(self, *, test_name: str, size: Size): # Tests will log in "/tests/test.[test name].log": log_path = Path(__file__).parent.parent / f"test.{test_name}.log" super().__init__( driver_class=DriverTest, - log_path=log_path, - log_verbosity=log_verbosity, - log_color_system="256", ) # Let's disable all features by default