Merge pull request #722 from Textualize/log-verbosity

Log verbosity
This commit is contained in:
Will McGugan
2022-09-02 14:01:38 +01:00
committed by GitHub
29 changed files with 465 additions and 280 deletions

View File

@@ -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")

View File

@@ -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__":

View File

@@ -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__":

View File

@@ -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()

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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__":

View File

@@ -52,4 +52,4 @@ class CenterApp(App):
)
app = CenterApp(log_verbosity=3)
app = CenterApp()

View File

@@ -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()

View File

@@ -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:

27
src/textual/_log.py Normal file
View File

@@ -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

View File

@@ -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"<stylesheet> 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.

View File

@@ -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)

View File

@@ -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),

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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__(

View File

@@ -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:

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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="")

View File

@@ -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