cctally 1.22.3 → 1.23.0

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
@@ -66,7 +66,6 @@ import textwrap
66
66
  import threading
67
67
  import time
68
68
  import traceback
69
- import unicodedata
70
69
  import urllib.error
71
70
  import urllib.request
72
71
  from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
@@ -329,6 +328,22 @@ format_display_dt = _lib_display_tz.format_display_dt
329
328
  _argparse_tz = _lib_display_tz._argparse_tz
330
329
 
331
330
 
331
+ # fmt/color/table primitives moved to _lib_fmt.py (#126 C11)
332
+ _lib_fmt = _load_sibling("_lib_fmt")
333
+ _parse_iso_datetime_optional = _lib_fmt._parse_iso_datetime_optional
334
+ _format_ts_compact = _lib_fmt._format_ts_compact
335
+ _format_week_window = _lib_fmt._format_week_window
336
+ _supports_color_stdout = _lib_fmt._supports_color_stdout
337
+ _style_ansi = _lib_fmt._style_ansi
338
+ _supports_unicode_stdout = _lib_fmt._supports_unicode_stdout
339
+ _display_width = _lib_fmt._display_width
340
+ _boxed_table = _lib_fmt._boxed_table
341
+ _fmt_num = _lib_fmt._fmt_num
342
+ _truncate_num = _lib_fmt._truncate_num
343
+ _ANSI_ESC_RE = _lib_fmt._ANSI_ESC_RE
344
+ _truncate_display = _lib_fmt._truncate_display
345
+
346
+
332
347
  _cctally_parser = _load_sibling("_cctally_parser")
333
348
  build_parser = _cctally_parser.build_parser
334
349
  _nonneg_int = _cctally_parser._nonneg_int
@@ -351,14 +366,22 @@ _build_codex_weekly_parser = _cctally_parser._build_codex_weekly_parser
351
366
  _build_codex_session_parser = _cctally_parser._build_codex_session_parser
352
367
 
353
368
 
369
+ _lib_alert_axes = _load_sibling("_lib_alert_axes")
370
+ severity_for = _lib_alert_axes.severity_for
371
+ AlertAxisDescriptor = _lib_alert_axes.AlertAxisDescriptor
372
+ AXIS_REGISTRY = _lib_alert_axes.AXIS_REGISTRY
373
+ AXIS_BY_ID = _lib_alert_axes.AXIS_BY_ID
374
+
354
375
  _lib_alerts_payload = _load_sibling("_lib_alerts_payload")
355
376
  _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
356
377
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
357
378
  _alert_text_budget = _lib_alerts_payload._alert_text_budget
379
+ _alert_text_projected = _lib_alerts_payload._alert_text_projected
358
380
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
359
381
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
360
382
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
361
383
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
384
+ _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
362
385
 
363
386
  _lib_five_hour = _load_sibling("_lib_five_hour")
364
387
  _FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
@@ -784,6 +807,8 @@ _PERCENT_NORMALIZE_DECIMALS = _cctally_record._PERCENT_NORMALIZE_DECIMALS
784
807
  _normalize_percent = _cctally_record._normalize_percent
785
808
  maybe_record_milestone = _cctally_record.maybe_record_milestone
786
809
  maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
810
+ maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
811
+ _weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
787
812
  _compute_block_totals = _cctally_record._compute_block_totals
788
813
  maybe_update_five_hour_block = _cctally_record.maybe_update_five_hour_block
789
814
  cmd_record_usage = _cctally_record.cmd_record_usage
@@ -1920,75 +1945,8 @@ ORIGINAL_ENTRYPOINT: "str | None" = None
1920
1945
  _UPDATE_WORKER: "UpdateWorker | None" = None
1921
1946
 
1922
1947
 
