Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/copilot_usage/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
21 changes: 12 additions & 9 deletions src/copilot_usage/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -422,7 +424,9 @@ 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=lambda: {}
)
code_changes: CodeChanges | None = None
model_calls: int = 0
user_messages: int = 0
Expand All @@ -433,17 +437,17 @@ 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: [])
Comment on lines +440 to +441
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shutdown_cycles uses dataclasses.field(default_factory=lambda: []). Prefer default_factory=list (or default_factory=list[tuple[...]] not possible) to avoid an unnecessary lambda and keep the field factory picklable/readable; also the extra parentheses around the field(...) call can be dropped for clarity.

Suggested change
shutdown_cycles: list[tuple[datetime | None, SessionShutdownData]] = (
dataclasses.field(default_factory=lambda: [])
shutdown_cycles: list[tuple[datetime | None, SessionShutdownData]] = dataclasses.field(
default_factory=list

Copilot uses AI. Check for mistakes.
)

# Post-shutdown activity (only populated for resumed/active sessions)
active_model_calls: int = 0
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 <= "
Expand All @@ -454,7 +458,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


# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion src/copilot_usage/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 30 additions & 2 deletions tests/copilot_usage/test_models.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion tests/copilot_usage/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading