Repository: Textualize/textual

Issue:
textual.log from worker thread is not working
Since textual 3.2.0 calling `textual.log` from a worker thread not longer works. If I revert to textual 3.1.1 logging from worker threads work correctly.

Here is an example:

```
from datetime import datetime

from textual import log, work
from textual.app import App, ComposeResult
from textual.widgets import Digits


class ClockApp(App):
    CSS = """
    Screen { align: center middle; }
    Digits { width: auto; }
    """

    def compose(self) -> ComposeResult:
        yield Digits("")

    def on_ready(self) -> None:
        self.update_clock()
        self.set_interval(1, self.update_clock)

    def update_clock(self) -> None:
        clock = datetime.now().time()
        self.query_one(Digits).update(f"{clock:%T}")
        self.worker()

    @work(thread=True)
    async def worker(self) -> None:
        log("Log message")


if __name__ == "__main__":
    app = ClockApp()
    app.run()
```

The `Log message` is correctly logged in textual 3.1.1 but is not logged in textual 3.2.0


Discussion:
We found the following entries in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [Why do some key combinations never make it to my app?](https://textual.textualize.io/FAQ/#why-do-some-key-combinations-never-make-it-to-my-app)
- [Why doesn't Textual support ANSI themes?](https://textual.textualize.io/FAQ/#why-doesnt-textual-support-ansi-themes)
- [How do I fix WorkerDeclarationError?](https://textual.textualize.io/FAQ/#how-do-i-fix-workerdeclarationerror)
- [How can I select and copy text in a Textual app?](https://textual.textualize.io/FAQ/#how-can-i-select-and-copy-text-in-a-textual-app)
- [How can I set a translucent app background?](https://textual.textualize.io/FAQ/#how-can-i-set-a-translucent-app-background)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
You'll need to run with `TEXTUAL_DEBUG=1`. From the [release notes](https://github.com/Textualize/textual/releases/tag/v3.2.0) for Textual v3.2.0:

> Log messages could be written to stdout when there was no app, which could happen when using run_async or threads. Now they will be suppressed, unless the env var TEXTUAL_DEBUG is set
Thanks for the clarification. I read the release notes, but did not think that this would be affected.

When thinking about it, we could make the developer user experience slightly better. What I am doing (and have been doing since starting using textual) is to run my application like this:

```
textual run --dev ./my_app.py
```

Why doesn't this automatically enable the `TEXTUAL_DEBUG` variable? If you think it would be a good idea, I can take a look at doing a PR.
The change @TomJGooding highlighted was fixing an issue where log messages were written to stdout. The change fixed that, but breaking logging from threads was unintentional.

Fix is incoming, but note that you will need to use `app.log` rather than the global log.
Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.



Patch:
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."""


Test patch:
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