Skip to content

Commit 749a372

Browse files
author
Michael Brewer
authored
refactor(feature-toggles): Code coverage and housekeeping (#530)
1 parent f6a5b2a commit 749a372

File tree

11 files changed

+294
-108
lines changed

11 files changed

+294
-108
lines changed

Diff for: Makefile

+1-1
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,4 @@ changelog:
8585
docker run -v "${PWD}":/workdir quay.io/git-chglog/git-chglog $$(git describe --abbrev=0 --tag).. > TMP_CHANGELOG.md
8686

8787
mypy:
88-
poetry run mypy aws_lambda_powertools
88+
poetry run mypy --pretty aws_lambda_powertools

Diff for: aws_lambda_powertools/utilities/feature_toggles/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
"""
33
from .appconfig_fetcher import AppConfigFetcher
44
from .configuration_store import ConfigurationStore
5-
from .exceptions import ConfigurationException
5+
from .exceptions import ConfigurationError
66
from .schema import ACTION, SchemaValidator
77
from .schema_fetcher import SchemaFetcher
88

99
__all__ = [
10-
"ConfigurationException",
10+
"ConfigurationError",
1111
"ConfigurationStore",
1212
"ACTION",
1313
"SchemaValidator",

Diff for: aws_lambda_powertools/utilities/feature_toggles/appconfig_fetcher.py

+22-12
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from aws_lambda_powertools.utilities.parameters import AppConfigProvider, GetParameterError, TransformParameterError
77

8-
from .exceptions import ConfigurationException
8+
from .exceptions import ConfigurationError
99
from .schema_fetcher import SchemaFetcher
1010

1111
logger = logging.getLogger(__name__)
@@ -25,12 +25,18 @@ def __init__(
2525
):
2626
"""This class fetches JSON schemas from AWS AppConfig
2727
28-
Args:
29-
environment (str): what appconfig environment to use 'dev/test' etc.
30-
service (str): what service name to use from the supplied environment
31-
configuration_name (str): what configuration to take from the environment & service combination
32-
cache_seconds (int): cache expiration time, how often to call AppConfig to fetch latest configuration
33-
config (Optional[Config]): boto3 client configuration
28+
Parameters
29+
----------
30+
environment: str
31+
what appconfig environment to use 'dev/test' etc.
32+
service: str
33+
what service name to use from the supplied environment
34+
configuration_name: str
35+
what configuration to take from the environment & service combination
36+
cache_seconds: int
37+
cache expiration time, how often to call AppConfig to fetch latest configuration
38+
config: Optional[Config]
39+
boto3 client configuration
3440
"""
3541
super().__init__(configuration_name, cache_seconds)
3642
self._logger = logger
@@ -39,11 +45,15 @@ def __init__(
3945
def get_json_configuration(self) -> Dict[str, Any]:
4046
"""Get configuration string from AWs AppConfig and return the parsed JSON dictionary
4147
42-
Raises:
43-
ConfigurationException: Any validation error or appconfig error that can occur
48+
Raises
49+
------
50+
ConfigurationError
51+
Any validation error or appconfig error that can occur
4452
45-
Returns:
46-
Dict[str, Any]: parsed JSON dictionary
53+
Returns
54+
-------
55+
Dict[str, Any]
56+
parsed JSON dictionary
4757
"""
4858
try:
4959
return self._conf_store.get(
@@ -54,4 +64,4 @@ def get_json_configuration(self) -> Dict[str, Any]:
5464
except (GetParameterError, TransformParameterError) as exc:
5565
error_str = f"unable to get AWS AppConfig configuration file, exception={str(exc)}"
5666
self._logger.error(error_str)
57-
raise ConfigurationException(error_str)
67+
raise ConfigurationError(error_str)
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import logging
2-
from typing import Any, Dict, List, Optional
2+
from typing import Any, Dict, List, Optional, cast
33

44
from . import schema
5-
from .exceptions import ConfigurationException
5+
from .exceptions import ConfigurationError
66
from .schema_fetcher import SchemaFetcher
77

88
logger = logging.getLogger(__name__)
@@ -12,8 +12,10 @@ class ConfigurationStore:
1212
def __init__(self, schema_fetcher: SchemaFetcher):
1313
"""constructor
1414
15-
Args:
16-
schema_fetcher (SchemaFetcher): A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc.
15+
Parameters
16+
----------
17+
schema_fetcher: SchemaFetcher
18+
A schema JSON fetcher, can be AWS AppConfig, Hashicorp Consul etc.
1719
"""
1820
self._logger = logger
1921
self._schema_fetcher = schema_fetcher
@@ -39,25 +41,28 @@ def _match_by_action(self, action: str, condition_value: Any, context_value: Any
3941
def _is_rule_matched(self, feature_name: str, rule: Dict[str, Any], rules_context: Dict[str, Any]) -> bool:
4042
rule_name = rule.get(schema.RULE_NAME_KEY, "")
4143
rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE)
42-
conditions: Dict[str, str] = rule.get(schema.CONDITIONS_KEY)
44+
conditions = cast(List[Dict], rule.get(schema.CONDITIONS_KEY))
4345

4446
for condition in conditions:
45-
context_value = rules_context.get(condition.get(schema.CONDITION_KEY))
47+
context_value = rules_context.get(str(condition.get(schema.CONDITION_KEY)))
4648
if not self._match_by_action(
47-
condition.get(schema.CONDITION_ACTION),
49+
condition.get(schema.CONDITION_ACTION, ""),
4850
condition.get(schema.CONDITION_VALUE),
4951
context_value,
5052
):
5153
logger.debug(
52-
f"rule did not match action, rule_name={rule_name}, rule_default_value={rule_default_value}, feature_name={feature_name}, context_value={str(context_value)}" # noqa: E501
54+
f"rule did not match action, rule_name={rule_name}, rule_default_value={rule_default_value}, "
55+
f"feature_name={feature_name}, context_value={str(context_value)} "
5356
)
5457
# context doesn't match condition
5558
return False
5659
# if we got here, all conditions match
5760
logger.debug(
58-
f"rule matched, rule_name={rule_name}, rule_default_value={rule_default_value}, feature_name={feature_name}" # noqa: E501
61+
f"rule matched, rule_name={rule_name}, rule_default_value={rule_default_value}, "
62+
f"feature_name={feature_name}"
5963
)
6064
return True
65+
return False
6166

6267
def _handle_rules(
6368
self,
@@ -70,66 +75,77 @@ def _handle_rules(
7075
for rule in rules:
7176
rule_default_value = rule.get(schema.RULE_DEFAULT_VALUE)
7277
if self._is_rule_matched(feature_name, rule, rules_context):
73-
return rule_default_value
78+
return bool(rule_default_value)
7479
# no rule matched, return default value of feature
7580
logger.debug(
76-
f"no rule matched, returning default value of feature, feature_default_value={feature_default_value}, feature_name={feature_name}" # noqa: E501
81+
f"no rule matched, returning default value of feature, feature_default_value={feature_default_value}, "
82+
f"feature_name={feature_name}"
7783
)
7884
return feature_default_value
85+
return False
7986

8087
def get_configuration(self) -> Dict[str, Any]:
8188
"""Get configuration string from AWs AppConfig and returned the parsed JSON dictionary
8289
83-
Raises:
84-
ConfigurationException: Any validation error or appconfig error that can occur
90+
Raises
91+
------
92+
ConfigurationError
93+
Any validation error or appconfig error that can occur
8594
86-
Returns:
87-
Dict[str, Any]: parsed JSON dictionary
95+
Returns
96+
------
97+
Dict[str, Any]
98+
parsed JSON dictionary
8899
"""
89-
schema: Dict[
90-
str, Any
91-
] = (
92-
self._schema_fetcher.get_json_configuration()
93-
) # parse result conf as JSON, keep in cache for self.max_age seconds
100+
# parse result conf as JSON, keep in cache for self.max_age seconds
101+
config = self._schema_fetcher.get_json_configuration()
94102
# validate schema
95-
self._schema_validator.validate_json_schema(schema)
96-
return schema
103+
self._schema_validator.validate_json_schema(config)
104+
return config
97105

98106
def get_feature_toggle(
99107
self, *, feature_name: str, rules_context: Optional[Dict[str, Any]] = None, value_if_missing: bool
100108
) -> bool:
101-
"""get a feature toggle boolean value. Value is calculated according to a set of rules and conditions.
102-
see below for explanation.
103-
104-
Args:
105-
feature_name (str): feature name that you wish to fetch
106-
rules_context (Optional[Dict[str, Any]]): dict of attributes that you would like to match the rules
107-
against, can be {'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'} etc.
108-
value_if_missing (bool): this will be the returned value in case the feature toggle doesn't exist in
109-
the schema or there has been an error while fetching the
110-
configuration from appconfig
111-
112-
Returns:
113-
bool: calculated feature toggle value. several possibilities:
114-
1. if the feature doesn't appear in the schema or there has been an error fetching the
115-
configuration -> error/warning log would appear and value_if_missing is returned
116-
2. feature exists and has no rules or no rules have matched -> return feature_default_value of
117-
the defined feature
118-
3. feature exists and a rule matches -> rule_default_value of rule is returned
109+
"""Get a feature toggle boolean value. Value is calculated according to a set of rules and conditions.
110+
111+
See below for explanation.
112+
113+
Parameters
114+
----------
115+
feature_name: str
116+
feature name that you wish to fetch
117+
rules_context: Optional[Dict[str, Any]]
118+
dict of attributes that you would like to match the rules
119+
against, can be {'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'} etc.
120+
value_if_missing: bool
121+
this will be the returned value in case the feature toggle doesn't exist in
122+
the schema or there has been an error while fetching the
123+
configuration from appconfig
124+
125+
Returns
126+
------
127+
bool
128+
calculated feature toggle value. several possibilities:
129+
1. if the feature doesn't appear in the schema or there has been an error fetching the
130+
configuration -> error/warning log would appear and value_if_missing is returned
131+
2. feature exists and has no rules or no rules have matched -> return feature_default_value of
132+
the defined feature
133+
3. feature exists and a rule matches -> rule_default_value of rule is returned
119134
"""
120135
if rules_context is None:
121136
rules_context = {}
122137

123138
try:
124139
toggles_dict: Dict[str, Any] = self.get_configuration()
125-
except ConfigurationException:
126-
logger.error("unable to get feature toggles JSON, returning provided value_if_missing value") # noqa: E501
140+
except ConfigurationError:
141+
logger.error("unable to get feature toggles JSON, returning provided value_if_missing value")
127142
return value_if_missing
128143

129144
feature: Dict[str, Dict] = toggles_dict.get(schema.FEATURES_KEY, {}).get(feature_name, None)
130145
if feature is None:
131146
logger.warning(
132-
f"feature does not appear in configuration, using provided value_if_missing, feature_name={feature_name}, value_if_missing={value_if_missing}" # noqa: E501
147+
f"feature does not appear in configuration, using provided value_if_missing, "
148+
f"feature_name={feature_name}, value_if_missing={value_if_missing}"
133149
)
134150
return value_if_missing
135151

@@ -138,38 +154,46 @@ def get_feature_toggle(
138154
if not rules_list:
139155
# not rules but has a value
140156
logger.debug(
141-
f"no rules found, returning feature default value, feature_name={feature_name}, default_value={feature_default_value}" # noqa: E501
157+
f"no rules found, returning feature default value, feature_name={feature_name}, "
158+
f"default_value={feature_default_value}"
142159
)
143-
return feature_default_value
160+
return bool(feature_default_value)
144161
# look for first rule match
145162
logger.debug(
146163
f"looking for rule match, feature_name={feature_name}, feature_default_value={feature_default_value}"
147-
) # noqa: E501
164+
)
148165
return self._handle_rules(
149166
feature_name=feature_name,
150167
rules_context=rules_context,
151-
feature_default_value=feature_default_value,
152-
rules=rules_list,
168+
feature_default_value=bool(feature_default_value),
169+
rules=cast(List, rules_list),
153170
)
154171

155172
def get_all_enabled_feature_toggles(self, *, rules_context: Optional[Dict[str, Any]] = None) -> List[str]:
156-
"""Get all enabled feature toggles while also taking into account rule_context (when a feature has defined rules)
157-
158-
Args:
159-
rules_context (Optional[Dict[str, Any]]): dict of attributes that you would like to match the rules
160-
against, can be {'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'} etc.
161-
162-
Returns:
163-
List[str]: a list of all features name that are enabled by also taking into account
164-
rule_context (when a feature has defined rules)
173+
"""Get all enabled feature toggles while also taking into account rule_context
174+
(when a feature has defined rules)
175+
176+
Parameters
177+
----------
178+
rules_context: Optional[Dict[str, Any]]
179+
dict of attributes that you would like to match the rules
180+
against, can be `{'tenant_id: 'X', 'username':' 'Y', 'region': 'Z'}` etc.
181+
182+
Returns
183+
----------
184+
List[str]
185+
a list of all features name that are enabled by also taking into account
186+
rule_context (when a feature has defined rules)
165187
"""
166188
if rules_context is None:
167189
rules_context = {}
190+
168191
try:
169192
toggles_dict: Dict[str, Any] = self.get_configuration()
170-
except ConfigurationException:
171-
logger.error("unable to get feature toggles JSON") # noqa: E501
193+
except ConfigurationError:
194+
logger.error("unable to get feature toggles JSON")
172195
return []
196+
173197
ret_list = []
174198
features: Dict[str, Any] = toggles_dict.get(schema.FEATURES_KEY, {})
175199
for feature_name, feature_dict_def in features.items():
@@ -188,4 +212,5 @@ def get_all_enabled_feature_toggles(self, *, rules_context: Optional[Dict[str, A
188212
):
189213
self._logger.debug(f"feature's calculated value is True, feature_name={feature_name}")
190214
ret_list.append(feature_name)
215+
191216
return ret_list
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
class ConfigurationException(Exception):
1+
class ConfigurationError(Exception):
22
"""When a a configuration store raises an exception on config retrieval or parsing"""

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

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from enum import Enum
2+
from logging import Logger
23
from typing import Any, Dict
34

4-
from .exceptions import ConfigurationException
5+
from .exceptions import ConfigurationError
56

67
FEATURES_KEY = "features"
78
RULES_KEY = "rules"
@@ -22,12 +23,12 @@ class ACTION(str, Enum):
2223

2324

2425
class SchemaValidator:
25-
def __init__(self, logger: object):
26+
def __init__(self, logger: Logger):
2627
self._logger = logger
2728

2829
def _raise_conf_exc(self, error_str: str) -> None:
2930
self._logger.error(error_str)
30-
raise ConfigurationException(error_str)
31+
raise ConfigurationError(error_str)
3132

3233
def _validate_condition(self, rule_name: str, condition: Dict[str, str]) -> None:
3334
if not condition or not isinstance(condition, dict):
@@ -47,7 +48,7 @@ def _validate_rule(self, feature_name: str, rule: Dict[str, Any]) -> None:
4748
self._raise_conf_exc(f"feature rule is not a dictionary, feature_name={feature_name}")
4849
rule_name = rule.get(RULE_NAME_KEY)
4950
if not rule_name or rule_name is None or not isinstance(rule_name, str):
50-
self._raise_conf_exc(f"invalid rule_name, feature_name={feature_name}")
51+
return self._raise_conf_exc(f"invalid rule_name, feature_name={feature_name}")
5152
rule_default_value = rule.get(RULE_DEFAULT_VALUE)
5253
if rule_default_value is None or not isinstance(rule_default_value, bool):
5354
self._raise_conf_exc(f"invalid rule_default_value, rule_name={rule_name}")
@@ -76,8 +77,8 @@ def _validate_feature(self, feature_name: str, feature_dict_def: Dict[str, Any])
7677
def validate_json_schema(self, schema: Dict[str, Any]) -> None:
7778
if not isinstance(schema, dict):
7879
self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, root schema is not a dictionary")
79-
features_dict: Dict = schema.get(FEATURES_KEY)
80+
features_dict = schema.get(FEATURES_KEY)
8081
if not isinstance(features_dict, dict):
81-
self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, missing features dictionary")
82+
return self._raise_conf_exc("invalid AWS AppConfig JSON schema detected, missing features dictionary")
8283
for feature_name, feature_dict_def in features_dict.items():
8384
self._validate_feature(feature_name, feature_dict_def)
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from abc import ABC, abstractclassmethod
1+
from abc import ABC, abstractmethod
22
from typing import Any, Dict
33

44

@@ -7,14 +7,18 @@ def __init__(self, configuration_name: str, cache_seconds: int):
77
self.configuration_name = configuration_name
88
self._cache_seconds = cache_seconds
99

10-
@abstractclassmethod
10+
@abstractmethod
1111
def get_json_configuration(self) -> Dict[str, Any]:
1212
"""Get configuration string from any configuration storing service and return the parsed JSON dictionary
1313
14-
Raises:
15-
ConfigurationException: Any error that can occur during schema fetch or JSON parse
14+
Raises
15+
------
16+
ConfigurationError
17+
Any error that can occur during schema fetch or JSON parse
1618
17-
Returns:
18-
Dict[str, Any]: parsed JSON dictionary
19+
Returns
20+
-------
21+
Dict[str, Any]
22+
parsed JSON dictionary
1923
"""
20-
return None
24+
return NotImplemented # pragma: no cover

0 commit comments

Comments
 (0)