devin-cli-usage 0.1.0__tar.gz → 0.1.1__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: devin-cli-usage
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Devin CLI account quota monitor using local Devin authentication
5
5
  Project-URL: Homepage, https://github.com/wakamex/devin-cli-usage
6
6
  Project-URL: Source, https://github.com/wakamex/devin-cli-usage
@@ -59,10 +59,10 @@ uv tool install .
59
59
  ## Authentication and data
60
60
 
61
61
  The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
62
- `~/.local/share/devin/credentials.toml`, then calls the read-only
63
- `GetUserStatus` service used by the installed client. It rereads credentials
64
- for every live request. It never refreshes credentials or modifies
65
- Devin-owned files.
62
+ `~/.local/share/devin/credentials.toml`, or the corresponding path below
63
+ `XDG_DATA_HOME` when it is set, then calls the read-only `GetUserStatus`
64
+ service used by the installed client. It rereads credentials for every live
65
+ request. It never refreshes credentials or modifies Devin-owned files.
66
66
 
67
67
  The normalized JSON includes:
68
68
 
@@ -77,8 +77,9 @@ vendor sentinel for an unlimited or non-credit-based allowance and is
77
77
  preserved verbatim.
78
78
 
79
79
  The tool writes only its own non-secret cache at
80
- `~/.local/share/devin/usage-limits.json`. JSON results report whether they are
81
- `live`, `cached`, `stale`, or `unavailable`.
80
+ `~/.local/share/devin/usage-limits.json`, also following `XDG_DATA_HOME` when
81
+ set. JSON results report whether they are `live`, `cached`, `stale`, or
82
+ `unavailable`.
82
83
 
83
84
  Environment overrides:
84
85
 
@@ -50,10 +50,10 @@ uv tool install .
50
50
  ## Authentication and data
51
51
 
52
52
  The tool reads `windsurf_api_key` and `api_server_url` from Devin's local
53
- `~/.local/share/devin/credentials.toml`, then calls the read-only
54
- `GetUserStatus` service used by the installed client. It rereads credentials
55
- for every live request. It never refreshes credentials or modifies
56
- Devin-owned files.
53
+ `~/.local/share/devin/credentials.toml`, or the corresponding path below
54
+ `XDG_DATA_HOME` when it is set, then calls the read-only `GetUserStatus`
55
+ service used by the installed client. It rereads credentials for every live
56
+ request. It never refreshes credentials or modifies Devin-owned files.
57
57
 
58
58
  The normalized JSON includes:
59
59
 
@@ -68,8 +68,9 @@ vendor sentinel for an unlimited or non-credit-based allowance and is
68
68
  preserved verbatim.
69
69
 
70
70
  The tool writes only its own non-secret cache at
71
- `~/.local/share/devin/usage-limits.json`. JSON results report whether they are
72
- `live`, `cached`, `stale`, or `unavailable`.
71
+ `~/.local/share/devin/usage-limits.json`, also following `XDG_DATA_HOME` when
72
+ set. JSON results report whether they are `live`, `cached`, `stale`, or
73
+ `unavailable`.
73
74
 
74
75
  Environment overrides:
75
76
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devin-cli-usage"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "Devin CLI account quota monitor using local Devin authentication"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import json
8
+ import math
8
9
  import os
9
10
  import re
10
11
  import shutil
@@ -46,17 +47,31 @@ def _parse_iso(value: object) -> datetime | None:
46
47
  def _number(value: object) -> int | float | None:
47
48
  if isinstance(value, bool):
48
49
  return None
49
- if isinstance(value, int | float):
50
+ if isinstance(value, int):
50
51
  return value
52
+ if isinstance(value, float):
53
+ return value if math.isfinite(value) else None
51
54
  if isinstance(value, str):
52
55
  try:
53
56
  parsed = float(value)
54
57
  except ValueError:
55
58
  return None
59
+ if not math.isfinite(parsed):
60
+ return None
56
61
  return int(parsed) if parsed.is_integer() else parsed
57
62
  return None
58
63
 
