Skip to content

Commit 185ba76

Browse files
authored
Merge pull request #5799 from graingert/fix-get-event-loop-call
don't call get_event_loop() if it's deprecated, handle RuntimeError from get_event_loop after asyncio.run
2 parents 8461d1f + 3de7196 commit 185ba76

File tree

7 files changed

+152
-17
lines changed

7 files changed

+152
-17
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1212

1313
- Fixed `VERTICAL_BREAKPOINTS` doesn't work https://github.com/Textualize/textual/pull/5785
1414
- Fixed `Button` allowing text selection https://github.com/Textualize/textual/pull/5770
15+
- Fixed running `App.run` after `asyncio.run` https://github.com/Textualize/textual/pull/5799
16+
- Fixed triggering a deprecation warning in py >= 3.10 https://github.com/Textualize/textual/pull/5799
1517
- Fixed `Input` invalid cursor position after updating the value https://github.com/Textualize/textual/issues/5811
1618

1719
### Added

src/textual/_animator.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from textual import _time
1212
from textual._callback import invoke
13+
from textual._compat import cached_property
1314
from textual._easing import DEFAULT_EASING, EASING
1415
from textual._types import AnimationLevel, CallbackType
1516
from textual.timer import Timer
@@ -242,11 +243,16 @@ def __init__(self, app: App, frames_per_second: int = 60) -> None:
242243
callback=self,
243244
pause=True,
244245
)
246+
247+
@cached_property
248+
def _idle_event(self) -> asyncio.Event:
245249
"""The timer that runs the animator."""
246-
self._idle_event = asyncio.Event()
250+
return asyncio.Event()
251+
252+
@cached_property
253+
def _complete_event(self) -> asyncio.Event:
247254
"""Flag if no animations are currently taking place."""
248-
self._complete_event = asyncio.Event()
249-
"""Flag if no animations are currently taking place and none are scheduled."""
255+
return asyncio.Event()
250256

251257
async def start(self) -> None:
252258
"""Start the animator task."""

src/textual/_compat.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from typing import Any, Generic, TypeVar, overload
5+
6+
if sys.version_info >= (3, 12):
7+
from functools import cached_property
8+
else:
9+
# based on the code from Python 3.14:
10+
# https://github.com/python/cpython/blob/
11+
# 5507eff19c757a908a2ff29dfe423e35595fda00/Lib/functools.py#L1089-L1138
12+
# Copyright (C) 2006 Python Software Foundation.
13+
# vendored under the PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 because
14+
# prior to Python 3.12 cached_property used a threading.Lock, which makes
15+
# it very slow.
16+
_T_co = TypeVar("_T_co", covariant=True)
17+
_NOT_FOUND = object()
18+
19+
class cached_property(Generic[_T_co]):
20+
def __init__(self, func: Callable[[Any, _T_co]]) -> None:
21+
self.func = func
22+
self.attrname = None
23+
self.__doc__ = func.__doc__
24+
self.__module__ = func.__module__
25+
26+
def __set_name__(self, owner: type[any], name: str) -> None:
27+
if self.attrname is None:
28+
self.attrname = name
29+
elif name != self.attrname:
30+
raise TypeError(
31+
"Cannot assign the same cached_property to two different names "
32+
f"({self.attrname!r} and {name!r})."
33+
)
34+
35+
@overload
36+
def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ...
37+
38+
@overload
39+
def __get__(
40+
self, instance: object, owner: type[Any] | None = None
41+
) -> _T_co: ...
42+
43+
def __get__(
44+
self, instance: object, owner: type[Any] | None = None
45+
) -> _T_co | Self:
46+
if instance is None:
47+
return self
48+
if self.attrname is None:
49+
raise TypeError(
50+
"Cannot use cached_property instance without calling __set_name__ on it."
51+
)
52+
try:
53+
cache = instance.__dict__
54+
except (
55+
AttributeError
56+
): # not all objects have __dict__ (e.g. class defines slots)
57+
msg = (
58+
f"No '__dict__' attribute on {type(instance).__name__!r} "
59+
f"instance to cache {self.attrname!r} property."
60+
)
61+
raise TypeError(msg) from None
62+
val = cache.get(self.attrname, _NOT_FOUND)
63+
if val is _NOT_FOUND:
64+
val = self.func(instance)
65+
try:
66+
cache[self.attrname] = val
67+
except TypeError:
68+
msg = (
69+
f"The '__dict__' attribute on {type(instance).__name__!r} instance "
70+
f"does not support item assignment for caching {self.attrname!r} property."
71+
)
72+
raise TypeError(msg) from None
73+
return val

src/textual/app.py

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
from textual._ansi_sequences import SYNC_END, SYNC_START
7575
from textual._ansi_theme import ALABASTER, MONOKAI
7676
from textual._callback import invoke
77+
from textual._compat import cached_property
7778
from textual._compose import compose
7879
from textual._compositor import CompositorUpdate
7980
from textual._context import active_app, active_message_pump
@@ -150,6 +151,9 @@
150151
if constants.DEBUG:
151152
warnings.simplefilter("always", ResourceWarning)
152153

154+
# `asyncio.get_event_loop()` is deprecated since Python 3.10:
155+
_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0)
156+
153157
ComposeResult = Iterable[Widget]
154158
RenderResult: TypeAlias = "RenderableType | Visual | SupportsVisual"
155159
"""Result of Widget.render()"""
@@ -645,9 +649,6 @@ def __init__(
645649
"""The unhandled exception which is leading to the app shutting down,
646650
or None if the app is still running with no unhandled exceptions."""
647651

648-
self._exception_event: asyncio.Event = asyncio.Event()
649-
"""An event that will be set when the first exception is encountered."""
650-
651652
self.title = (
652653
self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}"
653654
)
@@ -841,6 +842,11 @@ def __init__(
841842
)
842843
)
843844

