Skip to content

Commit 53379ee

Browse files
refactor: split MessageDict by role for mypy narrowing
1 parent cb505ea commit 53379ee

10 files changed

Lines changed: 291 additions & 99 deletions

models/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
from models.record_data import RecordDataUnion
77
from models.search import SearchHitDict
88
from models.session import (
9+
AssistantMessageDict,
910
MessageDict,
11+
ProgressMessageDict,
1012
QuickSessionInfoDict,
13+
ResultMessageDict,
1114
RoleLiteral,
1215
SessionDict,
1316
SessionMetadataDict,
17+
SystemMessageDict,
1418
ToolUseDict,
19+
UserMessageDict,
1520
)
1621
from models.stats import FilesTouchedDict, SessionStatsDict
1722
from models.tool_results import ToolResultUnion
@@ -20,17 +25,22 @@
2025
"ErrorResponse",
2126
"ExportStateDict",
2227
"FilesTouchedDict",
28+
"AssistantMessageDict",
2329
"MessageDict",
30+
"ProgressMessageDict",
2431
"ProjectDict",
2532
"ProjectSessionRowDict",
2633
"QuickSessionInfoDict",
34+
"ResultMessageDict",
2735
"RoleLiteral",
2836
"SearchHitDict",
2937
"SessionDict",
3038
"SessionListItemDict",
3139
"SessionMetadataDict",
3240
"SessionStatsDict",
41+
"SystemMessageDict",
3342
"RecordDataUnion",
3443
"ToolResultUnion",
3544
"ToolUseDict",
45+
"UserMessageDict",
3646
]

models/session.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Parsed session shapes from jsonl_parser."""
22

3-
from typing import Any, Literal, NotRequired, TypedDict
3+
from typing import Any, Literal, NotRequired, TypedDict, Union
44

55
from models.record_data import RecordDataUnion
66
from models.tool_results import ToolNameLiteral, ToolResultUnion
@@ -26,32 +26,66 @@ class MessageUsageDict(TypedDict, total=False):
2626
SystemSubtypeLiteral = Literal["compact_boundary", "init"]
2727

2828

29-
class MessageDict(TypedDict):
30-
role: RoleLiteral
31-
uuid: NotRequired[str | None]
32-
parent_uuid: NotRequired[str | None]
33-
timestamp: NotRequired[str | None]
29+
class BaseMessageDict(TypedDict, total=False):
30+
"""Fields shared across every parsed message role."""
31+
32+
uuid: str | None
33+
parent_uuid: str | None
34+
timestamp: str | None
35+
is_sidechain: bool
36+
37+
38+
class UserMessageDict(BaseMessageDict):
39+
role: Literal["user"]
3440
text: NotRequired[str]
35-
content: NotRequired[str]
3641
images: NotRequired[list[Any] | None]
37-
is_sidechain: NotRequired[bool]
3842
tool_result: NotRequired[ToolResultUnion | None]
3943
tool_result_parsed: NotRequired[dict[str, object] | None]
4044
slug: NotRequired[str | None]
45+
46+
47+
class AssistantMessageDict(BaseMessageDict):
48+
role: Literal["assistant"]
49+
text: NotRequired[str]
4150
model: NotRequired[str]
4251
stop_reason: NotRequired[str]
4352
thinking: NotRequired[str | None]
4453
tool_uses: NotRequired[list[ToolUseDict] | None]
4554
is_api_error: NotRequired[bool]
4655
usage: NotRequired[MessageUsageDict]
56+
57+
58+
class SystemMessageDict(BaseMessageDict):
59+
role: Literal["system"]
60+
text: NotRequired[str]
4761
subtype: NotRequired[str]
62+
content: NotRequired[str]
4863
level: NotRequired[str]
49-
data: NotRequired[RecordDataUnion]
64+
65+
66+
class ResultMessageDict(BaseMessageDict):
67+
role: Literal["result"]
68+
text: NotRequired[str]
69+
content: NotRequired[str]
70+
71+
72+
class ProgressMessageDict(BaseMessageDict):
73+
role: Literal["progress"]
5074
progress_type: NotRequired[str]
75+
data: NotRequired[RecordDataUnion]
5176
tool_use_id: NotRequired[str | None]
5277
parent_tool_use_id: NotRequired[str | None]
5378

5479

80+
MessageDict = Union[
81+
UserMessageDict,
82+
AssistantMessageDict,
83+
SystemMessageDict,
84+
ResultMessageDict,
85+
ProgressMessageDict,
86+
]
87+
88+
5589
class SessionMetadataDict(TypedDict):
5690
"""Metadata accumulated while parsing a Claude Code JSONL session.
5791
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Negative fixture: role-inappropriate MessageDict access must fail mypy strict."""
2+
3+
from models.session import MessageDict, UserMessageDict
4+
5+
user_msg: UserMessageDict = {"role": "user", "text": "hello"}
6+
_ = user_msg["thinking"]
7+
8+
union_msg: MessageDict = {"role": "user", "text": "hello"}
9+
_ = union_msg["thinking"]
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Positive fixture: discriminant narrowing allows role-specific fields."""
2+
3+
from models.session import MessageDict, UserMessageDict
4+
5+
messages: list[MessageDict] = [{"role": "user", "text": "hello"}]
6+
for msg in messages:
7+
if msg["role"] == "user":
8+
narrowed: UserMessageDict = msg
9+
_ = narrowed.get("slug")

