Просмотр исходного кода

Apply code-review findings across adapter, tests and docs

- Classify send failures uniformly on ChattoError: the chunked text
  path no longer reports our own bugs as retryable, and the standalone
  cron sender carries the flag at all (both now tested).
- Hold _seen/_dispatched_ids in bounded deques; the maxlen evicts the
  oldest ID, so the manual trim loops go away.
- Drop _ensure_token: its fast path returned what _get_chatto_client
  returns anyway, and connect() now makes a single client call.
- Reuse the was_dispatched local in the edit path instead of re-checking
  membership.
- Test hygiene: rescue an orphaned docstring into position, drop two
  dead adapter._token fixture lines.
- Docs/metadata: plugin.yaml no longer claims websockets must be
  installed (it is vendored), pyrightconfig drops the stale trial.py
  exclude, ruff joins the dev group, README notes that
  CHATTO_ALLOWED_USERS matches case-sensitively.
Paul Klumpp 1 неделя назад
Родитель
Сommit
11388b5909
7 измененных файлов с 88 добавлено и 31 удалено
  1. 1 1
      README.md
  2. 19 23
      adapter.py
  3. 2 2
      plugin.yaml
  4. 1 0
      pyproject.toml
  5. 1 2
      pyrightconfig.json
  6. 37 3
      test_adapter.py
  7. 27 0
      uv.lock

+ 1 - 1
README.md

@@ -161,7 +161,7 @@ gateway:
 | `CHATTO_PASSWORD` | Yes* | — | Chatto password |
 | `CHATTO_PASSWORD` | Yes* | — | Chatto password |
 | `CHATTO_TOKEN` | No | — | Existing bearer token — alternative to login/password |
 | `CHATTO_TOKEN` | No | — | Existing bearer token — alternative to login/password |
 | `CHATTO_HOME_CHANNEL` | No | First joined room | Default delivery target for cron/notification output when there is no inbound conversation context to reply into. Not exempt from the mention lists. |
 | `CHATTO_HOME_CHANNEL` | No | First joined room | Default delivery target for cron/notification output when there is no inbound conversation context to reply into. Not exempt from the mention lists. |
