codex-cli-usage 0.1.8__tar.gz → 0.1.10__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.
@@ -0,0 +1,29 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ contents: read
14
+ id-token: write
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v7.0.1
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v9.0.0
20
+ with:
21
+ enable-cache: false
22
+ - name: Verify version tag
23
+ run: test "$GITHUB_REF_NAME" = "v$(uv --no-config version --short)"
24
+ - name: Test
25
+ run: uv --no-config run --locked python -m unittest discover -s tests
26
+ - name: Build
27
+ run: uv --no-config build --no-sources
28
+ - name: Publish
29
+ run: uv publish --trusted-publishing always
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: codex-cli-usage
3
- Version: 0.1.8
3
+ Version: 0.1.10
4
4
  Summary: Codex CLI usage monitor — fetches rate limits through the Codex app-server
5
5
  Project-URL: Homepage, https://github.com/wakamex/codex-cli-usage
6
6
  Project-URL: Source, https://github.com/wakamex/codex-cli-usage
@@ -100,7 +100,10 @@ window actually classifies as 5-hour or weekly. Readers should prefer
100
100
 
101
101
  ### Authentication
102
102
 
103
- The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
103
+ The OAuth tokens live at `~/.codex/auth.json` by default and are written by the
104
+ Codex CLI on login. When `CODEX_HOME` is set, the direct compatibility fallback
105
+ reads `$CODEX_HOME/auth.json`. The preferred app-server path leaves credential
106
+ resolution to Codex.
104
107
 
105
108
  Codex owns automatic token refresh and credential persistence. The compatibility
106
109
  fallback reads `auth.json` but never modifies credentials.
@@ -91,7 +91,10 @@ window actually classifies as 5-hour or weekly. Readers should prefer
91
91
 
92
92
  ### Authentication
93
93
 
94
- The OAuth tokens live at `~/.codex/auth.json`, written by the Codex CLI on login.
94
+ The OAuth tokens live at `~/.codex/auth.json` by default and are written by the
95
+ Codex CLI on login. When `CODEX_HOME` is set, the direct compatibility fallback
96
+ reads `$CODEX_HOME/auth.json`. The preferred app-server path leaves credential
97
+ resolution to Codex.
95
98
 
96
99
  Codex owns automatic token refresh and credential persistence. The compatibility
97
100
  fallback reads `auth.json` but never modifies credentials.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codex-cli-usage"
3
- version = "0.1.8"
3
+ version = "0.1.10"
4
4
  description = "Codex CLI usage monitor — fetches rate limits through the Codex app-server"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -16,6 +16,8 @@ Usage:
16
16
 
17
17
  import argparse
18
18
  import json
19
+ import math
20
+ import os
19
21
  import queue
20
22
  import shutil
21
23
  import signal
@@ -66,8 +68,15 @@ def _find_codex_path(filename: str) -> Path:
66
68
  return native
67
69
 
68
70
 
71
+ def _find_codex_auth_path() -> Path:
72
+ codex_home = os.environ.get("CODEX_HOME")
73
+ if codex_home:
74
+ return Path(codex_home) / "auth.json"
75
+ return _find_codex_path("auth.json")
76
+
77
+
69
78
  CODEX_DIR = Path.home() / ".codex"
70
- AUTH_FILE = _find_codex_path("auth.json")
79
+ AUTH_FILE = _find_codex_auth_path()
71
80
  USAGE_FILE = _find_codex_path("usage-limits.json")
72
81
  DAEMON_INTERVAL = 300 # 5 minutes
73
82
  USAGE_URL = "https://chatgpt.com/backend-api/codex/usage"
