cctally 1.8.0 → 1.8.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/bin/cctally CHANGED
@@ -80,6 +80,7 @@ import textwrap
80
80
  import threading
81
81
  import time
82
82
  import traceback
83
+ import unicodedata
83
84
  import urllib.error
84
85
  import urllib.request
85
86
  from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
@@ -358,6 +359,19 @@ TrendView = _lib_view_models.TrendView
358
359
  build_trend_view = _lib_view_models.build_trend_view
359
360
  SessionsView = _lib_view_models.SessionsView
360
361
  build_sessions_view = _lib_view_models.build_sessions_view
362
+ BlocksView = _lib_view_models.BlocksView
363
+ build_blocks_view = _lib_view_models.build_blocks_view
364
+ build_blocks_view_from_table_rows = _lib_view_models.build_blocks_view_from_table_rows
365
+ ForecastView = _lib_view_models.ForecastView
366
+ build_forecast_view = _lib_view_models.build_forecast_view
367
+ CodexDailyView = _lib_view_models.CodexDailyView
368
+ build_codex_daily_view = _lib_view_models.build_codex_daily_view
369
+ CodexMonthlyView = _lib_view_models.CodexMonthlyView
370
+ build_codex_monthly_view = _lib_view_models.build_codex_monthly_view
371
+ CodexWeeklyView = _lib_view_models.CodexWeeklyView
372
+ build_codex_weekly_view = _lib_view_models.build_codex_weekly_view
373
+ CodexSessionView = _lib_view_models.CodexSessionView
374
+ build_codex_session_view = _lib_view_models.build_codex_session_view
361
375
 
362
376
  _lib_render = _load_sibling("_lib_render")
363
377
  _CODEX_MONTHS = _lib_render._CODEX_MONTHS
@@ -776,6 +790,7 @@ _dashboard_build_monthly_periods = _cctally_dashboard._dashboard_build_monthly_p
776
790
  _dashboard_build_weekly_periods = _cctally_dashboard._dashboard_build_weekly_periods
777
791
  _build_block_detail = _cctally_dashboard._build_block_detail
778
792
  _dashboard_build_blocks_panel = _cctally_dashboard._dashboard_build_blocks_panel
793
+ _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
779
794
  _dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
780
795
  _empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
781
796
  _iso_z = _cctally_dashboard._iso_z
@@ -2691,6 +2706,29 @@ def _supports_unicode_stdout() -> bool:
2691
2706
  return "UTF" in encoding
2692
2707
 
2693
2708
 
