Skip to content

Commit b04ec7c

Browse files
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
1 parent b88f63b commit b04ec7c

5 files changed

Lines changed: 142 additions & 52 deletions

File tree

models/session.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,45 @@ class SessionMetadataDict(TypedDict):
9797
entry_counts: NotRequired[dict[str, int]]
9898

9999

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+
)
137+
138+
100139
class SessionDict(TypedDict):
101140
session_id: str
102141
title: str

tests/test_jsonl_parser.py

Lines changed: 19 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
# ---------------------------------------------------------------------------

tests/test_jsonl_validation.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,35 @@ def test_metadata_not_dict(self):
6060
validate_session_dict(_valid_payload(metadata="not-a-dict"))
6161
assert exc_info.value.path == "metadata"
6262

63+
def test_metadata_missing_session_id(self):
64+
with pytest.raises(SessionValidationError) as exc_info:
65+
validate_session_dict(
66+
_valid_payload(metadata={"models_used": [], "first_timestamp": None})
67+
)
68+
assert exc_info.value.path == "metadata.session_id"
69+
70+
def test_metadata_missing_models_used(self):
71+
with pytest.raises(SessionValidationError) as exc_info:
72+
validate_session_dict(
73+
_valid_payload(metadata={"session_id": "abc123", "first_timestamp": None})
74+
)
75+
assert exc_info.value.path == "metadata.models_used"
76+
77+
def test_metadata_missing_first_timestamp(self):
78+
with pytest.raises(SessionValidationError) as exc_info:
79+
validate_session_dict(
80+
_valid_payload(metadata={"session_id": "abc123", "models_used": []})
81+
)
82+
assert exc_info.value.path == "metadata.first_timestamp"
83+
84+
def test_metadata_first_timestamp_null_allowed(self):
85+
result = validate_session_dict(
86+
_valid_payload(
87+
metadata={"session_id": "abc123", "models_used": [], "first_timestamp": None}
88+
)
89+
)
90+
assert result["metadata"]["first_timestamp"] is None
91+
6392
def test_message_not_dict(self):
6493
with pytest.raises(SessionValidationError) as exc_info:
6594
validate_session_dict(_valid_payload(messages=["not-a-dict"]))

utils/jsonl_parser.py

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

1111
from models.record_data import RecordDataUnion
12-
from models.session import MessageDict, RoleLiteral, SessionDict, SessionMetadataDict, ToolUseDict
12+
from models.session import (
13+
SESSION_METADATA_FIELD_NAMES,
14+
MessageDict,
15+
RoleLiteral,
16+
SessionDict,
17+
SessionMetadataDict,
18+
ToolUseDict,
19+
)
1320
from models.tool_results import ToolResultUnion, is_tool_result_dict
1421
from utils.jsonl_helpers import (
1522
entry_message as _entry_message,
@@ -70,48 +77,15 @@ def _safe_int(val: Any) -> int:
7077
return 0
7178

7279

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-
}
80+
# Set-backed fields are sorted when finalized; all other builder keys pass through as-is.
81+
_METADATA_SET_FIELDS = frozenset(
82+
{"models_used", "service_tiers", "files_read", "files_written", "files_created"}
83+
)
10684

10785