845+
@cached_property
846+
def _exception_event(self) -> asyncio.Event:
847+
"""An event that will be set when the first exception is encountered."""
848+
return asyncio.Event()
849+
844850
def __init_subclass__(cls, *args, **kwargs) -> None:
845851
for variable_name, screen_collection in (
846852
("SCREENS", cls.SCREENS),
@@ -2140,9 +2146,9 @@ def run(
21402146
App return value.
21412147
"""
21422148

2143-
async def run_app() -> None:
2149+
async def run_app() -> ReturnType | None:
21442150
"""Run the app."""
2145-
await self.run_async(
2151+
return await self.run_async(
21462152
headless=headless,
21472153
inline=inline,
21482154
inline_no_clear=inline_no_clear,
@@ -2151,9 +2157,24 @@ async def run_app() -> None:
21512157
auto_pilot=auto_pilot,
21522158
)
21532159

2154-
event_loop = asyncio.get_event_loop() if loop is None else loop
2155-
event_loop.run_until_complete(run_app())
2156-
return self.return_value
2160+
if loop is None:
2161+
if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
2162+
# N.B. This does work with Python<3.10, but global Locks, Events, etc
2163+
# eagerly bind the event loop, and result in Future bound to wrong
2164+
# loop errors.
2165+
return asyncio.run(run_app())
2166+
try:
2167+
global_loop = asyncio.get_event_loop()
2168+
except RuntimeError:
2169+
# the global event loop may have been destroyed by someone running
2170+
# asyncio.run(), or asyncio.set_event_loop(None), in which case
2171+
# we need to use asyncio.run() also. (We run this outside the
2172+
# context of an exception handler)
2173+
pass
2174+
else:
2175+
return global_loop.run_until_complete(run_app())
2176+
return asyncio.run(run_app())
2177+
return loop.run_until_complete(run_app())
21572178

21582179
async def _on_css_change(self) -> None:
21592180
"""Callback for the file monitor, called when CSS files change."""

src/textual/message_pump.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
from __future__ import annotations
1212

1313
import asyncio
14+
import sys
1415
import threading
15-
from asyncio import CancelledError, Queue, QueueEmpty, Task, create_task
16+
from asyncio import CancelledError, QueueEmpty, Task, create_task
1617
from contextlib import contextmanager
1718
from functools import partial
1819
from time import perf_counter
@@ -22,15 +23,18 @@
2223
Awaitable,
2324
Callable,
2425
Generator,
26+
Generic,
2527
Iterable,
2628
Type,
2729
TypeVar,
2830
cast,
31+
overload,
2932
)
3033
from weakref import WeakSet
3134

3235
from textual import Logger, events, log, messages
3336
from textual._callback import invoke
37+
from textual._compat import cached_property
3438
from textual._context import NoActiveAppError, active_app, active_message_pump
3539
from textual._context import message_hook as message_hook_context_var
3640
from textual._context import prevent_message_types_stack
@@ -114,7 +118,6 @@ class MessagePump(metaclass=_MessagePumpMeta):
114118
"""Base class which supplies a message pump."""
115119

116120
def __init__(self, parent: MessagePump | None = None) -> None:
117-
self._message_queue: Queue[Message | None] = Queue()
118121
self._parent = parent
119122
self._running: bool = False
120123
self._closing: bool = False
@@ -125,7 +128,6 @@ def __init__(self, parent: MessagePump | None = None) -> None:
125128
self._timers: WeakSet[Timer] = WeakSet()
126129
self._last_idle: float = time()
127130
self._max_idle: float | None = None
128-
self._mounted_event = asyncio.Event()
129131
self._is_mounted = False
130132
"""Having this explicit Boolean is an optimization.
131133
@@ -143,6 +145,14 @@ def __init__(self, parent: MessagePump | None = None) -> None:
143145
144146
"""
145147

148+
@cached_property
149+
def _message_queue(self) -> asyncio.Queue[Message | None]:
150+
return asyncio.Queue()
151+
152+
@cached_property
153+
def _mounted_event(self) -> asyncio.Event:
154+
return asyncio.Event()
155+
146156
@property
147157
def _prevent_message_types_stack(self) -> list[set[type[Message]]]:
148158
"""The stack that manages prevented messages."""

src/textual/timer.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from textual import _time, events
1717
from textual._callback import invoke
18+
from textual._compat import cached_property
1819
from textual._context import active_app
1920
from textual._time import sleep
2021
from textual._types import MessageTarget
@@ -62,11 +63,16 @@ def __init__(
6263
self._callback = callback
6364
self._repeat = repeat
6465
self._skip = skip
65-
self._active = Event()
6666
self._task: Task | None = None
6767
self._reset: bool = False
68-
if not pause:
69-
self._active.set()
68+
self._original_pause = pause
69+
70+
@cached_property
71+
def _active(self) -> Event:
72+
event = Event()
73+
if not self._original_pause:
74+
event.set()
75+
return event
7076

7177
def __rich_repr__(self) -> Result:
7278
yield self._interval

tests/test_app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,20 @@ def on_mount(self) -> None:
367367
app = MyApp()
368368
result = await app.run_async()
369369
assert result == 42
370+
371+
372+
def test_app_loop_run_after_asyncio_run() -> None:
373+
"""Test that App.run runs after asyncio.run has run."""
374+
375+
class MyApp(App[int]):
376+
def on_mount(self) -> None:
377+
self.exit(42)
378+
379+
async def amain():
380+
pass
381+
382+
asyncio.run(amain())
383+
384+
app = MyApp()
385+
result = app.run()
386+
assert result == 42

0 commit comments

Comments
 (0)