2709
+ def _display_width(s: str) -> int:
2710
+ """Terminal cells consumed by ``s``.
2711
+
2712
+ Counts each codepoint by its East Asian Width: ``W`` / ``F`` (Wide
2713
+ / Fullwidth) → 2 cells; combining marks → 0; everything else → 1.
2714
+ Ambiguous (``A``) defaults to 1, matching every non-CJK terminal
2715
+ locale — cctally has no CJK content in cell data, and `→` / `—` /
2716
+ `·` (all `A`) are intentionally rendered narrow.
2717
+
2718
+ Used by `_boxed_table` so cells containing wide glyphs (notably
2719
+ `⚡` U+26A1 on credit-row annotations) pad to the right cell count
2720
+ rather than the right codepoint count. Without this, `len()`-based
2721
+ padding under-pads by one cell per wide glyph and the right border
2722
+ drifts off-column on those rows only.
2723
+ """
2724
+ width = 0
2725
+ for ch in s:
2726
+ if unicodedata.combining(ch):
2727
+ continue
2728
+ width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
2729
+ return width
2730
+
2731
+
2694
2732
  def _boxed_table(
2695
2733
  headers: list[str],
2696
2734
  rows: list[list[str]],
@@ -2714,15 +2752,20 @@ def _boxed_table(
2714
2752
 
2715
2753
  widths: list[int] = []
2716
2754
  for idx, header in enumerate(headers):
2717
- max_cell = max((len(r[idx]) for r in sanitized_rows), default=0)
2718
- widths.append(max(len(header), max_cell))
2755
+ max_cell = max((_display_width(r[idx]) for r in sanitized_rows), default=0)
2756
+ widths.append(max(_display_width(header), max_cell))
2719
2757
 
2720
2758
  def _pad(text: str, width: int, align: str) -> str:
2759
+ deficit = width - _display_width(text)
2760
+ if deficit <= 0:
2761
+ return text
2762
+ pad = " " * deficit
2721
2763
  if align == "right":
2722
- return text.rjust(width)
2764
+ return pad + text
2723
2765
  if align == "center":
2724
- return text.center(width)
2725
- return text.ljust(width)
2766
+ left = deficit // 2
2767
+ return (" " * left) + text + (" " * (deficit - left))
2768
+ return text + pad
2726
2769
 
2727
2770
  if _supports_unicode_stdout():
2728
2771
  chars = {
@@ -4987,13 +5030,30 @@ def cmd_blocks(args: argparse.Namespace) -> int:
4987
5030
  range_start - BLOCK_DURATION, range_end + BLOCK_DURATION,
4988
5031
  )
4989
5032
 
4990
- # Group into blocks
4991
- blocks = _group_entries_into_blocks(
4992
- all_entries, mode="auto",
5033
+ # Group into blocks via the view-model kernel (issue #56). The
5034
+ # heuristic-aware ``aggregated`` tuple holds the full Block list
5035
+ # (gaps included, oldest-first) — same shape the JSON / table
5036
+ # renderers expect. We materialize back to a list because
5037
+ # ``_maybe_swap_active_block_to_canonical`` mutates in-place.
5038
+ #
5039
+ # ``skip_rows=True`` (issue #60 review fix) opts out of the
5040
+ # dashboard-row construction inside ``build_blocks_view`` — the
5041
+ # per-block per-model enrichment that scans every entry per
5042
+ # non-gap block (O(B × N)). The CLI never reads ``view.rows``
5043
+ # (only ``view.aggregated`` here), so on large all-history blocks
5044
+ # runs we avoid quadratic-ish work we'd discard.
5045
+ view = build_blocks_view(
5046
+ all_entries,
5047
+ now_utc=now_utc,
4993
5048
  recorded_windows=recorded_windows,
4994
5049
  block_start_overrides=block_start_overrides,
4995
- now=now_utc,
5050
+ range_start=range_start,
5051
+ range_end=range_end,
5052
+ display_tz=tz,
5053
+ mode="auto",
5054
+ skip_rows=True,
4996
5055
  )
5056
+ blocks = list(view.aggregated)
4997
5057
 
4998
5058
  # Bug E (v1.7.2 round-4): when the ACTIVE block is heuristic-anchored
4999
5059
  # but a canonical ``five_hour_blocks`` row exists for the current 5h
@@ -5508,7 +5568,14 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
5508
5568
  range_start, range_end = range
5509
5569
 
5510
5570
  entries = get_codex_entries(range_start, range_end)
5511
- days = _aggregate_codex_daily(entries, tz_name=tz_name)
5571
+ # Route through ``build_codex_daily_view`` (issue #58). The View
5572
+ # wraps ``_aggregate_codex_daily`` without changing it — preserves
5573
+ # LiteLLM token semantics, intentional dedup vs upstream, and
5574
+ # ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
5575
+ view = build_codex_daily_view(
5576
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
5577
+ )
5578
+ days = list(view.rows) # asc — matches aggregator default
5512
5579
  if args.order == "desc":
5513
5580
  days = list(reversed(days))
5514
5581
 
@@ -5530,7 +5597,7 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
5530
5597
  y, m, d = bucket.split("-")
5531
5598
  return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
5532
5599
 
5533
- tz_label = tz_name or _local_tz_name()
5600
+ tz_label = view.display_tz_label
5534
5601
  title = f"Codex Token Usage Report - Daily (Timezone: {tz_label})"
5535
5602
  print(_render_codex_bucket_table(
5536
5603
  days,
@@ -5560,7 +5627,11 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
5560
5627
  range_start, range_end = range
5561
5628
 
5562
5629
  entries = get_codex_entries(range_start, range_end)
5563
- months = _aggregate_codex_monthly(entries, tz_name=tz_name)
5630
+ # Route through ``build_codex_monthly_view`` (issue #58).
5631
+ view = build_codex_monthly_view(
5632
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
5633
+ )
5634
+ months = list(view.rows)
5564
5635
  if args.order == "desc":
5565
5636
  months = list(reversed(months))
5566
5637
 
@@ -5582,7 +5653,7 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
5582
5653
  y, m = bucket.split("-")
5583
5654
  return f"{_CODEX_MONTHS[int(m) - 1]}\n{y}"
5584
5655
 
5585
- tz_label = tz_name or _local_tz_name()
5656
+ tz_label = view.display_tz_label
5586
5657
  title = f"Codex Token Usage Report - Monthly (Timezone: {tz_label})"
5587
5658
  print(_render_codex_bucket_table(
5588
5659
  months,
@@ -5615,7 +5686,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
5615
5686
  week_start_idx = WEEKDAY_MAP[week_start_name]
5616
5687
 
5617
5688
  entries = get_codex_entries(range_start, range_end)
5618
- weeks = _aggregate_codex_weekly(entries, tz_name, week_start_idx)
5689
+ # Route through ``build_codex_weekly_view`` (issue #58).
5690
+ view = build_codex_weekly_view(
5691
+ entries, now_utc=now_utc, tz_name=tz_name,
5692
+ week_start_idx=week_start_idx,
5693
+ )
5694
+ weeks = list(view.rows)
5619
5695
  if args.order == "desc":
5620
5696
  weeks = list(reversed(weeks))
5621
5697
 
@@ -5640,7 +5716,7 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
5640
5716
  y, m, d = bucket.split("-")
5641
5717
  return f"{_CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
5642
5718
 
5643
- tz_label = tz_name or _local_tz_name()
5719
+ tz_label = view.display_tz_label
5644
5720
  title = f"Codex Token Usage Report - Weekly (Timezone: {tz_label})"
5645
5721
  print(_render_codex_bucket_table(
5646
5722
  weeks,
@@ -5670,8 +5746,13 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5670
5746
  range_start, range_end = range
5671
5747
 
5672
5748
  entries = get_codex_entries(range_start, range_end)
5673
- sessions = _aggregate_codex_sessions(entries)
5674
- # Aggregator returns descending by last_activity; --order asc reverses.
5749
+ # Route through ``build_codex_session_view`` (issue #58). View rows
5750
+ # come descending by last_activity (aggregator default + upstream
5751
+ # parity); --order asc reverses.
5752
+ view = build_codex_session_view(
5753
+ entries, now_utc=_command_as_of(), tz_name=tz_name,
5754
+ )
5755
+ sessions = list(view.rows)
5675
5756
  if args.order == "asc":
5676
5757
  sessions = list(reversed(sessions))
5677
5758
 
@@ -5685,7 +5766,7 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
5685
5766
  print(_codex_sessions_to_json(sessions))
5686
5767
  return 0
5687
5768
 
5688
- tz_label = tz_name or _local_tz_name()
5769
+ tz_label = view.display_tz_label
5689
5770
  # Upstream uses "Sessions" (plural) in the session banner title.
5690
5771
  title = f"Codex Token Usage Report - Sessions (Timezone: {tz_label})"
5691
5772
  print(_render_codex_session_table(
@@ -8241,7 +8322,16 @@ def cmd_forecast(args: argparse.Namespace) -> int:
8241
8322
  # sync_cache(conn) here — that prior call ran on the stats DB connection
8242
8323
  # (wrong conn) and was a no-op for the real cache anyway.
8243
8324
 
8244
- inputs = _load_forecast_inputs(conn, now_utc, skip_sync=args.no_sync)
8325
+ # Route through ``build_forecast_view`` (issue #57). The View is the
8326
+ # kernel-pattern wrapper; ``view.output`` carries the existing
8327
+ # ``ForecastOutput`` math result so every downstream renderer here
8328
+ # (text / JSON / status-line / share) reuses the same projection,
8329
+ # verdict, budgets, and per-method rate fields without recomputing.
8330
+ view = build_forecast_view(
8331
+ conn, now_utc=now_utc, targets=tuple(targets),
8332
+ skip_sync=args.no_sync, display_tz=args._resolved_tz,
8333
+ )
8334
+ inputs = view.output.inputs if view.output is not None else None
8245
8335
  if inputs is None:
8246
8336
  # No snapshot for the current week.
8247
8337
  if getattr(args, "format", None):
@@ -8307,12 +8397,12 @@ def cmd_forecast(args: argparse.Namespace) -> int:
8307
8397
  print("forecast: no data for current week yet")
8308
8398
  return 0
8309
8399
 
8310
- output = _compute_forecast(inputs, targets)
8400
+ output = view.output
8311
8401
 
8312
8402
  # Shareable-reports gate: --format short-circuits the JSON / status-line /
8313
8403
  # terminal dispatch via `_share_render_and_emit`. The mutex in
8314
8404
  # `_add_share_args(has_status_line=True)` keeps `--format`, `--json`, and
8315
- # `--status-line` from coexisting. The gate fires AFTER `_compute_forecast`
8405
+ # `--status-line` from coexisting. The gate fires AFTER ``build_forecast_view``
8316
8406
  # so the snapshot reuses the same projection math as the terminal/JSON
8317
8407
  # paths — no parallel computation.
8318
8408
  if getattr(args, "format", None):
@@ -8857,10 +8947,21 @@ def cmd_five_hour_blocks(args: argparse.Namespace) -> int:
8857
8947
  )
8858
8948
  else:
8859
8949
  period_end = _share_now_utc()
8860
- snap = _build_five_hour_blocks_snapshot(
8950
+ # Build a BlocksView from the API-anchored table rows
8951
+ # (issue #56). Reset-aware totals come from the table's
8952
+ # per-block columns (CLAUDE.md 5-hour gotcha block) so the
8953
+ # share snapshot's footer reads from the single typed
8954
+ # source rather than re-summing inline.
8955
+ view = build_blocks_view_from_table_rows(
8861
8956
  block_dicts,
8862
8957
  period_start=period_start,
8863
8958
  period_end=period_end,
8959
+ display_tz=args._resolved_tz,
8960
+ )
8961
+ snap = _build_five_hour_blocks_snapshot(
8962
+ view,
8963
+ period_start=period_start,
8964
+ period_end=period_end,
8864
8965
  display_tz=display_tz_str,
8865
8966
  version=_share_resolve_version(),
8866
8967
  theme=args.theme,
@@ -13182,7 +13283,7 @@ def _build_project_snapshot(
13182
13283
 
13183
13284
 
13184
13285
  def _build_five_hour_blocks_snapshot(
13185
- rows: list[dict],
13286
+ view: "BlocksView",
13186
13287
  *,
13187
13288
  period_start: dt.datetime,
13188
13289
  period_end: dt.datetime,
@@ -13194,13 +13295,18 @@ def _build_five_hour_blocks_snapshot(
13194
13295
  ) -> "ShareSnapshot":
13195
13296
  """Build a ShareSnapshot for `cctally five-hour-blocks`.
13196
13297
 
13197
- `rows` is the list of per-block dicts produced inside
13198
- `cmd_five_hour_blocks` (sqlite Row converted to dict, with the
13199
- `__is_active` side-channel attached). Schema fields used:
13200
- `block_start_at` (ISO timestamp), `total_cost_usd`,
13201
- `final_five_hour_percent`, `crossed_seven_day_reset` (0/1 int),
13202
- `seven_day_pct_at_block_start`, `seven_day_pct_at_block_end`, plus
13203
- the synthetic `__is_active` flag.
13298
+ `view` is the ``BlocksView`` produced by
13299
+ ``build_blocks_view_from_table_rows`` (issue #56). The
13300
+ API-anchored block dicts (sqlite Row → dict with the
13301
+ ``__is_active`` / ``__credits`` side-channels attached) live on
13302
+ ``view.aggregated``; reset-aware totals come from
13303
+ ``view.total_cost_usd`` so the share footer reads from the typed
13304
+ single source rather than re-summing inline. Schema fields used
13305
+ from each dict: ``block_start_at`` (ISO timestamp),
13306
+ ``total_cost_usd``, ``final_five_hour_percent``,
13307
+ ``crossed_seven_day_reset`` (0/1 int),
13308
+ ``seven_day_pct_at_block_start``, ``seven_day_pct_at_block_end``,
13309
+ plus the synthetic ``__is_active`` flag.
13204
13310
 
13205
13311
  Deviations from the plan sketch (which assumed dict rows with keys
13206
13312
  `block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
@@ -13232,11 +13338,12 @@ def _build_five_hour_blocks_snapshot(
13232
13338
  the builder owns the canonical subtitle shape — no post-build
13233
13339
  re-stamp at the gate site.
13234
13340
 
13235
- Caller MUST pass `rows` already in the desired chronological order
13236
- (cmd_five_hour_blocks pulls newest-first; we reverse here so the
13237
- BarChart bars line up oldest→newest left-to-right). Tabular row
13238
- order in the snapshot is irrelevant because the snapshot is what
13239
- gets rendered (the gate site short-circuits the table renderer).
13341
+ Caller MUST pass a view whose ``aggregated`` block dicts are
13342
+ already in the desired chronological order (cmd_five_hour_blocks
13343
+ pulls newest-first; we reverse here so the BarChart bars line up
13344
+ oldest→newest left-to-right). Tabular row order in the snapshot is
13345
+ irrelevant because the snapshot is what gets rendered (the gate
13346
+ site short-circuits the table renderer).
13240
13347
  """
13241
13348
  _lib_share = _share_load_lib()
13242
13349
  columns = (
@@ -13249,9 +13356,11 @@ def _build_five_hour_blocks_snapshot(
13249
13356
  _lib_share.ColumnSpec(key="cross_reset", label="Reset",
13250
13357
  align="left"),
13251
13358
  )
13252
- # Reverse so BarChart x-axis runs oldest→newest (cmd_five_hour_blocks
13253
- # produces newest-first DESC); table-row order in the snapshot tracks
13254
- # chart order so consumer expectations align.
13359
+ # `view.aggregated` carries the newest-first DESC block dicts the
13360
+ # caller built from the SELECT. Reverse so BarChart x-axis runs
13361
+ # oldest→newest; table-row order tracks chart order so consumer
13362
+ # expectations align.
13363
+ rows = list(view.aggregated)
13255
13364
  chrono_rows = list(reversed(rows))
13256
13365
  snap_rows: list = []
13257
13366
  chart_pts: list = []
@@ -13307,7 +13416,11 @@ def _build_five_hour_blocks_snapshot(
13307
13416
  _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
13308
13417
  if chart_pts else None
13309
13418
  )
13310
- sum_cost = sum(p.y_value for p in chart_pts)
13419
+ # Reset-aware total comes from the BlocksView (issue #56); avg
13420
+ # divides by `chart_pts` count so the share footer "Sum" totalled
13421
+ # and the per-block `chart_pts` cost values share a single source-
13422
+ # of-truth at `view.total_cost_usd`.
13423
+ sum_cost = view.total_cost_usd
13311
13424
  avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
13312
13425
  crossed_count = sum(
13313
13426
  1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))