From 094eef37b22698e429a0091f53b4ff8aec272c6d Mon Sep 17 00:00:00 2001 From: Ryan Meyers Date: Mon, 10 Nov 2025 10:15:35 -0600 Subject: [PATCH 1/8] python 3.14 deprecates eager event loop binding --- .gitignore | 3 ++ .python-version | 2 +- pyproject.toml | 3 +- src/textual_serve/_compat.py | 73 ++++++++++++++++++++++++++++++++ src/textual_serve/app_service.py | 22 +++++++--- src/textual_serve/server.py | 57 ++++++++++++------------- 6 files changed, 120 insertions(+), 40 deletions(-) create mode 100644 src/textual_serve/_compat.py diff --git a/.gitignore b/.gitignore index 2be5544..d062eed 100644 --- a/.gitignore +++ b/.gitignore @@ -161,3 +161,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# uv +uv.lock \ No newline at end of file diff --git a/.python-version b/.python-version index 371cfe3..6324d40 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.11.1 +3.14 diff --git a/pyproject.toml b/pyproject.toml index 62b6804..e765a22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ dependencies = [ "aiohttp-jinja2>=1.6", "jinja2>=3.1.4", "rich", - "textual>=0.66.0", + "textual>=0.66.0; python_version < '3.14'", + "textual>=6.3.0; python_version >= '3.14'", ] readme = "README.md" requires-python = ">= 3.9" diff --git a/src/textual_serve/_compat.py b/src/textual_serve/_compat.py new file mode 100644 index 0000000..9f660f0 --- /dev/null +++ b/src/textual_serve/_compat.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import sys +from typing import Any, Callable, Generic, Self, 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_serve/app_service.py b/src/textual_serve/app_service.py index e15b007..f2457bd 100644 --- a/src/textual_serve/app_service.py +++ b/src/textual_serve/app_service.py @@ -1,22 +1,26 @@ from __future__ import annotations -from pathlib import Path import asyncio import io import json +import logging import os -from typing import Awaitable, Callable, Literal +import sys +import uuid from asyncio.subprocess import Process -import logging - from importlib.metadata import version -import uuid +from pathlib import Path +from typing import Awaitable, Callable, Literal -from textual_serve.download_manager import DownloadManager from textual_serve._binary_encode import load as binary_load +from textual_serve._compat import cached_property +from textual_serve.download_manager import DownloadManager log = logging.getLogger("textual-serve") +# `asyncio.get_event_loop()` is deprecated since Python 3.10: +_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0) + class AppService: """Creates and manages a single Textual app subprocess. @@ -51,9 +55,13 @@ def __init__( self._process: Process | None = None self._task: asyncio.Task[None] | None = None self._stdin: asyncio.StreamWriter | None = None - self._exit_event = asyncio.Event() + self._download_manager = download_manager + @cached_property + def _exit_event(self) -> asyncio.Event: + return asyncio.Event() + @property def stdin(self) -> asyncio.StreamWriter: """The processes standard input stream.""" diff --git a/src/textual_serve/server.py b/src/textual_serve/server.py index 665718d..6416979 100644 --- a/src/textual_serve/server.py +++ b/src/textual_serve/server.py @@ -1,26 +1,21 @@ from __future__ import annotations import asyncio - import logging import os -from pathlib import Path import signal import sys - +from importlib.metadata import version +from pathlib import Path from typing import Any import aiohttp_jinja2 -from aiohttp import web -from aiohttp import WSMsgType -from aiohttp.web_runner import GracefulExit import jinja2 - -from importlib.metadata import version - +from aiohttp import WSMsgType, web +from aiohttp.web_runner import GracefulExit from rich.console import Console -from rich.logging import RichHandler from rich.highlighter import RegexHighlighter +from rich.logging import RichHandler from textual_serve.download_manager import DownloadManager @@ -204,36 +199,36 @@ async def on_startup(self, app: web.Application) -> None: self.console.print(f"Serving {self.command!r} on {self.public_url}") self.console.print("\n[cyan]Press Ctrl+C to quit") - def serve(self, debug: bool = False) -> None: - """Serve the Textual application. - - This will run a local webserver until it is closed with Ctrl+C + async def _make_server(self) -> Any: + """Make the aiohttp web server. + Returns: + New aiohttp web server. """ - self.debug = debug - self.initialize_logging() - - try: - loop = asyncio.get_event_loop() - except Exception: - loop = asyncio.new_event_loop() + app = await self._make_app() + runner = web.AppRunner(app, handle_signals=False) + await runner.setup() + site = web.TCPSite(runner, self.host, self.port) + await site.start() try: + loop = asyncio.get_running_loop() loop.add_signal_handler(signal.SIGINT, self.request_exit) loop.add_signal_handler(signal.SIGTERM, self.request_exit) except NotImplementedError: pass + # Keep the server running indefinitely + await asyncio.Event().wait() - if self.debug: - log.info("Running in debug mode. You may use textual dev tools.") + def serve(self, debug: bool = False) -> None: + """Serve the Textual application. - web.run_app( - self._make_app(), - host=self.host, - port=self.port, - handle_signals=False, - loop=loop, - print=lambda *args: None, - ) + This will run a local webserver until it is closed with Ctrl+C + + """ + self.debug = debug + self.initialize_logging() + + asyncio.run(self._make_server()) @aiohttp_jinja2.template("app_index.html") async def handle_index(self, request: web.Request) -> dict[str, Any]: From 79385a56a0b7a26e17fb9cb5a9ebcba04b769b62 Mon Sep 17 00:00:00 2001 From: Ryan Date: Sat, 22 Nov 2025 07:45:39 -0600 Subject: [PATCH 2/8] Update src/textual_serve/_compat.py Syntax on any/Any Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/textual_serve/_compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual_serve/_compat.py b/src/textual_serve/_compat.py index 9f660f0..ebe8307 100644 --- a/src/textual_serve/_compat.py +++ b/src/textual_serve/_compat.py @@ -23,7 +23,7 @@ def __init__(self, func: Callable[[Any, _T_co]]) -> None: self.__doc__ = func.__doc__ self.__module__ = func.__module__ - def __set_name__(self, owner: type[any], name: str) -> None: + def __set_name__(self, owner: type[Any], name: str) -> None: if self.attrname is None: self.attrname = name elif name != self.attrname: From a8cd049ca8b0be1400b6ea33e1eba8004fd72633 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 13:47:17 +0000 Subject: [PATCH 3/8] Initial plan From 80940129ee68c760edd8112a76e37f2cbaa746be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 13:49:51 +0000 Subject: [PATCH 4/8] Catch GracefulExit exception and cleanup runner Co-authored-by: sreyemnayr <8558670+sreyemnayr@users.noreply.github.com> --- src/textual_serve/server.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/textual_serve/server.py b/src/textual_serve/server.py index 6416979..69c7750 100644 --- a/src/textual_serve/server.py +++ b/src/textual_serve/server.py @@ -217,7 +217,14 @@ async def _make_server(self) -> Any: except NotImplementedError: pass # Keep the server running indefinitely - await asyncio.Event().wait() + try: + await asyncio.Event().wait() + except GracefulExit: + # Gracefully handle exit signal + pass + finally: + # Cleanup + await runner.cleanup() def serve(self, debug: bool = False) -> None: """Serve the Textual application. From 5867a25a50bb85bbc5dde2b44f441cdbabe4a929 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 13:51:56 +0000 Subject: [PATCH 5/8] Ensure runner cleanup is protected from early exceptions Co-authored-by: sreyemnayr <8558670+sreyemnayr@users.noreply.github.com> --- src/textual_serve/server.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/textual_serve/server.py b/src/textual_serve/server.py index 69c7750..66dfd19 100644 --- a/src/textual_serve/server.py +++ b/src/textual_serve/server.py @@ -208,20 +208,21 @@ async def _make_server(self) -> Any: app = await self._make_app() runner = web.AppRunner(app, handle_signals=False) await runner.setup() - site = web.TCPSite(runner, self.host, self.port) - await site.start() try: - loop = asyncio.get_running_loop() - loop.add_signal_handler(signal.SIGINT, self.request_exit) - loop.add_signal_handler(signal.SIGTERM, self.request_exit) - except NotImplementedError: - pass - # Keep the server running indefinitely - try: - await asyncio.Event().wait() - except GracefulExit: - # Gracefully handle exit signal - pass + site = web.TCPSite(runner, self.host, self.port) + await site.start() + try: + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGINT, self.request_exit) + loop.add_signal_handler(signal.SIGTERM, self.request_exit) + except NotImplementedError: + pass + # Keep the server running indefinitely + try: + await asyncio.Event().wait() + except GracefulExit: + # Gracefully handle exit signal + pass finally: # Cleanup await runner.cleanup() From c8527a7d6faa7b800ef077a96c29675efa6a7824 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 14:01:39 +0000 Subject: [PATCH 6/8] Initial plan From c31e4c0a047bf16a72efc616e0aed9265562e209 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 14:05:00 +0000 Subject: [PATCH 7/8] Add __all__ to explicitly mark cached_property as re-exported Co-authored-by: sreyemnayr <8558670+sreyemnayr@users.noreply.github.com> --- src/textual_serve/_compat.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual_serve/_compat.py b/src/textual_serve/_compat.py index ebe8307..1c35501 100644 --- a/src/textual_serve/_compat.py +++ b/src/textual_serve/_compat.py @@ -3,6 +3,8 @@ import sys from typing import Any, Callable, Generic, Self, TypeVar, overload +__all__ = ["cached_property"] + if sys.version_info >= (3, 12): from functools import cached_property else: From 409a73f87c74436680a03ecec72aec59398e3df4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 14:37:09 +0000 Subject: [PATCH 8/8] Move __all__ before imports per PEP 8 style guidelines Co-authored-by: sreyemnayr <8558670+sreyemnayr@users.noreply.github.com> --- src/textual_serve/_compat.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual_serve/_compat.py b/src/textual_serve/_compat.py index 1c35501..71a9b22 100644 --- a/src/textual_serve/_compat.py +++ b/src/textual_serve/_compat.py @@ -1,10 +1,10 @@ from __future__ import annotations +__all__ = ["cached_property"] + import sys from typing import Any, Callable, Generic, Self, TypeVar, overload -__all__ = ["cached_property"] - if sys.version_info >= (3, 12): from functools import cached_property else: