cctally 1.26.0 → 1.27.1

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 CHANGED
@@ -5,6 +5,19 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.27.1] - 2026-06-04
9
+
10
+ ### Fixed
11
+ - **Three Linux-only bugs, surfaced by running the full test suite on Linux for the first time** (cctally is developed on macOS, whose case-insensitive filesystem and BSD tooling had masked them): (1) `cctally statusline` with `display.tz = utc` no longer prints a spurious `invalid timezone 'utc'; using 'UTC'` warning on Linux — the lowercase `utc` preference is now normalized to the portable IANA key `UTC` before resolution (Linux's case-sensitive zoneinfo rejects `"utc"` where macOS silently accepts it); (2) the local web dashboard's background update-check thread no longer crashes with `TypeError: 'Event' object is not callable` when the dashboard shuts down — its internal stop-event field was shadowing `threading.Thread._stop`, which `Thread.join()` invokes during teardown; (3) `cctally setup --uninstall` now reliably terminates a running legacy usage-poller on Linux even when it was launched from a long filesystem path — the process-identity check passes `ps -ww` (unlimited-width output) so Linux's default ~80-column truncation can't drop the identifying token and mis-read the live daemon as a dead PID. No action needed on upgrade.
12
+
13
+ ### Changed
14
+ - **Internal (no user-facing change): the full test suite (`bin/cctally-test-all`) is now Linux-portable, and a GitHub-hosted Linux matrix (Ubuntu × Python 3.11/3.12/3.13) runs it on every tag, weekly, and on demand** — closing the cross-version verification gap left when the floor was lowered to 3.11 in 1.27.0. The portability work fixed interpreter- and OS-divergences that only ever ran on macOS before: a `pytest-xdist` timezone leak (one test pinned a Pacific `tzset()` whose libc state outlived `monkeypatch`, flipping later tests' date boundaries — now reset suite-wide via an autouse `conftest` fixture), terminal-width-dependent block-gap rendering under `COLUMNS=80`, Python 3.13's `~~~^^^` traceback caret-anchors in a migration golden, the macOS-`osascript`-vs-Linux-`notify-send` notifier-dispatch assumptions, a BSD-vs-util-linux `script` PTY invocation in the update-banner harness (now a portable `pty.spawn`), a host-config leak in the dashboard-envelope golden, and a detached background update-check that raced fixture teardown. (#132)
15
+
16
+ ## [1.27.0] - 2026-06-04
17
+
18
+ ### Changed
19
+ - **cctally now supports Python 3.11 and 3.12, not just 3.13.** The supported floor is lowered from 3.13 to 3.11 (Debian 12, Ubuntu 24.04, and other distros whose system `python3` is 3.11/3.12 can now run cctally without a newer interpreter). This was previously blocked by a one-cent floating-point rounding difference between Python versions in a rendered cost Total; rendered cost/percent totals now route through a `math.fsum`-backed exact-summation chokepoint, so every figure is byte-identical on 3.11/3.12/3.13 (verified by running the reporting suite on all three interpreters). Homebrew installs are unaffected (the formula still bundles its own modern Python). No action needed on upgrade.
20
+
8
21
  ## [1.26.0] - 2026-06-03
9
22
 
10
23
  ### Added
package/README.md CHANGED
@@ -24,7 +24,7 @@ If you're using `ccusage` to watch Claude Code spend, `cctally` covers the same
24
24
 
25
25
  ## Installation
26
26
 
27
- **Requirements:** Python 3.13+, macOS or Linux, Claude Code installed and run at least once.
27
+ **Requirements:** Python 3.11+, macOS or Linux, Claude Code installed and run at least once.
28
28
 
29
29
  ### Homebrew (macOS / Linux)
30
30
 
@@ -19,6 +19,7 @@ from typing import Any, Literal, Optional
19
19
  from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
20
20
 
21
21
  from _cctally_core import eprint, now_utc_iso, parse_iso_datetime, _command_as_of
22
+ from _lib_fmt import stable_sum
22
23
  import _lib_cache_report as crk
23
24
 
24
25
 
@@ -467,10 +468,10 @@ def _render_cache_day_rows(
467
468
  tot_cc = sum(row.cache_creation_tokens for row in rows)
468
469
  tot_cr = sum(row.cache_read_tokens for row in rows)
469
470
  tot_tokens = sum(row.total_tokens for row in rows)
470
- tot_cost = sum(row.cost for row in rows)
471
- tot_saved = sum(row.saved_usd for row in rows)
472
- tot_wasted = sum(row.wasted_usd for row in rows)
473
- tot_net = sum(row.net_usd for row in rows)
471
+ tot_cost = stable_sum(row.cost for row in rows)
472
+ tot_saved = stable_sum(row.saved_usd for row in rows)
473
+ tot_wasted = stable_sum(row.wasted_usd for row in rows)
474
+ tot_net = stable_sum(row.net_usd for row in rows)
474
475
  tot_hit = crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr)
475
476
  footer_cells = [
476
477
  ("Total", _yellow),
@@ -616,10 +617,10 @@ def _render_cache_session_rows(
616
617
  tot_cc = sum(r.cache_creation_tokens for r in rows)
617
618
  tot_cr = sum(r.cache_read_tokens for r in rows)
618
619
  tot_tokens = sum(r.total_tokens for r in rows)
619
- tot_cost = sum(r.cost for r in rows)
620
- tot_saved = sum(r.saved_usd for r in rows)
621
- tot_wasted = sum(r.wasted_usd for r in rows)
622
- tot_net = sum(r.net_usd for r in rows)
620
+ tot_cost = stable_sum(r.cost for r in rows)
621
+ tot_saved = stable_sum(r.saved_usd for r in rows)
622
+ tot_wasted = stable_sum(r.wasted_usd for r in rows)
623
+ tot_net = stable_sum(r.net_usd for r in rows)
623
624
  tot_hit = crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr)
624
625
 
625
626
  footer_cells = [
@@ -991,13 +992,13 @@ def _emit_cache_report_json(
991
992
  "cacheCreationTokens": tot_cc,
992
993
  "cacheReadTokens": tot_cr,
993
994
  "totalTokens": sum(r.total_tokens for r in rows),
994
- "cost": round(sum(r.cost for r in rows), 6),
995
+ "cost": round(stable_sum(r.cost for r in rows), 6),
995
996
  "cacheHitPercent": round(
996
997
  crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr), 2
997
998
  ),
998
- "savedUsd": round(sum(r.saved_usd for r in rows), 6),
999
- "wastedUsd": round(sum(r.wasted_usd for r in rows), 6),
1000
- "netUsd": round(sum(r.net_usd for r in rows), 6),
999
+ "savedUsd": round(stable_sum(r.saved_usd for r in rows), 6),
1000
+ "wastedUsd": round(stable_sum(r.wasted_usd for r in rows), 6),
1001
+ "netUsd": round(stable_sum(r.net_usd for r in rows), 6),
1001
1002
  },
1002
1003
  "generatedAt": now_utc_iso(now_utc=now_utc),
1003
1004
  }
@@ -279,6 +279,7 @@ from _lib_display_tz import (
279
279
  _compute_display_block,
280
280
  )
281
281
  from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
282
+ from _lib_fmt import stable_sum
282
283
  from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
283
284
  from _lib_five_hour import _canonical_5h_window_key
284
285
  from _lib_subscription_weeks import _compute_subscription_weeks
@@ -1319,7 +1320,7 @@ def _build_daily_share_panel_data(options: dict,
1319
1320
  # 7 and reverse to oldest→newest so the Recap template's days[-1]
1320
1321
  # anchor lands on today.
1321
1322
  last_7 = list(reversed(daily[:7]))
1322
- total = sum(float(getattr(r, "cost_usd", 0.0) or 0.0) for r in last_7) or 1.0
1323
+ total = stable_sum(float(getattr(r, "cost_usd", 0.0) or 0.0) for r in last_7) or 1.0
1323
1324
  days: list[dict] = []
1324
1325
  for r in last_7:
1325
1326
  cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
@@ -1721,10 +1722,10 @@ def _build_projects_share_panel_data(options: dict,
1721
1722
  wc = (tp.get("weekly_cost") or [])[-take:]
1722
1723
  wp = (tp.get("weekly_pct") or [])[-take:]
1723
1724
  ws = (tp.get("sessions_per_week") or [])[-take:]
1724
- cost = float(sum(wc))
1725
+ cost = float(stable_sum(wc))
1725
1726
  running_total += cost
1726
1727
  valid_pct = [float(p) for p in wp if p is not None]
1727
- attributed = sum(valid_pct) if valid_pct else None
1728
+ attributed = stable_sum(valid_pct) if valid_pct else None
1728
1729
  # Sum per-week distinct session counts. Slight over-count when a
1729
1730
  # single session spans a week boundary; the envelope's per-week
1730
1731
  # bucketing has no session-id sets to union, so this is the
@@ -1735,6 +1736,8 @@ def _build_projects_share_panel_data(options: dict,
1735
1736
  "bucket_path": tp["bucket_path"],
1736
1737
  "cost_usd": cost,
1737
1738
  "attributed_pct": attributed,
1739
+ # Integer session counts — bare sum() is exact (NOT a
1740
+ # stable_sum float-output site; see test_stable_sum_chokepoint).
1738
1741
  "sessions_count": int(sum(ws)),
1739
1742
  })
1740
1743
  rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
@@ -2193,14 +2196,14 @@ def build_cache_report_snapshot(
2193
2196
  # 7-day rollup: today + 6 prior. Walk by string date; ``days_newest_first``
2194
2197
  # is already in the right order.
2195
2198
  seven_day_rows = days[:7]
2196
- seven_day_net_usd = sum(r.net_usd for r in seven_day_rows)
2199
+ seven_day_net_usd = stable_sum(r.net_usd for r in seven_day_rows)
2197
2200
  seven_day_anomaly_count = sum(
2198
2201
  1 for r in seven_day_rows if r.anomaly_triggered
2199
2202
  )
2200
2203
 
2201
2204
  # 14-day counterfactual: sum(saved_usd) across the window.
2202
- fourteen_day_counterfactual_usd = sum(r.saved_usd for r in days)
2203
- fourteen_day_wasted_usd = sum(r.wasted_usd for r in days)
2205
+ fourteen_day_counterfactual_usd = stable_sum(r.saved_usd for r in days)
2206
+ fourteen_day_wasted_usd = stable_sum(r.wasted_usd for r in days)
2204
2207
  denom = fourteen_day_counterfactual_usd + abs(fourteen_day_wasted_usd)
2205
2208
  fourteen_day_efficiency_ratio = (
2206
2209
  (fourteen_day_counterfactual_usd / denom) if denom > 1e-9 else 0.0
@@ -3431,7 +3434,7 @@ def _build_projects_envelope(
3431
3434
  })
3432
3435
  # Stable sort: desc by total window cost, ties broken by key.
3433
3436
  trend_projects.sort(
3434
- key=lambda p: (-sum(p["weekly_cost"]), p["key"]),
3437
+ key=lambda p: (-stable_sum(p["weekly_cost"]), p["key"]),
3435
3438
  )
3436
3439
 
3437
3440
  trend_block = {
@@ -3762,7 +3765,7 @@ def _project_detail_for_window(
3762
3765
  sliced = weekly_pct_arr[-take:] if take > 0 else []
3763
3766
  wp = [p for p in sliced if p is not None]
3764
3767
  if wp:
3765
- win_pct = sum(wp)
3768
+ win_pct = stable_sum(wp)
3766
3769
 
3767
3770
  # Best-effort cleanup of the per-call TEMP TABLE so a reused conn
3768
3771
  # doesn't carry path state into the next drill (tests share conns;
@@ -2066,7 +2066,7 @@ def _is_no_such_table_error(exc: sqlite3.OperationalError) -> bool:
2066
2066
 
2067
2067
  * Substring match on the lowercased message (stable for ~20 years).
2068
2068
  * ``exc.sqlite_errorcode == SQLITE_ERROR (1)`` (Python 3.11+;
2069
- cctally's floor is 3.13 per ``__min_python_version__``). The
2069
+ cctally's floor is 3.11 per ``__min_python_version__``). The
2070
2070
  ``getattr(..., None) in (None, 1)`` form degrades gracefully if
2071
2071
  the attribute is ever missing — substring-only on legacy Python.
2072
2072
 
@@ -27,6 +27,7 @@ import os
27
27
  import sys
28
28
 
29
29
  from _cctally_core import _command_as_of, eprint, open_db, parse_iso_datetime
30
+ from _lib_fmt import stable_sum
30
31
 
31
32
 
32
33
  def _cctally():
@@ -243,11 +244,11 @@ def _project_json_output(
243
244
  denominator used by per-project attribution). `models[]` is included
244
245
  per-project only when `--breakdown` is requested to avoid payload bloat.
245
246
  """
246
- total_cost = sum(r["cost_usd"] for r in rows)
247
+ total_cost = stable_sum(r["cost_usd"] for r in rows)
247
248
  # Aggregate used % across all weeks with snapshots in the range.
248
249
  total_used_pct: float | None
249
250
  if week_snapshots:
250
- total_used_pct = sum(week_snapshots.values())
251
+ total_used_pct = stable_sum(week_snapshots.values())
251
252
  else:
252
253
  total_used_pct = None
253
254
 
@@ -34,6 +34,7 @@ import sys
34
34
  from typing import Any
35
35
 
36
36
  from _cctally_core import _command_as_of, eprint, open_db, parse_iso_datetime
37
+ from _lib_fmt import stable_sum
37
38
 
38
39
 
39
40
  def _cctally():
@@ -134,7 +135,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
134
135
  if getattr(args, "instances", False):
135
136
  groups = c._aggregate_daily_by_project(keyed, tz=tz, mode=args.mode)
136
137
  aug = c._project_disambiguate_labels(
137
- [{"key": k, "cost_usd": sum(b.cost_usd for b in bl)}
138
+ [{"key": k, "cost_usd": stable_sum(b.cost_usd for b in bl)}
138
139
  for k, bl in groups]
139
140
  )
140
141
  json_groups: list = []
@@ -998,11 +998,15 @@ def _legacy_stop_active_poller() -> str:
998
998
  # Ownership probe: the PID file is at a predictable /tmp path that
999
999
  # outlives the daemon on uncleanly exit, and macOS PIDs cycle in a
1000
1000
  # narrow space — verify the live process is actually our legacy
1001
- # poller before signaling. ps's `-o command=` emits the full cmdline
1002
- # with no header on both macOS BSD ps and Linux util-linux ps.
1001
+ # poller before signaling. `-o command=` emits the cmdline with no
1002
+ # header on both macOS BSD ps and Linux util-linux ps; `-ww` forces
1003
+ # UNLIMITED width so the cmdline is never truncated. Without it,
1004
+ # Linux util-linux ps clamps the column to ~80 chars (macOS BSD ps
1005
+ # does not), so a poller launched from a long path drops the
1006
+ # "usage-poller.py" token off the end → a false "stale-pid".
1003
1007
  try:
1004
1008
  probe = subprocess.run(
1005
- ["ps", "-p", str(pid), "-o", "command="],
1009
+ ["ps", "-ww", "-p", str(pid), "-o", "command="],
1006
1010
  capture_output=True, text=True, timeout=2.0,
1007
1011
  )
1008
1012
  except (OSError, subprocess.TimeoutExpired):
@@ -19,6 +19,7 @@ import sys
19
19
 
20
20
  import _lib_changelog # module-qualified: _lib_changelog._read_latest_changelog_version()
21
21
  from _lib_display_tz import format_display_dt
22
+ from _lib_fmt import stable_sum
22
23
  from _lib_render import _project_disambiguate_labels
23
24
 
24
25
 
@@ -406,7 +407,7 @@ def _build_report_snapshot(
406
407
  avg_dpp = view.avg_dollars_per_pct
407
408
  else:
408
409
  avg_dpp = (
409
- sum(p.y_value for p in chart_pts) / len(chart_pts)
410
+ stable_sum(p.y_value for p in chart_pts) / len(chart_pts)
410
411
  if chart_pts else 0.0
411
412
  )
412
413
  totals = (
@@ -627,7 +628,7 @@ def _build_monthly_snapshot(
627
628
  _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
628
629
  if chart_pts else None
629
630
  )
630
- sum_cost = sum(p.y_value for p in chart_pts)
631
+ sum_cost = stable_sum(p.y_value for p in chart_pts)
631
632
  avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
632
633
  totals = (
633
634
  _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
@@ -811,12 +812,12 @@ def _build_weekly_snapshot(
811
812
  )
812
813
  if chart_pts else None
813
814
  )
814
- sum_cost = sum(p.y_value for p in chart_pts)
815
+ sum_cost = stable_sum(p.y_value for p in chart_pts)
815
816
  pct_values = [
816
817
  float(o[0]) for o in overlay
817
818
  if o is not None and o[0] is not None
818
819
  ]
819
- avg_pct = (sum(pct_values) / len(pct_values)) if pct_values else 0.0
820
+ avg_pct = (stable_sum(pct_values) / len(pct_values)) if pct_values else 0.0
820
821
  peak_pct = max(pct_values, default=0.0)
821
822
  totals = (
822
823
  _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
@@ -1144,7 +1145,7 @@ def _build_project_snapshot(
1144
1145
  notes = (
1145
1146
  f"Showing top 12 in chart; table includes all {len(chart_pts)}.",
1146
1147
  )
1147
- sum_cost = sum(p.y_value for p in chart_pts)
1148
+ sum_cost = stable_sum(p.y_value for p in chart_pts)
1148
1149
  totals = (
1149
1150
  _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
1150
1151
  _lib_share.Totalled(label="Projects", value=str(len(chart_pts))),
@@ -1541,7 +1542,7 @@ def _build_session_snapshot(
1541
1542
  notes = (
1542
1543
  f"Showing top 15 in chart; table includes all {len(chart_pts)}.",
1543
1544
  )
1544
- sum_cost = sum(p.y_value for p in chart_pts)
1545
+ sum_cost = stable_sum(p.y_value for p in chart_pts)
1545
1546
  totals = (
1546
1547
  _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
1547
1548
  _lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
@@ -91,6 +91,14 @@ def _resolve_statusline_tz(cli_tz, cfg, warn_once):
91
91
  tz_name = c._local_tz_name() or "UTC"
92
92
  except Exception:
93
93
  tz_name = "UTC"
94
+ elif tz_name and tz_name.lower() == "utc":
95
+ # Canonical "utc" (the value get_display_tz_pref / normalize_display_tz_value
96
+ # emit) -> the portable IANA key "UTC". macOS's case-insensitive
97
+ # filesystem resolves ZoneInfo("utc") to UTC, but Linux's case-sensitive
98
+ # /usr/share/zoneinfo raises ZoneInfoNotFoundError, which would emit a
99
+ # spurious "invalid timezone 'utc'" warning below. Mirrors
100
+ # resolve_display_tz, which maps canonical "utc" -> ZoneInfo("Etc/UTC").
101
+ tz_name = "UTC"
94
102
  try:
95
103
  ZoneInfo(tz_name)
96
104
  except (ZoneInfoNotFoundError, Exception):
@@ -221,6 +221,7 @@ from _lib_display_tz import (
221
221
  _compute_display_block,
222
222
  )
223
223
  from _lib_aggregators import _aggregate_monthly
224
+ from _lib_fmt import stable_sum
224
225
 
225
226
 
226
227
  # === Module-level back-ref shims for helpers that STAY in bin/cctally ======
@@ -1926,11 +1927,11 @@ def _tui_build_snapshot(
1926
1927
  weekly_periods = _dashboard_build_weekly_periods(
1927
1928
  conn, now_utc, n=12, skip_sync=skip_sync
1928
1929
  )
1929
- # ``sum(..., 0.0)`` pins the type to ``float`` on empty rows so
1930
- # the envelope stays byte-stable with the pre-fix ``0.0`` shape
1930
+ # ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
1931
+ # so the envelope stays byte-stable with the pre-fix ``0.0`` shape
1931
1932
  # (the dashboard fixture goldens assert exact JSON match).
1932
- weekly_total_cost_usd = sum(
1933
- (r.cost_usd for r in weekly_periods), 0.0,
1933
+ weekly_total_cost_usd = stable_sum(
1934
+ r.cost_usd for r in weekly_periods
1934
1935
  )
1935
1936
  weekly_total_tokens = sum(
1936
1937
  (r.total_tokens for r in weekly_periods), 0,
@@ -1949,8 +1950,8 @@ def _tui_build_snapshot(
1949
1950
  conn, now_utc, n=12, skip_sync=skip_sync,
1950
1951
  display_tz=_build_display_tz,
1951
1952
  )
1952
- monthly_total_cost_usd = sum(
1953
- (r.cost_usd for r in monthly_periods), 0.0,
1953
+ monthly_total_cost_usd = stable_sum(
1954
+ r.cost_usd for r in monthly_periods
1954
1955
  )
1955
1956
  monthly_total_tokens = sum(
1956
1957
  (r.total_tokens for r in monthly_periods), 0,
@@ -1992,8 +1993,8 @@ def _tui_build_snapshot(
1992
1993
  conn, now_utc, n=30, skip_sync=skip_sync,
1993
1994
  display_tz=_build_display_tz,
1994
1995
  )
1995
- daily_total_cost_usd = sum(
1996
- (r.cost_usd for r in daily_panel), 0.0,
1996
+ daily_total_cost_usd = stable_sum(
1997
+ r.cost_usd for r in daily_panel
1997
1998
  )
1998
1999
  daily_total_tokens = sum(
1999
2000
  (r.total_tokens for r in daily_panel), 0,
@@ -4183,7 +4184,7 @@ def _tui_modal_trend(snap, runtime, width):
4183
4184
  show_age = bucket != "narrow"
4184
4185
  # Header
4185
4186
  valid = [h for h in history if h.dollars_per_percent is not None]
4186
- avg_dpp = sum(h.dollars_per_percent for h in valid) / len(valid) if valid else 0.0
4187
+ avg_dpp = stable_sum(h.dollars_per_percent for h in valid) / len(valid) if valid else 0.0
4187
4188
  if len(valid) >= 2:
4188
4189
  first_dpp = valid[0].dollars_per_percent
4189
4190
  last_dpp = valid[-1].dollars_per_percent
@@ -1890,13 +1890,13 @@ class _DashboardUpdateCheckThread(threading.Thread):
1890
1890
  snapshot_ref: "_SnapshotRef | None" = None,
1891
1891
  ) -> None:
1892
1892
  super().__init__(name="cctally-update-check")
1893
- self._stop = stop_event
1893
+ self._stop_event = stop_event
1894
1894
  self._hub = hub
1895
1895
  self._ref = snapshot_ref
1896
1896
 
1897
1897
  def run(self) -> None:
1898
1898
  c = _cctally()
1899
- while not self._stop.is_set():
1899
+ while not self._stop_event.is_set():
1900
1900
  try:
1901
1901
  # Self-heal runs every tick (every 30 min by default),
1902
1902
  # NOT gated by `_is_update_check_due`'s 24h TTL. Catches
@@ -1938,7 +1938,7 @@ class _DashboardUpdateCheckThread(threading.Thread):
1938
1938
  )
1939
1939
  except Exception:
1940
1940
  pass
1941
- self._stop.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
1941
+ self._stop_event.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
1942
1942
 
1943
1943
 
1944
1944
  def cmd_update(args) -> int:
@@ -77,6 +77,8 @@ _resolve_tz = _lib_display_tz._resolve_tz
77
77
 
78
78
  _lib_subscription_weeks = _load_lib("_lib_subscription_weeks")
79
79
  SubWeek = _lib_subscription_weeks.SubWeek
80
+ _lib_fmt = _load_lib("_lib_fmt")
81
+ stable_sum = _lib_fmt.stable_sum
80
82
 
81
83
 
82
84
  # === Honest imports from extracted homes ===================================
@@ -260,7 +262,7 @@ def _aggregate_daily_by_project(
260
262
  ranked: list[tuple[Any, list[BucketUsage], float]] = []
261
263
  for key in order:
262
264
  buckets = _aggregate_daily(grouped[key], mode=mode, tz=tz) # date-asc
263
- total = sum(b.cost_usd for b in buckets)
265
+ total = stable_sum(b.cost_usd for b in buckets)
264
266
  ranked.append((key, buckets, total))
265
267
 
266
268
  ranked.sort(key=lambda t: (-t[2], t[0].display_key))
@@ -21,6 +21,41 @@ from typing import Any, Callable, Iterable, Literal, Optional
21
21
  from zoneinfo import ZoneInfo
22
22
 
23
23
 
24
+ def _import_stable_sum():
25
+ """Resolve ``_lib_fmt.stable_sum`` — the interpreter-stable float-summation
26
+ chokepoint (math.fsum) used for output-bound cost totals.
27
+
28
+ This kernel is loaded by file path from both ``bin/cctally`` and the test
29
+ harness, where ``_lib_fmt`` (a foundational kernel) is already imported, so
30
+ the ``sys.modules`` fast path is what runs in practice. The path-load
31
+ fallback differs from ``_import_share_lib``'s: ``_lib_share`` is dep-free,
32
+ but ``_lib_fmt`` imports the leaf kernels ``_cctally_core`` +
33
+ ``_lib_display_tz`` *by name*, so the fallback must put ``bin/`` on
34
+ ``sys.path`` before executing it or those imports raise ModuleNotFoundError.
35
+ The leaves never import back, so this stays acyclic.
36
+ """
37
+ import sys
38
+ if "_lib_fmt" in sys.modules:
39
+ return sys.modules["_lib_fmt"].stable_sum
40
+ from pathlib import Path
41
+ import importlib.util
42
+ bin_dir = Path(__file__).resolve().parent
43
+ if str(bin_dir) not in sys.path:
44
+ sys.path.insert(0, str(bin_dir))
45
+ spec = importlib.util.spec_from_file_location("_lib_fmt", bin_dir / "_lib_fmt.py")
46
+ m = importlib.util.module_from_spec(spec)
47
+ sys.modules["_lib_fmt"] = m
48
+ try:
49
+ spec.loader.exec_module(m)
50
+ except Exception:
51
+ sys.modules.pop("_lib_fmt", None)
52
+ raise
53
+ return m.stable_sum
54
+
55
+
56
+ stable_sum = _import_stable_sum()
57
+
58
+
24
59
  # Anthropic's per-call >200K-tokens tier — kept in sync with bin/_lib_pricing.
25
60
  # Callers may override via the ``tiered_threshold`` kwarg.
26
61
  DEFAULT_TIERED_THRESHOLD = 200_000
@@ -732,7 +767,7 @@ def _aggregate_cache_breakdown(
732
767
  return tuple(out)
733
768
  head = out[:top_n]
734
769
  tail = out[top_n:]
735
- other_net = sum(r.net_usd for r in tail)
770
+ other_net = stable_sum(r.net_usd for r in tail)
736
771
  # True aggregate hit % over the tail buckets — sum directly from the
737
772
  # CacheBreakdownRow token fields (EFF-4 — avoids the previous triple
738
773
  # walk over ``buckets.items()``).
@@ -800,7 +835,7 @@ def _aggregate_cache_breakdown_from_rows(
800
835
  return tuple(out)
801
836
  head = out[:top_n]
802
837
  tail = out[top_n:]
803
- other_net = sum(r.net_usd for r in tail)
838
+ other_net = stable_sum(r.net_usd for r in tail)
804
839
  tail_input = sum(r.input_tokens for r in tail)
805
840
  tail_creation = sum(r.cache_creation_tokens for r in tail)
806
841
  tail_read = sum(r.cache_read_tokens for r in tail)
@@ -119,6 +119,7 @@ format_display_dt = _lib_display_tz.format_display_dt
119
119
  _lib_fmt = _load_lib("_lib_fmt")
120
120
  _style_ansi = _lib_fmt._style_ansi
121
121
  _supports_unicode_stdout = _lib_fmt._supports_unicode_stdout
122
+ stable_sum = _lib_fmt.stable_sum
122
123
 
123
124
 
124
125
  # === Honest imports from extracted homes ===================================
@@ -706,7 +707,7 @@ def _diff_resolve_used_pct(window: ParsedWindow) -> tuple:
706
707
  return None, "n/a"
707
708
  if not vals:
708
709
  return None, "n/a"
709
- return sum(vals) / len(vals), "avg"
710
+ return stable_sum(vals) / len(vals), "avg"
710
711
  finally:
711
712
  conn.close()
712
713
  return None, "n/a"
@@ -855,7 +856,7 @@ def _sum_metric_bundles(bundles) -> "MetricBundle | None":
855
856
  bundles = list(bundles)
856
857
  if not bundles:
857
858
  return None
858
- cost = sum(b.cost_usd for b in bundles)
859
+ cost = stable_sum(b.cost_usd for b in bundles)
859
860
  ti = sum(b.tokens_input or 0 for b in bundles)
860
861
  to = sum(b.tokens_output or 0 for b in bundles)
861
862
  tcr = sum(b.tokens_cache_read or 0 for b in bundles)
package/bin/_lib_fmt.py CHANGED
@@ -17,6 +17,7 @@ Spec: docs/superpowers/specs/2026-06-01-extract-fmt-color-table-primitives-desig
17
17
  from __future__ import annotations
18
18
 
19
19
  import datetime as dt
20
+ import math
20
21
  import os
21
22
  import pathlib
22
23
  import re
@@ -275,6 +276,17 @@ def _boxed_table(
275
276
  return "\n".join(out_lines)
276
277
 
277
278
 
279
+ def stable_sum(values):
280
+ """Exactly-rounded, interpreter-stable sum of float addends.
281
+
282
+ math.fsum (Shewchuk) returns a single correctly-rounded result that is
283
+ byte-identical across all CPython versions, unlike the built-in sum(),
284
+ which switched to Neumaier compensated summation for floats in 3.12. Use at
285
+ every float sum() whose result reaches byte-compared output.
286
+ """
287
+ return math.fsum(values)
288
+
289
+
278
290
  def _fmt_num(n: int) -> str:
279
291
  """Format integer with comma separators: 1234567 -> '1,234,567'."""
280
292
  return f"{n:,}"
@@ -108,6 +108,7 @@ _style_ansi = _lib_fmt._style_ansi
108
108
  _fmt_num = _lib_fmt._fmt_num
109
109
  _truncate_num = _lib_fmt._truncate_num
110
110
  _boxed_table = _lib_fmt._boxed_table
111
+ stable_sum = _lib_fmt.stable_sum
111
112
 
112
113
 
113
114
  # Module-level back-ref shims. Each shim resolves
@@ -788,7 +789,7 @@ def _bucket_totals_dict(buckets) -> dict[str, Any]:
788
789
  "outputTokens": sum(b.output_tokens for b in buckets),
789
790
  "cacheCreationTokens": sum(b.cache_creation_tokens for b in buckets),
790
791
  "cacheReadTokens": sum(b.cache_read_tokens for b in buckets),
791
- "totalCost": sum(b.cost_usd for b in buckets),
792
+ "totalCost": stable_sum(b.cost_usd for b in buckets),
792
793
  "totalTokens": sum(b.total_tokens for b in buckets),
793
794
  }
794
795
 
@@ -1354,7 +1355,7 @@ def _render_bucket_table(
1354
1355
  tot_cc = sum(d.cache_creation_tokens for d in footer_buckets)
1355
1356
  tot_cr = sum(d.cache_read_tokens for d in footer_buckets)
1356
1357
  tot_tokens = sum(d.total_tokens for d in footer_buckets)
1357
- tot_cost = sum(d.cost_usd for d in footer_buckets)
1358
+ tot_cost = stable_sum(d.cost_usd for d in footer_buckets)
1358
1359
  footer_cells = [
1359
1360
  ("Total", _yellow),
1360
1361
  ("", None),
@@ -1695,7 +1696,7 @@ def _render_weekly_table(
1695
1696
  tot_cc = sum(d.cache_creation_tokens for d in buckets)
1696
1697
  tot_cr = sum(d.cache_read_tokens for d in buckets)
1697
1698
  tot_tokens = sum(d.total_tokens for d in buckets)
1698
- tot_cost = sum(d.cost_usd for d in buckets)
1699
+ tot_cost = stable_sum(d.cost_usd for d in buckets)
1699
1700
  footer_cells = [
1700
1701
  ("Total", _yellow),
1701
1702
  ("", None),
@@ -1984,7 +1985,7 @@ def _render_codex_bucket_table(
1984
1985
  tot_reasoning = sum(b.reasoning_output_tokens for b in buckets)
1985
1986
  tot_non_cached = max(0, tot_input_inclusive - tot_cached)
1986
1987
  tot_tokens = tot_input_inclusive + tot_output
1987
- tot_cost = sum(b.cost_usd for b in buckets)
1988
+ tot_cost = stable_sum(b.cost_usd for b in buckets)
1988
1989
  footer_cells = [
1989
1990
  ("Total", _yellow),
1990
1991
  ("", None),
@@ -2264,7 +2265,7 @@ def _render_codex_session_table(
2264
2265
  tot_reasoning = sum(s.reasoning_output_tokens for s in sessions)
2265
2266
  tot_non_cached = max(0, tot_input_inclusive - tot_cached)
2266
2267
  tot_tokens = tot_input_inclusive + tot_output
2267
- tot_cost = sum(s.cost_usd for s in sessions)
2268
+ tot_cost = stable_sum(s.cost_usd for s in sessions)
2268
2269
  footer_cells = [
2269
2270
  ("Total", _yellow),
2270
2271
  ("", None), ("", None), ("", None),
@@ -2540,7 +2541,7 @@ def _render_claude_session_table(
2540
2541
  tot_output = sum(s.output_tokens for s in sessions)
2541
2542
  # Issue #104: Total Tokens footer sums all four components.
2542
2543
  tot_tokens = sum(s.total_tokens for s in sessions)
2543
- tot_cost = sum(s.cost_usd for s in sessions)
2544
+ tot_cost = stable_sum(s.cost_usd for s in sessions)
2544
2545
  footer_cells = [
2545
2546
  ("Total", _yellow),
2546
2547
  ("", None), ("", None), ("", None),
@@ -155,6 +155,40 @@ def _import_share_lib():
155
155
  _LS = _import_share_lib()
156
156
 
157
157
 
158
+ def _import_stable_sum():
159
+ """Resolve ``_lib_fmt.stable_sum`` — the interpreter-stable float-summation
160
+ chokepoint (math.fsum) used for output-bound cost totals.
161
+
162
+ The ``sys.modules`` fast path is what runs in practice (``_lib_fmt`` is a
163
+ foundational kernel already imported by ``bin/cctally`` and the test
164
+ harness). The path-load fallback differs from ``_import_share_lib``'s:
165
+ ``_lib_share`` is dep-free, but ``_lib_fmt`` imports the leaf kernels
166
+ ``_cctally_core`` + ``_lib_display_tz`` *by name*, so the fallback must put
167
+ ``bin/`` on ``sys.path`` before executing it or those imports raise
168
+ ModuleNotFoundError. The leaves never import back, so this stays acyclic.
169
+ """
170
+ import sys
171
+ if "_lib_fmt" in sys.modules:
172
+ return sys.modules["_lib_fmt"].stable_sum
173
+ from pathlib import Path
174
+ import importlib.util
175
+ bin_dir = Path(__file__).resolve().parent
176
+ if str(bin_dir) not in sys.path:
177
+ sys.path.insert(0, str(bin_dir))
178
+ spec = importlib.util.spec_from_file_location("_lib_fmt", bin_dir / "_lib_fmt.py")
179
+ m = importlib.util.module_from_spec(spec)
180
+ sys.modules["_lib_fmt"] = m
181
+ try:
182
+ spec.loader.exec_module(m)
183
+ except Exception:
184
+ sys.modules.pop("_lib_fmt", None)
185
+ raise
186
+ return m.stable_sum
187
+
188
+
189
+ stable_sum = _import_stable_sum()
190
+
191
+
158
192
  def _kpi_strip(*items: tuple[str, str]) -> tuple:
159
193
  """Generic KPI strip → tuple of `Totalled`."""
160
194
  return tuple(_LS.Totalled(label=lbl, value=val) for lbl, val in items)
@@ -280,7 +314,7 @@ def _detect_residual(
280
314
  `has_other_residual` in `_cross_tab_columns`.
281
315
  """
282
316
  for row_total, breakdown in rows_and_breakdowns:
283
- top_k_sum = sum(float(breakdown.get(lbl, 0.0)) for lbl in top_k_labels)
317
+ top_k_sum = stable_sum(float(breakdown.get(lbl, 0.0)) for lbl in top_k_labels)
284
318
  if abs(row_total - top_k_sum) > epsilon:
285
319
  return True
286
320
  return False
@@ -537,7 +571,7 @@ def _build_daily_recap(*, panel_data, options):
537
571
  start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
538
572
  end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
539
573
  end = end_anchor + _dt.timedelta(days=1)
540
- sum_cost = sum(float(d["cost_usd"]) for d in days)
574
+ sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
541
575
  return _LS.ShareSnapshot(
542
576
  cmd="daily",
543
577
  title=f"Daily — last {len(days)} day{'s' if len(days) != 1 else ''}",
@@ -591,7 +625,7 @@ def _build_monthly_recap(*, panel_data, options):
591
625
  end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
592
626
  else:
593
627
  end = start
594
- sum_cost = sum(float(m["cost_usd"]) for m in months)
628
+ sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
595
629
  return _LS.ShareSnapshot(
596
630
  cmd="monthly",
597
631
  title=f"Monthly — last {len(months)} month{'s' if len(months) != 1 else ''}",
@@ -761,7 +795,7 @@ def _build_sessions_recap(*, panel_data, options):
761
795
  sessions = panel_data.get("sessions") or []
762
796
  cap = options.get("top_n", 15)
763
797
  rows_iter = sessions[:cap]
764
- sum_cost = sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
798
+ sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
765
799
  starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
766
800
  start = min(starts) if starts else _utc_now()
767
801
  end = max(starts) if starts else start
@@ -1067,7 +1101,7 @@ def _build_daily_visual(*, panel_data, options):
1067
1101
  start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
1068
1102
  end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
1069
1103
  end = end_anchor + _dt.timedelta(days=1)
1070
- sum_cost = sum(float(d["cost_usd"]) for d in days)
1104
+ sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
1071
1105
  return _LS.ShareSnapshot(
1072
1106
  cmd="daily",
1073
1107
  title=f"Daily visual — last {len(days)} day{'s' if len(days) != 1 else ''}",
@@ -1103,7 +1137,7 @@ def _build_daily_detail(*, panel_data, options):
1103
1137
  start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
1104
1138
  end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
1105
1139
  end = end_anchor + _dt.timedelta(days=1)
1106
- sum_cost = sum(float(d["cost_usd"]) for d in days)
1140
+ sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
1107
1141
  top_n = max(int(options.get("top_n", 5)), 1)
1108
1142
 
1109
1143
  breakdowns = [dict(d.get("projects") or {}) for d in days]
@@ -1170,7 +1204,7 @@ def _build_monthly_visual(*, panel_data, options):
1170
1204
  end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
1171
1205
  else:
1172
1206
  end = start
1173
- sum_cost = sum(float(m["cost_usd"]) for m in months)
1207
+ sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
1174
1208
  return _LS.ShareSnapshot(
1175
1209
  cmd="monthly",
1176
1210
  title=f"Monthly visual — last {len(months)} month{'s' if len(months) != 1 else ''}",
@@ -1211,7 +1245,7 @@ def _build_monthly_detail(*, panel_data, options):
1211
1245
  end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
1212
1246
  else:
1213
1247
  end = start
1214
- sum_cost = sum(float(m["cost_usd"]) for m in months)
1248
+ sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
1215
1249
  top_n = max(int(options.get("top_n", 5)), 1)
1216
1250
 
1217
1251
  breakdowns = [dict(m.get("models") or {}) for m in months]
@@ -1476,7 +1510,7 @@ def _build_sessions_visual(*, panel_data, options):
1476
1510
  sessions = panel_data.get("sessions") or []
1477
1511
  cap = int(options.get("top_n", 8))
1478
1512
  rows_iter = sessions[:cap]
1479
- sum_cost = sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
1513
+ sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
1480
1514
  starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
1481
1515
  start = min(starts) if starts else _utc_now()
1482
1516
  end = max(starts) if starts else start
@@ -1522,7 +1556,7 @@ def _build_sessions_detail(*, panel_data, options):
1522
1556
  sessions = panel_data.get("sessions") or []
1523
1557
  cap = options.get("top_n", 50)
1524
1558
  rows_iter = sessions[:cap]
1525
- sum_cost = sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
1559
+ sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
1526
1560
  starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
1527
1561
  start = min(starts) if starts else _utc_now()
1528
1562
  end = max(starts) if starts else start
@@ -102,6 +102,13 @@ def _load_lib(name: str):
102
102
  return mod
103
103
 
104
104
 
105
+ # ``_lib_fmt``'s only intra-repo deps are the leaf kernels ``_cctally_core`` +
106
+ # ``_lib_display_tz`` (neither imports back), so loading it here is acyclic.
107
+ # ``stable_sum`` is the interpreter-stable
108
+ # float-summation chokepoint (math.fsum) used for output-bound totals.
109
+ stable_sum = _load_lib("_lib_fmt").stable_sum
110
+
111
+
105
112
  # === Row dataclasses (Task 2: moved verbatim from _cctally_tui.py) =========
106
113
  # Field order, types, and defaults match the originals byte-stable.
107
114
 
@@ -928,7 +935,7 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
928
935
  # least 3 samples qualify.
929
936
  valid_dpps = [r.dollars_per_percent for r in rows
930
937
  if r.dollars_per_percent is not None]
931
- avg = (sum(valid_dpps) / len(valid_dpps)) if len(valid_dpps) >= 3 else None
938
+ avg = (stable_sum(valid_dpps) / len(valid_dpps)) if len(valid_dpps) >= 3 else None
932
939
 
933
940
  # Issue #59 — pre-compute the 4-week-median-non-current dpp scalar
934
941
  # the dashboard's Trend modal hero KV displays. Rule mirrors
@@ -1290,7 +1297,7 @@ def build_blocks_view_from_table_rows(
1290
1297
  (``cmd_five_hour_blocks`` produces newest-first DESC).
1291
1298
  """
1292
1299
  rows_seq = list(block_dicts)
1293
- total_cost = sum(
1300
+ total_cost = stable_sum(
1294
1301
  float(d.get("total_cost_usd") or 0.0) for d in rows_seq
1295
1302
  )
1296
1303
  total_tok = sum(
package/bin/cctally CHANGED
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import sys
15
15
 
16
- __min_python_version__ = (3, 13)
16
+ __min_python_version__ = (3, 11)
17
17
 
18
18
  if sys.version_info < __min_python_version__:
19
19
  print(
@@ -2720,7 +2720,19 @@ def _post_command_update_hooks(command: str | None, args) -> None:
2720
2720
  ``_spawn_background_update_check`` here would create ``config.json``,
2721
2721
  ``update-state.json``, and ``update.log`` on a fresh install where
2722
2722
  the existing-install gate already makes the symlink work a no-op.
2723
- Same rationale as doctor."""
2723
+ Same rationale as doctor.
2724
+
2725
+ Test/CI seam: ``CCTALLY_DISABLE_UPDATE_CHECK`` short-circuits the whole
2726
+ hook (mirrors ``CCTALLY_DISABLE_DEV_AUTODETECT``). The background
2727
+ ``_spawn_background_update_check`` is a DETACHED process that
2728
+ ``mkdir``s APP_DIR to write ``update-state.json`` / ``update.log``; a
2729
+ fixture harness that runs a command and then asserts on APP_DIR (e.g.
2730
+ ``cctally-setup-test``'s uninstall-purge "data dir is gone" check)
2731
+ otherwise races that detached writer re-creating the dir after the
2732
+ command returns. Setting this env var makes such harnesses
2733
+ deterministic without disabling the feature for real users."""
2734
+ if os.environ.get("CCTALLY_DISABLE_UPDATE_CHECK"):
2735
+ return
2724
2736
  if command == "setup" and getattr(args, "uninstall", False):
2725
2737
  return
2726
2738
  if command == "doctor":
@@ -25,7 +25,7 @@ const result = spawnSync(python, [scriptPath, ...process.argv.slice(2)], {
25
25
  if (result.error) {
26
26
  if (result.error.code === 'ENOENT') {
27
27
  console.error(
28
- `cctally: cannot find ${python}. Install Python 3.13+ ` +
28
+ `cctally: cannot find ${python}. Install Python 3.11+ ` +
29
29
  'or set CCTALLY_PYTHON to its path.'
30
30
  );
31
31
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.26.0",
3
+ "version": "1.27.1",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {