From 6bddf9a0b4630520be6295a48dfe7a07505af5cb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 25 Dec 2024 06:51:25 -0600 Subject: [PATCH 1/7] common(cmd) AsyncTmuxCmd --- src/libtmux/common.py | 139 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index db0b4151f..02f4571f5 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,6 +7,7 @@ from __future__ import annotations +import asyncio import logging import re import shutil @@ -267,6 +268,144 @@ def __init__(self, *args: t.Any) -> None: ) +class AsyncTmuxCmd: + """ + An asyncio-compatible class for running any tmux command via subprocess. + + Attributes + ---------- + cmd : list[str] + The full command (including the "tmux" binary path). + stdout : list[str] + Lines of stdout output from tmux. + stderr : list[str] + Lines of stderr output from tmux. + returncode : int + The process return code. + + Examples + -------- + >>> import asyncio + >>> + >>> async def main(): + ... proc = await AsyncTmuxCmd.run('-V') + ... if proc.stderr: + ... raise exc.LibTmuxException( + ... f"Error invoking tmux: {proc.stderr}" + ... ) + ... print("tmux version:", proc.stdout) + ... + >>> asyncio.run(main()) + tmux version: [...] + + This is equivalent to calling: + + .. code-block:: console + + $ tmux -V + """ + + def __init__( + self, + cmd: list[str], + stdout: list[str], + stderr: list[str], + returncode: int, + ) -> None: + """ + Store the results of a completed tmux subprocess run. + + Parameters + ---------- + cmd : list[str] + The command used to invoke tmux. + stdout : list[str] + Captured lines from tmux stdout. + stderr : list[str] + Captured lines from tmux stderr. + returncode : int + Subprocess exit code. + """ + self.cmd: list[str] = cmd + self.stdout: list[str] = stdout + self.stderr: list[str] = stderr + self.returncode: int = returncode + + @classmethod + async def run(cls, *args: t.Any) -> AsyncTmuxCmd: + """ + Execute a tmux command asynchronously and capture its output. + + Parameters + ---------- + *args : str + Arguments to be passed after the "tmux" binary name. + + Returns + ------- + AsyncTmuxCmd + An instance containing the cmd, stdout, stderr, and returncode. + + Raises + ------ + exc.TmuxCommandNotFound + If no "tmux" executable is found in the user's PATH. + exc.LibTmuxException + If there's any unexpected exception creating or communicating + with the tmux subprocess. + """ + tmux_bin: str | None = shutil.which("tmux") + if not tmux_bin: + msg = "tmux executable not found in PATH" + raise exc.TmuxCommandNotFound( + msg, + ) + + # Convert all arguments to strings, accounting for Python 3.7+ strings + cmd: list[str] = [tmux_bin] + [str_from_console(a) for a in args] + + try: + process: asyncio.subprocess.Process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + raw_stdout, raw_stderr = await process.communicate() + returncode: int = ( + process.returncode if process.returncode is not None else -1 + ) + + except Exception as e: + logger.exception("Exception for %s", " ".join(cmd)) + msg = f"Exception while running tmux command: {e}" + raise exc.LibTmuxException( + msg, + ) from e + + stdout_str: str = console_to_str(raw_stdout) + stderr_str: str = console_to_str(raw_stderr) + + # Split on newlines, filtering out any trailing empty lines + stdout_split: list[str] = [line for line in stdout_str.split("\n") if line] + stderr_split: list[str] = [line for line in stderr_str.split("\n") if line] + + # Workaround for tmux "has-session" command behavior + if "has-session" in cmd and stderr_split and not stdout_split: + # If `has-session` fails, it might output an error on stderr + # with nothing on stdout. We replicate the original logic here: + stdout_split = [stderr_split[0]] + + logger.debug("stdout for %s: %s", " ".join(cmd), stdout_split) + logger.debug("stderr for %s: %s", " ".join(cmd), stderr_split) + + return cls( + cmd=cmd, + stdout=stdout_split, + stderr=stderr_split, + returncode=returncode, + ) + + def get_version() -> LooseVersion: """Return tmux version. From 4ce377622df055c6af172a26107291632f7ead90 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 25 Dec 2024 07:00:30 -0600 Subject: [PATCH 2/7] Server,Session,Window,Pane: Add `.acmd` --- src/libtmux/pane.py | 49 ++++++++++++++++++++- src/libtmux/server.py | 97 ++++++++++++++++++++++++++++++++++++++++-- src/libtmux/session.py | 57 +++++++++++++++++++++++++ src/libtmux/window.py | 51 +++++++++++++++++++++- 4 files changed, 249 insertions(+), 5 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index f16cbe9f7..07081da64 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -13,7 +13,7 @@ import typing as t import warnings -from libtmux.common import has_gte_version, has_lt_version, tmux_cmd +from libtmux.common import AsyncTmuxCmd, has_gte_version, has_lt_version, tmux_cmd from libtmux.constants import ( PANE_DIRECTION_FLAG_MAP, RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, @@ -201,6 +201,53 @@ def cmd( return self.server.cmd(cmd, *args, target=target) + async def acmd( + self, + cmd: str, + *args: t.Any, + target: str | int | None = None, + ) -> AsyncTmuxCmd: + """Execute tmux subcommand within pane context. + + Automatically binds target by adding ``-t`` for object's pane ID to the + command. Pass ``target`` to keyword arguments to override. + + Examples + -------- + >>> import asyncio + >>> async def test_acmd(): + ... result = await pane.acmd('split-window', '-P') + ... print(result.stdout[0]) + >>> asyncio.run(test_acmd()) + libtmux...:... + + From raw output to an enriched `Pane` object: + + >>> async def test_from_pane(): + ... pane_id_result = await pane.acmd( + ... 'split-window', '-P', '-F#{pane_id}' + ... ) + ... return Pane.from_pane_id( + ... pane_id=pane_id_result.stdout[0], + ... server=session.server + ... ) + >>> asyncio.run(test_from_pane()) + Pane(%... Window(@... ...:..., Session($1 libtmux_...))) + + Parameters + ---------- + target : str, optional + Optional custom target override. By default, the target is the pane ID. + + Returns + ------- + :meth:`server.cmd` + """ + if target is None: + target = self.pane_id + + return await self.server.acmd(cmd, *args, target=target) + """ Commands (tmux-like) """ diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 1eaf82f66..30feb30c0 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -24,6 +24,7 @@ from . import exc, formats from .common import ( + AsyncTmuxCmd, EnvironmentMixin, PaneDict, SessionDict, @@ -251,8 +252,12 @@ def cmd( Output of `tmux -L ... new-window -P -F#{window_id}` to a `Window` object: - >>> Window.from_window_id(window_id=session.cmd( - ... 'new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) + >>> Window.from_window_id( + ... window_id=session.cmd( + ... 'new-window', '-P', '-F#{window_id}' + ... ).stdout[0], + ... server=session.server, + ... ) Window(@4 3:..., Session($1 libtmux_...)) Create a pane from a window: @@ -263,7 +268,9 @@ def cmd( Output of `tmux -L ... split-window -P -F#{pane_id}` to a `Pane` object: >>> Pane.from_pane_id(pane_id=window.cmd( - ... 'split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server) + ... 'split-window', '-P', '-F#{pane_id}').stdout[0], + ... server=window.server + ... ) Pane(%... Window(@... ...:..., Session($1 libtmux_...))) Parameters @@ -301,6 +308,90 @@ def cmd( return tmux_cmd(*svr_args, *cmd_args) + async def acmd( + self, + cmd: str, + *args: t.Any, + target: str | int | None = None, + ) -> AsyncTmuxCmd: + """Execute tmux command respective of socket name and file, return output. + + Examples + -------- + >>> import asyncio + >>> async def test_acmd(): + ... result = await server.acmd('display-message', 'hi') + ... print(result.stdout) + >>> asyncio.run(test_acmd()) + [] + + New session: + + >>> async def test_new_session(): + ... result = await server.acmd( + ... 'new-session', '-d', '-P', '-F#{session_id}' + ... ) + ... print(result.stdout[0]) + >>> asyncio.run(test_new_session()) + $... + + Output of `tmux -L ... new-window -P -F#{window_id}` to a `Window` object: + + >>> async def test_new_window(): + ... result = await session.acmd('new-window', '-P', '-F#{window_id}') + ... window_id = result.stdout[0] + ... window = Window.from_window_id(window_id=window_id, server=server) + ... print(window) + >>> asyncio.run(test_new_window()) + Window(@... ...:..., Session($... libtmux_...)) + + Create a pane from a window: + + >>> async def test_split_window(): + ... result = await server.acmd('split-window', '-P', '-F#{pane_id}') + ... print(result.stdout[0]) + >>> asyncio.run(test_split_window()) + %... + + Output of `tmux -L ... split-window -P -F#{pane_id}` to a `Pane` object: + + >>> async def test_pane(): + ... result = await window.acmd('split-window', '-P', '-F#{pane_id}') + ... pane_id = result.stdout[0] + ... pane = Pane.from_pane_id(pane_id=pane_id, server=server) + ... print(pane) + >>> asyncio.run(test_pane()) + Pane(%... Window(@... ...:..., Session($1 libtmux_...))) + + Parameters + ---------- + target : str, optional + Optional custom target. + + Returns + ------- + :class:`common.AsyncTmuxCmd` + """ + svr_args: list[str | int] = [cmd] + cmd_args: list[str | int] = [] + if self.socket_name: + svr_args.insert(0, f"-L{self.socket_name}") + if self.socket_path: + svr_args.insert(0, f"-S{self.socket_path}") + if self.config_file: + svr_args.insert(0, f"-f{self.config_file}") + if self.colors: + if self.colors == 256: + svr_args.insert(0, "-2") + elif self.colors == 88: + svr_args.insert(0, "-8") + else: + raise exc.UnknownColorOption + + cmd_args = ["-t", str(target), *args] if target is not None else [*args] + + return await AsyncTmuxCmd.run(*svr_args, *cmd_args) + @property def attached_sessions(self) -> list[Session]: """Return active :class:`Session`s. diff --git a/src/libtmux/session.py b/src/libtmux/session.py index d1433e014..e13b6bfef 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -22,6 +22,7 @@ from . import exc from .common import ( + AsyncTmuxCmd, EnvironmentMixin, WindowDict, handle_option_error, @@ -234,6 +235,62 @@ def cmd( target = self.session_id return self.server.cmd(cmd, *args, target=target) + async def acmd( + self, + cmd: str, + *args: t.Any, + target: str | int | None = None, + ) -> AsyncTmuxCmd: + """Execute tmux subcommand within session context. + + Automatically binds target by adding ``-t`` for object's session ID to the + command. Pass ``target`` to keyword arguments to override. + + Examples + -------- + >>> import asyncio + >>> async def test_acmd(): + ... result = await session.acmd('new-window', '-P') + ... print(result.stdout[0]) + >>> asyncio.run(test_acmd()) + libtmux...:....0 + + From raw output to an enriched `Window` object: + + >>> async def test_from_window(): + ... window_id_result = await session.acmd( + ... 'new-window', '-P', '-F#{window_id}' + ... ) + ... return Window.from_window_id( + ... window_id=window_id_result.stdout[0], + ... server=session.server + ... ) + >>> asyncio.run(test_from_window()) + Window(@... ...:..., Session($1 libtmux_...)) + + Parameters + ---------- + target : str, optional + Optional custom target override. By default, the target is the session ID. + + Returns + ------- + :meth:`server.cmd` + + Notes + ----- + .. versionchanged:: 0.34 + + Passing target by ``-t`` is ignored. Use ``target`` keyword argument instead. + + .. versionchanged:: 0.8 + + Renamed from ``.tmux`` to ``.cmd``. + """ + if target is None: + target = self.session_id + return await self.server.acmd(cmd, *args, target=target) + """ Commands (tmux-like) """ diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 121c7ea03..256b05bf0 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -25,7 +25,7 @@ from libtmux.pane import Pane from . import exc -from .common import PaneDict, WindowOptionDict, handle_option_error +from .common import AsyncTmuxCmd, PaneDict, WindowOptionDict, handle_option_error if t.TYPE_CHECKING: import sys @@ -226,6 +226,55 @@ def cmd( return self.server.cmd(cmd, *args, target=target) + async def acmd( + self, + cmd: str, + *args: t.Any, + target: str | int | None = None, + ) -> AsyncTmuxCmd: + """Execute tmux subcommand within window context. + + Automatically binds target by adding ``-t`` for object's window ID to the + command. Pass ``target`` to keyword arguments to override. + + Examples + -------- + Create a pane from a window: + + >>> import asyncio + >>> async def test_acmd(): + ... result = await window.acmd('split-window', '-P', '-F#{pane_id}') + ... print(result.stdout[0]) + >>> asyncio.run(test_acmd()) + %... + + Magic, directly to a `Pane`: + + >>> async def test_from_pane(): + ... pane_id_result = await session.acmd( + ... 'split-window', '-P', '-F#{pane_id}' + ... ) + ... return Pane.from_pane_id( + ... pane_id=pane_id_result.stdout[0], + ... server=session.server + ... ) + >>> asyncio.run(test_from_pane()) + Pane(%... Window(@... ...:..., Session($1 libtmux_...))) + + Parameters + ---------- + target : str, optional + Optional custom target override. By default, the target is the window ID. + + Returns + ------- + :meth:`server.cmd` + """ + if target is None: + target = self.window_id + + return await self.server.acmd(cmd, *args, target=target) + """ Commands (tmux-like) """ From 145305597dc33af72105e13fda6d09a109342bf7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 31 Dec 2024 12:50:33 -0600 Subject: [PATCH 3/7] py(deps[dev]) Add `pytest-asyncio` See also: - https://github.com/pytest-dev/pytest-asyncio - https://pypi.python.org/pypi/pytest-asyncio --- pyproject.toml | 2 ++ uv.lock | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 361b411b9..abb8ebaf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,7 @@ dev-dependencies = [ "typing-extensions; python_version < '3.11'", "gp-libs", "pytest", + "pytest-asyncio", "pytest-rerunfailures", "pytest-mock", "pytest-watcher", @@ -98,6 +99,7 @@ testing = [ "typing-extensions; python_version < '3.11'", "gp-libs", "pytest", + "pytest-asyncio", "pytest-rerunfailures", "pytest-mock", "pytest-watcher", diff --git a/uv.lock b/uv.lock index 9d385394a..202d8501c 100644 --- a/uv.lock +++ b/uv.lock @@ -426,6 +426,7 @@ dev = [ { name = "myst-parser", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -471,6 +472,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -494,6 +496,7 @@ dev = [ { name = "mypy" }, { name = "myst-parser" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -530,6 +533,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -788,6 +792,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, +] + [[package]] name = "pytest-cov" version = "6.1.1" From 04bcc9a87593e41cbfebe1fd2994db04b364fcd7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 31 Dec 2024 12:54:38 -0600 Subject: [PATCH 4/7] tests(async) Basic example --- tests/test_async.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/test_async.py diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 000000000..29a55fdf4 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,27 @@ +"""Tests for libtmux with :mod`asyncio` support.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import pytest + +from libtmux.session import Session + +if TYPE_CHECKING: + from libtmux.server import Server + +logger = logging.getLogger(__name__) + + +@pytest.mark.asyncio +async def test_asyncio(server: Server) -> None: + """Test basic asyncio usage.""" + result = await server.acmd("new-session", "-d", "-P", "-F#{session_id}") + session_id = result.stdout[0] + session = Session.from_session_id( + session_id=session_id, + server=server, + ) + assert isinstance(session, Session) From e1ed4dee47ede399173ca91b2065639a9e32d8c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 23 Feb 2025 04:47:40 -0600 Subject: [PATCH 5/7] AsyncTmuxCmd: Updates for TmuxCmd --- src/libtmux/common.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 02f4571f5..11e9b8c2b 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -361,16 +361,17 @@ async def run(cls, *args: t.Any) -> AsyncTmuxCmd: msg, ) - # Convert all arguments to strings, accounting for Python 3.7+ strings - cmd: list[str] = [tmux_bin] + [str_from_console(a) for a in args] + cmd: list[str] = [tmux_bin] + [str(c) for c in args] try: process: asyncio.subprocess.Process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + text=True, + errors="backslashreplace", ) - raw_stdout, raw_stderr = await process.communicate() + stdout, stderr = await process.communicate() returncode: int = ( process.returncode if process.returncode is not None else -1 ) @@ -382,12 +383,14 @@ async def run(cls, *args: t.Any) -> AsyncTmuxCmd: msg, ) from e - stdout_str: str = console_to_str(raw_stdout) - stderr_str: str = console_to_str(raw_stderr) + # Split on newlines and filter empty lines + stdout_split: list[str] = stdout.split("\n") + # remove trailing newlines from stdout + while stdout_split and stdout_split[-1] == "": + stdout_split.pop() - # Split on newlines, filtering out any trailing empty lines - stdout_split: list[str] = [line for line in stdout_str.split("\n") if line] - stderr_split: list[str] = [line for line in stderr_str.split("\n") if line] + stderr_split = stderr.split("\n") + stderr_split = list(filter(None, stderr_split)) # filter empty values # Workaround for tmux "has-session" command behavior if "has-session" in cmd and stderr_split and not stdout_split: From a52f37a2f52a10f831fd0597108f589e32854bf5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 23 Feb 2025 04:49:31 -0600 Subject: [PATCH 6/7] fix(AsyncTmuxCmd): Handle text decoding manually for async subprocess The AsyncTmuxCmd class was updated to handle text decoding manually since asyncio.create_subprocess_exec() doesn't support the text=True parameter that subprocess.Popen() supports. Changes: - Remove text=True and errors=backslashreplace from create_subprocess_exec() - Handle bytes output by manually decoding with decode(errors="backslashreplace") - Keep string processing logic consistent with tmux_cmd class This fixes the ValueError("text must be False") error that occurred when trying to use text mode with asyncio subprocesses. The async version now properly handles text decoding while maintaining the same behavior as the synchronous tmux_cmd class. --- src/libtmux/common.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 11e9b8c2b..a634b7077 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -368,10 +368,8 @@ async def run(cls, *args: t.Any) -> AsyncTmuxCmd: *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, - text=True, - errors="backslashreplace", ) - stdout, stderr = await process.communicate() + stdout_bytes, stderr_bytes = await process.communicate() returncode: int = ( process.returncode if process.returncode is not None else -1 ) @@ -383,6 +381,10 @@ async def run(cls, *args: t.Any) -> AsyncTmuxCmd: msg, ) from e + # Decode bytes to string with error handling + stdout = stdout_bytes.decode(errors="backslashreplace") + stderr = stderr_bytes.decode(errors="backslashreplace") + # Split on newlines and filter empty lines stdout_split: list[str] = stdout.split("\n") # remove trailing newlines from stdout From e398a7acb180c83751ef327277a642ebb0162335 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Mon, 26 May 2025 04:17:31 -0500 Subject: [PATCH 7/7] py(deps[dev]) Bump dev packages --- uv.lock | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 202d8501c..9005c0935 100644 --- a/uv.lock +++ b/uv.lock @@ -794,14 +794,15 @@ wheels = [ [[package]] name = "pytest-asyncio" -version = "0.25.3" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } +sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, + { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, ] [[package]]