gemini-cli-usage 0.1.6__tar.gz → 0.1.8__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 version --short)"
24
+ - name: Test
25
+ run: uv run --locked python -m unittest discover -s tests
26
+ - name: Build
27
+ run: uv build --no-sources
28
+ - name: Publish
29
+ run: uv publish --trusted-publishing always
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemini-cli-usage
3
- Version: 0.1.6
3
+ Version: 0.1.8
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gemini-cli-usage"
3
- version = "0.1.6"
3
+ version = "0.1.8"
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"
@@ -23,6 +23,7 @@ import re
23
23
  import signal
24
24
  import shutil
25
25
  import sys
26
+ import tempfile
26
27
  import time
27
28
  import urllib.error
28
29
  import urllib.parse
@@ -181,12 +182,35 @@ def get_oauth_credentials() -> dict | None:
181
182
  return data if isinstance(data, dict) else None
182
183
 
183
184
 
184
- def _write_oauth_credentials(creds: dict):
185
+ def _write_oauth_credentials(
186
+ creds: dict, expected_refresh_token: str | None = None
187
+ ) -> dict:
188
+ latest = get_oauth_credentials()
189
+ if expected_refresh_token and latest:
190
+ latest_refresh_token = latest.get("refresh_token")
191
+ if latest_refresh_token and latest_refresh_token != expected_refresh_token:
192
+ return latest
193
+ creds = {**latest, **creds}
194
+
195
+ tmp: Path | None = None
185
196
  try:
186
- OAUTH_FILE.write_text(json.dumps(creds, indent=2) + "\n")
197
+ OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
198
+ fd, tmp_name = tempfile.mkstemp(
199
+ prefix=f".{OAUTH_FILE.name}.", dir=OAUTH_FILE.parent
200
+ )
201
+ tmp = Path(tmp_name)
202
+ os.fchmod(fd, 0o600)
203
+ with os.fdopen(fd, "w") as file:
204
+ file.write(json.dumps(creds, indent=2) + "\n")
205
+ os.replace(tmp, OAUTH_FILE)
187
206
  except OSError:
188
207
  # Best effort only; the refreshed token can still be used in-memory.
189
- pass
208
+ if tmp:
209
+ try:
210
+ tmp.unlink()
211
+ except OSError:
212
+ pass
213
+ return creds
190
214
 
191
215
 
192
216
  def _get_gemini_cli_oauth2_path() -> Path | None:
@@ -304,8 +328,16 @@ def refresh_access_token(creds: dict) -> dict:
304
328
  data=payload,
305
329
  headers={"Content-Type": "application/x-www-form-urlencoded"},
306
330
  )
307
- with urllib.request.urlopen(req, timeout=10) as resp:
308
- result = json.loads(resp.read())
331
+ try:
332
+ with urllib.request.urlopen(req, timeout=10) as resp:
333
+ result = json.loads(resp.read())
334
+ except urllib.error.HTTPError as exc:
335
+ latest = get_oauth_credentials()
336
+ if exc.code in (400, 401) and latest:
337
+ latest_refresh_token = latest.get("refresh_token")
338
+ if latest_refresh_token and latest_refresh_token != refresh_token:
339
+ return latest
340
+ raise
309
341
 
310
342
  updated = dict(creds)
311
343
  updated["access_token"] = result["access_token"]
@@ -316,17 +348,27 @@ def refresh_access_token(creds: dict) -> dict:
316
348
  updated["id_token"] = result["id_token"]
317
349
  if result.get("refresh_token"):
318
350
  updated["refresh_token"] = result["refresh_token"]
319
- _write_oauth_credentials(updated)
320
- return updated
351
+ return _write_oauth_credentials(updated, expected_refresh_token=refresh_token)
321
352
 
322
353
 
323
- def get_access_token() -> str:
354
+ def get_access_token(
355
+ force_refresh: bool = False, rejected_token: str | None = None
356
+ ) -> str:
324
357
  creds = get_oauth_credentials()
325
358
  if not creds:
326
359
  raise RuntimeError("No OAuth credentials at ~/.gemini/oauth_creds.json")
327
360
 
361
+ token = creds.get("access_token")
362
+ token_was_replaced = (
363
+ rejected_token is not None
364
+ and isinstance(token, str)
365
+ and bool(token)
366
+ and token != rejected_token
367
+ )
328
368
  expiry_date = int(creds.get("expiry_date", 0) or 0)
