codex-meter 0.3.0__py3-none-any.whl

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.
codex_meter/render.py ADDED
@@ -0,0 +1,545 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import datetime as dt
5
+ import io
6
+ import json
7
+ import shutil
8
+ import sys
9
+ from dataclasses import asdict
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from codex_meter.aggregation import aggregate_model_mode, aggregate_total
16
+ from codex_meter.humanize import compact_number, format_int, redact, short_table_label
17
+ from codex_meter.models import (
18
+ Aggregate,
19
+ CostTotals,
20
+ LoadResult,
21
+ RuntimeOptions,
22
+ TokenTotals,
23
+ decimal_string,
24
+ )
25
+ from codex_meter.pricing import PRICING_SOURCES, RateCard
26
+ from codex_meter.timeutil import iso_z, window_label
27
+
28
+ __all__ = ["format_int", "redact", "render", "render_limits"]
29
+
30
+
31
+ def _rate_card(options: RuntimeOptions) -> RateCard:
32
+ return RateCard.load(options.rates_file, options.pricing_mode)
33
+
34
+
35
+ def _make_console(buffer: io.StringIO, options: RuntimeOptions) -> Console:
36
+ if options.width is not None:
37
+ width = options.width
38
+ elif options.compact:
39
+ width = 100
40
+ else:
41
+ width = shutil.get_terminal_size((140, 24)).columns
42
+ return Console(file=buffer, width=width, soft_wrap=False, _environ={})
43
+
44
+
45
+ def aggregate_to_dict(item: Aggregate, show_prompts: bool = False) -> dict:
46
+ return {
47
+ "key": item.key,
48
+ "label": redact(item.label, show_prompts),
49
+ "events": item.totals.events,
50
+ "input_tokens": item.totals.input_tokens,
51
+ "cached_input_tokens": item.totals.cached_input_tokens,
52
+ "uncached_input_tokens": item.totals.uncached_input_tokens,
53
+ "output_tokens": item.totals.output_tokens,
54
+ "reasoning_output_tokens": item.totals.reasoning_output_tokens,
55
+ "total_tokens": item.totals.total_tokens,
56
+ "credits": float(item.costs.adjusted_credits),
57
+ "standard_credits": float(item.costs.standard_credits),
58
+ "api_dollars": float(item.costs.api_dollars),
59
+ "credits_exact": decimal_string(item.costs.adjusted_credits),
60
+ "standard_credits_exact": decimal_string(item.costs.standard_credits),
61
+ "api_dollars_exact": decimal_string(item.costs.api_dollars),
62
+ "cache_savings_credits": float(item.cache_savings.adjusted_credits),
63
+ "cache_savings_standard_credits": float(item.cache_savings.standard_credits),
64
+ "cache_savings_api_dollars": float(item.cache_savings.api_dollars),
65
+ "cache_savings_credits_exact": decimal_string(item.cache_savings.adjusted_credits),
66
+ "cache_savings_standard_credits_exact": decimal_string(item.cache_savings.standard_credits),
67
+ "cache_savings_api_dollars_exact": decimal_string(item.cache_savings.api_dollars),
68
+ "models": sorted(item.models),
69
+ "service_tiers": sorted(item.service_tiers),
70
+ "plan_types": sorted(item.plan_types),
71
+ "usage_sources": sorted(item.usage_sources),
72
+ "model_context_window": item.model_context_window,
73
+ "long_context_events": item.long_context_events,
74
+ "unknown_model_events": item.unknown_model_events,
75
+ "unknown_tier_events": item.unknown_tier_events,
76
+ "pricing_status": pricing_status(item),
77
+ "unpriced_events": item.costs.unpriced_events,
78
+ "api_unpriced_events": item.costs.api_unpriced_events,
79
+ "credit_unpriced_events": item.costs.credit_unpriced_events,
80
+ "estimated_events": pricing_estimated_events(item),
81
+ "ambiguous_reasoning_events": item.costs.ambiguous_reasoning_events,
82
+ "local_rate_override_events": item.costs.local_override_events,
83
+ }
84
+
85
+
86
+ def pricing_estimated_events(item: Aggregate) -> int:
87
+ return (
88
+ item.costs.estimated_events
89
+ + item.unknown_tier_events
90
+ + item.costs.ambiguous_reasoning_events
91
+ )
92
+
93
+
94
+ def pricing_status(item: Aggregate) -> str:
95
+ if item.costs.unpriced_events or item.unknown_model_events:
96
+ return "partial"
97
+ if pricing_estimated_events(item):
98
+ return "estimated"
99
+ return "exact"
100
+
101
+
102
+ def pricing_warnings(item: Aggregate) -> list[str]:
103
+ warnings: list[str] = []
104
+ if item.costs.api_unpriced_events:
105
+ warnings.append(f"{item.costs.api_unpriced_events:,} events have no API-dollar rate.")
106
+ if item.costs.credit_unpriced_events:
107
+ warnings.append(f"{item.costs.credit_unpriced_events:,} events have no Codex credit rate.")
108
+ if item.unknown_model_events:
109
+ warnings.append(
110
+ f"{item.unknown_model_events:,} events used models with no known rate card."
111
+ )
112
+ if item.unknown_tier_events:
113
+ warnings.append(
114
+ f"{item.unknown_tier_events:,} events used inferred service tiers; costs are estimates."
115
+ )
116
+ if item.costs.estimated_events:
117
+ warnings.append(f"{item.costs.estimated_events:,} events used explicit estimate pricing.")
118
+ if item.costs.ambiguous_reasoning_events:
119
+ warnings.append(
120
+ f"{item.costs.ambiguous_reasoning_events:,} events had ambiguous reasoning token shape."
121
+ )
122
+ return warnings
123
+
124
+
125
+ def rate_limit_sample_to_dict(sample) -> dict:
126
+ item = asdict(sample)
127
+ item["path"] = str(sample.path)
128
+ item["timestamp"] = iso_z(sample.timestamp)
129
+ return item
130
+
131
+
132
+ def report_payload(
133
+ result: LoadResult,
134
+ options: RuntimeOptions,
135
+ rows: list[Aggregate],
136
+ command: str,
137
+ rate_card: RateCard | None = None,
138
+ ) -> dict:
139
+ card = rate_card or _rate_card(options)
140
+ total = aggregate_total(result, options, rate_card=card)
141
+ return {
142
+ "command": command,
143
+ "generated_at": iso_z(options.end),
144
+ "window": {
145
+ "start": iso_z(options.start),
146
+ "end": iso_z(options.end),
147
+ "label": window_label(options.start, options.end, options.timezone),
148
+ "timezone": options.timezone,
149
+ },
150
+ "totals": aggregate_to_dict(total, options.show_prompts)
151
+ | {"duplicates": result.duplicates},
152
+ "breakdowns": [aggregate_to_dict(row, options.show_prompts) for row in rows],
153
+ "model_mode": [
154
+ aggregate_to_dict(row, options.show_prompts)
155
+ for row in aggregate_model_mode(result, options, rate_card=card)
156
+ ],
157
+ "pricing": {
158
+ "mode": options.pricing_mode,
159
+ "offline": options.offline,
160
+ "status": pricing_status(total),
161
+ "warnings": pricing_warnings(total),
162
+ "sources": [asdict(source) for source in PRICING_SOURCES],
163
+ },
164
+ "metadata": {
165
+ "session_root": str(options.session_root),
166
+ "state_db": str(options.state_db),
167
+ "tier_sources": result.tier_sources,
168
+ "plan_types": sorted(result.plan_types),
169
+ "warning_count": len(result.warnings),
170
+ },
171
+ "rate_limit_samples": [
172
+ rate_limit_sample_to_dict(sample)
173
+ for sample in _recent_samples(result.credit_samples, options.top_threads)
174
+ ],
175
+ "warnings": result.warnings,
176
+ }
177
+
178
+
179
+ def write_output(text: str, output: Path | None) -> None:
180
+ if output:
181
+ output.expanduser().write_text(text)
182
+ else:
183
+ sys.stdout.write(text)
184
+
185
+
186
+ def render_table(
187
+ result: LoadResult,
188
+ options: RuntimeOptions,
189
+ rows: list[Aggregate],
190
+ title: str,
191
+ rate_card: RateCard | None = None,
192
+ ) -> str:
193
+ buffer = io.StringIO()
194
+ console = _make_console(buffer, options)
195
+ total = aggregate_total(result, options, rate_card=rate_card or _rate_card(options))
196
+ console.print(f"[bold]{title}[/bold]")
197
+ console.print(f"Window: {window_label(options.start, options.end, options.timezone)}")
198
+ console.print(f"Session root: {options.session_root}")
199
+ if result.warnings:
200
+ for warning in result.warnings:
201
+ console.print(f"[yellow]Warning:[/yellow] {warning}")
202
+ for warning in pricing_warnings(total):
203
+ console.print(
204
+ f"[yellow]Warning:[/yellow] {warning} Reported costs are {pricing_status(total)}."
205
+ )
206
+ console.print()
207
+
208
+ table = Table(show_lines=False, expand=not options.compact)
209
+ table.add_column("Group")
210
+ table.add_column("Models")
211
+ table.add_column("Input", justify="right")
212
+ table.add_column("Cached", justify="right")
213
+ table.add_column("Output", justify="right")
214
+ table.add_column("Total", justify="right")
215
+ table.add_column("Credits", justify="right")
216
+ table.add_column("API $", justify="right")
217
+ for row in rows:
218
+ table.add_row(
219
+ short_table_label(redact(row.label, options.show_prompts)),
220
+ "\n".join(sorted(row.models)) or "-",
221
+ _table_int(row.totals.input_tokens, options),
222
+ _table_int(row.totals.cached_input_tokens, options),
223
+ _table_int(row.totals.output_tokens, options),
224
+ _table_int(row.totals.total_tokens, options),
225
+ _table_float(row.costs.adjusted_credits, options),
226
+ _table_float(row.costs.api_dollars, options, prefix="$"),
227
+ )
228
+ table.add_section()
229
+ table.add_row(
230
+ "Total",
231
+ "\n".join(sorted(total.models)) or "-",
232
+ _table_int(total.totals.input_tokens, options),
233
+ _table_int(total.totals.cached_input_tokens, options),
234
+ _table_int(total.totals.output_tokens, options),
235
+ _table_int(total.totals.total_tokens, options),
236
+ _table_float(total.costs.adjusted_credits, options),
237
+ _table_float(total.costs.api_dollars, options, prefix="$"),
238
+ )
239
+ console.print(table)
240
+ console.print()
241
+ events_text = format_int(total.totals.events)
242
+ duplicates_text = format_int(result.duplicates)
243
+ console.print(f"Events: {events_text} | Duplicates skipped: {duplicates_text}")
244
+ if total.costs.adjusted_credits != total.costs.standard_credits:
245
+ console.print(f"Standard-mode baseline: {total.costs.standard_credits:,.2f} credits")
246
+ if total.cache_savings.adjusted_credits or total.cache_savings.api_dollars:
247
+ console.print(
248
+ "Cache savings: "
249
+ f"{total.cache_savings.adjusted_credits:,.2f} credits | "
250
+ f"${total.cache_savings.api_dollars:,.2f}"
251
+ )
252
+ if result.tier_sources:
253
+ sources = ", ".join(
254
+ f"{key}={format_int(value)}" for key, value in sorted(result.tier_sources.items())
255
+ )
256
+ console.print(f"Service-tier sources: {sources}")
257
+ if result.plan_types:
258
+ console.print(f"Plan types: {', '.join(sorted(result.plan_types))}")
259
+ return buffer.getvalue()
260
+
261
+
262
+ def _table_int(value: int, options: RuntimeOptions) -> str:
263
+ return compact_number(value) if options.compact else format_int(value)
264
+
265
+
266
+ def _table_float(value: float, options: RuntimeOptions, prefix: str = "") -> str:
267
+ if options.compact:
268
+ return compact_number(value, prefix=prefix)
269
+ if prefix:
270
+ return f"{prefix}{value:,.2f}"
271
+ return f"{value:,.2f}"
272
+
273
+
274
+ def render_json(
275
+ result: LoadResult,
276
+ options: RuntimeOptions,
277
+ rows: list[Aggregate],
278
+ command: str,
279
+ rate_card: RateCard | None = None,
280
+ ) -> str:
281
+ return (
282
+ json.dumps(report_payload(result, options, rows, command, rate_card=rate_card), indent=2)
283
+ + "\n"
284
+ )
285
+
286
+
287
+ def render_csv(rows: list[Aggregate], show_prompts: bool) -> str:
288
+ output = io.StringIO()
289
+ writer = csv.DictWriter(
290
+ output,
291
+ fieldnames=[
292
+ "key",
293
+ "label",
294
+ "events",
295
+ "input_tokens",
296
+ "cached_input_tokens",
297
+ "uncached_input_tokens",
298
+ "output_tokens",
299
+ "reasoning_output_tokens",
300
+ "total_tokens",
301
+ "credits",
302
+ "standard_credits",
303
+ "api_dollars",
304
+ "pricing_status",
305
+ "unpriced_events",
306
+ "estimated_events",
307
+ "models",
308
+ "service_tiers",
309
+ ],
310
+ )
311
+ writer.writeheader()
312
+ for row in rows:
313
+ item = aggregate_to_dict(row, show_prompts)
314
+ item["models"] = ",".join(item["models"])
315
+ item["service_tiers"] = ",".join(item["service_tiers"])
316
+ writer.writerow({key: item[key] for key in writer.fieldnames or []})
317
+ return output.getvalue()
318
+
319
+
320
+ def render_markdown(rows: list[Aggregate], show_prompts: bool) -> str:
321
+ headers = [
322
+ "Group",
323
+ "Events",
324
+ "Input",
325
+ "Cached",
326
+ "Output",
327
+ "Total",
328
+ "Credits",
329
+ "API $",
330
+ "Pricing",
331
+ ]
332
+ lines = [
333
+ "| " + " | ".join(headers) + " |",
334
+ "| " + " | ".join(["---"] * len(headers)) + " |",
335
+ ]
336
+ totals = TokenTotals()
337
+ costs = CostTotals()
338
+ row_statuses: set[str] = set()
339
+ for row in rows:
340
+ row_statuses.add(pricing_status(row))
341
+ totals.events += row.totals.events
342
+ totals.input_tokens += row.totals.input_tokens
343
+ totals.cached_input_tokens += row.totals.cached_input_tokens
344
+ totals.output_tokens += row.totals.output_tokens
345
+ totals.total_tokens += row.totals.total_tokens
346
+ costs.add(row.costs)
347
+ lines.append(
348
+ "| "
349
+ + " | ".join(
350
+ [
351
+ redact(row.label, show_prompts).replace("|", "\\|"),
352
+ str(row.totals.events),
353
+ str(row.totals.input_tokens),
354
+ str(row.totals.cached_input_tokens),
355
+ str(row.totals.output_tokens),
356
+ str(row.totals.total_tokens),
357
+ f"{row.costs.adjusted_credits:.2f}",
358
+ f"{row.costs.api_dollars:.2f}",
359
+ pricing_status(row),
360
+ ]
361
+ )
362
+ + " |"
363
+ )
364
+ if rows:
365
+ total_status = (
366
+ "partial"
367
+ if "partial" in row_statuses
368
+ else "estimated"
369
+ if "estimated" in row_statuses
370
+ else "exact"
371
+ )
372
+ lines.append(
373
+ "| "
374
+ + " | ".join(
375
+ [
376
+ "**Total**",
377
+ str(totals.events),
378
+ str(totals.input_tokens),
379
+ str(totals.cached_input_tokens),
380
+ str(totals.output_tokens),
381
+ str(totals.total_tokens),
382
+ f"{costs.adjusted_credits:.2f}",
383
+ f"{costs.api_dollars:.2f}",
384
+ total_status,
385
+ ]
386
+ )
387
+ + " |"
388
+ )
389
+ return "\n".join(lines) + "\n"
390
+
391
+
392
+ def render(
393
+ result: LoadResult,
394
+ options: RuntimeOptions,
395
+ rows: list[Aggregate],
396
+ command: str,
397
+ output_format: str,
398
+ output: Path | None,
399
+ ) -> None:
400
+ card = _rate_card(options)
401
+ if output_format == "json":
402
+ text = render_json(result, options, rows, command, rate_card=card)
403
+ elif output_format == "csv":
404
+ text = render_csv(rows, options.show_prompts)
405
+ elif output_format == "markdown":
406
+ text = render_markdown(rows, options.show_prompts)
407
+ else:
408
+ text = render_table(
409
+ result, options, rows, f"Codex Meter - {command.title()}", rate_card=card
410
+ )
411
+ write_output(text, output)
412
+
413
+
414
+ LIMITS_CSV_FIELDS = (
415
+ "timestamp",
416
+ "session_id",
417
+ "plan_type",
418
+ "credits",
419
+ "primary_used_percent",
420
+ "primary_window_minutes",
421
+ "primary_resets_at",
422
+ "secondary_used_percent",
423
+ "secondary_window_minutes",
424
+ "secondary_resets_at",
425
+ "rate_limit_reached_type",
426
+ )
427
+
428
+
429
+ def render_limits_table(result: LoadResult, options: RuntimeOptions) -> str:
430
+ buffer = io.StringIO()
431
+ console = _make_console(buffer, options)
432
+ console.print("[bold]Codex Meter - Limits[/bold]")
433
+ console.print(f"Window: {window_label(options.start, options.end, options.timezone)}")
434
+ samples = _recent_samples(result.credit_samples, options.top_threads)
435
+ if not samples:
436
+ console.print("No rate-limit samples found.")
437
+ return buffer.getvalue()
438
+ if options.compact:
439
+ for sample in samples:
440
+ console.print(
441
+ f"- {iso_z(sample.timestamp)} | credits={sample.credits} | "
442
+ f"primary={sample.primary_used_percent}%/"
443
+ f"{sample.primary_window_minutes}m reset={sample.primary_resets_at} | "
444
+ f"secondary={sample.secondary_used_percent}%/"
445
+ f"{sample.secondary_window_minutes}m reset={sample.secondary_resets_at}"
446
+ )
447
+ return buffer.getvalue()
448
+ table = Table(show_lines=False, expand=not options.compact)
449
+ table.add_column("Time")
450
+ table.add_column("Plan")
451
+ table.add_column("Credits", justify="right")
452
+ table.add_column("Primary %", justify="right")
453
+ table.add_column("Reset In", justify="right")
454
+ table.add_column("Secondary %", justify="right")
455
+ table.add_column("Reset In", justify="right")
456
+ for sample in samples:
457
+ table.add_row(
458
+ iso_z(sample.timestamp),
459
+ str(sample.plan_type or "-"),
460
+ str(sample.credits if sample.credits is not None else "-"),
461
+ _percent(sample.primary_used_percent),
462
+ _reset_epoch(sample.primary_resets_at),
463
+ _percent(sample.secondary_used_percent),
464
+ _reset_epoch(sample.secondary_resets_at),
465
+ )
466
+ console.print(table)
467
+ return buffer.getvalue()
468
+
469
+
470
+ def _percent(value: object) -> str:
471
+ return "-" if value is None else f"{float(value):.1f}%"
472
+
473
+
474
+ def _reset_epoch(value: object) -> str:
475
+ if value in {None, ""}:
476
+ return "-"
477
+ try:
478
+ reset_at = dt.datetime.fromtimestamp(float(value), tz=dt.UTC)
479
+ except (TypeError, ValueError, OSError):
480
+ return str(value)
481
+ remaining = reset_at - dt.datetime.now(tz=dt.UTC)
482
+ seconds = int(remaining.total_seconds())
483
+ if seconds <= 0:
484
+ return "now"
485
+ minutes, leftover = divmod(seconds, 60)
486
+ hours, minutes = divmod(minutes, 60)
487
+ if hours:
488
+ return f"{hours}h {minutes}m"
489
+ return f"{minutes}m {leftover}s"
490
+
491
+
492
+ def _recent_samples(samples: list, limit: int) -> list:
493
+ return sorted(samples, key=lambda sample: sample.timestamp)[-limit:]
494
+
495
+
496
+ def render_limits_csv(result: LoadResult, options: RuntimeOptions) -> str:
497
+ out = io.StringIO()
498
+ writer = csv.DictWriter(out, fieldnames=list(LIMITS_CSV_FIELDS))
499
+ writer.writeheader()
500
+ for sample in _recent_samples(result.credit_samples, options.top_threads):
501
+ row = rate_limit_sample_to_dict(sample)
502
+ writer.writerow({key: row.get(key, "") for key in LIMITS_CSV_FIELDS})
503
+ return out.getvalue()
504
+
505
+
506
+ def render_limits_markdown(result: LoadResult, options: RuntimeOptions) -> str:
507
+ headers = ["Timestamp", "Plan", "Credits", "Primary %", "Secondary %"]
508
+ lines = [
509
+ "| " + " | ".join(headers) + " |",
510
+ "| " + " | ".join(["---"] * len(headers)) + " |",
511
+ ]
512
+ for sample in _recent_samples(result.credit_samples, options.top_threads):
513
+ primary = sample.primary_used_percent
514
+ secondary = sample.secondary_used_percent
515
+ lines.append(
516
+ "| "
517
+ + " | ".join(
518
+ [
519
+ iso_z(sample.timestamp),
520
+ str(sample.plan_type or ""),
521
+ str(sample.credits if sample.credits is not None else ""),
522
+ str(primary if primary is not None else ""),
523
+ str(secondary if secondary is not None else ""),
524
+ ]
525
+ )
526
+ + " |"
527
+ )
528
+ return "\n".join(lines) + "\n"
529
+
530
+
531
+ def render_limits(
532
+ result: LoadResult,
533
+ options: RuntimeOptions,
534
+ output_format: str,
535
+ output: Path | None,
536
+ ) -> None:
537
+ if output_format == "json":
538
+ text = render_json(result, options, [], "limits", rate_card=_rate_card(options))
539
+ elif output_format == "csv":
540
+ text = render_limits_csv(result, options)
541
+ elif output_format == "markdown":
542
+ text = render_limits_markdown(result, options)
543
+ else:
544
+ text = render_limits_table(result, options)
545
+ write_output(text, output)
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime as dt
4
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
5
+
6
+
7
+ def local_timezone() -> dt.tzinfo:
8
+ return dt.datetime.now().astimezone().tzinfo or dt.UTC
9
+
10
+
11
+ def load_timezone(name: str | None) -> dt.tzinfo:
12
+ if not name or name == "local":
13
+ return local_timezone()
14
+ try:
15
+ return ZoneInfo(name)
16
+ except ZoneInfoNotFoundError as exc:
17
+ raise ValueError(f"Unknown IANA timezone: {name}") from exc
18
+
19
+
20
+ def parse_datetime(value: str | None, default: dt.datetime | None = None) -> dt.datetime:
21
+ if not value:
22
+ if default is None:
23
+ raise ValueError("missing datetime")
24
+ return default
25
+
26
+ raw = value.strip()
27
+ if raw.endswith("Z"):
28
+ raw = raw[:-1] + "+00:00"
29
+ if len(raw) == 8 and raw.isdigit():
30
+ raw = f"{raw[:4]}-{raw[4:6]}-{raw[6:8]} 00:00:00"
31
+ elif len(raw) == 10 and raw[4] == "-" and raw[7] == "-":
32
+ raw = raw + " 00:00:00"
33
+
34
+ parsed = dt.datetime.fromisoformat(raw)
35
+ if parsed.tzinfo is None:
36
+ parsed = parsed.replace(tzinfo=local_timezone())
37
+ return parsed
38
+
39
+
40
+ def parse_event_timestamp(value: str | None) -> dt.datetime | None:
41
+ if not value:
42
+ return None
43
+ raw = value[:-1] + "+00:00" if value.endswith("Z") else value
44
+ try:
45
+ parsed = dt.datetime.fromisoformat(raw)
46
+ except ValueError:
47
+ return None
48
+ if parsed.tzinfo is None:
49
+ parsed = parsed.replace(tzinfo=dt.UTC)
50
+ return parsed
51
+
52
+
53
+ def iso_z(value: dt.datetime) -> str:
54
+ return value.astimezone(dt.UTC).isoformat().replace("+00:00", "Z")
55
+
56
+
57
+ def day_key(value: dt.datetime, tz: dt.tzinfo) -> str:
58
+ return value.astimezone(tz).strftime("%Y-%m-%d")
59
+
60
+
61
+ def month_key(value: dt.datetime, tz: dt.tzinfo) -> str:
62
+ return value.astimezone(tz).strftime("%Y-%m")
63
+
64
+
65
+ def week_key(value: dt.datetime, tz: dt.tzinfo) -> str:
66
+ local = value.astimezone(tz)
67
+ year, week, _ = local.isocalendar()
68
+ return f"{year}-W{week:02d}"
69
+
70
+
71
+ def window_label(start: dt.datetime, end: dt.datetime, tzname: str) -> str:
72
+ tz = load_timezone(tzname)
73
+ start_label = start.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S %Z")
74
+ end_label = end.astimezone(tz).strftime("%Y-%m-%d %H:%M:%S %Z")
75
+ return f"{start_label} to {end_label}"