cctally 1.7.2 → 1.7.4
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 +27 -0
- package/bin/_cctally_alerts.py +12 -5
- package/bin/_cctally_cache.py +12 -11
- package/bin/_cctally_config.py +34 -19
- package/bin/_cctally_core.py +890 -0
- package/bin/_cctally_dashboard.py +104 -113
- package/bin/_cctally_db.py +271 -41
- package/bin/_cctally_record.py +516 -116
- package/bin/_cctally_refresh.py +35 -20
- package/bin/_cctally_setup.py +26 -16
- package/bin/_cctally_sync_week.py +21 -6
- package/bin/_cctally_tui.py +128 -52
- package/bin/_cctally_update.py +11 -16
- package/bin/_lib_aggregators.py +7 -1
- package/bin/_lib_diff_kernel.py +19 -21
- package/bin/_lib_render.py +20 -0
- package/bin/_lib_subscription_weeks.py +17 -9
- package/bin/cctally +191 -779
- package/dashboard/static/assets/index-DhCnIFq9.js +18 -0
- package/dashboard/static/assets/index-Dv5Dzag5.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BgpoazlS.js +0 -18
- package/dashboard/static/assets/index-nJdUaGys.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.4] - 2026-05-17
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- Extract leaf+mid kernel symbols (eprint, datetime/week helpers, alerts validation, open_db, make_week_ref, get_latest_usage_for_week) from `bin/cctally` into new `bin/_cctally_core.py`; rewrite ~200 sibling shim functions to honest top-level imports; the `c = _cctally()` accessor now survives only for residual Z-high cross-cutters (#50)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- `cctally blocks`: round-5 Bug J follow-up — `_load_recorded_five_hour_windows` now sorts `credit_moments` ascending before the inner credit-pick loop. The pre-fix loop broke on the first matching credit in `[next_bs, rs]` regardless of order; with two in-place credits inside the same pre-credit canonical 5h window and SQLite returning rows in insertion order (no `ORDER BY` on `week_reset_events`), pair `(A, B)` could latch onto credit_2 instead of credit_1, collapsing two distinct truncated anchors onto the same 10-minute floor and silently dropping one via override-map overwrite — re-introducing the phantom heuristic `~` row Bug J was meant to eliminate. Sorting once ensures the break consistently picks the earliest credit, which by construction is the one whose floor equals the next block's `block_start_at`. P2 (not seen in production; every observed in-place credit so far is one-per-week). Regressions: `tests/test_in_place_credit_detection.py::test_load_recorded_five_hour_windows_truncates_each_overlap_independently` (exercises reverse-time SQL order to prove the order-dependent bug) + `test_group_entries_into_blocks_no_phantom_between_two_credits` (renderer-side defense in depth — three canonical blocks render with `recorded` anchor and no `~` row in the gap). Issue [#44](https://github.com/omrikais/cctally-dev/issues/44).
|
|
15
|
+
- `cctally record-usage`: in-place weekly credit race-defensive cleanup is now drift-tolerant. The DELETE that scrubs stale-replica snapshots out of the post-credit segment switched from strict `round(weekly_percent, 1) = round(prior_pct, 1)` equality to a `ABS(weekly_percent - ?) < 1.0` tolerance band around the observed pre-credit baseline. Today `claude-statusline` replays cctally's `hwm-7d` value byte-identically (its in-memory HWM equals the HWM we just wrote), so the existing strict predicate has worked — this is forward-looking defense. If Anthropic ever rounds the `--percent` payload differently from the OAuth API used by `record-usage`, or if `statusline` grows its own coarser rounding, a replay at e.g. `67.5` against a stored `prior_pct = 67.4` would slip past strict equality and then dominate the reset-aware clamp's MAX over the post-credit segment, masking legitimate post-credit values. 1.0pp band absorbs single-digit rounding drift; legitimate post-credit observations are ≥25pp away by the detection threshold's hypothesis so they're never caught. New schema migration `007_observed_pre_credit_pct` adds a nullable `observed_pre_credit_pct REAL` column to `week_reset_events`; the in-place credit INSERT stamps the value at write time so future cleanup tooling can re-derive the baseline without re-walking the snapshot stream. Simple `ALTER TABLE ADD COLUMN` — no UNIQUE constraint change, so no rename-recreate-copy (contrast migrations 005 / 006); live `CREATE TABLE` updated in lockstep so fresh installs land the new column directly via the dispatcher's fresh-install fast-stamp. Regression: `tests/test_in_place_credit_detection.py::test_credit_branch_cleanup_tolerates_rounding_drift` reproduces the drift scenario (stale replay at 67.5 vs prior_pct=67.4) — fails under strict-equality, passes under the band. P2 defensive hardening; not seen in production. Issue [#45](https://github.com/omrikais/cctally-dev/issues/45).
|
|
16
|
+
- `cctally record-usage`: in-place 5h credit race-defensive cleanup is now drift-tolerant — symmetric follow-up to the weekly fix above. The DELETE that scrubs stale-replica snapshots out of the post-credit 5h segment switched from strict `round(five_hour_percent, 1) = round(prior_5h_pct, 1)` equality to a `ABS(five_hour_percent - ?) < 1.0` tolerance band around the observed pre-credit baseline. Pure predicate change — no migration needed because `five_hour_reset_events.prior_percent` (stamped at write time since v1.7.3, issue #43) already provides the durable pre-credit value that the weekly variant required migration 007 to add. Same forward-looking failure mode as the weekly path: if Anthropic ever rounds the `--five-hour-percent` payload differently from the OAuth API used by `record-usage`, or if `statusline` grows its own coarser rounding for the 5h dimension, a replay at e.g. `27.5` against a stored `prior_5h_pct = 27.4` would slip past strict equality and then dominate the reset-aware 5h clamp's MAX over the post-credit segment, masking legitimate post-credit 5h values. The 1.0pp band stays 4pp below the 5.0pp `_FIVE_HOUR_RESET_PCT_DROP_THRESHOLD` so legitimate post-credit observations (≥5pp away from `prior_5h_pct` by the detection threshold's hypothesis) are never caught. Regression: `tests/test_in_place_5h_credit_detection.py::test_credit_branch_5h_cleanup_tolerates_rounding_drift` reproduces the drift scenario (stale 27.5 replay vs `prior_5h_pct=27.4` → reset-aware 5h clamp masks the legitimate 4.0% post-credit seed up to 27.5 under strict equality; the band catches the replay and the seed lands). P2 defensive hardening; not seen in production. Issue [#48](https://github.com/omrikais/cctally-dev/issues/48).
|
|
17
|
+
|
|
18
|
+
## [1.7.3] - 2026-05-16
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- `cctally record-usage`: detect in-place 5h credits on a `>=5.0pp` drop within the same canonical `five_hour_window_key` while `prior_5h_resets_at > now_utc`; write a `five_hour_reset_events` row with the credit moment floored to a 10-minute slot on `effective_reset_at_utc` (supports stacked credits across DISTINCT 10-min slots up to ~30 per 5h block; same-slot collisions silently absorbed by `INSERT OR IGNORE` per spec §2.3 documented cap — an intentional bound, not a bug); force-write `hwm-5h` via the credit-only escape hatch since the normal monotonic-up write at `bin/_cctally_record.py:1722-1731` would refuse to decrease it; then DELETE stale-replica snapshots scoped to `(five_hour_window_key = ?, captured_at_utc >= effective_iso, round(five_hour_percent, 1) = round(prior_5h_pct, 1))` so `claude-statusline`'s in-memory replay of pre-credit `--percent` values across the credit moment cannot poison the post-credit clamp segment. Mirrors the v1.7.2 weekly credit-detection path parallel-not-identical; threshold is 5pp (not 25) because intra-block percents are smaller, and `effective_reset_at_utc` floor is 10 minutes (not the hour) so distinct intra-hour stacked credits stay reachable.
|
|
22
|
+
- schema: new `five_hour_reset_events` table with columns `(detected_at_utc, five_hour_window_key, prior_percent, post_percent, effective_reset_at_utc)` and `UNIQUE(five_hour_window_key, effective_reset_at_utc)`. Payload diverges from the weekly precedent (`prior_percent` + `post_percent` instead of boundary keys) because the 5h variant has a stable canonical window key and only the percent moves — storing both lets renderers show `−Δpp` without re-querying snapshots and supports the rare stacked-credit chain across distinct 10-minute slots. Created adjacent to `week_reset_events` in `_apply_schema` (both are parallel concepts at different cadences); no FK on `five_hour_window_key` per the CLAUDE.md documentation-only-FK gotcha; no `_backfill_five_hour_reset_events` call (forward-only ship per spec Q5; historical backfill deferred).
|
|
23
|
+
- migration `006_five_hour_milestones_reset_event_id`: adds `reset_event_id INTEGER NOT NULL DEFAULT 0` to `five_hour_milestones` and reshapes UNIQUE from `(five_hour_window_key, percent_threshold)` to `(five_hour_window_key, percent_threshold, reset_event_id)` so post-credit threshold crossings re-fire as distinct rows instead of being silently absorbed by `INSERT OR IGNORE` against the pre-credit row at the same threshold. SQLite can't ALTER a UNIQUE constraint in place — the handler uses the rename-recreate-copy idiom inside its own `BEGIN/COMMIT`. Live DDL at `bin/cctally:3902` is updated in lockstep with the migration handler so the dispatcher's fresh-install fast-stamp path lands the correct shape without invoking the handler; the `PRAGMA table_info` probe stamps the marker without redoing the rename when the column is already present (covers fresh-install + partial-failure-retry cases). Mirrors weekly migration 005's pattern. Sentinel `0` = pre-credit / no-event; existing rows backfill to 0 via the column DEFAULT.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- 5h DB clamp at `bin/_cctally_record.py:1521-1527` becomes reset-aware via JOIN on `five_hour_reset_events` keyed on the canonical `five_hour_window_key`; the `COALESCE`-to-epoch-zero default keeps legacy clamp behavior byte-identical on DBs without any event rows, while a credited window's `MAX(five_hour_percent)` over the post-credit segment correctly excludes pre-credit snapshots so a fresh post-credit OAuth value (e.g. 4%) lands instead of being held back by stale pre-credit history (e.g. 28%). `unixepoch()` wraps both sides of the captured-vs-effective comparison so the `Z`-vs-`+00:00` offset mix in stored values doesn't break the bound under lex compare — same rule as the production weekly clamp from v1.7.2.
|
|
27
|
+
- renderers: `cctally five-hour-blocks` shows an inline `⚡ credited −Xpp @ HH:MM` chip beside the block-start cell on credited block rows (multiple credits in one block concatenate as `⚡ −Xpp, −Ypp` across distinct 10-min slots); text + JSON envelope (new `credits[]` field per block) + share-output (`--format md` / `html` / `svg`) all carry the annotation via `_build_five_hour_blocks_snapshot`'s `__credits` side-channel piggy-backed on the existing `block_start` cell formatting. `cctally five-hour-breakdown` interleaves a `⚡ CREDIT −Xpp @ HH:MM` divider row between pre-credit and post-credit milestones (merged stream ordered by `captured_at_utc`); JSON gains `credits[]` parallel to `milestones[]` and each milestone now carries `resetEventId` (sentinel 0 = pre-credit, positive integer = post-credit segment id). Dashboard 5h panel chip + `CurrentWeekModal.tsx` 5h milestones section (new envelope key `current_week.five_hour_milestones` parallel to weekly's `current_week.milestones`) + dashboard alerts list row-identity widening (alert id becomes `five_hour:{window_key}:{threshold}:{reset_event_id}` mirroring the weekly precedent at `bin/_cctally_dashboard.py:2597` — NOT a filter, both segments stay in the list as distinct rows) + TUI 5h tile `[⚡ −Xpp]` badge.
|
|
28
|
+
- migration `003_merge_5h_block_duplicates_v1`: defensive update to the milestone dedup loop — widens the dedup key from `percent_threshold` alone to `(percent_threshold, reset_event_id)` via a `PRAGMA table_info` probe so an operator-triggered re-run after migration 006 (`cctally db unskip 003_merge_5h_block_duplicates_v1`, fresh-DB-from-corrupted-backup, future tooling) doesn't silently collapse legitimately distinct pre/post-credit milestone rows at the same threshold inside one physical block. Byte-identical on the legacy upgrade path where the column doesn't yet exist (003 runs before 006 in migration order; `has_seg=False` collapses the key tuple to `(threshold, 0)`).
|
|
29
|
+
- `cctally record-usage`: credit pivots (HWM force-write + stale-replica DELETE) now run UNCONDITIONALLY when a credit is detected on either axis — gating them on `INSERT OR IGNORE`'s `rowcount` (5h branch) or on the `already is None` pre-check (weekly branch) wedged the system permanently on pre-credit HWM + stale-replica rows if a prior invocation committed the event row but crashed before the pivots could run (memory `project_dedup_must_not_gate_side_effects.md`: "Skipping a no-op INSERT must NOT skip milestones/rollups/alerts; prior run may have died mid-flight"). The event-row INSERT stays gated against duplicates; pivots are individually idempotent so re-running them on the recovery tick is safe. Regressions: `tests/test_in_place_5h_credit_detection.py::test_pivots_run_when_event_row_already_committed` + `tests/test_in_place_credit_detection.py::test_weekly_pivots_run_when_event_row_already_committed`.
|
|
30
|
+
- `cctally record-usage`: round-4 follow-up — 5h credit-detection dedup pre-check refined from a single-field `post_percent` compare to a pair-check against BOTH `prior_percent` AND `post_percent` of the most-recent stored event row, AND the post-detection pivots (HWM force-write + stale-replica DELETE) hoisted OUT of the `if not is_dup:` block so they fire on every detection entry. The round-3 single-field predicate false-positived on a legitimate second credit when the user was idle between credits (Credit 1 lands prior=20/post=5; Credit 2 arrives with new CLI percent=0 while prior_5h_pct still reads 5 from the post-Credit-1 snapshot — `5 == 5` matched the stored `post_percent` so the second credit was silently swallowed, no event row written, no HWM force-write, no DELETE). Pair-check disambiguates: a genuine replay matches BOTH fields; a new credit-with-idle matches at most ONE. Hoisting the pivots is the second half of the same fix — a recovery tick where the pair-check legitimately dedupes (event row already durable from a crashed prior invocation, snapshot rolled back so prior_5h_pct still reads the pre-credit value, pair `(20,5)` matches stored `(20,5)`) still needs to force HWM down and DELETE stale replicas; pre-fix those pivots sat INSIDE `if not is_dup:` and got skipped on every recovery tick, leaving the system wedged on the pre-credit HWM forever. Both pivots are individually idempotent (file overwrite, DELETE on stable predicate) so re-running on a replay or recovery tick is always safe. Codex r4 P1 finding ([PR #46 comment 3252721643](https://github.com/omrikais/cctally-dev/pull/46#discussion_r3252721643), issue [#43](https://github.com/omrikais/cctally-dev/issues/43)). Regressions: `tests/test_in_place_5h_credit_detection.py::test_consecutive_credits_with_idle_between` (proves pair-check lets Credit 2 land) + `tests/test_in_place_5h_credit_detection.py::test_replay_with_pair_match_still_runs_pivots` (proves pivots fire when pair-check dedupes).
|
|
31
|
+
- `cctally record-usage`: 5h self-heal probe at `bin/_cctally_record.py:1958+` becomes segment-aware via `_resolve_active_five_hour_reset_event_id`, mirroring the weekly Probe 1 precedent at `bin/_cctally_record.py:1880-1908`. Without segment scoping a credited block's `MAX(percent_threshold)` over the whole ledger reads the pre-credit ceiling (e.g. 28%) and silently suppresses the post-credit ledger's heal even when it has zero rows; with scoping, post-credit threshold crossings inside a stale-`last_observed_at_utc` slot finally trigger heal. Regression: `tests/test_in_place_5h_credit_detection.py::test_5h_self_heal_probe_scoped_to_active_segment`.
|
|
32
|
+
- dashboard alerts envelope: weekly alerts `context` block now exposes `reset_event_id` parallel to the 5h shape — pre-Round-3 the weekly path widened the row identity (`id` includes `:{reset_event_id}`) but did NOT include the segment in `context`, while the 5h path did. Round-3 adds `context.reset_event_id` to weekly so both axes mirror the same shape and downstream consumers (panel, modal, third-party) can discriminate pre- vs post-credit crossings of the same (week, threshold) without scraping the opaque `id` string. Regression: `tests/test_5h_milestone_segment_threading.py::test_weekly_alerts_context_exposes_reset_event_id`.
|
|
33
|
+
- React component coverage: new credit-chip tests in `dashboard/web/__tests__/CurrentWeekPanel.test.tsx` (single + stacked deltas, `data-credit-count` analytics attr, in-row placement) + new 5h-milestone-section tests in `CurrentWeekModal.test.tsx` (section visibility gate, count pill, `⚡ CREDIT` divider row with `colspan=5`, React row key disambiguation by `reset_event_id` so post-credit threshold repeats render as distinct rows). Server-side envelope coverage extended in `tests/test_five_hour_block_envelope.py` (new `_seed_credit_event` helper + populated `credits[]` + empty-array contract + ascending-order chain across stacked credits). Share-output goldens: new `tests/fixtures/share/five-hour-blocks-credit-{md,html,svg}/` fixtures (one in-place credit seeded in `stats.db` via `bin/build-share-fixtures.py`) assert the `⚡ -20pp` chip carries uniformly through all three share-render formats. Dashboard CSS: new `.credit-chip` + `.mcw-5h-credit-row` + `.mcw-5h-credit-cell` + `.m-sec.sec-5h` rules in `dashboard/web/src/index.css` so the credit annotations match the rest of the panel/modal's accent-amber treatment.
|
|
34
|
+
|
|
8
35
|
## [1.7.2] - 2026-05-16
|
|
9
36
|
|
|
10
37
|
### Fixed
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -76,6 +76,14 @@ _build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
|
76
76
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
# === Honest imports from extracted homes ===================================
|
|
80
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
81
|
+
# import from _cctally_core. `LOG_DIR` stays on the _cctally() accessor
|
|
82
|
+
# per Q1=B (path constants propagate via monkeypatch.setitem against the
|
|
83
|
+
# cctally namespace).
|
|
84
|
+
from _cctally_core import now_utc_iso
|
|
85
|
+
|
|
86
|
+
|
|
79
87
|
def _alerts_log_path() -> "pathlib.Path":
|
|
80
88
|
"""Return ``~/.local/share/cctally/logs/alerts.log`` (parent dirs created).
|
|
81
89
|
|
|
@@ -167,7 +175,7 @@ def _dispatch_alert_notification(
|
|
|
167
175
|
or ""
|
|
168
176
|
)
|
|
169
177
|
line = (
|
|
170
|
-
f"{
|
|
178
|
+
f"{now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
|
|
171
179
|
f"\t{mode}\t{status}\n"
|
|
172
180
|
)
|
|
173
181
|
with open(log_path, "a") as f:
|
|
@@ -194,7 +202,6 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
194
202
|
2 --threshold out of [1, 100] range
|
|
195
203
|
3 other spawn error (PermissionError, OSError, ...)
|
|
196
204
|
"""
|
|
197
|
-
c = _cctally()
|
|
198
205
|
axis = "weekly" if args.axis == "weekly" else "five_hour"
|
|
199
206
|
threshold = int(args.threshold)
|
|
200
207
|
if not (1 <= threshold <= 100):
|
|
@@ -206,7 +213,7 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
206
213
|
if axis == "weekly":
|
|
207
214
|
payload = _build_alert_payload_weekly(
|
|
208
215
|
threshold=threshold,
|
|
209
|
-
crossed_at_utc=
|
|
216
|
+
crossed_at_utc=now_utc_iso(),
|
|
210
217
|
week_start_date=dt.date.today().isoformat(),
|
|
211
218
|
cumulative_cost_usd=1.23,
|
|
212
219
|
dollars_per_percent=0.01,
|
|
@@ -214,9 +221,9 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
214
221
|
else:
|
|
215
222
|
payload = _build_alert_payload_five_hour(
|
|
216
223
|
threshold=threshold,
|
|
217
|
-
crossed_at_utc=
|
|
224
|
+
crossed_at_utc=now_utc_iso(),
|
|
218
225
|
five_hour_window_key=int(dt.datetime.now(dt.timezone.utc).timestamp()),
|
|
219
|
-
block_start_at=
|
|
226
|
+
block_start_at=now_utc_iso(),
|
|
220
227
|
block_cost_usd=1.23,
|
|
221
228
|
primary_model="claude-sonnet-4-6",
|
|
222
229
|
)
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -118,17 +118,18 @@ def _cctally():
|
|
|
118
118
|
return sys.modules["cctally"]
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
121
|
+
# === Honest imports from extracted homes ===================================
|
|
122
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
123
|
+
# (Z-leaf + Z-mid) import from _cctally_core. The legacy shim function
|
|
124
|
+
# for ``eprint`` is deleted.
|
|
125
|
+
from _cctally_core import eprint
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Module-level back-ref shims for the three out-of-scope JSONL/project
|
|
129
|
+
# discovery helpers that STAY in bin/cctally per spec §3.7. Each shim
|
|
130
|
+
# resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind time),
|
|
131
|
+
# so monkeypatches on cctally's namespace propagate into the moved code
|
|
132
|
+
# unchanged.
|
|
132
133
|
def _decode_escaped_cwd(*args, **kwargs):
|
|
133
134
|
return sys.modules["cctally"]._decode_escaped_cwd(*args, **kwargs)
|
|
134
135
|
|
package/bin/_cctally_config.py
CHANGED
|
@@ -48,6 +48,25 @@ def _cctally():
|
|
|
48
48
|
return sys.modules["cctally"]
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
# === Honest imports from extracted homes ===================================
|
|
52
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
53
|
+
# import from _cctally_core; the Bucket-X helper `normalize_display_tz_value`
|
|
54
|
+
# imports from `_lib_display_tz`. Path constants (`CONFIG_PATH`,
|
|
55
|
+
# `CONFIG_LOCK_PATH`) plus out-of-scope validators
|
|
56
|
+
# (`_normalize_alerts_enabled_value`, `_validate_dashboard_bind_value`,
|
|
57
|
+
# `_validate_update_check_ttl_hours_value`, `_normalize_update_check_enabled_value`,
|
|
58
|
+
# `get_display_tz_pref`, `UPDATE_DEFAULT_TTL_HOURS`) stay on the
|
|
59
|
+
# _cctally() accessor.
|
|
60
|
+
from _cctally_core import (
|
|
61
|
+
eprint,
|
|
62
|
+
ensure_dirs,
|
|
63
|
+
DEFAULT_WEEK_START,
|
|
64
|
+
_get_alerts_config,
|
|
65
|
+
_AlertsConfigError,
|
|
66
|
+
)
|
|
67
|
+
from _lib_display_tz import normalize_display_tz_value
|
|
68
|
+
|
|
69
|
+
|
|
51
70
|
_CONFIG_CORRUPT_WARNED = False # one-shot warn flag for load_config
|
|
52
71
|
|
|
53
72
|
|
|
@@ -61,20 +80,19 @@ def _warn_config_corrupt_once(reason: str) -> None:
|
|
|
61
80
|
return
|
|
62
81
|
_CONFIG_CORRUPT_WARNED = True
|
|
63
82
|
c = _cctally()
|
|
64
|
-
|
|
83
|
+
eprint(
|
|
65
84
|
f"warning: ignoring corrupt {c.CONFIG_PATH} ({reason}); "
|
|
66
85
|
"using in-memory defaults"
|
|
67
86
|
)
|
|
68
87
|
|
|
69
88
|
|
|
70
89
|
def _default_config_data() -> dict[str, Any]:
|
|
71
|
-
c = _cctally()
|
|
72
90
|
return {
|
|
73
91
|
"collector": {
|
|
74
92
|
"host": "127.0.0.1",
|
|
75
93
|
"port": 17321,
|
|
76
94
|
"token": secrets.token_hex(16),
|
|
77
|
-
"week_start":
|
|
95
|
+
"week_start": DEFAULT_WEEK_START,
|
|
78
96
|
}
|
|
79
97
|
}
|
|
80
98
|
|
|
@@ -122,7 +140,7 @@ def config_writer_lock():
|
|
|
122
140
|
either the pre-rename or post-rename file, never partial bytes.
|
|
123
141
|
"""
|
|
124
142
|
c = _cctally()
|
|
125
|
-
|
|
143
|
+
ensure_dirs()
|
|
126
144
|
c.CONFIG_LOCK_PATH.touch()
|
|
127
145
|
fh = open(c.CONFIG_LOCK_PATH, "w")
|
|
128
146
|
try:
|
|
@@ -154,7 +172,7 @@ def load_config() -> dict[str, Any]:
|
|
|
154
172
|
#17 fix).
|
|
155
173
|
"""
|
|
156
174
|
c = _cctally()
|
|
157
|
-
|
|
175
|
+
ensure_dirs()
|
|
158
176
|
parsed = _try_read_config()
|
|
159
177
|
if parsed is not None:
|
|
160
178
|
return parsed
|
|
@@ -186,8 +204,7 @@ def _load_config_unlocked() -> dict[str, Any]:
|
|
|
186
204
|
do its own save_config call atomically. Corrupt-file path returns
|
|
187
205
|
in-memory defaults (caller's save will overwrite cleanly).
|
|
188
206
|
"""
|
|
189
|
-
|
|
190
|
-
c.ensure_dirs()
|
|
207
|
+
ensure_dirs()
|
|
191
208
|
parsed = _try_read_config()
|
|
192
209
|
if parsed is not None:
|
|
193
210
|
return parsed
|
|
@@ -208,7 +225,7 @@ def save_config(data: dict[str, Any]) -> None:
|
|
|
208
225
|
not the read-modify-write semantics of `cctally config set`.
|
|
209
226
|
"""
|
|
210
227
|
c = _cctally()
|
|
211
|
-
|
|
228
|
+
ensure_dirs()
|
|
212
229
|
payload = (json.dumps(data, indent=2) + "\n").encode("utf-8")
|
|
213
230
|
tmp = c.CONFIG_PATH.with_name(f"{c.CONFIG_PATH.name}.tmp.{os.getpid()}")
|
|
214
231
|
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
@@ -248,7 +265,7 @@ def cmd_config(args: argparse.Namespace) -> int:
|
|
|
248
265
|
return _cmd_config_set(args)
|
|
249
266
|
if action == "unset":
|
|
250
267
|
return _cmd_config_unset(args)
|
|
251
|
-
|
|
268
|
+
eprint(f"cctally config: unknown action {action!r}")
|
|
252
269
|
return 2
|
|
253
270
|
|
|
254
271
|
|
|
@@ -265,7 +282,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
265
282
|
if key == "display.tz":
|
|
266
283
|
return c.get_display_tz_pref(config)
|
|
267
284
|
if key == "alerts.enabled":
|
|
268
|
-
return bool(
|
|
285
|
+
return bool(_get_alerts_config(config)["enabled"])
|
|
269
286
|
if key == "dashboard.bind":
|
|
270
287
|
# Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
|
|
271
288
|
# bind time). LAN exposure is opt-in via `set dashboard.bind lan`
|
|
@@ -306,10 +323,9 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
306
323
|
|
|
307
324
|
|
|
308
325
|
def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
309
|
-
c = _cctally()
|
|
310
326
|
key = args.key
|
|
311
327
|
if key is not None and key not in ALLOWED_CONFIG_KEYS:
|
|
312
|
-
|
|
328
|
+
eprint(f"cctally config: unknown config key {key!r}")
|
|
313
329
|
return 2
|
|
314
330
|
pairs: "list[tuple[str, object]]" = []
|
|
315
331
|
if key is None:
|
|
@@ -350,13 +366,13 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
350
366
|
c = _cctally()
|
|
351
367
|
key, raw = args.key, args.value
|
|
352
368
|
if key not in ALLOWED_CONFIG_KEYS:
|
|
353
|
-
|
|
369
|
+
eprint(f"cctally config: unknown config key {key!r}")
|
|
354
370
|
return 2
|
|
355
371
|
if key == "display.tz":
|
|
356
372
|
try:
|
|
357
|
-
canonical =
|
|
373
|
+
canonical = normalize_display_tz_value(raw)
|
|
358
374
|
except ValueError:
|
|
359
|
-
|
|
375
|
+
eprint(f"cctally config: invalid IANA zone {raw!r}")
|
|
360
376
|
return 2
|
|
361
377
|
with config_writer_lock():
|
|
362
378
|
config = _load_config_unlocked()
|
|
@@ -399,8 +415,8 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
399
415
|
# Validate the would-be merged block before persisting so
|
|
400
416
|
# we never write a config that fails subsequent reads.
|
|
401
417
|
try:
|
|
402
|
-
|
|
403
|
-
except
|
|
418
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
419
|
+
except _AlertsConfigError as exc:
|
|
404
420
|
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
405
421
|
return 2
|
|
406
422
|
config["alerts"] = alerts_block
|
|
@@ -489,10 +505,9 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
489
505
|
|
|
490
506
|
|
|
491
507
|
def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
492
|
-
c = _cctally()
|
|
493
508
|
key = args.key
|
|
494
509
|
if key not in ALLOWED_CONFIG_KEYS:
|
|
495
|
-
|
|
510
|
+
eprint(f"cctally config: unknown config key {key!r}")
|
|
496
511
|
return 2
|
|
497
512
|
if key == "display.tz":
|
|
498
513
|
with config_writer_lock():
|