feat(jinja2): add unixtime filter to format Unix timestamps in notifications (#3641) (#4503)
Build and push containers / metadata (push) Waiting to run
Build and push containers / build-push-containers (push) Waiting to run
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Waiting to run
ChangeDetection.io App Test / lint-code (push) Waiting to run
ChangeDetection.io App Test / lint-translations (push) Waiting to run
ChangeDetection.io App Test / lint-template-i18n (push) Waiting to run

This commit is contained in:
contentforge-press authored and GitHub committed 2026-10-07 16:24:24 +02:00
1 parent a1ce35619a
commit e8da5fc1f0
5 files changed
+180 -3

No files matched your search

@@ -10,6 +10,7 @@ from .safe_jinja import (
DEFAULT_JINJA2_EXTENSIONS,
)
from .plugins.regex import regex_replace
from .plugins.datetime_fmt import unixtime
__all__ = [
'TimeExtension',
@@ -19,4 +20,5 @@ __all__ = [
'JINJA2_MAX_RETURN_PAYLOAD_SIZE',
'DEFAULT_JINJA2_EXTENSIONS',
'regex_replace',
'unixtime',
]
@@ -2,5 +2,6 @@
Jinja2 custom filter plugins for changedetection.io
"""
from .regex import regex_replace
from .datetime_fmt import unixtime
__all__ = ['regex_replace']
__all__ = ['regex_replace', 'unixtime']
@@ -0,0 +1,117 @@
"""
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
@@ -8,7 +8,7 @@ import jinja2.sandbox
import typing as t
import os
from .extensions.TimeExtension import TimeExtension
from .plugins import regex_replace
from .plugins import regex_replace, unixtime
JINJA2_MAX_RETURN_PAYLOAD_SIZE = 1024 * int(os.getenv("JINJA2_MAX_RETURN_PAYLOAD_SIZE_KB", 1024 * 10))
@@ -40,6 +40,7 @@ def create_jinja_env(extensions=None, **kwargs) -> jinja2.sandbox.ImmutableSandb
# Register custom filters
jinja2_env.filters['regex_replace'] = regex_replace
jinja2_env.filters['unixtime'] = unixtime
return jinja2_env
@@ -61,4 +62,3 @@ def render_fully_escaped(content):
"""
from markupsafe import escape
return str(escape(content))
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
Tests for the ``unixtime`` Jinja2 filter (#3641).
Pure unit tests that render through the real (sandboxed) Jinja environment,
so they also confirm the filter is registered and usable in notifications.
"""
from ..jinja2_custom import render
# Timestamp chosen so the UTC result is unambiguous:
# 1759188682 -> 2025-09-29 23:31:00 UTC
TS = 1759188682
def test_unixtime_default_format():
# Pin UTC so the result is independent of the runner's TZ. The default
# format string (%Y-%m-%d %H:%M:%S %Z) is still exercised; the default
# timezone otherwise follows $TZ.
out = render('{{ ts | unixtime("%Y-%m-%d %H:%M:%S %Z", "UTC") }}', ts=TS)
assert out == "2025-09-29 23:31:22 UTC"
def test_unixtime_custom_format():
out = render('{{ ts | unixtime("%d.%m.%Y %H:%M", "UTC") }}', ts=TS)
assert out == "29.09.2025 23:31"
def test_unixtime_milliseconds():
out = render('{{ ts | unixtime("%d.%m.%Y %H:%M", "UTC") }}', ts=TS * 1000)
assert out == "29.09.2025 23:31"
def test_unixtime_microseconds():
out = render('{{ ts | unixtime("%d.%m.%Y %H:%M", "UTC") }}', ts=TS * 1_000_000)
assert out == "29.09.2025 23:31"
def test_unixtime_extracts_from_prose():
out = render('{{ s | unixtime("%d.%m.%Y %H:%M", "UTC") }}',
s="last_battle_at: 1759188682")
assert out == "29.09.2025 23:31"
def test_unixtime_named_timezone():
out = render('{{ ts | unixtime("%d.%m.%Y %H:%M", "Europe/Berlin") }}', ts=TS)
assert out == "30.09.2025 01:31"
def test_unixtime_no_timestamp_falls_back():
# No usable timestamp -> the original token is returned, no exception.
assert render("{{ s | unixtime }}", s="no timestamp here") == "no timestamp here"
assert render("{{ s | unixtime }}", s=None) == "None"
def test_unixtime_small_numbers_ignored():
assert render('{{ s | unixtime("%Y", "UTC") }}', s="price: 42") == "price: 42"