1923
- def _parse_iso_datetime_optional(value: Any) -> dt.datetime | None:
1924
- if not isinstance(value, str) or not value.strip():
1925
- return None
1926
- try:
1927
- return parse_iso_datetime(value, "timestamp")
1928
- except ValueError:
1929
- return None
1930
-
1931
-
1932
- def _format_ts_compact(
1933
- value: str | None,
1934
- tz: "ZoneInfo | None" = None,
1935
- ) -> str:
1936
- """Compact ISO-instant -> "YYYY-MM-DD HH:MM" line.
1937
-
1938
- F5 fix: optional ``tz`` localizes the parsed instant before strftime
1939
- AND appends the offset suffix via ``display_tz_label`` so the column
1940
- becomes unambiguous (mirrors ``format_display_dt``'s pattern). When
1941
- ``tz`` is None, returns the legacy UTC-clock string with no suffix
1942
- so existing callers keep their byte-stable output.
1943
- """
1944
- parsed = _parse_iso_datetime_optional(value)
1945
- if parsed is None:
1946
- return "n/a"
1947
- if tz is None:
1948
- # Host-local fallback / default-config path: preserve the original
1949
- # byte-stable host-naive strftime output (UTC-aware datetimes render
1950
- # UTC clock, no suffix). This branch is reachable in production for
1951
- # users whose ``display.tz`` resolves to None — NOT a legacy or
1952
- # back-compat path. The non-None branch routes through
1953
- # ``format_display_dt`` for tz-aware rendering.
1954
- return parsed.strftime("%Y-%m-%d %H:%M")
1955
- return format_display_dt(parsed, tz, fmt="%Y-%m-%d %H:%M", suffix=True)
1956
-
1957
-
1958
- def _format_week_window(
1959
- week_start_date: str | None,
1960
- week_end_date: str | None,
1961
- week_start_at: str | None,
1962
- week_end_at: str | None,
1963
- tz: "ZoneInfo | None" = None,
1964
- ) -> str:
1965
- """Render a "<start> -> <end>" week-window column. F5 adds tz-aware
1966
- rendering for ISO-timestamp-bearing rows; legacy date-only rows pass
1967
- through unchanged. ``tz=None`` preserves byte-stable callers."""
1968
- if week_start_at and week_end_at:
1969
- return (
1970
- f"{_format_ts_compact(week_start_at, tz=tz)} -> "
1971
- f"{_format_ts_compact(week_end_at, tz=tz)}"
1972
- )
1973
- return f"{week_start_date or 'n/a'} -> {week_end_date or 'n/a'}"
1974
-
1975
-
1976
- def _supports_color_stdout() -> bool:
1977
- # Matches ccusage's picocolors behavior exactly.
1978
- # FORCE_COLOR always enables (any value, including empty)
1979
- if "FORCE_COLOR" in os.environ:
1980
- return True
1981
- # NO_COLOR always disables (any value, including empty)
1982
- if "NO_COLOR" in os.environ:
1983
- return False
1984
- # CI environments get color
1985
- if "CI" in os.environ:
1986
- return True
1987
- # TTY check on stdout or stderr
1988
- if sys.stdout.isatty() or sys.stderr.isatty():
1989
- term = os.environ.get("TERM", "")
1990
- return term.lower() != "dumb"
1991
- return False
1948
+ # fmt/color/table primitives + _parse_iso_datetime_optional now live in
1949
+ # _lib_fmt.py (re-exported above) #126 C11.
1992
1950
 
1993
1951
 
1994
1952
  def _resolve_color_enabled(args: argparse.Namespace) -> bool:
@@ -2049,209 +2007,6 @@ def _resolve_color_enabled(args: argparse.Namespace) -> bool:
2049
2007
  return False
2050
2008
 
2051
2009
 
2052
- def _style_ansi(text: str, code: str, enabled: bool) -> str:
2053
- if not enabled:
2054
- return text
2055
- return f"\033[{code}m{text}\033[0m"
2056
-
2057
-
2058
- def _supports_unicode_stdout() -> bool:
2059
- encoding = (sys.stdout.encoding or "").upper()
2060
- return "UTF" in encoding
2061
-
2062
-
2063
- def _display_width(s: str) -> int:
2064
- """Terminal cells consumed by ``s``.
2065
-
2066
- Counts each codepoint by its East Asian Width: ``W`` / ``F`` (Wide
2067
- / Fullwidth) → 2 cells; combining marks → 0; everything else → 1.
2068
- Ambiguous (``A``) defaults to 1, matching every non-CJK terminal
2069
- locale — cctally has no CJK content in cell data, and `→` / `—` /
2070
- `·` (all `A`) are intentionally rendered narrow.
2071
-
2072
- Used by `_boxed_table` so cells containing wide glyphs (notably
2073
- `⚡` U+26A1 on credit-row annotations) pad to the right cell count
2074
- rather than the right codepoint count. Without this, `len()`-based
2075
- padding under-pads by one cell per wide glyph and the right border
2076
- drifts off-column on those rows only.
2077
- """
2078
- width = 0
2079
- for ch in s:
2080
- if unicodedata.combining(ch):
2081
- continue
2082
- width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
2083
- return width
2084
-
2085
-
2086
- def _boxed_table(
2087
- headers: list[str],
2088
- rows: list[list[str]],
2089
- aligns: list[str] | None = None,
2090
- *,
2091
- color_header: bool = True,
2092
- compact: bool = False,
2093
- ) -> str:
2094
- if not headers:
2095
- return ""
2096
- col_count = len(headers)
2097
- aligns = aligns or ["left"] * col_count
2098
- if len(aligns) != col_count:
2099
- raise ValueError("aligns length must match headers length")
2100
-
2101
- sanitized_rows: list[list[str]] = []
2102
- for row in rows:
2103
- normalized = [str(cell).replace("\n", " ") for cell in row]
2104
- if len(normalized) != col_count:
2105
- raise ValueError("row length must match headers length")
2106
- sanitized_rows.append(normalized)
2107
-
2108
- widths: list[int] = []
2109
- for idx, header in enumerate(headers):
2110
- max_cell = max((_display_width(r[idx]) for r in sanitized_rows), default=0)
2111
- widths.append(max(_display_width(header), max_cell))
2112
-
2113
- def _pad(text: str, width: int, align: str) -> str:
2114
- deficit = width - _display_width(text)
2115
- if deficit <= 0:
2116
- return text
2117
- pad = " " * deficit
2118
- if align == "right":
2119
- return pad + text
2120
- if align == "center":
2121
- left = deficit // 2
2122
- return (" " * left) + text + (" " * (deficit - left))
2123
- return text + pad
2124
-
2125
- if _supports_unicode_stdout():
2126
- chars = {
2127
- "top_left": "┌",
2128
- "top_mid": "┬",
2129
- "top_right": "┐",
2130
- "mid_left": "├",
2131
- "mid_mid": "┼",
2132
- "mid_right": "┤",
2133
- "bottom_left": "└",
2134
- "bottom_mid": "┴",
2135
- "bottom_right": "┘",
2136
- "h": "─",
2137
- "v": "│",
2138
- }
2139
- else:
2140
- chars = {
2141
- "top_left": "+",
2142
- "top_mid": "+",
2143
- "top_right": "+",
2144
- "mid_left": "+",
2145
- "mid_mid": "+",
2146
- "mid_right": "+",
2147
- "bottom_left": "+",
2148
- "bottom_mid": "+",
2149
- "bottom_right": "+",
2150
- "h": "-",
2151
- "v": "|",
2152
- }
2153
-
2154
- color_enabled = _supports_color_stdout()
2155
-
2156
- def _dim(s: str) -> str:
2157
- return _style_ansi(s, "90", color_enabled)
2158
-
2159
- # Issue #91 (Shape B): ``compact`` drops the 1-space cell padding to
2160
- # 0 on this content-sized table (which has no proportional-width path
2161
- # to force). Borders and rows both key off ``pad`` so the default
2162
- # (``pad == 1``) reproduces the prior output byte-for-byte.
2163
- pad = 0 if compact else 1
2164
- pad_s = " " * pad
2165
-
2166
- def make_border(left: str, mid: str, right: str) -> str:
2167
- return _dim(
2168
- left
2169
- + mid.join(chars["h"] * (w + 2 * pad) for w in widths)
2170
- + right
2171
- )
2172
-
2173
- def make_row(cells: list[str], *, header: bool = False) -> str:
2174
- is_total = not header and cells and cells[0].strip() == "Total"
2175
- styled_cells: list[str] = []
2176
- for i, raw in enumerate(cells):
2177
- text = _pad(raw, widths[i], aligns[i])
2178
- if header and color_header:
2179
- text = _style_ansi(text, "36", color_enabled) # cyan text, like ccusage table head
2180
- elif is_total:
2181
- text = _style_ansi(text, "32", color_enabled) # green text for totals
2182
- styled_cells.append(text)
2183
- v = _dim(chars["v"])
2184
- return (
2185
- v
2186
- + pad_s
2187
- + f"{pad_s}{v}{pad_s}".join(styled_cells)
2188
- + pad_s
2189
- + v
2190
- )
2191
-
2192
- top = make_border(chars["top_left"], chars["top_mid"], chars["top_right"])
2193
- mid = make_border(chars["mid_left"], chars["mid_mid"], chars["mid_right"])
2194
- bottom = make_border(chars["bottom_left"], chars["bottom_mid"], chars["bottom_right"])
2195
-
2196
- out_lines = [top, make_row(headers, header=True), mid]
2197
- for idx, row in enumerate(sanitized_rows):
2198
- out_lines.append(make_row(row, header=False))
2199
- if idx < len(sanitized_rows) - 1:
2200
- out_lines.append(mid)
2201
- out_lines.append(bottom)
2202
- return "\n".join(out_lines)
2203
-
2204
-
2205
- def _fmt_num(n: int) -> str:
2206
- """Format integer with comma separators: 1234567 -> '1,234,567'."""
2207
- return f"{n:,}"
2208
-
2209
-
2210
- def _truncate_num(formatted: str, width: int) -> str:
2211
- """Truncate a formatted number to fit width, replacing tail with '…'."""
2212
- if len(formatted) <= width:
2213
- return formatted
2214
- return formatted[: width - 1] + "\u2026"
2215
-
2216
-
2217
- _ANSI_ESC_RE = re.compile(r"\033\[[0-9;]*m")
2218
-
2219
-
2220
- def _truncate_display(text: str, width: int) -> str:
2221
- """Truncate to `width` visible chars, preserving ANSI escape sequences.
2222
-
2223
- Unlike `_truncate_num`, which slices raw string indices, this walks
2224
- the text treating `\\033[...m` sequences as zero-width and counts
2225
- printable chars toward the width budget. Used for left-aligned
2226
- cells that may carry a styled anomaly-glyph prefix — slicing those
2227
- with `_truncate_num` can cut through an ANSI escape and bleed
2228
- color into adjacent cells.
2229
- """
2230
- # Fast path: no ANSI codes, fall back to raw-slice truncation.
2231
- if "\033" not in text:
2232
- return _truncate_num(text, width)
2233
- stripped_len = len(_ANSI_ESC_RE.sub("", text))
2234
- if stripped_len <= width:
2235
- return text
2236
- # Walk chars until we've emitted (width - 1) visible chars, copying
2237
- # ANSI sequences verbatim. Append reset + ellipsis to close any open
2238
- # style and preserve the fit-to-width contract.
2239
- out: list[str] = []
2240
- visible = 0
2241
- i = 0
2242
- target = width - 1
2243
- while i < len(text) and visible < target:
2244
- m = _ANSI_ESC_RE.match(text, i)
2245
- if m:
2246
- out.append(m.group(0))
2247
- i = m.end()
2248
- continue
2249
- out.append(text[i])
2250
- visible += 1
2251
- i += 1
2252
- return "".join(out) + "\033[0m\u2026"
2253
-
2254
-
2255
2010
  def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
