cctally 1.93.0 → 1.94.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.
@@ -18,7 +18,7 @@ import subprocess
18
18
  import sys
19
19
 
20
20
  import _lib_changelog # module-qualified: _lib_changelog._read_latest_changelog_version()
21
- from _lib_display_tz import format_display_dt
21
+ from _lib_display_tz import _resolve_tz, format_display_dt, resolve_display_tz_name
22
22
  from _lib_fmt import stable_sum
23
23
  from _lib_render import _project_disambiguate_labels
24
24
 
@@ -270,28 +270,68 @@ def _share_period_label(
270
270
  ) -> str:
271
271
  """Render the canonical "<start> → <end> (<tz>)" period label.
272
272
 
273
- Used by both the report and daily snapshot builders so the period label
274
- format stays consistent across share-enabled subcommands.
273
+ Used by every CLI share snapshot builder so the period label format
274
+ stays consistent across share-enabled subcommands.
275
+
276
+ FULL ISO (#503 S2 D4). It used to render `%b %d`, so a report spanning
277
+ 2020 to 2026 read `Jan 01 → May 09` and named neither year. This
278
+ matches the shape `bin/_cctally_codex.py` and
279
+ `bin/_cctally_source_analytics.py` already use.
275
280
  """
276
281
  return (
277
- f"{period_start.strftime('%b %d')} → "
278
- f"{period_end.strftime('%b %d')} ({display_tz_label})"
282
+ f"{period_start.date().isoformat()} → "
283
+ f"{period_end.date().isoformat()} ({display_tz_label})"
279
284
  )
280
285
 
281
286
 
287
+ def _share_display_zone(tz: "ZoneInfo | None"):
288
+ """The concrete zone `_share_display_tz_label` names, as a tzinfo.
289
+
290
+ `resolve_display_tz` returns `None` for the `local` configuration
291
+ token, which is the DEFAULT. Every site that lifts a calendar label
292
+ into a `PeriodSpec` boundary must anchor it in this zone, because that
293
+ is the zone the artifact's period is labelled with (#503 S2 D7).
294
+ Falls back to UTC only when the resolved name is not loadable, which
295
+ keeps a stale host zone from turning a render into an exception.
296
+ """
297
+ if tz is not None:
298
+ return tz
299
+ resolved = _resolve_tz(_share_display_tz_label(None), strict_iana=False,
300
+ fallback=None)
301
+ return resolved if resolved is not None else dt.timezone.utc
302
+
303
+
304
+ def _share_ground_civil_date(d: "dt.date", tz: "ZoneInfo | None") -> dt.datetime:
305
+ """Lift a calendar label to midnight IN THE ZONE the artifact names.
306
+
307
+ `2026-04-13` is a calendar label, not an instant. Lifting it to UTC
308
+ midnight and then converting it into `America/New_York` — which is
309
+ what `period_civil_dates` does for a non-`civil_bucket` period —
310
+ reports `2026-04-12`, a day the artifact's own table does not contain
311
+ (#503 S2 D7). Grounding it here keeps the boundary a real instant, so
312
+ the conversion is the identity and the two boundary kinds stay
313
+ distinguishable without a second flag on the command-line path.
314
+ """
315
+ return dt.datetime(d.year, d.month, d.day, tzinfo=_share_display_zone(tz))
316
+
317
+
282
318
  def _share_parse_date_to_dt(value, tz: "ZoneInfo | None") -> dt.datetime:
283
319
  """Coerce a `YYYY-MM-DD` string or `dt.date` into a tz-aware datetime.
284
320
 
285
321
  Used by the share gate sites to lift week-boundary date strings
286
- (`weekStartDate`, `weekEndDate`) into the tz-aware datetimes that
287
- `PeriodSpec` expects. None / empty / unparseable -> current UTC; the
288
- caller already gated on a non-empty trend before reaching this path,
289
- so the fallback is purely defensive against missing-data corner cases.
322
+ (`weekStartDate`, `weekEndDate`) and 5-hour block date parts into the
323
+ tz-aware datetimes that `PeriodSpec` expects. None / empty /
324
+ unparseable -> current UTC; the caller already gated on a non-empty
325
+ trend before reaching this path, so the fallback is purely defensive
326
+ against missing-data corner cases.
327
+
328
+ A parsed calendar label is grounded through `_share_ground_civil_date`.
290
329
  """
