Skip to content

Commit ddb4b93

Browse files
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 6391975 commit ddb4b93

3 files changed

Lines changed: 71 additions & 104 deletions

File tree

models/session.py

Lines changed: 39 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -55,85 +55,50 @@ class MessageDict(TypedDict):
5555
class SessionMetadataDict(TypedDict):
5656
"""Metadata accumulated while parsing a Claude Code JSONL session.
5757
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.
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.
6766
"""
6867

6968
session_id: str
7069
models_used: list[str]
7170
first_timestamp: str | None
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]]
98-
99-
100-
# Canonical metadata field set for parse_session builder / finalize parity.
101-
# Keep in sync with SessionMetadataDict above.
102-
SESSION_METADATA_REQUIRED_KEYS = frozenset({"session_id", "models_used", "first_timestamp"})
103-
104-
SESSION_METADATA_FIELD_NAMES = frozenset(
105-
{
106-
"session_id",
107-
"models_used",
108-
"first_timestamp",
109-
"last_timestamp",
110-
"total_input_tokens",
111-
"total_output_tokens",
112-
"total_cache_read_tokens",
113-
"total_cache_creation_tokens",
114-
"total_tool_calls",
115-
"tool_call_counts",
116-
"version",
117-
"cwd",
118-
"git_branch",
119-
"permission_mode",
120-
"compactions",
121-
"total_ephemeral_5m_tokens",
122-
"total_ephemeral_1h_tokens",
123-
"service_tiers",
124-
"session_wall_time_seconds",
125-
"compact_boundaries",
126-
"api_errors",
127-
"files_read",
128-
"files_written",
129-
"files_created",
130-
"bash_commands",
131-
"web_fetches",
132-
"sidechain_messages",
133-
"stop_reasons",
134-
"entry_counts",
135-
}
136-
)
71+
last_timestamp: str | None
72+
total_input_tokens: int
73+
total_output_tokens: int
74+
total_cache_read_tokens: int
75+
total_cache_creation_tokens: int
76+
total_tool_calls: int
77+
tool_call_counts: dict[str, int]
78+
version: str | None
79+
cwd: str | None
80+
git_branch: str | None
81+
permission_mode: str | None
82+
compactions: int
83+
total_ephemeral_5m_tokens: int
84+
total_ephemeral_1h_tokens: int
85+
service_tiers: list[str]
86+
session_wall_time_seconds: float | None
87+
compact_boundaries: list[dict[str, Any]]
88+
api_errors: int
89+
files_read: list[str]
90+
files_written: list[str]
91+
files_created: list[str]
92+
bash_commands: list[Any]
93+
web_fetches: list[Any]
94+
sidechain_messages: int
95+
stop_reasons: dict[str, int]
96+
entry_counts: dict[str, int]
97+
98+
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__
137102

138103

139104
class SessionMetadataBuilderDict(TypedDict):

tests/test_jsonl_validation.py

Lines changed: 25 additions & 23 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", "models_used": [], "first_timestamp": None},
33+
"metadata": _full_metadata(),
2434
}
2535
base.update(overrides)
2636
return base
@@ -61,45 +71,37 @@ def test_metadata_not_dict(self):
6171
assert exc_info.value.path == "metadata"
6272

6373
def test_metadata_missing_session_id(self):
74+
metadata = _full_metadata()
75+
del metadata["session_id"]
6476
with pytest.raises(SessionValidationError) as exc_info:
65-
validate_session_dict(
66-
_valid_payload(metadata={"models_used": [], "first_timestamp": None})
67-
)
77+
validate_session_dict(_valid_payload(metadata=metadata))
6878
assert exc_info.value.path == "metadata.session_id"
6979

7080
def test_metadata_missing_models_used(self):
81+
metadata = _full_metadata()
82+
del metadata["models_used"]
7183
with pytest.raises(SessionValidationError) as exc_info:
72-
validate_session_dict(
73-
_valid_payload(metadata={"session_id": "abc123", "first_timestamp": None})
74-
)
84+
validate_session_dict(_valid_payload(metadata=metadata))
7585
assert exc_info.value.path == "metadata.models_used"
7686

7787
def test_metadata_missing_first_timestamp(self):
88+
metadata = _full_metadata()
89+
del metadata["first_timestamp"]
7890
with pytest.raises(SessionValidationError) as exc_info:
79-
validate_session_dict(
80-
_valid_payload(metadata={"session_id": "abc123", "models_used": []})
81-
)
91+
validate_session_dict(_valid_payload(metadata=metadata))
8292
assert exc_info.value.path == "metadata.first_timestamp"
8393

8494
def test_metadata_first_timestamp_null_allowed(self):
8595
result = validate_session_dict(
86-
_valid_payload(
87-
metadata={"session_id": "abc123", "models_used": [], "first_timestamp": None}
88-
)
96+
_valid_payload(metadata=_full_metadata(first_timestamp=None))
8997
)
9098
assert result["metadata"]["first_timestamp"] is None
9199

92100
def test_metadata_models_used_requires_string_elements(self):
101+
metadata = _full_metadata()
102+
metadata["models_used"] = ["claude-sonnet", 42]
93103
with pytest.raises(SessionValidationError) as exc_info:
94-
validate_session_dict(
95-
_valid_payload(
96-
metadata={
97-
"session_id": "abc123",
98-
"models_used": ["claude-sonnet", 42],
99-
"first_timestamp": None,
100-
}
101-
)
102-
)
104+
validate_session_dict(_valid_payload(metadata=metadata))
103105
assert exc_info.value.path == "metadata.models_used[1]"
104106

105107
def test_message_not_dict(self):

utils/validation.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Any, cast, get_args
44

55
from models.errors import SessionValidationError
6-
from models.session import RoleLiteral, SessionDict
6+
from models.session import SESSION_METADATA_REQUIRED_KEYS, RoleLiteral, SessionDict
77

88
_VALID_ROLES = frozenset(get_args(RoleLiteral))
99

@@ -58,13 +58,13 @@ def _require_str_list(path: str, val: Any) -> list[str]:
5858

5959

6060
def _validate_session_metadata(metadata: dict[str, Any]) -> None:
61-
"""Enforce SessionMetadataDict required keys at the runtime boundary."""
62-
_require_field(metadata, "session_id", str, "str", path="metadata.session_id")
63-
if "models_used" not in metadata:
64-
raise SessionValidationError("metadata.models_used", "missing required field")
61+
"""Enforce SessionMetadataDict keys at the runtime boundary."""
62+
for key in SESSION_METADATA_REQUIRED_KEYS:
63+
if key not in metadata:
64+
raise SessionValidationError(f"metadata.{key}", "missing required field")
65+
66+
_require_value("metadata.session_id", metadata["session_id"], str, "str")
6567
_require_str_list("metadata.models_used", metadata["models_used"])
66-
if "first_timestamp" not in metadata:
67-
raise SessionValidationError("metadata.first_timestamp", "missing required field")
6868
_require_optional_str("metadata.first_timestamp", metadata["first_timestamp"])
6969

7070

0 commit comments

Comments
 (0)