@@ -323,7 +332,7 @@ def _fetch_usage_direct() -> dict:
323
332
  """
324
333
  auth = get_auth()
325
334
  if not auth:
326
- raise RuntimeError("No auth at ~/.codex/auth.json — run `codex` first")
335
+ raise RuntimeError(f"No auth at {AUTH_FILE}. Run `codex` first")
327
336
 
328
337
  tokens = auth.get("tokens", {})
329
338
  access_token = tokens.get("access_token")
@@ -381,13 +390,36 @@ def classify_window(seconds: int | float | None, kind: str) -> tuple[str, str]:
381
390
 
382
391
 
383
392
  def _build_window(kind: str, window: dict) -> dict:
393
+ used_percent = window.get("used_percent")
384
394
  seconds = window.get("limit_window_seconds")
385
- key, label = classify_window(seconds, kind)
386
395
  reset_at = window.get("reset_at")
396
+ for value in (used_percent, seconds, reset_at):
397
+ if value is not None and (
398
+ not isinstance(value, int | float)
399
+ or isinstance(value, bool)
400
+ or (isinstance(value, float) and not math.isfinite(value))
401
+ ):
402
+ raise UsageServiceError("Invalid rate limit response")
403
+ if seconds is not None and seconds <= 0:
404
+ raise UsageServiceError("Invalid rate limit response")
405
+
406
+ try:
407
+ key, label = classify_window(seconds, kind)
408
+ except OverflowError:
409
+ raise UsageServiceError("Invalid rate limit response") from None
410
+ try:
411
+ resets_at = (
412
+ datetime.fromtimestamp(reset_at, tz=timezone.utc).isoformat()
413
+ if reset_at is not None
414
+ else None
415
+ )
416
+ except (OSError, OverflowError, ValueError):
417
+ raise UsageServiceError("Invalid rate limit response") from None
418
+
387
419
  return {
388
420
  "kind": kind,
389
- "pct": window.get("used_percent"),
390
- "resets_at": datetime.fromtimestamp(reset_at, tz=timezone.utc).isoformat() if reset_at else None,
421
+ "pct": used_percent,
422
+ "resets_at": resets_at,
391
423
  "window_secs": seconds,
392
424
  "key": key,
393
425
  "label": label,
@@ -442,11 +474,13 @@ def build_usage_json(api_data: dict) -> dict:
442
474
  # Code review limits
443
475
  cr = api_data.get("code_review_rate_limit") or {}
444
476
  cr_primary = cr.get("primary_window")
445
- if cr_primary and cr_primary.get("used_percent", 0) > 0:
446
- result["code_review"] = {
447
- "pct": cr_primary["used_percent"],
448
- "resets_at": datetime.fromtimestamp(cr_primary["reset_at"], tz=timezone.utc).isoformat() if cr_primary.get("reset_at") else None,
449
- }
477
+ if isinstance(cr_primary, dict):
478
+ window = _build_window("code_review", cr_primary)
479
+ if window["pct"] is not None and window["pct"] > 0:
480
+ result["code_review"] = {
481
+ "pct": window["pct"],
482
+ "resets_at": window["resets_at"],
483
+ }
450
484
 
451
485
  # Credits
452
486
  credits = api_data.get("credits") or {}
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import os
2
3
  import tempfile
3
4
  import unittest
4
5
  import urllib.error
@@ -65,6 +66,14 @@ class AuthenticationRecoveryTests(unittest.TestCase):
65
66
  def write_auth(self, auth):
66
67
  self.auth_file.write_text(json.dumps(auth))
67
68
 
69
+ def test_codex_home_sets_direct_auth_path(self):
70
+ with tempfile.TemporaryDirectory() as tmp, patch.dict(
71
+ os.environ, {"CODEX_HOME": tmp}, clear=True
72
+ ):
73
+ self.assertEqual(
74
+ codex_cli_usage._find_codex_auth_path(), Path(tmp) / "auth.json"
75
+ )
76
+
68
77
  def test_transient_403_retries_same_token_without_refresh(self):
69
78
  requests = []
70
79
 
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
  from unittest.mock import patch
7
7
 
8
8
  from codex_cli_usage import (
9
+ UsageServiceError,
9
10
  build_usage_json,
10
11
  classify_window,
11
12
  cmd_status,
@@ -86,6 +87,20 @@ class UsageWindowTests(unittest.TestCase):
86
87
  "window_secs": None,
87
88
  })
88
89
 
90
+ def test_malformed_window_values_are_unavailable(self):
91
+ invalid_windows = (
92
+ {"used_percent": float("nan")},
93
+ {"used_percent": 1, "limit_window_seconds": float("inf")},
94
+ {"used_percent": 1, "reset_at": "not-a-timestamp"},
95
+ {"used_percent": 1, "reset_at": 10**30},
96
+ )
97
+
98
+ for window in invalid_windows:
99
+ with self.subTest(window=window), self.assertRaisesRegex(
100
+ UsageServiceError, "Invalid rate limit response"
101
+ ):
102
+ build_usage_json({"rate_limit": {"primary_window": window}})
103
+
89
104
  def test_status_shows_one_weekly_row_and_no_spark_bucket(self):
90
105
  output = StringIO()
91
106
  with patch(
@@ -101,7 +116,11 @@ class UsageWindowTests(unittest.TestCase):
101
116
  def test_statusline_enumerates_structured_windows(self):
102
117
  usage = build_usage_json(fixture("primary_5h_secondary_weekly.json"))
103
118
  output = StringIO()
104
- with patch("codex_cli_usage._get_cached_usage", return_value=usage), redirect_stdout(output):
119
+ with (
120
+ patch("codex_cli_usage._get_cached_usage", return_value=usage),
121
+ patch("codex_cli_usage._TTY", False),
122
+ redirect_stdout(output),
123
+ ):
105
124
  cmd_statusline()
106
125
 
107
126
  self.assertIn("5h:12%", output.getvalue())
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "codex-cli-usage"
7
+ version = "0.1.10"
8
+ source = { editable = "." }
@@ -1,18 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- release:
5
- types: [published]
6
-
7
- permissions:
8
- id-token: write
9
-
10
- jobs:
11
- publish:
12
- runs-on: ubuntu-latest
13
- environment: pypi
14
- steps:
15
- - uses: actions/checkout@v4
16
- - uses: astral-sh/setup-uv@v4
17
- - run: uv build
18
- - uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,20 +0,0 @@
1
- version = 1
2
- revision = 3
3
- requires-python = ">=3.12"
4
-
5
- [options]
6
- exclude-newer = "2026-07-05T20:06:09.848919716Z"
7
- exclude-newer-span = "P7D"
8
-
9
- [options.exclude-newer-package]
10
- clanker-analytics = false
11
- ghp = false
12
- search-claude-history = false
13
- gemini-cli-usage = false
14
- ccusage = false
15
- codex-cli-usage = false
16
-
17
- [[package]]
18
- name = "codex-cli-usage"
19
- version = "0.1.8"
20
- source = { editable = "." }