Skip to content

Commit 7aa54e1

Browse files
chore(types): reduce pyrefly errors 130 → 21 (another 84% drop on top of 38%)
Continues the clean-up on fix/pyrefly-zero-errors. Combined with the prior commit, the total reduction this branch is 205 → 21 errors (90%). **NoReturn annotations for error re-raisers** (−22 errors): Slack's `_handle_slack_error` and Google Chat's `_handle_google_chat_error` always raise but were typed `-> None`/`-> Any`, so every `try/except` that propagated through them showed as "missing return". Annotated both as `NoReturn` to let pyrefly skip the downstream paths. **Framework-agnostic `request` duck-typing** (−14 not-async errors): `hasattr(request, "text") and callable(request.text)` narrows `Any` to `object`, making `await request.text()` fail type-checking. Swapped to `getattr(request, "text", None)` (preserves `Any`) plus `inspect.isawaitable` for the sync-vs-async branch — same runtime behavior, but properly typed. Applied across all 7 adapters that duck-type aiohttp / FastAPI / Flask requests. Bonus: also fixes the latent runtime bug where a sync `request.text` (Flask) would have raised `TypeError: object is not awaitable`. **Handler sync/async union** (−3 not-async errors): handler types are `Callable[..., Awaitable[None] | None]` but `_run_handlers`, `_direct_message_handlers`, and pattern handlers all `await` unconditionally. Switched to `inspect.isawaitable` guards — same runtime fix, same pragmatism about the TS port's `Promise<void> | void` convention. **Protocol conformance**: - `_ChatSingleton` now `Protocol` (was plain class), so `Chat` structurally satisfies it without an import cycle - `ThreadImpl.channel` returns `Channel` (protocol) not `ChannelImpl` so `ThreadImpl` passes the `Thread` protocol - `set_state` param renamed `new_state` → `state` to match protocol - `_ChannelImplConfigForChat` removed (duplicate of `_ChannelImplConfigWithAdapter`) - `ChatInstance` protocol now declares `handle_incoming_message` (Discord adapter was calling it but the protocol didn't advertise it) **TypedDict unions duck-typed via `cast`**: many adapters `.get()` against TypedDict unions where the key only exists on some variants. Added narrow `cast(...)` calls where the code has already runtime-verified the variant via a `type` check. **Card-element helpers**: helpers typed `dict[str, Any]` but callers passed `CardChild` (union of TypedDicts). Added `cast("dict[str, Any]", child)` at the `_render_child` / `_convert_child_to_widgets` branches — the variant is already narrowed via `child.get("type")`. **Misc**: - `WhatsAppRawMessage.message` tightened `dict[str, Any]` → `WhatsAppInboundMessage` - `GitHubAdapter._parse_issue_comment` / `_parse_review_comment` param typed `GitHubRepository` instead of `dict[str, Any]` - `_get_attachment_type` returns `Literal["audio","file","image","video"]` - `is_encrypted_token_data` isn't a TypeGuard, so added an `assert` to collapse the `Optional` before the attribute access Baseline regenerated: 213 → 21 entries (90% of original errors gone). `pyrefly check --baseline=.pyrefly-baseline.json` shows 0 new errors. Validation: - `uv run pytest tests/ -q` → 3359 passed, 11 skipped - `uv run ruff check src/ tests/ scripts/` → clean - `uv run ruff format --check src/ tests/ scripts/` → clean - `uv run pyrefly check --baseline=.pyrefly-baseline.json` → 0 new
1 parent 43c2cf0 commit 7aa54e1

21 files changed

Lines changed: 349 additions & 1516 deletions

File tree

.pyrefly-baseline.json

Lines changed: 26 additions & 1334 deletions
Large diffs are not rendered by default.

src/chat_sdk/adapters/discord/adapter.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
from __future__ import annotations
1010

1111
import hmac
12+
import inspect
1213
import json
1314
import os
1415
import re
1516
from contextvars import ContextVar
1617
from datetime import datetime, timezone
17-
from typing import Any
18+
from typing import Any, Literal, cast
1819
from urllib.parse import quote
1920