329
- if time.time() * 1000 >= expiry_date - 60_000:
369
+ if not token_was_replaced and (
370
+ force_refresh or time.time() * 1000 >= expiry_date - 60_000
371
+ ):
330
372
  creds = refresh_access_token(creds)
331
373
 
332
374
  token = creds.get("access_token")
@@ -426,32 +468,41 @@ def fetch_quota(project_root: Path | None = None) -> dict:
426
468
  )
427
469
 
428
470
  access_token = get_access_token()
429
- load_res = _load_code_assist(access_token)
430
-
431
- env_project = (
432
- os.environ.get("GOOGLE_CLOUD_PROJECT")
433
- or os.environ.get("GOOGLE_CLOUD_PROJECT_ID")
434
- or None
435
- )
436
- project_id = load_res.get("cloudaicompanionProject") or env_project
437
- if not project_id:
438
- raise RuntimeError(
439
- "No Code Assist project ID available. Set GOOGLE_CLOUD_PROJECT if your account requires it."
440
- )
441
-
442
- quota_res = _code_assist_post(
443
- "retrieveUserQuota", {"project": project_id}, access_token
444
- )
445
- current_tier = load_res.get("currentTier") or {}
446
- paid_tier = load_res.get("paidTier") or {}
447
- result = {
448
- "project_id": project_id,
449
- "user_tier": paid_tier.get("id") or current_tier.get("id"),
450
- "user_tier_name": paid_tier.get("name") or current_tier.get("name"),
451
- "buckets": _parse_quota_buckets(quota_res.get("buckets") or []),
452
- }
453
- result["summary_bucket"] = _select_summary_bucket(result)
454
- return result
471
+ for attempt in range(2):
472
+ try:
473
+ load_res = _load_code_assist(access_token)
474
+ env_project = (
475
+ os.environ.get("GOOGLE_CLOUD_PROJECT")
476
+ or os.environ.get("GOOGLE_CLOUD_PROJECT_ID")
477
+ or None
478
+ )
479
+ project_id = load_res.get("cloudaicompanionProject") or env_project
480
+ if not project_id:
481
+ raise RuntimeError(
482
+ "No Code Assist project ID available. Set GOOGLE_CLOUD_PROJECT if your account requires it."
483
+ )
484
+
485
+ quota_res = _code_assist_post(
486
+ "retrieveUserQuota", {"project": project_id}, access_token
487
+ )
488
+ current_tier = load_res.get("currentTier") or {}
489
+ paid_tier = load_res.get("paidTier") or {}
490
+ result = {
491
+ "project_id": project_id,
492
+ "user_tier": paid_tier.get("id") or current_tier.get("id"),
493
+ "user_tier_name": paid_tier.get("name") or current_tier.get("name"),
494
+ "buckets": _parse_quota_buckets(quota_res.get("buckets") or []),
495
+ }
496
+ result["summary_bucket"] = _select_summary_bucket(result)
497
+ return result
498
+ except urllib.error.HTTPError as exc:
499
+ if exc.code in (401, 403) and attempt == 0:
500
+ access_token = get_access_token(
501
+ force_refresh=True, rejected_token=access_token
502
+ )
503
+ continue
504
+ raise
505
+ raise RuntimeError("Failed to fetch quota after token refresh")
455
506
 
456
507
 
457
508
  def build_usage_json(project_root: Path | None = None) -> dict:
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import tempfile
6
6
  import unittest
7
+ import urllib.error
7
8
  from datetime import UTC, datetime, timedelta
8
9
  from pathlib import Path
9
10
  from unittest import mock
@@ -266,6 +267,131 @@ class GeminiUsageTests(unittest.TestCase):
266
267
  self.assertIn("GEMINI_OAUTH_CLIENT_ID", str(exc.exception))
267
268
  self.assertIn("run `gemini`", str(exc.exception))
268
269
 
