Skip to content

Commit 62dd956

Browse files
committed
chore: support non UTC timestamps everywhere
1 parent b1462e7 commit 62dd956

File tree

7 files changed

+747
-367
lines changed

7 files changed

+747
-367
lines changed

aws_lambda_powertools/utilities/feature_flags/feature_flags.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
from .base import StoreProvider
88
from .exceptions import ConfigurationStoreError
99
from .time_conditions import (
10-
compare_utc_datetime_range,
11-
compare_utc_days_of_week,
12-
compare_utc_time_range,
10+
compare_datetime_range,
11+
compare_days_of_week,
12+
compare_time_range,
1313
)
1414

1515

@@ -64,9 +64,9 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
6464
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
6565
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
6666
schema.RuleAction.VALUE_NOT_IN_KEY.value: lambda a, b: b not in a,
67-
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: lambda a, b: compare_utc_time_range(a, b),
68-
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: lambda a, b: compare_utc_datetime_range(a, b),
69-
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: lambda a, b: compare_utc_days_of_week(a, b),
67+
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value: lambda a, b: compare_time_range(a, b),
68+
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value: lambda a, b: compare_datetime_range(a, b),
69+
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value: lambda a, b: compare_days_of_week(a, b),
7070
}
7171

7272
try:
@@ -101,7 +101,7 @@ def _evaluate_conditions(
101101
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
102102
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
103103
):
104-
context_value = condition.get(schema.CONDITION_KEY) # e.g., CURRENT_TIME_UTC
104+
context_value = condition.get(schema.CONDITION_KEY) # e.g., CURRENT_TIME
105105

