Skip to content

Commit b88f63b

Browse files
SessionMetadataDict: enforce required fields for mypy shape checking
Promote session_id, models_used, and first_timestamp to required TypedDict keys; keep extended accounting fields as NotRequired. Add _finalize_session_metadata() as the single typed construction site and document the required/optional contract.
1 parent 554b2cf commit b88f63b

5 files changed

Lines changed: 84 additions & 38 deletions

File tree

models/session.py

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -52,36 +52,49 @@ class MessageDict(TypedDict):
5252
parent_tool_use_id: NotRequired[str | None]
5353

5454

55-
class SessionMetadataDict(TypedDict, total=False):
55+
class SessionMetadataDict(TypedDict):
56+
"""Metadata accumulated while parsing a Claude Code JSONL session.
57+
58+
Required keys are always present after ``parse_session()``:
59+
60+
- ``session_id`` — derived from the ``.jsonl`` filename (stable identity).
61+
- ``models_used`` — model names from assistant messages (empty list when none).
62+
- ``first_timestamp`` — ISO timestamp of the earliest entry, or ``None`` when
63+
the file has no timestamps.
64+
65+
Remaining fields are optional in partial or stub data (tests, export filters)
66+
but are populated with defaults by the parser for full sessions.
67+
"""
68+
5669
session_id: str
5770
models_used: list[str]
58-
total_input_tokens: int
59-
total_output_tokens: int
60-
total_cache_read_tokens: int
61-
total_cache_creation_tokens: int
62-
total_tool_calls: int
63-
tool_call_counts: dict[str, int]
6471
first_timestamp: str | None
65-
last_timestamp: str | None
66-
version: str | None
67-
cwd: str | None
68-
git_branch: str | None
69-
permission_mode: str | None
70-
compactions: int
71-
total_ephemeral_5m_tokens: int
72-
total_ephemeral_1h_tokens: int
73-
service_tiers: list[str]
74-
session_wall_time_seconds: float | None
75-
compact_boundaries: list[dict[str, Any]]
76-
api_errors: int
77-
files_read: list[str]
78-
files_written: list[str]
79-
files_created: list[str]
80-
bash_commands: list[Any]
81-
web_fetches: list[Any]
82-
sidechain_messages: int
83-
stop_reasons: dict[str, int]
84-
entry_counts: dict[str, int]
72+
last_timestamp: NotRequired[str | None]
73+
total_input_tokens: NotRequired[int]
74+
total_output_tokens: NotRequired[int]
75+
total_cache_read_tokens: NotRequired[int]
76+
total_cache_creation_tokens: NotRequired[int]
77+
total_tool_calls: NotRequired[int]
78+
tool_call_counts: NotRequired[dict[str, int]]
79+
version: NotRequired[str | None]
80+
cwd: NotRequired[str | None]
81+
git_branch: NotRequired[str | None]
82+
permission_mode: NotRequired[str | None]
83+
compactions: NotRequired[int]
84+
total_ephemeral_5m_tokens: NotRequired[int]
85+
total_ephemeral_1h_tokens: NotRequired[int]
86+
service_tiers: NotRequired[list[str]]
87+
session_wall_time_seconds: NotRequired[float | None]
88+
compact_boundaries: NotRequired[list[dict[str, Any]]]
89+
api_errors: NotRequired[int]
90+
files_read: NotRequired[list[str]]
91+
files_written: NotRequired[list[str]]
92+
files_created: NotRequired[list[str]]
93+
bash_commands: NotRequired[list[Any]]
94+
web_fetches: NotRequired[list[Any]]
95+
sidechain_messages: NotRequired[int]
96+
stop_reasons: NotRequired[dict[str, int]]
97+
entry_counts: NotRequired[dict[str, int]]
8598

8699

87100
class SessionDict(TypedDict):

tests/test_exclusion_helpers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ def _session(
4545
) -> dict:
4646
return {
4747
"title": title,
48-
"metadata": {"models_used": models or []},
48+
"metadata": {
49+
"session_id": "stub",
50+
"models_used": models or [],
51+
"first_timestamp": None,
52+
},
4953
"messages": messages or [],
5054
}
5155

tests/test_jsonl_validation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def _valid_payload(**overrides: Any) -> dict[str, Any]:
2020
"session_id": "abc123",
2121
"title": "Test Session",
2222
"messages": [{"role": "user", "text": "hello"}],
23-
"metadata": {"session_id": "abc123"},
23+
"metadata": {"session_id": "abc123", "models_used": [], "first_timestamp": None},
2424
}
2525
base.update(overrides)
2626
return base

utils/export_engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ def finalize(self, manifest: list[dict[str, Any]]) -> None:
176176

177177
def _resolve_first_timestamp(meta: SessionMetadataDict, sess_info: SessionListItemDict) -> str:
178178
"""Return first_timestamp from metadata, or synthesise from mtime without mutating *meta*."""
179-
ts = (meta.get("first_timestamp") or "").strip()
179+
ts = (meta["first_timestamp"] or "").strip()
180180
if not ts:
181181
ts = datetime.fromtimestamp(sess_info["modified"], tz=timezone.utc).strftime(
182182
"%Y-%m-%dT%H:%M:%S"

utils/jsonl_parser.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import Any, cast, get_args
1010

1111
from models.record_data import RecordDataUnion
12-
from models.session import MessageDict, RoleLiteral, SessionDict, ToolUseDict
12+
from models.session import MessageDict, RoleLiteral, SessionDict, SessionMetadataDict, ToolUseDict
1313
from models.tool_results import ToolResultUnion, is_tool_result_dict
1414
from utils.jsonl_helpers import (
1515
entry_message as _entry_message,
@@ -70,6 +70,41 @@ def _safe_int(val: Any) -> int:
7070
return 0
7171

7272

73+
def _finalize_session_metadata(raw: dict[str, Any]) -> SessionMetadataDict:
74+
"""Convert the mutable parse-time metadata builder into a SessionMetadataDict."""
75+
return {
76+
"session_id": raw["session_id"],
77+
"models_used": sorted(raw["models_used"]),
78+
"first_timestamp": raw["first_timestamp"],
79+
"last_timestamp": raw["last_timestamp"],
80+
"total_input_tokens": raw["total_input_tokens"],
81+
"total_output_tokens": raw["total_output_tokens"],
82+
"total_cache_read_tokens": raw["total_cache_read_tokens"],
83+
"total_cache_creation_tokens": raw["total_cache_creation_tokens"],
84+
"total_tool_calls": raw["total_tool_calls"],
85+
"tool_call_counts": raw["tool_call_counts"],
86+
"version": raw["version"],
87+
"cwd": raw["cwd"],
88+
"git_branch": raw["git_branch"],
89+
"permission_mode": raw["permission_mode"],
90+
"compactions": raw["compactions"],
91+
"total_ephemeral_5m_tokens": raw["total_ephemeral_5m_tokens"],
92+
"total_ephemeral_1h_tokens": raw["total_ephemeral_1h_tokens"],
93+
"service_tiers": sorted(raw["service_tiers"]),
94+
"session_wall_time_seconds": raw["session_wall_time_seconds"],
95+
"compact_boundaries": raw["compact_boundaries"],
96+
"api_errors": raw["api_errors"],
97+
"files_read": sorted(raw["files_read"]),
98+
"files_written": sorted(raw["files_written"]),
99+
"files_created": sorted(raw["files_created"]),
100+
"bash_commands": raw["bash_commands"],
101+
"web_fetches": raw["web_fetches"],
102+
"sidechain_messages": raw["sidechain_messages"],
103+
"stop_reasons": raw["stop_reasons"],
104+
"entry_counts": raw["entry_counts"],
105+
}
106+
107+
73108
def parse_session(filepath: str) -> SessionDict:
74109
"""Main entry point. Reads every line from a .jsonl file and builds up
75110
a session dict with messages, metadata (tokens, models, tool counts),
@@ -165,12 +200,6 @@ def parse_session(filepath: str) -> SessionDict:
165200
if type_str not in _SKIP_ENTRY_TYPES:
166201
messages.append(_fallback_message(entry, _coerce_role(type_str)))
167202

168-
metadata["models_used"] = sorted(metadata["models_used"])
169-
metadata["service_tiers"] = sorted(metadata["service_tiers"])
170-
metadata["files_read"] = sorted(metadata["files_read"])
171-
metadata["files_written"] = sorted(metadata["files_written"])
172-
metadata["files_created"] = sorted(metadata["files_created"])
173-
174203
# Compute wall clock time
175204
first_ts = metadata["first_timestamp"]
176205
last_ts = metadata["last_timestamp"]
@@ -189,7 +218,7 @@ def parse_session(filepath: str) -> SessionDict:
189218
"session_id": session_id,
190219
"title": title,
191220
"messages": messages,
192-
"metadata": metadata,
221+
"metadata": _finalize_session_metadata(metadata),
193222
}
194223
)
195224

0 commit comments

Comments
 (0)