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/output.py ADDED
@@ -0,0 +1,251 @@
1
+ """
2
+ Terminal summary rendering, JSON report writing, and .gitignore scaffolding.
3
+
4
+ Ported from src/output.ts. The JSON report re-serializes the snake_case
5
+ Python dataclasses into the same camelCase key shape the npm CLI's report
6
+ file uses, so a report from either distribution has the same on-disk shape.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import asdict
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from .adapters.csv_import import strip_control_chars
18
+ from .compare import ComparisonReport, PeriodOutcome
19
+ from .types import BreakdownMode, top_sessions
20
+
21
+ GITIGNORE_ENTRY = "teamspend-snapshot-*.json"
22
+ # How many sessions the terminal breakdown table shows per period.
23
+ SESSION_BREAKDOWN_LIMIT = 10
24
+
25
+
26
+ def scaffold_gitignore(cwd: str) -> bool:
27
+ """
28
+ Scaffolds a .gitignore entry for the report file in `cwd` if one doesn't
29
+ already exist, and returns whether the first-run spend-sensitivity
30
+ warning should be printed. The report contains per-user email + spend,
31
+ which is quasi-sensitive data that shouldn't land in a repo by accident
32
+ during a fast-moving migration.
33
+ """
34
+ gitignore_path = Path(cwd) / ".gitignore"
35
+ already_present = False
36
+
37
+ if gitignore_path.exists():
38
+ contents = gitignore_path.read_text(encoding="utf-8")
39
+ already_present = GITIGNORE_ENTRY in contents
40
+
41
+ if not already_present:
42
+ if gitignore_path.exists():
43
+ with gitignore_path.open("a", encoding="utf-8") as handle:
44
+ handle.write(f"\n{GITIGNORE_ENTRY}\n")
45
+ else:
46
+ gitignore_path.write_text(f"{GITIGNORE_ENTRY}\n", encoding="utf-8")
47
+ return True
48
+
49
+ return False
50
+
51
+
52
+ def _format_usd(amount: float) -> str:
53
+ return f"${amount:.2f}"
54
+
55
+
56
+ def _push_session_breakdown(lines: List[str], outcome: PeriodOutcome) -> None:
57
+ """
58
+ Appends a per-session cost breakdown for one period's outcome, or (when
59
+ --breakdown session was requested but this outcome's tool/adapter
60
+ doesn't produce session-level data) a clear explanation of why not.
61
+ Never silently shows nothing and never fabricates a breakdown for a
62
+ tool whose real data has no session concept.
63
+ """
64
+ if not outcome.result:
65
+ return # DATA UNAVAILABLE already covers this case.
66
+
67
+ all_sessions = [
68
+ session
69
+ for user in outcome.result.users
70
+ for session in (user.sessions or [])
71
+ ]
72
+ any_user_supports_sessions = any(
73
+ user.sessions is not None for user in outcome.result.users
74
+ )
75
+
76
+ if all_sessions:
77
+ shown = min(SESSION_BREAKDOWN_LIMIT, len(all_sessions))
78
+ lines.append(f" SESSION BREAKDOWN (top {shown} by cost):")
79
+ for i, session in enumerate(top_sessions(all_sessions, SESSION_BREAKDOWN_LIMIT)):
80
+ estimate_tag = " (estimated)" if session.is_estimated else ""
81
+ reqs = session.requests or 0
82
+ plural = "" if reqs == 1 else "s"
83
+ # session_id is local-log-sourced, same class of value as
84
+ # user_email below -- strip control chars so a crafted log
85
+ # entry can't inject terminal escape sequences via this print
86
+ # path either.
87
+ safe_session_id = strip_control_chars(session.session_id)
88
+ lines.append(
89
+ f" {i + 1}. {safe_session_id} "
90
+ f"{_format_usd(session.cost_usd)} {reqs} req{plural}{estimate_tag}"
91
+ )
92
+ elif any_user_supports_sessions:
93
+ lines.append(" SESSION BREAKDOWN: no session activity in this window.")
94
+ else:
95
+ lines.append(
96
+ f" SESSION BREAKDOWN: not available for {outcome.tool} -- this tool's data "
97
+ "source reports aggregate totals only, with no per-session/conversation "
98
+ "breakdown in its response shape. Session-level cost breakdown is only "
99
+ "available for claude-code-personal and opencode, which read local session "
100
+ "logs directly."
101
+ )
102
+ lines.append("")
103
+
104
+
105
+ def render_terminal_summary(
106
+ report: ComparisonReport, breakdown: Optional[BreakdownMode] = None
107
+ ) -> str:
108
+ lines = []
109
+ lines.append("teamspend snapshot -- migration cost comparison")
110
+ lines.append(f"Tools: {report.before.tool} -> {report.after.tool}")
111
+ lines.append("")
112
+
113
+ for outcome in (report.before, report.after):
114
+ label = outcome.label.upper()
115
+ lines.append(f"{label} ({outcome.tool})")
116
+ if outcome.result:
117
+ estimate_note = (
118
+ "estimated" if outcome.result.is_estimated else "exact, usage-based"
119
+ )
120
+ lines.append(
121
+ f" Total spend: {_format_usd(outcome.result.total_cost_usd)}"
122
+ f" ({estimate_note})"
123
+ )
124
+ lines.append(f" Active users: {len(outcome.result.users)}")
125
+ else:
126
+ message = str(outcome.error) if outcome.error else "unknown error"
127
+ lines.append(f" DATA UNAVAILABLE: {message}")
128
+ lines.append("")
129
+
130
+ if breakdown == "session":
131
+ _push_session_breakdown(lines, outcome)
132
+
133
+ if report.delta_usd is not None and report.delta_percent is not None:
134
+ sign = "+" if report.delta_usd >= 0 else "-"
135
+ lines.append(
136
+ f"DELTA: {sign}{_format_usd(abs(report.delta_usd))} "
137
+ f"({sign}{abs(report.delta_percent):.1f}%)"
138
+ )
139
+ else:
140
+ lines.append(
141
+ "DELTA: unavailable -- one or both periods failed to fetch, see above"
142
+ )
143
+
144
+ if report.top_spenders_across_both:
145
+ lines.append("")
146
+ lines.append("TOP SPENDERS (across both periods)")
147
+ for i, spender in enumerate(report.top_spenders_across_both):
148
+ # CSV-sourced emails are already stripped in csv_import.py; API-sourced
149
+ # emails weren't, leaving an inconsistent path to this same unsanitized
150
+ # terminal print -- strip here too so a compromised vendor response
151
+ # can't inject terminal escape sequences via user_email either.
152
+ email = strip_control_chars(spender.user_email) if spender.user_email else "(unknown)"
153
+ lines.append(
154
+ f" {i + 1}. {email} {spender.period} {_format_usd(spender.cost_usd)}"
155
+ )
156
+
157
+ return "\n".join(lines)
158
+
159
+
160
+ def _user_to_dict(u: Any) -> Dict[str, Any]:
161
+ payload: Dict[str, Any] = {
162
+ "userId": u.user_id,
163
+ "userEmail": u.user_email,
164
+ "inputTokens": u.input_tokens,
165
+ "outputTokens": u.output_tokens,
166
+ "cacheReadTokens": u.cache_read_tokens,
167
+ "cacheWriteTokens": u.cache_write_tokens,
168
+ "requests": u.requests,
169
+ "costUsd": u.cost_usd,
170
+ "isEstimated": u.is_estimated,
171
+ }
172
+ # Only present in the JSON report when the adapter actually produced
173
+ # session data AND the caller requested it -- matches the TypeScript
174
+ # port's stripSessionsUnlessRequested behavior in cli.py, which sets
175
+ # u.sessions back to None on every user when --breakdown session
176
+ # wasn't passed, so the default report shape is unchanged.
177
+ if u.sessions is not None:
178
+ payload["sessions"] = [
179
+ {
180
+ "sessionId": s.session_id,
181
+ "costUsd": s.cost_usd,
182
+ "inputTokens": s.input_tokens,
183
+ "outputTokens": s.output_tokens,
184
+ "requests": s.requests,
185
+ "isEstimated": s.is_estimated,
186
+ }
187
+ for s in u.sessions
188
+ ]
189
+ return payload
190
+
191
+
192
+ def _outcome_to_dict(outcome: PeriodOutcome) -> Dict[str, Any]:
193
+ return {
194
+ "label": outcome.label,
195
+ "tool": outcome.tool,
196
+ "result": (
197
+ {
198
+ "source": outcome.result.source,
199
+ "window": asdict(outcome.result.window),
200
+ "totalCostUsd": outcome.result.total_cost_usd,
201
+ "isEstimated": outcome.result.is_estimated,
202
+ "users": [_user_to_dict(u) for u in outcome.result.users],
203
+ }
204
+ if outcome.result
205
+ else None
206
+ ),
207
+ "error": {"message": str(outcome.error)} if outcome.error else None,
208
+ }
209
+
210
+
211
+ def report_to_json_dict(report: ComparisonReport) -> Dict[str, Any]:
212
+ return {
213
+ "before": _outcome_to_dict(report.before),
214
+ "after": _outcome_to_dict(report.after),
215
+ "deltaUsd": report.delta_usd,
216
+ "deltaPercent": report.delta_percent,
217
+ "topSpendersAcrossBoth": [
218
+ {
219
+ "period": entry.period,
220
+ "userEmail": entry.user_email,
221
+ "costUsd": entry.cost_usd,
222
+ }
223
+ for entry in report.top_spenders_across_both
224
+ ],
225
+ }
226
+
227
+
228
+ def write_json_report(report: ComparisonReport, cwd: str) -> str:
229
+ """
230
+ Always writes the JSON report file, regardless of --json. --json only
231
+ changes what prints to the terminal.
232
+ """
233
+ timestamp = (
234
+ datetime.now(timezone.utc)
235
+ .isoformat(timespec="milliseconds")
236
+ .replace("+00:00", "")
237
+ )
238
+ safe_timestamp = timestamp.replace(":", "").replace(".", "")[:15]
239
+ path = str(Path(cwd) / f"teamspend-snapshot-{safe_timestamp}.json")
240
+
241
+ payload = json.dumps(report_to_json_dict(report), indent=2)
242
+
243
+ # mode 0o600 restricts the file to owner read/write only. Without it,
244
+ # the default (masked by the process umask, typically 0o644) leaves
245
+ # per-user email + spend readable by any other local user on a shared
246
+ # host. os.open with O_CREAT + explicit mode avoids the umask-then-chmod
247
+ # race a separate os.chmod() call after write would have.
248
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
249
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
250
+ handle.write(payload)
251
+ return path
teamspend/py.typed ADDED
File without changes
teamspend/types.py ADDED
@@ -0,0 +1,111 @@
1
+ """
2
+ Shared data types for teamspend's fetch/compare pipeline.
3
+
4
+ Ported from src/schema.ts. Field names follow Python (snake_case)
5
+ convention; the JSON report written by teamspend.output re-serializes to
6
+ the same camelCase keys the npm package's report file uses, so a report
7
+ produced by either distribution has the same on-disk shape even though the
8
+ in-process Python objects use snake_case attributes (same convention this
9
+ account's other Python ports, e.g. skillguard-cli, already use).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import List, Literal, Optional
15
+
16
+ ToolId = Literal[
17
+ "cursor",
18
+ "claude-code",
19
+ "copilot",
20
+ "opencode",
21
+ "claude-code-personal",
22
+ "codex",
23
+ ]
24
+ """
25
+ Tools teamspend can pull spend data from. `claude-code-personal` is not a
26
+ separate vendor -- it's a credential-free local-file read mode for Claude
27
+ Code's own JSONL session logs, for someone who wants their personal usage
28
+ without org-admin API access (see
29
+ teamspend.adapters.claude_code_personal). `codex` (OpenAI's Codex CLI) is
30
+ the same shape as `opencode` -- no admin/team API, only local per-machine
31
+ session logs (see teamspend.adapters.codex).
32
+ """
33
+
34
+
35
+ @dataclass
36
+ class DateWindow:
37
+ start: str
38
+ """YYYY-MM-DD"""
39
+ end: str
40
+ """YYYY-MM-DD"""
41
+
42
+
43
+ @dataclass
44
+ class SessionUsage:
45
+ """
46
+ Cost attributed to a single session/conversation -- a bounded unit of
47
+ one interaction, and the most honest proxy teamspend can offer for
48
+ "cost per task." This is NOT a measure of task success, quality, or
49
+ ROI: no vendor exposes whether a session's output was actually good,
50
+ so teamspend never claims to know that. It only ever reports what a
51
+ session cost.
52
+
53
+ Ported from src/schema.ts's SessionUsage interface.
54
+ """
55
+
56
+ session_id: str
57
+ cost_usd: float
58
+ input_tokens: Optional[int]
59
+ output_tokens: Optional[int]
60
+ requests: Optional[int]
61
+ is_estimated: bool
62
+
63
+
64
+ @dataclass
65
+ class UserUsage:
66
+ user_id: str
67
+ user_email: Optional[str]
68
+ input_tokens: Optional[int]
69
+ output_tokens: Optional[int]
70
+ cache_read_tokens: Optional[int]
71
+ cache_write_tokens: Optional[int]
72
+ requests: Optional[int]
73
+ cost_usd: float
74
+ is_estimated: bool
75
+ # Per-session (per-conversation) cost breakdown, populated only by
76
+ # adapters whose underlying data source actually exposes a session
77
+ # identifier -- local-log-based adapters (claude_code_personal,
78
+ # opencode), which parse a real sessionId/sessionID out of each log
79
+ # entry. Admin-API-based adapters (cursor, claude_code, copilot) report
80
+ # aggregate per-user totals only, with no session concept anywhere in
81
+ # their response shape, so they leave this field None rather than
82
+ # fabricating session boundaries that don't exist. None by default so
83
+ # every adapter that predates this field keeps working unchanged.
84
+ sessions: Optional[List[SessionUsage]] = None
85
+
86
+
87
+ @dataclass
88
+ class AdapterResult:
89
+ """Normalized shape every adapter (and the CSV-import fallback) maps into."""
90
+
91
+ source: ToolId
92
+ window: DateWindow
93
+ total_cost_usd: float
94
+ is_estimated: bool
95
+ users: List[UserUsage] = field(default_factory=list)
96
+
97
+
98
+ BreakdownMode = Literal["session"]
99
+ """Supported values for the CLI's `--breakdown` flag."""
100
+
101
+
102
+ def sum_cost(users: List[UserUsage]) -> float:
103
+ return sum(user.cost_usd for user in users)
104
+
105
+
106
+ def top_spenders(users: List[UserUsage], limit: int) -> List[UserUsage]:
107
+ return sorted(users, key=lambda u: u.cost_usd, reverse=True)[:limit]
108
+
109
+
110
+ def top_sessions(sessions: List[SessionUsage], limit: int) -> List[SessionUsage]:
111
+ return sorted(sessions, key=lambda s: s.cost_usd, reverse=True)[:limit]