Przeglądaj źródła

Fix StartDM stub name: acronym-aware _method_name in protoc plugin

The plugin's snake_case splitter inserted an underscore before EVERY
capital, so the StartDM RPC generated a start_d_m stub while client.py
called self._svc.rooms.start_dm -- an AttributeError at call time.
StartDM is the only RPC in the current proto set with a consecutive-
capital acronym run, so it is the only affected method (verified by
snake-casing all 171 RPC names: exactly one changes).

_method_name now inserts a boundary only after a lowercase/digit or
before the final capital of a run followed by a lowercase letter:
StartDM -> start_dm, GetRoomDMStats -> get_room_dm_stats. Regenerated
_pb (single-line delta in rooms_connect.py), added a plugin regression
test, and documented the fix + the bot-permission caveat (a bot key
cannot call StartDM; PERMISSION_DENIED) in AGENTS.md gotchas.

Verification: ruff format/check clean; mypy unchanged vs baseline (6
pre-existing import-untyped); pytest 94 passed 2 skipped; live
get_server OK vs chat.chatto.run (0.5.0-beta.15); stub now exposes
start_dm and not start_d_m.

Bumped version 0.5.0b15 -> 0.5.0b15.post1.
the-codemancer 2 tygodni temu
rodzic
commit
04ea26edb4

+ 1 - 0
AGENTS.md

@@ -142,6 +142,7 @@ to its grants.
 - **0.5.0b10: DM rooms keep deleted participants in `member_user_ids`.** Since 0.5.0-beta.10, `RoomWithViewerState.member_user_ids` on a DM includes participants whose accounts were deleted (the directory no longer prunes them); clients render those as deleted users. Channel rooms still carry an empty list. The wire shape is unchanged (same field, same number) and `RoomWithViewerState.parse` already copies the full list — so no code change was needed, but code that resolves every `member_user_ids` entry to a `User` must tolerate users that `get_user` no longer returns.
 - **0.5.0b14: pinning is gated by a dedicated `message.pin` permission (server-side behavior change; no wire change).** `RoomService.CreatePinnedMessage`/`DeletePinnedMessage` required `room.manage` until 0.5.0-beta.13; since 0.5.0-beta.14 they require the new `message.pin` permission instead. The RPCs, their request/response messages, and chattolib's `pin_message`/`unpin_message`/`list_pinned_messages` wrappers are unchanged — but a bot/role that pinned via `room.manage` must now hold `message.pin` (or the server returns `PERMISSION_DENIED`). Same release added an additive `livekit` (`AdminLiveKitStatus`) field 6 to admin `GetSystemInfoResponse` (LiveKit setup/config state, server-API reachability as `AdminLiveKitConnectionState`, webhook receipt times; never the API/webhook secrets) — `admin_get_system_info()` returns it raw in `data["livekit"]` with no client change.
 - **Never regenerate protos from `chattocorp/chatto@main`.** `scripts/generate_pb.sh` defaults to a tag derived from `pyproject.toml`: `v<base>` for finals, and `v<base>-<kind>.<n>` for PEP 440 pre-releases (`0.5.0b6` → `v0.5.0-beta.6`), probing candidates with `git ls-remote` (exact ref match; the REST `/tags/{name}` endpoint 404s on lightweight tags). Override with `CHATTO_REF=<tag>` only when you know the deployed server has caught up. chattolib 0.4.19 shipped once from `main` and broke realtime for every downstream client because Chatto's main had a protocol-v2 rewrite that the deployed 0.4.19 server did not speak.
+- **0.5.0b15.post1: the protoc plugin's `_method_name` is acronym-aware.** The old splitter inserted `_` before *every* capital, so `StartDM` generated `start_d_m` — while `client.py` called `start_dm`, making `start_dm()` an `AttributeError` at call time (shipped broken since the 0.5.x ConnectRPC regeneration; `StartDM` is the only RPC in the current proto set with an acronym run, so nothing else changed). Fixed in `src/chattolib/_protoc_plugin.py` (`StartDM` → `start_dm`; `GetRoomDMStats` → `get_room_dm_stats`); regenerate to pick it up. Note the server side: a **bot key cannot call `StartDM` at all** (`PERMISSION_DENIED` — DMs are human-initiated; a bot can only answer inside a DM room a human already opened), so the wrapper is kept for human-login-style use but is not exercised by bot integration tests.
 
 ### Known bugs
 

+ 1 - 1
pyproject.toml

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "chattolib"
-version = "0.5.0b15"
+version = "0.5.0b15.post1"
 description = "Async Python client library for the Chatto webchat Connect API"
 requires-python = ">=3.11"
 license = "MPL-2.0 AND Apache-2.0"

+ 2 - 2
src/chattolib/_pb/chatto/api/v1/rooms_connect.py

@@ -105,7 +105,7 @@ class RoomServiceClient(ConnectClient):
             headers=headers,
         )
 
-    async def start_d_m(self, request: chatto_api_v1_rooms_pb2.StartDMRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.StartDMResponse:
+    async def start_dm(self, request: chatto_api_v1_rooms_pb2.StartDMRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.StartDMResponse:
         return await self.execute_unary(
             request=request,
             method=MethodInfo(
@@ -419,7 +419,7 @@ class RoomServiceClientSync(ConnectClientSync):
             headers=headers,
         )
 
-    def start_d_m(self, request: chatto_api_v1_rooms_pb2.StartDMRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.StartDMResponse:
+    def start_dm(self, request: chatto_api_v1_rooms_pb2.StartDMRequest, *, headers: dict[str, str] | None = None) -> chatto_api_v1_rooms_pb2.StartDMResponse:
         return self.execute_unary(
             request=request,
             method=MethodInfo(

+ 14 - 3
src/chattolib/_protoc_plugin.py

@@ -54,11 +54,22 @@ _HEADER = "# Generated by chattolib's protoc plugin (protoc-gen-chattolib). DO N
 
 
 def _method_name(m: Any) -> str:
-    """snake_case the RPC name, matching Python method naming."""
+    """snake_case the RPC name, matching Python method naming.
+
+    Acronym-aware: consecutive capitals (an acronym like ``DM`` in
+    ``StartDM``) do NOT get split apart — ``StartDM`` -> ``start_dm``,
+    not ``start_d_m``. An underscore is inserted before an uppercase run
+    only at a word boundary: after a lowercase/digit, or before the final
+    capital of a run that is followed by a lowercase letter.
+    """
+    name = m.name
     out = []
-    for i, ch in enumerate(m.name):
+    for i, ch in enumerate(name):
         if ch.isupper() and i:
-            out.append("_")
+            prev = name[i - 1]
+            nxt = name[i + 1] if i + 1 < len(name) else ""
+            if not prev.isupper() or nxt.islower():
+                out.append("_")
         out.append(ch.lower())
     return "".join(out)
 

+ 37 - 0
tests/test_protoc_plugin.py

@@ -104,6 +104,43 @@ def test_cross_file_message_reference_uses_defining_module():
     assert "chatto_api_v1_account_pb2.ListExternalIdentitiesRequest" not in content
 
 
+def test_acronym_rpc_keeps_acronym_whole():
+    """Regression: ``StartDM`` must snake_case to ``start_dm``, not ``start_d_m``.
+
+    (The old per-capital splitter chopped consecutive capitals — acronym runs
+    like ``DM`` — into single letters, so the generated stub was
+    ``start_d_m`` while ``client.py`` called ``start_dm``: an AttributeError
+    only reachable at call time.)
+    """
+    req = g.CodeGeneratorRequest()
+    req.file_to_generate.append("chatto/api/v1/rooms.proto")
+    fd = req.proto_file.add()
+    fd.name = "chatto/api/v1/rooms.proto"
+    fd.package = "chatto.api.v1"
+    for name in (
+        "StartDMRequest",
+        "StartDMResponse",
+        "GetRoomDMStatsRequest",
+        "GetRoomDMStatsResponse",
+    ):
+        fd.message_type.add().name = name
+    svc = fd.service.add()
+    svc.name = "RoomService"
+    for name in ("StartDM", "GetRoomDMStats"):
+        m = svc.method.add()
+        m.name = name
+        m.input_type = f".chatto.api.v1.{name}Request"
+        m.output_type = f".chatto.api.v1.{name}Response"
+    resp = generate(req)
+    content = resp.file[0].content
+    assert "async def start_dm(" in content
+    assert "def start_dm(" in content  # sync variant
+    assert "start_d_m" not in content
+    # acronym followed by another word: boundary before the acronym AND before
+    # the word that follows it
+    assert "async def get_room_dm_stats(" in content
+
+
 def test_skips_files_without_services():
     req = g.CodeGeneratorRequest()
     req.file_to_generate.append("chatto/realtime/v1/realtime.proto")