diff --git a/src/textual/_context.py b/src/textual/_context.py index e16817631d4..04b264d3316 100644 --- a/src/textual/_context.py +++ b/src/textual/_context.py @@ -5,4 +5,9 @@ if TYPE_CHECKING: from .app import App + +class NoActiveAppError(RuntimeError): + pass + + active_app: ContextVar["App"] = ContextVar("active_app") diff --git a/src/textual/app.py b/src/textual/app.py index 38d49e3b495..443b96a5c12 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -684,6 +684,7 @@ def _print_error_renderables(self) -> None: self._exit_renderables.clear() async def process_messages(self) -> None: + self._set_active() if self.devtools_enabled: diff --git a/src/textual/dom.py b/src/textual/dom.py index 23b51dbaa8b..01b9068efb1 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -9,6 +9,7 @@ from rich.text import Text from rich.tree import Tree +from ._context import NoActiveAppError from ._node_list import NodeList from .color import Color from .css._error_tools import friendly_list @@ -452,7 +453,7 @@ def add_class(self, *class_names: str) -> None: try: self.app.stylesheet.update(self.app, animate=True) self.refresh() - except LookupError: + except NoActiveAppError: pass def remove_class(self, *class_names: str) -> None: @@ -466,7 +467,7 @@ def remove_class(self, *class_names: str) -> None: try: self.app.stylesheet.update(self.app, animate=True) self.refresh() - except LookupError: + except NoActiveAppError: pass def toggle_class(self, *class_names: str) -> None: @@ -480,7 +481,7 @@ def toggle_class(self, *class_names: str) -> None: try: self.app.stylesheet.update(self.app, animate=True) self.refresh() - except LookupError: + except NoActiveAppError: pass def has_pseudo_class(self, *class_names: str) -> bool: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 9fefc5ec3f4..c4ae0a08a09 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -12,7 +12,7 @@ from . import log from ._timer import Timer, TimerCallback from ._callback import invoke -from ._context import active_app +from ._context import active_app, NoActiveAppError from .message import Message from . import messages @@ -74,8 +74,16 @@ def has_parent(self) -> bool: @property def app(self) -> "App": - """Get the current app.""" - return active_app.get() + """ + Get the current app. + + Raises: + NoActiveAppError: if no active app could be found for the current asyncio context + """ + try: + return active_app.get() + except LookupError: + raise NoActiveAppError() @property def is_parent_active(self): @@ -152,7 +160,13 @@ def set_timer( pause: bool = False, ) -> Timer: timer = Timer( - self, delay, self, name=name, callback=callback, repeat=0, pause=pause + self, + delay, + self, + name=name or f"set_timer#{Timer._timer_count}", + callback=callback, + repeat=0, + pause=pause, ) self._child_tasks.add(timer.start()) return timer @@ -170,7 +184,7 @@ def set_interval( self, interval, self, - name=name, + name=name or f"set_interval#{Timer._timer_count}", callback=callback, repeat=repeat or None, pause=pause, diff --git a/src/textual/screen.py b/src/textual/screen.py index cad5be6c39f..5b731e86267 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -165,7 +165,7 @@ async def handle_layout(self, message: messages.Layout) -> None: def on_mount(self, event: events.Mount) -> None: self._update_timer = self.set_interval( - UPDATE_PERIOD, self._on_update, pause=True + UPDATE_PERIOD, self._on_update, name=f"screen_update", pause=True ) async def on_resize(self, event: events.Resize) -> None: diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py index ec6291de9b2..a2a41aa8fba 100644 --- a/tests/utilities/test_app.py +++ b/tests/utilities/test_app.py @@ -13,7 +13,8 @@ from rich.console import Console from textual import events -from textual.app import App, ComposeResult +from textual.app import App, ComposeResult, WINDOWS +from textual._context import active_app from textual.driver import Driver from textual.geometry import Size @@ -77,7 +78,7 @@ def in_running_state( self, *, time_mocking_ticks_granularity_fps: int = 60, # i.e. when moving forward by 1 second we'll do it though 60 ticks - waiting_duration_after_initialisation: float = 0.1, + waiting_duration_after_initialisation: float = 1, waiting_duration_after_yield: float = 0, ) -> AsyncContextManager[MockedTimeMoveClockForward]: async def run_app() -> None: @@ -85,50 +86,35 @@ async def run_app() -> None: @contextlib.asynccontextmanager async def get_running_state_context_manager(): - self._set_active() - with mock_textual_timers( ticks_granularity_fps=time_mocking_ticks_granularity_fps - ) as move_time_forward: + ) as move_clock_forward: run_task = asyncio.create_task(run_app()) - await asyncio.sleep(0.001) - # timeout_before_yielding_task = asyncio.create_task( - # asyncio.sleep(waiting_duration_after_initialisation) - # ) - # done, pending = await asyncio.wait( - # ( - # run_task, - # timeout_before_yielding_task, - # ), - # return_when=asyncio.FIRST_COMPLETED, - # ) - # if run_task in done or run_task not in pending: - # raise RuntimeError( - # "TestApp is no longer running after its initialization period" - # ) - - await move_time_forward(seconds=waiting_duration_after_initialisation) - assert self._driver is not None + # We have to do this because `run_app()` is running in its own async task, and our test is going to + # run in this one - so the app must also be the active App in our current context: + self._set_active() - self.force_screen_update() + await move_clock_forward(seconds=waiting_duration_after_initialisation) + # make sure the App has entered its main loop at this stage: + assert self._driver is not None - yield move_time_forward + await self.force_screen_update() - await move_time_forward(seconds=waiting_duration_after_yield) + # And now it's time to pass the torch on to the test function! + # We provide the `move_clock_forward` function to it, + # so it can also do some time-based Textual stuff if it needs to: + yield move_clock_forward - self.force_screen_update() - # waiting_duration = max( - # waiting_duration_post_yield or 0, - # self.screen._update_timer._interval, - # ) - # await asyncio.sleep(waiting_duration) + await move_clock_forward(seconds=waiting_duration_after_yield) - # if force_timers_tick_after_yield: - # await textual_timers_force_tick() + # Make sure our screen is up to date before exiting the context manager, + # so tests using our `last_display_capture` for example can assert things on an up to date screen: + await self.force_screen_update() - assert not run_task.done() - await self.shutdown() + # End of simulated time: we just shut down ourselves: + assert not run_task.done() + await self.shutdown() return get_running_state_context_manager() @@ -145,12 +131,17 @@ async def boot_and_shutdown( ): pass - def force_screen_update(self, *, repaint: bool = True, layout: bool = True) -> None: + async def force_screen_update( + self, *, repaint: bool = True, layout: bool = True + ) -> None: try: - self.screen.refresh(repaint=repaint, layout=layout) - self.screen._on_update() + screen = self.screen except IndexError: - pass # the app may not have a screen yet + return # the app may not have a screen yet + screen.refresh(repaint=repaint, layout=layout) + screen._on_update() + + await let_asyncio_process_some_events() def on_exception(self, error: Exception) -> None: # In tests we want the errors to be raised, rather than printed to a Console @@ -161,6 +152,10 @@ def run(self): "Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`" ) + @property + def active_app(self) -> App | None: + return active_app.get() + @property def total_capture(self) -> str | None: return self.console.file.getvalue() @@ -229,6 +224,16 @@ def stop_application_mode(self) -> None: pass +# > The resolution of the monotonic clock on Windows is usually around 15.6 msec. +# > The best resolution is 0.5 msec. +# @link https://docs.python.org/3/library/asyncio-platforms.html: +ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD = 0.025 if WINDOWS else 0.002 + + +async def let_asyncio_process_some_events() -> None: + await asyncio.sleep(ASYNCIO_EVENTS_PROCESSING_REQUIRED_PERIOD) + + def mock_textual_timers( *, ticks_granularity_fps: int = 60, @@ -248,11 +253,17 @@ async def sleep_mock(duration: float) -> None: target_event_monotonic_time = current_time + duration pending_sleep_events.append((target_event_monotonic_time, event)) # Ok, let's wait for this Event - # - which can only be "unlocked" by calls to `move_clock_forward()` + # (which can only be "unlocked" by calls to `move_clock_forward()`) await event.wait() # Our replacement for "textual._timer.Timer.get_time" and "textual.message.Message._get_time": def get_time_mock() -> float: + nonlocal current_time + + # let's make the time advance slightly between 2 consecutive calls of this function, + # within the same order of magnitude than 2 consecutive calls to ` timer.monotonic()`: + current_time += 1.1e-06 + return current_time async def move_clock_forward(*, seconds: float) -> tuple[float, int]: @@ -262,11 +273,14 @@ async def move_clock_forward(*, seconds: float) -> tuple[float, int]: activated_timers_count_total = 0 for tick_counter in range(ticks_count): current_time += single_tick_duration - activated_timers_count_total += check_sleep_timers_to_activate() + activated_timers_count = check_sleep_timers_to_activate() + activated_timers_count_total += activated_timers_count + # Let's give an opportunity to asyncio-related stuff to happen, + # now that we likely unlocked some occurrences of `await sleep(duration)`: + if activated_timers_count: + await let_asyncio_process_some_events() - # Let's give an opportunity to asyncio-related stuff to happen, - # now that we unlocked some occurrences of `await sleep(duration)`: - await asyncio.sleep(0.0001) + await let_asyncio_process_some_events() return current_time, activated_timers_count_total @@ -277,8 +291,8 @@ def check_sleep_timers_to_activate() -> int: for i, (target_event_monotonic_time, event) in enumerate( pending_sleep_events ): - if target_event_monotonic_time < current_time: - continue + if current_time < target_event_monotonic_time: + continue # not time for you yet, dear awaiter... # Right, let's release this waiting event! event.set() activated_timers_count += 1