diff --git a/pydantic_settings/__init__.py b/pydantic_settings/__init__.py index 7b99f88..e2d68cc 100644 --- a/pydantic_settings/__init__.py +++ b/pydantic_settings/__init__.py @@ -3,6 +3,7 @@ DotEnvSettingsSource, EnvSettingsSource, InitSettingsSource, + KeyringSettingsSource, PydanticBaseSettingsSource, SecretsSettingsSource, ) @@ -13,6 +14,7 @@ 'DotEnvSettingsSource', 'EnvSettingsSource', 'InitSettingsSource', + 'KeyringSettingsSource', 'PydanticBaseSettingsSource', 'SecretsSettingsSource', 'SettingsConfigDict', diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 64e9d64..c1845d6 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -14,6 +14,7 @@ DotenvType, EnvSettingsSource, InitSettingsSource, + KeyringSettingsSource, PydanticBaseSettingsSource, SecretsSettingsSource, ) @@ -25,6 +26,7 @@ class SettingsConfigDict(ConfigDict, total=False): env_file: DotenvType | None env_file_encoding: str | None env_nested_delimiter: str | None + keyring_backend: str | None secrets_dir: str | Path | None @@ -54,6 +56,7 @@ class BaseSettings(BaseModel): `None` to indicate that environment variables should not be loaded from an env file. _env_file_encoding: The env file encoding, e.g. `'latin-1'`. Defaults to `None`. _env_nested_delimiter: The nested env values delimiter. Defaults to `None`. + _keyring_backend: The keyring backend, e.g. `'SecretService Keyring'`. Defaults to `None`. _secrets_dir: The secret files directory. Defaults to `None`. """ @@ -64,6 +67,7 @@ def __init__( _env_file: DotenvType | None = ENV_FILE_SENTINEL, _env_file_encoding: str | None = None, _env_nested_delimiter: str | None = None, + _keyring_backend: str | None = None, _secrets_dir: str | Path | None = None, **values: Any, ) -> None: @@ -76,6 +80,7 @@ def __init__( _env_file=_env_file, _env_file_encoding=_env_file_encoding, _env_nested_delimiter=_env_nested_delimiter, + _keyring_backend=_keyring_backend, _secrets_dir=_secrets_dir, ) ) @@ -87,6 +92,7 @@ def settings_customise_sources( init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, + keyring_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> tuple[PydanticBaseSettingsSource, ...]: """ @@ -97,12 +103,13 @@ def settings_customise_sources( init_settings: The `InitSettingsSource` instance. env_settings: The `EnvSettingsSource` instance. dotenv_settings: The `DotEnvSettingsSource` instance. + keyring_settings: The `KeyringSettingsSource` instance. file_secret_settings: The `SecretsSettingsSource` instance. Returns: A tuple containing the sources and their order for loading the settings values. """ - return init_settings, env_settings, dotenv_settings, file_secret_settings + return init_settings, env_settings, dotenv_settings, keyring_settings, file_secret_settings def _settings_build_values( self, @@ -112,6 +119,7 @@ def _settings_build_values( _env_file: DotenvType | None = None, _env_file_encoding: str | None = None, _env_nested_delimiter: str | None = None, + _keyring_backend: str | None = None, _secrets_dir: str | Path | None = None, ) -> dict[str, Any]: # Determine settings config values @@ -126,6 +134,7 @@ def _settings_build_values( if _env_nested_delimiter is not None else self.model_config.get('env_nested_delimiter') ) + keyring_backend = _keyring_backend if _keyring_backend is not None else self.model_config.get('keyring_backend') secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') # Configure built-in sources @@ -144,6 +153,13 @@ def _settings_build_values( env_prefix=env_prefix, env_nested_delimiter=env_nested_delimiter, ) + keyring_settings = KeyringSettingsSource( + self.__class__, + keyring_backend=keyring_backend, + case_sensitive=case_sensitive, + env_prefix=env_prefix, + env_nested_delimiter=env_nested_delimiter, + ) file_secret_settings = SecretsSettingsSource( self.__class__, secrets_dir=secrets_dir, case_sensitive=case_sensitive, env_prefix=env_prefix @@ -154,6 +170,7 @@ def _settings_build_values( init_settings=init_settings, env_settings=env_settings, dotenv_settings=dotenv_settings, + keyring_settings=keyring_settings, file_secret_settings=file_secret_settings, ) if sources: @@ -172,6 +189,7 @@ def _settings_build_values( env_file=None, env_file_encoding=None, env_nested_delimiter=None, + keyring_backend=None, secrets_dir=None, protected_namespaces=('model_', 'settings_'), ) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index baa10b8..a8a2ca1 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -7,7 +7,7 @@ from collections import deque from dataclasses import is_dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, List, Mapping, Sequence, Tuple, Union, cast +from typing import TYPE_CHECKING, Any, Iterator, List, Mapping, Sequence, Tuple, Union, cast from pydantic import AliasChoices, AliasPath, BaseModel, Json from pydantic._internal._typing_extra import origin_is_union @@ -17,6 +17,12 @@ from pydantic_settings.utils import path_type_label +try: + import keyring + from keyring.backends.SecretService import Keyring as SecretServiceKeyring +except ImportError: + pass + if TYPE_CHECKING: from pydantic_settings.main import BaseSettings @@ -632,6 +638,77 @@ def read_env_file( return file_vars +class KeyringSettingsSource(EnvSettingsSource): + """ + Source class for loading settings values from keyrings. + """ + + def __init__( + self, + settings_cls: type[BaseSettings], + keyring_backend: str | None = None, + case_sensitive: bool | None = None, + env_prefix: str | None = None, + env_nested_delimiter: str | None = None, + ) -> None: + self.keyring_backend = ( + keyring_backend if keyring_backend is not None else settings_cls.model_config.get('keyring_backend') + ) + super().__init__(settings_cls, case_sensitive, env_prefix, env_nested_delimiter) + + def _load_env_vars(self) -> Mapping[str, str | None]: + return self._read_keyring(self.case_sensitive) + + def _read_keyring(self, case_sensitive: bool) -> Mapping[str, str | None]: + keyring_backend = self.keyring_backend + if keyring_backend is None: + kr = cast(SecretServiceKeyring, keyring.core.get_keyring()) + else: + # Correct mis-cast annotation in source package: https://github.com/jaraco/keyring/issues/645 + all_keyrings = cast(Iterator[SecretServiceKeyring], keyring.backend.get_all_keyring()) + try: + kr = next(be for be in all_keyrings if be.name == keyring_backend) + except StopIteration: + # Same behaviour as when a named dotenv file is not found + return {} + + keyring_vars: dict[str, str | None] = {} + kr_collection = kr.get_preferred_collection() # type: ignore[no-untyped-call] + kr_items = kr_collection.get_all_items() + keyring_vars.update({item.get_attributes()['service']: item.get_secret().decode() for item in kr_items}) + if not case_sensitive: + return {k.lower(): v for k, v in keyring_vars.items()} + else: + return keyring_vars + + def __call__(self) -> dict[str, Any]: + data: dict[str, Any] = super().__call__() + + data_lower_keys: list[str] = [] + if not self.case_sensitive: + data_lower_keys = [x.lower() for x in data.keys()] + + # As `extra` config is allowed in keyring settings source, We have to + # update data with extra env variables from keyring. + for env_name, env_value in self.env_vars.items(): + if env_name.startswith(self.env_prefix) and env_value is not None: + env_name_without_prefix = env_name[self.env_prefix_len :] + first_key, *_ = env_name_without_prefix.split(self.env_nested_delimiter) + + if (data_lower_keys and first_key not in data_lower_keys) or ( + not data_lower_keys and first_key not in data + ): + data[first_key] = env_value + + return data + + def __repr__(self) -> str: + return ( + f'KeyringSettingsSource(keyring_backend={self.keyring_backend!r}, ' + f'env_nested_delimiter={self.env_nested_delimiter!r}, env_prefix_len={self.env_prefix_len!r})' + ) + + def _annotation_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool: if any(isinstance(md, Json) for md in metadata): # type: ignore[misc] return False diff --git a/pyproject.toml b/pyproject.toml index 1859192..2d8d967 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,9 @@ dependencies = [ ] dynamic = ['version'] +[project.optional-dependencies] +keyring = ['keyring'] + [project.urls] Homepage = 'https://github.com/pydantic/pydantic-settings' Funding = 'https://github.com/sponsors/samuelcolvin' diff --git a/requirements/linting.in b/requirements/linting.in index 64c9833..62725ed 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -3,3 +3,4 @@ ruff pyupgrade mypy pre-commit +keyring diff --git a/requirements/linting.txt b/requirements/linting.txt index e0b3c86..58621a5 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -2,14 +2,18 @@ # This file is autogenerated by pip-compile with Python 3.7 # by the following command: # -# pip-compile --output-file=requirements/linting.txt requirements/linting.in +# pip-compile --config=pyproject.toml --output-file=requirements/linting.txt requirements/linting.in # black==23.3.0 # via -r requirements/linting.in +cffi==1.15.1 + # via cryptography cfgv==3.3.1 # via pre-commit click==8.1.3 # via black +cryptography==41.0.3 + # via secretstorage distlib==0.3.6 # via virtualenv filelock==3.12.0 @@ -19,8 +23,21 @@ identify==2.5.24 importlib-metadata==6.6.0 # via # click + # keyring # pre-commit # virtualenv +importlib-resources==5.12.0 + # via keyring +jaraco-classes==3.2.3 + # via keyring +jeepney==0.8.0 + # via + # keyring + # secretstorage +keyring==24.1.1 + # via -r requirements/linting.in +more-itertools==9.1.0 + # via jaraco-classes mypy==1.3.0 # via -r requirements/linting.in mypy-extensions==1.0.0 @@ -39,12 +56,16 @@ platformdirs==3.5.1 # virtualenv pre-commit==2.21.0 # via -r requirements/linting.in +pycparser==2.21 + # via cffi pyupgrade==3.3.2 # via -r requirements/linting.in pyyaml==6.0 # via pre-commit ruff==0.0.270 # via -r requirements/linting.in +secretstorage==3.3.3 + # via keyring tokenize-rt==5.0.0 # via pyupgrade tomli==2.0.1 @@ -64,7 +85,9 @@ typing-extensions==4.6.2 virtualenv==20.23.0 # via pre-commit zipp==3.15.0 - # via importlib-metadata + # via + # importlib-metadata + # importlib-resources # The following packages are considered to be unsafe in a requirements file: # setuptools