teamspend-cli 0.2.1__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.
- teamspend/__init__.py +82 -0
- teamspend/adapters/__init__.py +9 -0
- teamspend/adapters/claude_code.py +77 -0
- teamspend/adapters/claude_code_personal.py +258 -0
- teamspend/adapters/codex.py +265 -0
- teamspend/adapters/copilot.py +189 -0
- teamspend/adapters/csv_import.py +127 -0
- teamspend/adapters/cursor.py +114 -0
- teamspend/adapters/opencode.py +250 -0
- teamspend/cli.py +318 -0
- teamspend/compare.py +77 -0
- teamspend/errors.py +96 -0
- teamspend/http_client.py +204 -0
- teamspend/output.py +251 -0
- teamspend/py.typed +0 -0
- teamspend/types.py +111 -0
- teamspend_cli-0.2.1.dist-info/METADATA +386 -0
- teamspend_cli-0.2.1.dist-info/RECORD +21 -0
- teamspend_cli-0.2.1.dist-info/WHEEL +4 -0
- teamspend_cli-0.2.1.dist-info/entry_points.txt +2 -0
- teamspend_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
teamspend/cli.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
teamspend CLI.
|
|
4
|
+
|
|
5
|
+
Ported from src/cli.ts (which uses Node's built-in `parseArgs`); this port
|
|
6
|
+
uses the stdlib `argparse` to avoid a CLI-framework dependency. Flags,
|
|
7
|
+
defaults, and validation error text are kept identical to the npm CLI's.
|
|
8
|
+
|
|
9
|
+
Console entry point: `teamspend --tools <a>,<b> --before ... --after ...`,
|
|
10
|
+
installed via the `teamspend` console-script defined in pyproject.toml.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import concurrent.futures
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from typing import List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
from .adapters.claude_code import fetch_claude_code_spend
|
|
22
|
+
from .adapters.claude_code_personal import fetch_claude_code_personal_usage
|
|
23
|
+
from .adapters.codex import fetch_codex_usage
|
|
24
|
+
from .adapters.copilot import fetch_copilot_spend
|
|
25
|
+
from .adapters.csv_import import import_from_csv
|
|
26
|
+
from .adapters.cursor import fetch_cursor_spend
|
|
27
|
+
from .adapters.opencode import fetch_opencode_spend
|
|
28
|
+
from .compare import ComparisonReport, PeriodOutcome, build_comparison
|
|
29
|
+
from .errors import DataUnavailableError, InvalidCliArgError
|
|
30
|
+
from .output import render_terminal_summary, scaffold_gitignore, write_json_report
|
|
31
|
+
from .types import AdapterResult, BreakdownMode, DateWindow, ToolId
|
|
32
|
+
|
|
33
|
+
KNOWN_TOOLS: List[ToolId] = [
|
|
34
|
+
"cursor",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"copilot",
|
|
37
|
+
"opencode",
|
|
38
|
+
"claude-code-personal",
|
|
39
|
+
"codex",
|
|
40
|
+
]
|
|
41
|
+
KNOWN_BREAKDOWN_MODES: List[BreakdownMode] = ["session"]
|
|
42
|
+
DATE_RANGE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}):(\d{4}-\d{2}-\d{2})$")
|
|
43
|
+
_VERSION = "0.1.0"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_date_range(flag: str, value: str) -> DateWindow:
|
|
47
|
+
match = DATE_RANGE_RE.match(value)
|
|
48
|
+
if not match:
|
|
49
|
+
raise InvalidCliArgError(
|
|
50
|
+
f'--{flag} must be in the form YYYY-MM-DD:YYYY-MM-DD, got "{value}"'
|
|
51
|
+
)
|
|
52
|
+
return DateWindow(start=match.group(1), end=match.group(2))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_tools(raw_tools: str) -> Tuple[ToolId, ToolId]:
|
|
56
|
+
parts = [t.strip() for t in raw_tools.split(",")]
|
|
57
|
+
if len(parts) != 2:
|
|
58
|
+
raise InvalidCliArgError(
|
|
59
|
+
f'--tools must name exactly two tools, got "{raw_tools}"'
|
|
60
|
+
)
|
|
61
|
+
for tool in parts:
|
|
62
|
+
if tool not in KNOWN_TOOLS:
|
|
63
|
+
raise InvalidCliArgError(
|
|
64
|
+
f'Unknown tool "{tool}" -- expected one of: {", ".join(KNOWN_TOOLS)}'
|
|
65
|
+
)
|
|
66
|
+
return parts[0], parts[1] # type: ignore[return-value]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_window_order(before: DateWindow, after: DateWindow) -> None:
|
|
70
|
+
if before.start >= after.start:
|
|
71
|
+
raise InvalidCliArgError(
|
|
72
|
+
f"--before ({before.start}) must be earlier than --after ({after.start})"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_breakdown_mode(raw: Optional[str]) -> Optional[BreakdownMode]:
|
|
77
|
+
"""
|
|
78
|
+
Parses --breakdown. Unset (None) is the default (no change from prior
|
|
79
|
+
behavior). A set-but-unrecognized value raises rather than silently
|
|
80
|
+
being ignored, since a typo like `--breakdown sessions` should surface
|
|
81
|
+
immediately instead of quietly reporting the plain flat total the
|
|
82
|
+
caller didn't ask for.
|
|
83
|
+
"""
|
|
84
|
+
if raw is None:
|
|
85
|
+
return None
|
|
86
|
+
if raw in KNOWN_BREAKDOWN_MODES:
|
|
87
|
+
return raw # type: ignore[return-value]
|
|
88
|
+
raise InvalidCliArgError(
|
|
89
|
+
f'--breakdown must be one of: {", ".join(KNOWN_BREAKDOWN_MODES)}, got "{raw}"'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _strip_sessions_unless_requested(
|
|
94
|
+
result: Optional[AdapterResult], breakdown: Optional[BreakdownMode]
|
|
95
|
+
) -> Optional[AdapterResult]:
|
|
96
|
+
"""
|
|
97
|
+
Removes per-session data from a fetched result when the caller didn't
|
|
98
|
+
ask for it via --breakdown session. Adapters populate `sessions`
|
|
99
|
+
whenever their underlying data has it, independent of any CLI flag;
|
|
100
|
+
stripping it back out here (rather than teaching every adapter about
|
|
101
|
+
the flag) is what keeps the default JSON report and terminal output
|
|
102
|
+
unchanged from before this feature existed.
|
|
103
|
+
"""
|
|
104
|
+
if result is None or breakdown == "session":
|
|
105
|
+
return result
|
|
106
|
+
for user in result.users:
|
|
107
|
+
user.sessions = None
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_copilot_seat_price() -> Optional[float]:
|
|
112
|
+
"""
|
|
113
|
+
Parses TEAMSPEND_COPILOT_SEAT_PRICE_USD, an optional per-seat monthly
|
|
114
|
+
price the caller supplies since GitHub's API never exposes an org's
|
|
115
|
+
actual negotiated seat price. Returns None if unset. Raises
|
|
116
|
+
InvalidCliArgError for a set-but-unparseable or negative value.
|
|
117
|
+
"""
|
|
118
|
+
raw = os.environ.get("TEAMSPEND_COPILOT_SEAT_PRICE_USD")
|
|
119
|
+
if not raw:
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
parsed = float(raw)
|
|
123
|
+
except ValueError:
|
|
124
|
+
parsed = float("nan")
|
|
125
|
+
if not (parsed >= 0):
|
|
126
|
+
raise InvalidCliArgError(
|
|
127
|
+
f'TEAMSPEND_COPILOT_SEAT_PRICE_USD must be a non-negative number, got "{raw}"'
|
|
128
|
+
)
|
|
129
|
+
return parsed
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _fetch_tool(
|
|
133
|
+
tool: ToolId, window: DateWindow, csv_path: Optional[str]
|
|
134
|
+
) -> Optional[AdapterResult]:
|
|
135
|
+
env_var = f"TEAMSPEND_{tool.upper().replace('-', '_')}_TOKEN"
|
|
136
|
+
api_key = os.environ.get(env_var)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
if tool == "cursor":
|
|
140
|
+
if not api_key:
|
|
141
|
+
raise RuntimeError(f"Missing {env_var}")
|
|
142
|
+
return fetch_cursor_spend(window, api_key)
|
|
143
|
+
if tool == "claude-code":
|
|
144
|
+
if not api_key:
|
|
145
|
+
raise RuntimeError(f"Missing {env_var}")
|
|
146
|
+
return fetch_claude_code_spend(window, api_key)
|
|
147
|
+
if tool == "copilot":
|
|
148
|
+
if not api_key:
|
|
149
|
+
raise RuntimeError(f"Missing {env_var}")
|
|
150
|
+
org = os.environ.get("TEAMSPEND_COPILOT_ORG")
|
|
151
|
+
if not org:
|
|
152
|
+
raise RuntimeError("Missing TEAMSPEND_COPILOT_ORG")
|
|
153
|
+
return fetch_copilot_spend(
|
|
154
|
+
window, api_key, org, _parse_copilot_seat_price()
|
|
155
|
+
)
|
|
156
|
+
if tool == "opencode":
|
|
157
|
+
# No API key: opencode has no admin/team API, only local
|
|
158
|
+
# per-machine session logs (see adapters/opencode.py for how
|
|
159
|
+
# those are resolved and read).
|
|
160
|
+
return fetch_opencode_spend(window)
|
|
161
|
+
if tool == "claude-code-personal":
|
|
162
|
+
# Deliberately no credential check -- this mode reads Claude
|
|
163
|
+
# Code's own local JSONL session logs and never calls an admin
|
|
164
|
+
# API, so TEAMSPEND_CLAUDE_CODE_PERSONAL_TOKEN is never
|
|
165
|
+
# required.
|
|
166
|
+
return fetch_claude_code_personal_usage(window)
|
|
167
|
+
if tool == "codex":
|
|
168
|
+
# No API key: Codex CLI has no admin/team API, only local
|
|
169
|
+
# per-machine rollout logs (see adapters/codex.py for how
|
|
170
|
+
# those are resolved and read).
|
|
171
|
+
return fetch_codex_usage(window)
|
|
172
|
+
raise InvalidCliArgError(f'No adapter for tool "{tool}"')
|
|
173
|
+
except DataUnavailableError:
|
|
174
|
+
if csv_path:
|
|
175
|
+
return import_from_csv(csv_path, tool, window)
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
180
|
+
parser = argparse.ArgumentParser(
|
|
181
|
+
prog="teamspend",
|
|
182
|
+
add_help=False,
|
|
183
|
+
description=(
|
|
184
|
+
"Compare AI coding tool spend before and after a migration."
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument("--tools")
|
|
188
|
+
parser.add_argument("--before")
|
|
189
|
+
parser.add_argument("--after")
|
|
190
|
+
parser.add_argument("--json", action="store_true", default=False)
|
|
191
|
+
parser.add_argument("--before-csv", dest="before_csv")
|
|
192
|
+
parser.add_argument("--after-csv", dest="after_csv")
|
|
193
|
+
parser.add_argument("--breakdown")
|
|
194
|
+
parser.add_argument("--version", action="store_true", default=False)
|
|
195
|
+
parser.add_argument("-h", "--help", action="store_true", default=False)
|
|
196
|
+
return parser
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_USAGE = (
|
|
200
|
+
"Usage: teamspend --tools <a>,<b> --before YYYY-MM-DD:YYYY-MM-DD "
|
|
201
|
+
"--after YYYY-MM-DD:YYYY-MM-DD [--json] [--before-csv <path>] [--after-csv <path>] "
|
|
202
|
+
"[--breakdown session]"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def run(argv: List[str]) -> int:
|
|
207
|
+
"""
|
|
208
|
+
Runs the CLI against `argv` (NOT including the program name -- this
|
|
209
|
+
matches Python's usual `sys.argv[1:]` convention, unlike the TypeScript
|
|
210
|
+
original's `process.argv.slice(2)` call site which strips two leading
|
|
211
|
+
entries; both end up passing "just the flags" to their respective
|
|
212
|
+
`run`/`run_cli` functions). Returns the process exit code.
|
|
213
|
+
"""
|
|
214
|
+
parser = _build_parser()
|
|
215
|
+
try:
|
|
216
|
+
args = parser.parse_args(argv)
|
|
217
|
+
except SystemExit:
|
|
218
|
+
return 1
|
|
219
|
+
|
|
220
|
+
if args.help:
|
|
221
|
+
print(_USAGE)
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
if args.version:
|
|
225
|
+
print(f"teamspend {_VERSION}")
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
if not args.tools or not args.before or not args.after:
|
|
230
|
+
print(_USAGE, file=sys.stderr)
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
before_tool, after_tool = _validate_tools(args.tools)
|
|
234
|
+
before_window = _parse_date_range("before", args.before)
|
|
235
|
+
after_window = _parse_date_range("after", args.after)
|
|
236
|
+
_validate_window_order(before_window, after_window)
|
|
237
|
+
breakdown = _parse_breakdown_mode(args.breakdown)
|
|
238
|
+
|
|
239
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
|
240
|
+
before_future = executor.submit(
|
|
241
|
+
_fetch_tool, before_tool, before_window, args.before_csv
|
|
242
|
+
)
|
|
243
|
+
after_future = executor.submit(
|
|
244
|
+
_fetch_tool, after_tool, after_window, args.after_csv
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
before_result: Optional[AdapterResult] = None
|
|
248
|
+
before_error: Optional[BaseException] = None
|
|
249
|
+
try:
|
|
250
|
+
before_result = before_future.result()
|
|
251
|
+
except BaseException as error: # noqa: BLE001 -- mirrors Promise.allSettled, any adapter failure becomes a per-side error, never a crash
|
|
252
|
+
before_error = error
|
|
253
|
+
|
|
254
|
+
after_result: Optional[AdapterResult] = None
|
|
255
|
+
after_error: Optional[BaseException] = None
|
|
256
|
+
try:
|
|
257
|
+
after_result = after_future.result()
|
|
258
|
+
except BaseException as error: # noqa: BLE001
|
|
259
|
+
after_error = error
|
|
260
|
+
|
|
261
|
+
before = PeriodOutcome(
|
|
262
|
+
label="before",
|
|
263
|
+
tool=before_tool,
|
|
264
|
+
result=_strip_sessions_unless_requested(before_result, breakdown),
|
|
265
|
+
error=before_error,
|
|
266
|
+
)
|
|
267
|
+
after = PeriodOutcome(
|
|
268
|
+
label="after",
|
|
269
|
+
tool=after_tool,
|
|
270
|
+
result=_strip_sessions_unless_requested(after_result, breakdown),
|
|
271
|
+
error=after_error,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
report: ComparisonReport = build_comparison(before, after)
|
|
275
|
+
cwd = os.getcwd()
|
|
276
|
+
scaffolded = scaffold_gitignore(cwd)
|
|
277
|
+
json_path = write_json_report(report, cwd)
|
|
278
|
+
|
|
279
|
+
if scaffolded:
|
|
280
|
+
print(
|
|
281
|
+
"Note: teamspend-snapshot-*.json contains per-user email and "
|
|
282
|
+
"spend data -- added to .gitignore.",
|
|
283
|
+
file=sys.stderr,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Printed every run, not just on first-run gitignore scaffolding: a
|
|
287
|
+
# .gitignore entry protects the on-disk file, but does nothing about
|
|
288
|
+
# this same per-user email + spend data being printed to stdout,
|
|
289
|
+
# which lands in CI build logs (often world-readable for public
|
|
290
|
+
# repos) if this command is wired into a scheduled workflow.
|
|
291
|
+
print(
|
|
292
|
+
"Note: this output includes per-user email and spend data. If "
|
|
293
|
+
"running in CI, confirm build logs are private.",
|
|
294
|
+
file=sys.stderr,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if args.json:
|
|
298
|
+
import json as json_module
|
|
299
|
+
|
|
300
|
+
from .output import report_to_json_dict
|
|
301
|
+
|
|
302
|
+
print(json_module.dumps(report_to_json_dict(report), indent=2))
|
|
303
|
+
else:
|
|
304
|
+
print(render_terminal_summary(report, breakdown))
|
|
305
|
+
print(f"\nFull report: {json_path}")
|
|
306
|
+
|
|
307
|
+
return 0 if before.result and after.result else 1
|
|
308
|
+
except Exception as error: # noqa: BLE001 -- top-level crash guard, mirrors src/cli.ts's catch-all
|
|
309
|
+
print(str(error), file=sys.stderr)
|
|
310
|
+
return 1
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def main() -> None:
|
|
314
|
+
sys.exit(run(sys.argv[1:]))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
main()
|
teamspend/compare.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Builds the before/after comparison report from two independently-resolved
|
|
3
|
+
adapter fetches.
|
|
4
|
+
|
|
5
|
+
Ported from src/compare.ts. Never treats a partial failure as a complete
|
|
6
|
+
comparison: if either side failed, delta is null and the failed side is
|
|
7
|
+
marked, not silently dropped from the report.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import List, Literal, Optional
|
|
13
|
+
|
|
14
|
+
from .types import AdapterResult, ToolId, top_spenders
|
|
15
|
+
|
|
16
|
+
PeriodLabel = Literal["before", "after"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PeriodOutcome:
|
|
21
|
+
label: PeriodLabel
|
|
22
|
+
tool: ToolId
|
|
23
|
+
result: Optional[AdapterResult]
|
|
24
|
+
error: Optional[BaseException]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class TopSpenderEntry:
|
|
29
|
+
period: PeriodLabel
|
|
30
|
+
user_email: Optional[str]
|
|
31
|
+
cost_usd: float
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ComparisonReport:
|
|
36
|
+
before: PeriodOutcome
|
|
37
|
+
after: PeriodOutcome
|
|
38
|
+
delta_usd: Optional[float]
|
|
39
|
+
delta_percent: Optional[float]
|
|
40
|
+
top_spenders_across_both: List[TopSpenderEntry] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_comparison(before: PeriodOutcome, after: PeriodOutcome) -> ComparisonReport:
|
|
44
|
+
before_cost = before.result.total_cost_usd if before.result else None
|
|
45
|
+
after_cost = after.result.total_cost_usd if after.result else None
|
|
46
|
+
|
|
47
|
+
delta_usd = (
|
|
48
|
+
after_cost - before_cost
|
|
49
|
+
if before_cost is not None and after_cost is not None
|
|
50
|
+
else None
|
|
51
|
+
)
|
|
52
|
+
delta_percent = (
|
|
53
|
+
(delta_usd / before_cost) * 100
|
|
54
|
+
if delta_usd is not None and before_cost is not None and before_cost != 0
|
|
55
|
+
else None
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
top_spenders_across_both: List[TopSpenderEntry] = []
|
|
59
|
+
if before.result:
|
|
60
|
+
for user in top_spenders(before.result.users, 5):
|
|
61
|
+
top_spenders_across_both.append(
|
|
62
|
+
TopSpenderEntry("before", user.user_email, user.cost_usd)
|
|
63
|
+
)
|
|
64
|
+
if after.result:
|
|
65
|
+
for user in top_spenders(after.result.users, 5):
|
|
66
|
+
top_spenders_across_both.append(
|
|
67
|
+
TopSpenderEntry("after", user.user_email, user.cost_usd)
|
|
68
|
+
)
|
|
69
|
+
top_spenders_across_both.sort(key=lambda entry: entry.cost_usd, reverse=True)
|
|
70
|
+
|
|
71
|
+
return ComparisonReport(
|
|
72
|
+
before=before,
|
|
73
|
+
after=after,
|
|
74
|
+
delta_usd=delta_usd,
|
|
75
|
+
delta_percent=delta_percent,
|
|
76
|
+
top_spenders_across_both=top_spenders_across_both[:5],
|
|
77
|
+
)
|
teamspend/errors.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Named error types for every failure mode the fetch/compare pipeline can hit.
|
|
3
|
+
|
|
4
|
+
Ported from src/errors.ts. Message text is kept identical to the
|
|
5
|
+
TypeScript original so a bug report reads the same regardless of which
|
|
6
|
+
distribution (npm or PyPI) produced it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Iterable, List, Sequence
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthenticationError(Exception):
|
|
14
|
+
"""Raised when an admin API responds 401/403 for a tool's credential."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, tool: str, credential_env_var: str) -> None:
|
|
17
|
+
self.tool = tool
|
|
18
|
+
self.credential_env_var = credential_env_var
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"Auth failed for {tool}: check {credential_env_var} is set and valid"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RetryExhaustedError(Exception):
|
|
25
|
+
"""Raised when fetch_with_retry exhausts its retry budget."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, tool: str, failure_kind: str, attempts: int) -> None:
|
|
28
|
+
self.tool = tool
|
|
29
|
+
self.failure_kind = failure_kind
|
|
30
|
+
self.attempts = attempts
|
|
31
|
+
super().__init__(f"{tool} failed after {attempts} retries ({failure_kind})")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SchemaDriftError(Exception):
|
|
35
|
+
"""Raised when a vendor API response is missing a field require_field expects."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
tool: str,
|
|
40
|
+
unexpected_field: str,
|
|
41
|
+
tried_aliases: Sequence[str] = (),
|
|
42
|
+
) -> None:
|
|
43
|
+
self.tool = tool
|
|
44
|
+
self.unexpected_field = unexpected_field
|
|
45
|
+
self.tried_aliases: List[str] = list(tried_aliases)
|
|
46
|
+
alias_suffix = (
|
|
47
|
+
f" (also checked known aliases: {', '.join(self.tried_aliases)})"
|
|
48
|
+
if self.tried_aliases
|
|
49
|
+
else ""
|
|
50
|
+
)
|
|
51
|
+
super().__init__(
|
|
52
|
+
f"{tool} API returned an unexpected shape (field: {unexpected_field})"
|
|
53
|
+
f"{alias_suffix}, teamspend may need an update"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DataUnavailableError(Exception):
|
|
58
|
+
"""Raised when a tool's admin API cannot serve the requested window at all."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, tool: str, reason: str) -> None:
|
|
61
|
+
self.tool = tool
|
|
62
|
+
self.reason = reason
|
|
63
|
+
super().__init__(
|
|
64
|
+
f"No API data available for {tool}: {reason}, "
|
|
65
|
+
"provide a CSV file per the documented schema"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CSVSchemaError(Exception):
|
|
70
|
+
"""Raised when an imported CSV is missing one of the required columns."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, expected_columns: Iterable[str]) -> None:
|
|
73
|
+
self.expected_columns: List[str] = list(expected_columns)
|
|
74
|
+
super().__init__(
|
|
75
|
+
f"CSV file doesn't match expected columns: {', '.join(self.expected_columns)}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class EmptyCSVError(Exception):
|
|
80
|
+
"""Raised for a zero-byte or whitespace-only CSV file."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, path: str) -> None:
|
|
83
|
+
self.path = path
|
|
84
|
+
super().__init__(f"CSV file is empty: {path}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CSVRowError(Exception):
|
|
88
|
+
"""Raised for a single malformed CSV row (bad cost, empty email)."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, row_number: int, reason: str) -> None:
|
|
91
|
+
self.row_number = row_number
|
|
92
|
+
super().__init__(f"CSV row {row_number} is invalid: {reason}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class InvalidCliArgError(Exception):
|
|
96
|
+
"""Raised for a malformed or missing CLI flag."""
|
teamspend/http_client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared fetch+retry wrapper used by every admin-API adapter.
|
|
3
|
+
|
|
4
|
+
Ported from src/http-client.ts. Retries 429 and 5xx/timeout identically
|
|
5
|
+
with exponential backoff (base 0.5s, doubling, cap 3 retries), then fails
|
|
6
|
+
with a named error rather than ever returning a partial or guessed result.
|
|
7
|
+
|
|
8
|
+
Uses only `urllib` from the standard library (no `requests` dependency),
|
|
9
|
+
matching the npm package's "zero runtime dependencies" design -- the
|
|
10
|
+
TypeScript original uses native `fetch` for the same reason.
|
|
11
|
+
|
|
12
|
+
The real network call is a separate, injectable `transport` parameter so
|
|
13
|
+
tests can simulate arbitrary HTTP statuses and network failures without a
|
|
14
|
+
real socket, the same technique the TypeScript test suite uses by stubbing
|
|
15
|
+
the global `fetch`.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json as json_module
|
|
20
|
+
import time
|
|
21
|
+
import urllib.error
|
|
22
|
+
import urllib.request
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Callable, Dict, Optional, Sequence
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
from .errors import AuthenticationError, RetryExhaustedError, SchemaDriftError
|
|
28
|
+
|
|
29
|
+
MAX_RETRIES = 3
|
|
30
|
+
BASE_DELAY_SECONDS = 0.5
|
|
31
|
+
REQUEST_TIMEOUT_SECONDS = 30.0
|
|
32
|
+
|
|
33
|
+
_SENSITIVE_HEADERS = ("Authorization", "x-api-key")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
37
|
+
"""Strips auth headers before following a redirect to a different host.
|
|
38
|
+
|
|
39
|
+
urllib's default HTTPRedirectHandler forwards every original request
|
|
40
|
+
header -- including Authorization/x-api-key -- to wherever a 3xx
|
|
41
|
+
Location points, even a different host. `requests` strips auth headers
|
|
42
|
+
on cross-origin redirects by default; bare urllib does not. Every
|
|
43
|
+
teamspend admin-API call carries a live vendor token, so a compromised
|
|
44
|
+
or misconfigured upstream (or a MITM in front of it) could otherwise
|
|
45
|
+
use a redirect to exfiltrate the token to an attacker-controlled host.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
49
|
+
new_request = super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
50
|
+
if new_request is None:
|
|
51
|
+
return None
|
|
52
|
+
if urlsplit(req.full_url).netloc != urlsplit(newurl).netloc:
|
|
53
|
+
for header in _SENSITIVE_HEADERS:
|
|
54
|
+
# Request.remove_header() does a raw dict .pop() with no case
|
|
55
|
+
# normalization, while add_header() stores keys via
|
|
56
|
+
# .capitalize() ("x-api-key" -> "X-api-key"). Passing the raw
|
|
57
|
+
# header name here silently no-ops for anything whose casing
|
|
58
|
+
# doesn't already match its capitalize()'d form.
|
|
59
|
+
new_request.remove_header(header.capitalize())
|
|
60
|
+
return new_request
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_opener = urllib.request.build_opener(_SameOriginRedirectHandler)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class HttpResponse:
|
|
68
|
+
status: int
|
|
69
|
+
body: bytes
|
|
70
|
+
|
|
71
|
+
def json(self) -> Any:
|
|
72
|
+
return json_module.loads(self.body.decode("utf-8"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TransportError(Exception):
|
|
76
|
+
"""Internal: network-level failure (timeout, DNS, connection refused).
|
|
77
|
+
|
|
78
|
+
Never raised to a caller of fetch_with_retry -- always converted into a
|
|
79
|
+
RetryExhaustedError("timeout") once retries are exhausted, matching the
|
|
80
|
+
TypeScript original's handling of a rejected fetch() promise.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
Transport = Callable[[str, Dict[str, str], float], HttpResponse]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def default_transport(url: str, headers: Dict[str, str], timeout: float) -> HttpResponse:
|
|
88
|
+
"""Real network transport. GET only -- every teamspend admin-API call is a GET."""
|
|
89
|
+
request = urllib.request.Request(url, headers=headers, method="GET")
|
|
90
|
+
try:
|
|
91
|
+
with _opener.open(request, timeout=timeout) as response: # noqa: S310 -- fixed https:// admin-API URLs built from constants, never from unsanitized input
|
|
92
|
+
return HttpResponse(status=response.status, body=response.read())
|
|
93
|
+
except urllib.error.HTTPError as error:
|
|
94
|
+
# HTTPError already carries the response body/status for non-2xx --
|
|
95
|
+
# treat it as a normal (non-2xx) HttpResponse so the retry/auth
|
|
96
|
+
# logic below can inspect the status code uniformly.
|
|
97
|
+
return HttpResponse(status=error.code, body=error.read())
|
|
98
|
+
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
|
99
|
+
raise TransportError(str(error)) from error
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_retryable(status: int) -> Optional[str]:
|
|
103
|
+
if status == 429:
|
|
104
|
+
return "rate-limit"
|
|
105
|
+
if 500 <= status < 600:
|
|
106
|
+
return "server-error"
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fetch_with_retry(
|
|
111
|
+
tool: str,
|
|
112
|
+
url: str,
|
|
113
|
+
auth_header: Dict[str, str],
|
|
114
|
+
*,
|
|
115
|
+
transport: Optional[Transport] = None,
|
|
116
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
117
|
+
response_type: str = "json",
|
|
118
|
+
empty_on: Sequence[int] = (),
|
|
119
|
+
) -> Any:
|
|
120
|
+
"""
|
|
121
|
+
Fetches `url` with `auth_header`, retrying 429/5xx/timeout up to
|
|
122
|
+
MAX_RETRIES times with exponential backoff. Returns the parsed JSON
|
|
123
|
+
body on success (or the raw text body if `response_type="text"` --
|
|
124
|
+
used by the Copilot adapter for its NDJSON report downloads, which
|
|
125
|
+
aren't valid single-document JSON). Raises AuthenticationError
|
|
126
|
+
immediately on 401/403 (never retried), RetryExhaustedError once the
|
|
127
|
+
retry budget is spent.
|
|
128
|
+
|
|
129
|
+
`empty_on` is a set of status codes that resolve to `None` instead of
|
|
130
|
+
raising -- used by the Copilot adapter to treat a 404 on a single day's
|
|
131
|
+
report (no activity that day) as "zero data," not a failure. Never
|
|
132
|
+
passed by Cursor/Claude Code, which have no such concept.
|
|
133
|
+
|
|
134
|
+
`transport` defaults to None and resolves to `default_transport` inside
|
|
135
|
+
the function body (rather than as the parameter's default value) on
|
|
136
|
+
purpose: a default value is bound once, at function-definition time, so
|
|
137
|
+
binding it directly to `default_transport` would freeze a stale
|
|
138
|
+
reference that a test monkeypatching `http_client.default_transport`
|
|
139
|
+
could never actually intercept.
|
|
140
|
+
"""
|
|
141
|
+
if transport is None:
|
|
142
|
+
transport = default_transport
|
|
143
|
+
last_failure_kind = "timeout"
|
|
144
|
+
|
|
145
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
146
|
+
try:
|
|
147
|
+
response = transport(url, auth_header, REQUEST_TIMEOUT_SECONDS)
|
|
148
|
+
except TransportError:
|
|
149
|
+
last_failure_kind = "timeout"
|
|
150
|
+
if attempt < MAX_RETRIES:
|
|
151
|
+
sleep(BASE_DELAY_SECONDS * (2**attempt))
|
|
152
|
+
continue
|
|
153
|
+
raise RetryExhaustedError(tool, last_failure_kind, attempt + 1)
|
|
154
|
+
|
|
155
|
+
if response.status in (401, 403):
|
|
156
|
+
raise AuthenticationError(
|
|
157
|
+
tool, f"TEAMSPEND_{tool.upper().replace('-', '_')}_TOKEN"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if response.status in empty_on:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
retry_kind = _is_retryable(response.status)
|
|
164
|
+
if retry_kind:
|
|
165
|
+
last_failure_kind = retry_kind
|
|
166
|
+
if attempt < MAX_RETRIES:
|
|
167
|
+
sleep(BASE_DELAY_SECONDS * (2**attempt))
|
|
168
|
+
continue
|
|
169
|
+
raise RetryExhaustedError(tool, retry_kind, attempt + 1)
|
|
170
|
+
|
|
171
|
+
if not (200 <= response.status < 300):
|
|
172
|
+
raise RuntimeError(f"{tool} returned unexpected status {response.status}")
|
|
173
|
+
|
|
174
|
+
if response_type == "text":
|
|
175
|
+
return response.body.decode("utf-8")
|
|
176
|
+
return response.json()
|
|
177
|
+
|
|
178
|
+
# Unreachable, but keeps type checkers satisfied about a return path.
|
|
179
|
+
raise RetryExhaustedError(tool, last_failure_kind, MAX_RETRIES + 1)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def require_field(
|
|
183
|
+
obj: Dict[str, Any],
|
|
184
|
+
field_name: str,
|
|
185
|
+
tool: str,
|
|
186
|
+
aliases: Sequence[str] = (),
|
|
187
|
+
) -> Any:
|
|
188
|
+
"""
|
|
189
|
+
Asserts a field exists on a parsed API response before it is read.
|
|
190
|
+
Raises SchemaDriftError rather than silently coercing/guessing when a
|
|
191
|
+
vendor's response shape changes without notice.
|
|
192
|
+
|
|
193
|
+
`aliases` is an optional, ordered list of legacy/alternate key names to
|
|
194
|
+
fall back to if the primary `field_name` is absent, guarding against a
|
|
195
|
+
vendor silently renaming a field. No adapter currently passes aliases --
|
|
196
|
+
Cursor and Anthropic have not renamed anything -- but the mechanism
|
|
197
|
+
exists so a future rename degrades gracefully instead of hard-failing.
|
|
198
|
+
"""
|
|
199
|
+
if field_name in obj and obj[field_name] is not None:
|
|
200
|
+
return obj[field_name]
|
|
201
|
+
for alias in aliases:
|
|
202
|
+
if alias in obj and obj[alias] is not None:
|
|
203
|
+
return obj[alias]
|
|
204
|
+
raise SchemaDriftError(tool, field_name, list(aliases))
|