Skip to content

Commit 0ab103c

Browse files
committed
Merge branch 'develop' of https://github.com/awslabs/aws-lambda-powertools-python into feature/905-datetime
* 'develop' of https://github.com/awslabs/aws-lambda-powertools-python: feat(feature_flags): support beyond boolean values (JSON values) (aws-powertools#804) docs: consistency around admonitions and snippets (aws-powertools#919) chore(deps-dev): bump mypy from 0.920 to 0.930 (aws-powertools#925) fix(event-sources): handle dynamodb null type as none, not bool (aws-powertools#929) fix(apigateway): support @app.not_found() syntax & housekeeping (aws-powertools#926) docs: Added GraphQL Sample API to Examples section of README.md (aws-powertools#930) feat(idempotency): support dataclasses & pydantic models payloads (aws-powertools#908) feat(tracer): ignore tracing for certain hostname(s) or url(s) (aws-powertools#910) feat(event-sources): cache parsed json in data class (aws-powertools#909) fix(warning): future distutils deprecation (aws-powertools#921)
2 parents 00a07d4 + be15e3c commit 0ab103c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2725
-2226
lines changed

Diff for: README.md

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ With [pip](https://pip.pypa.io/en/latest/index.html) installed, run: ``pip insta
3838
* [Serverless Shopping cart](https://github.com/aws-samples/aws-serverless-shopping-cart)
3939
* [Serverless Airline](https://github.com/aws-samples/aws-serverless-airline-booking)
4040
* [Serverless E-commerce platform](https://github.com/aws-samples/aws-serverless-ecommerce-platform)
41+
* [Serverless GraphQL Nanny Booking Api](https://github.com/trey-rosius/babysitter_api)
4142

4243
## Credits
4344

Diff for: aws_lambda_powertools/event_handler/api_gateway.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,7 @@ def _remove_prefix(self, path: str) -> str:
579579
@staticmethod
580580
def _path_starts_with(path: str, prefix: str):
581581
"""Returns true if the `path` starts with a prefix plus a `/`"""
582-
if not isinstance(prefix, str) or len(prefix) == 0:
582+
if not isinstance(prefix, str) or prefix == "":
583583
return False
584584

585585
return path.startswith(prefix + "/")
@@ -633,7 +633,9 @@ def _call_route(self, route: Route, args: Dict[str, str]) -> ResponseBuilder:
633633

634634
raise
635635

636-
def not_found(self, func: Callable):
636+
def not_found(self, func: Optional[Callable] = None):
637+
if func is None:
638+
return self.exception_handler(NotFoundError)
637639
return self.exception_handler(NotFoundError)(func)
638640

639641
def exception_handler(self, exc_class: Type[Exception]):

Diff for: aws_lambda_powertools/shared/functions.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
1-
from distutils.util import strtobool
21
from typing import Any, Optional, Union
32

43

4+
def strtobool(value: str) -> bool:
5+
"""Convert a string representation of truth to True or False.
6+
7+
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
8+
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
9+
'value' is anything else.
10+
11+
> note:: Copied from distutils.util.
12+
"""
13+
value = value.lower()
14+
if value in ("y", "yes", "t", "true", "on", "1"):
15+
return True
16+
if value in ("n", "no", "f", "false", "off", "0"):
17+
return False
18+
raise ValueError(f"invalid truth value {value!r}")
19+
20+
521
def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bool:
622
"""Pick explicit choice over truthy env value, if available, otherwise return truthy env value
723

Diff for: aws_lambda_powertools/shared/types.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
from typing import Any, Callable, TypeVar
1+
from typing import Any, Callable, Dict, List, TypeVar, Union
22

33
AnyCallableT = TypeVar("AnyCallableT", bound=Callable[..., Any]) # noqa: VNE001
4+
# JSON primitives only, mypy doesn't support recursive tho
5+
JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]]

Diff for: aws_lambda_powertools/tracing/tracer.py

+26-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import logging
66
import numbers
77
import os
8-
from typing import Any, Callable, Dict, Optional, Sequence, Union, cast, overload
8+
from typing import Any, Callable, Dict, List, Optional, Sequence, Union, cast, overload
99

1010
from ..shared import constants
1111
from ..shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice
@@ -758,7 +758,7 @@ def _patch_xray_provider(self):
758758
# Due to Lazy Import, we need to activate `core` attrib via import
759759
# we also need to include `patch`, `patch_all` methods
760760
# to ensure patch calls are done via the provider
761-
from aws_xray_sdk.core import xray_recorder
761+
from aws_xray_sdk.core import xray_recorder # type: ignore
762762

763763
provider = xray_recorder
764764
provider.patch = aws_xray_sdk.core.patch
@@ -778,3 +778,27 @@ def _disable_xray_trace_batching(self):
778778

779779
def _is_xray_provider(self):
780780
return "aws_xray_sdk" in self.provider.__module__
781+
782+
def ignore_endpoint(self, hostname: Optional[str] = None, urls: Optional[List[str]] = None):
783+
"""If you want to ignore certain httplib requests you can do so based on the hostname or URL that is being
784+
requested.
785+
786+
> NOTE: If the provider is not xray, nothing will be added to ignore list
787+
788+
Documentation
789+
--------------
790+
- https://github.com/aws/aws-xray-sdk-python#ignoring-httplib-requests
791+
792+
Parameters
793+
----------
794+
hostname : Optional, str
795+
The hostname is matched using the Python fnmatch library which does Unix glob style matching.
796+
urls: Optional, List[str]
797+
List of urls to ignore. Example `tracer.ignore_endpoint(urls=["/ignored-url"])`
798+
"""
799+
if not self._is_xray_provider():
800+
return
801+
802+
from aws_xray_sdk.ext.httplib import add_ignored # type: ignore
803+
804+
add_ignored(hostname=hostname, urls=urls)

Diff for: aws_lambda_powertools/utilities/data_classes/active_mq_event.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ def decoded_data(self) -> str:
2727
@property
2828
def json_data(self) -> Any:
2929
"""Parses the data as json"""
30-
return json.loads(self.decoded_data)
30+
if self._json_data is None:
31+
self._json_data = json.loads(self.decoded_data)
32+
return self._json_data
3133

3234
@property
3335
def connection_id(self) -> str:

Diff for: aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def user_parameters(self) -> str:
2323
@property
2424
def decoded_user_parameters(self) -> Dict[str, Any]:
2525
"""Json Decoded user parameters"""
26-
return json.loads(self.user_parameters)
26+
if self._json_data is None:
27+
self._json_data = json.loads(self.user_parameters)
28+
return self._json_data
2729

2830

2931
class CodePipelineActionConfiguration(DictWrapper):

Diff for: aws_lambda_powertools/utilities/data_classes/cognito_user_pool_event.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,7 @@ def session(self) -> List[ChallengeResult]:
687687
@property
688688
def client_metadata(self) -> Optional[Dict[str, str]]:
689689
"""One or more key-value pairs that you can provide as custom input to the Lambda function that you
690-
specify for the create auth challenge trigger.."""
690+
specify for the create auth challenge trigger."""
691691
return self["request"].get("clientMetadata")
692692

693693

Diff for: aws_lambda_powertools/utilities/data_classes/common.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class DictWrapper:
88

99
def __init__(self, data: Dict[str, Any]):
1010
self._data = data
11+
self._json_data: Optional[Any] = None
1112

1213
def __getitem__(self, key: str) -> Any:
1314
return self._data[key]
@@ -37,7 +38,7 @@ def get_header_value(
3738
name_lower = name.lower()
3839

3940
return next(
40-
# Iterate over the dict and do a case insensitive key comparison
41+
# Iterate over the dict and do a case-insensitive key comparison
4142
(value for key, value in headers.items() if key.lower() == name_lower),
4243
# Default value is returned if no matches was found
4344
default_value,
@@ -65,7 +66,9 @@ def body(self) -> Optional[str]:
6566
@property
6667
def json_body(self) -> Any:
6768
"""Parses the submitted body as json"""
68-
return json.loads(self.decoded_body)
69+
if self._json_data is None:
70+
self._json_data = json.loads(self.decoded_body)
71+
return self._json_data
6972

7073
@property
7174
def decoded_body(self) -> str:
@@ -113,7 +116,7 @@ def get_header_value(
113116
default_value: str, optional
114117
Default value if no value was found by name
115118
case_sensitive: bool
116-
Whether to use a case sensitive look up
119+
Whether to use a case-sensitive look up
117120
Returns
118121
-------
119122
str, optional

Diff for: aws_lambda_powertools/utilities/data_classes/dynamo_db_stream_event.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -106,14 +106,13 @@ def ns_value(self) -> Optional[List[str]]:
106106
return self.get("NS")
107107

108108
@property
109-
def null_value(self) -> Optional[bool]:
109+
def null_value(self) -> None:
110110
"""An attribute of type Null.
111111
112112
Example:
113113
>>> {"NULL": True}
114114
"""
115-
item = self.get("NULL")
116-
return None if item is None else bool(item)
115+
return None
117116

118117
@property
119118
def s_value(self) -> Optional[str]:

Diff for: aws_lambda_powertools/utilities/data_classes/rabbit_mq_event.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ def decoded_data(self) -> str:
8888
@property
8989
def json_data(self) -> Any:
9090
"""Parses the data as json"""
91-
return json.loads(self.decoded_data)
91+
if self._json_data is None:
92+
self._json_data = json.loads(self.decoded_data)
93+
return self._json_data
9294

9395

9496
class RabbitMQEvent(DictWrapper):

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

+47-13
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Any, Dict, List, Optional, Union, cast
33

44
from ... import Logger
5+
from ...shared.types import JSONType
56
from . import schema
67
from .base import StoreProvider
78
from .exceptions import ConfigurationStoreError
@@ -97,21 +98,30 @@ def _evaluate_conditions(
9798
return True
9899

99100
def _evaluate_rules(
100-
self, *, feature_name: str, context: Dict[str, Any], feat_default: bool, rules: Dict[str, Any]
101+
self,
102+
*,
103+
feature_name: str,
104+
context: Dict[str, Any],
105+
feat_default: Any,
106+
rules: Dict[str, Any],
107+
boolean_feature: bool,
101108
) -> bool:
102109
"""Evaluates whether context matches rules and conditions, otherwise return feature default"""
103110
for rule_name, rule in rules.items():
104111
rule_match_value = rule.get(schema.RULE_MATCH_VALUE)
105112

106113
# Context might contain PII data; do not log its value
107114
self.logger.debug(
108-
f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={feat_default}"
115+
f"Evaluating rule matching, rule={rule_name}, feature={feature_name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
109116
)
110117
if self._evaluate_conditions(rule_name=rule_name, feature_name=feature_name, rule=rule, context=context):
111-
return bool(rule_match_value)
118+
# Maintenance: Revisit before going GA.
119+
return bool(rule_match_value) if boolean_feature else rule_match_value
112120

113121
# no rule matched, return default value of feature
114-
self.logger.debug(f"no rule matched, returning feature default, default={feat_default}, name={feature_name}")
122+
self.logger.debug(
123+
f"no rule matched, returning feature default, default={str(feat_default)}, name={feature_name}, boolean_feature={boolean_feature}" # noqa: E501
124+
)
115125
return feat_default
116126

117127
def get_configuration(self) -> Dict:
@@ -164,7 +174,7 @@ def get_configuration(self) -> Dict:
164174

165175
return config
166176

167-
def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> bool:
177+
def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: JSONType) -> JSONType:
168178
"""Evaluate whether a feature flag should be enabled according to stored schema and input context
169179
170180
**Logic when evaluating a feature flag**
@@ -181,14 +191,15 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
181191
Attributes that should be evaluated against the stored schema.
182192
183193
for example: `{"tenant_id": "X", "username": "Y", "region": "Z"}`
184-
default: bool
194+
default: JSONType
185195
default value if feature flag doesn't exist in the schema,
186196
or there has been an error when fetching the configuration from the store
197+
Can be boolean or any JSON values for non-boolean features.
187198
188199
Returns
189200
------
190-
bool
191-
whether feature should be enabled or not
201+
JSONType
202+
whether feature should be enabled (bool flags) or JSON value when non-bool feature matches
192203
193204
Raises
194205
------
@@ -211,12 +222,27 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
211222

212223
rules = feature.get(schema.RULES_KEY)
213224
feat_default = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
225+
# Maintenance: Revisit before going GA. We might to simplify customers on-boarding by not requiring it
226+
# for non-boolean flags. It'll need minor implementation changes, docs changes, and maybe refactor
227+
# get_enabled_features. We can minimize breaking change, despite Beta label, by having a new
228+
# method `get_matching_features` returning Dict[feature_name, feature_value]
229+
boolean_feature = feature.get(
230+
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
231+
) # backwards compatability ,assume feature flag
214232
if not rules:
215-
self.logger.debug(f"no rules found, returning feature default, name={name}, default={feat_default}")
216-
return bool(feat_default)
233+
self.logger.debug(
234+
f"no rules found, returning feature default, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
235+
)
236+
# Maintenance: Revisit before going GA. We might to simplify customers on-boarding by not requiring it
237+
# for non-boolean flags.
238+
return bool(feat_default) if boolean_feature else feat_default
217239

218-
self.logger.debug(f"looking for rule match, name={name}, default={feat_default}")
219-
return self._evaluate_rules(feature_name=name, context=context, feat_default=bool(feat_default), rules=rules)
240+
self.logger.debug(
241+
f"looking for rule match, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
242+
)
243+
return self._evaluate_rules(
244+
feature_name=name, context=context, feat_default=feat_default, rules=rules, boolean_feature=boolean_feature
245+
)
220246

221247
def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> List[str]:
222248
"""Get all enabled feature flags while also taking into account context
@@ -259,11 +285,19 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
259285
for name, feature in features.items():
260286
rules = feature.get(schema.RULES_KEY, {})
261287
feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
288+
boolean_feature = feature.get(
289+
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
290+
) # backwards compatability ,assume feature flag
291+
262292
if feature_default_value and not rules:
263293
self.logger.debug(f"feature is enabled by default and has no defined rules, name={name}")
264294
features_enabled.append(name)
265295
elif self._evaluate_rules(
266-
feature_name=name, context=context, feat_default=feature_default_value, rules=rules
296+
feature_name=name,
297+
context=context,
298+
feat_default=feature_default_value,
299+
rules=rules,
300+
boolean_feature=boolean_feature,
267301
):
268302
self.logger.debug(f"feature's calculated value is True, name={name}")
269303
features_enabled.append(name)

0 commit comments

Comments
 (0)