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.
- package/CHANGELOG.md +14 -0
- package/bin/_cctally_alerts.py +128 -24
- package/bin/_cctally_config.py +202 -11
- package/bin/_cctally_core.py +118 -0
- package/bin/_cctally_dashboard.py +193 -26
- package/bin/_cctally_forecast.py +480 -16
- package/bin/_cctally_milestones.py +146 -0
- package/bin/_cctally_parser.py +11 -4
- package/bin/_cctally_project.py +51 -0
- package/bin/_cctally_record.py +227 -1
- package/bin/_lib_alert_axes.py +21 -7
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +70 -0
- package/bin/cctally +19 -0
- package/dashboard/static/assets/index-C2F1_Mxt.js +18 -0
- package/dashboard/static/assets/{index-ZHOC14y-.css → index-D34qf0LE.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CXZDQrV3.js +0 -18
package/bin/_cctally_forecast.py
CHANGED
|
@@ -33,7 +33,7 @@ import math
|
|
|
33
33
|
import os
|
|
34
34
|
import sqlite3
|
|
35
35
|
import sys
|
|
36
|
-
from dataclasses import dataclass
|
|
36
|
+
from dataclasses import dataclass, replace
|
|
37
37
|
|
|
38
38
|
from _cctally_core import (
|
|
39
39
|
_command_as_of, _normalize_week_boundary_dt, compute_week_bounds,
|
|
@@ -1578,8 +1578,12 @@ def cmd_budget(args: argparse.Namespace) -> int:
|
|
|
1578
1578
|
return 2
|
|
1579
1579
|
|
|
1580
1580
|
if action == "set":
|
|
1581
|
+
if getattr(args, "project", None) is not None:
|
|
1582
|
+
return _cmd_budget_set_project(args)
|
|
1581
1583
|
return _cmd_budget_set(args)
|
|
1582
1584
|
if action == "unset":
|
|
1585
|
+
if getattr(args, "project", None) is not None:
|
|
1586
|
+
return _cmd_budget_unset_project(args)
|
|
1583
1587
|
return _cmd_budget_unset(args)
|
|
1584
1588
|
|
|
1585
1589
|
# ── bare status ──
|
|
@@ -1593,11 +1597,42 @@ def cmd_budget(args: argparse.Namespace) -> int:
|
|
|
1593
1597
|
except _BudgetConfigError as exc:
|
|
1594
1598
|
eprint(f"cctally budget: {exc}")
|
|
1595
1599
|
return 2
|
|
1600
|
+
|
|
1601
|
+
# Per-project section is appended to WHICHEVER global path runs (unset,
|
|
1602
|
+
# no-data, full status) — gated on budget.projects being non-empty. When
|
|
1603
|
+
# empty, NOTHING is appended → the existing global render paths stay
|
|
1604
|
+
# byte-identical (spec §7.3a). It needs the budget window, so the work
|
|
1605
|
+
# happens after we have a conn / now_utc below.
|
|
1606
|
+
has_projects = bool(budget_cfg["projects"])
|
|
1607
|
+
|
|
1596
1608
|
target = budget_cfg["weekly_usd"]
|
|
1597
|
-
if target is None:
|
|
1598
|
-
return _budget_render_unset(args) # exit 0, friendly message
|
|
1599
1609
|
|
|
1610
|
+
# Resolve the window-dependent per-project rows once (only when configured).
|
|
1611
|
+
# `project_rows` is None when no window resolves (no snapshot yet) — the
|
|
1612
|
+
# render paths degrade to the no-data note for the section. When projects
|
|
1613
|
+
# are unconfigured, we never open a connection just for them: the
|
|
1614
|
+
# individual global paths open their own.
|
|
1600
1615
|
now_utc = _command_as_of() # honors the CCTALLY_AS_OF testing hook
|
|
1616
|
+
project_rows = None
|
|
1617
|
+
project_window_resolved = False
|
|
1618
|
+
if has_projects:
|
|
1619
|
+
pj_conn = open_db()
|
|
1620
|
+
try:
|
|
1621
|
+
project_rows = _build_project_budget_rows(pj_conn, budget_cfg, now_utc)
|
|
1622
|
+
finally:
|
|
1623
|
+
pj_conn.close()
|
|
1624
|
+
project_window_resolved = project_rows is not None
|
|
1625
|
+
|
|
1626
|
+
if target is None:
|
|
1627
|
+
# Global budget unset → friendly message, then (if configured) the
|
|
1628
|
+
# per-project section. --json carries status:"unset" + projects[].
|
|
1629
|
+
return _budget_render_unset(
|
|
1630
|
+
args,
|
|
1631
|
+
project_rows=project_rows,
|
|
1632
|
+
has_projects=has_projects,
|
|
1633
|
+
project_window_resolved=project_window_resolved,
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1601
1636
|
conn = open_db()
|
|
1602
1637
|
inputs = _build_budget_status_inputs(
|
|
1603
1638
|
conn,
|
|
@@ -1609,26 +1644,212 @@ def cmd_budget(args: argparse.Namespace) -> int:
|
|
|
1609
1644
|
# No usage snapshot yet → no resolvable week window (spec §6 worst case).
|
|
1610
1645
|
if getattr(args, "format", None):
|
|
1611
1646
|
snap = _build_budget_no_data_snapshot(args, budget_cfg, now_utc)
|
|
1647
|
+
snap = _append_project_share_rows(snap, project_rows, has_projects)
|
|
1612
1648
|
c._share_render_and_emit(snap, args)
|
|
1613
1649
|
return 0
|
|
1614
1650
|
if getattr(args, "json", False):
|
|
1615
|
-
|
|
1651
|
+
payload = {
|
|
1616
1652
|
"schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
|
|
1617
1653
|
"status": "no_data",
|
|
1618
1654
|
"weekly_usd": target,
|
|
1619
|
-
}
|
|
1655
|
+
}
|
|
1656
|
+
if has_projects:
|
|
1657
|
+
_append_project_json(payload, project_rows)
|
|
1658
|
+
print(json.dumps(payload))
|
|
1620
1659
|
return 0
|
|
1621
1660
|
print(f"Weekly budget: ${target:,.2f} — no usage data yet this week.")
|
|
1661
|
+
_print_project_section_or_note(
|
|
1662
|
+
project_rows, has_projects, project_window_resolved, args
|
|
1663
|
+
)
|
|
1622
1664
|
return 0
|
|
1623
1665
|
|
|
1624
1666
|
status = c.compute_budget_status(inputs)
|
|
1625
1667
|
if getattr(args, "format", None):
|
|
1626
1668
|
snap = _build_budget_snapshot(args, budget_cfg, inputs, status)
|
|
1669
|
+
snap = _append_project_share_rows(snap, project_rows, has_projects)
|
|
1627
1670
|
c._share_render_and_emit(snap, args)
|
|
1628
1671
|
return 0
|
|
1629
1672
|
if getattr(args, "json", False):
|
|
1630
|
-
return _budget_emit_json(
|
|
1631
|
-
|
|
1673
|
+
return _budget_emit_json(
|
|
1674
|
+
budget_cfg, inputs, status,
|
|
1675
|
+
project_rows=project_rows, has_projects=has_projects,
|
|
1676
|
+
)
|
|
1677
|
+
rc = _budget_render_terminal(args, budget_cfg, inputs, status)
|
|
1678
|
+
_print_project_section_or_note(
|
|
1679
|
+
project_rows, has_projects, project_window_resolved, args
|
|
1680
|
+
)
|
|
1681
|
+
return rc
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _resolve_project_budget_target(raw: str):
|
|
1685
|
+
"""Resolve the ``--project`` value to a canonical git-root path.
|
|
1686
|
+
|
|
1687
|
+
BOTH branches route through ``_resolve_project_key(..., "git-root", {})``
|
|
1688
|
+
so the stored key is the SAME canonical bucket ``_sum_cost_by_project``
|
|
1689
|
+
buckets entries under — otherwise a sub-directory path (e.g.
|
|
1690
|
+
``~/code/monorepo/packages/foo`` under a monorepo git-root) would store
|
|
1691
|
+
a key that never matches any entry, permanently rendering ``$0``.
|
|
1692
|
+
|
|
1693
|
+
``__CWD__`` (the bare-flag sentinel) → resolve ``os.getcwd()`` to its
|
|
1694
|
+
``.git`` root; a result that is ``is_no_git``/``is_unknown`` (not inside
|
|
1695
|
+
a git repo) → return ``None`` (the caller emits the "not inside a git
|
|
1696
|
+
repository" error + exit 2).
|
|
1697
|
+
|
|
1698
|
+
An explicit path → resolve the same way: take ``.git_root`` when a ``.git``
|
|
1699
|
+
is found (so a sub-dir path collapses onto its monorepo root), else the
|
|
1700
|
+
normalized ``bucket_path`` (a path that is itself a git-root resolves to
|
|
1701
|
+
itself; a genuinely non-git path keeps its normalized form). Explicit
|
|
1702
|
+
paths never return ``None`` — they always resolve to a usable key.
|
|
1703
|
+
"""
|
|
1704
|
+
c = _cctally()
|
|
1705
|
+
if raw == "__CWD__":
|
|
1706
|
+
key = c._resolve_project_key(os.getcwd(), "git-root", {})
|
|
1707
|
+
if key.is_no_git or key.is_unknown or not key.git_root:
|
|
1708
|
+
return None
|
|
1709
|
+
return key.git_root
|
|
1710
|
+
key = c._resolve_project_key(raw, "git-root", {})
|
|
1711
|
+
return key.git_root or key.bucket_path
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def _cmd_budget_set_project(args: argparse.Namespace) -> int:
|
|
1715
|
+
"""`cctally budget set AMOUNT --project[=PATH]` — write one entry into
|
|
1716
|
+
`budget.projects`, keyed by the resolved canonical git-root. Writes the
|
|
1717
|
+
DEFAULT config (F4); Task 3's forward-only reconcile runs after the write."""
|
|
1718
|
+
c = _cctally()
|
|
1719
|
+
raw_amount = getattr(args, "amount", None)
|
|
1720
|
+
if raw_amount is None:
|
|
1721
|
+
eprint(
|
|
1722
|
+
"cctally budget: `set --project` requires an amount, e.g. "
|
|
1723
|
+
"cctally budget set 25 --project"
|
|
1724
|
+
)
|
|
1725
|
+
return 2
|
|
1726
|
+
try:
|
|
1727
|
+
amount = float(raw_amount)
|
|
1728
|
+
except (TypeError, ValueError):
|
|
1729
|
+
eprint(f"cctally budget: amount must be a positive number, got {raw_amount!r}")
|
|
1730
|
+
return 2
|
|
1731
|
+
if not math.isfinite(amount) or amount <= 0:
|
|
1732
|
+
eprint(
|
|
1733
|
+
f"cctally budget: amount must be a positive finite number, "
|
|
1734
|
+
f"got {raw_amount!r}"
|
|
1735
|
+
)
|
|
1736
|
+
return 2
|
|
1737
|
+
|
|
1738
|
+
root = _resolve_project_budget_target(args.project)
|
|
1739
|
+
if root is None:
|
|
1740
|
+
eprint("cctally budget: not inside a git repository")
|
|
1741
|
+
return 2
|
|
1742
|
+
|
|
1743
|
+
with c.config_writer_lock():
|
|
1744
|
+
config = c._load_config_unlocked()
|
|
1745
|
+
existing = config.get("budget")
|
|
1746
|
+
if existing is not None and not isinstance(existing, dict):
|
|
1747
|
+
eprint("cctally budget: budget config must be an object")
|
|
1748
|
+
return 2
|
|
1749
|
+
block = dict(existing or {})
|
|
1750
|
+
# Guard the merge-copy: a hand-edited non-dict `budget.projects`
|
|
1751
|
+
# (string / non-pair list) would traceback on `dict(...)` BEFORE
|
|
1752
|
+
# `_get_budget_config` can raise a controlled _BudgetConfigError.
|
|
1753
|
+
# Mirror the `existing` budget-block guard above → clean exit 2.
|
|
1754
|
+
existing_projects = block.get("projects")
|
|
1755
|
+
if existing_projects is not None and not isinstance(
|
|
1756
|
+
existing_projects, dict
|
|
1757
|
+
):
|
|
1758
|
+
eprint("cctally budget: budget.projects must be an object")
|
|
1759
|
+
return 2
|
|
1760
|
+
projects = dict(existing_projects or {})
|
|
1761
|
+
projects[root] = amount
|
|
1762
|
+
block["projects"] = projects
|
|
1763
|
+
config["budget"] = block
|
|
1764
|
+
try:
|
|
1765
|
+
validated = _get_budget_config(config)
|
|
1766
|
+
except _BudgetConfigError as exc:
|
|
1767
|
+
eprint(f"cctally budget: {exc}")
|
|
1768
|
+
return 2
|
|
1769
|
+
block["projects"] = dict(validated["projects"])
|
|
1770
|
+
config["budget"] = block
|
|
1771
|
+
c.save_config(config)
|
|
1772
|
+
|
|
1773
|
+
# Forward-only reconcile (spec §6.8): record `root`'s already-crossed
|
|
1774
|
+
# (project, threshold) pairs alerted_at-set WITHOUT dispatch, so setting a
|
|
1775
|
+
# budget mid-week (already over) doesn't storm. Scoped to the TOUCHED
|
|
1776
|
+
# project so it never latches a sibling's already-crossed-but-not-yet-
|
|
1777
|
+
# dispatched threshold (which would permanently suppress that alert).
|
|
1778
|
+
c._reconcile_project_budget_milestones_on_write(
|
|
1779
|
+
validated, touched_projects={root}
|
|
1780
|
+
)
|
|
1781
|
+
|
|
1782
|
+
basename = os.path.basename(root) or root
|
|
1783
|
+
if getattr(args, "json", False):
|
|
1784
|
+
print(json.dumps({
|
|
1785
|
+
"status": "set",
|
|
1786
|
+
"project_key": root,
|
|
1787
|
+
"budget_usd": amount,
|
|
1788
|
+
}))
|
|
1789
|
+
return 0
|
|
1790
|
+
print(f"Project budget set: {basename} ${amount:,.2f}")
|
|
1791
|
+
return 0
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def _cmd_budget_unset_project(args: argparse.Namespace) -> int:
|
|
1795
|
+
"""`cctally budget unset --project[=PATH]` — remove one `budget.projects`
|
|
1796
|
+
entry. Idempotent (message-only when absent)."""
|
|
1797
|
+
c = _cctally()
|
|
1798
|
+
root = _resolve_project_budget_target(args.project)
|
|
1799
|
+
if root is None:
|
|
1800
|
+
eprint("cctally budget: not inside a git repository")
|
|
1801
|
+
return 2
|
|
1802
|
+
|
|
1803
|
+
removed = False
|
|
1804
|
+
with c.config_writer_lock():
|
|
1805
|
+
config = c._load_config_unlocked()
|
|
1806
|
+
existing = config.get("budget")
|
|
1807
|
+
if existing is not None and not isinstance(existing, dict):
|
|
1808
|
+
eprint("cctally budget: budget config must be an object")
|
|
1809
|
+
return 2
|
|
1810
|
+
block = dict(existing or {})
|
|
1811
|
+
# Guard the merge-copy: a hand-edited non-dict `budget.projects`
|
|
1812
|
+
# would traceback on `dict(...)` before the validator can report a
|
|
1813
|
+
# controlled error (mirrors `_cmd_budget_set_project`).
|
|
1814
|
+
existing_projects = block.get("projects")
|
|
1815
|
+
if existing_projects is not None and not isinstance(
|
|
1816
|
+
existing_projects, dict
|
|
1817
|
+
):
|
|
1818
|
+
eprint("cctally budget: budget.projects must be an object")
|
|
1819
|
+
return 2
|
|
1820
|
+
projects = dict(existing_projects or {})
|
|
1821
|
+
if root in projects:
|
|
1822
|
+
projects.pop(root)
|
|
1823
|
+
removed = True
|
|
1824
|
+
block["projects"] = projects
|
|
1825
|
+
config["budget"] = block
|
|
1826
|
+
try:
|
|
1827
|
+
validated = _get_budget_config(config)
|
|
1828
|
+
except _BudgetConfigError as exc:
|
|
1829
|
+
eprint(f"cctally budget: {exc}")
|
|
1830
|
+
return 2
|
|
1831
|
+
c.save_config(config)
|
|
1832
|
+
|
|
1833
|
+
# Reconcile scoped to the TOUCHED project. The unset removed `root` from
|
|
1834
|
+
# the map, so this is a no-op for `root` — and it must NOT scan the
|
|
1835
|
+
# remaining projects: scanning them would latch a sibling's already-crossed-
|
|
1836
|
+
# but-not-yet-dispatched threshold, permanently suppressing its real alert.
|
|
1837
|
+
c._reconcile_project_budget_milestones_on_write(
|
|
1838
|
+
validated, touched_projects={root}
|
|
1839
|
+
)
|
|
1840
|
+
|
|
1841
|
+
basename = os.path.basename(root) or root
|
|
1842
|
+
if getattr(args, "json", False):
|
|
1843
|
+
print(json.dumps({
|
|
1844
|
+
"status": "unset" if removed else "noop",
|
|
1845
|
+
"project_key": root,
|
|
1846
|
+
}))
|
|
1847
|
+
return 0
|
|
1848
|
+
if removed:
|
|
1849
|
+
print(f"Project budget cleared: {basename}")
|
|
1850
|
+
else:
|
|
1851
|
+
print(f"No project budget set for: {basename}")
|
|
1852
|
+
return 0
|
|
1632
1853
|
|
|
1633
1854
|
|
|
1634
1855
|
def _cmd_budget_set(args: argparse.Namespace) -> int:
|
|
@@ -1722,21 +1943,41 @@ def _cmd_budget_unset(args: argparse.Namespace) -> int:
|
|
|
1722
1943
|
return 0
|
|
1723
1944
|
|
|
1724
1945
|
|
|
1725
|
-
def _budget_render_unset(
|
|
1726
|
-
|
|
1946
|
+
def _budget_render_unset(
|
|
1947
|
+
args: argparse.Namespace,
|
|
1948
|
+
*,
|
|
1949
|
+
project_rows=None,
|
|
1950
|
+
has_projects: bool = False,
|
|
1951
|
+
project_window_resolved: bool = False,
|
|
1952
|
+
) -> int:
|
|
1953
|
+
"""No GLOBAL budget set → friendly stdout message, exit 0 (NOT an error).
|
|
1954
|
+
|
|
1955
|
+
When per-project budgets ARE configured (``has_projects``), the
|
|
1956
|
+
per-project section is STILL rendered below the unset message (spec
|
|
1957
|
+
§7.3a) — a project-only configuration is fully supported. When
|
|
1958
|
+
``has_projects`` is False, every output mode is byte-identical to the
|
|
1959
|
+
pre-feature behavior (no ``projects`` key in --json, no extra lines).
|
|
1960
|
+
"""
|
|
1727
1961
|
c = _cctally()
|
|
1728
1962
|
if getattr(args, "format", None):
|
|
1729
1963
|
snap = _build_budget_no_budget_snapshot(args)
|
|
1964
|
+
snap = _append_project_share_rows(snap, project_rows, has_projects)
|
|
1730
1965
|
c._share_render_and_emit(snap, args)
|
|
1731
1966
|
return 0
|
|
1732
1967
|
if getattr(args, "json", False):
|
|
1733
|
-
|
|
1968
|
+
payload = {
|
|
1734
1969
|
"schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
|
|
1735
1970
|
"status": "unset",
|
|
1736
1971
|
"weekly_usd": None,
|
|
1737
|
-
}
|
|
1972
|
+
}
|
|
1973
|
+
if has_projects:
|
|
1974
|
+
_append_project_json(payload, project_rows)
|
|
1975
|
+
print(json.dumps(payload))
|
|
1738
1976
|
return 0
|
|
1739
1977
|
print("No weekly budget set. Set one with: cctally budget set <amount>.")
|
|
1978
|
+
_print_project_section_or_note(
|
|
1979
|
+
project_rows, has_projects, project_window_resolved, args
|
|
1980
|
+
)
|
|
1740
1981
|
return 0
|
|
1741
1982
|
|
|
1742
1983
|
|
|
@@ -1821,9 +2062,16 @@ def _budget_alerts_line(budget_cfg, status) -> str:
|
|
|
1821
2062
|
return f" Alerts: on · thresholds {thr_str} · {tail}"
|
|
1822
2063
|
|
|
1823
2064
|
|
|
1824
|
-
def _budget_emit_json(
|
|
2065
|
+
def _budget_emit_json(
|
|
2066
|
+
budget_cfg, inputs, status, *, project_rows=None, has_projects=False
|
|
2067
|
+
) -> int:
|
|
1825
2068
|
"""Emit the full BudgetStatus + config echo + window as JSON (schemaVersion 1).
|
|
1826
|
-
Window timestamps are `…Z`, ignoring display.tz (every --json is UTC).
|
|
2069
|
+
Window timestamps are `…Z`, ignoring display.tz (every --json is UTC).
|
|
2070
|
+
|
|
2071
|
+
When per-project budgets are configured (``has_projects``), an additive
|
|
2072
|
+
``projects: [...]`` array is appended — ADDITIVE only, NO schemaVersion
|
|
2073
|
+
bump (spec §7.4). Absent when projects are empty so the global --json stays
|
|
2074
|
+
byte-identical."""
|
|
1827
2075
|
payload = {
|
|
1828
2076
|
"schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
|
|
1829
2077
|
"status": "ok",
|
|
@@ -1846,16 +2094,232 @@ def _budget_emit_json(budget_cfg, inputs, status) -> int:
|
|
|
1846
2094
|
"low_confidence": status.low_confidence,
|
|
1847
2095
|
"crossed_thresholds": list(status.crossed_thresholds),
|
|
1848
2096
|
}
|
|
2097
|
+
if has_projects:
|
|
2098
|
+
_append_project_json(payload, project_rows)
|
|
1849
2099
|
print(json.dumps(payload))
|
|
1850
2100
|
return 0
|
|
1851
2101
|
|
|
1852
2102
|
|
|
2103
|
+
def _build_project_budget_rows(conn, budget_cfg, now_utc):
|
|
2104
|
+
"""Build per-project budget status dicts for the configured projects.
|
|
2105
|
+
|
|
2106
|
+
Returns ``None`` when no budget week window resolves (no usage snapshot
|
|
2107
|
+
yet) — the caller renders the "no usage data yet this week" note instead
|
|
2108
|
+
of a table. Otherwise a list of dicts (one per configured project),
|
|
2109
|
+
SORTED by ``consumption_pct`` descending, each carrying the same verdict
|
|
2110
|
+
fields as the global status (from one ``compute_budget_status`` codepath).
|
|
2111
|
+
|
|
2112
|
+
Spend is the shared ``_sum_cost_by_project`` scan over the current week
|
|
2113
|
+
``[week_start_at, now]``; the recent-rate input is a SECOND scan over the
|
|
2114
|
+
CLAMPED trailing-24h window ``[max(week_start_at, now-24h), now]``. The
|
|
2115
|
+
clamp at ``week_start_at`` is MANDATORY (spec §7.1): without it a fresh
|
|
2116
|
+
week (``now < week_start + 24h``) pulls pre-reset spend into
|
|
2117
|
+
``rate_recent`` and false-WARN/OVERs a project. Mirrors the global
|
|
2118
|
+
``_build_budget_status_inputs`` (forecast.py) exactly.
|
|
2119
|
+
|
|
2120
|
+
A configured ``project_key`` with no matching entry this week (deleted /
|
|
2121
|
+
moved / never-matched repo, spec §7.2) is absent from both scan maps →
|
|
2122
|
+
``spent=0`` / ``recent_24h=0`` → a ``$0 / 0% / ok`` row, never an error.
|
|
2123
|
+
"""
|
|
2124
|
+
c = _cctally()
|
|
2125
|
+
window = _resolve_current_budget_window(conn, now_utc)
|
|
2126
|
+
if window is None:
|
|
2127
|
+
return None
|
|
2128
|
+
week_start_at, week_end_at = window
|
|
2129
|
+
week = c._sum_cost_by_project(week_start_at, now_utc, mode="auto")
|
|
2130
|
+
recent_start = max(week_start_at, now_utc - dt.timedelta(hours=24))
|
|
2131
|
+
last24h = c._sum_cost_by_project(recent_start, now_utc, mode="auto")
|
|
2132
|
+
thresholds = tuple(budget_cfg["alert_thresholds"])
|
|
2133
|
+
|
|
2134
|
+
# Resolve every configured key to its ProjectKey ONCE, then route the
|
|
2135
|
+
# display labels through the shared collision-disambiguation primitive
|
|
2136
|
+
# (`_project_disambiguate_labels`, the SAME one cmd_project's table and
|
|
2137
|
+
# `_build_project_snapshot` use). A bare `display_key` is just the
|
|
2138
|
+
# basename, so two distinct git-roots sharing a basename (e.g.
|
|
2139
|
+
# `/work/app` + `/personal/app`) would BOTH render as `app` — and in
|
|
2140
|
+
# anonymized share BOTH collapse to a single `project-1`. The primitive
|
|
2141
|
+
# suffixes the colliding rows with their parent-dir segment
|
|
2142
|
+
# ("app (work)" / "app (personal)"); non-colliding rows keep `display_key`.
|
|
2143
|
+
resolver_cache: dict = {}
|
|
2144
|
+
pkeys = [
|
|
2145
|
+
c._resolve_project_key(key, "git-root", resolver_cache)
|
|
2146
|
+
for key in budget_cfg["projects"]
|
|
2147
|
+
]
|
|
2148
|
+
disambig = c._project_disambiguate_labels(
|
|
2149
|
+
[{"key": pk} for pk in pkeys]
|
|
2150
|
+
)
|
|
2151
|
+
|
|
2152
|
+
rows = []
|
|
2153
|
+
for idx, (key, target) in enumerate(budget_cfg["projects"].items()):
|
|
2154
|
+
inputs = c.BudgetInputs(
|
|
2155
|
+
target_usd=float(target),
|
|
2156
|
+
spent_usd=float(week.get(key, 0.0)),
|
|
2157
|
+
recent_24h_usd=float(last24h.get(key, 0.0)),
|
|
2158
|
+
week_start_at=week_start_at,
|
|
2159
|
+
week_end_at=week_end_at,
|
|
2160
|
+
now=now_utc,
|
|
2161
|
+
alert_thresholds=thresholds,
|
|
2162
|
+
)
|
|
2163
|
+
status = c.compute_budget_status(inputs)
|
|
2164
|
+
label = disambig.get(idx, pkeys[idx].display_key)
|
|
2165
|
+
rows.append({
|
|
2166
|
+
"project": label,
|
|
2167
|
+
"project_key": key,
|
|
2168
|
+
"budget_usd": float(target),
|
|
2169
|
+
"spent_usd": status.spent_usd,
|
|
2170
|
+
"consumption_pct": status.consumption_pct,
|
|
2171
|
+
"verdict": status.verdict,
|
|
2172
|
+
"low_confidence": status.low_confidence,
|
|
2173
|
+
})
|
|
2174
|
+
rows.sort(key=lambda r: r["consumption_pct"], reverse=True)
|
|
2175
|
+
return rows
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
def _render_project_budget_section(rows, *, color: bool) -> str:
|
|
2179
|
+
"""Render the per-project budget table as plain aligned text below the
|
|
2180
|
+
global status block (spec §7.3). Columns:
|
|
2181
|
+
``Project · Budget · Spent · Used % · Verdict`` (LOW CONF cue), already
|
|
2182
|
+
sorted by Used % desc by ``_build_project_budget_rows``."""
|
|
2183
|
+
c = _cctally()
|
|
2184
|
+
headers = ["Project", "Budget", "Spent", "Used %", "Verdict"]
|
|
2185
|
+
body = []
|
|
2186
|
+
for r in rows:
|
|
2187
|
+
verdict_label = {"ok": "OK", "warn": "WARN", "over": "OVER"}.get(
|
|
2188
|
+
r["verdict"], r["verdict"].upper()
|
|
2189
|
+
)
|
|
2190
|
+
if r["low_confidence"]:
|
|
2191
|
+
verdict_label += " (LOW CONF)"
|
|
2192
|
+
body.append([
|
|
2193
|
+
r["project"],
|
|
2194
|
+
f"${r['budget_usd']:,.2f}",
|
|
2195
|
+
f"${r['spent_usd']:,.2f}",
|
|
2196
|
+
f"{r['consumption_pct']:.1f}%",
|
|
2197
|
+
verdict_label,
|
|
2198
|
+
])
|
|
2199
|
+
# Column widths sized to content (header + every cell).
|
|
2200
|
+
widths = [len(h) for h in headers]
|
|
2201
|
+
for cells in body:
|
|
2202
|
+
for i, cell in enumerate(cells):
|
|
2203
|
+
widths[i] = max(widths[i], len(cell))
|
|
2204
|
+
# Project + Verdict left-aligned; the three money/percent columns right.
|
|
2205
|
+
aligns = ["<", ">", ">", ">", "<"]
|
|
2206
|
+
|
|
2207
|
+
def _fmt_row(cells):
|
|
2208
|
+
return " ".join(
|
|
2209
|
+
f"{cell:{aligns[i]}{widths[i]}}" for i, cell in enumerate(cells)
|
|
2210
|
+
)
|
|
2211
|
+
|
|
2212
|
+
lines = ["", "Per-project budgets:", "", (" " + _fmt_row(headers)).rstrip()]
|
|
2213
|
+
for cells, r in zip(body, rows):
|
|
2214
|
+
rendered = (" " + _fmt_row(cells)).rstrip()
|
|
2215
|
+
if color:
|
|
2216
|
+
code = _budget_verdict_ansi_code(r["verdict"])
|
|
2217
|
+
rendered = c._style_ansi(rendered, code, color)
|
|
2218
|
+
lines.append(rendered)
|
|
2219
|
+
return "\n".join(lines)
|
|
2220
|
+
|
|
2221
|
+
|
|
2222
|
+
def _print_project_section_or_note(rows, has_projects, window_resolved, args):
|
|
2223
|
+
"""Terminal helper: when projects are configured, print either the
|
|
2224
|
+
per-project table (window resolved) or a brief no-data note (parallel to
|
|
2225
|
+
the global no-data text, spec §7.3a). No-op when projects are empty so the
|
|
2226
|
+
existing global terminal output stays byte-identical."""
|
|
2227
|
+
if not has_projects:
|
|
2228
|
+
return
|
|
2229
|
+
c = _cctally()
|
|
2230
|
+
if not window_resolved:
|
|
2231
|
+
print("\nPer-project budgets: no usage data yet this week.")
|
|
2232
|
+
return
|
|
2233
|
+
print(_render_project_budget_section(rows, color=c._supports_color_stdout()))
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
def _append_project_share_rows(snap, rows, has_projects):
|
|
2237
|
+
"""Append per-project ProjectCell rows to a budget ShareSnapshot so the
|
|
2238
|
+
share-output anonymization chokepoint (``_lib_share._scrub``) rewrites the
|
|
2239
|
+
basenames under default output and reveals them under ``--reveal-projects``
|
|
2240
|
+
(spec §7.5). No-op when projects are empty → existing share goldens stay
|
|
2241
|
+
byte-identical. Project names go through ``ProjectCell`` (the single
|
|
2242
|
+
anonymization chokepoint); the [Anonymization fails closed] invariant
|
|
2243
|
+
applies."""
|
|
2244
|
+
if not has_projects or not rows:
|
|
2245
|
+
return snap
|
|
2246
|
+
c = _cctally()
|
|
2247
|
+
_lib_share = c._share_load_lib()
|
|
2248
|
+
# Reuse the snapshot's 2-col (Metric/Value) shape but render each project
|
|
2249
|
+
# as a ProjectCell in the metric column + its spend in the value.
|
|
2250
|
+
extra_rows = []
|
|
2251
|
+
# A header-ish separator row keeps the per-project block visually distinct
|
|
2252
|
+
# without changing the column schema.
|
|
2253
|
+
extra_rows.append(_lib_share.Row(cells={
|
|
2254
|
+
"metric": _lib_share.TextCell("— Per-project budgets —"),
|
|
2255
|
+
"value": _lib_share.TextCell(""),
|
|
2256
|
+
}))
|
|
2257
|
+
for r in rows:
|
|
2258
|
+
verdict = r["verdict"].upper()
|
|
2259
|
+
# `spent` is the ONLY MoneyCell in the row so
|
|
2260
|
+
# `_lib_share._collect_project_costs` (which SUMS every MoneyCell in a
|
|
2261
|
+
# ProjectCell row) spend-RANKs the anonymized labels by spend alone —
|
|
2262
|
+
# matching the `project` share convention. A TextCell here would leave
|
|
2263
|
+
# every project at cost=0, falling back to lexical ordering. Budget /
|
|
2264
|
+
# consumption / verdict stay in the visible `value` TextCell (NOT a
|
|
2265
|
+
# second MoneyCell — that would inflate the rank key to spent+budget).
|
|
2266
|
+
# The 2-col Metric/Value table renders only `metric` + `value`, so the
|
|
2267
|
+
# extra `spent` cell is invisible in the artifact while the Value
|
|
2268
|
+
# column keeps the full "spent / budget (pct) VERDICT" string.
|
|
2269
|
+
extra_rows.append(_lib_share.Row(cells={
|
|
2270
|
+
"metric": _lib_share.ProjectCell(r["project"]),
|
|
2271
|
+
"spent": _lib_share.MoneyCell(r["spent_usd"]),
|
|
2272
|
+
"value": _lib_share.TextCell(
|
|
2273
|
+
f"${r['spent_usd']:,.2f} / ${r['budget_usd']:,.2f} "
|
|
2274
|
+
f"({r['consumption_pct']:.0f}%) {verdict}"
|
|
2275
|
+
),
|
|
2276
|
+
}))
|
|
2277
|
+
return _replace_snapshot_rows(snap, tuple(snap.rows) + tuple(extra_rows))
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def _replace_snapshot_rows(snap, rows):
|
|
2281
|
+
"""Return a copy of ``snap`` with ``rows`` replaced (frozen dataclass)."""
|
|
2282
|
+
return replace(snap, rows=rows)
|
|
2283
|
+
|
|
2284
|
+
|
|
2285
|
+
def _append_project_json(payload: dict, project_rows) -> None:
|
|
2286
|
+
"""Attach the additive ``projects[]`` array to a budget ``--json`` payload
|
|
2287
|
+
(spec §7.4). Always present when this helper is called (the caller has
|
|
2288
|
+
already gated on ``has_projects``); empty ``project_rows`` → ``[]`` so a
|
|
2289
|
+
project-only configuration with no resolvable rows still emits the key.
|
|
2290
|
+
Single chokepoint for the three ``--json`` paths (full status / no-data /
|
|
2291
|
+
unset) so the append shape stays identical across them."""
|
|
2292
|
+
payload["projects"] = (
|
|
2293
|
+
_project_rows_json(project_rows) if project_rows else []
|
|
2294
|
+
)
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
def _project_rows_json(rows) -> list:
|
|
2298
|
+
"""Project the per-project status dicts onto the additive ``projects[]``
|
|
2299
|
+
JSON shape (spec §7.4): real paths, no anonymization (every --json emits
|
|
2300
|
+
real values, like ``project --json``)."""
|
|
2301
|
+
return [
|
|
2302
|
+
{
|
|
2303
|
+
"project": r["project"],
|
|
2304
|
+
"project_key": r["project_key"],
|
|
2305
|
+
"budget_usd": r["budget_usd"],
|
|
2306
|
+
"spent_usd": r["spent_usd"],
|
|
2307
|
+
"consumption_pct": r["consumption_pct"],
|
|
2308
|
+
"verdict": r["verdict"],
|
|
2309
|
+
"low_confidence": r["low_confidence"],
|
|
2310
|
+
}
|
|
2311
|
+
for r in rows
|
|
2312
|
+
]
|
|
2313
|
+
|
|
2314
|
+
|
|
1853
2315
|
def _build_budget_snapshot(args, budget_cfg, inputs, status):
|
|
1854
2316
|
"""Build a `_lib_share.ShareSnapshot` (cmd="budget") for `--format` output.
|
|
1855
2317
|
|
|
1856
|
-
|
|
1857
|
-
`
|
|
1858
|
-
|
|
2318
|
+
This builds the GLOBAL budget rows only; when per-project budgets are
|
|
2319
|
+
configured, `_append_project_share_rows` appends ProjectCell rows so
|
|
2320
|
+
`--reveal-projects` reveals (or `_scrub` anonymizes) the per-project
|
|
2321
|
+
basenames via the share chokepoint. No parallel renderer; the gate calls
|
|
2322
|
+
`_share_render_and_emit(snap, args)`."""
|
|
1859
2323
|
c = _cctally()
|
|
1860
2324
|
_lib_share = c._share_load_lib()
|
|
1861
2325
|
tz_label = c._share_display_tz_label(getattr(args, "_resolved_tz", None))
|