Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions keep/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,29 @@ def timestamp_delta(
return dt + delta


def _assume_utc(dt: datetime.datetime) -> datetime.datetime:
"""Attach UTC to a datetime that carries no offset.

A timestamp written without an offset, such as ``2024-01-01T00:00:00``,
is the documented input to to_utc and to_timestamp, and it is what most
alert payloads carry. astimezone reads such a value in the host's local
timezone, so the same alert converted on a UTC server and on a server in
Asia/Kolkata comes out 5.5 hours apart. UTC is the only reading that does
not depend on where Keep happens to run.
"""
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
return dt.replace(tzinfo=datetime.timezone.utc)
return dt


def to_utc(dt: datetime.datetime | str = "") -> datetime.datetime:
if isinstance(dt, str):
try:
dt = parser.parse(dt.strip())
except ParserError:
# Failed to parse the date
return ""
utc_dt = dt.astimezone(pytz.utc)
utc_dt = _assume_utc(dt).astimezone(pytz.utc)
return utc_dt


Expand All @@ -241,7 +256,7 @@ def to_timestamp(dt: datetime.datetime | str = "") -> int:
except ParserError:
# Failed to parse the date
return 0
return int(dt.timestamp())
return int(_assume_utc(dt).timestamp())


def datetime_compare(t1: datetime = None, t2: datetime = None) -> float:
Expand Down Expand Up @@ -558,6 +573,8 @@ def is_business_hours(
if not dt: # Handle case where parsing failed
return False

dt = _assume_utc(dt)

# Convert to specified timezone
dt = dt.astimezone(tz)

Expand Down
59 changes: 59 additions & 0 deletions tests/test_functions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import json
import time
from datetime import timedelta

import pytest
Expand Down Expand Up @@ -1262,3 +1263,61 @@ def test_dict_merge():
result = functions.dict_merge(d1, "invalid", {"e": 7})
assert result == {"a": 1, "b": 2, "e": 7}



@pytest.fixture
def host_timezone(request, monkeypatch):
"""Run the test as if Keep were deployed in the given timezone.

time.tzset reads TZ into process-global state, so it has to be called
again on the way out or the setting leaks into every test that follows.
"""
monkeypatch.setenv("TZ", request.param)
time.tzset()
yield request.param
monkeypatch.undo()
time.tzset()


@pytest.mark.parametrize(
"host_timezone", ["UTC", "America/New_York", "Asia/Kolkata"], indirect=True
)
def test_to_utc_reads_an_offsetless_string_as_utc(host_timezone):
"""An offsetless timestamp must not be read in the host's timezone.

This is the example the workflow function docs give for to_utc. Before the
fix astimezone applied the host offset, so the same string converted on a
UTC server and on a server in Asia/Kolkata came out 5.5 hours apart.
"""
assert functions.to_utc("2024-01-01T00:00:00") == datetime.datetime(
2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc
)


@pytest.mark.parametrize(
"host_timezone", ["UTC", "America/New_York", "Asia/Kolkata"], indirect=True
)
def test_to_timestamp_reads_an_offsetless_string_as_utc(host_timezone):
assert functions.to_timestamp("2024-01-01T00:00:00") == 1704067200


@pytest.mark.parametrize(
"host_timezone", ["UTC", "America/New_York", "Asia/Kolkata"], indirect=True
)
def test_to_utc_keeps_an_explicit_offset(host_timezone):
"""A string that states its offset is unaffected by either the host or the fix."""
assert functions.to_utc("2024-01-01T02:00:00+02:00") == datetime.datetime(
2024, 1, 1, 0, 0, tzinfo=datetime.timezone.utc
)


@pytest.mark.parametrize(
"host_timezone", ["UTC", "America/New_York", "Asia/Kolkata"], indirect=True
)
def test_is_business_hours_reads_an_offsetless_string_as_utc(host_timezone):
"""The routing decision must not depend on where Keep runs.

18:00 UTC is inside the default 08:00-20:00 window, and it is a Monday.
Read as New York local time the same string is 23:00 UTC, which is outside.
"""
assert functions.is_business_hours("2024-01-01T18:00:00") is True
Loading