Repository: Textualize/textual

Issue:
Deprecation in Python 3.14 will break `python -m textual` and `App.run()`
## The bug

Python 3.14 removes the ability to use `get_event_loop` to implicitly create a loop. You can get the same failure with `-Werror` on Python 3.13.

This means `python -m textual` errors in 3.14 and `App.run()` will fail if not given a loop:

```python
$ python -m textual
Traceback (most recent call last):
  File "<frozen runpy>", line 198, in _run_module_as_main
  File "<frozen runpy>", line 88, in _run_code
  File "C:\Users\ducks\Source\ducktools-pytui\.venv_textualtest\Lib\site-packages\textual\__main__.py", line 5, in <module>
    app.run()
    ~~~~~~~^^
  File "C:\Users\ducks\Source\ducktools-pytui\.venv_textualtest\Lib\site-packages\textual\app.py", line 2154, in run
    event_loop = asyncio.get_event_loop() if loop is None else loop
                 ~~~~~~~~~~~~~~~~~~~~~~^^
  File "C:\Users\ducks\AppData\Local\Python\pythoncore-3.14-64\Lib\asyncio\events.py", line 718, in get_event_loop
    raise RuntimeError('There is no current event loop in thread %r.'
                       % threading.current_thread().name)
RuntimeError: There is no current event loop in thread 'MainThread'.
```

It looks like the logic that defended against this was removed recently: https://github.com/Textualize/textual/commit/4e4e2a377bee9d6885e7a31c05b97fb07e4f73e3



Discussion:
Thank you for your issue. Give us a little time to review it.

PS. You might want to check the [FAQ](https://textual.textualize.io/FAQ/) if you haven't done so already.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
Dup of https://github.com/Textualize/textual/issues/5788



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1be1fb908..041c7ff433 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 - Fixed `VERTICAL_BREAKPOINTS` doesn't work https://github.com/Textualize/textual/pull/5785
 - Fixed `Button` allowing text selection https://github.com/Textualize/textual/pull/5770
+- Fixed running `App.run` after `asyncio.run` https://github.com/Textualize/textual/pull/5799
+- Fixed triggering a deprecation warning in py >= 3.10 https://github.com/Textualize/textual/pull/5799
 - Fixed `Input` invalid cursor position after updating the value https://github.com/Textualize/textual/issues/5811
 
 ### Added
diff --git a/src/textual/_animator.py b/src/textual/_animator.py
index 6d13c288c2..75927ec9a4 100644
--- a/src/textual/_animator.py
+++ b/src/textual/_animator.py
@@ -10,6 +10,7 @@
 
 from textual import _time
 from textual._callback import invoke
+from textual._compat import cached_property
 from textual._easing import DEFAULT_EASING, EASING
 from textual._types import AnimationLevel, CallbackType
 from textual.timer import Timer
@@ -242,11 +243,16 @@ def __init__(self, app: App, frames_per_second: int = 60) -> None:
             callback=self,
             pause=True,
         )
+
+    @cached_property
+    def _idle_event(self) -> asyncio.Event:
         """The timer that runs the animator."""
-        self._idle_event = asyncio.Event()
+        return asyncio.Event()
+
+    @cached_property
+    def _complete_event(self) -> asyncio.Event:
         """Flag if no animations are currently taking place."""
-        self._complete_event = asyncio.Event()
-        """Flag if no animations are currently taking place and none are scheduled."""
+        return asyncio.Event()
 
     async def start(self) -> None:
         """Start the animator task."""
diff --git a/src/textual/_compat.py b/src/textual/_compat.py
new file mode 100644
index 0000000000..32d7d7d136
--- /dev/null
+++ b/src/textual/_compat.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import sys
+from typing import Any, Generic, TypeVar, overload
+
+if sys.version_info >= (3, 12):
+    from functools import cached_property
+else:
+    # based on the code from Python 3.14:
+    # https://github.com/python/cpython/blob/
+    # 5507eff19c757a908a2ff29dfe423e35595fda00/Lib/functools.py#L1089-L1138
+    # Copyright (C) 2006 Python Software Foundation.
+    # vendored under the PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 because
+    # prior to Python 3.12 cached_property used a threading.Lock, which makes
+    # it very slow.
+    _T_co = TypeVar("_T_co", covariant=True)
+    _NOT_FOUND = object()
+
+    class cached_property(Generic[_T_co]):
+        def __init__(self, func: Callable[[Any, _T_co]]) -> None:
+            self.func = func
+            self.attrname = None
+            self.__doc__ = func.__doc__
+            self.__module__ = func.__module__
+
+        def __set_name__(self, owner: type[any], name: str) -> None:
+            if self.attrname is None:
+                self.attrname = name
+            elif name != self.attrname:
+                raise TypeError(
+                    "Cannot assign the same cached_property to two different names "
+                    f"({self.attrname!r} and {name!r})."
+                )
+
+        @overload
+        def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ...
+
+        @overload
+        def __get__(
+            self, instance: object, owner: type[Any] | None = None
+        ) -> _T_co: ...
+
+        def __get__(
+            self, instance: object, owner: type[Any] | None = None
+        ) -> _T_co | Self:
+            if instance is None:
+                return self
+            if self.attrname is None:
+                raise TypeError(
+                    "Cannot use cached_property instance without calling __set_name__ on it."
+                )
+            try:
+                cache = instance.__dict__
+            except (
+                AttributeError
+            ):  # not all objects have __dict__ (e.g. class defines slots)
+                msg = (
+                    f"No '__dict__' attribute on {type(instance).__name__!r} "
+                    f"instance to cache {self.attrname!r} property."
+                )
+                raise TypeError(msg) from None
+            val = cache.get(self.attrname, _NOT_FOUND)
+            if val is _NOT_FOUND:
+                val = self.func(instance)
+                try:
+                    cache[self.attrname] = val
+                except TypeError:
+                    msg = (
+                        f"The '__dict__' attribute on {type(instance).__name__!r} instance "
+                        f"does not support item assignment for caching {self.attrname!r} property."
+                    )
+                    raise TypeError(msg) from None
+            return val
diff --git a/src/textual/app.py b/src/textual/app.py
index 9ae50e7421..ba87005aba 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -74,6 +74,7 @@
 from textual._ansi_sequences import SYNC_END, SYNC_START
 from textual._ansi_theme import ALABASTER, MONOKAI
 from textual._callback import invoke
+from textual._compat import cached_property
 from textual._compose import compose
 from textual._compositor import CompositorUpdate
 from textual._context import active_app, active_message_pump
@@ -150,6 +151,9 @@
 if constants.DEBUG:
     warnings.simplefilter("always", ResourceWarning)
 
+# `asyncio.get_event_loop()` is deprecated since Python 3.10:
+_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0)
+
 ComposeResult = Iterable[Widget]
 RenderResult: TypeAlias = "RenderableType | Visual | SupportsVisual"
 """Result of Widget.render()"""
@@ -645,9 +649,6 @@ def __init__(
         """The unhandled exception which is leading to the app shutting down,
         or None if the app is still running with no unhandled exceptions."""
 
-        self._exception_event: asyncio.Event = asyncio.Event()
-        """An event that will be set when the first exception is encountered."""
-
         self.title = (
             self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}"
         )
@@ -841,6 +842,11 @@ def __init__(
                     )
                 )
 
+    @cached_property
+    def _exception_event(self) -> asyncio.Event:
+        """An event that will be set when the first exception is encountered."""
+        return asyncio.Event()
+
     def __init_subclass__(cls, *args, **kwargs) -> None:
         for variable_name, screen_collection in (
             ("SCREENS", cls.SCREENS),
@@ -2140,9 +2146,9 @@ def run(
             App return value.
         """
 
-        async def run_app() -> None:
+        async def run_app() -> ReturnType | None:
             """Run the app."""
-            await self.run_async(
+            return await self.run_async(
                 headless=headless,
                 inline=inline,
                 inline_no_clear=inline_no_clear,
@@ -2151,9 +2157,24 @@ async def run_app() -> None:
                 auto_pilot=auto_pilot,
             )
 
-        event_loop = asyncio.get_event_loop() if loop is None else loop
-        event_loop.run_until_complete(run_app())
-        return self.return_value
+        if loop is None:
+            if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
+                # N.B. This does work with Python<3.10, but global Locks, Events, etc
+                # eagerly bind the event loop, and result in Future bound to wrong
+                # loop errors.
+                return asyncio.run(run_app())
+            try:
+                global_loop = asyncio.get_event_loop()
+            except RuntimeError:
+                # the global event loop may have been destroyed by someone running
+                # asyncio.run(), or asyncio.set_event_loop(None), in which case
+                # we need to use asyncio.run() also. (We run this outside the
+                # context of an exception handler)
+                pass
+            else:
+                return global_loop.run_until_complete(run_app())
+            return asyncio.run(run_app())
+        return loop.run_until_complete(run_app())
 
     async def _on_css_change(self) -> None:
         """Callback for the file monitor, called when CSS files change."""
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 9ec49f9047..7a1e3bde7d 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -11,8 +11,9 @@
 from __future__ import annotations
 
 import asyncio
+import sys
 import threading
-from asyncio import CancelledError, Queue, QueueEmpty, Task, create_task
+from asyncio import CancelledError, QueueEmpty, Task, create_task
 from contextlib import contextmanager
 from functools import partial
 from time import perf_counter
@@ -22,15 +23,18 @@
     Awaitable,
     Callable,
     Generator,
+    Generic,
     Iterable,
     Type,
     TypeVar,
     cast,
+    overload,
 )
 from weakref import WeakSet
 
 from textual import Logger, events, log, messages
 from textual._callback import invoke
+from textual._compat import cached_property
 from textual._context import NoActiveAppError, active_app, active_message_pump
 from textual._context import message_hook as message_hook_context_var
 from textual._context import prevent_message_types_stack
@@ -114,7 +118,6 @@ class MessagePump(metaclass=_MessagePumpMeta):
     """Base class which supplies a message pump."""
 
     def __init__(self, parent: MessagePump | None = None) -> None:
-        self._message_queue: Queue[Message | None] = Queue()
         self._parent = parent
         self._running: bool = False
         self._closing: bool = False
@@ -125,7 +128,6 @@ def __init__(self, parent: MessagePump | None = None) -> None:
         self._timers: WeakSet[Timer] = WeakSet()
         self._last_idle: float = time()
         self._max_idle: float | None = None
-        self._mounted_event = asyncio.Event()
         self._is_mounted = False
         """Having this explicit Boolean is an optimization.
 
@@ -143,6 +145,14 @@ def __init__(self, parent: MessagePump | None = None) -> None:
         
         """
 
+    @cached_property
+    def _message_queue(self) -> asyncio.Queue[Message | None]:
+        return asyncio.Queue()
+
+    @cached_property
+    def _mounted_event(self) -> asyncio.Event:
+        return asyncio.Event()
+
     @property
     def _prevent_message_types_stack(self) -> list[set[type[Message]]]:
         """The stack that manages prevented messages."""
diff --git a/src/textual/timer.py b/src/textual/timer.py
index 998662a24e..f4b7b1ac1c 100644
--- a/src/textual/timer.py
+++ b/src/textual/timer.py
@@ -15,6 +15,7 @@
 
 from textual import _time, events
 from textual._callback import invoke
+from textual._compat import cached_property
 from textual._context import active_app
 from textual._time import sleep
 from textual._types import MessageTarget
@@ -62,11 +63,16 @@ def __init__(
         self._callback = callback
         self._repeat = repeat
         self._skip = skip
-        self._active = Event()
         self._task: Task | None = None
         self._reset: bool = False
-        if not pause:
-            self._active.set()
+        self._original_pause = pause
+
+    @cached_property
+    def _active(self) -> Event:
+        event = Event()
+        if not self._original_pause:
+            event.set()
+        return event
 
     def __rich_repr__(self) -> Result:
         yield self._interval


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index 782b99e671..3c6d54aed8 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -367,3 +367,20 @@ def on_mount(self) -> None:
     app = MyApp()
     result = await app.run_async()
     assert result == 42
+
+
+def test_app_loop_run_after_asyncio_run() -> None:
+    """Test that App.run runs after asyncio.run has run."""
+
+    class MyApp(App[int]):
+        def on_mount(self) -> None:
+            self.exit(42)
+
+    async def amain():
+        pass
+
+    asyncio.run(amain())
+
+    app = MyApp()
+    result = app.run()
+    assert result == 42