cctally 1.22.2 → 1.22.4
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.
- package/CHANGELOG.md +21 -0
- package/bin/_cctally_cache_report.py +1133 -880
- package/bin/_cctally_codex.py +518 -0
- package/bin/_cctally_dashboard.py +3 -3
- package/bin/_cctally_diff.py +240 -0
- package/bin/_cctally_doctor.py +479 -0
- package/bin/_cctally_five_hour.py +1688 -0
- package/bin/_cctally_forecast.py +1979 -0
- package/bin/_cctally_milestones.py +433 -0
- package/bin/_cctally_percent_breakdown.py +199 -0
- package/bin/_cctally_pricing_check.py +393 -0
- package/bin/_cctally_record.py +8 -5
- package/bin/_cctally_reporting.py +749 -0
- package/bin/_cctally_setup.py +172 -13
- package/bin/_cctally_statusline.py +630 -0
- package/bin/_cctally_sync_week.py +5 -4
- package/bin/_cctally_weekrefs.py +484 -0
- package/bin/_lib_cache_report.py +938 -0
- package/bin/_lib_diff_kernel.py +5 -8
- package/bin/_lib_fmt.py +325 -0
- package/bin/_lib_pricing_debug.py +182 -0
- package/bin/_lib_render.py +9 -24
- package/bin/_lib_subscription_weeks.py +2 -2
- package/bin/cctally +466 -9190
- package/package.json +15 -1
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Weekly cost-snapshot persistence + percent/budget milestone DB layer.
|
|
2
|
+
|
|
3
|
+
Eager I/O sibling: bin/cctally loads this at startup and re-exports all 11
|
|
4
|
+
symbols onto the cctally namespace. Consumers reach them via the call-time
|
|
5
|
+
``c = _cctally()`` accessor (forecast/config/dashboard/sync_week/view_models),
|
|
6
|
+
``sys.modules["cctally"]`` shims (record/tui), and ``ns[...]`` in tests.
|
|
7
|
+
|
|
8
|
+
Holds (11): WeekCostResult, compute_week_cost, get_latest_cost_for_week,
|
|
9
|
+
insert_cost_snapshot, get_max_milestone_for_week, get_milestone_cost_for_week,
|
|
10
|
+
get_milestones_for_week, insert_percent_milestone, insert_budget_milestone,
|
|
11
|
+
_reconcile_budget_milestones_on_set, _reconcile_budget_on_config_write.
|
|
12
|
+
|
|
13
|
+
Accessor discipline (spec §2): _cctally_core kernel symbols + _budget_alerts_active
|
|
14
|
+
are honest-imported; _sum_cost_for_range / _resolve_current_budget_window are
|
|
15
|
+
reached via the call-time _cctally() accessor (ns-patchable). No _lib_ kernel.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import sqlite3
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
from _cctally_core import (
|
|
25
|
+
_budget_alerts_active,
|
|
26
|
+
_canonicalize_optional_iso,
|
|
27
|
+
_command_as_of,
|
|
28
|
+
_get_latest_row_for_week,
|
|
29
|
+
eprint,
|
|
30
|
+
format_local_iso,
|
|
31
|
+
now_utc_iso,
|
|
32
|
+
open_db,
|
|
33
|
+
parse_iso_datetime,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cctally():
|
|
38
|
+
"""Call-time accessor to the cctally module namespace (ns-patchable)."""
|
|
39
|
+
return sys.modules["cctally"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class WeekCostResult:
|
|
44
|
+
week_start: dt.date
|
|
45
|
+
week_end: dt.date
|
|
46
|
+
start_iso: str
|
|
47
|
+
end_iso: str
|
|
48
|
+
cost_usd: float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compute_week_cost(
|
|
52
|
+
week_start: dt.date,
|
|
53
|
+
week_end: dt.date,
|
|
54
|
+
mode: str,
|
|
55
|
+
offline: bool,
|
|
56
|
+
project: str | None,
|
|
57
|
+
start_iso_override: str | None = None,
|
|
58
|
+
end_iso_override: str | None = None,
|
|
59
|
+
) -> WeekCostResult:
|
|
60
|
+
# internal fallback: host-local intentional
|
|
61
|
+
now_local = dt.datetime.now().astimezone()
|
|
62
|
+
start_dt_override = (
|
|
63
|
+
parse_iso_datetime(start_iso_override, "weekStartAt")
|
|
64
|
+
if start_iso_override
|
|
65
|
+
else None
|
|
66
|
+
)
|
|
67
|
+
end_dt_override = (
|
|
68
|
+
parse_iso_datetime(end_iso_override, "weekEndAt")
|
|
69
|
+
if end_iso_override
|
|
70
|
+
else None
|
|
71
|
+
)
|
|
72
|
+
if start_dt_override is not None and end_dt_override is not None:
|
|
73
|
+
if end_dt_override <= start_dt_override:
|
|
74
|
+
raise ValueError("weekEndAt must be after weekStartAt")
|
|
75
|
+
|
|
76
|
+
if start_dt_override is not None:
|
|
77
|
+
start_iso = start_dt_override.isoformat(timespec="seconds")
|
|
78
|
+
else:
|
|
79
|
+
start_iso = format_local_iso(week_start, end_of_day=False)
|
|
80
|
+
|
|
81
|
+
if end_dt_override is not None:
|
|
82
|
+
in_current_window = (
|
|
83
|
+
start_dt_override is not None
|
|
84
|
+
and start_dt_override <= now_local < end_dt_override
|
|
85
|
+
)
|
|
86
|
+
end_iso = (
|
|
87
|
+
now_local.isoformat(timespec="seconds")
|
|
88
|
+
if in_current_window
|
|
89
|
+
else end_dt_override.isoformat(timespec="seconds")
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
is_current_week = week_start <= now_local.date() <= week_end
|
|
93
|
+
end_iso = (
|
|
94
|
+
now_local.isoformat(timespec="seconds")
|
|
95
|
+
if is_current_week
|
|
96
|
+
else format_local_iso(week_end, end_of_day=True)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
start_dt = parse_iso_datetime(start_iso, "start")
|
|
100
|
+
end_dt = parse_iso_datetime(end_iso, "end")
|
|
101
|
+
|
|
102
|
+
c = _cctally()
|
|
103
|
+
cost = c._sum_cost_for_range(start_dt, end_dt, mode=mode, project=project)
|
|
104
|
+
|
|
105
|
+
return WeekCostResult(
|
|
106
|
+
week_start=week_start,
|
|
107
|
+
week_end=week_end,
|
|
108
|
+
start_iso=start_iso,
|
|
109
|
+
end_iso=end_iso,
|
|
110
|
+
cost_usd=cost,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_latest_cost_for_week(conn: sqlite3.Connection, week_ref: WeekRef) -> sqlite3.Row | None:
|
|
115
|
+
return _get_latest_row_for_week(conn, "weekly_cost_snapshots", week_ref)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def insert_cost_snapshot(
|
|
119
|
+
conn: sqlite3.Connection,
|
|
120
|
+
week_start: dt.date,
|
|
121
|
+
week_end: dt.date,
|
|
122
|
+
week_start_at: str | None,
|
|
123
|
+
week_end_at: str | None,
|
|
124
|
+
range_start_iso: str,
|
|
125
|
+
range_end_iso: str,
|
|
126
|
+
cost_usd: float,
|
|
127
|
+
mode: str,
|
|
128
|
+
project: str | None,
|
|
129
|
+
) -> int:
|
|
130
|
+
start_at = _canonicalize_optional_iso(week_start_at, "weekStartAt")
|
|
131
|
+
end_at = _canonicalize_optional_iso(week_end_at, "weekEndAt")
|
|
132
|
+
range_start = parse_iso_datetime(range_start_iso, "rangeStartIso").isoformat(timespec="seconds")
|
|
133
|
+
range_end = parse_iso_datetime(range_end_iso, "rangeEndIso").isoformat(timespec="seconds")
|
|
134
|
+
cur = conn.execute(
|
|
135
|
+
"""
|
|
136
|
+
INSERT INTO weekly_cost_snapshots
|
|
137
|
+
(
|
|
138
|
+
captured_at_utc,
|
|
139
|
+
week_start_date,
|
|
140
|
+
week_end_date,
|
|
141
|
+
week_start_at,
|
|
142
|
+
week_end_at,
|
|
143
|
+
range_start_iso,
|
|
144
|
+
range_end_iso,
|
|
145
|
+
cost_usd,
|
|
146
|
+
mode,
|
|
147
|
+
project
|
|
148
|
+
)
|
|
149
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
150
|
+
""",
|
|
151
|
+
(
|
|
152
|
+
now_utc_iso(),
|
|
153
|
+
week_start.isoformat(),
|
|
154
|
+
week_end.isoformat(),
|
|
155
|
+
start_at,
|
|
156
|
+
end_at,
|
|
157
|
+
range_start,
|
|
158
|
+
range_end,
|
|
159
|
+
cost_usd,
|
|
160
|
+
mode,
|
|
161
|
+
project,
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
conn.commit()
|
|
165
|
+
return int(cur.lastrowid)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_max_milestone_for_week(
|
|
169
|
+
conn: sqlite3.Connection,
|
|
170
|
+
week_start_date: str,
|
|
171
|
+
*,
|
|
172
|
+
reset_event_id: int = 0,
|
|
173
|
+
) -> int | None:
|
|
174
|
+
"""Return the highest percent_threshold recorded for a week's segment,
|
|
175
|
+
or None.
|
|
176
|
+
|
|
177
|
+
``reset_event_id`` (v1.7.2): default 0 (= pre-credit / no-event
|
|
178
|
+
sentinel) preserves legacy behavior on un-credited weeks. When an
|
|
179
|
+
in-place credit lifts a week into a new segment, callers pass the
|
|
180
|
+
segment id so the segment's threshold ledger is independent of the
|
|
181
|
+
pre-credit one — the post-credit 1% / 2% / 3% milestones fire even
|
|
182
|
+
if the pre-credit segment already crossed those thresholds.
|
|
183
|
+
"""
|
|
184
|
+
row = conn.execute(
|
|
185
|
+
"""
|
|
186
|
+
SELECT MAX(percent_threshold) AS max_pct
|
|
187
|
+
FROM percent_milestones
|
|
188
|
+
WHERE week_start_date = ?
|
|
189
|
+
AND reset_event_id = ?
|
|
190
|
+
""",
|
|
191
|
+
(week_start_date, reset_event_id),
|
|
192
|
+
).fetchone()
|
|
193
|
+
if row and row["max_pct"] is not None:
|
|
194
|
+
return int(row["max_pct"])
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_milestone_cost_for_week(
|
|
199
|
+
conn: sqlite3.Connection,
|
|
200
|
+
week_start_date: str,
|
|
201
|
+
percent_threshold: int,
|
|
202
|
+
*,
|
|
203
|
+
reset_event_id: int = 0,
|
|
204
|
+
) -> float | None:
|
|
205
|
+
"""Return the cumulative_cost_usd for a specific (week, threshold,
|
|
206
|
+
segment), or None.
|
|
207
|
+
|
|
208
|
+
``reset_event_id`` (v1.7.2): segment-aware lookup. Default 0 preserves
|
|
209
|
+
legacy behavior. Used by ``maybe_record_milestone`` to compute the
|
|
210
|
+
marginal cost between consecutive thresholds inside the SAME segment
|
|
211
|
+
— without the filter, the post-credit threshold-3 row would compute
|
|
212
|
+
its marginal against the pre-credit threshold-2 cost (wrong segment).
|
|
213
|
+
"""
|
|
214
|
+
row = conn.execute(
|
|
215
|
+
"""
|
|
216
|
+
SELECT cumulative_cost_usd
|
|
217
|
+
FROM percent_milestones
|
|
218
|
+
WHERE week_start_date = ?
|
|
219
|
+
AND percent_threshold = ?
|
|
220
|
+
AND reset_event_id = ?
|
|
221
|
+
""",
|
|
222
|
+
(week_start_date, percent_threshold, reset_event_id),
|
|
223
|
+
).fetchone()
|
|
224
|
+
if row:
|
|
225
|
+
return float(row["cumulative_cost_usd"])
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_milestones_for_week(
|
|
230
|
+
conn: sqlite3.Connection,
|
|
231
|
+
week_start_date: str,
|
|
232
|
+
) -> list[sqlite3.Row]:
|
|
233
|
+
"""Return all milestones for a week, ordered by threshold ascending."""
|
|
234
|
+
return conn.execute(
|
|
235
|
+
"""
|
|
236
|
+
SELECT *
|
|
237
|
+
FROM percent_milestones
|
|
238
|
+
WHERE week_start_date = ?
|
|
239
|
+
ORDER BY percent_threshold ASC
|
|
240
|
+
""",
|
|
241
|
+
(week_start_date,),
|
|
242
|
+
).fetchall()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def insert_percent_milestone(
|
|
246
|
+
conn: sqlite3.Connection,
|
|
247
|
+
week_start_date: str,
|
|
248
|
+
week_end_date: str,
|
|
249
|
+
week_start_at: str | None,
|
|
250
|
+
week_end_at: str | None,
|
|
251
|
+
percent_threshold: int,
|
|
252
|
+
cumulative_cost_usd: float,
|
|
253
|
+
marginal_cost_usd: float | None,
|
|
254
|
+
usage_snapshot_id: int,
|
|
255
|
+
cost_snapshot_id: int,
|
|
256
|
+
five_hour_percent_at_crossing: float | None = None,
|
|
257
|
+
*,
|
|
258
|
+
commit: bool = True,
|
|
259
|
+
reset_event_id: int = 0,
|
|
260
|
+
) -> int:
|
|
261
|
+
"""Insert a percent_milestones row idempotently.
|
|
262
|
+
|
|
263
|
+
Returns the SQLite rowcount: 1 on a genuinely new crossing, 0 if a row
|
|
264
|
+
for (week_start_date, percent_threshold, reset_event_id) already exists.
|
|
265
|
+
Race-safe under concurrent record-usage instances — aligns with the
|
|
266
|
+
existing 5h-milestone INSERT OR IGNORE pattern (see five_hour_milestones
|
|
267
|
+
write path).
|
|
268
|
+
|
|
269
|
+
``reset_event_id`` (v1.7.2 segment column, migration 005): defaults to
|
|
270
|
+
``0`` (= pre-credit / no-event sentinel). When an in-place credit fires
|
|
271
|
+
for the current week, the caller (``maybe_record_milestone``) resolves
|
|
272
|
+
the active segment from ``week_reset_events`` and passes it in so
|
|
273
|
+
post-credit threshold crossings land as a SEPARATE row from any
|
|
274
|
+
pre-credit one at the same (week, threshold). The UNIQUE constraint
|
|
275
|
+
is on the 3-tuple, so (week=W, threshold=T, segment=0) and (W, T,
|
|
276
|
+
event_id) coexist.
|
|
277
|
+
|
|
278
|
+
Callers that need the row id MUST follow up with an explicit
|
|
279
|
+
`SELECT id FROM percent_milestones WHERE week_start_date=? AND
|
|
280
|
+
percent_threshold=? AND reset_event_id=?` query — `lastrowid` is
|
|
281
|
+
unreliable when the row is the silent-duplicate target.
|
|
282
|
+
|
|
283
|
+
``commit=False`` skips the inner ``conn.commit()`` so the caller can
|
|
284
|
+
bundle the INSERT with a follow-up ``alerted_at`` UPDATE in a single
|
|
285
|
+
transaction (set-then-dispatch atomicity, spec §3.2). Used by
|
|
286
|
+
``record_percent_milestone_if_crossed`` to mirror the 5h path's
|
|
287
|
+
single-commit pattern; without it, a crash between INSERT and UPDATE
|
|
288
|
+
permanently strands ``alerted_at`` NULL because the next call's
|
|
289
|
+
``INSERT OR IGNORE`` returns rowcount==0 and the dispatch guard
|
|
290
|
+
``if inserted == 1`` skips re-firing.
|
|
291
|
+
"""
|
|
292
|
+
cur = conn.execute(
|
|
293
|
+
"""
|
|
294
|
+
INSERT OR IGNORE INTO percent_milestones
|
|
295
|
+
(
|
|
296
|
+
captured_at_utc,
|
|
297
|
+
week_start_date,
|
|
298
|
+
week_end_date,
|
|
299
|
+
week_start_at,
|
|
300
|
+
week_end_at,
|
|
301
|
+
percent_threshold,
|
|
302
|
+
cumulative_cost_usd,
|
|
303
|
+
marginal_cost_usd,
|
|
304
|
+
usage_snapshot_id,
|
|
305
|
+
cost_snapshot_id,
|
|
306
|
+
five_hour_percent_at_crossing,
|
|
307
|
+
reset_event_id
|
|
308
|
+
)
|
|
309
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
310
|
+
""",
|
|
311
|
+
(
|
|
312
|
+
now_utc_iso(),
|
|
313
|
+
week_start_date,
|
|
314
|
+
week_end_date,
|
|
315
|
+
week_start_at,
|
|
316
|
+
week_end_at,
|
|
317
|
+
percent_threshold,
|
|
318
|
+
cumulative_cost_usd,
|
|
319
|
+
marginal_cost_usd,
|
|
320
|
+
usage_snapshot_id,
|
|
321
|
+
cost_snapshot_id,
|
|
322
|
+
five_hour_percent_at_crossing,
|
|
323
|
+
reset_event_id,
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
if commit:
|
|
327
|
+
conn.commit()
|
|
328
|
+
return int(cur.rowcount)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def insert_budget_milestone(
|
|
332
|
+
conn: sqlite3.Connection,
|
|
333
|
+
*,
|
|
334
|
+
week_start_at: str,
|
|
335
|
+
threshold: int,
|
|
336
|
+
budget_usd: float,
|
|
337
|
+
spent_usd: float,
|
|
338
|
+
consumption_pct: float,
|
|
339
|
+
commit: bool = True,
|
|
340
|
+
) -> int:
|
|
341
|
+
"""INSERT OR IGNORE a budget threshold crossing. Returns ``cur.rowcount``
|
|
342
|
+
(1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
|
|
343
|
+
``(week_start_at, threshold)`` row).
|
|
344
|
+
|
|
345
|
+
Mirrors :func:`insert_percent_milestone`'s rowcount contract so the
|
|
346
|
+
alert-fire predicate (`if inserted == 1`) is race-safe without a
|
|
347
|
+
follow-up SELECT. ``alerted_at`` is left NULL — the caller stamps it in
|
|
348
|
+
the SAME transaction BEFORE dispatching (set-then-dispatch invariant,
|
|
349
|
+
CLAUDE.md Alerts gotcha). ``commit=False`` lets the caller bundle the
|
|
350
|
+
INSERT with the follow-up ``alerted_at`` UPDATE in one transaction so a
|
|
351
|
+
crash between them can't strand ``alerted_at`` NULL forever.
|
|
352
|
+
"""
|
|
353
|
+
cur = conn.execute(
|
|
354
|
+
"INSERT OR IGNORE INTO budget_milestones "
|
|
355
|
+
"(week_start_at, threshold, budget_usd, spent_usd, consumption_pct, "
|
|
356
|
+
" crossed_at_utc) "
|
|
357
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
358
|
+
(
|
|
359
|
+
week_start_at,
|
|
360
|
+
int(threshold),
|
|
361
|
+
float(budget_usd),
|
|
362
|
+
float(spent_usd),
|
|
363
|
+
float(consumption_pct),
|
|
364
|
+
now_utc_iso(),
|
|
365
|
+
),
|
|
366
|
+
)
|
|
367
|
+
if commit:
|
|
368
|
+
conn.commit()
|
|
369
|
+
return int(cur.rowcount)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _reconcile_budget_milestones_on_set(conn, *, target, thresholds, now_utc):
|
|
373
|
+
"""Forward-only-from-set reconcile (spec §5): on `budget set`, every
|
|
374
|
+
threshold ALREADY crossed for the current week is recorded with
|
|
375
|
+
``alerted_at`` SET but WITHOUT dispatch — so setting a budget when you're
|
|
376
|
+
already at 95% does NOT instant-popup. Thresholds not yet crossed get NO
|
|
377
|
+
row, so they fire later via :func:`maybe_record_budget_milestone`.
|
|
378
|
+
|
|
379
|
+
A mid-week target change re-runs this; thresholds already alerted stay
|
|
380
|
+
deduped via UNIQUE(week_start_at, threshold) + the ``alerted_at IS NULL``
|
|
381
|
+
guard on the UPDATE (so an existing alerted row is never re-stamped).
|
|
382
|
+
"""
|
|
383
|
+
c = _cctally()
|
|
384
|
+
window = c._resolve_current_budget_window(conn, now_utc)
|
|
385
|
+
if window is None:
|
|
386
|
+
return
|
|
387
|
+
week_start_at, _week_end_at = window
|
|
388
|
+
week_key = week_start_at.isoformat(timespec="seconds")
|
|
389
|
+
spent = c._sum_cost_for_range(week_start_at, now_utc, mode="auto")
|
|
390
|
+
# target > 0 guaranteed by the caller (_cmd_budget_set passes the validated
|
|
391
|
+
# weekly_usd); the else is belt-and-suspenders.
|
|
392
|
+
consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
|
|
393
|
+
for t in sorted(thresholds):
|
|
394
|
+
if consumption_pct + 1e-9 >= t:
|
|
395
|
+
insert_budget_milestone(
|
|
396
|
+
conn,
|
|
397
|
+
week_start_at=week_key,
|
|
398
|
+
threshold=t,
|
|
399
|
+
budget_usd=target,
|
|
400
|
+
spent_usd=spent,
|
|
401
|
+
consumption_pct=consumption_pct,
|
|
402
|
+
commit=False,
|
|
403
|
+
)
|
|
404
|
+
conn.execute(
|
|
405
|
+
"UPDATE budget_milestones SET alerted_at = ? "
|
|
406
|
+
"WHERE week_start_at = ? AND threshold = ? AND alerted_at IS NULL",
|
|
407
|
+
(now_utc_iso(), week_key, t),
|
|
408
|
+
)
|
|
409
|
+
conn.commit()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _reconcile_budget_on_config_write(validated_budget):
|
|
413
|
+
"""Forward-only reconcile shared by all three budget-config write
|
|
414
|
+
paths (`budget set`, `config set budget.*`, dashboard POST
|
|
415
|
+
/api/settings). Gated + best-effort: a budget with alerts off or no
|
|
416
|
+
thresholds records nothing; a stats.db failure never fails the write.
|
|
417
|
+
Runs OUTSIDE any config_writer_lock (open_db has its own locking)."""
|
|
418
|
+
thresholds = validated_budget.get("alert_thresholds") or []
|
|
419
|
+
if not (_budget_alerts_active(validated_budget) and thresholds):
|
|
420
|
+
return
|
|
421
|
+
try:
|
|
422
|
+
conn = open_db()
|
|
423
|
+
try:
|
|
424
|
+
_reconcile_budget_milestones_on_set(
|
|
425
|
+
conn,
|
|
426
|
+
target=validated_budget["weekly_usd"],
|
|
427
|
+
thresholds=thresholds,
|
|
428
|
+
now_utc=_command_as_of(),
|
|
429
|
+
)
|
|
430
|
+
finally:
|
|
431
|
+
conn.close()
|
|
432
|
+
except Exception as exc: # best-effort; never fail the write
|
|
433
|
+
eprint(f"[budget-milestone] reconcile on set failed: {exc}")
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""percent-breakdown command handler (cmd_percent_breakdown).
|
|
2
|
+
|
|
3
|
+
Eager I/O sibling: bin/cctally loads this at startup and re-exports
|
|
4
|
+
cmd_percent_breakdown onto the cctally namespace (parser dispatch in
|
|
5
|
+
_cctally_parser.py: pb.set_defaults(func=c.cmd_percent_breakdown)).
|
|
6
|
+
|
|
7
|
+
Accessor discipline (spec §2): _cctally_core kernel symbols are honest-imported;
|
|
8
|
+
everything else — load_config, resolve_display_tz, _format_ts_compact,
|
|
9
|
+
_boxed_table, _get_canonical_boundary_for_date, _apply_reset_events_to_weekrefs,
|
|
10
|
+
get_milestones_for_week (a C2 symbol, reached on the ns) — via the call-time
|
|
11
|
+
_cctally() accessor. No _lib_ kernel.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from _cctally_core import (
|
|
21
|
+
_canonicalize_optional_iso,
|
|
22
|
+
compute_week_bounds,
|
|
23
|
+
get_week_start_name,
|
|
24
|
+
make_week_ref,
|
|
25
|
+
now_utc_iso,
|
|
26
|
+
open_db,
|
|
27
|
+
parse_date_str,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cctally():
|
|
32
|
+
"""Call-time accessor to the cctally module namespace (ns-patchable)."""
|
|
33
|
+
return sys.modules["cctally"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_percent_breakdown(args: argparse.Namespace) -> int:
|
|
37
|
+
c = _cctally()
|
|
38
|
+
config = c.load_config()
|
|
39
|
+
tz = c.resolve_display_tz(args, config)
|
|
40
|
+
args._resolved_tz = tz
|
|
41
|
+
week_start_name = get_week_start_name(config, args.week_start_name)
|
|
42
|
+
|
|
43
|
+
conn = open_db()
|
|
44
|
+
try:
|
|
45
|
+
if args.week_start:
|
|
46
|
+
week_start = parse_date_str(args.week_start, "--week-start")
|
|
47
|
+
else:
|
|
48
|
+
latest_usage = conn.execute(
|
|
49
|
+
"""
|
|
50
|
+
SELECT week_start_date
|
|
51
|
+
FROM weekly_usage_snapshots
|
|
52
|
+
ORDER BY captured_at_utc DESC, id DESC
|
|
53
|
+
LIMIT 1
|
|
54
|
+
"""
|
|
55
|
+
).fetchone()
|
|
56
|
+
if latest_usage is not None:
|
|
57
|
+
week_start = dt.date.fromisoformat(latest_usage["week_start_date"])
|
|
58
|
+
else:
|
|
59
|
+
# internal fallback: host-local intentional
|
|
60
|
+
now_local = dt.datetime.now().astimezone()
|
|
61
|
+
week_start, _ = compute_week_bounds(now_local, week_start_name)
|
|
62
|
+
|
|
63
|
+
week_start_date = week_start.isoformat()
|
|
64
|
+
|
|
65
|
+
# Get week_end from any snapshot for this week
|
|
66
|
+
end_row = conn.execute(
|
|
67
|
+
"""
|
|
68
|
+
SELECT MAX(week_end_date) AS week_end_date
|
|
69
|
+
FROM (
|
|
70
|
+
SELECT week_end_date FROM weekly_usage_snapshots WHERE week_start_date = ?
|
|
71
|
+
UNION ALL
|
|
72
|
+
SELECT week_end_date FROM weekly_cost_snapshots WHERE week_start_date = ?
|
|
73
|
+
UNION ALL
|
|
74
|
+
SELECT week_end_date FROM percent_milestones WHERE week_start_date = ?
|
|
75
|
+
)
|
|
76
|
+
""",
|
|
77
|
+
(week_start_date, week_start_date, week_start_date),
|
|
78
|
+
).fetchone()
|
|
79
|
+
week_end_date = end_row["week_end_date"] if end_row and end_row["week_end_date"] else (
|
|
80
|
+
(week_start + dt.timedelta(days=6)).isoformat()
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Apply reset-event boundary rewrites (same path get_recent_weeks
|
|
84
|
+
# uses) so the display header shows the effective window — e.g.
|
|
85
|
+
# a post-reset short week shows "2026-04-23..2026-04-25" rather
|
|
86
|
+
# than the backdated API-derived "2026-04-18..2026-04-25".
|
|
87
|
+
canon_start, canon_end = c._get_canonical_boundary_for_date(conn, week_start_date)
|
|
88
|
+
display_start_iso = canon_start
|
|
89
|
+
display_end_iso = canon_end
|
|
90
|
+
if canon_start and canon_end:
|
|
91
|
+
try:
|
|
92
|
+
base_ref = make_week_ref(
|
|
93
|
+
week_start_date=week_start_date,
|
|
94
|
+
week_end_date=week_end_date,
|
|
95
|
+
week_start_at=canon_start,
|
|
96
|
+
week_end_at=canon_end,
|
|
97
|
+
)
|
|
98
|
+
adjusted = c._apply_reset_events_to_weekrefs(conn, [base_ref])
|
|
99
|
+
if adjusted:
|
|
100
|
+
display_start_iso = adjusted[0].week_start_at
|
|
101
|
+
display_end_iso = adjusted[0].week_end_at
|
|
102
|
+
except ValueError:
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
# v1.7.2 segment filter: when a week_reset_events row exists for
|
|
106
|
+
# the current ``week_end_at``, narrow the milestone listing to
|
|
107
|
+
# the active (latest) segment so a credited week's header (which
|
|
108
|
+
# already reflects the post-credit window via the canon-boundary
|
|
109
|
+
# rewrite above) is coherent with the body. Sentinel ``0`` covers
|
|
110
|
+
# pre-credit / no-event weeks; pre-005 DBs that didn't have the
|
|
111
|
+
# column also default to 0 via the migration's ALTER DEFAULT.
|
|
112
|
+
active_segment = 0
|
|
113
|
+
canon_end_for_lookup = None
|
|
114
|
+
latest_end_row = conn.execute(
|
|
115
|
+
"SELECT week_end_at FROM weekly_usage_snapshots "
|
|
116
|
+
"WHERE week_start_date = ? AND week_end_at IS NOT NULL "
|
|
117
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
|
|
118
|
+
(week_start_date,),
|
|
119
|
+
).fetchone()
|
|
120
|
+
if latest_end_row is not None:
|
|
121
|
+
canon_end_for_lookup = _canonicalize_optional_iso(
|
|
122
|
+
latest_end_row["week_end_at"], "pb.cur"
|
|
123
|
+
)
|
|
124
|
+
if canon_end_for_lookup:
|
|
125
|
+
seg_row = conn.execute(
|
|
126
|
+
"SELECT id FROM week_reset_events "
|
|
127
|
+
"WHERE new_week_end_at = ? "
|
|
128
|
+
"ORDER BY id DESC LIMIT 1",
|
|
129
|
+
(canon_end_for_lookup,),
|
|
130
|
+
).fetchone()
|
|
131
|
+
if seg_row is not None:
|
|
132
|
+
active_segment = int(seg_row["id"])
|
|
133
|
+
|
|
134
|
+
milestones = [
|
|
135
|
+
m for m in c.get_milestones_for_week(conn, week_start_date)
|
|
136
|
+
if int(m["reset_event_id"] or 0) == active_segment
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
milestone_list = []
|
|
140
|
+
for m in milestones:
|
|
141
|
+
milestone_list.append({
|
|
142
|
+
"percentThreshold": int(m["percent_threshold"]),
|
|
143
|
+
"cumulativeCostUSD": round(float(m["cumulative_cost_usd"]), 9),
|
|
144
|
+
"marginalCostUSD": round(float(m["marginal_cost_usd"]), 9) if m["marginal_cost_usd"] is not None else None,
|
|
145
|
+
"capturedAt": m["captured_at_utc"],
|
|
146
|
+
"fiveHourPercentAtCrossing": round(float(m["five_hour_percent_at_crossing"]), 1) if m["five_hour_percent_at_crossing"] is not None else None,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
output = {
|
|
150
|
+
"weekStartDate": week_start_date,
|
|
151
|
+
"weekEndDate": week_end_date,
|
|
152
|
+
"weekStartAt": display_start_iso,
|
|
153
|
+
"weekEndAt": display_end_iso,
|
|
154
|
+
"milestones": milestone_list,
|
|
155
|
+
"generatedAt": now_utc_iso(),
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if args.json:
|
|
159
|
+
print(json.dumps(output, indent=2))
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
# Prefer the reset-adjusted ISO timestamps in the terminal header
|
|
163
|
+
# when available; fall back to the raw dates for legacy installs.
|
|
164
|
+
if display_start_iso and display_end_iso:
|
|
165
|
+
print(
|
|
166
|
+
f"Week: {c._format_ts_compact(display_start_iso, tz=tz)} -> "
|
|
167
|
+
f"{c._format_ts_compact(display_end_iso, tz=tz)}"
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
print(f"Week: {week_start_date}..{week_end_date}")
|
|
171
|
+
if not milestone_list:
|
|
172
|
+
if active_segment > 0:
|
|
173
|
+
# v1.7.2: distinguish post-credit empty (just got
|
|
174
|
+
# credited, no crossings yet) from genuinely-empty week.
|
|
175
|
+
# The pre-credit ledger still exists in the DB — just
|
|
176
|
+
# filtered out of the body — so the user shouldn't see
|
|
177
|
+
# "No milestones" and assume the data is gone.
|
|
178
|
+
print(
|
|
179
|
+
"(post-credit segment, no milestones crossed yet)"
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
print("No percent milestones recorded for this week.")
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
print("Percent breakdown:\n")
|
|
186
|
+
headers = ["#", "Threshold", "Cumulative Cost", "Marginal Cost", "5h at crossing"]
|
|
187
|
+
rows: list[list[str]] = []
|
|
188
|
+
for idx, m in enumerate(milestone_list, start=1):
|
|
189
|
+
pct = f"{m['percentThreshold']}%"
|
|
190
|
+
cum = f"${m['cumulativeCostUSD']:.6f}"
|
|
191
|
+
marg = f"${m['marginalCostUSD']:.6f}" if m["marginalCostUSD"] is not None else "n/a"
|
|
192
|
+
fh = f"{m['fiveHourPercentAtCrossing']:.0f}%" if m["fiveHourPercentAtCrossing"] is not None else "n/a"
|
|
193
|
+
rows.append([str(idx), pct, cum, marg, fh])
|
|
194
|
+
|
|
195
|
+
print(c._boxed_table(headers, rows, ["right", "right", "right", "right", "right"]))
|
|
196
|
+
|
|
197
|
+
return 0
|
|
198
|
+
finally:
|
|
199
|
+
conn.close()
|