270
+ def test_write_oauth_credentials_is_atomic_and_private(self):
271
+ with tempfile.TemporaryDirectory() as tmp:
272
+ oauth_file = Path(tmp) / "oauth_creds.json"
273
+ with mock.patch.object(gemini_cli_usage, "OAUTH_FILE", oauth_file):
274
+ written = gemini_cli_usage._write_oauth_credentials(
275
+ {"access_token": "token", "refresh_token": "refresh"}
276
+ )
277
+
278
+ self.assertEqual(json.loads(oauth_file.read_text()), written)
279
+ self.assertEqual(oauth_file.stat().st_mode & 0o777, 0o600)
280
+ self.assertEqual(list(oauth_file.parent.glob(".oauth_creds.json.*")), [])
281
+
282
+ def test_refresh_does_not_overwrite_concurrently_rotated_credentials(self):
283
+ with tempfile.TemporaryDirectory() as tmp:
284
+ oauth_file = Path(tmp) / "oauth_creds.json"
285
+ original = {
286
+ "access_token": "old-access",
287
+ "refresh_token": "old-refresh",
288
+ "expiry_date": 0,
289
+ }
290
+ rotated = {
291
+ "access_token": "other-access",
292
+ "refresh_token": "other-refresh",
293
+ "expiry_date": 9_999_999_999_999,
294
+ }
295
+ _write_json(oauth_file, original)
296
+
297
+ class FakeResponse:
298
+ def __enter__(self):
299
+ return self
300
+
301
+ def __exit__(self, *_args):
302
+ return False
303
+
304
+ def read(self):
305
+ _write_json(oauth_file, rotated)
306
+ return json.dumps(
307
+ {"access_token": "our-access", "refresh_token": "our-refresh"}
308
+ ).encode()
309
+
310
+ with (
311
+ mock.patch.object(gemini_cli_usage, "OAUTH_FILE", oauth_file),
312
+ mock.patch.object(
313
+ gemini_cli_usage,
314
+ "_get_oauth_client_credentials",
315
+ return_value=("client", "secret"),
316
+ ),
317
+ mock.patch.object(
318
+ gemini_cli_usage.urllib.request,
319
+ "urlopen",
320
+ return_value=FakeResponse(),
321
+ ),
322
+ ):
323
+ result = gemini_cli_usage.refresh_access_token(original)
324
+
325
+ self.assertEqual(result, rotated)
326
+ self.assertEqual(json.loads(oauth_file.read_text()), rotated)
327
+
328
+ def test_rejected_token_uses_concurrently_replaced_access_token(self):
329
+ with tempfile.TemporaryDirectory() as tmp:
330
+ oauth_file = Path(tmp) / "oauth_creds.json"
331
+ _write_json(
332
+ oauth_file,
333
+ {
334
+ "access_token": "replacement",
335
+ "refresh_token": "refresh",
336
+ "expiry_date": 9_999_999_999_999,
337
+ },
338
+ )
339
+ with (
340
+ mock.patch.object(gemini_cli_usage, "OAUTH_FILE", oauth_file),
341
+ mock.patch.object(
342
+ gemini_cli_usage, "refresh_access_token"
343
+ ) as refresh_mock,
344
+ ):
345
+ token = gemini_cli_usage.get_access_token(
346
+ force_refresh=True, rejected_token="rejected"
347
+ )
348
+
349
+ self.assertEqual(token, "replacement")
350
+ refresh_mock.assert_not_called()
351
+
352
+ def test_fetch_quota_recovers_once_from_auth_error(self):
353
+ auth_error = urllib.error.HTTPError(
354
+ url="https://cloudcode-pa.googleapis.com",
355
+ code=401,
356
+ msg="Unauthorized",
357
+ hdrs={},
358
+ fp=None,
359
+ )
360
+ with (
361
+ mock.patch.object(
362
+ gemini_cli_usage,
363
+ "get_auth_type",
364
+ return_value="oauth-personal",
365
+ ),
366
+ mock.patch.object(
367
+ gemini_cli_usage,
368
+ "get_access_token",
369
+ side_effect=["stale", "fresh"],
370
+ ) as token_mock,
371
+ mock.patch.object(
372
+ gemini_cli_usage,
373
+ "_load_code_assist",
374
+ side_effect=[
375
+ auth_error,
376
+ {"cloudaicompanionProject": "project"},
377
+ ],
378
+ ),
379
+ mock.patch.object(
380
+ gemini_cli_usage,
381
+ "_code_assist_post",
382
+ return_value={"buckets": []},
383
+ ),
384
+ ):
385
+ quota = gemini_cli_usage.fetch_quota()
386
+
387
+ self.assertEqual(quota["project_id"], "project")
388
+ token_mock.assert_has_calls(
389
+ [
390
+ mock.call(),
391
+ mock.call(force_refresh=True, rejected_token="stale"),
392
+ ]
393
+ )
394
+
269
395
 
270
396
  if __name__ == "__main__":
271
397
  unittest.main()
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "gemini-cli-usage"
7
- version = "0.1.4"
7
+ version = "0.1.8"
8
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