Skip to content

Commit 45867fc

Browse files
ran-isenbergRan Isenbergheitorlessa
authored
feat(parameters): accept boto3_client to support private endpoints and ease testing (#1096)
Co-authored-by: Ran Isenberg <ran.isenberg@cyberark.com> Co-authored-by: heitorlessa <lessa@amazon.co.uk>
1 parent 0777858 commit 45867fc

File tree

9 files changed

+443
-109
lines changed

9 files changed

+443
-109
lines changed

aws_lambda_powertools/utilities/parameters/appconfig.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44

55

66
import os
7-
from typing import Any, Dict, Optional, Union
7+
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
88
from uuid import uuid4
99

1010
import boto3
1111
from botocore.config import Config
1212

13+
if TYPE_CHECKING:
14+
from mypy_boto3_appconfig import AppConfigClient
15+
1316
from ...shared import constants
1417
from ...shared.functions import resolve_env_var_choice
1518
from .base import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, BaseProvider
@@ -30,7 +33,9 @@ class AppConfigProvider(BaseProvider):
3033
config: botocore.config.Config, optional
3134
Botocore configuration to pass during client initialization
3235
boto3_session : boto3.session.Session, optional
33-
Boto3 session to use for AWS API communication
36+
Boto3 session to create a boto3_client from
37+
boto3_client: AppConfigClient, optional
38+
Boto3 AppConfig Client to use, boto3_session will be ignored if both are provided
3439
3540
Example
3641
-------
@@ -68,22 +73,24 @@ def __init__(
6873
application: Optional[str] = None,
6974
config: Optional[Config] = None,
7075
boto3_session: Optional[boto3.session.Session] = None,
76+
boto3_client: Optional["AppConfigClient"] = None,
7177
):
7278
"""
7379
Initialize the App Config client
7480
"""
7581

76-
config = config or Config()
77-
session = boto3_session or boto3.session.Session()
78-
self.client = session.client("appconfig", config=config)
82+
super().__init__()
83+
84+
self.client: "AppConfigClient" = self._build_boto3_client(
85+
service_name="appconfig", client=boto3_client, session=boto3_session, config=config
86+
)
87+
7988
self.application = resolve_env_var_choice(
8089
choice=application, env=os.getenv(constants.SERVICE_NAME_ENV, "service_undefined")
8190
)
8291
self.environment = environment
8392
self.current_version = ""
8493

85-
super().__init__()
86-
8794
def _get(self, name: str, **sdk_options) -> str:
8895
"""
8996
Retrieve a parameter value from AWS App config.

aws_lambda_powertools/utilities/parameters/base.py

+78-1
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,28 @@
77
from abc import ABC, abstractmethod
88
from collections import namedtuple
99
from datetime import datetime, timedelta
10-
from typing import Any, Dict, Optional, Tuple, Union
10+
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union
11+
12+
import boto3
13+
from botocore.config import Config
1114

1215
from .exceptions import GetParameterError, TransformParameterError
1316

17+
if TYPE_CHECKING:
18+
from mypy_boto3_appconfig import AppConfigClient
19+
from mypy_boto3_dynamodb import DynamoDBServiceResource
20+
from mypy_boto3_secretsmanager import SecretsManagerClient
21+
from mypy_boto3_ssm import SSMClient
22+
23+
1424
DEFAULT_MAX_AGE_SECS = 5
1525
ExpirableValue = namedtuple("ExpirableValue", ["value", "ttl"])
1626
# These providers will be dynamically initialized on first use of the helper functions
1727
DEFAULT_PROVIDERS: Dict[str, Any] = {}
1828
TRANSFORM_METHOD_JSON = "json"
1929
TRANSFORM_METHOD_BINARY = "binary"
2030
SUPPORTED_TRANSFORM_METHODS = [TRANSFORM_METHOD_JSON, TRANSFORM_METHOD_BINARY]
31+
ParameterClients = Union["AppConfigClient", "SecretsManagerClient", "SSMClient"]
2132

2233

2334
class BaseProvider(ABC):
@@ -180,6 +191,72 @@ def _get_multiple(self, path: str, **sdk_options) -> Dict[str, str]:
180191
def clear_cache(self):
181192
self.store.clear()
182193

194+
@staticmethod
195+
def _build_boto3_client(
196+
service_name: str,
197+
client: Optional[ParameterClients] = None,
198+
session: Optional[Type[boto3.Session]] = None,
199+
config: Optional[Type[Config]] = None,
200+
) -> Type[ParameterClients]:
201+
"""Builds a low level boto3 client with session and config provided
202+
203+
Parameters
204+
----------
205+
service_name : str
206+
AWS service name to instantiate a boto3 client, e.g. ssm
207+
client : Optional[ParameterClients], optional
208+
boto3 client instance, by default None
209+
session : Optional[Type[boto3.Session]], optional
210+
boto3 session instance, by default None
211+
config : Optional[Type[Config]], optional
212+
botocore config instance to configure client with, by default None
213+
214+
Returns
215+
-------
216+
Type[ParameterClients]
217+
Instance of a boto3 client for Parameters feature (e.g., ssm, appconfig, secretsmanager, etc.)
218+
"""
219+
if client is not None:
220+
return client
221+
222+
session = session or boto3.Session()
223+
config = config or Config()
224+
return session.client(service_name=service_name, config=config)
225+
226+
# maintenance: change DynamoDBServiceResource type to ParameterResourceClients when we expand
227+
@staticmethod
228+
def _build_boto3_resource_client(
229+
service_name: str,
230+
client: Optional["DynamoDBServiceResource"] = None,
231+
session: Optional[Type[boto3.Session]] = None,
232+
config: Optional[Type[Config]] = None,
233+
endpoint_url: Optional[str] = None,
234+
) -> "DynamoDBServiceResource":
235+
"""Builds a high level boto3 resource client with session, config and endpoint_url provided
236+
237+
Parameters
238+
----------
239+
service_name : str
240+
AWS service name to instantiate a boto3 client, e.g. ssm
241+
client : Optional[DynamoDBServiceResource], optional
242+
boto3 client instance, by default None
243+
session : Optional[Type[boto3.Session]], optional
244+
boto3 session instance, by default None
245+
config : Optional[Type[Config]], optional
246+
botocore config instance to configure client, by default None
247+
248+
Returns
249+
-------
250+
Type[DynamoDBServiceResource]
251+
Instance of a boto3 resource client for Parameters feature (e.g., dynamodb, etc.)
252+
"""
253+
if client is not None:
254+
return client
255+
256+
session = session or boto3.Session()
257+
config = config or Config()
258+
return session.resource(service_name=service_name, config=config, endpoint_url=endpoint_url)
259+
183260

184261
def get_transform_method(key: str, transform: Optional[str] = None) -> Optional[str]:
185262
"""

aws_lambda_powertools/utilities/parameters/dynamodb.py

+22-9
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
"""
44

55

6-
from typing import Dict, Optional
6+
from typing import TYPE_CHECKING, Dict, Optional
77

88
import boto3
99
from boto3.dynamodb.conditions import Key
1010
from botocore.config import Config
1111

1212
from .base import BaseProvider
1313

14+
if TYPE_CHECKING:
15+
from mypy_boto3_dynamodb import DynamoDBServiceResource
16+
from mypy_boto3_dynamodb.service_resource import Table
17+
1418

1519
class DynamoDBProvider(BaseProvider):
1620
"""
@@ -31,7 +35,9 @@ class DynamoDBProvider(BaseProvider):
3135
config: botocore.config.Config, optional
3236
Botocore configuration to pass during client initialization
3337
boto3_session : boto3.session.Session, optional
34-
Boto3 session to use for AWS API communication
38+
Boto3 session to create a boto3_client from
39+
boto3_client: DynamoDBServiceResource, optional
40+
Boto3 DynamoDB Resource Client to use; boto3_session will be ignored if both are provided
3541
3642
Example
3743
-------
@@ -152,15 +158,18 @@ def __init__(
152158
endpoint_url: Optional[str] = None,
153159
config: Optional[Config] = None,
154160
boto3_session: Optional[boto3.session.Session] = None,
161+
boto3_client: Optional["DynamoDBServiceResource"] = None,
155162
):
156163
"""
157164
Initialize the DynamoDB client
158165
"""
159-
160-
config = config or Config()
161-
session = boto3_session or boto3.session.Session()
162-
163-
self.table = session.resource("dynamodb", endpoint_url=endpoint_url, config=config).Table(table_name)
166+
self.table: "Table" = self._build_boto3_resource_client(
167+
service_name="dynamodb",
168+
client=boto3_client,
169+
session=boto3_session,
170+
config=config,
171+
endpoint_url=endpoint_url,
172+
).Table(table_name)
164173

165174
self.key_attr = key_attr
166175
self.sort_attr = sort_attr
@@ -183,7 +192,9 @@ def _get(self, name: str, **sdk_options) -> str:
183192
# Explicit arguments will take precedence over keyword arguments
184193
sdk_options["Key"] = {self.key_attr: name}
185194

186-
return self.table.get_item(**sdk_options)["Item"][self.value_attr]
195+
# maintenance: look for better ways to correctly type DynamoDB multiple return types
196+
# without a breaking change within ABC return type
197+
return self.table.get_item(**sdk_options)["Item"][self.value_attr] # type: ignore[return-value]
187198

188199
def _get_multiple(self, path: str, **sdk_options) -> Dict[str, str]:
189200
"""
@@ -209,4 +220,6 @@ def _get_multiple(self, path: str, **sdk_options) -> Dict[str, str]:
209220
response = self.table.query(**sdk_options)
210221
items.extend(response.get("Items", []))
211222

212-
return {item[self.sort_attr]: item[self.value_attr] for item in items}
223+
# maintenance: look for better ways to correctly type DynamoDB multiple return types
224+
# without a breaking change within ABC return type
225+
return {item[self.sort_attr]: item[self.value_attr] for item in items} # type: ignore[misc]

aws_lambda_powertools/utilities/parameters/secrets.py

+17-7
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
"""
44

55

6-
from typing import Any, Dict, Optional, Union
6+
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
77

88
import boto3
99
from botocore.config import Config
1010

11+
if TYPE_CHECKING:
12+
from mypy_boto3_secretsmanager import SecretsManagerClient
13+
1114
from .base import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, BaseProvider
1215

1316

@@ -20,7 +23,9 @@ class SecretsProvider(BaseProvider):
2023
config: botocore.config.Config, optional
2124
Botocore configuration to pass during client initialization
2225
boto3_session : boto3.session.Session, optional
23-
Boto3 session to use for AWS API communication
26+
Boto3 session to create a boto3_client from
27+
boto3_client: SecretsManagerClient, optional
28+
Boto3 SecretsManager Client to use, boto3_session will be ignored if both are provided
2429
2530
Example
2631
-------
@@ -60,17 +65,22 @@ class SecretsProvider(BaseProvider):
6065

6166
client: Any = None
6267

63-
def __init__(self, config: Optional[Config] = None, boto3_session: Optional[boto3.session.Session] = None):
68+
def __init__(
69+
self,
70+
config: Optional[Config] = None,
71+
boto3_session: Optional[boto3.session.Session] = None,
72+
boto3_client: Optional["SecretsManagerClient"] = None,
73+
):
6474
"""
6575
Initialize the Secrets Manager client
6676
"""
6777

68-
config = config or Config()
69-
session = boto3_session or boto3.session.Session()
70-
self.client = session.client("secretsmanager", config=config)
71-
7278
super().__init__()
7379

80+
self.client: "SecretsManagerClient" = self._build_boto3_client(
81+
service_name="secretsmanager", client=boto3_client, session=boto3_session, config=config
82+
)
83+
7484
def _get(self, name: str, **sdk_options) -> str:
7585
"""
7686
Retrieve a parameter value from AWS Systems Manager Parameter Store

aws_lambda_powertools/utilities/parameters/ssm.py

+17-7
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33
"""
44

55

6-
from typing import Any, Dict, Optional, Union
6+
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
77

88
import boto3
99
from botocore.config import Config
1010

1111
from .base import DEFAULT_MAX_AGE_SECS, DEFAULT_PROVIDERS, BaseProvider
1212

13+
if TYPE_CHECKING:
14+
from mypy_boto3_ssm import SSMClient
15+
1316

1417
class SSMProvider(BaseProvider):
1518
"""
@@ -20,7 +23,9 @@ class SSMProvider(BaseProvider):
2023
config: botocore.config.Config, optional
2124
Botocore configuration to pass during client initialization
2225
boto3_session : boto3.session.Session, optional
23-
Boto3 session to use for AWS API communication
26+
Boto3 session to create a boto3_client from
27+
boto3_client: SSMClient, optional
28+
Boto3 SSM Client to use, boto3_session will be ignored if both are provided
2429
2530
Example
2631
-------
@@ -76,17 +81,22 @@ class SSMProvider(BaseProvider):
7681

7782
client: Any = None
7883

79-
def __init__(self, config: Optional[Config] = None, boto3_session: Optional[boto3.session.Session] = None):
84+
def __init__(
85+
self,
86+
config: Optional[Config] = None,
87+
boto3_session: Optional[boto3.session.Session] = None,
88+
boto3_client: Optional["SSMClient"] = None,
89+
):
8090
"""
8191
Initialize the SSM Parameter Store client
8292
"""
8393

84-
config = config or Config()
85-
session = boto3_session or boto3.session.Session()
86-
self.client = session.client("ssm", config=config)
87-
8894
super().__init__()
8995

96+
self.client: "SSMClient" = self._build_boto3_client(
97+
service_name="ssm", client=boto3_client, session=boto3_session, config=config
98+
)
99+
90100
# We break Liskov substitution principle due to differences in signatures of this method and superclass get method
91101
# We ignore mypy error, as changes to the signature here or in a superclass is a breaking change to users
92102
def get( # type: ignore[override]

0 commit comments

Comments
 (0)