From b1e42bd9cd6ce8a113e1c244c65ddd09585f44a9 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sun, 1 Jun 2025 10:24:45 +0100 Subject: [PATCH 1/3] defer ready_event until connecting --- src/textual_dev/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual_dev/client.py b/src/textual_dev/client.py index d23f970..a2972e4 100644 --- a/src/textual_dev/client.py +++ b/src/textual_dev/client.py @@ -103,7 +103,6 @@ def __init__(self, host: str = "127.0.0.1", port: int | None = None) -> None: self.log_queue: Queue[str | bytes | Type[ClientShutdown]] | None = None self.spillover: int = 0 self.verbose: bool = False - self._ready_event: asyncio.Event = asyncio.Event() async def connect(self) -> None: """Connect to the devtools server. @@ -124,6 +123,7 @@ async def connect(self) -> None: log_queue = self.log_queue websocket = self.websocket + ready_event = asyncio.Event() async def update_console() -> None: """Coroutine function scheduled as a Task, which listens on @@ -141,7 +141,7 @@ async def update_console() -> None: self.console.width = payload["width"] self.console.height = payload["height"] self.verbose = payload.get("verbose", False) - self._ready_event.set() + ready_event.set() async def send_queued_logs(): """Coroutine function which is scheduled as a Task, which consumes @@ -162,7 +162,7 @@ async def send_queued_logs(): async def server_info_received() -> None: """Wait for the first server info message to be received and handled.""" try: - await asyncio.wait_for(self._ready_event.wait(), timeout=READY_TIMEOUT) + await asyncio.wait_for(ready_event.wait(), timeout=READY_TIMEOUT) except asyncio.TimeoutError: return From 2a7626d06ed3ce8c8cf0923869a5cd687ccc833b Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sun, 1 Jun 2025 10:28:33 +0100 Subject: [PATCH 2/3] lazily create shutdown event --- src/textual_dev/_compat.py | 75 ++++++++++++++++++++++++++++++++++++++ src/textual_dev/service.py | 6 ++- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/textual_dev/_compat.py diff --git a/src/textual_dev/_compat.py b/src/textual_dev/_compat.py new file mode 100644 index 0000000..4a64b86 --- /dev/null +++ b/src/textual_dev/_compat.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import sys +from typing import Any, Generic, TypeVar, overload + +__all__ = ["cached_property"] + +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_dev/service.py b/src/textual_dev/service.py index 84e0080..c92ac5e 100644 --- a/src/textual_dev/service.py +++ b/src/textual_dev/service.py @@ -18,6 +18,7 @@ from textual._time import time from textual_dev.renderables import DevConsoleHeader, DevConsoleLog, DevConsoleNotice +from textual_dev._compat import cached_property QUEUEABLE_TYPES = {"client_log", "client_spillover"} @@ -44,9 +45,12 @@ def __init__( self.verbose = verbose self.exclude = {name.upper() for name in exclude} if exclude else set() self.console = Console() - self.shutdown_event = asyncio.Event() self.clients: list[ClientHandler] = [] + @cached_property + def shutdown_event(self) -> asyncio.Event: + return asyncio.Event() + async def start(self) -> None: """Starts devtools tasks""" self.size_poll_task = asyncio.create_task(self._console_size_poller()) From ec27c5fea99ad9b41eca588025cce7a74c60b81e Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sun, 1 Jun 2025 12:03:26 +0100 Subject: [PATCH 3/3] test that DevtoolsClient() can be constructed after asyncio.run --- tests/devtools/test_devtools_client.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/devtools/test_devtools_client.py b/tests/devtools/test_devtools_client.py index 856e8df..307dadc 100644 --- a/tests/devtools/test_devtools_client.py +++ b/tests/devtools/test_devtools_client.py @@ -1,6 +1,6 @@ import json import types -from asyncio import Queue +import asyncio from datetime import datetime import msgpack @@ -23,6 +23,16 @@ def test_devtools_client_initialize_defaults(): assert devtools.url == f"ws://127.0.0.1:{DEVTOOLS_PORT}" +def test_devtools_client_initialize_defaults_after_asyncio_run(): + async def amain(): + pass + + asyncio.run(amain()) + + devtools = DevtoolsClient() + assert devtools.url == f"ws://127.0.0.1:{DEVTOOLS_PORT}" + + async def test_devtools_client_is_connected(devtools): assert devtools.is_connected @@ -59,7 +69,7 @@ async def test_devtools_log_places_encodes_and_queues_many_logs_as_string(devtoo async def test_devtools_log_spillover(devtools): # Give the devtools an intentionally small max queue size await devtools._stop_log_queue_processing() - devtools.log_queue = Queue(maxsize=2) + devtools.log_queue = asyncio.Queue(maxsize=2) # Force spillover of 2 devtools.log(DevtoolsLog((Panel("hello, world"),), CALLER))