Skip to content

feat: KeyringSettingsSource class to load keyring variables #140

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pydantic_settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
DotEnvSettingsSource,
EnvSettingsSource,
InitSettingsSource,
KeyringSettingsSource,
PydanticBaseSettingsSource,
SecretsSettingsSource,
)
Expand All @@ -13,6 +14,7 @@
'DotEnvSettingsSource',
'EnvSettingsSource',
'InitSettingsSource',
'KeyringSettingsSource',
'PydanticBaseSettingsSource',
'SecretsSettingsSource',
'SettingsConfigDict',
Expand Down
20 changes: 19 additions & 1 deletion pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
DotenvType,
EnvSettingsSource,
InitSettingsSource,
KeyringSettingsSource,
PydanticBaseSettingsSource,
SecretsSettingsSource,
)
Expand All @@ -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


Expand Down Expand Up @@ -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`.
"""

Expand All @@ -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:
Expand All @@ -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,
)
)
Expand All @@ -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, ...]:
"""
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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_'),
)
79 changes: 78 additions & 1 deletion pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions requirements/linting.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ ruff
pyupgrade
mypy
pre-commit
keyring
27 changes: 25 additions & 2 deletions requirements/linting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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