Skip to content

Commit d30ae2e

Browse files
authored
Merge branch 'develop' into feat/request-object-middleware-access
2 parents 2179577 + ca7d49e commit d30ae2e

22 files changed

Lines changed: 796 additions & 23 deletions

.github/workflows/quality_check.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jobs:
4343
quality_check:
4444
runs-on: ubuntu-latest
4545
strategy:
46+
fail-fast: false
4647
max-parallel: 5
4748
matrix:
4849
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

aws_lambda_powertools/shared/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,12 @@
6767
PRETTY_INDENT: int = 4
6868
COMPACT_INDENT: None = None
6969

70+
# Metadata constants
71+
LAMBDA_METADATA_API_ENV: str = "AWS_LAMBDA_METADATA_API"
72+
LAMBDA_METADATA_TOKEN_ENV: str = "AWS_LAMBDA_METADATA_TOKEN"
73+
METADATA_API_VERSION: str = "2026-01-15"
74+
METADATA_PATH: str = "/metadata/execution-environment"
75+
METADATA_DEFAULT_TIMEOUT_SECS: float = 1.0
76+
7077
# Idempotency constants
7178
IDEMPOTENCY_DISABLED_ENV: str = "POWERTOOLS_IDEMPOTENCY_DISABLED"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Exposes version constant to avoid circular dependencies."""
22

3-
VERSION = "3.25.0"
3+
VERSION = "3.26.0"

aws_lambda_powertools/utilities/data_classes/api_gateway_authorizer_event.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
DictWrapper,
1515
)
1616
from aws_lambda_powertools.utilities.data_classes.shared_functions import (
17-
get_header_value,
17+
get_header_value, # ty: ignore[deprecated]
1818
)
1919
from aws_lambda_powertools.warnings import PowertoolsDeprecationWarning
2020

@@ -232,7 +232,7 @@ def get_header_value(
232232
category=PowertoolsDeprecationWarning,
233233
stacklevel=2,
234234
)
235-
return get_header_value(self.headers, name, default_value, case_sensitive)
235+
return get_header_value(self.headers, name, default_value, case_sensitive) # ty: ignore[deprecated]
236236

237237

238238
class APIGatewayAuthorizerEventV2(DictWrapper):
@@ -358,7 +358,7 @@ def get_header_value(
358358
category=PowertoolsDeprecationWarning,
359359
stacklevel=2,
360360
)
361-
return get_header_value(self.headers, name, default_value, case_sensitive)
361+
return get_header_value(self.headers, name, default_value, case_sensitive) # ty: ignore[deprecated]
362362

363363

364364
class APIGatewayAuthorizerResponseV2:
@@ -691,12 +691,12 @@ def _add_route(self, effect: str, resource: str, conditions: list[dict] | None =
691691
self._deny_routes.append(route)
692692

693693
@override
694-
def allow_all_routes(self):
694+
def allow_all_routes(self, http_method: str = HttpVerb.ALL.value): # type: ignore[override] # noqa: ARG002
695695
"""Adds a '*' allow to the policy to authorize access to all methods of an API"""
696696
self._add_route(effect="Allow", resource="*")
697697

698698
@override
699-
def deny_all_routes(self):
699+
def deny_all_routes(self, http_method: str = HttpVerb.ALL.value): # type: ignore[override] # noqa: ARG002
700700
"""Adds a '*' allow to the policy to deny access to all methods of an API"""
701701

702702
self._add_route(effect="Deny", resource="*")

aws_lambda_powertools/utilities/data_classes/appsync/scalar_types_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def aws_date(timezone_offset: int = 0) -> str:
5555
str
5656
Returns current time as AWSDate scalar string with optional timezone offset
5757
"""
58-
return _formatted_time(datetime.datetime.utcnow(), "%Y-%m-%d", timezone_offset)
58+
return _formatted_time(datetime.datetime.now(datetime.timezone.utc), "%Y-%m-%d", timezone_offset)
5959

6060

6161
def aws_time(timezone_offset: int = 0) -> str:
@@ -71,7 +71,7 @@ def aws_time(timezone_offset: int = 0) -> str:
7171
str
7272
Returns current time as AWSTime scalar string with optional timezone offset
7373
"""
74-
return _formatted_time(datetime.datetime.utcnow(), "%H:%M:%S.%f", timezone_offset)
74+
return _formatted_time(datetime.datetime.now(datetime.timezone.utc), "%H:%M:%S.%f", timezone_offset)
7575

7676

7777
def aws_datetime(timezone_offset: int = 0) -> str:
@@ -87,7 +87,7 @@ def aws_datetime(timezone_offset: int = 0) -> str:
8787
str
8888
Returns current time as AWSDateTime scalar string with optional timezone offset
8989
"""
90-
return _formatted_time(datetime.datetime.utcnow(), "%Y-%m-%dT%H:%M:%S.%f", timezone_offset)
90+
return _formatted_time(datetime.datetime.now(datetime.timezone.utc), "%Y-%m-%dT%H:%M:%S.%f", timezone_offset)
9191

9292

9393
def aws_timestamp() -> int:

