Skip to content

Commit 235769c

Browse files
authored
Merge branch 'main' into cli_submodel_suppress
2 parents 1775221 + e9fb316 commit 235769c

File tree

13 files changed

+946
-248
lines changed

13 files changed

+946
-248
lines changed

docs/index.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1888,6 +1888,99 @@ class AzureKeyVaultSettings(BaseSettings):
18881888
)
18891889
```
18901890

1891+
## Google Cloud Secret Manager
1892+
1893+
Google Cloud Secret Manager allows you to store, manage, and access sensitive information as secrets in Google Cloud Platform. This integration lets you retrieve secrets directly from GCP Secret Manager for use in your Pydantic settings.
1894+
1895+
### Installation
1896+
1897+
The Google Cloud Secret Manager integration requires additional dependencies:
1898+
1899+
```bash
1900+
pip install "pydantic-settings[gcp-secret-manager]"
1901+
```
1902+
1903+
### Basic Usage
1904+
1905+
To use Google Cloud Secret Manager, you need to:
1906+
1907+
1. Create a `GoogleSecretManagerSettingsSource`. (See [GCP Authentication](#gcp-authentication) for authentication options.)
1908+
2. Add this source to your settings customization pipeline
1909+
1910+
```py
1911+
from pydantic import BaseModel
1912+
1913+
from pydantic_settings import (
1914+
BaseSettings,
1915+
GoogleSecretManagerSettingsSource,
1916+
PydanticBaseSettingsSource,
1917+
SettingsConfigDict,
1918+
)
1919+
1920+
1921+
class Database(BaseModel):
1922+
password: str
1923+
user: str
1924+
1925+
1926+
class Settings(BaseSettings):
1927+
database: Database
1928+
1929+
model_config = SettingsConfigDict(env_nested_delimiter='__')
1930+
1931+
@classmethod
1932+
def settings_customise_sources(
1933+
cls,
1934+
settings_cls: type[BaseSettings],
1935+
init_settings: PydanticBaseSettingsSource,
1936+
env_settings: PydanticBaseSettingsSource,
1937+
dotenv_settings: PydanticBaseSettingsSource,
1938+
file_secret_settings: PydanticBaseSettingsSource,
1939+
) -> tuple[PydanticBaseSettingsSource, ...]:
1940+
# Create the GCP Secret Manager settings source
1941+
gcp_settings = GoogleSecretManagerSettingsSource(
1942+
settings_cls,
1943+
# If not provided, will use google.auth.default()
1944+
# to get credentials from the environemnt
1945+
# credentials=your_credentials,
1946+
# If not provided, will use google.auth.default()
1947+
# to get project_id from the environemnt
1948+
project_id='your-gcp-project-id',
1949+
)
1950+
1951+
return (
1952+
init_settings,
1953+
env_settings,
1954+
dotenv_settings,
1955+
file_secret_settings,
1956+
gcp_settings,
1957+
)
1958+
```
1959+
1960+
### GCP Authentication
1961+
1962+
The `GoogleSecretManagerSettingsSource` supports several authentication methods:
1963+
1964+
1. **Default credentials** - If you don't provide credentials or project ID, it will use [`google.auth.default()`](https://google-auth.readthedocs.io/en/master/reference/google.auth.html#google.auth.default) to obtain them. This works with:
1965+
1966+
- Service account credentials from `GOOGLE_APPLICATION_CREDENTIALS` environment variable
1967+
- User credentials from `gcloud auth application-default login`
1968+
- Compute Engine, GKE, Cloud Run, or Cloud Functions default service accounts
1969+
1970+
2. **Explicit credentials** - You can also provide `credentials` directly. e.g. `sa_credentials = google.oauth2.service_account.Credentials.from_service_account_file('path/to/service-account.json')` and then `GoogleSecretManagerSettingsSource(credentials=sa_credentials)`
1971+
1972+
### Nested Models
1973+
1974+
For nested models, Secret Manager supports the `env_nested_delimiter` setting as long as it complies with the [naming rules](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets#create-a-secret). In the example above, you would create secrets named `database__password` and `database__user` in Secret Manager.
1975+
1976+
### Important Notes
1977+
1978+
1. **Case Sensitivity**: By default, secret names are case-sensitive.
1979+
2. **Secret Naming**: Create secrets in Google Secret Manager with names that match your field names (including any prefix). According the [Secret Manager documentation](https://cloud.google.com/secret-manager/docs/creating-and-accessing-secrets#create-a-secret), a secret name can contain uppercase and lowercase letters, numerals, hyphens, and underscores. The maximum allowed length for a name is 255 characters.
1980+
3. **Secret Versions**: The GoogleSecretManagerSettingsSource uses the "latest" version of secrets.
1981+
1982+
For more details on creating and managing secrets in Google Cloud Secret Manager, see the [official Google Cloud documentation](https://cloud.google.com/secret-manager/docs).
1983+
18911984
## Other settings source
18921985

18931986
Other settings sources are available for common configuration files:

pydantic_settings/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
DotEnvSettingsSource,
1515
EnvSettingsSource,
1616
ForceDecode,
17+
GoogleSecretManagerSettingsSource,
1718
InitSettingsSource,
1819
JsonConfigSettingsSource,
1920
NoDecode,
@@ -42,6 +43,7 @@
4243
'DotEnvSettingsSource',
4344
'EnvSettingsSource',
4445
'ForceDecode',
46+
'GoogleSecretManagerSettingsSource',
4547
'InitSettingsSource',
4648
'JsonConfigSettingsSource',
4749
'NoDecode',

pydantic_settings/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ def run(
559559
if not issubclass(model_cls, BaseSettings):
560560

561561
class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore
562+
__doc__ = model_cls.__doc__
562563
model_config = SettingsConfigDict(
563564
nested_model_default_partial_update=True,
564565
case_sensitive=True,

pydantic_settings/sources/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from .providers.dotenv import DotEnvSettingsSource, read_env_file
2323
from .providers.env import EnvSettingsSource
24+
from .providers.gcp import GoogleSecretManagerSettingsSource
2425
from .providers.json import JsonConfigSettingsSource
2526
from .providers.pyproject import PyprojectTomlConfigSettingsSource
2627
from .providers.secrets import SecretsSettingsSource
@@ -45,6 +46,7 @@
4546
'DotenvType',
4647
'EnvSettingsSource',
4748
'ForceDecode',
49+
'GoogleSecretManagerSettingsSource',
4850
'InitSettingsSource',
4951
'JsonConfigSettingsSource',
5052
'NoDecode',

pydantic_settings/sources/providers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from .dotenv import DotEnvSettingsSource
1515
from .env import EnvSettingsSource
16+
from .gcp import GoogleSecretManagerSettingsSource
1617
from .json import JsonConfigSettingsSource
1718
from .pyproject import PyprojectTomlConfigSettingsSource
1819
from .secrets import SecretsSettingsSource
@@ -31,6 +32,7 @@
3132
'CliSuppress',
3233
'DotEnvSettingsSource',
3334
'EnvSettingsSource',
35+
'GoogleSecretManagerSettingsSource',
3436
'JsonConfigSettingsSource',
3537
'PyprojectTomlConfigSettingsSource',
3638
'SecretsSettingsSource',

pydantic_settings/sources/providers/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -912,7 +912,9 @@ def _add_parser_submodels(
912912
is_model_suppressed = self._is_field_suppressed(field_info) or is_model_suppressed
913913
if not self.cli_avoid_json:
914914
added_args.append(arg_names[0])
915-
kwargs['help'] = CLI_SUPPRESS if is_model_suppressed else f'set {arg_names[0]} from JSON string'
915+
kwargs['nargs'] = '?'
916+
kwargs['const'] = '{}'
917+
kwargs['help'] = kwargs['help'] = CLI_SUPPRESS if is_model_suppressed else f'set {arg_names[0]} from JSON string (default: {{}})'
916918
model_group = self._add_group(parser, **model_group_kwargs)
917919
self._add_argument(model_group, *(f'{flag_prefix}{name}' for name in arg_names), **kwargs)
918920
for model in sub_models:

pydantic_settings/sources/providers/env.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ def explode_env_vars(self, field_name: str, field: FieldInfo, env_vars: Mapping[
242242
if (target_field or is_dict) and env_val:
243243
if target_field:
244244
is_complex, allow_json_failure = self._field_is_complex(target_field)
245+
if self.env_parse_enums:
246+
enum_val = _annotation_enum_name_to_val(target_field.annotation, env_val)
247+
env_val = env_val if enum_val is None else enum_val
245248
else:
246249
# nested field type is dict
247250
is_complex, allow_json_failure = True, True
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
@@ -51,6 +51,9 @@ 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"]
5353
aws-secrets-manager = ["boto3>=1.35.0", "boto3-stubs[secretsmanager]"]
54+
gcp-secret-manager = [
55+
"google-cloud-secret-manager>=2.23.1",
56+
]
5457

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

tests/test_settings.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2348,13 +2348,18 @@ class Settings(BaseSettings):
23482348

23492349

23502350
def test_env_parse_enums(env):
2351-
class Settings(BaseSettings):
2351+
class NestedEnum(BaseModel):
2352+
fruit: FruitsEnum
2353+
2354+
class Settings(BaseSettings, env_nested_delimiter='__'):
23522355
fruit: FruitsEnum
23532356
union_fruit: Optional[Union[int, FruitsEnum]] = None
2357+
nested: NestedEnum
23542358

23552359
with pytest.raises(ValidationError) as exc_info:
23562360
env.set('FRUIT', 'kiwi')
23572361
env.set('UNION_FRUIT', 'kiwi')
2362+
env.set('NESTED__FRUIT', 'kiwi')
23582363
s = Settings()
23592364
assert exc_info.value.errors(include_url=False) == [
23602365
{
@@ -2385,22 +2390,43 @@ class Settings(BaseSettings):
23852390
'msg': 'Input should be 0, 1 or 2',
23862391
'type': 'enum',
23872392
},
2393+
{
2394+
'ctx': {
2395+
'expected': '0, 1 or 2',
2396+
},
2397+
'input': 'kiwi',
2398+
'loc': (
2399+
'nested',
2400+
'fruit',
2401+
),
2402+
'msg': 'Input should be 0, 1 or 2',
2403+
'type': 'enum',
2404+
},
23882405
]
23892406

23902407
env.set('FRUIT', str(FruitsEnum.lime.value))
23912408
env.set('UNION_FRUIT', str(FruitsEnum.lime.value))
2409+
env.set('NESTED__FRUIT', str(FruitsEnum.lime.value))
23922410
s = Settings()
23932411
assert s.fruit == FruitsEnum.lime
2412+
assert s.union_fruit == FruitsEnum.lime
2413+
assert s.nested.fruit == FruitsEnum.lime
23942414

23952415
env.set('FRUIT', 'kiwi')
23962416
env.set('UNION_FRUIT', 'kiwi')
2417+
env.set('NESTED__FRUIT', 'kiwi')
23972418
s = Settings(_env_parse_enums=True)
23982419
assert s.fruit == FruitsEnum.kiwi
2420+
assert s.union_fruit == FruitsEnum.kiwi
2421+
assert s.nested.fruit == FruitsEnum.kiwi
23992422

24002423
env.set('FRUIT', str(FruitsEnum.lime.value))
24012424
env.set('UNION_FRUIT', str(FruitsEnum.lime.value))
2425+
env.set('NESTED__FRUIT', str(FruitsEnum.lime.value))
24022426
s = Settings(_env_parse_enums=True)
24032427
assert s.fruit == FruitsEnum.lime
2428+
assert s.union_fruit == FruitsEnum.lime
2429+
assert s.nested.fruit == FruitsEnum.lime
24042430

24052431

24062432
def test_env_parse_none_str(env):

0 commit comments

Comments
 (0)