multi-claude-cli 0.4.0__py3-none-any.whl

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.
multi_claude/usage.py ADDED
@@ -0,0 +1,243 @@
1
+ """账号用量:读 Claude Code 缓存在 `.claude.json` 里的 `cachedUsageUtilization`(方案 v0.2 §5.1.1),
2
+ 以及 statusLine 钩子存下的快照(方案 feature-statusline-usage §5.1.5),取两者中较新的一份。
3
+
4
+ 本模块不读凭据、不联网:用量数据是 Claude Code 运行时顺带写下的缓存,可能已经很旧
5
+ (实测正在使用的账号也可能几十个小时没刷新),所以每份结果都带获取时间,由调用方标注年龄。
6
+ 该字段未写入官方文档,任何字段缺失或类型不符都降级为“无数据”,绝不抛异常。
7
+ `.claude.json` 里还有邮箱、组织名等信息,这里只取用量与 `oauthAccount.accountUuid` 两处。
8
+ """
9
+
10
+ import json
11
+ import math
12
+ import os
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import NamedTuple, Optional
15
+
16
+ from . import accounts, platform
17
+ from .config import IDENTITY_DEFAULT, Account, Config
18
+
19
+ # 超过这个时长的缓存视为过期:仍然展示数值,但标注 stale。
20
+ STALE_AFTER = timedelta(hours=1)
21
+
22
+ STATUS_OK = "ok"
23
+ STATUS_STALE = "stale"
24
+ STATUS_NO_DATA = "no-data"
25
+ # 缓存属于以前登录的另一个账号(同一配置目录换过登录),数值与当前账号无关,不展示。
26
+ STATUS_OTHER_ACCOUNT = "other-account"
27
+ STATUS_UNREADABLE = "unreadable"
28
+
29
+ # 数据来源:Claude Code 自己的缓存,或 statusLine 钩子的快照。
30
+ SOURCE_CACHE = "cache"
31
+ SOURCE_STATUSLINE = "statusline"
32
+ SNAPSHOT_SCHEMA_VERSION = 1
33
+
34
+
35
+ class UsageWindow(NamedTuple):
36
+ # 0-100;缓存里实测为整数,也接受小数
37
+ percent: float
38
+ # 该窗口的重置时间(带时区);缺失或解析失败时为 None
39
+ resets_at: Optional[datetime]
40
+
41
+
42
+ class UsageReport(NamedTuple):
43
+ status: str
44
+ fetched_at: Optional[datetime]
45
+ five_hour: Optional[UsageWindow]
46
+ seven_day: Optional[UsageWindow]
47
+ source: str = SOURCE_CACHE
48
+
49
+
50
+ EMPTY = UsageReport(STATUS_NO_DATA, None, None, None)
51
+
52
+
53
+ def global_state_path(identity: str, account_dir: str) -> str:
54
+ """Claude 存放该账号全局状态(含用量缓存)的文件。
55
+
56
+ 与 accounts.settings_proxy_overrides 的取法一致:default 身份不设 CLAUDE_CONFIG_DIR,
57
+ Claude 用 ~/.claude.json;dir 身份用 <账号目录>/.claude.json。
58
+ """
59
+ if identity == IDENTITY_DEFAULT:
60
+ return os.path.join(os.path.expanduser("~"), ".claude.json")
61
+ return os.path.join(account_dir, ".claude.json")
62
+
63
+
64
+ def read_usage(path: str, now: datetime) -> UsageReport:
65
+ """读取并解析用量缓存。now 必须带时区(用于判断过期与窗口是否已重置)。"""
66
+ try:
67
+ with open(path, "r", encoding="utf-8") as handle:
68
+ data = json.load(handle)
69
+ except FileNotFoundError:
70
+ return EMPTY
71
+ except (OSError, ValueError):
72
+ # Claude 正在写这个文件时可能读到半截内容,按“读不出”处理,不影响其它账号。
73
+ return UsageReport(STATUS_UNREADABLE, None, None, None)
74
+ if not isinstance(data, dict):
75
+ return UsageReport(STATUS_UNREADABLE, None, None, None)
76
+ cache = data.get("cachedUsageUtilization")
77
+ if not isinstance(cache, dict):
78
+ return EMPTY
79
+ fetched_ms = cache.get("fetchedAtMs")
80
+ if not isinstance(fetched_ms, int) or isinstance(fetched_ms, bool):
81
+ return EMPTY
82
+ try:
83
+ fetched_at = datetime.fromtimestamp(fetched_ms / 1000.0, tz=timezone.utc)
84
+ except (OverflowError, OSError, ValueError):
85
+ # 超出平台时间范围的整数(文件被写坏)也只降级为无数据,不能让 usage / list --json 崩溃。
86
+ return EMPTY
87
+
88
+ cached_account = cache.get("accountUuid")
89
+ oauth = data.get("oauthAccount")
90
+ current_account = oauth.get("accountUuid") if isinstance(oauth, dict) else None
91
+ if isinstance(cached_account, str) and isinstance(current_account, str) and cached_account != current_account:
92
+ return UsageReport(STATUS_OTHER_ACCOUNT, fetched_at, None, None)
93
+
94
+ utilization = cache.get("utilization")
95
+ if not isinstance(utilization, dict):
96
+ return EMPTY
97
+ five_hour = _window(utilization.get("five_hour"))
98
+ seven_day = _window(utilization.get("seven_day"))
99
+ if five_hour is None and seven_day is None:
100
+ return EMPTY
101
+ status = STATUS_STALE if now - fetched_at > STALE_AFTER else STATUS_OK
102
+ return UsageReport(status, fetched_at, five_hour, seven_day)
103
+
104
+
105
+ def _window(value: object) -> Optional[UsageWindow]:
106
+ if not isinstance(value, dict):
107
+ return None
108
+ percent = value.get("utilization")
109
+ if isinstance(percent, bool) or not isinstance(percent, (int, float)) or not math.isfinite(percent):
110
+ return None
111
+ return UsageWindow(percent, _parse_time(value.get("resets_at")))
112
+
113
+
114
+ def _parse_time(value: object) -> Optional[datetime]:
115
+ if not isinstance(value, str):
116
+ return None
117
+ text = value[:-1] + "+00:00" if value.endswith("Z") else value
118
+ try:
119
+ parsed = datetime.fromisoformat(text)
120
+ except ValueError:
121
+ return None
122
+ # 没有时区的时间无法与 now 比较,视为解析失败。
123
+ return parsed if parsed.tzinfo is not None else None
124
+
125
+
126
+ def window_is_reset(window: UsageWindow, now: datetime) -> bool:
127
+ """窗口已过重置时间:缓存里的百分比不再有意义(与官方 statusline 丢弃过期窗口的语义一致)。"""
128
+ return window.resets_at is not None and window.resets_at <= now
129
+
130
+
131
+ def report_to_dict(report: UsageReport, now: datetime) -> dict:
132
+ """JSON 输出用的结构;时间用带时区的 ISO 8601。"""
133
+ def window_dict(window: Optional[UsageWindow]) -> Optional[dict]:
134
+ if window is None:
135
+ return None
136
+ return {"percent": window.percent,
137
+ "resets_at": window.resets_at.isoformat() if window.resets_at else None,
138
+ "reset": window_is_reset(window, now)}
139
+ return {
140
+ "status": report.status,
141
+ "fetched_at": report.fetched_at.isoformat() if report.fetched_at else None,
142
+ "age_seconds": int((now - report.fetched_at).total_seconds()) if report.fetched_at else None,
143
+ "five_hour": window_dict(report.five_hour),
144
+ "seven_day": window_dict(report.seven_day),
145
+ "source": report.source,
146
+ }
147
+
148
+
149
+ def snapshot_path(account: Account) -> str:
150
+ """statusLine 快照的位置。按账号目录名而不是账号名存放:改名后目录不变,快照仍对得上。"""
151
+ return os.path.join(platform.state_dir(), "usage", account.dir + ".json")
152
+
153
+
154
+ def current_account_uuid(state_path: str) -> Optional[str]:
155
+ """该账号全局状态文件里当前登录的 accountUuid;读不到时为 None。用来识别“同一目录换了登录”。"""
156
+ try:
157
+ with open(state_path, "r", encoding="utf-8") as handle:
158
+ data = json.load(handle)
159
+ except (OSError, ValueError):
160
+ return None
161
+ oauth = data.get("oauthAccount") if isinstance(data, dict) else None
162
+ value = oauth.get("accountUuid") if isinstance(oauth, dict) else None
163
+ return value if isinstance(value, str) else None
164
+
165
+
166
+ def statusline_windows(rate_limits: object) -> Optional[dict]:
167
+ """从 statusLine stdin 的 `rate_limits`(或快照里存的同名字段)中取出合法的窗口。
168
+
169
+ 只保留 `used_percentage`(0-100)与 `resets_at`(Unix 秒):stdin 里的其它内容不落盘。
170
+ 两个窗口都不合法时返回 None,调用方据此不写快照、不采用快照。
171
+ """
172
+ if not isinstance(rate_limits, dict):
173
+ return None
174
+ windows = {}
175
+ for key in ("five_hour", "seven_day"):
176
+ value = rate_limits.get(key)
177
+ if not isinstance(value, dict) or not _is_number(value.get("used_percentage")):
178
+ continue
179
+ resets_at = value.get("resets_at")
180
+ windows[key] = {"used_percentage": value["used_percentage"],
181
+ "resets_at": resets_at if _is_number(resets_at) else None}
182
+ return windows or None
183
+
184
+
185
+ def read_snapshot(path: str, account_dir: str, current_uuid: Optional[str], now: datetime) -> Optional[UsageReport]:
186
+ """读取 statusLine 快照;不能采用时返回 None(调用方退回缓存)。
187
+
188
+ 以下情况都不能采用:文件缺失或损坏;快照记录的配置目录与账号当前目录不同(目录被换掉);
189
+ 快照与当前登录的 accountUuid 不同(同一目录换了登录,数值属于另一个账号)。
190
+ """
191
+ try:
192
+ with open(path, "r", encoding="utf-8") as handle:
193
+ data = json.load(handle)
194
+ except (OSError, ValueError):
195
+ return None
196
+ if not isinstance(data, dict) or data.get("schema_version") != SNAPSHOT_SCHEMA_VERSION:
197
+ return None
198
+ captured_at = _parse_time(data.get("captured_at"))
199
+ if captured_at is None or data.get("config_dir") != os.path.realpath(account_dir):
200
+ return None
201
+ snapshot_uuid = data.get("account_uuid")
202
+ if isinstance(snapshot_uuid, str) and isinstance(current_uuid, str) and snapshot_uuid != current_uuid:
203
+ return None
204
+ windows = statusline_windows(data.get("rate_limits"))
205
+ if windows is None:
206
+ return None
207
+ status = STATUS_STALE if now - captured_at > STALE_AFTER else STATUS_OK
208
+ return UsageReport(status, captured_at, _snapshot_window(windows.get("five_hour")),
209
+ _snapshot_window(windows.get("seven_day")), SOURCE_STATUSLINE)
210
+
211
+
212
+ def read_account_usage(config: Config, account: Account, now: datetime) -> UsageReport:
213
+ """usage 与 list --json 用的入口:缓存与快照都可用时取时间较新的一份。
214
+
215
+ 快照不可用时原样返回缓存的结果,保持 no-data / unreadable / other-account 的原有语义;
216
+ 缓存没有可用数值时,快照即使已过期也比“无数据”有用,直接采用。
217
+ """
218
+ directory = accounts.account_dir(config, account.name)
219
+ state_path = global_state_path(account.identity, directory)
220
+ cache = read_usage(state_path, now)
221
+ snapshot = read_snapshot(snapshot_path(account), directory, current_account_uuid(state_path), now)
222
+ if snapshot is None:
223
+ return cache
224
+ if cache.status not in (STATUS_OK, STATUS_STALE) or cache.fetched_at is None:
225
+ return snapshot
226
+ return snapshot if snapshot.fetched_at > cache.fetched_at else cache
227
+
228
+
229
+ def _is_number(value: object) -> bool:
230
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
231
+
232
+
233
+ def _snapshot_window(value: Optional[dict]) -> Optional[UsageWindow]:
234
+ if value is None:
235
+ return None
236
+ resets_at = None
237
+ if value["resets_at"] is not None:
238
+ try:
239
+ resets_at = datetime.fromtimestamp(value["resets_at"], tz=timezone.utc)
240
+ except (OverflowError, OSError, ValueError):
241
+ # 越界的秒数只丢掉重置时间,百分比仍可展示。
242
+ resets_at = None
243
+ return UsageWindow(value["used_percentage"], resets_at)