-| `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto logins allowed to talk to the agent |
+| `CHATTO_ALLOWED_USERS` | No | _(deny all)_ | Comma-separated Chatto logins (or user IDs) allowed to talk to the agent. Exact match, case-sensitive. |
 | `CHATTO_ALLOW_ALL_USERS` | No | `false` | Allow any Chatto user to talk to the agent (`true`/`false`) |
 | `CHATTO_ALLOW_ALL_USERS` | No | `false` | Allow any Chatto user to talk to the agent (`true`/`false`) |
 | `CHATTO_REQUIRE_MENTION_ROOMS` | No | _(silent everywhere)_ | Comma-separated room IDs where Hermes answers only when addressed (`@name`, `@all`, `@here`). A room on neither mention list stays silent. |
 | `CHATTO_REQUIRE_MENTION_ROOMS` | No | _(silent everywhere)_ | Comma-separated room IDs where Hermes answers only when addressed (`@name`, `@all`, `@here`). A room on neither mention list stays silent. |
 | `CHATTO_OPTIONAL_MENTION_ROOMS` | No | — | Comma-separated room IDs where Hermes answers every message, addressed or not. Must not overlap `CHATTO_REQUIRE_MENTION_ROOMS` (rejected at startup). |
 | `CHATTO_OPTIONAL_MENTION_ROOMS` | No | — | Comma-separated room IDs where Hermes answers every message, addressed or not. Must not overlap `CHATTO_REQUIRE_MENTION_ROOMS` (rejected at startup). |

+ 19 - 23
adapter.py

@@ -30,6 +30,7 @@ import mimetypes
 import os
 import os
 import re
 import re
 import tempfile
 import tempfile
+from collections import deque
 from datetime import UTC, datetime
 from datetime import UTC, datetime
 from difflib import SequenceMatcher
 from difflib import SequenceMatcher
 from enum import StrEnum
 from enum import StrEnum
@@ -302,12 +303,13 @@ class ChattoAdapter(BasePlatformAdapter):
         self._room_kinds: dict[str, RoomKind] = {}
         self._room_kinds: dict[str, RoomKind] = {}
         # Event IDs already processed — chattolib may redeliver events across
         # Event IDs already processed — chattolib may redeliver events across
         # reconnects, so every inbound event is checked against this list.
         # reconnects, so every inbound event is checked against this list.
-        self._seen: list[str] = []
+        # Bounded deques: appending past the cap drops the oldest ID on its own.
+        self._seen: deque[str] = deque(maxlen=ChattoConstants.SEEN_CAP)
         # Message IDs this adapter has handed to the gateway (posted or
         # Message IDs this adapter has handed to the gateway (posted or
         # edit-re-dispatched). Edits of anything on this list never start a
         # edit-re-dispatched). Edits of anything on this list never start a
         # fresh turn — that is the lock against re-answering settled
         # fresh turn — that is the lock against re-answering settled
         # conversations by editing old messages.
         # conversations by editing old messages.
-        self._dispatched_ids: list[str] = []
+        self._dispatched_ids: deque[str] = deque(maxlen=ChattoConstants.SEEN_CAP)
         # session_key -> message ID currently being processed there. Written
         # session_key -> message ID currently being processed there. Written
         # by on_processing_start, cleared by on_processing_complete; an edit
         # by on_processing_start, cleared by on_processing_complete; an edit
         # landing on the recorded ID is a mid-run correction.
         # landing on the recorded ID is a mid-run correction.
@@ -398,16 +400,6 @@ class ChattoAdapter(BasePlatformAdapter):
             raise RuntimeError("Chatto client unavailable")
             raise RuntimeError("Chatto client unavailable")
         return client
         return client
 
 
-    async def _ensure_token(self) -> bool:
-        """Ensure we have a logged-in Chatto client and token."""
-        if self.chatto_config.token.value and isinstance(
-            self._chatto_client, ChattoClient
-        ):
-            return True
-
-        client = await self._get_chatto_client()
-        return client is not None
-
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
     # Connection
     # Connection
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
@@ -439,9 +431,6 @@ class ChattoAdapter(BasePlatformAdapter):
         """
         """
         logger.info("Chatto: connecting...")
         logger.info("Chatto: connecting...")
 
 
-        if not await self._ensure_token():
-            return False
-
         client = await self._get_chatto_client()
         client = await self._get_chatto_client()
         if client is None:
         if client is None:
             self._set_fatal_error(
             self._set_fatal_error(
@@ -598,9 +587,8 @@ class ChattoAdapter(BasePlatformAdapter):
     # ------------------------------------------------------------------ #
     # ------------------------------------------------------------------ #
 
 
     def _mark_seen(self, event_id: str) -> None:
     def _mark_seen(self, event_id: str) -> None:
+        # The deque's maxlen evicts the oldest ID — no manual trimming.
         self._seen.append(event_id)
         self._seen.append(event_id)
-        while len(self._seen) > ChattoConstants.SEEN_CAP:
-            del self._seen[0]
 
 
     def _is_seen(self, event_id: str) -> bool:
     def _is_seen(self, event_id: str) -> bool:
         return event_id in self._seen
         return event_id in self._seen
@@ -769,7 +757,7 @@ class ChattoAdapter(BasePlatformAdapter):
         client: ChattoClient,
         client: ChattoClient,
         argument: str,
         argument: str,
     ) -> tuple[str | None, RoomWithViewerState | None]:
     ) -> tuple[str | None, RoomWithViewerState | None]:
-        """Resolve a /join//leave argument to a room.
+        """Resolve a ``/join`` or ``/leave`` argument to a room.
 
 
         ``#name`` is looked up case-insensitively in a fresh directory scan
         ``#name`` is looked up case-insensitively in a fresh directory scan
         (which also refreshes our name/kind caches); anything else is treated
         (which also refreshes our name/kind caches); anything else is treated
@@ -1110,8 +1098,6 @@ class ChattoAdapter(BasePlatformAdapter):
         if not message_id:
         if not message_id:
             return
             return
         self._dispatched_ids.append(message_id)
         self._dispatched_ids.append(message_id)
-        while len(self._dispatched_ids) > ChattoConstants.SEEN_CAP:
-            del self._dispatched_ids[0]
 
 
     def _edit_is_fresh(self, message: Message) -> bool:
     def _edit_is_fresh(self, message: Message) -> bool:
         """Whether this edit is young enough to still be processed.
         """Whether this edit is young enough to still be processed.
@@ -1224,7 +1210,7 @@ class ChattoAdapter(BasePlatformAdapter):
             await self.cancel_session_processing(
             await self.cancel_session_processing(
                 session_key, release_guard=True, discard_pending=False
                 session_key, release_guard=True, discard_pending=False
             )
             )
-        elif payload.message_event_id not in self._dispatched_ids:
+        elif not was_dispatched:
             logger.info(
             logger.info(
                 "Chatto: msg %s was never dispatched - edit starts a fresh turn",
                 "Chatto: msg %s was never dispatched - edit starts a fresh turn",
                 payload.message_event_id,
                 payload.message_event_id,
@@ -1844,8 +1830,10 @@ class ChattoAdapter(BasePlatformAdapter):
             except Exception as e:
             except Exception as e:
                 # ChattoError included: both read as "this chunk did not go
                 # ChattoError included: both read as "this chunk did not go
                 # out" and stop the batch — the SendResult carries the reason.
                 # out" and stop the batch — the SendResult carries the reason.
+                # Only server-side failures count as retryable; a bug in our
+                # own code must not read as a transient network blip.
                 last_error = str(e)
                 last_error = str(e)
-                retryable = True
+                retryable = isinstance(e, ChattoError)
                 break
                 break
 
 
             self._mark_seen(msg_obj.id)
             self._mark_seen(msg_obj.id)
@@ -2435,6 +2423,8 @@ class ChattoAdapter(BasePlatformAdapter):
             self._mark_seen(msg.id)
             self._mark_seen(msg.id)
             return SendResult(success=True, message_id=msg.id)
             return SendResult(success=True, message_id=msg.id)
         except ChattoError as e:
         except ChattoError as e:
+            # Same classification as the chunked text path: server-side
+            # failures retry, anything else is ours and must not loop.
             return SendResult(success=False, error=str(e), retryable=True)
             return SendResult(success=False, error=str(e), retryable=True)
         except Exception as e:
         except Exception as e:
             return SendResult(success=False, error=str(e), retryable=False)
             return SendResult(success=False, error=str(e), retryable=False)
@@ -2811,7 +2801,13 @@ async def hermes_standalone_sender_fn(
                     chat_id,
                     chat_id,
                     exc,
                     exc,
                 )
                 )
-            return SendResult(success=False, error=str(exc))
+            # Same classification as the adapter's send paths: only
+            # server-side failures read as retryable.
+            return SendResult(
+                success=False,
+                error=str(exc),
+                retryable=isinstance(exc, ChattoError),
+            )
         return SendResult(success=True, message_id=message_ids[0])
         return SendResult(success=True, message_id=message_ids[0])
     finally:
     finally:
         try:
         try:

+ 2 - 2
plugin.yaml

@@ -7,8 +7,8 @@ description: >
   Connects to a Chatto server (cloud-hosted or self-hosted team chat) and relays messages
   Connects to a Chatto server (cloud-hosted or self-hosted team chat) and relays messages
   between rooms/DMs and the Hermes agent. Uses the Chatto ConnectRPC API
   between rooms/DMs and the Hermes agent. Uses the Chatto ConnectRPC API
   (JSON over HTTP) for outbound messages and the Chatto realtime WebSocket
   (JSON over HTTP) for outbound messages and the Chatto realtime WebSocket
-  protocol (binary protobuf) for inbound events.  No Python packages
-  required beyond websockets.
+  protocol (binary protobuf) for inbound events.  Nothing to install:
+  chattolib[realtime] and its dependencies are vendored into the plugin.
 author: Chatto community guys
 author: Chatto community guys
 requires_env:
 requires_env:
   - name: CHATTO_BASE_URL
   - name: CHATTO_BASE_URL

+ 1 - 0
pyproject.toml

@@ -21,6 +21,7 @@ dev = [
     "pytest>=9.0",
     "pytest>=9.0",
     "pytest-asyncio>=0.24",
     "pytest-asyncio>=0.24",
     "basedpyright>=1.29",
     "basedpyright>=1.29",
+    "ruff>=0.9",
 ]
 ]
 
 
 [tool.setuptools.packages.find]
 [tool.setuptools.packages.find]

+ 1 - 2
pyrightconfig.json

@@ -11,8 +11,7 @@
     "dist",
     "dist",
     ".venv",
     ".venv",
     "test_adapter.py",
     "test_adapter.py",
-    "test_platform_config.py",
-    "trial.py"
+    "test_platform_config.py"
   ],
   ],
   "typeCheckingMode": "standard",
   "typeCheckingMode": "standard",
   "pythonVersion": "3.11",
   "pythonVersion": "3.11",

+ 37 - 3
test_adapter.py

@@ -39,6 +39,7 @@ from vendor_path import setup_vendor_path
 setup_vendor_path()
 setup_vendor_path()
 
 
 from chattolib.client import ChattoClient
 from chattolib.client import ChattoClient
+from chattolib.exceptions import ChattoError
 from chattolib.realtime_types import ReactionPayload
 from chattolib.realtime_types import ReactionPayload
 from chattolib.types import (
 from chattolib.types import (
     Asset,
     Asset,
@@ -466,6 +467,19 @@ class TestSend:
         assert result.success is True
         assert result.success is True
         assert not result.raw_response or isinstance(result.raw_response, dict)
         assert not result.raw_response or isinstance(result.raw_response, dict)
 
 
+    async def test_send_server_error_is_retryable(self, adapter):
+        adapter._chatto_client.post_message.side_effect = ChattoError("server says no")
+        result = await adapter.send("room-1", "Hello world")
+        assert result.success is False
+        assert result.retryable is True
+
+    async def test_send_unexpected_error_is_not_retryable(self, adapter):
+        """A bug in our own code must not read as a transient network blip."""
+        adapter._chatto_client.post_message.side_effect = RuntimeError("bug")
+        result = await adapter.send("room-1", "Hello world")
+        assert result.success is False
+        assert result.retryable is False
+
 
 
 # -- Reactions --
 # -- Reactions --
 
 
@@ -535,7 +549,6 @@ class TestMessageEditing:
         adapter._chatto_client = MagicMock()
         adapter._chatto_client = MagicMock()
         adapter._chatto_client.update_message = AsyncMock()
         adapter._chatto_client.update_message = AsyncMock()
         adapter._chatto_client.delete_message = AsyncMock(return_value=True)
         adapter._chatto_client.delete_message = AsyncMock(return_value=True)
-        adapter._token = "test-token"
         return adapter
         return adapter
 
 
     async def test_edit_message(self, adapter):
     async def test_edit_message(self, adapter):
@@ -1763,7 +1776,6 @@ class TestRoomOperations:
         # swallow into a None return.
         # swallow into a None return.
         adapter._chatto_client.create_room = AsyncMock()
         adapter._chatto_client.create_room = AsyncMock()
         adapter._chatto_client.start_dm = AsyncMock()
         adapter._chatto_client.start_dm = AsyncMock()
-        adapter._token = "test-token"
         adapter._room_names = {}
         adapter._room_names = {}
         adapter._room_kinds = {}
         adapter._room_kinds = {}
         return adapter
         return adapter
@@ -1994,9 +2006,9 @@ class TestDmRoomCommands:
         assert "home channel" in self._reply(adapter)
         assert "home channel" in self._reply(adapter)
 
 
     async def test_commands_outside_dms_are_ignored(self):
     async def test_commands_outside_dms_are_ignored(self):
-        adapter = self._adapter()
         """In a channel the text is just a message — mention gating applies,
         """In a channel the text is just a message — mention gating applies,
         no command runs, nothing is sent."""
         no command runs, nothing is sent."""
+        adapter = self._adapter()
         adapter.chatto_config.require_mention_rooms.value = ["chan-1"]
         adapter.chatto_config.require_mention_rooms.value = ["chan-1"]
         adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
         adapter._room_kinds["chan-1"] = RoomKind.CHANNEL
 
 
@@ -2304,6 +2316,28 @@ class TestStandaloneSender:
         # The short-lived client is closed even on failure.
         # The short-lived client is closed even on failure.
         client.close.assert_awaited_once()
         client.close.assert_awaited_once()
 
 
+    @pytest.mark.parametrize(
+        ("error", "expected_retryable"),
+        [
+            (ChattoError("boom"), True),
+            (RuntimeError("boom"), False),
+        ],
+    )
+    async def test_failure_classification_matches_the_send_paths(
+        self, error, expected_retryable
+    ):
+        """Server-side failures retry; our own bugs must not loop."""
+        cfg = _make_config(token="test-token")
+        client = MagicMock()
+        client.post_message = AsyncMock(side_effect=error)
+        client.close = AsyncMock()
+        with patch("adapter.ChattoClient") as client_cls:
+            client_cls.return_value = client
+            result = await hermes_standalone_sender_fn(cfg, "room-1", "hello")
+
+        assert result.success is False
+        assert result.retryable is expected_retryable
+
     async def test_thread_id_reaches_every_chunk(self):
     async def test_thread_id_reaches_every_chunk(self):
         cfg = _make_config(token="test-token")
         cfg = _make_config(token="test-token")
         client, _ids = self._posted(5)
         client, _ids = self._posted(5)

+ 27 - 0
uv.lock

@@ -33,6 +33,7 @@ dev = [
     { name = "basedpyright" },
     { name = "basedpyright" },
     { name = "pytest" },
     { name = "pytest" },
     { name = "pytest-asyncio" },
     { name = "pytest-asyncio" },
+    { name = "ruff" },
 ]
 ]
 
 
 [package.metadata]
 [package.metadata]
@@ -42,6 +43,7 @@ dev = [
     { name = "basedpyright", specifier = ">=1.29" },
     { name = "basedpyright", specifier = ">=1.29" },
     { name = "pytest", specifier = ">=9.0" },
     { name = "pytest", specifier = ">=9.0" },
     { name = "pytest-asyncio", specifier = ">=0.24" },
     { name = "pytest-asyncio", specifier = ">=0.24" },
+    { name = "ruff", specifier = ">=0.9" },
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -125,6 +127,31 @@ wheels = [
     { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
     { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
 ]
 ]
 
 
+[[package]]
+name = "ruff"
+version = "0.16.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" },
+    { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" },
+    { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" },
+    { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" },
+    { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" },
+    { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" },
+]
+
 [[package]]
 [[package]]
 name = "typing-extensions"
 name = "typing-extensions"
 version = "4.16.0"
 version = "4.16.0"