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 1 commit
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
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
66 changes: 66 additions & 0 deletions src/textual/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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:
_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
9 changes: 6 additions & 3 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 @@ -648,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 @@ -844,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
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