From 9c7f2fb84a67b8a780be02010153b06406aafbeb Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:30:08 -0700 Subject: [PATCH 1/2] refactor: convert SessionSummary from Pydantic BaseModel to frozen dataclass Convert SessionSummary to @dataclasses.dataclass(frozen=True, slots=True) to align with the coding guideline: 'Internal intermediate state uses frozen dataclasses with slots=True'. Changes: - models.py: Replace BaseModel with frozen dataclass, move model_validator logic to __post_init__, use dataclasses.field for mutable defaults - parser.py: Replace model_copy(update={...}) with dataclasses.replace() - test_models.py: Update invariant tests to expect ValueError instead of ValidationError, add FrozenInstanceError and dataclasses.replace tests - test_parser.py: Replace model_dump() comparison with direct equality - architecture.md: Update models.py description Closes #1067 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/copilot_usage/docs/architecture.md | 2 +- src/copilot_usage/models.py | 19 +++++++-------- src/copilot_usage/parser.py | 2 +- tests/copilot_usage/test_models.py | 32 ++++++++++++++++++++++++-- tests/copilot_usage/test_parser.py | 2 +- 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/copilot_usage/docs/architecture.md b/src/copilot_usage/docs/architecture.md index 6e01125d..a208da4a 100644 --- a/src/copilot_usage/docs/architecture.md +++ b/src/copilot_usage/docs/architecture.md @@ -36,7 +36,7 @@ Monorepo containing Python CLI utilities that share tooling, CI, and common depe | `cli.py` | Click command group — routes commands to parser/report functions, handles CLI options, error display. Contains the interactive loop (invoked when no subcommand is given) which uses helpers from `interactive.py`. | | `interactive.py` | Interactive-mode UI helpers — session list rendering, file-watching (watchdog with 2-second debounce), version header, and session index building. Extracted from `cli.py` to separate interactive concerns from CLI routing. | | `parser.py` | Discovers sessions, reads events.jsonl line by line, builds SessionSummary per session via focused helpers: `_first_pass()` (extract identity/shutdowns/counters/post-shutdown resume data in a single pass), `_build_completed_summary()`, `_build_active_summary()`. | -| `models.py` | Pydantic v2 models for all event types + SessionSummary aggregate (includes model_calls and user_messages fields). Runtime validation at parse boundary. | +| `models.py` | Pydantic v2 models for all event types; frozen dataclass for SessionSummary aggregate (includes model_calls and user_messages fields). Runtime validation at parse boundary. | | `report.py` | Rich-formatted terminal output — summary tables (with Model Calls and User Msgs columns), live view, premium request breakdown. Shows raw counts and `~`-prefixed premium cost estimates for live/active sessions; historical post-shutdown views display exact API-provided numbers. | | `render_detail.py` | Session detail rendering — extracted from report.py. Displays event timeline, per-event metadata, and session-level aggregates. | | `_formatting.py` | Shared formatting utilities — `format_duration()` and `format_tokens()` with doctest-verified examples. Used by report.py and render_detail.py. | diff --git a/src/copilot_usage/models.py b/src/copilot_usage/models.py index d8d20f9d..fc996d26 100644 --- a/src/copilot_usage/models.py +++ b/src/copilot_usage/models.py @@ -6,13 +6,14 @@ """ import builtins +import dataclasses import math from datetime import UTC, datetime from enum import StrEnum from pathlib import Path -from typing import Final, Self +from typing import Final -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator __all__: Final[list[str]] = [ "EPOCH", @@ -407,7 +408,8 @@ def as_tool_execution(self) -> ToolExecutionData: # --------------------------------------------------------------------------- -class SessionSummary(BaseModel): +@dataclasses.dataclass(frozen=True, slots=True) +class SessionSummary: """Aggregated data across all events in a single session. Populated by a parser that walks the ``events.jsonl`` file; not @@ -422,7 +424,7 @@ class SessionSummary(BaseModel): model: str | None = None total_premium_requests: int = 0 total_api_duration_ms: int = 0 - model_metrics: dict[str, ModelMetrics] = Field(default_factory=dict) + model_metrics: dict[str, ModelMetrics] = dataclasses.field(default_factory=dict) code_changes: CodeChanges | None = None model_calls: int = 0 user_messages: int = 0 @@ -433,8 +435,8 @@ class SessionSummary(BaseModel): # Per-cycle shutdown data: (timestamp, parsed shutdown payload). # Populated at build time so renderers never re-scan the event list. - shutdown_cycles: list[tuple[datetime | None, SessionShutdownData]] = Field( - default_factory=lambda: [] + shutdown_cycles: list[tuple[datetime | None, SessionShutdownData]] = ( + dataclasses.field(default_factory=lambda: []) ) # Post-shutdown activity (only populated for resumed/active sessions) @@ -442,8 +444,8 @@ class SessionSummary(BaseModel): active_user_messages: int = 0 active_output_tokens: int = 0 - @model_validator(mode="after") - def _check_active_counters(self) -> Self: + def __post_init__(self) -> None: + """Validate active counters do not exceed their totals.""" if self.active_model_calls > self.model_calls: raise ValueError( f"active_model_calls ({self.active_model_calls}) must be <= " @@ -454,7 +456,6 @@ def _check_active_counters(self) -> Self: f"active_user_messages ({self.active_user_messages}) must be <= " f"user_messages ({self.user_messages})" ) - return self # --------------------------------------------------------------------------- diff --git a/src/copilot_usage/parser.py b/src/copilot_usage/parser.py index f0ed8f87..427b6190 100644 --- a/src/copilot_usage/parser.py +++ b/src/copilot_usage/parser.py @@ -1172,7 +1172,7 @@ def get_all_sessions(base_path: Path | None = None) -> list[SessionSummary]: if cached is not None and cached.file_id == file_id and not config_is_stale: if plan_id != cached.plan_id: fresh_name = _extract_session_name(events_path.parent) - summary = cached.summary.model_copy(update={"name": fresh_name}) + summary = dataclasses.replace(cached.summary, name=fresh_name) deferred_sessions.append( ( events_path, diff --git a/tests/copilot_usage/test_models.py b/tests/copilot_usage/test_models.py index 26ae3546..10debcf4 100644 --- a/tests/copilot_usage/test_models.py +++ b/tests/copilot_usage/test_models.py @@ -1,5 +1,6 @@ """Tests for copilot_usage.models — Pydantic v2 event parsing.""" +import dataclasses import json from datetime import UTC, datetime from unittest.mock import patch @@ -979,7 +980,7 @@ class TestSessionSummaryCallCountInvariant: def test_rejects_active_calls_exceeding_total(self) -> None: """SessionSummary must reject active_model_calls > model_calls.""" - with pytest.raises(ValidationError): + with pytest.raises(ValueError, match="active_model_calls"): SessionSummary( session_id="inv", model_calls=3, @@ -1016,7 +1017,7 @@ class TestSessionSummaryUserMessageInvariant: def test_rejects_active_messages_exceeding_total(self) -> None: """SessionSummary must reject active_user_messages > user_messages.""" - with pytest.raises(ValidationError): + with pytest.raises(ValueError, match="active_user_messages"): SessionSummary( session_id="inv", user_messages=3, @@ -1048,6 +1049,33 @@ def test_accepts_zero_messages(self) -> None: assert s.active_user_messages == 0 +class TestSessionSummaryFrozen: + """Tests that SessionSummary is a frozen dataclass.""" + + def test_field_assignment_raises(self) -> None: + """Assigning a field on a constructed SessionSummary raises FrozenInstanceError.""" + s = SessionSummary(session_id="frozen") + with pytest.raises(dataclasses.FrozenInstanceError): + s.name = "mutated" # type: ignore[misc] + + def test_post_init_rejects_active_calls_exceeding_total(self) -> None: + """__post_init__ raises ValueError when active_model_calls > model_calls.""" + with pytest.raises(ValueError, match="active_model_calls"): + SessionSummary( + session_id="inv", + model_calls=2, + active_model_calls=3, + ) + + def test_dataclass_replace_produces_new_instance(self) -> None: + """dataclasses.replace creates a new instance with updated fields.""" + original = SessionSummary(session_id="orig", name="old") + updated = dataclasses.replace(original, name="new") + assert updated.name == "new" + assert original.name == "old" + assert updated is not original + + # --------------------------------------------------------------------------- # shutdown_output_tokens # --------------------------------------------------------------------------- diff --git a/tests/copilot_usage/test_parser.py b/tests/copilot_usage/test_parser.py index 76568f72..b35d5f01 100644 --- a/tests/copilot_usage/test_parser.py +++ b/tests/copilot_usage/test_parser.py @@ -6879,7 +6879,7 @@ def test_cache_returns_correct_summaries(self, tmp_path: Path) -> None: # Ensure that all fields of the summaries are identical between # the initial parse and the cached results. - assert [s.model_dump() for s in first] == [s.model_dump() for s in second] + assert first == second def test_cache_refreshes_session_name_on_plan_rename(self, tmp_path: Path) -> None: """Cached summaries pick up plan.md edits without re-parsing events.""" From 44725ade24abfe3f1a47e6880d2a9121a43ed3c0 Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:43:11 -0700 Subject: [PATCH 2/2] fix: use lambda factory for model_metrics dataclass field (pyright) Change default_factory=dict to default_factory=lambda: {} for the model_metrics field on SessionSummary. Per coding guidelines, dataclass fields must use lambda factories for mutable defaults to avoid a known pyright strict-mode false-positive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/copilot_usage/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/copilot_usage/models.py b/src/copilot_usage/models.py index fc996d26..51fd4c98 100644 --- a/src/copilot_usage/models.py +++ b/src/copilot_usage/models.py @@ -424,7 +424,9 @@ class SessionSummary: model: str | None = None total_premium_requests: int = 0 total_api_duration_ms: int = 0 - model_metrics: dict[str, ModelMetrics] = dataclasses.field(default_factory=dict) + model_metrics: dict[str, ModelMetrics] = dataclasses.field( + default_factory=lambda: {} + ) code_changes: CodeChanges | None = None model_calls: int = 0 user_messages: int = 0