Skip to content

Commit 5dc0f25

Browse files
leandrodamascenaran-isenbergrubenfonseca
authored
feat(feature_flags): Add Time based feature flags actions (#1846)
Co-authored-by: @ran-isenberg <[email protected]> Co-authored-by: @rubenfonseca <[email protected]>
1 parent d4feaed commit 5dc0f25

15 files changed

+1517
-27
lines changed

Diff for: aws_lambda_powertools/utilities/feature_flags/feature_flags.py

+20-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
from . import schema
77
from .base import StoreProvider
88
from .exceptions import ConfigurationStoreError
9+
from .time_conditions import (
10+
compare_datetime_range,
11+
compare_days_of_week,
12+
compare_time_range,
13+
)
914

1015

1116
class FeatureFlags:
@@ -59,6 +64,9 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
5964
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
6065
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
6166
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_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),
6270
}
6371

6472
try:
@@ -83,10 +91,18 @@ def _evaluate_conditions(
8391
return False
8492

8593
for condition in conditions:
86-
context_value = context.get(str(condition.get(schema.CONDITION_KEY)))
94+
context_value = context.get(condition.get(schema.CONDITION_KEY, ""))
8795
cond_action = condition.get(schema.CONDITION_ACTION, "")
8896
cond_value = condition.get(schema.CONDITION_VALUE)
8997

98+
# time based rule actions have no user context. the context is the condition key
99+
if cond_action in (
100+
schema.RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value,
101+
schema.RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value,
102+
schema.RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value,
103+
):
104+
context_value = condition.get(schema.CONDITION_KEY) # e.g., CURRENT_TIME
105+
90106
if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value):
91107
self.logger.debug(
92108
f"rule did not match action, rule_name={rule_name}, rule_value={rule_match_value}, "
@@ -169,7 +185,7 @@ def get_configuration(self) -> Dict:
169185
# parse result conf as JSON, keep in cache for max age defined in store
170186
self.logger.debug(f"Fetching schema from registered store, store={self.store}")
171187
config: Dict = self.store.get_configuration()
172-
validator = schema.SchemaValidator(schema=config)
188+
validator = schema.SchemaValidator(schema=config, logger=self.logger)
173189
validator.validate()
174190

175191
return config
@@ -228,7 +244,7 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
228244
# method `get_matching_features` returning Dict[feature_name, feature_value]
229245
boolean_feature = feature.get(
230246
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
231-
) # backwards compatability ,assume feature flag
247+
) # backwards compatibility, assume feature flag
232248
if not rules:
233249
self.logger.debug(
234250
f"no rules found, returning feature default, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
@@ -287,7 +303,7 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
287303
feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
288304
boolean_feature = feature.get(
289305
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
290-
) # backwards compatability ,assume feature flag
306+
) # backwards compatibility, assume feature flag
291307

292308
if feature_default_value and not rules:
293309
self.logger.debug(f"feature is enabled by default and has no defined rules, name={name}")

Diff for: aws_lambda_powertools/utilities/feature_flags/schema.py

+172-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import logging
2+
import re
3+
from datetime import datetime
24
from enum import Enum
3-
from typing import Any, Dict, List, Optional, Union
5+
from typing import Any, Callable, Dict, List, Optional, Union
6+
7+
from dateutil import tz
48

59
from ... import Logger
610
from .base import BaseValidator
@@ -14,9 +18,12 @@
1418
CONDITION_VALUE = "value"
1519
CONDITION_ACTION = "action"
1620
FEATURE_DEFAULT_VAL_TYPE_KEY = "boolean_type"
21+
TIME_RANGE_FORMAT = "%H:%M" # hour:min 24 hours clock
22+
TIME_RANGE_RE_PATTERN = re.compile(r"2[0-3]:[0-5]\d|[0-1]\d:[0-5]\d") # 24 hour clock
23+
HOUR_MIN_SEPARATOR = ":"
1724

1825

19-
class RuleAction(str, Enum):
26+
class RuleAction(Enum):
2027
EQUALS = "EQUALS"
2128
NOT_EQUALS = "NOT_EQUALS"
2229
KEY_GREATER_THAN_VALUE = "KEY_GREATER_THAN_VALUE"
@@ -31,6 +38,37 @@ class RuleAction(str, Enum):
3138
KEY_NOT_IN_VALUE = "KEY_NOT_IN_VALUE"
3239
VALUE_IN_KEY = "VALUE_IN_KEY"
3340
VALUE_NOT_IN_KEY = "VALUE_NOT_IN_KEY"
41+
SCHEDULE_BETWEEN_TIME_RANGE = "SCHEDULE_BETWEEN_TIME_RANGE" # hour:min 24 hours clock
42+
SCHEDULE_BETWEEN_DATETIME_RANGE = "SCHEDULE_BETWEEN_DATETIME_RANGE" # full datetime format, excluding timezone
43+
SCHEDULE_BETWEEN_DAYS_OF_WEEK = "SCHEDULE_BETWEEN_DAYS_OF_WEEK" # MONDAY, TUESDAY, .... see TimeValues enum
44+
45+
46+
class TimeKeys(Enum):
47+
"""
48+
Possible keys when using time rules
49+
"""
50+
51+
CURRENT_TIME = "CURRENT_TIME"
52+
CURRENT_DAY_OF_WEEK = "CURRENT_DAY_OF_WEEK"
53+
CURRENT_DATETIME = "CURRENT_DATETIME"
54+
55+
56+
class TimeValues(Enum):
57+
"""
58+
Possible values when using time rules
59+
"""
60+
61+
START = "START"
62+
END = "END"
63+
TIMEZONE = "TIMEZONE"
64+
DAYS = "DAYS"
65+
SUNDAY = "SUNDAY"
66+
MONDAY = "MONDAY"
67+
TUESDAY = "TUESDAY"
68+
WEDNESDAY = "WEDNESDAY"
69+
THURSDAY = "THURSDAY"
70+
FRIDAY = "FRIDAY"
71+
SATURDAY = "SATURDAY"
3472

3573

3674
class SchemaValidator(BaseValidator):
@@ -143,7 +181,7 @@ def validate(self) -> None:
143181
if not isinstance(self.schema, dict):
144182
raise SchemaValidationError(f"Features must be a dictionary, schema={str(self.schema)}")
145183

146-
features = FeaturesValidator(schema=self.schema)
184+
features = FeaturesValidator(schema=self.schema, logger=self.logger)
147185
features.validate()
148186

149187

@@ -158,7 +196,7 @@ def validate(self):
158196
for name, feature in self.schema.items():
159197
self.logger.debug(f"Attempting to validate feature '{name}'")
160198
boolean_feature: bool = self.validate_feature(name, feature)
161-
rules = RulesValidator(feature=feature, boolean_feature=boolean_feature)
199+
rules = RulesValidator(feature=feature, boolean_feature=boolean_feature, logger=self.logger)
162200
rules.validate()
163201

164202
# returns True in case the feature is a regular feature flag with a boolean default value
@@ -196,14 +234,15 @@ def validate(self):
196234
return
197235

198236
if not isinstance(self.rules, dict):
237+
self.logger.debug(f"Feature rules must be a dictionary, feature={self.feature_name}")
199238
raise SchemaValidationError(f"Feature rules must be a dictionary, feature={self.feature_name}")
200239

201240
for rule_name, rule in self.rules.items():
202-
self.logger.debug(f"Attempting to validate rule '{rule_name}'")
241+
self.logger.debug(f"Attempting to validate rule={rule_name} and feature={self.feature_name}")
203242
self.validate_rule(
204243
rule=rule, rule_name=rule_name, feature_name=self.feature_name, boolean_feature=self.boolean_feature
205244
)
206-
conditions = ConditionsValidator(rule=rule, rule_name=rule_name)
245+
conditions = ConditionsValidator(rule=rule, rule_name=rule_name, logger=self.logger)
207246
conditions.validate()
208247

