Sfoglia il codice sorgente

Adopt Ruff across the plugin

First lint config for the repo (AGENTS.md already mandated 'ruff check .'
but no config existed, so it either drowned in vendor/ or ran with
foreign defaults): vendor/ and dist/ excluded; BLE001 deliberately off —
broad excepts are the house style at network boundaries where best-
effort side effects must never break delivery.

Code changes: PEP 585/604 annotations repo-wide; blocking file reads,
writes and a urllib download pushed off the event loop via
asyncio.to_thread (the send_image URL fetch stalled every platform while
a dead image URL timed out); debug logs instead of silent teardown
passes; assorted SIM/RUF cleanups. ruff format applied.
Paul Klumpp 1 settimana fa
parent
commit
84f49b35ed
8 ha cambiato i file con 549 aggiunte e 283 eliminazioni
  1. 0 1
      __init__.py
  2. 290 146
      adapter.py
  3. 34 18
      platform_config.py
  4. 18 0
      pyproject.toml
  5. 182 98
      test_adapter.py
  6. 12 7
      test_platform_config.py
  7. 5 4
      vendor_path.py
  8. 8 9
      vendor_split.py

+ 0 - 1
__init__.py

@@ -4,4 +4,3 @@ except ImportError:  # pragma: no cover - loaded outside a package (tests)
     from adapter import register
 
 __all__ = ["register"]
-

File diff suppressed because it is too large
+ 290 - 146
adapter.py


+ 34 - 18
platform_config.py

@@ -18,9 +18,8 @@ except ImportError:  # pragma: no cover - depends on how the module is loaded
 setup_vendor_path()
 
 import logging
-from typing import Any, Dict, Generic, Optional, TypeVar
+from typing import Any, ClassVar, Generic, TypeVar
 
-from gateway.config import PlatformConfig
 import utils
 
 # Absolute import — the vendor dir is on sys.path (see above) and chattolib's
@@ -28,6 +27,7 @@ import utils
 # Importing it relatively as well would load a *second* copy of every module
 # under a different name, so isinstance() checks across the two would fail.
 from chattolib import ChattoClient
+from gateway.config import PlatformConfig
 
 logger = logging.getLogger(__name__)
 
@@ -35,6 +35,7 @@ logger = logging.getLogger(__name__)
 # Constants
 # --------------------------------------------------------------------------- #
 
+
 class ChattoConstants:
     """
     Chatto Platform Constants
@@ -83,7 +84,7 @@ class ChattoConstants:
     HTTP_TIMEOUT = 30
 
     # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
-    EMOJI_TO_SHORTCODE: Dict[str, str] = {
+    EMOJI_TO_SHORTCODE: ClassVar[dict[str, str]] = {
         "👍": "thumbsup",
         "👎": "thumbsdown",
         "❤️": "heart",
@@ -119,7 +120,9 @@ class ChattoConstants:
     UPLOAD_CHUNK_SIZE = 256 * 1024
 
 
-def _get_env_or_extra_str_opt(env_var: str, extra_val: Optional[str | bool], default: Optional[str] = None) -> Optional[str]:
+def _get_env_or_extra_str_opt(
+    env_var: str, extra_val: str | bool | None, default: str | None = None
+) -> str | None:
     """Get a value from environment variable or extra config."""
     env_value = os.getenv(env_var)
     if env_value is not None:
@@ -131,14 +134,21 @@ def _get_env_or_extra_str_opt(env_var: str, extra_val: Optional[str | bool], def
         elif isinstance(extra_val, bool):
             return str(extra_val)
         else:
-            logger.error("extra_val: %s is supposed to be str, but %s was found.", extra_val, str(type(extra_val)))
+            logger.error(
+                "extra_val: %s is supposed to be str, but %s was found.",
+                extra_val,
+                str(type(extra_val)),
+            )
 
     if default is not None:
         logger.debug("Chatto: Defaulting to '%s'", default)
-        return default.strip() 
+        return default.strip()
     return None
 
-def _get_env_or_extra_str(env_var: str, extra_val: Optional[str | bool], default: Optional[str] = None) -> str:
+
+def _get_env_or_extra_str(
+    env_var: str, extra_val: str | bool | None, default: str | None = None
+) -> str:
     """Get a value from environment variable or extra config."""
     my_string = _get_env_or_extra_str_opt(env_var, extra_val, default)
     if my_string:
@@ -146,9 +156,13 @@ def _get_env_or_extra_str(env_var: str, extra_val: Optional[str | bool], default
     return ""
 
 
-def _get_env_or_extra_truthy(env_var: str, extra_val: Optional[str | bool], default: bool = False) -> bool:
+def _get_env_or_extra_truthy(
+    env_var: str, extra_val: str | bool | None, default: bool = False
+) -> bool:
     """Get a boolean value from environment variable or extra config."""
-    return utils.is_truthy_value(_get_env_or_extra_str(env_var, extra_val, str(default)), default)
+    return utils.is_truthy_value(
+        _get_env_or_extra_str(env_var, extra_val, str(default)), default
+    )
 
 
 def _split_str_to_list(mystring: str) -> list[str]:
@@ -156,7 +170,7 @@ def _split_str_to_list(mystring: str) -> list[str]:
     return [part.strip() for part in mystring.split(",") if part.strip()]
 
 
-def _get_env_or_extra_list(env_var: str, extra_val: Optional[list[str]]) -> list[str]:
+def _get_env_or_extra_list(env_var: str, extra_val: list[str] | None) -> list[str]:
     """Get a list of values from environment variable or extra config."""
     env_value = os.getenv(env_var)
     if env_value is not None:
@@ -183,7 +197,7 @@ class ConfigValue(Generic[T]):
     the actual setting instead of the always-truthy wrapper object.
     """
 
-    __slots__ = ("value", "field_name", "env_name")
+    __slots__ = ("env_name", "field_name", "value")
 
     def __init__(self, value: T, field_name: str, env_name: str) -> None:
         self.value = value
@@ -193,7 +207,7 @@ class ConfigValue(Generic[T]):
     def __bool__(self) -> bool:
         return bool(self.value)
 
-    def __eq__(self, other: Any) -> bool:
+    def __eq__(self, other: object) -> bool:
         if isinstance(other, ConfigValue):
             return self.value == other.value
         return self.value == other
@@ -232,7 +246,7 @@ class ConfigField(Generic[T]):
         kind: str,
         *,
         default: Any = None,
-        config_key: Optional[str] = None,
+        config_key: str | None = None,
         doc: str = "",
     ) -> None:
         # kind: "str" | "str_opt" | "bool" | "list"
@@ -259,7 +273,7 @@ class ConfigField(Generic[T]):
             f"set '{self.field_name}.value' if you really need to override it."
         )
 
-    def resolve(self, extra: Dict[str, Any]) -> ConfigValue:
+    def resolve(self, extra: dict[str, Any]) -> ConfigValue:
         """Read this field from the environment, then from ``extra``, then the default."""
         raw = extra.get(self.config_key)
 
@@ -291,7 +305,9 @@ class ChattoConfiguration:
     require_mention = ConfigField("bool", default=False)
     # free_response_channels: room IDs where the bot responds without being
     # mentioned via "@botname" even when require_mention is true.
-    free_response_channels_list = ConfigField("list", config_key="free_response_channels")
+    free_response_channels_list = ConfigField(
+        "list", config_key="free_response_channels"
+    )
     # Auto-thread: by default, Chatto creates a thread for replies to room
     # messages (not DMs, not already in a thread). This keeps conversations
     # organized in the room. Can be disabled via extra.auto_thread=false.
@@ -303,9 +319,9 @@ class ChattoConfiguration:
         """Resolve every declared ConfigField against env vars and
         ``PlatformConfig.extra`` (which Hermes pre-populates from config.yaml).
         """
-        extra: Dict[str, Any] = getattr(pconfig, "extra", None) or {}
+        extra: dict[str, Any] = getattr(pconfig, "extra", None) or {}
 
-        self._values: Dict[str, ConfigValue] = {
+        self._values: dict[str, ConfigValue] = {
             field.field_name: field.resolve(extra) for field in self.fields()
         }
 
@@ -322,4 +338,4 @@ class ChattoConfiguration:
             f"{name}={'***' if name in redacted and cv.value else cv.value!r}"
             for name, cv in self._values.items()
         ]
-        return "ChattoConfiguration(" + ", ".join(parts) + ")"
+        return "ChattoConfiguration(" + ", ".join(parts) + ")"

+ 18 - 0
pyproject.toml

