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.
- package/CHANGELOG.md +13 -0
- package/bin/_cctally_cache_report.py +1133 -880
- package/bin/_cctally_codex.py +518 -0
- package/bin/_cctally_dashboard.py +3 -3
- package/bin/_cctally_diff.py +240 -0
- package/bin/_cctally_doctor.py +479 -0
- package/bin/_cctally_five_hour.py +1688 -0
- package/bin/_cctally_forecast.py +1979 -0
- package/bin/_cctally_milestones.py +433 -0
- package/bin/_cctally_percent_breakdown.py +199 -0
- package/bin/_cctally_pricing_check.py +393 -0
- package/bin/_cctally_record.py +5 -3
- package/bin/_cctally_reporting.py +749 -0
- package/bin/_cctally_setup.py +172 -13
- package/bin/_cctally_statusline.py +630 -0
- package/bin/_cctally_sync_week.py +5 -4
- package/bin/_cctally_weekrefs.py +450 -0
- package/bin/_lib_cache_report.py +938 -0
- package/bin/_lib_pricing_debug.py +182 -0
- package/bin/_lib_subscription_weeks.py +2 -2
- package/bin/cctally +419 -8891
- package/package.json +14 -1
|
@@ -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
|