aws_lambda_powertools/utilities/data_classes/appsync_resolver_event.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from aws_lambda_powertools.utilities.data_classes.common import CaseInsensitiveDict, DictWrapper
99
from aws_lambda_powertools.utilities.data_classes.shared_functions import (
10-
get_header_value,
10+
get_header_value, # ty: ignore[deprecated]
1111
)
1212
from aws_lambda_powertools.warnings import PowertoolsDeprecationWarning
1313

@@ -275,4 +275,4 @@ def get_header_value(
275275
category=PowertoolsDeprecationWarning,
276276
stacklevel=2,
277277
)
278-
return get_header_value(self.request_headers, name, default_value, case_sensitive)
278+
return get_header_value(self.request_headers, name, default_value, case_sensitive) # ty: ignore[deprecated]

aws_lambda_powertools/utilities/data_classes/common.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from aws_lambda_powertools.shared.headers_serializer import BaseHeadersSerializer
2424

2525
from aws_lambda_powertools.utilities.data_classes.shared_functions import (
26-
get_header_value,
26+
get_header_value, # ty: ignore[deprecated]
2727
get_multi_value_query_string_values,
2828
get_query_string_value,
2929
)
@@ -39,8 +39,8 @@ def __init__(self, data=None, **kwargs):
3939
def get(self, k, default=None):
4040
return super().get(k.lower(), default)
4141

42-
def pop(self, k):
43-
return super().pop(k.lower())
42+
def pop(self, k, *args):
43+
return super().pop(k.lower(), *args)
4444

4545
def setdefault(self, k, default=None):
4646
return super().setdefault(k.lower(), default)
@@ -125,7 +125,7 @@ def _str_helper(self) -> dict[str, Any]:
125125
properties = self._properties()
126126
sensitive_properties = ["raw_event"]
127127
if hasattr(self, "_sensitive_properties"):
128-
sensitive_properties.extend(self._sensitive_properties) # pyright: ignore
128+
sensitive_properties.extend(self._sensitive_properties) # pyright: ignore # type: ignore[arg-type]
129129

