"""steward/core/formatting.py
How Steward writes a size, a rate, a span of time and a moment, and how it
grades a reading against Settings → Thresholds — once, for every page and
plugin (#5093). Registered as Jinja filters (|bytes, |rate, |duration, |ago,
|when) and the status_class global in app.py; Python callers import them from
here. A moment is written in the reader's own zone, on a 24-hour clock
(milestone 490); app.py hands the filters the zone saved on their account.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone, tzinfo
from functools import lru_cache
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones
from markupsafe import Markup, escape
NONE = "—"
_UNITS = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
_SPANS = (("d", 86400), ("h", 3600), ("m", 60), ("s", 1))
def human_bytes(n: float | None) -> str:
"""Binary units: '0 B', '512 B', '1.5 KiB', '1.4 GiB'."""
if n is None:
return NONE
size = float(n)
for unit in _UNITS:
if abs(size) < 1024 or unit == _UNITS[-1]:
return f"{int(size)} B" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
raise AssertionError("unreachable")
def human_rate(bytes_per_s: float | None) -> str:
"""Bytes per second in binary units: '12.3 MiB/s'."""
return NONE if bytes_per_s is None else f"{human_bytes(bytes_per_s)}/s"
def human_duration(seconds: float | None) -> str:
"""The two largest units of a span: '45s', '8m', '1m 30s', '2h 5m', '3d 4h'.
The second unit is dropped when it is zero. Negative spans read as None."""
if seconds is None or seconds < 0:
return NONE
left = int(seconds)
if left == 0:
return "0s"
parts = []
for i, (unit, size) in enumerate(_SPANS):
count, left = divmod(left, size)
if count:
parts.append(f"{count}{unit}")
nxt = _SPANS[i + 1] if i + 1 < len(_SPANS) else None
if nxt and left // nxt[1]:
parts.append(f"{left // nxt[1]}{nxt[0]}")
break
return " ".join(parts)
def _utc(when: datetime) -> datetime:
return when.replace(tzinfo=timezone.utc) if when.tzinfo is None else when.astimezone(timezone.utc)
def seconds_since(when: datetime | None, now: datetime | None = None) -> float | None:
"""Seconds from `when` to now; a naive datetime is taken as UTC."""
if when is None:
return None
return ((now or datetime.now(timezone.utc)) - _utc(when)).total_seconds()
def zone(name: str | None) -> tzinfo:
"""The zone a reader asked for by its IANA name, or UTC when none was
given or it is not one this machine knows."""
if not name:
return timezone.utc
try:
return ZoneInfo(name)
except (ZoneInfoNotFoundError, ValueError):
return timezone.utc
@lru_cache(maxsize=1)
def zone_names() -> tuple[str, ...]:
"""Every IANA zone this machine knows, sorted: the Account page's choices,
and what a saved zone is checked against."""
return tuple(sorted(n for n in available_timezones() if "/" in n or n == "UTC"))
def tz_label(tz: tzinfo | None = None, now: datetime | None = None) -> str:
"""The zone's short name as it stands now ('EDT', 'CET', 'UTC'), for a
column header that says which zone its times are in."""
return (now or datetime.now(timezone.utc)).astimezone(tz or timezone.utc).tzname() or "UTC"
def _exact(local: datetime) -> str:
"""'Thu 8 Oct 2026 06:58:12 EDT': the full moment, for a hover."""
return f"{local:%a} {local.day} {local:%b %Y %H:%M:%S} {local.tzname()}"
def _time(when: datetime, local: datetime, text: str) -> Markup:
return Markup('').format(
when.strftime("%Y-%m-%dT%H:%M:%SZ"), _exact(local), escape(text))
def ago(when: datetime | None, now: datetime | None = None, tz: tzinfo | None = None) -> Markup:
"""'3m ago' (or 'in 3m' for a future moment) in a