59
64
 
65
+ def _optional_number(data: dict, key: str) -> int | float | None:
66
+ raw = data.get(key)
67
+ if raw is None:
68
+ return None
69
+ value = _number(raw)
70
+ if value is None:
71
+ raise RuntimeError(f"Devin user-status response contained invalid {key}")
72
+ return value
73
+
74
+
60
75
  def _format_reset(value: object) -> str:
61
76
  seconds = _number(value)
62
77
  if seconds is None:
@@ -75,12 +90,26 @@ def _format_reset(value: object) -> str:
75
90
 
76
91
  def get_credentials_file() -> Path:
77
92
  override = os.environ.get("DEVIN_USAGE_CREDENTIALS_FILE")
78
- return Path(override).expanduser() if override else DEFAULT_CREDENTIALS_FILE
93
+ if override:
94
+ return Path(override).expanduser()
95
+ data_home = os.environ.get("XDG_DATA_HOME")
96
+ return (
97
+ Path(data_home).expanduser() / "devin" / "credentials.toml"
98
+ if data_home
99
+ else DEFAULT_CREDENTIALS_FILE
100
+ )
79
101
 
80
102
 
81
103
  def get_usage_file() -> Path:
82
104
  override = os.environ.get("DEVIN_USAGE_FILE")
83
- return Path(override).expanduser() if override else DEFAULT_USAGE_FILE
105
+ if override:
106
+ return Path(override).expanduser()
107
+ data_home = os.environ.get("XDG_DATA_HOME")
108
+ return (
109
+ Path(data_home).expanduser() / "devin" / "usage-limits.json"
110
+ if data_home
111
+ else DEFAULT_USAGE_FILE
112
+ )
84
113
 
85
114
 
86
115
  def get_credentials() -> tuple[str, str]:
@@ -168,17 +197,22 @@ def _normalized_plan_info(payload: dict, plan_status: dict) -> dict:
168
197
  info = plan_status.get("planInfo")
169
198
  if not isinstance(info, dict):
170
199
  info = {}
171
- return {
200
+ normalized = {
172
201
  key: value
173
202
  for key, source in (
174
203
  ("name", "planName"),
175
204
  ("billing_strategy", "billingStrategy"),
176
- ("monthly_prompt_credits", "monthlyPromptCredits"),
177
- ("monthly_flow_credits", "monthlyFlowCredits"),
178
- ("monthly_flex_credits", "monthlyFlexCreditPurchaseAmount"),
179
205
  )
180
206
  if (value := info.get(source)) is not None
181
207
  }
208
+ for key, source in (
209
+ ("monthly_prompt_credits", "monthlyPromptCredits"),
210
+ ("monthly_flow_credits", "monthlyFlowCredits"),
211
+ ("monthly_flex_credits", "monthlyFlexCreditPurchaseAmount"),
212
+ ):
213
+ if (value := _optional_number(info, source)) is not None:
214
+ normalized[key] = value
215
+ return normalized
182
216
 
183
217
 
184
218
  def _normalize(payload: dict) -> dict:
@@ -187,15 +221,19 @@ def _normalize(payload: dict) -> dict:
187
221
  raise RuntimeError("Devin user-status response did not contain userStatus")
188
222
  plan_status = user_status.get("planStatus")
189
223
  if not isinstance(plan_status, dict):
190
- plan_status = {}
224
+ raise RuntimeError("Devin user-status response did not contain planStatus")
191
225
  quotas = {
192
226
  "daily": {
193
- "remaining_pct": _number(plan_status.get("dailyQuotaRemainingPercent")),
194
- "reset_at": _number(plan_status.get("dailyQuotaResetAtUnix")),
227
+ "remaining_pct": _optional_number(
228
+ plan_status, "dailyQuotaRemainingPercent"
229
+ ),
230
+ "reset_at": _optional_number(plan_status, "dailyQuotaResetAtUnix"),
195
231
  },
196
232
  "weekly": {
197
- "remaining_pct": _number(plan_status.get("weeklyQuotaRemainingPercent")),
198
- "reset_at": _number(plan_status.get("weeklyQuotaResetAtUnix")),
233
+ "remaining_pct": _optional_number(
234
+ plan_status, "weeklyQuotaRemainingPercent"
235
+ ),
236
+ "reset_at": _optional_number(plan_status, "weeklyQuotaResetAtUnix"),
199
237
  },
200
238
  }
201
239
  credits = {
@@ -211,8 +249,12 @@ def _normalize(payload: dict) -> dict:
211
249
  ("acu_consumed", "acuConsumed"),
212
250
  ("acu_limit", "acuLimit"),
213
251
  )
214
- if (value := _number(plan_status.get(source))) is not None
252
+ if (value := _optional_number(plan_status, source)) is not None
215
253
  }
254
+ if not credits and not any(
255
+ quota["remaining_pct"] is not None for quota in quotas.values()
256
+ ):
257
+ raise RuntimeError("Devin user-status response did not contain usage data")
216
258
  return {
217
259
  "plan": _normalized_plan_info(payload, plan_status),
218
260
  "plan_period": {
@@ -25,6 +25,23 @@ class FakeResponse:
25
25
 
26
26
 
27
27
  class DevinUsageTests(unittest.TestCase):
28
+ def test_default_files_follow_xdg_data_home(self):
29
+ with (
30
+ tempfile.TemporaryDirectory() as temporary,
31
+ mock.patch.dict(
32
+ devin_usage.os.environ,
33
+ {"XDG_DATA_HOME": temporary},
34
+ clear=True,
35
+ ),
36
+ ):
37
+ data_dir = Path(temporary) / "devin"
38
+ self.assertEqual(
39
+ devin_usage.get_credentials_file(), data_dir / "credentials.toml"
40
+ )
41
+ self.assertEqual(
42
+ devin_usage.get_usage_file(), data_dir / "usage-limits.json"
43
+ )
44
+
28
45
  def test_credentials_are_reread_without_writes(self):
29
46
  with tempfile.TemporaryDirectory() as temporary:
30
47
  credentials = Path(temporary) / "credentials.toml"
@@ -88,6 +105,39 @@ class DevinUsageTests(unittest.TestCase):
88
105
  self.assertEqual(data["quotas"]["weekly"]["remaining_pct"], 50)
89
106
  self.assertEqual(data["credits"]["available_prompt"], 900)
90
107
 
108
+ def test_incomplete_plan_status_is_unavailable(self):
109
+ for plan_status in (None, [], {}):
110
+ with self.subTest(plan_status=plan_status):
111
+ payload = {"userStatus": {"planStatus": plan_status}}
112
+ with mock.patch.object(
113
+ devin_usage, "fetch_usage", return_value=payload
114
+ ):
115
+ data = devin_usage.build_usage_json()
116
+ self.assertEqual(data["status"], "unavailable")
117
+
118
+ def test_nonfinite_usage_is_unavailable(self):
119
+ payloads = (
120
+ {
121
+ "userStatus": {
122
+ "planStatus": {"dailyQuotaRemainingPercent": float("inf")}
123
+ }
124
+ },
125
+ {
126
+ "planInfo": {"monthlyPromptCredits": "NaN"},
127
+ "userStatus": {
128
+ "planStatus": {"dailyQuotaRemainingPercent": 75}
129
+ },
130
+ },
131
+ )
132
+ for payload in payloads:
133
+ with self.subTest(payload=payload):
134
+ with mock.patch.object(
135
+ devin_usage, "fetch_usage", return_value=payload
136
+ ):
137
+ data = devin_usage.build_usage_json()
138
+ self.assertEqual(data["status"], "unavailable")
139
+ json.dumps(data, allow_nan=False)
140
+
91
141
  def test_non_https_server_is_rejected(self):
92
142
  with tempfile.TemporaryDirectory() as temporary:
93
143
  credentials = Path(temporary) / "credentials.toml"
@@ -4,5 +4,5 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "devin-cli-usage"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  source = { editable = "." }