base.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. """Base classes and core functionality for pydantic-settings sources."""
  2. from __future__ import annotations as _annotations
  3. import json
  4. import os
  5. from abc import ABC, abstractmethod
  6. from dataclasses import asdict, is_dataclass
  7. from pathlib import Path
  8. from typing import TYPE_CHECKING, Any, Optional, cast
  9. from pydantic import AliasChoices, AliasPath, BaseModel, TypeAdapter
  10. from pydantic._internal._typing_extra import ( # type: ignore[attr-defined]
  11. get_origin,
  12. )
  13. from pydantic._internal._utils import is_model_class
  14. from pydantic.fields import FieldInfo
  15. from typing_extensions import get_args
  16. from typing_inspection.introspection import is_union_origin
  17. from ..exceptions import SettingsError
  18. from ..utils import _lenient_issubclass
  19. from .types import EnvNoneType, ForceDecode, NoDecode, PathType, PydanticModel, _CliSubCommand
  20. from .utils import (
  21. _annotation_is_complex,
  22. _get_alias_names,
  23. _get_model_fields,
  24. _union_is_complex,
  25. )
  26. if TYPE_CHECKING:
  27. from pydantic_settings.main import BaseSettings
  28. def get_subcommand(
  29. model: PydanticModel, is_required: bool = True, cli_exit_on_error: bool | None = None
  30. ) -> Optional[PydanticModel]:
  31. """
  32. Get the subcommand from a model.
  33. Args:
  34. model: The model to get the subcommand from.
  35. is_required: Determines whether a model must have subcommand set and raises error if not
  36. found. Defaults to `True`.
  37. cli_exit_on_error: Determines whether this function exits with error if no subcommand is found.
  38. Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`.
  39. Returns:
  40. The subcommand model if found, otherwise `None`.
  41. Raises:
  42. SystemExit: When no subcommand is found and is_required=`True` and cli_exit_on_error=`True`
  43. (the default).
  44. SettingsError: When no subcommand is found and is_required=`True` and
  45. cli_exit_on_error=`False`.
  46. """
  47. model_cls = type(model)
  48. if cli_exit_on_error is None and is_model_class(model_cls):
  49. model_default = model_cls.model_config.get('cli_exit_on_error')
  50. if isinstance(model_default, bool):
  51. cli_exit_on_error = model_default
  52. if cli_exit_on_error is None:
  53. cli_exit_on_error = True
  54. subcommands: list[str] = []
  55. for field_name, field_info in _get_model_fields(model_cls).items():
  56. if _CliSubCommand in field_info.metadata:
  57. if getattr(model, field_name) is not None:
  58. return getattr(model, field_name)
  59. subcommands.append(field_name)
  60. if is_required:
  61. error_message = (
  62. f'Error: CLI subcommand is required {{{", ".join(subcommands)}}}'
  63. if subcommands
  64. else 'Error: CLI subcommand is required but no subcommands were found.'
  65. )
  66. raise SystemExit(error_message) if cli_exit_on_error else SettingsError(error_message)
  67. return None
  68. class PydanticBaseSettingsSource(ABC):
  69. """
  70. Abstract base class for settings sources, every settings source classes should inherit from it.
  71. """
  72. def __init__(self, settings_cls: type[BaseSettings]):
  73. self.settings_cls = settings_cls
  74. self.config = settings_cls.model_config
  75. self._current_state: dict[str, Any] = {}
  76. self._settings_sources_data: dict[str, dict[str, Any]] = {}
  77. def _set_current_state(self, state: dict[str, Any]) -> None:
  78. """
  79. Record the state of settings from the previous settings sources. This should
  80. be called right before __call__.
  81. """
  82. self._current_state = state
  83. def _set_settings_sources_data(self, states: dict[str, dict[str, Any]]) -> None:
  84. """
  85. Record the state of settings from all previous settings sources. This should
  86. be called right before __call__.
  87. """
  88. self._settings_sources_data = states
  89. @property
  90. def current_state(self) -> dict[str, Any]:
  91. """
  92. The current state of the settings, populated by the previous settings sources.
  93. """
  94. return self._current_state
  95. @property
  96. def settings_sources_data(self) -> dict[str, dict[str, Any]]:
  97. """
  98. The state of all previous settings sources.
  99. """
  100. return self._settings_sources_data
  101. @abstractmethod
  102. def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
  103. """
  104. Gets the value, the key for model creation, and a flag to determine whether value is complex.
  105. This is an abstract method that should be overridden in every settings source classes.
  106. Args:
  107. field: The field.
  108. field_name: The field name.
  109. Returns:
  110. A tuple that contains the value, key and a flag to determine whether value is complex.
  111. """
  112. pass
  113. def field_is_complex(self, field: FieldInfo) -> bool:
  114. """
  115. Checks whether a field is complex, in which case it will attempt to be parsed as JSON.
  116. Args:
  117. field: The field.
  118. Returns:
  119. Whether the field is complex.
  120. """
  121. return _annotation_is_complex(field.annotation, field.metadata)
  122. def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any:
  123. """
  124. Prepares the value of a field.
  125. Args:
  126. field_name: The field name.
  127. field: The field.
  128. value: The value of the field that has to be prepared.
  129. value_is_complex: A flag to determine whether value is complex.
  130. Returns:
  131. The prepared value.
  132. """
  133. if value is not None and (self.field_is_complex(field) or value_is_complex):
  134. return self.decode_complex_value(field_name, field, value)
  135. return value
  136. def decode_complex_value(self, field_name: str, field: FieldInfo, value: Any) -> Any:
  137. """
  138. Decode the value for a complex field
  139. Args:
  140. field_name: The field name.
  141. field: The field.
  142. value: The value of the field that has to be prepared.
  143. Returns:
  144. The decoded value for further preparation
  145. """
  146. if field and (
  147. NoDecode in field.metadata
  148. or (self.config.get('enable_decoding') is False and ForceDecode not in field.metadata)
  149. ):
  150. return value
  151. return json.loads(value)
  152. @abstractmethod
  153. def __call__(self) -> dict[str, Any]:
  154. pass
  155. class ConfigFileSourceMixin(ABC):
  156. def _read_files(self, files: PathType | None) -> dict[str, Any]:
  157. if files is None:
  158. return {}
  159. if isinstance(files, (str, os.PathLike)):
  160. files = [files]
  161. vars: dict[str, Any] = {}
  162. for file in files:
  163. file_path = Path(file).expanduser()
  164. if file_path.is_file():
  165. vars.update(self._read_file(file_path))
  166. return vars
  167. @abstractmethod
  168. def _read_file(self, path: Path) -> dict[str, Any]:
  169. pass
  170. class DefaultSettingsSource(PydanticBaseSettingsSource):
  171. """
  172. Source class for loading default object values.
  173. Args:
  174. settings_cls: The Settings class.
  175. nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields.
  176. Defaults to `False`.
  177. """
  178. def __init__(self, settings_cls: type[BaseSettings], nested_model_default_partial_update: bool | None = None):
  179. super().__init__(settings_cls)
  180. self.defaults: dict[str, Any] = {}
  181. self.nested_model_default_partial_update = (
  182. nested_model_default_partial_update
  183. if nested_model_default_partial_update is not None
  184. else self.config.get('nested_model_default_partial_update', False)
  185. )
  186. if self.nested_model_default_partial_update:
  187. for field_name, field_info in settings_cls.model_fields.items():
  188. alias_names, *_ = _get_alias_names(field_name, field_info)
  189. preferred_alias = alias_names[0]
  190. if is_dataclass(type(field_info.default)):
  191. self.defaults[preferred_alias] = asdict(field_info.default)
  192. elif is_model_class(type(field_info.default)):
  193. self.defaults[preferred_alias] = field_info.default.model_dump()
  194. def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
  195. # Nothing to do here. Only implement the return statement to make mypy happy
  196. return None, '', False
  197. def __call__(self) -> dict[str, Any]:
  198. return self.defaults
  199. def __repr__(self) -> str:
  200. return (
  201. f'{self.__class__.__name__}(nested_model_default_partial_update={self.nested_model_default_partial_update})'
  202. )
  203. class InitSettingsSource(PydanticBaseSettingsSource):
  204. """
  205. Source class for loading values provided during settings class initialization.
  206. """
  207. def __init__(
  208. self,
  209. settings_cls: type[BaseSettings],
  210. init_kwargs: dict[str, Any],
  211. nested_model_default_partial_update: bool | None = None,
  212. ):
  213. self.init_kwargs = {}
  214. init_kwarg_names = set(init_kwargs.keys())
  215. for field_name, field_info in settings_cls.model_fields.items():
  216. alias_names, *_ = _get_alias_names(field_name, field_info)
  217. init_kwarg_name = init_kwarg_names & set(alias_names)
  218. if init_kwarg_name:
  219. preferred_alias = alias_names[0]
  220. init_kwarg_names -= init_kwarg_name
  221. self.init_kwargs[preferred_alias] = init_kwargs[init_kwarg_name.pop()]
  222. self.init_kwargs.update({key: val for key, val in init_kwargs.items() if key in init_kwarg_names})
  223. super().__init__(settings_cls)
  224. self.nested_model_default_partial_update = (
  225. nested_model_default_partial_update
  226. if nested_model_default_partial_update is not None
  227. else self.config.get('nested_model_default_partial_update', False)
  228. )
  229. def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
  230. # Nothing to do here. Only implement the return statement to make mypy happy
  231. return None, '', False
  232. def __call__(self) -> dict[str, Any]:
  233. return (
  234. TypeAdapter(dict[str, Any]).dump_python(self.init_kwargs)
  235. if self.nested_model_default_partial_update
  236. else self.init_kwargs
  237. )
  238. def __repr__(self) -> str:
  239. return f'{self.__class__.__name__}(init_kwargs={self.init_kwargs!r})'
  240. class PydanticBaseEnvSettingsSource(PydanticBaseSettingsSource):
  241. def __init__(
  242. self,
  243. settings_cls: type[BaseSettings],
  244. case_sensitive: bool | None = None,
  245. env_prefix: str | None = None,
  246. env_ignore_empty: bool | None = None,
  247. env_parse_none_str: str | None = None,
  248. env_parse_enums: bool | None = None,
  249. ) -> None:
  250. super().__init__(settings_cls)
  251. self.case_sensitive = case_sensitive if case_sensitive is not None else self.config.get('case_sensitive', False)
  252. self.env_prefix = env_prefix if env_prefix is not None else self.config.get('env_prefix', '')
  253. self.env_ignore_empty = (
  254. env_ignore_empty if env_ignore_empty is not None else self.config.get('env_ignore_empty', False)
  255. )
  256. self.env_parse_none_str = (
  257. env_parse_none_str if env_parse_none_str is not None else self.config.get('env_parse_none_str')
  258. )
  259. self.env_parse_enums = env_parse_enums if env_parse_enums is not None else self.config.get('env_parse_enums')
  260. def _apply_case_sensitive(self, value: str) -> str:
  261. return value.lower() if not self.case_sensitive else value
  262. def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]:
  263. """
  264. Extracts field info. This info is used to get the value of field from environment variables.
  265. It returns a list of tuples, each tuple contains:
  266. * field_key: The key of field that has to be used in model creation.
  267. * env_name: The environment variable name of the field.
  268. * value_is_complex: A flag to determine whether the value from environment variable
  269. is complex and has to be parsed.
  270. Args:
  271. field (FieldInfo): The field.
  272. field_name (str): The field name.
  273. Returns:
  274. list[tuple[str, str, bool]]: List of tuples, each tuple contains field_key, env_name, and value_is_complex.
  275. """
  276. field_info: list[tuple[str, str, bool]] = []
  277. if isinstance(field.validation_alias, (AliasChoices, AliasPath)):
  278. v_alias: str | list[str | int] | list[list[str | int]] | None = field.validation_alias.convert_to_aliases()
  279. else:
  280. v_alias = field.validation_alias
  281. if v_alias:
  282. if isinstance(v_alias, list): # AliasChoices, AliasPath
  283. for alias in v_alias:
  284. if isinstance(alias, str): # AliasPath
  285. field_info.append((alias, self._apply_case_sensitive(alias), True if len(alias) > 1 else False))
  286. elif isinstance(alias, list): # AliasChoices
  287. first_arg = cast(str, alias[0]) # first item of an AliasChoices must be a str
  288. field_info.append(
  289. (first_arg, self._apply_case_sensitive(first_arg), True if len(alias) > 1 else False)
  290. )
  291. else: # string validation alias
  292. field_info.append((v_alias, self._apply_case_sensitive(v_alias), False))
  293. if not v_alias or self.config.get('populate_by_name', False):
  294. if is_union_origin(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata):
  295. field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), True))
  296. else:
  297. field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), False))
  298. return field_info
  299. def _replace_field_names_case_insensitively(self, field: FieldInfo, field_values: dict[str, Any]) -> dict[str, Any]:
  300. """
  301. Replace field names in values dict by looking in models fields insensitively.
  302. By having the following models:
  303. ```py
  304. class SubSubSub(BaseModel):
  305. VaL3: str
  306. class SubSub(BaseModel):
  307. Val2: str
  308. SUB_sub_SuB: SubSubSub
  309. class Sub(BaseModel):
  310. VAL1: str
  311. SUB_sub: SubSub
  312. class Settings(BaseSettings):
  313. nested: Sub
  314. model_config = SettingsConfigDict(env_nested_delimiter='__')
  315. ```
  316. Then:
  317. _replace_field_names_case_insensitively(
  318. field,
  319. {"val1": "v1", "sub_SUB": {"VAL2": "v2", "sub_SUB_sUb": {"vAl3": "v3"}}}
  320. )
  321. Returns {'VAL1': 'v1', 'SUB_sub': {'Val2': 'v2', 'SUB_sub_SuB': {'VaL3': 'v3'}}}
  322. """
  323. values: dict[str, Any] = {}
  324. for name, value in field_values.items():
  325. sub_model_field: FieldInfo | None = None
  326. annotation = field.annotation
  327. # If field is Optional, we need to find the actual type
  328. if is_union_origin(get_origin(field.annotation)):
  329. args = get_args(annotation)
  330. if len(args) == 2 and type(None) in args:
  331. for arg in args:
  332. if arg is not None:
  333. annotation = arg
  334. break
  335. # This is here to make mypy happy
  336. # Item "None" of "Optional[Type[Any]]" has no attribute "model_fields"
  337. if not annotation or not hasattr(annotation, 'model_fields'):
  338. values[name] = value
  339. continue
  340. # Find field in sub model by looking in fields case insensitively
  341. for sub_model_field_name, f in annotation.model_fields.items():
  342. if not f.validation_alias and sub_model_field_name.lower() == name.lower():
  343. sub_model_field = f
  344. break
  345. if not sub_model_field:
  346. values[name] = value
  347. continue
  348. if _lenient_issubclass(sub_model_field.annotation, BaseModel) and isinstance(value, dict):
  349. values[sub_model_field_name] = self._replace_field_names_case_insensitively(sub_model_field, value)
  350. else:
  351. values[sub_model_field_name] = value
  352. return values
  353. def _replace_env_none_type_values(self, field_value: dict[str, Any]) -> dict[str, Any]:
  354. """
  355. Recursively parse values that are of "None" type(EnvNoneType) to `None` type(None).
  356. """
  357. values: dict[str, Any] = {}
  358. for key, value in field_value.items():
  359. if not isinstance(value, EnvNoneType):
  360. values[key] = value if not isinstance(value, dict) else self._replace_env_none_type_values(value)
  361. else:
  362. values[key] = None
  363. return values
  364. def _get_resolved_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
  365. """
  366. Gets the value, the preferred alias key for model creation, and a flag to determine whether value
  367. is complex.
  368. Note:
  369. In V3, this method should either be made public, or, this method should be removed and the
  370. abstract method get_field_value should be updated to include a "use_preferred_alias" flag.
  371. Args:
  372. field: The field.
  373. field_name: The field name.
  374. Returns:
  375. A tuple that contains the value, preferred key and a flag to determine whether value is complex.
  376. """
  377. field_value, field_key, value_is_complex = self.get_field_value(field, field_name)
  378. if not (value_is_complex or (self.config.get('populate_by_name', False) and (field_key == field_name))):
  379. field_infos = self._extract_field_info(field, field_name)
  380. preferred_key, *_ = field_infos[0]
  381. return field_value, preferred_key, value_is_complex
  382. return field_value, field_key, value_is_complex
  383. def __call__(self) -> dict[str, Any]:
  384. data: dict[str, Any] = {}
  385. for field_name, field in self.settings_cls.model_fields.items():
  386. try:
  387. field_value, field_key, value_is_complex = self._get_resolved_field_value(field, field_name)
  388. except Exception as e:
  389. raise SettingsError(
  390. f'error getting value for field "{field_name}" from source "{self.__class__.__name__}"'
  391. ) from e
  392. try:
  393. field_value = self.prepare_field_value(field_name, field, field_value, value_is_complex)
  394. except ValueError as e:
  395. raise SettingsError(
  396. f'error parsing value for field "{field_name}" from source "{self.__class__.__name__}"'
  397. ) from e
  398. if field_value is not None:
  399. if self.env_parse_none_str is not None:
  400. if isinstance(field_value, dict):
  401. field_value = self._replace_env_none_type_values(field_value)
  402. elif isinstance(field_value, EnvNoneType):
  403. field_value = None
  404. if (
  405. not self.case_sensitive
  406. # and _lenient_issubclass(field.annotation, BaseModel)
  407. and isinstance(field_value, dict)
  408. ):
  409. data[field_key] = self._replace_field_names_case_insensitively(field, field_value)
  410. else:
  411. data[field_key] = field_value
  412. return data
  413. __all__ = [
  414. 'ConfigFileSourceMixin',
  415. 'DefaultSettingsSource',
  416. 'InitSettingsSource',
  417. 'PydanticBaseEnvSettingsSource',
  418. 'PydanticBaseSettingsSource',
  419. 'SettingsError',
  420. ]