mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
new logging
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -52,4 +52,4 @@ class CenterApp(App):
|
||||
)
|
||||
|
||||
|
||||
app = CenterApp(log_verbosity=3)
|
||||
app = CenterApp()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
19
src/textual/_log.py
Normal file
19
src/textual/_log.py
Normal file
@@ -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
|
||||
@@ -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"<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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user