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.
@@ -102,12 +102,19 @@ worker / polling thread:
102
102
  accessor; eager re-export from this sibling means the cctally
103
103
  namespace exposes them unchanged.
104
104
 
105
+ What lives in bin/_cctally_core (promoted 2026-05-22, #84):
106
+ - All ``UPDATE_*`` path constants: ``UPDATE_STATE_PATH``,
107
+ ``UPDATE_SUPPRESS_PATH``, ``UPDATE_LOCK_PATH``, ``UPDATE_LOG_PATH``,
108
+ ``UPDATE_LOG_ROTATED_PATH``, ``UPDATE_CHECK_LAST_FETCH_PATH``.
109
+ Moved bodies in this sibling read them via call-time
110
+ ``_cctally_core.UPDATE_STATE_PATH`` etc. Tests patch via
111
+ ``monkeypatch.setattr(_cctally_core, "UPDATE_STATE_PATH", tmp)`` —
112
+ the conftest helper ``redirect_paths()`` covers this for the full
113
+ set, ``tests/test_update.py:update_paths`` covers it for the
114
+ UPDATE_* subset. The legacy ``setitem(ns, …)`` pattern is forbidden
115
+ by ``test_no_old_style_test_patches_for_promoted_globals``.
116
+
105
117
  What stays in bin/cctally:
106
- - All ``UPDATE_*`` path constants (source-of-truth at L2001-2023);
107
- consumed via ``c = _cctally(); c.UPDATE_STATE_PATH`` etc. in moved
108
- code so ``monkeypatch.setitem(ns, "UPDATE_STATE_PATH", tmp)`` in
109
- ``tests/test_update.py`` propagates transparently — no sibling-side
110
- patches needed. Mirrors Phase D #17/#18 precedent.
111
118
  - ``ORIGINAL_SYS_ARGV`` / ``ORIGINAL_ENTRYPOINT`` /
112
119
  ``_UPDATE_WORKER`` — module-level globals written by
113
120
  ``cmd_dashboard`` at boot (``global`` statement at L23205);
@@ -200,6 +207,7 @@ def _cctally():
200
207
 
201
208
  # === Honest imports from extracted homes ===================================
202
209
  # Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
210
+ import _cctally_core
203
211
  from _cctally_core import eprint, _now_utc
204
212
  from _cctally_config import save_config
205
213
 
@@ -405,7 +413,7 @@ def _load_update_state() -> dict[str, Any] | None:
405
413
  """
406
414
  c = _cctally()
407
415
  try:
408
- text = c.UPDATE_STATE_PATH.read_text(encoding="utf-8")
416
+ text = _cctally_core.UPDATE_STATE_PATH.read_text(encoding="utf-8")
409
417
  except FileNotFoundError:
410
418
  return None
411
419
  try:
@@ -437,12 +445,12 @@ def _save_update_state(state: dict[str, Any]) -> None:
437
445
  writers via ``UPDATE_LOCK_PATH`` (spec §5.3).
438
446
  """
439
447
  c = _cctally()
440
- c.UPDATE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
448
+ _cctally_core.UPDATE_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
441
449
  payload = (
442
450
  json.dumps(state, indent=2, sort_keys=True) + "\n"
443
451
  ).encode("utf-8")
444
- tmp = c.UPDATE_STATE_PATH.with_name(
445
- f"{c.UPDATE_STATE_PATH.name}.tmp.{os.getpid()}"
452
+ tmp = _cctally_core.UPDATE_STATE_PATH.with_name(
453
+ f"{_cctally_core.UPDATE_STATE_PATH.name}.tmp.{os.getpid()}"
446
454
  )
447
455
  fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
448
456
  try:
@@ -450,7 +458,7 @@ def _save_update_state(state: dict[str, Any]) -> None:
450
458
  os.fsync(fd)
451
459
  finally:
452
460
  os.close(fd)
453
- os.replace(str(tmp), str(c.UPDATE_STATE_PATH))
461
+ os.replace(str(tmp), str(_cctally_core.UPDATE_STATE_PATH))
454
462
 
455
463
 
456
464
  def _load_update_suppress() -> dict[str, Any]:
@@ -462,7 +470,7 @@ def _load_update_suppress() -> dict[str, Any]:
462
470
  c = _cctally()
463
471
  default = {"_schema": 1, "skipped_versions": [], "remind_after": None}
464
472
  try:
465
- text = c.UPDATE_SUPPRESS_PATH.read_text(encoding="utf-8")
473
+ text = _cctally_core.UPDATE_SUPPRESS_PATH.read_text(encoding="utf-8")
466
474
  except FileNotFoundError:
467
475
  return default
468
476
  try:
@@ -489,12 +497,12 @@ def _save_update_suppress(suppress: dict[str, Any]) -> None:
489
497
  """Persist ``update-suppress.json`` atomically. Same idiom as
490
498
  :func:`_save_update_state`."""
491
499
  c = _cctally()
492
- c.UPDATE_SUPPRESS_PATH.parent.mkdir(parents=True, exist_ok=True)
500
+ _cctally_core.UPDATE_SUPPRESS_PATH.parent.mkdir(parents=True, exist_ok=True)
493
501
  payload = (
494
502
  json.dumps(suppress, indent=2, sort_keys=True) + "\n"
495
503
  ).encode("utf-8")
496
- tmp = c.UPDATE_SUPPRESS_PATH.with_name(
497
- f"{c.UPDATE_SUPPRESS_PATH.name}.tmp.{os.getpid()}"
504
+ tmp = _cctally_core.UPDATE_SUPPRESS_PATH.with_name(
505
+ f"{_cctally_core.UPDATE_SUPPRESS_PATH.name}.tmp.{os.getpid()}"
498
506
  )
499
507
  fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
500
508
  try:
@@ -502,7 +510,7 @@ def _save_update_suppress(suppress: dict[str, Any]) -> None:
502
510
  os.fsync(fd)
503
511
  finally:
504
512
  os.close(fd)
505
- os.replace(str(tmp), str(c.UPDATE_SUPPRESS_PATH))
513
+ os.replace(str(tmp), str(_cctally_core.UPDATE_SUPPRESS_PATH))
506
514
 
507
515
 
508
516
  def _read_lock_pid(fd: int) -> int | None:
@@ -543,9 +551,9 @@ def _acquire_update_lock() -> int:
543
551
  COMMAND=cctally update
544
552
  """
545
553
  c = _cctally()
546
- c.UPDATE_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
554
+ _cctally_core.UPDATE_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
547
555
  fd = os.open(
548
- str(c.UPDATE_LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o644
556
+ str(_cctally_core.UPDATE_LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o644
549
557
  )
550
558
  try:
551
559
  fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
@@ -622,16 +630,16 @@ def _rotate_update_log_if_needed() -> None:
622
630
  """
623
631
  c = _cctally()
624
632
  try:
625
- size = c.UPDATE_LOG_PATH.stat().st_size
633
+ size = _cctally_core.UPDATE_LOG_PATH.stat().st_size
626
634
  except FileNotFoundError:
627
635
  return
628
636
  if size < c.UPDATE_LOG_ROTATE_BYTES:
629
637
  return
630
638
  try:
631
- c.UPDATE_LOG_ROTATED_PATH.unlink()
639
+ _cctally_core.UPDATE_LOG_ROTATED_PATH.unlink()
632
640
  except FileNotFoundError:
633
641
  pass
634
- c.UPDATE_LOG_PATH.rename(c.UPDATE_LOG_ROTATED_PATH)
642
+ _cctally_core.UPDATE_LOG_PATH.rename(_cctally_core.UPDATE_LOG_ROTATED_PATH)
635
643
 
636
644
 
637
645
  def _log_update_event(log_fd, event: str, **kv: Any) -> None:
@@ -892,7 +900,7 @@ def _self_heal_current_version() -> None:
892
900
  """
893
901
  c = _cctally()
894
902
  try:
895
- if (c.CHANGELOG_PATH.parent / ".git").exists():
903
+ if (_cctally_core.CHANGELOG_PATH.parent / ".git").exists():
896
904
  return
897
905
  fresh = _release_read_latest_release_version()
898
906
  if fresh is None:
@@ -1029,7 +1037,7 @@ def _is_update_check_due(config: dict) -> bool:
1029
1037
  return False
1030
1038
  ttl_hours = check_cfg.get("ttl_hours", c.UPDATE_DEFAULT_TTL_HOURS)
1031
1039
  try:
1032
- mtime = c.UPDATE_CHECK_LAST_FETCH_PATH.stat().st_mtime
1040
+ mtime = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.stat().st_mtime
1033
1041
  except FileNotFoundError:
1034
1042
  return True
1035
1043
  return (time.time() - mtime) >= ttl_hours * 3600
@@ -1052,8 +1060,8 @@ def _do_update_check() -> None:
1052
1060
  c = _cctally()
1053
1061
  # Touch marker FIRST — crash safety: a dead process mid-fetch must
1054
1062
  # not trigger another fetch within the TTL window.
1055
- c.UPDATE_CHECK_LAST_FETCH_PATH.parent.mkdir(parents=True, exist_ok=True)
1056
- c.UPDATE_CHECK_LAST_FETCH_PATH.touch()
1063
+ _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.parent.mkdir(parents=True, exist_ok=True)
1064
+ _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH.touch()
1057
1065
 
1058
1066
  method = c._detect_install_method(mutate=True)
1059
1067
 
@@ -1143,15 +1151,15 @@ def cmd_update_check_internal(args) -> int:
1143
1151
  """
1144
1152
  c = _cctally()
1145
1153
  # Ensure APP_DIR exists so log + state writes succeed on first run.
1146
- c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1154
+ _cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1147
1155
  try:
1148
- with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
1156
+ with open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
1149
1157
  _log_update_event(log_fd, "CHECK_START")
1150
1158
  c._do_update_check()
1151
1159
  _log_update_event(log_fd, "CHECK_EXIT", rc=0)
1152
1160
  except Exception as e:
1153
1161
  try:
1154
- with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
1162
+ with open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
1155
1163
  _log_update_event(log_fd, "CHECK_EXIT", rc=1, error=str(e)[:200])
1156
1164
  except Exception:
1157
1165
  pass
@@ -1615,10 +1623,10 @@ def _do_update_install(
1615
1623
  quoted = " ".join(shlex.quote(c2) for c2 in cmd)
1616
1624
  print(f"Would run: {quoted}")
1617
1625
  return 0
1618
- c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1626
+ _cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1619
1627
  lock_fd = c._acquire_update_lock()
1620
1628
  try:
1621
- with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
1629
+ with open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
1622
1630
  _log_update_event(log_fd, "INSTALL_START", method=method.method)
1623
1631
  for step_name, cmd in steps:
1624
1632
  _log_update_event(log_fd, "STEP_START", name=step_name)
@@ -1790,9 +1798,9 @@ class UpdateWorker:
1790
1798
  try:
1791
1799
  method = c._detect_install_method(mutate=True)
1792
1800
  c._preflight_install(method, version)
1793
- c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1801
+ _cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1794
1802
  lock_fd = c._acquire_update_lock()
1795
- log_fd = open(c.UPDATE_LOG_PATH, "a", encoding="utf-8")
1803
+ log_fd = open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8")
1796
1804
  _log_update_event(log_fd, "INSTALL_START", method=method.method)
1797
1805
  for step_name, cmd in c._build_update_steps(method, version):
1798
1806
  self._emit(run_id, {"type": "step", "name": step_name})
@@ -1923,8 +1931,8 @@ class _DashboardUpdateCheckThread(threading.Thread):
1923
1931
  # silently disable the polling cadence for the rest
1924
1932
  # of the dashboard's lifetime.
1925
1933
  try:
1926
- c.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1927
- with open(c.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
1934
+ _cctally_core.UPDATE_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
1935
+ with open(_cctally_core.UPDATE_LOG_PATH, "a", encoding="utf-8") as log_fd:
1928
1936
  _log_update_event(
1929
1937
  log_fd, "CHECK_FAILED", error=str(e)[:200]
1930
1938
  )
@@ -13,6 +13,8 @@ from __future__ import annotations
13
13
 
14
14
  import sys
15
15
 
16
+ import _cctally_core
17
+
16
18
 
17
19
  def _cctally():
18
20
  """Call-time accessor for the ``cctally`` module (project memory
@@ -35,7 +37,7 @@ def _read_latest_changelog_version() -> tuple[str, str] | None:
35
37
  """
36
38
  c = _cctally()
37
39
  try:
38
- text = c.CHANGELOG_PATH.read_text(encoding="utf-8")
40
+ text = _cctally_core.CHANGELOG_PATH.read_text(encoding="utf-8")
39
41
  except FileNotFoundError:
40
42
  return None
41
43
  m = c.RELEASE_HEADER_RE.search(text)
@@ -21,6 +21,18 @@ from collections.abc import Callable, Mapping
21
21
  from dataclasses import dataclass
22
22
  from typing import Any
23
23
 
24
+ # NOTE: ``_cctally_core`` is imported lazily inside ``_release_version()``
25
+ # (the sole consumer in this module) so that loading this file directly
26
+ # via ``importlib.util.spec_from_file_location`` — the established
27
+ # pattern used by ``_render_share_v2_fixture.py``,
28
+ # ``_compose_share_v2_fixture.py``, and ``tests/test_lib_share_templates.py``
29
+ # — does NOT depend on ``bin/`` being on ``sys.path`` at module-import
30
+ # time. A module-top ``import _cctally_core`` would raise
31
+ # ``ModuleNotFoundError`` for any standalone loader that hasn't already
32
+ # arranged for ``_cctally_core`` to be importable, regressing the
33
+ # parallel-loader pattern this module uses for ``_lib_share`` itself
34
+ # (see ``_import_share_lib`` below).
35
+
24
36
 
25
37
  # --- Panel set ---
26
38
  #
@@ -288,22 +300,28 @@ def _utc_now() -> _dt.datetime:
288
300
  def _release_version() -> str:
289
301
  """Read CHANGELOG-stamped latest version.
290
302
 
291
- Honors `CCTALLY_TEST_CHANGELOG_PATH` override (the documented test pattern
292
- at `bin/cctally:86`). Falls back to `"dev"` when CHANGELOG is unreadable
293
- or has no stamped release entry yet (pre-release dev builds).
303
+ Resolves through ``_cctally_core.CHANGELOG_PATH`` so the kernel's
304
+ env override (``CCTALLY_TEST_CHANGELOG_PATH``) and any test-side
305
+ ``monkeypatch.setattr(_cctally_core, "CHANGELOG_PATH", ...)`` both
306
+ propagate transparently. Falls back to ``"dev"`` when CHANGELOG is
307
+ unreadable or has no stamped release entry yet (pre-release dev
308
+ builds).
294
309
 
295
- Parallel to `_lib_changelog._read_latest_changelog_version`
296
- (re-exported on `bin/cctally` under the historical
297
- `_release_read_latest_release_version` name) — intentionally
310
+ Parallel to ``_lib_changelog._read_latest_changelog_version``
311
+ (re-exported on ``bin/cctally`` under the historical
312
+ ``_release_read_latest_release_version`` name) — intentionally
298
313
  duplicated so the template module stays free of any
299
- `bin/cctally` import. If CHANGELOG header format changes, update both.
314
+ ``bin/cctally`` import. If CHANGELOG header format changes, update both.
315
+
316
+ ``_cctally_core`` is imported lazily here (rather than at module top)
317
+ so that standalone loaders — see the note at the top of this file
318
+ and the ``_import_share_lib`` pattern below — can exec this module
319
+ without ``bin/`` already being on ``sys.path``. By the time
320
+ ``_release_version()`` is actually called, the caller (CLI boot,
321
+ dashboard, or fixture driver) has resolved the sibling layout.
300
322
  """
301
- from pathlib import Path
302
- p = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
303
- if p:
304
- path = Path(p)
305
- else:
306
- path = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
323
+ import _cctally_core # noqa: PLC0415 — lazy by design (see module-top NOTE)
324
+ path = _cctally_core.CHANGELOG_PATH
307
325
  try:
308
326
  for line in path.read_text(encoding="utf-8").splitlines():
309
327
  if line.startswith("## [") and "Unreleased" not in line: