mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-10-08 13:02:18 +00:00
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
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:
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"
|
||||
Reference in new issue
Block a user