pocketshell 0.3.29__tar.gz → 0.3.30__tar.gz

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.
Files changed (30) hide show
  1. {pocketshell-0.3.29 → pocketshell-0.3.30}/PKG-INFO +1 -1
  2. {pocketshell-0.3.29 → pocketshell-0.3.30}/pyproject.toml +1 -1
  3. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/usage.py +126 -17
  4. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_usage.py +148 -4
  5. {pocketshell-0.3.29 → pocketshell-0.3.30}/.gitignore +0 -0
  6. {pocketshell-0.3.29 → pocketshell-0.3.30}/README.md +0 -0
  7. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/__init__.py +0 -0
  8. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/__main__.py +0 -0
  9. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/agent_log.py +0 -0
  10. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/cli.py +0 -0
  11. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/daemon.py +0 -0
  12. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/env.py +0 -0
  13. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/hooks.py +0 -0
  14. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/jobs.py +0 -0
  15. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/logs.py +0 -0
  16. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/qr_share.py +0 -0
  17. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/repos.py +0 -0
  18. {pocketshell-0.3.29 → pocketshell-0.3.30}/src/pocketshell/sessions.py +0 -0
  19. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/__init__.py +0 -0
  20. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_agent_log.py +0 -0
  21. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_cli.py +0 -0
  22. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_daemon.py +0 -0
  23. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_env.py +0 -0
  24. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_hooks.py +0 -0
  25. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_jobs.py +0 -0
  26. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_logs.py +0 -0
  27. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_qr_share.py +0 -0
  28. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_repos.py +0 -0
  29. {pocketshell-0.3.29 → pocketshell-0.3.30}/tests/test_sessions.py +0 -0
  30. {pocketshell-0.3.29 → pocketshell-0.3.30}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocketshell
3
- Version: 0.3.29
3
+ Version: 0.3.30
4
4
  Summary: Unified server-side Python utility for the PocketShell Android client.
5
5
  Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
6
6
  Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
@@ -8,7 +8,7 @@ name = "pocketshell"
8
8
  # scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
9
9
  # runs that check before publishing to PyPI. See
10
10
  # tools/pocketshell/README.md ("Release flow") for the bump procedure.
11
- version = "0.3.29"
11
+ version = "0.3.30"
12
12
  description = "Unified server-side Python utility for the PocketShell Android client."
13
13
  readme = "README.md"
14
14
  requires-python = ">=3.11"
@@ -61,6 +61,17 @@ import click
61
61
 
62
62
  _CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
63
63
  _CODEX_AUTH_PATH = Path.home() / ".codex" / "auth.json"
64
+ _CODEX_COMPATIBLE_PROVIDERS = {
65
+ "codex",
66
+ "openai",
67
+ "openai-codex",
68
+ "openai_codex",
69
+ "chatgpt",
70
+ }
71
+ _CLAUDE_USAGE_AUTH_SETUP_MESSAGE = (
72
+ "Claude usage authentication needs setup on this host. "
73
+ "Open Claude Code on the host and complete sign-in, then refresh usage."
74
+ )
64
75
 
65
76
 
66
77
  def _resolve_quse_binary() -> Optional[str]:
@@ -151,16 +162,78 @@ def _percent_remaining_from_used(value: Any) -> Optional[float]:
151
162
  return round(max(0.0, min(100.0, 100.0 - used)), 2)
152
163
 
153
164
 
