234 lines
8.6 KiB
Python
234 lines
8.6 KiB
Python
import os
|
|
import warnings
|
|
from pathlib import Path
|
|
from typing import AbstractSet, Any, Callable, Dict, List, Mapping, Optional, Tuple, Union
|
|
|
|
from .fields import ModelField
|
|
from .main import BaseConfig, BaseModel, Extra
|
|
from .typing import display_as_type
|
|
from .utils import deep_update, path_type, sequence_like
|
|
|
|
env_file_sentinel = str(object())
|
|
|
|
SettingsSourceCallable = Callable[['BaseSettings'], Dict[str, Any]]
|
|
|
|
|
|
class SettingsError(ValueError):
|
|
pass
|
|
|
|
|
|
class BaseSettings(BaseModel):
|
|
"""
|
|
Base class for settings, allowing values to be overridden by environment variables.
|
|
|
|
This is useful in production for secrets you do not wish to save in code, it plays nicely with docker(-compose),
|
|
Heroku and any 12 factor app design.
|
|
"""
|
|
|
|
def __init__(
|
|
__pydantic_self__,
|
|
_env_file: Union[Path, str, None] = env_file_sentinel,
|
|
_env_file_encoding: Optional[str] = None,
|
|
_secrets_dir: Union[Path, str, None] = None,
|
|
**values: Any,
|
|
) -> None:
|
|
# Uses something other than `self` the first arg to allow "self" as a settable attribute
|
|
super().__init__(
|
|
**__pydantic_self__._build_values(
|
|
values, _env_file=_env_file, _env_file_encoding=_env_file_encoding, _secrets_dir=_secrets_dir
|
|
)
|
|
)
|
|
|
|
def _build_values(
|
|
self,
|
|
init_kwargs: Dict[str, Any],
|
|
_env_file: Union[Path, str, None] = None,
|
|
_env_file_encoding: Optional[str] = None,
|
|
_secrets_dir: Union[Path, str, None] = None,
|
|
) -> Dict[str, Any]:
|
|
# Configure built-in sources
|
|
init_settings = InitSettingsSource(init_kwargs=init_kwargs)
|
|
env_settings = EnvSettingsSource(
|
|
env_file=(_env_file if _env_file != env_file_sentinel else self.__config__.env_file),
|
|
env_file_encoding=(
|
|
_env_file_encoding if _env_file_encoding is not None else self.__config__.env_file_encoding
|
|
),
|
|
)
|
|
file_secret_settings = SecretsSettingsSource(secrets_dir=_secrets_dir or self.__config__.secrets_dir)
|
|
# Provide a hook to set built-in sources priority and add / remove sources
|
|
sources = self.__config__.customise_sources(
|
|
init_settings=init_settings, env_settings=env_settings, file_secret_settings=file_secret_settings
|
|
)
|
|
if sources:
|
|
return deep_update(*reversed([source(self) for source in sources]))
|
|
else:
|
|
# no one should mean to do this, but I think returning an empty dict is marginally preferable
|
|
# to an informative error and much better than a confusing error
|
|
return {}
|
|
|
|
class Config(BaseConfig):
|
|
env_prefix = ''
|
|
env_file = None
|
|
env_file_encoding = None
|
|
secrets_dir = None
|
|
validate_all = True
|
|
extra = Extra.forbid
|
|
arbitrary_types_allowed = True
|
|
case_sensitive = False
|
|
|
|
@classmethod
|
|
def prepare_field(cls, field: ModelField) -> None:
|
|
env_names: Union[List[str], AbstractSet[str]]
|
|
field_info_from_config = cls.get_field_info(field.name)
|
|
|
|
env = field_info_from_config.get('env') or field.field_info.extra.get('env')
|
|
if env is None:
|
|
if field.has_alias:
|
|
warnings.warn(
|
|
'aliases are no longer used by BaseSettings to define which environment variables to read. '
|
|
'Instead use the "env" field setting. '
|
|
'See https://pydantic-docs.helpmanual.io/usage/settings/#environment-variable-names',
|
|
FutureWarning,
|
|
)
|
|
env_names = {cls.env_prefix + field.name}
|
|
elif isinstance(env, str):
|
|
env_names = {env}
|
|
elif isinstance(env, (set, frozenset)):
|
|
env_names = env
|
|
elif sequence_like(env):
|
|
env_names = list(env)
|
|
else:
|
|
raise TypeError(f'invalid field env: {env!r} ({display_as_type(env)}); should be string, list or set')
|
|
|
|
if not cls.case_sensitive:
|
|
env_names = env_names.__class__(n.lower() for n in env_names)
|
|
field.field_info.extra['env_names'] = env_names
|
|
|
|
@classmethod
|
|
def customise_sources(
|
|
cls,
|
|
init_settings: SettingsSourceCallable,
|
|
env_settings: SettingsSourceCallable,
|
|
file_secret_settings: SettingsSourceCallable,
|
|
) -> Tuple[SettingsSourceCallable, ...]:
|
|
return init_settings, env_settings, file_secret_settings
|
|
|
|
__config__: Config # type: ignore
|
|
|
|
|
|
class InitSettingsSource:
|
|
__slots__ = ('init_kwargs',)
|
|
|
|
def __init__(self, init_kwargs: Dict[str, Any]):
|
|
self.init_kwargs = init_kwargs
|
|
|
|
def __call__(self, settings: BaseSettings) -> Dict[str, Any]:
|
|
return self.init_kwargs
|
|
|
|
def __repr__(self) -> str:
|
|
return f'InitSettingsSource(init_kwargs={self.init_kwargs!r})'
|
|
|
|
|
|
class EnvSettingsSource:
|
|
__slots__ = ('env_file', 'env_file_encoding')
|
|
|
|
def __init__(self, env_file: Union[Path, str, None], env_file_encoding: Optional[str]):
|
|
self.env_file: Union[Path, str, None] = env_file
|
|
self.env_file_encoding: Optional[str] = env_file_encoding
|
|
|
|
def __call__(self, settings: BaseSettings) -> Dict[str, Any]:
|
|
"""
|
|
Build environment variables suitable for passing to the Model.
|
|
"""
|
|
d: Dict[str, Optional[str]] = {}
|
|
|
|
if settings.__config__.case_sensitive:
|
|
env_vars: Mapping[str, Optional[str]] = os.environ
|
|
else:
|
|
env_vars = {k.lower(): v for k, v in os.environ.items()}
|
|
|
|
if self.env_file is not None:
|
|
env_path = Path(self.env_file).expanduser()
|
|
if env_path.is_file():
|
|
env_vars = {
|
|
**read_env_file(
|
|
env_path, encoding=self.env_file_encoding, case_sensitive=settings.__config__.case_sensitive
|
|
),
|
|
**env_vars,
|
|
}
|
|
|
|
for field in settings.__fields__.values():
|
|
env_val: Optional[str] = None
|
|
for env_name in field.field_info.extra['env_names']:
|
|
env_val = env_vars.get(env_name)
|
|
if env_val is not None:
|
|
break
|
|
|
|
if env_val is None:
|
|
continue
|
|
|
|
if field.is_complex():
|
|
try:
|
|
env_val = settings.__config__.json_loads(env_val) # type: ignore
|
|
except ValueError as e:
|
|
raise SettingsError(f'error parsing JSON for "{env_name}"') from e
|
|
d[field.alias] = env_val
|
|
return d
|
|
|
|
def __repr__(self) -> str:
|
|
return f'EnvSettingsSource(env_file={self.env_file!r}, env_file_encoding={self.env_file_encoding!r})'
|
|
|
|
|
|
class SecretsSettingsSource:
|
|
__slots__ = ('secrets_dir',)
|
|
|
|
def __init__(self, secrets_dir: Union[Path, str, None]):
|
|
self.secrets_dir: Union[Path, str, None] = secrets_dir
|
|
|
|
def __call__(self, settings: BaseSettings) -> Dict[str, Any]:
|
|
"""
|
|
Build fields from "secrets" files.
|
|
"""
|
|
secrets: Dict[str, Optional[str]] = {}
|
|
|
|
if self.secrets_dir is None:
|
|
return secrets
|
|
|
|
secrets_path = Path(self.secrets_dir).expanduser()
|
|
|
|
if not secrets_path.exists():
|
|
warnings.warn(f'directory "{secrets_path}" does not exist')
|
|
return secrets
|
|
|
|
if not secrets_path.is_dir():
|
|
raise SettingsError(f'secrets_dir must reference a directory, not a {path_type(secrets_path)}')
|
|
|
|
for field in settings.__fields__.values():
|
|
for env_name in field.field_info.extra['env_names']:
|
|
path = secrets_path / env_name
|
|
if path.is_file():
|
|
secrets[field.alias] = path.read_text().strip()
|
|
elif path.exists():
|
|
warnings.warn(
|
|
f'attempted to load secret file "{path}" but found a {path_type(path)} instead.',
|
|
stacklevel=4,
|
|
)
|
|
|
|
return secrets
|
|
|
|
def __repr__(self) -> str:
|
|
return f'SecretsSettingsSource(secrets_dir={self.secrets_dir!r})'
|
|
|
|
|
|
def read_env_file(file_path: Path, *, encoding: str = None, case_sensitive: bool = False) -> Dict[str, Optional[str]]:
|
|
try:
|
|
from dotenv import dotenv_values
|
|
except ImportError as e:
|
|
raise ImportError('python-dotenv is not installed, run `pip install pydantic[dotenv]`') from e
|
|
|
|
file_vars: Dict[str, Optional[str]] = dotenv_values(file_path, encoding=encoding or 'utf8')
|
|
if not case_sensitive:
|
|
return {k.lower(): v for k, v in file_vars.items()}
|
|
else:
|
|
return file_vars
|