cctally 1.23.0 → 1.25.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.
@@ -0,0 +1,141 @@
1
+ """Pure kernel: cross-platform alert dispatch decisions + severity->urgency.
2
+
3
+ resolve_notifier() picks the active notifier; build_command() returns the exact
4
+ arg-list to spawn (osascript / notify-send / a config-driven command_template),
5
+ or None ("no popup; log + dashboard only"). NO I/O at import time, and the
6
+ decision is parameterized on `platform` + `which_on_path` so every OS branch is
7
+ unit-testable from any host. bin/_cctally_alerts.py is the I/O glue: it injects
8
+ the real sys.platform / shutil.which and spawns the result with shell=False.
9
+
10
+ Trust model (spec Q3): alerts.command_template is *trusted local command
11
+ execution* (the user owns config.json). shell=False + the arg-list form block
12
+ alert-text shell-injection (a week label with $(...) or ; is one literal arg);
13
+ the native notify-send path adds a `--` end-of-options delimiter against
14
+ option-injection.
15
+
16
+ Stdlib-only. bin/cctally re-exports the public symbols.
17
+ Spec: docs/superpowers/specs/2026-06-02-alerts-dispatch-severity-seams-design.md
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import importlib.util as _ilu
22
+ import pathlib
23
+ import sys
24
+
25
+ # notify-send urgency tokens keyed by the 3-tier severity from
26
+ # _lib_alert_axes.severity_for.
27
+ _SEVERITY_URGENCY = {"info": "low", "warn": "normal", "critical": "critical"}
28
+
29
+ # The documented command_template substitution tokens (one-pass, no re-scan).
30
+ _TEMPLATE_TOKENS = frozenset(
31
+ {"title", "subtitle", "body", "severity", "urgency", "axis", "threshold", "metric"}
32
+ )
33
+
34
+
35
+ def _load_lib(name: str):
36
+ cached = sys.modules.get(name)
37
+ if cached is not None:
38
+ return cached
39
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
40
+ spec = _ilu.spec_from_file_location(name, p)
41
+ mod = _ilu.module_from_spec(spec)
42
+ sys.modules[name] = mod
43
+ spec.loader.exec_module(mod)
44
+ return mod
45
+
46
+
47
+ def severity_to_urgency(severity: str) -> str:
48
+ """Map a 3-tier severity to a notify-send urgency token."""
49
+ return _SEVERITY_URGENCY.get(severity, "normal")
50
+
51
+
52
+ def resolve_notifier(cfg: dict, *, platform: str, which_on_path) -> str:
53
+ """Return the effective notifier id for this host + validated alerts cfg.
54
+
55
+ `platform` is a sys.platform-style string; `which_on_path` is a callable
56
+ name -> bool. An explicitly-selected native notifier that is unavailable on
57
+ this host downgrades to 'none' (rather than being spawned and failing).
58
+ """
59
+ selector = cfg.get("notifier", "auto")
60
+ template = cfg.get("command_template")
61
+ is_darwin = platform == "darwin"
62
+ is_linux = platform.startswith("linux")
63
+
64
+ if selector == "osascript":
65
+ return "osascript" if is_darwin else "none"
66
+ if selector == "notify-send":
67
+ return "notify-send" if (is_linux and which_on_path("notify-send")) else "none"
68
+ if selector == "command":
69
+ return "command" if template else "none"
70
+ if selector == "none":
71
+ return "none"
72
+ # auto
73
+ if template:
74
+ return "command"
75
+ if is_darwin:
76
+ return "osascript"
77
+ if is_linux and which_on_path("notify-send"):
78
+ return "notify-send"
79
+ return "none"
80
+
81
+
82
+ def _substitute_tokens(arg: str, values: dict) -> str:
83
+ """One-pass left-to-right substitution: replace only the documented
84
+ {tokens}; leave every other character (including stray/unmatched braces)
85
+ literal; substituted values are NOT re-scanned. Missing keys -> ""."""
86
+ out = []
87
+ i, n = 0, len(arg)
88
+ while i < n:
89
+ ch = arg[i]
90
+ if ch == "{":
91
+ close = arg.find("}", i + 1)
92
+ if close != -1 and arg[i + 1:close] in _TEMPLATE_TOKENS:
93
+ out.append(str(values.get(arg[i + 1:close], "")))
94
+ i = close + 1
95
+ continue
96
+ out.append(ch)
97
+ i += 1
98
+ return "".join(out)
99
+
100
+
101
+ def build_command(
102
+ notifier: str,
103
+ *,
104
+ title: str,
105
+ subtitle: str,
106
+ body: str,
107
+ severity: str,
108
+ urgency: str,
109
+ payload: dict,
110
+ command_template,
111
+ ):
112
+ """Return the arg-list to spawn for `notifier`, or None ('no popup')."""
113
+ if notifier == "osascript":
114
+ esc = _load_lib("_lib_alerts_payload")._escape_applescript_string
115
+ script = (
116
+ f'display notification "{esc(body)}"'
117
+ f' with title "{esc(title)}"'
118
+ f' subtitle "{esc(subtitle)}"'
119
+ )
120
+ return ["osascript", "-e", script]
121
+ if notifier == "notify-send":
122
+ folded = f"{subtitle}\n{body}" if subtitle.strip() else body
123
+ return ["notify-send", "-u", urgency, "--", title, folded]
124
+ if notifier == "command":
125
+ if not command_template:
126
+ return None
127
+ # A payload key present-but-None (e.g. a weekly payload's metric=None)
128
+ # substitutes as "" — same surface as an absent key.
129
+ def _pv(key):
130
+ v = payload.get(key)
131
+ return "" if v is None else v
132
+
133
+ values = {
134
+ "title": title, "subtitle": subtitle, "body": body,
135
+ "severity": severity, "urgency": urgency,
136
+ "axis": _pv("axis"),
137
+ "threshold": _pv("threshold"),
138
+ "metric": _pv("metric"),
139
+ }
140
+ return [_substitute_tokens(a, values) for a in command_template]
141
+ return None # "none" or unknown -> no popup
@@ -244,6 +244,76 @@ def _build_alert_payload_budget(
244
244
  }
245
245
 
246
246
 
247
+ def _alert_text_project_budget(
248
+ payload: dict, tz: "ZoneInfo | None"
249
+ ) -> tuple[str, str, str]:
250
+ """Build (title, subtitle, body) for a PER-PROJECT equiv-$ budget threshold
251
+ alert (axis ``project_budget``, spec §5.3).
252
+
253
+ Mirrors :func:`_alert_text_budget` but prefixed with the project's basename
254
+ so a user reading the notification knows WHICH project crossed (e.g.
255
+ *"Project foo - $26.00 of $25.00 (104% of budget)"*). The rendered numbers
256
+ come from the payload (snapshotted at crossing), never live config that may
257
+ have changed since (Codex P0-4). ``week_start_at`` is an instant but the
258
+ text doesn't render it, so no ``format_display_dt`` call is needed; ``tz`` is
259
+ accepted for signature parity with peer ``_alert_text_*`` builders and
260
+ intentionally unused (same as ``_alert_text_budget``).
261
+ """
262
+ threshold = int(payload["threshold"])
263
+ ctx = payload.get("context") or {}
264
+ project = ctx.get("project") or "(project)"
265
+ title = "cctally - project budget"
266
+ subtitle = f"{project} - {threshold}% of budget"
267
+ spent = float(ctx.get("spent_usd") or 0.0)
268
+ budget = float(ctx.get("budget_usd") or 0.0)
269
+ consumption = float(ctx.get("consumption_pct") or 0.0)
270
+ body = (
271
+ f"Project {project} - ${spent:,.2f} of ${budget:,.2f} "
272
+ f"({consumption:.0f}% of budget)"
273
+ )
274
+ return title, subtitle, body
275
+
276
+
277
+ def _build_alert_payload_project_budget(
278
+ *,
279
+ threshold: int,
280
+ crossed_at_utc: str,
281
+ week_start_at: str,
282
+ project: str,
283
+ project_key: str,
284
+ budget_usd: float,
285
+ spent_usd: float,
286
+ consumption_pct: float,
287
+ ) -> dict:
288
+ """Build the alert payload for a PER-PROJECT equiv-$ budget threshold
289
+ crossing (axis ``project_budget``, the fifth alert axis; spec §5.3).
290
+
291
+ Mirrors :func:`_build_alert_payload_budget` with the project dimension
292
+ added: ``project`` is the collision-disambiguated basename
293
+ (``ProjectKey.display_key``, for human-readable notification text) and
294
+ ``project_key`` is the canonical git-root (``ProjectKey.bucket_path``, the
295
+ stable identity dimension of the UNIQUE dedup key). See
296
+ :func:`_build_alert_payload_weekly` for the ``alerted_at == crossed_at``
297
+ rationale (set-then-dispatch invariant). The dashboard envelope (Task 4)
298
+ surfaces this axis in the Recent-alerts panel from the row-sourced context.
299
+ """
300
+ return {
301
+ "id": f"project_budget:{week_start_at}:{project_key}:{int(threshold)}",
302
+ "axis": "project_budget",
303
+ "threshold": int(threshold),
304
+ "crossed_at": crossed_at_utc,
305
+ "alerted_at": crossed_at_utc, # set-then-dispatch
306
+ "context": {
307
+ "week_start_at": week_start_at,
308
+ "project": project,
309
+ "project_key": project_key,
310
+ "budget_usd": float(budget_usd),
311
+ "spent_usd": float(spent_usd),
312
+ "consumption_pct": float(consumption_pct),
313
+ },
314
+ }
315
+
316
+
247
317
  def _alert_text_projected(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
248
318
  """Build (title, subtitle, body) for a projected-pace alert (#121).
249
319
 
package/bin/cctally CHANGED
@@ -376,13 +376,20 @@ _lib_alerts_payload = _load_sibling("_lib_alerts_payload")
376
376
  _alert_text_weekly = _lib_alerts_payload._alert_text_weekly
377
377
  _alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
378
378
  _alert_text_budget = _lib_alerts_payload._alert_text_budget
379
+ _alert_text_project_budget = _lib_alerts_payload._alert_text_project_budget
379
380
  _alert_text_projected = _lib_alerts_payload._alert_text_projected
380
381
  _escape_applescript_string = _lib_alerts_payload._escape_applescript_string
381
382
  _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
382
383
  _build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
383
384
  _build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
385
+ _build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
384
386
  _build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
385
387
 
388
+ _lib_alert_dispatch = _load_sibling("_lib_alert_dispatch")
389
+ resolve_notifier = _lib_alert_dispatch.resolve_notifier
390
+ build_command = _lib_alert_dispatch.build_command
391
+ severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
392
+
386
393
  _lib_five_hour = _load_sibling("_lib_five_hour")
387
394
  _FIVE_HOUR_JITTER_FLOOR_SECONDS = _lib_five_hour._FIVE_HOUR_JITTER_FLOOR_SECONDS
388
395
  _floor_to_ten_minutes = _lib_five_hour._floor_to_ten_minutes
@@ -623,6 +630,10 @@ cmd_sync_week = _cctally_sync_week.cmd_sync_week
623
630
  # module-private (no external caller).
624
631
  _cctally_project = _load_sibling("_cctally_project")
625
632
  cmd_project = _cctally_project.cmd_project
633
+ # Shared per-project cost compute (#19/#121, spec §7.1). Re-exported so the
634
+ # per-project budget display (cmd_budget) and the Task 3 firing path reach it
635
+ # via the cctally namespace.
636
+ _sum_cost_by_project = _cctally_project._sum_cost_by_project
626
637
 
627
638
  # Eager re-export of bin/_cctally_pricing_check.py — `cmd_pricing_check`
628
639
  # is invoked via the parser's `set_defaults(func=c.cmd_pricing_check)`,
@@ -807,6 +818,7 @@ _PERCENT_NORMALIZE_DECIMALS = _cctally_record._PERCENT_NORMALIZE_DECIMALS
807
818
  _normalize_percent = _cctally_record._normalize_percent
808
819
  maybe_record_milestone = _cctally_record.maybe_record_milestone
809
820
  maybe_record_budget_milestone = _cctally_record.maybe_record_budget_milestone
821
+ maybe_record_project_budget_milestone = _cctally_record.maybe_record_project_budget_milestone
810
822
  maybe_record_projected_alert = _cctally_record.maybe_record_projected_alert
811
823
  _weekly_pct_week_avg_projection = _cctally_record._weekly_pct_week_avg_projection
812
824
  _compute_block_totals = _cctally_record._compute_block_totals
@@ -1102,6 +1114,11 @@ _render_forecast_terminal = _cctally_forecast._render_forecast_terminal
1102
1114
  _BUDGET_JSON_SCHEMA_VERSION = _cctally_forecast._BUDGET_JSON_SCHEMA_VERSION
1103
1115
  _cmd_budget_set = _cctally_forecast._cmd_budget_set
1104
1116
  _cmd_budget_unset = _cctally_forecast._cmd_budget_unset
1117
+ # Per-project budget set/unset (#19/#121, spec §4.3 / §7).
1118
+ _resolve_project_budget_target = _cctally_forecast._resolve_project_budget_target
1119
+ _cmd_budget_set_project = _cctally_forecast._cmd_budget_set_project
1120
+ _cmd_budget_unset_project = _cctally_forecast._cmd_budget_unset_project
1121
+ _build_project_budget_rows = _cctally_forecast._build_project_budget_rows
1105
1122
  _budget_render_unset = _cctally_forecast._budget_render_unset
1106
1123
  _budget_verdict_ansi_code = _cctally_forecast._budget_verdict_ansi_code
1107
1124
  _budget_render_terminal = _cctally_forecast._budget_render_terminal
@@ -2063,10 +2080,12 @@ get_milestone_cost_for_week = _cctally_milestones.get_milestone_cost_for
2063
2080
  get_milestones_for_week = _cctally_milestones.get_milestones_for_week # forecast c.; tui shim; percent-breakdown c.
2064
2081
  insert_percent_milestone = _cctally_milestones.insert_percent_milestone # record shim; idempotency-test mod.
2065
2082
  insert_budget_milestone = _cctally_milestones.insert_budget_milestone # record shim
2083
+ insert_project_budget_milestone = _cctally_milestones.insert_project_budget_milestone # record shim; project-budget-config-test ns[]
2066
2084
  insert_projected_milestone = _cctally_milestones.insert_projected_milestone # record shim
2067
2085
  _projected_levels_already_latched = _cctally_milestones._projected_levels_already_latched # record shim
2068
2086
  _reconcile_budget_milestones_on_set = _cctally_milestones._reconcile_budget_milestones_on_set # test_budget_alerts ns[]
2069
2087
  _reconcile_budget_on_config_write = _cctally_milestones._reconcile_budget_on_config_write # forecast/config/dashboard c.; test_forecast_ns_patch mod. patch
2088
+ _reconcile_project_budget_milestones_on_write = _cctally_milestones._reconcile_project_budget_milestones_on_write # forecast/config/dashboard c. (forward-only project-budget reconcile)
2070
2089
 
2071
2090
 
2072
2091
  # === Update-banner predicate (spec §4.2) extracted to