cctally 1.95.4 → 1.96.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 +71 -0
- package/README.md +4 -5
- package/bin/_cctally_config.py +46 -22
- package/bin/_cctally_core.py +125 -42
- package/bin/_cctally_dashboard.py +258 -100
- package/bin/_cctally_dashboard_cache_report.py +24 -1
- package/bin/_cctally_dashboard_envelope.py +11 -0
- package/bin/_cctally_dashboard_share.py +38 -4
- package/bin/_cctally_db.py +1 -1
- package/bin/_cctally_journal.py +272 -20
- package/bin/_cctally_parser.py +14 -3
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_reporting.py +12 -8
- package/bin/_cctally_share.py +86 -12
- package/bin/_cctally_store.py +26 -21
- package/bin/_cctally_tui.py +11 -5
- package/bin/_lib_dashboard_settings_contract.py +86 -0
- package/bin/_lib_journal.py +4 -2
- package/bin/_lib_render.py +47 -20
- package/bin/_lib_share.py +191 -48
- package/bin/cctally +5 -1
- package/dashboard/static/assets/index-Bhr5gZ14.js +97 -0
- package/dashboard/static/assets/index-DfN_fsLZ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BvCbpJJA.css +0 -1
- package/dashboard/static/assets/index-CRZMxeYI.js +0 -97
package/bin/_cctally_core.py
CHANGED
|
@@ -227,6 +227,16 @@ def _init_paths_from_env() -> None:
|
|
|
227
227
|
CLAUDE_PROJECTS_DIR = home / ".claude" / "projects"
|
|
228
228
|
|
|
229
229
|
|
|
230
|
+
# The statusline OAuth cache is HOST-GLOBAL and shared with the real Claude
|
|
231
|
+
# Code statusline, so /tmp stays the production location -- relocating it would
|
|
232
|
+
# change behaviour for real users. It lives here, as a module constant rather
|
|
233
|
+
# than inside _init_paths_from_env(), because it is deliberately NOT derived
|
|
234
|
+
# from APP_DIR or HOME; putting it here is what lets redirect_paths pin it and
|
|
235
|
+
# lets _bust_statusline_cache resolve it at CALL time instead of binding it as
|
|
236
|
+
# a default argument at import (#529 S4, spec section 5.4).
|
|
237
|
+
STATUSLINE_OAUTH_CACHE_PATH = "/tmp/claude-statusline-usage-cache.json"
|
|
238
|
+
|
|
239
|
+
|
|
230
240
|
def _truthy_env(name: str) -> bool:
|
|
231
241
|
"""A ``1``/``true``/``yes``/any-other-non-empty env value is truthy;
|
|
232
242
|
unset, empty, ``0``, ``false``, ``no`` are falsey (case-insensitive,
|
|
@@ -882,7 +892,20 @@ def note_stats_maintenance_released() -> None:
|
|
|
882
892
|
|
|
883
893
|
|
|
884
894
|
class _AlertsConfigError(ValueError):
|
|
885
|
-
"""Raised by _get_alerts_config on invalid alerts block.
|
|
895
|
+
"""Raised by _get_alerts_config on invalid alerts block.
|
|
896
|
+
|
|
897
|
+
``field`` carries the offending dotted config path (e.g.
|
|
898
|
+
``"alerts.notifier"``) so ``POST /api/settings`` can answer
|
|
899
|
+
``{error, field}`` on every rejection. It is set explicitly at each
|
|
900
|
+
raise site and never inferred from the message text, because inferring
|
|
901
|
+
it would let a reworded message silently move a machine-readable
|
|
902
|
+
pointer. Keyword-only with a ``None`` default, so every existing CLI
|
|
903
|
+
caller keeps working unchanged.
|
|
904
|
+
"""
|
|
905
|
+
|
|
906
|
+
def __init__(self, message: str, *, field: "str | None" = None) -> None:
|
|
907
|
+
super().__init__(message)
|
|
908
|
+
self.field = field
|
|
886
909
|
|
|
887
910
|
|
|
888
911
|
_ALERTS_CONFIG_VALID_KEYS = {
|
|
@@ -907,11 +930,15 @@ def _validate_threshold_list(name: str, value: object) -> "list[int]":
|
|
|
907
930
|
Error messages mention `alerts.<name>` so users can locate the
|
|
908
931
|
offending key in their config.json.
|
|
909
932
|
"""
|
|
933
|
+
field = f"alerts.{name}"
|
|
910
934
|
if not isinstance(value, list):
|
|
911
|
-
raise _AlertsConfigError(
|
|
935
|
+
raise _AlertsConfigError(
|
|
936
|
+
f"alerts.{name} must be a list of integers", field=field
|
|
937
|
+
)
|
|
912
938
|
if len(value) == 0:
|
|
913
939
|
raise _AlertsConfigError(
|
|
914
|
-
f"alerts.{name} must not be empty (disable alerts via alerts.enabled=false)"
|
|
940
|
+
f"alerts.{name} must not be empty (disable alerts via alerts.enabled=false)",
|
|
941
|
+
field=field,
|
|
915
942
|
)
|
|
916
943
|
out: "list[int]" = []
|
|
917
944
|
prev = -1
|
|
@@ -919,19 +946,23 @@ def _validate_threshold_list(name: str, value: object) -> "list[int]":
|
|
|
919
946
|
for item in value:
|
|
920
947
|
if not isinstance(item, int) or isinstance(item, bool):
|
|
921
948
|
raise _AlertsConfigError(
|
|
922
|
-
f"alerts.{name} items must be integers, got {type(item).__name__}: {item!r}"
|
|
949
|
+
f"alerts.{name} items must be integers, got {type(item).__name__}: {item!r}",
|
|
950
|
+
field=field,
|
|
923
951
|
)
|
|
924
952
|
if item < 1 or item > 100:
|
|
925
953
|
raise _AlertsConfigError(
|
|
926
|
-
f"alerts.{name} items must be in [1, 100], got {item}"
|
|
954
|
+
f"alerts.{name} items must be in [1, 100], got {item}",
|
|
955
|
+
field=field,
|
|
927
956
|
)
|
|
928
957
|
if item in seen:
|
|
929
958
|
raise _AlertsConfigError(
|
|
930
|
-
f"alerts.{name} contains duplicate value {item}"
|
|
959
|
+
f"alerts.{name} contains duplicate value {item}",
|
|
960
|
+
field=field,
|
|
931
961
|
)
|
|
932
962
|
if item <= prev:
|
|
933
963
|
raise _AlertsConfigError(
|
|
934
|
-
f"alerts.{name} must be strictly increasing, got {prev} then {item}"
|
|
964
|
+
f"alerts.{name} must be strictly increasing, got {prev} then {item}",
|
|
965
|
+
field=field,
|
|
935
966
|
)
|
|
936
967
|
seen.add(item)
|
|
937
968
|
prev = item
|
|
@@ -949,7 +980,7 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
949
980
|
"""
|
|
950
981
|
block = (cfg or {}).get("alerts", {}) or {}
|
|
951
982
|
if not isinstance(block, dict):
|
|
952
|
-
raise _AlertsConfigError("alerts must be an object")
|
|
983
|
+
raise _AlertsConfigError("alerts must be an object", field="alerts")
|
|
953
984
|
# warn-and-ignore unknown keys (forward compat; matches display.tz posture)
|
|
954
985
|
for k in block.keys():
|
|
955
986
|
if k not in _ALERTS_CONFIG_VALID_KEYS:
|
|
@@ -960,7 +991,8 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
960
991
|
enabled = block.get("enabled", False)
|
|
961
992
|
if not isinstance(enabled, bool):
|
|
962
993
|
raise _AlertsConfigError(
|
|
963
|
-
f"alerts.enabled must be a JSON boolean, got {type(enabled).__name__}: {enabled!r}"
|
|
994
|
+
f"alerts.enabled must be a JSON boolean, got {type(enabled).__name__}: {enabled!r}",
|
|
995
|
+
field="alerts.enabled",
|
|
964
996
|
)
|
|
965
997
|
weekly = _validate_threshold_list(
|
|
966
998
|
"weekly_thresholds", block.get("weekly_thresholds", [90, 95])
|
|
@@ -975,7 +1007,8 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
975
1007
|
if not isinstance(projected_enabled, bool):
|
|
976
1008
|
raise _AlertsConfigError(
|
|
977
1009
|
f"alerts.projected_enabled must be a JSON boolean, got "
|
|
978
|
-
f"{type(projected_enabled).__name__}: {projected_enabled!r}"
|
|
1010
|
+
f"{type(projected_enabled).__name__}: {projected_enabled!r}",
|
|
1011
|
+
field="alerts.projected_enabled",
|
|
979
1012
|
)
|
|
980
1013
|
# Dispatch-global keys (Phase B). `notifier` selects the backend;
|
|
981
1014
|
# `command_template` is an argv list for the `command` backend (and may be
|
|
@@ -985,31 +1018,39 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
985
1018
|
if notifier not in _ALERTS_VALID_NOTIFIERS:
|
|
986
1019
|
raise _AlertsConfigError(
|
|
987
1020
|
f"alerts.notifier must be one of {list(_ALERTS_VALID_NOTIFIERS)}, "
|
|
988
|
-
f"got {notifier!r}"
|
|
1021
|
+
f"got {notifier!r}",
|
|
1022
|
+
field="alerts.notifier",
|
|
989
1023
|
)
|
|
990
1024
|
command_template = block.get("command_template", None)
|
|
991
1025
|
if command_template is not None:
|
|
992
1026
|
if not isinstance(command_template, list) or not command_template:
|
|
993
1027
|
raise _AlertsConfigError(
|
|
994
|
-
"alerts.command_template must be null or a non-empty list of strings"
|
|
1028
|
+
"alerts.command_template must be null or a non-empty list of strings",
|
|
1029
|
+
field="alerts.command_template",
|
|
995
1030
|
)
|
|
996
1031
|
for el in command_template:
|
|
997
1032
|
if not isinstance(el, str):
|
|
998
1033
|
raise _AlertsConfigError(
|
|
999
1034
|
f"alerts.command_template elements must be strings, "
|
|
1000
|
-
f"got {type(el).__name__}: {el!r}"
|
|
1035
|
+
f"got {type(el).__name__}: {el!r}",
|
|
1036
|
+
field="alerts.command_template",
|
|
1001
1037
|
)
|
|
1002
1038
|
if "\x00" in el:
|
|
1003
1039
|
raise _AlertsConfigError(
|
|
1004
|
-
"alerts.command_template elements must not contain a NUL byte"
|
|
1040
|
+
"alerts.command_template elements must not contain a NUL byte",
|
|
1041
|
+
field="alerts.command_template",
|
|
1005
1042
|
)
|
|
1006
1043
|
if not command_template[0].strip():
|
|
1007
1044
|
raise _AlertsConfigError(
|
|
1008
|
-
"alerts.command_template[0] (the program) must not be empty/whitespace"
|
|
1045
|
+
"alerts.command_template[0] (the program) must not be empty/whitespace",
|
|
1046
|
+
field="alerts.command_template",
|
|
1009
1047
|
)
|
|
1010
1048
|
if notifier == "command" and command_template is None:
|
|
1049
|
+
# Cross-field: point at the leaf the caller just set, not at the
|
|
1050
|
+
# absent one, so a dashboard save highlights the field it sent.
|
|
1011
1051
|
raise _AlertsConfigError(
|
|
1012
|
-
"alerts.notifier='command' requires alerts.command_template to be set"
|
|
1052
|
+
"alerts.notifier='command' requires alerts.command_template to be set",
|
|
1053
|
+
field="alerts.notifier",
|
|
1013
1054
|
)
|
|
1014
1055
|
return {
|
|
1015
1056
|
"enabled": enabled,
|
|
@@ -1025,7 +1066,16 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
|
|
|
1025
1066
|
|
|
1026
1067
|
|
|
1027
1068
|
class _BudgetConfigError(ValueError):
|
|
1028
|
-
"""Raised by _get_budget_config on an invalid budget block.
|
|
1069
|
+
"""Raised by _get_budget_config on an invalid budget block.
|
|
1070
|
+
|
|
1071
|
+
``field`` follows the same contract as ``_AlertsConfigError.field``:
|
|
1072
|
+
the offending dotted path (e.g. ``"budget.codex.alerts_enabled"``),
|
|
1073
|
+
set explicitly at the raise site, keyword-only, defaulting to ``None``.
|
|
1074
|
+
"""
|
|
1075
|
+
|
|
1076
|
+
def __init__(self, message: str, *, field: "str | None" = None) -> None:
|
|
1077
|
+
super().__init__(message)
|
|
1078
|
+
self.field = field
|
|
1029
1079
|
|
|
1030
1080
|
|
|
1031
1081
|
def _validate_positive_budget_amount(v: object, label: str) -> float:
|
|
@@ -1039,9 +1089,11 @@ def _validate_positive_budget_amount(v: object, label: str) -> float:
|
|
|
1039
1089
|
only validates a value the caller has already decided must be a number.
|
|
1040
1090
|
"""
|
|
1041
1091
|
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
1042
|
-
raise _BudgetConfigError(f"{label} must be a number")
|
|
1092
|
+
raise _BudgetConfigError(f"{label} must be a number", field=label)
|
|
1043
1093
|
if not math.isfinite(float(v)) or float(v) <= 0:
|
|
1044
|
-
raise _BudgetConfigError(
|
|
1094
|
+
raise _BudgetConfigError(
|
|
1095
|
+
f"{label} must be a finite number > 0", field=label
|
|
1096
|
+
)
|
|
1045
1097
|
return float(v)
|
|
1046
1098
|
|
|
1047
1099
|
|
|
@@ -1088,23 +1140,26 @@ def _validate_account_budget_map(v: object, label: str) -> "dict[str, float]":
|
|
|
1088
1140
|
each value a non-bool finite number > 0. Returns a cleaned copy."""
|
|
1089
1141
|
if not isinstance(v, dict):
|
|
1090
1142
|
raise _BudgetConfigError(
|
|
1091
|
-
f"{label} must be an object, got {type(v).__name__}"
|
|
1143
|
+
f"{label} must be an object, got {type(v).__name__}", field=label
|
|
1092
1144
|
)
|
|
1093
1145
|
cleaned: "dict[str, float]" = {}
|
|
1094
1146
|
for acc_key, acc_val in v.items():
|
|
1095
1147
|
if not isinstance(acc_key, str) or not acc_key:
|
|
1096
1148
|
raise _BudgetConfigError(
|
|
1097
|
-
f"{label} keys must be non-empty strings (account keys)"
|
|
1149
|
+
f"{label} keys must be non-empty strings (account keys)",
|
|
1150
|
+
field=label,
|
|
1098
1151
|
)
|
|
1099
1152
|
if isinstance(acc_val, bool) or not isinstance(acc_val, (int, float)):
|
|
1100
1153
|
raise _BudgetConfigError(
|
|
1101
1154
|
f"{label} values must be numbers, "
|
|
1102
|
-
f"got {type(acc_val).__name__} for key {acc_key!r}"
|
|
1155
|
+
f"got {type(acc_val).__name__} for key {acc_key!r}",
|
|
1156
|
+
field=label,
|
|
1103
1157
|
)
|
|
1104
1158
|
if not math.isfinite(float(acc_val)) or float(acc_val) <= 0:
|
|
1105
1159
|
raise _BudgetConfigError(
|
|
1106
1160
|
f"{label} values must be finite numbers > 0, "
|
|
1107
|
-
f"got {acc_val!r} for key {acc_key!r}"
|
|
1161
|
+
f"got {acc_val!r} for key {acc_key!r}",
|
|
1162
|
+
field=label,
|
|
1108
1163
|
)
|
|
1109
1164
|
cleaned[acc_key] = float(acc_val)
|
|
1110
1165
|
return cleaned
|
|
@@ -1125,7 +1180,8 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
1125
1180
|
return out
|
|
1126
1181
|
if not isinstance(block, dict):
|
|
1127
1182
|
raise _BudgetConfigError(
|
|
1128
|
-
f"budget must be an object, got {type(block).__name__}"
|
|
1183
|
+
f"budget must be an object, got {type(block).__name__}",
|
|
1184
|
+
field="budget",
|
|
1129
1185
|
)
|
|
1130
1186
|
# warn-and-ignore unknown keys (forward compat; matches _get_alerts_config)
|
|
1131
1187
|
for k in block.keys():
|
|
@@ -1140,16 +1196,25 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
1140
1196
|
if v is None:
|
|
1141
1197
|
out["weekly_usd"] = None
|
|
1142
1198
|
elif isinstance(v, bool) or not isinstance(v, (int, float)):
|
|
1143
|
-
raise _BudgetConfigError(
|
|
1199
|
+
raise _BudgetConfigError(
|
|
1200
|
+
"budget.weekly_usd must be a number or null",
|
|
1201
|
+
field="budget.weekly_usd",
|
|
1202
|
+
)
|
|
1144
1203
|
elif not math.isfinite(float(v)) or float(v) <= 0:
|
|
1145
|
-
raise _BudgetConfigError(
|
|
1204
|
+
raise _BudgetConfigError(
|
|
1205
|
+
"budget.weekly_usd must be a finite number > 0",
|
|
1206
|
+
field="budget.weekly_usd",
|
|
1207
|
+
)
|
|
1146
1208
|
else:
|
|
1147
1209
|
out["weekly_usd"] = float(v)
|
|
1148
1210
|
|
|
1149
1211
|
if "alerts_enabled" in block:
|
|
1150
1212
|
v = block["alerts_enabled"]
|
|
1151
1213
|
if not isinstance(v, bool):
|
|
1152
|
-
raise _BudgetConfigError(
|
|
1214
|
+
raise _BudgetConfigError(
|
|
1215
|
+
"budget.alerts_enabled must be a boolean",
|
|
1216
|
+
field="budget.alerts_enabled",
|
|
1217
|
+
)
|
|
1153
1218
|
out["alerts_enabled"] = v
|
|
1154
1219
|
|
|
1155
1220
|
if "alert_thresholds" in block:
|
|
@@ -1162,39 +1227,47 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
1162
1227
|
if not isinstance(v, str) or v not in BUDGET_PERIODS:
|
|
1163
1228
|
raise _BudgetConfigError(
|
|
1164
1229
|
"budget.period must be one of "
|
|
1165
|
-
f"{', '.join(BUDGET_PERIODS)}, got {v!r}"
|
|
1230
|
+
f"{', '.join(BUDGET_PERIODS)}, got {v!r}",
|
|
1231
|
+
field="budget.period",
|
|
1166
1232
|
)
|
|
1167
1233
|
out["period"] = v
|
|
1168
1234
|
|
|
1169
1235
|
if "projected_enabled" in block:
|
|
1170
1236
|
v = block["projected_enabled"]
|
|
1171
1237
|
if not isinstance(v, bool):
|
|
1172
|
-
raise _BudgetConfigError(
|
|
1238
|
+
raise _BudgetConfigError(
|
|
1239
|
+
"budget.projected_enabled must be a boolean",
|
|
1240
|
+
field="budget.projected_enabled",
|
|
1241
|
+
)
|
|
1173
1242
|
out["projected_enabled"] = v
|
|
1174
1243
|
|
|
1175
1244
|
if "projects" in block:
|
|
1176
1245
|
v = block["projects"]
|
|
1177
1246
|
if not isinstance(v, dict):
|
|
1178
1247
|
raise _BudgetConfigError(
|
|
1179
|
-
f"budget.projects must be an object, got {type(v).__name__}"
|
|
1248
|
+
f"budget.projects must be an object, got {type(v).__name__}",
|
|
1249
|
+
field="budget.projects",
|
|
1180
1250
|
)
|
|
1181
1251
|
cleaned: "dict[str, float]" = {}
|
|
1182
1252
|
for proj_key, proj_val in v.items():
|
|
1183
1253
|
if not isinstance(proj_key, str):
|
|
1184
1254
|
raise _BudgetConfigError(
|
|
1185
|
-
"budget.projects keys must be strings (canonical git-root paths)"
|
|
1255
|
+
"budget.projects keys must be strings (canonical git-root paths)",
|
|
1256
|
+
field="budget.projects",
|
|
1186
1257
|
)
|
|
1187
1258
|
# Reuse the weekly_usd numeric rule per value: a non-bool finite
|
|
1188
1259
|
# number > 0 (bool is an int subclass, so reject it explicitly).
|
|
1189
1260
|
if isinstance(proj_val, bool) or not isinstance(proj_val, (int, float)):
|
|
1190
1261
|
raise _BudgetConfigError(
|
|
1191
1262
|
f"budget.projects values must be numbers, "
|
|
1192
|
-
f"got {type(proj_val).__name__} for key {proj_key!r}"
|
|
1263
|
+
f"got {type(proj_val).__name__} for key {proj_key!r}",
|
|
1264
|
+
field="budget.projects",
|
|
1193
1265
|
)
|
|
1194
1266
|
if not math.isfinite(float(proj_val)) or float(proj_val) <= 0:
|
|
1195
1267
|
raise _BudgetConfigError(
|
|
1196
1268
|
f"budget.projects values must be finite numbers > 0, "
|
|
1197
|
-
f"got {proj_val!r} for key {proj_key!r}"
|
|
1269
|
+
f"got {proj_val!r} for key {proj_key!r}",
|
|
1270
|
+
field="budget.projects",
|
|
1198
1271
|
)
|
|
1199
1272
|
cleaned[proj_key] = float(proj_val)
|
|
1200
1273
|
out["projects"] = cleaned
|
|
@@ -1203,7 +1276,8 @@ def _get_budget_config(cfg: dict) -> dict:
|
|
|
1203
1276
|
v = block["project_alerts_enabled"]
|
|
1204
1277
|
if not isinstance(v, bool):
|
|
1205
1278
|
raise _BudgetConfigError(
|
|
1206
|
-
"budget.project_alerts_enabled must be a boolean"
|
|
1279
|
+
"budget.project_alerts_enabled must be a boolean",
|
|
1280
|
+
field="budget.project_alerts_enabled",
|
|
1207
1281
|
)
|
|
1208
1282
|
out["project_alerts_enabled"] = v
|
|
1209
1283
|
|
|
@@ -1227,13 +1301,17 @@ def _validate_budget_thresholds(v: object, label: str) -> "list[int]":
|
|
|
1227
1301
|
an empty list is allowed (alerts silenced).
|
|
1228
1302
|
"""
|
|
1229
1303
|
if not isinstance(v, list):
|
|
1230
|
-
raise _BudgetConfigError(f"{label} must be a list of ints")
|
|
1304
|
+
raise _BudgetConfigError(f"{label} must be a list of ints", field=label)
|
|
1231
1305
|
cleaned: "list[int]" = []
|
|
1232
1306
|
for t in v:
|
|
1233
1307
|
if isinstance(t, bool) or not isinstance(t, int):
|
|
1234
|
-
raise _BudgetConfigError(
|
|
1308
|
+
raise _BudgetConfigError(
|
|
1309
|
+
f"{label} entries must be integers", field=label
|
|
1310
|
+
)
|
|
1235
1311
|
if t < 1 or t > 100:
|
|
1236
|
-
raise _BudgetConfigError(
|
|
1312
|
+
raise _BudgetConfigError(
|
|
1313
|
+
f"{label} entries must be in [1, 100]", field=label
|
|
1314
|
+
)
|
|
1237
1315
|
cleaned.append(t)
|
|
1238
1316
|
return sorted(set(cleaned)) # empty list allowed (silenced)
|
|
1239
1317
|
|
|
@@ -1252,7 +1330,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
1252
1330
|
return None
|
|
1253
1331
|
if not isinstance(v, dict):
|
|
1254
1332
|
raise _BudgetConfigError(
|
|
1255
|
-
f"budget.codex must be an object or null, got {type(v).__name__}"
|
|
1333
|
+
f"budget.codex must be an object or null, got {type(v).__name__}",
|
|
1334
|
+
field="budget.codex",
|
|
1256
1335
|
)
|
|
1257
1336
|
# warn-and-ignore unknown sub-keys (forward compat, like the parent block)
|
|
1258
1337
|
for k in v.keys():
|
|
@@ -1283,7 +1362,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
1283
1362
|
elif not _has_codex_accounts:
|
|
1284
1363
|
raise _BudgetConfigError(
|
|
1285
1364
|
"budget.codex.amount_usd is required (or set a per-account "
|
|
1286
|
-
"budget.codex.accounts map)"
|
|
1365
|
+
"budget.codex.accounts map)",
|
|
1366
|
+
field="budget.codex.amount_usd",
|
|
1287
1367
|
)
|
|
1288
1368
|
|
|
1289
1369
|
if "period" in v:
|
|
@@ -1292,7 +1372,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
1292
1372
|
raise _BudgetConfigError(
|
|
1293
1373
|
"budget.codex.period must be one of "
|
|
1294
1374
|
f"{', '.join(CODEX_BUDGET_PERIODS)} (NOT subscription-week), "
|
|
1295
|
-
f"got {p!r}"
|
|
1375
|
+
f"got {p!r}",
|
|
1376
|
+
field="budget.codex.period",
|
|
1296
1377
|
)
|
|
1297
1378
|
out["period"] = p
|
|
1298
1379
|
|
|
@@ -1300,7 +1381,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
1300
1381
|
ae = v["alerts_enabled"]
|
|
1301
1382
|
if not isinstance(ae, bool):
|
|
1302
1383
|
raise _BudgetConfigError(
|
|
1303
|
-
"budget.codex.alerts_enabled must be a boolean"
|
|
1384
|
+
"budget.codex.alerts_enabled must be a boolean",
|
|
1385
|
+
field="budget.codex.alerts_enabled",
|
|
1304
1386
|
)
|
|
1305
1387
|
out["alerts_enabled"] = ae
|
|
1306
1388
|
|
|
@@ -1313,7 +1395,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
|
|
|
1313
1395
|
pe = v["projected_enabled"]
|
|
1314
1396
|
if not isinstance(pe, bool):
|
|
1315
1397
|
raise _BudgetConfigError(
|
|
1316
|
-
"budget.codex.projected_enabled must be a boolean"
|
|
1398
|
+
"budget.codex.projected_enabled must be a boolean",
|
|
1399
|
+
field="budget.codex.projected_enabled",
|
|
1317
1400
|
)
|
|
1318
1401
|
out["projected_enabled"] = pe
|
|
1319
1402
|
|