cctally 1.11.0 → 1.12.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 +52 -0
- package/bin/_cctally_alerts.py +14 -18
- package/bin/_cctally_cache.py +366 -140
- package/bin/_cctally_config.py +31 -22
- package/bin/_cctally_core.py +145 -8
- package/bin/_cctally_dashboard.py +2 -1
- package/bin/_cctally_db.py +1696 -35
- package/bin/_cctally_record.py +27 -27
- package/bin/_cctally_setup.py +39 -27
- package/bin/_cctally_tui.py +2 -1
- package/bin/_cctally_update.py +41 -33
- package/bin/_lib_changelog.py +3 -1
- package/bin/_lib_jsonl.py +80 -16
- package/bin/_lib_share_templates.py +31 -13
- package/bin/cctally +112 -109
- package/package.json +1 -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,143 @@ 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
|
+
global CLAUDE_PROJECTS_DIR
|
|
71
|
+
|
|
72
|
+
home = pathlib.Path.home()
|
|
73
|
+
APP_DIR = home / ".local" / "share" / "cctally"
|
|
74
|
+
LEGACY_APP_DIR = home / ".local" / "share" / "ccusage-subscription"
|
|
75
|
+
LOG_DIR = APP_DIR / "logs"
|
|
76
|
+
|
|
77
|
+
DB_PATH = APP_DIR / "stats.db"
|
|
78
|
+
CACHE_DB_PATH = APP_DIR / "cache.db"
|
|
79
|
+
|
|
80
|
+
CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
|
|
81
|
+
CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
|
|
82
|
+
CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
|
|
83
|
+
|
|
84
|
+
CONFIG_PATH = APP_DIR / "config.json"
|
|
85
|
+
|
|
86
|
+
MIGRATION_ERROR_LOG_PATH = LOG_DIR / "migration-errors.log"
|
|
87
|
+
|
|
88
|
+
# CHANGELOG_PATH: honor CCTALLY_TEST_CHANGELOG_PATH env override; otherwise
|
|
89
|
+
# resolves to <repo>/CHANGELOG.md based on bin/_cctally_core.py's
|
|
90
|
+
# location (alongside bin/cctally, so the parent chain is the same).
|
|
91
|
+
override = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
|
|
92
|
+
if override:
|
|
93
|
+
CHANGELOG_PATH = pathlib.Path(override)
|
|
94
|
+
else:
|
|
95
|
+
CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
96
|
+
|
|
97
|
+
HOOK_TICK_LOG_DIR = APP_DIR / "logs"
|
|
98
|
+
HOOK_TICK_LOG_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log"
|
|
99
|
+
HOOK_TICK_LOG_ROTATED_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log.1"
|
|
100
|
+
HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
|
|
101
|
+
HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
|
|
102
|
+
|
|
103
|
+
UPDATE_STATE_PATH = APP_DIR / "update-state.json"
|
|
104
|
+
UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
|
|
105
|
+
UPDATE_LOCK_PATH = APP_DIR / "update.lock"
|
|
106
|
+
UPDATE_LOG_PATH = APP_DIR / "update.log"
|
|
107
|
+
UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
|
|
108
|
+
UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
|
|
109
|
+
|
|
110
|
+
CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
|
|
111
|
+
|
|
112
|
+
# Claude session JSONL root. Production path is `~/.claude/projects`;
|
|
113
|
+
# exposed as a module-level constant so cross-DB migrations (e.g.
|
|
114
|
+
# stats migration 008) and the dispatcher's empty-disk fallback can
|
|
115
|
+
# honor a fixture override via tests' `monkeypatch.setattr(
|
|
116
|
+
# _cctally_core, "CLAUDE_PROJECTS_DIR", tmp_path / "...")`. The
|
|
117
|
+
# `_get_claude_data_dirs()` helper in bin/cctally remains the
|
|
118
|
+
# authoritative resolver for ad-hoc reads (multi-root + env-aware);
|
|
119
|
+
# this constant is the single-rooted production default that 99% of
|
|
120
|
+
# callers want. For multi-root, env-aware resolution (mirroring
|
|
121
|
+
# `_get_claude_data_dirs`), use `_resolve_claude_projects_dirs()`.
|
|
122
|
+
CLAUDE_PROJECTS_DIR = home / ".claude" / "projects"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_init_paths_from_env()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_claude_projects_dirs() -> list[pathlib.Path]:
|
|
129
|
+
"""Return Claude Code projects dirs that exist on disk, env-aware.
|
|
130
|
+
|
|
131
|
+
Mirrors `_get_claude_data_dirs()` in bin/cctally but returns the
|
|
132
|
+
`projects/` subdir directly (since cross-DB migrations only care
|
|
133
|
+
about the JSONL root, not the parent Claude data dir). Honors
|
|
134
|
+
``CLAUDE_CONFIG_DIR`` (comma-separated multi-root) and falls back
|
|
135
|
+
to ``~/.config/claude`` then ``~/.claude``.
|
|
136
|
+
|
|
137
|
+
Used by stats migration 008's gate helper to avoid falsely
|
|
138
|
+
short-circuiting Layer C's empty-disk fallback when the user has
|
|
139
|
+
``CLAUDE_CONFIG_DIR=/other/path`` set AND no ``~/.claude/projects``
|
|
140
|
+
dir on disk: the gate would otherwise see zero JSONL files at the
|
|
141
|
+
hardcoded ``CLAUDE_PROJECTS_DIR`` and "pass" the gate, then run the
|
|
142
|
+
recompute as a no-op against an empty cache.
|
|
143
|
+
|
|
144
|
+
Tests can also feed an explicit list to the gate helper directly,
|
|
145
|
+
skipping this resolver.
|
|
146
|
+
"""
|
|
147
|
+
env_val = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
148
|
+
if env_val:
|
|
149
|
+
candidates = [pathlib.Path(p.strip()) for p in env_val.split(",") if p.strip()]
|
|
150
|
+
result = [
|
|
151
|
+
d / "projects"
|
|
152
|
+
for d in candidates
|
|
153
|
+
if d.is_dir() and (d / "projects").is_dir()
|
|
154
|
+
]
|
|
155
|
+
if result:
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
home = pathlib.Path.home()
|
|
159
|
+
defaults = [
|
|
160
|
+
home / ".config" / "claude",
|
|
161
|
+
home / ".claude",
|
|
162
|
+
]
|
|
163
|
+
return [d / "projects" for d in defaults if d.is_dir() and (d / "projects").is_dir()]
|
|
164
|
+
|
|
165
|
+
|
|
28
166
|
# === Logging =========================================================
|
|
29
167
|
|
|
30
168
|
|
|
@@ -240,9 +378,8 @@ def compute_week_bounds(anchor_dt: dt.datetime, week_start_name: str) -> tuple[d
|
|
|
240
378
|
|
|
241
379
|
|
|
242
380
|
def ensure_dirs() -> None:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
c.LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
382
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
246
383
|
|
|
247
384
|
|
|
248
385
|
# === Alerts validation cluster ======================================
|
|
@@ -353,7 +490,7 @@ def open_db() -> sqlite3.Connection:
|
|
|
353
490
|
_clear_migration_error_log_entries = c._clear_migration_error_log_entries
|
|
354
491
|
|
|
355
492
|
ensure_dirs()
|
|
356
|
-
conn = sqlite3.connect(
|
|
493
|
+
conn = sqlite3.connect(DB_PATH)
|
|
357
494
|
conn.row_factory = sqlite3.Row
|
|
358
495
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
359
496
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
@@ -256,6 +256,7 @@ def _cctally():
|
|
|
256
256
|
# import from _cctally_core; already-decentralized buckets (X = _lib_*,
|
|
257
257
|
# Y = _cctally_*) import from their natural home. These bypass the
|
|
258
258
|
# legacy shim pattern entirely.
|
|
259
|
+
import _cctally_core
|
|
259
260
|
from _cctally_core import (
|
|
260
261
|
eprint,
|
|
261
262
|
now_utc_iso,
|
|
@@ -6548,7 +6549,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6548
6549
|
c = _cctally()
|
|
6549
6550
|
conn.execute(
|
|
6550
6551
|
"ATTACH DATABASE ? AS cache_db",
|
|
6551
|
-
(str(
|
|
6552
|
+
(str(_cctally_core.CACHE_DB_PATH),),
|
|
6552
6553
|
)
|
|
6553
6554
|
conn.execute(
|
|
6554
6555
|
"CREATE TEMP VIEW IF NOT EXISTS session_entries AS "
|