2021
from chat_sdk.adapters.discord.cards import (
@@ -55,6 +56,7 @@
5556
LockScope,
5657
Message,
5758
MessageMetadata,
59+
PostableRaw,
5860
RawMessage,
5961
ReactionEvent,
6062
SlashCommandEvent,
@@ -380,7 +382,11 @@ def _handle_application_command_interaction(
380382
self._logger.warn("Chat instance not initialized, ignoring interaction")
381383
return
382384

383-
data = interaction.get("data", {})
385+
# `interaction["data"]` is a union of several TypedDicts (one per
386+
# interaction type). Cast to a plain dict so we can access shared
387+
# fields like `name` and `options` without pyrefly rejecting keys
388+
# that only appear on one variant.
389+
data = cast("dict[str, Any]", interaction.get("data", {}))
384390
command_name = data.get("name")
385391
if not command_name:
386392
self._logger.warn("No command name in application command interaction")
@@ -1160,7 +1166,7 @@ async def stream(
11601166

11611167
accumulated += text
11621168

1163-
postable: AdapterPostableMessage = {"raw": accumulated}
1169+
postable: AdapterPostableMessage = PostableRaw(raw=accumulated)
11641170

11651171
if message_id:
11661172
await self.edit_message(thread_id, message_id, postable)
@@ -1257,7 +1263,7 @@ def _parse_discord_message(self, raw: dict[str, Any], thread_id: str) -> Message
12571263
],
12581264
)
12591265

1260-
def _get_attachment_type(self, mime_type: str | None) -> str:
1266+
def _get_attachment_type(self, mime_type: str | None) -> Literal["audio", "file", "image", "video"]:
12611267
"""Determine attachment type from MIME type."""
12621268
if not mime_type:
12631269
return "file"
@@ -1399,20 +1405,26 @@ async def _discord_fetch(
13991405

14001406
async def _get_request_body(self, request: Any) -> str:
14011407
"""Extract the request body as a string."""
1402-
if hasattr(request, "body"):
1403-
body = request.body
1408+
# `hasattr` narrows `Any` to `object` and kills downstream type info,
1409+
# so we use `getattr(..., default)` which preserves `Any`. The
1410+
# `await` below is safe because `inspect.isawaitable` guards it.
1411+
body = getattr(request, "body", None)
1412+
if body is not None:
14041413
if callable(body):
14051414
body = body()
14061415
if hasattr(body, "read"):
1407-
raw = await body.read() if hasattr(body.read, "__await__") else body.read()
1416+
raw_read = body.read
1417+
raw = await raw_read() if inspect.iscoroutinefunction(raw_read) else raw_read()
14081418
return raw.decode("utf-8") if isinstance(raw, bytes) else raw
14091419
return body.decode("utf-8") if isinstance(body, bytes) else str(body)
1410-
if hasattr(request, "text"):
1411-
if callable(request.text):
1412-
return await request.text()
1413-
return request.text
1414-
if hasattr(request, "data"):
1415-
data = request.data
1420+
text_attr = getattr(request, "text", None)
1421+
if text_attr is not None:
1422+
if callable(text_attr):
1423+
result = text_attr()
1424+
return str(await result if inspect.isawaitable(result) else result)
1425+
return text_attr
1426+
data = getattr(request, "data", None)
1427+
if data is not None:
14161428
return data.decode("utf-8") if isinstance(data, bytes) else str(data)
14171429
return ""
14181430

src/chat_sdk/adapters/discord/cards.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from __future__ import annotations
99

10-
from typing import Any
10+
from typing import Any, cast
1111

1212
from chat_sdk.adapters.discord.types import DiscordActionRow, DiscordButton
1313
from chat_sdk.cards import (
@@ -113,12 +113,12 @@ def _process_child(
113113
elif child_type == "fields":
114114
_convert_fields_element(child, fields) # type: ignore[arg-type]
115115
elif child_type == "link":
116-
label = child.get("label", "") # type: ignore[union-attr]
117-
url = child.get("url", "") # type: ignore[union-attr]
116+
label = cast("str", child.get("label", ""))
117+
url = cast("str", child.get("url", ""))
118118
text_parts.append(f"[{_convert_emoji(label)}]({url})")
119119
elif child_type == "table":
120-
headers = child.get("headers", []) # type: ignore[union-attr]
121-
rows = child.get("rows", []) # type: ignore[union-attr]
120+
headers = cast("list[str]", child.get("headers", []))
121+
rows = cast("list[list[str]]", child.get("rows", []))
122122
text_parts.append("\n".join(render_gfm_table(headers, rows)))
123123
else:
124124
text = card_child_to_fallback_text(child)
@@ -270,8 +270,8 @@ def _child_to_fallback_text(child: CardChild) -> str | None:
270270
if (t := _child_to_fallback_text(c))
271271
)
272272
if child_type == "table":
273-
headers = child.get("headers", []) # type: ignore[union-attr]
274-
rows = child.get("rows", []) # type: ignore[union-attr]
273+
headers = cast("list[str]", child.get("headers", []))
274+
rows = cast("list[list[str]]", child.get("rows", []))
275275
return f"```\n{table_element_to_ascii(headers, rows)}\n```"
276276
if child_type == "divider":
277277
return "---"

src/chat_sdk/adapters/github/adapter.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import asyncio
1212
import hashlib
1313
import hmac
14+
import inspect
1415
import json
1516
import os
1617
import re
@@ -26,6 +27,7 @@
2627
GitHubIssueComment,
2728
GitHubRawMessage,
2829
GitHubReactionContent,
30+
GitHubRepository,
2931
GitHubReviewComment,
3032
GitHubThreadId,
3133
GitHubUser,
@@ -51,6 +53,7 @@
5153
LockScope,
5254
Message,
5355
MessageMetadata,
56+
PostableMarkdown,
5457
RawMessage,
5558
StreamChunk,
5659
StreamOptions,
@@ -365,7 +368,7 @@ def _handle_review_comment(
365368
def _parse_issue_comment(
366369
self,
367370
comment: GitHubIssueComment,
368-
repository: dict[str, Any],
371+
repository: GitHubRepository,
369372
pr_number: int,
370373
thread_id: str,
371374
thread_type: Literal["pr", "issue"] = "pr",
@@ -409,7 +412,7 @@ def _parse_issue_comment(
409412
def _parse_review_comment(
410413
self,
411414
comment: GitHubReviewComment,
412-
repository: dict[str, Any],
415+
repository: GitHubRepository,
413416
pr_number: int,
414417
thread_id: str,
415418
) -> Message:
@@ -528,7 +531,7 @@ async def stream(
528531
text += chunk
529532
elif hasattr(chunk, "type") and chunk.type == "markdown_text":
530533
text += chunk.text
531-
return await self.post_message(thread_id, {"markdown": text})
534+
return await self.post_message(thread_id, PostableMarkdown(markdown=text))
532535

533536
@staticmethod
534537
def _build_raw_message(decoded: Any, comment: dict[str, Any]) -> dict[str, Any]:
@@ -889,8 +892,11 @@ async def fetch_channel_info(self, channel_id: str) -> ChannelInfo:
889892

890893
def parse_message(self, raw: GitHubRawMessage) -> Message:
891894
"""Parse a raw message into normalized format."""
895+
# `raw` is a GitHubRawMessage dict with keys of varying value-types
896+
# depending on the event variant; `.get()` unions to `object | str`.
897+
# Narrow via casts where we've runtime-verified via `raw.get("type")`.
892898
if raw.get("type") == "issue_comment":
893-
thread_type = raw.get("thread_type", "pr") or "pr"
899+
thread_type = cast("Literal['issue', 'pr']", raw.get("thread_type", "pr") or "pr")
894900
thread_id = self.encode_thread_id(
895901
GitHubThreadId(
896902
owner=raw["repository"]["owner"]["login"],
@@ -900,10 +906,17 @@ def parse_message(self, raw: GitHubRawMessage) -> Message:
900906
)
901907
)
902908
return self._parse_issue_comment(
903-
raw["comment"], raw["repository"], raw["pr_number"], thread_id, thread_type
909+
cast("GitHubIssueComment", raw["comment"]),
910+
raw["repository"],
911+
raw["pr_number"],
912+
thread_id,
913+
thread_type,
904914
)
905915
else:
906-
root_comment_id = raw["comment"].get("in_reply_to_id") or raw["comment"]["id"]
916+
root_comment_id = cast(
917+
"int | None",
918+
raw["comment"].get("in_reply_to_id") or raw["comment"]["id"],
919+
)
907920
thread_id = self.encode_thread_id(
908921
GitHubThreadId(
909922
owner=raw["repository"]["owner"]["login"],
@@ -912,7 +925,12 @@ def parse_message(self, raw: GitHubRawMessage) -> Message:
912925
review_comment_id=root_comment_id,
913926
)
914927
)
915-
return self._parse_review_comment(raw["comment"], raw["repository"], raw["pr_number"], thread_id)
928+
return self._parse_review_comment(
929+
cast("GitHubReviewComment", raw["comment"]),
930+
raw["repository"],
931+
raw["pr_number"],
932+
thread_id,
933+
)
916934

917935
def render_formatted(self, content: FormattedContent) -> str:
918936
"""Render formatted content to GitHub markdown."""
@@ -1069,10 +1087,14 @@ async def _get_installation_id(self, owner: str, repo: str) -> int | None:
10691087
@staticmethod
10701088
async def _get_request_body(request: Any) -> str:
10711089
"""Extract body text from a request object."""
1072-
if hasattr(request, "text") and callable(request.text):
1073-
return await request.text()
1074-
if hasattr(request, "body"):
1075-
body = request.body
1090+
# `hasattr` narrows `Any` → `object` (which is not awaitable), so
1091+
# `getattr(..., None)` keeps `Any` for the framework duck-type path.
1092+
text_attr = getattr(request, "text", None)
1093+
if text_attr is not None and callable(text_attr):
1094+
result = text_attr()
1095+
return str(await result if inspect.isawaitable(result) else result)
1096+
body = getattr(request, "body", None)
1097+
if body is not None:
10761098
return body.decode("utf-8") if isinstance(body, bytes) else str(body)
10771099
return ""
10781100

src/chat_sdk/adapters/github/cards.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from __future__ import annotations
88

9-
from typing import Any
9+
from typing import Any, cast
1010

1111
from chat_sdk.cards import (
1212
CardChild,
@@ -92,42 +92,50 @@ def card_to_github_markdown(card: CardElement) -> str:
9292

9393

9494
def _render_child(child: CardChild) -> list[str]:
95-
"""Render a card child element to markdown lines."""
95+
"""Render a card child element to markdown lines.
96+
97+
The per-type helpers below accept `dict[str, Any]` because they access
98+
dynamic keys (`content`, `label`, etc.) that are specific to one
99+
variant of the `CardChild` union. The narrowing happens via the
100+
`child_type` check, so casting to `dict[str, Any]` at the call sites
101+
is safe and keeps the helpers simple.
102+
"""
96103
child_type = child.get("type", "")
97104

98105
if child_type == "text":
99-
return _render_text(child)
106+
return _render_text(cast("dict[str, Any]", child))
100107

101108
if child_type == "fields":
102-
return _render_fields(child)
109+
return _render_fields(cast("dict[str, Any]", child))
103110

104111
if child_type == "actions":
105-
return _render_actions(child)
112+
return _render_actions(cast("dict[str, Any]", child))
106113

107114
if child_type == "section":
108115
# Flatten section children
109116
result: list[str] = []
110-
for section_child in child.get("children", []):
117+
section_children = cast("list[CardChild]", child.get("children", []))
118+
for section_child in section_children:
111119
result.extend(_render_child(section_child))
112120
return result
113121

114122
if child_type == "image":
115-
alt = child.get("alt", "")
116-
url = child.get("url", "")
123+
alt = cast("str", child.get("alt", ""))
124+
url = cast("str", child.get("url", ""))
117125
if alt:
118126
return [f"![{_escape_markdown(alt)}]({url})"]
119127
return [f"![]({url})"]
120128

121129
if child_type == "link":
122-
label = child.get("label", "")
123-
url = child.get("url", "")
130+
label = cast("str", child.get("label", ""))
131+
url = cast("str", child.get("url", ""))
124132
return [f"[{_escape_markdown(label)}]({url})"]
125133

126134
if child_type == "divider":
127135
return ["---"]
128136

129137
if child_type == "table":
130-
return _render_table(child)
138+
return _render_table(cast("dict[str, Any]", child))
131139

132140
# Fallback
133141
text = card_child_to_fallback_text(child)
@@ -233,7 +241,7 @@ def _child_to_plain_text(child: CardChild) -> str | None:
233241
return None
234242

235243
if child_type == "table":
236-
return "\n".join(_render_table(child))
244+
return "\n".join(_render_table(cast("dict[str, Any]", child)))
237245

238246
if child_type == "section":
239247
return "\n".join(

0 commit comments

Comments
 (0)