tests/test_message_dict_mypy.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Type-level regression: MessageDict union rejects role-inappropriate access."""
2+
3+
from __future__ import annotations
4+
5+
import subprocess
6+
import sys
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
REPO_ROOT = Path(__file__).resolve().parent.parent
12+
MYPY_TYPES_DIR = REPO_ROOT / "tests" / "mypy_types"
13+
14+
15+
def _run_mypy_on(path: Path) -> subprocess.CompletedProcess[str]:
16+
return subprocess.run(
17+
[
18+
sys.executable,
19+
"-m",
20+
"mypy",
21+
"--strict",
22+
str(path),
23+
"--config-file",
24+
str(REPO_ROOT / "pyproject.toml"),
25+
],
26+
cwd=REPO_ROOT,
27+
capture_output=True,
28+
text=True,
29+
check=False,
30+
)
31+
32+
33+
@pytest.mark.parametrize(
34+
("fixture_name", "should_pass"),
35+
[
36+
("message_dict_invalid.py", False),
37+
("message_dict_valid.py", True),
38+
],
39+
)
40+
def test_message_dict_mypy_fixtures(fixture_name: str, should_pass: bool) -> None:
41+
result = _run_mypy_on(MYPY_TYPES_DIR / fixture_name)
42+
output = result.stdout + result.stderr
43+
if should_pass:
44+
assert result.returncode == 0, output
45+
else:
46+
assert result.returncode != 0
47+
assert "typeddict-item" in output

utils/json_exporter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime, timezone
66
from typing import Any
77

8-
from models.session import SessionDict, SessionMetadataDict
8+
from models.session import MessageDict, SessionDict, SessionMetadataDict
99
from models.stats import SessionStatsDict
1010

1111

@@ -39,11 +39,11 @@ def _serialize_metadata(meta: SessionMetadataDict) -> dict[str, Any]:
3939
return result
4040

4141

42-
def _serialize_messages(messages: list[Any]) -> list[dict[str, Any]]:
42+
def _serialize_messages(messages: list[MessageDict]) -> list[dict[str, Any]]:
4343
"""Same set-to-list cleanup, but for each message dict."""
44-
out = []
44+
out: list[dict[str, Any]] = []
4545
for msg in messages:
46-
clean = {}
46+
clean: dict[str, Any] = {}
4747
for key, val in msg.items():
4848
if isinstance(val, set):
4949
clean[key] = sorted(val)

0 commit comments

Comments
 (0)