Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…tools-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)
  • Loading branch information
heitorlessa committed Dec 31, 2021
2 parents 00a07d4 + be15e3c commit 0ab103c
Show file tree
Hide file tree
Showing 48 changed files with 2,725 additions and 2,226 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -38,6 +38,7 @@ With [pip](https://pip.pypa.io/en/latest/index.html) installed, run: ``pip insta
* [Serverless Shopping cart](https://github.com/aws-samples/aws-serverless-shopping-cart)
* [Serverless Airline](https://github.com/aws-samples/aws-serverless-airline-booking)
* [Serverless E-commerce platform](https://github.com/aws-samples/aws-serverless-ecommerce-platform)
* [Serverless GraphQL Nanny Booking Api](https://github.com/trey-rosius/babysitter_api)

## Credits

Expand Down
6 changes: 4 additions & 2 deletions aws_lambda_powertools/event_handler/api_gateway.py
Expand Up @@ -579,7 +579,7 @@ def _remove_prefix(self, path: str) -> str:
@staticmethod
def _path_starts_with(path: str, prefix: str):
"""Returns true if the `path` starts with a prefix plus a `/`"""
if not isinstance(prefix, str) or len(prefix) == 0:
if not isinstance(prefix, str) or prefix == "":
return False

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

raise

def not_found(self, func: Callable):
def not_found(self, func: Optional[Callable] = None):
if func is None:
return self.exception_handler(NotFoundError)
return self.exception_handler(NotFoundError)(func)

def exception_handler(self, exc_class: Type[Exception]):
Expand Down
18 changes: 17 additions & 1 deletion aws_lambda_powertools/shared/functions.py
@@ -1,7 +1,23 @@
from distutils.util import strtobool
from typing import Any, Optional, Union


def strtobool(value: str) -> bool:
"""Convert a string representation of truth to True or False.
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
'value' is anything else.
> note:: Copied from distutils.util.
"""
value = value.lower()
if value in ("y", "yes", "t", "true", "on", "1"):
return True
if value in ("n", "no", "f", "false", "off", "0"):
return False
raise ValueError(f"invalid truth value {value!r}")


def resolve_truthy_env_var_choice(env: str, choice: Optional[bool] = None) -> bool:
"""Pick explicit choice over truthy env value, if available, otherwise return truthy env value
Expand Down
4 changes: 3 additions & 1 deletion aws_lambda_powertools/shared/types.py
@@ -1,3 +1,5 @@
from typing import Any, Callable, TypeVar
from typing import Any, Callable, Dict, List, TypeVar, Union

AnyCallableT = TypeVar("AnyCallableT", bound=Callable[..., Any]) # noqa: VNE001
# JSON primitives only, mypy doesn't support recursive tho
JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]]
28 changes: 26 additions & 2 deletions aws_lambda_powertools/tracing/tracer.py
Expand Up @@ -5,7 +5,7 @@
import logging
import numbers
import os
from typing import Any, Callable, Dict, Optional, Sequence, Union, cast, overload
from typing import Any, Callable, Dict, List, Optional, Sequence, Union, cast, overload

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

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

def _is_xray_provider(self):
return "aws_xray_sdk" in self.provider.__module__

def ignore_endpoint(self, hostname: Optional[str] = None, urls: Optional[List[str]] = None):
"""If you want to ignore certain httplib requests you can do so based on the hostname or URL that is being
requested.
> NOTE: If the provider is not xray, nothing will be added to ignore list
Documentation
--------------
- https://github.com/aws/aws-xray-sdk-python#ignoring-httplib-requests
Parameters
----------
hostname : Optional, str
The hostname is matched using the Python fnmatch library which does Unix glob style matching.
urls: Optional, List[str]
List of urls to ignore. Example `tracer.ignore_endpoint(urls=["/ignored-url"])`
"""
if not self._is_xray_provider():
return

from aws_xray_sdk.ext.httplib import add_ignored # type: ignore

add_ignored(hostname=hostname, urls=urls)
Expand Up @@ -27,7 +27,9 @@ def decoded_data(self) -> str:
@property
def json_data(self) -> Any:
"""Parses the data as json"""
return json.loads(self.decoded_data)
if self._json_data is None:
self._json_data = json.loads(self.decoded_data)
return self._json_data

@property
def connection_id(self) -> str:
Expand Down
Expand Up @@ -23,7 +23,9 @@ def user_parameters(self) -> str:
@property
def decoded_user_parameters(self) -> Dict[str, Any]:
"""Json Decoded user parameters"""
return json.loads(self.user_parameters)
if self._json_data is None:
self._json_data = json.loads(self.user_parameters)
return self._json_data


class CodePipelineActionConfiguration(DictWrapper):
Expand Down
Expand Up @@ -687,7 +687,7 @@ def session(self) -> List[ChallengeResult]:
@property
def client_metadata(self) -> Optional[Dict[str, str]]:
"""One or more key-value pairs that you can provide as custom input to the Lambda function that you
specify for the create auth challenge trigger.."""
specify for the create auth challenge trigger."""
return self["request"].get("clientMetadata")


Expand Down
9 changes: 6 additions & 3 deletions aws_lambda_powertools/utilities/data_classes/common.py
Expand Up @@ -8,6 +8,7 @@ class DictWrapper:

def __init__(self, data: Dict[str, Any]):
self._data = data
self._json_data: Optional[Any] = None

def __getitem__(self, key: str) -> Any:
return self._data[key]
Expand Down Expand Up @@ -37,7 +38,7 @@ def get_header_value(
name_lower = name.lower()

return next(
# Iterate over the dict and do a case insensitive key comparison
# Iterate over the dict and do a case-insensitive key comparison
(value for key, value in headers.items() if key.lower() == name_lower),
# Default value is returned if no matches was found
default_value,
Expand Down Expand Up @@ -65,7 +66,9 @@ def body(self) -> Optional[str]:
@property
def json_body(self) -> Any:
"""Parses the submitted body as json"""
return json.loads(self.decoded_body)
if self._json_data is None:
self._json_data = json.loads(self.decoded_body)
return self._json_data

@property
def decoded_body(self) -> str:
Expand Down Expand Up @@ -113,7 +116,7 @@ def get_header_value(
default_value: str, optional
Default value if no value was found by name
case_sensitive: bool
Whether to use a case sensitive look up
Whether to use a case-sensitive look up
Returns
-------
str, optional
Expand Down
Expand Up @@ -106,14 +106,13 @@ def ns_value(self) -> Optional[List[str]]:
return self.get("NS")

@property
def null_value(self) -> Optional[bool]:
def null_value(self) -> None:
"""An attribute of type Null.
Example:
>>> {"NULL": True}
"""
item = self.get("NULL")
return None if item is None else bool(item)
return None

@property
def s_value(self) -> Optional[str]:
Expand Down
Expand Up @@ -88,7 +88,9 @@ def decoded_data(self) -> str:
@property
def json_data(self) -> Any:
"""Parses the data as json"""
return json.loads(self.decoded_data)
if self._json_data is None:
self._json_data = json.loads(self.decoded_data)
return self._json_data


class RabbitMQEvent(DictWrapper):
Expand Down
60 changes: 47 additions & 13 deletions aws_lambda_powertools/utilities/feature_flags/feature_flags.py
Expand Up @@ -2,6 +2,7 @@
from typing import Any, Dict, List, Optional, Union, cast

from ... import Logger
from ...shared.types import JSONType
from . import schema
from .base import StoreProvider
from .exceptions import ConfigurationStoreError
Expand Down Expand Up @@ -97,21 +98,30 @@ def _evaluate_conditions(
return True

def _evaluate_rules(
self, *, feature_name: str, context: Dict[str, Any], feat_default: bool, rules: Dict[str, Any]
self,
*,
feature_name: str,
context: Dict[str, Any],
feat_default: Any,
rules: Dict[str, Any],
boolean_feature: bool,
) -> bool:
"""Evaluates whether context matches rules and conditions, otherwise return feature default"""
for rule_name, rule in rules.items():
rule_match_value = rule.get(schema.RULE_MATCH_VALUE)

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

# no rule matched, return default value of feature
self.logger.debug(f"no rule matched, returning feature default, default={feat_default}, name={feature_name}")
self.logger.debug(
f"no rule matched, returning feature default, default={str(feat_default)}, name={feature_name}, boolean_feature={boolean_feature}" # noqa: E501
)
return feat_default

def get_configuration(self) -> Dict:
Expand Down Expand Up @@ -164,7 +174,7 @@ def get_configuration(self) -> Dict:

return config

def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: bool) -> bool:
def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, default: JSONType) -> JSONType:
"""Evaluate whether a feature flag should be enabled according to stored schema and input context
**Logic when evaluating a feature flag**
Expand All @@ -181,14 +191,15 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau
Attributes that should be evaluated against the stored schema.
for example: `{"tenant_id": "X", "username": "Y", "region": "Z"}`
default: bool
default: JSONType
default value if feature flag doesn't exist in the schema,
or there has been an error when fetching the configuration from the store
Can be boolean or any JSON values for non-boolean features.
Returns
------
bool
whether feature should be enabled or not
JSONType
whether feature should be enabled (bool flags) or JSON value when non-bool feature matches
Raises
------
Expand All @@ -211,12 +222,27 @@ def evaluate(self, *, name: str, context: Optional[Dict[str, Any]] = None, defau

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

self.logger.debug(f"looking for rule match, name={name}, default={feat_default}")
return self._evaluate_rules(feature_name=name, context=context, feat_default=bool(feat_default), rules=rules)
self.logger.debug(
f"looking for rule match, name={name}, default={str(feat_default)}, boolean_feature={boolean_feature}" # noqa: E501
)
return self._evaluate_rules(
feature_name=name, context=context, feat_default=feat_default, rules=rules, boolean_feature=boolean_feature
)

def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> List[str]:
"""Get all enabled feature flags while also taking into account context
Expand Down Expand Up @@ -259,11 +285,19 @@ def get_enabled_features(self, *, context: Optional[Dict[str, Any]] = None) -> L
for name, feature in features.items():
rules = feature.get(schema.RULES_KEY, {})
feature_default_value = feature.get(schema.FEATURE_DEFAULT_VAL_KEY)
boolean_feature = feature.get(
schema.FEATURE_DEFAULT_VAL_TYPE_KEY, True
) # backwards compatability ,assume feature flag

if feature_default_value and not rules:
self.logger.debug(f"feature is enabled by default and has no defined rules, name={name}")
features_enabled.append(name)
elif self._evaluate_rules(
feature_name=name, context=context, feat_default=feature_default_value, rules=rules
feature_name=name,
context=context,
feat_default=feature_default_value,
rules=rules,
boolean_feature=boolean_feature,
):
self.logger.debug(f"feature's calculated value is True, name={name}")
features_enabled.append(name)
Expand Down

0 comments on commit 0ab103c

Please sign in to comment.