diff --git a/CHANGES b/CHANGES index 16765e2a1e..e37d78e0cb 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,45 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### A tmux call can be given a deadline (#757) + +{class}`~libtmux.common.tmux_cmd` takes a `timeout`, and +{class}`~libtmux.Server` takes one as the default for every command it +issues, including the listings behind +{attr}`Server.sessions `, +{attr}`Session.windows ` and +{attr}`Window.panes `. Those listings do not go +through {meth}`Server.cmd() `, so until now a +consumer could not bound them at all. + +Without a timeout nothing changes: the default is to wait, as before. + +A tmux server can accept a connection and then never reply, and a call +waiting on one never returns. Giving up on it does not stop it either, +so the waiting calls only accumulate, and enough of them will exhaust a +thread pool and stall work aimed at healthy sockets. + +On expiry the tmux process is killed and reaped before +{exc}`~libtmux.exc.TmuxTimeout` is raised, so a call that timed out +leaves nothing running behind it. The command may still have taken +effect -- the process was stopped mid-command, and nothing can be said +either way. + +{exc}`~libtmux.exc.TmuxTimeout` is deliberately not a +{exc}`~libtmux.exc.LibTmuxException`. The listing accessors read one of +those as "nothing to list", which is correct for a daemon that has not +started and wrong for a server that has stopped answering: a caller +told there are no sessions goes on to create one on a server that +already has them. + +```python +server = Server(timeout=5) +server.sessions # raises TmuxTimeout rather than reporting [] +server.cmd("kill-server", timeout=30) # per-call override +``` + ### Documentation #### Cleaner `from_env` examples (#719) @@ -59,6 +98,10 @@ it. ### Development +#### Ruff 0.17.3 for development (#757) + +Minimum `ruff>=0.17.3` (was `>=0.17.1`) for development and CI lint checks. + #### CI recovers from incompatible tmux caches (#835) CI rebuilds incompatible cached tmux installations before running tests. diff --git a/pyproject.toml b/pyproject.toml index 8558760667..1d80f80423 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ dev = [ "coverage", "pytest-cov", # Lint - "ruff>=0.17.1", + "ruff>=0.17.3", "mypy", ] @@ -95,7 +95,7 @@ coverage =[ ] lint = [ "typing-extensions; python_version < '3.11'", - "ruff>=0.17.1", + "ruff>=0.17.3", "mypy", ] diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2871547700..9b8d05b9a2 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -303,13 +303,33 @@ class tmux_cmd: $ tmux new-session -s my session + Parameters + ---------- + tmux_bin : str, optional + Path to the tmux binary. Defaults to the first ``tmux`` on ``PATH``. + timeout : float, optional + Seconds to wait for the command. On expiry the tmux process is + killed and reaped, then :exc:`~libtmux.exc.TmuxTimeout` is + raised. ``None`` waits indefinitely. + + Raises + ------ + :exc:`~libtmux.exc.TmuxTimeout` + ``timeout`` elapsed. The command may or may not have taken + effect -- the process was killed mid-command. + Notes ----- .. versionchanged:: 0.8 Renamed from ``tmux`` to ``tmux_cmd``. """ - def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: + def __init__( + self, + *args: t.Any, + tmux_bin: str | None = None, + timeout: float | None = None, + ) -> None: resolved = tmux_bin or shutil.which("tmux") if not resolved: raise exc.TmuxCommandNotFound @@ -336,8 +356,15 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: encoding="utf-8", errors="backslashreplace", ) - stdout, stderr = self.process.communicate() + stdout, stderr = self.process.communicate(timeout=timeout) returncode = self.process.returncode + except subprocess.TimeoutExpired: + # Kill and reap before raising. A caller that gives up on an + # unbounded call leaves the child running, so repeated + # timeouts accumulate tmux clients that nothing is waiting on. + self.process.kill() + self.process.communicate() + raise exc.TmuxTimeout(cmd, t.cast("float", timeout)) from None except FileNotFoundError: raise exc.TmuxCommandNotFound from None except Exception: diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 57bb06102f..f87c9be0de 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -350,6 +350,26 @@ class WaitTimeout(LibTmuxException): """Function timed out without meeting condition.""" +class TmuxTimeout(Exception): + """A tmux command did not return within its timeout. + + Deliberately NOT a :exc:`LibTmuxException`. The listing accessors + absorb one of those as "nothing to list", which is right for a + daemon that has not started and wrong for a server that stopped + answering -- a caller told there are no sessions goes on to create + one on a server that already has them. + + Distinct from :exc:`WaitTimeout`, which is a helper giving up on a + condition. This one means the tmux process was killed mid-command, + so nothing can be said about whether it took effect. + """ + + def __init__(self, cmd: list[str], timeout: float, *args: object) -> None: + self.cmd = cmd + self.timeout = timeout + super().__init__(f"tmux did not return within {timeout}s: {' '.join(cmd)}") + + class VariableUnpackingError(LibTmuxException): """Error unpacking variable.""" diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa5..bb645e191d 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1133,6 +1133,7 @@ def fetch_objs( proc = tmux_cmd( *tmux_cmds, tmux_bin=server.tmux_bin, + timeout=server.timeout, ) raise_if_stderr(proc, list_cmd) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c34..9d39e05d92 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -176,10 +176,12 @@ def __init__( on_init: t.Callable[[Server], None] | None = None, socket_name_factory: t.Callable[[], str] | None = None, tmux_bin: str | pathlib.Path | None = None, + timeout: float | None = None, **kwargs: t.Any, ) -> None: EnvironmentMixin.__init__(self, "-g") self.tmux_bin = str(tmux_bin) if tmux_bin is not None else None + self.timeout = timeout self._windows: list[WindowDict] = [] self._panes: list[PaneDict] = [] @@ -342,6 +344,7 @@ def cmd( cmd: str, *args: t.Any, target: str | int | None = None, + timeout: float | None = None, ) -> tmux_cmd: """Execute tmux command respective of socket name and file, return output. @@ -408,7 +411,12 @@ def cmd( cmd_args = ["-t", str(target), *args] if target is not None else [*args] - return tmux_cmd(*svr_args, *cmd_args, tmux_bin=self.tmux_bin) + return tmux_cmd( + *svr_args, + *cmd_args, + tmux_bin=self.tmux_bin, + timeout=self.timeout if timeout is None else timeout, + ) @property def attached_sessions(self) -> list[Session]: diff --git a/tests/conftest.py b/tests/conftest.py index c0015e4e65..e745ced251 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,15 @@ from __future__ import annotations +import typing as t + import pytest from libtmux.common import get_version, get_version_str +if t.TYPE_CHECKING: + import pathlib + @pytest.fixture(autouse=True) def _clear_get_version_cache() -> None: @@ -20,3 +25,26 @@ def _clear_get_version_cache() -> None: """ get_version.cache_clear() get_version_str.cache_clear() + + +@pytest.fixture +def hanging_tmux(tmp_path: pathlib.Path) -> tuple[str, pathlib.Path]: + """Return a stand-in tmux that never answers, and its pid file. + + Models the failure a real fixture cannot produce cheaply: a server + that accepts a connection and never replies. ``-V`` is answered, + because a wedged SERVER does not stop the local binary reporting its + own version, and `Server.sessions` reads it on the way past. + ``exec`` preserves the recorded pid, so a test can check the process + afterwards instead of trusting that it was killed. + """ + pid_file = tmp_path / "pid" + binary = tmp_path / "tmux" + binary.write_text( + "#!/bin/sh\n" + 'if [ "$1" = "-V" ]; then echo "tmux 3.7"; exit 0; fi\n' + f"echo $$ > {pid_file}\n" + "exec sleep 30\n" + ) + binary.chmod(0o755) + return str(binary), pid_file diff --git a/tests/test_common.py b/tests/test_common.py index 426b72d573..b6e41bd2c5 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -4,8 +4,10 @@ import locale import logging +import os import re import sys +import time import typing as t import pytest @@ -29,6 +31,8 @@ ) if t.TYPE_CHECKING: + import pathlib + from libtmux.server import Server from libtmux.session import Session @@ -762,3 +766,39 @@ def test_tmux_cmd_format_separator_survives_non_utf8_locale( result = parse_output(line, "list-sessions", tmux_version) assert isinstance(result, dict) assert "session_id" in result + + +def test_tmux_cmd_timeout_kills_and_reaps( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """An expired command leaves no tmux process behind. + + The kill is the load-bearing half. Without it a caller that gives up + only stops waiting, so repeated timeouts accumulate tmux processes + nobody is listening to. + """ + binary, pid_file = hanging_tmux + + with pytest.raises(exc.TmuxTimeout) as excinfo: + tmux_cmd("list-sessions", tmux_bin=binary, timeout=0.3) + + assert excinfo.value.timeout == 0.3 + assert "list-sessions" in str(excinfo.value) + + pid = int(pid_file.read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + +def test_tmux_cmd_without_timeout_still_waits( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """The bound is opt-in; omitting it keeps the historical behaviour.""" + binary, _pid_file = hanging_tmux + started = time.monotonic() + + with pytest.raises(exc.TmuxTimeout): + tmux_cmd("list-sessions", tmux_bin=binary, timeout=0.3) + + # A test that never waits would pass whether or not `timeout` is read. + assert time.monotonic() - started >= 0.3 diff --git a/tests/test_server.py b/tests/test_server.py index 6175a7f9ad..cfe00e9c03 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1765,3 +1765,34 @@ def test_server_display_message_warns_on_tmux_error( """ with pytest.warns(UserWarning, match="only one of -F or argument"): server.display_message("x", get_text=True, format_string="#{version}") + + +def test_server_timeout_bounds_every_command( + hanging_tmux: tuple[str, pathlib.Path], +) -> None: + """A server-level timeout is the policy for commands through it.""" + binary, _pid_file = hanging_tmux + server = Server(tmux_bin=binary, timeout=0.3) + + with pytest.raises(exc.TmuxTimeout): + server.cmd("list-sessions") + + +@pytest.mark.parametrize("accessor", ["sessions", "windows", "panes", "clients"]) +def test_a_wedged_server_is_not_reported_as_empty( + hanging_tmux: tuple[str, pathlib.Path], + accessor: str, +) -> None: + """A listing must raise rather than answer empty. + + A tmux server that stopped answering has not said it has nothing. + Returning ``[]`` sends a caller on to create a session on a server + that already has them. Every accessor is covered because three of + them reimplemented the "empty means not ready" rule inline instead + of sharing it. + """ + binary, _pid_file = hanging_tmux + server = Server(tmux_bin=binary, timeout=0.3) + + with pytest.raises(exc.TmuxTimeout): + _ = getattr(server, accessor) diff --git a/uv.lock b/uv.lock index 4d9302a7d7..e9dba824de 100644 --- a/uv.lock +++ b/uv.lock @@ -829,7 +829,7 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, { name = "pytest-xdist" }, - { name = "ruff", specifier = ">=0.17.1" }, + { name = "ruff", specifier = ">=0.17.3" }, { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.1.0a39" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.1.0a39" }, @@ -844,7 +844,7 @@ docs = [ ] lint = [ { name = "mypy" }, - { name = "ruff", specifier = ">=0.17.1" }, + { name = "ruff", specifier = ">=0.17.3" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] testing = [ @@ -1401,27 +1401,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.17.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, - { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, - { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, - { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, - { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, - { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, - { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, - { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, - { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, - { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, ] [[package]]