209248
@staticmethod
@@ -233,12 +272,14 @@ def __init__(self, rule: Dict[str, Any], rule_name: str, logger: Optional[Union[
233272
self.logger = logger or logging.getLogger(__name__)
234273

235274
def validate(self):
275+
236276
if not self.conditions or not isinstance(self.conditions, list):
277+
self.logger.debug(f"Condition is empty or invalid for rule={self.rule_name}")
237278
raise SchemaValidationError(f"Invalid condition, rule={self.rule_name}")
238279

239280
for condition in self.conditions:
240281
# Condition can contain PII data; do not log condition value
241-
self.logger.debug(f"Attempting to validate condition for '{self.rule_name}'")
282+
self.logger.debug(f"Attempting to validate condition for {self.rule_name}")
242283
self.validate_condition(rule_name=self.rule_name, condition=condition)
243284

244285
@staticmethod
@@ -265,8 +306,132 @@ def validate_condition_key(condition: Dict[str, Any], rule_name: str):
265306
if not key or not isinstance(key, str):
266307
raise SchemaValidationError(f"'key' value must be a non empty string, rule={rule_name}")
267308

309+
# time actions need to have very specific keys
310+
# SCHEDULE_BETWEEN_TIME_RANGE => CURRENT_TIME
311+
# SCHEDULE_BETWEEN_DATETIME_RANGE => CURRENT_DATETIME
312+
# SCHEDULE_BETWEEN_DAYS_OF_WEEK => CURRENT_DAY_OF_WEEK
313+
action = condition.get(CONDITION_ACTION, "")
314+
if action == RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value and key != TimeKeys.CURRENT_TIME.value:
315+
raise SchemaValidationError(
316+
f"'condition with a 'SCHEDULE_BETWEEN_TIME_RANGE' action must have a 'CURRENT_TIME' condition key, rule={rule_name}" # noqa: E501
317+
)
318+
if action == RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value and key != TimeKeys.CURRENT_DATETIME.value:
319+
raise SchemaValidationError(
320+
f"'condition with a 'SCHEDULE_BETWEEN_DATETIME_RANGE' action must have a 'CURRENT_DATETIME' condition key, rule={rule_name}" # noqa: E501
321+
)
322+
if action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value and key != TimeKeys.CURRENT_DAY_OF_WEEK.value:
323+
raise SchemaValidationError(
324+
f"'condition with a 'SCHEDULE_BETWEEN_DAYS_OF_WEEK' action must have a 'CURRENT_DAY_OF_WEEK' condition key, rule={rule_name}" # noqa: E501
325+
)
326+
268327
@staticmethod
269328
def validate_condition_value(condition: Dict[str, Any], rule_name: str):
270329
value = condition.get(CONDITION_VALUE, "")
271330
if not value:
272331
raise SchemaValidationError(f"'value' key must not be empty, rule={rule_name}")
332+
action = condition.get(CONDITION_ACTION, "")
333+
334+
# time actions need to be parsed to make sure date and time format is valid and timezone is recognized
335+
if action == RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value:
336+
ConditionsValidator._validate_schedule_between_time_and_datetime_ranges(
337+
value, rule_name, action, ConditionsValidator._validate_time_value
338+
)
339+
elif action == RuleAction.SCHEDULE_BETWEEN_DATETIME_RANGE.value:
340+
ConditionsValidator._validate_schedule_between_time_and_datetime_ranges(
341+
value, rule_name, action, ConditionsValidator._validate_datetime_value
342+
)
343+
elif action == RuleAction.SCHEDULE_BETWEEN_DAYS_OF_WEEK.value:
344+
ConditionsValidator._validate_schedule_between_days_of_week(value, rule_name)
345+
346+
@staticmethod
347+
def _validate_datetime_value(datetime_str: str, rule_name: str):
348+
date = None
349+
350+
# We try to parse first with timezone information in order to return the correct error messages
351+
# when a timestamp with timezone is used. Otherwise, the user would get the first error "must be a valid
352+
# ISO8601 time format" which is misleading
353+
354+
try:
355+
# python < 3.11 don't support the Z timezone on datetime.fromisoformat,
356+
# so we replace any Z with the equivalent "+00:00"
357+
# datetime.fromisoformat is orders of magnitude faster than datetime.strptime
358+
date = datetime.fromisoformat(datetime_str.replace("Z", "+00:00"))
359+
except Exception:
360+
raise SchemaValidationError(f"'START' and 'END' must be a valid ISO8601 time format, rule={rule_name}")
361+
362+
# we only allow timezone information to be set via the TIMEZONE field
363+
# this way we can encode DST into the calculation. For instance, Copenhagen is
364+
# UTC+2 during winter, and UTC+1 during summer, which would be impossible to define
365+
# using a single ISO datetime string
366+
if date.tzinfo is not None:
367+
raise SchemaValidationError(
368+
"'START' and 'END' must not include timezone information. Set the timezone using the 'TIMEZONE' "
369+
f"field, rule={rule_name} "
370+
)
371+
372+
@staticmethod
373+
def _validate_time_value(time: str, rule_name: str):
374+
# Using a regex instead of strptime because it's several orders of magnitude faster
375+
match = TIME_RANGE_RE_PATTERN.match(time)
376+
377+
if not match:
378+
raise SchemaValidationError(
379+
f"'START' and 'END' must be a valid time format, time_format={TIME_RANGE_FORMAT}, rule={rule_name}"
380+
)
381+
382+
@staticmethod
383+
def _validate_schedule_between_days_of_week(value: Any, rule_name: str):
384+
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
385+
if not isinstance(value, dict):
386+
raise SchemaValidationError(error_str)
387+
388+
days = value.get(TimeValues.DAYS.value)
389+
if not isinstance(days, list) or not value:
390+
raise SchemaValidationError(error_str)
391+
for day in days:
392+
if not isinstance(day, str) or day not in [
393+
TimeValues.MONDAY.value,
394+
TimeValues.TUESDAY.value,
395+
TimeValues.WEDNESDAY.value,
396+
TimeValues.THURSDAY.value,
397+
TimeValues.FRIDAY.value,
398+
TimeValues.SATURDAY.value,
399+
TimeValues.SUNDAY.value,
400+
]:
401+
raise SchemaValidationError(
402+
f"condition value DAYS must represent a day of the week in 'TimeValues' enum, rule={rule_name}"
403+
)
404+
405+
timezone = value.get(TimeValues.TIMEZONE.value, "UTC")
406+
if not isinstance(timezone, str):
407+
raise SchemaValidationError(error_str)
408+
409+
# try to see if the timezone string corresponds to any known timezone
410+
if not tz.gettz(timezone):
411+
raise SchemaValidationError(f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}")
412+
413+
@staticmethod
414+
def _validate_schedule_between_time_and_datetime_ranges(
415+
value: Any, rule_name: str, action_name: str, validator: Callable[[str, str], None]
416+
):
417+
error_str = f"condition with a '{action_name}' action must have a condition value type dictionary with 'START' and 'END' keys, rule={rule_name}" # noqa: E501
418+
if not isinstance(value, dict):
419+
raise SchemaValidationError(error_str)
420+
421+
start_time = value.get(TimeValues.START.value)
422+
end_time = value.get(TimeValues.END.value)
423+
if not start_time or not end_time:
424+
raise SchemaValidationError(error_str)
425+
if not isinstance(start_time, str) or not isinstance(end_time, str):
426+
raise SchemaValidationError(f"'START' and 'END' must be a non empty string, rule={rule_name}")
427+
428+
validator(start_time, rule_name)
429+
validator(end_time, rule_name)
430+
431+
timezone = value.get(TimeValues.TIMEZONE.value, "UTC")
432+
if not isinstance(timezone, str):
433+
raise SchemaValidationError(f"'TIMEZONE' must be a string, rule={rule_name}")
434+
435+
# try to see if the timezone string corresponds to any known timezone
436+
if not tz.gettz(timezone):
437+
raise SchemaValidationError(f"'TIMEZONE' value must represent a valid IANA timezone, rule={rule_name}")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from datetime import datetime, tzinfo
2+
from typing import Dict, Optional
3+
4+
from dateutil.tz import gettz
5+
6+
from .schema import HOUR_MIN_SEPARATOR, TimeValues
7+
8+
9+
def _get_now_from_timezone(timezone: Optional[tzinfo]) -> datetime:
10+
"""
11+
Returns now in the specified timezone. Defaults to UTC if not present.
12+
At this stage, we already validated that the passed timezone string is valid, so we assume that
13+
gettz() will return a tzinfo object.
14+
"""
15+
timezone = gettz("UTC") if timezone is None else timezone
16+
return datetime.now(timezone)
17+
18+
19+
def compare_days_of_week(action: str, values: Dict) -> bool:
20+
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
21+
22+
# %A = Weekday as locale’s full name.
23+
current_day = _get_now_from_timezone(gettz(timezone_name)).strftime("%A").upper()
24+
25+
days = values.get(TimeValues.DAYS.value, [])
26+
return current_day in days
27+
28+
29+
def compare_datetime_range(action: str, values: Dict) -> bool:
30+
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
31+
timezone = gettz(timezone_name)
32+
current_time: datetime = _get_now_from_timezone(timezone)
33+
34+
start_date_str = values.get(TimeValues.START.value, "")
35+
end_date_str = values.get(TimeValues.END.value, "")
36+
37+
# Since start_date and end_date doesn't include timezone information, we mark the timestamp
38+
# with the same timezone as the current_time. This way all the 3 timestamps will be on
39+
# the same timezone.
40+
start_date = datetime.fromisoformat(start_date_str).replace(tzinfo=timezone)
41+
end_date = datetime.fromisoformat(end_date_str).replace(tzinfo=timezone)
42+
return start_date <= current_time <= end_date
43+
44+
45+
def compare_time_range(action: str, values: Dict) -> bool:
46+
timezone_name = values.get(TimeValues.TIMEZONE.value, "UTC")
47+
current_time: datetime = _get_now_from_timezone(gettz(timezone_name))
48+
49+
start_hour, start_min = values.get(TimeValues.START.value, "").split(HOUR_MIN_SEPARATOR)
50+
end_hour, end_min = values.get(TimeValues.END.value, "").split(HOUR_MIN_SEPARATOR)
51+
52+
start_time = current_time.replace(hour=int(start_hour), minute=int(start_min))
53+
end_time = current_time.replace(hour=int(end_hour), minute=int(end_min))
54+
55+
if int(end_hour) < int(start_hour):
56+
# When the end hour is smaller than start hour, it means we are crossing a day's boundary.
57+
# In this case we need to assert that current_time is **either** on one side or the other side of the boundary
58+
#
59+
# ┌─────┐ ┌─────┐ ┌─────┐
60+
# │20.00│ │00.00│ │04.00│
61+
# └─────┘ └─────┘ └─────┘
62+
# ───────────────────────────────────────────┬─────────────────────────────────────────▶
63+
# ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
64+
# │ │ │
65+
# │ either this area │ │ or this area
66+
# │ │ │
67+
# └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
68+
# │
69+
70+
return (start_time <= current_time) or (current_time <= end_time)
71+
else:
72+
# In normal circumstances, we need to assert **both** conditions
73+
return start_time <= current_time <= end_time

0 commit comments

Comments
 (0)