cctally 1.22.2 → 1.22.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ """diff command handler + its two-window debug-sample reporter.
2
+
3
+ Eager I/O sibling: bin/cctally loads this at startup (AFTER the _lib_diff_kernel
4
+ block) and re-exports cmd_diff + _emit_diff_debug_samples onto the cctally
5
+ namespace. The parser dispatches via c.cmd_diff; test_debug_sample_emission
6
+ reaches mod._emit_diff_debug_samples on the ns.
7
+
8
+ Accessor discipline (spec §2): _cctally_core kernel symbols are honest-imported;
9
+ the dedicated pure kernel _lib_diff_kernel is honest-imported as `dk`; every
10
+ OTHER cctally helper (display-tz, color, config bridge, the pricing-mismatch
11
+ debug cluster, and the module-level _DEBUG_REPORT_EMITTED flag) is reached via
12
+ the call-time _cctally() accessor. No _cctally_* sibling is imported directly.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import dataclasses
18
+ import shutil
19
+ import sqlite3
20
+ import sys
21
+
22
+ import _lib_diff_kernel as dk
23
+
24
+ from _cctally_core import _command_as_of, eprint
25
+
26
+
27
+ def _cctally():
28
+ """Call-time accessor to the cctally module namespace (ns-patchable)."""
29
+ return sys.modules["cctally"]
30
+
31
+
32
+ def _emit_diff_debug_samples(args, window_a, window_b) -> None:
33
+ """Two-window diff report (spec §7.2.2 Pattern D).
34
+
35
+ ``cmd_diff`` aggregates two windows; emitting a single union-report
36
+ would conflate per-window stats. This helper emits two separate
37
+ reports labeled by window token, then sets ``_DEBUG_REPORT_EMITTED``
38
+ so a downstream cmd_* composition doesn't double-emit.
39
+
40
+ Bypasses ``_emit_debug_samples_if_set``'s one-time guard internally
41
+ (it would short-circuit the second window).
42
+ """
43
+ c = _cctally()
44
+ if c._DEBUG_REPORT_EMITTED:
45
+ return
46
+ if not getattr(args, "debug", False):
47
+ return
48
+ sample_limit = int(getattr(args, "debug_samples", 5))
49
+ # Sync intent must match `_build_diff_result` (`skip_sync=not args.sync`).
50
+ # Under `diff --sync --debug` the rendered diff reflects freshly-synced
51
+ # JSONL while these debug stats, if they skipped sync, would be computed
52
+ # from the STALE cache — misleading in precisely the stale-cache case
53
+ # `--sync` exists to fix. So honor `--sync` here too: the first
54
+ # `get_entries(skip_sync=False)` runs the delta ingest, and every
55
+ # subsequent read (the second window here + `_build_diff_result`'s own
56
+ # reads) is a cheap delta no-op (file size/offset unchanged) — no
57
+ # redundant full walk. Without `--sync`, keep skipping (debug observes
58
+ # the cache as-is).
59
+ skip_sync = not bool(getattr(args, "sync", False))
60
+ try:
61
+ for window, label_letter, token in (
62
+ (window_a, "A", getattr(args, "a", "")),
63
+ (window_b, "B", getattr(args, "b", "")),
64
+ ):
65
+ try:
66
+ # Reuse the SAME half-open window accessor the rendered diff
67
+ # aggregation uses (`_diff_iter_claude_entries`): `ParsedWindow`
68
+ # exposes `start_utc`/`end_utc` (NOT `.start`/`.end`), and its
69
+ # `end_utc` is documented exclusive — the helper trims by 1 µs
70
+ # before hitting the inclusive-end shared cache reader so the
71
+ # debug report scopes to exactly the entries the rendered diff
72
+ # counts. Reading via `get_entries(window.start, window.end)`
73
+ # both raised AttributeError (wrong field names) and would have
74
+ # over-counted the exclusive end boundary.
75
+ entries = list(
76
+ dk._diff_iter_claude_entries(window, skip_sync=skip_sync)
77
+ )
78
+ except (sqlite3.DatabaseError, OSError) as exc:
79
+ eprint(
80
+ f"cctally --debug: window {label_letter} report "
81
+ f"unavailable: {exc}"
82
+ )
83
+ continue
84
+ # `_diff_iter_claude_entries` yields `_JoinedClaudeEntry`, which
85
+ # has no `.usage` attribute; adapt to `UsageEntry` before the
86
+ # mismatch compute, mirroring cmd_project / cmd_session. The stats
87
+ # helper reads `entry.usage`, so passing raw joined entries here
88
+ # crashed every priced entry with AttributeError — and the inner
89
+ # `try/except` only catches DatabaseError/OSError, so the crash
90
+ # escaped to main()'s generic handler (exit 1, zero diff output).
91
+ stats = c._compute_pricing_mismatch_stats(
92
+ c._usage_entry_from_joined(je) for je in entries
93
+ )
94
+ stats.command_label = f"diff (Window {label_letter}: {token})"
95
+ for line in c._render_pricing_mismatch_report(stats, sample_limit):
96
+ eprint(line)
97
+ finally:
98
+ # P1.2 (issue #89 review-loop): set the guard in finally so a
99
+ # downstream cmd_* composition doesn't double-emit even if one
100
+ # window raised — the partial output we did emit is enough.
101
+ c._DEBUG_REPORT_EMITTED = True
102
+
103
+
104
+ def cmd_diff(args: argparse.Namespace) -> int:
105
+ """Compare Claude usage between two windows."""
106
+ c = _cctally()
107
+ now_utc = _command_as_of()
108
+ if getattr(args, "debug_now", False):
109
+ print(f"now_utc={c._iso_z(now_utc)}")
110
+ return 0
111
+
112
+ # Resolve anchors (None when no snapshots exist; week tokens then
113
+ # raise NoAnchorError in the parser).
114
+ anchor_week_start, anchor_resets_at = dk._diff_resolve_anchor(now_utc)
115
+
116
+ # Validation already happened via _argparse_tz; resolve now to a ZoneInfo
117
+ # (or None for "local") and derive the IANA name for window resolution.
118
+ config = c._load_claude_config_for_args(args)
119
+ # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
120
+ # existing resolve_display_tz precedence absorbs the new alias.
121
+ c._bridge_z_into_tz(args, config)
122
+ tz_obj = c.resolve_display_tz(args, config)
123
+ args._resolved_tz = tz_obj
124
+ tz_name = (tz_obj.key if tz_obj is not None else c._local_tz_name())
125
+
126
+ try:
127
+ window_a = dk._parse_diff_window(
128
+ args.a, now_utc=now_utc,
129
+ anchor_resets_at=anchor_resets_at,
130
+ anchor_week_start=anchor_week_start,
131
+ tz_name=tz_name,
132
+ )
133
+ window_b = dk._parse_diff_window(
134
+ args.b, now_utc=now_utc,
135
+ anchor_resets_at=anchor_resets_at,
136
+ anchor_week_start=anchor_week_start,
137
+ tz_name=tz_name,
138
+ )
139
+ except dk.NoAnchorError as exc:
140
+ print(f"diff: {exc}", file=sys.stderr)
141
+ return 1
142
+ except ValueError as exc:
143
+ print(f"diff: {exc}", file=sys.stderr)
144
+ return 2
145
+
146
+ # Validate the remaining CLI surface (`--only` / `--with`) BEFORE the
147
+ # `--debug` emission below. `_emit_diff_debug_samples` prints reports and,
148
+ # under `--sync`, runs a cache ingest (a local-state mutation). A
149
+ # fail-fast usage error like `diff ... --only bogus --debug --sync` must
150
+ # not print unrelated debug output or touch the cache before returning
151
+ # exit 2 — so the validation gate has to precede the debug scan.
152
+ sections_requested = ["overall", "models", "projects", "cache"]
153
+ if args.only is not None:
154
+ sections_requested = [s.strip() for s in args.only.split(",") if s.strip()]
155
+ SUPPORTED_SECTIONS = {"overall", "models", "projects", "cache"}
156
+ if not sections_requested:
157
+ eprint(
158
+ "diff: --only specified no sections. "
159
+ f"Supported: {', '.join(sorted(SUPPORTED_SECTIONS))}"
160
+ )
161
+ return 2
162
+ unknown = [s for s in sections_requested if s not in SUPPORTED_SECTIONS]
163
+ if unknown:
164
+ eprint(
165
+ f"diff: --only contains unknown section(s): {', '.join(unknown)}. "
166
+ f"Supported: {', '.join(sorted(SUPPORTED_SECTIONS))}"
167
+ )
168
+ return 2
169
+ if args.with_extra:
170
+ for extra in (s.strip() for s in args.with_extra.split(",")):
171
+ if extra in ("trend", "time"):
172
+ print(
173
+ f"diff: --with {extra} is not yet implemented (deferred to v1.1)",
174
+ file=sys.stderr,
175
+ )
176
+ return 1
177
+
178
+ # Issue #89 spec §7.2.2 Pattern D: emit one --debug report per window
179
+ # before any rendering, with window-A then window-B labels. Runs only
180
+ # after the validation gate above so a usage error fails fast without
181
+ # debug output or a cache sync.
182
+ if getattr(args, "debug", False):
183
+ _emit_diff_debug_samples(args, window_a, window_b)
184
+
185
+ threshold = dk.NoiseThreshold(
186
+ show_all=bool(args.show_all),
187
+ user_override=(args.min_delta_usd is not None
188
+ or args.min_delta_pct is not None),
189
+ )
190
+ if args.min_delta_usd is not None:
191
+ threshold = dataclasses.replace(threshold, min_delta_usd=args.min_delta_usd)
192
+ if args.min_delta_pct is not None:
193
+ threshold = dataclasses.replace(threshold, min_delta_pct=args.min_delta_pct)
194
+
195
+ try:
196
+ result = dk._build_diff_result(
197
+ window_a, window_b,
198
+ threshold=threshold,
199
+ sections_requested=sections_requested,
200
+ sort=args.sort,
201
+ allow_mismatch=bool(args.allow_mismatch),
202
+ skip_sync=not bool(args.sync),
203
+ top=args.top,
204
+ )
205
+ except dk.WindowMismatchError as exc:
206
+ print(f"diff: {exc}", file=sys.stderr)
207
+ return 2
208
+
209
+ dk._check_diff_invariants(result)
210
+
211
+ options = {
212
+ "allow_mismatch": bool(args.allow_mismatch),
213
+ "show_all": bool(args.show_all),
214
+ "min_delta_usd": threshold.min_delta_usd,
215
+ "min_delta_pct": threshold.min_delta_pct,
216
+ "user_override_threshold": threshold.user_override,
217
+ "sort": args.sort,
218
+ "top": args.top,
219
+ "sections_requested": sections_requested,
220
+ "sync_run": bool(args.sync),
221
+ }
222
+
223
+ if args.emit_json:
224
+ print(dk._diff_render_json(result, options=options))
225
+ return 0
226
+
227
+ # Session A (spec §7.3): route through the new color resolver so
228
+ # the bool --color flag overrides NO_COLOR env, --no-color overrides
229
+ # FORCE_COLOR env, and deny-wins on the --color + --no-color clash.
230
+ # The old computation (sys.stdout.isatty() and not args.no_color)
231
+ # only honored --no-color + isatty; the resolver supersedes both
232
+ # with the full spec §7.3 precedence.
233
+ color = c._resolve_color_enabled(args)
234
+ width = args.width or shutil.get_terminal_size().columns
235
+ width = max(80, min(width, 160))
236
+ print(dk._diff_render_full_output(
237
+ result, color=color, width=width, raw_aggregates=result.raw_totals,
238
+ tz=tz_obj, compact=args.compact,
239
+ ))
240
+ return 0