Skip to content

don't call get_event_loop() if it's deprecated, handle RuntimeError from get_event_loop after asyncio.run #5799

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
May 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/textual/_animator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
73 changes: 73 additions & 0 deletions src/textual/_compat.py
Original file line number Diff line number Diff line change
@@ -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
37 changes: 29 additions & 8 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()"""
Expand Down Expand Up @@ -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__}"
)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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."""
Expand Down
16 changes: 13 additions & 3 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand All @@ -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."""
Expand Down
12 changes: 9 additions & 3 deletions src/textual/timer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading