Skip to content

Commit 4226897

Browse files
committed
Feat: breaking change to adapt to pydantic 2.0 and remove dependency on secret file to setup an ssm prefix
1 parent aa0c65f commit 4226897

File tree

7 files changed

+2229
-1640
lines changed

7 files changed

+2229
-1640
lines changed

poetry.lock

+1,949-1,485
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pydantic_ssm_settings/__init__.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from .settings import AwsSsmSourceConfig
1+
from .settings import AwsSsmSourceConfig, SsmSettingsConfigDict
2+
from .source import AwsSsmSettingsSource
23

3-
__all__ = ("AwsSsmSourceConfig",)
4+
__all__ = ("AwsSsmSourceConfig", "SsmSettingsConfigDict", "AwsSsmSettingsSource")
45
__version__ = "0.2.4"

pydantic_ssm_settings/settings.py

+38-16
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,61 @@
11
import logging
2-
from typing import Tuple
2+
from typing import Any, Tuple, Type
33

4-
from pydantic.env_settings import (
4+
from pydantic_settings import (
5+
BaseSettings,
56
EnvSettingsSource,
67
InitSettingsSource,
8+
PydanticBaseSettingsSource,
79
SecretsSettingsSource,
8-
SettingsSourceCallable,
10+
SettingsConfigDict,
911
)
1012

1113
from .source import AwsSsmSettingsSource
1214

1315
logger = logging.getLogger(__name__)
1416

1517

16-
class AwsSsmSourceConfig:
17-
@classmethod
18-
def customise_sources(
19-
cls,
18+
class SsmSettingsConfigDict(SettingsConfigDict):
19+
ssm_prefix: str
20+
21+
22+
class BaseSettingsSsmWrapper(BaseSettings):
23+
"""
24+
Wrapper to store the _ssm_prefix parameter as an instanc attribute.
25+
Need a direct access to the attributes dictionary to avoid raising an AttributeError:
26+
__pydantic_private__ exception
27+
"""
28+
29+
def __init__(self, *args, _ssm_prefix: str = None, **kwargs: Any) -> None:
30+
"""
31+
Args:
32+
_ssm_prefix: Prefix for all ssm parameters. Must be an absolute path,
33+
separated by "/". NB:unlike its _env_prefix counterpart, _ssm_prefix
34+
is treated case sensitively regardless of the _case_sensitive
35+
parameter value.
36+
"""
37+
self.__dict__["__ssm_prefix"] = _ssm_prefix
38+
super().__init__(self, *args, **kwargs)
39+
40+
41+
class AwsSsmSourceConfig(BaseSettingsSsmWrapper):
42+
def settings_customise_sources(
43+
self,
44+
settings_cls: Type[BaseSettings],
2045
init_settings: InitSettingsSource,
2146
env_settings: EnvSettingsSource,
47+
dotenv_settings: PydanticBaseSettingsSource,
2248
file_secret_settings: SecretsSettingsSource,
23-
) -> Tuple[SettingsSourceCallable, ...]:
24-
49+
) -> Tuple[PydanticBaseSettingsSource, ...]:
2550
ssm_settings = AwsSsmSettingsSource(
26-
ssm_prefix=file_secret_settings.secrets_dir,
27-
env_nested_delimiter=env_settings.env_nested_delimiter,
51+
settings_cls=settings_cls,
52+
ssm_prefix=self.__dict__["__ssm_prefix"],
2853
)
2954

3055
return (
3156
init_settings,
3257
env_settings,
33-
# Usurping the `secrets_dir` arg. We can't expect any other args to
34-
# be passed to # the Settings module because Pydantic will complain
35-
# about unexpected arguments. `secrets_dir` comes from `_secrets_dir`,
36-
# one of the few special kwargs that Pydantic will allow:
37-
# https://github.com/samuelcolvin/pydantic/blob/45db4ad3aa558879824a91dd3b011d0449eb2977/pydantic/env_settings.py#L33
58+
dotenv_settings,
59+
file_secret_settings,
3860
ssm_settings,
3961
)

pydantic_ssm_settings/source.py

+109-114
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
import os
1+
from __future__ import annotations as _annotations
2+
23
import logging
4+
import os
35
from pathlib import Path
4-
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Tuple
6+
from typing import TYPE_CHECKING, Any
57

6-
from botocore.exceptions import ClientError
7-
from botocore.client import Config
88
import boto3
9-
10-
from pydantic import BaseSettings
11-
from pydantic.typing import StrPath, get_origin, is_union
12-
from pydantic.utils import deep_update
13-
from pydantic.fields import ModelField
9+
from botocore.client import Config
10+
from botocore.exceptions import ClientError
11+
from pydantic import BaseModel
12+
from pydantic._internal._utils import lenient_issubclass
13+
from pydantic.fields import FieldInfo
14+
from pydantic_settings import BaseSettings
15+
from pydantic_settings.sources import (
16+
EnvSettingsSource,
17+
)
1418

1519
if TYPE_CHECKING:
1620
from mypy_boto3_ssm.client import SSMClient
@@ -23,16 +27,28 @@ class SettingsError(ValueError):
2327
pass
2428

2529

26-
class AwsSsmSettingsSource:
27-
__slots__ = ("ssm_prefix", "env_nested_delimiter")
28-
30+
class AwsSsmSettingsSource(EnvSettingsSource):
2931
def __init__(
3032
self,
31-
ssm_prefix: Optional[StrPath],
32-
env_nested_delimiter: Optional[str] = None,
33+
settings_cls: type[BaseSettings],
34+
case_sensitive: bool = None,
35+
ssm_prefix: str = None,
3336
):
34-
self.ssm_prefix: Optional[StrPath] = ssm_prefix
35-
self.env_nested_delimiter: Optional[str] = env_nested_delimiter
37+
# Ideally would retrieve ssm_prefix from self.config
38+
# but need the superclass to be initialized for that
39+
ssm_prefix_ = (
40+
ssm_prefix
41+
if ssm_prefix is not None
42+
else settings_cls.model_config.get("ssm_prefix", "/")
43+
)
44+
super().__init__(
45+
settings_cls,
46+
case_sensitive=case_sensitive,
47+
env_prefix=ssm_prefix_,
48+
env_nested_delimiter="/", # SSM only accepts / as a delimiter
49+
)
50+
self.ssm_prefix = ssm_prefix_
51+
assert self.ssm_prefix == self.env_prefix
3652

3753
@property
3854
def client(self) -> "SSMClient":
@@ -43,124 +59,103 @@ def client_config(self) -> Config:
4359
timeout = float(os.environ.get("SSM_TIMEOUT", 0.5))
4460
return Config(connect_timeout=timeout, read_timeout=timeout)
4561

46-
def load_from_ssm(self, secrets_path: Path, case_sensitive: bool):
47-
48-
if not secrets_path.is_absolute():
62+
def _load_env_vars(
63+
self,
64+
):
65+
"""
66+
Access env_prefix instead of ssm_prefix
67+
"""
68+
if not Path(self.env_prefix).is_absolute():
4969
raise ValueError("SSM prefix must be absolute path")
5070

51-
logger.debug(f"Building SSM settings with prefix of {secrets_path=}")
71+
logger.debug(f"Building SSM settings with prefix of {self.env_prefix=}")
5272

5373
output = {}
5474
try:
5575
paginator = self.client.get_paginator("get_parameters_by_path")
5676
response_iterator = paginator.paginate(
57-
Path=str(secrets_path), WithDecryption=True
77+
Path=self.env_prefix, WithDecryption=True, Recursive=True
5878
)
5979

6080
for page in response_iterator:
6181
for parameter in page["Parameters"]:
62-
key = Path(parameter["Name"]).relative_to(secrets_path).as_posix()
63-
output[key if case_sensitive else key.lower()] = parameter["Value"]
82+
key = (
83+
Path(parameter["Name"]).relative_to(self.env_prefix).as_posix()
84+
)
85+
output[
86+
self.env_prefix + key
87+
if self.case_sensitive
88+
else self.env_prefix.lower() + key.lower()
89+
] = parameter["Value"]
6490

6591
except ClientError:
66-
logger.exception("Failed to get parameters from %s", secrets_path)
92+
logger.exception("Failed to get parameters from %s", self.env_prefix)
6793

6894
return output
6995

70-
def __call__(self, settings: BaseSettings) -> Dict[str, Any]:
71-
"""
72-
Returns SSM values for all settings.
73-
"""
74-
d: Dict[str, Optional[Any]] = {}
75-
76-
if self.ssm_prefix is None:
77-
return d
78-
79-
ssm_values = self.load_from_ssm(
80-
secrets_path=Path(self.ssm_prefix),
81-
case_sensitive=settings.__config__.case_sensitive,
82-
)
96+
def __repr__(self) -> str:
97+
return f"AwsSsmSettingsSource(ssm_prefix={self.env_prefix!r})"
8398

84-
# The following was lifted from https://github.com/samuelcolvin/pydantic/blob/a21f0763ee877f0c86f254a5d60f70b1002faa68/pydantic/env_settings.py#L165-L237 # noqa
85-
for field in settings.__fields__.values():
86-
env_val: Optional[str] = None
87-
for env_name in field.field_info.extra["env_names"]:
88-
env_val = ssm_values.get(env_name)
89-
if env_val is not None:
90-
break
91-
92-
is_complex, allow_json_failure = self._field_is_complex(field)
93-
if is_complex:
94-
if env_val is None:
95-
# field is complex but no value found so far, try explode_env_vars
96-
env_val_built = self._explode_ssm_values(field, ssm_values)
97-
if env_val_built:
98-
d[field.alias] = env_val_built
99-
else:
100-
# field is complex and there's a value, decode that as JSON, then
101-
# add explode_env_vars
102-
try:
103-
env_val = settings.__config__.json_loads(env_val)
104-
except ValueError as e:
105-
if not allow_json_failure:
106-
raise SettingsError(
107-
f'error parsing JSON for "{env_name}"'
108-
) from e
109-
110-
if isinstance(env_val, dict):
111-
d[field.alias] = deep_update(
112-
env_val, self._explode_ssm_values(field, ssm_values)
113-
)
114-
else:
115-
d[field.alias] = env_val
116-
elif env_val is not None:
117-
# simplest case, field is not complex, we only need to add the
118-
# value if it was found
119-
d[field.alias] = env_val
120-
121-
return d
122-
123-
def _field_is_complex(self, field: ModelField) -> Tuple[bool, bool]:
124-
"""
125-
Find out if a field is complex, and if so whether JSON errors should be ignored
99+
def get_field_value(
100+
self, field: FieldInfo, field_name: str
101+
) -> tuple[Any, str, bool]:
126102
"""
127-
if field.is_complex():
128-
allow_json_failure = False
129-
elif (
130-
is_union(get_origin(field.type_))
131-
and field.sub_fields
132-
and any(f.is_complex() for f in field.sub_fields)
133-
):
134-
allow_json_failure = True
135-
else:
136-
return False, False
103+
Gets the value for field from environment variables and a flag to
104+
determine whether value is complex.
137105
138-
return True, allow_json_failure
106+
Args:
107+
field: The field.
108+
field_name: The field name.
139109
140-
def _explode_ssm_values(
141-
self, field: ModelField, env_vars: Mapping[str, Optional[str]]
142-
) -> Dict[str, Any]:
110+
Returns:
111+
A tuple contains the key, value if the file exists otherwise `None`, and
112+
a flag to determine whether value is complex.
143113
"""
144-
Process env_vars and extract the values of keys containing
145-
env_nested_delimiter into nested dictionaries.
146114

147-
This is applied to a single field, hence filtering by env_var prefix.
148-
"""
149-
prefixes = [
150-
f"{env_name}{self.env_nested_delimiter}"
151-
for env_name in field.field_info.extra["env_names"]
152-
]
153-
result: Dict[str, Any] = {}
154-
for env_name, env_val in env_vars.items():
155-
if not any(env_name.startswith(prefix) for prefix in prefixes):
156-
continue
157-
_, *keys, last_key = env_name.split(self.env_nested_delimiter)
158-
env_var = result
159-
for key in keys:
160-
env_var = env_var.setdefault(key, {})
161-
env_var[last_key] = env_val
162-
163-
return result
115+
# env_name = /asdf/foo
116+
# env_vars = {foo:xyz}
117+
env_val: str | None = None
118+
for field_key, env_name, value_is_complex in self._extract_field_info(
119+
field, field_name
120+
):
121+
env_val = self.env_vars.get(env_name)
122+
if env_val is not None:
123+
break
124+
125+
return env_val, field_key, value_is_complex
126+
127+
def __call__(self) -> dict[str, Any]:
128+
data: dict[str, Any] = {}
129+
130+
for field_name, field in self.settings_cls.model_fields.items():
131+
try:
132+
field_value, field_key, value_is_complex = self.get_field_value(
133+
field, field_name
134+
)
135+
except Exception as e:
136+
raise SettingsError(
137+
f'error getting value for field "{field_name}" from source "{self.__class__.__name__}"' # noqa
138+
) from e
139+
140+
try:
141+
field_value = self.prepare_field_value(
142+
field_name, field, field_value, value_is_complex
143+
)
144+
except ValueError as e:
145+
raise SettingsError(
146+
f'error parsing value for field "{field_name}" from source "{self.__class__.__name__}"' # noqa
147+
) from e
148+
149+
if field_value is not None:
150+
if (
151+
not self.case_sensitive
152+
and lenient_issubclass(field.annotation, BaseModel)
153+
and isinstance(field_value, dict)
154+
):
155+
data[field_key] = self._replace_field_names_case_insensitively(
156+
field, field_value
157+
)
158+
else:
159+
data[field_key] = field_value
164160

165-
def __repr__(self) -> str:
166-
return f"AwsSsmSettingsSource(ssm_prefix={self.ssm_prefix!r})"
161+
return data

pyproject.toml

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ requires = ["poetry>=0.12"]
55
[tool.poetry]
66
authors = ["Anthony Lukach <[email protected]>"]
77
description = "Replace Pydantic's builtin Secret Support with a configuration provider that loads parameters from AWS Systems Manager Parameter Store."
8-
homepage = ""
98
license = "MIT"
109
maintainers = ["Anthony Lukach <[email protected]>"]
1110
name = "pydantic-ssm-settings"
@@ -15,8 +14,9 @@ version = "0.2.4"
1514

1615
[tool.poetry.dependencies]
1716
"boto3" = "^1.21.45"
18-
pydantic = "^1.6.2"
19-
python = "^3.7"
17+
pydantic = "^2.5.2"
18+
python = "^3.9"
19+
pydantic-settings = ">=2.1.0"
2020

2121
[tool.poetry.dev-dependencies]
2222
black = "^22.3.0"

0 commit comments

Comments
 (0)