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.
- ai_token_usage-0.1.5.dist-info/METADATA +3 -0
- ai_token_usage-0.1.5.dist-info/RECORD +14 -0
- ai_token_usage-0.1.5.dist-info/WHEEL +5 -0
- ai_token_usage-0.1.5.dist-info/entry_points.txt +2 -0
- token_tracker/__init__.py +1 -0
- token_tracker/cli.py +79 -0
- token_tracker/providers/__init__.py +1 -0
- token_tracker/providers/base.py +9 -0
- token_tracker/providers/codex_direct.py +82 -0
- token_tracker/providers/codexbar.py +119 -0
- token_tracker/providers/openrouter.py +34 -0
- token_tracker/stores/__init__.py +1 -0
- token_tracker/stores/base.py +8 -0
- token_tracker/stores/google_sheets.py +98 -0
|
@@ -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 @@
|
|
|
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,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,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)
|