Skip to content

Commit 8bb9e79

Browse files
author
Ran Isenberg
committed
feature: time conditions
1 parent 577bb10 commit 8bb9e79

File tree

4 files changed

+323
-2
lines changed

4 files changed

+323
-2
lines changed

aws_lambda_powertools/utilities/feature_flags/feature_flags.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from . import schema
77
from .base import StoreProvider
88
from .exceptions import ConfigurationStoreError
9+
from .time_conditions import time_range_compare, time_selected_days_compare
910

1011

1112
class FeatureFlags:
@@ -59,6 +60,8 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
5960
schema.RuleAction.KEY_NOT_IN_VALUE.value: lambda a, b: a not in b,
6061
schema.RuleAction.VALUE_IN_KEY.value: lambda a, b: b in a,
6162
schema.RuleAction.VALUE_NOT_IN_KEY.value: lambda a, b: b not in a,
63+
schema.RuleAction.TIME_RANGE.value: lambda a, b: time_range_compare(a, b),
64+
schema.RuleAction.TIME_SELECTED_DAYS.value: lambda a, b: time_selected_days_compare(a, b),
6265
}
6366

6467
try:
@@ -87,6 +90,10 @@ def _evaluate_conditions(
8790
cond_action = condition.get(schema.CONDITION_ACTION, "")
8891
cond_value = condition.get(schema.CONDITION_VALUE)
8992

93+
# rule based actions have no user context. the context is the condition key
94+
if cond_action == schema.RuleAction.TIME_RANGE.value:
95+
context_value = condition.get(schema.CONDITION_KEY)
96+
9097
if not self._match_by_action(action=cond_action, condition_value=cond_value, context_value=context_value):
9198
self.logger.debug(
9299
f"rule did not match action, rule_name={rule_name}, rule_value={rule_match_value}, "
@@ -228,7 +235,7 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
228235
# method `get_matching_features` returning Dict[feature_name, feature_value]
229236
boolean_feature = feature.get(
230237
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
231-
) # backwards compatability ,assume feature flag
238+
) # backwards compatibility ,assume feature flag
232239
if not rules:
233240
self.logger.debug(
234241
f"no rules found, returning feature default, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
@@ -287,7 +294,7 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
287294
feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
288295
boolean_feature = feature.get(
289296
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
290-
) # backwards compatability ,assume feature flag
297+
) # backwards compatibility ,assume feature flag
291298

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

aws_lambda_powertools/utilities/feature_flags/schema.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,26 @@ class RuleAction(str, Enum):
3131
KEY_NOT_IN_VALUE = "KEY_NOT_IN_VALUE"
3232
VALUE_IN_KEY = "VALUE_IN_KEY"
3333
VALUE_NOT_IN_KEY = "VALUE_NOT_IN_KEY"
34+
TIME_RANGE = "TIME_RANGE" # 24 hours clock UTC time
35+
TIME_SELECTED_DAYS = "TIME_SELECTED_DAYS"
36+
37+
38+
class TimeKeys(str, Enum):
39+
CURRENT_HOUR_UTC = "CURRENT_HOUR_UTC"
40+
CURRENT_DAY_UTC = "CURRENT_DAY_UTC"
41+
CURRENT_TIME_UTC = "CURRENT_TIME_UTC"
42+
43+
44+
class TimeValues(str, Enum):
45+
START_TIME = "START_TIME"
46+
END_TIME = "END_TIME"
47+
SUNDAY = "SUNDAY"
48+
MONDAY = "MONDAY"
49+
TUESDAY = "TUESDAY"
50+
WEDNESDAY = "WEDNESDAY"
51+
THURSDAY = "THURSDAY"
52+
FRIDAY = "FRIDAY"
53+
SATURDAY = "SATURDAY"
3454

3555

3656
class SchemaValidator(BaseValidator):
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from datetime import datetime, timezone
2+
from typing import Dict
3+
4+
from .schema import TimeKeys, TimeValues
5+
6+
HOUR_MIN_SEPARATOR = ":"
7+
8+
9+
def time_range_compare(action: str, values: Dict) -> bool:
10+
if action == TimeKeys.CURRENT_TIME_UTC.value:
11+
return _time_range_compare_current_time_utc(action, values)
12+
elif action == TimeKeys.CURRENT_HOUR_UTC.value:
13+
return _time_range_compare_current_time_utc(action, values)
14+
# we assume it passed validation right? so no need to raise an error
15+
return False
16+
17+
18+
def time_selected_days_compare(action: str, values: Dict) -> bool:
19+
if action == TimeKeys.CURRENT_DAY_UTC.value:
20+
return _time_selected_days_current_days_compare(action, values)
21+
# we assume it passed validation right? so no need to raise an error
22+
return False
23+
24+
25+
def _time_selected_days_current_days_compare(action: str, values: Dict) -> bool:
26+
# implement here
27+
return True
28+
29+
30+
def _time_range_compare_current_time_utc(action: str, values: Dict) -> bool:
31+
current_time_utc: datetime = datetime.now(timezone.utc)
32+
start_date = datetime.strptime(values.get(TimeValues.START_TIME, ""), "%Y-%m-%dT%H:%M:%S%z")
33+
end_date = datetime.strptime(values.get(TimeValues.END_TIME, ""), "%Y-%m-%dT%H:%M:%S%z")
34+
return current_time_utc >= start_date and current_time_utc <= end_date
35+
36+
37+
def _time_range_compare_current_hour_utc(action: str, values: Dict) -> bool:
38+
current_time_utc: datetime = datetime.now(timezone.utc)
39+
start_hour, start_min = values.get(TimeValues.START_TIME, HOUR_MIN_SEPARATOR).split(HOUR_MIN_SEPARATOR)
40+
end_hour, end_min = values.get(TimeValues.END_TIME, HOUR_MIN_SEPARATOR).split(HOUR_MIN_SEPARATOR)
41+
return (
42+
current_time_utc.hour >= int(start_hour)
43+
and current_time_utc.hour <= int(end_hour)
44+
and current_time_utc.minute >= int(start_min)
45+
and current_time_utc.minute <= int(end_min)
46+
)
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
from typing import Dict, Optional
2+
3+
import pytest
4+
from botocore.config import Config
5+
6+
from aws_lambda_powertools.utilities.feature_flags.appconfig import AppConfigStore
7+
from aws_lambda_powertools.utilities.feature_flags.feature_flags import FeatureFlags
8+
from aws_lambda_powertools.utilities.feature_flags.schema import (
9+
CONDITION_ACTION,
10+
CONDITION_KEY,
11+
CONDITION_VALUE,
12+
CONDITIONS_KEY,
13+
FEATURE_DEFAULT_VAL_KEY,
14+
RULE_MATCH_VALUE,
15+
RULES_KEY,
16+
RuleAction,
17+
TimeKeys,
18+
TimeValues,
19+
)
20+
21+
22+
@pytest.fixture(scope="module")
23+
def config():
24+
return Config(region_name="us-east-1")
25+
26+
27+
def init_feature_flags(
28+
mocker, mock_schema: Dict, config: Config, envelope: str = "", jmespath_options: Optional[Dict] = None
29+
) -> FeatureFlags:
30+
mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get")
31+
mocked_get_conf.return_value = mock_schema
32+
33+
app_conf_fetcher = AppConfigStore(
34+
environment="test_env",
35+
application="test_app",
36+
name="test_conf_name",
37+
max_age=600,
38+
sdk_config=config,
39+
envelope=envelope,
40+
jmespath_options=jmespath_options,
41+
)
42+
feature_flags: FeatureFlags = FeatureFlags(store=app_conf_fetcher)
43+
return feature_flags
44+
45+
46+
def init_fetcher_side_effect(mocker, config: Config, side_effect) -> AppConfigStore:
47+
mocked_get_conf = mocker.patch("aws_lambda_powertools.utilities.parameters.AppConfigProvider.get")
48+
mocked_get_conf.side_effect = side_effect
49+
return AppConfigStore(
50+
environment="env",
51+
application="application",
52+
name="conf",
53+
max_age=1,
54+
sdk_config=config,
55+
)
56+
57+
58+
def test_time_based_utc_in_between_time_range_rule_match(mocker, config):
59+
expected_value = True
60+
mocked_app_config_schema = {
61+
"my_feature": {
62+
FEATURE_DEFAULT_VAL_KEY: False,
63+
RULES_KEY: {
64+
"lambda time is between UTC 11:11-23:59": {
65+
RULE_MATCH_VALUE: True,
66+
CONDITIONS_KEY: [
67+
{
68+
CONDITION_ACTION: RuleAction.TIME_RANGE.value, # this condition matches
69+
CONDITION_KEY: TimeKeys.CURRENT_HOUR_UTC.value,
70+
CONDITION_VALUE: {TimeValues.START_TIME.value: "11:11", TimeValues.END_TIME.value: "23:59"},
71+
},
72+
],
73+
}
74+
},
75+
}
76+
}
77+
# mock time for rule match
78+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
79+
toggle = feature_flags.evaluate(
80+
name="my_feature",
81+
context={},
82+
default=False,
83+
)
84+
assert toggle == expected_value
85+
86+
87+
def test_time_based_utc_in_between_full_time_range_rule_match(mocker, config):
88+
expected_value = True
89+
mocked_app_config_schema = {
90+
"my_feature": {
91+
FEATURE_DEFAULT_VAL_KEY: False,
92+
RULES_KEY: {
93+
"lambda time is between UTC october 5th 2022 12:14:32PM to october 10th 2022 12:15:00 PM": {
94+
RULE_MATCH_VALUE: True,
95+
CONDITIONS_KEY: [
96+
{
97+
CONDITION_ACTION: RuleAction.TIME_RANGE.value, # this condition matches
98+
CONDITION_KEY: TimeKeys.CURRENT_TIME_UTC.value,
99+
CONDITION_VALUE: {
100+
TimeValues.START_TIME.value: "2022-10-05T12:15:00Z",
101+
TimeValues.END_TIME.value: "2022-10-10T12:15:00Z",
102+
},
103+
},
104+
],
105+
}
106+
},
107+
}
108+
}
109+
# mock time for rule match
110+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
111+
toggle = feature_flags.evaluate(
112+
name="my_feature",
113+
context={},
114+
default=False,
115+
)
116+
assert toggle == expected_value
117+
118+
119+
def test_time_based_multiple_conditions_utc_in_between_time_range_rule_match(mocker, config):
120+
expected_value = True
121+
mocked_app_config_schema = {
122+
"my_feature": {
123+
FEATURE_DEFAULT_VAL_KEY: False,
124+
RULES_KEY: {
125+
"lambda time is between UTC 09:00-17:00 and username is ran": {
126+
RULE_MATCH_VALUE: True,
127+
CONDITIONS_KEY: [
128+
{
129+
CONDITION_ACTION: RuleAction.TIME_RANGE.value, # this condition matches
130+
CONDITION_KEY: TimeKeys.CURRENT_HOUR_UTC,
131+
CONDITION_VALUE: {TimeValues.START_TIME: "09:00", TimeValues.END_TIME: "17:00"},
132+
},
133+
{
134+
CONDITION_ACTION: RuleAction.EQUALS.value,
135+
CONDITION_KEY: "tenant",
136+
CONDITION_VALUE: {"username": "ran"},
137+
},
138+
],
139+
}
140+
},
141+
}
142+
}
143+
# mock time for rule match
144+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
145+
toggle = feature_flags.evaluate(
146+
name="my_feature",
147+
context={"username": "ran"},
148+
default=False,
149+
)
150+
assert toggle == expected_value
151+
152+
153+
def test_time_based_utc_days_range_rule_match(mocker, config):
154+
expected_value = True
155+
mocked_app_config_schema = {
156+
"my_feature": {
157+
FEATURE_DEFAULT_VAL_KEY: False,
158+
RULES_KEY: {
159+
"match only monday through friday": {
160+
RULE_MATCH_VALUE: True,
161+
CONDITIONS_KEY: [
162+
{
163+
CONDITION_ACTION: RuleAction.TIME_SELECTED_DAYS.value, # this condition matches
164+
CONDITION_KEY: TimeKeys.CURRENT_DAY_UTC, # similar to "IN" actions
165+
CONDITION_VALUE: [
166+
TimeValues.MONDAY,
167+
TimeValues.TUESDAY,
168+
TimeValues.WEDNESDAY,
169+
TimeValues.THURSDAY,
170+
TimeValues.FRIDAY,
171+
],
172+
},
173+
],
174+
}
175+
},
176+
}
177+
}
178+
# mock time for rule match
179+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
180+
toggle = feature_flags.evaluate(
181+
name="my_feature",
182+
context={},
183+
default=False,
184+
)
185+
assert toggle == expected_value
186+
187+
188+
def test_time_based_utc_only_weekend_rule_match(mocker, config):
189+
expected_value = True
190+
mocked_app_config_schema = {
191+
"my_feature": {
192+
FEATURE_DEFAULT_VAL_KEY: False,
193+
RULES_KEY: {
194+
"match only on weekend": {
195+
RULE_MATCH_VALUE: True,
196+
CONDITIONS_KEY: [
197+
{
198+
CONDITION_ACTION: RuleAction.TIME_SELECTED_DAYS.value, # this condition matches
199+
CONDITION_KEY: TimeKeys.CURRENT_DAY_UTC, # similar to "IN" actions
200+
CONDITION_VALUE: [TimeValues.SATURDAY, TimeValues.SUNDAY],
201+
},
202+
],
203+
}
204+
},
205+
}
206+
}
207+
# mock time for rule match
208+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
209+
toggle = feature_flags.evaluate(
210+
name="my_feature",
211+
context={},
212+
default=False,
213+
)
214+
assert toggle == expected_value
215+
216+
217+
def test_time_based_multiple_conditions_utc_days_range_and_certain_hours_rule_match(mocker, config):
218+
expected_value = True
219+
mocked_app_config_schema = {
220+
"my_feature": {
221+
FEATURE_DEFAULT_VAL_KEY: False,
222+
RULES_KEY: {
223+
"match when lambda time is between UTC 11:00-23:00 and day is either monday or thursday": {
224+
RULE_MATCH_VALUE: True,
225+
CONDITIONS_KEY: [
226+
{
227+
CONDITION_ACTION: RuleAction.TIME_RANGE.value, # this condition matches
228+
CONDITION_KEY: TimeKeys.CURRENT_HOUR_UTC,
229+
CONDITION_VALUE: {TimeValues.START_TIME: "11:00", TimeValues.END_TIME: "23:00"},
230+
},
231+
{
232+
CONDITION_ACTION: RuleAction.TIME_SELECTED_DAYS.value, # this condition matches
233+
CONDITION_KEY: TimeKeys.CURRENT_DAY_UTC,
234+
CONDITION_VALUE: [TimeValues.MONDAY, TimeValues.THURSDAY],
235+
},
236+
],
237+
}
238+
},
239+
}
240+
}
241+
# mock time for rule match
242+
feature_flags = init_feature_flags(mocker, mocked_app_config_schema, config)
243+
toggle = feature_flags.evaluate(
244+
name="my_feature",
245+
context={},
246+
default=False,
247+
)
248+
assert toggle == expected_value

0 commit comments

Comments
 (0)