108-
def parse_session(filepath: str) -> SessionDict:
109-
"""Main entry point. Reads every line from a .jsonl file and builds up
110-
a session dict with messages, metadata (tokens, models, tool counts),
111-
and file/command activity."""
112-
session_id = os.path.basename(filepath).replace(".jsonl", "")
113-
messages: list[MessageDict] = []
114-
metadata: dict[str, Any] = {
86+
def _new_session_metadata_builder(session_id: str) -> dict[str, Any]:
87+
"""Fresh mutable metadata accumulator for ``parse_session()``."""
88+
return {
11589
"session_id": session_id,
11690
"models_used": set(),
11791
"total_input_tokens": 0,
@@ -127,30 +101,42 @@ def parse_session(filepath: str) -> SessionDict:
127101
"git_branch": None,
128102
"permission_mode": None,
129103
"compactions": 0,
130-
# Extended token accounting
131104
"total_ephemeral_5m_tokens": 0,
132105
"total_ephemeral_1h_tokens": 0,
133106
"service_tiers": set(),
134-
# Timing
135107
"session_wall_time_seconds": None,
136-
# Compaction details
137108
"compact_boundaries": [],
138-
# Error tracking
139109
"api_errors": 0,
140-
# File activity (from tool_use inputs)
141110
"files_read": set(),
142111
"files_written": set(),
143112
"files_created": set(),
144113
"bash_commands": [],
145114
"web_fetches": [],
146-
# Sidechain tracking
147115
"sidechain_messages": 0,
148-
# Stop reasons
149116
"stop_reasons": {},
150-
# Entry type counts
151117
"entry_counts": {},
152118
}
153119

120+
121+
def _finalize_session_metadata(raw: dict[str, Any]) -> SessionMetadataDict:
122+
"""Convert the mutable parse-time metadata builder into a SessionMetadataDict."""
123+
finalized: dict[str, Any] = {}
124+
for key in SESSION_METADATA_FIELD_NAMES:
125+
val = raw[key]
126+
if key in _METADATA_SET_FIELDS:
127+
val = sorted(val)
128+
finalized[key] = val
129+
return cast(SessionMetadataDict, finalized)
130+
131+
132+
def parse_session(filepath: str) -> SessionDict:
133+
"""Main entry point. Reads every line from a .jsonl file and builds up
134+
a session dict with messages, metadata (tokens, models, tool counts),
135+
and file/command activity."""
136+
session_id = os.path.basename(filepath).replace(".jsonl", "")
137+
messages: list[MessageDict] = []
138+
metadata = _new_session_metadata_builder(session_id)
139+
154140
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
155141
for line in f:
156142
line = line.strip()
@@ -172,7 +158,7 @@ def parse_session(filepath: str) -> SessionDict:
172158
if isinstance(snap, dict):
173159
ts = snap.get("timestamp")
174160

175-
if ts:
161+
if isinstance(ts, str) and ts:
176162
if metadata["first_timestamp"] is None:
177163
metadata["first_timestamp"] = ts
178164
metadata["last_timestamp"] = ts

utils/validation.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ def _require_value(
3737
return val
3838

3939

40+
def _require_optional_str(path: str, val: Any) -> str | None:
41+
if val is None:
42+
return None
43+
if not isinstance(val, str):
44+
raise SessionValidationError(path, f"expected str or null, got {type(val).__name__}")
45+
return val
46+
47+
48+
def _validate_session_metadata(metadata: dict[str, Any]) -> None:
49+
"""Enforce SessionMetadataDict required keys at the runtime boundary."""
50+
_require_field(metadata, "session_id", str, "str", path="metadata.session_id")
51+
_require_field(metadata, "models_used", list, "list", path="metadata.models_used")
52+
if "first_timestamp" not in metadata:
53+
raise SessionValidationError("metadata.first_timestamp", "missing required field")
54+
_require_optional_str("metadata.first_timestamp", metadata["first_timestamp"])
55+
56+
4057
def validate_session_dict(data: dict[str, Any]) -> SessionDict:
4158
"""Validate a plain dict matches SessionDict before returning it."""
4259
# Runtime guard for dynamic callers; mypy already types the parameter as dict.
@@ -46,7 +63,8 @@ def validate_session_dict(data: dict[str, Any]) -> SessionDict:
4663
_require_field(data, "session_id", str, "str")
4764
_require_field(data, "title", str, "str")
4865
messages = _require_field(data, "messages", list, "list")
49-
_require_field(data, "metadata", dict, "dict")
66+
metadata = _require_field(data, "metadata", dict, "dict")
67+
_validate_session_metadata(metadata)
5068

5169
for index, message in enumerate(messages):
5270
path = f"messages[{index}]"

0 commit comments

Comments
 (0)