cctally 1.11.1 → 1.13.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.
@@ -46,6 +46,7 @@ import json
46
46
  import os
47
47
  import secrets
48
48
  import sys
49
+ from pathlib import Path
49
50
  from typing import Any
50
51
 
51
52
 
@@ -162,17 +163,60 @@ def config_writer_lock():
162
163
  fh.close()
163
164
 
164
165
 
165
- def load_config() -> dict[str, Any]:
166
+ def _load_config_from_explicit_path(path: "str | Path") -> dict[str, Any]:
167
+ """Read config from an explicit per-invocation override path (issue #88).
168
+
169
+ Contract differs from the default ``load_config()``:
170
+ - Missing file → ``SystemExit(2)`` with a clear stderr message.
171
+ - Unreadable / malformed JSON / non-object root → ``SystemExit(2)``
172
+ with a clear stderr message.
173
+ - Never writes, never acquires ``config_writer_lock``, never
174
+ creates the on-disk default config — the override is read-only
175
+ for this invocation.
176
+
177
+ Used by the ccusage drop-in ``--config <path>`` flag wired onto the
178
+ 10 Claude reporting commands (spec §3 T1.6 / issue #86 Session A).
179
+ """
180
+ p = Path(path)
181
+ if not p.exists():
182
+ eprint(f"cctally: --config: file not found: {p}")
183
+ raise SystemExit(2)
184
+ try:
185
+ raw = p.read_text(encoding="utf-8")
186
+ except OSError as exc:
187
+ eprint(f"cctally: --config: read failed for {p}: {exc}")
188
+ raise SystemExit(2) from exc
189
+ try:
190
+ data = json.loads(raw)
191
+ except json.JSONDecodeError as exc:
192
+ eprint(f"cctally: --config: invalid JSON in {p}: {exc}")
193
+ raise SystemExit(2) from exc
194
+ if not isinstance(data, dict):
195
+ eprint(
196
+ f"cctally: --config: {p} top-level must be a JSON object"
197
+ )
198
+ raise SystemExit(2)
199
+ return data
200
+
201
+
202
+ def load_config(path: "str | Path | None" = None) -> dict[str, Any]:
166
203
  """Read config.json, falling back to in-memory defaults on corruption.
167
204
 
168
- Concurrent-safety: readers see either the pre-rename or post-rename
169
- contents thanks to save_config's atomic os.replace. On corrupt or
170
- non-object JSON, emits a one-shot stderr warning and returns
171
- in-memory defaults WITHOUT re-saving the next legitimate
172
- save_config call (under config_writer_lock) will overwrite the bad
173
- bytes atomically. On first run (file missing), creates the file
174
- with a fresh collector token under the writer lock so two parallel
175
- first-run processes don't clobber each other.
205
+ When ``path`` is None (default): reads the persisted user config at
206
+ ``_cctally_core.CONFIG_PATH``, creating it on first run with a fresh
207
+ collector token under the writer lock. Concurrent-safety: readers see
208
+ either the pre-rename or post-rename contents thanks to save_config's
209
+ atomic os.replace. On corrupt or non-object JSON, emits a one-shot
210
+ stderr warning and returns in-memory defaults WITHOUT re-saving the
211
+ next legitimate save_config call (under config_writer_lock) will
212
+ overwrite the bad bytes atomically.
213
+
214
+ When ``path`` is set (issue #88 ccusage drop-in ``--config <path>``):
215
+ reads from the explicit override path and bypasses the default-path
216
+ branch entirely. Missing / unreadable / malformed paths surface as
217
+ ``SystemExit(2)`` with a clear stderr message — see
218
+ ``_load_config_from_explicit_path``. No writes, no first-run create,
219
+ no mutation of the on-disk default config.
176
220
 
177
221
  DEADLOCK NOTE: `fcntl.flock` is per-fd even within the same
178
222
  process. Callers that already hold config_writer_lock MUST use
@@ -180,6 +224,8 @@ def load_config() -> dict[str, Any]:
180
224
  inside an outer lock would block forever (verified during issue
181
225
  #17 fix).
182
226
  """
227
+ if path is not None:
228
+ return _load_config_from_explicit_path(path)
183
229
  c = _cctally()
184
230
  ensure_dirs()
185
231
  parsed = _try_read_config()
@@ -67,6 +67,7 @@ def _init_paths_from_env() -> None:
67
67
  global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
68
68
  global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
69
69
  global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
70
+ global CLAUDE_PROJECTS_DIR
70
71
 
71
72
  home = pathlib.Path.home()
72
73
  APP_DIR = home / ".local" / "share" / "cctally"
@@ -108,10 +109,60 @@ def _init_paths_from_env() -> None:
108
109
 
109
110
  CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
110
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
+
111
124
 
112
125
  _init_paths_from_env()
113
126
 
114
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
+
115
166
  # === Logging =========================================================
116
167
 
117
168