154
- def _window_from_detail(detail: Any) -> Optional[dict[str, Any]]:
165
+ def _reset_after_seconds_to_iso(
166
+ value: Any,
167
+ *,
168
+ now: Optional[datetime] = None,
169
+ ) -> Optional[str]:
170
+ if value is None:
171
+ return None
172
+ try:
173
+ seconds = float(value)
174
+ except (TypeError, ValueError):
175
+ return None
176
+ if seconds < 0:
177
+ return None
178
+ base = now or datetime.now(timezone.utc)
179
+ if base.tzinfo is None:
180
+ base = base.replace(tzinfo=timezone.utc)
181
+ reset_at = base.astimezone(timezone.utc).timestamp() + seconds
182
+ try:
183
+ parsed = datetime.fromtimestamp(reset_at, tz=timezone.utc)
184
+ except (OverflowError, OSError, ValueError):
185
+ return None
186
+ return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
187
+
188
+
189
+ def _window_label_from_seconds(value: Any) -> Optional[str]:
190
+ try:
191
+ seconds = int(float(value))
192
+ except (TypeError, ValueError, OverflowError):
193
+ return None
194
+ if seconds <= 0:
195
+ return None
196
+ units = (
197
+ (24 * 60 * 60, "d"),
198
+ (60 * 60, "h"),
199
+ (60, "m"),
200
+ )
201
+ for unit_seconds, suffix in units:
202
+ if seconds >= unit_seconds and seconds % unit_seconds == 0:
203
+ return f"{seconds // unit_seconds}{suffix}"
204
+ return f"{seconds}s"
205
+
206
+
207
+ def _window_label_from_detail(detail: dict[str, Any]) -> Optional[str]:
208
+ label = _window_label_from_seconds(detail.get("limit_window_seconds"))
209
+ if label is not None:
210
+ return label
211
+ try:
212
+ minutes = float(detail.get("window_minutes"))
213
+ except (TypeError, ValueError):
214
+ return None
215
+ return _window_label_from_seconds(minutes * 60)
216
+
217
+
218
+ def _window_from_detail(
219
+ detail: Any,
220
+ *,
221
+ now: Optional[datetime] = None,
222
+ ) -> Optional[dict[str, Any]]:
155
223
  if not isinstance(detail, dict):
156
224
  return None
157
225
  percent_remaining = _percent_remaining_from_used(detail.get("used_percent"))
158
- reset_at = _normalize_reset_at(detail.get("reset_at"))
159
- if percent_remaining is None and reset_at is None:
226
+ reset_at = _normalize_reset_at(detail.get("reset_at")) or _reset_after_seconds_to_iso(
227
+ detail.get("reset_after_seconds"),
228
+ now=now,
229
+ )
230
+ window = _window_label_from_detail(detail)
231
+ if percent_remaining is None and reset_at is None and window is None:
160
232
  return None
161
233
  return {
162
234
  "percent_remaining": percent_remaining,
163
235
  "reset_at": reset_at,
236
+ "window": window,
164
237
  }
165
238
 
166
239
 
@@ -169,6 +242,7 @@ def _merge_window(
169
242
  detail: Any,
170
243
  *,
171
244
  prefer_detail_percent: bool = False,
245
+ now: Optional[datetime] = None,
172
246
  ) -> Any:
173
247
  if not isinstance(current, dict):
174
248
  if not isinstance(detail, dict):
@@ -177,14 +251,21 @@ def _merge_window(
177
251
  else:
178
252
  current = dict(current)
179
253
 
180
- detail_window = _window_from_detail(detail)
254
+ detail_window = _window_from_detail(detail, now=now)
181
255
  if detail_window is not None:
182
256
  if prefer_detail_percent or current.get("percent_remaining") is None:
183
257
  current["percent_remaining"] = detail_window.get("percent_remaining")
184
258
  if current.get("reset_at") is None:
185
259
  current["reset_at"] = detail_window.get("reset_at")
260
+ if current.get("window") is None and detail_window.get("window") is not None:
261
+ current["window"] = detail_window.get("window")
186
262
 
187
- current["reset_at"] = _normalize_reset_at(current.get("reset_at"))
263
+ reset_after_seconds = current.get("reset_after_seconds")
264
+ current["reset_at"] = _normalize_reset_at(current.get("reset_at")) or _reset_after_seconds_to_iso(
265
+ reset_after_seconds,
266
+ now=now,
267
+ )
268
+ current.pop("reset_after_seconds", None)
188
269
  return current
189
270
 
190
271
 
@@ -195,15 +276,19 @@ def _actionable_error(provider: str, error: Any) -> Optional[str]:
195
276
  if not text:
196
277
  return None
197
278
  lower = text.lower()
279
+ if provider == "claude" and (
280
+ "claude " + "/login" in lower
281
+ or "run `claude" in lower
282
+ or "run claude" in lower
283
+ or "authentication " + "failed" in lower
284
+ ):
285
+ return _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
198
286
  if provider == "claude" and (
199
287
  "http error 401" in lower
200
288
  or "unauthorized" in lower
201
289
  or lower in {"no-credentials", "no credentials"}
202
290
  ):
203
- return (
204
- "Claude Code authentication failed on this host. "
205
- "Run `claude /login` in the host shell, then refresh usage."
206
- )
291
+ return _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
207
292
  if provider == "codex" and lower in {"no auth token", "no-auth-token", "no credentials"}:
208
293
  return (
209
294
  "Codex authentication is missing on this host. "
@@ -261,14 +346,30 @@ def _codex_needs_source_patch(record: dict[str, Any], detail_windows: dict[str,
261
346
  ):
262
347
  top_level = record.get(top_level_key)
263
348
  detail = detail_windows.get(detail_key)
264
- top_level_reset = top_level.get("reset_at") if isinstance(top_level, dict) else None
265
- detail_reset = detail.get("reset_at") if isinstance(detail, dict) else None
349
+ top_level_reset = None
350
+ if isinstance(top_level, dict):
351
+ top_level_reset = top_level.get("reset_at")
352
+ if top_level_reset is None:
353
+ top_level_reset = top_level.get("reset_after_seconds")
354
+ detail_reset = None
355
+ if isinstance(detail, dict):
356
+ detail_reset = detail.get("reset_at")
357
+ if detail_reset is None:
358
+ detail_reset = detail.get("reset_after_seconds")
266
359
  if top_level_reset is None and detail_reset is None:
267
360
  return True
268
361
  return False
269
362
 
270
363
 
271
- def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
364
+ def _is_codex_compatible_provider(provider: str) -> bool:
365
+ return provider.replace(" ", "_").lower() in _CODEX_COMPATIBLE_PROVIDERS
366
+
367
+
368
+ def normalize_usage_record(
369
+ record: dict[str, Any],
370
+ *,
371
+ now: Optional[datetime] = None,
372
+ ) -> dict[str, Any]:
272
373
  """Normalize a provider record emitted by ``quse --json``.
273
374
 
274
375
  PocketShell owns the app-facing schema even when it delegates provider
@@ -282,7 +383,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
282
383
  if not isinstance(detail_windows, dict):
283
384
  detail_windows = {}
284
385
 
285
- if provider == "codex":
386
+ if _is_codex_compatible_provider(provider):
286
387
  # Codex's ChatGPT usage response exposes the real primary/secondary
287
388
  # windows under details. Older quse versions hard-code short_term to
288
389
  # 100% and lose epoch reset timestamps, which regressed issue #501.
@@ -297,6 +398,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
297
398
  normalized.get("short_term"),
298
399
  detail_windows.get("primary_window"),
299
400
  prefer_detail_percent=True,
401
+ now=now,
300
402
  )
301
403
  if short_term is not None:
302
404
  normalized["short_term"] = short_term
@@ -304,6 +406,7 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
304
406
  normalized.get("long_term"),
305
407
  detail_windows.get("secondary_window"),
306
408
  prefer_detail_percent=True,
409
+ now=now,
307
410
  )
308
411
  if long_term is not None:
309
412
  normalized["long_term"] = long_term
@@ -311,20 +414,22 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
311
414
  short_term = _merge_window(
312
415
  normalized.get("short_term"),
313
416
  detail_windows.get("five_hour"),
417
+ now=now,
314
418
  )
315
419
  if short_term is not None:
316
420
  normalized["short_term"] = short_term
317
421
  long_term = _merge_window(
318
422
  normalized.get("long_term"),
319
423
  detail_windows.get("seven_day"),
424
+ now=now,
320
425
  )
321
426
  if long_term is not None:
322
427
  normalized["long_term"] = long_term
323
428
  else:
324
429
  if isinstance(normalized.get("short_term"), dict):
325
- normalized["short_term"] = _merge_window(normalized.get("short_term"), None)
430
+ normalized["short_term"] = _merge_window(normalized.get("short_term"), None, now=now)
326
431
  if isinstance(normalized.get("long_term"), dict):
327
- normalized["long_term"] = _merge_window(normalized.get("long_term"), None)
432
+ normalized["long_term"] = _merge_window(normalized.get("long_term"), None, now=now)
328
433
 
329
434
  actionable = _actionable_error(provider, normalized.get("error"))
330
435
  if actionable != normalized.get("error"):
@@ -332,7 +437,11 @@ def normalize_usage_record(record: dict[str, Any]) -> dict[str, Any]:
332
437
  return normalized
333
438
 
334
439
 
335
- def normalize_usage_stdout(stdout: str) -> str:
440
+ def normalize_usage_stdout(
441
+ stdout: str,
442
+ *,
443
+ now: Optional[datetime] = None,
444
+ ) -> str:
336
445
  """Normalize NDJSON stdout from ``quse --json`` for app consumption."""
337
446
  if not stdout.strip():
338
447
  return stdout
@@ -348,7 +457,7 @@ def normalize_usage_stdout(stdout: str) -> str:
348
457
  return stdout
349
458
  if not isinstance(parsed, dict):
350
459
  return stdout
351
- normalized = normalize_usage_record(parsed)
460
+ normalized = normalize_usage_record(parsed, now=now)
352
461
  changed = changed or normalized != parsed
353
462
  lines.append(json.dumps(normalized, sort_keys=True))
354
463
  suffix = "\n" if stdout.endswith("\n") else ""
@@ -20,13 +20,19 @@ from __future__ import annotations
20
20
 
21
21
  import json
22
22
  import subprocess
23
+ from datetime import datetime, timezone
23
24
  from typing import Sequence
24
25
  from unittest.mock import patch
25
26
 
26
27
  from click.testing import CliRunner
27
28
 
28
29
  from pocketshell.cli import cli, main
29
- from pocketshell.usage import usage_command
30
+ from pocketshell.usage import (
31
+ _CLAUDE_USAGE_AUTH_SETUP_MESSAGE,
32
+ _actionable_error,
33
+ normalize_usage_record,
34
+ usage_command,
35
+ )
30
36
 
31
37
 
32
38
  def _fake_completed(
@@ -112,10 +118,12 @@ def test_usage_json_normalizes_codex_detail_windows_and_epoch_resets() -> None:
112
118
  "windows": {
113
119
  "primary_window": {
114
120
  "used_percent": 12,
121
+ "limit_window_seconds": 18000,
115
122
  "reset_at": 1780828285,
116
123
  },
117
124
  "secondary_window": {
118
125
  "used_percent": 31,
126
+ "limit_window_seconds": 604800,
119
127
  "reset_at": 1781137638,
120
128
  },
121
129
  },
@@ -148,15 +156,29 @@ def test_usage_json_normalizes_codex_detail_windows_and_epoch_resets() -> None:
148
156
  assert codex["short_term"] == {
149
157
  "percent_remaining": 88.0,
150
158
  "reset_at": "2026-06-07T10:31:25Z",
159
+ "window": "5h",
151
160
  }
152
161
  assert codex["long_term"] == {
153
162
  "percent_remaining": 69.0,
154
163
  "reset_at": "2026-06-11T00:27:18Z",
164
+ "window": "7d",
155
165
  }
156
- assert "claude /login" in lines[1]["error"]
166
+ assert lines[1]["error"] == _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
167
+ assert "claude " + "/login" not in lines[1]["error"]
168
+ assert "authentication " + "failed" not in lines[1]["error"].lower()
157
169
  assert "HTTP Error 401" not in lines[1]["error"]
158
170
 
159
171
 
172
+ def test_claude_stale_auth_telemetry_error_is_usage_unavailable() -> None:
173
+ stale_error = (
174
+ "Claude Code authentication "
175
+ + "failed on this host. Run `claude "
176
+ + "/login` in the host shell."
177
+ )
178
+
179
+ assert _actionable_error("claude", stale_error) == _CLAUDE_USAGE_AUTH_SETUP_MESSAGE
180
+
181
+
160
182
  def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() -> None:
161
183
  raw = json.dumps(
162
184
  {
@@ -182,8 +204,16 @@ def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() ->
182
204
  ), patch(
183
205
  "pocketshell.usage._fetch_codex_detail_windows",
184
206
  return_value={
185
- "primary_window": {"used_percent": 13, "reset_at": 1780828285},
186
- "secondary_window": {"used_percent": 31, "reset_at": 1781137638},
207
+ "primary_window": {
208
+ "used_percent": 13,
209
+ "limit_window_seconds": 18000,
210
+ "reset_at": 1780828285,
211
+ },
212
+ "secondary_window": {
213
+ "used_percent": 31,
214
+ "limit_window_seconds": 604800,
215
+ "reset_at": 1781137638,
216
+ },
187
217
  },
188
218
  ):
189
219
  result = runner.invoke(usage_command, ["--json"])
@@ -193,10 +223,124 @@ def test_usage_json_patches_codex_resets_from_source_when_quse_dropped_them() ->
193
223
  assert codex["short_term"] == {
194
224
  "percent_remaining": 87.0,
195
225
  "reset_at": "2026-06-07T10:31:25Z",
226
+ "window": "5h",
196
227
  }
197
228
  assert codex["long_term"] == {
198
229
  "percent_remaining": 69.0,
199
230
  "reset_at": "2026-06-11T00:27:18Z",
231
+ "window": "7d",
232
+ }
233
+
234
+
235
+ def test_usage_json_normalizes_openai_compatible_detail_windows() -> None:
236
+ raw = json.dumps(
237
+ {
238
+ "provider": "openai",
239
+ "status": "ok",
240
+ "short_term": {"percent_remaining": 100.0, "reset_at": None},
241
+ "long_term": {"percent_remaining": 35.0, "reset_at": None},
242
+ "block_reason": None,
243
+ "error": None,
244
+ "details": {
245
+ "windows": {
246
+ "primary_window": {
247
+ "used_percent": 22,
248
+ "limit_window_seconds": 18000,
249
+ "reset_at": "2026-06-08T02:19:59Z",
250
+ },
251
+ "secondary_window": {
252
+ "used_percent": 65,
253
+ "limit_window_seconds": 604800,
254
+ "reset_at": "2026-06-11T00:27:17Z",
255
+ },
256
+ },
257
+ },
258
+ },
259
+ )
260
+ runner = CliRunner()
261
+ with patch("pocketshell.usage._resolve_quse_binary", return_value="/fake/quse"), patch(
262
+ "pocketshell.usage.subprocess.run",
263
+ return_value=_fake_completed(stdout=raw + "\n"),
264
+ ):
265
+ result = runner.invoke(usage_command, ["--json"])
266
+
267
+ assert result.exit_code == 0, result.output
268
+ record = json.loads(result.output)
269
+ assert record["short_term"] == {
270
+ "percent_remaining": 78.0,
271
+ "reset_at": "2026-06-08T02:19:59Z",
272
+ "window": "5h",
273
+ }
274
+ assert record["long_term"] == {
275
+ "percent_remaining": 35.0,
276
+ "reset_at": "2026-06-11T00:27:17Z",
277
+ "window": "7d",
278
+ }
279
+
280
+
281
+ def test_normalize_usage_record_preserves_codex_reset_after_seconds() -> None:
282
+ record = normalize_usage_record(
283
+ {
284
+ "provider": "codex",
285
+ "status": "ok",
286
+ "short_term": {"percent_remaining": 35.0, "reset_at": None},
287
+ "long_term": {"percent_remaining": 69.0, "reset_at": None},
288
+ "block_reason": None,
289
+ "error": None,
290
+ "details": {
291
+ "windows": {
292
+ "primary_window": {
293
+ "used_percent": 65,
294
+ "window_minutes": 300,
295
+ "reset_after_seconds": 3600,
296
+ },
297
+ "secondary_window": {
298
+ "used_percent": 31,
299
+ "limit_window_seconds": 604800,
300
+ "reset_after_seconds": "604800",
301
+ },
302
+ },
303
+ },
304
+ },
305
+ now=datetime(2026, 6, 8, 10, 0, tzinfo=timezone.utc),
306
+ )
307
+
308
+ assert record["short_term"] == {
309
+ "percent_remaining": 35.0,
310
+ "reset_at": "2026-06-08T11:00:00Z",
311
+ "window": "5h",
312
+ }
313
+ assert record["long_term"] == {
314
+ "percent_remaining": 69.0,
315
+ "reset_at": "2026-06-15T10:00:00Z",
316
+ "window": "7d",
317
+ }
318
+
319
+
320
+ def test_normalize_usage_record_converts_top_level_reset_after_seconds() -> None:
321
+ with patch("pocketshell.usage._fetch_codex_detail_windows", return_value=None):
322
+ record = normalize_usage_record(
323
+ {
324
+ "provider": "codex",
325
+ "status": "ok",
326
+ "short_term": {
327
+ "percent_remaining": 35.0,
328
+ "reset_at": None,
329
+ "reset_after_seconds": 3600,
330
+ "window": "5h",
331
+ },
332
+ "long_term": None,
333
+ "block_reason": None,
334
+ "error": None,
335
+ "details": {},
336
+ },
337
+ now=datetime(2026, 6, 8, 10, 0, tzinfo=timezone.utc),
338
+ )
339
+
340
+ assert record["short_term"] == {
341
+ "percent_remaining": 35.0,
342
+ "reset_at": "2026-06-08T11:00:00Z",
343
+ "window": "5h",
200
344
  }
201
345
 
202
346
 
File without changes
File without changes
File without changes