From efb4be6e08773d05f8e8d393579399c871562531 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 07:26:00 +0000 Subject: [PATCH 1/2] fix: treat watchdog as always-on hard dependency (#31) - Move Observer import to module top level, remove dead except ImportError guard - Simplify _stop_observer to call .stop()/.join() directly (no getattr dance) - Fix README: remove misleading 'uv add watchdog' conditional phrasing - Update implementation.md and plan.md docs to remove optional language - Add test_start_observer_returns_running_observer unit test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- src/copilot_usage/cli.py | 32 +++++++++--------------- src/copilot_usage/docs/implementation.md | 2 +- src/copilot_usage/docs/plan.md | 4 +-- tests/copilot_usage/test_cli.py | 19 +++++++++++++- 5 files changed, 34 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f4ee146..1743fce 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This shows the full summary dashboard with a numbered session list. From there: 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. +The display auto-refreshes when session files change (2-second debounce). #### Commands diff --git a/src/copilot_usage/cli.py b/src/copilot_usage/cli.py index 976ee64..4a6911d 100644 --- a/src/copilot_usage/cli.py +++ b/src/copilot_usage/cli.py @@ -16,6 +16,7 @@ import click from rich.console import Console from rich.table import Table +from watchdog.observers import Observer # type: ignore[import-untyped] from copilot_usage.models import SessionSummary from copilot_usage.parser import ( @@ -122,30 +123,21 @@ def dispatch(self, event: object) -> None: # noqa: ANN001 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 _start_observer(session_path: Path, change_event: threading.Event) -> Observer: + """Start a watchdog observer monitoring *session_path* for changes.""" + 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 -def _stop_observer(observer: object | None) -> None: +def _stop_observer(observer: Observer | 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) + observer.stop() + observer.join(timeout=2) def _interactive_loop(path: Path | None) -> None: diff --git a/src/copilot_usage/docs/implementation.md b/src/copilot_usage/docs/implementation.md index 1f7ac78..5e0c5d0 100644 --- a/src/copilot_usage/docs/implementation.md +++ b/src/copilot_usage/docs/implementation.md @@ -262,7 +262,7 @@ 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`). +The observer watches the session-state directory; if the directory doesn't exist at startup, no observer is created and auto-refresh is simply skipped. ### `_FileChangeHandler` with 2-second debounce diff --git a/src/copilot_usage/docs/plan.md b/src/copilot_usage/docs/plan.md index 758c575..7c51000 100644 --- a/src/copilot_usage/docs/plan.md +++ b/src/copilot_usage/docs/plan.md @@ -27,7 +27,7 @@ Standards: follows `microsasa/project-standards` - **`~/.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` — launches Rich interactive mode with numbered session list, cost view, and watchdog-based auto-refresh (2-second debounce) - `copilot-usage session ` — per-turn token breakdown, tools used, API call timeline, code changes (static CLI output) **Interactive mode**: @@ -36,7 +36,7 @@ The main interface. Launches a Rich-based interactive loop in the terminal: - **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. +- **Auto-refresh**: 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**: diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index b1e0bcb..17fa839 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -3,13 +3,14 @@ from __future__ import annotations import json +import threading from datetime import UTC, datetime from pathlib import Path from typing import Any from click.testing import CliRunner -from copilot_usage.cli import main +from copilot_usage.cli import _start_observer, _stop_observer, main # --------------------------------------------------------------------------- # Helpers @@ -479,3 +480,19 @@ def test_interactive_no_sessions(tmp_path: Path) -> None: result = runner.invoke(main, ["--path", str(tmp_path)], input="q\n") assert result.exit_code == 0 assert "No sessions" in result.output + + +# --------------------------------------------------------------------------- +# Watchdog observer tests +# --------------------------------------------------------------------------- + + +def test_start_observer_returns_running_observer(tmp_path: Path) -> None: + """_start_observer returns a non-None, alive observer for an existing dir.""" + change_event = threading.Event() + observer = _start_observer(tmp_path, change_event) + try: + assert observer is not None + assert observer.is_alive() + finally: + _stop_observer(observer) From 7bbac86c6f26cb19bc54eed52c9e246e0dc82a34 Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic Date: Sat, 14 Mar 2026 00:36:32 -0700 Subject: [PATCH 2/2] fix: resolve pyright errors from watchdog type annotations Use object types for observer (watchdog has no type stubs) with type: ignore on method calls. Fixes CI failure on PR #33. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/copilot_usage/cli.py | 8 ++++---- tests/copilot_usage/test_cli.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/copilot_usage/cli.py b/src/copilot_usage/cli.py index 4a6911d..cb6dd1d 100644 --- a/src/copilot_usage/cli.py +++ b/src/copilot_usage/cli.py @@ -123,7 +123,7 @@ def dispatch(self, event: object) -> None: # noqa: ANN001 self._change_event.set() -def _start_observer(session_path: Path, change_event: threading.Event) -> Observer: +def _start_observer(session_path: Path, change_event: threading.Event) -> object: """Start a watchdog observer monitoring *session_path* for changes.""" handler = _FileChangeHandler(change_event) observer = Observer() @@ -133,11 +133,11 @@ def _start_observer(session_path: Path, change_event: threading.Event) -> Observ return observer -def _stop_observer(observer: Observer | None) -> None: +def _stop_observer(observer: object | None) -> None: """Stop a watchdog observer if running.""" if observer is not None: - observer.stop() - observer.join(timeout=2) + observer.stop() # type: ignore[union-attr] + observer.join(timeout=2) # type: ignore[union-attr] def _interactive_loop(path: Path | None) -> None: diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index 17fa839..baef5a2 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -10,7 +10,11 @@ from click.testing import CliRunner -from copilot_usage.cli import _start_observer, _stop_observer, main +from copilot_usage.cli import ( + _start_observer, # pyright: ignore[reportPrivateUsage] + _stop_observer, # pyright: ignore[reportPrivateUsage] + main, +) # --------------------------------------------------------------------------- # Helpers @@ -490,9 +494,9 @@ def test_interactive_no_sessions(tmp_path: Path) -> None: def test_start_observer_returns_running_observer(tmp_path: Path) -> None: """_start_observer returns a non-None, alive observer for an existing dir.""" change_event = threading.Event() - observer = _start_observer(tmp_path, change_event) + observer = _start_observer(tmp_path, change_event) # pyright: ignore[reportUnknownVariableType] try: assert observer is not None - assert observer.is_alive() + assert observer.is_alive() # type: ignore[union-attr] finally: - _stop_observer(observer) + _stop_observer(observer) # pyright: ignore[reportUnknownArgumentType]