cctally 1.10.3 → 1.11.1
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 +22 -0
- package/bin/_cctally_alerts.py +14 -18
- package/bin/_cctally_cache.py +28 -29
- package/bin/_cctally_cache_report.py +938 -0
- package/bin/_cctally_config.py +31 -22
- package/bin/_cctally_core.py +94 -8
- package/bin/_cctally_dashboard.py +621 -7
- package/bin/_cctally_db.py +42 -30
- package/bin/_cctally_record.py +26 -26
- package/bin/_cctally_setup.py +28 -26
- package/bin/_cctally_tui.py +47 -1
- package/bin/_cctally_update.py +41 -33
- package/bin/_lib_changelog.py +3 -1
- package/bin/_lib_share_templates.py +31 -13
- package/bin/cctally +214 -495
- package/dashboard/static/assets/index-BJ16SzRL.js +18 -0
- package/dashboard/static/assets/index-C1xH9GBW.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Cy59E7Ru.js +0 -18
- package/dashboard/static/assets/index-Dp14ELVt.css +0 -1
package/bin/_cctally_config.py
CHANGED
|
@@ -9,25 +9,31 @@ predicate, `sync-week`, …) all resolve unchanged. Tests that mock
|
|
|
9
9
|
work because Python's bare-name lookup inside non-extracted bin/cctally
|
|
10
10
|
callers resolves in bin/cctally's namespace (where the re-export lives).
|
|
11
11
|
|
|
12
|
+
What lives in bin/_cctally_core (promoted 2026-05-22, #84):
|
|
13
|
+
- ``CONFIG_PATH`` / ``CONFIG_LOCK_PATH`` path constants. Reads use
|
|
14
|
+
call-time ``_cctally_core.CONFIG_PATH`` / ``_cctally_core.CONFIG_LOCK_PATH``;
|
|
15
|
+
tests patch via ``monkeypatch.setattr(_cctally_core, "X", v)`` (or
|
|
16
|
+
the conftest ``redirect_paths()`` helper). The legacy
|
|
17
|
+
``setitem(ns, "CONFIG_PATH", …)`` pattern is forbidden by
|
|
18
|
+
``test_no_old_style_test_patches_for_promoted_globals``.
|
|
19
|
+
|
|
12
20
|
What stays in bin/cctally:
|
|
13
21
|
- ``_ALERTS_BAD_CONFIG_WARNED`` + ``_warn_alerts_bad_config_once`` —
|
|
14
22
|
alerts-coupled warn-once flag/helper; the alerts-config readers
|
|
15
23
|
(``_get_alerts_config`` / ``_AlertsConfigError``) still live in
|
|
16
24
|
bin/cctally and these two travel with that block.
|
|
17
|
-
- ``CONFIG_PATH`` / ``CONFIG_LOCK_PATH`` path constants (spec §86–92
|
|
18
|
-
keeps every path constant in bin/cctally so monkeypatched
|
|
19
|
-
`cctally.CONFIG_PATH = …` redirects propagate everywhere).
|
|
20
25
|
- ``eprint`` / ``ensure_dirs`` / ``DEFAULT_WEEK_START`` ubiquitous
|
|
21
26
|
helpers/constants.
|
|
22
|
-
-
|
|
23
|
-
``
|
|
24
|
-
``_normalize_alerts_enabled_value``, ``_validate_dashboard_bind_value``,
|
|
27
|
+
- Non-path validator/normalizer primitives
|
|
28
|
+
(``_normalize_alerts_enabled_value``, ``_validate_dashboard_bind_value``,
|
|
25
29
|
``_normalize_update_check_enabled_value``,
|
|
26
30
|
``_validate_update_check_ttl_hours_value``,
|
|
27
31
|
``UPDATE_DEFAULT_TTL_HOURS``, ``get_display_tz_pref``) — these stay
|
|
28
32
|
near the subsystem they belong to; we reach them via the
|
|
29
33
|
``_cctally()`` accessor (call-time lookup so test monkeypatches on
|
|
30
34
|
bin/cctally's namespace still propagate, per spec §5.2).
|
|
35
|
+
(``normalize_display_tz_value`` imports directly from
|
|
36
|
+
``_lib_display_tz`` — see the import block below.)
|
|
31
37
|
|
|
32
38
|
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
33
39
|
"""
|
|
@@ -49,14 +55,17 @@ def _cctally():
|
|
|
49
55
|
|
|
50
56
|
|
|
51
57
|
# === Honest imports from extracted homes ===================================
|
|
52
|
-
# Spec 2026-05-17
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
58
|
+
# Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core; the
|
|
59
|
+
# Bucket-X helper ``normalize_display_tz_value`` imports from
|
|
60
|
+
# ``_lib_display_tz``. Path constants (``CONFIG_PATH``,
|
|
61
|
+
# ``CONFIG_LOCK_PATH``) moved to _cctally_core 2026-05-22 (#84) and are
|
|
62
|
+
# read via call-time ``_cctally_core.CONFIG_PATH`` etc. The out-of-scope
|
|
63
|
+
# non-path validators (``_normalize_alerts_enabled_value``,
|
|
64
|
+
# ``_validate_dashboard_bind_value``,
|
|
65
|
+
# ``_validate_update_check_ttl_hours_value``,
|
|
66
|
+
# ``_normalize_update_check_enabled_value``, ``get_display_tz_pref``,
|
|
67
|
+
# ``UPDATE_DEFAULT_TTL_HOURS``) stay on the _cctally() accessor.
|
|
68
|
+
import _cctally_core
|
|
60
69
|
from _cctally_core import (
|
|
61
70
|
eprint,
|
|
62
71
|
ensure_dirs,
|
|
@@ -81,7 +90,7 @@ def _warn_config_corrupt_once(reason: str) -> None:
|
|
|
81
90
|
_CONFIG_CORRUPT_WARNED = True
|
|
82
91
|
c = _cctally()
|
|
83
92
|
eprint(
|
|
84
|
-
f"warning: ignoring corrupt {
|
|
93
|
+
f"warning: ignoring corrupt {_cctally_core.CONFIG_PATH} ({reason}); "
|
|
85
94
|
"using in-memory defaults"
|
|
86
95
|
)
|
|
87
96
|
|
|
@@ -106,10 +115,10 @@ def _try_read_config() -> "dict[str, Any] | None":
|
|
|
106
115
|
config writer lock.
|
|
107
116
|
"""
|
|
108
117
|
c = _cctally()
|
|
109
|
-
if not
|
|
118
|
+
if not _cctally_core.CONFIG_PATH.exists():
|
|
110
119
|
return None
|
|
111
120
|
try:
|
|
112
|
-
raw =
|
|
121
|
+
raw = _cctally_core.CONFIG_PATH.read_text(encoding="utf-8")
|
|
113
122
|
except OSError as exc:
|
|
114
123
|
_warn_config_corrupt_once(f"read failed: {exc}")
|
|
115
124
|
return None
|
|
@@ -141,8 +150,8 @@ def config_writer_lock():
|
|
|
141
150
|
"""
|
|
142
151
|
c = _cctally()
|
|
143
152
|
ensure_dirs()
|
|
144
|
-
|
|
145
|
-
fh = open(
|
|
153
|
+
_cctally_core.CONFIG_LOCK_PATH.touch()
|
|
154
|
+
fh = open(_cctally_core.CONFIG_LOCK_PATH, "w")
|
|
146
155
|
try:
|
|
147
156
|
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
148
157
|
try:
|
|
@@ -177,7 +186,7 @@ def load_config() -> dict[str, Any]:
|
|
|
177
186
|
if parsed is not None:
|
|
178
187
|
return parsed
|
|
179
188
|
|
|
180
|
-
if
|
|
189
|
+
if _cctally_core.CONFIG_PATH.exists():
|
|
181
190
|
# Corrupt file: warning already emitted by _try_read_config.
|
|
182
191
|
# Return in-memory defaults; do NOT persist — a transient
|
|
183
192
|
# corruption is recoverable by the next legitimate
|
|
@@ -227,14 +236,14 @@ def save_config(data: dict[str, Any]) -> None:
|
|
|
227
236
|
c = _cctally()
|
|
228
237
|
ensure_dirs()
|
|
229
238
|
payload = (json.dumps(data, indent=2) + "\n").encode("utf-8")
|
|
230
|
-
tmp =
|
|
239
|
+
tmp = _cctally_core.CONFIG_PATH.with_name(f"{_cctally_core.CONFIG_PATH.name}.tmp.{os.getpid()}")
|
|
231
240
|
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
|
|
232
241
|
try:
|
|
233
242
|
os.write(fd, payload)
|
|
234
243
|
os.fsync(fd)
|
|
235
244
|
finally:
|
|
236
245
|
os.close(fd)
|
|
237
|
-
os.replace(str(tmp), str(
|
|
246
|
+
os.replace(str(tmp), str(_cctally_core.CONFIG_PATH))
|
|
238
247
|
|
|
239
248
|
|
|
240
249
|
ALLOWED_CONFIG_KEYS = (
|
package/bin/_cctally_core.py
CHANGED
|
@@ -5,14 +5,15 @@ logging (eprint), datetime helpers, week-name/bounds, time-of-day,
|
|
|
5
5
|
alerts-config validation, open_db, WeekRef + make_week_ref,
|
|
6
6
|
get_latest_usage_for_week.
|
|
7
7
|
|
|
8
|
-
Path constants (APP_DIR, DB_PATH, LOG_DIR)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
docs/superpowers/specs/2026-05-
|
|
8
|
+
Path constants (APP_DIR, DB_PATH, LOG_DIR, etc.) live in this module as
|
|
9
|
+
of 2026-05-22 (issue #84); `_cctally_core` is the single source of truth
|
|
10
|
+
and the only legal monkeypatch target for the 23 promoted globals listed
|
|
11
|
+
below. See docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md.
|
|
12
12
|
"""
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
import datetime as dt
|
|
15
15
|
import os
|
|
16
|
+
import pathlib
|
|
16
17
|
import re
|
|
17
18
|
import sqlite3
|
|
18
19
|
import sys
|
|
@@ -25,6 +26,92 @@ def _cctally():
|
|
|
25
26
|
return sys.modules["cctally"]
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
# === Path constants ==================================================
|
|
30
|
+
#
|
|
31
|
+
# Promoted from bin/cctally per docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md.
|
|
32
|
+
# After this promotion `_cctally_core` is the single source of truth and
|
|
33
|
+
# the only legal monkeypatch target. `bin/cctally` keeps eager re-exports
|
|
34
|
+
# for ad-hoc REPL / scripts; tests MUST target this module directly.
|
|
35
|
+
#
|
|
36
|
+
# Path-constant initialization is wrapped in `_init_paths_from_env()` so
|
|
37
|
+
# `tests/conftest.py:load_script()` can re-derive them from the current
|
|
38
|
+
# HOME env var without re-importing this module (which would invalidate
|
|
39
|
+
# tests' module-top `import _cctally_core` references). The bare module
|
|
40
|
+
# attributes below are populated by the call to _init_paths_from_env()
|
|
41
|
+
# at import time; subsequent load_script calls invoke it again.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _init_paths_from_env() -> None:
|
|
45
|
+
"""(Re)bind the 23 in-scope path globals from the current process env.
|
|
46
|
+
|
|
47
|
+
22 of the 23 resolve under ``Path.home()`` (i.e. the ``HOME`` env var).
|
|
48
|
+
The 23rd, ``CHANGELOG_PATH``, resolves from ``CCTALLY_TEST_CHANGELOG_PATH``
|
|
49
|
+
when set, else from ``__file__`` (``<repo>/CHANGELOG.md`` relative to
|
|
50
|
+
this kernel module's location) — independent of ``HOME``. Tests that
|
|
51
|
+
redirect the changelog (e.g. ``tests/test_release_internals.py``) drive
|
|
52
|
+
that override and rely on this re-init.
|
|
53
|
+
|
|
54
|
+
Called once at module import to populate the defaults, then again
|
|
55
|
+
by `tests/conftest.py:load_script()` after each `setenv("HOME", …)`
|
|
56
|
+
or `setenv("CCTALLY_TEST_CHANGELOG_PATH", …)` so the test sees a fresh
|
|
57
|
+
path set without the cost of re-importing `_cctally_core` (which would
|
|
58
|
+
break tests that cached the module object via a top-level
|
|
59
|
+
`import _cctally_core`).
|
|
60
|
+
"""
|
|
61
|
+
global APP_DIR, LEGACY_APP_DIR, LOG_DIR
|
|
62
|
+
global DB_PATH, CACHE_DB_PATH
|
|
63
|
+
global CACHE_LOCK_PATH, CACHE_LOCK_CODEX_PATH, CONFIG_LOCK_PATH
|
|
64
|
+
global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
|
|
65
|
+
global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
|
|
66
|
+
global HOOK_TICK_THROTTLE_PATH, HOOK_TICK_THROTTLE_LOCK_PATH
|
|
67
|
+
global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
|
|
68
|
+
global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
|
|
69
|
+
global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
|
|
70
|
+
|
|
71
|
+
home = pathlib.Path.home()
|
|
72
|
+
APP_DIR = home / ".local" / "share" / "cctally"
|
|
73
|
+
LEGACY_APP_DIR = home / ".local" / "share" / "ccusage-subscription"
|
|
74
|
+
LOG_DIR = APP_DIR / "logs"
|
|
75
|
+
|
|
76
|
+
DB_PATH = APP_DIR / "stats.db"
|
|
77
|
+
CACHE_DB_PATH = APP_DIR / "cache.db"
|
|
78
|
+
|
|
79
|
+
CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
|
|
80
|
+
CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
|
|
81
|
+
CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
|
|
82
|
+
|
|
83
|
+
CONFIG_PATH = APP_DIR / "config.json"
|
|
84
|
+
|
|
85
|
+
MIGRATION_ERROR_LOG_PATH = LOG_DIR / "migration-errors.log"
|
|
86
|
+
|
|
87
|
+
# CHANGELOG_PATH: honor CCTALLY_TEST_CHANGELOG_PATH env override; otherwise
|
|
88
|
+
# resolves to <repo>/CHANGELOG.md based on bin/_cctally_core.py's
|
|
89
|
+
# location (alongside bin/cctally, so the parent chain is the same).
|
|
90
|
+
override = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
|
|
91
|
+
if override:
|
|
92
|
+
CHANGELOG_PATH = pathlib.Path(override)
|
|
93
|
+
else:
|
|
94
|
+
CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
95
|
+
|
|
96
|
+
HOOK_TICK_LOG_DIR = APP_DIR / "logs"
|
|
97
|
+
HOOK_TICK_LOG_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log"
|
|
98
|
+
HOOK_TICK_LOG_ROTATED_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log.1"
|
|
99
|
+
HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
|
|
100
|
+
HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
|
|
101
|
+
|
|
102
|
+
UPDATE_STATE_PATH = APP_DIR / "update-state.json"
|
|
103
|
+
UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
|
|
104
|
+
UPDATE_LOCK_PATH = APP_DIR / "update.lock"
|
|
105
|
+
UPDATE_LOG_PATH = APP_DIR / "update.log"
|
|
106
|
+
UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
|
|
107
|
+
UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
|
|
108
|
+
|
|
109
|
+
CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_init_paths_from_env()
|
|
113
|
+
|
|
114
|
+
|
|
28
115
|
# === Logging =========================================================
|
|
29
116
|
|
|
30
117
|
|
|
@@ -240,9 +327,8 @@ def compute_week_bounds(anchor_dt: dt.datetime, week_start_name: str) -> tuple[d
|
|
|
240
327
|
|
|
241
328
|
|
|
242
329
|
def ensure_dirs() -> None:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
c.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
246
332
|
|
|
247
333
|
|
|
248
334
|
# === Alerts validation cluster ======================================
|
|
@@ -353,7 +439,7 @@ def open_db() -> sqlite3.Connection:
|
|
|
353
439
|
_clear_migration_error_log_entries = c._clear_migration_error_log_entries
|
|
354
440
|
|
|
355
441
|
ensure_dirs()
|
|
356
|
-
conn = sqlite3.connect(
|
|
442
|
+
conn = sqlite3.connect(DB_PATH)
|
|
357
443
|
conn.row_factory = sqlite3.Row
|
|
358
444
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
359
445
|
conn.execute("PRAGMA synchronous=NORMAL")
|