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 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