106106
if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value):
107107
self.logger.debug(

aws_lambda_powertools/utilities/feature_flags/schema.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from enum import Enum
55
from typing import Any, Callable, Dict, List, Optional, Union
66

7+
from dateutil import tz
8+
79
from ... import Logger
810
from .base import BaseValidator
911
from .exceptions import SchemaValidationError
@@ -42,14 +44,16 @@ class RuleAction(Enum):
4244

4345

4446
class TimeKeys(Enum):
45-
CURRENT_TIME_UTC = "CURRENT_TIME_UTC"
46-
CURRENT_DAY_OF_WEEK_UTC = "CURRENT_DAY_OF_WEEK_UTC"
47-
CURRENT_DATETIME_UTC = "CURRENT_DATETIME_UTC"
47+
CURRENT_TIME = "CURRENT_TIME"
48+
CURRENT_DAY_OF_WEEK = "CURRENT_DAY_OF_WEEK"
49+
CURRENT_DATETIME = "CURRENT_DATETIME"
4850

4951

5052
class TimeValues(Enum):
5153
START = "START"
5254
END = "END"
55+
TIMEZONE = "TIMEZONE"
56+
DAYS = "DAYS"
5357
SUNDAY = "SUNDAY"
5458
MONDAY = "MONDAY"
5559
TUESDAY = "TUESDAY"
@@ -291,17 +295,17 @@ def validate_condition_key(condition: Dict[str, Any], rule_name: str):
291295
if not key or not isinstance(key, str):
292296
raise SchemaValidationError(f"'key' value must be a non empty string, rule={rule_name}")
293297
action = condition.get(CONDITION_ACTION, "")
294-
if action == RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value and key != TimeKeys.CURRENT_TIME_UTC.value:
298+
if action == RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value and key != TimeKeys.CURRENT_TIME.value:
295299
raise SchemaValidationError(
296-
f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME_UTC' condition key, rule={rule_name}" # noqa: E501
300+
f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME' condition key, rule={rule_name}" # noqa: E501
297301
)
298-
if action == RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value and key != TimeKeys.CURRENT_DATETIME_UTC.value:
302+
if action == RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value and key != TimeKeys.CURRENT_DATETIME.value:
299303
raise SchemaValidationError(
300-
f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME_UTC' condition key, rule={rule_name}" # noqa: E501
304+
f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME' condition key, rule={rule_name}" # noqa: E501
301305
)
302-
if action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value and key != TimeKeys.CURRENT_DAY_OF_WEEK_UTC.value:
306+
if action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value and key != TimeKeys.CURRENT_DAY_OF_WEEK.value:
303307
raise SchemaValidationError(
304-
f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK_UTC' condition key, rule={rule_name}" # noqa: E501
308+
f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK' condition key, rule={rule_name}" # noqa: E501
305309
)
306310

307311
@staticmethod
@@ -324,13 +328,25 @@ def validate_condition_value(condition: Dict[str, Any], rule_name: str):
324328

325329
@staticmethod
326330
def _validate_datetime_value(datetime_str: str, rule_name: str):
331+
date = None
332+
327333
try:
328334
# python < 3.11 don't support the Z timezone on datetime.fromisoformat,
329335
# so we replace any Z with the equivalent "+00:00"
330-
datetime.fromisoformat(datetime_str.replace("Z", "+00:00"))
336+
date = datetime.fromisoformat(datetime_str.replace("Z", "+00:00"))
331337
except Exception:
332338
raise SchemaValidationError(f"'START' and 'END' must be a valid ISO8601 time format, rule={rule_name}")
333339

340+
# we only allow timezone information to be set via the TIMEZONE field
341+
# this way we can encode DST into the calculation. For instance, Copenhagen is
342+
# UTC+2 during winter, and UTC+1 during summer, which would be impossible to define
343+
# using a single ISO datetime string
344+
if date.tzinfo is not None:
345+
raise SchemaValidationError(
346+
"'START' and 'END' must not include timezone information. Set the timezone using the 'TIMEZONE' "
347+
f"field, rule={rule_name} "
348+
)
349+
334350
@staticmethod
335351
def _validate_time_value(time: str, rule_name: str):
336352
# Using a regex instead of strptime because it's several orders of magnitude faster
@@ -343,11 +359,14 @@ def _validate_time_value(time: str, rule_name: str):
343359

344360
@staticmethod
345361
def _validate_schedule_between_days_of_week(value: Any, rule_name: str):
346-
if not isinstance(value, list) or not value:
347-
raise SchemaValidationError(
348-
f"condition with a CURRENT_DAY_OF_WEEK_UTC action must have a non empty condition value type list, rule={rule_name}" # noqa: E501
349-
)
350-
for day in value:
362+
error_str = f"condition with a CURRENT_DAY_OF_WEEK action must have a condition value dictionary with 'DAYS' and 'TIMEZONE' (optional) keys, rule={rule_name}" # noqa: E501
363+
if not isinstance(value, dict):
364+
raise SchemaValidationError(error_str)
365+
366+
days = value.get(TimeValues.DAYS.value)
367+
if not isinstance(days, list) or not value:
368+
raise SchemaValidationError(error_str)
369+
for day in days:
351370
if not isinstance(day, str) or day not in [
352371
TimeValues.MONDAY.value,
353372
TimeValues.TUESDAY.value,
@@ -358,9 +377,15 @@ def _validate_schedule_between_days_of_week(value: Any, rule_name: str):
358377
TimeValues.SUNDAY.value,
359378
]:
360379
raise SchemaValidationError(
361-
f"condition value must represent a day of the week in 'TimeValues' enum, rule={rule_name}"
380+
f"condition value DAYS must represent a day of the week in 'TimeValues' enum, rule={rule_name}"
362381
)
363382

383+
timezone = value.get(TimeValues.TIMEZONE.value, "UTC")
384+
if not isinstance(timezone, str):
385+
raise SchemaValidationError(error_str)
386+
if not tz.gettz(timezone):
387+
raise SchemaValidationError(f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}")
388+
364389
@staticmethod
365390
def _validate_schedule_between_time_and_datetime_ranges(
366391
value: Any, rule_name: str, action_name: str, validator: Callable[[str, str], None]
@@ -376,3 +401,9 @@ def _validate_schedule_between_time_and_datetime_ranges(
376401
raise SchemaValidationError(f"'START' and 'END' must be a non empty string, rule={rule_name}")
377402
validator(start_time, rule_name)
378403
validator(end_time, rule_name)
404+
405+
timezone = value.get(TimeValues.TIMEZONE.value, "UTC")
406+
if not isinstance(timezone, str):
407+
raise SchemaValidationError(f"'TIMEZONE' must be a string, rule={rule_name}")
408+
if not tz.gettz(timezone):
409+
raise SchemaValidationError(f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}")
Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,49 @@
1-
from datetime import datetime, timezone
2-
from typing import Dict, List
1+
from datetime import datetime, tzinfo
2+
from typing import Dict, Optional
3+
4+
from dateutil.tz import gettz
35

46
from .schema import HOUR_MIN_SEPARATOR, TimeValues
57

68

7-
def _get_utc_time_now() -> datetime:
8-
return datetime.now(timezone.utc)
9+
def _get_now_from_timezone(timezone: Optional[tzinfo]) -> datetime:
10+
timezone = gettz("UTC") if timezone is None else timezone
11+
return datetime.now(timezone)
12+
13+
14+
def compare_days_of_week(action: str, values: Dict) -> bool:
15+
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
16+
current_day = _get_now_from_timezone(gettz(timezone_name)).strftime("%A").upper()
917

18+
days = values.get(TimeValues.DAYS.value, [])
19+
return current_day in days
1020

11-
def compare_utc_days_of_week(action: str, values: List[str]) -> bool:
12-
current_day = _get_utc_time_now().strftime("%A").upper()
13-
return current_day in values
1421

22+
def compare_datetime_range(action: str, values: Dict) -> bool:
23+
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
24+
timezone = gettz(timezone_name)
25+
current_time: datetime = _get_now_from_timezone(timezone)
1526

16-
def compare_utc_datetime_range(action: str, values: Dict) -> bool:
17-
current_time_utc: datetime = _get_utc_time_now()
27+
start_date_str = values.get(TimeValues.START.value, "")
28+
end_date_str = values.get(TimeValues.END.value, "")
1829

19-
# python < 3.11 don't support Z as a timezone on datetime.fromisoformat,
20-
# so we replace any Z with the equivalent "+00:00
21-
start_date_str = values.get(TimeValues.START.value, "").replace("Z", "+00:00")
22-
end_date_str = values.get(TimeValues.END.value, "").replace("Z", "+00:00")
30+
# Since start_date and end_date don't include timezone information, we mark the timestamp
31+
# with the same timezone as the current_time. This way all the 3 timestamps will be on
32+
# the same timezone
33+
start_date = datetime.fromisoformat(start_date_str).replace(tzinfo=timezone)
34+
end_date = datetime.fromisoformat(end_date_str).replace(tzinfo=timezone)
35+
return start_date <= current_time <= end_date
2336

24-
start_date = datetime.fromisoformat(start_date_str)
25-
end_date = datetime.fromisoformat(end_date_str)
26-
return start_date <= current_time_utc <= end_date
2737

38+
def compare_time_range(action: str, values: Dict) -> bool:
39+
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
40+
current_time: datetime = _get_now_from_timezone(gettz(timezone_name))
2841

29-
def compare_utc_time_range(action: str, values: Dict) -> bool:
30-
current_time_utc: datetime = _get_utc_time_now()
3142
start_hour, start_min = values.get(TimeValues.START.value, "").split(HOUR_MIN_SEPARATOR)
3243
end_hour, end_min = values.get(TimeValues.END.value, "").split(HOUR_MIN_SEPARATOR)
3344
return (
34-
current_time_utc.hour >= int(start_hour)
35-
and current_time_utc.hour <= int(end_hour)
36-
and current_time_utc.minute >= int(start_min)
37-
and current_time_utc.minute <= int(end_min)
45+
current_time.hour >= int(start_hour)
46+
and current_time.hour <= int(end_hour)
47+
and current_time.minute >= int(start_min)
48+
and current_time.minute <= int(end_min)
3849
)

0 commit comments

Comments
 (0)