291
330
  if value is None:
292
331
  return _share_now_utc()
293
332
  if isinstance(value, dt.datetime):
294
- return value if value.tzinfo else value.replace(tzinfo=tz or dt.timezone.utc)
333
+ return (value if value.tzinfo
334
+ else value.replace(tzinfo=_share_display_zone(tz)))
295
335
  if isinstance(value, dt.date):
296
336
  d = value
297
337
  else:
@@ -299,18 +339,23 @@ def _share_parse_date_to_dt(value, tz: "ZoneInfo | None") -> dt.datetime:
299
339
  d = dt.date.fromisoformat(str(value))
300
340
  except ValueError:
301
341
  return _share_now_utc()
302
- midnight = dt.datetime(d.year, d.month, d.day)
303
- return midnight.replace(tzinfo=tz or dt.timezone.utc)
342
+ return _share_ground_civil_date(d, tz)
304
343
 
305
344
 
306
345
  def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
307
- """Render a stable display-tz string for `PeriodSpec.display_tz`.
346
+ """Render the concrete IANA zone name for `PeriodSpec.display_tz`.
347
+
348
+ `resolve_display_tz` returns `None` for the "local" configuration
349
+ token (its callers then do a bare `astimezone()`). This used to map
350
+ that `None` to the literal string `"local"`, and every v1 share golden
351
+ printed `(local)` as a result — a label that names no zone, so a
352
+ reader could not tell which day boundary the artifact's dates use.
308
353
 
309
- `resolve_display_tz` returns `None` for "local" (caller does bare
310
- astimezone); the share snapshot needs a non-None string. Map None ->
311
- "local" and use ZoneInfo.key otherwise.
354
+ #503 S2 D7: resolve the token to the host's IANA zone instead. That is
355
+ the same zone the bare `astimezone()` produced, so the dates do not
356
+ move; only the label becomes truthful.
312
357
  """
313
- return tz.key if tz is not None else "local"
358
+ return tz.key if tz is not None else resolve_display_tz_name("local")
314
359
 
315
360
 
316
361
  def _build_report_snapshot(
@@ -320,8 +365,6 @@ def _build_report_snapshot(
320
365
  period_end: dt.datetime,
321
366
  display_tz: str,
322
367
  version: str,
323
- theme: str,
324
- reveal_projects: bool,
325
368
  ) -> "ShareSnapshot":
326
369
  """Build a ShareSnapshot for `cctally report`.
327
370
 
@@ -358,7 +401,7 @@ def _build_report_snapshot(
358
401
  wsd = r.week_start_date.isoformat() if r.week_start_date else None
359
402
  if isinstance(wsd, str) and wsd:
360
403
  try:
361
- week_label = dt.date.fromisoformat(wsd).strftime("%b %d")
404
+ week_label = dt.date.fromisoformat(wsd).isoformat()
362
405
  except ValueError:
363
406
  week_label = wsd
364
407
  else:
@@ -418,15 +461,12 @@ def _build_report_snapshot(
418
461
  else:
419
462
  title = "Weekly $ / % trend — no data"
420
463
  period_label = _share_period_label(period_start, period_end, display_tz)
421
- subtitle = " · ".join([
422
- period_label,
423
- theme,
424
- "real projects" if reveal_projects else "projects anonymized",
425
- ])
426
464
  return _lib_share.ShareSnapshot(
427
465
  cmd="report",
428
466
  title=title,
429
- subtitle=subtitle,
467
+ # #503 S2 D5 — the facts strip states the period and the
468
+ # privacy mode; the theme is dropped deliberately.
469
+ subtitle=None,
430
470
  period=_lib_share.PeriodSpec(
431
471
  start=period_start, end=period_end,
432
472
  display_tz=display_tz, label=period_label,
@@ -444,8 +484,6 @@ def _build_daily_snapshot(
444
484
  period_end: dt.datetime,
445
485
  display_tz: str,
446
486
  version: str,
447
- theme: str,
448
- reveal_projects: bool,
449
487
  ) -> "ShareSnapshot":
450
488
  """Build a ShareSnapshot for `cctally daily`.
451
489
 
@@ -496,7 +534,7 @@ def _build_daily_snapshot(
496
534
  bucket = getattr(r, "bucket", None)
497
535
  if isinstance(bucket, str) and bucket:
498
536
  try:
499
- date_str = dt.date.fromisoformat(bucket).strftime("%b %d")
537
+ date_str = dt.date.fromisoformat(bucket).isoformat()
500
538
  except ValueError:
501
539
  date_str = bucket
502
540
  else:
@@ -528,21 +566,18 @@ def _build_daily_snapshot(
528
566
  )
529
567
  if rows:
530
568
  title = (
531
- f"Daily usage — {period_start.strftime('%b %d')} → "
532
- f"{period_end.strftime('%b %d')}"
569
+ f"Daily usage — {period_start.date().isoformat()} → "
570
+ f"{period_end.date().isoformat()}"
533
571
  )
534
572
  else:
535
573
  title = "Daily usage — no data"
536
574
  period_label = _share_period_label(period_start, period_end, display_tz)
537
- subtitle = " · ".join([
538
- period_label,
539
- theme,
540
- "real projects" if reveal_projects else "projects anonymized",
541
- ])
542
575
  return _lib_share.ShareSnapshot(
543
576
  cmd="daily",
544
577
  title=title,
545
- subtitle=subtitle,
578
+ # #503 S2 D5 — the facts strip states the period and the
579
+ # privacy mode; the theme is dropped deliberately.
580
+ subtitle=None,
546
581
  period=_lib_share.PeriodSpec(
547
582
  start=period_start, end=period_end,
548
583
  display_tz=display_tz, label=period_label,
@@ -560,8 +595,6 @@ def _build_monthly_snapshot(
560
595
  period_end: dt.datetime,
561
596
  display_tz: str,
562
597
  version: str,
563
- theme: str,
564
- reveal_projects: bool,
565
598
  ) -> "ShareSnapshot":
566
599
  """Build a ShareSnapshot for `cctally monthly`.
567
600
 
@@ -646,15 +679,12 @@ def _build_monthly_snapshot(
646
679
  f"{period_start.strftime('%Y-%m')} → "
647
680
  f"{period_end.strftime('%Y-%m')} ({display_tz})"
648
681
  )
649
- subtitle = " · ".join([
650
- period_label,
651
- theme,
652
- "real projects" if reveal_projects else "projects anonymized",
653
- ])
654
682
  return _lib_share.ShareSnapshot(
655
683
  cmd="monthly",
656
684
  title=title,
657
- subtitle=subtitle,
685
+ # #503 S2 D5 — the facts strip states the period and the
686
+ # privacy mode; the theme is dropped deliberately.
687
+ subtitle=None,
658
688
  period=_lib_share.PeriodSpec(
659
689
  start=period_start, end=period_end,
660
690
  display_tz=display_tz, label=period_label,
@@ -672,8 +702,6 @@ def _build_weekly_snapshot(
672
702
  period_end: dt.datetime,
673
703
  display_tz: str,
674
704
  version: str,
675
- theme: str,
676
- reveal_projects: bool,
677
705
  breakdown_model: bool,
678
706
  ) -> "ShareSnapshot":
679
707
  """Build a ShareSnapshot for `cctally weekly`.
@@ -759,7 +787,7 @@ def _build_weekly_snapshot(
759
787
  bucket = getattr(r, "bucket", None)
760
788
  if isinstance(bucket, str) and bucket:
761
789
  try:
762
- week_label = dt.date.fromisoformat(bucket).strftime("%b %d")
790
+ week_label = dt.date.fromisoformat(bucket).isoformat()
763
791
  except ValueError:
764
792
  week_label = bucket
765
793
  else:
@@ -830,15 +858,12 @@ def _build_weekly_snapshot(
830
858
  else "Weekly usage — no data"
831
859
  )
832
860
  period_label = _share_period_label(period_start, period_end, display_tz)
833
- subtitle = " · ".join([
834
- period_label,
835
- theme,
836
- "real projects" if reveal_projects else "projects anonymized",
837
- ])
838
861
  return _lib_share.ShareSnapshot(
839
862
  cmd="weekly",
840
863
  title=title,
841
- subtitle=subtitle,
864
+ # #503 S2 D5 — the facts strip states the period and the
865
+ # privacy mode; the theme is dropped deliberately.
866
+ subtitle=None,
842
867
  period=_lib_share.PeriodSpec(
843
868
  start=period_start, end=period_end,
844
869
  display_tz=display_tz, label=period_label,
@@ -855,8 +880,6 @@ def _build_forecast_snapshot(
855
880
  week_end: dt.datetime,
856
881
  display_tz: str,
857
882
  version: str,
858
- theme: str,
859
- reveal_projects: bool,
860
883
  actual_series: list[tuple[str, float, float]],
861
884
  projected_series: list[tuple[str, float, float]],
862
885
  current_pct: float,
@@ -978,21 +1001,18 @@ def _build_forecast_snapshot(
978
1001
  ("LOW CONF — data thin",) if low_conf else ()
979
1002
  )
980
1003
  if actual_pts:
981
- title = f"Forecast — week of {week_start.strftime('%b %d')}"
1004
+ title = f"Forecast — week of {week_start.date().isoformat()}"
982
1005
  else:
983
1006
  title = "Forecast — no data"
984
1007
  # Reuse the shared period-label helper so forecast's subtitle period
985
1008
  # format matches sibling builders (cmd_daily / cmd_project / etc.).
986
1009
  period_label = _share_period_label(week_start, week_end, display_tz)
987
- subtitle = " · ".join([
988
- period_label,
989
- theme,
990
- "real projects" if reveal_projects else "projects anonymized",
991
- ])
992
1010
  return _lib_share.ShareSnapshot(
993
1011
  cmd="forecast",
994
1012
  title=title,
995
- subtitle=subtitle,
1013
+ # #503 S2 D5 — the facts strip states the period and the
1014
+ # privacy mode; the theme is dropped deliberately.
1015
+ subtitle=None,
996
1016
  period=_lib_share.PeriodSpec(
997
1017
  start=week_start, end=week_end,
998
1018
  display_tz=display_tz, label=period_label,
@@ -1010,8 +1030,6 @@ def _build_project_snapshot(
1010
1030
  period_end: dt.datetime,
1011
1031
  display_tz: str,
1012
1032
  version: str,
1013
- theme: str,
1014
- reveal_projects: bool,
1015
1033
  ) -> "ShareSnapshot":
1016
1034
  """Build a ShareSnapshot for `cctally project`.
1017
1035
 
@@ -1155,21 +1173,18 @@ def _build_project_snapshot(
1155
1173
  )
1156
1174
  if rows:
1157
1175
  title = (
1158
- f"Per-project usage — {period_start.strftime('%b %d')} → "
1159
- f"{period_end.strftime('%b %d')}"
1176
+ f"Per-project usage — {period_start.date().isoformat()} → "
1177
+ f"{period_end.date().isoformat()}"
1160
1178
  )
1161
1179
  else:
1162
1180
  title = "Per-project usage — no data"
1163
1181
  period_label = _share_period_label(period_start, period_end, display_tz)
1164
- subtitle = " · ".join([
1165
- period_label,
1166
- theme,
1167
- "real projects" if reveal_projects else "projects anonymized",
1168
- ])
1169
1182
  return _lib_share.ShareSnapshot(
1170
1183
  cmd="project",
1171
1184
  title=title,
1172
- subtitle=subtitle,
1185
+ # #503 S2 D5 — the facts strip states the period and the
1186
+ # privacy mode; the theme is dropped deliberately.
1187
+ subtitle=None,
1173
1188
  period=_lib_share.PeriodSpec(
1174
1189
  start=period_start, end=period_end,
1175
1190
  display_tz=display_tz, label=period_label,
@@ -1187,8 +1202,6 @@ def _build_five_hour_blocks_snapshot(
1187
1202
  period_end: dt.datetime,
1188
1203
  display_tz: str,
1189
1204
  version: str,
1190
- theme: str,
1191
- reveal_projects: bool,
1192
1205
  tz: "ZoneInfo | None",
1193
1206
  ) -> "ShareSnapshot":
1194
1207
  """Build a ShareSnapshot for `cctally five-hour-blocks`.
@@ -1277,7 +1290,7 @@ def _build_five_hour_blocks_snapshot(
1277
1290
  block_iso.replace("Z", "+00:00")
1278
1291
  )
1279
1292
  block_lbl = format_display_dt(
1280
- parsed, tz, fmt="%b %d %H:%M", suffix=False,
1293
+ parsed, tz, fmt="%Y-%m-%d %H:%M", suffix=False,
1281
1294
  )
1282
1295
  except (ValueError, AttributeError):
1283
1296
  block_lbl = str(block_iso)
@@ -1343,15 +1356,12 @@ def _build_five_hour_blocks_snapshot(
1343
1356
  else:
1344
1357
  title = "5-hour blocks — no data"
1345
1358
  period_label = _share_period_label(period_start, period_end, display_tz)
1346
- subtitle = " · ".join([
1347
- period_label,
1348
- theme,
1349
- "real projects" if reveal_projects else "projects anonymized",
1350
- ])
1351
1359
  return _lib_share.ShareSnapshot(
1352
1360
  cmd="five-hour-blocks",
1353
1361
  title=title,
1354
- subtitle=subtitle,
1362
+ # #503 S2 D5 — the facts strip states the period and the
1363
+ # privacy mode; the theme is dropped deliberately.
1364
+ subtitle=None,
1355
1365
  period=_lib_share.PeriodSpec(
1356
1366
  start=period_start, end=period_end,
1357
1367
  display_tz=display_tz, label=period_label,
@@ -1426,8 +1436,6 @@ def _build_session_snapshot(
1426
1436
  period_end: dt.datetime,
1427
1437
  display_tz: str,
1428
1438
  version: str,
1429
- theme: str,
1430
- reveal_projects: bool,
1431
1439
  top_n: int | None,
1432
1440
  tz: "ZoneInfo | None",
1433
1441
  ) -> "ShareSnapshot":
@@ -1573,21 +1581,18 @@ def _build_session_snapshot(
1573
1581
  title = f"Top {len(snap_rows)} sessions"
1574
1582
  else:
1575
1583
  title = (
1576
- f"Sessions — {period_start.strftime('%b %d')} → "
1577
- f"{period_end.strftime('%b %d')}"
1584
+ f"Sessions — {period_start.date().isoformat()} → "
1585
+ f"{period_end.date().isoformat()}"
1578
1586
  )
1579
1587
  else:
1580
1588
  title = "Sessions — no data"
1581
1589
  period_label = _share_period_label(period_start, period_end, display_tz)
1582
- subtitle = " · ".join([
1583
- period_label,
1584
- theme,
1585
- "real projects" if reveal_projects else "projects anonymized",
1586
- ])
1587
1590
  return _lib_share.ShareSnapshot(
1588
1591
  cmd="session",
1589
1592
  title=title,
1590
- subtitle=subtitle,
1593
+ # #503 S2 D5 — the facts strip states the period and the
1594
+ # privacy mode; the theme is dropped deliberately.
1595
+ subtitle=None,
1591
1596
  period=_lib_share.PeriodSpec(
1592
1597
  start=period_start, end=period_end,
1593
1598
  display_tz=display_tz, label=period_label,
@@ -1308,7 +1308,15 @@ def _share_int(value: object, default: int = 0) -> int:
1308
1308
  def _claude_project_share_rows(data: dict[str, object], lib) -> tuple[tuple, tuple, tuple]:
1309
1309
  """Adapt the established ``project`` JSON payload without Codex field guesses."""
1310
1310
  columns = (
1311
- lib.ColumnSpec(key="project", label="Project", kind="project"),
1311
+ # NO `kind="project"` (#503 S2 F4). That flag declares the
1312
+ # column LABEL to be a project display name — true for the
1313
+ # cross-tab Detail templates, whose headers are project names,
1314
+ # and false here, where the label is the literal header word.
1315
+ # With it, the anonymizer rewrote the header itself, so the
1316
+ # column ended up titled `project-1` and the artifact
1317
+ # misstated what it was. The CELLS are `ProjectCell`s and are
1318
+ # anonymized through the cell site, which is the correct one.
1319
+ lib.ColumnSpec(key="project", label="Project"),
1312
1320
  lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
1313
1321
  lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
1314
1322
  )
@@ -1516,7 +1524,9 @@ def _claude_report_share_rows(data: dict[str, object], lib) -> tuple[tuple, tupl
1516
1524
  continue
1517
1525
  date_value = str(payload_row.get("weekStartDate", "—"))
1518
1526
  try:
1519
- display_week = dt.date.fromisoformat(date_value).strftime("%b %d")
1527
+ # Full ISO (#503 S2 D4/F15): a `%b %d` week cell names no year,
1528
+ # so a multi-year artifact is ambiguous.
1529
+ display_week = dt.date.fromisoformat(date_value).isoformat()
1520
1530
  except ValueError:
1521
1531
  display_week = date_value
1522
1532
  used = payload_row.get("weeklyPercent")
@@ -1562,7 +1572,8 @@ def _source_share_rows(command: str, result: SourceResult, lib) -> tuple[tuple,
1562
1572
 
1563
1573
  if command == "project":
1564
1574
  columns = (
1565
- lib.ColumnSpec(key="project", label="Project", kind="project"),
1575
+ # No `kind="project"` — see `_claude_project_share_rows`.
1576
+ lib.ColumnSpec(key="project", label="Project"),
1566
1577
  lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
1567
1578
  lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
1568
1579
  )
@@ -1687,13 +1698,18 @@ def build_source_share_snapshot(
1687
1698
  "empty" if source_result.status == "empty" else "ok"
1688
1699
  )
1689
1700
  source_label = "Claude" if source_result.source == "claude" else "Codex"
1690
- period_label = f"{start.date().isoformat()} → {end.date().isoformat()} (UTC)"
1701
+ # The all-source CLI path has no display-tz plumbing; its bounds are
1702
+ # UTC instants, so `Etc/UTC` is both the honest and the concrete IANA
1703
+ # name for them (#503 S2 D7).
1704
+ period_label = f"{start.date().isoformat()} → {end.date().isoformat()} (Etc/UTC)"
1691
1705
  notes = tuple(warning.message for warning in source_result.warnings if source_result.status != "unavailable")
1692
1706
  return lib.ShareSnapshot(
1693
1707
  cmd=command,
1694
1708
  title=_TERMINAL_TITLES[command],
1695
- subtitle=period_label,
1696
- period=lib.PeriodSpec(start=start, end=end, display_tz="UTC", label=period_label),
1709
+ # #503 S2 D5 — the facts strip states this period, so a
1710
+ # subtitle repeating it is duplicate chrome.
1711
+ subtitle=None,
1712
+ period=lib.PeriodSpec(start=start, end=end, display_tz="Etc/UTC", label=period_label),
1697
1713
  columns=columns,
1698
1714
  rows=rows,
1699
1715
  chart=None,
@@ -1927,16 +1943,28 @@ def _emit_source_share(
1927
1943
  lib.ComposedSection(snap=claude_snap, drift_detected=False),
1928
1944
  lib.ComposedSection(snap=codex_snap, drift_detected=False),
1929
1945
  )
1930
- content = lib.compose(
1931
- sections,
1932
- opts=lib.ComposeOptions(
1933
- title=f"{_TERMINAL_TITLES[command]} — Claude + Codex",
1934
- theme=getattr(args, "theme", "light"),
1935
- format=args.format,
1936
- no_branding=bool(getattr(args, "no_branding", False)),
1937
- reveal_projects=reveal_projects,
1938
- ),
1939
- )
1946
+ # A privacy refusal is a MESSAGE and exit 3, exactly as on the
1947
+ # single-source path (`_cctally_share._share_render_and_emit`).
1948
+ # Without this catch the same `SharePrivacyViolation` reached the
1949
+ # generic handler in `bin/cctally` and became exit 1, so `share.md`
1950
+ # would have documented an exit code one of the two paths did not
1951
+ # produce (#503 S2 F19). The refusal precedes destination
1952
+ # resolution, so nothing is written.
1953
+ try:
1954
+ content = lib.compose(
1955
+ sections,
1956
+ opts=lib.ComposeOptions(
1957
+ title=f"{_TERMINAL_TITLES[command]} — Claude + Codex",
1958
+ theme=getattr(args, "theme", "light"),
1959
+ format=args.format,
1960
+ no_branding=bool(getattr(args, "no_branding", False)),
1961
+ reveal_projects=reveal_projects,
1962
+ ),
1963
+ )
1964
+ except lib.SharePrivacyViolation as exc:
1965
+ print(f"cctally: refused to write a share artifact — {exc}",
1966
+ file=sys.stderr)
1967
+ sys.exit(3)
1940
1968
  utc_date = codex_snap.generated_at.astimezone(UTC).strftime("%Y-%m-%d")
1941
1969
  kind, value = c._resolve_destination(args, cmd=command, generated_at_utc_date=utc_date)
1942
1970
  c._emit(content, kind=kind, value=value)