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/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."""
@@ -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))