@@ -25,6 +25,24 @@ dev = [
 [tool.setuptools.packages.find]
 where = ["."]
 
+[tool.ruff]
+# vendor/ ships upstream code (see VENDORING.md) and dist/ is build output;
+# neither is held to this repo's lint rules.
+extend-exclude = ["vendor", "dist"]
+
+[tool.ruff.lint]
+# BLE001: broad excepts are deliberate here. The adapter is a long-lived
+# gateway client whose best-effort side effects (reactions, typing, presence,
+# mark-seen) must never break message delivery, so network boundaries catch
+# wide and log instead of letting unexpected errors kill the loop.
+ignore = ["BLE001"]
+
+[tool.ruff.lint.per-file-ignores]
+# The plugin root doubles as the package directory; __init__ only re-exports.
+# N999 fires because the checkout directory name is not a valid identifier —
+# pytest already works around that via importlib mode.
+"__init__.py" = ["F401", "N999"]
+
 [tool.pytest.ini_options]
 # importlib mode: the plugin root is itself a package (__init__.py), so pytest's
 # default "prepend" mode tries to import the test modules as part of it, which

+ 182 - 98
test_adapter.py

@@ -16,9 +16,7 @@ All network calls are mocked — no real HTTP or WebSocket connections.
 import asyncio
 import os
 import sys
-import tempfile
-from unittest.mock import AsyncMock, MagicMock, patch, call
-from collections import OrderedDict
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 import pytest_asyncio
@@ -31,23 +29,20 @@ import pytest_asyncio
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 sys.path.insert(0, PLUGIN_ROOT)
 sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
-sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
-from adapter import (
-    ChattoAdapter,
-    _capabilities,
-    HermesChatType,
-    chat_type_for_room_kind,
-    hermes_check_fn as check_requirements,
-    hermes_validate_config as validate_config,
-    register,
-)
+# Importing ``adapter`` puts the vendored dependencies on sys.path as a side
+# effect, but import sorting may legally move that import after the chattolib
+# and gateway ones — so bootstrap the vendor paths explicitly instead.
+from vendor_path import setup_vendor_path
+
+setup_vendor_path()
+
 from chattolib.realtime_types import ReactionPayload
 from chattolib.types import (
     Asset,
-    DirectoryMember,
     AssetUpload,
     AssetUrl,
+    DirectoryMember,
     Message,
     MessageAttachment,
     PresenceStatus,
@@ -57,17 +52,30 @@ from chattolib.types import (
     RoomWithViewerState,
     User,
 )
-from platform_config import ChattoConstants
 from gateway.config import PlatformConfig
 from gateway.platforms.base import (
     BasePlatformAdapter,
     CachedMedia,
-    MessageEvent,
     MessageType,
     SendResult,
     get_inbound_media_max_bytes,
 )
 
+from adapter import (
+    ChattoAdapter,
+    HermesChatType,
+    _capabilities,
+    chat_type_for_room_kind,
+    register,
+)
+from adapter import (
+    hermes_check_fn as check_requirements,
+)
+from adapter import (
+    hermes_validate_config as validate_config,
+)
+from platform_config import ChattoConstants
+
 _EMOJI_TO_SHORTCODE = ChattoConstants.EMOJI_TO_SHORTCODE
 _MAX_MESSAGE_LENGTH = ChattoConstants.MAX_MESSAGE_LENGTH
 _SEEN_CAP = ChattoConstants.SEEN_CAP
@@ -75,6 +83,7 @@ _SEEN_CAP = ChattoConstants.SEEN_CAP
 
 # -- Helpers --
 
+
 class _MockPluginContext:
     """Minimal mock for the plugin registration context."""
 
@@ -83,7 +92,8 @@ class _MockPluginContext:
         self.registered_kwargs = None
 
     def register_platform(self, **kwargs):
-        from gateway.platform_registry import platform_registry, PlatformEntry
+        from gateway.platform_registry import PlatformEntry, platform_registry
+
         entry = PlatformEntry(
             name=kwargs["name"],
             label=kwargs.get("label", kwargs["name"]),
@@ -102,16 +112,21 @@ class _MockPluginContext:
 def _ensure_chatto_registered():
     """Register the platform so Platform(PLATFORM_NAME) resolves."""
     from gateway.platform_registry import platform_registry
+
     if not platform_registry.is_registered(ChattoConstants.PLATFORM_NAME):
         ctx = _MockPluginContext()
         register(ctx)
 
 
 _CHATTO_ENV_KEYS = [
-    "CHATTO_BASE_URL", "CHATTO_LOGIN", "CHATTO_PASSWORD",
+    "CHATTO_BASE_URL",
+    "CHATTO_LOGIN",
+    "CHATTO_PASSWORD",
     "CHATTO_HOME_CHANNEL",
-    "CHATTO_REQUIRE_MENTION", "CHATTO_ALLOWED_USERS",
-    "CHATTO_ALLOW_ALL_USERS", "CHATTO_AUTO_THREAD",
+    "CHATTO_REQUIRE_MENTION",
+    "CHATTO_ALLOWED_USERS",
+    "CHATTO_ALLOW_ALL_USERS",
+    "CHATTO_AUTO_THREAD",
     "CHATTO_REACTIONS",
 ]
 
@@ -135,8 +150,15 @@ def _make_config(**extra_overrides):
 
 def _make_room(room_id, name, kind):
     """Build a real chattolib Room, as the client would return."""
-    return Room(id=room_id, name=name, kind=kind, description="",
-                archived=False, group_id="", universal=kind != RoomKind.DM)
+    return Room(
+        id=room_id,
+        name=name,
+        kind=kind,
+        description="",
+        archived=False,
+        group_id="",
+        universal=kind != RoomKind.DM,
+    )
 
 
 def _make_user(user_id, login):
@@ -195,6 +217,7 @@ def _make_adapter(**extra_overrides):
 
 # -- Emoji shortcode conversion --
 
+
 class TestEmojiShortcode:
     """Test emoji to shortcode mapping."""
 
@@ -216,6 +239,7 @@ class TestEmojiShortcode:
 
 # -- Adapter instantiation and properties --
 
+
 class TestAdapterInstantiation:
     """Test ChattoAdapter creation and basic properties."""
 
@@ -251,6 +275,7 @@ class TestAdapterInstantiation:
 
 # -- Registration and requirements --
 
+
 class TestRegistration:
     """Test plugin registration."""
 
@@ -310,6 +335,7 @@ class TestRegistration:
 
 # -- Send functionality --
 
+
 class TestSend:
     """Test message sending functionality."""
 
@@ -356,6 +382,7 @@ class TestSend:
 
 # -- Reactions --
 
+
 class TestReactions:
     """Test reaction functionality."""
 
@@ -410,6 +437,7 @@ class TestReactions:
 
 # -- Edit and Delete Messages --
 
+
 class TestMessageEditing:
     """Test message editing and deletion."""
 
@@ -445,7 +473,9 @@ class TestMessageEditing:
         """Overlong content must fall back to send() (which splits), not be
         silently truncated into a lossy edit."""
         result = await adapter.edit_message(
-            "room-1", "msg-1", "x" * (_MAX_MESSAGE_LENGTH + 1),
+            "room-1",
+            "msg-1",
+            "x" * (_MAX_MESSAGE_LENGTH + 1),
         )
         assert result.success is False
         adapter._chatto_client.update_message.assert_not_called()
@@ -485,6 +515,7 @@ class TestMessageEditing:
 
 # -- Outgoing text formatting --
 
+
 class TestFormatMessage:
     """format_message() only fixes what renders wrong in Chatto."""
 
@@ -508,6 +539,7 @@ class TestFormatMessage:
 
 # -- Handoff threads --
 
+
 class TestHandoffThread:
     """create_handoff_thread() anchors a handoff on a seed message."""
 
@@ -527,7 +559,9 @@ class TestHandoffThread:
         result = await adapter.create_handoff_thread("room-1", "Refactor run")
 
         assert result == "seed-1"
-        assert adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
+        assert (
+            adapter._chatto_client.post_message.call_args.kwargs["room_id"] == "room-1"
+        )
         adapter._chatto_client.follow_thread.assert_called_once_with("room-1", "seed-1")
         # Our own seed must not come back in as inbound traffic.
         assert adapter._is_seen("seed-1") is True
@@ -545,6 +579,7 @@ class TestHandoffThread:
 
 # -- Native file / video / audio delivery --
 
+
 class TestUploadAsset:
     """Drives the real _upload_asset against real chattolib result types.
 
@@ -560,10 +595,17 @@ class TestUploadAsset:
         upload = AssetUpload(upload_id="up-1", room_id="room-1")
         adapter._chatto_client.create_upload = AsyncMock(return_value=upload)
         adapter._chatto_client.upload_chunk = AsyncMock(return_value=upload)
-        adapter._chatto_client.complete_upload = AsyncMock(return_value=(
-            upload,
-            Asset(id="asset-9", filename="horse.jpg", content_type="image/jpeg", size=103),
-        ))
+        adapter._chatto_client.complete_upload = AsyncMock(
+            return_value=(
+                upload,
+                Asset(
+                    id="asset-9",
+                    filename="horse.jpg",
+                    content_type="image/jpeg",
+                    size=103,
+                ),
+            )
+        )
         return adapter
 
     async def test_returns_the_asset_id(self, adapter):
@@ -573,11 +615,14 @@ class TestUploadAsset:
         """AssetUpload calls it upload_id, not id — reading the wrong field made
         every upload fail with 'CreateUpload returned no upload ID'."""
         await adapter._upload_asset("room-1", str(self.path))
-        assert adapter._chatto_client.upload_chunk.await_args.kwargs["upload_id"] == "up-1"
+        assert (
+            adapter._chatto_client.upload_chunk.await_args.kwargs["upload_id"] == "up-1"
+        )
 
     async def test_missing_upload_id_is_reported(self, adapter):
         adapter._chatto_client.create_upload = AsyncMock(
-            return_value=AssetUpload(upload_id="", room_id="room-1"))
+            return_value=AssetUpload(upload_id="", room_id="room-1")
+        )
         assert await adapter._upload_asset("room-1", str(self.path)) is None
 
 
@@ -606,7 +651,9 @@ class TestNativeSends:
     )
     async def test_uploads_and_attaches(self, adapter, method, arg_name):
         result = await getattr(adapter, method)(
-            "room-1", **{arg_name: "/tmp/thing.bin"}, caption="here you go",
+            "room-1",
+            **{arg_name: "/tmp/thing.bin"},
+            caption="here you go",
         )
         assert result.success is True
         adapter._upload_asset.assert_called_once_with("room-1", "/tmp/thing.bin")
@@ -634,6 +681,7 @@ class TestNativeSends:
 
 # -- Batched image delivery --
 
+
 class TestSendMultipleImages:
     """A batch of images belongs in ONE Chatto message."""
 
@@ -650,7 +698,8 @@ class TestSendMultipleImages:
 
     async def test_bundles_into_single_message(self, adapter):
         await adapter.send_multiple_images(
-            "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
+            "room-1",
+            [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
         )
         adapter._chatto_client.post_message.assert_called_once()
         call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
@@ -673,7 +722,8 @@ class TestSendMultipleImages:
     async def test_partial_upload_failure_still_sends_the_rest(self, adapter):
         adapter._upload_asset = AsyncMock(side_effect=["asset-1", None])
         await adapter.send_multiple_images(
-            "room-1", [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
+            "room-1",
+            [("/tmp/a.png", "first"), ("/tmp/b.png", "second")],
         )
         call_kwargs = adapter._chatto_client.post_message.call_args.kwargs
         assert call_kwargs["attachment_asset_ids"] == ["asset-1"]
@@ -689,6 +739,7 @@ class TestSendMultipleImages:
 
 # -- Reaction event forwarding --
 
+
 class TestReactionForwarding:
     """Human reactions reach the gateway's reaction hook surface."""
 
@@ -704,7 +755,9 @@ class TestReactionForwarding:
         event.kind = kind
         event.actor_id = actor_id
         payload = ReactionPayload(
-            room_id="room-1", message_event_id="msg-1", emoji="thumbsup",
+            room_id="room-1",
+            message_event_id="msg-1",
+            emoji="thumbsup",
         )
         # RealtimeEvent.get() only yields the payload for its own kind.
         event.get = MagicMock(side_effect=lambda k: payload if k == kind else None)
@@ -750,6 +803,7 @@ class TestReactionForwarding:
 
 # -- chat_type mapping --
 
+
 class TestChatTypeMapping:
     """RoomKind -> the gateway's chat_type vocabulary."""
 
@@ -803,6 +857,7 @@ class TestChatTypeMapping:
 
 # -- Presence --
 
+
 class TestPresence:
     """Presence is a server-side TTL: stop re-announcing and the bot goes offline."""
 
@@ -837,7 +892,8 @@ class TestPresence:
         adapter = self._adapter()
         adapter._closing = False
         adapter._chatto_client.update_presence = AsyncMock(
-            side_effect=[RuntimeError("boom"), None, None])
+            side_effect=[RuntimeError("boom"), None, None]
+        )
         with patch.object(ChattoConstants, "PRESENCE_REFRESH_INTERVAL", 0.01):
             task = asyncio.create_task(adapter._presence_refresh_loop())
             for _ in range(200):
@@ -855,7 +911,9 @@ class TestPresence:
 
     async def test_announce_online_reports_failure(self):
         adapter = self._adapter()
-        adapter._chatto_client.update_presence = AsyncMock(side_effect=RuntimeError("nope"))
+        adapter._chatto_client.update_presence = AsyncMock(
+            side_effect=RuntimeError("nope")
+        )
         assert await adapter._announce_online() is False
 
     async def test_disconnect_does_not_broadcast_offline(self):
@@ -869,6 +927,7 @@ class TestPresence:
 
 # -- Mentions of other people --
 
+
 class TestForeignMention:
     """With require_mention off the bot reads everything, so a message aimed at
     a named colleague would otherwise get an unsolicited answer. Acknowledge it
@@ -888,16 +947,20 @@ class TestForeignMention:
         adapter._room_kinds["room-1"] = RoomKind.CHANNEL
         adapter._room_kinds["dm-1"] = RoomKind.DM
         # The directory knows bob and nobody else.
-        adapter._chatto_client.get_user = AsyncMock(side_effect=lambda **kw: (
-            DirectoryMember(user=_make_user("user-2", "bob"))
-            if kw.get("login") == "bob" else None
-        ))
+        adapter._chatto_client.get_user = AsyncMock(
+            side_effect=lambda **kw: (
+                DirectoryMember(user=_make_user("user-2", "bob"))
+                if kw.get("login") == "bob"
+                else None
+            )
+        )
         return adapter
 
     async def _dispatch(self, adapter, body, room_id="room-1"):
         payload = _make_posted_payload(room_id=room_id)
         payload.fetch_message = AsyncMock(
-            return_value=_make_message(body=body, room_id=room_id))
+            return_value=_make_message(body=body, room_id=room_id)
+        )
         await adapter._dispatch_message_posted(payload)
 
     async def test_message_for_someone_else_is_only_acknowledged(self):
@@ -915,10 +978,13 @@ class TestForeignMention:
 
     async def test_several_people_addressed_and_none_of_them_us(self):
         adapter = self._adapter()
-        adapter._chatto_client.get_user = AsyncMock(side_effect=lambda **kw: (
-            DirectoryMember(user=_make_user("u", kw["login"]))
-            if kw.get("login") in {"bob", "carol"} else None
-        ))
+        adapter._chatto_client.get_user = AsyncMock(
+            side_effect=lambda **kw: (
+                DirectoryMember(user=_make_user("u", kw["login"]))
+                if kw.get("login") in {"bob", "carol"}
+                else None
+            )
+        )
         await self._dispatch(adapter, "@bob @carol schaut mal drüber")
         adapter.handle_message.assert_not_called()
         adapter.add_reaction.assert_awaited_once()
@@ -957,7 +1023,7 @@ class TestForeignMention:
         adapter = self._adapter()
         await self._dispatch(
             adapter,
-            'Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den '
+            "Bitte schreibe um 8 Uhr Europe/Berlin per @-mention den "
             'Chatto-Nutzer "nickk" an und sage: Guten Morgen.',
         )
         adapter.handle_message.assert_called_once()
@@ -1012,6 +1078,7 @@ class TestForeignMention:
 
 # -- require_mention --
 
+
 class TestRequireMention:
     """require_mention gates channels only — a DM is already addressed at the bot."""
 
@@ -1027,7 +1094,8 @@ class TestRequireMention:
     async def _dispatch(self, adapter, room_id, body):
         payload = _make_posted_payload(room_id=room_id)
         payload.fetch_message = AsyncMock(
-            return_value=_make_message(body=body, room_id=room_id))
+            return_value=_make_message(body=body, room_id=room_id)
+        )
         await adapter._dispatch_message_posted(payload)
 
     async def test_channel_without_mention_is_discarded(self):
@@ -1052,6 +1120,7 @@ class TestRequireMention:
 
 # -- Inbound attachments --
 
+
 class TestInboundAttachments:
     """Messages carrying files must reach the agent, body or not."""
 
@@ -1063,7 +1132,9 @@ class TestInboundAttachments:
         adapter._room_kinds["room-1"] = RoomKind.DM
         adapter._user_cache["user-1"] = _make_user("user-1", "alice")
         adapter.handle_message = AsyncMock()
-        adapter._download_attachment_bytes = AsyncMock(return_value=b"\x89PNG\r\n\x1a\nrest")
+        adapter._download_attachment_bytes = AsyncMock(
+            return_value=b"\x89PNG\r\n\x1a\nrest"
+        )
         return adapter
 
     async def test_image_attachment_becomes_media_url(self, adapter):
@@ -1075,7 +1146,10 @@ class TestInboundAttachments:
         )
         payload.fetch_message = AsyncMock(return_value=message)
 
-        with patch("adapter.cache_media_bytes", return_value=_cached("/cache/shot.png", "image/png", "image")):
+        with patch(
+            "adapter.cache_media_bytes",
+            return_value=_cached("/cache/shot.png", "image/png", "image"),
+        ):
             await adapter._dispatch_message_posted(payload)
 
         event = adapter.handle_message.call_args.args[0]
@@ -1087,11 +1161,15 @@ class TestInboundAttachments:
         """The empty-body early return is what silently ate file uploads."""
         payload = _make_posted_payload()
         message = _make_message(
-            body="", attachments=[_make_attachment("report.pdf", "application/pdf")],
+            body="",
+            attachments=[_make_attachment("report.pdf", "application/pdf")],
         )
         payload.fetch_message = AsyncMock(return_value=message)
 
-        with patch("adapter.cache_media_bytes", return_value=_cached("/cache/report.pdf", "application/pdf", "document")):
+        with patch(
+            "adapter.cache_media_bytes",
+            return_value=_cached("/cache/report.pdf", "application/pdf", "document"),
+        ):
             await adapter._dispatch_message_posted(payload)
 
         adapter.handle_message.assert_called_once()
@@ -1108,9 +1186,12 @@ class TestInboundAttachments:
     async def test_download_failure_still_delivers_the_text(self, adapter):
         adapter._download_attachment_bytes = AsyncMock(side_effect=RuntimeError("404"))
         payload = _make_posted_payload()
-        payload.fetch_message = AsyncMock(return_value=_make_message(
-            body="see attached", attachments=[_make_attachment("a.png", "image/png")],
-        ))
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(
+                body="see attached",
+                attachments=[_make_attachment("a.png", "image/png")],
+            )
+        )
 
         await adapter._dispatch_message_posted(payload)
 
@@ -1124,9 +1205,12 @@ class TestInboundAttachments:
         payload = _make_posted_payload()
         att = _make_attachment("clip.mp4", "video/mp4")
         att.asset_url = None
-        payload.fetch_message = AsyncMock(return_value=_make_message(
-            body="clip", attachments=[att],
-        ))
+        payload.fetch_message = AsyncMock(
+            return_value=_make_message(
+                body="clip",
+                attachments=[att],
+            )
+        )
 
         await adapter._dispatch_message_posted(payload)
 
@@ -1136,7 +1220,10 @@ class TestInboundAttachments:
 
     async def test_document_wins_over_image(self, adapter):
         """Mixed batches classify as DOCUMENT — that gates context injection."""
-        assert adapter._message_type_for_media_kinds(["image", "document"]) is MessageType.DOCUMENT
+        assert (
+            adapter._message_type_for_media_kinds(["image", "document"])
+            is MessageType.DOCUMENT
+        )
         assert adapter._message_type_for_media_kinds(["image"]) is MessageType.PHOTO
         assert adapter._message_type_for_media_kinds(["video"]) is MessageType.VIDEO
         assert adapter._message_type_for_media_kinds(["audio"]) is MessageType.AUDIO
@@ -1147,15 +1234,25 @@ class TestInboundAttachments:
         import httpx
 
         big = get_inbound_media_max_bytes() + 1
-        transport = httpx.MockTransport(lambda request: httpx.Response(
-            200, headers={"content-length": str(big)}, content=b"x",
-        ))
+        transport = httpx.MockTransport(
+            lambda request: httpx.Response(
+                200,
+                headers={"content-length": str(big)},
+                content=b"x",
+            )
+        )
         real_adapter = _make_adapter()
         real_client_cls = httpx.AsyncClient
 
-        with patch("httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)):
-            with pytest.raises(ValueError):
-                await real_adapter._download_attachment_bytes("https://chat.example.com/a.png")
+        with (
+            patch(
+                "httpx.AsyncClient", lambda **kw: real_client_cls(transport=transport)
+            ),
+            pytest.raises(ValueError),
+        ):
+            await real_adapter._download_attachment_bytes(
+                "https://chat.example.com/a.png"
+            )
 
 
 # NOTE: there are deliberately no tests for get_user(), set_presence() or
@@ -1167,6 +1264,7 @@ class TestInboundAttachments:
 
 # -- Room operations --
 
+
 class TestRoomOperations:
     """Test room creation and DM initiation."""
 
@@ -1188,7 +1286,9 @@ class TestRoomOperations:
 
     async def test_create_room(self, adapter):
         adapter._chatto_client.create_room.return_value = _make_room(
-            "room-123", "Test Room", RoomKind.CHANNEL,
+            "room-123",
+            "Test Room",
+            RoomKind.CHANNEL,
         )
         result = await adapter.create_room("Test Room", "A test room")
         assert result == "room-123"
@@ -1197,7 +1297,9 @@ class TestRoomOperations:
 
     async def test_start_dm(self, adapter):
         adapter._chatto_client.start_dm.return_value = _make_room(
-            "dm-123", "DM with user", RoomKind.DM,
+            "dm-123",
+            "DM with user",
+            RoomKind.DM,
         )
         result = await adapter.start_dm("user-123")
         assert result == "dm-123"
@@ -1207,10 +1309,12 @@ class TestRoomOperations:
 
 # -- DM room management (/join, /leave) --
 
+
 def _make_room_state(room, is_member):
     """Build a RoomWithViewerState the way list_rooms()/get_room() return it."""
     return RoomWithViewerState(
-        room=room, viewer_state=RoomViewerState(is_member=is_member),
+        room=room,
+        viewer_state=RoomViewerState(is_member=is_member),
     )
 
 
@@ -1218,7 +1322,6 @@ class TestDmRoomCommands:
     """/join and /leave arrive over DMs, change server-side membership and
     must never reach the agent pipeline."""
 
-
     def _adapter(self):
         adapter = _make_adapter()
         adapter.chatto_config.allow_all_users.value = True
@@ -1235,23 +1338,22 @@ class TestDmRoomCommands:
         adapter.send = AsyncMock()
         return adapter
 
-
     async def _dispatch(self, adapter, body, room_id="dm-1"):
         payload = _make_posted_payload(room_id=room_id)
         payload.fetch_message = AsyncMock(
-            return_value=_make_message(body=body, room_id=room_id))
+            return_value=_make_message(body=body, room_id=room_id)
+        )
         await adapter._dispatch_message_posted(payload)
 
-
     def _reply(self, adapter):
         assert adapter.send.await_count == 1
         return adapter.send.await_args.kwargs["content"]
 
-
     async def test_join_by_name_joins_and_watches(self):
         adapter = self._adapter()
         state = _make_room_state(
-            _make_room("room-9", "Deploy", RoomKind.CHANNEL), False)
+            _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
+        )
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
         adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
 
@@ -1261,13 +1363,11 @@ class TestDmRoomCommands:
         assert adapter._watch_room_ids == ["room-9"]
         assert "Joined 'Deploy' (room-9)" in self._reply(adapter)
 
-
     async def test_join_skips_rpc_when_already_a_member(self):
         adapter = self._adapter()
         """Natively invited accounts hold membership already — they only need
         seeding into the watch list."""
-        state = _make_room_state(
-            _make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
+        state = _make_room_state(_make_room("room-9", "Deploy", RoomKind.CHANNEL), True)
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[state])
 
         await self._dispatch(adapter, "/join #deploy")
@@ -1276,7 +1376,6 @@ class TestDmRoomCommands:
         assert adapter._watch_room_ids == ["room-9"]
         assert "Already a member" in self._reply(adapter)
 
-
     async def test_join_unknown_name_reports_without_joining(self):
         adapter = self._adapter()
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[])
@@ -1287,7 +1386,6 @@ class TestDmRoomCommands:
         assert "No room named '#nope'" in self._reply(adapter)
         assert adapter._watch_room_ids == []
 
-
     async def test_ambiguous_name_offers_the_candidate_ids(self):
         adapter = self._adapter()
         matches = [
@@ -1303,11 +1401,11 @@ class TestDmRoomCommands:
         reply = self._reply(adapter)
         assert "r-0" in reply and "r-1" in reply
 
-
     async def test_join_by_room_id_verifies_via_get_room(self):
         adapter = self._adapter()
         state = _make_room_state(
-            _make_room("room-9", "Deploy", RoomKind.CHANNEL), False)
+            _make_room("room-9", "Deploy", RoomKind.CHANNEL), False
+        )
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
         adapter._chatto_client.join_room = AsyncMock(return_value=state.room)
 
@@ -1317,12 +1415,10 @@ class TestDmRoomCommands:
         adapter._chatto_client.join_room.assert_awaited_once_with("room-9")
         assert adapter._watch_room_ids == ["room-9"]
 
-
     async def test_leave_stops_watching_the_room(self):
         adapter = self._adapter()
         adapter._watch_room_ids = ["room-7"]
-        state = _make_room_state(
-            _make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
+        state = _make_room_state(_make_room("room-7", "Deploy", RoomKind.CHANNEL), True)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
 
         await self._dispatch(adapter, "/leave room-7")
@@ -1331,11 +1427,9 @@ class TestDmRoomCommands:
         assert adapter._watch_room_ids == []
         assert "Left 'Deploy' (room-7)" in self._reply(adapter)
 
-
     async def test_leave_refuses_direct_messages(self):
         adapter = self._adapter()
-        state = _make_room_state(
-            _make_room("dm-2", "", RoomKind.DM), True)
+        state = _make_room_state(_make_room("dm-2", "", RoomKind.DM), True)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
 
         await self._dispatch(adapter, "/leave dm-2")
@@ -1343,14 +1437,12 @@ class TestDmRoomCommands:
         adapter._chatto_client.leave_room.assert_not_awaited()
         assert "Direct messages cannot be left" in self._reply(adapter)
 
-
     async def test_leave_refuses_home_channel(self):
         """Leaving CHATTO_HOME_CHANNEL would break cron/notification delivery."""
         adapter = self._adapter()
         adapter.chatto_config.home_channel.value = "room-7"
         adapter._watch_room_ids = ["room-7"]
-        state = _make_room_state(
-            _make_room("room-7", "Home", RoomKind.CHANNEL), True)
+        state = _make_room_state(_make_room("room-7", "Home", RoomKind.CHANNEL), True)
         adapter._chatto_client.get_room = AsyncMock(return_value=state)
 
         await self._dispatch(adapter, "/leave room-7")
@@ -1358,7 +1450,6 @@ class TestDmRoomCommands:
         adapter._chatto_client.leave_room.assert_not_awaited()
         assert "home channel" in self._reply(adapter)
 
-
     async def test_commands_outside_dms_are_ignored(self):
         adapter = self._adapter()
         """In a channel the text is just a message — mention gating applies,
@@ -1372,14 +1463,12 @@ class TestDmRoomCommands:
         adapter.handle_message.assert_not_called()
         adapter.send.assert_not_called()
 
-
     async def test_non_command_dm_falls_through_to_pipeline(self):
         adapter = self._adapter()
         await self._dispatch(adapter, "/status all good")
         adapter.handle_message.assert_awaited_once()
         adapter.send.assert_not_called()
 
-
     async def test_missing_argument_gets_usage_reply(self):
         adapter = self._adapter()
         for body in ("/join", "/leave"):
@@ -1388,18 +1477,15 @@ class TestDmRoomCommands:
             assert self._reply(adapter).startswith("Usage:")
 
 
-
 class TestRoomWatchRefresh:
     """_refresh_rooms mirrors watch-list membership against the server."""
 
-
     def _adapter(self):
         adapter = _make_adapter()
         client = adapter._chatto_client
         client.list_rooms = AsyncMock(return_value=[])
         return adapter
 
-
     async def test_unwatches_rooms_no_longer_joined(self):
         adapter = self._adapter()
         adapter._watch_room_ids = ["gone-1", "kept"]
@@ -1410,21 +1496,18 @@ class TestRoomWatchRefresh:
 
         assert adapter._watch_room_ids == ["kept"]
 
-
     async def test_warns_once_when_home_channel_is_not_joined(self):
         adapter = self._adapter()
         adapter.chatto_config.home_channel.value = "home-x"
         other = _make_room_state(_make_room("other", "Other", RoomKind.CHANNEL), True)
         adapter._chatto_client.list_rooms = AsyncMock(return_value=[other])
 
-
         await adapter._refresh_rooms()
         assert adapter._home_warning_logged
 
         await adapter._refresh_rooms()
         assert adapter._home_warning_logged
 
-
     async def test_no_warning_while_home_channel_is_member(self):
         adapter = self._adapter()
         adapter.chatto_config.home_channel.value = "home-x"
@@ -1437,6 +1520,7 @@ class TestRoomWatchRefresh:
 
 # -- Constants --
 
+
 class TestConstants:
     """Test that constants are properly defined."""
 

+ 12 - 7
test_platform_config.py

@@ -1,8 +1,5 @@
 import os
 import sys
-from typing import Optional
-
-import pytest
 
 PLUGIN_ROOT = os.path.abspath(os.path.dirname(__file__))
 sys.path.insert(0, PLUGIN_ROOT)
@@ -10,11 +7,11 @@ sys.path.insert(0, os.environ.get("HERMES_ROOT", "/opt/hermes"))
 sys.path.insert(0, "/root/.hermes/plugins/platforms/chatto")
 
 from platform_config import (
+    _get_env_or_extra_list,
     _get_env_or_extra_str,
     _get_env_or_extra_str_opt,
     _get_env_or_extra_truthy,
     _split_str_to_list,
-    _get_env_or_extra_list,
 )
 
 
@@ -55,11 +52,15 @@ class TestPlatformConfigHelpers:
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         assert _get_env_or_extra_truthy("CHATTO_TEST", "false") is False
 
-    def test_get_env_or_extra_truthy_default_true_when_none_and_no_extra(self, monkeypatch):
+    def test_get_env_or_extra_truthy_default_true_when_none_and_no_extra(
+        self, monkeypatch
+    ):
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         assert _get_env_or_extra_truthy("CHATTO_TEST", None, default=True) is True
 
-    def test_get_env_or_extra_truthy_default_false_when_none_and_no_extra(self, monkeypatch):
+    def test_get_env_or_extra_truthy_default_false_when_none_and_no_extra(
+        self, monkeypatch
+    ):
         monkeypatch.delenv("CHATTO_TEST", raising=False)
         assert _get_env_or_extra_truthy("CHATTO_TEST", None, default=False) is False
 
@@ -76,7 +77,11 @@ class TestPlatformConfigHelpers:
 
     def test_get_env_or_extra_list_prefers_env(self, monkeypatch):
         monkeypatch.setenv("CHATTO_TEST", " one,two , three")
-        assert _get_env_or_extra_list("CHATTO_TEST", ["ignored", "list"]) == ["one", "two", "three"]
+        assert _get_env_or_extra_list("CHATTO_TEST", ["ignored", "list"]) == [
+            "one",
+            "two",
+            "three",
+        ]
 
     def test_get_env_or_extra_list_uses_extra_list(self, monkeypatch):
         monkeypatch.delenv("CHATTO_TEST", raising=False)

+ 5 - 4
vendor_path.py

@@ -32,7 +32,6 @@ from __future__ import annotations
 import platform
 import sys
 from pathlib import Path
-from typing import List
 
 VENDOR_DIR = Path(__file__).parent / "vendor"
 
@@ -59,7 +58,9 @@ def _is_musl() -> bool:
 def platform_tag() -> str:
     """Return the vendor/platform/ subdirectory name for this interpreter."""
     system = platform.system().lower()
-    machine = _MACHINE_ALIASES.get(platform.machine().lower(), platform.machine().lower())
+    machine = _MACHINE_ALIASES.get(
+        platform.machine().lower(), platform.machine().lower()
+    )
 
     if system == "linux":
         return f"linux-{machine}-musl" if _is_musl() else f"linux-{machine}"
@@ -70,7 +71,7 @@ def platform_tag() -> str:
     return f"{system}-{machine}"
 
 
-def available_platforms() -> List[str]:
+def available_platforms() -> list[str]:
     """Platform directories that were actually vendored into this checkout."""
     platform_root = VENDOR_DIR / "platform"
     if not platform_root.is_dir():
@@ -78,7 +79,7 @@ def available_platforms() -> List[str]:
     return sorted(p.name for p in platform_root.iterdir() if p.is_dir())
 
 
-def setup_vendor_path() -> List[str]:
+def setup_vendor_path() -> list[str]:
     """Put the vendored packages for this platform on ``sys.path``.
 
     The compiled extensions are prepended, the pure-Python packages appended;

+ 8 - 9
vendor_split.py

@@ -19,7 +19,6 @@ import filecmp
 import shutil
 import sys
 from pathlib import Path
-from typing import Dict, List, Set, Tuple
 
 
 def _is_pure(dist_info: Path) -> bool:
@@ -34,7 +33,7 @@ def _is_pure(dist_info: Path) -> bool:
     return False
 
 
-def _record_files(dist_info: Path) -> List[str]:
+def _record_files(dist_info: Path) -> list[str]:
     record = dist_info / "RECORD"
     if not record.is_file():
         return []
@@ -42,10 +41,10 @@ def _record_files(dist_info: Path) -> List[str]:
         return [row[0] for row in csv.reader(handle) if row and row[0]]
 
 
-def classify(tree: Path) -> Tuple[Set[str], Set[str]]:
+def classify(tree: Path) -> tuple[set[str], set[str]]:
     """Return (pure_files, binary_files) as paths relative to ``tree``."""
-    pure: Set[str] = set()
-    binary: Set[str] = set()
+    pure: set[str] = set()
+    binary: set[str] = set()
     for dist_info in sorted(tree.glob("*.dist-info")):
         target = pure if _is_pure(dist_info) else binary
         target.update(_record_files(dist_info))
@@ -53,7 +52,7 @@ def classify(tree: Path) -> Tuple[Set[str], Set[str]]:
     return pure, binary
 
 
-def copy_files(tree: Path, names: Set[str], dest: Path) -> int:
+def copy_files(tree: Path, names: set[str], dest: Path) -> int:
     copied = 0
     for name in sorted(names):
         src = tree / name
@@ -71,14 +70,14 @@ def copy_files(tree: Path, names: Set[str], dest: Path) -> int:
     return copied
 
 
-def main(argv: List[str]) -> int:
+def main(argv: list[str]) -> int:
     if len(argv) < 4:
         print(__doc__, file=sys.stderr)
         return 2
 
     build_dir, vendor_dir, tags = Path(argv[1]), Path(argv[2]), argv[3:]
     common_dir = vendor_dir / "common"
-    reference: Dict[str, Path] = {}
+    reference: dict[str, Path] = {}
 
     for tag in tags:
         tree = build_dir / tag
@@ -106,7 +105,7 @@ def main(argv: List[str]) -> int:
         else:
             # The shared part must really be identical, otherwise common/ would
             # silently carry one platform's variant for all of them.
-            ref_files: Set[str] = reference["files"]  # type: ignore[assignment]
+            ref_files: set[str] = reference["files"]  # type: ignore[assignment]
             if pure != ref_files:
                 only_here = sorted(pure - ref_files)[:5]
                 only_ref = sorted(ref_files - pure)[:5]

Some files were not shown because too many files changed in this diff