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.
- package/CHANGELOG.md +22 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +961 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_share.py +350 -32
- package/bin/_lib_share_templates.py +233 -44
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11061 -34659
- package/dashboard/static/assets/index-BgpoazlS.js +18 -0
- package/dashboard/static/assets/index-nJdUaGys.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +25 -1
- package/dashboard/static/assets/index-Z6V0XgqK.js +0 -18
- package/dashboard/static/assets/index-ZPC0pk-h.css +0 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Display-timezone primitives — the datetime render chokepoint.
|
|
2
|
+
|
|
3
|
+
Pure-fn layer (no I/O at import time): holds every helper that resolves a
|
|
4
|
+
display tz from CLI args / config, localizes a datetime through that
|
|
5
|
+
resolution, and formats it for human display. `format_display_dt` is the
|
|
6
|
+
chokepoint per CLAUDE.md — all human-displayed datetimes route through it.
|
|
7
|
+
|
|
8
|
+
`bin/cctally` re-exports every symbol below so internal call sites resolve
|
|
9
|
+
unchanged. Future pure layers (alerts payload, render, aggregators) import
|
|
10
|
+
the chokepoint from here directly via `_load_sibling("_lib_display_tz")`,
|
|
11
|
+
so they stay pure without back-importing `cctally`.
|
|
12
|
+
|
|
13
|
+
Module-level flags `_DISPLAY_TZ_BAD_CONFIG_WARNED` and
|
|
14
|
+
`_DISPLAY_TZ_RESOLVE_WARNED` move with their owning functions; the
|
|
15
|
+
`global` declarations work fine in the new module. A private `_eprint`
|
|
16
|
+
duplicates `bin/cctally:eprint` (two-line stderr helper) so this pure
|
|
17
|
+
layer carries zero back-imports per the split design's §5.3 contract.
|
|
18
|
+
|
|
19
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import datetime as dt
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _eprint(*args: Any) -> None:
|
|
33
|
+
print(*args, file=sys.stderr)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _local_tz_name() -> str:
|
|
37
|
+
"""Return an IANA timezone name if resolvable, else a best-effort fallback.
|
|
38
|
+
|
|
39
|
+
Used in Codex command title banners ("(Timezone: <name>)"). Upstream
|
|
40
|
+
uses IANA names (e.g. "Asia/Jerusalem"); we mirror when possible.
|
|
41
|
+
"""
|
|
42
|
+
# Preferred: TZ env var if IANA-looking ("/" in the value).
|
|
43
|
+
tz = os.environ.get("TZ", "")
|
|
44
|
+
if tz and "/" in tz:
|
|
45
|
+
return tz
|
|
46
|
+
try:
|
|
47
|
+
tz_path = os.readlink("/etc/localtime")
|
|
48
|
+
if "zoneinfo/" in tz_path:
|
|
49
|
+
return tz_path.split("zoneinfo/", 1)[1]
|
|
50
|
+
except (OSError, ValueError):
|
|
51
|
+
pass
|
|
52
|
+
# Fallback: time.tzname[0] (e.g. "IST"); may be non-IANA.
|
|
53
|
+
try:
|
|
54
|
+
import time as _time
|
|
55
|
+
return _time.tzname[0] or ""
|
|
56
|
+
except Exception:
|
|
57
|
+
return ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _resolve_tz(tz_name: str | None, *, strict_iana: bool = False, fallback: Any = None) -> Any:
|
|
61
|
+
"""Return ZoneInfo(tz_name) or ``fallback``. Callers have already validated
|
|
62
|
+
the tz via _parse_cli_date_range; this is a defensive re-resolve that
|
|
63
|
+
falls back to ``fallback`` on any error so aggregators never crash.
|
|
64
|
+
|
|
65
|
+
With ``strict_iana=True``, names lacking a "/" (e.g. bare "UTC") are
|
|
66
|
+
rejected up-front and ``fallback`` is returned instead — mirrors
|
|
67
|
+
``_local_tz_name``'s gating to avoid the bare-UTC gotcha (see CLAUDE.md).
|
|
68
|
+
Default behavior (strict_iana=False, fallback=None) preserves the prior
|
|
69
|
+
contract for existing callers.
|
|
70
|
+
"""
|
|
71
|
+
if not tz_name:
|
|
72
|
+
return fallback
|
|
73
|
+
if strict_iana and "/" not in tz_name:
|
|
74
|
+
return fallback
|
|
75
|
+
try:
|
|
76
|
+
return ZoneInfo(tz_name)
|
|
77
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
78
|
+
return fallback
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
DISPLAY_TZ_DEFAULT = "local"
|
|
82
|
+
_DISPLAY_TZ_BAD_CONFIG_WARNED = False # one-shot warning flag
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def normalize_display_tz_value(raw: "str | None") -> str:
|
|
86
|
+
"""Canonicalize a display-tz value to "local" | "utc" | <IANA>.
|
|
87
|
+
|
|
88
|
+
- None / "" / case-insensitive "local" -> "local"
|
|
89
|
+
- case-insensitive "utc" -> "utc"
|
|
90
|
+
- anything else: must be a valid IANA name (validated via _resolve_tz
|
|
91
|
+
with strict_iana=True). Returns the trimmed value verbatim.
|
|
92
|
+
Raises ValueError on invalid input.
|
|
93
|
+
"""
|
|
94
|
+
if raw is None:
|
|
95
|
+
return "local"
|
|
96
|
+
s = str(raw).strip()
|
|
97
|
+
if not s:
|
|
98
|
+
return "local"
|
|
99
|
+
low = s.lower()
|
|
100
|
+
if low == "local":
|
|
101
|
+
return "local"
|
|
102
|
+
if low == "utc":
|
|
103
|
+
return "utc"
|
|
104
|
+
sentinel = object()
|
|
105
|
+
if _resolve_tz(s, strict_iana=True, fallback=sentinel) is sentinel:
|
|
106
|
+
raise ValueError(f"invalid IANA zone: {s!r}")
|
|
107
|
+
return s
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _config_has_explicit_display_tz(config: "dict | None") -> bool:
|
|
111
|
+
"""Return True iff `config["display"]["tz"]` is present and non-None.
|
|
112
|
+
|
|
113
|
+
Used by codex command tz-name resolution (F2): we need to distinguish
|
|
114
|
+
"user pinned a display tz" from "default 'local' fallback because no
|
|
115
|
+
config block is set" so upstream's `--timezone` can still apply in the
|
|
116
|
+
drop-in-parity case where neither --tz nor display.tz was specified.
|
|
117
|
+
"""
|
|
118
|
+
if not isinstance(config, dict):
|
|
119
|
+
return False
|
|
120
|
+
block = config.get("display")
|
|
121
|
+
return isinstance(block, dict) and block.get("tz") is not None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_display_tz_pref(config: "dict | None") -> str:
|
|
125
|
+
"""Read config['display']['tz']; default DISPLAY_TZ_DEFAULT.
|
|
126
|
+
|
|
127
|
+
Malformed value -> fall back to default with one-shot stderr warning.
|
|
128
|
+
Never raises.
|
|
129
|
+
"""
|
|
130
|
+
global _DISPLAY_TZ_BAD_CONFIG_WARNED
|
|
131
|
+
if not isinstance(config, dict):
|
|
132
|
+
return DISPLAY_TZ_DEFAULT
|
|
133
|
+
block = config.get("display")
|
|
134
|
+
if not isinstance(block, dict):
|
|
135
|
+
return DISPLAY_TZ_DEFAULT
|
|
136
|
+
raw = block.get("tz")
|
|
137
|
+
if raw is None:
|
|
138
|
+
return DISPLAY_TZ_DEFAULT
|
|
139
|
+
try:
|
|
140
|
+
return normalize_display_tz_value(raw)
|
|
141
|
+
except ValueError:
|
|
142
|
+
if not _DISPLAY_TZ_BAD_CONFIG_WARNED:
|
|
143
|
+
_eprint(
|
|
144
|
+
f"warning: ignoring malformed display.tz {raw!r} in config; "
|
|
145
|
+
f"using {DISPLAY_TZ_DEFAULT!r}"
|
|
146
|
+
)
|
|
147
|
+
_DISPLAY_TZ_BAD_CONFIG_WARNED = True
|
|
148
|
+
return DISPLAY_TZ_DEFAULT
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_display_tz(args: argparse.Namespace,
|
|
152
|
+
config: "dict | None") -> "ZoneInfo | None":
|
|
153
|
+
"""Returns ZoneInfo for fixed zones, None for "local" (caller does
|
|
154
|
+
bare astimezone()). Precedence: --tz flag > config > default.
|
|
155
|
+
Invalid --tz values are normalized via normalize_display_tz_value
|
|
156
|
+
(which raises ValueError -- caller is responsible for catching at
|
|
157
|
+
argparse-time via the shared _argparse_tz type=callable).
|
|
158
|
+
"""
|
|
159
|
+
flag = getattr(args, "tz", None)
|
|
160
|
+
if flag is not None and str(flag).strip() != "":
|
|
161
|
+
canonical = normalize_display_tz_value(flag) # may raise
|
|
162
|
+
else:
|
|
163
|
+
canonical = get_display_tz_pref(config)
|
|
164
|
+
if canonical == "local":
|
|
165
|
+
return None
|
|
166
|
+
if canonical == "utc":
|
|
167
|
+
return ZoneInfo("Etc/UTC")
|
|
168
|
+
return ZoneInfo(canonical)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def display_tz_label(localized: dt.datetime) -> str:
|
|
172
|
+
"""Returns the suffix label for an already-localized datetime.
|
|
173
|
+
|
|
174
|
+
Prefer ``localized.tzname()`` if alphanumeric and len <= 5; otherwise
|
|
175
|
+
fall back to a numeric offset via ``%z`` trimmed to "+HH" or "+HHMM".
|
|
176
|
+
Works uniformly across local / utc / explicit IANA -- caller has
|
|
177
|
+
already done astimezone() before passing in.
|
|
178
|
+
"""
|
|
179
|
+
if localized.tzinfo is None:
|
|
180
|
+
# Defensive: shouldn't happen because callers localize first
|
|
181
|
+
localized = localized.replace(tzinfo=dt.timezone.utc)
|
|
182
|
+
name = localized.tzname() or ""
|
|
183
|
+
if (name
|
|
184
|
+
and name.replace("+", "").replace("-", "").isalnum()
|
|
185
|
+
and len(name) <= 5
|
|
186
|
+
and any(c.isalpha() for c in name)):
|
|
187
|
+
return name
|
|
188
|
+
# Numeric fallback. %z gives "+HHMM"; trim trailing "00" -> "+HH".
|
|
189
|
+
raw = localized.strftime("%z")
|
|
190
|
+
if not raw:
|
|
191
|
+
return "UTC"
|
|
192
|
+
if raw.endswith("00") and len(raw) == 5:
|
|
193
|
+
return raw[:-2] # "+0500" -> "+05"
|
|
194
|
+
# Insert colon for readability when minutes != 00: "+0530" -> "+05:30"
|
|
195
|
+
if len(raw) == 5:
|
|
196
|
+
return f"{raw[:3]}:{raw[3:]}"
|
|
197
|
+
return raw
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _localize(d: dt.datetime, tz: "ZoneInfo | None") -> dt.datetime:
|
|
201
|
+
"""Localize a tz-aware datetime through the resolved display tz.
|
|
202
|
+
|
|
203
|
+
Mirrors ``format_display_dt``'s tz handling for callers that need a
|
|
204
|
+
localized ``dt.datetime`` (not a formatted string) — e.g. when the
|
|
205
|
+
same instant is fed to multiple ``strftime`` formats. ``tz=None``
|
|
206
|
+
falls back to host local via bare ``astimezone()``.
|
|
207
|
+
"""
|
|
208
|
+
return d.astimezone(tz) if tz is not None else d.astimezone()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_DISPLAY_TZ_RESOLVE_WARNED = False # one-shot host-zone-fallback warning
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _resolve_display_tz_obj(config: dict) -> ZoneInfo:
|
|
215
|
+
"""Resolve config.display.tz to a concrete ZoneInfo object.
|
|
216
|
+
|
|
217
|
+
Single source of truth used by ``_compute_display_block`` and the
|
|
218
|
+
dashboard snapshot builders (``_tui_build_snapshot``,
|
|
219
|
+
``_handle_get_block_detail``). Local-fallback case emits a one-shot
|
|
220
|
+
warning via ``_DISPLAY_TZ_RESOLVE_WARNED``. Returns a ZoneInfo
|
|
221
|
+
(never None).
|
|
222
|
+
"""
|
|
223
|
+
global _DISPLAY_TZ_RESOLVE_WARNED
|
|
224
|
+
pref = get_display_tz_pref(config)
|
|
225
|
+
if pref == "local":
|
|
226
|
+
host_iana = _local_tz_name()
|
|
227
|
+
if host_iana and "/" in host_iana:
|
|
228
|
+
return ZoneInfo(host_iana)
|
|
229
|
+
if not _DISPLAY_TZ_RESOLVE_WARNED:
|
|
230
|
+
_eprint(
|
|
231
|
+
"warning: display.tz='local' but host IANA zone could "
|
|
232
|
+
"not be resolved; using Etc/UTC. Set TZ to an IANA "
|
|
233
|
+
"name (e.g. America/New_York) to fix."
|
|
234
|
+
)
|
|
235
|
+
_DISPLAY_TZ_RESOLVE_WARNED = True
|
|
236
|
+
return ZoneInfo("Etc/UTC")
|
|
237
|
+
if pref == "utc":
|
|
238
|
+
return ZoneInfo("Etc/UTC")
|
|
239
|
+
try:
|
|
240
|
+
return ZoneInfo(pref)
|
|
241
|
+
except Exception:
|
|
242
|
+
# Defense in depth -- pref was already canonicalized via
|
|
243
|
+
# normalize_display_tz_value, but if a stale config slipped
|
|
244
|
+
# through, fall back rather than crash.
|
|
245
|
+
if not _DISPLAY_TZ_RESOLVE_WARNED:
|
|
246
|
+
_eprint(
|
|
247
|
+
f"warning: display.tz={pref!r} could not be loaded as "
|
|
248
|
+
f"a ZoneInfo; using Etc/UTC"
|
|
249
|
+
)
|
|
250
|
+
_DISPLAY_TZ_RESOLVE_WARNED = True
|
|
251
|
+
return ZoneInfo("Etc/UTC")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _apply_display_tz_override(
|
|
255
|
+
config: dict,
|
|
256
|
+
override: "str | None",
|
|
257
|
+
) -> dict:
|
|
258
|
+
"""Return a shallow-copied config with `display.tz` substituted from
|
|
259
|
+
the override (F3 fix).
|
|
260
|
+
|
|
261
|
+
`--tz` on `cctally dashboard` should win over the persisted
|
|
262
|
+
`config.display.tz` for the lifetime of the server. Plumbing the
|
|
263
|
+
canonicalized string here lets every reader that already calls
|
|
264
|
+
`load_config()` (envelope builder, snapshot builder, block-detail
|
|
265
|
+
handler) pick up the override by routing the loaded config through
|
|
266
|
+
this thin wrapper -- without changing their existing
|
|
267
|
+
`_resolve_display_tz_obj(load_config())` shape.
|
|
268
|
+
|
|
269
|
+
Override semantics: a canonical token from
|
|
270
|
+
``normalize_display_tz_value`` (``"local"`` / ``"utc"`` / IANA
|
|
271
|
+
name). ``None`` means "no override; let config win" -- the input
|
|
272
|
+
config is returned unchanged. Always returns a fresh dict so callers
|
|
273
|
+
can't mutate the passed-in config via the result.
|
|
274
|
+
"""
|
|
275
|
+
if override is None:
|
|
276
|
+
return config
|
|
277
|
+
out = dict(config)
|
|
278
|
+
out["display"] = dict(out.get("display") or {})
|
|
279
|
+
out["display"]["tz"] = override
|
|
280
|
+
out["display"]["pinned"] = True
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _compute_display_block(config: dict, generated_at: dt.datetime) -> dict:
|
|
285
|
+
"""Compute the dashboard envelope's ``display`` block.
|
|
286
|
+
|
|
287
|
+
Resolves ``config.display.tz`` to a CONCRETE IANA name server-side
|
|
288
|
+
(F1 fix per spec) so the browser never has to guess "local". Also
|
|
289
|
+
computes the offset label and signed offset seconds at the snapshot
|
|
290
|
+
timestamp, so the client can label times consistently with the
|
|
291
|
+
server-rendered week_lbl / block labels.
|
|
292
|
+
|
|
293
|
+
Resolution flows through ``_resolve_display_tz_obj`` -- the same
|
|
294
|
+
helper that ``_tui_build_snapshot`` and ``_handle_get_block_detail``
|
|
295
|
+
use, so all three sites share warn-once stderr semantics.
|
|
296
|
+
"""
|
|
297
|
+
pref = get_display_tz_pref(config)
|
|
298
|
+
resolved_obj = _resolve_display_tz_obj(config)
|
|
299
|
+
resolved_iana = resolved_obj.key
|
|
300
|
+
|
|
301
|
+
if generated_at.tzinfo is None:
|
|
302
|
+
generated_at = generated_at.replace(tzinfo=dt.timezone.utc)
|
|
303
|
+
localized = generated_at.astimezone(resolved_obj)
|
|
304
|
+
offset = localized.utcoffset()
|
|
305
|
+
offset_seconds = int(offset.total_seconds()) if offset is not None else 0
|
|
306
|
+
block: dict = {
|
|
307
|
+
"tz": pref,
|
|
308
|
+
"resolved_tz": resolved_iana,
|
|
309
|
+
"offset_label": display_tz_label(localized),
|
|
310
|
+
"offset_seconds": offset_seconds,
|
|
311
|
+
}
|
|
312
|
+
# F3: surface --tz-pin so the React client can render a read-only
|
|
313
|
+
# state for the Settings UI when the operator launched the server
|
|
314
|
+
# with an explicit --tz override. Override application leaves the
|
|
315
|
+
# `pinned` key on `config["display"]`; we forward it as a runtime
|
|
316
|
+
# signal (NOT persisted into config.json -- POST /api/settings is
|
|
317
|
+
# blocked under pin in this mode).
|
|
318
|
+
display_cfg = config.get("display") if isinstance(config, dict) else None
|
|
319
|
+
if isinstance(display_cfg, dict) and display_cfg.get("pinned"):
|
|
320
|
+
block["pinned"] = True
|
|
321
|
+
return block
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def format_display_dt(value: "str | dt.datetime",
|
|
325
|
+
tz: "ZoneInfo | None",
|
|
326
|
+
*, fmt: str, suffix: bool = True) -> str:
|
|
327
|
+
"""Targeted-swap chokepoint. Naive value treated as UTC.
|
|
328
|
+
|
|
329
|
+
Output: "<strftime fmt> <suffix>" when suffix=True, else just the
|
|
330
|
+
strftime. Suffix is computed from the localized datetime via
|
|
331
|
+
display_tz_label.
|
|
332
|
+
"""
|
|
333
|
+
if isinstance(value, str):
|
|
334
|
+
s = value.replace("Z", "+00:00")
|
|
335
|
+
parsed = dt.datetime.fromisoformat(s)
|
|
336
|
+
else:
|
|
337
|
+
parsed = value
|
|
338
|
+
if parsed.tzinfo is None:
|
|
339
|
+
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
|
340
|
+
localized = parsed.astimezone(tz) if tz is not None else parsed.astimezone()
|
|
341
|
+
body = localized.strftime(fmt)
|
|
342
|
+
if not suffix:
|
|
343
|
+
return body
|
|
344
|
+
return f"{body} {display_tz_label(localized)}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _argparse_tz(value: str) -> str:
|
|
348
|
+
"""argparse ``type=`` callable for ``--tz`` on any subcommand.
|
|
349
|
+
|
|
350
|
+
Canonicalizes via ``normalize_display_tz_value``: returns "local",
|
|
351
|
+
"utc", or a verbatim IANA name. Bad input raises
|
|
352
|
+
``argparse.ArgumentTypeError`` so argparse formats a standard
|
|
353
|
+
``error: argument --tz: ...`` message and exits 2.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
return normalize_display_tz_value(value)
|
|
357
|
+
except ValueError:
|
|
358
|
+
raise argparse.ArgumentTypeError(
|
|
359
|
+
f"invalid timezone {value!r} -- expected 'local', 'utc', "
|
|
360
|
+
f"or an IANA name"
|
|
361
|
+
)
|