Skip to content

Commit 472e9e1

Browse files
committed
Adds GCP Secret Manager source with doc and tests
1 parent 01407c0 commit 472e9e1

File tree

8 files changed

+783
-87
lines changed

8 files changed

+783
-87
lines changed

docs/index.md

Lines changed: 186 additions & 86 deletions
Large diffs are not rendered by default.

pydantic_settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
DotEnvSettingsSource,
1414
EnvSettingsSource,
1515
ForceDecode,
16+
GoogleSecretManagerSettingsSource,
1617
InitSettingsSource,
1718
JsonConfigSettingsSource,
1819
NoDecode,
@@ -40,6 +41,7 @@
4041
'DotEnvSettingsSource',
4142
'EnvSettingsSource',
4243
'ForceDecode',
44+
'GoogleSecretManagerSettingsSource',
4345
'InitSettingsSource',
4446
'JsonConfigSettingsSource',
4547
'NoDecode',

pydantic_settings/sources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
)
2121
from .providers.dotenv import DotEnvSettingsSource, read_env_file
2222
from .providers.env import EnvSettingsSource
23+
from .providers.gcp import GoogleSecretManagerSettingsSource
2324
from .providers.json import JsonConfigSettingsSource
2425
from .providers.pyproject import PyprojectTomlConfigSettingsSource
2526
from .providers.secrets import SecretsSettingsSource
@@ -43,6 +44,7 @@
4344
'DotenvType',
4445
'EnvSettingsSource',
4546
'ForceDecode',
47+
'GoogleSecretManagerSettingsSource',
4648
'InitSettingsSource',
4749
'JsonConfigSettingsSource',
4850
'NoDecode',

