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.
@@ -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
- - All validator/normalizer primitives (``normalize_display_tz_value``,
23
- ``_get_alerts_config``, ``_AlertsConfigError``,
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-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.
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 {c.CONFIG_PATH} ({reason}); "
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 c.CONFIG_PATH.exists():
118
+ if not _cctally_core.CONFIG_PATH.exists():
110
119
  return None
111
120
  try:
112
- raw = c.CONFIG_PATH.read_text(encoding="utf-8")
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
- c.CONFIG_LOCK_PATH.touch()
145
- fh = open(c.CONFIG_LOCK_PATH, "w")
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 c.CONFIG_PATH.exists():
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 = c.CONFIG_PATH.with_name(f"{c.CONFIG_PATH.name}.tmp.{os.getpid()}")
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(c.CONFIG_PATH))
246
+ os.replace(str(tmp), str(_cctally_core.CONFIG_PATH))
238
247
 
239
248
 
240
249
  ALLOWED_CONFIG_KEYS = (
@@ -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) intentionally live in
9
- bin/cctally and are read here via a call-time _cctally() accessor
10
- this is the ONLY accessor use inside core. See
11
- docs/superpowers/specs/2026-05-17-cctally-core-kernel-extraction.md §2.
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
- c = _cctally()
244
- c.APP_DIR.mkdir(parents=True, exist_ok=True)
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(c.DB_PATH)
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")