2256
2011
  for key in ("asOf", "costCapturedAt", "usageCapturedAt", "weekStartAt"):
2257
2012
  parsed = _parse_iso_datetime_optional(row.get(key))
@@ -2308,6 +2063,8 @@ get_milestone_cost_for_week = _cctally_milestones.get_milestone_cost_for
2308
2063
  get_milestones_for_week = _cctally_milestones.get_milestones_for_week # forecast c.; tui shim; percent-breakdown c.
2309
2064
  insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
2310
2065
  insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
2066
+ insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
2067
+ _projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
2311
2068
  _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
2312
2069
  _reconcile_budget_on_config_write = _cctally_milestones._reconcile_budget_on_config_write # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
2313
2070
 
@@ -2650,6 +2407,9 @@ _compute_cost_for_weekref = _cctally_weekrefs._compute_cost_for_weekre
2650
2407
  _apply_overlap_clamp_to_weekrefs = _cctally_weekrefs._apply_overlap_clamp_to_weekrefs
2651
2408
  _RESET_PCT_DROP_THRESHOLD = _cctally_weekrefs._RESET_PCT_DROP_THRESHOLD
2652
2409
  _FIVE_HOUR_RESET_PCT_DROP_THRESHOLD = _cctally_weekrefs._FIVE_HOUR_RESET_PCT_DROP_THRESHOLD
2410
+ _RESET_ZERO_FLOOR_PCT = _cctally_weekrefs._RESET_ZERO_FLOOR_PCT
2411
+ _RESET_ZERO_MIN_DROP_PCT = _cctally_weekrefs._RESET_ZERO_MIN_DROP_PCT
2412
+ _is_reset_drop = _cctally_weekrefs._is_reset_drop
2653
2413
 
2654
2414
 
2655
2415
  # Eager re-export of bin/_cctally_percent_breakdown.py — the percent-breakdown