Files
contentforge-press e8da5fc1f0
Build and push containers / metadata (push) Canceled after 0s
Build and push containers / build-push-containers (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Canceled after 0s
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Canceled after 0s
ChangeDetection.io App Test / lint-code (push) Canceled after 0s
ChangeDetection.io App Test / lint-translations (push) Canceled after 0s
ChangeDetection.io App Test / lint-template-i18n (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-11 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-12 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-13 (push) Canceled after 0s
ChangeDetection.io App Test / test-application-3-14 (push) Canceled after 0s
feat(jinja2): add unixtime filter to format Unix timestamps in notifications (#3641) (#4503)
2026-10-07 16:24:24 +02:00

118 lines
4.1 KiB
Python

"""
Unix-timestamp conversion filter plugin for Jinja2 templates.
Provides the ``unixtime`` filter, which converts a Unix timestamp (seconds,
milliseconds or microseconds) into a human-readable, strftime-formatted string.
Requested in #3641: API/JSON-style monitored pages often expose values such as
``last_battle_at: 1759188682`` and notifications printed the raw integer.
"""
import datetime
import os
import re
import typing as t
from loguru import logger
# Bound the size of input we will parse, mirroring the defensive limits used by
# the other Jinja plugins. A notification token should never be multi-MB.
MAX_INPUT_SIZE = 1024 * 1024 # 1 MB
# Default output format (overridable per call) and default timezone.
DEFAULT_FORMAT = "%Y-%m-%d %H:%M:%S %Z"
# A bare 10+ digit run is enough to locate a timestamp embedded in prose such
# as "last_battle_at: 1759188682" without accepting unrelated small numbers.
_TIMESTAMP_RE = re.compile(r"-?\d{9,}")
def _default_tzname() -> str:
return os.getenv("TZ", "UTC").strip() or "UTC"
def _resolve_timezone(tz_name: t.Optional[str]):
"""Return a tzinfo for tz_name, defaulting to UTC without third-party deps."""
name = (tz_name or _default_tzname()).strip()
if name.upper() in ("", "UTC"):
return datetime.timezone.utc
# datetime.timezone only supports fixed offsets; resolve common named zones
# via the UTC-offset environment when available. Avoid importing zoneinfo on
# runtimes without the tz database by falling back to UTC.
try:
from zoneinfo import ZoneInfo # stdlib on Python 3.9+
return ZoneInfo(name)
except Exception:
return datetime.timezone.utc
def _coerce_timestamp(raw: t.Any) -> t.Optional[int]:
"""
Extract a Unix timestamp in seconds from ints, floats, numeric strings or
prose containing one. Millisecond (13-digit) and microsecond (16-digit)
values are normalised to seconds. Returns None when no value is usable.
"""
if raw is None:
return None
if isinstance(raw, bool): # bool is a subclass of int — never treat as ts
return None
if isinstance(raw, (int, float)):
value = int(raw)
else:
text = str(raw)
if len(text) > MAX_INPUT_SIZE:
logger.warning("unixtime: input too large (%d bytes), truncating", len(text))
text = text[:MAX_INPUT_SIZE]
match = _TIMESTAMP_RE.search(text)
if not match:
return None
value = int(match.group(0))
if abs(value) >= 1_000_000_000_000_000: # microseconds
value //= 1_000_000
elif abs(value) >= 1_000_000_000_000: # milliseconds
value //= 1_000
# Anything >= ~1e9 is plausible as seconds. Smaller 9-digit values
# (e.g. 999999999 ≈ 2001) are still accepted by the regex.
return value
def unixtime(
value: t.Any,
fmt: str = DEFAULT_FORMAT,
tz_name: t.Optional[str] = None,
) -> t.Any:
"""
Format a Unix timestamp as a human-readable datetime.
Args:
value: timestamp as int/float/string, or text containing one. Seconds,
milliseconds and microseconds are all accepted.
fmt: ``strftime`` format string (default ``%Y-%m-%d %H:%M:%S %Z``).
tz_name: timezone name (default: ``$TZ`` or UTC).
Returns:
The formatted datetime string. On any failure (missing/invalid value or
format) the original value is returned unchanged, so a notification
never crashes and simply falls back to the raw token.
Examples:
{{ triggered_text | unixtime }}
{{ triggered_text | unixtime("%d.%m.%Y %H:%M") }}
{{ triggered_text | unixtime("%d.%m.%Y %H:%M", "Europe/Berlin") }}
"""
seconds = _coerce_timestamp(value)
if seconds is None:
return value
try:
tzinfo = _resolve_timezone(tz_name)
dt = datetime.datetime.fromtimestamp(seconds, tz=tzinfo)
return dt.strftime(fmt)
except (ValueError, OSError, OverflowError) as e:
# Bad format string or out-of-range timestamp — keep the raw token.
logger.warning("unixtime: could not format value %r (%s)", value, e)
return value