Skip to content
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

Feature/instance level source customization #350

Closed
Closed
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
43 changes: 43 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1645,6 +1645,49 @@ except ValidationError as exc_info:
```


### Instance Level Source Customization

To modify your sources on an instance level, use ``_settings_customize_instance``.
The following example will instantiate ``Settings`` using only the environment as a source:

~~~py
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource
from pydantic_settings.sources import EnvSettingsSource


class Settings(BaseSettings):
my_api_key: str


def settings_use_only_env(sources: tuple):
# Do not use unpacking, look for the ``EnvSettingSource`` since it might
# not be included if ``settings_customise_sources`` is defined.
_ = (source for source in sources if isinstance(source, EnvSettingsSource))

if (source_env := next(_, None)) is None:
raise ValueError(
'Could not find ``EnvSettingsSource``. Check that ``settings_customise_sources`` returns this source.'
)

return (source_env,)


# Should fail if not specified in env, even with init argument defined.
# To run the assertion do ``export MY_API_KEY='5678efgh'``.
settings = Settings(
my_api_key='1234abcd',
_settings_customize_instance=settings_use_only_env,
)
# assert settings.my_api_key == '5678efgh'

settings = Settings(my_api_key='1234abcd')
~~~

Note that ``sources`` will be the output of ``settings_customise_sources`` so
the size of the tuple is not set, thus why the env source must be searched for
and has potential of not being found.


## In-place reloading

In case you want to reload in-place an existing setting, you can do it by using its `__init__` method :
Expand Down
22 changes: 21 additions & 1 deletion pydantic_settings/main.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations as _annotations

from pathlib import Path
from typing import Any, ClassVar
from typing import Any, Callable, ClassVar

from pydantic import ConfigDict
from pydantic._internal._config import config_keys
Expand Down Expand Up @@ -138,6 +138,13 @@ def __init__(
_cli_exit_on_error: bool | None = None,
_cli_prefix: str | None = None,
_secrets_dir: str | Path | None = None,
_settings_customize_instance: (
Callable[
[tuple[PydanticBaseSettingsSource, ...]],
tuple[PydanticBaseSettingsSource, ...],
]
| None
) = None,
**values: Any,
) -> None:
# Uses something other than `self` the first arg to allow "self" as a settable attribute
Expand All @@ -163,6 +170,7 @@ def __init__(
_cli_exit_on_error=_cli_exit_on_error,
_cli_prefix=_cli_prefix,
_secrets_dir=_secrets_dir,
_settings_customize_instance=_settings_customize_instance,
)
)

Expand Down Expand Up @@ -212,6 +220,13 @@ def _settings_build_values(
_cli_exit_on_error: bool | None = None,
_cli_prefix: str | None = None,
_secrets_dir: str | Path | None = None,
_settings_customize_instance: (
Callable[
[tuple[PydanticBaseSettingsSource, ...]],
tuple[PydanticBaseSettingsSource, ...],
]
| None
) = None,
) -> dict[str, Any]:
# Determine settings config values
case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive')
Expand Down Expand Up @@ -297,6 +312,11 @@ def _settings_build_values(
dotenv_settings=dotenv_settings,
file_secret_settings=file_secret_settings,
)

# Instance hook since :meth:`settings_customise_sources` is a classmethod.
if _settings_customize_instance is not None:
sources = _settings_customize_instance(sources)

if not any([source for source in sources if isinstance(source, CliSettingsSource)]):
if cli_parse_args is not None or cli_settings_source is not None:
cli_settings = (
Expand Down
71 changes: 71 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4110,3 +4110,74 @@ def settings_customise_sources(
env.set('one', '1')
s = Settings(one=True, two=True)
assert s.four is True


def test_settings_build_values_customize_instance():
"""Test the ``_settings_customize_instance`` keyword argument of
``_settings_build_values``."""

class SillySource(PydanticBaseSettingsSource):
def get_field_value(self, field: FieldInfo, field_name: str) -> Any:
pass

def __call__(self) -> Dict[str, Any]:
return {'whatever': 'it is'}

class SillySettings(BaseSettings):
whatever: str

def customize_null(_):
return tuple()

def customize_silly(_):
return (SillySource(SillySettings),)

# NOTE: Returning an empty tuple should result in an empty dict when the
# method is called. Test directly the use of the keyword argument.
instance = SillySettings(whatever='the heck')
data = instance._settings_build_values({}, _settings_customize_instance=customize_null)
assert not data

data = instance._settings_build_values({}, _settings_customize_instance=customize_silly)
assert data == {'whatever': 'it is'}

# NOTE: No sources == error
with pytest.raises(ValidationError) as err:
SillySettings(_settings_customize_instance=customize_null)

assert err.value.error_count() == 1

# NOTE: Init ignored, silly source used.
instance = SillySettings(whatever='the heck', _settings_customize_instance=customize_silly)
assert instance.whatever == 'it is'


def test_settings_customize_instance_env_only(env):
"""This is the example from the documentation added for this."""

class Settings(BaseSettings):
my_api_key: str

def settings_use_only_env(sources: tuple):
# Do not use unpacking, look for the ``EnvSettingSource`` since it might
# not be included if ``settings_customise_sources`` is defined.
_ = (source for source in sources if isinstance(source, EnvSettingsSource))

if (source_env := next(_, None)) is None:
raise ValueError(
'Could not find ``EnvSettingsSource``. Check that ``settings_customise_sources`` returns this source.'
)

return (source_env,)

# Should fail if not specified in env, even with init argument defined.
env.set('MY_API_KEY', '5678efgh')
settings = Settings(
my_api_key='1234abcd',
_settings_customize_instance=settings_use_only_env,
)
assert settings.my_api_key == '5678efgh'

# Should work normally without specifying
settings = Settings(my_api_key='1234abcd')
assert settings.my_api_key == '1234abcd'
Loading