ai-token-usage 0.1.5__py2.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.
@@ -0,0 +1,3 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-token-usage
3
+ Version: 0.1.5
@@ -0,0 +1,14 @@
1
+ token_tracker/__init__.py,sha256=3v6ELg7ohMsCl1Cv3ldXYCyz0qO-0mSPp3TdEwQWMy4,29
2
+ token_tracker/cli.py,sha256=tDQp7ysLiMnlidLo-dqXDprtniGmn4F0EAa1aNMpnUc,2872
3
+ token_tracker/providers/__init__.py,sha256=g5Ixyv5KjMt3JMHmxvW52RYTWORxawWF2u2TiKXlzu4,35
4
+ token_tracker/providers/base.py,sha256=aWz0KnkXzJeiK-x0bCcYBPyHN5aRwgXzumJZe3fy7FQ,248
5
+ token_tracker/providers/codex_direct.py,sha256=ihrVHYhoQIiuWqVeW5ulAqqRjAnguI_SqNA5DTgbl3A,3554
6
+ token_tracker/providers/codexbar.py,sha256=hbiGBRNVEVYDChEFHGjvpNpOB6FLdANgPLAX8u6YbVg,5195
7
+ token_tracker/providers/openrouter.py,sha256=-7beOd0GKOUZePG5u_NHshx6RbR5N1xXz7UJpcDJzag,1124
8
+ token_tracker/stores/__init__.py,sha256=h7ad-GQ1g8LfS8xSljhq1ITJvPh1kpRbVh5Twt3OSoQ,32
9
+ token_tracker/stores/base.py,sha256=sWUuZ0ZT2uzWXy7tYacO8oiEZ_ey9-D2iu3dRdbkwZ8,236
10
+ token_tracker/stores/google_sheets.py,sha256=Fvo1d0YZve33RQlARcbwhXHXBjfjZwhRz1tczzJPW_E,4257
11
+ ai_token_usage-0.1.5.dist-info/METADATA,sha256=SqyEwPOWA9oAryRjdbEw4tsqIhceDTgwkTi15O1iMRc,58
12
+ ai_token_usage-0.1.5.dist-info/WHEEL,sha256=tkmg4JIqwd9H8mL30xA7crRmoStyCtGp0VWshokd1Jc,105
13
+ ai_token_usage-0.1.5.dist-info/entry_points.txt,sha256=c0h8ZPdyOmwYe19N8A0SxbKx5zkuNZBFbNVTrN9wfT0,57
14
+ ai_token_usage-0.1.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ token-tracker = token_tracker.cli:main
@@ -0,0 +1 @@
1
+ """Token Tracker package."""
token_tracker/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ import argparse
2
+ import logging
3
+ import sys
4
+ import os
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from .providers.openrouter import OpenRouterProvider
10
+ from .providers.codex_direct import CodexDirectProvider
11
+ from .providers.codexbar import CodexBarProvider
12
+ from .stores.google_sheets import GoogleSheetsStore
13
+
14
+ def parse_args(argv=None):
15
+ parser = argparse.ArgumentParser(description="Collect token usage and push to Google Sheets")
16
+ parser.add_argument("-i", "--init", action="store_true",
17
+ help="Overwrite the sheets (uses df_to_sheet). Default: append.")
18
+ parser.add_argument("--log-level", default="INFO",
19
+ help="Logging level (DEBUG, INFO, WARNING, ERROR)")
20
+ parser.add_argument("--codex", action="store_true", help="Only run direct Codex API fetch")
21
+ parser.add_argument("--codex-bar", action="store_true", help="Only run codexbar fetch")
22
+ parser.add_argument("--openrouter", action="store_true", help="Only run OpenRouter fetch")
23
+ return parser.parse_args(argv)
24
+
25
+ def main(argv=None) -> None:
26
+ args = parse_args(argv)
27
+
28
+ numeric_level = getattr(logging, args.log_level.upper(), logging.INFO)
29
+ logging.basicConfig(level=numeric_level, format="%(asctime)s %(levelname)s: %(message)s")
30
+
31
+ now_stamp = datetime.now(ZoneInfo("America/New_York")).strftime("%Y-%m-%d %H:%M:%S")
32
+ had_error = False
33
+
34
+ run_all = not (args.codex or args.codex_bar or args.openrouter)
35
+ run_codex = run_all or args.codex
36
+ run_codex_bar = run_all or args.codex_bar
37
+ run_openrouter = run_all or args.openrouter
38
+
39
+ try:
40
+ store = GoogleSheetsStore()
41
+ except Exception:
42
+ logging.exception("Failed to initialize GoogleSheetsStore")
43
+ sys.exit(1)
44
+
45
+ if run_codex:
46
+ try:
47
+ provider = CodexDirectProvider()
48
+ df = provider.fetch(now=now_stamp)
49
+ store.save(df, name="codex", init=args.init)
50
+ except Exception:
51
+ logging.exception("Failed to process direct Codex data")
52
+ had_error = True
53
+
54
+ if run_codex_bar:
55
+ try:
56
+ provider = CodexBarProvider()
57
+ df = provider.fetch(now=now_stamp)
58
+ store.save(df, name="codexbar", init=args.init)
59
+ except Exception:
60
+ logging.exception("Failed to process codexbar data")
61
+ had_error = True
62
+
63
+ if run_openrouter:
64
+ try:
65
+ provider = OpenRouterProvider()
66
+ df = provider.fetch(now=now_stamp)
67
+ store.save(df, name="openrouter", init=args.init)
68
+ except Exception:
69
+ logging.exception("Failed to process OpenRouter data")
70
+ had_error = True
71
+
72
+ if had_error:
73
+ logging.error("Completed with errors")
74
+ sys.exit(1)
75
+
76
+ logging.info("All sources written successfully")
77
+
78
+ if __name__ == "__main__":
79
+ main()
@@ -0,0 +1 @@
1
+ """Providers for Token Tracker."""
@@ -0,0 +1,9 @@
1
+ from abc import ABC, abstractmethod
2
+ import pandas as pd
3
+ from typing import Optional
4
+
5
+ class BaseProvider(ABC):
6
+ @abstractmethod
7
+ def fetch(self, now: str) -> pd.DataFrame:
8
+ """Fetch usage data and return as a DataFrame."""
9
+ pass
@@ -0,0 +1,82 @@
1
+ import requests
2
+ import pandas as pd
3
+ import json
4
+ from pathlib import Path
5
+ from datetime import datetime
6
+ from zoneinfo import ZoneInfo
7
+ from typing import Optional
8
+ from .base import BaseProvider
9
+ from project_toolkit import settings
10
+
11
+ CODEX_DIRECT_COLS = [
12
+ "time", "email", "plan_type",
13
+ "rate_limit.p.used_percent", "rate_limit.p.limit_window_seconds",
14
+ "rate_limit.p.reset_after_seconds", "rate_limit.p.reset_at",
15
+ "rate_limit.s.used_percent", "rate_limit.s.limit_window_seconds",
16
+ "rate_limit.s.reset_after_seconds", "rate_limit.s.reset_at",
17
+ "credits.has_credits", "credits.unlimited",
18
+ "credits.overage_limit_reached", "credits.balance",
19
+ "spend_control.reached", "spend_control.individual_limit"
20
+ ]
21
+
22
+ class CodexDirectProvider(BaseProvider):
23
+ def __init__(self, api_key: Optional[str] = None):
24
+ self.api_key = api_key or settings.env("CODEX_API_KEY")
25
+ if not self.api_key:
26
+ auth_file = Path.home() / ".config" / "codex" / "auth.json"
27
+ if auth_file.exists():
28
+ try:
29
+ with auth_file.open("r") as f:
30
+ auth_data = json.load(f)
31
+ self.api_key = auth_data.get("token") or auth_data.get("CODEX_API_KEY")
32
+ except Exception:
33
+ pass
34
+
35
+ def fetch(self, now: str) -> pd.DataFrame:
36
+ if not self.api_key:
37
+ raise ValueError("CODEX_API_KEY not found in environment or auth file")
38
+
39
+ response = requests.get(
40
+ "https://chatgpt.com/backend-api/wham/usage",
41
+ headers={
42
+ "Authorization": f"Bearer {self.api_key}",
43
+ "Accept": "application/json"
44
+ }
45
+ )
46
+ response.raise_for_status()
47
+ data = response.json()
48
+
49
+ def convert_ts(ts):
50
+ if not ts:
51
+ return None
52
+ return datetime.fromtimestamp(ts, tz=ZoneInfo("America/New_York")).isoformat()
53
+
54
+ rate_limit = data.get("rate_limit", {}) or {}
55
+ primary = rate_limit.get("primary_window", {}) or {}
56
+ secondary = rate_limit.get("secondary_window", {}) or {}
57
+ credits_data = data.get("credits", {}) or {}
58
+ spend_control = data.get("spend_control", {}) or {}
59
+
60
+ row = {
61
+ "time": now,
62
+ "email": data.get("email"),
63
+ "plan_type": data.get("plan_type"),
64
+ "rate_limit.p.used_percent": primary.get("used_percent"),
65
+ "rate_limit.p.limit_window_seconds": primary.get("limit_window_seconds"),
66
+ "rate_limit.p.reset_after_seconds": primary.get("reset_after_seconds"),
67
+ "rate_limit.p.reset_at": convert_ts(primary.get("reset_at")),
68
+ "rate_limit.s.used_percent": secondary.get("used_percent"),
69
+ "rate_limit.s.limit_window_seconds": secondary.get("limit_window_seconds"),
70
+ "rate_limit.s.reset_after_seconds": secondary.get("reset_after_seconds"),
71
+ "rate_limit.s.reset_at": convert_ts(secondary.get("reset_at")),
72
+ "credits.has_credits": credits_data.get("has_credits"),
73
+ "credits.unlimited": credits_data.get("unlimited"),
74
+ "credits.overage_limit_reached": credits_data.get("overage_limit_reached"),
75
+ "credits.balance": credits_data.get("balance"),
76
+ "spend_control.reached": spend_control.get("reached"),
77
+ "spend_control.individual_limit": spend_control.get("individual_limit")
78
+ }
79
+
80
+ df = pd.DataFrame([row], columns=CODEX_DIRECT_COLS)
81
+ df.attrs["raw_json"] = data
82
+ return df
@@ -0,0 +1,119 @@
1
+ import subprocess
2
+ import shlex
3
+ import json
4
+ import re
5
+ import pandas as pd
6
+ from typing import Optional
7
+ from .base import BaseProvider
8
+
9
+ COL_TIME = "time"
10
+ COL_CODEX_USAGE = "Codex Usage"
11
+ COL_CODEX_RESETS = "Codex Resets"
12
+ COL_CODEX_5HR_USAGE = "Codex 5hr Usage"
13
+ COL_CODEX_5HR_RESET = "Codex 5hr Reset"
14
+ COL_CODEX_WEEKLY_USAGE = "Codex Weekly Usage"
15
+ COL_CODEX_WEEKLY_RESET = "Codex Weekly Reset"
16
+ COL_CODEX_CREDITS = "Codex Credits"
17
+ COL_CODEX_PLAN = "Codex Plan"
18
+ COL_COPILOT_PREMIUM = "Copilot Premium"
19
+ COL_COPILOT_CHAT = "Copilot Chat"
20
+ COL_COPILOT_PLAN = "Copilot Plan"
21
+ COL_GEMINI_PRO = "Gemini Pro"
22
+ COL_GEMINI_PRO_RESETS = "Gemini Pro Resets"
23
+ COL_GEMINI_FLASH = "Gemini Flash"
24
+ COL_GEMINI_FLASH_RESET = "Gemini Flash Reset"
25
+ COL_GEMINI_FLASH_LITE = "Gemini Flash Lite"
26
+ COL_GEMINI_FLASHLITE_RESET = "Gemini Flashlite Reset"
27
+ COL_ANTIGRAVITY_CLAUDE = "Antigravity Claude"
28
+ COL_ANTIGRAVITY_CLAUDE_RESET = "Antigravity Claude Reset"
29
+ COL_ANTIGRAVITY_GEMINI_PRO = "Antigravity Gemini Pro"
30
+ COL_ANTIGRAVITY_GEMINI_PRO_RESET = "Antigravity Gemini Pro Reset"
31
+ COL_ANTIGRAVITY_FLASHLITE = "Antigravity Flashlite"
32
+ COL_ANTIGRAVITY_FLASHLITE_RESETS = "Antigravity Flashlite Resets"
33
+ COL_ANTIGRAVITY_PLAN = "Antigravity Plan"
34
+
35
+ CODEXBAR_COLS = [
36
+ COL_TIME, COL_CODEX_USAGE, COL_CODEX_RESETS, COL_CODEX_5HR_USAGE, COL_CODEX_5HR_RESET,
37
+ COL_CODEX_WEEKLY_USAGE, COL_CODEX_WEEKLY_RESET, COL_CODEX_CREDITS, COL_CODEX_PLAN,
38
+ COL_COPILOT_PREMIUM, COL_COPILOT_CHAT, COL_COPILOT_PLAN, COL_GEMINI_PRO, COL_GEMINI_PRO_RESETS,
39
+ COL_GEMINI_FLASH, COL_GEMINI_FLASH_RESET, COL_GEMINI_FLASH_LITE, COL_GEMINI_FLASHLITE_RESET,
40
+ COL_ANTIGRAVITY_CLAUDE, COL_ANTIGRAVITY_CLAUDE_RESET, COL_ANTIGRAVITY_GEMINI_PRO,
41
+ COL_ANTIGRAVITY_GEMINI_PRO_RESET, COL_ANTIGRAVITY_FLASHLITE, COL_ANTIGRAVITY_FLASHLITE_RESETS,
42
+ COL_ANTIGRAVITY_PLAN,
43
+ ]
44
+
45
+ PROVIDER_MAP = {
46
+ "codex": {
47
+ "primary": (COL_CODEX_5HR_USAGE, COL_CODEX_5HR_RESET),
48
+ "secondary": (COL_CODEX_WEEKLY_USAGE, COL_CODEX_WEEKLY_RESET)
49
+ },
50
+ "copilot": {
51
+ "primary": (COL_COPILOT_PREMIUM, None),
52
+ "secondary": (COL_COPILOT_CHAT, None)
53
+ },
54
+ "gemini": {
55
+ "primary": (COL_GEMINI_PRO, COL_GEMINI_PRO_RESETS),
56
+ "secondary": (COL_GEMINI_FLASH_LITE, COL_GEMINI_FLASHLITE_RESET),
57
+ "tertiary": (COL_GEMINI_FLASH, COL_GEMINI_FLASH_RESET)
58
+ },
59
+ "antigravity": {
60
+ "primary": (COL_ANTIGRAVITY_CLAUDE, COL_ANTIGRAVITY_CLAUDE_RESET),
61
+ "secondary": (COL_ANTIGRAVITY_GEMINI_PRO, COL_ANTIGRAVITY_GEMINI_PRO_RESET),
62
+ "tertiary": (COL_ANTIGRAVITY_FLASHLITE, COL_ANTIGRAVITY_FLASHLITE_RESETS)
63
+ },
64
+ }
65
+
66
+ class CodexBarProvider(BaseProvider):
67
+ def __init__(self, command: str = "codexbar usage --json"):
68
+ self.command = command
69
+
70
+ def fetch(self, now: str) -> pd.DataFrame:
71
+ try:
72
+ proc = subprocess.run(shlex.split(self.command), capture_output=True, text=True, check=True)
73
+ out_text = proc.stdout
74
+ except Exception:
75
+ proc = subprocess.run(self.command, capture_output=True, text=True, shell=True)
76
+ if proc.returncode != 0:
77
+ raise RuntimeError(f"Command failed: {proc.stderr or proc.stdout}")
78
+ out_text = proc.stdout
79
+
80
+ m = re.search(r"(\[|\{)", out_text)
81
+ if not m:
82
+ raise ValueError("No JSON found in command output")
83
+ data = json.loads(out_text[m.start():].strip())
84
+ items = data if isinstance(data, list) else [data]
85
+
86
+ out = {c: None for c in CODEXBAR_COLS}
87
+ out[COL_TIME] = now
88
+
89
+ for item in items:
90
+ prov = (item.get("provider") or item.get("source") or "").lower()
91
+ usage = item.get("usage") or {}
92
+
93
+ plan = usage.get("loginMethod") or (usage.get("identity") or {}).get("loginMethod")
94
+ if ("antigravity" in prov or "local" in prov) and plan and out.get(COL_ANTIGRAVITY_PLAN) is None:
95
+ out[COL_ANTIGRAVITY_PLAN] = plan
96
+
97
+ if "codex" in prov:
98
+ out[COL_CODEX_CREDITS] = (item.get("credits") or {}).get("remaining")
99
+ if plan and out.get(COL_CODEX_PLAN) is None:
100
+ out[COL_CODEX_PLAN] = plan
101
+
102
+ for key, mapping in PROVIDER_MAP.items():
103
+ if key in prov:
104
+ for slot, (val_col, reset_col) in mapping.items():
105
+ slot_data = usage.get(slot) or {}
106
+ val = slot_data.get("usedPercent")
107
+ rst = slot_data.get("resetDescription") or slot_data.get("resetsAt")
108
+
109
+ if val_col and out.get(val_col) is None:
110
+ out[val_col] = val
111
+ if reset_col and out.get(reset_col) is None:
112
+ out[reset_col] = rst
113
+
114
+ out[COL_CODEX_USAGE] = out.get(COL_CODEX_5HR_USAGE) if out.get(COL_CODEX_5HR_USAGE) is not None else out.get(COL_CODEX_WEEKLY_USAGE)
115
+ out[COL_CODEX_RESETS] = out.get(COL_CODEX_5HR_RESET) if out.get(COL_CODEX_5HR_RESET) is not None else out.get(COL_CODEX_WEEKLY_RESET)
116
+
117
+ df = pd.DataFrame([[out[c] for c in CODEXBAR_COLS]], columns=CODEXBAR_COLS)
118
+ df.attrs["raw_json"] = data
119
+ return df
@@ -0,0 +1,34 @@
1
+ import requests
2
+ import pandas as pd
3
+ from typing import Optional
4
+ from .base import BaseProvider
5
+ from project_toolkit import settings
6
+
7
+ OPENROUTER_KEYS = [
8
+ "limit", "limit_reset", "limit_remaining", "include_byok_in_limit",
9
+ "usage", "usage_daily", "usage_weekly", "usage_monthly",
10
+ "byok_usage", "byok_usage_daily", "byok_usage_weekly", "byok_usage_monthly",
11
+ "is_free_tier", "expires_at"
12
+ ]
13
+
14
+ class OpenRouterProvider(BaseProvider):
15
+ def __init__(self, api_key: Optional[str] = None):
16
+ self.api_key = api_key or settings.env("OPENROUTER_API_KEY")
17
+
18
+ def fetch(self, now: str) -> pd.DataFrame:
19
+ if not self.api_key:
20
+ raise ValueError("OPENROUTER_API_KEY not found")
21
+
22
+ response = requests.get(
23
+ "https://openrouter.ai/api/v1/key",
24
+ headers={"Authorization": f"Bearer {self.api_key}"},
25
+ )
26
+ response.raise_for_status()
27
+ data = response.json().get("data", {})
28
+
29
+ row = {"time": now}
30
+ row.update({k: data.get(k) for k in OPENROUTER_KEYS})
31
+
32
+ df = pd.DataFrame([row])
33
+ df.attrs["raw_json"] = data
34
+ return df
@@ -0,0 +1 @@
1
+ """Stores for Token Tracker."""
@@ -0,0 +1,8 @@
1
+ from abc import ABC, abstractmethod
2
+ import pandas as pd
3
+
4
+ class BaseStore(ABC):
5
+ @abstractmethod
6
+ def save(self, df: pd.DataFrame, name: str, init: bool = False) -> None:
7
+ """Save the DataFrame to the store."""
8
+ pass
@@ -0,0 +1,98 @@
1
+ import logging
2
+ import json
3
+ import hashlib
4
+ from pathlib import Path
5
+ import pandas as pd
6
+ from .base import BaseStore
7
+ from project_toolkit import settings
8
+
9
+ class GoogleSheetsStore(BaseStore):
10
+ def __init__(self, sheet_id: str = None):
11
+ self.sheet_id = sheet_id or settings.env("TOKEN_USAGE_SHEET_ID")
12
+ if not self.sheet_id:
13
+ raise RuntimeError("Environment variable TOKEN_USAGE_SHEET_ID is not set")
14
+
15
+ from project_toolkit.google.sheet import GoogleSheetsManager
16
+ self.manager = GoogleSheetsManager()
17
+
18
+ def _last_fetch_file(self, name: str) -> Path:
19
+ return Path.cwd() / f"{name}_last_fetch.json"
20
+
21
+ def _load_hash_data(self, name: str) -> dict:
22
+ p = self._last_fetch_file(name)
23
+ if not p.exists():
24
+ return {}
25
+ try:
26
+ with p.open("r", encoding="utf-8") as fh:
27
+ return json.load(fh)
28
+ except Exception:
29
+ logging.exception("Failed to load %s", p)
30
+ return {}
31
+
32
+ def _write_hash_data(self, name: str, data: dict) -> None:
33
+ p = self._last_fetch_file(name)
34
+ tmp = p.with_suffix(".tmp")
35
+ with tmp.open("w", encoding="utf-8") as fh:
36
+ json.dump(data, fh, indent=2, sort_keys=True, ensure_ascii=False, default=str)
37
+ tmp.replace(p)
38
+
39
+ def _snapshot_hash(self, df: pd.DataFrame) -> str:
40
+ df2 = df.copy()
41
+ if "time" in getattr(df2, "columns", []):
42
+ df2 = df2.drop(columns=["time"], errors=True)
43
+
44
+ records = df2.where(pd.notnull(df2), None).to_dict(orient="records")
45
+ s = json.dumps(records, sort_keys=True, default=str, separators=(',', ':'))
46
+ return hashlib.sha256(s.encode("utf-8")).hexdigest()
47
+
48
+ def save(self, df: pd.DataFrame, name: str, init: bool = False) -> None:
49
+ if df is None or getattr(df, "empty", False):
50
+ logging.warning("Provided DataFrame is empty; not writing to sheet %s", name)
51
+ return
52
+
53
+ raw = getattr(df, "attrs", {}).get("raw_json")
54
+ if raw is None:
55
+ try:
56
+ raw = df.where(pd.notnull(df), None).to_dict(orient="records")
57
+ except Exception:
58
+ raw = df.to_dict(orient="records") if hasattr(df, "to_dict") else (df if isinstance(df, (list, dict)) else None)
59
+
60
+ try:
61
+ curr_hash = self._snapshot_hash(df)
62
+ except Exception:
63
+ logging.exception("Failed to compute snapshot hash for %s", name)
64
+ curr_hash = None
65
+ if raw is not None:
66
+ try:
67
+ if isinstance(raw, list) and all(isinstance(r, dict) for r in raw):
68
+ raw_no_time = [{k: v for k, v in r.items() if k != "time"} for r in raw]
69
+ elif isinstance(raw, dict):
70
+ raw_no_time = {k: v for k, v in raw.items() if k != "time"}
71
+ else:
72
+ raw_no_time = raw
73
+ s = json.dumps(raw_no_time, sort_keys=True, default=str, separators=(',', ':'))
74
+ curr_hash = hashlib.sha256(s.encode('utf-8')).hexdigest()
75
+ except Exception:
76
+ logging.exception("Failed to compute fallback hash from raw for %s", name)
77
+
78
+ stored_data = self._load_hash_data(name)
79
+ stored_hash = stored_data.get("hash")
80
+
81
+ if not init and curr_hash is not None and stored_hash == curr_hash:
82
+ logging.info("No change detected for %s — skipping write", name)
83
+ else:
84
+ try:
85
+ if init:
86
+ logging.info("Initializing sheet '%s' (sheet_id=%s)", name, self.sheet_id)
87
+ self.manager.df_to_sheet(df, sheet_id=self.sheet_id, sheet_name=name)
88
+ else:
89
+ logging.info("Appending to sheet '%s' (sheet_id=%s)", name, self.sheet_id)
90
+ self.manager.append_to_sheet(df, sheet_id=self.sheet_id, sheet_name=name)
91
+ except Exception:
92
+ logging.exception("Failed to write DataFrame to Google Sheets")
93
+ raise
94
+
95
+ try:
96
+ self._write_hash_data(name, {"hash": curr_hash, "raw": raw})
97
+ except Exception:
98
+ logging.exception("Failed to update %s_last_fetch.json", name)