Ver Fonte

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 há 1 semana atrás
pai
commit
bc2804309a
8 ficheiros alterados com 549 adições e 283 exclusões
  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"]
-

Diff do ficheiro suprimidas por serem muito extensas
+ 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]

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff