Skip to content

Commit 7efe474

Browse files
claude-code-chat-browser: SessionMetadataDict — tighten all-NotRequired fields for mypy shape enforcement (#106)
* 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. * Address PR review: metadata parity guard, runtime validation, shared field names - Add SESSION_METADATA_FIELD_NAMES constant and _new_session_metadata_builder() - Drive _finalize_session_metadata() from the field-name set (loop, not duplicate list) - Validate required metadata keys in validate_session_dict at runtime - Add parity tests and metadata validation tests - Ignore non-str timestamps when accumulating first_timestamp/last_timestamp * Tighten metadata validation and typed builder for parser review feedback - Require each models_used entry to be a string at validate_session_dict boundary - Add _entry_timestamp() so invalid top-level timestamps fall back to snapshot - Introduce SessionMetadataBuilderDict for parse-time accumulator typing - Return explicit SessionMetadataDict from finalize (no cast) - Align tool_dispatch file-activity handlers with builder type * Address Timon review: derive metadata constants, require full parser shape Derive SESSION_METADATA_FIELD_NAMES and SESSION_METADATA_REQUIRED_KEYS from SessionMetadataDict introspection. Promote all finalize-produced fields to required so mypy enforces completeness. Drive runtime validation from the derived required-key set; update validation tests to use full metadata.
1 parent 554b2cf commit 7efe474

8 files changed

Lines changed: 260 additions & 49 deletions

File tree

models/session.py

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,29 @@ 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+
``parse_session()`` always produces every field below via
59+
``_finalize_session_metadata()``; defaults are zeros, empty collections,
60+
or ``None`` where noted. Mypy treats the full shape as required so parser
61+
and finalize code cannot drop a field silently.
62+
63+
The three identity/timing keys are also enforced at the runtime validation
64+
boundary (``validate_session_dict``) with stricter type checks; remaining
65+
keys must be present but are only type-checked lightly there.
66+
"""
67+
5668
session_id: str
5769
models_used: list[str]
70+
first_timestamp: str | None
71+
last_timestamp: str | None
5872
total_input_tokens: int
5973
total_output_tokens: int
6074
total_cache_read_tokens: int
6175
total_cache_creation_tokens: int
6276
total_tool_calls: int
6377
tool_call_counts: dict[str, int]
64-
first_timestamp: str | None
65-
last_timestamp: str | None
6678
version: str | None
6779
cwd: str | None
6880
git_branch: str | None
@@ -84,6 +96,45 @@ class SessionMetadataDict(TypedDict, total=False):
8496
entry_counts: dict[str, int]
8597

8698

99+
# Derived from SessionMetadataDict — single source of truth for parity tests.
100+
SESSION_METADATA_FIELD_NAMES = frozenset(SessionMetadataDict.__annotations__)
101+
SESSION_METADATA_REQUIRED_KEYS = SessionMetadataDict.__required_keys__
102+
103+
104+
class SessionMetadataBuilderDict(TypedDict):
105+
"""Mutable metadata accumulator during JSONL parsing; sets are sorted at finalize."""
106+
107+
session_id: str
108+
models_used: set[str]
109+
first_timestamp: str | None
110+
last_timestamp: str | None
111+
total_input_tokens: int
112+
total_output_tokens: int
113+
total_cache_read_tokens: int
114+
total_cache_creation_tokens: int
115+
total_tool_calls: int
116+
tool_call_counts: dict[str, int]
117+
version: str | None
118+
cwd: str | None
119+
git_branch: str | None
120+
permission_mode: str | None
121+
compactions: int
122+
total_ephemeral_5m_tokens: int
123+
total_ephemeral_1h_tokens: int
124+
service_tiers: set[str]
125+
session_wall_time_seconds: float | None
126+
compact_boundaries: list[dict[str, Any]]
127+
api_errors: int
128+
files_read: set[str]
129+
files_written: set[str]
130+
files_created: set[str]
131+
bash_commands: list[Any]
132+
web_fetches: list[Any]
133+
sidechain_messages: int
134+
stop_reasons: dict[str, int]
135+
entry_counts: dict[str, int]
136+
137+
87138
class SessionDict(TypedDict):
88139
session_id: str
89140
title: str

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_parser.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@
77

88
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
99

10+
from models.session import SESSION_METADATA_FIELD_NAMES
1011
from utils.jsonl_helpers import (
1112
extract_images,
1213
extract_text,
1314
infer_title,
1415
normalize_content,
1516
strip_system_tags,
1617
)
17-
from utils.jsonl_parser import parse_session, quick_session_info
18+
from utils.jsonl_parser import (
19+
_finalize_session_metadata,
20+
_new_session_metadata_builder,
21+
parse_session,
22+
quick_session_info,
23+
)
1824
from utils.tool_dispatch import _parse_tool_result
1925

2026
# ---------------------------------------------------------------------------
@@ -38,6 +44,18 @@ def _parse_entries(entries: list) -> dict:
3844
os.unlink(path)
3945

4046

47+
class TestSessionMetadataFinalize:
48+
def test_builder_keys_match_field_names_constant(self):
49+
raw = _new_session_metadata_builder("parity-test")
50+
assert set(raw.keys()) == SESSION_METADATA_FIELD_NAMES
51+
52+
def test_finalize_preserves_all_builder_keys(self):
53+
raw = _new_session_metadata_builder("parity-test")
54+
finalized = _finalize_session_metadata(raw)
55+
assert set(finalized.keys()) == SESSION_METADATA_FIELD_NAMES
56+
assert set(finalized.keys()) == set(raw.keys())
57+
58+
4159
# ---------------------------------------------------------------------------
4260
# _parse_tool_result
4361
# ---------------------------------------------------------------------------
@@ -720,6 +738,23 @@ def test_file_history_snapshot_timestamp(self):
720738
finally:
721739
os.unlink(path)
722740

741+
def test_file_history_snapshot_timestamp_falls_back_when_top_level_invalid(self):
742+
path = _write_jsonl(
743+
[
744+
{
745+
"type": "file-history-snapshot",
746+
"timestamp": 1,
747+
"snapshot": {"timestamp": "2026-01-02T12:00:00Z"},
748+
},
749+
]
750+
)
751+
try:
752+
s = parse_session(path)
753+
assert s["metadata"]["first_timestamp"] == "2026-01-02T12:00:00Z"
754+
assert s["metadata"]["last_timestamp"] == "2026-01-02T12:00:00Z"
755+
finally:
756+
os.unlink(path)
757+
723758
def test_summary_entry_type_produces_no_message(self, caplog):
724759
path = _write_jsonl(
725760
[

tests/test_jsonl_validation.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,28 @@
99
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
1010

1111
from models.errors import SessionValidationError
12-
from utils.jsonl_parser import parse_session
12+
from utils.jsonl_parser import (
13+
_finalize_session_metadata,
14+
_new_session_metadata_builder,
15+
parse_session,
16+
)
1317
from utils.validation import validate_session_dict
1418

1519
FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures")
1620

1721

22+
def _full_metadata(**overrides: Any) -> dict[str, Any]:
23+
raw = _new_session_metadata_builder("abc123")
24+
raw.update(overrides)
25+
return dict(_finalize_session_metadata(raw))
26+
27+
1828
def _valid_payload(**overrides: Any) -> dict[str, Any]:
1929
base: dict[str, Any] = {
2030
"session_id": "abc123",
2131
"title": "Test Session",
2232
"messages": [{"role": "user", "text": "hello"}],
23-
"metadata": {"session_id": "abc123"},
33+
"metadata": _full_metadata(),
2434
}
2535
base.update(overrides)
2636
return base
@@ -60,6 +70,40 @@ def test_metadata_not_dict(self):
6070
validate_session_dict(_valid_payload(metadata="not-a-dict"))
6171
assert exc_info.value.path == "metadata"
6272

73+
def test_metadata_missing_session_id(self):
74+
metadata = _full_metadata()
75+
del metadata["session_id"]
76+
with pytest.raises(SessionValidationError) as exc_info:
77+
validate_session_dict(_valid_payload(metadata=metadata))
78+
assert exc_info.value.path == "metadata.session_id"
79+
80+
def test_metadata_missing_models_used(self):
81+
metadata = _full_metadata()
82+
del metadata["models_used"]
83+
with pytest.raises(SessionValidationError) as exc_info:
84+
validate_session_dict(_valid_payload(metadata=metadata))
85+
assert exc_info.value.path == "metadata.models_used"
86+
87+
def test_metadata_missing_first_timestamp(self):
88+
metadata = _full_metadata()
89+
del metadata["first_timestamp"]
90+
with pytest.raises(SessionValidationError) as exc_info:
91+
validate_session_dict(_valid_payload(metadata=metadata))
92+
assert exc_info.value.path == "metadata.first_timestamp"
93+
94+
def test_metadata_first_timestamp_null_allowed(self):
95+
result = validate_session_dict(
96+
_valid_payload(metadata=_full_metadata(first_timestamp=None))
97+
)
98+
assert result["metadata"]["first_timestamp"] is None
99+
100+
def test_metadata_models_used_requires_string_elements(self):
101+
metadata = _full_metadata()
102+
metadata["models_used"] = ["claude-sonnet", 42]
103+
with pytest.raises(SessionValidationError) as exc_info:
104+
validate_session_dict(_valid_payload(metadata=metadata))
105+
assert exc_info.value.path == "metadata.models_used[1]"
106+
63107
def test_message_not_dict(self):
64108
with pytest.raises(SessionValidationError) as exc_info:
65109
validate_session_dict(_valid_payload(messages=["not-a-dict"]))

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"

0 commit comments

Comments
 (0)