Skip to content

Commit 694fd1b

Browse files
authored
Merge branch 'main' into cli_retrieve_unknown_args
2 parents d1bb2be + ed7fd42 commit 694fd1b

File tree

13 files changed

+988
-253
lines changed

13 files changed

+988
-253
lines changed

docs/index.md

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

1892+
## Google Cloud Secret Manager
1893+
1894+
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.
1895+
1896+
### Installation
1897+
1898+
The Google Cloud Secret Manager integration requires additional dependencies:
1899+
1900+
```bash
1901+
pip install "pydantic-settings[gcp-secret-manager]"
1902+
```
1903+
1904+
### Basic Usage
1905+
1906+
To use Google Cloud Secret Manager, you need to:
1907+
1908+
1. Create a `GoogleSecretManagerSettingsSource`. (See [GCP Authentication](#gcp-authentication) for authentication options.)
1909+
2. Add this source to your settings customization pipeline
1910+
1911+
```py
1912+
from pydantic import BaseModel
1913+
1914+
from pydantic_settings import (
1915+
BaseSettings,
1916+
GoogleSecretManagerSettingsSource,
1917+
PydanticBaseSettingsSource,
1918+
SettingsConfigDict,
1919+
)
1920+
1921+
1922+
class Database(BaseModel):
1923+
password: str
1924+
user: str
1925+
1926+
1927+
class Settings(BaseSettings):
1928+
database: Database
1929+
1930+
model_config = SettingsConfigDict(env_nested_delimiter='__')
1931+
1932+
@classmethod
1933+
def settings_customise_sources(
1934+
cls,
1935+
settings_cls: type[BaseSettings],
1936+
init_settings: PydanticBaseSettingsSource,
1937+
env_settings: PydanticBaseSettingsSource,
1938+
dotenv_settings: PydanticBaseSettingsSource,
1939+
file_secret_settings: PydanticBaseSettingsSource,
1940+
) -> tuple[PydanticBaseSettingsSource, ...]:
1941+
# Create the GCP Secret Manager settings source
1942+
gcp_settings = GoogleSecretManagerSettingsSource(
1943+
settings_cls,
1944+
# If not provided, will use google.auth.default()
1945+
# to get credentials from the environemnt
1946+
# credentials=your_credentials,
1947+
# If not provided, will use google.auth.default()
1948+
# to get project_id from the environemnt
1949+
project_id='your-gcp-project-id',
1950+
)
1951+
1952+
return (
1953+
init_settings,
1954+
env_settings,
1955+
dotenv_settings,
1956+
file_secret_settings,
1957+
gcp_settings,
1958+
)
1959+
```
1960+
1961+
### GCP Authentication
1962+
1963+
The `GoogleSecretManagerSettingsSource` supports several authentication methods:
1964+
1965+
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:
1966+
1967+
- Service account credentials from `GOOGLE_APPLICATION_CREDENTIALS` environment variable
1968+
- User credentials from `gcloud auth application-default login`
1969+
- Compute Engine, GKE, Cloud Run, or Cloud Functions default service accounts
1970+
1971+
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)`
1972+
1973+
### Nested Models
1974+
1975+
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.
1976+
1977+
### Important Notes
1978+
1979+
1. **Case Sensitivity**: By default, secret names are case-sensitive.
1980+
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.
1981+
3. **Secret Versions**: The GoogleSecretManagerSettingsSource uses the "latest" version of secrets.
1982+
1983+
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).
1984+
18921985
## Other settings source
18931986

18941987
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
@@ -15,6 +15,7 @@
1515
DotEnvSettingsSource,
1616
EnvSettingsSource,
1717
ForceDecode,
18+
GoogleSecretManagerSettingsSource,
1819
InitSettingsSource,
1920
JsonConfigSettingsSource,
2021
NoDecode,
@@ -44,6 +45,7 @@
4445
'DotEnvSettingsSource',
4546
'EnvSettingsSource',
4647
'ForceDecode',
48+
'GoogleSecretManagerSettingsSource',
4749
'InitSettingsSource',
4850
'JsonConfigSettingsSource',
4951
'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
@@ -22,6 +22,7 @@
2222
)
2323
from .providers.dotenv import DotEnvSettingsSource, read_env_file
2424
from .providers.env import EnvSettingsSource
25+
from .providers.gcp import GoogleSecretManagerSettingsSource
2526
from .providers.json import JsonConfigSettingsSource
2627
from .providers.pyproject import PyprojectTomlConfigSettingsSource
2728
from .providers.secrets import SecretsSettingsSource
@@ -47,6 +48,7 @@
4748
'DotenvType',
4849
'EnvSettingsSource',
4950
'ForceDecode',
51+
'GoogleSecretManagerSettingsSource',
5052
'InitSettingsSource',
5153
'JsonConfigSettingsSource',
5254
'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: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,7 @@ def _add_parser_args(
673673
group: Any,
674674
alias_prefixes: list[str],
675675
model_default: Any,
676+
is_model_suppressed: bool = False,
676677
) -> ArgumentParser:
677678
subparsers: Any = None
678679
alias_path_args: dict[str, str] = {}
@@ -746,7 +747,7 @@ def _add_parser_args(
746747
is_parser_submodel = sub_models and not is_append_action
747748
kwargs: dict[str, Any] = {}
748749
kwargs['default'] = CLI_SUPPRESS
749-
kwargs['help'] = self._help_format(field_name, field_info, model_default)
750+
kwargs['help'] = self._help_format(field_name, field_info, model_default, is_model_suppressed)
750751
kwargs['metavar'] = self._metavar_format(field_info.annotation)
751752
kwargs['required'] = (
752753
self.cli_enforce_required and field_info.is_required() and model_default is PydanticUndefined
@@ -787,6 +788,7 @@ def _add_parser_args(
787788
field_info,
788789
alias_names,
789790
model_default=model_default,
791+
is_model_suppressed=is_model_suppressed,
790792
)
791793
elif _CliUnknownArgs in field_info.metadata:
792794
self._cli_unknown_args[kwargs['dest']] = []
@@ -882,6 +884,7 @@ def _add_parser_submodels(
882884
field_info: FieldInfo,
883885
alias_names: tuple[str, ...],
884886
model_default: Any,
887+
is_model_suppressed: bool,
885888
) -> None:
886889
if issubclass(model, CliMutuallyExclusiveGroup):
887890
# Argparse has deprecated "calling add_argument_group() or add_mutually_exclusive_group() on a
@@ -919,9 +922,14 @@ def _add_parser_submodels(
919922
model_group_kwargs['description'] = desc_header
920923

921924
preferred_alias = alias_names[0]
925+
is_model_suppressed = self._is_field_suppressed(field_info) or is_model_suppressed
922926
if not self.cli_avoid_json:
923927
added_args.append(arg_names[0])
924-
kwargs['help'] = f'set {arg_names[0]} from JSON string'
928+
kwargs['nargs'] = '?'
929+
kwargs['const'] = '{}'
930+
kwargs['help'] = kwargs['help'] = (
931+
CLI_SUPPRESS if is_model_suppressed else f'set {arg_names[0]} from JSON string (default: {{}})'
932+
)
925933
model_group = self._add_group(parser, **model_group_kwargs)
926934
self._add_argument(model_group, *(f'{flag_prefix}{name}' for name in arg_names), **kwargs)
927935
for model in sub_models:
@@ -934,6 +942,7 @@ def _add_parser_submodels(
934942
group=model_group if model_group else model_group_kwargs,
935943
alias_prefixes=[f'{arg_prefix}{name}.' for name in alias_names[1:]],
936944
model_default=model_default,
945+
is_model_suppressed=is_model_suppressed,
937946
)
938947

939948
def _add_parser_alias_paths(
@@ -1026,9 +1035,11 @@ def _metavar_format_recurse(self, obj: Any) -> str:
10261035
def _metavar_format(self, obj: Any) -> str:
10271036
return self._metavar_format_recurse(obj).replace(', ', ',')
10281037

1029-
def _help_format(self, field_name: str, field_info: FieldInfo, model_default: Any) -> str:
1038+
def _help_format(
1039+
self, field_name: str, field_info: FieldInfo, model_default: Any, is_model_suppressed: bool
1040+
) -> str:
10301041
_help = field_info.description if field_info.description else ''
1031-
if _help == CLI_SUPPRESS or CLI_SUPPRESS in field_info.metadata:
1042+
if is_model_suppressed or self._is_field_suppressed(field_info):
10321043
return CLI_SUPPRESS
10331044

10341045
if field_info.is_required() and model_default in (PydanticUndefined, None):
@@ -1048,3 +1059,7 @@ def _help_format(self, field_name: str, field_info: FieldInfo, model_default: An
10481059
default = f'(default factory: {self._metavar_format(field_info.default_factory)})'
10491060
_help += f' {default}' if _help else default
10501061
return _help.replace('%', '%%') if issubclass(type(self._root_parser), ArgumentParser) else _help
1062+
1063+
def _is_field_suppressed(self, field_info: FieldInfo) -> bool:
1064+
_help = field_info.description if field_info.description else ''
1065+
return _help == CLI_SUPPRESS or CLI_SUPPRESS in field_info.metadata

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'

0 commit comments

Comments
 (0)