pydantic_settings/sources/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
)
1313
from .dotenv import DotEnvSettingsSource
1414
from .env import EnvSettingsSource
15+
from .gcp import GoogleSecretManagerSettingsSource
1516
from .json import JsonConfigSettingsSource
1617
from .pyproject import PyprojectTomlConfigSettingsSource
1718
from .secrets import SecretsSettingsSource
@@ -29,6 +30,7 @@
2930
'CliSuppress',
3031
'DotEnvSettingsSource',
3132
'EnvSettingsSource',
33+
'GoogleSecretManagerSettingsSource',
3234
'JsonConfigSettingsSource',
3335
'PyprojectTomlConfigSettingsSource',
3436
'SecretsSettingsSource',
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
from __future__ import annotations as _annotations
2+
3+
from collections.abc import Iterator, Mapping
4+
from functools import cached_property
5+
from typing import TYPE_CHECKING, Optional
6+
7+
from .env import EnvSettingsSource
8+
9+
if TYPE_CHECKING:
10+
from google.auth import default as google_auth_default
11+
from google.auth.credentials import Credentials
12+
from google.cloud.secretmanager import SecretManagerServiceClient
13+
14+
from pydantic_settings.main import BaseSettings
15+
else:
16+
Credentials = None
17+
SecretManagerServiceClient = None
18+
google_auth_default = None
19+
20+
21+
def import_gcp_secret_manager() -> None:
22+
global Credentials
23+
global SecretManagerServiceClient
24+
global google_auth_default
25+
26+
try:
27+
from google.auth import default as google_auth_default
28+
from google.auth.credentials import Credentials
29+
from google.cloud.secretmanager import SecretManagerServiceClient
30+
except ImportError as e:
31+
raise ImportError(
32+
'GCP Secret Namager dependencies are not installed, run `pip install pydantic-settings[gcp-secret-manager]`'
33+
) from e
34+
35+
36+
class GoogleSecretManagerMapping(Mapping[str, Optional[str]]):
37+
_loaded_secrets: dict[str, str | None]
38+
_secret_client: SecretManagerServiceClient
39+
40+
def __init__(self, secret_client: SecretManagerServiceClient, project_id: str) -> None:
41+
self._loaded_secrets = {}
42+
self._secret_client = secret_client
43+
self._project_id = project_id
44+
45+
@property
46+
def _gcp_project_path(self) -> str:
47+
return self._secret_client.common_project_path(self._project_id)
48+
49+
@cached_property
50+
def _secret_names(self) -> list[str]:
51+
return [
52+
self._secret_client.parse_secret_path(secret.name).get('secret', '')
53+
for secret in self._secret_client.list_secrets(parent=self._gcp_project_path)
54+
]
55+
56+
def _secret_version_path(self, key: str, version: str = 'latest') -> str:
57+
return self._secret_client.secret_version_path(self._project_id, key, version)
58+
59+
def __getitem__(self, key: str) -> str | None:
60+
if key not in self._loaded_secrets:
61+
# If we know the key isn't available in secret manager, raise a key error
62+
if key not in self._secret_names:
63+
raise KeyError(key)
64+
65+
try:
66+
self._loaded_secrets[key] = self._secret_client.access_secret_version(
67+
name=self._secret_version_path(key)
68+
).payload.data.decode('UTF-8')
69+
except Exception:
70+
raise KeyError(key)
71+
72+
return self._loaded_secrets[key]
73+
74+
def __len__(self) -> int:
75+
return len(self._secret_names)
76+
77+
def __iter__(self) -> Iterator[str]:
78+
return iter(self._secret_names)
79+
80+
81+
class GoogleSecretManagerSettingsSource(EnvSettingsSource):
82+
_credentials: Credentials
83+
_secret_client: SecretManagerServiceClient
84+
_project_id: str
85+
86+
def __init__(
87+
self,
88+
settings_cls: type[BaseSettings],
89+
credentials: Credentials | None = None,
90+
project_id: str | None = None,
91+
env_prefix: str | None = None,
92+
env_parse_none_str: str | None = None,
93+
env_parse_enums: bool | None = None,
94+
secret_client: SecretManagerServiceClient | None = None,
95+
) -> None:
96+
# Import Google Packages if they haven't already been imported
97+
if SecretManagerServiceClient is None or Credentials is None or google_auth_default is None:
98+
import_gcp_secret_manager()
99+
100+
# If credentials or project_id are not passed, then
101+
# try to get them from the default function
102+
if not credentials or not project_id:
103+
_creds, _project_id = google_auth_default() # type: ignore[no-untyped-call]
104+
105+
# Set the credentials and/or project id if they weren't specified
106+
if credentials is None:
107+
credentials = _creds
108+
109+
if project_id is None:
110+
if isinstance(_project_id, str):
111+
project_id = _project_id
112+
else:
113+
raise AttributeError(
114+
'project_id is required to be specified either as an argument or from the google.auth.default. See https://google-auth.readthedocs.io/en/master/reference/google.auth.html#google.auth.default'
115+
)
116+
117+
self._credentials: Credentials = credentials
118+
self._project_id: str = project_id
119+
120+
if secret_client:
121+
self._secret_client = secret_client
122+
else:
123+
self._secret_client = SecretManagerServiceClient(credentials=self._credentials)
124+
125+
super().__init__(
126+
settings_cls,
127+
case_sensitive=True,
128+
env_prefix=env_prefix,
129+
env_ignore_empty=False,
130+
env_parse_none_str=env_parse_none_str,
131+
env_parse_enums=env_parse_enums,
132+
)
133+
134+
def _load_env_vars(self) -> Mapping[str, Optional[str]]:
135+
return GoogleSecretManagerMapping(self._secret_client, project_id=self._project_id)
136+
137+
def __repr__(self) -> str:
138+
return f'{self.__class__.__name__}(project_id={self._project_id!r}, env_nested_delimiter={self.env_nested_delimiter!r})'
139+
140+
141+
__all__ = ['GoogleSecretManagerSettingsSource', 'GoogleSecretManagerMapping']

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ dynamic = ['version']
5050
yaml = ["pyyaml>=6.0.1"]
5151
toml = ["tomli>=2.0.1"]
5252
azure-key-vault = ["azure-keyvault-secrets>=4.8.0", "azure-identity>=1.16.0"]
53+
gcp-secret-manager = [
54+
"google-cloud-secret-manager>=2.23.1",
55+
]
5356

5457
[project.urls]
5558
Homepage = 'https://github.com/pydantic/pydantic-settings'

0 commit comments

Comments
 (0)