cctally 1.7.0 → 1.7.2
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 +27 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5403 -0
- package/bin/_cctally_db.py +1837 -0
- package/bin/_cctally_record.py +2305 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4487 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +441 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +137 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11694 -35448
- package/package.json +24 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Pure SemVer primitives — parse, format, bump-compute, sort-key.
|
|
2
|
+
|
|
3
|
+
Eager-imported from bin/cctally to back release-flow internals and the
|
|
4
|
+
update-banner version-compare predicate. Zero I/O, zero module-level
|
|
5
|
+
side effects; safe to import from any context (script, SourceFileLoader,
|
|
6
|
+
compile+exec).
|
|
7
|
+
|
|
8
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
# Exported as a building block: bin/cctally's RELEASE_HEADER_RE and
|
|
15
|
+
# _cctally_release's _FORMULA_VERSION_RE both reuse this numeric-component
|
|
16
|
+
# pattern for SemVer matching.
|
|
17
|
+
_SEMVER_NUM = r'(?:0|[1-9]\d*)'
|
|
18
|
+
|
|
19
|
+
_SEMVER_RE = re.compile(
|
|
20
|
+
rf'^({_SEMVER_NUM})\.({_SEMVER_NUM})\.({_SEMVER_NUM})'
|
|
21
|
+
rf'(?:-([a-zA-Z][a-zA-Z0-9-]*)\.({_SEMVER_NUM}))?$'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _release_parse_semver(s: str) -> tuple[int, int, int, str | None, int | None]:
|
|
26
|
+
"""Parse SemVer; raises ValueError on malformed input."""
|
|
27
|
+
m = _SEMVER_RE.match(s)
|
|
28
|
+
if not m:
|
|
29
|
+
raise ValueError(f"invalid semver: {s!r}")
|
|
30
|
+
major, minor, patch, prerelease_id, prerelease_n = m.groups()
|
|
31
|
+
return (
|
|
32
|
+
int(major),
|
|
33
|
+
int(minor),
|
|
34
|
+
int(patch),
|
|
35
|
+
prerelease_id,
|
|
36
|
+
int(prerelease_n) if prerelease_n is not None else None,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _release_format_semver(
|
|
41
|
+
major: int, minor: int, patch: int,
|
|
42
|
+
prerelease_id: str | None = None, prerelease_n: int | None = None,
|
|
43
|
+
) -> str:
|
|
44
|
+
base = f"{major}.{minor}.{patch}"
|
|
45
|
+
if prerelease_id is None:
|
|
46
|
+
return base
|
|
47
|
+
return f"{base}-{prerelease_id}.{prerelease_n}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _release_compute_next_version(
|
|
51
|
+
current: str | None, kind: str, bump: str | None, prerelease_id: str,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Pure function. Implements bump rules from spec Section 4.4."""
|
|
54
|
+
if current is None:
|
|
55
|
+
# First-ever release. Treat as 0.0.0 base.
|
|
56
|
+
cur_maj, cur_min, cur_pat, cur_id, cur_n = 0, 0, 0, None, None
|
|
57
|
+
else:
|
|
58
|
+
cur_maj, cur_min, cur_pat, cur_id, cur_n = _release_parse_semver(current)
|
|
59
|
+
is_prerelease = cur_id is not None
|
|
60
|
+
|
|
61
|
+
if kind == "finalize":
|
|
62
|
+
if not is_prerelease:
|
|
63
|
+
raise ValueError("cannot finalize: current version is not a prerelease")
|
|
64
|
+
return _release_format_semver(cur_maj, cur_min, cur_pat)
|
|
65
|
+
|
|
66
|
+
if kind == "prerelease":
|
|
67
|
+
if is_prerelease:
|
|
68
|
+
if bump is not None:
|
|
69
|
+
raise ValueError("--bump invalid when current version is a prerelease; rc counter increments only")
|
|
70
|
+
return _release_format_semver(cur_maj, cur_min, cur_pat, cur_id, cur_n + 1)
|
|
71
|
+
if bump is None:
|
|
72
|
+
raise ValueError("--bump required for first prerelease from stable version")
|
|
73
|
+
# Apply bump kind to current stable, then attach -<id>.1
|
|
74
|
+
nxt = _release_compute_next_version(current or "0.0.0", bump, None, prerelease_id)
|
|
75
|
+
nxt_maj, nxt_min, nxt_pat, _, _ = _release_parse_semver(nxt)
|
|
76
|
+
return _release_format_semver(nxt_maj, nxt_min, nxt_pat, prerelease_id, 1)
|
|
77
|
+
|
|
78
|
+
if is_prerelease:
|
|
79
|
+
raise ValueError("current version is a prerelease; run 'cctally release finalize' first or use --bump in a prerelease bump")
|
|
80
|
+
|
|
81
|
+
if kind == "patch":
|
|
82
|
+
return _release_format_semver(cur_maj, cur_min, cur_pat + 1)
|
|
83
|
+
if kind == "minor":
|
|
84
|
+
return _release_format_semver(cur_maj, cur_min + 1, 0)
|
|
85
|
+
if kind == "major":
|
|
86
|
+
return _release_format_semver(cur_maj + 1, 0, 0)
|
|
87
|
+
raise ValueError(f"unknown bump kind: {kind!r}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _release_semver_sort_key(
|
|
91
|
+
parsed: tuple[int, int, int, str | None, int | None],
|
|
92
|
+
) -> tuple:
|
|
93
|
+
"""Total-order sort key for `_release_parse_semver` output.
|
|
94
|
+
|
|
95
|
+
SemVer §11.4: a stable release has higher precedence than a pre-release
|
|
96
|
+
of the same MAJOR.MINOR.PATCH. Naive tuple comparison breaks because
|
|
97
|
+
Python rejects ``None < str`` at runtime. The key returned here makes
|
|
98
|
+
stable releases sort *after* their pre-releases by inverting the
|
|
99
|
+
"has-prerelease" axis: stable → ``(maj, min, pat, 1, "", 0)``,
|
|
100
|
+
pre-release → ``(maj, min, pat, 0, id, n)``.
|
|
101
|
+
"""
|
|
102
|
+
maj, min_, pat, pre_id, pre_n = parsed
|
|
103
|
+
if pre_id is None:
|
|
104
|
+
return (maj, min_, pat, 1, "", 0)
|
|
105
|
+
return (maj, min_, pat, 0, pre_id, pre_n)
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Subscription-week boundary computation.
|
|
2
|
+
|
|
3
|
+
Self-contained subscription-week domain: the `SubWeek` frozen dataclass +
|
|
4
|
+
the helpers that compute, clamp, and reset-event-shift a list of weeks
|
|
5
|
+
from `weekly_usage_snapshots` / `weekly_cost_snapshots` / config-based
|
|
6
|
+
calendar-week math.
|
|
7
|
+
|
|
8
|
+
This is the first `_lib_*` module to back-reference `bin/cctally` for
|
|
9
|
+
shared utility helpers (`parse_iso_datetime`, `load_config`,
|
|
10
|
+
`get_week_start_name`, `WEEKDAY_MAP`). The back-reference uses the same
|
|
11
|
+
`_cctally()` call-time accessor pattern established in
|
|
12
|
+
`bin/_cctally_release.py` (spec §5.5) — never `import cctally` at module
|
|
13
|
+
top, which would pin to the *original* module instance and break
|
|
14
|
+
SourceFileLoader-based test isolation. Module-load time stays
|
|
15
|
+
self-contained; only call-time resolves through `sys.modules["cctally"]`.
|
|
16
|
+
|
|
17
|
+
Sibling dependency: `_compute_subscription_weeks` calls `_resolve_tz` /
|
|
18
|
+
`_local_tz_name` (from `_lib_display_tz`) in the no-snapshot
|
|
19
|
+
config-based fallback path; loaded via `_load_lib` at module load time
|
|
20
|
+
(same shape as `bin/_lib_alerts_payload.py`).
|
|
21
|
+
|
|
22
|
+
Why the planned-extract set of 4 became 6: `_apply_overlap_clamp_to_subweeks`
|
|
23
|
+
calls `_clamp_end_ats_to_next_start` (originally listed as private,
|
|
24
|
+
implicit), and `_compute_subscription_weeks` calls
|
|
25
|
+
`_apply_reset_events_to_subweeks` (originally elsewhere in `bin/cctally`).
|
|
26
|
+
Moving both keeps the subscription-week domain self-contained and avoids
|
|
27
|
+
inventing a call-time back-reference to `_apply_reset_events_to_subweeks`.
|
|
28
|
+
`_apply_overlap_clamp_to_weekrefs` (operates on `WeekRef`, NOT `SubWeek`)
|
|
29
|
+
stays in `bin/cctally` and reaches `_clamp_end_ats_to_next_start` through
|
|
30
|
+
the re-export block.
|
|
31
|
+
|
|
32
|
+
`bin/cctally` re-exports every public symbol below so the ~50 internal
|
|
33
|
+
call sites + SourceFileLoader-based tests (`tests/test_subweek_display_dates`,
|
|
34
|
+
`tests/test_dashboard_period_builders`) resolve unchanged.
|
|
35
|
+
|
|
36
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import bisect
|
|
41
|
+
import datetime as dt
|
|
42
|
+
import pathlib
|
|
43
|
+
import sqlite3
|
|
44
|
+
import sys
|
|
45
|
+
from dataclasses import dataclass, replace
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cctally():
|
|
49
|
+
"""Resolve the current `cctally` module at call-time.
|
|
50
|
+
|
|
51
|
+
Spec §5.5 — defers the lookup so SourceFileLoader-loaded test instances
|
|
52
|
+
of `bin/cctally` (which reassign `sys.modules["cctally"]`) are seen by
|
|
53
|
+
this module's back-references. Mirror of `bin/_cctally_release._cctally()`.
|
|
54
|
+
"""
|
|
55
|
+
return sys.modules["cctally"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_lib(name: str):
|
|
59
|
+
cached = sys.modules.get(name)
|
|
60
|
+
if cached is not None:
|
|
61
|
+
return cached
|
|
62
|
+
import importlib.util as _ilu
|
|
63
|
+
p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
|
|
64
|
+
spec = _ilu.spec_from_file_location(name, p)
|
|
65
|
+
mod = _ilu.module_from_spec(spec)
|
|
66
|
+
sys.modules[name] = mod
|
|
67
|
+
spec.loader.exec_module(mod)
|
|
68
|
+
return mod
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_lib_display_tz = _load_lib("_lib_display_tz")
|
|
72
|
+
_resolve_tz = _lib_display_tz._resolve_tz
|
|
73
|
+
_local_tz_name = _lib_display_tz._local_tz_name
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class SubWeek:
|
|
78
|
+
"""One subscription-week bounded interval.
|
|
79
|
+
|
|
80
|
+
`start_ts` / `end_ts` are ISO-8601 strings with TZ offset.
|
|
81
|
+
|
|
82
|
+
`start_date` doubles as the **internal bucket key** (matched against
|
|
83
|
+
`BucketUsage.bucket`) and the **lookup key** for
|
|
84
|
+
`weekly_usage_snapshots.week_start_date`. It reflects the API-derived
|
|
85
|
+
boundary at snapshot capture time and is intentionally NOT shifted by
|
|
86
|
+
`_apply_reset_events_to_subweeks` so the usage-% join stays joinable
|
|
87
|
+
after an early reset.
|
|
88
|
+
|
|
89
|
+
`display_start_date` is the user-facing start date and tracks `start_ts`
|
|
90
|
+
after `_apply_reset_events_to_subweeks` may have rewritten the latter
|
|
91
|
+
to the early-reset effective moment. For weeks never touched by a reset
|
|
92
|
+
event, `display_start_date == start_date`.
|
|
93
|
+
|
|
94
|
+
`end_date` is the inclusive last-day for display; it is NOT a lookup key
|
|
95
|
+
(`_get_latest_row_for_week` joins on `week_start_date` only) and is
|
|
96
|
+
already shifted in lockstep with `end_ts` by the existing clamp /
|
|
97
|
+
reset-event code, so a separate display field for the end is redundant.
|
|
98
|
+
|
|
99
|
+
`source` is either "snapshot" (boundary came from a `weekly_usage_snapshots`
|
|
100
|
+
row) or "extrapolated" (inferred from the anchor via 7-day multiples).
|
|
101
|
+
"""
|
|
102
|
+
start_ts: str # ISO-8601, e.g. "2026-04-14T03:00:00+00:00"
|
|
103
|
+
end_ts: str # ISO-8601, start_ts + 7d
|
|
104
|
+
start_date: dt.date
|
|
105
|
+
end_date: dt.date # start_date + 6d (inclusive last day for display)
|
|
106
|
+
source: str # "snapshot" | "extrapolated"
|
|
107
|
+
display_start_date: dt.date
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _discover_week_anchor(conn: sqlite3.Connection) -> str | None:
|
|
111
|
+
"""Return one known `week_start_at` value, or None if unavailable.
|
|
112
|
+
|
|
113
|
+
Preference order (per spec A1.6 Step 1):
|
|
114
|
+
1. earliest week_start_at in weekly_usage_snapshots
|
|
115
|
+
2. earliest week_start_at in weekly_cost_snapshots
|
|
116
|
+
3. None — caller falls back to config-based calendar-week math.
|
|
117
|
+
"""
|
|
118
|
+
for table in ("weekly_usage_snapshots", "weekly_cost_snapshots"):
|
|
119
|
+
row = conn.execute(
|
|
120
|
+
f"SELECT week_start_at FROM {table} "
|
|
121
|
+
f"WHERE week_start_at IS NOT NULL "
|
|
122
|
+
f"ORDER BY week_start_at ASC LIMIT 1"
|
|
123
|
+
).fetchone()
|
|
124
|
+
if row is not None and row[0]:
|
|
125
|
+
return row[0]
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _clamp_end_ats_to_next_start(
|
|
130
|
+
pairs: list[tuple[str | None, str | None]],
|
|
131
|
+
) -> list[str | None]:
|
|
132
|
+
"""For each (start_at, end_at) pair, return a clamped end_at.
|
|
133
|
+
|
|
134
|
+
When the next pair's start_at falls strictly inside the current pair's
|
|
135
|
+
(start_at, end_at) interval, the current end_at is replaced by that
|
|
136
|
+
next start_at. This corrects weekly_usage_snapshots.week_end_at, which
|
|
137
|
+
is captured once from Anthropic's --resets-at at week-start and never
|
|
138
|
+
updated when a later early reset actually ends the week sooner. The
|
|
139
|
+
overlap of the next week's start_at inside the current week's
|
|
140
|
+
interval is the observable ground-truth signal of an early reset.
|
|
141
|
+
|
|
142
|
+
`pairs` must be sorted by start_at ascending. None values are
|
|
143
|
+
passed through unchanged and never participate in clamping.
|
|
144
|
+
"""
|
|
145
|
+
parse_iso_datetime = _cctally().parse_iso_datetime
|
|
146
|
+
n = len(pairs)
|
|
147
|
+
if n < 2:
|
|
148
|
+
return [p[1] for p in pairs]
|
|
149
|
+
out: list[str | None] = []
|
|
150
|
+
for i, (cur_start, cur_end) in enumerate(pairs):
|
|
151
|
+
if cur_end is None or i + 1 >= n:
|
|
152
|
+
out.append(cur_end)
|
|
153
|
+
continue
|
|
154
|
+
nxt_start = pairs[i + 1][0]
|
|
155
|
+
if nxt_start is None:
|
|
156
|
+
out.append(cur_end)
|
|
157
|
+
continue
|
|
158
|
+
cur_end_dt = parse_iso_datetime(cur_end, "week.end_at")
|
|
159
|
+
nxt_start_dt = parse_iso_datetime(nxt_start, "week.start_at")
|
|
160
|
+
if cur_start is not None:
|
|
161
|
+
cur_start_dt = parse_iso_datetime(cur_start, "week.start_at")
|
|
162
|
+
if not (cur_start_dt < nxt_start_dt < cur_end_dt):
|
|
163
|
+
out.append(cur_end)
|
|
164
|
+
continue
|
|
165
|
+
elif nxt_start_dt >= cur_end_dt:
|
|
166
|
+
out.append(cur_end)
|
|
167
|
+
continue
|
|
168
|
+
out.append(nxt_start)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _apply_overlap_clamp_to_subweeks(weeks: list[SubWeek]) -> list[SubWeek]:
|
|
173
|
+
"""Clamp each SubWeek's end_ts to the next SubWeek's start_ts on overlap.
|
|
174
|
+
|
|
175
|
+
The early-reset fix: weekly_usage_snapshots.week_end_at stays stale
|
|
176
|
+
across Anthropic early resets, so _compute_subscription_weeks() emits
|
|
177
|
+
SubWeeks whose end_ts may sit past the real end. The next week's
|
|
178
|
+
start_ts (itself from a fresh snapshot) reveals the true boundary.
|
|
179
|
+
Clamping here corrects both display (--json weekEndAt) and the
|
|
180
|
+
_aggregate_weekly bucketing interval [start_ts, end_ts).
|
|
181
|
+
|
|
182
|
+
Input must be sorted by start_ts ascending (invariant of
|
|
183
|
+
_compute_subscription_weeks in all three branches).
|
|
184
|
+
"""
|
|
185
|
+
parse_iso_datetime = _cctally().parse_iso_datetime
|
|
186
|
+
if len(weeks) < 2:
|
|
187
|
+
return weeks
|
|
188
|
+
pairs: list[tuple[str | None, str | None]] = [(w.start_ts, w.end_ts) for w in weeks]
|
|
189
|
+
new_ends = _clamp_end_ats_to_next_start(pairs)
|
|
190
|
+
result: list[SubWeek] = []
|
|
191
|
+
for w, new_end in zip(weeks, new_ends):
|
|
192
|
+
if new_end is None or new_end == w.end_ts:
|
|
193
|
+
result.append(w)
|
|
194
|
+
continue
|
|
195
|
+
new_end_dt = parse_iso_datetime(new_end, "week.end_ts (clamped)")
|
|
196
|
+
# internal fallback: host-local intentional
|
|
197
|
+
new_end_date = (new_end_dt - dt.timedelta(seconds=1)).astimezone().date()
|
|
198
|
+
result.append(replace(w, end_ts=new_end, end_date=new_end_date))
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _apply_reset_events_to_subweeks(
|
|
203
|
+
conn: sqlite3.Connection, weeks: list[SubWeek]
|
|
204
|
+
) -> list[SubWeek]:
|
|
205
|
+
"""Override SubWeek boundaries with reset-event effective moments.
|
|
206
|
+
|
|
207
|
+
Same semantics as `_apply_reset_events_to_weekrefs` but for SubWeek:
|
|
208
|
+
- SubWeek whose end_ts equals event.old_week_end_at (instant)
|
|
209
|
+
is the PRE-reset week → end_ts := effective_reset_at_utc
|
|
210
|
+
and end_date := (reset_dt - 1s).astimezone().date()
|
|
211
|
+
- SubWeek whose end_ts equals event.new_week_end_at (instant)
|
|
212
|
+
is the POST-reset week → start_ts := effective_reset_at_utc
|
|
213
|
+
(start_date kept; it is the bucket key + lookup key for
|
|
214
|
+
weekly_usage_snapshots.week_start_date).
|
|
215
|
+
|
|
216
|
+
Compares by parsed datetime instant — SubWeek.{start,end}_ts are
|
|
217
|
+
raw snapshot strings that may be written in non-UTC offsets while
|
|
218
|
+
`week_reset_events.{old,new}_week_end_at` are canonicalized UTC.
|
|
219
|
+
"""
|
|
220
|
+
parse_iso_datetime = _cctally().parse_iso_datetime
|
|
221
|
+
rows = conn.execute(
|
|
222
|
+
"SELECT old_week_end_at, new_week_end_at, effective_reset_at_utc "
|
|
223
|
+
"FROM week_reset_events"
|
|
224
|
+
).fetchall()
|
|
225
|
+
if not rows:
|
|
226
|
+
return weeks
|
|
227
|
+
parsed_events: list[tuple[dt.datetime, dt.datetime, str]] = []
|
|
228
|
+
for r in rows:
|
|
229
|
+
try:
|
|
230
|
+
old_dt = parse_iso_datetime(r["old_week_end_at"], "evt.old_end")
|
|
231
|
+
new_dt = parse_iso_datetime(r["new_week_end_at"], "evt.new_end")
|
|
232
|
+
except ValueError:
|
|
233
|
+
continue
|
|
234
|
+
parsed_events.append((old_dt, new_dt, r["effective_reset_at_utc"]))
|
|
235
|
+
if not parsed_events:
|
|
236
|
+
return weeks
|
|
237
|
+
|
|
238
|
+
out: list[SubWeek] = []
|
|
239
|
+
for w in weeks:
|
|
240
|
+
new_w = w
|
|
241
|
+
try:
|
|
242
|
+
end_dt = parse_iso_datetime(w.end_ts, "subweek.end_ts")
|
|
243
|
+
except ValueError:
|
|
244
|
+
out.append(w)
|
|
245
|
+
continue
|
|
246
|
+
for old_dt, new_dt, reset_at in parsed_events:
|
|
247
|
+
if end_dt == old_dt:
|
|
248
|
+
try:
|
|
249
|
+
reset_dt = parse_iso_datetime(reset_at, "evt.eff")
|
|
250
|
+
except ValueError:
|
|
251
|
+
continue
|
|
252
|
+
# internal fallback: host-local intentional
|
|
253
|
+
new_end_date = (reset_dt - dt.timedelta(seconds=1)).astimezone().date()
|
|
254
|
+
new_w = replace(new_w, end_ts=reset_at, end_date=new_end_date)
|
|
255
|
+
if end_dt == new_dt:
|
|
256
|
+
try:
|
|
257
|
+
reset_dt = parse_iso_datetime(reset_at, "evt.eff")
|
|
258
|
+
except ValueError:
|
|
259
|
+
continue
|
|
260
|
+
# internal fallback: host-local intentional
|
|
261
|
+
new_display_start = reset_dt.astimezone().date()
|
|
262
|
+
new_w = replace(
|
|
263
|
+
new_w,
|
|
264
|
+
start_ts=reset_at,
|
|
265
|
+
display_start_date=new_display_start,
|
|
266
|
+
)
|
|
267
|
+
# start_date intentionally NOT touched — it is the bucket /
|
|
268
|
+
# lookup key into weekly_usage_snapshots.week_start_date.
|
|
269
|
+
out.append(new_w)
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _compute_subscription_weeks(
|
|
274
|
+
conn: sqlite3.Connection,
|
|
275
|
+
range_start: dt.datetime,
|
|
276
|
+
range_end: dt.datetime,
|
|
277
|
+
) -> list[SubWeek]:
|
|
278
|
+
"""Generate the ordered list of subscription weeks overlapping [range_start, range_end].
|
|
279
|
+
|
|
280
|
+
Prefers snapshot rows (authoritative reset boundaries from actual data)
|
|
281
|
+
and extrapolates by 7-day multiples only for the range tail before the
|
|
282
|
+
earliest snapshot. When no snapshots exist at all, falls back to
|
|
283
|
+
config-based calendar-week boundaries with every week tagged
|
|
284
|
+
"extrapolated".
|
|
285
|
+
|
|
286
|
+
Anthropic's reset day-of-week is not strictly stable across long spans —
|
|
287
|
+
it can shift (observed: Thursday cycles in Feb, Friday cycles from Mar
|
|
288
|
+
onward). A single-anchor 7-day-multiple extrapolation therefore generates
|
|
289
|
+
dates that miss actual snapshot boundaries for middle weeks. Using
|
|
290
|
+
snapshot rows directly for weeks they cover avoids that drift.
|
|
291
|
+
"""
|
|
292
|
+
cctally = _cctally()
|
|
293
|
+
parse_iso_datetime = cctally.parse_iso_datetime
|
|
294
|
+
|
|
295
|
+
# Case A: snapshots exist.
|
|
296
|
+
snap_rows = conn.execute(
|
|
297
|
+
"SELECT "
|
|
298
|
+
" MIN(week_start_at) AS week_start_at, "
|
|
299
|
+
" MIN(week_end_at) AS week_end_at, "
|
|
300
|
+
" week_start_date, "
|
|
301
|
+
" MIN(week_end_date) AS week_end_date "
|
|
302
|
+
"FROM weekly_usage_snapshots "
|
|
303
|
+
"WHERE week_start_at IS NOT NULL "
|
|
304
|
+
" AND week_end_at IS NOT NULL "
|
|
305
|
+
" AND week_start_date IS NOT NULL "
|
|
306
|
+
"GROUP BY week_start_date "
|
|
307
|
+
"ORDER BY MIN(week_start_at) ASC"
|
|
308
|
+
).fetchall()
|
|
309
|
+
|
|
310
|
+
weeks: list[SubWeek] = []
|
|
311
|
+
|
|
312
|
+
if snap_rows:
|
|
313
|
+
parsed_snaps: list[tuple[dt.datetime, dt.datetime, str, str, str, str | None]] = []
|
|
314
|
+
for row in snap_rows:
|
|
315
|
+
start_ts, end_ts, start_date_s, end_date_s = row
|
|
316
|
+
start_dt = parse_iso_datetime(start_ts, "week_start_at")
|
|
317
|
+
end_dt = parse_iso_datetime(end_ts, "week_end_at")
|
|
318
|
+
parsed_snaps.append((start_dt, end_dt, start_ts, end_ts, start_date_s, end_date_s))
|
|
319
|
+
|
|
320
|
+
snap_start_dts = [r[0] for r in parsed_snaps]
|
|
321
|
+
|
|
322
|
+
# Pick initial anchor: first snapshot >= range_start; else last snapshot
|
|
323
|
+
# < range_start; else the earliest snapshot (only happens when all
|
|
324
|
+
# snapshots are before range_start — we'll step forward from it).
|
|
325
|
+
idx_ge = bisect.bisect_left(snap_start_dts, range_start)
|
|
326
|
+
if idx_ge < len(parsed_snaps):
|
|
327
|
+
anchor_dt = parsed_snaps[idx_ge][0]
|
|
328
|
+
elif parsed_snaps:
|
|
329
|
+
anchor_dt = parsed_snaps[-1][0]
|
|
330
|
+
else: # unreachable given `if snap_rows:` guard, defensive
|
|
331
|
+
anchor_dt = range_start
|
|
332
|
+
|
|
333
|
+
# Slide anchor back to land at-or-before range_start.
|
|
334
|
+
current = anchor_dt
|
|
335
|
+
while current > range_start:
|
|
336
|
+
current -= dt.timedelta(days=7)
|
|
337
|
+
# If anchor was already far before range_start, step forward until the
|
|
338
|
+
# slice [current, current+7d) overlaps range_start.
|
|
339
|
+
while current + dt.timedelta(days=7) <= range_start:
|
|
340
|
+
current += dt.timedelta(days=7)
|
|
341
|
+
|
|
342
|
+
# Walk forward. For each 7-day slice overlapping the range, emit a
|
|
343
|
+
# SubWeek. When a slice's local start_date matches a snapshot row,
|
|
344
|
+
# use that row's verbatim bounds (drives snapshot-based Used % join).
|
|
345
|
+
# After emitting, re-anchor to the next snapshot whenever it sits
|
|
346
|
+
# within MAX_REANCHOR of `current`. This handles three cases:
|
|
347
|
+
# - normal 7d cadence (~7d ahead): matches exactly
|
|
348
|
+
# - day-of-week drift (Thursday → Friday cycles, ~7±1d ahead)
|
|
349
|
+
# - early reset (snapshot ~1d after current when Anthropic ends the
|
|
350
|
+
# week before the original --resets-at); the previous heuristic
|
|
351
|
+
# (|cand - natural_next| <= HALF_WEEK) rejected early-reset
|
|
352
|
+
# snapshots because they sit ~6d from the +7d natural step.
|
|
353
|
+
# Snapshots farther than MAX_REANCHOR represent a multi-week data
|
|
354
|
+
# gap; extrapolate one week and retry on the next iteration.
|
|
355
|
+
MAX_REANCHOR = dt.timedelta(days=10, hours=12)
|
|
356
|
+
while current < range_end:
|
|
357
|
+
end = current + dt.timedelta(days=7)
|
|
358
|
+
if end > range_start and current < range_end:
|
|
359
|
+
# internal fallback: host-local intentional
|
|
360
|
+
local_start = current.astimezone().date()
|
|
361
|
+
# Match snapshots by datetime equality against the sorted
|
|
362
|
+
# snap_start_dts list — keying on local_start_s (current's
|
|
363
|
+
# date in the *reader's* local TZ) was TZ-unsafe: snapshots
|
|
364
|
+
# written in another TZ whose UTC hour sits near midnight
|
|
365
|
+
# would flip to a different local date on a machine in a
|
|
366
|
+
# different TZ (travel / WSL vs. host), missing the lookup
|
|
367
|
+
# and relabeling the week as "extrapolated" (dropping
|
|
368
|
+
# Used % / $/1%). Datetime equality is TZ-invariant.
|
|
369
|
+
idx = bisect.bisect_left(snap_start_dts, current)
|
|
370
|
+
if idx < len(snap_start_dts) and snap_start_dts[idx] == current:
|
|
371
|
+
rec = parsed_snaps[idx]
|
|
372
|
+
else:
|
|
373
|
+
rec = None
|
|
374
|
+
if rec is not None:
|
|
375
|
+
s_dt, e_dt, s_ts, e_ts, s_date_s, e_date_s = rec
|
|
376
|
+
start_date_obj = dt.date.fromisoformat(s_date_s)
|
|
377
|
+
if e_date_s:
|
|
378
|
+
end_date_obj = dt.date.fromisoformat(e_date_s)
|
|
379
|
+
else:
|
|
380
|
+
end_date_obj = start_date_obj + dt.timedelta(days=6)
|
|
381
|
+
weeks.append(SubWeek(
|
|
382
|
+
start_ts=s_ts,
|
|
383
|
+
end_ts=e_ts,
|
|
384
|
+
start_date=start_date_obj,
|
|
385
|
+
end_date=end_date_obj,
|
|
386
|
+
source="snapshot",
|
|
387
|
+
display_start_date=start_date_obj,
|
|
388
|
+
))
|
|
389
|
+
else:
|
|
390
|
+
local_end = local_start + dt.timedelta(days=6)
|
|
391
|
+
weeks.append(SubWeek(
|
|
392
|
+
start_ts=current.isoformat(timespec="seconds"),
|
|
393
|
+
end_ts=end.isoformat(timespec="seconds"),
|
|
394
|
+
start_date=local_start,
|
|
395
|
+
end_date=local_end,
|
|
396
|
+
source="extrapolated",
|
|
397
|
+
display_start_date=local_start,
|
|
398
|
+
))
|
|
399
|
+
|
|
400
|
+
# Determine next `current`: prefer the next snapshot's start_dt
|
|
401
|
+
# when it sits within MAX_REANCHOR of `current` (covers normal
|
|
402
|
+
# cadence, drift, and early-reset weeks). Otherwise step +7d
|
|
403
|
+
# to emit one extrapolated week inside a multi-week data gap.
|
|
404
|
+
natural_next = end
|
|
405
|
+
snap_idx = bisect.bisect_right(snap_start_dts, current)
|
|
406
|
+
re_anchored = False
|
|
407
|
+
while snap_idx < len(snap_start_dts):
|
|
408
|
+
cand = snap_start_dts[snap_idx]
|
|
409
|
+
if cand <= current: # strictly ahead only
|
|
410
|
+
snap_idx += 1
|
|
411
|
+
continue
|
|
412
|
+
if (cand - current) <= MAX_REANCHOR:
|
|
413
|
+
current = cand
|
|
414
|
+
re_anchored = True
|
|
415
|
+
break
|
|
416
|
+
# Next snapshot is far ahead — keep natural step.
|
|
417
|
+
break
|
|
418
|
+
if not re_anchored:
|
|
419
|
+
current = natural_next
|
|
420
|
+
|
|
421
|
+
return _apply_overlap_clamp_to_subweeks(
|
|
422
|
+
_apply_reset_events_to_subweeks(conn, weeks)
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Case A2 (spec A1.6 Step 1 fallback): no usage snapshots, but a
|
|
426
|
+
# cost-snapshot may carry a known reset boundary. Use that anchor to
|
|
427
|
+
# extrapolate 7-day cycles in both directions across the range
|
|
428
|
+
# before falling through to calendar-week math.
|
|
429
|
+
# NOTE: weekly_cost_snapshots contributes timing only — cost is
|
|
430
|
+
# always recomputed from session_entries (see CLAUDE.md gotcha
|
|
431
|
+
# "weekly ignores weekly_cost_snapshots for cost").
|
|
432
|
+
anchor_ts = _discover_week_anchor(conn)
|
|
433
|
+
if anchor_ts is not None:
|
|
434
|
+
anchor_dt = parse_iso_datetime(anchor_ts, "week_start_at (anchor)")
|
|
435
|
+
# Slide anchor back by full weeks until we're at-or-before range_start.
|
|
436
|
+
current = anchor_dt
|
|
437
|
+
while current > range_start:
|
|
438
|
+
current -= dt.timedelta(days=7)
|
|
439
|
+
# If anchor was already far past range_end, current may still be
|
|
440
|
+
# past range_end; outer while loop handles that naturally (zero
|
|
441
|
+
# iterations). Conversely if anchor is before range_start we need
|
|
442
|
+
# to step forward to the first week overlapping the range.
|
|
443
|
+
while current + dt.timedelta(days=7) <= range_start:
|
|
444
|
+
current += dt.timedelta(days=7)
|
|
445
|
+
# Emit one SubWeek per 7-day slice until we pass range_end.
|
|
446
|
+
while current < range_end:
|
|
447
|
+
end = current + dt.timedelta(days=7)
|
|
448
|
+
# internal fallback: host-local intentional
|
|
449
|
+
local_start = current.astimezone().date()
|
|
450
|
+
local_end = local_start + dt.timedelta(days=6)
|
|
451
|
+
weeks.append(SubWeek(
|
|
452
|
+
start_ts=current.isoformat(timespec="seconds"),
|
|
453
|
+
end_ts=end.isoformat(timespec="seconds"),
|
|
454
|
+
start_date=local_start,
|
|
455
|
+
end_date=local_end,
|
|
456
|
+
source="extrapolated",
|
|
457
|
+
display_start_date=local_start,
|
|
458
|
+
))
|
|
459
|
+
current = end
|
|
460
|
+
return _apply_overlap_clamp_to_subweeks(weeks)
|
|
461
|
+
|
|
462
|
+
# Case B: no snapshots — config-based calendar-week fallback.
|
|
463
|
+
config = cctally.load_config()
|
|
464
|
+
week_start_name = cctally.get_week_start_name(config)
|
|
465
|
+
week_start_idx = cctally.WEEKDAY_MAP[week_start_name]
|
|
466
|
+
# internal fallback: host-local intentional
|
|
467
|
+
local_start_date = range_start.astimezone().date()
|
|
468
|
+
diff = (local_start_date.weekday() - week_start_idx) % 7
|
|
469
|
+
current_date = local_start_date - dt.timedelta(days=diff)
|
|
470
|
+
# Use the IANA ZoneInfo so `datetime.combine(date, time, tzinfo=tz)`
|
|
471
|
+
# produces the correct historical offset per-date (handles DST
|
|
472
|
+
# transitions across a long range). Fall back to the fixed-offset
|
|
473
|
+
# snapshot on exotic platforms where IANA resolution fails.
|
|
474
|
+
# internal fallback: host-local intentional (datetime.now().astimezone().tzinfo)
|
|
475
|
+
tz = _resolve_tz(_local_tz_name()) or dt.datetime.now().astimezone().tzinfo
|
|
476
|
+
while True:
|
|
477
|
+
end_date = current_date + dt.timedelta(days=7)
|
|
478
|
+
start_dt = dt.datetime.combine(current_date, dt.time(0, 0), tzinfo=tz)
|
|
479
|
+
end_dt = dt.datetime.combine(end_date, dt.time(0, 0), tzinfo=tz)
|
|
480
|
+
if start_dt >= range_end:
|
|
481
|
+
break
|
|
482
|
+
if end_dt > range_start:
|
|
483
|
+
weeks.append(SubWeek(
|
|
484
|
+
start_ts=start_dt.isoformat(timespec="seconds"),
|
|
485
|
+
end_ts=end_dt.isoformat(timespec="seconds"),
|
|
486
|
+
start_date=current_date,
|
|
487
|
+
end_date=end_date - dt.timedelta(days=1),
|
|
488
|
+
source="extrapolated",
|
|
489
|
+
display_start_date=current_date,
|
|
490
|
+
))
|
|
491
|
+
current_date = end_date
|
|
492
|
+
return _apply_overlap_clamp_to_subweeks(weeks)
|