From 75336475eb72cbdd7003728b4a864f869b395349 Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic Date: Fri, 13 Mar 2026 15:33:49 -0700 Subject: [PATCH] feat: copilot-usage CLI tool Parse local Copilot CLI session data for token usage, premium requests, model breakdowns, and cost estimates. - Rich interactive mode with watchdog auto-refresh (FSEvents on macOS) - Commands: summary, session, cost, live + interactive (no subcommand) - Shutdown aggregation (SUM all events, not last-only) - Active vs historical session detection with last_resume_time - Premium requests: exact from shutdown data, N/A for active - 327 tests (272 unit @ 95% coverage + 55 e2e), 8 fixture scenarios - Docs: README, architecture, changelog, implementation, plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .editorconfig | 17 + .github/dependabot.yml | 11 + .github/workflows/ci.yml | 17 + .gitignore | 9 + .python-version | 1 + Makefile | 78 + README.md | 240 ++ docs/architecture.md | 88 + docs/changelog.md | 189 ++ docs/implementation.md | 439 ++++ docs/plan.md | 137 ++ pyproject.toml | 88 + src/copilot_usage/__init__.py | 0 src/copilot_usage/cli.py | 434 ++++ src/copilot_usage/logging_config.py | 39 + src/copilot_usage/models.py | 255 +++ src/copilot_usage/parser.py | 395 ++++ src/copilot_usage/pricing.py | 151 ++ src/copilot_usage/py.typed | 0 src/copilot_usage/report.py | 982 +++++++++ tests/copilot_usage/test_cli.py | 481 +++++ tests/copilot_usage/test_models.py | 243 +++ tests/copilot_usage/test_parser.py | 1924 +++++++++++++++++ tests/copilot_usage/test_pricing.py | 123 ++ tests/copilot_usage/test_report.py | 1241 +++++++++++ tests/e2e/__init__.py | 0 .../events.jsonl | 15 + .../events.jsonl | 22 + .../events.jsonl | 18 + .../e2e/fixtures/corrupt-session/events.jsonl | 8 + tests/e2e/fixtures/empty-session/events.jsonl | 1 + .../multi-shutdown-completed/events.jsonl | 12 + .../multi-shutdown-resumed/events.jsonl | 23 + .../e2e/fixtures/resumed-session/events.jsonl | 19 + tests/e2e/test_e2e.py | 565 +++++ uv.lock | 526 +++++ 36 files changed, 8791 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Makefile create mode 100644 README.md create mode 100644 docs/architecture.md create mode 100644 docs/changelog.md create mode 100644 docs/implementation.md create mode 100644 docs/plan.md create mode 100644 pyproject.toml create mode 100644 src/copilot_usage/__init__.py create mode 100644 src/copilot_usage/cli.py create mode 100644 src/copilot_usage/logging_config.py create mode 100644 src/copilot_usage/models.py create mode 100644 src/copilot_usage/parser.py create mode 100644 src/copilot_usage/pricing.py create mode 100644 src/copilot_usage/py.typed create mode 100644 src/copilot_usage/report.py create mode 100644 tests/copilot_usage/test_cli.py create mode 100644 tests/copilot_usage/test_models.py create mode 100644 tests/copilot_usage/test_parser.py create mode 100644 tests/copilot_usage/test_pricing.py create mode 100644 tests/copilot_usage/test_report.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/fixtures/0faecbdf-b889-4bca-a51a-5254f5488cb6/events.jsonl create mode 100644 tests/e2e/fixtures/4a547040-0318-44a4-a514-7ac9cbecc17e/events.jsonl create mode 100644 tests/e2e/fixtures/b5df8a34-87f0-46d5-b865-83e89095b8dc/events.jsonl create mode 100644 tests/e2e/fixtures/corrupt-session/events.jsonl create mode 100644 tests/e2e/fixtures/empty-session/events.jsonl create mode 100644 tests/e2e/fixtures/multi-shutdown-completed/events.jsonl create mode 100644 tests/e2e/fixtures/multi-shutdown-resumed/events.jsonl create mode 100644 tests/e2e/fixtures/resumed-session/events.jsonl create mode 100644 tests/e2e/test_e2e.py create mode 100644 uv.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b852e78 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 4 + +[*.{ts,tsx,js,jsx,json,yaml,yml,css}] +indent_size = 2 + +[*.swift] +indent_size = 4 + +[Makefile] +indent_style = tab diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7fee8a1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c684707 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI +on: + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - run: uv sync + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pyright + - run: uv run bandit -r src/ + - run: uv run pytest --cov --cov-fail-under=80 -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2dacbfd --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +.env +dist/ +*.egg-info/ +.coverage +htmlcov/ +.pyright/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f03c1f --- /dev/null +++ b/Makefile @@ -0,0 +1,78 @@ +.PHONY: check fix lint typecheck security test test-unit test-e2e + +# Colors and symbols +GREEN := \033[0;32m +RED := \033[0;31m +CYAN := \033[0;36m +BOLD := \033[1m +RESET := \033[0m + +# Verbose flag: make check V=1 for full output +V ?= 0 +ifeq ($(V),1) + QUIET := + TEST_FLAGS := --cov --cov-fail-under=80 -v +else + QUIET := > /dev/null 2>&1 + TEST_FLAGS := --cov --cov-fail-under=80 -q --no-header +endif + +# All checks (mirrors CI โ€” run before pushing a PR) +check: + @printf "\n$(BOLD)$(CYAN)๐Ÿ” Running all checks...$(RESET)\n" + @$(MAKE) --no-print-directory lint + @$(MAKE) --no-print-directory typecheck + @$(MAKE) --no-print-directory security + @$(MAKE) --no-print-directory test + @printf "\n$(BOLD)$(CYAN)๐ŸŽ‰ All checks passed!$(RESET)\n\n" + +# Lint (includes format check) +lint: + @printf "\n$(BOLD)$(CYAN)๐Ÿงน Linting...$(RESET)\n" + @uv run ruff check . $(QUIET) && printf " $(GREEN)โœ… ruff lint$(RESET)\n" || { printf " $(RED)โŒ ruff lint$(RESET)\n"; exit 1; } + @uv run ruff format --check . $(QUIET) && printf " $(GREEN)โœ… ruff format$(RESET)\n" || { printf " $(RED)โŒ ruff format$(RESET)\n"; exit 1; } + +# Type checking +typecheck: + @printf "\n$(BOLD)$(CYAN)๐Ÿ”ฌ Type checking...$(RESET)\n" + @uv run pyright $(QUIET) && printf " $(GREEN)โœ… pyright$(RESET)\n" || { printf " $(RED)โŒ pyright$(RESET)\n"; exit 1; } + +# Security scan +security: + @printf "\n$(BOLD)$(CYAN)๐Ÿ›ก๏ธ Security scan...$(RESET)\n" + @uv run bandit -r src/ -q $(QUIET) && printf " $(GREEN)โœ… bandit$(RESET)\n" || { printf " $(RED)โŒ bandit$(RESET)\n"; exit 1; } + +# Tests with coverage +test: + @printf "\n$(BOLD)$(CYAN)๐Ÿงช Testing...$(RESET)\n" + @COV=$$(uv run pytest tests/copilot_usage $(TEST_FLAGS) 2>&1); \ + if [ $$? -eq 0 ]; then \ + COV_PCT=$$(echo "$$COV" | grep "^TOTAL" | awk '{print $$NF}' | tr -d '%'); \ + printf " $(GREEN)โœ… unit tests ($${COV_PCT}%% coverage)$(RESET)\n"; \ + else \ + printf " $(RED)โŒ unit tests$(RESET)\n"; exit 1; \ + fi + @E2E=$$(uv run pytest tests/e2e -q --no-header 2>&1); \ + if [ $$? -eq 0 ]; then \ + PASSED=$$(echo "$$E2E" | tail -1 | grep -oE '[0-9]+ passed' | awk '{print $$1}'); \ + printf " $(GREEN)โœ… e2e tests ($${PASSED} passed)$(RESET)\n"; \ + else \ + printf " $(RED)โŒ e2e tests$(RESET)\n"; exit 1; \ + fi + +# Run only unit tests +test-unit: + @printf "\n$(BOLD)$(CYAN)๐Ÿงช Unit tests...$(RESET)\n" + @uv run pytest tests/copilot_usage -v --cov --cov-fail-under=80 + +# Run only e2e tests +test-e2e: + @printf "\n$(BOLD)$(CYAN)๐Ÿงช E2E tests...$(RESET)\n" + @uv run pytest tests/e2e -v + +# Auto-fix +fix: + @printf "\n$(BOLD)$(CYAN)๐Ÿ”ง Auto-fixing...$(RESET)\n" + @uv run ruff check --fix . 2>/dev/null; uv run ruff format . $(QUIET) + @printf " $(GREEN)โœ… ruff fix + format$(RESET)\n" + @printf "\n$(BOLD)$(CYAN)โœจ All fixed!$(RESET)\n\n" diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4ee146 --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# CLI Tools + +Monorepo for personal Python CLI utilities. Each tool is a separate package under `src/` with its own entry point. + +## Tools + +### copilot-usage + +Parses local Copilot CLI session data to show token usage, premium requests, model breakdown, and raw event counts โ€” the data GitHub's usage dashboard doesn't show you (or shows with multi-day delays). + +**Why?** GitHub's usage page has significant delays in reporting CLI premium request consumption. Your local `~/.copilot/session-state/` files have the real data โ€” this tool surfaces it instantly. + +#### Installation + +```bash +# From the repo (dev mode โ€” no install needed) +cd ~/projects/cli-tools +uv run copilot-usage summary + +# Global install (available everywhere) +uv tool install ~/projects/cli-tools +copilot-usage summary +``` + +#### Interactive Mode + +Run `copilot-usage` with no subcommand to launch the interactive session: + +``` +$ copilot-usage +``` + +This shows the full summary dashboard with a numbered session list. From there: +- Enter a **session number** to drill into that session's detail view +- Press **c** to see the cost breakdown +- Press **r** to refresh data +- Press **q** to quit + +Each sub-view has a "Press Enter to go back" prompt to return home. + +If `watchdog` is installed, the display auto-refreshes when session files change (2-second debounce). Install it with `uv add watchdog` for live-updating views. + +#### Commands + +##### `copilot-usage summary` + +Show usage totals across all sessions with per-model and per-session breakdowns. Sessions table includes Model Calls (assistant.turn_start count) and User Msgs columns for raw event counts. + +``` +copilot-usage summary [--since DATE] [--until DATE] [--path PATH] +``` + +Options: +- `--since` โ€” show sessions starting after this date (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS`) +- `--until` โ€” show sessions starting before this date +- `--path` โ€” custom session-state directory (default: `~/.copilot/session-state/`) + +Example: +``` +$ copilot-usage summary + +Copilot Usage Summary (2026-03-07 โ†’ 2026-03-08) + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Totals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ 0 premium requests 2337 model calls 647 user messages 2.2M output tokens โ”‚ +โ”‚ 6h 47m 42s API duration 3 sessions โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + + Per-Model Breakdown +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Model โ”‚ Requests โ”‚ Premium Cost โ”‚ Input Tokens โ”‚ Output Tokens โ”‚ Cache Read โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ claude-haiku-4.5 โ”‚ 99 โ”‚ 0 โ”‚ 2.9M โ”‚ 93.7K โ”‚ 2.4M โ”‚ +โ”‚ claude-opus-4.6 โ”‚ 622 โ”‚ 0 โ”‚ 31.2M โ”‚ 1.1M โ”‚ 30.0M โ”‚ +โ”‚ claude-opus-4.6-1m โ”‚ 1420 โ”‚ 1986 โ”‚ 294.7M โ”‚ 1.0M โ”‚ 278.6M โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + + Sessions +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Name โ”‚ Model โ”‚ Premium โ”‚ Model Calls โ”‚ User Msgs โ”‚ Output Tokens โ”‚ Status โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Copilot CLI Usage โ”‚ claude-opus-4.6โ€ฆ โ”‚ 288 โ”‚ 539 โ”‚ 200 โ”‚ 468.0K โ”‚ Active ๐ŸŸข โ”‚ +โ”‚ Tracker โ€” Plan โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ Stock Market โ”‚ claude-opus-4.6โ€ฆ โ”‚ 504 โ”‚ 967 โ”‚ 169 โ”‚ 483.8K โ”‚ Active ๐ŸŸข โ”‚ +โ”‚ Tracker โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ ShapeShifter โ”‚ claude-opus-4.6 โ”‚ 1194 โ”‚ 831 โ”‚ 278 โ”‚ 1.2M โ”‚ Active ๐ŸŸข โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +##### `copilot-usage cost` + +Show premium request costs from shutdown data (raw counts, no estimation). + +``` +copilot-usage cost [--since DATE] [--until DATE] [--path PATH] +``` + +Uses `render_cost_view` to show a per-session, per-model breakdown with 6 columns: Session, Model, Requests, Premium Cost, Model Calls, and Output Tokens. Resumed sessions include a "โ†ณ Since last shutdown" row with active-period stats. + +Example: +``` +$ copilot-usage cost + +๐Ÿ’ฐ Cost Breakdown +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Session โ”‚ Model โ”‚ Requests โ”‚ Premium Cost โ”‚ Model Calls โ”‚ Output Tokens โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Session Alpha โ”‚ claude-opus-4.6-1m โ”‚ 235 โ”‚ 288 โ”‚ 3 โ”‚ 93.6K โ”‚ +โ”‚ Session Beta โ”‚ claude-opus-4.6-1m โ”‚ 592 โ”‚ 504 โ”‚ 3 โ”‚ 207.3K โ”‚ +โ”‚ Session Gamma โ”‚ claude-opus-4.6 โ”‚ 8 โ”‚ 10 โ”‚ 2 โ”‚ 400 โ”‚ +โ”‚ โ†ณ Since last shutdown โ”‚ claude-opus-4.6 โ”‚ N/A โ”‚ N/A โ”‚ 1 โ”‚ 150 โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Grand Total โ”‚ โ”‚ 835 โ”‚ 802 โ”‚ 9 โ”‚ 301.5K โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +##### `copilot-usage live` + +Show currently active (running) Copilot sessions with real-time stats. + +``` +copilot-usage live [--path PATH] +``` + +Example: +``` +$ copilot-usage live + + ๐ŸŸข Active Copilot Sessions +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Session ID โ”‚ Name โ”‚ Model โ”‚ Running โ”‚ Messages โ”‚ Output Tokens โ”‚ CWD โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ ๐ŸŸข b5df8a34 โ”‚ Copilot CLI Usage Tracker โ€” โ”‚ claude-opus-4.6-1m โ”‚ 126h 21m โ”‚ 200 โ”‚ 468,625 โ”‚ /Users/you โ”‚ +โ”‚ โ”‚ Plan โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ ๐ŸŸข 0faecbdf โ”‚ Stock Market Tracker โ”‚ claude-opus-4.6-1m โ”‚ 136h 17m โ”‚ 169 โ”‚ 483,762 โ”‚ /Users/you โ”‚ +โ”‚ ๐ŸŸข 4a547040 โ”‚ ShapeShifter โ”‚ claude-opus-4.6 โ”‚ 150h 33m โ”‚ 278 โ”‚ 1,225,990 โ”‚ /Users/you โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +##### `copilot-usage session` + +Show detailed per-turn breakdown for a specific session. Accepts a session ID prefix (first 8 chars is enough). + +``` +copilot-usage session SESSION_ID [--path PATH] +``` + +Example: +``` +$ copilot-usage session b5df8a34 +``` + +#### How It Works + +Copilot CLI stores session data locally in `~/.copilot/session-state/{session-id}/`. Each session directory contains an `events.jsonl` file with structured events: + +- **`session.start`** โ€” session ID, start time, working directory +- **`assistant.message`** โ€” per-message output tokens and model info +- **`session.shutdown`** โ€” the goldmine: `totalPremiumRequests`, `modelMetrics` (per-model input/output/cache tokens, request counts), `codeChanges` +- **`tool.execution_complete`** โ€” model used per tool call + +For active sessions (no shutdown event yet), the tool sums individual message tokens to build a running total. For resumed sessions (activity after a shutdown), it merges the shutdown baseline with post-resume tokens. + +#### Model Pricing (reference) + +GitHub Copilot charges different premium-request multipliers per model. This tool reports raw counts (model calls, user messages, exact premium requests from shutdown data) โ€” not estimated billing. The multiplier table below is provided for reference only: + +| Model | Multiplier | Tier | +|---|---|---| +| `claude-opus-4.6` / `claude-opus-4.5` | 3ร— | Premium | +| `claude-opus-4.6-1m` | 6ร— | Premium | +| `claude-sonnet-4.6` / `claude-sonnet-4.5` / `claude-sonnet-4` | 1ร— | Standard | +| `gpt-5.4` / `gpt-5.2` / `gpt-5.1` / `gpt-5.x-codex` / `gpt-5.1-codex-max` | 1ร— | Standard | +| `gemini-3-pro-preview` | 1ร— | Standard | +| `claude-haiku-4.5` / `gpt-5.1-codex-mini` | 0.33ร— | Light | +| `gpt-5-mini` / `gpt-4.1` | 0ร— | Free/Included | + +## Development + +**Prerequisites:** [uv](https://docs.astral.sh/uv/) (v0.10+) + +```bash +git clone git@github.com:microsasa/cli-tools.git +cd cli-tools +uv sync +``` + +### Make Targets + +| Command | Description | +|---|---| +| `make check` | Run all checks (lint + typecheck + security + tests) | +| `make test` | Unit tests (80% coverage enforced) + e2e tests | +| `make test-unit` | Unit tests only with verbose output | +| `make test-e2e` | E2E tests only | +| `make lint` | Ruff lint + format check | +| `make fix` | Auto-fix lint/formatting issues | + +Add `V=1` for verbose output: `make check V=1` + +### Project Structure + +``` +cli-tools/ +โ”œโ”€โ”€ src/ +โ”‚ โ””โ”€โ”€ copilot_usage/ +โ”‚ โ”œโ”€โ”€ cli.py # Click commands + interactive loop + watchdog auto-refresh +โ”‚ โ”œโ”€โ”€ models.py # Pydantic data models +โ”‚ โ”œโ”€โ”€ parser.py # events.jsonl parsing +โ”‚ โ”œโ”€โ”€ pricing.py # Model cost multipliers +โ”‚ โ”œโ”€โ”€ logging_config.py # Loguru configuration +โ”‚ โ””โ”€โ”€ report.py # Rich terminal output +โ”œโ”€โ”€ tests/ +โ”‚ โ”œโ”€โ”€ copilot_usage/ # Unit tests +โ”‚ โ””โ”€โ”€ e2e/ # End-to-end tests with fixtures +โ”œโ”€โ”€ docs/ +โ”‚ โ”œโ”€โ”€ plan.md +โ”‚ โ”œโ”€โ”€ architecture.md +โ”‚ โ”œโ”€โ”€ changelog.md +โ”‚ โ””โ”€โ”€ implementation.md +โ”œโ”€โ”€ Makefile +โ””โ”€โ”€ pyproject.toml +``` + +### Stack + +- **Python 3.12+** with pyright strict mode +- **Click** โ€” CLI framework +- **Rich** โ€” terminal tables and formatting +- **Pydantic v2** โ€” runtime-validated data models +- **Ruff** โ€” linting + formatting +- **Bandit** โ€” security scanning +- **pytest + pytest-cov** โ€” testing (80% coverage minimum) + +## Future Tools + +- **repo-health** โ€” audit any repo against project-standards (missing Makefile targets, wrong ruff rules, etc.) +- **session-manager** โ€” list/search/clean up Copilot CLI sessions in `~/.copilot/session-state/` +- **docker-status** โ€” pretty overview of all OrbStack containers, ports, health, resource usage +- **env-check** โ€” verify dev environment matches standards (uv version, Python, pnpm, Docker, VS Code extensions) \ No newline at end of file diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..fcee222 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,88 @@ +# CLI Tools โ€” Architecture + +## Overview + +Monorepo containing Python CLI utilities that share tooling, CI, and common dependencies. Each tool is a separate package under `src/` with its own Click entry point. + +--- + +## copilot-usage + +### Data Flow + +``` +~/.copilot/session-state/ src/copilot_usage/ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ {session-id}/ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ events.jsonl โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ parser โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ models โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ report โ”‚โ”€โ”€โ”€โ–ถ terminal +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ {session-id}/ โ”‚ โ”‚ discover โ”‚ โ”‚ Pydantic โ”‚ โ”‚ Rich โ”‚ +โ”‚ events.jsonl โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ parse โ”‚ โ”‚ validate โ”‚ โ”‚ tables โ”‚ +โ”‚ โ”‚ โ”‚ summarizeโ”‚ โ”‚ โ”‚ โ”‚ panels โ”‚ +โ”‚ ... โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ pricing โ”‚ + โ”‚ โ”‚ + โ”‚ multipliers โ”‚ + โ”‚ (reference) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Components + +| Module | Responsibility | +|--------|---------------| +| `cli.py` | Click command group โ€” routes commands to parser/report functions, handles CLI options, error display. Also contains the interactive loop (invoked when no subcommand is given) with watchdog-based auto-refresh (2-second debounce). | +| `parser.py` | Discovers sessions, reads events.jsonl line by line, builds SessionSummary per session. Counts raw events (model calls via assistant.turn_start, user messages). | +| `models.py` | Pydantic v2 models for all event types + 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), session detail, live view, premium request breakdown. Reports raw counts, no estimation. | +| `pricing.py` | Model pricing registry โ€” multiplier lookup, tier categorization. Multipliers retained as reference data only; not used for estimation. | +| `logging_config.py` | Loguru setup โ€” stderr warnings only, no file output. Called once from CLI entry point. | + +### Event Processing Pipeline + +1. **Discovery** โ€” `discover_sessions()` scans `~/.copilot/session-state/*/events.jsonl`, returns paths sorted by modification time +2. **Parsing** โ€” `parse_events()` reads each line as JSON, creates `SessionEvent` objects via Pydantic validation. Malformed lines are skipped with a warning. +3. **Typed dispatch** โ€” `SessionEvent.parse_data()` uses match/case on event type to return the correct typed data model (`SessionStartData`, `AssistantMessageData`, etc.) +4. **Summarization** โ€” `build_session_summary()` walks the event list: + - Extracts session metadata from `session.start` + - Counts raw events: model calls (assistant.turn_start count), user messages (user.message count) + - For completed sessions: uses `session.shutdown` aggregate metrics directly โ€” **sums all shutdown events** (shutdown metrics are per-lifecycle, not cumulative) + - For active/resumed sessions: sums `outputTokens` from individual `assistant.message` events + - Detects resumed sessions: if events exist after `session.shutdown`, marks `is_active = True` + - Tracks `last_resume_time` from `session.resume` events โ€” used to calculate "Running" duration for active sessions + - Reports exact premium requests from shutdown data only โ€” no multiplier-based estimation +5. **Rendering** โ€” Report functions receive `SessionSummary` objects and render Rich output + +### Key Design Decisions + +**Pydantic at the boundary, not everywhere.** Raw JSON is validated into Pydantic models during parsing. After that, typed Python objects flow through the system โ€” no re-validation needed internally. + +**Shutdown event as source of truth.** The `session.shutdown` event contains pre-aggregated metrics (total tokens, premium requests, model breakdown). We use these directly instead of re-summing individual events โ€” more accurate and faster. + +**Resumed session detection.** Sessions can be shut down and resumed. The parser checks for events after the last `session.shutdown` to detect this. Resumed sessions get `is_active = True` with shutdown metrics preserved as historical data. + +**Graceful degradation.** Unknown event types are parsed as `GenericEventData` (Pydantic `extra="allow"`). Missing fields get defaults. The tool never crashes on unexpected data. + +### Testing Strategy + +> For detailed implementation internals (shutdown aggregation, active detection, edge cases), see [implementation.md](implementation.md). + +``` +tests/ +โ”œโ”€โ”€ copilot_usage/ Unit tests โ€” synthetic data, test functions in isolation +โ”‚ โ”œโ”€โ”€ test_models.py Pydantic model creation and validation +โ”‚ โ”œโ”€โ”€ test_parser.py Event parsing, session summary building, edge cases +โ”‚ โ”œโ”€โ”€ test_pricing.py Pricing lookups, cost estimation +โ”‚ โ”œโ”€โ”€ test_report.py Rich output formatting, rendering functions +โ”‚ โ””โ”€โ”€ test_cli.py Click command invocation via CliRunner +โ””โ”€โ”€ e2e/ E2e tests โ€” real CLI commands against fixture data + โ”œโ”€โ”€ fixtures/ Anonymized events from real Copilot sessions (8 fixtures) + โ””โ”€โ”€ test_e2e.py Full pipeline: CLI โ†’ parser โ†’ models โ†’ report โ†’ output +``` + +- **327 total tests**: 272 unit tests + 55 e2e tests +- **Unit tests**: 96% coverage, test individual functions with synthetic data +- **E2e tests**: Run actual CLI commands against 8 anonymized fixture sessions, assert on output content +- Coverage is measured on unit tests only (e2e coverage would be misleading) diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..279bbfe --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,189 @@ +# CLI Tools โ€” Changelog + +Append-only history of what was planned and delivered, PR by PR. Newest entries first. + +--- + +## fix: code review fixes + implementation doc โ€” 2026-03-13 + +**Plan**: Address code review findings and add detailed implementation documentation. + +**Done**: +- Fixed loguru format strings (was using f-strings instead of `{}` placeholders) +- Fixed model_calls duplication in resumed sessions +- Fixed premium total consistency across views +- Fixed active-in-historical leak (active sessions no longer bleed into historical section) +- Fixed parent `--path` fallback propagation +- Fixed TOCTOU race in session discovery (`_safe_mtime()` + catch in `get_all_sessions()`) +- Removed Start Time column from sessions table +- Added `last_resume_time` for accurate Running duration display +- Created `docs/implementation.md` โ€” deep-dive into internals (shutdown aggregation, active detection, edge cases) +- 13 new e2e tests (55 total), 327 total tests, 96% unit coverage + +--- + +## feat: add interactive mode โ€” 2026-03-13 + +**Plan**: Add an interactive session loop when `copilot-usage` is invoked without a subcommand. Replace the deleted Textual TUI with a simpler Rich + input() approach. + +**Done**: +- Interactive loop in `cli.py` โ€” summary view with numbered session list +- Session detail drill-down by number, cost view via `c`, refresh via `r`, quit via `q` +- "Press Enter to go back" navigation between views +- Updated report.py with `render_full_summary`, `render_cost_view`, `render_session_detail` accepting `target_console` +- Removed Textual dependency, kept Rich only +- 247 unit tests + 30 e2e tests passing, 98% coverage + +--- + +## refactor: remove multiplier estimation, report raw event counts โ€” 2026-03-13 + +**Plan**: Strip out all multiplier-based premium request estimation. Report raw facts: model calls (assistant.turn_start count), user messages, output tokens, and exact premium requests from shutdown data only. + +**Done**: +- Removed estimated_premium_requests from SessionSummary +- Added model_calls and user_messages fields +- Simplified cost command to raw data only +- Removed ~ prefix estimation display +- Updated all tests and fixtures + +--- + +## build: switch to loguru, align with latest standards โ€” 2026-03-13 + +**Plan**: Replace stdlib logging with loguru per project-standards ยง13. Update per-file test ignores to match ยง15. + +**Done**: +- Replaced `import logging` with loguru in parser.py +- Added minimal CLI logging config (stderr warnings only, no file output) +- Updated pyproject.toml per-file-ignores: added S105, S106 for test credentials +- Pinned pydantic>=2,<3 per dependency pinning standard + +--- + +## fix: address Copilot code review (10 issues) โ€” 2026-03-13 + +**Plan**: Fix all 10 issues flagged by Copilot code review on PR #1. + +**Done**: +- Pinned pydantic>=2,<3 (was unpinned) +- Anonymized remaining "microsasa" in fixture files +- Updated README pricing table to match corrected multipliers +- Fixed e2e test name mismatch +- Dynamic e2e pass count in Makefile (was hardcoded) +- Cache tokens now includes both read + write +- Added pluralization ("1 session" vs "N sessions") +- Cost command shows "mixed" for multi-model sessions +- Widened toolRequests type to dict[str, object] + +--- + +## fix: detect resumed sessions + correct model pricing โ€” 2026-03-12 + +**Plan**: Fix resumed session detection and correct model multipliers from actual GitHub data. + +**Done**: +- Fixed build_session_summary to detect events after shutdown (resumed = active) +- Corrected all model multipliers: Opus 3x/6x (was 50x), Haiku 0.33x (was 0.25x), GPT-5-mini/GPT-4.1 0x (was 0.25x) +- Added model fallback inference from modelMetrics keys when currentModel missing +- E2e fixture for resumed sessions, corrupt sessions +- 244 tests total, 98% coverage + +--- + +## docs: add project docs (architecture, changelog, updated plan) โ€” 2026-03-12 + +**Plan**: Add architecture.md and changelog.md per project-standards ยง13. Update plan.md to be forward-looking only (remove completed phase checkboxes). + +**Done**: +- Created docs/architecture.md with data flow diagram, component descriptions, pipeline, design decisions +- Created docs/changelog.md (this file) with backfilled history +- Rewrote docs/plan.md โ€” removed completed phases and checkboxes, kept scope, decisions, future ideas + +--- + +## docs: add comprehensive README โ€” 2026-03-12 + +**Plan**: Create README with installation, usage examples, dev setup, and all 4 commands documented. + +**Done**: +- README.md with real command output examples (anonymized) +- Installation instructions (dev mode + global install) +- Model pricing table, dev workflow, project structure + +--- + +## fix: detect resumed sessions โ€” 2026-03-12 + +**Plan**: Sessions that are resumed after shutdown were incorrectly showing as "Completed." Fix parser to detect post-shutdown events. + +**Done**: +- Fixed `build_session_summary` to check for events after last `session.shutdown` +- Resumed sessions marked `is_active = True`, post-shutdown tokens merged +- New e2e fixture: `resumed-session/events.jsonl` +- 4 unit tests + 2 e2e tests + +--- + +## build: separate unit and e2e test output โ€” 2026-03-12 + +**Plan**: `make test` should show unit test coverage and e2e tests as separate lines. + +**Done**: +- Split `test` target: unit tests with coverage, e2e tests with pass count +- Added `make test-unit` and `make test-e2e` targets +- Output: `โœ… unit tests (93% coverage)` + `โœ… e2e tests (15 passed)` + +--- + +## test: add e2e tests with anonymized fixture data โ€” 2026-03-12 + +**Plan**: Create e2e tests that run actual CLI commands against real (anonymized) session data. + +**Done**: +- Extracted 55 events across 3 sessions from real data, anonymized content (~23KB) +- 13 e2e tests covering summary, session, cost, live commands +- Added `--path` option to `session` command (was missing) +- Fixture data preserves full event sequences (no gaps) + +--- + +## feat: wire up CLI commands, add CI workflow โ€” 2026-03-09 + +**Plan**: Replace stub CLI commands with real implementations. Create GitHub Actions CI. + +**Done**: +- All 4 commands wired up: summary, session, cost, live +- `--since`, `--until`, `--path` options on all commands +- Session prefix matching (first 8 chars of UUID) +- Graceful error handling (no tracebacks) +- `.github/workflows/ci.yml` โ€” PR gate per standards +- 13 CLI tests + +--- + +## feat: build core features (models, parser, reports, pricing, live) โ€” 2026-03-09 + +**Plan**: Build all core modules โ€” Pydantic models, event parser, Rich reports, pricing data, live session tracking. + +**Done**: +- `models.py` โ€” 15 Pydantic models covering all event types + SessionSummary +- `parser.py` โ€” session discovery, event parsing, summary building, active session handling +- `report.py` โ€” summary tables, session detail with event timeline, live session view, cost breakdown +- `pricing.py` โ€” 17 model multipliers, lookup with partial matching, cost estimation +- 178 unit tests, 93% coverage + +--- + +## feat: initial project scaffold โ€” 2026-03-09 + +**Plan**: Create cli-tools monorepo with copilot-usage CLI stub, full tooling per project-standards. + +**Done**: +- `uv init --python 3.12`, dependencies (pydantic, click, rich) + dev deps +- pyproject.toml with full tool config (ruff 13 rules, pyright strict, pytest, coverage 80%) +- Pretty Makefile with emoji output and V=1 verbose +- src/copilot_usage/ package with Click CLI stub (4 commands) +- .editorconfig, .github/dependabot.yml, .gitignore, py.typed +- Pushed to microsasa/cli-tools (private), main = empty init, dev = work +- `make check` passing (94% coverage) diff --git a/docs/implementation.md b/docs/implementation.md new file mode 100644 index 0000000..1f7ac78 --- /dev/null +++ b/docs/implementation.md @@ -0,0 +1,439 @@ +# Implementation Details + +Deep-dive into how `copilot-usage` works under the hood. For the high-level architecture and data flow diagram, see [architecture.md](architecture.md). This document is for developers maintaining the code. + +--- + +## Session Data Model + +### Storage location + +Copilot CLI stores session data at: + +``` +~/.copilot/session-state/{uuid}/ +โ”œโ”€โ”€ events.jsonl # Append-only event log (one JSON object per line) +โ””โ”€โ”€ plan.md # Optional โ€” session name extracted from first heading +``` + +The `{uuid}` is a full UUID assigned by the Copilot CLI at session creation. + +### Event envelope + +Every line in `events.jsonl` is a JSON object conforming to the `SessionEvent` model (`models.py:177โ€“207`): + +| Field | Type | Description | +|----------------|--------------------|--------------------------------------------------------------| +| `type` | `str` | Event type identifier (e.g. `"session.start"`) | +| `data` | `dict[str, object]` | Event-specific payload โ€” parsed on demand via `parse_data()` | +| `id` | `str \| None` | Event UUID | +| `timestamp` | `datetime \| None` | ISO 8601 timestamp | +| `parentId` | `str \| None` | Links tool completions to their turn | +| `currentModel` | `str \| None` | Top-level model field (present on shutdown events) | + +### Known event types + +Defined in `EventType` enum (`models.py:22โ€“37`): + +| Event type | Data class | Key fields | +|-----------------------------------|-------------------------|--------------------------------------------------------------------| +| `session.start` | `SessionStartData` | `sessionId`, `startTime`, `context.cwd` | +| `session.shutdown` | `SessionShutdownData` | `totalPremiumRequests`, `totalApiDurationMs`, `modelMetrics`, `codeChanges` | +| `session.resume` | `GenericEventData` | No typed model โ€” only `timestamp` is used | +| `session.error` | `GenericEventData` | Catch-all | +| `session.plan_changed` | `GenericEventData` | Catch-all | +| `session.workspace_file_changed` | `GenericEventData` | Catch-all | +| `assistant.message` | `AssistantMessageData` | `outputTokens`, `content`, `toolRequests` | +| `assistant.turn_start` | `GenericEventData` | Counted for model calls โ€” no typed payload needed | +| `assistant.turn_end` | `GenericEventData` | Catch-all | +| `tool.execution_start` | `GenericEventData` | Catch-all | +| `tool.execution_complete` | `ToolExecutionData` | `model`, `success`, `toolTelemetry.properties.tool_name` | +| `user.message` | `UserMessageData` | `content`, `attachments` | +| `abort` | `GenericEventData` | Catch-all | + +Typed dispatch happens in `SessionEvent.parse_data()` (`models.py:193โ€“207`) via `match`/`case`. Unknown types fall through to `GenericEventData(extra="allow")`, which accepts any JSON fields without validation errors. + +### SessionSummary fields + +`SessionSummary` (`models.py:229โ€“256`) is a computed aggregate โ€” never parsed directly from JSON. Built by `build_session_summary()`. + +| Field | Type | How it's populated | +|--------------------------|-----------------------------|-------------------------------------------------------------------------------------| +| `session_id` | `str` | From `session.start` โ†’ `data.sessionId` | +| `start_time` | `datetime \| None` | From `session.start` โ†’ `data.startTime` | +| `end_time` | `datetime \| None` | Timestamp of last `session.shutdown`; `None` if resumed | +| `name` | `str \| None` | Extracted from `plan.md` first heading (see Session Name Resolution) | +| `cwd` | `str \| None` | From `session.start` โ†’ `data.context.cwd` | +| `model` | `str \| None` | Last model seen in shutdowns, or inferred (see below) | +| `total_premium_requests` | `int` | Sum of `totalPremiumRequests` across all shutdown events | +| `total_api_duration_ms` | `int` | Sum of `totalApiDurationMs` across all shutdown events | +| `model_metrics` | `dict[str, ModelMetrics]` | Merged from all shutdown events (same model โ†’ sum values) | +| `code_changes` | `CodeChanges \| None` | From the last shutdown event that has it | +| `model_calls` | `int` | Count of `assistant.turn_start` events across entire session | +| `user_messages` | `int` | Count of `user.message` events across entire session | +| `is_active` | `bool` | `True` if no shutdowns, or if events exist after last shutdown | +| `last_resume_time` | `datetime \| None` | Timestamp of `session.resume` event (if any, after last shutdown) | +| `events_path` | `Path \| None` | Set by `get_all_sessions()` after building โ€” not from events | +| `active_model_calls` | `int` | `assistant.turn_start` count after last shutdown (resumed sessions only) | +| `active_user_messages` | `int` | `user.message` count after last shutdown (resumed sessions only) | +| `active_output_tokens` | `int` | Sum of `outputTokens` from `assistant.message` events after last shutdown | + +--- + +## Shutdown Event Processing + +This is the most critical logic in the codebase. Getting it wrong means incorrect premium request counts and token totals. + +### Key insight: shutdown events are NOT cumulative + +Each `session.shutdown` event represents the metrics for **one lifecycle** (start โ†’ shutdown). If a session is resumed and shut down again, you get two separate shutdown events with independent metric snapshots. To get the session's total, you must **sum across all shutdowns**. + +### The code path + +In `build_session_summary()` (`parser.py:151โ€“361`): + +**Phase 1 โ€” Collect all shutdowns** (`parser.py:207โ€“218`): +```python +all_shutdowns: list[tuple[int, SessionShutdownData, str | None]] = [] +# ... +elif ev.type == EventType.SESSION_SHUTDOWN: + # ... validate, extract data ... + all_shutdowns.append((idx, data, current_model)) +``` +Each tuple stores `(event_index, shutdown_data, resolved_model)`. + +**Phase 2 โ€” Sum across all shutdowns** (`parser.py:268โ€“301`): +```python +for _idx, sd, _m in all_shutdowns: + total_premium += sd.totalPremiumRequests + total_api_duration += sd.totalApiDurationMs + # ... merge model_metrics ... +``` + +### Model metrics merging + +When two shutdowns reference the **same model**, their `ModelMetrics` are summed field-by-field (`parser.py:281โ€“298`): + +```python +if model_name in merged_metrics: + existing = merged_metrics[model_name] + merged_metrics[model_name] = ModelMetrics( + requests=RequestMetrics( + count=existing.requests.count + metrics.requests.count, + cost=existing.requests.cost + metrics.requests.cost, + ), + usage=TokenUsage( + inputTokens=existing.usage.inputTokens + metrics.usage.inputTokens, + outputTokens=existing.usage.outputTokens + metrics.usage.outputTokens, + cacheReadTokens=existing.usage.cacheReadTokens + metrics.usage.cacheReadTokens, + cacheWriteTokens=existing.usage.cacheWriteTokens + metrics.usage.cacheWriteTokens, + ), + ) +``` + +When they reference **different models**, separate entries are kept in the `merged_metrics` dict. + +### Model resolution for shutdowns + +The model name for a shutdown is resolved in priority order (`parser.py:213โ€“216`): +1. `currentModel` from the event envelope (top-level field) +2. `currentModel` from the shutdown data payload +3. Inferred from `modelMetrics` keys โ€” if one key, use it; if multiple, pick the one with highest `requests.count` (`_infer_model_from_metrics()`, `parser.py:33โ€“43`) + +--- + +## Active vs Historical Session Detection + +### Three session states + +| State | Shutdowns? | Events after last shutdown? | `is_active` | `end_time` | +|---------------------|------------|----------------------------|-------------|-------------| +| **Completed** | โ‰ฅ1 | No | `False` | Last shutdown timestamp | +| **Resumed (active)**| โ‰ฅ1 | Yes | `True` | `None` | +| **Pure active** | 0 | N/A | `True` | `None` | + +### Detection logic + +After collecting all shutdowns, `build_session_summary()` scans events after the last shutdown index (`parser.py:238โ€“265`): + +```python +_RESUME_INDICATOR_TYPES: set[str] = { + EventType.SESSION_RESUME, + EventType.USER_MESSAGE, + EventType.ASSISTANT_MESSAGE, +} + +last_shutdown_idx = all_shutdowns[-1][0] if all_shutdowns else -1 + +if all_shutdowns and last_shutdown_idx >= 0: + for ev in events[last_shutdown_idx + 1:]: + if ev.type in _RESUME_INDICATOR_TYPES: + session_resumed = True + # ... count post-shutdown tokens, messages, model calls ... +``` + +The presence of **any** `session.resume`, `user.message`, or `assistant.message` event after the last shutdown triggers `session_resumed = True`. + +### `last_resume_time` + +Populated from the `timestamp` of the `session.resume` event after the last shutdown (`parser.py:256โ€“257`). Used by the report layer to calculate "Running Time" โ€” showing duration since resume, not since original start. + +### Resumed session summary construction + +For resumed sessions (`parser.py:302โ€“320`): +- `end_time` is set to `None` (not the last shutdown timestamp) +- `model_metrics` contain the **merged shutdown data** (historical baseline) +- `active_model_calls`, `active_user_messages`, `active_output_tokens` contain **only** post-shutdown counts +- `model_calls` and `user_messages` are the **total** counts across the entire session + +--- + +## Premium Request Tracking + +### Where premium requests come from + +The **only** source of premium request counts is `SessionShutdownData.totalPremiumRequests` (`models.py:115`). This is a pre-computed value from the Copilot CLI โ€” not something we calculate. + +For sessions with multiple shutdowns, the total is summed: `total_premium += sd.totalPremiumRequests` (`parser.py:276`). + +### Active sessions show "โ€”" + +If a session has no shutdown data (pure active), `total_premium_requests` is `0`, and the report displays "โ€”" (`report.py:677โ€“680`): + +```python +if s.total_premium_requests > 0: + pr_display = str(s.total_premium_requests) +else: + pr_display = "โ€”" +``` + +### Why estimation was removed + +Early versions attempted to estimate premium requests using `multiplier ร— request_count`. This was removed (see changelog: "refactor: remove multiplier estimation") because: + +1. **Multipliers don't map 1:1 to API calls.** A single `assistant.turn_start` may result in multiple API calls (tool use loops, retries), or a single API call may be counted as multiple premium requests at the billing layer. +2. **Shutdown data is authoritative.** The `totalPremiumRequests` field reflects actual billing, making estimation redundant for completed sessions. +3. **Active sessions have no reliable estimate.** Without shutdown data, any number would be misleading. + +### Total includes resumed sessions + +The grand total premium requests across all sessions includes resumed sessions that have shutdown data โ€” their `total_premium_requests` reflects the sum of all their shutdown cycles. + +--- + +## Interactive Loop Architecture + +Defined in `_interactive_loop()` (`cli.py:155โ€“250`). + +### Non-blocking input with `select()` + +The loop uses `select.select()` on stdin (`cli.py:103โ€“108`) with a 500ms timeout: + +```python +def _read_line_nonblocking(timeout: float = 0.5) -> str | None: + ready, _, _ = select.select([sys.stdin], [], [], timeout) + if ready: + return sys.stdin.readline().strip() + return None +``` + +This is **Unix only** โ€” `select()` on stdin doesn't work on Windows. The 500ms timeout allows the main loop to check for file-change events between input polls. + +### Fallback to blocking `input()` + +If `select()` raises `ValueError` or `OSError` (e.g. stdin is piped, not a real TTY, or during testing), the loop falls back to blocking `input()` (`cli.py:193โ€“199`): + +```python +except (ValueError, OSError): + try: + line = input().strip() + except (EOFError, KeyboardInterrupt): + break +``` + +### Watchdog file observer + +A `watchdog.Observer` watches `~/.copilot/session-state/` recursively for **any** filesystem change โ€” new session directories, lockfile creation/deletion, `events.jsonl` writes, etc. (`cli.py:129โ€“141`): + +```python +observer = Observer() +observer.schedule(handler, str(session_path), recursive=True) +observer.daemon = True +observer.start() +``` + +The observer is optional โ€” if `watchdog` is not installed, the import fails silently and `observer` stays `None` (`cli.py:131`). + +### `_FileChangeHandler` with 2-second debounce + +`_FileChangeHandler` (`cli.py:111โ€“123`) triggers on any filesystem event in the session-state tree and enforces a 2-second debounce using `time.monotonic()`: + +```python +def dispatch(self, event): + now = time.monotonic() + if now - self._last_trigger > 2.0: + self._last_trigger = now + self._change_event.set() +``` + +Each trigger causes a full `get_all_sessions()` re-read, picking up new sessions, closed sessions, and updated event data. The debounce prevents rapid redraws during high-frequency event writes (e.g. tool execution loops producing many events per second). Manual refresh (`r`) is still available as a fallback. + +### View state machine + +The interactive loop maintains a simple view state (`cli.py:166โ€“168`): + +``` +view: str = "home" | "detail" | "cost" +``` + +Transitions: +- **home โ†’ detail**: User enters a session number +- **home โ†’ cost**: User enters `c` +- **detail โ†’ home**: User presses Enter +- **cost โ†’ home**: User presses Enter +- **home โ†’ home**: User enters `r` (refresh) + +On any view transition, `get_all_sessions()` is re-called to pick up new data. Auto-refresh via watchdog also triggers re-render of the current view. + +--- + +## Cost View Rendering + +Defined in `render_cost_view()` (`report.py:898โ€“982`). + +### Per-model rows within sessions + +Each session's `model_metrics` dict is iterated to produce one table row per model (`report.py:931โ€“947`): + +```python +for model_name in sorted(s.model_metrics): + mm = s.model_metrics[model_name] + table.add_row(name, model_name, str(mm.requests.count), ...) + name = "" # blank after first row + model_calls_display = "" # shown only on first model row +``` + +The session name and model calls are shown **only on the first model row** โ€” subsequent model rows for the same session have blank session/model-calls columns to avoid visual repetition. + +### "โ†ณ Since last shutdown" rows + +For active (resumed) sessions, an extra row is appended (`report.py:960โ€“970`): + +```python +if s.is_active: + table.add_row( + " โ†ณ Since last shutdown", + s.model or "โ€”", + "N/A", # no premium requests available + "N/A", # no premium cost available + str(s.active_model_calls), + format_tokens(s.active_output_tokens), + ) +``` + +Premium columns show `N/A` because there's no shutdown data for the active period. + +### Historical vs active sections in full summary + +`render_full_summary()` (`report.py:871โ€“891`) renders two distinct sections: + +1. **Historical Data** (`_render_historical_section`, `report.py:725โ€“822`): Sessions with shutdown data. Includes sessions where `total_premium_requests > 0` OR sessions that have `model_metrics` and are **not** active. +2. **Active Sessions** (`_render_active_section`, `report.py:825โ€“868`): Sessions where `is_active == True`. Shows `active_model_calls`, `active_user_messages`, `active_output_tokens`, and running time. + +Resumed sessions appear in **both** sections โ€” historical section for their shutdown data, active section for their post-shutdown activity. + +### Grand total row + +After all session rows, a section divider and grand total row is added (`report.py:972โ€“980`). Grand totals accumulate `requests.count`, `requests.cost`, `model_calls`, and `output_tokens` from both shutdown metrics and active periods. + +--- + +## Edge Cases & Error Handling + +### Corrupt/malformed JSON lines + +`parse_events()` (`parser.py:98โ€“124`) handles two failure modes per line: + +1. **JSON decode failure**: `json.JSONDecodeError` โ†’ logged via `loguru.warning`, line skipped +2. **Pydantic validation failure**: `ValidationError` โ†’ logged with error count, line skipped + +Valid lines in the same file are still processed. A file with 99 valid lines and 1 corrupt line produces 99 events. + +### Empty sessions + +A session directory with just a `session.start` event (and nothing else) produces a valid `SessionSummary` with `is_active=True`, `model_calls=0`, `user_messages=0`, `total_premium_requests=0`. + +Sessions where `parse_events()` returns an empty list (no valid events at all) are skipped entirely by `get_all_sessions()` (`parser.py:384`). + +### TOCTOU races + +Two levels of protection against files disappearing between discovery and read: + +1. **Discovery**: `_safe_mtime()` (`parser.py:64โ€“69`) returns `0.0` instead of crashing when a file vanishes between `glob()` and `stat()`. +2. **Parsing**: `get_all_sessions()` (`parser.py:377โ€“385`) catches `FileNotFoundError` and `OSError` during `parse_events()` and skips the session with a warning. + +### Unknown event types + +Events with types not in `EventType` still parse successfully โ€” `SessionEvent.type` is `str`, not the enum. `parse_data()` returns `GenericEventData(extra="allow")` for unknown types, accepting any fields. + +In `build_session_summary()`, unknown types are simply ignored โ€” the `for idx, ev in enumerate(events)` loop only has branches for known types, with no `else` clause needed. + +### Unknown models in pricing + +`lookup_model_pricing()` (`pricing.py:106โ€“146`) has a three-tier resolution: +1. **Exact match** in `KNOWN_PRICING` +2. **Partial match** โ€” `model_name.startswith(key)` or `key.startswith(model_name)`, longest match wins +3. **Fallback** โ€” returns 1ร— standard multiplier, emits `UserWarning` + +--- + +## Session Name Resolution + +Implemented in `_extract_session_name()` (`parser.py:132โ€“143`). + +### Resolution order + +1. **Primary**: Read `plan.md` from the session directory. If it exists and the first line starts with `# `, extract the heading text after `# `. +2. **Fallback**: The report layer uses `s.name or s.session_id[:12]` โ€” showing the first 12 characters of the session UUID. + +### How it's called + +`build_session_summary()` calls `_extract_session_name(session_dir)` when `session_dir` is provided (`parser.py:235`). The `session_dir` parameter is passed by `get_all_sessions()` as `events_path.parent` (`parser.py:385`). + +--- + +## Model Multiplier Reference + +From `pricing.py:68โ€“90` โ€” `_RAW_MULTIPLIERS` dict: + +| Model | Multiplier | Tier | +|------------------------|------------|----------| +| `claude-sonnet-4.6` | 1ร— | Standard | +| `claude-sonnet-4.5` | 1ร— | Standard | +| `claude-sonnet-4` | 1ร— | Standard | +| `claude-opus-4.6` | 3ร— | Premium | +| `claude-opus-4.6-1m` | 6ร— | Premium | +| `claude-opus-4.5` | 3ร— | Premium | +| `claude-haiku-4.5` | 0.33ร— | Light | +| `gpt-5.4` | 1ร— | Standard | +| `gpt-5.2` | 1ร— | Standard | +| `gpt-5.1` | 1ร— | Standard | +| `gpt-5.1-codex` | 1ร— | Standard | +| `gpt-5.2-codex` | 1ร— | Standard | +| `gpt-5.3-codex` | 1ร— | Standard | +| `gpt-5.1-codex-max` | 1ร— | Standard | +| `gpt-5.1-codex-mini` | 0.33ร— | Light | +| `gpt-5-mini` | 0ร— | Light | +| `gpt-4.1` | 0ร— | Light | +| `gemini-3-pro-preview` | 1ร— | Standard | + +Tier is derived from the multiplier (`pricing.py:60โ€“65`): โ‰ฅ3.0 โ†’ Premium, <1.0 โ†’ Light, otherwise Standard. + +**Important:** `pricing.py` is **reference data only**. The multipliers are not used in any runtime calculations โ€” premium request counts come exclusively from `session.shutdown` events. The pricing module exists for `categorize_model()` (tier lookup) and potential future use. + +### Model resolution for active sessions + +When no shutdown data exists, the model is resolved in `build_session_summary()` (`parser.py:322โ€“339`): + +1. Scan `tool.execution_complete` events for a `model` field (`parser.py:324โ€“330`) +2. Fall back to `~/.copilot/config.json` โ†’ `data.model` field (`_read_config_model()`, `parser.py:46โ€“56`) diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..f6ebfbf --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,137 @@ +# CLI Tools โ€” Plan + +Monorepo for all Python CLI utilities under `microsasa`. Each tool is a separate package with its own entry point, sharing dev tooling, CI, and common dependencies. + +Repo: `microsasa/cli-tools` (private) +Location: `~/projects/cli-tools/` +Standards: follows `microsasa/project-standards` + +--- + +## Tools + +### copilot-usage (shipped) + +**Problem**: GitHub's usage dashboard has significant delays (or gaps) in reporting Copilot CLI premium request consumption. The local `~/.copilot/session-state/*/events.jsonl` files contain rich, accurate usage data that isn't being surfaced anywhere. + +**Solution**: CLI tool that parses local session data and presents a usage dashboard in the terminal. + +**Data sources** โ€” each session in `~/.copilot/session-state/{session-id}/` contains: +- **`events.jsonl`** โ€” every event logged during the session: + - `session.start` โ€” session ID, version, start time, cwd + - `assistant.message` โ€” per-message `outputTokens` and model info + - `session.shutdown` โ€” `totalPremiumRequests`, `totalApiDurationMs`, `modelMetrics` (per-model `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, request count & cost), `codeChanges`, `currentModel` + - `tool.execution_complete` โ€” model used per tool call + - `user.message` โ€” user prompts +- **`workspace.yaml`** โ€” session workspace/cwd info +- **`~/.copilot/logs/process-*.log`** โ€” CompactionProcessor lines show real-time token utilization + +**Commands**: +- `copilot-usage` โ€” launches Rich interactive mode with numbered session list, cost view, and watchdog-based auto-refresh (2-second debounce when `watchdog` is installed) +- `copilot-usage session ` โ€” per-turn token breakdown, tools used, API call timeline, code changes (static CLI output) + +**Interactive mode**: +The main interface. Launches a Rich-based interactive loop in the terminal: +- **Home view**: summary dashboard with numbered session list showing premium requests, model calls, user messages, output tokens, and status +- **Session detail**: enter a session number to drill into per-turn breakdown +- **Cost view**: press `c` to see premium request breakdown per session, per model +- **Manual refresh**: press `r` to reload session data +- **Auto-refresh**: if `watchdog` is installed, monitors `events.jsonl` files for changes and auto-refreshes the current view (2-second debounce). This provides the live-updating dashboard experience. +- **Quit**: press `q` to exit + +**Data philosophy**: +- **Historical data** (completed shutdown cycles): exact numbers from shutdown events โ€” premium requests, model metrics, input/output/cache tokens, API duration. Never estimated. A session can have multiple shutdown cycles (shutdown โ†’ resume โ†’ shutdown). +- **Active session data** (since last shutdown or session start): event counts from events.jsonl โ€” model calls, user messages, output tokens. Premium requests are NOT available in events.jsonl between shutdowns; estimation approaches TBD. +- Reports clearly separate historical and active data โ€” never mix exact and estimated numbers. + +**Planned features**: +- Active session premium request estimation โ€” iterate on approaches and validate against `/usage` command output + +### Future tool ideas +- **repo-health** โ€” audit any repo against project-standards (missing Makefile targets, wrong ruff rules, missing py.typed, etc.) +- **session-manager** โ€” list/search/clean up Copilot CLI sessions in `~/.copilot/session-state/` +- **docker-status** โ€” pretty overview of all OrbStack containers, ports, health, resource usage +- **env-check** โ€” verify dev environment matches standards (uv version, Python, pnpm, Docker, VS Code extensions) + +--- + +## Project Structure + +``` +cli-tools/ +โ”œโ”€โ”€ .editorconfig +โ”œโ”€โ”€ .github/ +โ”‚ โ”œโ”€โ”€ dependabot.yml +โ”‚ โ””โ”€โ”€ workflows/ +โ”‚ โ””โ”€โ”€ ci.yml +โ”œโ”€โ”€ Makefile +โ”œโ”€โ”€ pyproject.toml +โ”œโ”€โ”€ README.md +โ”œโ”€โ”€ docs/ +โ”‚ โ”œโ”€โ”€ plan.md # This file +โ”‚ โ”œโ”€โ”€ architecture.md # System design and data flow +โ”‚ โ”œโ”€โ”€ changelog.md # Append-only PR history +โ”‚ โ””โ”€โ”€ implementation.md # Deep-dive into internals +โ”œโ”€โ”€ src/ +โ”‚ โ””โ”€โ”€ copilot_usage/ +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ py.typed +โ”‚ โ”œโ”€โ”€ cli.py +โ”‚ โ”œโ”€โ”€ parser.py +โ”‚ โ”œโ”€โ”€ models.py +โ”‚ โ”œโ”€โ”€ report.py +โ”‚ โ””โ”€โ”€ pricing.py +โ””โ”€โ”€ tests/ + โ”œโ”€โ”€ copilot_usage/ # Unit tests + โ”‚ โ”œโ”€โ”€ test_cli.py + โ”‚ โ”œโ”€โ”€ test_parser.py + โ”‚ โ”œโ”€โ”€ test_models.py + โ”‚ โ”œโ”€โ”€ test_pricing.py + โ”‚ โ””โ”€โ”€ test_report.py + โ””โ”€โ”€ e2e/ # E2e tests with anonymized fixtures + โ”œโ”€โ”€ fixtures/ + โ””โ”€โ”€ test_e2e.py +``` + +When adding a new tool, add a new package under `src/` and test dirs under `tests/`: +``` +src/ +โ”œโ”€โ”€ copilot_usage/ # existing +โ””โ”€โ”€ repo_health/ # new tool + โ”œโ”€โ”€ __init__.py + โ”œโ”€โ”€ py.typed + โ”œโ”€โ”€ cli.py + โ””โ”€โ”€ ... +tests/ +โ”œโ”€โ”€ copilot_usage/ # existing unit tests +โ”œโ”€โ”€ repo_health/ # new tool unit tests +โ””โ”€โ”€ e2e/ # e2e tests for all tools +``` + +Each tool gets its own entry point in `pyproject.toml`: +```toml +[project.scripts] +copilot-usage = "copilot_usage.cli:main" +repo-health = "repo_health.cli:main" +``` + +--- + +## Key Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Repo structure | Monorepo for all CLI tools | Shared tooling config, one CI pipeline, less boilerplate | +| Python tooling | uv + pyproject.toml | Per project-standards | +| Type checking | pyright (strict mode) | No duck typing, per project-standards | +| Linting + formatting | ruff (13 rule groups) | Per project-standards | +| Security scanning | bandit | Per project-standards | +| Coverage threshold | 80% minimum (unit tests) | Per project-standards | +| CI | GitHub Actions PR gate | Per project-standards | +| Data validation | Pydantic v2 | Runtime + static type safety | +| CLI framework | Click | Cleaner than argparse | +| Terminal output | Rich | Beautiful tables, colors | +| Local dev workflow | Makefile (pretty output) | Per project-standards | +| Commit convention | Conventional commits | Per project-standards | +| Testing | Unit tests (coverage) + e2e tests (fixture data) | Unit for logic, e2e for full CLI pipeline | +| E2e fixtures | Anonymized real data, ~23KB | Real event structure, no sensitive content | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b3a1989 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,88 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/copilot_usage"] + +[project] +name = "cli-tools" +version = "0.1.0" +description = "Python CLI tools monorepo" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "pydantic>=2,<3", + "click", + "rich", + "loguru>=0.7.3", + "watchdog>=4", +] + +[project.scripts] +copilot-usage = "copilot_usage.cli:main" + +[dependency-groups] +dev = [ + "pytest", + "pytest-cov", + "pyright", + "ruff", + "bandit", +] + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "B", # flake8-bugbear (common bug patterns) + "C4", # flake8-comprehensions (unnecessary list/dict/set calls) + "SIM", # flake8-simplify (simplifiable code) + "I", # isort (import sorting) + "UP", # pyupgrade (modernize syntax to target-version) + "ICN", # flake8-import-conventions (import alias conventions) + "ISC", # flake8-implicit-str-concat (catches accidental string concat) + "RET", # flake8-return (unnecessary return/else patterns) + "S", # flake8-bandit (security โ€” inline checks) + "PERF", # perflint (performance anti-patterns) +] +ignore = [ + "E501", # line length โ€” handled by ruff format +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101", "S105", "S106"] # allow assert + hardcoded test credentials + +[tool.ruff.lint.isort] +known-first-party = ["copilot_usage"] +combine-as-imports = true + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.pyright] +typeCheckingMode = "strict" +pythonVersion = "3.12" +reportUnusedFunction = "warning" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --strict-markers --tb=short" + +[tool.coverage.run] +source = ["src/copilot_usage"] +branch = true + +[tool.coverage.report] +fail_under = 80 +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", +] diff --git a/src/copilot_usage/__init__.py b/src/copilot_usage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/copilot_usage/cli.py b/src/copilot_usage/cli.py new file mode 100644 index 0000000..976ee64 --- /dev/null +++ b/src/copilot_usage/cli.py @@ -0,0 +1,434 @@ +"""CLI entry-point for copilot-usage. + +Provides ``summary``, ``session``, ``cost``, and ``live`` commands, +plus an interactive Rich-based session when invoked without a subcommand. +""" + +from __future__ import annotations + +import select +import sys +import threading +import time +from datetime import UTC, datetime +from pathlib import Path + +import click +from rich.console import Console +from rich.table import Table + +from copilot_usage.models import SessionSummary +from copilot_usage.parser import ( + build_session_summary, + discover_sessions, + get_all_sessions, + parse_events, +) +from copilot_usage.report import ( + render_cost_view, + render_full_summary, + render_live_sessions, + render_session_detail, + render_summary, +) + +_DATE_FORMATS = ["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"] + + +def _ensure_aware(dt: datetime | None) -> datetime | None: + """Attach UTC timezone if *dt* is naive (click.DateTime produces naive).""" + if dt is not None and dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt + + +# --------------------------------------------------------------------------- +# Interactive mode helpers +# --------------------------------------------------------------------------- + +_HOME_PROMPT = "\nEnter session # for detail, [c] cost, [r] refresh, [q] quit: " +_BACK_PROMPT = "\nPress Enter to go back... " + + +def _render_session_list(console: Console, sessions: list[SessionSummary]) -> None: + """Print a numbered list of sessions for interactive selection.""" + table = Table(title="Sessions", border_style="cyan") + table.add_column("#", style="bold cyan", justify="right", width=4) + table.add_column("Name", style="bold", max_width=40) + table.add_column("Model") + table.add_column("Status") + + for idx, s in enumerate(sessions, start=1): + name = s.name or s.session_id[:12] + model = s.model or "โ€”" + status = "๐ŸŸข Active" if s.is_active else "Completed" + table.add_row(str(idx), name, model, status) + + console.print(table) + + +def _show_session_by_index( + console: Console, + sessions: list[SessionSummary], + index: int, +) -> None: + """Render session detail for the session at *index* (1-based).""" + if index < 1 or index > len(sessions): + console.print(f"[red]Invalid session number: {index}[/red]") + return + + s = sessions[index - 1] + if s.events_path is None: + console.print("[red]No events path for this session.[/red]") + return + + events = parse_events(s.events_path) + render_session_detail(events, s, target_console=console) + + +def _draw_home(console: Console, sessions: list[SessionSummary]) -> None: + """Clear screen and render the home view.""" + console.clear() + render_full_summary(sessions, target_console=console) + console.print() + _render_session_list(console, sessions) + + +def _write_prompt(prompt: str) -> None: + """Write prompt to stdout without a newline wait.""" + sys.stdout.write(prompt) + sys.stdout.flush() + + +def _read_line_nonblocking(timeout: float = 0.5) -> str | None: + """Return a line from stdin if available within *timeout*, else None.""" + ready, _, _ = select.select([sys.stdin], [], [], timeout) + if ready: + return sys.stdin.readline().strip() + return None + + +class _FileChangeHandler: + """Watchdog handler that triggers refresh on any session-state change.""" + + def __init__(self, change_event: threading.Event) -> None: + self._change_event = change_event + self._last_trigger = 0.0 + + def dispatch(self, event: object) -> None: # noqa: ANN001 + now = time.monotonic() + if now - self._last_trigger > 2.0: # debounce 2s + self._last_trigger = now + self._change_event.set() + + +def _start_observer(session_path: Path, change_event: threading.Event) -> object | None: + """Start a watchdog observer if available. Returns the observer or None.""" + try: + from watchdog.observers import Observer # type: ignore[import-untyped] + + handler = _FileChangeHandler(change_event) + observer = Observer() + observer.schedule(handler, str(session_path), recursive=True) # type: ignore[arg-type] + observer.daemon = True + observer.start() + return observer + except ImportError: + return None + + +def _stop_observer(observer: object | None) -> None: + """Stop a watchdog observer if running.""" + if observer is not None: + stop = getattr(observer, "stop", None) + if callable(stop): + stop() + join = getattr(observer, "join", None) + if callable(join): + join(timeout=2) + + +def _interactive_loop(path: Path | None) -> None: + """Run the interactive Rich session loop with auto-refresh on file changes.""" + console = Console() + session_path = path or Path.home() / ".copilot" / "session-state" + + # File watcher for auto-refresh + change_event = threading.Event() + observer = ( + _start_observer(session_path, change_event) if session_path.exists() else None + ) + + view: str = "home" # "home" | "detail" | "cost" + detail_idx: int | None = None + + sessions = get_all_sessions(path) + _draw_home(console, sessions) + _write_prompt(_HOME_PROMPT) + + try: + while True: + # Auto-refresh on file change + if change_event.is_set(): + change_event.clear() + sessions = get_all_sessions(path) + if view == "home": + _draw_home(console, sessions) + _write_prompt(_HOME_PROMPT) + elif view == "cost": + console.clear() + render_cost_view(sessions, target_console=console) + _write_prompt(_BACK_PROMPT) + elif view == "detail" and detail_idx is not None: + console.clear() + _show_session_by_index(console, sessions, detail_idx) + _write_prompt(_BACK_PROMPT) + + # Non-blocking stdin read + try: + line = _read_line_nonblocking(timeout=0.5) + except (ValueError, OSError): + # stdin not selectable (e.g. testing) โ€” fall back to blocking + try: + line = input().strip() + except (EOFError, KeyboardInterrupt): + break + + if line is None: + continue + + # Sub-view: any input returns home + if view in ("detail", "cost"): + view = "home" + detail_idx = None + sessions = get_all_sessions(path) + _draw_home(console, sessions) + _write_prompt(_HOME_PROMPT) + continue + + # Home view commands + if line in ("q", "Q"): + break + + if line == "": + _write_prompt(_HOME_PROMPT) + continue + + if line in ("c", "C"): + view = "cost" + console.clear() + render_cost_view(sessions, target_console=console) + _write_prompt(_BACK_PROMPT) + continue + + if line in ("r", "R"): + sessions = get_all_sessions(path) + _draw_home(console, sessions) + _write_prompt(_HOME_PROMPT) + continue + + try: + num = int(line) + except ValueError: + console.print(f"[red]Unknown command: {line}[/red]") + _write_prompt(_HOME_PROMPT) + continue + + view = "detail" + detail_idx = num + console.clear() + _show_session_by_index(console, sessions, num) + _write_prompt(_BACK_PROMPT) + + except KeyboardInterrupt: + pass + finally: + _stop_observer(observer) + + +@click.group(invoke_without_command=True) +@click.option( + "--path", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Custom session-state directory.", +) +@click.pass_context +def main(ctx: click.Context, path: Path | None) -> None: + """Copilot CLI usage tracker โ€” parse local session data for token metrics.""" + from copilot_usage.logging_config import setup_logging + + setup_logging() + + ctx.ensure_object(dict) + ctx.obj["path"] = path + + if ctx.invoked_subcommand is None: + _interactive_loop(path) + + +# --------------------------------------------------------------------------- +# summary +# --------------------------------------------------------------------------- + + +@main.command() +@click.option( + "--since", + type=click.DateTime(formats=_DATE_FORMATS), + default=None, + help="Show sessions starting after this date.", +) +@click.option( + "--until", + type=click.DateTime(formats=_DATE_FORMATS), + default=None, + help="Show sessions starting before this date.", +) +@click.option( + "--path", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Custom session-state directory.", +) +@click.pass_context +def summary( + ctx: click.Context, + since: datetime | None, + until: datetime | None, + path: Path | None, +) -> None: + """Show usage summary across all sessions.""" + path = path or ctx.obj.get("path") + try: + sessions = get_all_sessions(path) + render_summary(sessions, since=_ensure_aware(since), until=_ensure_aware(until)) + except Exception as exc: # noqa: BLE001 + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# session +# --------------------------------------------------------------------------- + + +@main.command() +@click.argument("session_id") +@click.option( + "--path", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Custom session-state directory.", +) +@click.pass_context +def session(ctx: click.Context, session_id: str, path: Path | None) -> None: + """Show detailed usage for a specific session.""" + path = path or ctx.obj.get("path") + try: + event_paths = discover_sessions(path) + if not event_paths: + click.echo("No sessions found.", err=True) + sys.exit(1) + + # Parse all sessions and find the one matching by prefix + available: list[str] = [] + for events_path in event_paths: + events = parse_events(events_path) + if not events: + continue + s = build_session_summary(events, session_dir=events_path.parent) + if s.session_id.startswith(session_id): + render_session_detail(events, s) + return + if s.session_id: + available.append(s.session_id[:8]) + + click.echo(f"Error: no session matching '{session_id}'", err=True) + if available: + click.echo(f"Available: {', '.join(available)}", err=True) + sys.exit(1) + except SystemExit: + raise + except Exception as exc: # noqa: BLE001 + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# cost +# --------------------------------------------------------------------------- + + +@main.command() +@click.option( + "--since", + type=click.DateTime(formats=_DATE_FORMATS), + default=None, + help="Show sessions starting after this date.", +) +@click.option( + "--until", + type=click.DateTime(formats=_DATE_FORMATS), + default=None, + help="Show sessions starting before this date.", +) +@click.option( + "--path", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Custom session-state directory.", +) +@click.pass_context +def cost( + ctx: click.Context, + since: datetime | None, + until: datetime | None, + path: Path | None, +) -> None: + """Show premium request costs from shutdown data.""" + path = path or ctx.obj.get("path") + try: + sessions = get_all_sessions(path) + + # Filter by date range + since_aware = _ensure_aware(since) + until_aware = _ensure_aware(until) + filtered = sessions + if since_aware is not None or until_aware is not None: + filtered = [ + s + for s in sessions + if s.start_time is not None + and (since_aware is None or s.start_time >= since_aware) + and (until_aware is None or s.start_time <= until_aware) + ] + + render_cost_view(filtered) + except Exception as exc: # noqa: BLE001 + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + +# --------------------------------------------------------------------------- +# live +# --------------------------------------------------------------------------- + + +@main.command() +@click.option( + "--path", + type=click.Path(exists=True, path_type=Path), + default=None, + help="Custom session-state directory.", +) +@click.pass_context +def live(ctx: click.Context, path: Path | None) -> None: + """Show usage for active sessions.""" + path = path or ctx.obj.get("path") + try: + sessions = get_all_sessions(path) + render_live_sessions(sessions) + except Exception as exc: # noqa: BLE001 + click.echo(f"Error: {exc}", err=True) + sys.exit(1) diff --git a/src/copilot_usage/logging_config.py b/src/copilot_usage/logging_config.py new file mode 100644 index 0000000..fd7c741 --- /dev/null +++ b/src/copilot_usage/logging_config.py @@ -0,0 +1,39 @@ +"""Logging configuration โ€” console-only for CLI tool.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +from loguru import logger + +if TYPE_CHECKING: + from loguru import Record + +LEVEL_EMOJI: dict[str, str] = { + "TRACE": "๐Ÿ”", + "DEBUG": "๐Ÿ›", + "INFO": "โ„น๏ธ ", + "SUCCESS": "โœ…", + "WARNING": "โš ๏ธ ", + "ERROR": "โŒ", + "CRITICAL": "๐Ÿ”ฅ", +} + +CONSOLE_FORMAT = ( + "{time:HH:mm:ss} " + "{extra[emoji]} " + "{level:<7} " + "{message}" +) + + +def _emoji_patcher(record: Record) -> None: + record["extra"]["emoji"] = LEVEL_EMOJI.get(record["level"].name, " ") + + +def setup_logging() -> None: + """Configure loguru for CLI use: stderr only, WARNING level.""" + logger.remove() + logger.configure(patcher=_emoji_patcher) + logger.add(sys.stderr, format=CONSOLE_FORMAT, level="WARNING", colorize=True) diff --git a/src/copilot_usage/models.py b/src/copilot_usage/models.py new file mode 100644 index 0000000..700bbc6 --- /dev/null +++ b/src/copilot_usage/models.py @@ -0,0 +1,255 @@ +"""Pydantic v2 models for parsing Copilot CLI session events. + +Each line in ~/.copilot/session-state/*/events.jsonl is a JSON event. +These models provide typed parsing for all known event types plus a +flexible fallback for unknown ones. +""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from pathlib import Path + +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class EventType(StrEnum): + """Known Copilot CLI event types.""" + + SESSION_START = "session.start" + SESSION_SHUTDOWN = "session.shutdown" + SESSION_RESUME = "session.resume" + SESSION_ERROR = "session.error" + SESSION_PLAN_CHANGED = "session.plan_changed" + SESSION_WORKSPACE_FILE_CHANGED = "session.workspace_file_changed" + ASSISTANT_MESSAGE = "assistant.message" + ASSISTANT_TURN_START = "assistant.turn_start" + ASSISTANT_TURN_END = "assistant.turn_end" + TOOL_EXECUTION_START = "tool.execution_start" + TOOL_EXECUTION_COMPLETE = "tool.execution_complete" + USER_MESSAGE = "user.message" + ABORT = "abort" + + +# --------------------------------------------------------------------------- +# Shared / nested models +# --------------------------------------------------------------------------- + + +class SessionContext(BaseModel): + """Context attached to a session.start event.""" + + cwd: str | None = None + + +class TokenUsage(BaseModel): + """Token usage breakdown for a single model.""" + + inputTokens: int = 0 + outputTokens: int = 0 + cacheReadTokens: int = 0 + cacheWriteTokens: int = 0 + + +class RequestMetrics(BaseModel): + """Request count and cost for a single model.""" + + count: int = 0 + cost: int = 0 + + +class ModelMetrics(BaseModel): + """Combined request + usage metrics for one model.""" + + requests: RequestMetrics = Field(default_factory=RequestMetrics) + usage: TokenUsage = Field(default_factory=TokenUsage) + + +class CodeChanges(BaseModel): + """Codeโ€change stats from a session.shutdown event.""" + + linesAdded: int = 0 + linesRemoved: int = 0 + filesModified: list[str] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Event data payloads +# --------------------------------------------------------------------------- + + +class SessionStartData(BaseModel): + """Payload for ``session.start`` events.""" + + sessionId: str + version: int = 1 + producer: str = "" + copilotVersion: str = "" + startTime: datetime | None = None + context: SessionContext = Field(default_factory=SessionContext) + + +class AssistantMessageData(BaseModel): + """Payload for ``assistant.message`` events.""" + + messageId: str = "" + content: str = "" + outputTokens: int = 0 + interactionId: str = "" + reasoningText: str | None = None + reasoningOpaque: str | None = None + toolRequests: list[dict[str, object]] = Field( + default_factory=lambda: list[dict[str, object]]() + ) + + +class SessionShutdownData(BaseModel): + """Payload for ``session.shutdown`` events.""" + + shutdownType: str = "" + totalPremiumRequests: int = 0 + totalApiDurationMs: int = 0 + sessionStartTime: int = 0 + codeChanges: CodeChanges | None = None + modelMetrics: dict[str, ModelMetrics] = Field(default_factory=dict) + currentModel: str | None = None + + +class ToolTelemetry(BaseModel): + """Telemetry attached to tool execution events.""" + + properties: dict[str, str] = Field(default_factory=dict) + + +class ToolExecutionData(BaseModel): + """Payload for ``tool.execution_complete`` events.""" + + toolCallId: str = "" + model: str | None = None + interactionId: str | None = None + success: bool = False + toolTelemetry: ToolTelemetry | None = None + + +class UserMessageData(BaseModel): + """Payload for ``user.message`` events.""" + + content: str = "" + transformedContent: str | None = None + attachments: list[str] = Field(default_factory=list) + interactionId: str | None = None + + +# --------------------------------------------------------------------------- +# Generic / fallback data (for events we don't model in detail) +# --------------------------------------------------------------------------- + + +class GenericEventData(BaseModel, extra="allow"): + """Catchโ€all payload for event types not yet modeled explicitly.""" + + +# --------------------------------------------------------------------------- +# Typed event helpers +# --------------------------------------------------------------------------- + + +EventData = ( + SessionStartData + | AssistantMessageData + | SessionShutdownData + | ToolExecutionData + | UserMessageData + | GenericEventData +) + + +# --------------------------------------------------------------------------- +# Event envelope +# --------------------------------------------------------------------------- + + +class SessionEvent(BaseModel): + """A single event from an ``events.jsonl`` file. + + ``data`` is kept as a generic dict-like object; callers can use the + helper ``parsed_data`` property (or ``parse_data()``) to get a typed + payload when needed. + """ + + type: str + data: dict[str, object] = Field(default_factory=dict) + id: str | None = None + timestamp: datetime | None = None + parentId: str | None = None + # session.shutdown has currentModel at the top level + currentModel: str | None = None + + def parse_data(self) -> EventData: + """Return a strongly-typed data payload based on ``self.type``.""" + match self.type: + case EventType.SESSION_START: + return SessionStartData.model_validate(self.data) + case EventType.ASSISTANT_MESSAGE: + return AssistantMessageData.model_validate(self.data) + case EventType.SESSION_SHUTDOWN: + return SessionShutdownData.model_validate(self.data) + case EventType.TOOL_EXECUTION_COMPLETE: + return ToolExecutionData.model_validate(self.data) + case EventType.USER_MESSAGE: + return UserMessageData.model_validate(self.data) + case _: + return GenericEventData.model_validate(self.data) + + +# --------------------------------------------------------------------------- +# EventBase โ€” common fields only (useful for filtering / indexing) +# --------------------------------------------------------------------------- + + +class EventBase(BaseModel): + """Common envelope fields shared by every event.""" + + type: str + id: str | None = None + timestamp: datetime | None = None + parentId: str | None = None + + +# --------------------------------------------------------------------------- +# Session summary (aggregated from all events in one session) +# --------------------------------------------------------------------------- + + +class SessionSummary(BaseModel): + """Aggregated data across all events in a single session. + + Populated by a parser that walks the ``events.jsonl`` file; not + parsed directly from JSON. + """ + + session_id: str + start_time: datetime | None = None + end_time: datetime | None = None + name: str | None = None + cwd: str | None = None + model: str | None = None + total_premium_requests: int = 0 + total_api_duration_ms: int = 0 + model_metrics: dict[str, ModelMetrics] = Field(default_factory=dict) + code_changes: CodeChanges | None = None + model_calls: int = 0 + user_messages: int = 0 + last_resume_time: datetime | None = None + is_active: bool = False + events_path: Path | None = None + + # Post-shutdown activity (only populated for resumed/active sessions) + active_model_calls: int = 0 + active_user_messages: int = 0 + active_output_tokens: int = 0 diff --git a/src/copilot_usage/parser.py b/src/copilot_usage/parser.py new file mode 100644 index 0000000..022bfa7 --- /dev/null +++ b/src/copilot_usage/parser.py @@ -0,0 +1,395 @@ +"""Event parser for Copilot CLI session data. + +Discovers session directories, parses ``events.jsonl`` files into typed +:class:`SessionEvent` objects, and builds per-session :class:`SessionSummary` +aggregates. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from loguru import logger +from pydantic import ValidationError + +from copilot_usage.models import ( + CodeChanges, + EventType, + ModelMetrics, + RequestMetrics, + SessionEvent, + SessionShutdownData, + SessionStartData, + SessionSummary, + TokenUsage, + ToolExecutionData, +) + +_DEFAULT_BASE: Path = Path.home() / ".copilot" / "session-state" +_CONFIG_PATH: Path = Path.home() / ".copilot" / "config.json" + + +def _infer_model_from_metrics(metrics: dict[str, ModelMetrics]) -> str | None: + """Pick a model name from *metrics* when ``currentModel`` is absent. + + If there is exactly one key, return it. With multiple keys, return + the one with the highest ``requests.count``. + """ + if not metrics: + return None + if len(metrics) == 1: + return next(iter(metrics)) + return max(metrics, key=lambda m: metrics[m].requests.count) + + +def _read_config_model(config_path: Path | None = None) -> str | None: + """Read the active model from ``~/.copilot/config.json``.""" + path = config_path or _CONFIG_PATH + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + model = data.get("model") + return model if isinstance(model, str) else None + except (json.JSONDecodeError, OSError): + return None + + +# --------------------------------------------------------------------------- +# Discovery +# --------------------------------------------------------------------------- + + +def _safe_mtime(path: Path) -> float: + """Return *path*'s mtime, or ``0`` if the file no longer exists.""" + try: + return path.stat().st_mtime + except FileNotFoundError: + return 0.0 + + +def discover_sessions(base_path: Path | None = None) -> list[Path]: + """Find all session directories containing events.jsonl. + + Default *base_path*: ``~/.copilot/session-state/`` + + Returns list of paths to ``events.jsonl`` files, sorted by + modification time (newest first). + + Tolerates directories deleted between the glob and the stat call + (TOCTOU race) by assigning mtime 0 to vanished paths. + """ + root = base_path or _DEFAULT_BASE + if not root.is_dir(): + return [] + return sorted( + root.glob("*/events.jsonl"), + key=_safe_mtime, + reverse=True, + ) + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def parse_events(events_path: Path) -> list[SessionEvent]: + """Parse an ``events.jsonl`` file into a list of :class:`SessionEvent`. + + Lines that fail JSON decoding or Pydantic validation are skipped with + a warning. + """ + events: list[SessionEvent] = [] + with events_path.open(encoding="utf-8") as fh: + for lineno, line in enumerate(fh, start=1): + stripped = line.strip() + if not stripped: + continue + try: + raw = json.loads(stripped) + except json.JSONDecodeError: + logger.warning("{}:{} โ€” malformed JSON, skipping", events_path, lineno) + continue + try: + events.append(SessionEvent.model_validate(raw)) + except ValidationError as exc: + logger.warning( + "{}:{} โ€” validation error ({}), skipping", + events_path, + lineno, + exc.error_count(), + ) + return events + + +# --------------------------------------------------------------------------- +# Session name extraction +# --------------------------------------------------------------------------- + + +def _extract_session_name(session_dir: Path) -> str | None: + """Try to read a session name from ``plan.md`` in *session_dir*.""" + plan = session_dir / "plan.md" + if not plan.is_file(): + return None + try: + first_line = plan.read_text(encoding="utf-8").split("\n", maxsplit=1)[0] + if first_line.startswith("# "): + return first_line.removeprefix("# ").strip() + except OSError: + pass + return None + + +# --------------------------------------------------------------------------- +# Summary builder +# --------------------------------------------------------------------------- + + +def build_session_summary( + events: list[SessionEvent], + *, + session_dir: Path | None = None, + config_path: Path | None = None, +) -> SessionSummary: + """Build a :class:`SessionSummary` from parsed events. + + Reports raw facts only โ€” no estimation or multiplier-based + calculations. + + For **completed** sessions (``session.shutdown`` as last meaningful + event): + * Uses shutdown data directly (totalPremiumRequests, modelMetrics, โ€ฆ). + + For **resumed** sessions (events after the last + ``session.shutdown``): + * Uses the shutdown's modelMetrics as a baseline. + * Adds ``outputTokens`` from post-shutdown ``assistant.message`` + events. + * Sets ``is_active = True``. + + For **active** sessions (no shutdown at all): + * Sums ``outputTokens`` from individual ``assistant.message`` + events. + * Reads model from ``~/.copilot/config.json`` when not found in + events. + * Sets ``is_active = True``. + + If *session_dir* is given the session name is extracted from + ``plan.md`` when present. + """ + session_id = "" + start_time = None + end_time = None + cwd: str | None = None + model: str | None = None + all_shutdowns: list[tuple[int, SessionShutdownData, str | None]] = [] + user_message_count = 0 + total_output_tokens = 0 + model_token_map: dict[str, int] = {} + total_turn_starts = 0 + + for idx, ev in enumerate(events): + # -- session.start ------------------------------------------------ + if ev.type == EventType.SESSION_START: + try: + data = ev.parse_data() + except ValidationError: + continue + if isinstance(data, SessionStartData): + session_id = data.sessionId + start_time = data.startTime + cwd = data.context.cwd + + # -- session.shutdown --------------------------------------------- + elif ev.type == EventType.SESSION_SHUTDOWN: + try: + data = ev.parse_data() + except ValidationError: + continue + if isinstance(data, SessionShutdownData): + current_model = ev.currentModel or data.currentModel + if not current_model and data.modelMetrics: + current_model = _infer_model_from_metrics(data.modelMetrics) + all_shutdowns.append((idx, data, current_model)) + end_time = ev.timestamp + model = current_model + + # -- user.message ------------------------------------------------- + elif ev.type == EventType.USER_MESSAGE: + user_message_count += 1 + + # -- assistant.turn_start ----------------------------------------- + elif ev.type == EventType.ASSISTANT_TURN_START: + total_turn_starts += 1 + + # -- assistant.message -------------------------------------------- + elif ev.type == EventType.ASSISTANT_MESSAGE: + raw_tokens = ev.data.get("outputTokens") + if isinstance(raw_tokens, int): + total_output_tokens += raw_tokens + + # Derive name + name = _extract_session_name(session_dir) if session_dir else None + + # --- Detect resumed session (events after last shutdown) -------------- + _RESUME_INDICATOR_TYPES: set[str] = { + EventType.SESSION_RESUME, + EventType.USER_MESSAGE, + EventType.ASSISTANT_MESSAGE, + } + + session_resumed = False + post_shutdown_output_tokens = 0 + post_shutdown_turn_starts = 0 + post_shutdown_user_messages = 0 + last_resume_time = None + + last_shutdown_idx = all_shutdowns[-1][0] if all_shutdowns else -1 + + if all_shutdowns and last_shutdown_idx >= 0: + for ev in events[last_shutdown_idx + 1 :]: + if ev.type in _RESUME_INDICATOR_TYPES: + session_resumed = True + if ev.type == EventType.SESSION_RESUME and ev.timestamp is not None: + last_resume_time = ev.timestamp + if ev.type == EventType.ASSISTANT_MESSAGE: + raw_tokens = ev.data.get("outputTokens") + if isinstance(raw_tokens, int): + post_shutdown_output_tokens += raw_tokens + if ev.type == EventType.ASSISTANT_TURN_START: + post_shutdown_turn_starts += 1 + if ev.type == EventType.USER_MESSAGE: + post_shutdown_user_messages += 1 + + # --- completed or resumed session ------------------------------------ + if all_shutdowns: + # Sum across ALL shutdown cycles + total_premium = 0 + total_api_duration = 0 + merged_metrics: dict[str, ModelMetrics] = {} + last_code_changes: CodeChanges | None = None + + for _idx, sd, _m in all_shutdowns: + total_premium += sd.totalPremiumRequests + total_api_duration += sd.totalApiDurationMs + if sd.codeChanges is not None: + last_code_changes = sd.codeChanges + for model_name, metrics in sd.modelMetrics.items(): + if model_name in merged_metrics: + existing = merged_metrics[model_name] + merged_metrics[model_name] = ModelMetrics( + requests=RequestMetrics( + count=existing.requests.count + metrics.requests.count, + cost=existing.requests.cost + metrics.requests.cost, + ), + usage=TokenUsage( + inputTokens=existing.usage.inputTokens + + metrics.usage.inputTokens, + outputTokens=existing.usage.outputTokens + + metrics.usage.outputTokens, + cacheReadTokens=existing.usage.cacheReadTokens + + metrics.usage.cacheReadTokens, + cacheWriteTokens=existing.usage.cacheWriteTokens + + metrics.usage.cacheWriteTokens, + ), + ) + else: + merged_metrics[model_name] = metrics + + return SessionSummary( + session_id=session_id, + start_time=start_time, + end_time=None if session_resumed else end_time, + name=name, + cwd=cwd, + model=model, + total_premium_requests=total_premium, + total_api_duration_ms=total_api_duration, + model_metrics=merged_metrics, + code_changes=last_code_changes, + model_calls=total_turn_starts, + user_messages=user_message_count, + is_active=session_resumed, + last_resume_time=last_resume_time, + active_model_calls=post_shutdown_turn_starts, + active_user_messages=post_shutdown_user_messages, + active_output_tokens=post_shutdown_output_tokens, + ) + + # --- active session (no shutdown) ------------------------------------ + # Try to determine model from tool.execution_complete events + for ev in events: + if ev.type == EventType.TOOL_EXECUTION_COMPLETE: + try: + parsed = ev.parse_data() + except ValidationError: + continue + if isinstance(parsed, ToolExecutionData) and parsed.model: + model = parsed.model + break + + # Fall back to ~/.copilot/config.json for active sessions + if model is None: + model = _read_config_model(config_path) + + if model and total_output_tokens: + model_token_map[model] = total_output_tokens + + active_metrics: dict[str, ModelMetrics] = {} + for m, tokens in model_token_map.items(): + active_metrics[m] = ModelMetrics( + usage=TokenUsage(outputTokens=tokens), + ) + + return SessionSummary( + session_id=session_id, + start_time=start_time, + end_time=end_time, + name=name, + cwd=cwd, + model=model, + total_premium_requests=0, + total_api_duration_ms=0, + model_metrics=active_metrics, + code_changes=None, + model_calls=total_turn_starts, + user_messages=user_message_count, + is_active=True, + ) + + +# --------------------------------------------------------------------------- +# Convenience +# --------------------------------------------------------------------------- + + +def get_all_sessions(base_path: Path | None = None) -> list[SessionSummary]: + """Discover โ†’ parse โ†’ build summary for every session. + + Returns list sorted by ``start_time`` (newest first). Sessions + without a ``start_time`` sort last. + """ + paths = discover_sessions(base_path) + summaries: list[SessionSummary] = [] + for events_path in paths: + try: + events = parse_events(events_path) + except (FileNotFoundError, OSError) as exc: + logger.warning("Skipping vanished session %s: %s", events_path, exc) + continue + if not events: + continue + summary = build_session_summary(events, session_dir=events_path.parent) + summary.events_path = events_path + summaries.append(summary) + + def _sort_key(s: SessionSummary) -> str: + if s.start_time is None: + return "" + return s.start_time.isoformat() + + summaries.sort(key=_sort_key, reverse=True) + return summaries diff --git a/src/copilot_usage/pricing.py b/src/copilot_usage/pricing.py new file mode 100644 index 0000000..03ef94a --- /dev/null +++ b/src/copilot_usage/pricing.py @@ -0,0 +1,151 @@ +"""Model pricing data and premium-request cost estimation. + +GitHub Copilot charges different premium-request multipliers depending on the +AI model used. This module provides: + +* A ``ModelPricing`` Pydantic model for per-model pricing metadata. +* A registry of known multipliers (easy to update in one place). +* Lookup helpers that handle exact matches, partial matches, and unknown models. +* A cost-estimation function that works with ``SessionSummary.model_metrics``. +""" + +from __future__ import annotations + +import warnings +from enum import StrEnum +from typing import Final + +from pydantic import BaseModel + +__all__: list[str] = [ + "ModelPricing", + "PricingTier", + "KNOWN_PRICING", + "lookup_model_pricing", + "categorize_model", +] + + +# --------------------------------------------------------------------------- +# Tier enum +# --------------------------------------------------------------------------- + + +class PricingTier(StrEnum): + """Broad pricing tiers for Copilot models.""" + + PREMIUM = "premium" + STANDARD = "standard" + LIGHT = "light" + + +# --------------------------------------------------------------------------- +# Pydantic model +# --------------------------------------------------------------------------- + + +class ModelPricing(BaseModel): + """Pricing metadata for a single AI model.""" + + model_name: str + multiplier: float = 1.0 + tier: PricingTier = PricingTier.STANDARD + + +# --------------------------------------------------------------------------- +# Known pricing registry โ€” edit this dict to update multipliers. +# --------------------------------------------------------------------------- + + +def _tier_from_multiplier(m: float) -> PricingTier: + if m >= 3.0: + return PricingTier.PREMIUM + if m < 1.0: + return PricingTier.LIGHT + return PricingTier.STANDARD + + +_RAW_MULTIPLIERS: Final[dict[str, float]] = { + # Claude ----------------------------------------------------------------- + "claude-sonnet-4.6": 1.0, + "claude-sonnet-4.5": 1.0, + "claude-sonnet-4": 1.0, + "claude-opus-4.6": 3.0, + "claude-opus-4.6-1m": 6.0, + "claude-opus-4.5": 3.0, + "claude-haiku-4.5": 0.33, + # GPT -------------------------------------------------------------------- + "gpt-5.4": 1.0, + "gpt-5.2": 1.0, + "gpt-5.1": 1.0, + "gpt-5.1-codex": 1.0, + "gpt-5.2-codex": 1.0, + "gpt-5.3-codex": 1.0, + "gpt-5.1-codex-max": 1.0, + "gpt-5.1-codex-mini": 0.33, + "gpt-5-mini": 0.0, + "gpt-4.1": 0.0, + # Gemini ----------------------------------------------------------------- + "gemini-3-pro-preview": 1.0, +} + +KNOWN_PRICING: Final[dict[str, ModelPricing]] = { + name: ModelPricing( + model_name=name, + multiplier=mult, + tier=_tier_from_multiplier(mult), + ) + for name, mult in _RAW_MULTIPLIERS.items() +} + +# --------------------------------------------------------------------------- +# Lookup helpers +# --------------------------------------------------------------------------- + + +def lookup_model_pricing(model_name: str) -> ModelPricing: + """Return ``ModelPricing`` for *model_name*. + + Resolution order: + + 1. Exact match in ``KNOWN_PRICING``. + 2. Partial match โ€” *model_name* starts with a known key, or a known key + starts with *model_name*. + 3. Fallback โ€” returns a 1ร— standard entry and emits a + :class:`UserWarning`. + """ + # 1. Exact + if model_name in KNOWN_PRICING: + return KNOWN_PRICING[model_name] + + # 2. Partial (longest matching key wins to avoid false positives) + best: ModelPricing | None = None + best_len = 0 + for key, pricing in KNOWN_PRICING.items(): + if model_name.startswith(key) or key.startswith(model_name): + match_len = min(len(key), len(model_name)) + if match_len > best_len: + best = pricing + best_len = match_len + + if best is not None: + return ModelPricing( + model_name=model_name, + multiplier=best.multiplier, + tier=best.tier, + ) + + # 3. Unknown + warnings.warn( + f"Unknown model '{model_name}'; assuming 1ร— standard pricing.", + UserWarning, + stacklevel=2, + ) + return ModelPricing( + model_name=model_name, multiplier=1.0, tier=PricingTier.STANDARD + ) + + +def categorize_model(model_name: str) -> PricingTier: + """Return the pricing tier for *model_name*.""" + return lookup_model_pricing(model_name).tier diff --git a/src/copilot_usage/py.typed b/src/copilot_usage/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/copilot_usage/report.py b/src/copilot_usage/report.py new file mode 100644 index 0000000..a92ebf6 --- /dev/null +++ b/src/copilot_usage/report.py @@ -0,0 +1,982 @@ +"""Rendering helpers for Copilot CLI session data. + +Uses Rich tables and panels to display session information in +the terminal. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from copilot_usage.models import ( + AssistantMessageData, + CodeChanges, + EventType, + ModelMetrics, + SessionEvent, + SessionShutdownData, + SessionSummary, + ToolExecutionData, + UserMessageData, +) + +__all__ = [ + "format_duration", + "format_tokens", + "render_cost_view", + "render_full_summary", + "render_live_sessions", + "render_session_detail", + "render_summary", +] + +_MAX_CONTENT_LEN = 80 + + +def format_tokens(n: int) -> str: + """Format token count with K/M suffix. + + Examples: + >>> format_tokens(1627935) + '1.6M' + >>> format_tokens(16655) + '16.7K' + >>> format_tokens(500) + '500' + >>> format_tokens(0) + '0' + """ + if n >= 1_000_000: + return f"{n / 1_000_000:.1f}M" + if n >= 1_000: + return f"{n / 1_000:.1f}K" + return str(n) + + +def format_duration(ms: int) -> str: + """Format milliseconds to human-readable duration. + + Examples: + >>> format_duration(389114) + '6m 29s' + >>> format_duration(5000) + '5s' + >>> format_duration(0) + '0s' + >>> format_duration(3661000) + '1h 1m 1s' + """ + if ms <= 0: + return "0s" + total_seconds = ms // 1000 + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + seconds = total_seconds % 60 + + parts: list[str] = [] + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if seconds or not parts: + parts.append(f"{seconds}s") + return " ".join(parts) + + +def _format_duration(start: datetime) -> str: + """Return a human-readable duration from *start* to now. + + Formats as ``Xh Ym`` when >= 1 hour, otherwise ``Ym Zs``. + """ + now = datetime.now(tz=UTC) + # Ensure start is timezone-aware for subtraction + start_aware = start.replace(tzinfo=UTC) if start.tzinfo is None else start + delta = now - start_aware + total_seconds = max(int(delta.total_seconds()), 0) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m {seconds}s" + + +def _estimated_output_tokens(session: SessionSummary) -> int: + """Sum outputTokens across all models in *session.model_metrics*.""" + return sum(m.usage.outputTokens for m in session.model_metrics.values()) + + +def render_live_sessions(sessions: list[SessionSummary]) -> None: + """Render overview of active sessions only. + + Filters to ``is_active=True`` sessions. + Shows running time as ``Xh Ym`` or ``Ym Zs``. + """ + console = Console() + + active = [s for s in sessions if s.is_active] + + if not active: + console.print( + Panel( + "No active Copilot sessions found", + title="Live Sessions", + border_style="dim", + ) + ) + return + + table = Table(title="๐ŸŸข Active Copilot Sessions") + table.add_column("Session ID", style="cyan", no_wrap=True) + table.add_column("Name", style="green") + table.add_column("Model", style="magenta") + table.add_column("Running", style="yellow", justify="right") + table.add_column("Messages", style="blue", justify="right") + table.add_column("Output Tokens", style="red", justify="right") + table.add_column("CWD", style="dim") + + for s in active: + short_id = s.session_id[:8] if s.session_id else "โ€”" + name = s.name or "โ€”" + model = s.model or "โ€”" + running = ( + _format_duration(s.last_resume_time or s.start_time) + if s.start_time + else "โ€”" + ) + messages = str(s.user_messages) + tokens = f"{_estimated_output_tokens(s):,}" + cwd = s.cwd or "โ€”" + + table.add_row( + f"๐ŸŸข {short_id}", + name, + model, + running, + messages, + tokens, + cwd, + ) + + console.print(table) + + +# --------------------------------------------------------------------------- +# Session detail helpers +# --------------------------------------------------------------------------- + + +def _format_relative_time(delta: timedelta) -> str: + """Format a timedelta as ``+M:SS`` or ``+H:MM:SS``.""" + total_seconds = max(int(delta.total_seconds()), 0) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + if hours: + return f"+{hours}:{minutes:02d}:{seconds:02d}" + return f"+{minutes}:{seconds:02d}" + + +def _truncate(text: str, max_len: int = _MAX_CONTENT_LEN) -> str: + """Truncate *text* to *max_len* characters, appending 'โ€ฆ' if needed.""" + if len(text) <= max_len: + return text + return text[: max_len - 1] + "โ€ฆ" + + +def _format_detail_duration( + start: datetime | None, + end: datetime | None, +) -> str: + """Return a human-readable duration string between two timestamps.""" + if start is None or end is None: + return "โ€”" + delta = end - start + total_seconds = max(int(delta.total_seconds()), 0) + if total_seconds < 60: + return f"{total_seconds}s" + minutes, seconds = divmod(total_seconds, 60) + if minutes < 60: + return f"{minutes}m {seconds}s" + hours, minutes = divmod(minutes, 60) + return f"{hours}h {minutes}m" + + +def _event_type_label(event_type: str) -> Text: + """Return a colour-coded :class:`Text` label for *event_type*.""" + match event_type: + case EventType.USER_MESSAGE: + return Text("user message", style="bold blue") + case EventType.ASSISTANT_MESSAGE: + return Text("assistant", style="bold green") + case EventType.TOOL_EXECUTION_COMPLETE: + return Text("tool", style="bold yellow") + case EventType.TOOL_EXECUTION_START: + return Text("tool start", style="yellow") + case EventType.ASSISTANT_TURN_START: + return Text("turn start", style="green") + case EventType.ASSISTANT_TURN_END: + return Text("turn end", style="green") + case EventType.SESSION_START: + return Text("session start", style="bold cyan") + case EventType.SESSION_SHUTDOWN: + return Text("session end", style="bold cyan") + case _: + return Text(event_type, style="dim") + + +def _build_event_details(ev: SessionEvent) -> str: + """Build a one-line detail string for a timeline row.""" + match ev.type: + case EventType.USER_MESSAGE: + data = ev.parse_data() + if isinstance(data, UserMessageData) and data.content: + return _truncate(data.content) + return "" + + case EventType.ASSISTANT_MESSAGE: + data = ev.parse_data() + if isinstance(data, AssistantMessageData): + parts: list[str] = [] + if data.outputTokens: + parts.append(f"tokens={data.outputTokens}") + if data.content: + parts.append(_truncate(data.content, 60)) + return " ".join(parts) + return "" + + case EventType.TOOL_EXECUTION_COMPLETE: + data = ev.parse_data() + if isinstance(data, ToolExecutionData): + parts_t: list[str] = [] + tool_name = _extract_tool_name(data) + if tool_name: + parts_t.append(tool_name) + parts_t.append("โœ“" if data.success else "โœ—") + if data.model: + parts_t.append(f"model={data.model}") + return " ".join(parts_t) + return "" + + case EventType.SESSION_SHUTDOWN: + data = ev.parse_data() + if isinstance(data, SessionShutdownData): + return f"type={data.shutdownType}" if data.shutdownType else "" + return "" + + case _: + return "" + + +def _extract_tool_name(data: ToolExecutionData) -> str: + """Try to extract a human-readable tool name from telemetry.""" + if data.toolTelemetry and data.toolTelemetry.properties: + return data.toolTelemetry.properties.get("tool_name", "") + return "" + + +# --------------------------------------------------------------------------- +# Header / aggregate / shutdown-cycle helpers for session detail +# --------------------------------------------------------------------------- + + +def _render_header( + summary: SessionSummary, + *, + target_console: Console | None = None, +) -> None: + """Print a Rich panel with session metadata.""" + out = target_console or Console() + + status = "[green]active[/green]" if summary.is_active else "[dim]completed[/dim]" + start_str = ( + summary.start_time.strftime("%Y-%m-%d %H:%M:%S") if summary.start_time else "โ€”" + ) + duration = _format_detail_duration(summary.start_time, summary.end_time) + name = summary.name or "unnamed" + + content = ( + f"[bold]Session:[/bold] {summary.session_id}\n" + f"[bold]Name:[/bold] {name}\n" + f"[bold]Model:[/bold] {summary.model or 'โ€”'}\n" + f"[bold]Status:[/bold] {status}\n" + f"[bold]Started:[/bold] {start_str}\n" + f"[bold]Duration:[/bold] {duration}" + ) + out.print(Panel(content, title="Session Detail", border_style="blue")) + + +def _render_aggregate_stats( + summary: SessionSummary, + *, + target_console: Console | None = None, +) -> None: + """Print aggregate stats panel (model calls, user msgs, tokens, premium).""" + out = target_console or Console() + + total_output = sum(mm.usage.outputTokens for mm in summary.model_metrics.values()) + + lines = [ + f"[green]{summary.model_calls}[/green] model calls " + f"[green]{summary.user_messages}[/green] user messages " + f"[green]{format_tokens(total_output)}[/green] output tokens", + f"[green]{summary.total_premium_requests}[/green] premium requests " + f"[green]{format_duration(summary.total_api_duration_ms)}[/green] API duration", + ] + out.print(Panel("\n".join(lines), title="Aggregate Stats", border_style="cyan")) + + +def _render_shutdown_cycles( + events: list[SessionEvent], + *, + target_console: Console | None = None, +) -> None: + """Render per-shutdown-cycle table from session events.""" + out = target_console or Console() + + shutdown_events: list[SessionShutdownData] = [] + shutdown_timestamps: list[datetime | None] = [] + for ev in events: + if ev.type == EventType.SESSION_SHUTDOWN: + data = ev.parse_data() + if isinstance(data, SessionShutdownData): + shutdown_events.append(data) + shutdown_timestamps.append(ev.timestamp) + + if not shutdown_events: + out.print("[dim]No shutdown cycles recorded.[/dim]") + return + + table = Table(title="Shutdown Cycles", border_style="cyan") + table.add_column("Date", style="cyan") + table.add_column("Premium Req", justify="right", style="green") + table.add_column("Model Calls", justify="right") + table.add_column("Output Tokens", justify="right") + table.add_column("API Duration", justify="right") + + for sd, ts in zip(shutdown_events, shutdown_timestamps, strict=True): + date_str = ts.strftime("%Y-%m-%d %H:%M") if ts else "โ€”" + total_requests = sum(mm.requests.count for mm in sd.modelMetrics.values()) + total_output = sum(mm.usage.outputTokens for mm in sd.modelMetrics.values()) + table.add_row( + date_str, + str(sd.totalPremiumRequests), + str(total_requests), + format_tokens(total_output), + format_duration(sd.totalApiDurationMs), + ) + + out.print(table) + + +def _render_active_period( + summary: SessionSummary, + *, + target_console: Console | None = None, +) -> None: + """Show model calls / messages / tokens since last shutdown (if active).""" + out = target_console or Console() + + if not summary.is_active: + return + + content = ( + f"[green]{summary.active_model_calls}[/green] model calls " + f"[green]{summary.active_user_messages}[/green] user messages " + f"[green]{format_tokens(summary.active_output_tokens)}[/green] output tokens" + ) + out.print( + Panel( + content, + title="๐ŸŸข Active Period (since last shutdown)", + border_style="green", + ) + ) + + +def _render_recent_events( + events: list[SessionEvent], + session_start: datetime, + *, + target_console: Console | None = None, + max_events: int = 10, +) -> None: + """Show the most recent *max_events* events with timestamp, type, brief info.""" + out = target_console or Console() + + if not events: + out.print("[dim]No events to display.[/dim]") + return + + recent = events[-max_events:] + + table = Table( + title="Recent Events", show_lines=False, expand=True, title_style="bold" + ) + table.add_column("Time", style="cyan", width=12, no_wrap=True) + table.add_column("Event", width=16) + table.add_column("Details", ratio=1) + + for ev in recent: + if ev.timestamp is not None: + delta = ev.timestamp - session_start + rel = _format_relative_time(delta) + else: + rel = "โ€”" + + label = _event_type_label(ev.type) + details = _build_event_details(ev) + table.add_row(rel, label, details) + + out.print(table) + + +def _render_code_changes( + code_changes: CodeChanges | None, + *, + target_console: Console | None = None, +) -> None: + """Print code-change stats if present.""" + out = target_console or Console() + + if code_changes is None: + return + + if ( + not code_changes.filesModified + and not code_changes.linesAdded + and not code_changes.linesRemoved + ): + return + + table = Table(title="Code Changes", title_style="bold", expand=False) + table.add_column("Metric", style="bold") + table.add_column("Value", justify="right") + table.add_row("Files modified", str(len(code_changes.filesModified))) + table.add_row("Lines added", f"[green]+{code_changes.linesAdded}[/green]") + table.add_row("Lines removed", f"[red]-{code_changes.linesRemoved}[/red]") + out.print(table) + + +# --------------------------------------------------------------------------- +# Main session detail entry point +# --------------------------------------------------------------------------- + + +def render_session_detail( + events: list[SessionEvent], + summary: SessionSummary, + *, + target_console: Console | None = None, +) -> None: + """Render a useful summary view of a single session. + + Displays: + - Header panel (name, ID, model, status, start time) + - Aggregate stats (model calls, user messages, output tokens, premium) + - Per-shutdown-cycle table + - Active period (if session is active) + - Last 10 events (recent activity, not a full timeline) + - Code changes (if any) + + Parameters + ---------- + events: + The full list of parsed :class:`SessionEvent` objects for this + session. + summary: + Pre-computed :class:`SessionSummary` for the session. + target_console: + Optional :class:`Console` to print to (defaults to a fresh + console). + """ + out = target_console or Console() + + _render_header(summary, target_console=out) + out.print() + + _render_aggregate_stats(summary, target_console=out) + out.print() + + _render_shutdown_cycles(events, target_console=out) + out.print() + + _render_active_period(summary, target_console=out) + + session_start = summary.start_time or ( + events[0].timestamp if events and events[0].timestamp else datetime.now(tz=UTC) + ) + _render_recent_events(events, session_start, target_console=out) + out.print() + + _render_code_changes(summary.code_changes, target_console=out) + + +# --------------------------------------------------------------------------- +# Summary report +# --------------------------------------------------------------------------- + + +def _filter_sessions( + sessions: list[SessionSummary], + since: datetime | None, + until: datetime | None, +) -> list[SessionSummary]: + """Return sessions whose start_time falls within [since, until].""" + if since is None and until is None: + return sessions + + filtered: list[SessionSummary] = [] + for s in sessions: + if s.start_time is None: + continue + if since is not None and s.start_time < since: + continue + if until is not None and s.start_time > until: + continue + filtered.append(s) + return filtered + + +def _aggregate_model_metrics( + sessions: list[SessionSummary], +) -> dict[str, ModelMetrics]: + """Merge model metrics across all sessions into a single dict.""" + merged: dict[str, ModelMetrics] = {} + for s in sessions: + for model_name, mm in s.model_metrics.items(): + if model_name not in merged: + merged[model_name] = ModelMetrics( + requests=mm.requests.model_copy(), + usage=mm.usage.model_copy(), + ) + else: + existing = merged[model_name] + existing.requests.count += mm.requests.count + existing.requests.cost += mm.requests.cost + existing.usage.inputTokens += mm.usage.inputTokens + existing.usage.outputTokens += mm.usage.outputTokens + existing.usage.cacheReadTokens += mm.usage.cacheReadTokens + existing.usage.cacheWriteTokens += mm.usage.cacheWriteTokens + return merged + + +def _render_summary_header( + console: Console, + sessions: list[SessionSummary], +) -> None: + """Print the report header with date range.""" + start_times = [s.start_time for s in sessions if s.start_time is not None] + if start_times: + earliest = min(start_times).strftime("%Y-%m-%d") + latest = max(start_times).strftime("%Y-%m-%d") + subtitle = f"{earliest} โ†’ {latest}" + else: + subtitle = "no sessions" + console.print() + console.print( + Text("Copilot Usage Summary", style="bold cyan"), + Text(f" ({subtitle})", style="dim"), + ) + console.print() + + +def _render_totals(console: Console, sessions: list[SessionSummary]) -> None: + """Render the totals panel.""" + total_premium = sum(s.total_premium_requests for s in sessions) + total_model_calls = sum(s.model_calls for s in sessions) + total_user_messages = sum(s.user_messages for s in sessions) + total_duration = sum(s.total_api_duration_ms for s in sessions) + total_sessions = len(sessions) + + total_output = 0 + for s in sessions: + for mm in s.model_metrics.values(): + total_output += mm.usage.outputTokens + + pr_label = "premium request" if total_premium == 1 else "premium requests" + session_label = "session" if total_sessions == 1 else "sessions" + lines = [ + f"[green]{total_premium}[/green] {pr_label} " + f"[green]{total_model_calls}[/green] model calls " + f"[green]{total_user_messages}[/green] user messages " + f"[green]{format_tokens(total_output)}[/green] output tokens", + f"[green]{format_duration(total_duration)}[/green] API duration " + f"[green]{total_sessions}[/green] {session_label}", + ] + + console.print(Panel("\n".join(lines), title="Totals", border_style="cyan")) + + +def _render_model_table(console: Console, sessions: list[SessionSummary]) -> None: + """Render the per-model breakdown table.""" + merged = _aggregate_model_metrics(sessions) + if not merged: + return + + table = Table(title="Per-Model Breakdown", border_style="cyan") + table.add_column("Model", style="bold") + table.add_column("Requests", justify="right") + table.add_column("Premium Cost", justify="right") + table.add_column("Input Tokens", justify="right") + table.add_column("Output Tokens", justify="right") + table.add_column("Cache Read", justify="right") + + for model_name in sorted(merged): + mm = merged[model_name] + table.add_row( + model_name, + str(mm.requests.count), + str(mm.requests.cost), + format_tokens(mm.usage.inputTokens), + format_tokens(mm.usage.outputTokens), + format_tokens(mm.usage.cacheReadTokens), + ) + + console.print(table) + + +def _render_session_table( + console: Console, + sessions: list[SessionSummary], +) -> None: + """Render the per-session table sorted by start time (newest first).""" + if not sessions: + return + + sorted_sessions = sorted( + sessions, + key=lambda s: s.start_time.isoformat() if s.start_time else "", + reverse=True, + ) + + table = Table(title="Sessions", border_style="cyan") + table.add_column("Name", style="bold", max_width=40) + table.add_column("Model") + table.add_column("Premium", justify="right") + table.add_column("Model Calls", justify="right") + table.add_column("User Msgs", justify="right") + table.add_column("Output Tokens", justify="right") + table.add_column("Status") + + for s in sorted_sessions: + name = s.name or s.session_id[:12] + model = s.model or "โ€”" + + output_tokens = sum(mm.usage.outputTokens for mm in s.model_metrics.values()) + + if s.is_active: + status = Text("Active ๐ŸŸข", style="yellow") + else: + status = Text("Completed", style="dim") + + # Show premium requests from shutdown data if > 0, otherwise "โ€”" + if s.total_premium_requests > 0: + pr_display = str(s.total_premium_requests) + else: + pr_display = "โ€”" + + table.add_row( + name, + model, + pr_display, + str(s.model_calls), + str(s.user_messages), + format_tokens(output_tokens), + status, + ) + + console.print(table) + + +def render_summary( + sessions: list[SessionSummary], + since: datetime | None = None, + until: datetime | None = None, +) -> None: + """Render the full summary report to the terminal using Rich. + + Filters sessions by date range when *since* and/or *until* are given. + """ + console = Console() + filtered = _filter_sessions(sessions, since, until) + + if not filtered: + console.print("[yellow]No sessions found.[/yellow]") + return + + _render_summary_header(console, filtered) + _render_totals(console, filtered) + console.print() + _render_model_table(console, filtered) + console.print() + _render_session_table(console, filtered) + console.print() + + +# --------------------------------------------------------------------------- +# Two-section full summary (for interactive mode) +# --------------------------------------------------------------------------- + + +def _render_historical_section( + console: Console, + sessions: list[SessionSummary], +) -> None: + """Render Section 1: Historical Data from shutdown cycles.""" + # Filter to sessions that have shutdown data + historical = [ + s + for s in sessions + if s.total_premium_requests > 0 or (s.model_metrics and not s.is_active) + ] + + if not historical: + console.print("[dim]No historical shutdown data.[/dim]") + return + + # Totals panel + total_premium = sum(s.total_premium_requests for s in historical) + total_model_calls = sum(s.model_calls for s in historical) + total_user_messages = sum(s.user_messages for s in historical) + total_duration = sum(s.total_api_duration_ms for s in historical) + total_output = sum( + mm.usage.outputTokens for s in historical for mm in s.model_metrics.values() + ) + + lines = [ + f"[green]{total_premium}[/green] premium requests " + f"[green]{total_model_calls}[/green] model calls " + f"[green]{total_user_messages}[/green] user messages " + f"[green]{format_tokens(total_output)}[/green] output tokens", + f"[green]{format_duration(total_duration)}[/green] API duration", + ] + console.print( + Panel("\n".join(lines), title="๐Ÿ“Š Historical Totals", border_style="cyan") + ) + + # Per-model table + merged = _aggregate_model_metrics(historical) + if merged: + table = Table(title="Per-Model Breakdown", border_style="cyan") + table.add_column("Model", style="bold") + table.add_column("Requests", justify="right") + table.add_column("Premium Cost", justify="right") + table.add_column("Input Tokens", justify="right") + table.add_column("Output Tokens", justify="right") + table.add_column("Cache Read", justify="right") + + for model_name in sorted(merged): + mm = merged[model_name] + table.add_row( + model_name, + str(mm.requests.count), + str(mm.requests.cost), + format_tokens(mm.usage.inputTokens), + format_tokens(mm.usage.outputTokens), + format_tokens(mm.usage.cacheReadTokens), + ) + console.print(table) + + # Per-session table + sorted_sessions = sorted( + historical, + key=lambda s: s.start_time.isoformat() if s.start_time else "", + reverse=True, + ) + session_table = Table(title="Sessions (Shutdown Data)", border_style="cyan") + session_table.add_column("Name", style="bold", max_width=40) + session_table.add_column("Model") + session_table.add_column("Premium", justify="right") + session_table.add_column("Model Calls", justify="right") + session_table.add_column("User Msgs", justify="right") + session_table.add_column("Output Tokens", justify="right") + session_table.add_column("Status") + + for s in sorted_sessions: + name = s.name or s.session_id[:12] + model = s.model or "โ€”" + output_tokens = sum(mm.usage.outputTokens for mm in s.model_metrics.values()) + status = ( + Text("Active ๐ŸŸข", style="yellow") + if s.is_active + else Text("Completed", style="dim") + ) + pr_display = ( + str(s.total_premium_requests) if s.total_premium_requests > 0 else "โ€”" + ) + + session_table.add_row( + name, + model, + pr_display, + str(s.model_calls), + str(s.user_messages), + format_tokens(output_tokens), + status, + ) + + console.print(session_table) + + +def _render_active_section( + console: Console, + sessions: list[SessionSummary], +) -> None: + """Render Section 2: Active Sessions since last shutdown.""" + active = [s for s in sessions if s.is_active] + + if not active: + console.print( + Panel( + "No active sessions", title="๐ŸŸข Active Sessions", border_style="green" + ) + ) + return + + table = Table( + title="๐ŸŸข Active Sessions (Since Last Shutdown)", border_style="green" + ) + table.add_column("Name", style="bold", max_width=40) + table.add_column("Model") + table.add_column("Model Calls", justify="right") + table.add_column("User Msgs", justify="right") + table.add_column("Output Tokens", justify="right") + table.add_column("Running Time", justify="right") + + for s in active: + name = s.name or s.session_id[:12] + model = s.model or "โ€”" + running = ( + _format_duration(s.last_resume_time or s.start_time) + if s.start_time + else "โ€”" + ) + + table.add_row( + name, + model, + str(s.active_model_calls), + str(s.active_user_messages), + format_tokens(s.active_output_tokens), + running, + ) + + console.print(table) + + +def render_full_summary( + sessions: list[SessionSummary], + *, + target_console: Console | None = None, +) -> None: + """Render the two-section summary for interactive mode. + + Section 1: Historical shutdown data (totals, per-model, per-session). + Section 2: Active sessions since last shutdown. + """ + console = target_console or Console() + + if not sessions: + console.print("[yellow]No sessions found.[/yellow]") + return + + _render_summary_header(console, sessions) + _render_historical_section(console, sessions) + console.print() + _render_active_section(console, sessions) + + +# --------------------------------------------------------------------------- +# Cost view (for interactive mode) +# --------------------------------------------------------------------------- + + +def render_cost_view( + sessions: list[SessionSummary], + *, + target_console: Console | None = None, +) -> None: + """Render per-session, per-model cost breakdown. + + For active sessions, appends a "โ†ณ Since last shutdown" row with N/A + for premium and the active model calls / output tokens. + """ + console = target_console or Console() + + if not sessions: + console.print("[yellow]No sessions found.[/yellow]") + return + + table = Table(title="๐Ÿ’ฐ Cost Breakdown", border_style="cyan") + table.add_column("Session", style="bold", max_width=35) + table.add_column("Model") + table.add_column("Requests", justify="right") + table.add_column("Premium Cost", justify="right", style="green") + table.add_column("Model Calls", justify="right") + table.add_column("Output Tokens", justify="right") + + grand_premium = 0 + grand_requests = 0 + grand_model_calls = 0 + grand_output = 0 + + for s in sessions: + name = s.name or s.session_id[:12] + model_calls_display = str(s.model_calls) + + if s.model_metrics: + for model_name in sorted(s.model_metrics): + mm = s.model_metrics[model_name] + table.add_row( + name, + model_name, + str(mm.requests.count), + str(mm.requests.cost), + model_calls_display, + format_tokens(mm.usage.outputTokens), + ) + grand_requests += mm.requests.count + grand_premium += mm.requests.cost + grand_output += mm.usage.outputTokens + # Only show session-level info once + name = "" + model_calls_display = "" + else: + table.add_row( + name, + s.model or "โ€”", + "โ€”", + "โ€”", + str(s.model_calls), + "โ€”", + ) + + grand_model_calls += s.model_calls + + if s.is_active: + table.add_row( + " โ†ณ Since last shutdown", + s.model or "โ€”", + "N/A", + "N/A", + str(s.active_model_calls), + format_tokens(s.active_output_tokens), + ) + grand_model_calls += s.active_model_calls + grand_output += s.active_output_tokens + + table.add_section() + table.add_row( + "[bold]Grand Total[/bold]", + "", + f"[bold]{grand_requests}[/bold]", + f"[bold]{grand_premium}[/bold]", + f"[bold]{grand_model_calls}[/bold]", + f"[bold]{format_tokens(grand_output)}[/bold]", + ) + + console.print(table) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py new file mode 100644 index 0000000..b1e0bcb --- /dev/null +++ b/tests/copilot_usage/test_cli.py @@ -0,0 +1,481 @@ +"""Tests for copilot_usage.cli โ€” wired-up CLI commands.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from click.testing import CliRunner + +from copilot_usage.cli import main + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NOW = datetime(2025, 1, 15, 12, 0, 0, tzinfo=UTC) + + +def _write_session( + base: Path, + session_id: str, + *, + name: str | None = None, + model: str = "claude-sonnet-4", + premium: int = 3, + output_tokens: int = 1500, + active: bool = False, +) -> Path: + """Create a minimal events.jsonl file inside *base*//.""" + session_dir = base / session_id[:8] + session_dir.mkdir(parents=True, exist_ok=True) + + events: list[dict[str, Any]] = [ + { + "type": "session.start", + "timestamp": "2025-01-15T10:00:00Z", + "data": { + "sessionId": session_id, + "startTime": "2025-01-15T10:00:00Z", + "context": {"cwd": "/home/user/project"}, + }, + }, + { + "type": "user.message", + "timestamp": "2025-01-15T10:01:00Z", + "data": {"content": "hello"}, + }, + { + "type": "assistant.turn_start", + "timestamp": "2025-01-15T10:01:01Z", + "data": {"turnId": "0", "interactionId": "int-1"}, + }, + ] + + if not active: + events.append( + { + "type": "session.shutdown", + "timestamp": "2025-01-15T11:00:00Z", + "currentModel": model, + "data": { + "shutdownType": "normal", + "totalPremiumRequests": premium, + "totalApiDurationMs": 5000, + "modelMetrics": { + model: { + "requests": {"count": premium, "cost": premium}, + "usage": { + "inputTokens": 500, + "outputTokens": output_tokens, + "cacheReadTokens": 100, + "cacheWriteTokens": 50, + }, + } + }, + }, + } + ) + + events_path = session_dir / "events.jsonl" + with events_path.open("w") as fh: + for ev in events: + fh.write(json.dumps(ev) + "\n") + + if name: + (session_dir / "plan.md").write_text(f"# {name}\n") + + return session_dir + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_cli_help() -> None: + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "usage tracker" in result.output.lower() + + +def test_summary_command(tmp_path: Path) -> None: + _write_session(tmp_path, "aaaa1111-0000-0000-0000-000000000000", name="First") + _write_session(tmp_path, "bbbb2222-0000-0000-0000-000000000000", name="Second") + + runner = CliRunner() + result = runner.invoke(main, ["summary", "--path", str(tmp_path)]) + assert result.exit_code == 0 + assert "First" in result.output or "Summary" in result.output + + +def test_summary_no_sessions(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(main, ["summary", "--path", str(tmp_path)]) + assert result.exit_code == 0 + assert "No sessions" in result.output + + +def test_summary_with_since(tmp_path: Path) -> None: + _write_session(tmp_path, "cccc3333-0000-0000-0000-000000000000", name="Recent") + runner = CliRunner() + result = runner.invoke( + main, ["summary", "--path", str(tmp_path), "--since", "2025-01-01"] + ) + assert result.exit_code == 0 + + +def test_session_command(tmp_path: Path) -> None: + _write_session(tmp_path, "dddd4444-0000-0000-0000-000000000000", name="Detail") + runner = CliRunner() + # Patch discover_sessions to use our tmp_path + result = runner.invoke(main, ["session", "dddd4444"]) + # Will fail with "no session" because it looks in default path; test error path + assert ( + result.exit_code != 0 or "dddd4444" in result.output or "Error" in result.output + ) + + +def test_session_not_found(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(main, ["session", "zzzzzzzz", "--path", str(tmp_path)]) + assert result.exit_code != 0 + + +def test_cost_command(tmp_path: Path) -> None: + _write_session( + tmp_path, + "eeee5555-0000-0000-0000-000000000000", + name="Cost Test", + premium=5, + ) + runner = CliRunner() + result = runner.invoke(main, ["cost", "--path", str(tmp_path)]) + assert result.exit_code == 0 + assert "Cost" in result.output or "Total" in result.output + + +def test_cost_no_sessions(tmp_path: Path) -> None: + runner = CliRunner() + result = runner.invoke(main, ["cost", "--path", str(tmp_path)]) + assert result.exit_code == 0 + assert "No sessions" in result.output + + +def test_cost_with_date_filter(tmp_path: Path) -> None: + _write_session(tmp_path, "ffff6666-0000-0000-0000-000000000000", name="Filtered") + runner = CliRunner() + result = runner.invoke( + main, + [ + "cost", + "--path", + str(tmp_path), + "--since", + "2025-01-01", + "--until", + "2025-12-31", + ], + ) + assert result.exit_code == 0 + + +def test_live_command(tmp_path: Path) -> None: + _write_session( + tmp_path, + "gggg7777-0000-0000-0000-000000000000", + name="Active Session", + active=True, + ) + runner = CliRunner() + result = runner.invoke(main, ["live", "--path", str(tmp_path)]) + assert result.exit_code == 0 + + +def test_live_no_active(tmp_path: Path) -> None: + _write_session(tmp_path, "hhhh8888-0000-0000-0000-000000000000", name="Done") + runner = CliRunner() + result = runner.invoke(main, ["live", "--path", str(tmp_path)]) + assert result.exit_code == 0 + assert "No active" in result.output + + +def test_session_prefix_match(tmp_path: Path, monkeypatch: Any) -> None: + """Test that session command matches by prefix when using custom path.""" + _write_session(tmp_path, "iiii9999-0000-0000-0000-000000000000", name="Prefix Test") + + def _fake_discover(_base_path: Path | None = None) -> list[Path]: + return sorted( + tmp_path.glob("*/events.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + monkeypatch.setattr("copilot_usage.cli.discover_sessions", _fake_discover) + runner = CliRunner() + result = runner.invoke(main, ["session", "iiii9999"]) + assert result.exit_code == 0 + assert "iiii9999" in result.output + + +def test_session_shows_available_on_miss(tmp_path: Path, monkeypatch: Any) -> None: + """Test that session command shows available IDs when no match found.""" + _write_session(tmp_path, "jjjj0000-0000-0000-0000-000000000000", name="Exists") + + def _fake_discover(_base_path: Path | None = None) -> list[Path]: + return sorted( + tmp_path.glob("*/events.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + monkeypatch.setattr("copilot_usage.cli.discover_sessions", _fake_discover) + runner = CliRunner() + result = runner.invoke(main, ["session", "notfound"]) + assert result.exit_code != 0 + assert "jjjj0000" in result.output + + +# --------------------------------------------------------------------------- +# Coverage gap tests +# --------------------------------------------------------------------------- + + +def test_summary_invalid_path() -> None: + """--path with non-existent dir โ†’ click rejects before our code runs.""" + runner = CliRunner() + result = runner.invoke(main, ["summary", "--path", "/nonexistent/xyz_fake_path"]) + assert result.exit_code != 0 + # Click itself produces the error; no Python traceback should appear + assert "Traceback" not in (result.output or "") + + +def test_summary_error_handling(tmp_path: Path, monkeypatch: Any) -> None: + """Exercise the except-Exception branch (lines 77-79) in summary.""" + + def _exploding_sessions(_base: Path | None = None) -> list[object]: + msg = "disk on fire" + raise OSError(msg) + + monkeypatch.setattr("copilot_usage.cli.get_all_sessions", _exploding_sessions) + runner = CliRunner() + result = runner.invoke(main, ["summary", "--path", str(tmp_path)]) + assert result.exit_code != 0 + assert "disk on fire" in result.output + + +def test_session_no_sessions(tmp_path: Path, monkeypatch: Any) -> None: + """session command with empty discover โ†’ 'No sessions found.' (lines 99-101).""" + + def _empty_discover(_base: Path | None = None) -> list[Path]: + return [] + + monkeypatch.setattr("copilot_usage.cli.discover_sessions", _empty_discover) + runner = CliRunner() + result = runner.invoke(main, ["session", "anything"]) + assert result.exit_code != 0 + assert "No sessions found" in result.output + + +def test_session_skips_empty_events(tmp_path: Path, monkeypatch: Any) -> None: + """session command skips files with no parseable events (line 107, 118).""" + # Create a session dir with an empty events.jsonl + empty_dir = tmp_path / "empty-sess" + empty_dir.mkdir() + (empty_dir / "events.jsonl").write_text("\n", encoding="utf-8") + + # Also create a valid session to generate the "Available" list + _write_session(tmp_path, "kkkk1111-0000-0000-0000-000000000000", name="Valid") + + def _fake_discover(_base: Path | None = None) -> list[Path]: + return sorted( + tmp_path.glob("*/events.jsonl"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + + monkeypatch.setattr("copilot_usage.cli.discover_sessions", _fake_discover) + runner = CliRunner() + result = runner.invoke(main, ["session", "nonexistent"]) + assert result.exit_code != 0 + assert "no session matching" in result.output + + +def test_session_error_handling(tmp_path: Path, monkeypatch: Any) -> None: + """Trigger an exception in session detail โ†’ friendly error (lines 129-131).""" + + def _exploding_discover(_base: Path | None = None) -> list[Path]: + msg = "permission denied" + raise PermissionError(msg) + + monkeypatch.setattr("copilot_usage.cli.discover_sessions", _exploding_discover) + runner = CliRunner() + result = runner.invoke(main, ["session", "anything"]) + assert result.exit_code != 0 + assert "permission denied" in result.output + assert "Traceback" not in (result.output or "") + + +def test_cost_no_model_metrics(tmp_path: Path) -> None: + """Session with no model metrics โ†’ cost command doesn't crash (line 201).""" + session_dir = tmp_path / "nomodel00" + session_dir.mkdir(parents=True) + events: list[dict[str, object]] = [ + { + "type": "session.start", + "timestamp": "2025-01-15T10:00:00Z", + "data": { + "sessionId": "nomodel00-0000-0000-0000-000000000000", + "startTime": "2025-01-15T10:00:00Z", + "context": {"cwd": "/home/user"}, + }, + }, + { + "type": "session.shutdown", + "timestamp": "2025-01-15T11:00:00Z", + "data": { + "shutdownType": "normal", + "totalPremiumRequests": 0, + "totalApiDurationMs": 0, + "modelMetrics": {}, + }, + }, + ] + events_path = session_dir / "events.jsonl" + with events_path.open("w") as fh: + for ev in events: + fh.write(json.dumps(ev) + "\n") + + runner = CliRunner() + result = runner.invoke(main, ["cost", "--path", str(tmp_path)]) + assert result.exit_code == 0 + assert "Cost" in result.output or "Total" in result.output + + +def test_cost_zero_multiplier_model(tmp_path: Path) -> None: + """gpt-5-mini (0ร— multiplier) โ†’ shows 0 for premium cost.""" + _write_session( + tmp_path, + "freefree-0000-0000-0000-000000000000", + name="Free Model", + model="gpt-5-mini", + premium=0, + output_tokens=500, + ) + runner = CliRunner() + result = runner.invoke(main, ["cost", "--path", str(tmp_path)]) + assert result.exit_code == 0 + assert "Free Model" in result.output + assert "gpt-5-mini" in result.output + + +def test_cost_error_handling(tmp_path: Path, monkeypatch: Any) -> None: + """Exercise the except-Exception branch (lines 226-228) in cost.""" + + def _exploding_sessions(_base: Path | None = None) -> list[object]: + msg = "cost explosion" + raise RuntimeError(msg) + + monkeypatch.setattr("copilot_usage.cli.get_all_sessions", _exploding_sessions) + runner = CliRunner() + result = runner.invoke(main, ["cost", "--path", str(tmp_path)]) + assert result.exit_code != 0 + assert "cost explosion" in result.output + + +def test_live_error_handling(tmp_path: Path, monkeypatch: Any) -> None: + """Exercise the except-Exception branch (lines 248-250) in live.""" + + def _exploding_sessions(_base: Path | None = None) -> list[object]: + msg = "live explosion" + raise RuntimeError(msg) + + monkeypatch.setattr("copilot_usage.cli.get_all_sessions", _exploding_sessions) + runner = CliRunner() + result = runner.invoke(main, ["live", "--path", str(tmp_path)]) + assert result.exit_code != 0 + assert "live explosion" in result.output + + +# --------------------------------------------------------------------------- +# Interactive mode tests +# --------------------------------------------------------------------------- + + +def test_interactive_quit_immediately(tmp_path: Path) -> None: + """Interactive loop exits cleanly on 'q' input.""" + _write_session(tmp_path, "int10000-0000-0000-0000-000000000000", name="Interactive") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="q\n") + assert result.exit_code == 0 + + +def test_interactive_empty_input_exits(tmp_path: Path) -> None: + """Empty input (just Enter) exits the interactive loop.""" + _write_session(tmp_path, "int20000-0000-0000-0000-000000000000", name="EmptyExit") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="\n") + assert result.exit_code == 0 + + +def test_interactive_cost_view(tmp_path: Path) -> None: + """Pressing 'c' shows cost view, then 'q' exits.""" + _write_session(tmp_path, "int30000-0000-0000-0000-000000000000", name="CostView") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="c\nq\n") + assert result.exit_code == 0 + assert "Cost" in result.output + + +def test_interactive_refresh(tmp_path: Path) -> None: + """Pressing 'r' refreshes the data, then 'q' exits.""" + _write_session(tmp_path, "int40000-0000-0000-0000-000000000000", name="Refresh") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="r\nq\n") + assert result.exit_code == 0 + + +def test_interactive_session_detail(tmp_path: Path) -> None: + """Entering a session number shows session detail.""" + _write_session(tmp_path, "int50000-0000-0000-0000-000000000000", name="DetailView") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="1\nq\n") + assert result.exit_code == 0 + + +def test_interactive_invalid_number(tmp_path: Path) -> None: + """Entering an out-of-range number shows error, then 'q' exits.""" + _write_session(tmp_path, "int60000-0000-0000-0000-000000000000", name="BadNum") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="99\nq\n") + assert result.exit_code == 0 + assert "Invalid session number" in result.output + + +def test_interactive_unknown_command(tmp_path: Path) -> None: + """Unknown input shows error message.""" + _write_session(tmp_path, "int70000-0000-0000-0000-000000000000", name="UnknownCmd") + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="xyz\nq\n") + assert result.exit_code == 0 + assert "Unknown command" in result.output + + +def test_interactive_eof_exits(tmp_path: Path) -> None: + """EOF (no input) exits the interactive loop cleanly.""" + _write_session(tmp_path, "int80000-0000-0000-0000-000000000000", name="EOF") + runner = CliRunner() + # CliRunner with no input sends EOF + result = runner.invoke(main, ["--path", str(tmp_path)]) + assert result.exit_code == 0 + + +def test_interactive_no_sessions(tmp_path: Path) -> None: + """Interactive mode with no sessions shows 'No sessions found'.""" + runner = CliRunner() + result = runner.invoke(main, ["--path", str(tmp_path)], input="q\n") + assert result.exit_code == 0 + assert "No sessions" in result.output diff --git a/tests/copilot_usage/test_models.py b/tests/copilot_usage/test_models.py new file mode 100644 index 0000000..669f82e --- /dev/null +++ b/tests/copilot_usage/test_models.py @@ -0,0 +1,243 @@ +"""Tests for copilot_usage.models โ€” Pydantic v2 event parsing.""" + +import json +from datetime import UTC, datetime + +from copilot_usage.models import ( + AssistantMessageData, + CodeChanges, + EventBase, + EventType, + ModelMetrics, + RequestMetrics, + SessionEvent, + SessionShutdownData, + SessionStartData, + SessionSummary, + TokenUsage, + ToolExecutionData, + UserMessageData, +) + +# --------------------------------------------------------------------------- +# Raw JSON fixtures (from real events.jsonl files) +# --------------------------------------------------------------------------- + +RAW_SESSION_START = json.loads( + '{"type":"session.start","data":{"sessionId":"0faecbdf-b889-4bca-a51a-5254f5488cb6",' + '"version":1,"producer":"copilot-agent","copilotVersion":"1.0.2",' + '"startTime":"2026-03-07T15:15:20.265Z","context":{"cwd":"/Users/sasa"}},' + '"id":"7283e3ac-5608-4a28-a37b-32b744733314",' + '"timestamp":"2026-03-07T15:15:20.267Z","parentId":null}' +) + +RAW_ASSISTANT_MESSAGE = json.loads( + '{"type":"assistant.message","data":{"messageId":"dca91a42",' + '"content":"some content","toolRequests":[],' + '"interactionId":"c0c803cf","reasoningOpaque":"...",' + '"reasoningText":"...","outputTokens":373},' + '"id":"161d0d5a","timestamp":"2026-03-07T15:23:45.175Z",' + '"parentId":"d03b9461"}' +) + +RAW_SHUTDOWN = json.loads( + '{"type":"session.shutdown","data":{"shutdownType":"routine",' + '"totalPremiumRequests":24,"totalApiDurationMs":389114,' + '"sessionStartTime":1772896520265,' + '"codeChanges":{"linesAdded":134,"linesRemoved":2,' + '"filesModified":["/Users/sasa/test_github_models.sh"]},' + '"modelMetrics":{"claude-opus-4.6-1m":{"requests":{"count":53,"cost":24},' + '"usage":{"inputTokens":1627935,"outputTokens":16655,' + '"cacheReadTokens":1424086,"cacheWriteTokens":0}}}},' + '"currentModel":"claude-opus-4.6-1m"}' +) + +RAW_TOOL_EXEC = json.loads( + '{"type":"tool.execution_complete","data":{"toolCallId":"toolu_xxx",' + '"model":"claude-opus-4.6-1m","interactionId":"c0c803cf","success":true,' + '"toolTelemetry":{"properties":{"outcome":"answered"}}},' + '"id":"xxx","timestamp":"2026-03-07T15:23:45.175Z","parentId":"yyy"}' +) + +RAW_USER_MESSAGE = json.loads( + '{"type":"user.message","data":{"content":"hey there",' + '"transformedContent":"...","attachments":[],"interactionId":"c0c803cf"},' + '"id":"d6648885","timestamp":"2026-03-07T15:23:35.661Z",' + '"parentId":"f09411f5"}' +) + + +# --------------------------------------------------------------------------- +# Enum +# --------------------------------------------------------------------------- + + +def test_event_type_values() -> None: + assert EventType.SESSION_START == "session.start" + assert EventType.SESSION_SHUTDOWN == "session.shutdown" + assert EventType.USER_MESSAGE == "user.message" + + +# --------------------------------------------------------------------------- +# Leaf models +# --------------------------------------------------------------------------- + + +def test_token_usage() -> None: + t = TokenUsage(inputTokens=100, outputTokens=50) + assert t.cacheReadTokens == 0 + assert t.cacheWriteTokens == 0 + + +def test_request_metrics() -> None: + r = RequestMetrics(count=10, cost=5) + assert r.count == 10 + + +def test_model_metrics() -> None: + m = ModelMetrics() + assert m.requests.count == 0 + assert m.usage.inputTokens == 0 + + +def test_code_changes() -> None: + c = CodeChanges(linesAdded=10, linesRemoved=2, filesModified=["a.py"]) + assert len(c.filesModified) == 1 + + +# --------------------------------------------------------------------------- +# Event data payloads +# --------------------------------------------------------------------------- + + +def test_session_start_data() -> None: + d = SessionStartData.model_validate(RAW_SESSION_START["data"]) + assert d.sessionId == "0faecbdf-b889-4bca-a51a-5254f5488cb6" + assert d.copilotVersion == "1.0.2" + assert d.context.cwd == "/Users/sasa" + assert d.startTime is not None + + +def test_assistant_message_data() -> None: + d = AssistantMessageData.model_validate(RAW_ASSISTANT_MESSAGE["data"]) + assert d.outputTokens == 373 + assert d.reasoningText == "..." + + +def test_session_shutdown_data() -> None: + d = SessionShutdownData.model_validate(RAW_SHUTDOWN["data"]) + assert d.totalPremiumRequests == 24 + assert d.totalApiDurationMs == 389114 + assert d.codeChanges is not None + assert d.codeChanges.linesAdded == 134 + assert "claude-opus-4.6-1m" in d.modelMetrics + m = d.modelMetrics["claude-opus-4.6-1m"] + assert m.requests.count == 53 + assert m.usage.inputTokens == 1627935 + + +def test_tool_execution_data() -> None: + d = ToolExecutionData.model_validate(RAW_TOOL_EXEC["data"]) + assert d.success is True + assert d.model == "claude-opus-4.6-1m" + assert d.toolTelemetry is not None + assert d.toolTelemetry.properties["outcome"] == "answered" + + +def test_user_message_data() -> None: + d = UserMessageData.model_validate(RAW_USER_MESSAGE["data"]) + assert d.content == "hey there" + assert d.interactionId == "c0c803cf" + + +# --------------------------------------------------------------------------- +# EventBase +# --------------------------------------------------------------------------- + + +def test_event_base() -> None: + e = EventBase.model_validate(RAW_SESSION_START) + assert e.type == "session.start" + assert e.id == "7283e3ac-5608-4a28-a37b-32b744733314" + assert e.parentId is None + assert e.timestamp is not None + + +# --------------------------------------------------------------------------- +# SessionEvent envelope + parse_data() +# --------------------------------------------------------------------------- + + +def test_session_event_start() -> None: + ev = SessionEvent.model_validate(RAW_SESSION_START) + assert ev.type == "session.start" + data = ev.parse_data() + assert isinstance(data, SessionStartData) + + +def test_session_event_shutdown() -> None: + ev = SessionEvent.model_validate(RAW_SHUTDOWN) + assert ev.currentModel == "claude-opus-4.6-1m" + data = ev.parse_data() + assert isinstance(data, SessionShutdownData) + + +def test_session_event_assistant_message() -> None: + ev = SessionEvent.model_validate(RAW_ASSISTANT_MESSAGE) + data = ev.parse_data() + assert isinstance(data, AssistantMessageData) + + +def test_session_event_tool_exec() -> None: + ev = SessionEvent.model_validate(RAW_TOOL_EXEC) + data = ev.parse_data() + assert isinstance(data, ToolExecutionData) + + +def test_session_event_user_message() -> None: + ev = SessionEvent.model_validate(RAW_USER_MESSAGE) + data = ev.parse_data() + assert isinstance(data, UserMessageData) + + +def test_session_event_unknown_type() -> None: + raw = {"type": "some.future.event", "data": {"foo": "bar"}, "id": "x"} + ev = SessionEvent.model_validate(raw) + data = ev.parse_data() + assert data is not None + + +# --------------------------------------------------------------------------- +# SessionSummary +# --------------------------------------------------------------------------- + + +def test_session_summary_defaults() -> None: + s = SessionSummary(session_id="abc") + assert s.is_active is False + assert s.user_messages == 0 + assert s.model_calls == 0 + assert s.model_metrics == {} + assert s.code_changes is None + + +def test_session_summary_full() -> None: + s = SessionSummary( + session_id="abc", + start_time=datetime(2026, 3, 7, 15, 0, tzinfo=UTC), + model="claude-opus-4.6-1m", + total_premium_requests=24, + total_api_duration_ms=389114, + model_metrics={ + "claude-opus-4.6-1m": ModelMetrics( + requests=RequestMetrics(count=53, cost=24), + usage=TokenUsage(inputTokens=1627935, outputTokens=16655), + ) + }, + code_changes=CodeChanges(linesAdded=134, linesRemoved=2), + user_messages=10, + model_calls=5, + is_active=False, + ) + assert s.total_premium_requests == 24 + assert s.model_metrics["claude-opus-4.6-1m"].usage.inputTokens == 1627935 diff --git a/tests/copilot_usage/test_parser.py b/tests/copilot_usage/test_parser.py new file mode 100644 index 0000000..ab524e3 --- /dev/null +++ b/tests/copilot_usage/test_parser.py @@ -0,0 +1,1924 @@ +"""Tests for copilot_usage.parser โ€” session discovery, parsing, and summary.""" + +from __future__ import annotations + +import json +import time +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import patch + +import pytest + +from copilot_usage.models import ( + AssistantMessageData, + CodeChanges, + EventType, + GenericEventData, + ModelMetrics, + RequestMetrics, + SessionContext, + SessionEvent, + SessionShutdownData, + SessionStartData, + SessionSummary, + TokenUsage, + ToolExecutionData, + ToolTelemetry, + UserMessageData, +) +from copilot_usage.parser import ( + build_session_summary, + discover_sessions, + get_all_sessions, + parse_events, +) + +# --------------------------------------------------------------------------- +# Fixtures โ€” synthetic events.jsonl content +# --------------------------------------------------------------------------- + +_START_EVENT = json.dumps( + { + "type": "session.start", + "data": { + "sessionId": "test-session-001", + "version": 1, + "producer": "copilot-agent", + "copilotVersion": "1.0.0", + "startTime": "2026-03-07T10:00:00.000Z", + "context": {"cwd": "/home/user/project"}, + }, + "id": "ev-start", + "timestamp": "2026-03-07T10:00:00.000Z", + "parentId": None, + } +) + +_USER_MSG = json.dumps( + { + "type": "user.message", + "data": { + "content": "hello", + "transformedContent": "hello", + "attachments": [], + "interactionId": "int-1", + }, + "id": "ev-user1", + "timestamp": "2026-03-07T10:01:00.000Z", + "parentId": "ev-start", + } +) + +_ASSISTANT_MSG = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "msg-1", + "content": "hi there", + "toolRequests": [], + "interactionId": "int-1", + "outputTokens": 150, + }, + "id": "ev-asst1", + "timestamp": "2026-03-07T10:01:05.000Z", + "parentId": "ev-user1", + } +) + +_ASSISTANT_MSG_2 = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "msg-2", + "content": "more content", + "toolRequests": [], + "interactionId": "int-1", + "outputTokens": 200, + }, + "id": "ev-asst2", + "timestamp": "2026-03-07T10:01:10.000Z", + "parentId": "ev-asst1", + } +) + +_TOOL_EXEC = json.dumps( + { + "type": "tool.execution_complete", + "data": { + "toolCallId": "tc-1", + "model": "claude-sonnet-4", + "interactionId": "int-1", + "success": True, + }, + "id": "ev-tool1", + "timestamp": "2026-03-07T10:01:07.000Z", + "parentId": "ev-asst1", + } +) + +_TURN_START_1 = json.dumps( + { + "type": "assistant.turn_start", + "data": {"turnId": "0", "interactionId": "int-1"}, + "id": "ev-turn-start-1", + "timestamp": "2026-03-07T10:01:01.000Z", + "parentId": "ev-user1", + } +) + +_TURN_START_2 = json.dumps( + { + "type": "assistant.turn_start", + "data": {"turnId": "1", "interactionId": "int-1"}, + "id": "ev-turn-start-2", + "timestamp": "2026-03-07T10:01:08.000Z", + "parentId": "ev-asst1", + } +) + +_SHUTDOWN_EVENT = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 5, + "totalApiDurationMs": 12000, + "sessionStartTime": 1772895600000, + "codeChanges": { + "linesAdded": 50, + "linesRemoved": 10, + "filesModified": ["a.py", "b.py"], + }, + "modelMetrics": { + "claude-sonnet-4": { + "requests": {"count": 8, "cost": 5}, + "usage": { + "inputTokens": 5000, + "outputTokens": 350, + "cacheReadTokens": 1000, + "cacheWriteTokens": 0, + }, + } + }, + "currentModel": "claude-sonnet-4", + }, + "id": "ev-shutdown", + "timestamp": "2026-03-07T11:00:00.000Z", + "parentId": "ev-asst2", + "currentModel": "claude-sonnet-4", + } +) + +_RESUME_EVENT = json.dumps( + { + "type": "session.resume", + "data": {}, + "id": "ev-resume", + "timestamp": "2026-03-07T12:00:00.000Z", + "parentId": "ev-shutdown", + } +) + +_POST_RESUME_USER_MSG = json.dumps( + { + "type": "user.message", + "data": { + "content": "continue working", + "transformedContent": "continue working", + "attachments": [], + "interactionId": "int-2", + }, + "id": "ev-user2", + "timestamp": "2026-03-07T12:01:00.000Z", + "parentId": "ev-resume", + } +) + +_POST_RESUME_ASSISTANT_MSG = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "msg-3", + "content": "resuming work", + "toolRequests": [], + "interactionId": "int-2", + "outputTokens": 250, + }, + "id": "ev-asst3", + "timestamp": "2026-03-07T12:01:05.000Z", + "parentId": "ev-user2", + } +) + +_POST_RESUME_TURN_START = json.dumps( + { + "type": "assistant.turn_start", + "data": {"turnId": "2", "interactionId": "int-2"}, + "id": "ev-turn-start-post-resume", + "timestamp": "2026-03-07T12:01:01.000Z", + "parentId": "ev-user2", + } +) + +_SHUTDOWN_EVENT_2 = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 10, + "totalApiDurationMs": 20000, + "sessionStartTime": 1772895600000, + "codeChanges": { + "linesAdded": 80, + "linesRemoved": 20, + "filesModified": ["a.py", "b.py", "c.py"], + }, + "modelMetrics": { + "claude-sonnet-4": { + "requests": {"count": 15, "cost": 10}, + "usage": { + "inputTokens": 9000, + "outputTokens": 700, + "cacheReadTokens": 2000, + "cacheWriteTokens": 0, + }, + } + }, + "currentModel": "claude-sonnet-4", + }, + "id": "ev-shutdown-2", + "timestamp": "2026-03-07T13:00:00.000Z", + "parentId": "ev-asst3", + "currentModel": "claude-sonnet-4", + } +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _write_events(path: Path, *lines: str) -> Path: + """Write event lines to an events.jsonl file and return the path.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return path + + +def _completed_events( + tmp_path: Path, +) -> tuple[list[SessionEvent], Path]: + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _ASSISTANT_MSG, + _ASSISTANT_MSG_2, + _SHUTDOWN_EVENT, + ) + return parse_events(p), p.parent + + +def _active_events( + tmp_path: Path, +) -> tuple[list[SessionEvent], Path]: + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _ASSISTANT_MSG, + _ASSISTANT_MSG_2, + _TOOL_EXEC, + ) + return parse_events(p), p.parent + + +# --------------------------------------------------------------------------- +# discover_sessions +# --------------------------------------------------------------------------- + + +class TestDiscoverSessions: + def test_finds_sessions(self, tmp_path: Path) -> None: + s1 = tmp_path / "session-a" / "events.jsonl" + s2 = tmp_path / "session-b" / "events.jsonl" + _write_events(s1, _START_EVENT) + _write_events(s2, _START_EVENT) + result = discover_sessions(tmp_path) + assert len(result) == 2 + assert all(p.name == "events.jsonl" for p in result) + + def test_sorted_newest_first(self, tmp_path: Path) -> None: + older = tmp_path / "old" / "events.jsonl" + newer = tmp_path / "new" / "events.jsonl" + _write_events(older, _START_EVENT) + time.sleep(0.05) + _write_events(newer, _START_EVENT) + result = discover_sessions(tmp_path) + assert result[0] == newer + + def test_empty_directory(self, tmp_path: Path) -> None: + assert discover_sessions(tmp_path) == [] + + def test_nonexistent_directory(self, tmp_path: Path) -> None: + assert discover_sessions(tmp_path / "nope") == [] + + def test_stat_race_file_deleted_between_glob_and_sort(self, tmp_path: Path) -> None: + """TOCTOU: session dir deleted after glob but before stat().""" + s1 = tmp_path / "session-a" / "events.jsonl" + s2 = tmp_path / "session-b" / "events.jsonl" + _write_events(s1, _START_EVENT) + _write_events(s2, _START_EVENT) + + original_stat = Path.stat + + def _flaky_stat(self: Path) -> object: + if self == s1: + raise FileNotFoundError(f"deleted: {self}") + return original_stat(self) + + with patch.object(Path, "stat", _flaky_stat): + result = discover_sessions(tmp_path) + + # s2 still returned; s1 may also be present (with mtime 0) + assert any(p == s2 for p in result) + # The call must not raise + assert isinstance(result, list) + + def test_get_all_sessions_skips_vanished_session(self, tmp_path: Path) -> None: + """TOCTOU: events.jsonl deleted after discover but before parse.""" + s1 = tmp_path / "session-a" / "events.jsonl" + s2 = tmp_path / "session-b" / "events.jsonl" + _write_events(s1, _START_EVENT) + _write_events(s2, _START_EVENT) + + original_open = Path.open + + def _flaky_open(self: Path, *args: object, **kwargs: object) -> object: # type: ignore[override] + if self == s1: + raise FileNotFoundError(f"deleted: {self}") + return original_open(self, *args, **kwargs) # type: ignore[arg-type] + + with patch.object(Path, "open", _flaky_open): + summaries = get_all_sessions(tmp_path) + + # Only s2 should produce a summary + assert len(summaries) == 1 + + +# --------------------------------------------------------------------------- +# parse_events +# --------------------------------------------------------------------------- + + +class TestParseEvents: + def test_parses_valid_events(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + assert len(events) == 3 + assert events[0].type == "session.start" + assert events[1].type == "user.message" + assert events[2].type == "assistant.message" + + def test_skips_malformed_json(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, "NOT-JSON{{{", _USER_MSG) + events = parse_events(p) + assert len(events) == 2 + + def test_skips_empty_lines(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + p.parent.mkdir(parents=True) + p.write_text(_START_EVENT + "\n\n\n" + _USER_MSG + "\n", encoding="utf-8") + events = parse_events(p) + assert len(events) == 2 + + def test_empty_file(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p) + events = parse_events(p) + assert events == [] + + def test_skips_validation_errors(self, tmp_path: Path) -> None: + bad_event = json.dumps({"no_type_field": True}) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, bad_event) + events = parse_events(p) + assert len(events) == 1 + + +# --------------------------------------------------------------------------- +# build_session_summary โ€” completed session +# --------------------------------------------------------------------------- + + +class TestBuildSessionSummaryCompleted: + def test_session_id(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.session_id == "test-session-001" + + def test_not_active(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.is_active is False + + def test_uses_shutdown_data(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.total_premium_requests == 5 + assert summary.total_api_duration_ms == 12000 + assert "claude-sonnet-4" in summary.model_metrics + assert summary.model_metrics["claude-sonnet-4"].usage.outputTokens == 350 + + def test_code_changes(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.code_changes is not None + assert summary.code_changes.linesAdded == 50 + + def test_message_count(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.user_messages == 1 # 1 user message + assert summary.model_calls == 0 # no turn_starts in _completed_events + + def test_model(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.model == "claude-sonnet-4" + + def test_timestamps(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.start_time == datetime(2026, 3, 7, 10, 0, tzinfo=UTC) + assert summary.end_time == datetime(2026, 3, 7, 11, 0, tzinfo=UTC) + + def test_session_name_from_plan(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + plan = sdir / "plan.md" + plan.write_text("# My Cool Project\n\nSome details.\n", encoding="utf-8") + summary = build_session_summary(events, session_dir=sdir) + assert summary.name == "My Cool Project" + + +# --------------------------------------------------------------------------- +# build_session_summary โ€” active session (no shutdown) +# --------------------------------------------------------------------------- + + +class TestBuildSessionSummaryActive: + def test_is_active(self, tmp_path: Path) -> None: + events, sdir = _active_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.is_active is True + + def test_sums_output_tokens(self, tmp_path: Path) -> None: + events, sdir = _active_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + # 150 + 200 = 350 total output tokens + assert summary.model is not None + total = sum(m.usage.outputTokens for m in summary.model_metrics.values()) + assert total == 350 + + def test_zero_premium_requests(self, tmp_path: Path) -> None: + events, sdir = _active_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.total_premium_requests == 0 + + def test_no_code_changes(self, tmp_path: Path) -> None: + events, sdir = _active_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.code_changes is None + + def test_model_from_tool_exec(self, tmp_path: Path) -> None: + events, sdir = _active_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.model == "claude-sonnet-4" + + def test_cwd(self, tmp_path: Path) -> None: + events, sdir = _active_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.cwd == "/home/user/project" + + def test_last_resume_time_none_for_active(self, tmp_path: Path) -> None: + """Active session with no resume event โ†’ last_resume_time is None.""" + events, sdir = _active_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.last_resume_time is None + + +# --------------------------------------------------------------------------- +# build_session_summary โ€” resumed session (shutdown followed by more events) +# --------------------------------------------------------------------------- + + +class TestBuildSessionSummaryResumed: + def test_resumed_session_is_active(self, tmp_path: Path) -> None: + """Session with shutdown followed by new messages โ†’ is_active=True.""" + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _ASSISTANT_MSG, + _SHUTDOWN_EVENT, + _RESUME_EVENT, + _POST_RESUME_USER_MSG, + _POST_RESUME_ASSISTANT_MSG, + ) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.is_active is True + + def test_resumed_session_sums_post_shutdown_tokens(self, tmp_path: Path) -> None: + """Post-shutdown tokens go to active_output_tokens, not merged into metrics.""" + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _ASSISTANT_MSG, + _SHUTDOWN_EVENT, + _RESUME_EVENT, + _POST_RESUME_USER_MSG, + _POST_RESUME_ASSISTANT_MSG, + ) + events = parse_events(p) + summary = build_session_summary(events) + # Shutdown had 350 โ€” stays at 350 in historical metrics + assert "claude-sonnet-4" in summary.model_metrics + assert summary.model_metrics["claude-sonnet-4"].usage.outputTokens == 350 + # Post-resume 250 goes to active_output_tokens + assert summary.active_output_tokens == 250 + + def test_multiple_shutdowns_uses_latest(self, tmp_path: Path) -> None: + """Session shut down and resumed multiple times โ†’ last shutdown wins.""" + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _ASSISTANT_MSG, + _SHUTDOWN_EVENT, + _RESUME_EVENT, + _POST_RESUME_USER_MSG, + _POST_RESUME_ASSISTANT_MSG, + _SHUTDOWN_EVENT_2, + ) + events = parse_events(p) + summary = build_session_summary(events) + # Two shutdowns: 5 + 10 = 15 total premium requests + assert summary.is_active is False + assert summary.total_premium_requests == 15 + # Output tokens summed: 350 + 700 = 1050 + assert summary.model_metrics["claude-sonnet-4"].usage.outputTokens == 1050 + + def test_shutdown_as_last_event_is_completed(self, tmp_path: Path) -> None: + """Normal completed session (shutdown is last) โ†’ is_active=False.""" + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _ASSISTANT_MSG, + _ASSISTANT_MSG_2, + _SHUTDOWN_EVENT, + ) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.is_active is False + assert summary.end_time == datetime(2026, 3, 7, 11, 0, tzinfo=UTC) + + def test_last_resume_time_none_for_completed(self, tmp_path: Path) -> None: + """Completed session (no resume) โ†’ last_resume_time is None.""" + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _ASSISTANT_MSG, + _SHUTDOWN_EVENT, + ) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.last_resume_time is None + + def test_last_resume_time_set_for_resumed(self, tmp_path: Path) -> None: + """Resumed session โ†’ last_resume_time equals resume event timestamp.""" + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _ASSISTANT_MSG, + _SHUTDOWN_EVENT, + _RESUME_EVENT, + _POST_RESUME_USER_MSG, + _POST_RESUME_ASSISTANT_MSG, + ) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.last_resume_time == datetime(2026, 3, 7, 12, 0, tzinfo=UTC) + + def test_resumed_session_no_current_model_infers_from_metrics( + self, tmp_path: Path + ) -> None: + """Shutdown with modelMetrics but no currentModel โ†’ model inferred, tokens kept.""" + shutdown_no_model = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 3, + "totalApiDurationMs": 5000, + "sessionStartTime": 0, + "modelMetrics": { + "claude-sonnet-4": { + "requests": {"count": 3, "cost": 3}, + "usage": { + "inputTokens": 2000, + "outputTokens": 400, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + }, + } + }, + }, + "id": "ev-sd", + "timestamp": "2026-03-07T11:00:00.000Z", + } + ) + resume_ev = json.dumps( + { + "type": "session.resume", + "data": {}, + "id": "ev-resume", + "timestamp": "2026-03-07T12:00:00.000Z", + } + ) + post_user = json.dumps( + { + "type": "user.message", + "data": {"content": "hi", "attachments": []}, + "id": "ev-u", + "timestamp": "2026-03-07T12:01:00.000Z", + } + ) + post_asst = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "m-p", + "content": "ok", + "toolRequests": [], + "interactionId": "int-r", + "outputTokens": 100, + }, + "id": "ev-a", + "timestamp": "2026-03-07T12:01:05.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + shutdown_no_model, + resume_ev, + post_user, + post_asst, + ) + events = parse_events(p) + summary = build_session_summary(events) + + assert summary.is_active is True + assert summary.model == "claude-sonnet-4" + assert "claude-sonnet-4" in summary.model_metrics + # 400 from shutdown stays at 400 in historical + assert summary.model_metrics["claude-sonnet-4"].usage.outputTokens == 400 + # 100 post-resume goes to active + assert summary.active_output_tokens == 100 + + def test_model_inferred_from_highest_request_count(self, tmp_path: Path) -> None: + """Shutdown with multiple models, no currentModel โ†’ picks highest requests.count.""" + shutdown_multi = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 12, + "totalApiDurationMs": 8000, + "sessionStartTime": 0, + "modelMetrics": { + "gpt-4": { + "requests": {"count": 2, "cost": 2}, + "usage": { + "inputTokens": 500, + "outputTokens": 100, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + }, + }, + "claude-sonnet-4": { + "requests": {"count": 10, "cost": 10}, + "usage": { + "inputTokens": 4000, + "outputTokens": 800, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + }, + }, + }, + }, + "id": "ev-sd", + "timestamp": "2026-03-07T11:00:00.000Z", + } + ) + resume_ev = json.dumps( + { + "type": "session.resume", + "data": {}, + "id": "ev-resume", + "timestamp": "2026-03-07T12:00:00.000Z", + } + ) + post_asst = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "m-p", + "content": "ok", + "toolRequests": [], + "interactionId": "int-r", + "outputTokens": 50, + }, + "id": "ev-a", + "timestamp": "2026-03-07T12:01:05.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, shutdown_multi, resume_ev, post_asst) + events = parse_events(p) + summary = build_session_summary(events) + + # claude-sonnet-4 has count=10 > gpt-4 count=2 + assert summary.model == "claude-sonnet-4" + # Historical stays at 800 (from shutdown) + assert summary.model_metrics["claude-sonnet-4"].usage.outputTokens == 800 + # 50 post-resume goes to active + assert summary.active_output_tokens == 50 + + +# --------------------------------------------------------------------------- +# build_session_summary โ€” multi-shutdown resumed (2+ shutdowns then still active) +# --------------------------------------------------------------------------- + + +class TestBuildSessionSummaryMultiShutdownResumed: + """Two shutdowns followed by a resume with post-resume activity (still active).""" + + @staticmethod + def _build(tmp_path: Path) -> SessionSummary: + shutdown_1 = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 3, + "totalApiDurationMs": 4000, + "sessionStartTime": 0, + "modelMetrics": { + "claude-sonnet-4": { + "requests": {"count": 3, "cost": 3}, + "usage": { + "inputTokens": 800, + "outputTokens": 150, + "cacheReadTokens": 200, + "cacheWriteTokens": 0, + }, + } + }, + "currentModel": "claude-sonnet-4", + }, + "id": "ev-sd1", + "timestamp": "2026-03-07T09:00:00.000Z", + "currentModel": "claude-sonnet-4", + } + ) + resume_1 = json.dumps( + { + "type": "session.resume", + "data": {}, + "id": "ev-resume1", + "timestamp": "2026-03-07T11:00:00.000Z", + } + ) + mid_user = json.dumps( + { + "type": "user.message", + "data": {"content": "mid", "attachments": []}, + "id": "ev-mid-u", + "timestamp": "2026-03-07T11:01:00.000Z", + } + ) + mid_turn_start = json.dumps( + { + "type": "assistant.turn_start", + "data": {"turnId": "1", "interactionId": "int-mid"}, + "id": "ev-mid-ts", + "timestamp": "2026-03-07T11:01:01.000Z", + } + ) + mid_asst = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "m-mid", + "content": "ok", + "toolRequests": [], + "interactionId": "int-mid", + "outputTokens": 250, + }, + "id": "ev-mid-a", + "timestamp": "2026-03-07T11:01:05.000Z", + } + ) + shutdown_2 = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 7, + "totalApiDurationMs": 9000, + "sessionStartTime": 0, + "modelMetrics": { + "claude-opus-4.6": { + "requests": {"count": 5, "cost": 7}, + "usage": { + "inputTokens": 1500, + "outputTokens": 350, + "cacheReadTokens": 400, + "cacheWriteTokens": 0, + }, + } + }, + "currentModel": "claude-opus-4.6", + }, + "id": "ev-sd2", + "timestamp": "2026-03-07T12:00:00.000Z", + "currentModel": "claude-opus-4.6", + } + ) + resume_2 = json.dumps( + { + "type": "session.resume", + "data": {}, + "id": "ev-resume2", + "timestamp": "2026-03-07T14:00:00.000Z", + } + ) + post_user_1 = json.dumps( + { + "type": "user.message", + "data": {"content": "post1", "attachments": []}, + "id": "ev-post-u1", + "timestamp": "2026-03-07T14:01:00.000Z", + } + ) + post_turn_start_1 = json.dumps( + { + "type": "assistant.turn_start", + "data": {"turnId": "2", "interactionId": "int-post1"}, + "id": "ev-post-ts1", + "timestamp": "2026-03-07T14:01:01.000Z", + } + ) + post_asst_1 = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "m-p1", + "content": "reply1", + "toolRequests": [], + "interactionId": "int-post1", + "outputTokens": 100, + }, + "id": "ev-post-a1", + "timestamp": "2026-03-07T14:01:05.000Z", + } + ) + post_user_2 = json.dumps( + { + "type": "user.message", + "data": {"content": "post2", "attachments": []}, + "id": "ev-post-u2", + "timestamp": "2026-03-07T14:05:00.000Z", + } + ) + post_turn_start_2 = json.dumps( + { + "type": "assistant.turn_start", + "data": {"turnId": "3", "interactionId": "int-post2"}, + "id": "ev-post-ts2", + "timestamp": "2026-03-07T14:05:01.000Z", + } + ) + post_asst_2 = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "m-p2", + "content": "reply2", + "toolRequests": [], + "interactionId": "int-post2", + "outputTokens": 125, + }, + "id": "ev-post-a2", + "timestamp": "2026-03-07T14:05:05.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _TURN_START_1, + _ASSISTANT_MSG, + shutdown_1, + resume_1, + mid_user, + mid_turn_start, + mid_asst, + shutdown_2, + resume_2, + post_user_1, + post_turn_start_1, + post_asst_1, + post_user_2, + post_turn_start_2, + post_asst_2, + ) + events = parse_events(p) + return build_session_summary(events) + + def test_is_active(self, tmp_path: Path) -> None: + """2 shutdowns + resume with post-resume activity โ†’ is_active=True.""" + summary = self._build(tmp_path) + assert summary.is_active is True + + def test_total_premium_requests_summed(self, tmp_path: Path) -> None: + """Premium requests aggregated from both shutdowns: 3 + 7 = 10.""" + summary = self._build(tmp_path) + assert summary.total_premium_requests == 10 + + def test_merged_model_metrics_has_both_models(self, tmp_path: Path) -> None: + """Merged metrics contain keys for both sonnet and opus.""" + summary = self._build(tmp_path) + assert "claude-sonnet-4" in summary.model_metrics + assert "claude-opus-4.6" in summary.model_metrics + + def test_merged_model_metrics_values(self, tmp_path: Path) -> None: + """Each model has correct metrics from its respective shutdown.""" + summary = self._build(tmp_path) + sonnet = summary.model_metrics["claude-sonnet-4"] + assert sonnet.requests.count == 3 + assert sonnet.usage.inputTokens == 800 + assert sonnet.usage.outputTokens == 150 + + opus = summary.model_metrics["claude-opus-4.6"] + assert opus.requests.count == 5 + assert opus.usage.inputTokens == 1500 + assert opus.usage.outputTokens == 350 + + def test_active_turn_starts_only_after_last_shutdown(self, tmp_path: Path) -> None: + """active_model_calls counts only turn_starts after last shutdown.""" + summary = self._build(tmp_path) + assert summary.active_model_calls == 2 + + def test_active_user_messages_only_after_last_shutdown( + self, tmp_path: Path + ) -> None: + """active_user_messages counts only user.messages after last shutdown.""" + summary = self._build(tmp_path) + assert summary.active_user_messages == 2 + + def test_active_output_tokens_only_after_last_shutdown( + self, tmp_path: Path + ) -> None: + """active_output_tokens sums only outputTokens after last shutdown: 100+125.""" + summary = self._build(tmp_path) + assert summary.active_output_tokens == 225 + + def test_last_resume_time_is_latest_resume(self, tmp_path: Path) -> None: + """last_resume_time is set to the timestamp of the last session.resume.""" + summary = self._build(tmp_path) + # resume_2 timestamp is 2026-03-07T14:00:00.000Z + assert summary.last_resume_time == datetime(2026, 3, 7, 14, 0, tzinfo=UTC) + + +# --------------------------------------------------------------------------- +# get_all_sessions +# --------------------------------------------------------------------------- + + +class TestGetAllSessions: + def test_returns_summaries(self, tmp_path: Path) -> None: + s1 = tmp_path / "a" / "events.jsonl" + s2 = tmp_path / "b" / "events.jsonl" + _write_events(s1, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + _write_events(s2, _START_EVENT, _SHUTDOWN_EVENT) + result = get_all_sessions(tmp_path) + assert len(result) == 2 + + def test_sorted_newest_first(self, tmp_path: Path) -> None: + older_start = json.dumps( + { + "type": "session.start", + "data": { + "sessionId": "old", + "version": 1, + "startTime": "2026-01-01T00:00:00.000Z", + "context": {}, + }, + "id": "e1", + "timestamp": "2026-01-01T00:00:00.000Z", + } + ) + newer_start = json.dumps( + { + "type": "session.start", + "data": { + "sessionId": "new", + "version": 1, + "startTime": "2026-06-01T00:00:00.000Z", + "context": {}, + }, + "id": "e2", + "timestamp": "2026-06-01T00:00:00.000Z", + } + ) + _write_events(tmp_path / "a" / "events.jsonl", older_start) + _write_events(tmp_path / "b" / "events.jsonl", newer_start) + result = get_all_sessions(tmp_path) + assert result[0].session_id == "new" + assert result[1].session_id == "old" + + def test_empty_base(self, tmp_path: Path) -> None: + assert get_all_sessions(tmp_path) == [] + + +# --------------------------------------------------------------------------- +# Real data smoke test (against ~/.copilot/session-state/) +# --------------------------------------------------------------------------- + +_REAL_BASE = Path.home() / ".copilot" / "session-state" + + +class TestRealData: + """Smoke tests against actual session data โ€” skipped if not present.""" + + @pytest.mark.skipif( + not _REAL_BASE.is_dir(), + reason="No real session data available", + ) + def test_discover_finds_sessions(self) -> None: + paths = discover_sessions(_REAL_BASE) + assert len(paths) >= 1 + + @pytest.mark.skipif( + not _REAL_BASE.is_dir(), + reason="No real session data available", + ) + def test_get_all_sessions_returns_summaries(self) -> None: + summaries = get_all_sessions(_REAL_BASE) + assert len(summaries) >= 1 + for s in summaries: + assert s.session_id != "" + + +# --------------------------------------------------------------------------- +# Pydantic model unit tests +# --------------------------------------------------------------------------- + + +class TestTokenUsageModel: + def test_defaults(self) -> None: + t = TokenUsage() + assert t.inputTokens == 0 + assert t.outputTokens == 0 + assert t.cacheReadTokens == 0 + assert t.cacheWriteTokens == 0 + + def test_custom_values(self) -> None: + t = TokenUsage(inputTokens=100, outputTokens=50) + assert t.inputTokens == 100 + assert t.outputTokens == 50 + + +class TestRequestMetricsModel: + def test_defaults(self) -> None: + r = RequestMetrics() + assert r.count == 0 + assert r.cost == 0 + + +class TestModelMetricsModel: + def test_defaults(self) -> None: + m = ModelMetrics() + assert m.requests.count == 0 + assert m.usage.outputTokens == 0 + + def test_nested(self) -> None: + m = ModelMetrics( + requests=RequestMetrics(count=3, cost=10), + usage=TokenUsage(inputTokens=500, outputTokens=200), + ) + assert m.requests.count == 3 + assert m.usage.inputTokens == 500 + + +class TestCodeChangesModel: + def test_defaults(self) -> None: + c = CodeChanges() + assert c.linesAdded == 0 + assert c.linesRemoved == 0 + assert c.filesModified == [] + + def test_with_files(self) -> None: + c = CodeChanges(linesAdded=10, filesModified=["a.py"]) + assert c.filesModified == ["a.py"] + + +class TestSessionContextModel: + def test_defaults(self) -> None: + ctx = SessionContext() + assert ctx.cwd is None + + def test_with_cwd(self) -> None: + ctx = SessionContext(cwd="/home/user") + assert ctx.cwd == "/home/user" + + +class TestSessionStartDataModel: + def test_required_session_id(self) -> None: + d = SessionStartData(sessionId="abc") + assert d.sessionId == "abc" + assert d.version == 1 + assert d.producer == "" + assert d.startTime is None + assert d.context.cwd is None + + def test_missing_session_id_raises(self) -> None: + with pytest.raises(Exception): # noqa: B017 + SessionStartData.model_validate({}) + + +class TestAssistantMessageDataModel: + def test_defaults(self) -> None: + d = AssistantMessageData() + assert d.messageId == "" + assert d.content == "" + assert d.outputTokens == 0 + assert d.reasoningText is None + assert d.toolRequests == [] + + def test_optional_reasoning(self) -> None: + d = AssistantMessageData(reasoningText="thinking...", reasoningOpaque="x") + assert d.reasoningText == "thinking..." + assert d.reasoningOpaque == "x" + + +class TestSessionShutdownDataModel: + def test_defaults(self) -> None: + d = SessionShutdownData() + assert d.shutdownType == "" + assert d.totalPremiumRequests == 0 + assert d.codeChanges is None + assert d.modelMetrics == {} + assert d.currentModel is None + + def test_with_model_metrics(self) -> None: + d = SessionShutdownData( + modelMetrics={ + "gpt-4": ModelMetrics( + usage=TokenUsage(outputTokens=100), + ) + } + ) + assert d.modelMetrics["gpt-4"].usage.outputTokens == 100 + + def test_empty_model_metrics(self) -> None: + d = SessionShutdownData(modelMetrics={}) + assert d.modelMetrics == {} + + +class TestToolExecutionDataModel: + def test_defaults(self) -> None: + d = ToolExecutionData() + assert d.toolCallId == "" + assert d.model is None + assert d.success is False + assert d.toolTelemetry is None + + def test_with_telemetry(self) -> None: + d = ToolExecutionData( + toolCallId="tc-1", + success=True, + toolTelemetry=ToolTelemetry(properties={"key": "value"}), + ) + assert d.toolTelemetry is not None + assert d.toolTelemetry.properties["key"] == "value" + + +class TestUserMessageDataModel: + def test_defaults(self) -> None: + d = UserMessageData() + assert d.content == "" + assert d.transformedContent is None + assert d.attachments == [] + assert d.interactionId is None + + +class TestGenericEventDataModel: + def test_allows_extra_fields(self) -> None: + d = GenericEventData.model_validate({"foo": "bar", "num": 42}) + assert d.model_extra is not None + assert d.model_extra["foo"] == "bar" + + +class TestSessionSummaryModel: + def test_defaults(self) -> None: + s = SessionSummary(session_id="s1") + assert s.session_id == "s1" + assert s.is_active is False + assert s.user_messages == 0 + assert s.model_calls == 0 + assert s.model_metrics == {} + assert s.code_changes is None + + +class TestEventTypeEnum: + def test_values(self) -> None: + assert EventType.SESSION_START == "session.start" + assert EventType.SESSION_SHUTDOWN == "session.shutdown" + assert EventType.ASSISTANT_MESSAGE == "assistant.message" + assert EventType.TOOL_EXECUTION_COMPLETE == "tool.execution_complete" + assert EventType.USER_MESSAGE == "user.message" + assert EventType.ABORT == "abort" + + +# --------------------------------------------------------------------------- +# SessionEvent.parse_data() โ€” all branches +# --------------------------------------------------------------------------- + + +class TestSessionEventParseData: + def test_parse_session_start(self) -> None: + ev = SessionEvent( + type="session.start", + data={"sessionId": "s1", "version": 1, "context": {}}, + ) + result = ev.parse_data() + assert isinstance(result, SessionStartData) + assert result.sessionId == "s1" + + def test_parse_assistant_message(self) -> None: + ev = SessionEvent( + type="assistant.message", + data={"messageId": "m1", "content": "hi", "outputTokens": 10}, + ) + result = ev.parse_data() + assert isinstance(result, AssistantMessageData) + assert result.outputTokens == 10 + + def test_parse_session_shutdown(self) -> None: + ev = SessionEvent( + type="session.shutdown", + data={"shutdownType": "routine", "totalPremiumRequests": 3}, + ) + result = ev.parse_data() + assert isinstance(result, SessionShutdownData) + assert result.totalPremiumRequests == 3 + + def test_parse_tool_execution_complete(self) -> None: + ev = SessionEvent( + type="tool.execution_complete", + data={"toolCallId": "tc-1", "model": "gpt-4", "success": True}, + ) + result = ev.parse_data() + assert isinstance(result, ToolExecutionData) + assert result.model == "gpt-4" + + def test_parse_user_message(self) -> None: + ev = SessionEvent( + type="user.message", + data={"content": "hello"}, + ) + result = ev.parse_data() + assert isinstance(result, UserMessageData) + assert result.content == "hello" + + def test_parse_unknown_event_type(self) -> None: + ev = SessionEvent( + type="some.unknown.event", + data={"arbitrary": "data", "count": 42}, + ) + result = ev.parse_data() + assert isinstance(result, GenericEventData) + + def test_parse_abort_event(self) -> None: + ev = SessionEvent(type="abort", data={"reason": "user"}) + result = ev.parse_data() + assert isinstance(result, GenericEventData) + + +# --------------------------------------------------------------------------- +# Edge cases โ€” build_session_summary +# --------------------------------------------------------------------------- + + +class TestBuildSessionSummaryEdgeCases: + def test_empty_events(self) -> None: + summary = build_session_summary([]) + assert summary.session_id == "" + assert summary.is_active is True + + def test_no_session_dir(self, tmp_path: Path) -> None: + events, _ = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=None) + assert summary.name is None + + def test_plan_md_without_heading(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + plan = sdir / "plan.md" + plan.write_text("Just some text without heading\n", encoding="utf-8") + summary = build_session_summary(events, session_dir=sdir) + assert summary.name is None + + def test_no_plan_md(self, tmp_path: Path) -> None: + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.name is None + + def test_shutdown_without_code_changes(self, tmp_path: Path) -> None: + shutdown_no_cc = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 2, + "totalApiDurationMs": 500, + "sessionStartTime": 0, + "modelMetrics": {}, + }, + "id": "ev-sd", + "timestamp": "2026-03-07T11:00:00.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, shutdown_no_cc) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.code_changes is None + assert summary.model_metrics == {} + + def test_active_session_no_tool_exec_no_model(self, tmp_path: Path) -> None: + """Active session with assistant messages but no tool.execution_complete.""" + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events, config_path=tmp_path / "no-config.json") + assert summary.is_active is True + assert summary.model is None + # No model so tokens can't be attributed + assert summary.model_metrics == {} + + def test_active_session_with_model_tokens(self, tmp_path: Path) -> None: + """Active session where model is found via tool exec โ†’ tokens attributed.""" + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG, _TOOL_EXEC) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.is_active is True + assert summary.model == "claude-sonnet-4" + assert summary.model_metrics["claude-sonnet-4"].usage.outputTokens == 150 + + def test_unexpected_event_types_ignored(self, tmp_path: Path) -> None: + weird = json.dumps( + { + "type": "some.custom.event", + "data": {"x": 1}, + "id": "ev-weird", + "timestamp": "2026-03-07T10:05:00.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, weird, _USER_MSG) + events = parse_events(p) + assert len(events) == 3 + summary = build_session_summary(events) + assert summary.user_messages == 1 + + def test_shutdown_model_from_data_currentModel(self, tmp_path: Path) -> None: + """When top-level currentModel is absent, use data.currentModel.""" + shutdown_ev = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 1, + "totalApiDurationMs": 100, + "sessionStartTime": 0, + "currentModel": "gpt-4", + "modelMetrics": {}, + }, + "id": "ev-sd", + "timestamp": "2026-03-07T11:00:00.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, shutdown_ev) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.model == "gpt-4" + + def test_assistant_message_without_output_tokens(self, tmp_path: Path) -> None: + msg_no_tokens = json.dumps( + { + "type": "assistant.message", + "data": {"messageId": "m1", "content": "hi"}, + "id": "ev-m", + "timestamp": "2026-03-07T10:01:00.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, msg_no_tokens, _TOOL_EXEC) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.is_active is True + # No outputTokens โ†’ model_metrics should be empty even though model is known + assert summary.model_metrics == {} + + +# --------------------------------------------------------------------------- +# get_all_sessions โ€” additional edge cases +# --------------------------------------------------------------------------- + + +class TestGetAllSessionsEdgeCases: + def test_skips_empty_events_files(self, tmp_path: Path) -> None: + _write_events(tmp_path / "empty" / "events.jsonl") + _write_events(tmp_path / "valid" / "events.jsonl", _START_EVENT) + result = get_all_sessions(tmp_path) + assert len(result) == 1 + + def test_sessions_without_start_time_sort_last(self, tmp_path: Path) -> None: + no_time_start = json.dumps( + { + "type": "session.start", + "data": {"sessionId": "no-time", "version": 1, "context": {}}, + "id": "e1", + } + ) + with_time_start = json.dumps( + { + "type": "session.start", + "data": { + "sessionId": "has-time", + "version": 1, + "startTime": "2026-06-01T00:00:00.000Z", + "context": {}, + }, + "id": "e2", + "timestamp": "2026-06-01T00:00:00.000Z", + } + ) + _write_events(tmp_path / "a" / "events.jsonl", no_time_start) + _write_events(tmp_path / "b" / "events.jsonl", with_time_start) + result = get_all_sessions(tmp_path) + assert len(result) == 2 + assert result[0].session_id == "has-time" + assert result[1].session_id == "no-time" + + def test_nonexistent_base(self, tmp_path: Path) -> None: + result = get_all_sessions(tmp_path / "does_not_exist") + assert result == [] + + +# --------------------------------------------------------------------------- +# Coverage gap tests โ€” parser.py +# --------------------------------------------------------------------------- + + +class TestParserCoverageGaps: + """Tests targeting specific uncovered lines in parser.py.""" + + def test_extract_session_name_os_error(self, tmp_path: Path) -> None: + """plan.md exists but is unreadable โ†’ name is None (lines 103-104).""" + events, sdir = _completed_events(tmp_path) + plan = sdir / "plan.md" + plan.write_text("# Title\n", encoding="utf-8") + plan.chmod(0o000) + try: + summary = build_session_summary(events, session_dir=sdir) + assert summary.name is None + finally: + plan.chmod(0o644) + + def test_session_start_validation_error(self, tmp_path: Path) -> None: + """Malformed session.start data โ†’ skipped (lines 155-156).""" + bad_start = json.dumps( + { + "type": "session.start", + "data": {"sessionId": 12345}, # sessionId should be str + "id": "ev-bad", + "timestamp": "2026-03-07T10:00:00.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, bad_start, _USER_MSG, _ASSISTANT_MSG, _TOOL_EXEC) + events = parse_events(p) + summary = build_session_summary(events) + # session.start was skipped, so no session_id extracted + assert summary.session_id == "" + assert summary.is_active is True + + def test_session_shutdown_validation_error(self, tmp_path: Path) -> None: + """Malformed session.shutdown data โ†’ skipped (lines 166-167).""" + bad_shutdown = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": "not-a-number", + }, + "id": "ev-sd", + "timestamp": "2026-03-07T11:00:00.000Z", + "currentModel": "gpt-4", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, bad_shutdown) + events = parse_events(p) + summary = build_session_summary(events) + # Shutdown was skipped โ†’ session is active + assert summary.is_active is True + assert summary.session_id == "test-session-001" + + def test_resumed_session_new_model_not_in_metrics(self, tmp_path: Path) -> None: + """Resumed session uses model not in shutdown metrics โ†’ new entry (line 226).""" + # Shutdown with model A, resume uses model B + shutdown_model_a = json.dumps( + { + "type": "session.shutdown", + "data": { + "shutdownType": "routine", + "totalPremiumRequests": 5, + "totalApiDurationMs": 1000, + "sessionStartTime": 0, + "modelMetrics": { + "claude-sonnet-4": { + "requests": {"count": 5, "cost": 5}, + "usage": { + "inputTokens": 1000, + "outputTokens": 200, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + }, + } + }, + "currentModel": "claude-sonnet-4", + }, + "id": "ev-sd", + "timestamp": "2026-03-07T11:00:00.000Z", + "currentModel": "gpt-5.1", + } + ) + resume_ev = json.dumps( + { + "type": "session.resume", + "data": {}, + "id": "ev-resume", + "timestamp": "2026-03-07T12:00:00.000Z", + } + ) + post_resume_msg = json.dumps( + { + "type": "assistant.message", + "data": { + "messageId": "m-post", + "content": "resumed", + "toolRequests": [], + "interactionId": "int-r", + "outputTokens": 300, + }, + "id": "ev-post", + "timestamp": "2026-03-07T12:01:00.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, _START_EVENT, _USER_MSG, shutdown_model_a, resume_ev, post_resume_msg + ) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.is_active is True + # Post-resume tokens go to active, not merged into model_metrics + assert summary.active_output_tokens == 300 + # gpt-5.1 should NOT be in historical model_metrics (it's post-resume activity) + assert "gpt-5.1" not in summary.model_metrics + # Original model metrics preserved + assert "claude-sonnet-4" in summary.model_metrics + + def test_active_session_tool_exec_validation_error(self, tmp_path: Path) -> None: + """Bad tool.execution_complete in active session โ†’ skipped (lines 252-253).""" + bad_tool = json.dumps( + { + "type": "tool.execution_complete", + "data": {"toolCallId": 999, "success": "not-bool"}, + "id": "ev-bad-tool", + "timestamp": "2026-03-07T10:02:00.000Z", + } + ) + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG, bad_tool) + events = parse_events(p) + summary = build_session_summary(events, config_path=tmp_path / "no-config.json") + assert summary.is_active is True + # No model could be extracted from the bad tool event + assert summary.model is None + + +# --------------------------------------------------------------------------- +# model_calls and user_messages +# --------------------------------------------------------------------------- + + +class TestModelCallsAndUserMessages: + """Tests for model_calls and user_messages fields.""" + + def test_active_session_counts_turn_starts(self, tmp_path: Path) -> None: + """Active session with 5 turn_starts โ†’ model_calls = 5.""" + opus_tool = json.dumps( + { + "type": "tool.execution_complete", + "data": { + "toolCallId": "tc-1", + "model": "claude-opus-4.6", + "interactionId": "int-1", + "success": True, + }, + "id": "ev-tool-opus", + "timestamp": "2026-03-07T10:01:07.000Z", + } + ) + turns = [ + json.dumps( + { + "type": "assistant.turn_start", + "data": {"turnId": str(i), "interactionId": "int-1"}, + "id": f"ev-turn-{i}", + "timestamp": f"2026-03-07T10:{i + 2:02d}:00.000Z", + } + ) + for i in range(5) + ] + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, opus_tool, *turns, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.is_active is True + assert summary.model_calls == 5 + assert summary.user_messages == 1 + assert summary.total_premium_requests == 0 + + def test_completed_session_counts_turn_starts(self, tmp_path: Path) -> None: + """Completed session records model_calls from turn_start events.""" + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _TURN_START_1, + _ASSISTANT_MSG, + _TURN_START_2, + _ASSISTANT_MSG_2, + _SHUTDOWN_EVENT, + ) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.is_active is False + assert summary.model_calls == 2 + assert summary.user_messages == 1 + assert summary.total_premium_requests == 5 + + def test_completed_session_uses_exact_premium_requests( + self, tmp_path: Path + ) -> None: + """Shutdown as last event โ†’ uses shutdown's totalPremiumRequests.""" + events, sdir = _completed_events(tmp_path) + summary = build_session_summary(events, session_dir=sdir) + assert summary.is_active is False + assert summary.total_premium_requests == 5 + + def test_active_session_zero_multiplier_model(self, tmp_path: Path) -> None: + """Active session using gpt-5-mini (0ร—) โ†’ no estimation, just raw counts.""" + free_tool = json.dumps( + { + "type": "tool.execution_complete", + "data": { + "toolCallId": "tc-free", + "model": "gpt-5-mini", + "interactionId": "int-1", + "success": True, + }, + "id": "ev-tool-free", + "timestamp": "2026-03-07T10:01:07.000Z", + } + ) + turns = [ + json.dumps( + { + "type": "assistant.turn_start", + "data": {"turnId": str(i), "interactionId": "int-1"}, + "id": f"ev-turn-{i}", + "timestamp": f"2026-03-07T10:{i + 2:02d}:00.000Z", + } + ) + for i in range(3) + ] + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, free_tool, *turns, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events) + assert summary.is_active is True + assert summary.model_calls == 3 + assert summary.total_premium_requests == 0 + + +# --------------------------------------------------------------------------- +# config.json model reading +# --------------------------------------------------------------------------- + + +class TestConfigModelReading: + """Tests for reading model from config.json for active sessions.""" + + def test_active_session_reads_config_model(self, tmp_path: Path) -> None: + """Active session with no tool exec reads model from config.json.""" + config = tmp_path / "config.json" + config.write_text('{"model": "claude-opus-4.6-1m"}', encoding="utf-8") + + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events, config_path=config) + assert summary.model == "claude-opus-4.6-1m" + + def test_tool_exec_model_takes_precedence(self, tmp_path: Path) -> None: + """Model from tool.execution_complete overrides config.json.""" + config = tmp_path / "config.json" + config.write_text('{"model": "gpt-5.1"}', encoding="utf-8") + + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG, _TOOL_EXEC) + events = parse_events(p) + summary = build_session_summary(events, config_path=config) + assert summary.model == "claude-sonnet-4" + + def test_missing_config_returns_none(self, tmp_path: Path) -> None: + """No config.json โ†’ model stays None.""" + config = tmp_path / "nonexistent" / "config.json" + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events, config_path=config) + assert summary.model is None + + def test_invalid_config_json(self, tmp_path: Path) -> None: + """Malformed config.json โ†’ model stays None.""" + config = tmp_path / "config.json" + config.write_text("NOT JSON{{{", encoding="utf-8") + + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events, config_path=config) + assert summary.model is None + + def test_config_without_model_key(self, tmp_path: Path) -> None: + """config.json without 'model' key โ†’ model stays None.""" + config = tmp_path / "config.json" + config.write_text('{"reasoning_effort": "high"}', encoding="utf-8") + + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT, _USER_MSG, _ASSISTANT_MSG) + events = parse_events(p) + summary = build_session_summary(events, config_path=config) + assert summary.model is None + + +# --------------------------------------------------------------------------- +# build_session_summary โ€” empty session (only session.start) +# --------------------------------------------------------------------------- + + +class TestBuildSessionSummaryEmptySession: + """Session with only a session.start event and nothing else.""" + + def test_is_active(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.is_active is True + + def test_session_id(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.session_id == "test-session-001" + + def test_zero_premium_requests(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.total_premium_requests == 0 + + def test_zero_output_tokens(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.active_output_tokens == 0 + + def test_zero_model_calls(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.model_calls == 0 + + def test_zero_user_messages(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.user_messages == 0 + + def test_name_from_plan_md(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + (tmp_path / "s" / "plan.md").write_text( + "# Empty Session\n\nNothing here.", encoding="utf-8" + ) + events = parse_events(p) + summary = build_session_summary( + events, session_dir=tmp_path / "s", config_path=Path("/dev/null") + ) + assert summary.name == "Empty Session" + + def test_no_plan_md_name_is_none(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary( + events, session_dir=tmp_path / "s", config_path=Path("/dev/null") + ) + assert summary.name is None + + def test_model_is_none_without_config(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.model is None + + def test_empty_model_metrics(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.model_metrics == {} + + def test_end_time_is_none(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.end_time is None + + def test_no_code_changes(self, tmp_path: Path) -> None: + p = tmp_path / "s" / "events.jsonl" + _write_events(p, _START_EVENT) + events = parse_events(p) + summary = build_session_summary(events, config_path=Path("/dev/null")) + assert summary.code_changes is None diff --git a/tests/copilot_usage/test_pricing.py b/tests/copilot_usage/test_pricing.py new file mode 100644 index 0000000..0c59c28 --- /dev/null +++ b/tests/copilot_usage/test_pricing.py @@ -0,0 +1,123 @@ +"""Tests for copilot_usage.pricing.""" + +from __future__ import annotations + +import warnings + +import pytest + +from copilot_usage.pricing import ( + KNOWN_PRICING, + ModelPricing, + PricingTier, + categorize_model, + lookup_model_pricing, +) + +# --------------------------------------------------------------------------- +# ModelPricing basics +# --------------------------------------------------------------------------- + + +class TestModelPricing: + def test_defaults(self) -> None: + p = ModelPricing(model_name="test-model") + assert p.multiplier == 1.0 + assert p.tier == PricingTier.STANDARD + + def test_explicit_values(self) -> None: + p = ModelPricing(model_name="opus", multiplier=50.0, tier=PricingTier.PREMIUM) + assert p.model_name == "opus" + assert p.multiplier == 50.0 + assert p.tier == PricingTier.PREMIUM + + +# --------------------------------------------------------------------------- +# KNOWN_PRICING registry +# --------------------------------------------------------------------------- + + +class TestKnownPricing: + def test_registry_not_empty(self) -> None: + assert len(KNOWN_PRICING) > 0 + + @pytest.mark.parametrize( + ("model", "expected_mult"), + [ + ("claude-sonnet-4", 1.0), + ("claude-opus-4.6", 3.0), + ("claude-opus-4.6-1m", 6.0), + ("claude-haiku-4.5", 0.33), + ("gpt-5.1-codex-max", 1.0), + ("gpt-4.1", 0.0), + ("gpt-5-mini", 0.0), + ("gemini-3-pro-preview", 1.0), + ], + ) + def test_known_multipliers(self, model: str, expected_mult: float) -> None: + assert KNOWN_PRICING[model].multiplier == expected_mult + + @pytest.mark.parametrize( + ("model", "expected_tier"), + [ + ("claude-opus-4.5", PricingTier.PREMIUM), + ("claude-sonnet-4.6", PricingTier.STANDARD), + ("claude-haiku-4.5", PricingTier.LIGHT), + ("gpt-5-mini", PricingTier.LIGHT), + ], + ) + def test_known_tiers(self, model: str, expected_tier: PricingTier) -> None: + assert KNOWN_PRICING[model].tier == expected_tier + + +# --------------------------------------------------------------------------- +# lookup_model_pricing +# --------------------------------------------------------------------------- + + +class TestLookupModelPricing: + def test_exact_match(self) -> None: + p = lookup_model_pricing("claude-sonnet-4.6") + assert p.multiplier == 1.0 + assert p.model_name == "claude-sonnet-4.6" + + def test_partial_match_model_longer(self) -> None: + """Model name is longer than any key โ€” still matches the longest prefix.""" + p = lookup_model_pricing("claude-opus-4.6-1m") + assert p.multiplier == 6.0 + + def test_partial_match_model_shorter(self) -> None: + """Model name is a prefix of a known key.""" + p = lookup_model_pricing("gemini-3-pro") + assert p.multiplier == 1.0 + + def test_unknown_model_warns(self) -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + p = lookup_model_pricing("totally-unknown-model-9000") + assert p.multiplier == 1.0 + assert p.tier == PricingTier.STANDARD + assert len(caught) == 1 + assert "Unknown model" in str(caught[0].message) + + def test_unknown_model_returns_name(self) -> None: + with warnings.catch_warnings(record=True): + warnings.simplefilter("always") + p = lookup_model_pricing("mystery") + assert p.model_name == "mystery" + + +# --------------------------------------------------------------------------- +# categorize_model +# --------------------------------------------------------------------------- + + +class TestCategorizeModel: + def test_premium(self) -> None: + assert categorize_model("claude-opus-4.6") == PricingTier.PREMIUM + + def test_standard(self) -> None: + assert categorize_model("gpt-5.4") == PricingTier.STANDARD + + def test_light(self) -> None: + assert categorize_model("claude-haiku-4.5") == PricingTier.LIGHT diff --git a/tests/copilot_usage/test_report.py b/tests/copilot_usage/test_report.py new file mode 100644 index 0000000..e596f05 --- /dev/null +++ b/tests/copilot_usage/test_report.py @@ -0,0 +1,1241 @@ +"""Tests for copilot_usage.report โ€” rendering helpers.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from io import StringIO +from unittest.mock import patch + +from rich.console import Console + +from copilot_usage.models import ( + CodeChanges, + EventType, + ModelMetrics, + RequestMetrics, + SessionEvent, + SessionSummary, + TokenUsage, +) +from copilot_usage.report import ( + format_duration, + format_tokens, + render_cost_view, + render_full_summary, + render_live_sessions, + render_summary, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_session( + *, + session_id: str = "abcdef1234567890", + name: str | None = "My Session", + model: str | None = "claude-sonnet-4", + start_time: datetime | None = None, + is_active: bool = True, + user_messages: int = 5, + model_calls: int = 3, + output_tokens: int = 1200, + cwd: str | None = "/home/user/project", +) -> SessionSummary: + metrics: dict[str, ModelMetrics] = {} + if model and output_tokens: + metrics[model] = ModelMetrics( + usage=TokenUsage(outputTokens=output_tokens), + ) + return SessionSummary( + session_id=session_id, + start_time=start_time, + name=name, + model=model, + is_active=is_active, + user_messages=user_messages, + model_calls=model_calls, + model_metrics=metrics, + cwd=cwd, + ) + + +def _capture_output(sessions: list[SessionSummary]) -> str: + """Render live sessions and capture the console output as a string.""" + buf: list[str] = [] + console = Console(file=None, force_terminal=False, width=120) + + with patch("copilot_usage.report.Console", return_value=console): + + def _capture_print(*args: object, **kwargs: object) -> None: + from io import StringIO + + sio = StringIO() + c = Console(file=sio, force_terminal=False, width=120) + c.print(*args, **kwargs) # type: ignore[arg-type] + buf.append(sio.getvalue()) + + console.print = _capture_print # type: ignore[method-assign] + render_live_sessions(sessions) + + return "\n".join(buf) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestRenderLiveSessions: + """Tests for render_live_sessions.""" + + def test_empty_list_shows_no_active(self) -> None: + output = _capture_output([]) + assert "No active Copilot sessions found" in output + + def test_all_completed_shows_no_active(self) -> None: + completed = _make_session(is_active=False) + output = _capture_output([completed]) + assert "No active Copilot sessions found" in output + + def test_active_session_shows_short_id(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session(start_time=now - timedelta(minutes=10)) + output = _capture_output([session]) + assert "abcdef12" in output + + def test_active_session_shows_name(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session( + name="Test Session", start_time=now - timedelta(minutes=5) + ) + output = _capture_output([session]) + assert "Test Session" in output + + def test_active_session_shows_model(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session(model="gpt-4", start_time=now - timedelta(minutes=5)) + output = _capture_output([session]) + assert "gpt-4" in output + + def test_active_session_shows_running_time_minutes(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session(start_time=now - timedelta(minutes=5, seconds=30)) + output = _capture_output([session]) + assert "5m" in output + + def test_active_session_shows_running_time_hours(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session(start_time=now - timedelta(hours=2, minutes=15)) + output = _capture_output([session]) + assert "2h 15m" in output + + def test_active_session_shows_message_count(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session(user_messages=42, start_time=now - timedelta(minutes=5)) + output = _capture_output([session]) + assert "42" in output + + def test_active_session_shows_output_tokens(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session( + output_tokens=15000, start_time=now - timedelta(minutes=5) + ) + output = _capture_output([session]) + assert "15,000" in output + + def test_active_session_shows_cwd(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session( + cwd="/home/user/work", start_time=now - timedelta(minutes=5) + ) + output = _capture_output([session]) + assert "/home/user/work" in output + + def test_missing_fields_show_dash(self) -> None: + session = _make_session( + name=None, model=None, cwd=None, start_time=None, output_tokens=0 + ) + output = _capture_output([session]) + # Should still render without errors + assert "abcdef12" in output + + def test_mixed_active_and_completed(self) -> None: + now = datetime.now(tz=UTC) + active = _make_session( + session_id="active__12345678", + start_time=now - timedelta(minutes=10), + is_active=True, + ) + completed = _make_session( + session_id="completed12345678", + is_active=False, + ) + output = _capture_output([active, completed]) + assert "active__" in output + assert "complete" not in output + + def test_multiple_active_sessions(self) -> None: + now = datetime.now(tz=UTC) + s1 = _make_session( + session_id="session_1_abcdefg", + name="First", + start_time=now - timedelta(minutes=5), + ) + s2 = _make_session( + session_id="session_2_hijklmn", + name="Second", + start_time=now - timedelta(hours=1), + ) + output = _capture_output([s1, s2]) + assert "session_" in output + assert "First" in output + assert "Second" in output + + def test_table_title_contains_active_indicator(self) -> None: + now = datetime.now(tz=UTC) + session = _make_session(start_time=now - timedelta(minutes=1)) + output = _capture_output([session]) + assert "Active Copilot Sessions" in output + + +# --------------------------------------------------------------------------- +# Helpers for session detail tests +# --------------------------------------------------------------------------- + + +def _capture_console(fn: object, *args: object, **kwargs: object) -> str: + """Call *fn* with a capturing Console and return the output string.""" + from io import StringIO + + sio = StringIO() + c = Console(file=sio, force_terminal=False, width=120) + fn(*args, target_console=c, **kwargs) # type: ignore[operator] + return sio.getvalue() + + +def _make_event( + event_type: str, + *, + data: dict[str, object] | None = None, + timestamp: datetime | None = None, + current_model: str | None = None, +) -> SessionEvent: + return SessionEvent( + type=event_type, + data=data or {}, + timestamp=timestamp, + currentModel=current_model, + ) + + +# --------------------------------------------------------------------------- +# Tests โ€” render_session_detail (recent events, shutdown cycles, aggregate) +# --------------------------------------------------------------------------- + + +class TestRenderRecentEvents: + """Tests for recent events display (via render_session_detail).""" + + def test_empty_events_shows_message(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(is_active=False) + output = _capture_console(render_session_detail, [], summary) + assert "No events to display" in output + + def test_user_message_shown(self) -> None: + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.USER_MESSAGE, + data={"content": "hello world"}, + timestamp=start + timedelta(seconds=30), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "user message" in output + assert "hello world" in output + + def test_only_last_10_events_shown(self) -> None: + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.USER_MESSAGE, + data={"content": f"msg-{i}"}, + timestamp=start + timedelta(seconds=i * 10), + ) + for i in range(15) + ] + output = _capture_console(render_session_detail, events, summary) + # First 5 should not appear; last 10 should + assert "msg-0" not in output + assert "msg-4" not in output + assert "msg-5" in output + assert "msg-14" in output + + def test_assistant_message_shows_tokens(self) -> None: + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.ASSISTANT_MESSAGE, + data={"content": "Sure!", "outputTokens": 42, "messageId": "m1"}, + timestamp=start + timedelta(seconds=5), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "assistant" in output + assert "tokens=42" in output + + def test_tool_execution_shows_name_and_success(self) -> None: + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.TOOL_EXECUTION_COMPLETE, + data={ + "toolCallId": "tc1", + "success": True, + "model": "gpt-4", + "toolTelemetry": { + "properties": {"tool_name": "bash"}, + }, + }, + timestamp=start + timedelta(seconds=10), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "tool" in output + assert "bash" in output + assert "โœ“" in output + assert "model=gpt-4" in output + + def test_event_without_timestamp_shows_dash(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(is_active=False) + events = [ + _make_event(EventType.USER_MESSAGE, data={"content": "hi"}), + ] + output = _capture_console(render_session_detail, events, summary) + assert "โ€”" in output + + def test_long_content_truncated(self) -> None: + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + long_msg = "A" * 200 + events = [ + _make_event( + EventType.USER_MESSAGE, + data={"content": long_msg}, + timestamp=start, + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "โ€ฆ" in output + assert long_msg not in output + + +# --------------------------------------------------------------------------- +# Tests โ€” render_session_detail +# --------------------------------------------------------------------------- + + +class TestRenderSessionDetail: + """Tests for render_session_detail.""" + + def test_renders_header_with_session_id(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(session_id="abc-123", is_active=False) + output = _capture_console(render_session_detail, [], summary) + assert "abc-123" in output + assert "Session Detail" in output + + def test_renders_aggregate_stats(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session( + output_tokens=5000, + model_calls=10, + user_messages=7, + is_active=False, + ) + output = _capture_console(render_session_detail, [], summary) + assert "Aggregate Stats" in output + assert "10" in output # model_calls + assert "7" in output # user_messages + + def test_renders_no_shutdown_cycles_message(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(is_active=False) + output = _capture_console(render_session_detail, [], summary) + assert "No shutdown cycles recorded" in output + + def test_renders_shutdown_cycle_table(self) -> None: + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.SESSION_SHUTDOWN, + data={ + "shutdownType": "normal", + "totalPremiumRequests": 5, + "totalApiDurationMs": 120_000, + "modelMetrics": { + "claude-sonnet-4": { + "requests": {"count": 3, "cost": 5}, + "usage": {"outputTokens": 800}, + } + }, + }, + timestamp=start + timedelta(hours=1), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "Shutdown Cycles" in output + assert "5" in output # premium requests + + def test_renders_recent_events_title(self) -> None: + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.USER_MESSAGE, + data={"content": "hello"}, + timestamp=start + timedelta(seconds=10), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "Recent Events" in output + assert "hello" in output + + def test_renders_code_changes(self) -> None: + from copilot_usage.report import render_session_detail + + summary = SessionSummary( + session_id="test-session", + code_changes=CodeChanges( + linesAdded=10, + linesRemoved=3, + filesModified=["src/main.py", "README.md"], + ), + ) + output = _capture_console(render_session_detail, [], summary) + assert "Code Changes" in output + assert "Files modified" in output + assert "2" in output + assert "+10" in output + assert "-3" in output + + def test_empty_events_and_no_code_changes(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(is_active=True) + output = _capture_console(render_session_detail, [], summary) + assert "Session Detail" in output + assert "No events to display" in output + + def test_active_session_shows_active_status(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(is_active=True) + output = _capture_console(render_session_detail, [], summary) + assert "active" in output + + def test_active_session_shows_active_period(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(is_active=True) + summary.active_model_calls = 3 + summary.active_user_messages = 2 + summary.active_output_tokens = 500 + output = _capture_console(render_session_detail, [], summary) + assert "Active Period" in output + + def test_completed_session_no_active_period(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(is_active=False) + output = _capture_console(render_session_detail, [], summary) + assert "Active Period" not in output + + def test_completed_session_shows_duration(self) -> None: + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + end = datetime(2025, 1, 1, 0, 5, 30, tzinfo=UTC) + summary = SessionSummary( + session_id="dur-test", + start_time=start, + end_time=end, + is_active=False, + ) + output = _capture_console(render_session_detail, [], summary) + assert "5m 30s" in output + + def test_session_name_displayed(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(name="My Cool Session", is_active=False) + output = _capture_console(render_session_detail, [], summary) + assert "My Cool Session" in output + + def test_unnamed_session_shows_unnamed(self) -> None: + from copilot_usage.report import render_session_detail + + summary = _make_session(name=None, is_active=False) + output = _capture_console(render_session_detail, [], summary) + assert "unnamed" in output + + +# --------------------------------------------------------------------------- +# format_tokens tests +# --------------------------------------------------------------------------- + + +def test_format_tokens_millions() -> None: + assert format_tokens(1_627_935) == "1.6M" + + +def test_format_tokens_thousands() -> None: + assert format_tokens(16_655) == "16.7K" + + +def test_format_tokens_small() -> None: + assert format_tokens(500) == "500" + + +def test_format_tokens_zero() -> None: + assert format_tokens(0) == "0" + + +def test_format_tokens_exact_boundary_million() -> None: + assert format_tokens(1_000_000) == "1.0M" + + +def test_format_tokens_exact_boundary_thousand() -> None: + assert format_tokens(1_000) == "1.0K" + + +# --------------------------------------------------------------------------- +# format_duration tests +# --------------------------------------------------------------------------- + + +def test_format_duration_minutes_seconds() -> None: + assert format_duration(389_114) == "6m 29s" + + +def test_format_duration_seconds_only() -> None: + assert format_duration(5_000) == "5s" + + +def test_format_duration_zero() -> None: + assert format_duration(0) == "0s" + + +def test_format_duration_negative() -> None: + assert format_duration(-100) == "0s" + + +def test_format_duration_hours() -> None: + assert format_duration(3_661_000) == "1h 1m 1s" + + +def test_format_duration_exact_minute() -> None: + assert format_duration(60_000) == "1m" + + +def test_format_duration_exact_hour() -> None: + assert format_duration(3_600_000) == "1h" + + +# --------------------------------------------------------------------------- +# render_summary helpers +# --------------------------------------------------------------------------- + +_OPUS_METRICS = ModelMetrics( + requests=RequestMetrics(count=53, cost=24), + usage=TokenUsage( + inputTokens=1_627_935, + outputTokens=16_655, + cacheReadTokens=1_424_086, + ), +) + +_SONNET_METRICS = ModelMetrics( + requests=RequestMetrics(count=10, cost=5), + usage=TokenUsage( + inputTokens=200_000, + outputTokens=5_000, + cacheReadTokens=100_000, + ), +) + + +def _make_summary_session( + *, + session_id: str = "abc-123", + name: str | None = "Test Session", + model: str | None = "claude-opus-4.6-1m", + start_time: datetime | None = None, + is_active: bool = False, + premium: int = 24, + duration_ms: int = 389_114, + metrics: dict[str, ModelMetrics] | None = None, + user_messages: int = 10, + model_calls: int = 5, +) -> SessionSummary: + if start_time is None: + start_time = datetime(2026, 3, 7, 15, 0, tzinfo=UTC) + return SessionSummary( + session_id=session_id, + start_time=start_time, + name=name, + model=model, + total_premium_requests=premium, + total_api_duration_ms=duration_ms, + model_metrics=metrics + if metrics is not None + else {"claude-opus-4.6-1m": _OPUS_METRICS}, + user_messages=user_messages, + model_calls=model_calls, + is_active=is_active, + ) + + +def _capture_summary( + sessions: list[SessionSummary], + since: datetime | None = None, + until: datetime | None = None, +) -> str: + """Capture Rich output from render_summary to a plain string.""" + buf = StringIO() + console = Console(file=buf, force_terminal=True, width=120) + + from copilot_usage import report as _mod + + original = _mod.Console # type: ignore[attr-defined] + _mod.Console = lambda **_kwargs: console # type: ignore[assignment,misc,return-value,attr-defined] + try: + render_summary(sessions, since=since, until=until) + finally: + _mod.Console = original # type: ignore[assignment,attr-defined] + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# render_summary tests +# --------------------------------------------------------------------------- + + +class TestRenderSummary: + """Tests for render_summary.""" + + def test_no_sessions(self) -> None: + output = _capture_summary([]) + assert "No sessions found" in output + + def test_single_session(self) -> None: + output = _capture_summary([_make_summary_session()]) + assert "Copilot Usage Summary" in output + assert "24" in output # premium requests + assert "1.6M" in output # input tokens + assert "16.7K" in output # output tokens + assert "6m 29s" in output # duration + assert "Test Session" in output + assert "Completed" in output + + def test_active_session(self) -> None: + output = _capture_summary([_make_summary_session(is_active=True)]) + assert "Active" in output + + def test_session_without_name_shows_id(self) -> None: + output = _capture_summary( + [_make_summary_session(name=None, session_id="abcdef123456XYZ")] + ) + assert "abcdef123456" in output + + def test_multiple_models(self) -> None: + session = _make_summary_session( + metrics={ + "claude-opus-4.6-1m": _OPUS_METRICS, + "claude-sonnet-4.5": _SONNET_METRICS, + } + ) + output = _capture_summary([session]) + assert "claude-opus-4.6-1m" in output + assert "claude-sonnet-4.5" in output + + def test_empty_model_metrics(self) -> None: + session = _make_summary_session(metrics={}) + output = _capture_summary([session]) + assert "Copilot Usage Summary" in output + assert "0" in output + + def test_since_filter(self) -> None: + old = _make_summary_session( + session_id="old", + name="Old Session", + start_time=datetime(2026, 1, 1, tzinfo=UTC), + ) + new = _make_summary_session( + session_id="new", + name="New Session", + start_time=datetime(2026, 6, 1, tzinfo=UTC), + ) + output = _capture_summary( + [old, new], + since=datetime(2026, 3, 1, tzinfo=UTC), + ) + assert "New Session" in output + assert "Old Session" not in output + + def test_until_filter(self) -> None: + old = _make_summary_session( + session_id="old", + name="Old Session", + start_time=datetime(2026, 1, 1, tzinfo=UTC), + ) + new = _make_summary_session( + session_id="new", + name="New Session", + start_time=datetime(2026, 6, 1, tzinfo=UTC), + ) + output = _capture_summary( + [old, new], + until=datetime(2026, 3, 1, tzinfo=UTC), + ) + assert "Old Session" in output + assert "New Session" not in output + + def test_no_start_time_excluded_by_filter(self) -> None: + s = SessionSummary(session_id="no-time", start_time=None) + output = _capture_summary([s], since=datetime(2026, 1, 1, tzinfo=UTC)) + assert "No sessions found" in output + + def test_sorts_newest_first(self) -> None: + s1 = _make_summary_session( + session_id="s1", + name="Older", + start_time=datetime(2026, 1, 1, tzinfo=UTC), + ) + s2 = _make_summary_session( + session_id="s2", + name="Newer", + start_time=datetime(2026, 6, 1, tzinfo=UTC), + ) + output = _capture_summary([s1, s2]) + pos_newer = output.index("Newer") + pos_older = output.index("Older") + assert pos_newer < pos_older + + def test_totals_aggregate_across_sessions(self) -> None: + s1 = _make_summary_session(session_id="s1", premium=10, duration_ms=100_000) + s2 = _make_summary_session(session_id="s2", premium=14, duration_ms=289_114) + output = _capture_summary([s1, s2]) + assert "24" in output # 10 + 14 premium + assert "2" in output # 2 sessions + + def test_zero_tokens_session(self) -> None: + session = _make_summary_session( + metrics={"claude-sonnet-4": ModelMetrics()}, + premium=0, + duration_ms=0, + ) + output = _capture_summary([session]) + assert "Copilot Usage Summary" in output + assert "0s" in output + + +# --------------------------------------------------------------------------- +# Coverage gap tests โ€” report.py +# --------------------------------------------------------------------------- + + +class TestReportCoverageGaps: + """Tests targeting specific uncovered lines in report.py.""" + + def test_detail_duration_under_60_seconds(self) -> None: + """_format_detail_duration with < 60s โ†’ returns '{n}s' (line 195).""" + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + end = datetime(2025, 1, 1, 0, 0, 45, tzinfo=UTC) + summary = SessionSummary( + session_id="short-dur", + start_time=start, + end_time=end, + is_active=False, + ) + output = _capture_console(render_session_detail, [], summary) + assert "45s" in output + + def test_event_type_label_tool_start(self) -> None: + """EventType.TOOL_EXECUTION_START โ†’ 'tool start' via render_session_detail.""" + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.TOOL_EXECUTION_START, + data={"toolCallId": "tc1", "toolName": "bash"}, + timestamp=start + timedelta(seconds=5), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "tool start" in output + + def test_event_type_label_turn_start(self) -> None: + """EventType.ASSISTANT_TURN_START โ†’ 'turn start' via render_session_detail.""" + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.ASSISTANT_TURN_START, + data={"turnId": "0"}, + timestamp=start + timedelta(seconds=2), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "turn start" in output + + def test_event_type_label_turn_end(self) -> None: + """EventType.ASSISTANT_TURN_END โ†’ 'turn end' via render_session_detail.""" + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.ASSISTANT_TURN_END, + data={"turnId": "0"}, + timestamp=start + timedelta(seconds=3), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "turn end" in output + + def test_event_type_label_unknown(self) -> None: + """Unknown event type โ†’ renders the raw type string.""" + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + "session.info", + data={"infoType": "mcp"}, + timestamp=start + timedelta(seconds=1), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "session.info" in output + + def test_user_message_empty_content(self) -> None: + """User message with empty content renders without error.""" + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.USER_MESSAGE, + data={"content": ""}, + timestamp=start + timedelta(seconds=1), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "user message" in output + + def test_assistant_message_no_tokens_no_content(self) -> None: + """Assistant message with 0 tokens and empty content renders.""" + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.ASSISTANT_MESSAGE, + data={"messageId": "m1", "content": "", "outputTokens": 0}, + timestamp=start + timedelta(seconds=1), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "assistant" in output + + def test_shutdown_event_empty_data(self) -> None: + """session.shutdown with empty shutdownType renders in shutdown cycles.""" + from copilot_usage.report import render_session_detail + + start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) + summary = _make_session(start_time=start, is_active=False) + events = [ + _make_event( + EventType.SESSION_SHUTDOWN, + data={ + "shutdownType": "", + "totalPremiumRequests": 0, + "totalApiDurationMs": 0, + }, + timestamp=start + timedelta(seconds=60), + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "Shutdown Cycles" in output + + def test_code_changes_all_zeros(self) -> None: + """CodeChanges with 0 lines and no files โ†’ early return (line 413).""" + from copilot_usage.report import render_session_detail + + summary = SessionSummary( + session_id="zero-cc", + code_changes=CodeChanges( + linesAdded=0, + linesRemoved=0, + filesModified=[], + ), + ) + output = _capture_console(render_session_detail, [], summary) + # Code Changes section should NOT appear + assert "Code Changes" not in output + + def test_summary_header_no_start_times(self) -> None: + """Sessions with no start_time โ†’ 'no sessions' subtitle (line 533).""" + session = SessionSummary(session_id="no-time", start_time=None) + output = _capture_summary([session]) + assert "no sessions" in output + + def test_session_table_empty_sessions(self) -> None: + """render_summary with sessions that all lack start_time still renders.""" + s = SessionSummary(session_id="no-time", start_time=None) + # This exercises _render_session_table with a session that has no + # start_time (the "โ€”" path on line 631). + output = _capture_summary([s]) + assert "no-time" in output or "no sessions" in output + + def test_session_detail_no_start_time_uses_first_event(self) -> None: + """render_session_detail with no start_time โ†’ uses first event timestamp.""" + from copilot_usage.report import render_session_detail + + event_time = datetime(2025, 3, 1, 10, 0, 0, tzinfo=UTC) + summary = SessionSummary(session_id="no-start", start_time=None) + events = [ + _make_event( + EventType.USER_MESSAGE, + data={"content": "hi"}, + timestamp=event_time, + ), + ] + output = _capture_console(render_session_detail, events, summary) + assert "Recent Events" in output + + +# --------------------------------------------------------------------------- +# Premium requests display (raw facts, no estimation) +# --------------------------------------------------------------------------- + + +class TestPremiumRequestsDisplay: + """Tests for premium requests display in summary.""" + + def test_active_session_shows_dash_for_premium(self) -> None: + """Active session with no shutdown data shows 'โ€”' for premium.""" + session = SessionSummary( + session_id="active-sess-1234", + name="Active Session", + model="claude-opus-4.6", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=True, + total_premium_requests=0, + user_messages=5, + ) + output = _capture_summary([session]) + assert "โ€”" in output + + def test_summary_shows_exact_for_completed(self) -> None: + """Completed session shows exact number without '~'.""" + session = SessionSummary( + session_id="done-sess-1234ab", + name="Completed Session", + model="claude-sonnet-4", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + total_premium_requests=42, + user_messages=10, + ) + output = _capture_summary([session]) + assert "42" in output + # Should not have "~42" for completed sessions + assert "~42" not in output + + +# --------------------------------------------------------------------------- +# render_full_summary capture helper +# --------------------------------------------------------------------------- + + +def _capture_full_summary(sessions: list[SessionSummary]) -> str: + """Capture Rich output from render_full_summary to a plain string.""" + buf = StringIO() + console = Console(file=buf, force_terminal=True, width=120) + render_full_summary(sessions, target_console=console) + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# render_full_summary tests +# --------------------------------------------------------------------------- + + +class TestRenderFullSummary: + """Tests for render_full_summary (two-section interactive view).""" + + def test_no_sessions(self) -> None: + output = _capture_full_summary([]) + assert "No sessions found" in output + + def test_historical_section_rendered(self) -> None: + session = SessionSummary( + session_id="hist-1234-abcdef", + name="HistSess", + model="claude-sonnet-4", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + total_premium_requests=10, + user_messages=3, + model_calls=5, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + requests=RequestMetrics(count=5, cost=10), + usage=TokenUsage( + inputTokens=500, outputTokens=1200, cacheReadTokens=100 + ), + ) + }, + ) + output = _capture_full_summary([session]) + assert "Historical Totals" in output + assert "HistSess" in output + + def test_active_section_rendered(self) -> None: + session = SessionSummary( + session_id="actv-5678-abcdef", + name="Active Session", + model="claude-opus-4.6", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=True, + user_messages=2, + model_calls=1, + active_model_calls=1, + active_user_messages=2, + active_output_tokens=500, + ) + output = _capture_full_summary([session]) + assert "Active Sessions" in output + assert "Active Session" in output + + def test_no_active_shows_panel(self) -> None: + session = SessionSummary( + session_id="done-9999-abcdef", + name="Done", + model="claude-sonnet-4", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + total_premium_requests=5, + user_messages=1, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + requests=RequestMetrics(count=1, cost=5), + usage=TokenUsage(outputTokens=100), + ) + }, + ) + output = _capture_full_summary([session]) + assert "No active sessions" in output + + def test_mixed_sessions(self) -> None: + completed = SessionSummary( + session_id="comp-aaaa-bbbbbb", + name="Completed One", + model="claude-sonnet-4", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + total_premium_requests=20, + user_messages=5, + model_calls=8, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + requests=RequestMetrics(count=8, cost=20), + usage=TokenUsage( + inputTokens=1000, outputTokens=2000, cacheReadTokens=200 + ), + ) + }, + ) + active = SessionSummary( + session_id="actv-cccc-dddddd", + name="Active One", + model="claude-opus-4.6", + start_time=datetime(2025, 1, 15, 12, 0, tzinfo=UTC), + is_active=True, + user_messages=3, + model_calls=2, + active_model_calls=2, + active_user_messages=3, + active_output_tokens=800, + ) + output = _capture_full_summary([completed, active]) + assert "Historical Totals" in output + assert "Active Sessions" in output + assert "Completed One" in output + assert "Active One" in output + + def test_no_historical_data(self) -> None: + """Session with no model_metrics and no premium reqs โ†’ no historical.""" + session = SessionSummary( + session_id="empty-1111-aaaaaa", + name="Empty", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=True, + active_model_calls=1, + active_user_messages=1, + active_output_tokens=100, + ) + output = _capture_full_summary([session]) + assert "No historical shutdown data" in output + + +# --------------------------------------------------------------------------- +# render_cost_view capture helper +# --------------------------------------------------------------------------- + + +def _capture_cost_view(sessions: list[SessionSummary]) -> str: + """Capture Rich output from render_cost_view to a plain string.""" + buf = StringIO() + console = Console(file=buf, force_terminal=True, width=120) + render_cost_view(sessions, target_console=console) + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# render_cost_view tests +# --------------------------------------------------------------------------- + + +class TestRenderCostView: + """Tests for render_cost_view (per-session, per-model cost breakdown).""" + + def test_no_sessions(self) -> None: + output = _capture_cost_view([]) + assert "No sessions found" in output + + def test_completed_session_cost(self) -> None: + session = SessionSummary( + session_id="cost-1111-abcdef", + name="Cost Session", + model="claude-sonnet-4", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + total_premium_requests=15, + model_calls=10, + user_messages=5, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + requests=RequestMetrics(count=10, cost=15), + usage=TokenUsage( + inputTokens=800, outputTokens=1500, cacheReadTokens=50 + ), + ) + }, + ) + output = _capture_cost_view([session]) + assert "Cost Breakdown" in output + assert "Cost Session" in output + assert "Grand Total" in output + assert "15" in output + + def test_active_session_shows_shutdown_row(self) -> None: + session = SessionSummary( + session_id="actv-2222-abcdef", + name="Active Cost", + model="claude-opus-4.6", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=True, + model_calls=5, + user_messages=3, + active_model_calls=3, + active_output_tokens=600, + model_metrics={ + "claude-opus-4.6": ModelMetrics( + requests=RequestMetrics(count=5, cost=10), + usage=TokenUsage(outputTokens=1000), + ) + }, + ) + output = _capture_cost_view([session]) + assert "Since last shutdown" in output + assert "N/A" in output + + def test_session_without_metrics(self) -> None: + session = SessionSummary( + session_id="nometric-3333-ab", + name="No Metrics", + model="gpt-5-mini", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + model_calls=2, + user_messages=1, + ) + output = _capture_cost_view([session]) + assert "No Metrics" in output + assert "โ€”" in output + + def test_multi_model_session(self) -> None: + session = SessionSummary( + session_id="multi-4444-abcde", + name="Multi Model", + model="claude-sonnet-4", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + model_calls=15, + user_messages=8, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + requests=RequestMetrics(count=10, cost=10), + usage=TokenUsage(outputTokens=2000), + ), + "claude-haiku-4.5": ModelMetrics( + requests=RequestMetrics(count=5, cost=2), + usage=TokenUsage(outputTokens=500), + ), + }, + ) + output = _capture_cost_view([session]) + assert "claude-sonnet-4" in output + assert "claude-haiku-4.5" in output + assert "Grand Total" in output diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/fixtures/0faecbdf-b889-4bca-a51a-5254f5488cb6/events.jsonl b/tests/e2e/fixtures/0faecbdf-b889-4bca-a51a-5254f5488cb6/events.jsonl new file mode 100644 index 0000000..96f533e --- /dev/null +++ b/tests/e2e/fixtures/0faecbdf-b889-4bca-a51a-5254f5488cb6/events.jsonl @@ -0,0 +1,15 @@ +{"type":"session.start","data":{"sessionId":"0faecbdf-b889-4bca-a51a-5254f5488cb6","version":1,"producer":"copilot-agent","copilotVersion":"1.0.2","startTime":"2026-03-07T15:15:20.265Z","context":{"cwd":"/Users/testuser"}},"id":"7283e3ac-5608-4a28-a37b-32b744733314","timestamp":"2026-03-07T15:15:20.267Z","parentId":null} +{"type":"user.message","data":{"content":"Sample message content 5.","transformedContent":"Transformed content 6.","attachments":[],"interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b"},"id":"d6648885-4c3a-45c0-9bcd-3649f368650b","timestamp":"2026-03-07T15:23:35.661Z","parentId":"f09411f5-bdea-41ee-aa24-f458a16ad19e"} +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b"},"id":"f8b779fb-5120-4abb-975b-a82a5802ad42","timestamp":"2026-03-07T15:23:36.243Z","parentId":"d6648885-4c3a-45c0-9bcd-3649f368650b"} +{"type":"assistant.message","data":{"messageId":"dca91a42-cdc2-4316-af3f-724930fbc5d8","content":"Sample message content 7.","toolRequests":[{"toolCallId":"toolu_vrtx_01BcqbS8Lv6dReRDKqt2SKaD","name":"ask_user","arguments":{"question":"Sample question text?","choices":["Command-line tool (CLI)","Web app with a dashboard","Both (CLI + web dashboard)"]},"type":"function"}],"interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b","reasoningOpaque":"opaque_reasoning_data_redacted","reasoningText":"Model reasoning for this turn.","outputTokens":373},"id":"161d0d5a-fd98-4057-b0fd-1c1e6b4cd8fa","timestamp":"2026-03-07T15:23:45.175Z","parentId":"d03b9461-0f7e-489d-a43d-a43eaa067984"} +{"type":"tool.execution_start","data":{"toolCallId":"toolu_vrtx_01BcqbS8Lv6dReRDKqt2SKaD","toolName":"ask_user","arguments":{"question":"Sample question text?","choices":["Command-line tool (CLI)","Web app with a dashboard","Both (CLI + web dashboard)"]}},"id":"ade11586-6892-4832-9bb7-1f3dbb9982c1","timestamp":"2026-03-07T15:23:45.175Z","parentId":"68898f18-b3af-4868-8f3a-a66ffb372291"} +{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_01BcqbS8Lv6dReRDKqt2SKaD","model":"claude-opus-4.6-1m","interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b","success":true,"result":{"content":"Sample message content 8.","detailedContent":"User responded: web app to start but eventually a phone app"},"toolTelemetry":{"properties":{"outcome":"answered"},"restrictedProperties":{"question":"Sample question text?","choices":"[\"Option A\",\"Option B\",\"Option C\"]","answer":"Sample answer text."}}},"id":"ebcf076d-804f-42b4-8133-d3678fe9ed04","timestamp":"2026-03-07T15:25:43.187Z","parentId":"ae4415e6-0d0c-49a8-ae8e-e23677af9e7f"} +{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"73eccdd3-d143-469a-8a3e-de4ce743eaf4","timestamp":"2026-03-07T15:25:43.189Z","parentId":"ebcf076d-804f-42b4-8133-d3678fe9ed04"} +{"type":"assistant.turn_start","data":{"turnId":"1","interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b"},"id":"cba998c0-ccda-4caa-a92e-7195f8b803c3","timestamp":"2026-03-07T15:25:43.190Z","parentId":"73eccdd3-d143-469a-8a3e-de4ce743eaf4"} +{"type":"assistant.message","data":{"messageId":"ef6831f6-479c-4f72-8fa5-b6fa9dd93496","content":"","toolRequests":[{"toolCallId":"toolu_vrtx_01J7ea292LGnPUMeJvnRLjTS","name":"ask_user","arguments":{"question":"Sample question text?","choices":["React + Node.js/Express (Recommended)","Next.js (React full-stack)","Python (Flask/Django) + vanilla JS","No preference \u2014 pick what's best"]},"type":"function"}],"interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b","outputTokens":121},"id":"c178d697-003b-4c75-ad93-0244bf99348e","timestamp":"2026-03-07T15:25:46.939Z","parentId":"b54b7c57-c50e-4ce4-8bb8-aa07ba02c46b"} +{"type":"tool.execution_start","data":{"toolCallId":"toolu_vrtx_01J7ea292LGnPUMeJvnRLjTS","toolName":"ask_user","arguments":{"question":"Sample question text?","choices":["React + Node.js/Express (Recommended)","Next.js (React full-stack)","Python (Flask/Django) + vanilla JS","No preference \u2014 pick what's best"]}},"id":"f2e36711-ef81-4b2b-a435-b7d3b6577976","timestamp":"2026-03-07T15:25:46.940Z","parentId":"c178d697-003b-4c75-ad93-0244bf99348e"} +{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_01J7ea292LGnPUMeJvnRLjTS","model":"claude-opus-4.6-1m","interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b","success":true,"result":{"content":"Sample message content 9.","detailedContent":"User responded: react and node are not bad, but dont i need a backend?"},"toolTelemetry":{"properties":{"outcome":"answered"},"restrictedProperties":{"question":"Sample question text?","choices":"[\"Option A\",\"Option B\",\"Option C\"]","answer":"Sample answer text."}}},"id":"df28dd7e-407d-48d4-a846-34b7b7f03496","timestamp":"2026-03-07T15:26:12.079Z","parentId":"a362e024-93e0-4b2f-a666-f5a34f4f157d"} +{"type":"assistant.turn_end","data":{"turnId":"1"},"id":"292d9d1a-c0c7-452e-8f5f-8c2211dbf633","timestamp":"2026-03-07T15:26:12.081Z","parentId":"df28dd7e-407d-48d4-a846-34b7b7f03496"} +{"type":"assistant.turn_start","data":{"turnId":"2","interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b"},"id":"a505dffb-462f-43a8-ab2c-0f03ecd71c3a","timestamp":"2026-03-07T15:26:12.081Z","parentId":"292d9d1a-c0c7-452e-8f5f-8c2211dbf633"} +{"type":"assistant.message","data":{"messageId":"0dc6d330-8f5c-4435-a455-98ca6f3db20d","content":"Sample message content 10.","toolRequests":[{"toolCallId":"toolu_vrtx_0156zSh27so1uLj9p1GriHgv","name":"ask_user","arguments":{"question":"Sample question text?","choices":["Natural language strategy notes (AI interprets them)","Specific price/threshold alerts","Portfolio allocation rules","A mix of all of these"]},"type":"function"}],"interactionId":"c0c803cf-dc15-48db-8c12-6d3750ee200b","outputTokens":259},"id":"a830f6b0-8853-4afa-8426-e367704a607b","timestamp":"2026-03-07T15:26:18.008Z","parentId":"cd870055-921e-463a-8f3c-a2a56c061c0b"} +{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":504,"totalApiDurationMs":9251391,"sessionStartTime":1772896520265,"codeChanges":{"linesAdded":3442,"linesRemoved":334,"filesModified":["/Users/testuser/test_github_models.sh","/Users/testuser/.copilot/session-state/0faecbdf-b889-4bca-a51a-5254f5488cb6/plan.md","/Users/testuser/.copilot/session-state/0faecbdf-b889-4bca-a51a-5254f5488cb6/files/architecture.html","/Users/testuser/projects/stock-tracker/backend/pyproject.toml","/Users/testuser/projects/stock-tracker/backend/app/config.py","/Users/testuser/projects/stock-tracker/backend/app/models/__init__.py","/Users/testuser/projects/stock-tracker/backend/app/main.py","/Users/testuser/projects/stock-tracker/backend/alembic/env.py","/Users/testuser/projects/stock-tracker/backend/app/tests/conftest.py","/Users/testuser/projects/stock-tracker/backend/app/middleware/cors.py","/Users/testuser/projects/stock-tracker/backend/app/routes/alerts.py","/Users/testuser/projects/stock-tracker/Makefile","/Users/testuser/projects/stock-tracker/backend/app/services/market_service.py","/Users/testuser/projects/stock-tracker/backend/app/middleware/errors.py","/Users/testuser/projects/stock-tracker/backend/app/services/auth_service.py","/Users/testuser/projects/stock-tracker/backend/app/schemas/user.py","/Users/testuser/projects/stock-tracker/backend/app/schemas/reflection.py","/Users/testuser/projects/stock-tracker/backend/app/schemas/guidance.py","/Users/testuser/projects/stock-tracker/backend/app/schemas/watchlist.py","/Users/testuser/projects/stock-tracker/backend/tests/conftest.py","/Users/testuser/projects/stock-tracker/backend/app/services/prompt_builder.py","/Users/testuser/projects/stock-tracker/backend/app/services/reflection_service.py","/Users/testuser/projects/stock-tracker/backend/tests/unit/test_llm_service.py","/Users/testuser/projects/stock-tracker/backend/tests/integration/test_reflection_flow.py"]},"modelMetrics":{"claude-opus-4.6-1m":{"requests":{"count":592,"cost":504},"usage":{"inputTokens":139858863,"outputTokens":207322,"cacheReadTokens":133584312,"cacheWriteTokens":0}}},"currentModel":"claude-opus-4.6-1m"},"id":"41bf7826-a663-436b-a3c2-b36922dd4fb1","timestamp":"2026-03-09T17:34:33.684Z","parentId":"e0176500-308a-4b33-816e-ca5ef6c733b1"} diff --git a/tests/e2e/fixtures/4a547040-0318-44a4-a514-7ac9cbecc17e/events.jsonl b/tests/e2e/fixtures/4a547040-0318-44a4-a514-7ac9cbecc17e/events.jsonl new file mode 100644 index 0000000..284ceeb --- /dev/null +++ b/tests/e2e/fixtures/4a547040-0318-44a4-a514-7ac9cbecc17e/events.jsonl @@ -0,0 +1,22 @@ +{"type":"session.start","data":{"sessionId":"4a547040-0318-44a4-a514-7ac9cbecc17e","version":1,"producer":"copilot-agent","copilotVersion":"1.0.2","startTime":"2026-03-07T00:59:57.423Z","context":{"cwd":"/Users/testuser"}},"id":"bc5df6f2-8304-4741-9901-f4203504172f","timestamp":"2026-03-07T00:59:57.425Z","parentId":null} +{"type":"session.info","data":{"infoType":"folder_trust","message":"Folder /Users/testuser has been added to trusted folders."},"id":"5d7d19b5-1e5f-4f25-a5eb-cb399cf66afc","timestamp":"2026-03-07T01:00:05.991Z","parentId":"bc5df6f2-8304-4741-9901-f4203504172f"} +{"type":"session.error","data":{"errorType":"authentication","message":"You must be logged in to send messages. Please run /login"},"id":"7ead4a4e-3087-47b8-a8e0-f47a3c416b44","timestamp":"2026-03-07T01:00:13.923Z","parentId":"39b371b1-69bc-48a5-97b6-e5733a2db549"} +{"type":"session.info","data":{"infoType":"mcp","message":"GitHub MCP Server: Connected"},"id":"b0f74224-49d6-4492-a127-2232911e9413","timestamp":"2026-03-07T01:00:46.729Z","parentId":"7ead4a4e-3087-47b8-a8e0-f47a3c416b44"} +{"type":"session.info","data":{"infoType":"authentication","message":"Signed in successfully as testuser!"},"id":"c79a6d12-6fd5-4b75-82d8-fc3988f11448","timestamp":"2026-03-07T01:00:46.729Z","parentId":"b0f74224-49d6-4492-a127-2232911e9413"} +{"type":"session.model_change","data":{"newModel":"claude-opus-4.6-1m"},"id":"a2ea8346-3f67-482c-a271-cf6a5bec9172","timestamp":"2026-03-07T01:18:58.464Z","parentId":"cf57c9dc-9ff5-4eae-acba-4f1e0fd0d6e4"} +{"type":"session.info","data":{"infoType":"model","message":"Model changed to: Claude Opus 4.6 (1M context)(Internal only) (high)"},"id":"00f56ce8-a10b-4c27-b6df-644f43b5a8eb","timestamp":"2026-03-07T01:18:58.465Z","parentId":"a2ea8346-3f67-482c-a271-cf6a5bec9172"} +{"type":"user.message","data":{"content":"Sample message content 11.","transformedContent":"Transformed content 12.","attachments":[],"interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e"},"id":"60bf807b-9ce4-417a-a045-fff7a2e8699a","timestamp":"2026-03-07T01:19:39.585Z","parentId":"da71e654-da90-485f-97cc-e14d7a6096fa"} +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e"},"id":"d92b2815-68f5-4953-95a3-69958096d91b","timestamp":"2026-03-07T01:19:40.209Z","parentId":"60bf807b-9ce4-417a-a045-fff7a2e8699a"} +{"type":"assistant.message","data":{"messageId":"1dd7c178-ed26-4f94-85c7-5c40acb1bd7f","content":"\n\n","toolRequests":[{"toolCallId":"toolu_vrtx_019REeYN1fV9a4vQ9GcyZaL4","name":"report_intent","arguments":{"intent":"Planning iPhone app"},"type":"function"},{"toolCallId":"toolu_vrtx_01ThGGHY1fU4YkDoNkgyhxJg","name":"ask_user","arguments":{"question":"Sample question text?","allow_freeform":true},"type":"function"}],"interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e","reasoningOpaque":"opaque_reasoning_data_redacted","reasoningText":"Model reasoning for this turn.","outputTokens":205},"id":"34ddd054-8239-4147-9cbd-30f2efeb1d7d","timestamp":"2026-03-07T01:19:45.750Z","parentId":"4acb0315-a92b-4ae0-8be3-9c4919f0960e"} +{"type":"tool.execution_start","data":{"toolCallId":"toolu_vrtx_019REeYN1fV9a4vQ9GcyZaL4","toolName":"report_intent","arguments":{"intent":"Planning iPhone app"}},"id":"64d626ba-a9c3-4c90-be59-78b7f6fc3b42","timestamp":"2026-03-07T01:19:45.751Z","parentId":"7b508bc7-43bb-4c8a-be79-7293192e5810"} +{"type":"tool.execution_start","data":{"toolCallId":"toolu_vrtx_01ThGGHY1fU4YkDoNkgyhxJg","toolName":"ask_user","arguments":{"question":"Sample question text?","allow_freeform":true}},"id":"c0ec8041-fedc-40a4-9ffd-bf89d85a952c","timestamp":"2026-03-07T01:19:45.751Z","parentId":"64d626ba-a9c3-4c90-be59-78b7f6fc3b42"} +{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_019REeYN1fV9a4vQ9GcyZaL4","model":"claude-opus-4.6-1m","interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e","success":true,"result":{"content":"Intent logged","detailedContent":"Planning iPhone app"},"toolTelemetry":{}},"id":"d26a3c56-5330-4db8-a5b4-cb2573e736b5","timestamp":"2026-03-07T01:19:45.752Z","parentId":"553c9fbf-5d24-4c08-a876-37825b689f76"} +{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_01ThGGHY1fU4YkDoNkgyhxJg","model":"claude-opus-4.6-1m","interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e","success":true,"result":{"content":"Sample message content 13.","detailedContent":"User responded: what do you mean?"},"toolTelemetry":{"properties":{"outcome":"answered"},"restrictedProperties":{"question":"Sample question text?","answer":"Sample answer text."}}},"id":"4f2139bf-d22c-472a-be4e-67b5215f9f73","timestamp":"2026-03-07T01:19:52.913Z","parentId":"a9725677-af55-460f-8c6a-2c4da395004c"} +{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"5108d7cd-a6a3-4160-982d-f28704451dc6","timestamp":"2026-03-07T01:19:52.915Z","parentId":"4f2139bf-d22c-472a-be4e-67b5215f9f73"} +{"type":"assistant.turn_start","data":{"turnId":"1","interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e"},"id":"16bc7951-24a9-43ff-9d94-400660f1da9b","timestamp":"2026-03-07T01:19:52.916Z","parentId":"5108d7cd-a6a3-4160-982d-f28704451dc6"} +{"type":"assistant.message","data":{"messageId":"4dddab48-055e-40be-bae6-2d2518edd9b9","content":"","toolRequests":[{"toolCallId":"toolu_vrtx_01T5y9SYqDTisFQjquJms2sX","name":"ask_user","arguments":{"question":"Sample question text?","allow_freeform":true},"type":"function"}],"interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e","outputTokens":125},"id":"b5ca5878-0c84-4157-ade9-43b438590fa7","timestamp":"2026-03-07T01:19:57.306Z","parentId":"8f04a0ec-86ea-4fd7-9317-d2dad4d59b49"} +{"type":"tool.execution_start","data":{"toolCallId":"toolu_vrtx_01T5y9SYqDTisFQjquJms2sX","toolName":"ask_user","arguments":{"question":"Sample question text?","allow_freeform":true}},"id":"9a05c67e-3363-48d5-912b-31655c066dbd","timestamp":"2026-03-07T01:19:57.306Z","parentId":"b5ca5878-0c84-4157-ade9-43b438590fa7"} +{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_01T5y9SYqDTisFQjquJms2sX","model":"claude-opus-4.6-1m","interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e","success":true,"result":{"content":"Sample message content 14.","detailedContent":"User responded: a hybrid between a game and a workout tracker"},"toolTelemetry":{"properties":{"outcome":"answered"},"restrictedProperties":{"question":"Sample question text?","answer":"Sample answer text."}}},"id":"84891ed1-4ab8-4506-862c-1af2aae497ef","timestamp":"2026-03-07T01:20:17.469Z","parentId":"cb1186d4-290d-4fc0-9cf9-46079a1769bd"} +{"type":"assistant.turn_end","data":{"turnId":"1"},"id":"f1e91aca-0412-47bc-abb5-66a99a31c72a","timestamp":"2026-03-07T01:20:17.471Z","parentId":"84891ed1-4ab8-4506-862c-1af2aae497ef"} +{"type":"assistant.turn_start","data":{"turnId":"2","interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e"},"id":"09d6f050-f69a-4ca7-bf20-e8022cc3b162","timestamp":"2026-03-07T01:20:17.472Z","parentId":"f1e91aca-0412-47bc-abb5-66a99a31c72a"} +{"type":"assistant.message","data":{"messageId":"1ee46acc-18c0-46f7-896a-b48eb519364b","content":"","toolRequests":[{"toolCallId":"toolu_vrtx_01VjJHCbS9fyPmEpRtxDdmp6","name":"ask_user","arguments":{"allow_freeform":true,"question":"Sample question text?","choices":["Beginner \u2014 never used Xcode or Swift","Some experience \u2014 built a small app or followed tutorials","Experienced \u2014 comfortable with Swift/Xcode"]},"type":"function"}],"interactionId":"133ea5e2-3a11-4c50-8bab-6b8c102d191e","outputTokens":160},"id":"0c8b7dc5-a844-4616-8d5d-b79525273ca8","timestamp":"2026-03-07T01:20:21.367Z","parentId":"94f5f5cb-0609-438f-946a-9ee1f7e0fb47"} diff --git a/tests/e2e/fixtures/b5df8a34-87f0-46d5-b865-83e89095b8dc/events.jsonl b/tests/e2e/fixtures/b5df8a34-87f0-46d5-b865-83e89095b8dc/events.jsonl new file mode 100644 index 0000000..2e905e7 --- /dev/null +++ b/tests/e2e/fixtures/b5df8a34-87f0-46d5-b865-83e89095b8dc/events.jsonl @@ -0,0 +1,18 @@ +{"type":"session.start","data":{"sessionId":"b5df8a34-87f0-46d5-b865-83e89095b8dc","version":1,"producer":"copilot-agent","copilotVersion":"1.0.2","startTime":"2026-03-08T01:11:20.932Z","context":{"cwd":"/Users/testuser"}},"id":"2450edf9-5a8b-4099-91fd-3cdbdc33e354","timestamp":"2026-03-08T01:11:20.934Z","parentId":null} +{"type":"session.error","data":{"errorType":"authorization","message":"Authorization error, you may need to run /login","statusCode":401},"id":"029f85c7-5630-42fc-9f33-2300721f9efa","timestamp":"2026-03-08T01:12:37.937Z","parentId":"0f32058f-0def-4314-89ab-2b8b59f58180"} +{"type":"session.info","data":{"infoType":"mcp","message":"GitHub MCP Server: Connected"},"id":"5b8f1954-0da9-46f3-9191-b1ab45d9b8ba","timestamp":"2026-03-08T01:13:12.747Z","parentId":"f9df5736-a253-451e-9bd3-fd1dad3f3bb8"} +{"type":"session.info","data":{"infoType":"authentication","message":"Signed in successfully as testuser!"},"id":"3306f49c-856e-475a-9238-339e80abb0fe","timestamp":"2026-03-08T01:13:12.747Z","parentId":"5b8f1954-0da9-46f3-9191-b1ab45d9b8ba"} +{"type":"user.message","data":{"content":"Sample message content 1.","transformedContent":"Transformed content 2.","attachments":[],"interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589"},"id":"97157327-274f-402c-ac16-b49b97fa47a9","timestamp":"2026-03-08T01:13:17.469Z","parentId":"f29f677d-1f49-4e8d-8419-0b632d15dd9a"} +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589"},"id":"acac45df-3e21-4f40-980b-7511b87bd653","timestamp":"2026-03-08T01:13:17.677Z","parentId":"97157327-274f-402c-ac16-b49b97fa47a9"} +{"type":"assistant.message","data":{"messageId":"543531aa-71ce-44b3-b9ea-7df636f71f5f","content":"\n\n","toolRequests":[{"toolCallId":"toolu_vrtx_01QvrE17JFFGrQjyxnjNydLC","name":"ask_user","arguments":{"question":"Sample question text?","choices":["LLM/AI tokens (OpenAI, Anthropic, GitHub Models, etc.)","GitHub Copilot usage (local CLI + Copilot on github.com)","GitHub API rate-limit tokens","Something else"]},"type":"function"}],"interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589","reasoningOpaque":"opaque_reasoning_data_redacted","reasoningText":"Model reasoning for this turn.","outputTokens":413},"id":"ccd56ba8-8bb8-44aa-b79c-08ff5f0b45c0","timestamp":"2026-03-08T01:13:27.642Z","parentId":"d424548c-c747-4d28-bfd7-da29fc536258"} +{"type":"tool.execution_start","data":{"toolCallId":"toolu_vrtx_01QvrE17JFFGrQjyxnjNydLC","toolName":"ask_user","arguments":{"question":"Sample question text?","choices":["LLM/AI tokens (OpenAI, Anthropic, GitHub Models, etc.)","GitHub Copilot usage (local CLI + Copilot on github.com)","GitHub API rate-limit tokens","Something else"]}},"id":"788b9932-df5b-43ff-b01a-54202abc4c6b","timestamp":"2026-03-08T01:13:27.642Z","parentId":"788863af-593c-42ae-ad62-002d02de6d2e"} +{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_01QvrE17JFFGrQjyxnjNydLC","model":"claude-opus-4.6-1m","interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589","success":true,"result":{"content":"Sample message content 3.","detailedContent":"User selected: GitHub Copilot usage (local CLI + Copilot on github.com)"},"toolTelemetry":{"properties":{"outcome":"answered"},"restrictedProperties":{"question":"Sample question text?","choices":"[\"Option A\",\"Option B\",\"Option C\"]","answer":"Sample answer text."}}},"id":"af98be55-3a7b-425e-9e05-0f2f880e94e6","timestamp":"2026-03-08T01:13:35.473Z","parentId":"1f0804d5-8a93-4bfc-a60e-2d4770dd7973"} +{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"5817a3ab-bba2-4bbf-99de-ee80a88ee49f","timestamp":"2026-03-08T01:13:35.476Z","parentId":"af98be55-3a7b-425e-9e05-0f2f880e94e6"} +{"type":"assistant.turn_start","data":{"turnId":"1","interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589"},"id":"0a3d6854-9772-4e64-9464-0cece947f6af","timestamp":"2026-03-08T01:13:35.477Z","parentId":"5817a3ab-bba2-4bbf-99de-ee80a88ee49f"} +{"type":"assistant.message","data":{"messageId":"dba67034-dc75-4fa3-a8fe-2b294152d201","content":"","toolRequests":[{"toolCallId":"toolu_vrtx_01CfcEZckzE3qUrR6k97KG5X","name":"ask_user","arguments":{"question":"Sample question text?","choices":["Request counts and frequency per session","Estimated token counts (input/output)","Cost estimates based on model pricing","All of the above \u2014 full usage dashboard","Just a simple summary of activity across sessions"]},"type":"function"}],"interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589","outputTokens":150},"id":"91cfe694-575a-4a3a-ab4c-a879b6cbed85","timestamp":"2026-03-08T01:13:39.574Z","parentId":"0593041a-a768-4439-b24a-cb139f23f4fb"} +{"type":"tool.execution_start","data":{"toolCallId":"toolu_vrtx_01CfcEZckzE3qUrR6k97KG5X","toolName":"ask_user","arguments":{"question":"Sample question text?","choices":["Request counts and frequency per session","Estimated token counts (input/output)","Cost estimates based on model pricing","All of the above \u2014 full usage dashboard","Just a simple summary of activity across sessions"]}},"id":"b95f29ab-f706-4305-9e23-77fedd331c15","timestamp":"2026-03-08T01:13:39.574Z","parentId":"91cfe694-575a-4a3a-ab4c-a879b6cbed85"} +{"type":"tool.execution_complete","data":{"toolCallId":"toolu_vrtx_01CfcEZckzE3qUrR6k97KG5X","model":"claude-opus-4.6-1m","interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589","success":true,"result":{"content":"Sample message content 4.","detailedContent":"User selected: All of the above \u2014 full usage dashboard"},"toolTelemetry":{"properties":{"outcome":"answered"},"restrictedProperties":{"question":"Sample question text?","choices":"[\"Option A\",\"Option B\",\"Option C\"]","answer":"Sample answer text."}}},"id":"8f79a922-451d-494f-b2e9-4e8ebe48c666","timestamp":"2026-03-08T01:13:50.505Z","parentId":"1e7e2c72-a5d1-49aa-9c54-9d64ef23053e"} +{"type":"assistant.turn_end","data":{"turnId":"1"},"id":"97e2ad9a-09e1-443d-a85f-45b2292e672d","timestamp":"2026-03-08T01:13:50.507Z","parentId":"8f79a922-451d-494f-b2e9-4e8ebe48c666"} +{"type":"assistant.turn_start","data":{"turnId":"2","interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589"},"id":"3dba101e-0e8a-4ace-ad7f-7d743099ccd0","timestamp":"2026-03-08T01:13:50.507Z","parentId":"97e2ad9a-09e1-443d-a85f-45b2292e672d"} +{"type":"assistant.message","data":{"messageId":"0468095e-58a0-42d9-8fc7-137bdf5f76bb","content":"","toolRequests":[{"toolCallId":"toolu_vrtx_01Ek7QiSntzkqqeGuyN4zt2u","name":"ask_user","arguments":{"question":"Sample question text?","choices":["CLI tool (run a command, see a report in the terminal)","Web dashboard (local server with a browser UI)","Python script that generates reports (text/CSV/JSON)","TUI (terminal UI with live updating)"]},"type":"function"}],"interactionId":"53f6b35d-c8a7-4fb5-a93a-b97bc576f589","outputTokens":127},"id":"cc443f3c-177c-45f7-9397-5459774cc293","timestamp":"2026-03-08T01:13:54.096Z","parentId":"e571aef9-5b7b-4280-ac06-55213d0b04c3"} +{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":288,"totalApiDurationMs":4385999,"sessionStartTime":1772932280932,"codeChanges":{"linesAdded":1877,"linesRemoved":71,"filesModified":["/Users/testuser/.copilot/session-state/b5df8a34-87f0-46d5-b865-83e89095b8dc/plan.md","/Users/testuser/cross-project-standards.md","/Users/testuser/project-standards/README.md","/Users/testuser/projects/project-standards/README.md"]},"modelMetrics":{"claude-opus-4.6-1m":{"requests":{"count":235,"cost":288},"usage":{"inputTokens":32009299,"outputTokens":93601,"cacheReadTokens":29562835,"cacheWriteTokens":0}},"claude-haiku-4.5":{"requests":{"count":24,"cost":0},"usage":{"inputTokens":416124,"outputTokens":10560,"cacheReadTokens":332146,"cacheWriteTokens":0}},"claude-opus-4.6":{"requests":{"count":250,"cost":0},"usage":{"inputTokens":10939428,"outputTokens":100817,"cacheReadTokens":10590172,"cacheWriteTokens":0}}},"currentModel":"claude-opus-4.6-1m"},"id":"fe9ebba9-5424-4037-b790-cdb5a84b2bcc","timestamp":"2026-03-09T17:34:49.943Z","parentId":"b189a59f-115b-4ab8-a46d-a5d84295aded"} diff --git a/tests/e2e/fixtures/corrupt-session/events.jsonl b/tests/e2e/fixtures/corrupt-session/events.jsonl new file mode 100644 index 0000000..ff8b2a6 --- /dev/null +++ b/tests/e2e/fixtures/corrupt-session/events.jsonl @@ -0,0 +1,8 @@ +{"type":"session.start","data":{"sessionId":"corrupt0-0000-0000-0000-000000000000","version":1,"producer":"copilot-agent","copilotVersion":"1.0.2","startTime":"2026-03-07T08:00:00.000Z","context":{"cwd":"/Users/testuser"}},"id":"c-start","timestamp":"2026-03-07T08:00:00.000Z","parentId":null} +{"type":"user.message","data":{"content":"First message.","transformedContent":"First message.","attachments":[],"interactionId":"c-int-1"},"id":"c-user1","timestamp":"2026-03-07T08:01:00.000Z","parentId":"c-start"} +THIS IS NOT VALID JSON AT ALL {{{ +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"c-int-1"},"id":"c-turn1","timestamp":"2026-03-07T08:01:01.000Z","parentId":"c-user1"} +ANOTHER CORRUPT LINE !!! +{"type":"assistant.message","data":{"messageId":"c-msg-1","content":"Reply to first.","toolRequests":[],"interactionId":"c-int-1","outputTokens":100},"id":"c-asst1","timestamp":"2026-03-07T08:01:05.000Z","parentId":"c-turn1"} +{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"c-turn-end1","timestamp":"2026-03-07T08:01:06.000Z","parentId":"c-asst1"} +{"type":"session.shutdown","data":{"shutdownType":"normal","totalPremiumRequests":2,"totalApiDurationMs":1000,"sessionStartTime":0,"modelMetrics":{"gpt-5-mini":{"requests":{"count":2,"cost":0},"usage":{"inputTokens":500,"outputTokens":100,"cacheReadTokens":0,"cacheWriteTokens":0}}},"currentModel":"gpt-5-mini"},"id":"c-shutdown","timestamp":"2026-03-07T09:00:00.000Z","parentId":"c-asst1","currentModel":"gpt-5-mini"} diff --git a/tests/e2e/fixtures/empty-session/events.jsonl b/tests/e2e/fixtures/empty-session/events.jsonl new file mode 100644 index 0000000..936d448 --- /dev/null +++ b/tests/e2e/fixtures/empty-session/events.jsonl @@ -0,0 +1 @@ +{"type":"session.start","timestamp":"2026-03-10T08:00:00.000Z","sessionId":"empty-sess-0000-0000-000000000000","data":{"sessionId":"empty-sess-0000-0000-000000000000","startTime":"2026-03-10T08:00:00.000Z","currentModel":"claude-sonnet-4","workingDirectory":"/Users/anon/projects/empty"}} diff --git a/tests/e2e/fixtures/multi-shutdown-completed/events.jsonl b/tests/e2e/fixtures/multi-shutdown-completed/events.jsonl new file mode 100644 index 0000000..0553a0b --- /dev/null +++ b/tests/e2e/fixtures/multi-shutdown-completed/events.jsonl @@ -0,0 +1,12 @@ +{"type":"session.start","data":{"sessionId":"multi-shutdown-completed-0001","version":1,"producer":"copilot-agent","copilotVersion":"1.0.2","startTime":"2026-03-07T09:00:00.000Z","context":{"cwd":"/Users/testuser/projects/webapp"}},"id":"msc-start","timestamp":"2026-03-07T09:00:00.000Z","parentId":null} +{"type":"user.message","data":{"content":"Set up the project structure.","transformedContent":"Set up the project structure.","attachments":[],"interactionId":"msc-int-1"},"id":"msc-user1","timestamp":"2026-03-07T09:01:00.000Z","parentId":"msc-start"} +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"msc-int-1"},"id":"msc-turn-start-1","timestamp":"2026-03-07T09:01:01.000Z","parentId":"msc-user1"} +{"type":"assistant.message","data":{"messageId":"msc-msg-1","content":"Creating project structure.","toolRequests":[],"interactionId":"msc-int-1","outputTokens":180},"id":"msc-asst1","timestamp":"2026-03-07T09:01:10.000Z","parentId":"msc-turn-start-1"} +{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"msc-turn-end-1","timestamp":"2026-03-07T09:01:11.000Z","parentId":"msc-asst1"} +{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":5,"totalApiDurationMs":8000,"sessionStartTime":1772809200000,"codeChanges":{"linesAdded":40,"linesRemoved":2,"filesModified":["setup.py","README.md"]},"modelMetrics":{"claude-sonnet-4":{"requests":{"count":5,"cost":5},"usage":{"inputTokens":1000,"outputTokens":200,"cacheReadTokens":300,"cacheWriteTokens":0}}},"currentModel":"claude-sonnet-4"},"id":"msc-shutdown-1","timestamp":"2026-03-07T10:00:00.000Z","parentId":"msc-turn-end-1","currentModel":"claude-sonnet-4"} +{"type":"session.resume","data":{},"id":"msc-resume","timestamp":"2026-03-07T11:00:00.000Z","parentId":"msc-shutdown-1"} +{"type":"user.message","data":{"content":"Add authentication module.","transformedContent":"Add authentication module.","attachments":[],"interactionId":"msc-int-2"},"id":"msc-user2","timestamp":"2026-03-07T11:01:00.000Z","parentId":"msc-resume"} +{"type":"assistant.turn_start","data":{"turnId":"1","interactionId":"msc-int-2"},"id":"msc-turn-start-2","timestamp":"2026-03-07T11:01:01.000Z","parentId":"msc-user2"} +{"type":"assistant.message","data":{"messageId":"msc-msg-2","content":"Implementing auth module.","toolRequests":[],"interactionId":"msc-int-2","outputTokens":350},"id":"msc-asst2","timestamp":"2026-03-07T11:01:15.000Z","parentId":"msc-turn-start-2"} +{"type":"assistant.turn_end","data":{"turnId":"1"},"id":"msc-turn-end-2","timestamp":"2026-03-07T11:01:16.000Z","parentId":"msc-asst2"} +{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":10,"totalApiDurationMs":15000,"sessionStartTime":1772809200000,"codeChanges":{"linesAdded":120,"linesRemoved":8,"filesModified":["auth.py","models.py","routes.py"]},"modelMetrics":{"claude-opus-4.6":{"requests":{"count":8,"cost":10},"usage":{"inputTokens":2000,"outputTokens":400,"cacheReadTokens":500,"cacheWriteTokens":0}}},"currentModel":"claude-opus-4.6"},"id":"msc-shutdown-2","timestamp":"2026-03-07T12:00:00.000Z","parentId":"msc-turn-end-2","currentModel":"claude-opus-4.6"} diff --git a/tests/e2e/fixtures/multi-shutdown-resumed/events.jsonl b/tests/e2e/fixtures/multi-shutdown-resumed/events.jsonl new file mode 100644 index 0000000..d78a653 --- /dev/null +++ b/tests/e2e/fixtures/multi-shutdown-resumed/events.jsonl @@ -0,0 +1,23 @@ +{"type":"session.start","data":{"sessionId":"multi-shutdown-resumed-0001","version":1,"producer":"copilot-agent","copilotVersion":"1.0.3","startTime":"2026-03-06T08:00:00.000Z","context":{"cwd":"/Users/testuser/workspace"}},"id":"msr-start","timestamp":"2026-03-06T08:00:00.000Z","parentId":null} +{"type":"user.message","data":{"content":"Implement the user auth module.","transformedContent":"Implement the user auth module.","attachments":[],"interactionId":"msr-int-1"},"id":"msr-user1","timestamp":"2026-03-06T08:01:00.000Z","parentId":"msr-start"} +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"msr-int-1"},"id":"msr-turn-start-1","timestamp":"2026-03-06T08:01:01.000Z","parentId":"msr-user1"} +{"type":"assistant.message","data":{"messageId":"msr-msg-1","content":"I'll create the auth module now.","toolRequests":[],"interactionId":"msr-int-1","outputTokens":180},"id":"msr-asst1","timestamp":"2026-03-06T08:01:10.000Z","parentId":"msr-turn-start-1"} +{"type":"tool.execution_start","data":{"toolCallId":"msr-tc-1","toolName":"edit_file"},"id":"msr-tool-start-1","timestamp":"2026-03-06T08:01:11.000Z","parentId":"msr-asst1"} +{"type":"tool.execution_complete","data":{"toolCallId":"msr-tc-1","model":"claude-sonnet-4","interactionId":"msr-int-1","success":true},"id":"msr-tool-end-1","timestamp":"2026-03-06T08:01:15.000Z","parentId":"msr-tool-start-1"} +{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"msr-turn-end-1","timestamp":"2026-03-06T08:01:16.000Z","parentId":"msr-tool-end-1"} +{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":3,"totalApiDurationMs":4000,"sessionStartTime":1772812800000,"codeChanges":{"linesAdded":20,"linesRemoved":2,"filesModified":["auth.py"]},"modelMetrics":{"claude-sonnet-4":{"requests":{"count":3,"cost":3},"usage":{"inputTokens":800,"outputTokens":150,"cacheReadTokens":200,"cacheWriteTokens":0}}},"currentModel":"claude-sonnet-4"},"id":"msr-shutdown-1","timestamp":"2026-03-06T09:00:00.000Z","parentId":"msr-turn-end-1","currentModel":"claude-sonnet-4"} +{"type":"session.resume","data":{},"id":"msr-resume-1","timestamp":"2026-03-06T11:00:00.000Z","parentId":"msr-shutdown-1"} +{"type":"user.message","data":{"content":"Now add the database layer.","transformedContent":"Now add the database layer.","attachments":[],"interactionId":"msr-int-2"},"id":"msr-user2","timestamp":"2026-03-06T11:01:00.000Z","parentId":"msr-resume-1"} +{"type":"assistant.turn_start","data":{"turnId":"1","interactionId":"msr-int-2"},"id":"msr-turn-start-2","timestamp":"2026-03-06T11:01:01.000Z","parentId":"msr-user2"} +{"type":"assistant.message","data":{"messageId":"msr-msg-2","content":"Adding the database integration.","toolRequests":[],"interactionId":"msr-int-2","outputTokens":250},"id":"msr-asst2","timestamp":"2026-03-06T11:01:15.000Z","parentId":"msr-turn-start-2"} +{"type":"assistant.turn_end","data":{"turnId":"1"},"id":"msr-turn-end-2","timestamp":"2026-03-06T11:01:16.000Z","parentId":"msr-asst2"} +{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":7,"totalApiDurationMs":9000,"sessionStartTime":1772812800000,"codeChanges":{"linesAdded":55,"linesRemoved":8,"filesModified":["auth.py","db.py"]},"modelMetrics":{"claude-opus-4.6":{"requests":{"count":5,"cost":7},"usage":{"inputTokens":1500,"outputTokens":350,"cacheReadTokens":400,"cacheWriteTokens":0}}},"currentModel":"claude-opus-4.6"},"id":"msr-shutdown-2","timestamp":"2026-03-06T12:00:00.000Z","parentId":"msr-turn-end-2","currentModel":"claude-opus-4.6"} +{"type":"session.resume","data":{},"id":"msr-resume-2","timestamp":"2026-03-06T14:00:00.000Z","parentId":"msr-shutdown-2"} +{"type":"user.message","data":{"content":"Add input validation to the auth endpoints.","transformedContent":"Add input validation to the auth endpoints.","attachments":[],"interactionId":"msr-int-3"},"id":"msr-user3","timestamp":"2026-03-06T14:01:00.000Z","parentId":"msr-resume-2"} +{"type":"assistant.turn_start","data":{"turnId":"2","interactionId":"msr-int-3"},"id":"msr-turn-start-3","timestamp":"2026-03-06T14:01:01.000Z","parentId":"msr-user3"} +{"type":"assistant.message","data":{"messageId":"msr-msg-3","content":"Adding validation logic.","toolRequests":[],"interactionId":"msr-int-3","outputTokens":100},"id":"msr-asst3","timestamp":"2026-03-06T14:01:10.000Z","parentId":"msr-turn-start-3"} +{"type":"assistant.turn_end","data":{"turnId":"2"},"id":"msr-turn-end-3","timestamp":"2026-03-06T14:01:11.000Z","parentId":"msr-asst3"} +{"type":"user.message","data":{"content":"Also add rate limiting middleware.","transformedContent":"Also add rate limiting middleware.","attachments":[],"interactionId":"msr-int-4"},"id":"msr-user4","timestamp":"2026-03-06T14:05:00.000Z","parentId":"msr-turn-end-3"} +{"type":"assistant.turn_start","data":{"turnId":"3","interactionId":"msr-int-4"},"id":"msr-turn-start-4","timestamp":"2026-03-06T14:05:01.000Z","parentId":"msr-user4"} +{"type":"assistant.message","data":{"messageId":"msr-msg-4","content":"Implementing rate limiter.","toolRequests":[],"interactionId":"msr-int-4","outputTokens":125},"id":"msr-asst4","timestamp":"2026-03-06T14:05:10.000Z","parentId":"msr-turn-start-4"} +{"type":"assistant.turn_end","data":{"turnId":"3"},"id":"msr-turn-end-4","timestamp":"2026-03-06T14:05:11.000Z","parentId":"msr-asst4"} diff --git a/tests/e2e/fixtures/resumed-session/events.jsonl b/tests/e2e/fixtures/resumed-session/events.jsonl new file mode 100644 index 0000000..57b5c0f --- /dev/null +++ b/tests/e2e/fixtures/resumed-session/events.jsonl @@ -0,0 +1,19 @@ +{"type":"session.start","data":{"sessionId":"resumed-session-0001","version":1,"producer":"copilot-agent","copilotVersion":"1.0.2","startTime":"2026-03-06T10:00:00.000Z","context":{"cwd":"/Users/testuser/project"}},"id":"rs-start","timestamp":"2026-03-06T10:00:00.000Z","parentId":null} +{"type":"user.message","data":{"content":"Pre-shutdown message.","transformedContent":"Pre-shutdown message.","attachments":[],"interactionId":"rs-int-1"},"id":"rs-user1","timestamp":"2026-03-06T10:01:00.000Z","parentId":"rs-start"} +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"rs-int-1"},"id":"rs-turn-start-1","timestamp":"2026-03-06T10:01:01.000Z","parentId":"rs-user1"} +{"type":"assistant.message","data":{"messageId":"rs-msg-1","content":"Pre-shutdown reply.","toolRequests":[],"interactionId":"rs-int-1","outputTokens":200},"id":"rs-asst1","timestamp":"2026-03-06T10:01:05.000Z","parentId":"rs-turn-start-1"} +{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"rs-turn-end-1","timestamp":"2026-03-06T10:01:06.000Z","parentId":"rs-asst1"} +{"type":"user.message","data":{"content":"Second pre-shutdown message.","transformedContent":"Second pre-shutdown message.","attachments":[],"interactionId":"rs-int-2"},"id":"rs-user2","timestamp":"2026-03-06T10:05:00.000Z","parentId":"rs-turn-end-1"} +{"type":"assistant.turn_start","data":{"turnId":"1","interactionId":"rs-int-2"},"id":"rs-turn-start-2","timestamp":"2026-03-06T10:05:01.000Z","parentId":"rs-user2"} +{"type":"assistant.message","data":{"messageId":"rs-msg-2","content":"Second pre-shutdown reply.","toolRequests":[],"interactionId":"rs-int-2","outputTokens":300},"id":"rs-asst2","timestamp":"2026-03-06T10:05:10.000Z","parentId":"rs-turn-start-2"} +{"type":"assistant.turn_end","data":{"turnId":"1"},"id":"rs-turn-end-2","timestamp":"2026-03-06T10:05:11.000Z","parentId":"rs-asst2"} +{"type":"session.shutdown","data":{"shutdownType":"routine","totalPremiumRequests":8,"totalApiDurationMs":5000,"sessionStartTime":1772809200000,"codeChanges":{"linesAdded":30,"linesRemoved":5,"filesModified":["main.py"]},"modelMetrics":{"claude-sonnet-4":{"requests":{"count":6,"cost":8},"usage":{"inputTokens":4000,"outputTokens":500,"cacheReadTokens":800,"cacheWriteTokens":0}}},"currentModel":"claude-sonnet-4"},"id":"rs-shutdown","timestamp":"2026-03-06T11:00:00.000Z","parentId":"rs-turn-end-2","currentModel":"claude-sonnet-4"} +{"type":"session.resume","data":{},"id":"rs-resume","timestamp":"2026-03-06T14:00:00.000Z","parentId":"rs-shutdown"} +{"type":"user.message","data":{"content":"Post-resume message.","transformedContent":"Post-resume message.","attachments":[],"interactionId":"rs-int-3"},"id":"rs-user3","timestamp":"2026-03-06T14:01:00.000Z","parentId":"rs-resume"} +{"type":"assistant.turn_start","data":{"turnId":"2","interactionId":"rs-int-3"},"id":"rs-turn-start-3","timestamp":"2026-03-06T14:01:01.000Z","parentId":"rs-user3"} +{"type":"assistant.message","data":{"messageId":"rs-msg-3","content":"Post-resume reply.","toolRequests":[],"interactionId":"rs-int-3","outputTokens":150},"id":"rs-asst3","timestamp":"2026-03-06T14:01:10.000Z","parentId":"rs-turn-start-3"} +{"type":"assistant.turn_end","data":{"turnId":"2"},"id":"rs-turn-end-3","timestamp":"2026-03-06T14:01:11.000Z","parentId":"rs-asst3"} +{"type":"user.message","data":{"content":"Another post-resume message.","transformedContent":"Another post-resume message.","attachments":[],"interactionId":"rs-int-4"},"id":"rs-user4","timestamp":"2026-03-06T14:05:00.000Z","parentId":"rs-turn-end-3"} +{"type":"assistant.turn_start","data":{"turnId":"3","interactionId":"rs-int-4"},"id":"rs-turn-start-4","timestamp":"2026-03-06T14:05:01.000Z","parentId":"rs-user4"} +{"type":"assistant.message","data":{"messageId":"rs-msg-4","content":"Another post-resume reply.","toolRequests":[],"interactionId":"rs-int-4","outputTokens":175},"id":"rs-asst4","timestamp":"2026-03-06T14:05:10.000Z","parentId":"rs-turn-start-4"} +{"type":"assistant.turn_end","data":{"turnId":"3"},"id":"rs-turn-end-4","timestamp":"2026-03-06T14:05:11.000Z","parentId":"rs-asst4"} diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py new file mode 100644 index 0000000..3cf6e1a --- /dev/null +++ b/tests/e2e/test_e2e.py @@ -0,0 +1,565 @@ +"""End-to-end tests running CLI commands against anonymized fixture data.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from copilot_usage.cli import main + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture(autouse=True) +def _wide_terminal(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure Rich tables are not truncated during tests.""" + monkeypatch.setenv("COLUMNS", "200") + + +# --------------------------------------------------------------------------- +# summary +# --------------------------------------------------------------------------- + + +class TestSummaryE2E: + """Tests for the ``summary`` command.""" + + def test_finds_eight_sessions(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "8 sessions" in result.output + + def test_total_premium_requests(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # All sessions including resumed: 504 + 288 + 2 + 15 + 10 + 8 = 827 + assert "827 premium requests" in result.output + + def test_model_names_in_output(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "claude-haiku-4.5" in result.output + assert "claude-opus-4.6" in result.output + + def test_active_and_completed_status_shown(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "Completed" in result.output + assert "Active" in result.output + + def test_date_filtering_excludes_sessions(self) -> None: + result = CliRunner().invoke( + main, ["summary", "--path", str(FIXTURES), "--since", "2026-03-08"] + ) + assert result.exit_code == 0 + # b5df (2026-03-08) + empty-session (2026-03-10) match + assert "2 sessions" in result.output + assert "288 premium requests" in result.output + + def test_model_calls_shown(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "20 model calls" in result.output + + def test_user_messages_shown(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "14 user messages" in result.output + + +# --------------------------------------------------------------------------- +# session +# --------------------------------------------------------------------------- + + +class TestSessionE2E: + """Tests for the ``session`` command.""" + + def test_lookup_by_prefix(self) -> None: + result = CliRunner().invoke(main, ["session", "b5df", "--path", str(FIXTURES)]) + assert "b5df8a34" in result.output + assert "claude-opus-4.6-1m" in result.output + + def test_not_found_error(self) -> None: + result = CliRunner().invoke(main, ["session", "badid", "--path", str(FIXTURES)]) + assert result.exit_code != 0 + assert "no session matching 'badid'" in result.output + + def test_session_detail_sections_shown(self) -> None: + result = CliRunner().invoke(main, ["session", "b5df", "--path", str(FIXTURES)]) + assert "Session Detail" in result.output + assert "Aggregate Stats" in result.output + + +# --------------------------------------------------------------------------- +# cost +# --------------------------------------------------------------------------- + + +class TestCostE2E: + """Tests for the ``cost`` command.""" + + def test_cost_table_shown(self) -> None: + result = CliRunner().invoke(main, ["cost", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "Cost Breakdown" in result.output + + def test_total_premium_requests(self) -> None: + result = CliRunner().invoke(main, ["cost", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # Grand total Premium Cost: 288 + 504 + 15 + 0 + 0 + 8 + 10 + 0 = 825 + assert "825" in result.output + + def test_active_session_shows_dash(self) -> None: + """Active session with no shutdown shows 'โ€”' for premium.""" + result = CliRunner().invoke(main, ["cost", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # Active sessions (e.g. empty-sess) display em-dash for premium + assert "empty-sess" in result.output + assert "โ€”" in result.output + + +# --------------------------------------------------------------------------- +# resumed session +# --------------------------------------------------------------------------- + + +class TestResumedSessionE2E: + """Tests for resumed session detection.""" + + def test_resumed_session_shows_active(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # At 80-char width "resumed-" truncates to "resumeโ€ฆ" + assert "resume" in result.output + assert "Active" in result.output + + def test_resumed_session_live(self) -> None: + result = CliRunner().invoke(main, ["live", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # The resumed session should appear in live (active sessions only) + assert "resumed-" in result.output + + def test_multiplier_values(self) -> None: + result = CliRunner().invoke(main, ["cost", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # Cost table no longer has multiplier column; check premium cost total + assert "825" in result.output + + +# --------------------------------------------------------------------------- +# live +# --------------------------------------------------------------------------- + + +class TestLiveE2E: + """Tests for the ``live`` command.""" + + def test_active_session_shown(self) -> None: + result = CliRunner().invoke(main, ["live", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "4a5470" in result.output + + def test_completed_sessions_not_shown(self) -> None: + result = CliRunner().invoke(main, ["live", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "0faecbdf" not in result.output + assert "b5df8a34" not in result.output + + +# --------------------------------------------------------------------------- +# corrupt session +# --------------------------------------------------------------------------- + + +class TestCorruptSessionE2E: + """Tests for handling corrupt/malformed events.jsonl files.""" + + def test_summary_survives_corrupt_lines(self) -> None: + """Summary still works when events.jsonl has malformed JSON lines.""" + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # 8 sessions total + assert "8 sessions" in result.output + + def test_corrupt_session_appears_in_summary(self) -> None: + """The corrupt session is parsed (valid lines kept) and shown.""" + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "Completed" in result.output + + +# --------------------------------------------------------------------------- +# date filter partial +# --------------------------------------------------------------------------- + + +class TestSummaryDateFilterPartialE2E: + """Tests for --since filtering that includes only some sessions.""" + + def test_summary_date_filter_partial(self) -> None: + """--since includes only sessions after 2026-03-07T12:00:00.""" + result = CliRunner().invoke( + main, + ["summary", "--path", str(FIXTURES), "--since", "2026-03-07T12:00:00"], + ) + assert result.exit_code == 0 + # b5df (2026-03-08), 0faecbdf (2026-03-07T15:15), empty-session (2026-03-10) + assert "3 sessions" in result.output + + def test_until_excludes_later_sessions(self) -> None: + """--until 2026-03-07 keeps only sessions starting on or before that date.""" + result = CliRunner().invoke( + main, + ["summary", "--path", str(FIXTURES), "--until", "2026-03-07"], + ) + assert result.exit_code == 0 + # multi-shutdown-resumed (2026-03-06) + resumed-session (2026-03-06) + assert "2 sessions" in result.output + + def test_since_and_until_combined(self) -> None: + """--since 2026-03-06 --until 2026-03-07 narrows to the same 2 sessions.""" + result = CliRunner().invoke( + main, + [ + "summary", + "--path", + str(FIXTURES), + "--since", + "2026-03-06", + "--until", + "2026-03-07", + ], + ) + assert result.exit_code == 0 + assert "2 sessions" in result.output + assert "18 premium requests" in result.output + + def test_summary_date_filter_includes_all(self) -> None: + """--since far in the past includes all sessions.""" + result = CliRunner().invoke( + main, + ["summary", "--path", str(FIXTURES), "--since", "2020-01-01"], + ) + assert result.exit_code == 0 + assert "8 sessions" in result.output + + +# --------------------------------------------------------------------------- +# cost with free model +# --------------------------------------------------------------------------- + + +class TestCostWithFreeModelE2E: + """Tests for cost command with 0ร— multiplier models.""" + + def test_cost_with_free_model(self) -> None: + """gpt-5-mini (0ร— multiplier) from corrupt-session fixture shows up.""" + result = CliRunner().invoke(main, ["cost", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # Cost table no longer has multiplier column + assert "gpt-5-mini" in result.output + + def test_cost_free_model_premium_zero(self) -> None: + """Free model session has 2 requests and 0 premium cost in cost table.""" + result = CliRunner().invoke(main, ["cost", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # corrupt-session row: gpt-5-mini with 2 requests and 0 premium cost + assert "corrupt0" in result.output + assert "gpt-5-mini" in result.output + # Verify the corrupt session row shows 2 requests and 0 premium + lines = result.output.splitlines() + corrupt_line = next(line for line in lines if "corrupt0" in line) + assert "gpt-5-mini" in corrupt_line + assert "0" in corrupt_line # premium cost is 0 + + +# --------------------------------------------------------------------------- +# session detail timeline +# --------------------------------------------------------------------------- + + +class TestSessionDetailTimelineE2E: + """Tests for session command output including recent events.""" + + def test_session_detail_shows_recent_events(self) -> None: + """Session detail output includes event types from fixture data.""" + result = CliRunner().invoke( + main, ["session", "corrupt0", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "Recent Events" in result.output + assert "user message" in result.output + assert "assistant" in result.output + + def test_session_detail_shows_tool_events(self) -> None: + """Session detail includes turn start/end events.""" + result = CliRunner().invoke( + main, ["session", "corrupt0", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "turn start" in result.output + + def test_session_detail_shows_shutdown_cycles(self) -> None: + """Session detail includes shutdown cycles table.""" + result = CliRunner().invoke( + main, ["session", "corrupt0", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "Shutdown Cycles" in result.output + + +# --------------------------------------------------------------------------- +# premium requests for active sessions +# --------------------------------------------------------------------------- + + +class TestPremiumRequestsE2E: + """Tests for premium request display in active/resumed/completed sessions.""" + + def test_active_session_shows_dash_for_premium(self) -> None: + """Active session (no shutdown) shows 'โ€”' for premium requests.""" + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # Active sessions like empty-sess display em-dash for unknown premium + assert "empty-sess" in result.output + assert "โ€”" in result.output + + def test_completed_session_shows_exact_premium(self) -> None: + """Completed sessions show exact premium requests.""" + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "288" in result.output # b5df completed session + + def test_total_includes_all_sessions(self) -> None: + """Totals include premium requests from all sessions (including resumed).""" + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "827 premium requests" in result.output + + +# --------------------------------------------------------------------------- +# multi-shutdown completed session +# --------------------------------------------------------------------------- + + +class TestMultiShutdownCompletedE2E: + """Tests for a session with 2 shutdowns, different models, completed.""" + + def test_session_appears_in_summary(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + # Session name truncated to first 12 chars: "multi-shutdo" + assert "multi-shutdo" in result.output + + def test_both_model_names_in_cost(self) -> None: + result = CliRunner().invoke(main, ["cost", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "claude-sonnet-4" in result.output + assert "claude-opus-4.6" in result.output + + def test_summed_premium_requests(self) -> None: + """5 + 10 = 15 premium requests visible in session detail.""" + result = CliRunner().invoke( + main, ["session", "multi-shutdown-c", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "15 premium requests" in result.output + + def test_status_is_completed(self) -> None: + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "Completed" in result.output + + def test_not_in_live(self) -> None: + """Completed session should not appear in live view.""" + result = CliRunner().invoke(main, ["live", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "multi-shutdown-completed" not in result.output + + def test_session_detail(self) -> None: + result = CliRunner().invoke( + main, ["session", "multi-shutdown-c", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "multi-shutdown-completed" in result.output + assert "Shutdown Cycles" in result.output + + +# --------------------------------------------------------------------------- +# multi-shutdown resumed session (2 shutdowns, still active) +# --------------------------------------------------------------------------- + + +class TestMultiShutdownResumedE2E: + """Tests for a session with 2 shutdowns followed by a resume (still active).""" + + def test_session_in_live(self) -> None: + """Multi-shutdown-resumed session appears in live (it is active).""" + result = CliRunner().invoke(main, ["live", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "multi-sh" in result.output + + def test_summary_shows_active(self) -> None: + """Session shows Active status in summary.""" + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "Active" in result.output + + def test_premium_requests_in_cost(self) -> None: + """Summed premium requests (3 + 7 = 10) visible in session detail.""" + result = CliRunner().invoke( + main, ["session", "multi-shutdown-r", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "10 premium requests" in result.output + + def test_session_detail(self) -> None: + """Session detail can be retrieved by prefix.""" + result = CliRunner().invoke( + main, ["session", "multi-shutdown-r", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "multi-shutdown-resumed" in result.output + assert "Active" in result.output + assert "Shutdown Cycles" in result.output + + +# --------------------------------------------------------------------------- +# empty session (session.start only) +# --------------------------------------------------------------------------- + + +class TestEmptySessionE2E: + """Tests for a session with only a session.start event.""" + + def test_empty_session_in_summary(self) -> None: + """Empty session appears in summary output.""" + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "empty-" in result.output + + def test_empty_session_is_active(self) -> None: + """Session with only session.start shows as Active.""" + result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "Active" in result.output + + def test_empty_session_in_live(self) -> None: + """Empty session appears in live view (it is active).""" + result = CliRunner().invoke(main, ["live", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + assert "empty-se" in result.output + + def test_empty_session_detail(self) -> None: + """Session detail can be retrieved by prefix.""" + result = CliRunner().invoke( + main, ["session", "empty-sess", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "empty-sess-0000" in result.output + assert "active" in result.output.lower() + + def test_empty_session_zero_stats(self) -> None: + """Empty session has 0 model calls and 0 user messages in detail.""" + result = CliRunner().invoke( + main, ["session", "empty-sess", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + # Premium shows dash for active sessions + assert "โ€”" in result.output + + +# --------------------------------------------------------------------------- +# unhappy paths +# --------------------------------------------------------------------------- + + +class TestUnhappyPathE2E: + """Tests for error handling and edge cases.""" + + def test_nonexistent_path_error(self) -> None: + """--path to nonexistent directory triggers Click exists=True validation.""" + result = CliRunner().invoke( + main, ["summary", "--path", "/nonexistent-dir-xyz-abc-999"] + ) + assert result.exit_code != 0 + assert "does not exist" in result.output + + def test_empty_directory_graceful(self) -> None: + """--path to a directory with no events.jsonl shows zero sessions.""" + with tempfile.TemporaryDirectory() as td: + result = CliRunner().invoke(main, ["summary", "--path", td]) + assert result.exit_code == 0 + assert "0 sessions" in result.output or "No sessions" in result.output + + def test_session_command_no_argument(self) -> None: + """session command with no SESSION_ID argument shows usage error.""" + result = CliRunner().invoke(main, ["session"]) + assert result.exit_code != 0 + assert "SESSION_ID" in result.output + + +# --------------------------------------------------------------------------- +# pure active session detail +# --------------------------------------------------------------------------- + + +class TestPureActiveSessionE2E: + """Tests for session detail of a pure active session (no shutdown).""" + + def test_active_status(self) -> None: + """Pure active session (4a5470) shows active status.""" + result = CliRunner().invoke( + main, ["session", "4a5470", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "active" in result.output.lower() + + def test_no_shutdown_cycles(self) -> None: + """Pure active session has no shutdown cycles recorded.""" + result = CliRunner().invoke( + main, ["session", "4a5470", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "No shutdown cycles recorded" in result.output + + def test_has_recent_events(self) -> None: + """Pure active session shows Recent Events table.""" + result = CliRunner().invoke( + main, ["session", "4a5470", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "Recent Events" in result.output + + +# --------------------------------------------------------------------------- +# shutdown aggregation regression +# --------------------------------------------------------------------------- + + +class TestShutdownAggregationE2E: + """Regression: multi-shutdown sessions must SUM premium, not take last.""" + + def test_total_premium_is_sum_not_last(self) -> None: + """multi-shutdown-completed: 5 + 10 = 15 total (not 5, not 10).""" + result = CliRunner().invoke( + main, ["session", "multi-shutdown-c", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "15 premium requests" in result.output + + def test_both_shutdown_cycles_visible(self) -> None: + """Both shutdown cycles appear in the detail table.""" + result = CliRunner().invoke( + main, ["session", "multi-shutdown-c", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "Shutdown Cycles" in result.output + lines = result.output.splitlines() + # Find rows in the shutdown cycles table containing premium values + cycle_rows = [line for line in lines if "2026-03-07" in line] + assert len(cycle_rows) >= 2, f"Expected 2 shutdown cycles, got: {cycle_rows}" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8a0886d --- /dev/null +++ b/uv.lock @@ -0,0 +1,526 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "cli-tools" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "loguru" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "watchdog" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "loguru", specifier = ">=0.7.3" }, + { name = "pydantic", specifier = ">=2,<3" }, + { name = "rich" }, + { name = "watchdog", specifier = ">=4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +]