diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c7debf71d..87aedccbb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed issues with initial flicker in `TextArea` rendering https://github.com/Textualize/textual/issues/5841vcomm - Fixed issue with workers that have large parameter lists breaking dev tools https://github.com/Textualize/textual/pull/5850 - Fixed post_message failing on 3.8 https://github.com/Textualize/textual/pull/5848 +- Fixed log not working from threads https://github.com/Textualize/textual/pull/5863 ### Added diff --git a/src/textual/__init__.py b/src/textual/__init__.py index 3ef972f5e6..f0b587248b 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -8,6 +8,7 @@ from __future__ import annotations import inspect +import weakref from typing import TYPE_CHECKING, Callable import rich.repr @@ -35,6 +36,8 @@ if TYPE_CHECKING: from importlib.metadata import version + from textual.app import App + __version__ = version("textual") """The version of Textual.""" @@ -62,10 +65,17 @@ def __init__( log_callable: LogCallable | None, group: LogGroup = LogGroup.INFO, verbosity: LogVerbosity = LogVerbosity.NORMAL, + app: App | None = None, ) -> None: self._log = log_callable self._group = group self._verbosity = verbosity + self._app = None if app is None else weakref.ref(app) + + @property + def app(self) -> App | None: + """The associated application, or `None` if there isn't one.""" + return None if self._app is None else self._app() def __rich_repr__(self) -> rich.repr.Result: yield self._group, LogGroup.INFO @@ -82,17 +92,20 @@ def __call__(self, *args: object, **kwargs) -> None: with open(constants.LOG_FILE, "a") as log_file: print(output, file=log_file) - try: - app = active_app.get() - except LookupError: - if constants.DEBUG: - print_args = ( - *args, - *[f"{key}={value!r}" for key, value in kwargs.items()], - ) - print(*print_args) - return - if app.devtools is None or not app.devtools.is_connected: + + app = self.app + if app is None: + try: + app = active_app.get() + except LookupError: + if constants.DEBUG: + print_args = ( + *args, + *[f"{key}={value!r}" for key, value in kwargs.items()], + ) + print(*print_args) + return + if not app._is_devtools_connected: return current_frame = inspect.currentframe() @@ -129,52 +142,52 @@ def verbosity(self, verbose: bool) -> Logger: New logger. """ verbosity = LogVerbosity.HIGH if verbose else LogVerbosity.NORMAL - return Logger(self._log, self._group, verbosity) + return Logger(self._log, self._group, verbosity, app=self.app) @property def verbose(self) -> Logger: """A verbose logger.""" - return Logger(self._log, self._group, LogVerbosity.HIGH) + return Logger(self._log, self._group, LogVerbosity.HIGH, app=self.app) @property def event(self) -> Logger: """Logs events.""" - return Logger(self._log, LogGroup.EVENT) + return Logger(self._log, LogGroup.EVENT, app=self.app) @property def debug(self) -> Logger: """Logs debug messages.""" - return Logger(self._log, LogGroup.DEBUG) + return Logger(self._log, LogGroup.DEBUG, app=self.app) @property def info(self) -> Logger: """Logs information.""" - return Logger(self._log, LogGroup.INFO) + return Logger(self._log, LogGroup.INFO, app=self.app) @property def warning(self) -> Logger: """Logs warnings.""" - return Logger(self._log, LogGroup.WARNING) + return Logger(self._log, LogGroup.WARNING, app=self.app) @property def error(self) -> Logger: """Logs errors.""" - return Logger(self._log, LogGroup.ERROR) + return Logger(self._log, LogGroup.ERROR, app=self.app) @property def system(self) -> Logger: """Logs system information.""" - return Logger(self._log, LogGroup.SYSTEM) + return Logger(self._log, LogGroup.SYSTEM, app=self.app) @property def logging(self) -> Logger: """Logs from stdlib logging module.""" - return Logger(self._log, LogGroup.LOGGING) + return Logger(self._log, LogGroup.LOGGING, app=self.app) @property def worker(self) -> Logger: """Logs worker information.""" - return Logger(self._log, LogGroup.WORKER) + return Logger(self._log, LogGroup.WORKER, app=self.app) log = Logger(None) @@ -185,4 +198,10 @@ def worker(self) -> Logger: from textual import log log(locals()) ``` + +!!! note + This logger will only work if there is an active app in the current thread. + Use `app.log` to write logs from a thread without an active app. + + """ diff --git a/src/textual/app.py b/src/textual/app.py index 87370c5152..c57b254d7f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -682,7 +682,7 @@ def __init__( will be ignored. """ - self._logger = Logger(self._log) + self._logger = Logger(self._log, app=self) self._css_has_errors = False @@ -842,6 +842,11 @@ def __init__( ) ) + @property + def _is_devtools_connected(self) -> bool: + """Is the app connected to the devtools?""" + return self.devtools is not None and self.devtools.is_connected + @cached_property def _exception_event(self) -> asyncio.Event: """An event that will be set when the first exception is encountered.""" diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000000..7522a2a0dc --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,42 @@ +import inspect +from typing import Any + +from textual import work +from textual._log import LogGroup, LogVerbosity +from textual.app import App + + +async def test_log_from_worker() -> None: + """Check that log calls from threaded workers call app._log""" + + log_messages: list[tuple] = [] + + class LogApp(App): + + def _log( + self, + group: LogGroup, + verbosity: LogVerbosity, + _textual_calling_frame: inspect.Traceback, + *objects: Any, + **kwargs, + ) -> None: + log_messages.append(objects) + + @property + def _is_devtools_connected(self): + """Fake connected devtools.""" + return True + + def on_mount(self) -> None: + self.do_work() + self.call_after_refresh(self.exit) + + @work(thread=True) + def do_work(self) -> None: + self.log("HELLO from do_work") + + app = LogApp() + async with app.run_test() as pilot: + await pilot.pause() + assert ("HELLO from do_work",) in log_messages