cctally 1.6.3 → 1.7.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.
@@ -0,0 +1,560 @@
1
+ """`config.json` reader, writer, lock, validators, and `cctally config` entry point.
2
+
3
+ Eager I/O sibling: bin/cctally loads this at startup and re-exports
4
+ every public symbol so bare-name callers (the dashboard `/api/settings`
5
+ handler, `cmd_record_usage` reading `load_config()`, `cmd_refresh_usage`
6
+ gating on `_get_oauth_usage_config(load_config())`, the update-check
7
+ predicate, `sync-week`, …) all resolve unchanged. Tests that mock
8
+ `load_config` via ``monkeypatch.setitem(ns, "load_config", …)`` still
9
+ work because Python's bare-name lookup inside non-extracted bin/cctally
10
+ callers resolves in bin/cctally's namespace (where the re-export lives).
11
+
12
+ What stays in bin/cctally:
13
+ - ``_ALERTS_BAD_CONFIG_WARNED`` + ``_warn_alerts_bad_config_once`` —
14
+ alerts-coupled warn-once flag/helper; the alerts-config readers
15
+ (``_get_alerts_config`` / ``_AlertsConfigError``) still live in
16
+ 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
+ - ``eprint`` / ``ensure_dirs`` / ``DEFAULT_WEEK_START`` ubiquitous
21
+ 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``,
25
+ ``_normalize_update_check_enabled_value``,
26
+ ``_validate_update_check_ttl_hours_value``,
27
+ ``UPDATE_DEFAULT_TTL_HOURS``, ``get_display_tz_pref``) — these stay
28
+ near the subsystem they belong to; we reach them via the
29
+ ``_cctally()`` accessor (call-time lookup so test monkeypatches on
30
+ bin/cctally's namespace still propagate, per spec §5.2).
31
+
32
+ Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import contextlib
38
+ import fcntl
39
+ import json
40
+ import os
41
+ import secrets
42
+ import sys
43
+ from typing import Any
44
+
45
+
46
+ def _cctally():
47
+ """Resolve the current `cctally` module at call-time (spec §5.5)."""
48
+ return sys.modules["cctally"]
49
+
50
+
51
+ _CONFIG_CORRUPT_WARNED = False # one-shot warn flag for load_config
52
+
53
+
54
+ def _warn_config_corrupt_once(reason: str) -> None:
55
+ """Emit a single stderr warning per process when config.json is
56
+ unreadable. Mirrors the warn-once pattern used by
57
+ `_DISPLAY_TZ_BAD_CONFIG_WARNED` for malformed display.tz values.
58
+ """
59
+ global _CONFIG_CORRUPT_WARNED
60
+ if _CONFIG_CORRUPT_WARNED:
61
+ return
62
+ _CONFIG_CORRUPT_WARNED = True
63
+ c = _cctally()
64
+ c.eprint(
65
+ f"warning: ignoring corrupt {c.CONFIG_PATH} ({reason}); "
66
+ "using in-memory defaults"
67
+ )
68
+
69
+
70
+ def _default_config_data() -> dict[str, Any]:
71
+ c = _cctally()
72
+ return {
73
+ "collector": {
74
+ "host": "127.0.0.1",
75
+ "port": 17321,
76
+ "token": secrets.token_hex(16),
77
+ "week_start": c.DEFAULT_WEEK_START,
78
+ }
79
+ }
80
+
81
+
82
+ def _try_read_config() -> "dict[str, Any] | None":
83
+ """Read+parse CONFIG_PATH. Returns None when missing OR corrupt.
84
+
85
+ Corrupt cases (non-JSON or non-object root) emit a one-shot stderr
86
+ warning and return None — caller decides whether to fall back to
87
+ in-memory defaults or to overwrite with fresh defaults under the
88
+ config writer lock.
89
+ """
90
+ c = _cctally()
91
+ if not c.CONFIG_PATH.exists():
92
+ return None
93
+ try:
94
+ raw = c.CONFIG_PATH.read_text(encoding="utf-8")
95
+ except OSError as exc:
96
+ _warn_config_corrupt_once(f"read failed: {exc}")
97
+ return None
98
+ try:
99
+ data = json.loads(raw)
100
+ except json.JSONDecodeError as exc:
101
+ _warn_config_corrupt_once(f"JSONDecodeError: {exc}")
102
+ return None
103
+ if not isinstance(data, dict):
104
+ _warn_config_corrupt_once("non-object JSON root")
105
+ return None
106
+ return data
107
+
108
+
109
+ @contextlib.contextmanager
110
+ def config_writer_lock():
111
+ """Exclusive fcntl.flock around config.json read-modify-write.
112
+
113
+ Mirrors the cache.db.lock pattern (see sync_cache) but uses blocking
114
+ LOCK_EX rather than LOCK_NB: config writes are millisecond-scale, so
115
+ a brief wait is preferable to silently dropping a writer's update.
116
+ Used by:
117
+ - cctally config set / unset (CLI path)
118
+ - dashboard POST /api/settings handler
119
+ - load_config first-run create path
120
+ External readers (load_config in the no-write path) do NOT acquire
121
+ this lock — atomic os.replace in save_config guarantees readers see
122
+ either the pre-rename or post-rename file, never partial bytes.
123
+ """
124
+ c = _cctally()
125
+ c.ensure_dirs()
126
+ c.CONFIG_LOCK_PATH.touch()
127
+ fh = open(c.CONFIG_LOCK_PATH, "w")
128
+ try:
129
+ fcntl.flock(fh, fcntl.LOCK_EX)
130
+ try:
131
+ yield
132
+ finally:
133
+ fcntl.flock(fh, fcntl.LOCK_UN)
134
+ finally:
135
+ fh.close()
136
+
137
+
138
+ def load_config() -> dict[str, Any]:
139
+ """Read config.json, falling back to in-memory defaults on corruption.
140
+
141
+ Concurrent-safety: readers see either the pre-rename or post-rename
142
+ contents thanks to save_config's atomic os.replace. On corrupt or
143
+ non-object JSON, emits a one-shot stderr warning and returns
144
+ in-memory defaults WITHOUT re-saving — the next legitimate
145
+ save_config call (under config_writer_lock) will overwrite the bad
146
+ bytes atomically. On first run (file missing), creates the file
147
+ with a fresh collector token under the writer lock so two parallel
148
+ first-run processes don't clobber each other.
149
+
150
+ DEADLOCK NOTE: `fcntl.flock` is per-fd even within the same
151
+ process. Callers that already hold config_writer_lock MUST use
152
+ `_load_config_unlocked()` instead — re-entering this function
153
+ inside an outer lock would block forever (verified during issue
154
+ #17 fix).
155
+ """
156
+ c = _cctally()
157
+ c.ensure_dirs()
158
+ parsed = _try_read_config()
159
+ if parsed is not None:
160
+ return parsed
161
+
162
+ if c.CONFIG_PATH.exists():
163
+ # Corrupt file: warning already emitted by _try_read_config.
164
+ # Return in-memory defaults; do NOT persist — a transient
165
+ # corruption is recoverable by the next legitimate
166
+ # `cctally config set` (which now runs under the writer lock
167
+ # with an atomic write).
168
+ return _default_config_data()
169
+
170
+ # First-run create: hold the writer lock so two simultaneous
171
+ # first-runners agree on a single committed token.
172
+ with config_writer_lock():
173
+ parsed = _try_read_config()
174
+ if parsed is not None:
175
+ return parsed
176
+ data = _default_config_data()
177
+ save_config(data)
178
+ return data
179
+
180
+
181
+ def _load_config_unlocked() -> dict[str, Any]:
182
+ """`load_config` variant for use INSIDE an already-held
183
+ config_writer_lock. Skips the first-run lock acquisition (which
184
+ would self-deadlock — `fcntl.flock` is per-fd, not per-process)
185
+ and never persists: the writer that already holds the lock will
186
+ do its own save_config call atomically. Corrupt-file path returns
187
+ in-memory defaults (caller's save will overwrite cleanly).
188
+ """
189
+ c = _cctally()
190
+ c.ensure_dirs()
191
+ parsed = _try_read_config()
192
+ if parsed is not None:
193
+ return parsed
194
+ return _default_config_data()
195
+
196
+
197
+ def save_config(data: dict[str, Any]) -> None:
198
+ """Persist `data` to config.json atomically.
199
+
200
+ Writes JSON to a sibling temp path (unique per writer PID so two
201
+ unsynchronized writers cannot clobber each other's tmp), fsyncs the
202
+ contents to disk, then `os.replace`s onto CONFIG_PATH. POSIX
203
+ rename(2) is atomic on the same filesystem, so concurrent readers
204
+ see either the old or the new file contents — never partial bytes.
205
+
206
+ Concurrent writers must additionally serialize via
207
+ config_writer_lock; the atomic rename alone protects readers but
208
+ not the read-modify-write semantics of `cctally config set`.
209
+ """
210
+ c = _cctally()
211
+ c.ensure_dirs()
212
+ payload = (json.dumps(data, indent=2) + "\n").encode("utf-8")
213
+ tmp = c.CONFIG_PATH.with_name(f"{c.CONFIG_PATH.name}.tmp.{os.getpid()}")
214
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
215
+ try:
216
+ os.write(fd, payload)
217
+ os.fsync(fd)
218
+ finally:
219
+ os.close(fd)
220
+ os.replace(str(tmp), str(c.CONFIG_PATH))
221
+
222
+
223
+ ALLOWED_CONFIG_KEYS = (
224
+ "display.tz",
225
+ "alerts.enabled",
226
+ "dashboard.bind",
227
+ "update.check.enabled",
228
+ "update.check.ttl_hours",
229
+ )
230
+
231
+
232
+ def cmd_config(args: argparse.Namespace) -> int:
233
+ """Get/set/unset persisted user preferences in config.json.
234
+
235
+ Currently the only allowed key is "display.tz". Future keys join
236
+ via ALLOWED_CONFIG_KEYS without changing the gate.
237
+
238
+ Read-modify-write paths (set/unset) acquire config_writer_lock and
239
+ re-read config.json INSIDE the lock so concurrent invocations are
240
+ serialized; a stale pre-lock copy would lose updates.
241
+ """
242
+ c = _cctally()
243
+ action = args.action
244
+
245
+ if action == "get":
246
+ return _cmd_config_get(args, c.load_config())
247
+ if action == "set":
248
+ return _cmd_config_set(args)
249
+ if action == "unset":
250
+ return _cmd_config_unset(args)
251
+ c.eprint(f"cctally config: unknown action {action!r}")
252
+ return 2
253
+
254
+
255
+ def _config_known_value(config: dict, key: str) -> "object":
256
+ """Return the stored value for one of ALLOWED_CONFIG_KEYS.
257
+
258
+ For ``display.tz`` returns the canonicalized form via
259
+ ``get_display_tz_pref`` so the user sees what cctally actually
260
+ applies. For ``alerts.enabled`` returns the validated boolean from
261
+ ``_get_alerts_config`` (defaults to False when unset). Returns
262
+ ``None`` only for unknown keys (caller treats as "missing").
263
+ """
264
+ c = _cctally()
265
+ if key == "display.tz":
266
+ return c.get_display_tz_pref(config)
267
+ if key == "alerts.enabled":
268
+ return bool(c._get_alerts_config(config)["enabled"])
269
+ if key == "dashboard.bind":
270
+ # Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
271
+ # bind time). LAN exposure is opt-in via `set dashboard.bind lan`
272
+ # or per-call `--host 0.0.0.0`.
273
+ block = config.get("dashboard") if isinstance(config, dict) else None
274
+ if not isinstance(block, dict):
275
+ block = {}
276
+ stored = block.get("bind")
277
+ if not stored:
278
+ return "loopback"
279
+ try:
280
+ return c._validate_dashboard_bind_value(stored)
281
+ except ValueError:
282
+ # Hand-edited junk: surface the default rather than the bad value;
283
+ # `cmd_dashboard` warns at server-start when it hits the same path.
284
+ return "loopback"
285
+ if key in ("update.check.enabled", "update.check.ttl_hours"):
286
+ # Defaults mirror `_is_update_check_due` (True / 24 hours).
287
+ # Hand-edited junk surfaces as the default — matches dashboard.bind.
288
+ update_block = (
289
+ config.get("update") if isinstance(config, dict) else None
290
+ )
291
+ if not isinstance(update_block, dict):
292
+ update_block = {}
293
+ check_block = update_block.get("check")
294
+ if not isinstance(check_block, dict):
295
+ check_block = {}
296
+ if key == "update.check.enabled":
297
+ stored = check_block.get("enabled", True)
298
+ return bool(stored) if isinstance(stored, bool) else True
299
+ # update.check.ttl_hours
300
+ stored = check_block.get("ttl_hours", c.UPDATE_DEFAULT_TTL_HOURS)
301
+ try:
302
+ return c._validate_update_check_ttl_hours_value(stored)
303
+ except ValueError:
304
+ return c.UPDATE_DEFAULT_TTL_HOURS
305
+ return None
306
+
307
+
308
+ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
309
+ c = _cctally()
310
+ key = args.key
311
+ if key is not None and key not in ALLOWED_CONFIG_KEYS:
312
+ c.eprint(f"cctally config: unknown config key {key!r}")
313
+ return 2
314
+ pairs: "list[tuple[str, object]]" = []
315
+ if key is None:
316
+ for k in ALLOWED_CONFIG_KEYS:
317
+ v = _config_known_value(config, k)
318
+ pairs.append((k, v if v is not None else ""))
319
+ else:
320
+ v = _config_known_value(config, key)
321
+ pairs.append((key, v if v is not None else ""))
322
+
323
+ if getattr(args, "emit_json", False):
324
+ # Walk every dot-delimited segment so keys deeper than two
325
+ # segments (e.g. `update.check.enabled`) nest correctly. The
326
+ # earlier `partition` form collapsed three-segment keys into
327
+ # a flat tail (`{"update": {"check.enabled": ...}}`) and
328
+ # diverged from `config set --json` / on-disk shape.
329
+ out: "dict[str, object]" = {}
330
+ for k, v in pairs:
331
+ segments = k.split(".")
332
+ node: dict = out
333
+ for seg in segments[:-1]:
334
+ node = node.setdefault(seg, {})
335
+ node[segments[-1]] = v
336
+ print(json.dumps(out, indent=2))
337
+ else:
338
+ for k, v in pairs:
339
+ # Preserve canonical bool stringification (true/false) so
340
+ # round-trips via `config set alerts.enabled <plain-text>` work.
341
+ if isinstance(v, bool):
342
+ rendered = "true" if v else "false"
343
+ else:
344
+ rendered = str(v)
345
+ print(f"{k}={rendered}")
346
+ return 0
347
+
348
+
349
+ def _cmd_config_set(args: argparse.Namespace) -> int:
350
+ c = _cctally()
351
+ key, raw = args.key, args.value
352
+ if key not in ALLOWED_CONFIG_KEYS:
353
+ c.eprint(f"cctally config: unknown config key {key!r}")
354
+ return 2
355
+ if key == "display.tz":
356
+ try:
357
+ canonical = c.normalize_display_tz_value(raw)
358
+ except ValueError:
359
+ c.eprint(f"cctally config: invalid IANA zone {raw!r}")
360
+ return 2
361
+ with config_writer_lock():
362
+ config = _load_config_unlocked()
363
+ config.setdefault("display", {})["tz"] = canonical
364
+ save_config(config)
365
+ if getattr(args, "emit_json", False):
366
+ print(json.dumps({"display": {"tz": canonical}}, indent=2))
367
+ else:
368
+ print(f"display.tz={canonical}")
369
+ return 0
370
+ if key == "alerts.enabled":
371
+ try:
372
+ normalized = c._normalize_alerts_enabled_value(raw)
373
+ except ValueError as exc:
374
+ print(f"cctally: {exc}", file=sys.stderr)
375
+ return 2
376
+ # Read-modify-write under config_writer_lock, preserving any
377
+ # other alerts.* keys (e.g. user-customized weekly_thresholds).
378
+ # _load_config_unlocked is mandatory here — calling load_config
379
+ # would self-deadlock on the same fcntl.flock fd.
380
+ with config_writer_lock():
381
+ config = _load_config_unlocked()
382
+ # Pre-merge type guard: a hand-edited config with a non-dict
383
+ # alerts block (e.g. ``"alerts": "bad"``) makes the dict()
384
+ # copy below raise ValueError before _get_alerts_config can
385
+ # surface a controlled error. Surface the same message
386
+ # _AlertsConfigError would so the user sees a recoverable
387
+ # rc=2 instead of an uncaught ValueError.
388
+ existing_alerts = config.get("alerts")
389
+ if existing_alerts is not None and not isinstance(
390
+ existing_alerts, dict
391
+ ):
392
+ print(
393
+ "cctally: alerts config error: alerts must be an object",
394
+ file=sys.stderr,
395
+ )
396
+ return 2
397
+ alerts_block = dict(existing_alerts or {})
398
+ alerts_block["enabled"] = normalized
399
+ # Validate the would-be merged block before persisting so
400
+ # we never write a config that fails subsequent reads.
401
+ try:
402
+ c._get_alerts_config({**config, "alerts": alerts_block})
403
+ except c._AlertsConfigError as exc:
404
+ print(f"cctally: alerts config error: {exc}", file=sys.stderr)
405
+ return 2
406
+ config["alerts"] = alerts_block
407
+ save_config(config)
408
+ if getattr(args, "emit_json", False):
409
+ print(json.dumps({"alerts": {"enabled": normalized}}, indent=2))
410
+ else:
411
+ print(f"alerts.enabled={'true' if normalized else 'false'}")
412
+ return 0
413
+ if key == "dashboard.bind":
414
+ # Validation rejects whitespace / empty / non-string up front;
415
+ # write proceeds under config_writer_lock with _load_config_unlocked
416
+ # (calling load_config inside the writer-lock self-deadlocks per the
417
+ # CLAUDE.md gotcha — fcntl.flock is per-fd, not per-process).
418
+ try:
419
+ canonical = c._validate_dashboard_bind_value(raw)
420
+ except ValueError as exc:
421
+ print(f"cctally: {exc}", file=sys.stderr)
422
+ return 2
423
+ with config_writer_lock():
424
+ config = _load_config_unlocked()
425
+ existing = config.get("dashboard")
426
+ if existing is not None and not isinstance(existing, dict):
427
+ print(
428
+ "cctally: dashboard config error: dashboard must be an object",
429
+ file=sys.stderr,
430
+ )
431
+ return 2
432
+ block = dict(existing or {})
433
+ block["bind"] = canonical
434
+ config["dashboard"] = block
435
+ save_config(config)
436
+ if getattr(args, "emit_json", False):
437
+ print(json.dumps({"dashboard": {"bind": canonical}}, indent=2))
438
+ else:
439
+ print(f"dashboard.bind={canonical}")
440
+ return 0
441
+ if key in ("update.check.enabled", "update.check.ttl_hours"):
442
+ # Validate first; rejection short-circuits before lock acquisition.
443
+ if key == "update.check.enabled":
444
+ try:
445
+ normalized: object = c._normalize_update_check_enabled_value(raw)
446
+ except ValueError as exc:
447
+ print(f"cctally: {exc}", file=sys.stderr)
448
+ return 2
449
+ inner_key = "enabled"
450
+ else:
451
+ try:
452
+ normalized = c._validate_update_check_ttl_hours_value(raw)
453
+ except ValueError as exc:
454
+ print(f"cctally: {exc}", file=sys.stderr)
455
+ return 2
456
+ inner_key = "ttl_hours"
457
+ with config_writer_lock():
458
+ config = _load_config_unlocked()
459
+ existing_update = config.get("update")
460
+ if existing_update is not None and not isinstance(existing_update, dict):
461
+ print(
462
+ "cctally: update config error: update must be an object",
463
+ file=sys.stderr,
464
+ )
465
+ return 2
466
+ update_block = dict(existing_update or {})
467
+ existing_check = update_block.get("check")
468
+ if existing_check is not None and not isinstance(existing_check, dict):
469
+ print(
470
+ "cctally: update config error: update.check must be an object",
471
+ file=sys.stderr,
472
+ )
473
+ return 2
474
+ check_block = dict(existing_check or {})
475
+ check_block[inner_key] = normalized
476
+ update_block["check"] = check_block
477
+ config["update"] = update_block
478
+ save_config(config)
479
+ if getattr(args, "emit_json", False):
480
+ print(json.dumps({"update": {"check": {inner_key: normalized}}}, indent=2))
481
+ else:
482
+ if isinstance(normalized, bool):
483
+ rendered = "true" if normalized else "false"
484
+ else:
485
+ rendered = str(normalized)
486
+ print(f"{key}={rendered}")
487
+ return 0
488
+ return 2 # unreachable given the gate above
489
+
490
+
491
+ def _cmd_config_unset(args: argparse.Namespace) -> int:
492
+ c = _cctally()
493
+ key = args.key
494
+ if key not in ALLOWED_CONFIG_KEYS:
495
+ c.eprint(f"cctally config: unknown config key {key!r}")
496
+ return 2
497
+ if key == "display.tz":
498
+ with config_writer_lock():
499
+ config = _load_config_unlocked()
500
+ block = config.get("display")
501
+ if isinstance(block, dict) and "tz" in block:
502
+ del block["tz"]
503
+ if not block:
504
+ config.pop("display", None)
505
+ save_config(config)
506
+ # idempotent: silent on missing key
507
+ return 0
508
+ if key == "alerts.enabled":
509
+ # Mirror the display.tz branch: writer-lock + _load_config_unlocked
510
+ # (NOT load_config — fcntl.flock is per-fd so re-entry would
511
+ # self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
512
+ # `enabled` key preserves any user-customized threshold lists
513
+ # (`weekly_thresholds`, `five_hour_thresholds`); the read-time
514
+ # validator (`_get_alerts_config`) re-applies the canonical
515
+ # default of `enabled = False` for the missing key on next get.
516
+ with config_writer_lock():
517
+ config = _load_config_unlocked()
518
+ block = config.get("alerts")
519
+ if isinstance(block, dict) and "enabled" in block:
520
+ del block["enabled"]
521
+ if not block:
522
+ config.pop("alerts", None)
523
+ save_config(config)
524
+ # idempotent: silent on missing key
525
+ return 0
526
+ if key == "dashboard.bind":
527
+ # Mirror the display.tz / alerts.enabled branches: writer-lock +
528
+ # _load_config_unlocked. Drops only the `bind` key; if `dashboard`
529
+ # ends up empty, drop the parent block too so config.json stays tidy.
530
+ with config_writer_lock():
531
+ config = _load_config_unlocked()
532
+ block = config.get("dashboard")
533
+ if isinstance(block, dict) and "bind" in block:
534
+ del block["bind"]
535
+ if not block:
536
+ config.pop("dashboard", None)
537
+ save_config(config)
538
+ # idempotent: silent on missing key
539
+ return 0
540
+ if key in ("update.check.enabled", "update.check.ttl_hours"):
541
+ # Mirror the dashboard.bind branch: drop the leaf, then prune
542
+ # empty `check` and empty `update` so config.json stays tidy.
543
+ inner_key = (
544
+ "enabled" if key == "update.check.enabled" else "ttl_hours"
545
+ )
546
+ with config_writer_lock():
547
+ config = _load_config_unlocked()
548
+ update_block = config.get("update")
549
+ if isinstance(update_block, dict):
550
+ check_block = update_block.get("check")
551
+ if isinstance(check_block, dict) and inner_key in check_block:
552
+ del check_block[inner_key]
553
+ if not check_block:
554
+ del update_block["check"]
555
+ if not update_block:
556
+ config.pop("update", None)
557
+ save_config(config)
558
+ # idempotent: silent on missing key
559
+ return 0
560
+ return 2 # unreachable given the gate above