gemini-cli-usage 0.1.7__tar.gz → 0.1.9__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: gemini-cli-usage
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Gemini CLI quota monitor — fetches Code Assist quota data from Google's backend
5
5
  Project-URL: Homepage, https://github.com/wakamex/gemini-cli-usage
6
6
  Project-URL: Source, https://github.com/wakamex/gemini-cli-usage
@@ -83,7 +83,8 @@ Google's internal Code Assist API:
83
83
  - `retrieveUserQuota`
84
84
 
85
85
  This tool mirrors that flow using the OAuth credentials in
86
- `~/.gemini/oauth_creds.json`.
86
+ `~/.gemini/oauth_creds.json`. When `GEMINI_CLI_HOME` is set, it follows Gemini
87
+ CLI and uses `$GEMINI_CLI_HOME/.gemini` instead.
87
88
 
88
89
  ## Notes
89
90
 
@@ -102,7 +103,8 @@ This tool mirrors that flow using the OAuth credentials in
102
103
  `remainingAmount` and a usable fraction. Otherwise the tool reports `% used`
103
104
  plus reset time.
104
105
  - Auth detection follows Gemini CLI precedence: environment variables first,
105
- then workspace `.gemini/settings.json`, then global `~/.gemini/settings.json`.
106
+ then workspace `.gemini/settings.json`, then the global settings file under
107
+ the effective Gemini CLI home.
106
108
 
107
109
  ## Options
108
110
 
@@ -74,7 +74,8 @@ Google's internal Code Assist API:
74
74
  - `retrieveUserQuota`
75
75
 
76
76
  This tool mirrors that flow using the OAuth credentials in
77
- `~/.gemini/oauth_creds.json`.
77
+ `~/.gemini/oauth_creds.json`. When `GEMINI_CLI_HOME` is set, it follows Gemini
78
+ CLI and uses `$GEMINI_CLI_HOME/.gemini` instead.
78
79
 
79
80
  ## Notes
80
81
 
@@ -93,7 +94,8 @@ This tool mirrors that flow using the OAuth credentials in
93
94
  `remainingAmount` and a usable fraction. Otherwise the tool reports `% used`
94
95
  plus reset time.
95
96
  - Auth detection follows Gemini CLI precedence: environment variables first,
96
- then workspace `.gemini/settings.json`, then global `~/.gemini/settings.json`.
97
+ then workspace `.gemini/settings.json`, then the global settings file under
98
+ the effective Gemini CLI home.
97
99
 
98
100
  ## Options
99
101
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gemini-cli-usage"
3
- version = "0.1.7"
3
+ version = "0.1.9"
4
4
  description = "Gemini CLI quota monitor — fetches Code Assist quota data from Google's backend"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  import argparse
20
20
  import json
21
+ import math
21
22
  import os
22
23
  import re
23
24
  import signal
@@ -31,7 +32,12 @@ import urllib.request
31
32
  from datetime import UTC, datetime
32
33
  from pathlib import Path
33
34
 
34
- _NATIVE_GEMINI_DIR = Path.home() / ".gemini"
35
+ def _gemini_dir() -> Path:
36
+ home = os.environ.get("GEMINI_CLI_HOME")
37
+ return (Path(home) if home else Path.home()) / ".gemini"
38
+
39
+
40
+ _NATIVE_GEMINI_DIR = _gemini_dir()
35
41
 
36
42
 
37
43
  def _wsl_gemini_dirs() -> list[Path]:
@@ -68,7 +74,7 @@ def _wsl_gemini_dirs() -> list[Path]:
68
74
  def _find_gemini_file(filename: str) -> Path:
69
75
  """Return the first existing .gemini/<filename> from native + WSL paths."""
70
76
  native = _NATIVE_GEMINI_DIR / filename
71
- if native.exists():
77
+ if native.exists() or os.environ.get("GEMINI_CLI_HOME"):
72
78
  return native
73
79
  for wsl_dir in _wsl_gemini_dirs():
74
80
  candidate = wsl_dir / filename
@@ -121,12 +127,13 @@ def _iso_now() -> str:
121
127
 
122
128
 
123
129
  def _parse_iso(timestamp: str | None) -> datetime | None:
124
- if not timestamp:
130
+ if not isinstance(timestamp, str) or not timestamp:
125
131
  return None
126
132
  try:
127
- return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
133
+ parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
128
134
  except ValueError:
129
135
  return None
136
+ return parsed if parsed.tzinfo is not None else None
130
137
 
131
138
 
132
139
  def _read_auth_type_from_settings(path: Path) -> str | None:
