"""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