130130
result: dict[str, Any] = {}
131131
for property_key in properties:
@@ -142,7 +142,7 @@ def _str_helper(self) -> dict[str, Any]:
142142
# Checks if the key is a list and if it is a subclass of the parent class
143143
elif isinstance(property_value, list):
144144
for seq, item in enumerate(property_value):
145-
if issubclass(item.__class__, DictWrapper):
145+
if issubclass(item.__class__, DictWrapper) and isinstance(item, DictWrapper):
146146
result[property_key][seq] = item._str_helper()
147147
except Exception:
148148
result[property_key] = "[Cannot be deserialized]"
@@ -331,7 +331,7 @@ def get_header_value(
331331
category=PowertoolsDeprecationWarning,
332332
stacklevel=2,
333333
)
334-
return get_header_value(
334+
return get_header_value( # ty: ignore[deprecated]
335335
headers=self.headers,
336336
name=name,
337337
default_value=default_value,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""
2+
Utility to fetch data from the AWS Lambda Metadata Endpoint
3+
"""
4+
5+
from aws_lambda_powertools.utilities.metadata.exceptions import LambdaMetadataError
6+
from aws_lambda_powertools.utilities.metadata.lambda_metadata import (
7+
LambdaMetadata,
8+
clear_metadata_cache,
9+
get_lambda_metadata,
10+
)
11+
12+
__all__ = [
13+
"LambdaMetadata",
14+
"LambdaMetadataError",
15+
"get_lambda_metadata",
16+
"clear_metadata_cache",
17+
]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""
2+
Lambda Metadata Service exceptions
3+
"""
4+
5+
6+
class LambdaMetadataError(Exception):
7+
"""Raised when the Lambda Metadata Endpoint is unavailable or returns an error."""
8+
9+
def __init__(self, message: str, status_code: int = -1):
10+
self.status_code = status_code
11+
super().__init__(message)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""
2+
Lambda Metadata Service client
3+
4+
Fetches execution environment metadata from the Lambda Metadata Endpoint,
5+
with caching for the sandbox lifetime.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
import os
12+
import urllib.request
13+
from dataclasses import dataclass, field
14+
from json import JSONDecodeError
15+
from json import loads as json_loads
16+
from typing import Any
17+
18+
from aws_lambda_powertools.shared.constants import (
19+
LAMBDA_INITIALIZATION_TYPE,
20+
LAMBDA_METADATA_API_ENV,
21+
LAMBDA_METADATA_TOKEN_ENV,
22+
METADATA_API_VERSION,
23+
METADATA_DEFAULT_TIMEOUT_SECS,
24+
METADATA_PATH,
25+
POWERTOOLS_DEV_ENV,
26+
)
27+
from aws_lambda_powertools.utilities.metadata.exceptions import LambdaMetadataError
28+
29+
logger = logging.getLogger(__name__)
30+
31+
_cache: dict[str, Any] = {}
32+
33+
34+
@dataclass(frozen=True)
35+
class LambdaMetadata:
36+
"""Lambda execution environment metadata returned by the metadata endpoint."""
37+
38+
availability_zone_id: str | None = None
39+
"""The Availability Zone ID where the function is executing (e.g. ``use1-az1``)."""
40+
41+
_raw: dict[str, Any] = field(default_factory=dict, repr=False)
42+
"""Full raw response for forward-compatibility with future fields."""
43+
44+
45+
def _is_lambda_environment() -> bool:
46+
"""Check whether we are running inside a Lambda execution environment."""
47+
return os.environ.get(LAMBDA_INITIALIZATION_TYPE, "") != ""
48+
49+
50+
def _is_dev_mode() -> bool:
51+
"""Check whether POWERTOOLS_DEV is enabled."""
52+
return os.environ.get(POWERTOOLS_DEV_ENV, "false").strip().lower() in ("true", "1")
53+
54+
55+
def _build_metadata(data: dict[str, Any]) -> LambdaMetadata:
56+
"""Build a LambdaMetadata dataclass from the raw endpoint response."""
57+
return LambdaMetadata(
58+
availability_zone_id=data.get("AvailabilityZoneID"),
59+
_raw=data,
60+
)
61+
62+
63+
def _fetch_metadata(timeout: float = METADATA_DEFAULT_TIMEOUT_SECS) -> dict[str, Any]:
64+
"""
65+
Fetch metadata from the Lambda Metadata Endpoint via HTTP.
66+
67+
Parameters
68+
----------
69+
timeout : float
70+
Request timeout in seconds.
71+
72+
Returns
73+
-------
74+
dict[str, Any]
75+
Parsed JSON response from the metadata endpoint.
76+
77+
Raises
78+
------
79+
LambdaMetadataError
80+
If required environment variables are missing, the endpoint returns
81+
a non-200 status, or the response cannot be parsed.
82+
"""
83+
api = os.environ.get(LAMBDA_METADATA_API_ENV)
84+
token = os.environ.get(LAMBDA_METADATA_TOKEN_ENV)
85+
86+
if not api:
87+
raise LambdaMetadataError(
88+
f"Environment variable {LAMBDA_METADATA_API_ENV} is not set. Ensure {LAMBDA_METADATA_API_ENV} is set.",
89+
)
90+
if not token:
91+
raise LambdaMetadataError(
92+
f"Environment variable {LAMBDA_METADATA_TOKEN_ENV} is not set. Ensure {LAMBDA_METADATA_TOKEN_ENV} is set.",
93+
)
94+
95+
url = f"http://{api}/{METADATA_API_VERSION}{METADATA_PATH}"
96+
logger.debug("Fetching Lambda metadata from: %s", url)
97+
98+
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
99+
100+
try:
101+
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec B310
102+
status = resp.status
103+
body = resp.read().decode("utf-8")
104+
except urllib.error.HTTPError as exc:
105+
raise LambdaMetadataError(
106+
f"Metadata request failed with status {exc.code}",
107+
status_code=exc.code,
108+
) from exc
109+
except Exception as exc:
110+
raise LambdaMetadataError(f"Failed to fetch Lambda metadata: {exc}") from exc
111+
112+
if status != 200:
113+
raise LambdaMetadataError(
114+
f"Metadata request failed with status {status}",
115+
status_code=status,
116+
)
117+
118+
try:
119+
data: dict[str, Any] = json_loads(body)
120+
except (JSONDecodeError, TypeError) as exc:
121+
raise LambdaMetadataError(f"Failed to parse metadata response: {exc}") from exc
122+
123+
logger.debug("Lambda metadata response: %s", data)
124+
return data
125+
126+
127+
def get_lambda_metadata(*, timeout: float = METADATA_DEFAULT_TIMEOUT_SECS) -> LambdaMetadata:
128+
"""
129+
Retrieve Lambda execution environment metadata.
130+
131+
Returns cached metadata on subsequent calls. When not running in a Lambda
132+
environment (local dev, tests) or when ``POWERTOOLS_DEV`` is enabled,
133+
returns an empty ``LambdaMetadata``.
134+
135+
Parameters
136+
----------
137+
timeout : float
138+
HTTP request timeout in seconds (default 1.0).
139+
140+
Returns
141+
-------
142+
LambdaMetadata
143+
Metadata about the current execution environment.
144+
145+
Raises
146+
------
147+
LambdaMetadataError
148+
If the metadata endpoint is unavailable or returns an error.
149+
150+
Example
151+
-------
152+
>>> from aws_lambda_powertools.utilities.metadata import get_lambda_metadata
153+
>>> metadata = get_lambda_metadata()
154+
>>> metadata.availability_zone_id # e.g. "use1-az1"
155+
"""
156+
if _is_dev_mode() or not _is_lambda_environment():
157+
return LambdaMetadata()
158+
159+
if _cache:
160+
return _build_metadata(_cache)
161+
162+
data = _fetch_metadata(timeout=timeout)
163+
_cache.update(data)
164+
return _build_metadata(_cache)
165+
166+
167+
def clear_metadata_cache() -> None:
168+
"""
169+
Clear the cached metadata.
170+
171+
Useful for testing or when you need to force a fresh fetch
172+
(e.g. after SnapStart restore).
173+
"""
174+
_cache.clear()

0 commit comments

Comments
 (0)