cctally 1.7.0 → 1.7.2
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 +27 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5403 -0
- package/bin/_cctally_db.py +1837 -0
- package/bin/_cctally_record.py +2305 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4487 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +441 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +137 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11694 -35448
- package/package.json +24 -1
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
"""OAuth-usage refresh: API fetch, UA discovery, renderers, `cctally refresh-usage` entry point.
|
|
2
|
+
|
|
3
|
+
Eager I/O sibling: bin/cctally loads this at startup and re-exports
|
|
4
|
+
every public symbol so bare-name callers (the dashboard
|
|
5
|
+
`POST /api/sync` handler, `cmd_hook_tick`'s oauth-refresh path, the
|
|
6
|
+
record-usage milestone gate, …) all resolve unchanged. Tests reaching
|
|
7
|
+
in via ``ns["X"]`` direct-dict access (extensive — see
|
|
8
|
+
`tests/test_refresh_usage_helpers.py`, `tests/test_refresh_usage_cmd.py`,
|
|
9
|
+
`tests/test_refresh_usage_inproc.py`, `tests/test_oauth_usage_config.py`,
|
|
10
|
+
`tests/test_ua_discovery.py`, `tests/test_hook_tick_rate_limit.py`,
|
|
11
|
+
`tests/test_dashboard_api_sync_refresh.py`) still work because the
|
|
12
|
+
re-export populates cctally's namespace at module-load time.
|
|
13
|
+
|
|
14
|
+
Stays in bin/cctally (reached via the ``_cctally()`` accessor):
|
|
15
|
+
- ``_resolve_oauth_token`` / ``_read_keychain_oauth_blob`` —
|
|
16
|
+
auth-layer primitives also consumed outside refresh.
|
|
17
|
+
- ``_seconds_since_iso``, ``_select_last_known_snapshot``,
|
|
18
|
+
``_newest_snapshot_age_seconds`` — generic time / DB helpers
|
|
19
|
+
used by dashboard, doctor, and freshness chips.
|
|
20
|
+
- ``cmd_record_usage`` — hot-path record-usage entry (Phase D).
|
|
21
|
+
- ``load_config`` — already in ``_cctally_config.py``; we read it
|
|
22
|
+
through cctally's namespace so test monkeypatches propagate.
|
|
23
|
+
- ``HOOK_TICK_DEFAULT_THROTTLE_SECONDS`` — config-fallback constant.
|
|
24
|
+
- Tiny helpers: ``eprint``, ``now_utc_iso``, ``_iso_to_epoch``,
|
|
25
|
+
``_normalize_percent``, ``_forecast_color_enabled``,
|
|
26
|
+
``_format_short_duration``.
|
|
27
|
+
|
|
28
|
+
§5.6 Option C call-site rewrites (call-time lookup so test
|
|
29
|
+
monkeypatches on cctally's namespace propagate into this module):
|
|
30
|
+
- `_discover_cc_version`, `_fetch_oauth_usage`,
|
|
31
|
+
`_bust_statusline_cache`, `_refresh_usage_inproc`,
|
|
32
|
+
`_get_oauth_usage_config` — all monkeypatched in at least one
|
|
33
|
+
test; their internal call sites route through `c.X`.
|
|
34
|
+
|
|
35
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import dataclasses
|
|
41
|
+
import datetime as dt
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import pathlib
|
|
45
|
+
import re
|
|
46
|
+
import socket
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
import urllib.error
|
|
50
|
+
import urllib.request
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cctally():
|
|
54
|
+
"""Resolve the current `cctally` module at call-time (spec §5.5)."""
|
|
55
|
+
return sys.modules["cctally"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# =========================================================================
|
|
59
|
+
# Exception classes
|
|
60
|
+
# =========================================================================
|
|
61
|
+
|
|
62
|
+
class RefreshUsageNetworkError(Exception):
|
|
63
|
+
"""Raised when the OAuth usage API can't be reached or returns non-2xx."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RefreshUsageRateLimitError(RefreshUsageNetworkError):
|
|
67
|
+
"""Raised when the OAuth usage API returns HTTP 429.
|
|
68
|
+
|
|
69
|
+
Subclass of RefreshUsageNetworkError for backward compatibility:
|
|
70
|
+
callers that already except RefreshUsageNetworkError continue to
|
|
71
|
+
catch this; specific handlers can except RefreshUsageRateLimitError
|
|
72
|
+
first to branch on the rate-limit case.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RefreshUsageMalformedError(Exception):
|
|
77
|
+
"""Raised when the OAuth usage API response is unparseable or missing
|
|
78
|
+
required seven_day fields (utilization or resets_at)."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# =========================================================================
|
|
82
|
+
# _RefreshUsageResult + URL
|
|
83
|
+
# =========================================================================
|
|
84
|
+
|
|
85
|
+
@dataclasses.dataclass
|
|
86
|
+
class _RefreshUsageResult:
|
|
87
|
+
"""Outcome of a single _refresh_usage_inproc() invocation.
|
|
88
|
+
|
|
89
|
+
status enum:
|
|
90
|
+
ok - fetch + record-usage succeeded.
|
|
91
|
+
rate_limited - Anthropic 429 (fallback=True; last-known data still valid).
|
|
92
|
+
no_oauth_token - _resolve_oauth_token returned None.
|
|
93
|
+
fetch_failed - RefreshUsageNetworkError (DNS, connection, non-429 HTTP error).
|
|
94
|
+
parse_failed - RefreshUsageMalformedError or seven_day field extraction raised.
|
|
95
|
+
record_failed - cmd_record_usage returned non-zero or raised.
|
|
96
|
+
|
|
97
|
+
payload (success only): the dict normally passed to
|
|
98
|
+
_serialize_refresh_usage_json / _render_refresh_usage_text by
|
|
99
|
+
cmd_refresh_usage. None on non-ok statuses. Tests examining only
|
|
100
|
+
status/fallback/reason can ignore this field.
|
|
101
|
+
|
|
102
|
+
warnings (success only): non-fatal degradations that occurred during
|
|
103
|
+
the fetch (e.g. unparseable five_hour fields silently dropped). The
|
|
104
|
+
CLI command emits these via eprint; structured callers may surface
|
|
105
|
+
them however they like. Empty list on no warnings.
|
|
106
|
+
"""
|
|
107
|
+
status: str
|
|
108
|
+
fallback: bool = False
|
|
109
|
+
reason: "str | None" = None
|
|
110
|
+
payload: "dict | None" = None
|
|
111
|
+
warnings: list = dataclasses.field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# =========================================================================
|
|
118
|
+
# Claude Code version discovery (User-Agent surface)
|
|
119
|
+
# =========================================================================
|
|
120
|
+
|
|
121
|
+
# Strict semver match with optional prerelease. Three numeric parts;
|
|
122
|
+
# 4-part form like "2.1.116.4" is intentionally rejected (the spec
|
|
123
|
+
# requires a valid `claude-code/<semver>` UA only).
|
|
124
|
+
_CC_SEMVER_RE = re.compile(
|
|
125
|
+
r"(?<!\d)(?<!\d\.)(\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?)(?!\.?\d)"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_cc_semver(s) -> str | None:
|
|
130
|
+
"""Extract the first valid `MAJOR.MINOR.PATCH(-prerelease)?` token from
|
|
131
|
+
`s`. Rejects 4-part forms (e.g. "2.1.116.4") and non-numeric prefixes.
|
|
132
|
+
Returns None if no match.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(s, str) or not s:
|
|
135
|
+
return None
|
|
136
|
+
m = _CC_SEMVER_RE.search(s)
|
|
137
|
+
return m.group(1) if m else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
CLAUDE_CODE_UA_FALLBACK_VERSION = "2.1.116"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _discover_cc_version() -> str:
|
|
144
|
+
"""Discover the active Claude Code version for our `claude-code/<X>` UA.
|
|
145
|
+
|
|
146
|
+
Order: `claude --version` (5s timeout) → highest semver under
|
|
147
|
+
`~/.local/share/claude/versions/` → CLAUDE_CODE_UA_FALLBACK_VERSION.
|
|
148
|
+
Never raises.
|
|
149
|
+
"""
|
|
150
|
+
# Tier 1: active executable.
|
|
151
|
+
try:
|
|
152
|
+
proc = subprocess.run(
|
|
153
|
+
["claude", "--version"],
|
|
154
|
+
capture_output=True, text=True, timeout=5, check=False,
|
|
155
|
+
)
|
|
156
|
+
v = _parse_cc_semver(proc.stdout) if proc.returncode == 0 else None
|
|
157
|
+
if v:
|
|
158
|
+
return v
|
|
159
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
# Tier 2: versions/ directory.
|
|
163
|
+
try:
|
|
164
|
+
versions_dir = pathlib.Path.home() / ".local" / "share" / "claude" / "versions"
|
|
165
|
+
if versions_dir.is_dir():
|
|
166
|
+
candidates = []
|
|
167
|
+
for entry in versions_dir.iterdir():
|
|
168
|
+
if not entry.is_dir():
|
|
169
|
+
continue
|
|
170
|
+
v = _parse_cc_semver(entry.name)
|
|
171
|
+
if v:
|
|
172
|
+
candidates.append(v)
|
|
173
|
+
if candidates:
|
|
174
|
+
# Sort by tuple-of-int parts; prerelease tuples sort lower.
|
|
175
|
+
def _key(s):
|
|
176
|
+
base, _, pre = s.partition("-")
|
|
177
|
+
base_parts = tuple(int(x) for x in base.split("."))
|
|
178
|
+
# Prerelease present -> sorts lower than no-prerelease for same base.
|
|
179
|
+
return (base_parts, 0 if pre else 1, pre)
|
|
180
|
+
candidates.sort(key=_key)
|
|
181
|
+
return candidates[-1]
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
return CLAUDE_CODE_UA_FALLBACK_VERSION
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolve_oauth_usage_user_agent(
|
|
189
|
+
oauth_usage_cfg: dict,
|
|
190
|
+
*,
|
|
191
|
+
version_resolver=None,
|
|
192
|
+
) -> str:
|
|
193
|
+
"""Return the User-Agent string for /api/oauth/usage requests.
|
|
194
|
+
|
|
195
|
+
Honors `oauth_usage_cfg["user_agent"]` override; otherwise builds
|
|
196
|
+
`claude-code/<version>` via `version_resolver` (injectable for tests).
|
|
197
|
+
"""
|
|
198
|
+
if version_resolver is None:
|
|
199
|
+
# §5.6 Option C: route through cctally's namespace so
|
|
200
|
+
# `monkeypatch.setitem(ns, "_discover_cc_version", …)` propagates
|
|
201
|
+
# into this caller (tests/test_refresh_usage_helpers.py).
|
|
202
|
+
version_resolver = _cctally()._discover_cc_version
|
|
203
|
+
override = oauth_usage_cfg.get("user_agent")
|
|
204
|
+
if override:
|
|
205
|
+
return override
|
|
206
|
+
return f"claude-code/{version_resolver()}"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# =========================================================================
|
|
210
|
+
# Core OAuth fetch
|
|
211
|
+
# =========================================================================
|
|
212
|
+
|
|
213
|
+
def _fetch_oauth_usage(token: str, timeout_seconds: float) -> dict:
|
|
214
|
+
"""GET the OAuth usage API and return the parsed JSON object.
|
|
215
|
+
|
|
216
|
+
Raises ``RefreshUsageNetworkError`` for any network-layer failure
|
|
217
|
+
(connection, DNS, timeout, non-2xx HTTP). Raises
|
|
218
|
+
``RefreshUsageMalformedError`` when the response body is not JSON
|
|
219
|
+
or is missing the required ``seven_day.utilization`` or
|
|
220
|
+
``seven_day.resets_at`` fields.
|
|
221
|
+
"""
|
|
222
|
+
c = _cctally()
|
|
223
|
+
cfg = c._get_oauth_usage_config(c.load_config())
|
|
224
|
+
user_agent = _resolve_oauth_usage_user_agent(cfg)
|
|
225
|
+
req = urllib.request.Request(
|
|
226
|
+
_OAUTH_USAGE_URL,
|
|
227
|
+
headers={
|
|
228
|
+
"Authorization": f"Bearer {token}",
|
|
229
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
230
|
+
"Content-Type": "application/json",
|
|
231
|
+
"User-Agent": user_agent,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
try:
|
|
235
|
+
with urllib.request.urlopen(req, timeout=timeout_seconds) as resp:
|
|
236
|
+
body = resp.read()
|
|
237
|
+
except urllib.error.HTTPError as e:
|
|
238
|
+
snippet = ""
|
|
239
|
+
try:
|
|
240
|
+
snippet = (e.read() or b"").decode("utf-8", errors="replace")[:200]
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
msg = f"HTTP {e.code} {e.reason}" + (f": {snippet}" if snippet else "")
|
|
244
|
+
if e.code == 429:
|
|
245
|
+
raise RefreshUsageRateLimitError(msg) from e
|
|
246
|
+
raise RefreshUsageNetworkError(msg) from e
|
|
247
|
+
except urllib.error.URLError as e:
|
|
248
|
+
raise RefreshUsageNetworkError(f"URLError: {e.reason}") from e
|
|
249
|
+
except socket.timeout as e:
|
|
250
|
+
raise RefreshUsageNetworkError(
|
|
251
|
+
f"timed out after {timeout_seconds}s"
|
|
252
|
+
) from e
|
|
253
|
+
except OSError as e:
|
|
254
|
+
raise RefreshUsageNetworkError(f"OSError: {e}") from e
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
data = json.loads(body)
|
|
258
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
|
259
|
+
raise RefreshUsageMalformedError("response was not JSON") from e
|
|
260
|
+
|
|
261
|
+
seven = data.get("seven_day") if isinstance(data, dict) else None
|
|
262
|
+
if not isinstance(seven, dict) or "utilization" not in seven:
|
|
263
|
+
raise RefreshUsageMalformedError("response missing seven_day.utilization")
|
|
264
|
+
resets_at = seven.get("resets_at")
|
|
265
|
+
if not isinstance(resets_at, str) or not resets_at.strip():
|
|
266
|
+
raise RefreshUsageMalformedError("response missing seven_day.resets_at")
|
|
267
|
+
|
|
268
|
+
return data
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# =========================================================================
|
|
272
|
+
# Renderers (text + JSON)
|
|
273
|
+
# =========================================================================
|
|
274
|
+
|
|
275
|
+
# ANSI codes for refresh-usage's compact one-liner. Mirrors the
|
|
276
|
+
# yellow/orange/dim palette used by ~/.claude/statusline-command.sh.
|
|
277
|
+
_REFRESH_USAGE_ANSI = {
|
|
278
|
+
"yellow": "\033[33m",
|
|
279
|
+
"orange": "\033[38;5;208m",
|
|
280
|
+
"dim": "\033[2m",
|
|
281
|
+
"reset": "\033[0m",
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _render_refresh_usage_text(payload: dict, color: bool, now_epoch: int) -> str:
|
|
286
|
+
"""Render the compact one-liner.
|
|
287
|
+
|
|
288
|
+
Format: ``refresh-usage: 7d N% (in Xd Yh) | 5h N% (in Zh) [src:S cache:C]``.
|
|
289
|
+
The ``5h`` segment is omitted entirely when ``payload["five_hour"]`` is None.
|
|
290
|
+
Color codes are emitted only when ``color`` is True.
|
|
291
|
+
"""
|
|
292
|
+
c = _cctally()
|
|
293
|
+
a = _REFRESH_USAGE_ANSI if color else {k: "" for k in _REFRESH_USAGE_ANSI}
|
|
294
|
+
|
|
295
|
+
seven = payload["seven_day"]
|
|
296
|
+
seven_pct = seven["used_percent"]
|
|
297
|
+
seven_resets = seven.get("resets_at_epoch")
|
|
298
|
+
if seven_resets is not None:
|
|
299
|
+
seven_ttl = c._format_short_duration(seven_resets - now_epoch)
|
|
300
|
+
seven_seg = (
|
|
301
|
+
f"{a['yellow']}7d {seven_pct:.0f}%{a['reset']}"
|
|
302
|
+
f" {a['orange']}(in {seven_ttl}){a['reset']}"
|
|
303
|
+
)
|
|
304
|
+
else:
|
|
305
|
+
seven_seg = f"{a['yellow']}7d {seven_pct:.0f}%{a['reset']}"
|
|
306
|
+
|
|
307
|
+
five = payload.get("five_hour")
|
|
308
|
+
if five is not None:
|
|
309
|
+
five_pct = five["used_percent"]
|
|
310
|
+
five_resets = five.get("resets_at_epoch")
|
|
311
|
+
if five_resets is not None:
|
|
312
|
+
five_ttl = c._format_short_duration(five_resets - now_epoch)
|
|
313
|
+
five_seg = (
|
|
314
|
+
f" | {a['yellow']}5h {five_pct:.0f}%{a['reset']}"
|
|
315
|
+
f" {a['orange']}(in {five_ttl}){a['reset']}"
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
five_seg = f" | {a['yellow']}5h {five_pct:.0f}%{a['reset']}"
|
|
319
|
+
else:
|
|
320
|
+
five_seg = ""
|
|
321
|
+
|
|
322
|
+
tag = (
|
|
323
|
+
f" {a['dim']}[src:{payload['source']} "
|
|
324
|
+
f"cache:{payload['statusline_cache']}]{a['reset']}"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
return f"{a['dim']}refresh-usage:{a['reset']} {seven_seg}{five_seg}{tag}"
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _serialize_refresh_usage_json(payload: dict) -> str:
|
|
331
|
+
"""Serialize the JSON-mode payload deterministically (sorted keys, 2-space indent)."""
|
|
332
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# =========================================================================
|
|
336
|
+
# OAuth-usage config block (validator + defaults)
|
|
337
|
+
# =========================================================================
|
|
338
|
+
|
|
339
|
+
class OauthUsageConfigError(ValueError):
|
|
340
|
+
"""Raised by _get_oauth_usage_config on invalid oauth_usage block."""
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
_OAUTH_USAGE_DEFAULTS = {
|
|
344
|
+
"user_agent": None,
|
|
345
|
+
"throttle_seconds": 15,
|
|
346
|
+
"fresh_threshold_seconds": 30,
|
|
347
|
+
"stale_after_seconds": 90,
|
|
348
|
+
}
|
|
349
|
+
_OAUTH_USAGE_THROTTLE_MIN = 5
|
|
350
|
+
_OAUTH_USAGE_THROTTLE_MAX = 600
|
|
351
|
+
_OAUTH_USAGE_USER_AGENT_MAX_LEN = 256
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _get_oauth_usage_config(cfg: dict) -> dict:
|
|
355
|
+
"""Return the validated, defaults-filled oauth_usage block.
|
|
356
|
+
|
|
357
|
+
Raises OauthUsageConfigError on invalid values. Unknown sub-keys are
|
|
358
|
+
silently ignored to preserve forward compatibility.
|
|
359
|
+
"""
|
|
360
|
+
block = cfg.get("oauth_usage") if isinstance(cfg, dict) else None
|
|
361
|
+
if block is None:
|
|
362
|
+
return dict(_OAUTH_USAGE_DEFAULTS)
|
|
363
|
+
if not isinstance(block, dict):
|
|
364
|
+
raise OauthUsageConfigError(
|
|
365
|
+
f"oauth_usage must be an object, got {type(block).__name__}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
out = dict(_OAUTH_USAGE_DEFAULTS)
|
|
369
|
+
|
|
370
|
+
if "user_agent" in block and block["user_agent"] is not None:
|
|
371
|
+
ua = block["user_agent"]
|
|
372
|
+
if not isinstance(ua, str) or not ua:
|
|
373
|
+
raise OauthUsageConfigError(
|
|
374
|
+
"oauth_usage.user_agent must be a non-empty string or null"
|
|
375
|
+
)
|
|
376
|
+
if len(ua) > _OAUTH_USAGE_USER_AGENT_MAX_LEN:
|
|
377
|
+
raise OauthUsageConfigError(
|
|
378
|
+
f"oauth_usage.user_agent exceeds {_OAUTH_USAGE_USER_AGENT_MAX_LEN} chars"
|
|
379
|
+
)
|
|
380
|
+
out["user_agent"] = ua
|
|
381
|
+
|
|
382
|
+
for key in ("throttle_seconds", "fresh_threshold_seconds", "stale_after_seconds"):
|
|
383
|
+
if key in block and block[key] is not None:
|
|
384
|
+
v = block[key]
|
|
385
|
+
if not isinstance(v, int) or isinstance(v, bool):
|
|
386
|
+
raise OauthUsageConfigError(
|
|
387
|
+
f"oauth_usage.{key} must be an integer"
|
|
388
|
+
)
|
|
389
|
+
if v < 1:
|
|
390
|
+
raise OauthUsageConfigError(
|
|
391
|
+
f"oauth_usage.{key} must be >= 1"
|
|
392
|
+
)
|
|
393
|
+
out[key] = v
|
|
394
|
+
|
|
395
|
+
t = out["throttle_seconds"]
|
|
396
|
+
if t < _OAUTH_USAGE_THROTTLE_MIN or t > _OAUTH_USAGE_THROTTLE_MAX:
|
|
397
|
+
raise OauthUsageConfigError(
|
|
398
|
+
f"oauth_usage.throttle_seconds must be in [{_OAUTH_USAGE_THROTTLE_MIN}, "
|
|
399
|
+
f"{_OAUTH_USAGE_THROTTLE_MAX}], got {t}"
|
|
400
|
+
)
|
|
401
|
+
if out["fresh_threshold_seconds"] >= out["stale_after_seconds"]:
|
|
402
|
+
raise OauthUsageConfigError(
|
|
403
|
+
"oauth_usage.fresh_threshold_seconds must be < stale_after_seconds"
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return out
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# =========================================================================
|
|
410
|
+
# Statusline cache bust + freshness + rate-limit handler
|
|
411
|
+
# =========================================================================
|
|
412
|
+
|
|
413
|
+
_STATUSLINE_OAUTH_CACHE = "/tmp/claude-statusline-usage-cache.json"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _bust_statusline_cache(path: str = _STATUSLINE_OAUTH_CACHE) -> str:
|
|
417
|
+
"""Best-effort delete of the statusline OAuth cache file.
|
|
418
|
+
|
|
419
|
+
Returns one of: ``"busted"`` (file existed and was removed),
|
|
420
|
+
``"absent"`` (file did not exist), ``"error"`` (delete failed for
|
|
421
|
+
a non-FileNotFoundError reason — logged via eprint, does NOT raise).
|
|
422
|
+
"""
|
|
423
|
+
c = _cctally()
|
|
424
|
+
try:
|
|
425
|
+
os.remove(path)
|
|
426
|
+
return "busted"
|
|
427
|
+
except FileNotFoundError:
|
|
428
|
+
return "absent"
|
|
429
|
+
except OSError as exc:
|
|
430
|
+
c.eprint(f"refresh-usage: cache-bust failed: {exc}")
|
|
431
|
+
return "error"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _freshness_label(age_seconds: float, oauth_usage_cfg: dict) -> str:
|
|
435
|
+
"""Stub — replaced in Task C1 with full three-tier logic."""
|
|
436
|
+
if age_seconds <= oauth_usage_cfg["fresh_threshold_seconds"]:
|
|
437
|
+
return "fresh"
|
|
438
|
+
if age_seconds <= oauth_usage_cfg["stale_after_seconds"]:
|
|
439
|
+
return "aging"
|
|
440
|
+
return "stale"
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _cmd_refresh_usage_handle_rate_limit(args: argparse.Namespace, exc) -> int:
|
|
444
|
+
"""Implements the §3.2(c) fallback contract: serve last-known
|
|
445
|
+
snapshot from DB, exit 0 in all 429 cases."""
|
|
446
|
+
c = _cctally()
|
|
447
|
+
snap = c._select_last_known_snapshot()
|
|
448
|
+
cfg = _get_oauth_usage_config(c.load_config())
|
|
449
|
+
json_mode = bool(getattr(args, "json", False))
|
|
450
|
+
quiet = bool(getattr(args, "quiet", False))
|
|
451
|
+
|
|
452
|
+
if snap is None:
|
|
453
|
+
c.eprint("refresh-usage: rate-limited; no last-known data; "
|
|
454
|
+
"status-line will populate on next CC tick")
|
|
455
|
+
if json_mode:
|
|
456
|
+
print(json.dumps({
|
|
457
|
+
"status": "rate_limited",
|
|
458
|
+
"fallback": None,
|
|
459
|
+
"freshness": None,
|
|
460
|
+
"reason": "no prior snapshot",
|
|
461
|
+
}, sort_keys=True, indent=2))
|
|
462
|
+
return 0
|
|
463
|
+
|
|
464
|
+
captured_iso = snap.pop("captured_at_utc", None)
|
|
465
|
+
age_s = c._seconds_since_iso(captured_iso) if captured_iso else None
|
|
466
|
+
label = (
|
|
467
|
+
_freshness_label(age_s, cfg) if age_s is not None else "stale"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
c.eprint(f"refresh-usage: rate-limited; using last-known "
|
|
471
|
+
f"(captured {int(age_s) if age_s is not None else '?'}s ago)")
|
|
472
|
+
|
|
473
|
+
if json_mode:
|
|
474
|
+
envelope = {
|
|
475
|
+
"status": "rate_limited",
|
|
476
|
+
"fallback": snap,
|
|
477
|
+
"freshness": {
|
|
478
|
+
"label": label,
|
|
479
|
+
"captured_at": captured_iso,
|
|
480
|
+
"age_seconds": int(age_s) if age_s is not None else None,
|
|
481
|
+
},
|
|
482
|
+
"reason": "user-agent rate-limit gate",
|
|
483
|
+
}
|
|
484
|
+
print(json.dumps(envelope, sort_keys=True, indent=2))
|
|
485
|
+
elif not quiet:
|
|
486
|
+
# Reuse the standard text renderer with the fallback payload.
|
|
487
|
+
color_mode = getattr(args, "color", "auto") or "auto"
|
|
488
|
+
color = c._forecast_color_enabled(color_mode, sys.stdout)
|
|
489
|
+
now_epoch = int(dt.datetime.now(dt.timezone.utc).timestamp())
|
|
490
|
+
print(_render_refresh_usage_text(snap, color=color, now_epoch=now_epoch))
|
|
491
|
+
return 0
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# =========================================================================
|
|
495
|
+
# In-process refresh + cmd_refresh_usage
|
|
496
|
+
# =========================================================================
|
|
497
|
+
|
|
498
|
+
def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
499
|
+
"""Force-fetch the OAuth usage API and persist via cmd_record_usage.
|
|
500
|
+
|
|
501
|
+
This is the in-process counterpart to ``cmd_refresh_usage`` (force-fetch
|
|
502
|
+
semantics: NO local throttle, busts statusline cache on success). NOT the
|
|
503
|
+
same path as ``_hook_tick_oauth_refresh`` - that one honors
|
|
504
|
+
``oauth_usage.throttle_seconds`` and would silently skip an explicit user
|
|
505
|
+
chip-click within the throttle window.
|
|
506
|
+
|
|
507
|
+
Returns a structured ``_RefreshUsageResult`` so callers (chiefly
|
|
508
|
+
``cmd_refresh_usage`` for stdout printing and the dashboard's
|
|
509
|
+
``POST /api/sync`` handler for JSON envelope construction) can branch on
|
|
510
|
+
a 6-value status enum without parsing free-form stderr.
|
|
511
|
+
|
|
512
|
+
The harness env var ``CCTALLY_TEST_REFRESH_RESULT`` short-circuits to a
|
|
513
|
+
deterministic outcome (``ok``/``rate_limited``/``no_oauth_token``/
|
|
514
|
+
``fetch_failed``/``parse_failed``/``record_failed``) so bash-level golden
|
|
515
|
+
harnesses can exercise warning paths without faking OAuth on the wire.
|
|
516
|
+
"""
|
|
517
|
+
c = _cctally()
|
|
518
|
+
forced = os.environ.get("CCTALLY_TEST_REFRESH_RESULT")
|
|
519
|
+
if forced:
|
|
520
|
+
return _RefreshUsageResult(
|
|
521
|
+
status=forced,
|
|
522
|
+
fallback=(forced == "rate_limited"),
|
|
523
|
+
reason="test stub",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
token = c._resolve_oauth_token()
|
|
527
|
+
if not token:
|
|
528
|
+
return _RefreshUsageResult(status="no_oauth_token", reason="no token")
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
532
|
+
except RefreshUsageRateLimitError as exc:
|
|
533
|
+
return _RefreshUsageResult(status="rate_limited", fallback=True,
|
|
534
|
+
reason=str(exc))
|
|
535
|
+
except RefreshUsageNetworkError as exc:
|
|
536
|
+
return _RefreshUsageResult(status="fetch_failed", reason=str(exc))
|
|
537
|
+
except RefreshUsageMalformedError as exc:
|
|
538
|
+
return _RefreshUsageResult(status="parse_failed", reason=str(exc))
|
|
539
|
+
except OauthUsageConfigError as exc:
|
|
540
|
+
return _RefreshUsageResult(
|
|
541
|
+
status="fetch_failed",
|
|
542
|
+
reason=f"invalid oauth_usage config: {exc}",
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
seven = api.get("seven_day") or {}
|
|
546
|
+
try:
|
|
547
|
+
# Normalize at the OAuth ingress so the payload JSON published
|
|
548
|
+
# on the SSE envelope (`used_percent` field) is clean even when
|
|
549
|
+
# this code path doesn't reach cmd_record_usage (e.g. payload
|
|
550
|
+
# built then a downstream cmd_record_usage call fails).
|
|
551
|
+
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
552
|
+
seven_resets_iso = seven["resets_at"]
|
|
553
|
+
seven_resets_epoch = c._iso_to_epoch(seven_resets_iso)
|
|
554
|
+
except (TypeError, ValueError, KeyError) as exc:
|
|
555
|
+
return _RefreshUsageResult(
|
|
556
|
+
status="parse_failed",
|
|
557
|
+
reason=f"OAuth response had unparseable seven_day fields: {exc}",
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
561
|
+
five_pct = None
|
|
562
|
+
five_resets_iso = None
|
|
563
|
+
five_resets_epoch = None
|
|
564
|
+
warnings: list = []
|
|
565
|
+
if five is not None and "utilization" in five and "resets_at" in five:
|
|
566
|
+
try:
|
|
567
|
+
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
568
|
+
five_resets_iso = five["resets_at"]
|
|
569
|
+
five_resets_epoch = c._iso_to_epoch(five_resets_iso)
|
|
570
|
+
except (TypeError, ValueError) as exc:
|
|
571
|
+
# 5h is optional - silently degrade rather than fail the command
|
|
572
|
+
# (parity with the previous cmd_refresh_usage behavior; the eprint
|
|
573
|
+
# warning is emitted by cmd_refresh_usage when consuming
|
|
574
|
+
# result.warnings, so /api/sync callers don't get stderr noise).
|
|
575
|
+
five_pct = None
|
|
576
|
+
five_resets_iso = None
|
|
577
|
+
five_resets_epoch = None
|
|
578
|
+
warnings.append(
|
|
579
|
+
f"ignoring unparseable five_hour fields: {exc}"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
record_args = argparse.Namespace(
|
|
583
|
+
percent=seven_pct,
|
|
584
|
+
resets_at=str(seven_resets_epoch),
|
|
585
|
+
five_hour_percent=five_pct,
|
|
586
|
+
five_hour_resets_at=(
|
|
587
|
+
str(five_resets_epoch) if five_resets_epoch is not None else None
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
try:
|
|
591
|
+
rc = c.cmd_record_usage(record_args)
|
|
592
|
+
except Exception as exc:
|
|
593
|
+
return _RefreshUsageResult(status="record_failed", reason=str(exc))
|
|
594
|
+
if rc != 0:
|
|
595
|
+
return _RefreshUsageResult(status="record_failed", reason=f"exit {rc}")
|
|
596
|
+
|
|
597
|
+
# §5.6 Option C: route through cctally's namespace so
|
|
598
|
+
# `monkeypatch.setitem(ns, "_bust_statusline_cache", …)` propagates
|
|
599
|
+
# (tests/test_refresh_usage_cmd.py:55, test_refresh_usage_inproc.py:18).
|
|
600
|
+
cache_state = c._bust_statusline_cache()
|
|
601
|
+
|
|
602
|
+
fetched_at = c.now_utc_iso()
|
|
603
|
+
fresh_envelope = {
|
|
604
|
+
"label": "fresh",
|
|
605
|
+
"captured_at": fetched_at,
|
|
606
|
+
"age_seconds": 0,
|
|
607
|
+
}
|
|
608
|
+
payload = {
|
|
609
|
+
"schema_version": 1,
|
|
610
|
+
"fetched_at": fetched_at,
|
|
611
|
+
"seven_day": {
|
|
612
|
+
"used_percent": seven_pct,
|
|
613
|
+
"resets_at": seven_resets_iso,
|
|
614
|
+
"resets_at_epoch": seven_resets_epoch,
|
|
615
|
+
},
|
|
616
|
+
"five_hour": (
|
|
617
|
+
{
|
|
618
|
+
"used_percent": five_pct,
|
|
619
|
+
"resets_at": five_resets_iso,
|
|
620
|
+
"resets_at_epoch": five_resets_epoch,
|
|
621
|
+
}
|
|
622
|
+
if five_pct is not None
|
|
623
|
+
else None
|
|
624
|
+
),
|
|
625
|
+
"freshness": fresh_envelope,
|
|
626
|
+
"source": "api",
|
|
627
|
+
"statusline_cache": cache_state,
|
|
628
|
+
}
|
|
629
|
+
return _RefreshUsageResult(status="ok", payload=payload, warnings=warnings)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
633
|
+
"""Force-fetch the OAuth usage API and persist via cmd_record_usage.
|
|
634
|
+
|
|
635
|
+
Returns: 0 success OR rate-limited (graceful fallback), 2 token missing,
|
|
636
|
+
3 network/HTTP non-429 failure, 4 malformed response, 5 cmd_record_usage
|
|
637
|
+
internal failure.
|
|
638
|
+
|
|
639
|
+
Thin shell over ``_refresh_usage_inproc``: dispatches the structured
|
|
640
|
+
result to the appropriate stderr/stdout renderer while preserving every
|
|
641
|
+
pre-refactor exit code and user-facing message.
|
|
642
|
+
"""
|
|
643
|
+
c = _cctally()
|
|
644
|
+
timeout = float(getattr(args, "timeout", 5.0) or 5.0)
|
|
645
|
+
# §5.6 Option C: route through cctally's namespace so
|
|
646
|
+
# `monkeypatch.setitem(ns, "_refresh_usage_inproc", _spy)` propagates
|
|
647
|
+
# (tests/test_dashboard_api_sync_refresh.py:199).
|
|
648
|
+
result = c._refresh_usage_inproc(timeout_seconds=timeout)
|
|
649
|
+
|
|
650
|
+
if result.status == "ok":
|
|
651
|
+
# Surface non-fatal degradations (e.g. dropped five_hour fields).
|
|
652
|
+
# Emit BEFORE rendering so stderr flushes consistently for harnesses
|
|
653
|
+
# that grep across both streams.
|
|
654
|
+
for warning in result.warnings:
|
|
655
|
+
c.eprint(f"refresh-usage: {warning}")
|
|
656
|
+
payload = result.payload or {}
|
|
657
|
+
if getattr(args, "json", False):
|
|
658
|
+
print(_serialize_refresh_usage_json(payload))
|
|
659
|
+
elif not getattr(args, "quiet", False):
|
|
660
|
+
color_mode = getattr(args, "color", "auto") or "auto"
|
|
661
|
+
color = c._forecast_color_enabled(color_mode, sys.stdout)
|
|
662
|
+
now_epoch = int(dt.datetime.now(dt.timezone.utc).timestamp())
|
|
663
|
+
print(_render_refresh_usage_text(payload, color=color, now_epoch=now_epoch))
|
|
664
|
+
return 0
|
|
665
|
+
|
|
666
|
+
if result.status == "rate_limited":
|
|
667
|
+
return _cmd_refresh_usage_handle_rate_limit(
|
|
668
|
+
args, RefreshUsageRateLimitError(result.reason or "rate limited"))
|
|
669
|
+
|
|
670
|
+
if result.status == "no_oauth_token":
|
|
671
|
+
c.eprint("refresh-usage: no OAuth token found "
|
|
672
|
+
"(run 'claude' once to authenticate)")
|
|
673
|
+
return 2
|
|
674
|
+
|
|
675
|
+
if result.status == "fetch_failed":
|
|
676
|
+
# Distinguish the OauthUsageConfigError sub-case (returns 2) from
|
|
677
|
+
# genuine network/HTTP failures (returns 3). The helper carries the
|
|
678
|
+
# config-error reason string verbatim so we can detect it here.
|
|
679
|
+
reason = result.reason or ""
|
|
680
|
+
if reason.startswith("invalid oauth_usage config:"):
|
|
681
|
+
c.eprint(f"cctally: {reason}")
|
|
682
|
+
return 2
|
|
683
|
+
c.eprint(f"refresh-usage: OAuth fetch failed: {reason}")
|
|
684
|
+
return 3
|
|
685
|
+
|
|
686
|
+
if result.status == "parse_failed":
|
|
687
|
+
# Five-hour parse failures are silently degraded inside the helper;
|
|
688
|
+
# only seven_day failures (RefreshUsageMalformedError or unparseable
|
|
689
|
+
# field extraction) propagate here. Preserve the original exact
|
|
690
|
+
# error-line shape for bash harnesses that grep stderr.
|
|
691
|
+
c.eprint(f"refresh-usage: {result.reason or ''}")
|
|
692
|
+
return 4
|
|
693
|
+
|
|
694
|
+
if result.status == "record_failed":
|
|
695
|
+
reason = result.reason or ""
|
|
696
|
+
if reason.startswith("exit "):
|
|
697
|
+
try:
|
|
698
|
+
rc = int(reason.split()[1])
|
|
699
|
+
except (IndexError, ValueError):
|
|
700
|
+
rc = -1
|
|
701
|
+
c.eprint(f"refresh-usage: failed to record usage (exit {rc})")
|
|
702
|
+
else:
|
|
703
|
+
c.eprint(f"refresh-usage: failed to record usage: {reason}")
|
|
704
|
+
return 5
|
|
705
|
+
|
|
706
|
+
# Defensive: unknown status from _refresh_usage_inproc -> treat as parse error.
|
|
707
|
+
c.eprint(f"refresh-usage: unexpected status {result.status!r}")
|
|
708
|
+
return 4
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# =========================================================================
|
|
712
|
+
# Hook-tick OAuth refresh path
|
|
713
|
+
# =========================================================================
|
|
714
|
+
|
|
715
|
+
def _hook_tick_oauth_refresh(
|
|
716
|
+
timeout_seconds: float = 5.0,
|
|
717
|
+
throttle_seconds: float | None = None,
|
|
718
|
+
) -> tuple[str, dict | None]:
|
|
719
|
+
"""Run the same OAuth fetch + record-usage path as cmd_refresh_usage,
|
|
720
|
+
BUT do NOT call _bust_statusline_cache().
|
|
721
|
+
|
|
722
|
+
`throttle_seconds` controls the DB-snapshot freshness gate (skip the
|
|
723
|
+
fetch if the newest weekly_usage_snapshots row is younger than this).
|
|
724
|
+
`None` => read `oauth_usage.throttle_seconds` from config (falling back
|
|
725
|
+
to HOOK_TICK_DEFAULT_THROTTLE_SECONDS on validation error). An explicit
|
|
726
|
+
value bypasses config so `cmd_hook_tick`'s already-resolved
|
|
727
|
+
`--throttle-seconds` override (including the `0` escape hatch) reaches
|
|
728
|
+
this gate end-to-end.
|
|
729
|
+
|
|
730
|
+
Returns (status_str, payload_or_none) where status_str is one of:
|
|
731
|
+
"ok(7d=N,5h=M)" | "ok(7d=N)"
|
|
732
|
+
"skipped-no-token" | "skipped(fresh:Ns)"
|
|
733
|
+
"err(network)" | "err(parse)" | "err(record-usage=K)"
|
|
734
|
+
payload_or_none is the raw OAuth-API response dict (`seven_day` /
|
|
735
|
+
`five_hour`) on success, or None on any non-ok branch.
|
|
736
|
+
"""
|
|
737
|
+
c = _cctally()
|
|
738
|
+
token = c._resolve_oauth_token()
|
|
739
|
+
if not token:
|
|
740
|
+
return "skipped-no-token", None
|
|
741
|
+
if throttle_seconds is None:
|
|
742
|
+
try:
|
|
743
|
+
throttle_seconds = float(_get_oauth_usage_config(c.load_config())["throttle_seconds"])
|
|
744
|
+
except OauthUsageConfigError:
|
|
745
|
+
throttle_seconds = float(c.HOOK_TICK_DEFAULT_THROTTLE_SECONDS)
|
|
746
|
+
age_s = c._newest_snapshot_age_seconds()
|
|
747
|
+
if age_s is not None and age_s < throttle_seconds:
|
|
748
|
+
return f"skipped(fresh:{int(age_s)}s)", None
|
|
749
|
+
try:
|
|
750
|
+
api = c._fetch_oauth_usage(token=token, timeout_seconds=timeout_seconds)
|
|
751
|
+
except RefreshUsageRateLimitError:
|
|
752
|
+
return "err(rate-limit)", None
|
|
753
|
+
except RefreshUsageNetworkError:
|
|
754
|
+
return "err(network)", None
|
|
755
|
+
except RefreshUsageMalformedError:
|
|
756
|
+
return "err(parse)", None
|
|
757
|
+
seven = api["seven_day"]
|
|
758
|
+
try:
|
|
759
|
+
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
760
|
+
seven_resets_epoch = c._iso_to_epoch(seven["resets_at"])
|
|
761
|
+
except (TypeError, ValueError, KeyError):
|
|
762
|
+
return "err(parse)", None
|
|
763
|
+
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
764
|
+
five_pct: float | None = None
|
|
765
|
+
five_resets_epoch: int | None = None
|
|
766
|
+
if five is not None and "utilization" in five and "resets_at" in five:
|
|
767
|
+
try:
|
|
768
|
+
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
769
|
+
five_resets_epoch = c._iso_to_epoch(five["resets_at"])
|
|
770
|
+
except (TypeError, ValueError):
|
|
771
|
+
five_pct = None
|
|
772
|
+
five_resets_epoch = None
|
|
773
|
+
record_args = argparse.Namespace(
|
|
774
|
+
percent=seven_pct,
|
|
775
|
+
resets_at=str(seven_resets_epoch),
|
|
776
|
+
five_hour_percent=five_pct,
|
|
777
|
+
five_hour_resets_at=str(five_resets_epoch) if five_resets_epoch is not None else None,
|
|
778
|
+
)
|
|
779
|
+
try:
|
|
780
|
+
rc = c.cmd_record_usage(record_args)
|
|
781
|
+
except Exception:
|
|
782
|
+
return "err(record-usage=exc)", None
|
|
783
|
+
if rc != 0:
|
|
784
|
+
return f"err(record-usage={rc})", None
|
|
785
|
+
parts = [f"7d={int(round(seven_pct))}"]
|
|
786
|
+
if five_pct is not None:
|
|
787
|
+
parts.append(f"5h={int(round(five_pct))}")
|
|
788
|
+
return f"ok({','.join(parts)})", api
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _hook_tick_make_mock_refresh(payload_str: str):
|
|
792
|
+
"""Return a stand-in for `_hook_tick_oauth_refresh` driven by a fixed
|
|
793
|
+
JSON payload (or sentinel string). Used only by the test harness via
|
|
794
|
+
the hidden `--mock-oauth-response` flag.
|
|
795
|
+
"""
|
|
796
|
+
def _mock(timeout_seconds: float = 5.0, throttle_seconds: float | None = None):
|
|
797
|
+
if payload_str == "NETWORK_ERROR":
|
|
798
|
+
return "err(network)", None
|
|
799
|
+
if payload_str == "NO_TOKEN":
|
|
800
|
+
return "skipped-no-token", None
|
|
801
|
+
try:
|
|
802
|
+
data = json.loads(payload_str)
|
|
803
|
+
except ValueError:
|
|
804
|
+
return "err(parse)", None
|
|
805
|
+
seven = data.get("seven_day", {})
|
|
806
|
+
five = data.get("five_hour")
|
|
807
|
+
seven_pct = float(seven.get("utilization", 0))
|
|
808
|
+
parts = [f"7d={int(round(seven_pct))}"]
|
|
809
|
+
if isinstance(five, dict) and "utilization" in five:
|
|
810
|
+
parts.append(f"5h={int(round(float(five['utilization'])))}")
|
|
811
|
+
return f"ok({','.join(parts)})", data
|
|
812
|
+
return _mock
|