@@ -311,7 +318,7 @@ def _get_oauth_client_credentials(creds: dict | None = None) -> tuple[str, str]:
311
318
  def refresh_access_token(creds: dict) -> dict:
312
319
  refresh_token = creds.get("refresh_token")
313
320
  if not refresh_token:
314
- raise RuntimeError("No refresh token in ~/.gemini/oauth_creds.json")
321
+ raise RuntimeError(f"No refresh token in {OAUTH_FILE}")
315
322
 
316
323
  client_id, client_secret = _get_oauth_client_credentials(creds)
317
324
  payload = urllib.parse.urlencode(
@@ -356,7 +363,7 @@ def get_access_token(
356
363
  ) -> str:
357
364
  creds = get_oauth_credentials()
358
365
  if not creds:
359
- raise RuntimeError("No OAuth credentials at ~/.gemini/oauth_creds.json")
366
+ raise RuntimeError(f"No OAuth credentials at {OAUTH_FILE}")
360
367
 
361
368
  token = creds.get("access_token")
362
369
  token_was_replaced = (
@@ -373,7 +380,7 @@ def get_access_token(
373
380
 
374
381
  token = creds.get("access_token")
375
382
  if not token:
376
- raise RuntimeError("No access token in ~/.gemini/oauth_creds.json")
383
+ raise RuntimeError(f"No access token in {OAUTH_FILE}")
377
384
  return token
378
385
 
379
386
 
@@ -415,13 +422,37 @@ def _load_code_assist(access_token: str) -> dict:
415
422
  )
416
423
 
417
424
 
418
- def _parse_quota_buckets(buckets: list[dict]) -> list[dict]:
425
+ def _parse_quota_buckets(buckets: object) -> list[dict]:
426
+ if not isinstance(buckets, list):
427
+ raise RuntimeError("Invalid quota response")
428
+
419
429
  parsed = []
420
430
  for bucket in buckets:
431
+ if not isinstance(bucket, dict):
432
+ raise RuntimeError("Invalid quota response")
433
+
421
434
  remaining = None
422
435
  limit = None
423
- used_pct = None
436
+ model = bucket.get("modelId")
424
437
  remaining_fraction = bucket.get("remainingFraction")
438
+ reset_time = bucket.get("resetTime")
439
+
440
+ if model is None or model == "" or remaining_fraction is None:
441
+ continue
442
+
443
+ if (
444
+ not isinstance(model, str)
445
+ or not model.strip()
446
+ or not isinstance(remaining_fraction, int | float)
447
+ or isinstance(remaining_fraction, bool)
448
+ or (
449
+ isinstance(remaining_fraction, float)
450
+ and not math.isfinite(remaining_fraction)
451
+ )
452
+ or not 0 <= remaining_fraction <= 1
453
+ or (reset_time is not None and _parse_iso(reset_time) is None)
454
+ ):
455
+ raise RuntimeError("Invalid quota response")
425
456
 
426
457
  try:
427
458
  if bucket.get("remainingAmount") is not None:
@@ -429,20 +460,18 @@ def _parse_quota_buckets(buckets: list[dict]) -> list[dict]:
429
460
  except (TypeError, ValueError):
430
461
  remaining = None
431
462
 
432
- if isinstance(remaining_fraction, int | float):
433
- used_pct = (1 - float(remaining_fraction)) * 100
434
- if remaining is not None and isinstance(remaining_fraction, int | float):
435
- if remaining_fraction > 0:
436
- limit = round(remaining / float(remaining_fraction))
463
+ used_pct = (1 - float(remaining_fraction)) * 100
464
+ if remaining is not None and remaining_fraction > 0:
465
+ limit = round(remaining / float(remaining_fraction))
437
466
 
438
467
  parsed.append(
439
468
  {
440
- "model": bucket.get("modelId"),
469
+ "model": model,
441
470
  "remaining": remaining,
442
471
  "limit": limit,
443
472
  "used_pct": used_pct,
444
473
  "remaining_fraction": remaining_fraction,
445
- "reset_time": bucket.get("resetTime"),
474
+ "reset_time": reset_time,
446
475
  "token_type": bucket.get("tokenType"),
447
476
  }
448
477
  )
@@ -485,13 +514,17 @@ def fetch_quota(project_root: Path | None = None) -> dict:
485
514
  quota_res = _code_assist_post(
486
515
  "retrieveUserQuota", {"project": project_id}, access_token
487
516
  )
517
+ if not isinstance(quota_res, dict) or not isinstance(
518
+ quota_res.get("buckets"), list
519
+ ):
520
+ raise RuntimeError("Invalid quota response")
488
521
  current_tier = load_res.get("currentTier") or {}
489
522
  paid_tier = load_res.get("paidTier") or {}
490
523
  result = {
491
524
  "project_id": project_id,
492
525
  "user_tier": paid_tier.get("id") or current_tier.get("id"),
493
526
  "user_tier_name": paid_tier.get("name") or current_tier.get("name"),
494
- "buckets": _parse_quota_buckets(quota_res.get("buckets") or []),
527
+ "buckets": _parse_quota_buckets(quota_res["buckets"]),
495
528
  }
496
529
  result["summary_bucket"] = _select_summary_bucket(result)
497
530
  return result
@@ -37,6 +37,12 @@ def _usage_payload(project_root: Path, updated_at: str) -> dict:
37
37
 
38
38
 
39
39
  class GeminiUsageTests(unittest.TestCase):
40
+ def test_gemini_cli_home_sets_global_gemini_directory(self):
41
+ with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(
42
+ os.environ, {"GEMINI_CLI_HOME": tmp}, clear=True
43
+ ):
44
+ self.assertEqual(gemini_cli_usage._gemini_dir(), Path(tmp) / ".gemini")
45
+
40
46
  def test_env_auth_overrides_workspace_and_global_settings(self):
41
47
  with tempfile.TemporaryDirectory() as tmp:
42
48
  tmp_path = Path(tmp)
@@ -117,6 +123,53 @@ class GeminiUsageTests(unittest.TestCase):
117
123
  self.assertIn("q:3.5%", statusline)
118
124
  self.assertNotIn("q:0.07%", statusline)
119
125
 
126
+ def test_fetch_quota_rejects_missing_buckets(self):
127
+ with (
128
+ mock.patch.object(
129
+ gemini_cli_usage, "get_auth_type", return_value="oauth-personal"
130
+ ),
131
+ mock.patch.object(
132
+ gemini_cli_usage, "get_access_token", return_value="token"
133
+ ),
134
+ mock.patch.object(
135
+ gemini_cli_usage,
136
+ "_load_code_assist",
137
+ return_value={"cloudaicompanionProject": "project"},
138
+ ),
139
+ mock.patch.object(gemini_cli_usage, "_code_assist_post", return_value={}),
140
+ ):
141
+ with self.assertRaisesRegex(RuntimeError, "Invalid quota response"):
142
+ gemini_cli_usage.fetch_quota()
143
+
144
+ def test_quota_buckets_require_finite_fraction_and_valid_reset(self):
145
+ invalid_buckets = (
146
+ {"modelId": "model", "remainingFraction": float("nan")},
147
+ {"modelId": "model", "remainingFraction": float("inf")},
148
+ {"modelId": "model", "remainingFraction": 1.1},
149
+ {
150
+ "modelId": "model",
151
+ "remainingFraction": 0.5,
152
+ "resetTime": "not-a-timestamp",
153
+ },
154
+ )
155
+
156
+ for bucket in invalid_buckets:
157
+ with self.subTest(bucket=bucket), self.assertRaisesRegex(
158
+ RuntimeError, "Invalid quota response"
159
+ ):
160
+ gemini_cli_usage._parse_quota_buckets([bucket])
161
+
162
+ def test_quota_buckets_skip_incomplete_entries(self):
163
+ buckets = gemini_cli_usage._parse_quota_buckets(
164
+ [
165
+ {"remainingFraction": 0.5},
166
+ {"modelId": "missing-fraction"},
167
+ {"modelId": "valid-model", "remainingFraction": 0.25},
168
+ ]
169
+ )
170
+
171
+ self.assertEqual([bucket["model"] for bucket in buckets], ["valid-model"])
172
+
120
173
  def test_force_refresh_bypasses_cache(self):
121
174
  with tempfile.TemporaryDirectory() as tmp:
122
175
  tmp_path = Path(tmp)
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "gemini-cli-usage"
7
- version = "0.1.7"
7
+ version = "0.1.9"
8
8
  source = { editable = "." }
@@ -1,20 +0,0 @@
1
- name: Publish to PyPI
2
-
3
- on:
4
- workflow_dispatch:
5
- push:
6
- tags:
7
- - "v*"
8
-
9
- permissions:
10
- id-token: write
11
-
12
- jobs:
13
- publish:
14
- runs-on: ubuntu-latest
15
- environment: pypi
16
- steps:
17
- - uses: actions/checkout@v4
18
- - uses: astral-sh/setup-uv@v4
19
- - run: uv build
20
- - uses: pypa/gh-action-pypi-publish@release/v1