diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 74242e8c9ab..c670ea38274 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -58,7 +58,6 @@ body: attributes: label: AWS Lambda function runtime options: - - "3.7" - "3.8" - "3.9" - "3.10" diff --git a/.github/ISSUE_TEMPLATE/static_typing.yml b/.github/ISSUE_TEMPLATE/static_typing.yml index 29b59ea1461..eb8c7a77387 100644 --- a/.github/ISSUE_TEMPLATE/static_typing.yml +++ b/.github/ISSUE_TEMPLATE/static_typing.yml @@ -25,7 +25,6 @@ body: attributes: label: AWS Lambda function runtime options: - - "3.7" - "3.8" - "3.9" - "3.10" diff --git a/.github/workflows/quality_check.yml b/.github/workflows/quality_check.yml index 40ccbe99887..d28357be3b1 100644 --- a/.github/workflows/quality_check.yml +++ b/.github/workflows/quality_check.yml @@ -44,7 +44,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] env: PYTHON: "${{ matrix.python-version }}" permissions: diff --git a/.github/workflows/quality_check_pydanticv2.yml b/.github/workflows/quality_check_pydanticv2.yml index d0af2934986..2d84f1154ba 100644 --- a/.github/workflows/quality_check_pydanticv2.yml +++ b/.github/workflows/quality_check_pydanticv2.yml @@ -43,7 +43,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] env: PYTHON: "${{ matrix.python-version }}" permissions: diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index cff41718d87..5780bba255b 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -47,7 +47,7 @@ jobs: strategy: fail-fast: false # needed so if a version fails, the others will still be able to complete and cleanup matrix: - version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + version: ["3.8", "3.9", "3.10", "3.11", "3.12"] if: ${{ github.actor != 'dependabot[bot]' && github.repository == 'aws-powertools/powertools-lambda-python' }} steps: - name: "Checkout" diff --git a/README.md b/README.md index d3f0ec30603..c1ab7abaf29 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build](https://github.com/aws-powertools/powertools-lambda-python/actions/workflows/quality_check.yml/badge.svg)](https://github.com/aws-powertools/powertools-lambda-python/actions/workflows/python_build.yml) [![codecov.io](https://codecov.io/github/aws-powertools/powertools-lambda-python/branch/develop/graphs/badge.svg)](https://app.codecov.io/gh/aws-powertools/powertools-lambda-python) -![PythonSupport](https://img.shields.io/static/v1?label=python&message=%203.7|%203.8|%203.9|%203.10|%203.11|%203.12&color=blue?style=flat-square&logo=python) ![PyPI version](https://badge.fury.io/py/aws-lambda-powertools.svg) ![PyPi monthly downloads](https://img.shields.io/pypi/dm/aws-lambda-powertools) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/aws-powertools/powertools-lambda-python/badge)](https://api.securityscorecards.dev/projects/github.com/aws-powertools/powertools-lambda-python) [![Join our Discord](https://dcbadge.vercel.app/api/server/B8zZKbbyET)](https://discord.gg/B8zZKbbyET) +![PythonSupport](https://img.shields.io/static/v1?label=python&message=%203.8|%203.9|%203.10|%203.11|%203.12&color=blue?style=flat-square&logo=python) ![PyPI version](https://badge.fury.io/py/aws-lambda-powertools.svg) ![PyPi monthly downloads](https://img.shields.io/pypi/dm/aws-lambda-powertools) [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/aws-powertools/powertools-lambda-python/badge)](https://api.securityscorecards.dev/projects/github.com/aws-powertools/powertools-lambda-python) [![Join our Discord](https://dcbadge.vercel.app/api/server/B8zZKbbyET)](https://discord.gg/B8zZKbbyET) Powertools for AWS Lambda (Python) is a developer toolkit to implement Serverless [best practices and increase developer velocity](https://docs.powertools.aws.dev/lambda/python/latest/#features). diff --git a/aws_lambda_powertools/logging/compat.py b/aws_lambda_powertools/logging/compat.py deleted file mode 100644 index ebbefb7af6c..00000000000 --- a/aws_lambda_powertools/logging/compat.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work.""" -from __future__ import annotations - -import io -import logging -import os -import traceback - - -def findCaller(stack_info=False, stacklevel=2): # pragma: no cover - """ - Find the stack frame of the caller so that we can note the source - file name, line number and function name. - """ - f = logging.currentframe() # noqa: VNE001 - # On some versions of IronPython, currentframe() returns None if - # IronPython isn't run with -X:Frames. - if f is None: - return "(unknown file)", 0, "(unknown function)", None - while stacklevel > 0: - next_f = f.f_back - if next_f is None: - ## We've got options here. - ## If we want to use the last (deepest) frame: - break - ## If we want to mimic the warnings module: - # return ("sys", 1, "(unknown function)", None) # noqa: ERA001 - ## If we want to be pedantic: # noqa: ERA001 - # raise ValueError("call stack is not deep enough") # noqa: ERA001 - f = next_f # noqa: VNE001 - if not _is_internal_frame(f): - stacklevel -= 1 - co = f.f_code - sinfo = None - if stack_info: - with io.StringIO() as sio: - sio.write("Stack (most recent call last):\n") - traceback.print_stack(f, file=sio) - sinfo = sio.getvalue() - if sinfo[-1] == "\n": - sinfo = sinfo[:-1] - return co.co_filename, f.f_lineno, co.co_name, sinfo - - -# The following is based on warnings._is_internal_frame. It makes sure that -# frames of the import mechanism are skipped when logging at module level and -# using a stacklevel value greater than one. -def _is_internal_frame(frame): # pragma: no cover - """Signal whether the frame is a CPython or logging module internal.""" - filename = os.path.normcase(frame.f_code.co_filename) - return filename == logging._srcfile or ("importlib" in filename and "_bootstrap" in filename) diff --git a/aws_lambda_powertools/logging/logger.py b/aws_lambda_powertools/logging/logger.py index 88c903b7cb6..ab159061bff 100644 --- a/aws_lambda_powertools/logging/logger.py +++ b/aws_lambda_powertools/logging/logger.py @@ -22,7 +22,6 @@ overload, ) -from aws_lambda_powertools.logging import compat from aws_lambda_powertools.shared import constants from aws_lambda_powertools.shared.functions import ( extract_event_from_common_models, @@ -302,9 +301,6 @@ def _init_logger( self.addHandler(self.logger_handler) self.structure_logs(formatter_options=formatter_options, **kwargs) - # Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work - self._logger.findCaller = compat.findCaller # type: ignore[method-assign] - # Pytest Live Log feature duplicates log records for colored output # but we explicitly add a filter for log deduplication. # This flag disables this protection when you explicit want logs to be duplicated (#262) @@ -467,9 +463,6 @@ def info( extra = extra or {} extra = {**extra, **kwargs} - # Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work - if sys.version_info < (3, 8): # pragma: no cover - return self._logger.info(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra) return self._logger.info( msg, *args, @@ -492,9 +485,6 @@ def error( extra = extra or {} extra = {**extra, **kwargs} - # Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work - if sys.version_info < (3, 8): # pragma: no cover - return self._logger.error(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra) return self._logger.error( msg, *args, @@ -517,9 +507,6 @@ def exception( extra = extra or {} extra = {**extra, **kwargs} - # Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work - if sys.version_info < (3, 8): # pragma: no cover - return self._logger.exception(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra) return self._logger.exception( msg, *args, @@ -542,9 +529,6 @@ def critical( extra = extra or {} extra = {**extra, **kwargs} - # Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work - if sys.version_info < (3, 8): # pragma: no cover - return self._logger.critical(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra) return self._logger.critical( msg, *args, @@ -567,9 +551,6 @@ def warning( extra = extra or {} extra = {**extra, **kwargs} - # Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work - if sys.version_info < (3, 8): # pragma: no cover - return self._logger.warning(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra) return self._logger.warning( msg, *args, @@ -592,9 +573,6 @@ def debug( extra = extra or {} extra = {**extra, **kwargs} - # Maintenance: We can drop this upon Py3.7 EOL. It's a backport for "location" key to work - if sys.version_info < (3, 8): # pragma: no cover - return self._logger.debug(msg, *args, exc_info=exc_info, stack_info=stack_info, extra=extra) return self._logger.debug( msg, *args, diff --git a/aws_lambda_powertools/shared/types.py b/aws_lambda_powertools/shared/types.py index 100005159e4..d5014c4c467 100644 --- a/aws_lambda_powertools/shared/types.py +++ b/aws_lambda_powertools/shared/types.py @@ -1,10 +1,5 @@ import sys -from typing import Any, Callable, Dict, List, TypeVar, Union - -if sys.version_info >= (3, 8): - from typing import Literal, Protocol, TypedDict -else: - from typing_extensions import Literal, Protocol, TypedDict +from typing import Any, Callable, Dict, List, Literal, Protocol, TypedDict, TypeVar, Union if sys.version_info >= (3, 9): from typing import Annotated @@ -16,7 +11,6 @@ else: from typing_extensions import NotRequired - # Even though `get_args` and `get_origin` were added in Python 3.8, they only handle Annotated correctly on 3.10. # So for python < 3.10 we use the backport from typing_extensions. if sys.version_info >= (3, 10): diff --git a/aws_lambda_powertools/utilities/data_classes/active_mq_event.py b/aws_lambda_powertools/utilities/data_classes/active_mq_event.py index f5404154ea7..f0839a70442 100644 --- a/aws_lambda_powertools/utilities/data_classes/active_mq_event.py +++ b/aws_lambda_powertools/utilities/data_classes/active_mq_event.py @@ -1,3 +1,4 @@ +from functools import cached_property from typing import Any, Dict, Iterator, Optional from aws_lambda_powertools.utilities.data_classes.common import DictWrapper @@ -23,12 +24,9 @@ def decoded_data(self) -> str: """Decodes the data as a str""" return base64_decode(self.data) - @property + @cached_property def json_data(self) -> Any: - """Parses the data as json""" - if self._json_data is None: - self._json_data = self._json_deserializer(self.decoded_data) - return self._json_data + return self._json_deserializer(self.decoded_data) @property def connection_id(self) -> str: diff --git a/aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py b/aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py index 1813d6016b5..cc7a75cc05e 100644 --- a/aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py +++ b/aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py @@ -1,5 +1,6 @@ import tempfile import zipfile +from functools import cached_property from typing import Any, Dict, List, Optional from urllib.parse import unquote_plus @@ -17,12 +18,13 @@ def user_parameters(self) -> Optional[str]: """User parameters""" return self.get("UserParameters", None) - @property + @cached_property def decoded_user_parameters(self) -> Optional[Dict[str, Any]]: """Json Decoded user parameters""" - if self._json_data is None and self.user_parameters is not None: - self._json_data = self._json_deserializer(self.user_parameters) - return self._json_data + if self.user_parameters is not None: + return self._json_deserializer(self.user_parameters) + + return None class CodePipelineActionConfiguration(DictWrapper): diff --git a/aws_lambda_powertools/utilities/data_classes/common.py b/aws_lambda_powertools/utilities/data_classes/common.py index 0560159ecc5..25fb5a4c170 100644 --- a/aws_lambda_powertools/utilities/data_classes/common.py +++ b/aws_lambda_powertools/utilities/data_classes/common.py @@ -1,6 +1,7 @@ import base64 import json from collections.abc import Mapping +from functools import cached_property from typing import Any, Callable, Dict, Iterator, List, Optional, overload from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer @@ -24,7 +25,6 @@ def __init__(self, data: Dict[str, Any], json_deserializer: Optional[Callable] = by default json.loads """ self._data = data - self._json_data: Optional[Any] = None self._json_deserializer = json_deserializer or json.loads def __getitem__(self, key: str) -> Any: @@ -138,14 +138,12 @@ def body(self) -> Optional[str]: """Submitted body of the request as a string""" return self.get("body") - @property + @cached_property def json_body(self) -> Any: """Parses the submitted body as json""" - if self._json_data is None: - self._json_data = self._json_deserializer(self.decoded_body) - return self._json_data + return self._json_deserializer(self.decoded_body) - @property + @cached_property def decoded_body(self) -> str: """Dynamically base64 decode body as a str""" body: str = self["body"] diff --git a/aws_lambda_powertools/utilities/data_classes/kafka_event.py b/aws_lambda_powertools/utilities/data_classes/kafka_event.py index f54f979bace..d3d1425f0f2 100644 --- a/aws_lambda_powertools/utilities/data_classes/kafka_event.py +++ b/aws_lambda_powertools/utilities/data_classes/kafka_event.py @@ -1,4 +1,5 @@ import base64 +from functools import cached_property from typing import Any, Dict, Iterator, List, Optional from aws_lambda_powertools.utilities.data_classes.common import DictWrapper @@ -53,12 +54,10 @@ def decoded_value(self) -> bytes: """Decodes the base64 encoded value as bytes.""" return base64.b64decode(self.value) - @property + @cached_property def json_value(self) -> Any: """Decodes the text encoded data as JSON.""" - if self._json_data is None: - self._json_data = self._json_deserializer(self.decoded_value.decode("utf-8")) - return self._json_data + return self._json_deserializer(self.decoded_value.decode("utf-8")) @property def headers(self) -> List[Dict[str, List[int]]]: diff --git a/aws_lambda_powertools/utilities/data_classes/kinesis_firehose_event.py b/aws_lambda_powertools/utilities/data_classes/kinesis_firehose_event.py index 3e5db8cb9d8..492aac53176 100644 --- a/aws_lambda_powertools/utilities/data_classes/kinesis_firehose_event.py +++ b/aws_lambda_powertools/utilities/data_classes/kinesis_firehose_event.py @@ -2,6 +2,7 @@ import json import warnings from dataclasses import dataclass, field +from functools import cached_property from typing import Any, Callable, ClassVar, Dict, Iterator, List, Optional, Tuple from typing_extensions import Literal @@ -70,7 +71,6 @@ class KinesisFirehoseDataTransformationRecord: metadata: Optional[KinesisFirehoseDataTransformationRecordMetadata] = None json_serializer: Callable = json.dumps json_deserializer: Callable = json.loads - _json_data: Optional[Any] = None def asdict(self) -> Dict: if self.result not in self._valid_result_types: @@ -102,14 +102,13 @@ def data_as_text(self) -> str: return "" return self.data_as_bytes.decode("utf-8") - @property + @cached_property def data_as_json(self) -> Dict: """Decoded base64-encoded data loaded to json""" if not self.data: return {} - if self._json_data is None: - self._json_data = self.json_deserializer(self.data_as_text) - return self._json_data + + return self.json_deserializer(self.data_as_text) @dataclass(repr=False, order=False) @@ -240,12 +239,10 @@ def data_as_text(self) -> str: """Decoded base64-encoded data as text""" return self.data_as_bytes.decode("utf-8") - @property + @cached_property def data_as_json(self) -> dict: """Decoded base64-encoded data loaded to json""" - if self._json_data is None: - self._json_data = self._json_deserializer(self.data_as_text) - return self._json_data + return self._json_deserializer(self.data_as_text) def build_data_transformation_response( self, diff --git a/aws_lambda_powertools/utilities/data_classes/rabbit_mq_event.py b/aws_lambda_powertools/utilities/data_classes/rabbit_mq_event.py index ab792f3b893..0eaae042621 100644 --- a/aws_lambda_powertools/utilities/data_classes/rabbit_mq_event.py +++ b/aws_lambda_powertools/utilities/data_classes/rabbit_mq_event.py @@ -1,3 +1,4 @@ +from functools import cached_property from typing import Any, Dict, List from aws_lambda_powertools.utilities.data_classes.common import DictWrapper @@ -84,12 +85,10 @@ def decoded_data(self) -> str: """Decodes the data as a str""" return base64_decode(self.data) - @property + @cached_property def json_data(self) -> Any: """Parses the data as json""" - if self._json_data is None: - self._json_data = self._json_deserializer(self.decoded_data) - return self._json_data + return self._json_deserializer(self.decoded_data) class RabbitMQEvent(DictWrapper): diff --git a/aws_lambda_powertools/utilities/data_classes/sqs_event.py b/aws_lambda_powertools/utilities/data_classes/sqs_event.py index ffec9854a2e..4ca1b8f51c9 100644 --- a/aws_lambda_powertools/utilities/data_classes/sqs_event.py +++ b/aws_lambda_powertools/utilities/data_classes/sqs_event.py @@ -1,3 +1,4 @@ +from functools import cached_property from typing import Any, Dict, Iterator, Optional, Type, TypeVar from aws_lambda_powertools.utilities.data_classes import S3Event @@ -107,7 +108,7 @@ def body(self) -> str: """The message's contents (not URL-encoded).""" return self["body"] - @property + @cached_property def json_body(self) -> Any: """Deserializes JSON string available in 'body' property @@ -132,9 +133,7 @@ def json_body(self) -> Any: data: list = record.json_body # ["telemetry_values"] ``` """ - if self._json_data is None: - self._json_data = self._json_deserializer(self["body"]) - return self._json_data + return self._json_deserializer(self["body"]) @property def attributes(self) -> SQSRecordAttributes: diff --git a/aws_lambda_powertools/utilities/data_classes/vpc_lattice.py b/aws_lambda_powertools/utilities/data_classes/vpc_lattice.py index f12c53d841a..15144e41d7d 100644 --- a/aws_lambda_powertools/utilities/data_classes/vpc_lattice.py +++ b/aws_lambda_powertools/utilities/data_classes/vpc_lattice.py @@ -1,3 +1,4 @@ +from functools import cached_property from typing import Any, Dict, Optional, overload from aws_lambda_powertools.shared.headers_serializer import ( @@ -18,12 +19,10 @@ def body(self) -> str: """The VPC Lattice body.""" return self["body"] - @property + @cached_property def json_body(self) -> Any: """Parses the submitted body as json""" - if self._json_data is None: - self._json_data = self._json_deserializer(self.decoded_body) - return self._json_data + return self._json_deserializer(self.decoded_body) @property def headers(self) -> Dict[str, str]: diff --git a/aws_lambda_powertools/utilities/streaming/compat.py b/aws_lambda_powertools/utilities/streaming/compat.py index 531c7c6e7fa..1b30b3a74f0 100644 --- a/aws_lambda_powertools/utilities/streaming/compat.py +++ b/aws_lambda_powertools/utilities/streaming/compat.py @@ -1,206 +1,4 @@ -""" -Currently, the same as https://github.com/boto/botocore/blob/b9c540905a6c9/botocore/response.py -We created this because the version of StreamingBody included with the Lambda Runtime is too old, and -doesn't support many of the standard IO methods (like readline). +from botocore.response import StreamingBody -As soon as the version of botocore included with the Lambda runtime is equal or greater than 1.29.13, we can drop -this file completely. See https://docs.aws.amazon.com/lambda/latest/dg/lambda-python.html. -""" -import logging -from io import IOBase -from typing import Optional - -import botocore -from botocore import endpoint -from botocore.compat import set_socket_timeout -from botocore.exceptions import ( - IncompleteReadError, - ReadTimeoutError, - ResponseStreamingError, -) -from urllib3.exceptions import ProtocolError as URLLib3ProtocolError -from urllib3.exceptions import ReadTimeoutError as URLLib3ReadTimeoutError - -logger = logging.getLogger(__name__) - -# Splitting the botocore version string into major, minor, and patch versions, -# and performing a conditional check based on the extracted versions. -major, minor, patch = map(int, botocore.__version__.split(".")) - -if major == 1 and (minor < 29 or patch < 13): - - class PowertoolsStreamingBody(IOBase): - """Wrapper class for a HTTP response body. - - This provides a few additional conveniences that do not exist - in the urllib3 model: - * Set the timeout on the socket (i.e read() timeouts) - * Auto validation of content length, if the amount of bytes - we read does not match the content length, an exception - is raised. - """ - - _DEFAULT_CHUNK_SIZE = 1024 - - def __init__(self, raw_stream, content_length): - self._raw_stream = raw_stream - self._content_length = content_length - self._amount_read = 0 - - def __del__(self): - # Extending destructor in order to preserve the underlying raw_stream. - # The ability to add custom cleanup logic introduced in Python3.4+. - # https://www.python.org/dev/peps/pep-0442/ - pass - - def set_socket_timeout(self, timeout): - """Set the timeout seconds on the socket.""" - # The problem we're trying to solve is to prevent .read() calls from - # hanging. This can happen in rare cases. What we'd like to ideally - # do is set a timeout on the .read() call so that callers can retry - # the request. - # Unfortunately, this isn't currently possible in requests. - # See: https://github.com/kennethreitz/requests/issues/1803 - # So what we're going to do is reach into the guts of the stream and - # grab the socket object, which we can set the timeout on. We're - # putting in a check here so in case this interface goes away, we'll - # know. - try: - set_socket_timeout(self._raw_stream, timeout) - except AttributeError: - logger.error( - "Cannot access the socket object of " - "a streaming response. It's possible " - "the interface has changed.", - exc_info=True, - ) - raise - - def readable(self): - try: - return self._raw_stream.readable() - except AttributeError: - return False - - def read(self, amt=None): - """Read at most amt bytes from the stream. - If the amt argument is omitted, read all data. - """ - try: - chunk = self._raw_stream.read(amt) - except URLLib3ReadTimeoutError as e: - raise ReadTimeoutError(endpoint_url=e.url, error=e) - except URLLib3ProtocolError as e: - raise ResponseStreamingError(error=e) - self._amount_read += len(chunk) - if amt is None or (not chunk and amt > 0): - # If the server sends empty contents or - # we ask to read all of the contents, then we know - # we need to verify the content length. - self._verify_content_length() - return chunk - - def readlines(self, hint: Optional[int] = -1): - return self._raw_stream.readlines(hint) - - def __iter__(self): - """Return an iterator to yield 1k chunks from the raw stream.""" - return self.iter_chunks(self._DEFAULT_CHUNK_SIZE) - - def __next__(self): - """Return the next 1k chunk from the raw stream.""" - current_chunk = self.read(self._DEFAULT_CHUNK_SIZE) - if current_chunk: - return current_chunk - raise StopIteration() - - def __enter__(self): - return self._raw_stream - - def __exit__(self, *args): - self._raw_stream.close() - - next = __next__ # noqa: A003, VNE003 - - def iter_lines(self, chunk_size=_DEFAULT_CHUNK_SIZE, keepends=False): - """Return an iterator to yield lines from the raw stream. - This is achieved by reading chunk of bytes (of size chunk_size) at a - time from the raw stream, and then yielding lines from there. - """ - pending = b"" - for chunk in self.iter_chunks(chunk_size): - lines = (pending + chunk).splitlines(True) - for line in lines[:-1]: - yield line.splitlines(keepends)[0] - pending = lines[-1] - if pending: - yield pending.splitlines(keepends)[0] - - def iter_chunks(self, chunk_size=_DEFAULT_CHUNK_SIZE): - """Return an iterator to yield chunks of chunk_size bytes from the raw - stream. - """ - while True: - current_chunk = self.read(chunk_size) - if current_chunk == b"": - break - yield current_chunk - - def _verify_content_length(self): - # See: https://github.com/kennethreitz/requests/issues/1855 - # Basically, our http library doesn't do this for us, so we have - # to do this ourself. - if self._content_length is not None and self._amount_read != int(self._content_length): - raise IncompleteReadError( - actual_bytes=self._amount_read, - expected_bytes=int(self._content_length), - ) - - def tell(self): - return self._raw_stream.tell() - - def close(self): - """Close the underlying http response stream.""" - self._raw_stream.close() - - def convert_to_response_dict(http_response, operation_model): - """Convert an HTTP response object to a request dict. - - This converts the requests library's HTTP response object to - a dictionary. - - :type http_response: botocore.vendored.requests.model.Response - :param http_response: The HTTP response from an AWS service request. - - :rtype: dict - :return: A response dictionary which will contain the following keys: - * headers (dict) - * status_code (int) - * body (string or file-like object) - - """ - response_dict = { - "headers": http_response.headers, - "status_code": http_response.status_code, - "context": { - "operation_name": operation_model.name, - }, - } - if response_dict["status_code"] >= 300: - response_dict["body"] = http_response.content - elif operation_model.has_event_stream_output: - response_dict["body"] = http_response.raw - elif operation_model.has_streaming_output: - length = response_dict["headers"].get("content-length") - response_dict["body"] = PowertoolsStreamingBody(http_response.raw, length) - else: - response_dict["body"] = http_response.content - return response_dict - - # monkey patch boto3 - endpoint.convert_to_response_dict = convert_to_response_dict -else: - from botocore.response import StreamingBody - - # Expose PowertoolsStreamingBody as StreamingBody - vars()["PowertoolsStreamingBody"] = StreamingBody +# aliasing as 3.7 is no longer supported but unsure if anyone took a dependency on it (hyrum's law) +PowertoolsStreamingBody = StreamingBody diff --git a/docs/core/event_handler/appsync.md b/docs/core/event_handler/appsync.md index 789bf788004..fcadc2a1f27 100644 --- a/docs/core/event_handler/appsync.md +++ b/docs/core/event_handler/appsync.md @@ -61,7 +61,7 @@ Here's an example with two separate functions to resolve `getTodo` and `listTodo === "getting_started_graphql_api_resolver.py" - ```python hl_lines="14 21 31 33-34 43 45 53 55 66" + ```python hl_lines="7 15 25 27 28 37 39 47 49 60" --8<-- "examples/event_handler_graphql/src/getting_started_graphql_api_resolver.py" ``` @@ -123,7 +123,7 @@ You can nest `app.resolver()` decorator multiple times when resolving fields wit === "nested_mappings.py" - ```python hl_lines="11 17 27-28 28 30 37" + ```python hl_lines="4 11 21 22 24 31" --8<-- "examples/event_handler_graphql/src/nested_mappings.py" ``` @@ -137,7 +137,7 @@ You can nest `app.resolver()` decorator multiple times when resolving fields wit For Lambda Python3.8+ runtime, this utility supports async functions when you use in conjunction with `asyncio.run`. -```python hl_lines="14 21 31-32 41 43" title="Resolving GraphQL resolvers async" +```python hl_lines="6 15 25 26 35 37" title="Resolving GraphQL resolvers async" --8<-- "examples/event_handler_graphql/src/async_resolvers.py" ``` @@ -162,13 +162,13 @@ Use the following code for `merchantInfo` and `searchMerchant` functions respect === "graphql_transformer_merchant_info.py" - ```python hl_lines="11 13 29-30 34-35 43" + ```python hl_lines="4 7 23 24 29 30 37" --8<-- "examples/event_handler_graphql/src/graphql_transformer_merchant_info.py" ``` === "graphql_transformer_search_merchant.py" - ```python hl_lines="11 13 28-29 43 49" + ```python hl_lines="4 7 22 23 37 43" --8<-- "examples/event_handler_graphql/src/graphql_transformer_search_merchant.py" ``` @@ -196,7 +196,7 @@ You can subclass [AppSyncResolverEvent](../../utilities/data_classes.md#appsync- === "custom_models.py.py" - ```python hl_lines="11 14 32-34 37-38 45 52" + ```python hl_lines="4 8-10 26-28 31 32 39 46" --8<-- "examples/event_handler_graphql/src/custom_models.py" ``` @@ -225,7 +225,7 @@ Let's assume you have `split_operation.py` as your Lambda function entrypoint an We import **Router** instead of **AppSyncResolver**; syntax wise is exactly the same. - ```python hl_lines="11 15 25-26" + ```python hl_lines="4 9 19 20" --8<-- "examples/event_handler_graphql/src/split_operation_module.py" ``` @@ -255,7 +255,7 @@ You can use `append_context` when you want to share data between your App and Ro === "split_route_append_context_module.py" - ```python hl_lines="29" + ```python hl_lines="23" --8<-- "examples/event_handler_graphql/src/split_operation_append_context_module.py" ``` @@ -269,13 +269,13 @@ Here's an example of how you can test your synchronous resolvers: === "assert_graphql_response.py" - ```python hl_lines="6 26 29" + ```python hl_lines="8 28 31" --8<-- "examples/event_handler_graphql/src/assert_graphql_response.py" ``` === "assert_graphql_response_module.py" - ```python hl_lines="17" + ```python hl_lines="11" --8<-- "examples/event_handler_graphql/src/assert_graphql_response_module.py" ``` @@ -298,7 +298,7 @@ And an example for testing asynchronous resolvers. Note that this requires the ` === "assert_async_graphql_response_module.py" - ```python hl_lines="21" + ```python hl_lines="15" --8<-- "examples/event_handler_graphql/src/assert_async_graphql_response_module.py" ``` diff --git a/docs/roadmap.md b/docs/roadmap.md index e42fae21c97..766733c754c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -120,7 +120,7 @@ We want to investigate security and scaling requirements for these special regio ### V3 -With Python 3.7 reaching [end-of-life in AWS Lambda by the end of the year](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html), we want to plan some breaking changes. As always, we plan on having ample notice, a detailed upgrade guide, and keep breaking changes to a minimum to ease transition (e.g., it took ~7 months from v2 to surpass v1 downloads). +We are in the process of planning the roadmap for v3. As always, our approach includes providing sufficient advance notice, a comprehensive upgrade guide, and minimizing breaking changes to facilitate a smooth transition (e.g., it took ~7 months from v2 to surpass v1 downloads). For example, these are on our mind but not settled yet until we have a public tracker to discuss what these means in detail. @@ -128,7 +128,6 @@ For example, these are on our mind but not settled yet until we have a public tr - **Parser**: Deserialize Amazon DynamoDB data types automatically (like Event Source Data Classes) - **Parameters**: Increase default `max_age` for `get_secret` - **Event Source Data Classes**: Return sane defaults for any property that has `Optional[]` returns -- **Python 3.7 EOL**: Update PyPi and Layers to only support 3.8 - **Upgrade tool**: Consider building a CST (Concrete Syntax Tree) tool to ease certain upgrade actions like `pyupgrade` and `django-upgrade` - **Batch**: Stop at first error for Amazon DynamoDB Streams and Amazon Kinesis Data Streams (e.g., `stop_on_failure=True`) diff --git a/docs/upgrade.md b/docs/upgrade.md index d9602da1a53..11d8cdbe83a 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -32,7 +32,7 @@ We've made minimal breaking changes to make your transition to v2 as smooth as p Before you start, we suggest making a copy of your current working project or create a new branch with git. -1. **Upgrade** Python to at least v3.7 +1. **Upgrade** Python to at least v3.8 2. **Ensure** you have the latest version via [Lambda Layer or PyPi](index.md#install){target="_blank"}. 3. **Review** the following sections to confirm whether they affect your code diff --git a/docs/utilities/idempotency.md b/docs/utilities/idempotency.md index bc355580935..17848a7828b 100644 --- a/docs/utilities/idempotency.md +++ b/docs/utilities/idempotency.md @@ -143,7 +143,7 @@ Similar to [idempotent decorator](#idempotent-decorator), you can use `idempoten When using `idempotent_function`, you must tell us which keyword parameter in your function signature has the data we should use via **`data_keyword_argument`**. -!!! tip "We support JSON serializable data, [Python Dataclasses](https://docs.python.org/3.7/library/dataclasses.html){target="_blank" rel="nofollow"}, [Parser/Pydantic Models](parser.md){target="_blank"}, and our [Event Source Data Classes](./data_classes.md){target="_blank"}." +!!! tip "We support JSON serializable data, [Python Dataclasses](https://docs.python.org/3.12/library/dataclasses.html){target="_blank" rel="nofollow"}, [Parser/Pydantic Models](parser.md){target="_blank"}, and our [Event Source Data Classes](./data_classes.md){target="_blank"}." ???+ warning "Limitation" Make sure to call your decorated function using keyword arguments. diff --git a/examples/event_handler_graphql/src/assert_async_graphql_response_module.py b/examples/event_handler_graphql/src/assert_async_graphql_response_module.py index 8ef072a02f7..371eeaa23f8 100644 --- a/examples/event_handler_graphql/src/assert_async_graphql_response_module.py +++ b/examples/event_handler_graphql/src/assert_async_graphql_response_module.py @@ -1,10 +1,3 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - import asyncio from typing import List @@ -13,6 +6,7 @@ from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import AppSyncResolver from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import TypedDict from aws_lambda_powertools.tracing import aiohttp_trace_config from aws_lambda_powertools.utilities.typing import LambdaContext diff --git a/examples/event_handler_graphql/src/assert_graphql_response_module.py b/examples/event_handler_graphql/src/assert_graphql_response_module.py index c7869a587fc..a7cb58c1d98 100644 --- a/examples/event_handler_graphql/src/assert_graphql_response_module.py +++ b/examples/event_handler_graphql/src/assert_graphql_response_module.py @@ -1,15 +1,9 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import AppSyncResolver from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import TypedDict from aws_lambda_powertools.utilities.typing import LambdaContext tracer = Tracer() diff --git a/examples/event_handler_graphql/src/async_resolvers.py b/examples/event_handler_graphql/src/async_resolvers.py index 072f42dbba9..08ecbcba85b 100644 --- a/examples/event_handler_graphql/src/async_resolvers.py +++ b/examples/event_handler_graphql/src/async_resolvers.py @@ -1,11 +1,4 @@ import asyncio -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List import aiohttp @@ -13,6 +6,7 @@ from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import AppSyncResolver from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import TypedDict from aws_lambda_powertools.tracing import aiohttp_trace_config from aws_lambda_powertools.utilities.typing import LambdaContext diff --git a/examples/event_handler_graphql/src/custom_models.py b/examples/event_handler_graphql/src/custom_models.py index 6d82e1ba9be..ae2f0180e15 100644 --- a/examples/event_handler_graphql/src/custom_models.py +++ b/examples/event_handler_graphql/src/custom_models.py @@ -1,15 +1,9 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import AppSyncResolver from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import TypedDict from aws_lambda_powertools.utilities.data_classes.appsync import scalar_types_utils from aws_lambda_powertools.utilities.data_classes.appsync_resolver_event import ( AppSyncResolverEvent, diff --git a/examples/event_handler_graphql/src/getting_started_graphql_api_resolver.py b/examples/event_handler_graphql/src/getting_started_graphql_api_resolver.py index 9edd8c68dad..e960a357d17 100644 --- a/examples/event_handler_graphql/src/getting_started_graphql_api_resolver.py +++ b/examples/event_handler_graphql/src/getting_started_graphql_api_resolver.py @@ -1,10 +1,3 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List import requests @@ -13,6 +6,7 @@ from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import AppSyncResolver from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import TypedDict from aws_lambda_powertools.utilities.data_classes.appsync import scalar_types_utils from aws_lambda_powertools.utilities.typing import LambdaContext diff --git a/examples/event_handler_graphql/src/graphql_transformer_merchant_info.py b/examples/event_handler_graphql/src/graphql_transformer_merchant_info.py index 55f963bb8d5..017528e3481 100644 --- a/examples/event_handler_graphql/src/graphql_transformer_merchant_info.py +++ b/examples/event_handler_graphql/src/graphql_transformer_merchant_info.py @@ -1,15 +1,9 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import AppSyncResolver from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import TypedDict from aws_lambda_powertools.utilities.data_classes.appsync import scalar_types_utils from aws_lambda_powertools.utilities.typing import LambdaContext diff --git a/examples/event_handler_graphql/src/graphql_transformer_search_merchant.py b/examples/event_handler_graphql/src/graphql_transformer_search_merchant.py index 1dd52945f93..9b685a280dd 100644 --- a/examples/event_handler_graphql/src/graphql_transformer_search_merchant.py +++ b/examples/event_handler_graphql/src/graphql_transformer_search_merchant.py @@ -1,15 +1,9 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import AppSyncResolver from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import TypedDict from aws_lambda_powertools.utilities.data_classes.appsync import scalar_types_utils from aws_lambda_powertools.utilities.typing import LambdaContext diff --git a/examples/event_handler_graphql/src/nested_mappings.py b/examples/event_handler_graphql/src/nested_mappings.py index c7869a587fc..a7cb58c1d98 100644 --- a/examples/event_handler_graphql/src/nested_mappings.py +++ b/examples/event_handler_graphql/src/nested_mappings.py @@ -1,15 +1,9 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler import AppSyncResolver from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.shared.types import TypedDict from aws_lambda_powertools.utilities.typing import LambdaContext tracer = Tracer() diff --git a/examples/event_handler_graphql/src/split_operation_append_context_module.py b/examples/event_handler_graphql/src/split_operation_append_context_module.py index e30e345c313..15ed7af1b9e 100644 --- a/examples/event_handler_graphql/src/split_operation_append_context_module.py +++ b/examples/event_handler_graphql/src/split_operation_append_context_module.py @@ -1,14 +1,8 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler.appsync import Router +from aws_lambda_powertools.shared.types import TypedDict tracer = Tracer() logger = Logger() diff --git a/examples/event_handler_graphql/src/split_operation_module.py b/examples/event_handler_graphql/src/split_operation_module.py index 12569bc23bc..e4c7f978b73 100644 --- a/examples/event_handler_graphql/src/split_operation_module.py +++ b/examples/event_handler_graphql/src/split_operation_module.py @@ -1,14 +1,8 @@ -import sys - -if sys.version_info >= (3, 8): - from typing import TypedDict -else: - from typing_extensions import TypedDict - from typing import List from aws_lambda_powertools import Logger, Tracer from aws_lambda_powertools.event_handler.appsync import Router +from aws_lambda_powertools.shared.types import TypedDict tracer = Tracer() logger = Logger() diff --git a/layer/sar/template.txt b/layer/sar/template.txt index fc6ed8892a4..d813a6a77d7 100644 --- a/layer/sar/template.txt +++ b/layer/sar/template.txt @@ -14,7 +14,7 @@ Metadata: SourceCodeUrl: https://github.com/aws-powertools/powertools-lambda-python Transform: AWS::Serverless-2016-10-31 -Description: AWS Lambda Layer for aws-lambda-powertools with python 3.12, 3.11, 3.10, 3.9, 3.8 or 3.7 +Description: AWS Lambda Layer for aws-lambda-powertools with python 3.12, 3.11, 3.10, 3.9 or 3.8 Resources: LambdaLayer: @@ -29,7 +29,6 @@ Resources: - python3.10 - python3.9 - python3.8 - - python3.7 LicenseInfo: 'Available under the Apache-2.0 license.' RetentionPolicy: Retain diff --git a/pyproject.toml b/pyproject.toml index 12b64b23cbd..c0349853aca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT No Attribution License (MIT-0)", "Natural Language :: English", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -39,7 +38,7 @@ license = "MIT" "Releases" = "https://github.com/aws-powertools/powertools-lambda-python/releases" [tool.poetry.dependencies] -python = "^3.7.4" +python = ">=3.8,<4.0.0" aws-xray-sdk = { version = "^2.8.0", optional = true } fastjsonschema = { version = "^2.14.5", optional = true } pydantic = { version = "^1.8.2", optional = true } diff --git a/tests/e2e/idempotency_redis/test_idempotency_redis.py b/tests/e2e/idempotency_redis/test_idempotency_redis.py index 7a8c55374f8..4b5840ac477 100644 --- a/tests/e2e/idempotency_redis/test_idempotency_redis.py +++ b/tests/e2e/idempotency_redis/test_idempotency_redis.py @@ -6,8 +6,6 @@ from tests.e2e.utils import data_fetcher from tests.e2e.utils.data_fetcher.common import GetLambdaResponseOptions, get_lambda_response_in_parallel -pytest.skip(reason="Redis tests disabled until we deprecate Python 3.7.", allow_module_level=True) - @pytest.fixture def ttl_cache_expiration_handler_fn_arn(infrastructure: dict) -> str: diff --git a/tests/e2e/utils/infrastructure.py b/tests/e2e/utils/infrastructure.py index 5adef6133f8..1137fc222a3 100644 --- a/tests/e2e/utils/infrastructure.py +++ b/tests/e2e/utils/infrastructure.py @@ -251,9 +251,7 @@ def _create_temp_cdk_app(self): def _determine_runtime_version(self) -> Runtime: """Determine Python runtime version based on the current Python interpreter""" version = sys.version_info - if version.major == 3 and version.minor == 7: - return Runtime.PYTHON_3_7 - elif version.major == 3 and version.minor == 8: + if version.major == 3 and version.minor == 8: return Runtime.PYTHON_3_8 elif version.major == 3 and version.minor == 9: return Runtime.PYTHON_3_9 diff --git a/tests/functional/event_handler/test_appsync.py b/tests/functional/event_handler/test_appsync.py index 54695eba240..5699e560065 100644 --- a/tests/functional/event_handler/test_appsync.py +++ b/tests/functional/event_handler/test_appsync.py @@ -1,5 +1,4 @@ import asyncio -import sys import pytest @@ -121,7 +120,6 @@ def get_locations(name: str, description: str = ""): assert result2 == "value2description" -@pytest.mark.skipif(sys.version_info < (3, 8), reason="only for python versions that support asyncio.run") def test_resolver_async(): # GIVEN app = AppSyncResolver() diff --git a/tests/functional/idempotency/test_idempotency.py b/tests/functional/idempotency/test_idempotency.py index 905248011e6..f5b441e5e91 100644 --- a/tests/functional/idempotency/test_idempotency.py +++ b/tests/functional/idempotency/test_idempotency.py @@ -1,6 +1,5 @@ import copy import datetime -import sys import warnings from unittest.mock import MagicMock @@ -143,7 +142,6 @@ def lambda_handler(event, context): stubber.deactivate() -@pytest.mark.skipif(sys.version_info < (3, 8), reason="issue with pytest mock lib for < 3.8") @pytest.mark.parametrize("idempotency_config", [{"use_local_cache": True}], indirect=True) def test_idempotent_lambda_in_progress_with_cache( idempotency_config: IdempotencyConfig, @@ -1713,7 +1711,6 @@ def test_invalid_dynamodb_persistence_layer(): assert str(ve.value) == "key_attr [id] and sort_key_attr [id] cannot be the same!" -@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher for dataclasses") def test_idempotent_function_dataclasses(): # Scenario _prepare_data should convert a python dataclasses to a dict dataclasses = get_dataclasses_lib() @@ -1747,7 +1744,6 @@ def test_idempotent_function_other(data): assert _prepare_data(data) == data -@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher for dataclasses") def test_idempotent_function_dataclass_with_jmespath(): # GIVEN dataclasses = get_dataclasses_lib() @@ -1773,7 +1769,6 @@ def collect_payment(payment: Payment): assert result == payment.transaction_id -@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher for dataclasses") def test_idempotent_function_pydantic_with_jmespath(): # GIVEN config = IdempotencyConfig(event_key_jmespath="transaction_id", use_local_cache=True) diff --git a/tests/unit/test_tracing.py b/tests/unit/test_tracing.py index ae140eb2bee..fdb7310495b 100644 --- a/tests/unit/test_tracing.py +++ b/tests/unit/test_tracing.py @@ -1,5 +1,4 @@ import contextlib -import sys from typing import NamedTuple from unittest import mock from unittest.mock import MagicMock @@ -83,9 +82,7 @@ class InSubsegment(NamedTuple): in_subsegment = InSubsegment() in_subsegment.in_subsegment.return_value.__enter__.return_value.put_annotation = in_subsegment.put_annotation in_subsegment.in_subsegment.return_value.__enter__.return_value.put_metadata = in_subsegment.put_metadata - - if sys.version_info >= (3, 8): # 3.8 introduced AsyncMock - in_subsegment.in_subsegment.return_value.__aenter__.return_value.put_metadata = in_subsegment.put_metadata + in_subsegment.in_subsegment.return_value.__aenter__.return_value.put_metadata = in_subsegment.put_metadata yield in_subsegment