├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── docs ├── extra │ ├── terminal.css │ └── tweaks.css ├── favicon.png ├── index.md ├── logo-white.svg └── theme │ └── main.html ├── mkdocs.yml ├── pydantic_settings ├── __init__.py ├── exceptions.py ├── main.py ├── py.typed ├── sources │ ├── __init__.py │ ├── base.py │ ├── providers │ │ ├── __init__.py │ │ ├── aws.py │ │ ├── azure.py │ │ ├── cli.py │ │ ├── dotenv.py │ │ ├── env.py │ │ ├── gcp.py │ │ ├── json.py │ │ ├── pyproject.py │ │ ├── secrets.py │ │ ├── toml.py │ │ └── yaml.py │ ├── types.py │ └── utils.py ├── utils.py └── version.py ├── pyproject.toml ├── tests ├── conftest.py ├── example_test_config.json ├── test_docs.py ├── test_settings.py ├── test_source_aws_secrets_manager.py ├── test_source_azure_key_vault.py ├── test_source_cli.py ├── test_source_gcp_secret_manager.py ├── test_source_json.py ├── test_source_pyproject_toml.py ├── test_source_toml.py ├── test_source_yaml.py └── test_utils.py └── uv.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: samuelcolvin 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: {} 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: set up uv 19 | uses: astral-sh/setup-uv@v6 20 | with: 21 | python-version: '3.12' 22 | 23 | - name: Install dependencies 24 | # Installing pip is required for the pre-commit action: 25 | run: | 26 | uv sync --group linting --all-extras 27 | uv pip install pip 28 | 29 | - uses: pre-commit/action@v3.0.1 30 | with: 31 | extra_args: --all-files 32 | 33 | test: 34 | name: test py${{ matrix.python }} on ${{ matrix.os }} 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | os: [ubuntu-latest, macos-latest, windows-latest] 39 | python: ['3.9', '3.10', '3.11', '3.12', '3.13'] 40 | 41 | env: 42 | PYTHON: ${{ matrix.python }} 43 | OS: ${{ matrix.os }} 44 | UV_PYTHON_PREFERENCE: only-managed 45 | 46 | runs-on: ${{ matrix.os }} 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | 51 | - name: set up uv 52 | uses: astral-sh/setup-uv@v6 53 | with: 54 | python-version: ${{ matrix.python }} 55 | 56 | - name: Install dependencies 57 | run: | 58 | uv sync --group testing --all-extras 59 | 60 | - run: mkdir coverage 61 | 62 | - name: test 63 | run: make test 64 | env: 65 | COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python }} 66 | CONTEXT: ${{ runner.os }}-py${{ matrix.python }} 67 | 68 | - name: uninstall deps 69 | run: uv pip uninstall PyYAML 70 | 71 | - name: test without deps 72 | run: make test 73 | env: 74 | COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python }}-without-deps 75 | CONTEXT: ${{ runner.os }}-py${{ matrix.python }}-without-deps 76 | 77 | - name: store coverage files 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: coverage-${{ matrix.python }}-${{ runner.os }} 81 | path: coverage 82 | include-hidden-files: true 83 | 84 | coverage: 85 | runs-on: ubuntu-latest 86 | needs: [test] 87 | steps: 88 | - uses: actions/checkout@v4 89 | with: 90 | # needed for diff-cover 91 | fetch-depth: 0 92 | 93 | - name: get coverage files 94 | uses: actions/download-artifact@v4 95 | with: 96 | merge-multiple: true 97 | path: coverage 98 | 99 | - uses: astral-sh/setup-uv@v5 100 | with: 101 | enable-cache: true 102 | 103 | - run: uv sync --group testing --all-extras 104 | 105 | - run: uv run coverage combine coverage 106 | 107 | - run: uv run coverage html --show-contexts --title "Pydantic Settings coverage for ${{ github.sha }}" 108 | 109 | - name: Store coverage html 110 | uses: actions/upload-artifact@v4 111 | with: 112 | name: coverage-html 113 | path: htmlcov 114 | include-hidden-files: true 115 | 116 | - run: uv run coverage xml 117 | 118 | - run: uv run diff-cover coverage.xml --html-report index.html 119 | 120 | - name: Store diff coverage html 121 | uses: actions/upload-artifact@v4 122 | with: 123 | name: diff-coverage-html 124 | path: index.html 125 | 126 | - run: uv run coverage report --fail-under 98 127 | 128 | check: # This job does nothing and is only used for the branch protection 129 | if: always() 130 | needs: [lint, test, coverage] 131 | runs-on: ubuntu-latest 132 | 133 | outputs: 134 | result: ${{ steps.all-green.outputs.result }} 135 | 136 | steps: 137 | - name: Decide whether the needed jobs succeeded or failed 138 | uses: re-actors/alls-green@release/v1 139 | id: all-green 140 | with: 141 | jobs: ${{ toJSON(needs) }} 142 | 143 | release: 144 | needs: [check] 145 | if: needs.check.outputs.result == 'success' && startsWith(github.ref, 'refs/tags/') 146 | runs-on: ubuntu-latest 147 | environment: release 148 | 149 | permissions: 150 | id-token: write 151 | 152 | steps: 153 | - uses: actions/checkout@v4 154 | 155 | - uses: actions/setup-python@v5 156 | with: 157 | python-version: '3.12' 158 | 159 | - name: Install 'build' library 160 | run: pip install -U build 161 | 162 | - name: Check version 163 | id: check-tag 164 | uses: samuelcolvin/check-python-version@v4.1 165 | with: 166 | version_file_path: pydantic_settings/version.py 167 | 168 | - name: Build library 169 | run: python -m build 170 | 171 | - name: Upload package to PyPI 172 | uses: pypa/gh-action-pypi-publish@release/v1 173 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | env/ 3 | .envrc 4 | venv/ 5 | .venv/ 6 | env3*/ 7 | Pipfile 8 | *.lock 9 | !uv.lock 10 | *.py[cod] 11 | *.egg-info/ 12 | /build/ 13 | dist/ 14 | .cache/ 15 | .mypy_cache/ 16 | test.py 17 | .coverage 18 | .hypothesis 19 | /htmlcov/ 20 | /site/ 21 | /site.zip 22 | .pytest_cache/ 23 | .python-version 24 | .vscode/ 25 | _build/ 26 | .auto-format 27 | /sandbox/ 28 | /.ghtopdep_cache/ 29 | /worktrees/ 30 | /.ruff_cache/ 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.3.0 4 | hooks: 5 | - id: check-yaml 6 | args: ['--unsafe'] 7 | - id: check-toml 8 | - id: end-of-file-fixer 9 | - id: trailing-whitespace 10 | 11 | - repo: local 12 | hooks: 13 | - id: lint 14 | name: Lint 15 | entry: make lint 16 | types: [python] 17 | language: system 18 | pass_filenames: false 19 | - id: mypy 20 | name: Mypy 21 | entry: make mypy 22 | types: [python] 23 | language: system 24 | pass_filenames: false 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Samuel Colvin and other contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := all 2 | sources = pydantic_settings tests 3 | 4 | .PHONY: install 5 | install: 6 | uv sync --all-extras --all-groups 7 | 8 | .PHONY: refresh-lockfiles 9 | refresh-lockfiles: 10 | @echo "Updating uv.lock file" 11 | uv lock -U 12 | 13 | .PHONY: format 14 | format: 15 | uv run ruff check --fix $(sources) 16 | uv run ruff format $(sources) 17 | 18 | .PHONY: lint 19 | lint: 20 | uv run ruff check $(sources) 21 | uv run ruff format --check $(sources) 22 | 23 | .PHONY: mypy 24 | mypy: 25 | uv run mypy pydantic_settings 26 | 27 | .PHONY: test 28 | test: 29 | uv run coverage run -m pytest --durations=10 30 | 31 | .PHONY: testcov 32 | testcov: test 33 | @echo "building coverage html" 34 | @uv run coverage html 35 | 36 | .PHONY: all 37 | all: lint mypy testcov 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pydantic-settings 2 | 3 | [![CI](https://github.com/pydantic/pydantic-settings/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/pydantic/pydantic-settings/actions/workflows/ci.yml?query=branch%3Amain) 4 | [![Coverage](https://codecov.io/gh/pydantic/pydantic-settings/branch/main/graph/badge.svg)](https://codecov.io/gh/pydantic/pydantic-settings) 5 | [![pypi](https://img.shields.io/pypi/v/pydantic-settings.svg)](https://pypi.python.org/pypi/pydantic-settings) 6 | [![license](https://img.shields.io/github/license/pydantic/pydantic-settings.svg)](https://github.com/pydantic/pydantic-settings/blob/main/LICENSE) 7 | [![downloads](https://static.pepy.tech/badge/pydantic-settings/month)](https://pepy.tech/project/pydantic-settings) 8 | [![versions](https://img.shields.io/pypi/pyversions/pydantic-settings.svg)](https://github.com/pydantic/pydantic-settings) 9 | 10 | Settings management using Pydantic. 11 | 12 | See [documentation](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for more details. 13 | -------------------------------------------------------------------------------- /docs/extra/terminal.css: -------------------------------------------------------------------------------- 1 | .terminal { 2 | background: #300a24; 3 | border-radius: 4px; 4 | padding: 5px 10px; 5 | } 6 | 7 | pre.terminal-content { 8 | display: inline-block; 9 | line-height: 1.3 !important; 10 | white-space: pre-wrap; 11 | word-wrap: break-word; 12 | background: #300a24 !important; 13 | color: #d0d0d0 !important; 14 | } 15 | 16 | .ansi2 { 17 | font-weight: lighter; 18 | } 19 | .ansi3 { 20 | font-style: italic; 21 | } 22 | .ansi32 { 23 | color: #00aa00; 24 | } 25 | .ansi34 { 26 | color: #5656fe; 27 | } 28 | .ansi35 { 29 | color: #E850A8; 30 | } 31 | .ansi38-1 { 32 | color: #cf0000; 33 | } 34 | .ansi38-5 { 35 | color: #E850A8; 36 | } 37 | .ansi38-68 { 38 | color: #2a54a8; 39 | } 40 | -------------------------------------------------------------------------------- /docs/extra/tweaks.css: -------------------------------------------------------------------------------- 1 | .sponsors { 2 | display: flex; 3 | justify-content: center; 4 | flex-wrap: wrap; 5 | align-items: center; 6 | margin: 1rem 0; 7 | } 8 | 9 | .sponsors > div { 10 | text-align: center; 11 | width: 33%; 12 | padding-bottom: 20px; 13 | } 14 | 15 | .sponsors span { 16 | display: block; 17 | } 18 | 19 | @media screen and (max-width: 599px) { 20 | .sponsors span { 21 | display: none; 22 | } 23 | } 24 | 25 | .sponsors img { 26 | width: 65%; 27 | border-radius: 5px; 28 | } 29 | 30 | /*blog post*/ 31 | aside.blog { 32 | display: flex; 33 | align-items: center; 34 | } 35 | 36 | aside.blog img { 37 | width: 50px; 38 | height: 50px; 39 | border-radius: 25px; 40 | margin-right: 20px; 41 | } 42 | 43 | /* Define the company grid layout */ 44 | 45 | #grid-container { 46 | width: 100%; 47 | text-align: center; 48 | } 49 | 50 | #company-grid { 51 | display: inline-block; 52 | margin: 0 auto; 53 | gap: 10px; 54 | align-content: center; 55 | justify-content: center; 56 | grid-auto-flow: column; 57 | } 58 | 59 | [data-md-color-scheme="slate"] #company-grid { 60 | background-color: #ffffff; 61 | border-radius: .5rem; 62 | } 63 | 64 | .tile { 65 | display: flex; 66 | text-align: center; 67 | width: 120px; 68 | height: 120px; 69 | display: inline-block; 70 | margin: 10px; 71 | padding: 5px; 72 | border-radius: .5rem; 73 | } 74 | 75 | .tile img { 76 | width: 100px; 77 | } 78 | -------------------------------------------------------------------------------- /docs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydantic/pydantic-settings/e9f7994872ebcd7a284d98d0ed501cc314a6a7fa/docs/favicon.png -------------------------------------------------------------------------------- /docs/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /docs/theme/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 | {% include 'announce.html' ignore missing %} 5 | {% endblock %} 6 | 7 | {% block content %} 8 | {{ super() }} 9 | 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: pydantic 2 | site_description: Data validation using Python type hints 3 | strict: true 4 | site_url: https://docs.pydantic.dev/ 5 | 6 | theme: 7 | name: 'material' 8 | custom_dir: 'docs/theme' 9 | palette: 10 | - media: "(prefers-color-scheme: light)" 11 | scheme: default 12 | primary: pink 13 | accent: pink 14 | toggle: 15 | icon: material/lightbulb-outline 16 | name: "Switch to dark mode" 17 | - media: "(prefers-color-scheme: dark)" 18 | scheme: slate 19 | primary: pink 20 | accent: pink 21 | toggle: 22 | icon: material/lightbulb 23 | name: "Switch to light mode" 24 | features: 25 | - content.tabs.link 26 | - content.code.annotate 27 | - announce.dismiss 28 | - navigation.tabs 29 | logo: 'logo-white.svg' 30 | favicon: 'favicon.png' 31 | 32 | repo_name: pydantic/pydantic 33 | repo_url: https://github.com/pydantic/pydantic 34 | edit_uri: edit/main/docs/ 35 | 36 | extra_css: 37 | - 'extra/terminal.css' 38 | - 'extra/tweaks.css' 39 | 40 | nav: 41 | - index.md 42 | 43 | markdown_extensions: 44 | - tables 45 | - toc: 46 | permalink: true 47 | title: Page contents 48 | - admonition 49 | - pymdownx.highlight 50 | - pymdownx.extra 51 | - pymdownx.emoji: 52 | emoji_index: !!python/name:materialx.emoji.twemoji 53 | emoji_generator: !!python/name:materialx.emoji.to_svg 54 | - pymdownx.tabbed: 55 | alternate_style: true 56 | 57 | extra: 58 | version: 59 | provider: mike 60 | 61 | plugins: 62 | - mike: 63 | alias_type: symlink 64 | canonical_version: latest 65 | - search 66 | - exclude: 67 | glob: 68 | - __pycache__/* 69 | -------------------------------------------------------------------------------- /pydantic_settings/__init__.py: -------------------------------------------------------------------------------- 1 | from .exceptions import SettingsError 2 | from .main import BaseSettings, CliApp, SettingsConfigDict 3 | from .sources import ( 4 | CLI_SUPPRESS, 5 | AWSSecretsManagerSettingsSource, 6 | AzureKeyVaultSettingsSource, 7 | CliExplicitFlag, 8 | CliImplicitFlag, 9 | CliMutuallyExclusiveGroup, 10 | CliPositionalArg, 11 | CliSettingsSource, 12 | CliSubCommand, 13 | CliSuppress, 14 | CliUnknownArgs, 15 | DotEnvSettingsSource, 16 | EnvSettingsSource, 17 | ForceDecode, 18 | GoogleSecretManagerSettingsSource, 19 | InitSettingsSource, 20 | JsonConfigSettingsSource, 21 | NoDecode, 22 | PydanticBaseSettingsSource, 23 | PyprojectTomlConfigSettingsSource, 24 | SecretsSettingsSource, 25 | TomlConfigSettingsSource, 26 | YamlConfigSettingsSource, 27 | get_subcommand, 28 | ) 29 | from .version import VERSION 30 | 31 | __all__ = ( 32 | 'CLI_SUPPRESS', 33 | 'AWSSecretsManagerSettingsSource', 34 | 'AzureKeyVaultSettingsSource', 35 | 'BaseSettings', 36 | 'CliApp', 37 | 'CliExplicitFlag', 38 | 'CliImplicitFlag', 39 | 'CliMutuallyExclusiveGroup', 40 | 'CliPositionalArg', 41 | 'CliSettingsSource', 42 | 'CliSubCommand', 43 | 'CliSuppress', 44 | 'CliUnknownArgs', 45 | 'DotEnvSettingsSource', 46 | 'EnvSettingsSource', 47 | 'ForceDecode', 48 | 'GoogleSecretManagerSettingsSource', 49 | 'InitSettingsSource', 50 | 'JsonConfigSettingsSource', 51 | 'NoDecode', 52 | 'PydanticBaseSettingsSource', 53 | 'PyprojectTomlConfigSettingsSource', 54 | 'SecretsSettingsSource', 55 | 'SettingsConfigDict', 56 | 'SettingsError', 57 | 'TomlConfigSettingsSource', 58 | 'YamlConfigSettingsSource', 59 | '__version__', 60 | 'get_subcommand', 61 | ) 62 | 63 | __version__ = VERSION 64 | -------------------------------------------------------------------------------- /pydantic_settings/exceptions.py: -------------------------------------------------------------------------------- 1 | class SettingsError(ValueError): 2 | """Base exception for settings-related errors.""" 3 | 4 | pass 5 | -------------------------------------------------------------------------------- /pydantic_settings/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import asyncio 4 | import inspect 5 | import threading 6 | from argparse import Namespace 7 | from types import SimpleNamespace 8 | from typing import Any, ClassVar, TypeVar 9 | 10 | from pydantic import ConfigDict 11 | from pydantic._internal._config import config_keys 12 | from pydantic._internal._signature import _field_name_for_signature 13 | from pydantic._internal._utils import deep_update, is_model_class 14 | from pydantic.dataclasses import is_pydantic_dataclass 15 | from pydantic.main import BaseModel 16 | 17 | from .exceptions import SettingsError 18 | from .sources import ( 19 | ENV_FILE_SENTINEL, 20 | CliSettingsSource, 21 | DefaultSettingsSource, 22 | DotEnvSettingsSource, 23 | DotenvType, 24 | EnvSettingsSource, 25 | InitSettingsSource, 26 | PathType, 27 | PydanticBaseSettingsSource, 28 | PydanticModel, 29 | SecretsSettingsSource, 30 | get_subcommand, 31 | ) 32 | 33 | T = TypeVar('T') 34 | 35 | 36 | class SettingsConfigDict(ConfigDict, total=False): 37 | case_sensitive: bool 38 | nested_model_default_partial_update: bool | None 39 | env_prefix: str 40 | env_file: DotenvType | None 41 | env_file_encoding: str | None 42 | env_ignore_empty: bool 43 | env_nested_delimiter: str | None 44 | env_nested_max_split: int | None 45 | env_parse_none_str: str | None 46 | env_parse_enums: bool | None 47 | cli_prog_name: str | None 48 | cli_parse_args: bool | list[str] | tuple[str, ...] | None 49 | cli_parse_none_str: str | None 50 | cli_hide_none_type: bool 51 | cli_avoid_json: bool 52 | cli_enforce_required: bool 53 | cli_use_class_docs_for_groups: bool 54 | cli_exit_on_error: bool 55 | cli_prefix: str 56 | cli_flag_prefix_char: str 57 | cli_implicit_flags: bool | None 58 | cli_ignore_unknown_args: bool | None 59 | cli_kebab_case: bool | None 60 | secrets_dir: PathType | None 61 | json_file: PathType | None 62 | json_file_encoding: str | None 63 | yaml_file: PathType | None 64 | yaml_file_encoding: str | None 65 | yaml_config_section: str | None 66 | """ 67 | Specifies the top-level key in a YAML file from which to load the settings. 68 | If provided, the settings will be loaded from the nested section under this key. 69 | This is useful when the YAML file contains multiple configuration sections 70 | and you only want to load a specific subset into your settings model. 71 | """ 72 | 73 | pyproject_toml_depth: int 74 | """ 75 | Number of levels **up** from the current working directory to attempt to find a pyproject.toml 76 | file. 77 | 78 | This is only used when a pyproject.toml file is not found in the current working directory. 79 | """ 80 | 81 | pyproject_toml_table_header: tuple[str, ...] 82 | """ 83 | Header of the TOML table within a pyproject.toml file to use when filling variables. 84 | This is supplied as a `tuple[str, ...]` instead of a `str` to accommodate for headers 85 | containing a `.`. 86 | 87 | For example, `toml_table_header = ("tool", "my.tool", "foo")` can be used to fill variable 88 | values from a table with header `[tool."my.tool".foo]`. 89 | 90 | To use the root table, exclude this config setting or provide an empty tuple. 91 | """ 92 | 93 | toml_file: PathType | None 94 | enable_decoding: bool 95 | 96 | 97 | # Extend `config_keys` by pydantic settings config keys to 98 | # support setting config through class kwargs. 99 | # Pydantic uses `config_keys` in `pydantic._internal._config.ConfigWrapper.for_model` 100 | # to extract config keys from model kwargs, So, by adding pydantic settings keys to 101 | # `config_keys`, they will be considered as valid config keys and will be collected 102 | # by Pydantic. 103 | config_keys |= set(SettingsConfigDict.__annotations__.keys()) 104 | 105 | 106 | class BaseSettings(BaseModel): 107 | """ 108 | Base class for settings, allowing values to be overridden by environment variables. 109 | 110 | This is useful in production for secrets you do not wish to save in code, it plays nicely with docker(-compose), 111 | Heroku and any 12 factor app design. 112 | 113 | All the below attributes can be set via `model_config`. 114 | 115 | Args: 116 | _case_sensitive: Whether environment and CLI variable names should be read with case-sensitivity. 117 | Defaults to `None`. 118 | _nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields. 119 | Defaults to `False`. 120 | _env_prefix: Prefix for all environment variables. Defaults to `None`. 121 | _env_file: The env file(s) to load settings values from. Defaults to `Path('')`, which 122 | means that the value from `model_config['env_file']` should be used. You can also pass 123 | `None` to indicate that environment variables should not be loaded from an env file. 124 | _env_file_encoding: The env file encoding, e.g. `'latin-1'`. Defaults to `None`. 125 | _env_ignore_empty: Ignore environment variables where the value is an empty string. Default to `False`. 126 | _env_nested_delimiter: The nested env values delimiter. Defaults to `None`. 127 | _env_nested_max_split: The nested env values maximum nesting. Defaults to `None`, which means no limit. 128 | _env_parse_none_str: The env string value that should be parsed (e.g. "null", "void", "None", etc.) 129 | into `None` type(None). Defaults to `None` type(None), which means no parsing should occur. 130 | _env_parse_enums: Parse enum field names to values. Defaults to `None.`, which means no parsing should occur. 131 | _cli_prog_name: The CLI program name to display in help text. Defaults to `None` if _cli_parse_args is `None`. 132 | Otherwise, defaults to sys.argv[0]. 133 | _cli_parse_args: The list of CLI arguments to parse. Defaults to None. 134 | If set to `True`, defaults to sys.argv[1:]. 135 | _cli_settings_source: Override the default CLI settings source with a user defined instance. Defaults to None. 136 | _cli_parse_none_str: The CLI string value that should be parsed (e.g. "null", "void", "None", etc.) into 137 | `None` type(None). Defaults to _env_parse_none_str value if set. Otherwise, defaults to "null" if 138 | _cli_avoid_json is `False`, and "None" if _cli_avoid_json is `True`. 139 | _cli_hide_none_type: Hide `None` values in CLI help text. Defaults to `False`. 140 | _cli_avoid_json: Avoid complex JSON objects in CLI help text. Defaults to `False`. 141 | _cli_enforce_required: Enforce required fields at the CLI. Defaults to `False`. 142 | _cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions. 143 | Defaults to `False`. 144 | _cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs. 145 | Defaults to `True`. 146 | _cli_prefix: The root parser command line arguments prefix. Defaults to "". 147 | _cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'. 148 | _cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags. 149 | (e.g. --flag, --no-flag). Defaults to `False`. 150 | _cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`. 151 | _cli_kebab_case: CLI args use kebab case. Defaults to `False`. 152 | _secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`. 153 | """ 154 | 155 | def __init__( 156 | __pydantic_self__, 157 | _case_sensitive: bool | None = None, 158 | _nested_model_default_partial_update: bool | None = None, 159 | _env_prefix: str | None = None, 160 | _env_file: DotenvType | None = ENV_FILE_SENTINEL, 161 | _env_file_encoding: str | None = None, 162 | _env_ignore_empty: bool | None = None, 163 | _env_nested_delimiter: str | None = None, 164 | _env_nested_max_split: int | None = None, 165 | _env_parse_none_str: str | None = None, 166 | _env_parse_enums: bool | None = None, 167 | _cli_prog_name: str | None = None, 168 | _cli_parse_args: bool | list[str] | tuple[str, ...] | None = None, 169 | _cli_settings_source: CliSettingsSource[Any] | None = None, 170 | _cli_parse_none_str: str | None = None, 171 | _cli_hide_none_type: bool | None = None, 172 | _cli_avoid_json: bool | None = None, 173 | _cli_enforce_required: bool | None = None, 174 | _cli_use_class_docs_for_groups: bool | None = None, 175 | _cli_exit_on_error: bool | None = None, 176 | _cli_prefix: str | None = None, 177 | _cli_flag_prefix_char: str | None = None, 178 | _cli_implicit_flags: bool | None = None, 179 | _cli_ignore_unknown_args: bool | None = None, 180 | _cli_kebab_case: bool | None = None, 181 | _secrets_dir: PathType | None = None, 182 | **values: Any, 183 | ) -> None: 184 | super().__init__( 185 | **__pydantic_self__._settings_build_values( 186 | values, 187 | _case_sensitive=_case_sensitive, 188 | _nested_model_default_partial_update=_nested_model_default_partial_update, 189 | _env_prefix=_env_prefix, 190 | _env_file=_env_file, 191 | _env_file_encoding=_env_file_encoding, 192 | _env_ignore_empty=_env_ignore_empty, 193 | _env_nested_delimiter=_env_nested_delimiter, 194 | _env_nested_max_split=_env_nested_max_split, 195 | _env_parse_none_str=_env_parse_none_str, 196 | _env_parse_enums=_env_parse_enums, 197 | _cli_prog_name=_cli_prog_name, 198 | _cli_parse_args=_cli_parse_args, 199 | _cli_settings_source=_cli_settings_source, 200 | _cli_parse_none_str=_cli_parse_none_str, 201 | _cli_hide_none_type=_cli_hide_none_type, 202 | _cli_avoid_json=_cli_avoid_json, 203 | _cli_enforce_required=_cli_enforce_required, 204 | _cli_use_class_docs_for_groups=_cli_use_class_docs_for_groups, 205 | _cli_exit_on_error=_cli_exit_on_error, 206 | _cli_prefix=_cli_prefix, 207 | _cli_flag_prefix_char=_cli_flag_prefix_char, 208 | _cli_implicit_flags=_cli_implicit_flags, 209 | _cli_ignore_unknown_args=_cli_ignore_unknown_args, 210 | _cli_kebab_case=_cli_kebab_case, 211 | _secrets_dir=_secrets_dir, 212 | ) 213 | ) 214 | 215 | @classmethod 216 | def settings_customise_sources( 217 | cls, 218 | settings_cls: type[BaseSettings], 219 | init_settings: PydanticBaseSettingsSource, 220 | env_settings: PydanticBaseSettingsSource, 221 | dotenv_settings: PydanticBaseSettingsSource, 222 | file_secret_settings: PydanticBaseSettingsSource, 223 | ) -> tuple[PydanticBaseSettingsSource, ...]: 224 | """ 225 | Define the sources and their order for loading the settings values. 226 | 227 | Args: 228 | settings_cls: The Settings class. 229 | init_settings: The `InitSettingsSource` instance. 230 | env_settings: The `EnvSettingsSource` instance. 231 | dotenv_settings: The `DotEnvSettingsSource` instance. 232 | file_secret_settings: The `SecretsSettingsSource` instance. 233 | 234 | Returns: 235 | A tuple containing the sources and their order for loading the settings values. 236 | """ 237 | return init_settings, env_settings, dotenv_settings, file_secret_settings 238 | 239 | def _settings_build_values( 240 | self, 241 | init_kwargs: dict[str, Any], 242 | _case_sensitive: bool | None = None, 243 | _nested_model_default_partial_update: bool | None = None, 244 | _env_prefix: str | None = None, 245 | _env_file: DotenvType | None = None, 246 | _env_file_encoding: str | None = None, 247 | _env_ignore_empty: bool | None = None, 248 | _env_nested_delimiter: str | None = None, 249 | _env_nested_max_split: int | None = None, 250 | _env_parse_none_str: str | None = None, 251 | _env_parse_enums: bool | None = None, 252 | _cli_prog_name: str | None = None, 253 | _cli_parse_args: bool | list[str] | tuple[str, ...] | None = None, 254 | _cli_settings_source: CliSettingsSource[Any] | None = None, 255 | _cli_parse_none_str: str | None = None, 256 | _cli_hide_none_type: bool | None = None, 257 | _cli_avoid_json: bool | None = None, 258 | _cli_enforce_required: bool | None = None, 259 | _cli_use_class_docs_for_groups: bool | None = None, 260 | _cli_exit_on_error: bool | None = None, 261 | _cli_prefix: str | None = None, 262 | _cli_flag_prefix_char: str | None = None, 263 | _cli_implicit_flags: bool | None = None, 264 | _cli_ignore_unknown_args: bool | None = None, 265 | _cli_kebab_case: bool | None = None, 266 | _secrets_dir: PathType | None = None, 267 | ) -> dict[str, Any]: 268 | # Determine settings config values 269 | case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive') 270 | env_prefix = _env_prefix if _env_prefix is not None else self.model_config.get('env_prefix') 271 | nested_model_default_partial_update = ( 272 | _nested_model_default_partial_update 273 | if _nested_model_default_partial_update is not None 274 | else self.model_config.get('nested_model_default_partial_update') 275 | ) 276 | env_file = _env_file if _env_file != ENV_FILE_SENTINEL else self.model_config.get('env_file') 277 | env_file_encoding = ( 278 | _env_file_encoding if _env_file_encoding is not None else self.model_config.get('env_file_encoding') 279 | ) 280 | env_ignore_empty = ( 281 | _env_ignore_empty if _env_ignore_empty is not None else self.model_config.get('env_ignore_empty') 282 | ) 283 | env_nested_delimiter = ( 284 | _env_nested_delimiter 285 | if _env_nested_delimiter is not None 286 | else self.model_config.get('env_nested_delimiter') 287 | ) 288 | env_nested_max_split = ( 289 | _env_nested_max_split 290 | if _env_nested_max_split is not None 291 | else self.model_config.get('env_nested_max_split') 292 | ) 293 | env_parse_none_str = ( 294 | _env_parse_none_str if _env_parse_none_str is not None else self.model_config.get('env_parse_none_str') 295 | ) 296 | env_parse_enums = _env_parse_enums if _env_parse_enums is not None else self.model_config.get('env_parse_enums') 297 | 298 | cli_prog_name = _cli_prog_name if _cli_prog_name is not None else self.model_config.get('cli_prog_name') 299 | cli_parse_args = _cli_parse_args if _cli_parse_args is not None else self.model_config.get('cli_parse_args') 300 | cli_settings_source = ( 301 | _cli_settings_source if _cli_settings_source is not None else self.model_config.get('cli_settings_source') 302 | ) 303 | cli_parse_none_str = ( 304 | _cli_parse_none_str if _cli_parse_none_str is not None else self.model_config.get('cli_parse_none_str') 305 | ) 306 | cli_parse_none_str = cli_parse_none_str if not env_parse_none_str else env_parse_none_str 307 | cli_hide_none_type = ( 308 | _cli_hide_none_type if _cli_hide_none_type is not None else self.model_config.get('cli_hide_none_type') 309 | ) 310 | cli_avoid_json = _cli_avoid_json if _cli_avoid_json is not None else self.model_config.get('cli_avoid_json') 311 | cli_enforce_required = ( 312 | _cli_enforce_required 313 | if _cli_enforce_required is not None 314 | else self.model_config.get('cli_enforce_required') 315 | ) 316 | cli_use_class_docs_for_groups = ( 317 | _cli_use_class_docs_for_groups 318 | if _cli_use_class_docs_for_groups is not None 319 | else self.model_config.get('cli_use_class_docs_for_groups') 320 | ) 321 | cli_exit_on_error = ( 322 | _cli_exit_on_error if _cli_exit_on_error is not None else self.model_config.get('cli_exit_on_error') 323 | ) 324 | cli_prefix = _cli_prefix if _cli_prefix is not None else self.model_config.get('cli_prefix') 325 | cli_flag_prefix_char = ( 326 | _cli_flag_prefix_char 327 | if _cli_flag_prefix_char is not None 328 | else self.model_config.get('cli_flag_prefix_char') 329 | ) 330 | cli_implicit_flags = ( 331 | _cli_implicit_flags if _cli_implicit_flags is not None else self.model_config.get('cli_implicit_flags') 332 | ) 333 | cli_ignore_unknown_args = ( 334 | _cli_ignore_unknown_args 335 | if _cli_ignore_unknown_args is not None 336 | else self.model_config.get('cli_ignore_unknown_args') 337 | ) 338 | cli_kebab_case = _cli_kebab_case if _cli_kebab_case is not None else self.model_config.get('cli_kebab_case') 339 | 340 | secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') 341 | 342 | # Configure built-in sources 343 | default_settings = DefaultSettingsSource( 344 | self.__class__, nested_model_default_partial_update=nested_model_default_partial_update 345 | ) 346 | init_settings = InitSettingsSource( 347 | self.__class__, 348 | init_kwargs=init_kwargs, 349 | nested_model_default_partial_update=nested_model_default_partial_update, 350 | ) 351 | env_settings = EnvSettingsSource( 352 | self.__class__, 353 | case_sensitive=case_sensitive, 354 | env_prefix=env_prefix, 355 | env_nested_delimiter=env_nested_delimiter, 356 | env_nested_max_split=env_nested_max_split, 357 | env_ignore_empty=env_ignore_empty, 358 | env_parse_none_str=env_parse_none_str, 359 | env_parse_enums=env_parse_enums, 360 | ) 361 | dotenv_settings = DotEnvSettingsSource( 362 | self.__class__, 363 | env_file=env_file, 364 | env_file_encoding=env_file_encoding, 365 | case_sensitive=case_sensitive, 366 | env_prefix=env_prefix, 367 | env_nested_delimiter=env_nested_delimiter, 368 | env_nested_max_split=env_nested_max_split, 369 | env_ignore_empty=env_ignore_empty, 370 | env_parse_none_str=env_parse_none_str, 371 | env_parse_enums=env_parse_enums, 372 | ) 373 | 374 | file_secret_settings = SecretsSettingsSource( 375 | self.__class__, secrets_dir=secrets_dir, case_sensitive=case_sensitive, env_prefix=env_prefix 376 | ) 377 | # Provide a hook to set built-in sources priority and add / remove sources 378 | sources = self.settings_customise_sources( 379 | self.__class__, 380 | init_settings=init_settings, 381 | env_settings=env_settings, 382 | dotenv_settings=dotenv_settings, 383 | file_secret_settings=file_secret_settings, 384 | ) + (default_settings,) 385 | if not any([source for source in sources if isinstance(source, CliSettingsSource)]): 386 | if isinstance(cli_settings_source, CliSettingsSource): 387 | sources = (cli_settings_source,) + sources 388 | elif cli_parse_args is not None: 389 | cli_settings = CliSettingsSource[Any]( 390 | self.__class__, 391 | cli_prog_name=cli_prog_name, 392 | cli_parse_args=cli_parse_args, 393 | cli_parse_none_str=cli_parse_none_str, 394 | cli_hide_none_type=cli_hide_none_type, 395 | cli_avoid_json=cli_avoid_json, 396 | cli_enforce_required=cli_enforce_required, 397 | cli_use_class_docs_for_groups=cli_use_class_docs_for_groups, 398 | cli_exit_on_error=cli_exit_on_error, 399 | cli_prefix=cli_prefix, 400 | cli_flag_prefix_char=cli_flag_prefix_char, 401 | cli_implicit_flags=cli_implicit_flags, 402 | cli_ignore_unknown_args=cli_ignore_unknown_args, 403 | cli_kebab_case=cli_kebab_case, 404 | case_sensitive=case_sensitive, 405 | ) 406 | sources = (cli_settings,) + sources 407 | if sources: 408 | state: dict[str, Any] = {} 409 | states: dict[str, dict[str, Any]] = {} 410 | for source in sources: 411 | if isinstance(source, PydanticBaseSettingsSource): 412 | source._set_current_state(state) 413 | source._set_settings_sources_data(states) 414 | 415 | source_name = source.__name__ if hasattr(source, '__name__') else type(source).__name__ 416 | source_state = source() 417 | 418 | states[source_name] = source_state 419 | state = deep_update(source_state, state) 420 | return state 421 | else: 422 | # no one should mean to do this, but I think returning an empty dict is marginally preferable 423 | # to an informative error and much better than a confusing error 424 | return {} 425 | 426 | model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( 427 | extra='forbid', 428 | arbitrary_types_allowed=True, 429 | validate_default=True, 430 | case_sensitive=False, 431 | env_prefix='', 432 | nested_model_default_partial_update=False, 433 | env_file=None, 434 | env_file_encoding=None, 435 | env_ignore_empty=False, 436 | env_nested_delimiter=None, 437 | env_nested_max_split=None, 438 | env_parse_none_str=None, 439 | env_parse_enums=None, 440 | cli_prog_name=None, 441 | cli_parse_args=None, 442 | cli_parse_none_str=None, 443 | cli_hide_none_type=False, 444 | cli_avoid_json=False, 445 | cli_enforce_required=False, 446 | cli_use_class_docs_for_groups=False, 447 | cli_exit_on_error=True, 448 | cli_prefix='', 449 | cli_flag_prefix_char='-', 450 | cli_implicit_flags=False, 451 | cli_ignore_unknown_args=False, 452 | cli_kebab_case=False, 453 | json_file=None, 454 | json_file_encoding=None, 455 | yaml_file=None, 456 | yaml_file_encoding=None, 457 | yaml_config_section=None, 458 | toml_file=None, 459 | secrets_dir=None, 460 | protected_namespaces=('model_validate', 'model_dump', 'settings_customise_sources'), 461 | enable_decoding=True, 462 | ) 463 | 464 | 465 | class CliApp: 466 | """ 467 | A utility class for running Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as 468 | CLI applications. 469 | """ 470 | 471 | @staticmethod 472 | def _run_cli_cmd(model: Any, cli_cmd_method_name: str, is_required: bool) -> Any: 473 | command = getattr(type(model), cli_cmd_method_name, None) 474 | if command is None: 475 | if is_required: 476 | raise SettingsError(f'Error: {type(model).__name__} class is missing {cli_cmd_method_name} entrypoint') 477 | return model 478 | 479 | # If the method is asynchronous, we handle its execution based on the current event loop status. 480 | if inspect.iscoroutinefunction(command): 481 | # For asynchronous methods, we have two execution scenarios: 482 | # 1. If no event loop is running in the current thread, run the coroutine directly with asyncio.run(). 483 | # 2. If an event loop is already running in the current thread, run the coroutine in a separate thread to avoid conflicts. 484 | try: 485 | # Check if an event loop is currently running in this thread. 486 | loop = asyncio.get_running_loop() 487 | except RuntimeError: 488 | loop = None 489 | 490 | if loop and loop.is_running(): 491 | # We're in a context with an active event loop (e.g., Jupyter Notebook). 492 | # Running asyncio.run() here would cause conflicts, so we use a separate thread. 493 | exception_container = [] 494 | 495 | def run_coro() -> None: 496 | try: 497 | # Execute the coroutine in a new event loop in this separate thread. 498 | asyncio.run(command(model)) 499 | except Exception as e: 500 | exception_container.append(e) 501 | 502 | thread = threading.Thread(target=run_coro) 503 | thread.start() 504 | thread.join() 505 | if exception_container: 506 | # Propagate exceptions from the separate thread. 507 | raise exception_container[0] 508 | else: 509 | # No event loop is running; safe to run the coroutine directly. 510 | asyncio.run(command(model)) 511 | else: 512 | # For synchronous methods, call them directly. 513 | command(model) 514 | 515 | return model 516 | 517 | @staticmethod 518 | def run( 519 | model_cls: type[T], 520 | cli_args: list[str] | Namespace | SimpleNamespace | dict[str, Any] | None = None, 521 | cli_settings_source: CliSettingsSource[Any] | None = None, 522 | cli_exit_on_error: bool | None = None, 523 | cli_cmd_method_name: str = 'cli_cmd', 524 | **model_init_data: Any, 525 | ) -> T: 526 | """ 527 | Runs a Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application. 528 | Running a model as a CLI application requires the `cli_cmd` method to be defined in the model class. 529 | 530 | Args: 531 | model_cls: The model class to run as a CLI application. 532 | cli_args: The list of CLI arguments to parse. If `cli_settings_source` is specified, this may 533 | also be a namespace or dictionary of pre-parsed CLI arguments. Defaults to `sys.argv[1:]`. 534 | cli_settings_source: Override the default CLI settings source with a user defined instance. 535 | Defaults to `None`. 536 | cli_exit_on_error: Determines whether this function exits on error. If model is subclass of 537 | `BaseSettings`, defaults to BaseSettings `cli_exit_on_error` value. Otherwise, defaults to 538 | `True`. 539 | cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd". 540 | model_init_data: The model init data. 541 | 542 | Returns: 543 | The ran instance of model. 544 | 545 | Raises: 546 | SettingsError: If model_cls is not subclass of `BaseModel` or `pydantic.dataclasses.dataclass`. 547 | SettingsError: If model_cls does not have a `cli_cmd` entrypoint defined. 548 | """ 549 | 550 | if not (is_pydantic_dataclass(model_cls) or is_model_class(model_cls)): 551 | raise SettingsError( 552 | f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass' 553 | ) 554 | 555 | cli_settings = None 556 | cli_parse_args = True if cli_args is None else cli_args 557 | if cli_settings_source is not None: 558 | if isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)): 559 | cli_settings = cli_settings_source(parsed_args=cli_parse_args) 560 | else: 561 | cli_settings = cli_settings_source(args=cli_parse_args) 562 | elif isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)): 563 | raise SettingsError('Error: `cli_args` must be list[str] or None when `cli_settings_source` is not used') 564 | 565 | model_init_data['_cli_parse_args'] = cli_parse_args 566 | model_init_data['_cli_exit_on_error'] = cli_exit_on_error 567 | model_init_data['_cli_settings_source'] = cli_settings 568 | if not issubclass(model_cls, BaseSettings): 569 | 570 | class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore 571 | __doc__ = model_cls.__doc__ 572 | model_config = SettingsConfigDict( 573 | nested_model_default_partial_update=True, 574 | case_sensitive=True, 575 | cli_hide_none_type=True, 576 | cli_avoid_json=True, 577 | cli_enforce_required=True, 578 | cli_implicit_flags=True, 579 | cli_kebab_case=True, 580 | ) 581 | 582 | model = CliAppBaseSettings(**model_init_data) 583 | model_init_data = {} 584 | for field_name, field_info in type(model).model_fields.items(): 585 | model_init_data[_field_name_for_signature(field_name, field_info)] = getattr(model, field_name) 586 | 587 | return CliApp._run_cli_cmd(model_cls(**model_init_data), cli_cmd_method_name, is_required=False) 588 | 589 | @staticmethod 590 | def run_subcommand( 591 | model: PydanticModel, cli_exit_on_error: bool | None = None, cli_cmd_method_name: str = 'cli_cmd' 592 | ) -> PydanticModel: 593 | """ 594 | Runs the model subcommand. Running a model subcommand requires the `cli_cmd` method to be defined in 595 | the nested model subcommand class. 596 | 597 | Args: 598 | model: The model to run the subcommand from. 599 | cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. 600 | Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`. 601 | cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd". 602 | 603 | Returns: 604 | The ran subcommand model. 605 | 606 | Raises: 607 | SystemExit: When no subcommand is found and cli_exit_on_error=`True` (the default). 608 | SettingsError: When no subcommand is found and cli_exit_on_error=`False`. 609 | """ 610 | 611 | subcommand = get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error) 612 | return CliApp._run_cli_cmd(subcommand, cli_cmd_method_name, is_required=True) 613 | -------------------------------------------------------------------------------- /pydantic_settings/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pydantic/pydantic-settings/e9f7994872ebcd7a284d98d0ed501cc314a6a7fa/pydantic_settings/py.typed -------------------------------------------------------------------------------- /pydantic_settings/sources/__init__.py: -------------------------------------------------------------------------------- 1 | """Package for handling configuration sources in pydantic-settings.""" 2 | 3 | from .base import ( 4 | ConfigFileSourceMixin, 5 | DefaultSettingsSource, 6 | InitSettingsSource, 7 | PydanticBaseEnvSettingsSource, 8 | PydanticBaseSettingsSource, 9 | get_subcommand, 10 | ) 11 | from .providers.aws import AWSSecretsManagerSettingsSource 12 | from .providers.azure import AzureKeyVaultSettingsSource 13 | from .providers.cli import ( 14 | CLI_SUPPRESS, 15 | CliExplicitFlag, 16 | CliImplicitFlag, 17 | CliMutuallyExclusiveGroup, 18 | CliPositionalArg, 19 | CliSettingsSource, 20 | CliSubCommand, 21 | CliSuppress, 22 | CliUnknownArgs, 23 | ) 24 | from .providers.dotenv import DotEnvSettingsSource, read_env_file 25 | from .providers.env import EnvSettingsSource 26 | from .providers.gcp import GoogleSecretManagerSettingsSource 27 | from .providers.json import JsonConfigSettingsSource 28 | from .providers.pyproject import PyprojectTomlConfigSettingsSource 29 | from .providers.secrets import SecretsSettingsSource 30 | from .providers.toml import TomlConfigSettingsSource 31 | from .providers.yaml import YamlConfigSettingsSource 32 | from .types import DEFAULT_PATH, ENV_FILE_SENTINEL, DotenvType, ForceDecode, NoDecode, PathType, PydanticModel 33 | 34 | __all__ = [ 35 | 'CLI_SUPPRESS', 36 | 'ENV_FILE_SENTINEL', 37 | 'DEFAULT_PATH', 38 | 'AWSSecretsManagerSettingsSource', 39 | 'AzureKeyVaultSettingsSource', 40 | 'CliExplicitFlag', 41 | 'CliImplicitFlag', 42 | 'CliMutuallyExclusiveGroup', 43 | 'CliPositionalArg', 44 | 'CliSettingsSource', 45 | 'CliSubCommand', 46 | 'CliSuppress', 47 | 'CliUnknownArgs', 48 | 'DefaultSettingsSource', 49 | 'DotEnvSettingsSource', 50 | 'DotenvType', 51 | 'EnvSettingsSource', 52 | 'ForceDecode', 53 | 'GoogleSecretManagerSettingsSource', 54 | 'InitSettingsSource', 55 | 'JsonConfigSettingsSource', 56 | 'NoDecode', 57 | 'PathType', 58 | 'PydanticBaseEnvSettingsSource', 59 | 'PydanticBaseSettingsSource', 60 | 'ConfigFileSourceMixin', 61 | 'PydanticModel', 62 | 'PyprojectTomlConfigSettingsSource', 63 | 'SecretsSettingsSource', 64 | 'TomlConfigSettingsSource', 65 | 'YamlConfigSettingsSource', 66 | 'get_subcommand', 67 | 'read_env_file', 68 | ] 69 | -------------------------------------------------------------------------------- /pydantic_settings/sources/base.py: -------------------------------------------------------------------------------- 1 | """Base classes and core functionality for pydantic-settings sources.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | import json 6 | import os 7 | from abc import ABC, abstractmethod 8 | from dataclasses import asdict, is_dataclass 9 | from pathlib import Path 10 | from typing import TYPE_CHECKING, Any, Optional, cast 11 | 12 | from pydantic import AliasChoices, AliasPath, BaseModel, TypeAdapter 13 | from pydantic._internal._typing_extra import ( # type: ignore[attr-defined] 14 | get_origin, 15 | ) 16 | from pydantic._internal._utils import is_model_class 17 | from pydantic.fields import FieldInfo 18 | from typing_extensions import get_args 19 | from typing_inspection.introspection import is_union_origin 20 | 21 | from ..exceptions import SettingsError 22 | from ..utils import _lenient_issubclass 23 | from .types import EnvNoneType, ForceDecode, NoDecode, PathType, PydanticModel, _CliSubCommand 24 | from .utils import ( 25 | _annotation_is_complex, 26 | _get_alias_names, 27 | _get_model_fields, 28 | _union_is_complex, 29 | ) 30 | 31 | if TYPE_CHECKING: 32 | from pydantic_settings.main import BaseSettings 33 | 34 | 35 | def get_subcommand( 36 | model: PydanticModel, is_required: bool = True, cli_exit_on_error: bool | None = None 37 | ) -> Optional[PydanticModel]: 38 | """ 39 | Get the subcommand from a model. 40 | 41 | Args: 42 | model: The model to get the subcommand from. 43 | is_required: Determines whether a model must have subcommand set and raises error if not 44 | found. Defaults to `True`. 45 | cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. 46 | Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`. 47 | 48 | Returns: 49 | The subcommand model if found, otherwise `None`. 50 | 51 | Raises: 52 | SystemExit: When no subcommand is found and is_required=`True` and cli_exit_on_error=`True` 53 | (the default). 54 | SettingsError: When no subcommand is found and is_required=`True` and 55 | cli_exit_on_error=`False`. 56 | """ 57 | 58 | model_cls = type(model) 59 | if cli_exit_on_error is None and is_model_class(model_cls): 60 | model_default = model_cls.model_config.get('cli_exit_on_error') 61 | if isinstance(model_default, bool): 62 | cli_exit_on_error = model_default 63 | if cli_exit_on_error is None: 64 | cli_exit_on_error = True 65 | 66 | subcommands: list[str] = [] 67 | for field_name, field_info in _get_model_fields(model_cls).items(): 68 | if _CliSubCommand in field_info.metadata: 69 | if getattr(model, field_name) is not None: 70 | return getattr(model, field_name) 71 | subcommands.append(field_name) 72 | 73 | if is_required: 74 | error_message = ( 75 | f'Error: CLI subcommand is required {{{", ".join(subcommands)}}}' 76 | if subcommands 77 | else 'Error: CLI subcommand is required but no subcommands were found.' 78 | ) 79 | raise SystemExit(error_message) if cli_exit_on_error else SettingsError(error_message) 80 | 81 | return None 82 | 83 | 84 | class PydanticBaseSettingsSource(ABC): 85 | """ 86 | Abstract base class for settings sources, every settings source classes should inherit from it. 87 | """ 88 | 89 | def __init__(self, settings_cls: type[BaseSettings]): 90 | self.settings_cls = settings_cls 91 | self.config = settings_cls.model_config 92 | self._current_state: dict[str, Any] = {} 93 | self._settings_sources_data: dict[str, dict[str, Any]] = {} 94 | 95 | def _set_current_state(self, state: dict[str, Any]) -> None: 96 | """ 97 | Record the state of settings from the previous settings sources. This should 98 | be called right before __call__. 99 | """ 100 | self._current_state = state 101 | 102 | def _set_settings_sources_data(self, states: dict[str, dict[str, Any]]) -> None: 103 | """ 104 | Record the state of settings from all previous settings sources. This should 105 | be called right before __call__. 106 | """ 107 | self._settings_sources_data = states 108 | 109 | @property 110 | def current_state(self) -> dict[str, Any]: 111 | """ 112 | The current state of the settings, populated by the previous settings sources. 113 | """ 114 | return self._current_state 115 | 116 | @property 117 | def settings_sources_data(self) -> dict[str, dict[str, Any]]: 118 | """ 119 | The state of all previous settings sources. 120 | """ 121 | return self._settings_sources_data 122 | 123 | @abstractmethod 124 | def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: 125 | """ 126 | Gets the value, the key for model creation, and a flag to determine whether value is complex. 127 | 128 | This is an abstract method that should be overridden in every settings source classes. 129 | 130 | Args: 131 | field: The field. 132 | field_name: The field name. 133 | 134 | Returns: 135 | A tuple that contains the value, key and a flag to determine whether value is complex. 136 | """ 137 | pass 138 | 139 | def field_is_complex(self, field: FieldInfo) -> bool: 140 | """ 141 | Checks whether a field is complex, in which case it will attempt to be parsed as JSON. 142 | 143 | Args: 144 | field: The field. 145 | 146 | Returns: 147 | Whether the field is complex. 148 | """ 149 | return _annotation_is_complex(field.annotation, field.metadata) 150 | 151 | def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: 152 | """ 153 | Prepares the value of a field. 154 | 155 | Args: 156 | field_name: The field name. 157 | field: The field. 158 | value: The value of the field that has to be prepared. 159 | value_is_complex: A flag to determine whether value is complex. 160 | 161 | Returns: 162 | The prepared value. 163 | """ 164 | if value is not None and (self.field_is_complex(field) or value_is_complex): 165 | return self.decode_complex_value(field_name, field, value) 166 | return value 167 | 168 | def decode_complex_value(self, field_name: str, field: FieldInfo, value: Any) -> Any: 169 | """ 170 | Decode the value for a complex field 171 | 172 | Args: 173 | field_name: The field name. 174 | field: The field. 175 | value: The value of the field that has to be prepared. 176 | 177 | Returns: 178 | The decoded value for further preparation 179 | """ 180 | if field and ( 181 | NoDecode in field.metadata 182 | or (self.config.get('enable_decoding') is False and ForceDecode not in field.metadata) 183 | ): 184 | return value 185 | 186 | return json.loads(value) 187 | 188 | @abstractmethod 189 | def __call__(self) -> dict[str, Any]: 190 | pass 191 | 192 | 193 | class ConfigFileSourceMixin(ABC): 194 | def _read_files(self, files: PathType | None) -> dict[str, Any]: 195 | if files is None: 196 | return {} 197 | if isinstance(files, (str, os.PathLike)): 198 | files = [files] 199 | vars: dict[str, Any] = {} 200 | for file in files: 201 | file_path = Path(file).expanduser() 202 | if file_path.is_file(): 203 | vars.update(self._read_file(file_path)) 204 | return vars 205 | 206 | @abstractmethod 207 | def _read_file(self, path: Path) -> dict[str, Any]: 208 | pass 209 | 210 | 211 | class DefaultSettingsSource(PydanticBaseSettingsSource): 212 | """ 213 | Source class for loading default object values. 214 | 215 | Args: 216 | settings_cls: The Settings class. 217 | nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields. 218 | Defaults to `False`. 219 | """ 220 | 221 | def __init__(self, settings_cls: type[BaseSettings], nested_model_default_partial_update: bool | None = None): 222 | super().__init__(settings_cls) 223 | self.defaults: dict[str, Any] = {} 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 | if self.nested_model_default_partial_update: 230 | for field_name, field_info in settings_cls.model_fields.items(): 231 | alias_names, *_ = _get_alias_names(field_name, field_info) 232 | preferred_alias = alias_names[0] 233 | if is_dataclass(type(field_info.default)): 234 | self.defaults[preferred_alias] = asdict(field_info.default) 235 | elif is_model_class(type(field_info.default)): 236 | self.defaults[preferred_alias] = field_info.default.model_dump() 237 | 238 | def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: 239 | # Nothing to do here. Only implement the return statement to make mypy happy 240 | return None, '', False 241 | 242 | def __call__(self) -> dict[str, Any]: 243 | return self.defaults 244 | 245 | def __repr__(self) -> str: 246 | return ( 247 | f'{self.__class__.__name__}(nested_model_default_partial_update={self.nested_model_default_partial_update})' 248 | ) 249 | 250 | 251 | class InitSettingsSource(PydanticBaseSettingsSource): 252 | """ 253 | Source class for loading values provided during settings class initialization. 254 | """ 255 | 256 | def __init__( 257 | self, 258 | settings_cls: type[BaseSettings], 259 | init_kwargs: dict[str, Any], 260 | nested_model_default_partial_update: bool | None = None, 261 | ): 262 | self.init_kwargs = {} 263 | init_kwarg_names = set(init_kwargs.keys()) 264 | for field_name, field_info in settings_cls.model_fields.items(): 265 | alias_names, *_ = _get_alias_names(field_name, field_info) 266 | init_kwarg_name = init_kwarg_names & set(alias_names) 267 | if init_kwarg_name: 268 | preferred_alias = alias_names[0] 269 | init_kwarg_names -= init_kwarg_name 270 | self.init_kwargs[preferred_alias] = init_kwargs[init_kwarg_name.pop()] 271 | self.init_kwargs.update({key: val for key, val in init_kwargs.items() if key in init_kwarg_names}) 272 | 273 | super().__init__(settings_cls) 274 | self.nested_model_default_partial_update = ( 275 | nested_model_default_partial_update 276 | if nested_model_default_partial_update is not None 277 | else self.config.get('nested_model_default_partial_update', False) 278 | ) 279 | 280 | def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: 281 | # Nothing to do here. Only implement the return statement to make mypy happy 282 | return None, '', False 283 | 284 | def __call__(self) -> dict[str, Any]: 285 | return ( 286 | TypeAdapter(dict[str, Any]).dump_python(self.init_kwargs) 287 | if self.nested_model_default_partial_update 288 | else self.init_kwargs 289 | ) 290 | 291 | def __repr__(self) -> str: 292 | return f'{self.__class__.__name__}(init_kwargs={self.init_kwargs!r})' 293 | 294 | 295 | class PydanticBaseEnvSettingsSource(PydanticBaseSettingsSource): 296 | def __init__( 297 | self, 298 | settings_cls: type[BaseSettings], 299 | case_sensitive: bool | None = None, 300 | env_prefix: str | None = None, 301 | env_ignore_empty: bool | None = None, 302 | env_parse_none_str: str | None = None, 303 | env_parse_enums: bool | None = None, 304 | ) -> None: 305 | super().__init__(settings_cls) 306 | self.case_sensitive = case_sensitive if case_sensitive is not None else self.config.get('case_sensitive', False) 307 | self.env_prefix = env_prefix if env_prefix is not None else self.config.get('env_prefix', '') 308 | self.env_ignore_empty = ( 309 | env_ignore_empty if env_ignore_empty is not None else self.config.get('env_ignore_empty', False) 310 | ) 311 | self.env_parse_none_str = ( 312 | env_parse_none_str if env_parse_none_str is not None else self.config.get('env_parse_none_str') 313 | ) 314 | self.env_parse_enums = env_parse_enums if env_parse_enums is not None else self.config.get('env_parse_enums') 315 | 316 | def _apply_case_sensitive(self, value: str) -> str: 317 | return value.lower() if not self.case_sensitive else value 318 | 319 | def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]: 320 | """ 321 | Extracts field info. This info is used to get the value of field from environment variables. 322 | 323 | It returns a list of tuples, each tuple contains: 324 | * field_key: The key of field that has to be used in model creation. 325 | * env_name: The environment variable name of the field. 326 | * value_is_complex: A flag to determine whether the value from environment variable 327 | is complex and has to be parsed. 328 | 329 | Args: 330 | field (FieldInfo): The field. 331 | field_name (str): The field name. 332 | 333 | Returns: 334 | list[tuple[str, str, bool]]: List of tuples, each tuple contains field_key, env_name, and value_is_complex. 335 | """ 336 | field_info: list[tuple[str, str, bool]] = [] 337 | if isinstance(field.validation_alias, (AliasChoices, AliasPath)): 338 | v_alias: str | list[str | int] | list[list[str | int]] | None = field.validation_alias.convert_to_aliases() 339 | else: 340 | v_alias = field.validation_alias 341 | 342 | if v_alias: 343 | if isinstance(v_alias, list): # AliasChoices, AliasPath 344 | for alias in v_alias: 345 | if isinstance(alias, str): # AliasPath 346 | field_info.append((alias, self._apply_case_sensitive(alias), True if len(alias) > 1 else False)) 347 | elif isinstance(alias, list): # AliasChoices 348 | first_arg = cast(str, alias[0]) # first item of an AliasChoices must be a str 349 | field_info.append( 350 | (first_arg, self._apply_case_sensitive(first_arg), True if len(alias) > 1 else False) 351 | ) 352 | else: # string validation alias 353 | field_info.append((v_alias, self._apply_case_sensitive(v_alias), False)) 354 | 355 | if not v_alias or self.config.get('populate_by_name', False): 356 | if is_union_origin(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata): 357 | field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), True)) 358 | else: 359 | field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), False)) 360 | 361 | return field_info 362 | 363 | def _replace_field_names_case_insensitively(self, field: FieldInfo, field_values: dict[str, Any]) -> dict[str, Any]: 364 | """ 365 | Replace field names in values dict by looking in models fields insensitively. 366 | 367 | By having the following models: 368 | 369 | ```py 370 | class SubSubSub(BaseModel): 371 | VaL3: str 372 | 373 | class SubSub(BaseModel): 374 | Val2: str 375 | SUB_sub_SuB: SubSubSub 376 | 377 | class Sub(BaseModel): 378 | VAL1: str 379 | SUB_sub: SubSub 380 | 381 | class Settings(BaseSettings): 382 | nested: Sub 383 | 384 | model_config = SettingsConfigDict(env_nested_delimiter='__') 385 | ``` 386 | 387 | Then: 388 | _replace_field_names_case_insensitively( 389 | field, 390 | {"val1": "v1", "sub_SUB": {"VAL2": "v2", "sub_SUB_sUb": {"vAl3": "v3"}}} 391 | ) 392 | Returns {'VAL1': 'v1', 'SUB_sub': {'Val2': 'v2', 'SUB_sub_SuB': {'VaL3': 'v3'}}} 393 | """ 394 | values: dict[str, Any] = {} 395 | 396 | for name, value in field_values.items(): 397 | sub_model_field: FieldInfo | None = None 398 | 399 | annotation = field.annotation 400 | 401 | # If field is Optional, we need to find the actual type 402 | if is_union_origin(get_origin(field.annotation)): 403 | args = get_args(annotation) 404 | if len(args) == 2 and type(None) in args: 405 | for arg in args: 406 | if arg is not None: 407 | annotation = arg 408 | break 409 | 410 | # This is here to make mypy happy 411 | # Item "None" of "Optional[Type[Any]]" has no attribute "model_fields" 412 | if not annotation or not hasattr(annotation, 'model_fields'): 413 | values[name] = value 414 | continue 415 | else: 416 | model_fields: dict[str, FieldInfo] = annotation.model_fields 417 | 418 | # Find field in sub model by looking in fields case insensitively 419 | for sub_model_field_name, sub_model_field in model_fields.items(): 420 | aliases, _ = _get_alias_names(sub_model_field_name, sub_model_field) 421 | _search = (alias for alias in aliases if alias.lower() == name.lower()) 422 | if field_key := next(_search, None): 423 | break 424 | 425 | if not field_key: 426 | values[name] = value 427 | continue 428 | 429 | if _lenient_issubclass(sub_model_field.annotation, BaseModel) and isinstance(value, dict): 430 | values[field_key] = self._replace_field_names_case_insensitively(sub_model_field, value) 431 | else: 432 | values[field_key] = value 433 | 434 | return values 435 | 436 | def _replace_env_none_type_values(self, field_value: dict[str, Any]) -> dict[str, Any]: 437 | """ 438 | Recursively parse values that are of "None" type(EnvNoneType) to `None` type(None). 439 | """ 440 | values: dict[str, Any] = {} 441 | 442 | for key, value in field_value.items(): 443 | if not isinstance(value, EnvNoneType): 444 | values[key] = value if not isinstance(value, dict) else self._replace_env_none_type_values(value) 445 | else: 446 | values[key] = None 447 | 448 | return values 449 | 450 | def _get_resolved_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: 451 | """ 452 | Gets the value, the preferred alias key for model creation, and a flag to determine whether value 453 | is complex. 454 | 455 | Note: 456 | In V3, this method should either be made public, or, this method should be removed and the 457 | abstract method get_field_value should be updated to include a "use_preferred_alias" flag. 458 | 459 | Args: 460 | field: The field. 461 | field_name: The field name. 462 | 463 | Returns: 464 | A tuple that contains the value, preferred key and a flag to determine whether value is complex. 465 | """ 466 | field_value, field_key, value_is_complex = self.get_field_value(field, field_name) 467 | if not (value_is_complex or (self.config.get('populate_by_name', False) and (field_key == field_name))): 468 | field_infos = self._extract_field_info(field, field_name) 469 | preferred_key, *_ = field_infos[0] 470 | return field_value, preferred_key, value_is_complex 471 | return field_value, field_key, value_is_complex 472 | 473 | def __call__(self) -> dict[str, Any]: 474 | data: dict[str, Any] = {} 475 | 476 | for field_name, field in self.settings_cls.model_fields.items(): 477 | try: 478 | field_value, field_key, value_is_complex = self._get_resolved_field_value(field, field_name) 479 | except Exception as e: 480 | raise SettingsError( 481 | f'error getting value for field "{field_name}" from source "{self.__class__.__name__}"' 482 | ) from e 483 | 484 | try: 485 | field_value = self.prepare_field_value(field_name, field, field_value, value_is_complex) 486 | except ValueError as e: 487 | raise SettingsError( 488 | f'error parsing value for field "{field_name}" from source "{self.__class__.__name__}"' 489 | ) from e 490 | 491 | if field_value is not None: 492 | if self.env_parse_none_str is not None: 493 | if isinstance(field_value, dict): 494 | field_value = self._replace_env_none_type_values(field_value) 495 | elif isinstance(field_value, EnvNoneType): 496 | field_value = None 497 | if ( 498 | not self.case_sensitive 499 | # and _lenient_issubclass(field.annotation, BaseModel) 500 | and isinstance(field_value, dict) 501 | ): 502 | data[field_key] = self._replace_field_names_case_insensitively(field, field_value) 503 | else: 504 | data[field_key] = field_value 505 | 506 | return data 507 | 508 | 509 | __all__ = [ 510 | 'ConfigFileSourceMixin', 511 | 'DefaultSettingsSource', 512 | 'InitSettingsSource', 513 | 'PydanticBaseEnvSettingsSource', 514 | 'PydanticBaseSettingsSource', 515 | 'SettingsError', 516 | ] 517 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/__init__.py: -------------------------------------------------------------------------------- 1 | """Package containing individual source implementations.""" 2 | 3 | from .aws import AWSSecretsManagerSettingsSource 4 | from .azure import AzureKeyVaultSettingsSource 5 | from .cli import ( 6 | CliExplicitFlag, 7 | CliImplicitFlag, 8 | CliMutuallyExclusiveGroup, 9 | CliPositionalArg, 10 | CliSettingsSource, 11 | CliSubCommand, 12 | CliSuppress, 13 | ) 14 | from .dotenv import DotEnvSettingsSource 15 | from .env import EnvSettingsSource 16 | from .gcp import GoogleSecretManagerSettingsSource 17 | from .json import JsonConfigSettingsSource 18 | from .pyproject import PyprojectTomlConfigSettingsSource 19 | from .secrets import SecretsSettingsSource 20 | from .toml import TomlConfigSettingsSource 21 | from .yaml import YamlConfigSettingsSource 22 | 23 | __all__ = [ 24 | 'AWSSecretsManagerSettingsSource', 25 | 'AzureKeyVaultSettingsSource', 26 | 'CliExplicitFlag', 27 | 'CliImplicitFlag', 28 | 'CliMutuallyExclusiveGroup', 29 | 'CliPositionalArg', 30 | 'CliSettingsSource', 31 | 'CliSubCommand', 32 | 'CliSuppress', 33 | 'DotEnvSettingsSource', 34 | 'EnvSettingsSource', 35 | 'GoogleSecretManagerSettingsSource', 36 | 'JsonConfigSettingsSource', 37 | 'PyprojectTomlConfigSettingsSource', 38 | 'SecretsSettingsSource', 39 | 'TomlConfigSettingsSource', 40 | 'YamlConfigSettingsSource', 41 | ] 42 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/aws.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations # important for BaseSettings import to work 2 | 3 | import json 4 | from collections.abc import Mapping 5 | from typing import TYPE_CHECKING, Optional 6 | 7 | from .env import EnvSettingsSource 8 | 9 | if TYPE_CHECKING: 10 | from pydantic_settings.main import BaseSettings 11 | 12 | 13 | boto3_client = None 14 | SecretsManagerClient = None 15 | 16 | 17 | def import_aws_secrets_manager() -> None: 18 | global boto3_client 19 | global SecretsManagerClient 20 | 21 | try: 22 | from boto3 import client as boto3_client 23 | from mypy_boto3_secretsmanager.client import SecretsManagerClient 24 | except ImportError as e: # pragma: no cover 25 | raise ImportError( 26 | 'AWS Secrets Manager dependencies are not installed, run `pip install pydantic-settings[aws-secrets-manager]`' 27 | ) from e 28 | 29 | 30 | class AWSSecretsManagerSettingsSource(EnvSettingsSource): 31 | _secret_id: str 32 | _secretsmanager_client: SecretsManagerClient # type: ignore 33 | 34 | def __init__( 35 | self, 36 | settings_cls: type[BaseSettings], 37 | secret_id: str, 38 | region_name: str | None = None, 39 | env_prefix: str | None = None, 40 | env_parse_none_str: str | None = None, 41 | env_parse_enums: bool | None = None, 42 | ) -> None: 43 | import_aws_secrets_manager() 44 | self._secretsmanager_client = boto3_client('secretsmanager', region_name=region_name) # type: ignore 45 | self._secret_id = secret_id 46 | super().__init__( 47 | settings_cls, 48 | case_sensitive=True, 49 | env_prefix=env_prefix, 50 | env_nested_delimiter='--', 51 | env_ignore_empty=False, 52 | env_parse_none_str=env_parse_none_str, 53 | env_parse_enums=env_parse_enums, 54 | ) 55 | 56 | def _load_env_vars(self) -> Mapping[str, Optional[str]]: 57 | response = self._secretsmanager_client.get_secret_value(SecretId=self._secret_id) # type: ignore 58 | 59 | return json.loads(response['SecretString']) 60 | 61 | def __repr__(self) -> str: 62 | return ( 63 | f'{self.__class__.__name__}(secret_id={self._secret_id!r}, ' 64 | f'env_nested_delimiter={self.env_nested_delimiter!r})' 65 | ) 66 | 67 | 68 | __all__ = [ 69 | 'AWSSecretsManagerSettingsSource', 70 | ] 71 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/azure.py: -------------------------------------------------------------------------------- 1 | """Azure Key Vault settings source.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | from collections.abc import Iterator, Mapping 6 | from typing import TYPE_CHECKING, Optional 7 | 8 | from pydantic.fields import FieldInfo 9 | 10 | from .env import EnvSettingsSource 11 | 12 | if TYPE_CHECKING: 13 | from azure.core.credentials import TokenCredential 14 | from azure.core.exceptions import ResourceNotFoundError 15 | from azure.keyvault.secrets import SecretClient 16 | 17 | from pydantic_settings.main import BaseSettings 18 | else: 19 | TokenCredential = None 20 | ResourceNotFoundError = None 21 | SecretClient = None 22 | 23 | 24 | def import_azure_key_vault() -> None: 25 | global TokenCredential 26 | global SecretClient 27 | global ResourceNotFoundError 28 | 29 | try: 30 | from azure.core.credentials import TokenCredential 31 | from azure.core.exceptions import ResourceNotFoundError 32 | from azure.keyvault.secrets import SecretClient 33 | except ImportError as e: # pragma: no cover 34 | raise ImportError( 35 | 'Azure Key Vault dependencies are not installed, run `pip install pydantic-settings[azure-key-vault]`' 36 | ) from e 37 | 38 | 39 | class AzureKeyVaultMapping(Mapping[str, Optional[str]]): 40 | _loaded_secrets: dict[str, str | None] 41 | _secret_client: SecretClient 42 | _secret_names: list[str] 43 | 44 | def __init__( 45 | self, 46 | secret_client: SecretClient, 47 | case_sensitive: bool, 48 | ) -> None: 49 | self._loaded_secrets = {} 50 | self._secret_client = secret_client 51 | self._case_sensitive = case_sensitive 52 | self._secret_map: dict[str, str] = self._load_remote() 53 | 54 | def _load_remote(self) -> dict[str, str]: 55 | secret_names: Iterator[str] = ( 56 | secret.name for secret in self._secret_client.list_properties_of_secrets() if secret.name and secret.enabled 57 | ) 58 | if self._case_sensitive: 59 | return {name: name for name in secret_names} 60 | return {name.lower(): name for name in secret_names} 61 | 62 | def __getitem__(self, key: str) -> str | None: 63 | if not self._case_sensitive: 64 | key = key.lower() 65 | if key not in self._loaded_secrets and key in self._secret_map: 66 | self._loaded_secrets[key] = self._secret_client.get_secret(self._secret_map[key]).value 67 | return self._loaded_secrets[key] 68 | 69 | def __len__(self) -> int: 70 | return len(self._secret_map) 71 | 72 | def __iter__(self) -> Iterator[str]: 73 | return iter(self._secret_map.keys()) 74 | 75 | 76 | class AzureKeyVaultSettingsSource(EnvSettingsSource): 77 | _url: str 78 | _credential: TokenCredential 79 | 80 | def __init__( 81 | self, 82 | settings_cls: type[BaseSettings], 83 | url: str, 84 | credential: TokenCredential, 85 | dash_to_underscore: bool = False, 86 | case_sensitive: bool | None = None, 87 | env_prefix: str | None = None, 88 | env_parse_none_str: str | None = None, 89 | env_parse_enums: bool | None = None, 90 | ) -> None: 91 | import_azure_key_vault() 92 | self._url = url 93 | self._credential = credential 94 | self._dash_to_underscore = dash_to_underscore 95 | super().__init__( 96 | settings_cls, 97 | case_sensitive=case_sensitive, 98 | env_prefix=env_prefix, 99 | env_nested_delimiter='--', 100 | env_ignore_empty=False, 101 | env_parse_none_str=env_parse_none_str, 102 | env_parse_enums=env_parse_enums, 103 | ) 104 | 105 | def _load_env_vars(self) -> Mapping[str, Optional[str]]: 106 | secret_client = SecretClient(vault_url=self._url, credential=self._credential) 107 | return AzureKeyVaultMapping(secret_client, self.case_sensitive) 108 | 109 | def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]: 110 | if self._dash_to_underscore: 111 | return list((x[0], x[1].replace('_', '-'), x[2]) for x in super()._extract_field_info(field, field_name)) 112 | return super()._extract_field_info(field, field_name) 113 | 114 | def __repr__(self) -> str: 115 | return f'{self.__class__.__name__}(url={self._url!r}, env_nested_delimiter={self.env_nested_delimiter!r})' 116 | 117 | 118 | __all__ = ['AzureKeyVaultMapping', 'AzureKeyVaultSettingsSource'] 119 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/dotenv.py: -------------------------------------------------------------------------------- 1 | """Dotenv file settings source.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | import os 6 | import warnings 7 | from collections.abc import Mapping 8 | from pathlib import Path 9 | from typing import TYPE_CHECKING, Any 10 | 11 | from dotenv import dotenv_values 12 | from pydantic._internal._typing_extra import ( # type: ignore[attr-defined] 13 | get_origin, 14 | ) 15 | from typing_inspection.introspection import is_union_origin 16 | 17 | from ..types import ENV_FILE_SENTINEL, DotenvType 18 | from ..utils import ( 19 | _annotation_is_complex, 20 | _union_is_complex, 21 | parse_env_vars, 22 | ) 23 | from .env import EnvSettingsSource 24 | 25 | if TYPE_CHECKING: 26 | from pydantic_settings.main import BaseSettings 27 | 28 | 29 | class DotEnvSettingsSource(EnvSettingsSource): 30 | """ 31 | Source class for loading settings values from env files. 32 | """ 33 | 34 | def __init__( 35 | self, 36 | settings_cls: type[BaseSettings], 37 | env_file: DotenvType | None = ENV_FILE_SENTINEL, 38 | env_file_encoding: str | None = None, 39 | case_sensitive: bool | None = None, 40 | env_prefix: str | None = None, 41 | env_nested_delimiter: str | None = None, 42 | env_nested_max_split: int | None = None, 43 | env_ignore_empty: bool | None = None, 44 | env_parse_none_str: str | None = None, 45 | env_parse_enums: bool | None = None, 46 | ) -> None: 47 | self.env_file = env_file if env_file != ENV_FILE_SENTINEL else settings_cls.model_config.get('env_file') 48 | self.env_file_encoding = ( 49 | env_file_encoding if env_file_encoding is not None else settings_cls.model_config.get('env_file_encoding') 50 | ) 51 | super().__init__( 52 | settings_cls, 53 | case_sensitive, 54 | env_prefix, 55 | env_nested_delimiter, 56 | env_nested_max_split, 57 | env_ignore_empty, 58 | env_parse_none_str, 59 | env_parse_enums, 60 | ) 61 | 62 | def _load_env_vars(self) -> Mapping[str, str | None]: 63 | return self._read_env_files() 64 | 65 | @staticmethod 66 | def _static_read_env_file( 67 | file_path: Path, 68 | *, 69 | encoding: str | None = None, 70 | case_sensitive: bool = False, 71 | ignore_empty: bool = False, 72 | parse_none_str: str | None = None, 73 | ) -> Mapping[str, str | None]: 74 | file_vars: dict[str, str | None] = dotenv_values(file_path, encoding=encoding or 'utf8') 75 | return parse_env_vars(file_vars, case_sensitive, ignore_empty, parse_none_str) 76 | 77 | def _read_env_file( 78 | self, 79 | file_path: Path, 80 | ) -> Mapping[str, str | None]: 81 | return self._static_read_env_file( 82 | file_path, 83 | encoding=self.env_file_encoding, 84 | case_sensitive=self.case_sensitive, 85 | ignore_empty=self.env_ignore_empty, 86 | parse_none_str=self.env_parse_none_str, 87 | ) 88 | 89 | def _read_env_files(self) -> Mapping[str, str | None]: 90 | env_files = self.env_file 91 | if env_files is None: 92 | return {} 93 | 94 | if isinstance(env_files, (str, os.PathLike)): 95 | env_files = [env_files] 96 | 97 | dotenv_vars: dict[str, str | None] = {} 98 | for env_file in env_files: 99 | env_path = Path(env_file).expanduser() 100 | if env_path.is_file(): 101 | dotenv_vars.update(self._read_env_file(env_path)) 102 | 103 | return dotenv_vars 104 | 105 | def __call__(self) -> dict[str, Any]: 106 | data: dict[str, Any] = super().__call__() 107 | is_extra_allowed = self.config.get('extra') != 'forbid' 108 | 109 | # As `extra` config is allowed in dotenv settings source, We have to 110 | # update data with extra env variables from dotenv file. 111 | for env_name, env_value in self.env_vars.items(): 112 | if not env_value or env_name in data: 113 | continue 114 | env_used = False 115 | for field_name, field in self.settings_cls.model_fields.items(): 116 | for _, field_env_name, _ in self._extract_field_info(field, field_name): 117 | if env_name == field_env_name or ( 118 | ( 119 | _annotation_is_complex(field.annotation, field.metadata) 120 | or ( 121 | is_union_origin(get_origin(field.annotation)) 122 | and _union_is_complex(field.annotation, field.metadata) 123 | ) 124 | ) 125 | and env_name.startswith(field_env_name) 126 | ): 127 | env_used = True 128 | break 129 | if env_used: 130 | break 131 | if not env_used: 132 | if is_extra_allowed and env_name.startswith(self.env_prefix): 133 | # env_prefix should be respected and removed from the env_name 134 | normalized_env_name = env_name[len(self.env_prefix) :] 135 | data[normalized_env_name] = env_value 136 | else: 137 | data[env_name] = env_value 138 | return data 139 | 140 | def __repr__(self) -> str: 141 | return ( 142 | f'{self.__class__.__name__}(env_file={self.env_file!r}, env_file_encoding={self.env_file_encoding!r}, ' 143 | f'env_nested_delimiter={self.env_nested_delimiter!r}, env_prefix_len={self.env_prefix_len!r})' 144 | ) 145 | 146 | 147 | def read_env_file( 148 | file_path: Path, 149 | *, 150 | encoding: str | None = None, 151 | case_sensitive: bool = False, 152 | ignore_empty: bool = False, 153 | parse_none_str: str | None = None, 154 | ) -> Mapping[str, str | None]: 155 | warnings.warn( 156 | 'read_env_file will be removed in the next version, use DotEnvSettingsSource._static_read_env_file if you must', 157 | DeprecationWarning, 158 | ) 159 | return DotEnvSettingsSource._static_read_env_file( 160 | file_path, 161 | encoding=encoding, 162 | case_sensitive=case_sensitive, 163 | ignore_empty=ignore_empty, 164 | parse_none_str=parse_none_str, 165 | ) 166 | 167 | 168 | __all__ = ['DotEnvSettingsSource', 'read_env_file'] 169 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import os 4 | from collections.abc import Mapping 5 | from typing import ( 6 | TYPE_CHECKING, 7 | Any, 8 | ) 9 | 10 | from pydantic._internal._utils import deep_update, is_model_class 11 | from pydantic.dataclasses import is_pydantic_dataclass 12 | from pydantic.fields import FieldInfo 13 | from typing_extensions import get_args, get_origin 14 | from typing_inspection.introspection import is_union_origin 15 | 16 | from ...utils import _lenient_issubclass 17 | from ..base import PydanticBaseEnvSettingsSource 18 | from ..types import EnvNoneType 19 | from ..utils import ( 20 | _annotation_enum_name_to_val, 21 | _get_model_fields, 22 | _union_is_complex, 23 | parse_env_vars, 24 | ) 25 | 26 | if TYPE_CHECKING: 27 | from pydantic_settings.main import BaseSettings 28 | 29 | 30 | class EnvSettingsSource(PydanticBaseEnvSettingsSource): 31 | """ 32 | Source class for loading settings values from environment variables. 33 | """ 34 | 35 | def __init__( 36 | self, 37 | settings_cls: type[BaseSettings], 38 | case_sensitive: bool | None = None, 39 | env_prefix: str | None = None, 40 | env_nested_delimiter: str | None = None, 41 | env_nested_max_split: int | None = None, 42 | env_ignore_empty: bool | None = None, 43 | env_parse_none_str: str | None = None, 44 | env_parse_enums: bool | None = None, 45 | ) -> None: 46 | super().__init__( 47 | settings_cls, case_sensitive, env_prefix, env_ignore_empty, env_parse_none_str, env_parse_enums 48 | ) 49 | self.env_nested_delimiter = ( 50 | env_nested_delimiter if env_nested_delimiter is not None else self.config.get('env_nested_delimiter') 51 | ) 52 | self.env_nested_max_split = ( 53 | env_nested_max_split if env_nested_max_split is not None else self.config.get('env_nested_max_split') 54 | ) 55 | self.maxsplit = (self.env_nested_max_split or 0) - 1 56 | self.env_prefix_len = len(self.env_prefix) 57 | 58 | self.env_vars = self._load_env_vars() 59 | 60 | def _load_env_vars(self) -> Mapping[str, str | None]: 61 | return parse_env_vars(os.environ, self.case_sensitive, self.env_ignore_empty, self.env_parse_none_str) 62 | 63 | def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: 64 | """ 65 | Gets the value for field from environment variables and a flag to determine whether value is complex. 66 | 67 | Args: 68 | field: The field. 69 | field_name: The field name. 70 | 71 | Returns: 72 | A tuple that contains the value (`None` if not found), key, and 73 | a flag to determine whether value is complex. 74 | """ 75 | 76 | env_val: str | None = None 77 | for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name): 78 | env_val = self.env_vars.get(env_name) 79 | if env_val is not None: 80 | break 81 | 82 | return env_val, field_key, value_is_complex 83 | 84 | def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: 85 | """ 86 | Prepare value for the field. 87 | 88 | * Extract value for nested field. 89 | * Deserialize value to python object for complex field. 90 | 91 | Args: 92 | field: The field. 93 | field_name: The field name. 94 | 95 | Returns: 96 | A tuple contains prepared value for the field. 97 | 98 | Raises: 99 | ValuesError: When There is an error in deserializing value for complex field. 100 | """ 101 | is_complex, allow_parse_failure = self._field_is_complex(field) 102 | if self.env_parse_enums: 103 | enum_val = _annotation_enum_name_to_val(field.annotation, value) 104 | value = value if enum_val is None else enum_val 105 | 106 | if is_complex or value_is_complex: 107 | if isinstance(value, EnvNoneType): 108 | return value 109 | elif value is None: 110 | # field is complex but no value found so far, try explode_env_vars 111 | env_val_built = self.explode_env_vars(field_name, field, self.env_vars) 112 | if env_val_built: 113 | return env_val_built 114 | else: 115 | # field is complex and there's a value, decode that as JSON, then add explode_env_vars 116 | try: 117 | value = self.decode_complex_value(field_name, field, value) 118 | except ValueError as e: 119 | if not allow_parse_failure: 120 | raise e 121 | 122 | if isinstance(value, dict): 123 | return deep_update(value, self.explode_env_vars(field_name, field, self.env_vars)) 124 | else: 125 | return value 126 | elif value is not None: 127 | # simplest case, field is not complex, we only need to add the value if it was found 128 | return value 129 | 130 | def _field_is_complex(self, field: FieldInfo) -> tuple[bool, bool]: 131 | """ 132 | Find out if a field is complex, and if so whether JSON errors should be ignored 133 | """ 134 | if self.field_is_complex(field): 135 | allow_parse_failure = False 136 | elif is_union_origin(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata): 137 | allow_parse_failure = True 138 | else: 139 | return False, False 140 | 141 | return True, allow_parse_failure 142 | 143 | # Default value of `case_sensitive` is `None`, because we don't want to break existing behavior. 144 | # We have to change the method to a non-static method and use 145 | # `self.case_sensitive` instead in V3. 146 | def next_field( 147 | self, field: FieldInfo | Any | None, key: str, case_sensitive: bool | None = None 148 | ) -> FieldInfo | None: 149 | """ 150 | Find the field in a sub model by key(env name) 151 | 152 | By having the following models: 153 | 154 | ```py 155 | class SubSubModel(BaseSettings): 156 | dvals: Dict 157 | 158 | class SubModel(BaseSettings): 159 | vals: list[str] 160 | sub_sub_model: SubSubModel 161 | 162 | class Cfg(BaseSettings): 163 | sub_model: SubModel 164 | ``` 165 | 166 | Then: 167 | next_field(sub_model, 'vals') Returns the `vals` field of `SubModel` class 168 | next_field(sub_model, 'sub_sub_model') Returns `sub_sub_model` field of `SubModel` class 169 | 170 | Args: 171 | field: The field. 172 | key: The key (env name). 173 | case_sensitive: Whether to search for key case sensitively. 174 | 175 | Returns: 176 | Field if it finds the next field otherwise `None`. 177 | """ 178 | if not field: 179 | return None 180 | 181 | annotation = field.annotation if isinstance(field, FieldInfo) else field 182 | for type_ in get_args(annotation): 183 | type_has_key = self.next_field(type_, key, case_sensitive) 184 | if type_has_key: 185 | return type_has_key 186 | if is_model_class(annotation) or is_pydantic_dataclass(annotation): # type: ignore[arg-type] 187 | fields = _get_model_fields(annotation) 188 | # `case_sensitive is None` is here to be compatible with the old behavior. 189 | # Has to be removed in V3. 190 | for field_name, f in fields.items(): 191 | for _, env_name, _ in self._extract_field_info(f, field_name): 192 | if case_sensitive is None or case_sensitive: 193 | if field_name == key or env_name == key: 194 | return f 195 | elif field_name.lower() == key.lower() or env_name.lower() == key.lower(): 196 | return f 197 | return None 198 | 199 | def explode_env_vars(self, field_name: str, field: FieldInfo, env_vars: Mapping[str, str | None]) -> dict[str, Any]: 200 | """ 201 | Process env_vars and extract the values of keys containing env_nested_delimiter into nested dictionaries. 202 | 203 | This is applied to a single field, hence filtering by env_var prefix. 204 | 205 | Args: 206 | field_name: The field name. 207 | field: The field. 208 | env_vars: Environment variables. 209 | 210 | Returns: 211 | A dictionary contains extracted values from nested env values. 212 | """ 213 | if not self.env_nested_delimiter: 214 | return {} 215 | 216 | ann = field.annotation 217 | is_dict = ann is dict or _lenient_issubclass(get_origin(ann), dict) 218 | 219 | prefixes = [ 220 | f'{env_name}{self.env_nested_delimiter}' for _, env_name, _ in self._extract_field_info(field, field_name) 221 | ] 222 | result: dict[str, Any] = {} 223 | for env_name, env_val in env_vars.items(): 224 | try: 225 | prefix = next(prefix for prefix in prefixes if env_name.startswith(prefix)) 226 | except StopIteration: 227 | continue 228 | # we remove the prefix before splitting in case the prefix has characters in common with the delimiter 229 | env_name_without_prefix = env_name[len(prefix) :] 230 | *keys, last_key = env_name_without_prefix.split(self.env_nested_delimiter, self.maxsplit) 231 | env_var = result 232 | target_field: FieldInfo | None = field 233 | for key in keys: 234 | target_field = self.next_field(target_field, key, self.case_sensitive) 235 | if isinstance(env_var, dict): 236 | env_var = env_var.setdefault(key, {}) 237 | 238 | # get proper field with last_key 239 | target_field = self.next_field(target_field, last_key, self.case_sensitive) 240 | 241 | # check if env_val maps to a complex field and if so, parse the env_val 242 | if (target_field or is_dict) and env_val: 243 | if target_field: 244 | is_complex, allow_json_failure = self._field_is_complex(target_field) 245 | if self.env_parse_enums: 246 | enum_val = _annotation_enum_name_to_val(target_field.annotation, env_val) 247 | env_val = env_val if enum_val is None else enum_val 248 | else: 249 | # nested field type is dict 250 | is_complex, allow_json_failure = True, True 251 | if is_complex: 252 | try: 253 | env_val = self.decode_complex_value(last_key, target_field, env_val) # type: ignore 254 | except ValueError as e: 255 | if not allow_json_failure: 256 | raise e 257 | if isinstance(env_var, dict): 258 | if last_key not in env_var or not isinstance(env_val, EnvNoneType) or env_var[last_key] == {}: 259 | env_var[last_key] = env_val 260 | 261 | return result 262 | 263 | def __repr__(self) -> str: 264 | return ( 265 | f'{self.__class__.__name__}(env_nested_delimiter={self.env_nested_delimiter!r}, ' 266 | f'env_prefix_len={self.env_prefix_len!r})' 267 | ) 268 | 269 | 270 | __all__ = ['EnvSettingsSource'] 271 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/gcp.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | from collections.abc import Iterator, Mapping 4 | from functools import cached_property 5 | from typing import TYPE_CHECKING, Optional 6 | 7 | from .env import EnvSettingsSource 8 | 9 | if TYPE_CHECKING: 10 | from google.auth import default as google_auth_default 11 | from google.auth.credentials import Credentials 12 | from google.cloud.secretmanager import SecretManagerServiceClient 13 | 14 | from pydantic_settings.main import BaseSettings 15 | else: 16 | Credentials = None 17 | SecretManagerServiceClient = None 18 | google_auth_default = None 19 | 20 | 21 | def import_gcp_secret_manager() -> None: 22 | global Credentials 23 | global SecretManagerServiceClient 24 | global google_auth_default 25 | 26 | try: 27 | from google.auth import default as google_auth_default 28 | from google.auth.credentials import Credentials 29 | from google.cloud.secretmanager import SecretManagerServiceClient 30 | except ImportError as e: # pragma: no cover 31 | raise ImportError( 32 | 'GCP Secret Manager dependencies are not installed, run `pip install pydantic-settings[gcp-secret-manager]`' 33 | ) from e 34 | 35 | 36 | class GoogleSecretManagerMapping(Mapping[str, Optional[str]]): 37 | _loaded_secrets: dict[str, str | None] 38 | _secret_client: SecretManagerServiceClient 39 | 40 | def __init__(self, secret_client: SecretManagerServiceClient, project_id: str) -> None: 41 | self._loaded_secrets = {} 42 | self._secret_client = secret_client 43 | self._project_id = project_id 44 | 45 | @property 46 | def _gcp_project_path(self) -> str: 47 | return self._secret_client.common_project_path(self._project_id) 48 | 49 | @cached_property 50 | def _secret_names(self) -> list[str]: 51 | return [ 52 | self._secret_client.parse_secret_path(secret.name).get('secret', '') 53 | for secret in self._secret_client.list_secrets(parent=self._gcp_project_path) 54 | ] 55 | 56 | def _secret_version_path(self, key: str, version: str = 'latest') -> str: 57 | return self._secret_client.secret_version_path(self._project_id, key, version) 58 | 59 | def __getitem__(self, key: str) -> str | None: 60 | if key not in self._loaded_secrets: 61 | # If we know the key isn't available in secret manager, raise a key error 62 | if key not in self._secret_names: 63 | raise KeyError(key) 64 | 65 | try: 66 | self._loaded_secrets[key] = self._secret_client.access_secret_version( 67 | name=self._secret_version_path(key) 68 | ).payload.data.decode('UTF-8') 69 | except Exception: 70 | raise KeyError(key) 71 | 72 | return self._loaded_secrets[key] 73 | 74 | def __len__(self) -> int: 75 | return len(self._secret_names) 76 | 77 | def __iter__(self) -> Iterator[str]: 78 | return iter(self._secret_names) 79 | 80 | 81 | class GoogleSecretManagerSettingsSource(EnvSettingsSource): 82 | _credentials: Credentials 83 | _secret_client: SecretManagerServiceClient 84 | _project_id: str 85 | 86 | def __init__( 87 | self, 88 | settings_cls: type[BaseSettings], 89 | credentials: Credentials | None = None, 90 | project_id: str | None = None, 91 | env_prefix: str | None = None, 92 | env_parse_none_str: str | None = None, 93 | env_parse_enums: bool | None = None, 94 | secret_client: SecretManagerServiceClient | None = None, 95 | ) -> None: 96 | # Import Google Packages if they haven't already been imported 97 | if SecretManagerServiceClient is None or Credentials is None or google_auth_default is None: 98 | import_gcp_secret_manager() 99 | 100 | # If credentials or project_id are not passed, then 101 | # try to get them from the default function 102 | if not credentials or not project_id: 103 | _creds, _project_id = google_auth_default() # type: ignore[no-untyped-call] 104 | 105 | # Set the credentials and/or project id if they weren't specified 106 | if credentials is None: 107 | credentials = _creds 108 | 109 | if project_id is None: 110 | if isinstance(_project_id, str): 111 | project_id = _project_id 112 | else: 113 | raise AttributeError( 114 | 'project_id is required to be specified either as an argument or from the google.auth.default. See https://google-auth.readthedocs.io/en/master/reference/google.auth.html#google.auth.default' 115 | ) 116 | 117 | self._credentials: Credentials = credentials 118 | self._project_id: str = project_id 119 | 120 | if secret_client: 121 | self._secret_client = secret_client 122 | else: 123 | self._secret_client = SecretManagerServiceClient(credentials=self._credentials) 124 | 125 | super().__init__( 126 | settings_cls, 127 | case_sensitive=True, 128 | env_prefix=env_prefix, 129 | env_ignore_empty=False, 130 | env_parse_none_str=env_parse_none_str, 131 | env_parse_enums=env_parse_enums, 132 | ) 133 | 134 | def _load_env_vars(self) -> Mapping[str, Optional[str]]: 135 | return GoogleSecretManagerMapping(self._secret_client, project_id=self._project_id) 136 | 137 | def __repr__(self) -> str: 138 | return f'{self.__class__.__name__}(project_id={self._project_id!r}, env_nested_delimiter={self.env_nested_delimiter!r})' 139 | 140 | 141 | __all__ = ['GoogleSecretManagerSettingsSource', 'GoogleSecretManagerMapping'] 142 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/json.py: -------------------------------------------------------------------------------- 1 | """JSON file settings source.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | import json 6 | from pathlib import Path 7 | from typing import ( 8 | TYPE_CHECKING, 9 | Any, 10 | ) 11 | 12 | from ..base import ConfigFileSourceMixin, InitSettingsSource 13 | from ..types import DEFAULT_PATH, PathType 14 | 15 | if TYPE_CHECKING: 16 | from pydantic_settings.main import BaseSettings 17 | 18 | 19 | class JsonConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): 20 | """ 21 | A source class that loads variables from a JSON file 22 | """ 23 | 24 | def __init__( 25 | self, 26 | settings_cls: type[BaseSettings], 27 | json_file: PathType | None = DEFAULT_PATH, 28 | json_file_encoding: str | None = None, 29 | ): 30 | self.json_file_path = json_file if json_file != DEFAULT_PATH else settings_cls.model_config.get('json_file') 31 | self.json_file_encoding = ( 32 | json_file_encoding 33 | if json_file_encoding is not None 34 | else settings_cls.model_config.get('json_file_encoding') 35 | ) 36 | self.json_data = self._read_files(self.json_file_path) 37 | super().__init__(settings_cls, self.json_data) 38 | 39 | def _read_file(self, file_path: Path) -> dict[str, Any]: 40 | with open(file_path, encoding=self.json_file_encoding) as json_file: 41 | return json.load(json_file) 42 | 43 | def __repr__(self) -> str: 44 | return f'{self.__class__.__name__}(json_file={self.json_file_path})' 45 | 46 | 47 | __all__ = ['JsonConfigSettingsSource'] 48 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/pyproject.py: -------------------------------------------------------------------------------- 1 | """Pyproject TOML file settings source.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | from pathlib import Path 6 | from typing import ( 7 | TYPE_CHECKING, 8 | ) 9 | 10 | from .toml import TomlConfigSettingsSource 11 | 12 | if TYPE_CHECKING: 13 | from pydantic_settings.main import BaseSettings 14 | 15 | 16 | class PyprojectTomlConfigSettingsSource(TomlConfigSettingsSource): 17 | """ 18 | A source class that loads variables from a `pyproject.toml` file. 19 | """ 20 | 21 | def __init__( 22 | self, 23 | settings_cls: type[BaseSettings], 24 | toml_file: Path | None = None, 25 | ) -> None: 26 | self.toml_file_path = self._pick_pyproject_toml_file( 27 | toml_file, settings_cls.model_config.get('pyproject_toml_depth', 0) 28 | ) 29 | self.toml_table_header: tuple[str, ...] = settings_cls.model_config.get( 30 | 'pyproject_toml_table_header', ('tool', 'pydantic-settings') 31 | ) 32 | self.toml_data = self._read_files(self.toml_file_path) 33 | for key in self.toml_table_header: 34 | self.toml_data = self.toml_data.get(key, {}) 35 | super(TomlConfigSettingsSource, self).__init__(settings_cls, self.toml_data) 36 | 37 | @staticmethod 38 | def _pick_pyproject_toml_file(provided: Path | None, depth: int) -> Path: 39 | """Pick a `pyproject.toml` file path to use. 40 | 41 | Args: 42 | provided: Explicit path provided when instantiating this class. 43 | depth: Number of directories up the tree to check of a pyproject.toml. 44 | 45 | """ 46 | if provided: 47 | return provided.resolve() 48 | rv = Path.cwd() / 'pyproject.toml' 49 | count = 0 50 | if not rv.is_file(): 51 | child = rv.parent.parent / 'pyproject.toml' 52 | while count < depth: 53 | if child.is_file(): 54 | return child 55 | if str(child.parent) == rv.root: 56 | break # end discovery after checking system root once 57 | child = child.parent.parent / 'pyproject.toml' 58 | count += 1 59 | return rv 60 | 61 | 62 | __all__ = ['PyprojectTomlConfigSettingsSource'] 63 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/secrets.py: -------------------------------------------------------------------------------- 1 | """Secrets file settings source.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | import os 6 | import warnings 7 | from pathlib import Path 8 | from typing import ( 9 | TYPE_CHECKING, 10 | Any, 11 | ) 12 | 13 | from pydantic.fields import FieldInfo 14 | 15 | from pydantic_settings.utils import path_type_label 16 | 17 | from ...exceptions import SettingsError 18 | from ..base import PydanticBaseEnvSettingsSource 19 | from ..types import PathType 20 | 21 | if TYPE_CHECKING: 22 | from pydantic_settings.main import BaseSettings 23 | 24 | 25 | class SecretsSettingsSource(PydanticBaseEnvSettingsSource): 26 | """ 27 | Source class for loading settings values from secret files. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | settings_cls: type[BaseSettings], 33 | secrets_dir: PathType | None = None, 34 | case_sensitive: bool | None = None, 35 | env_prefix: str | None = None, 36 | env_ignore_empty: bool | None = None, 37 | env_parse_none_str: str | None = None, 38 | env_parse_enums: bool | None = None, 39 | ) -> None: 40 | super().__init__( 41 | settings_cls, case_sensitive, env_prefix, env_ignore_empty, env_parse_none_str, env_parse_enums 42 | ) 43 | self.secrets_dir = secrets_dir if secrets_dir is not None else self.config.get('secrets_dir') 44 | 45 | def __call__(self) -> dict[str, Any]: 46 | """ 47 | Build fields from "secrets" files. 48 | """ 49 | secrets: dict[str, str | None] = {} 50 | 51 | if self.secrets_dir is None: 52 | return secrets 53 | 54 | secrets_dirs = [self.secrets_dir] if isinstance(self.secrets_dir, (str, os.PathLike)) else self.secrets_dir 55 | secrets_paths = [Path(p).expanduser() for p in secrets_dirs] 56 | self.secrets_paths = [] 57 | 58 | for path in secrets_paths: 59 | if not path.exists(): 60 | warnings.warn(f'directory "{path}" does not exist') 61 | else: 62 | self.secrets_paths.append(path) 63 | 64 | if not len(self.secrets_paths): 65 | return secrets 66 | 67 | for path in self.secrets_paths: 68 | if not path.is_dir(): 69 | raise SettingsError(f'secrets_dir must reference a directory, not a {path_type_label(path)}') 70 | 71 | return super().__call__() 72 | 73 | @classmethod 74 | def find_case_path(cls, dir_path: Path, file_name: str, case_sensitive: bool) -> Path | None: 75 | """ 76 | Find a file within path's directory matching filename, optionally ignoring case. 77 | 78 | Args: 79 | dir_path: Directory path. 80 | file_name: File name. 81 | case_sensitive: Whether to search for file name case sensitively. 82 | 83 | Returns: 84 | Whether file path or `None` if file does not exist in directory. 85 | """ 86 | for f in dir_path.iterdir(): 87 | if f.name == file_name: 88 | return f 89 | elif not case_sensitive and f.name.lower() == file_name.lower(): 90 | return f 91 | return None 92 | 93 | def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: 94 | """ 95 | Gets the value for field from secret file and a flag to determine whether value is complex. 96 | 97 | Args: 98 | field: The field. 99 | field_name: The field name. 100 | 101 | Returns: 102 | A tuple that contains the value (`None` if the file does not exist), key, and 103 | a flag to determine whether value is complex. 104 | """ 105 | 106 | for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name): 107 | # paths reversed to match the last-wins behaviour of `env_file` 108 | for secrets_path in reversed(self.secrets_paths): 109 | path = self.find_case_path(secrets_path, env_name, self.case_sensitive) 110 | if not path: 111 | # path does not exist, we currently don't return a warning for this 112 | continue 113 | 114 | if path.is_file(): 115 | return path.read_text().strip(), field_key, value_is_complex 116 | else: 117 | warnings.warn( 118 | f'attempted to load secret file "{path}" but found a {path_type_label(path)} instead.', 119 | stacklevel=4, 120 | ) 121 | 122 | return None, field_key, value_is_complex 123 | 124 | def __repr__(self) -> str: 125 | return f'{self.__class__.__name__}(secrets_dir={self.secrets_dir!r})' 126 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/toml.py: -------------------------------------------------------------------------------- 1 | """TOML file settings source.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | import sys 6 | from pathlib import Path 7 | from typing import ( 8 | TYPE_CHECKING, 9 | Any, 10 | ) 11 | 12 | from ..base import ConfigFileSourceMixin, InitSettingsSource 13 | from ..types import DEFAULT_PATH, PathType 14 | 15 | if TYPE_CHECKING: 16 | from pydantic_settings.main import BaseSettings 17 | 18 | if sys.version_info >= (3, 11): 19 | import tomllib 20 | else: 21 | tomllib = None 22 | import tomli 23 | else: 24 | tomllib = None 25 | tomli = None 26 | 27 | 28 | def import_toml() -> None: 29 | global tomli 30 | global tomllib 31 | if sys.version_info < (3, 11): 32 | if tomli is not None: 33 | return 34 | try: 35 | import tomli 36 | except ImportError as e: # pragma: no cover 37 | raise ImportError('tomli is not installed, run `pip install pydantic-settings[toml]`') from e 38 | else: 39 | if tomllib is not None: 40 | return 41 | import tomllib 42 | 43 | 44 | class TomlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): 45 | """ 46 | A source class that loads variables from a TOML file 47 | """ 48 | 49 | def __init__( 50 | self, 51 | settings_cls: type[BaseSettings], 52 | toml_file: PathType | None = DEFAULT_PATH, 53 | ): 54 | self.toml_file_path = toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get('toml_file') 55 | self.toml_data = self._read_files(self.toml_file_path) 56 | super().__init__(settings_cls, self.toml_data) 57 | 58 | def _read_file(self, file_path: Path) -> dict[str, Any]: 59 | import_toml() 60 | with open(file_path, mode='rb') as toml_file: 61 | if sys.version_info < (3, 11): 62 | return tomli.load(toml_file) 63 | return tomllib.load(toml_file) 64 | 65 | def __repr__(self) -> str: 66 | return f'{self.__class__.__name__}(toml_file={self.toml_file_path})' 67 | -------------------------------------------------------------------------------- /pydantic_settings/sources/providers/yaml.py: -------------------------------------------------------------------------------- 1 | """YAML file settings source.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | from pathlib import Path 6 | from typing import ( 7 | TYPE_CHECKING, 8 | Any, 9 | ) 10 | 11 | from ..base import ConfigFileSourceMixin, InitSettingsSource 12 | from ..types import DEFAULT_PATH, PathType 13 | 14 | if TYPE_CHECKING: 15 | import yaml 16 | 17 | from pydantic_settings.main import BaseSettings 18 | else: 19 | yaml = None 20 | 21 | 22 | def import_yaml() -> None: 23 | global yaml 24 | if yaml is not None: 25 | return 26 | try: 27 | import yaml 28 | except ImportError as e: 29 | raise ImportError('PyYAML is not installed, run `pip install pydantic-settings[yaml]`') from e 30 | 31 | 32 | class YamlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): 33 | """ 34 | A source class that loads variables from a yaml file 35 | """ 36 | 37 | def __init__( 38 | self, 39 | settings_cls: type[BaseSettings], 40 | yaml_file: PathType | None = DEFAULT_PATH, 41 | yaml_file_encoding: str | None = None, 42 | yaml_config_section: str | None = None, 43 | ): 44 | self.yaml_file_path = yaml_file if yaml_file != DEFAULT_PATH else settings_cls.model_config.get('yaml_file') 45 | self.yaml_file_encoding = ( 46 | yaml_file_encoding 47 | if yaml_file_encoding is not None 48 | else settings_cls.model_config.get('yaml_file_encoding') 49 | ) 50 | self.yaml_config_section = ( 51 | yaml_config_section 52 | if yaml_config_section is not None 53 | else settings_cls.model_config.get('yaml_config_section') 54 | ) 55 | self.yaml_data = self._read_files(self.yaml_file_path) 56 | 57 | if self.yaml_config_section: 58 | try: 59 | self.yaml_data = self.yaml_data[self.yaml_config_section] 60 | except KeyError: 61 | raise KeyError( 62 | f'yaml_config_section key "{self.yaml_config_section}" not found in {self.yaml_file_path}' 63 | ) 64 | super().__init__(settings_cls, self.yaml_data) 65 | 66 | def _read_file(self, file_path: Path) -> dict[str, Any]: 67 | import_yaml() 68 | with open(file_path, encoding=self.yaml_file_encoding) as yaml_file: 69 | return yaml.safe_load(yaml_file) or {} 70 | 71 | def __repr__(self) -> str: 72 | return f'{self.__class__.__name__}(yaml_file={self.yaml_file_path})' 73 | 74 | 75 | __all__ = ['YamlConfigSettingsSource'] 76 | -------------------------------------------------------------------------------- /pydantic_settings/sources/types.py: -------------------------------------------------------------------------------- 1 | """Type definitions for pydantic-settings sources.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | from collections.abc import Sequence 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING, Any, TypeVar, Union 8 | 9 | if TYPE_CHECKING: 10 | from pydantic._internal._dataclasses import PydanticDataclass 11 | from pydantic.main import BaseModel 12 | 13 | PydanticModel = TypeVar('PydanticModel', bound=Union[PydanticDataclass, BaseModel]) 14 | else: 15 | PydanticModel = Any 16 | 17 | 18 | class EnvNoneType(str): 19 | pass 20 | 21 | 22 | class NoDecode: 23 | """Annotation to prevent decoding of a field value.""" 24 | 25 | pass 26 | 27 | 28 | class ForceDecode: 29 | """Annotation to force decoding of a field value.""" 30 | 31 | pass 32 | 33 | 34 | DotenvType = Union[Path, str, Sequence[Union[Path, str]]] 35 | PathType = Union[Path, str, Sequence[Union[Path, str]]] 36 | DEFAULT_PATH: PathType = Path('') 37 | 38 | # This is used as default value for `_env_file` in the `BaseSettings` class and 39 | # `env_file` in `DotEnvSettingsSource` so the default can be distinguished from `None`. 40 | # See the docstring of `BaseSettings` for more details. 41 | ENV_FILE_SENTINEL: DotenvType = Path('') 42 | 43 | 44 | class _CliSubCommand: 45 | pass 46 | 47 | 48 | class _CliPositionalArg: 49 | pass 50 | 51 | 52 | class _CliImplicitFlag: 53 | pass 54 | 55 | 56 | class _CliExplicitFlag: 57 | pass 58 | 59 | 60 | class _CliUnknownArgs: 61 | pass 62 | 63 | 64 | __all__ = [ 65 | 'DEFAULT_PATH', 66 | 'ENV_FILE_SENTINEL', 67 | 'DotenvType', 68 | 'EnvNoneType', 69 | 'ForceDecode', 70 | 'NoDecode', 71 | 'PathType', 72 | 'PydanticModel', 73 | '_CliExplicitFlag', 74 | '_CliImplicitFlag', 75 | '_CliPositionalArg', 76 | '_CliSubCommand', 77 | '_CliUnknownArgs', 78 | ] 79 | -------------------------------------------------------------------------------- /pydantic_settings/sources/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions for pydantic-settings sources.""" 2 | 3 | from __future__ import annotations as _annotations 4 | 5 | from collections import deque 6 | from collections.abc import Mapping, Sequence 7 | from dataclasses import is_dataclass 8 | from enum import Enum 9 | from typing import Any, Optional, cast 10 | 11 | from pydantic import BaseModel, Json, RootModel, Secret 12 | from pydantic._internal._utils import is_model_class 13 | from pydantic.dataclasses import is_pydantic_dataclass 14 | from typing_extensions import get_args, get_origin 15 | from typing_inspection import typing_objects 16 | 17 | from ..exceptions import SettingsError 18 | from ..utils import _lenient_issubclass 19 | from .types import EnvNoneType 20 | 21 | 22 | def _get_env_var_key(key: str, case_sensitive: bool = False) -> str: 23 | return key if case_sensitive else key.lower() 24 | 25 | 26 | def _parse_env_none_str(value: str | None, parse_none_str: str | None = None) -> str | None | EnvNoneType: 27 | return value if not (value == parse_none_str and parse_none_str is not None) else EnvNoneType(value) 28 | 29 | 30 | def parse_env_vars( 31 | env_vars: Mapping[str, str | None], 32 | case_sensitive: bool = False, 33 | ignore_empty: bool = False, 34 | parse_none_str: str | None = None, 35 | ) -> Mapping[str, str | None]: 36 | return { 37 | _get_env_var_key(k, case_sensitive): _parse_env_none_str(v, parse_none_str) 38 | for k, v in env_vars.items() 39 | if not (ignore_empty and v == '') 40 | } 41 | 42 | 43 | def _annotation_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool: 44 | # If the model is a root model, the root annotation should be used to 45 | # evaluate the complexity. 46 | if annotation is not None and _lenient_issubclass(annotation, RootModel) and annotation is not RootModel: 47 | annotation = cast('type[RootModel[Any]]', annotation) 48 | root_annotation = annotation.model_fields['root'].annotation 49 | if root_annotation is not None: # pragma: no branch 50 | annotation = root_annotation 51 | 52 | if any(isinstance(md, Json) for md in metadata): # type: ignore[misc] 53 | return False 54 | 55 | origin = get_origin(annotation) 56 | 57 | # Check if annotation is of the form Annotated[type, metadata]. 58 | if typing_objects.is_annotated(origin): 59 | # Return result of recursive call on inner type. 60 | inner, *meta = get_args(annotation) 61 | return _annotation_is_complex(inner, meta) 62 | 63 | if origin is Secret: 64 | return False 65 | 66 | return ( 67 | _annotation_is_complex_inner(annotation) 68 | or _annotation_is_complex_inner(origin) 69 | or hasattr(origin, '__pydantic_core_schema__') 70 | or hasattr(origin, '__get_pydantic_core_schema__') 71 | ) 72 | 73 | 74 | def _annotation_is_complex_inner(annotation: type[Any] | None) -> bool: 75 | if _lenient_issubclass(annotation, (str, bytes)): 76 | return False 77 | 78 | return _lenient_issubclass( 79 | annotation, (BaseModel, Mapping, Sequence, tuple, set, frozenset, deque) 80 | ) or is_dataclass(annotation) 81 | 82 | 83 | def _union_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool: 84 | """Check if a union type contains any complex types.""" 85 | return any(_annotation_is_complex(arg, metadata) for arg in get_args(annotation)) 86 | 87 | 88 | def _annotation_contains_types( 89 | annotation: type[Any] | None, 90 | types: tuple[Any, ...], 91 | is_include_origin: bool = True, 92 | is_strip_annotated: bool = False, 93 | ) -> bool: 94 | """Check if a type annotation contains any of the specified types.""" 95 | if is_strip_annotated: 96 | annotation = _strip_annotated(annotation) 97 | if is_include_origin is True and get_origin(annotation) in types: 98 | return True 99 | for type_ in get_args(annotation): 100 | if _annotation_contains_types(type_, types, is_include_origin=True, is_strip_annotated=is_strip_annotated): 101 | return True 102 | return annotation in types 103 | 104 | 105 | def _strip_annotated(annotation: Any) -> Any: 106 | if typing_objects.is_annotated(get_origin(annotation)): 107 | return annotation.__origin__ 108 | else: 109 | return annotation 110 | 111 | 112 | def _annotation_enum_val_to_name(annotation: type[Any] | None, value: Any) -> Optional[str]: 113 | for type_ in (annotation, get_origin(annotation), *get_args(annotation)): 114 | if _lenient_issubclass(type_, Enum): 115 | if value in tuple(val.value for val in type_): 116 | return type_(value).name 117 | return None 118 | 119 | 120 | def _annotation_enum_name_to_val(annotation: type[Any] | None, name: Any) -> Any: 121 | for type_ in (annotation, get_origin(annotation), *get_args(annotation)): 122 | if _lenient_issubclass(type_, Enum): 123 | if name in tuple(val.name for val in type_): 124 | return type_[name] 125 | return None 126 | 127 | 128 | def _get_model_fields(model_cls: type[Any]) -> dict[str, Any]: 129 | """Get fields from a pydantic model or dataclass.""" 130 | 131 | if is_pydantic_dataclass(model_cls) and hasattr(model_cls, '__pydantic_fields__'): 132 | return model_cls.__pydantic_fields__ 133 | if is_model_class(model_cls): 134 | return model_cls.model_fields 135 | raise SettingsError(f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass') 136 | 137 | 138 | def _get_alias_names( 139 | field_name: str, field_info: Any, alias_path_args: dict[str, str] = {}, case_sensitive: bool = True 140 | ) -> tuple[tuple[str, ...], bool]: 141 | """Get alias names for a field, handling alias paths and case sensitivity.""" 142 | from pydantic import AliasChoices, AliasPath 143 | 144 | alias_names: list[str] = [] 145 | is_alias_path_only: bool = True 146 | if not any((field_info.alias, field_info.validation_alias)): 147 | alias_names += [field_name] 148 | is_alias_path_only = False 149 | else: 150 | new_alias_paths: list[AliasPath] = [] 151 | for alias in (field_info.alias, field_info.validation_alias): 152 | if alias is None: 153 | continue 154 | elif isinstance(alias, str): 155 | alias_names.append(alias) 156 | is_alias_path_only = False 157 | elif isinstance(alias, AliasChoices): 158 | for name in alias.choices: 159 | if isinstance(name, str): 160 | alias_names.append(name) 161 | is_alias_path_only = False 162 | else: 163 | new_alias_paths.append(name) 164 | else: 165 | new_alias_paths.append(alias) 166 | for alias_path in new_alias_paths: 167 | name = cast(str, alias_path.path[0]) 168 | name = name.lower() if not case_sensitive else name 169 | alias_path_args[name] = 'dict' if len(alias_path.path) > 2 else 'list' 170 | if not alias_names and is_alias_path_only: 171 | alias_names.append(name) 172 | if not case_sensitive: 173 | alias_names = [alias_name.lower() for alias_name in alias_names] 174 | return tuple(dict.fromkeys(alias_names)), is_alias_path_only 175 | 176 | 177 | def _is_function(obj: Any) -> bool: 178 | """Check if an object is a function.""" 179 | from types import BuiltinFunctionType, FunctionType 180 | 181 | return isinstance(obj, (FunctionType, BuiltinFunctionType)) 182 | 183 | 184 | __all__ = [ 185 | '_annotation_contains_types', 186 | '_annotation_enum_name_to_val', 187 | '_annotation_enum_val_to_name', 188 | '_annotation_is_complex', 189 | '_annotation_is_complex_inner', 190 | '_get_alias_names', 191 | '_get_env_var_key', 192 | '_get_model_fields', 193 | '_is_function', 194 | '_parse_env_none_str', 195 | '_strip_annotated', 196 | '_union_is_complex', 197 | 'parse_env_vars', 198 | ] 199 | -------------------------------------------------------------------------------- /pydantic_settings/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import types 3 | from pathlib import Path 4 | from typing import Any, _GenericAlias # type: ignore [attr-defined] 5 | 6 | from typing_extensions import get_origin 7 | 8 | _PATH_TYPE_LABELS = { 9 | Path.is_dir: 'directory', 10 | Path.is_file: 'file', 11 | Path.is_mount: 'mount point', 12 | Path.is_symlink: 'symlink', 13 | Path.is_block_device: 'block device', 14 | Path.is_char_device: 'char device', 15 | Path.is_fifo: 'FIFO', 16 | Path.is_socket: 'socket', 17 | } 18 | 19 | 20 | def path_type_label(p: Path) -> str: 21 | """ 22 | Find out what sort of thing a path is. 23 | """ 24 | assert p.exists(), 'path does not exist' 25 | for method, name in _PATH_TYPE_LABELS.items(): 26 | if method(p): 27 | return name 28 | 29 | return 'unknown' # pragma: no cover 30 | 31 | 32 | # TODO remove and replace usage by `isinstance(cls, type) and issubclass(cls, class_or_tuple)` 33 | # once we drop support for Python 3.10. 34 | def _lenient_issubclass(cls: Any, class_or_tuple: Any) -> bool: # pragma: no cover 35 | try: 36 | return isinstance(cls, type) and issubclass(cls, class_or_tuple) 37 | except TypeError: 38 | if get_origin(cls) is not None: 39 | # Up until Python 3.10, isinstance(, type) is True 40 | # (e.g. list[int]) 41 | return False 42 | raise 43 | 44 | 45 | if sys.version_info < (3, 10): 46 | _WithArgsTypes = tuple() 47 | else: 48 | _WithArgsTypes = (_GenericAlias, types.GenericAlias, types.UnionType) 49 | -------------------------------------------------------------------------------- /pydantic_settings/version.py: -------------------------------------------------------------------------------- 1 | VERSION = '2.9.1' 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['hatchling'] 3 | build-backend = 'hatchling.build' 4 | 5 | [tool.hatch.version] 6 | path = 'pydantic_settings/version.py' 7 | 8 | [project] 9 | name = 'pydantic-settings' 10 | description = 'Settings management using Pydantic' 11 | authors = [ 12 | {name = 'Samuel Colvin', email = 's@muelcolvin.com'}, 13 | {name = 'Eric Jolibois', email = 'em.jolibois@gmail.com'}, 14 | {name = 'Hasan Ramezani', email = 'hasan.r67@gmail.com'}, 15 | ] 16 | license = 'MIT' 17 | readme = 'README.md' 18 | classifiers = [ 19 | 'Development Status :: 5 - Production/Stable', 20 | 'Programming Language :: Python', 21 | 'Programming Language :: Python :: 3', 22 | 'Programming Language :: Python :: 3 :: Only', 23 | 'Programming Language :: Python :: 3.9', 24 | 'Programming Language :: Python :: 3.10', 25 | 'Programming Language :: Python :: 3.11', 26 | 'Programming Language :: Python :: 3.12', 27 | 'Programming Language :: Python :: 3.13', 28 | 'Intended Audience :: Developers', 29 | 'Intended Audience :: Information Technology', 30 | 'Intended Audience :: System Administrators', 31 | 'License :: OSI Approved :: MIT License', 32 | 'Framework :: Pydantic', 33 | 'Framework :: Pydantic :: 2', 34 | 'Operating System :: Unix', 35 | 'Operating System :: POSIX :: Linux', 36 | 'Environment :: Console', 37 | 'Environment :: MacOS X', 38 | 'Topic :: Software Development :: Libraries :: Python Modules', 39 | 'Topic :: Internet', 40 | ] 41 | requires-python = '>=3.9' 42 | dependencies = [ 43 | 'pydantic>=2.7.0', 44 | 'python-dotenv>=0.21.0', 45 | 'typing-inspection>=0.4.0', 46 | ] 47 | dynamic = ['version'] 48 | 49 | [project.optional-dependencies] 50 | yaml = ["pyyaml>=6.0.1"] 51 | toml = ["tomli>=2.0.1"] 52 | azure-key-vault = ["azure-keyvault-secrets>=4.8.0", "azure-identity>=1.16.0"] 53 | aws-secrets-manager = ["boto3>=1.35.0", "boto3-stubs[secretsmanager]"] 54 | gcp-secret-manager = [ 55 | "google-cloud-secret-manager>=2.23.1", 56 | ] 57 | 58 | [project.urls] 59 | Homepage = 'https://github.com/pydantic/pydantic-settings' 60 | Funding = 'https://github.com/sponsors/samuelcolvin' 61 | Source = 'https://github.com/pydantic/pydantic-settings' 62 | Changelog = 'https://github.com/pydantic/pydantic-settings/releases' 63 | Documentation = 'https://docs.pydantic.dev/dev-v2/concepts/pydantic_settings/' 64 | 65 | [dependency-groups] 66 | linting = [ 67 | "black", 68 | "mypy", 69 | "pre-commit", 70 | "pyyaml", 71 | "ruff", 72 | "types-pyyaml", 73 | "boto3-stubs[secretsmanager]", 74 | ] 75 | testing = [ 76 | "coverage[toml]", 77 | "pytest", 78 | "pytest-examples", 79 | "pytest-mock", 80 | "pytest-pretty", 81 | "moto[secretsmanager]", 82 | "diff-cover>=9.2.0", 83 | ] 84 | 85 | [tool.pytest.ini_options] 86 | testpaths = 'tests' 87 | filterwarnings = [ 88 | 'error', 89 | 'ignore:This is a placeholder until pydantic-settings.*:UserWarning', 90 | 'ignore::DeprecationWarning:botocore.*:', 91 | ] 92 | 93 | # https://coverage.readthedocs.io/en/latest/config.html#run 94 | [tool.coverage.run] 95 | include = [ 96 | "pydantic_settings/**/*.py", 97 | "tests/**/*.py", 98 | ] 99 | branch = true 100 | 101 | # https://coverage.readthedocs.io/en/latest/config.html#report 102 | [tool.coverage.report] 103 | skip_covered = true 104 | show_missing = true 105 | ignore_errors = true 106 | precision = 2 107 | exclude_lines = [ 108 | 'pragma: no cover', 109 | 'raise NotImplementedError', 110 | 'if TYPE_CHECKING:', 111 | 'if typing.TYPE_CHECKING:', 112 | '@overload', 113 | '@deprecated', 114 | '@typing.overload', 115 | '@abstractmethod', 116 | '\(Protocol\):$', 117 | 'typing.assert_never', 118 | '$\s*assert_never\(', 119 | 'if __name__ == .__main__.:', 120 | 'except ImportError as _import_error:', 121 | '$\s*pass$', 122 | ] 123 | 124 | [tool.coverage.paths] 125 | source = [ 126 | 'pydantic_settings/', 127 | ] 128 | 129 | [tool.ruff] 130 | line-length = 120 131 | target-version = 'py39' 132 | 133 | [tool.ruff.lint.pyupgrade] 134 | keep-runtime-typing = true 135 | 136 | [tool.ruff.lint] 137 | extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I'] 138 | flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} 139 | isort = { known-first-party = ['pydantic_settings', 'tests'] } 140 | mccabe = { max-complexity = 14 } 141 | pydocstyle = { convention = 'google' } 142 | 143 | [tool.ruff.format] 144 | quote-style = 'single' 145 | 146 | [tool.mypy] 147 | python_version = '3.10' 148 | show_error_codes = true 149 | follow_imports = 'silent' 150 | strict_optional = true 151 | warn_redundant_casts = true 152 | warn_unused_ignores = true 153 | disallow_any_generics = true 154 | check_untyped_defs = true 155 | no_implicit_reexport = true 156 | warn_unused_configs = true 157 | disallow_subclassing_any = true 158 | disallow_incomplete_defs = true 159 | disallow_untyped_decorators = true 160 | disallow_untyped_calls = true 161 | 162 | # for strict mypy: (this is the tricky one :-)) 163 | disallow_untyped_defs = true 164 | 165 | # remaining arguments from `mypy --strict` which cause errors 166 | # no_implicit_optional = true 167 | # warn_return_any = true 168 | 169 | # ansi2html and devtools are required to avoid the need to install these packages when running linting, 170 | # they're used in the docs build script 171 | [[tool.mypy.overrides]] 172 | module = [ 173 | 'dotenv.*', 174 | ] 175 | ignore_missing_imports = true 176 | 177 | # configuring https://github.com/pydantic/hooky 178 | [tool.hooky] 179 | assignees = ['samuelcolvin', 'dmontagu', 'hramezani'] 180 | reviewers = ['samuelcolvin', 'dmontagu', 'hramezani'] 181 | require_change_file = false 182 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | import pytest 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Iterator 11 | 12 | 13 | class SetEnv: 14 | def __init__(self): 15 | self.envars = set() 16 | 17 | def set(self, name, value): 18 | self.envars.add(name) 19 | os.environ[name] = value 20 | 21 | def pop(self, name): 22 | self.envars.remove(name) 23 | os.environ.pop(name) 24 | 25 | def clear(self): 26 | for n in self.envars: 27 | os.environ.pop(n) 28 | 29 | 30 | @pytest.fixture 31 | def cd_tmp_path(tmp_path: Path) -> Iterator[Path]: 32 | """Change directory into the value of the ``tmp_path`` fixture. 33 | 34 | .. rubric:: Example 35 | .. code-block:: python 36 | 37 | from typing import TYPE_CHECKING 38 | 39 | if TYPE_CHECKING: 40 | from pathlib import Path 41 | 42 | 43 | def test_something(cd_tmp_path: Path) -> None: 44 | ... 45 | 46 | Returns: 47 | Value of the :fixture:`tmp_path` fixture (a :class:`~pathlib.Path` object). 48 | 49 | """ 50 | prev_dir = Path.cwd() 51 | os.chdir(tmp_path) 52 | try: 53 | yield tmp_path 54 | finally: 55 | os.chdir(prev_dir) 56 | 57 | 58 | @pytest.fixture 59 | def env(): 60 | setenv = SetEnv() 61 | 62 | yield setenv 63 | 64 | setenv.clear() 65 | 66 | 67 | @pytest.fixture 68 | def docs_test_env(): 69 | setenv = SetEnv() 70 | 71 | # envs for basic usage example 72 | setenv.set('my_auth_key', 'xxx') 73 | setenv.set('my_api_key', 'xxx') 74 | 75 | # envs for parsing environment variable values example 76 | setenv.set('V0', '0') 77 | setenv.set('SUB_MODEL', '{"v1": "json-1", "v2": "json-2"}') 78 | setenv.set('SUB_MODEL__V2', 'nested-2') 79 | setenv.set('SUB_MODEL__V3', '3') 80 | setenv.set('SUB_MODEL__DEEP__V4', 'v4') 81 | 82 | # envs for parsing environment variable values example with env_nested_max_split=1 83 | setenv.set('GENERATION_LLM_PROVIDER', 'anthropic') 84 | setenv.set('GENERATION_LLM_API_KEY', 'your-api-key') 85 | setenv.set('GENERATION_LLM_API_VERSION', '2024-03-15') 86 | 87 | yield setenv 88 | 89 | setenv.clear() 90 | 91 | 92 | @pytest.fixture 93 | def cli_test_env(): 94 | setenv = SetEnv() 95 | 96 | # envs for reproducible cli tests 97 | setenv.set('COLUMNS', '80') 98 | 99 | yield setenv 100 | 101 | setenv.clear() 102 | -------------------------------------------------------------------------------- /tests/example_test_config.json: -------------------------------------------------------------------------------- 1 | {"foobar": "test"} 2 | -------------------------------------------------------------------------------- /tests/test_docs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations as _annotations 2 | 3 | import platform 4 | import re 5 | import sys 6 | from pathlib import Path 7 | 8 | import pytest 9 | from pytest_examples import CodeExample, EvalExample, find_examples 10 | from pytest_examples.config import ExamplesConfig 11 | from pytest_examples.lint import black_format 12 | 13 | DOCS_ROOT = Path(__file__).parent.parent / 'docs' 14 | 15 | 16 | def skip_docs_tests(): 17 | if sys.platform not in {'linux', 'darwin'}: 18 | return 'not in linux or macos' 19 | 20 | if platform.python_implementation() != 'CPython': 21 | return 'not cpython' 22 | 23 | 24 | class GroupModuleGlobals: 25 | def __init__(self) -> None: 26 | self.name = None 27 | self.module_dict: dict[str, str] = {} 28 | 29 | def get(self, name: str | None): 30 | if name is not None and name == self.name: 31 | return self.module_dict 32 | 33 | def set(self, name: str | None, module_dict: dict[str, str]): 34 | self.name = name 35 | if self.name is None: 36 | self.module_dict = None 37 | else: 38 | self.module_dict = module_dict 39 | 40 | 41 | group_globals = GroupModuleGlobals() 42 | 43 | skip_reason = skip_docs_tests() 44 | 45 | 46 | def print_callback(print_statement: str) -> str: 47 | # make error display uniform 48 | s = re.sub(r'(https://errors.pydantic.dev)/.+?/', r'\1/2/', print_statement) 49 | # hack until https://github.com/pydantic/pytest-examples/issues/11 is fixed 50 | if '' in s: 51 | # avoid function repr breaking black formatting 52 | s = re.sub('', 'math.cos', s) 53 | return black_format(s, ExamplesConfig()).rstrip('\n') 54 | return s 55 | 56 | 57 | @pytest.mark.filterwarnings('ignore:(parse_obj_as|schema_json_of|schema_of) is deprecated.*:DeprecationWarning') 58 | @pytest.mark.skipif(bool(skip_reason), reason=skip_reason or 'not skipping') 59 | @pytest.mark.parametrize('example', find_examples(str(DOCS_ROOT), skip=sys.platform == 'win32'), ids=str) 60 | def test_docs_examples( # noqa C901 61 | example: CodeExample, eval_example: EvalExample, tmp_path: Path, mocker, docs_test_env 62 | ): 63 | eval_example.print_callback = print_callback 64 | 65 | prefix_settings = example.prefix_settings() 66 | test_settings = prefix_settings.get('test') 67 | lint_settings = prefix_settings.get('lint') 68 | if test_settings == 'skip' and lint_settings == 'skip': 69 | pytest.skip('both test and lint skipped') 70 | 71 | requires_settings = prefix_settings.get('requires') 72 | if requires_settings: 73 | major, minor = map(int, requires_settings.split('.')) 74 | if sys.version_info < (major, minor): 75 | pytest.skip(f'requires python {requires_settings}') 76 | 77 | group_name = prefix_settings.get('group') 78 | 79 | if '# ignore-above' in example.source: 80 | eval_example.set_config(ruff_ignore=['E402']) 81 | if group_name: 82 | eval_example.set_config(ruff_ignore=['F821']) 83 | 84 | # eval_example.set_config(line_length=120) 85 | if lint_settings != 'skip': 86 | if eval_example.update_examples: 87 | eval_example.format(example) 88 | else: 89 | eval_example.lint(example) 90 | 91 | if test_settings == 'skip': 92 | return 93 | 94 | group_name = prefix_settings.get('group') 95 | d = group_globals.get(group_name) 96 | 97 | xfail = None 98 | if test_settings and test_settings.startswith('xfail'): 99 | xfail = test_settings[5:].lstrip(' -') 100 | 101 | rewrite_assertions = prefix_settings.get('rewrite_assert', 'true') == 'true' 102 | 103 | try: 104 | if test_settings == 'no-print-intercept': 105 | d2 = eval_example.run(example, module_globals=d, rewrite_assertions=rewrite_assertions) 106 | elif eval_example.update_examples: 107 | d2 = eval_example.run_print_update(example, module_globals=d, rewrite_assertions=rewrite_assertions) 108 | else: 109 | d2 = eval_example.run_print_check(example, module_globals=d, rewrite_assertions=rewrite_assertions) 110 | except BaseException as e: # run_print_check raises a BaseException 111 | if xfail: 112 | pytest.xfail(f'{xfail}, {type(e).__name__}: {e}') 113 | raise 114 | else: 115 | if xfail: 116 | pytest.fail('expected xfail') 117 | group_globals.set(group_name, d2) 118 | -------------------------------------------------------------------------------- /tests/test_source_aws_secrets_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test pydantic_settings.AWSSecretsManagerSettingsSource. 3 | """ 4 | 5 | import json 6 | import os 7 | 8 | import pytest 9 | 10 | try: 11 | import yaml 12 | from moto import mock_aws 13 | except ImportError: 14 | yaml = None 15 | mock_aws = None 16 | 17 | from pydantic import BaseModel, Field 18 | 19 | from pydantic_settings import ( 20 | AWSSecretsManagerSettingsSource, 21 | BaseSettings, 22 | PydanticBaseSettingsSource, 23 | ) 24 | from pydantic_settings.sources.providers.aws import import_aws_secrets_manager 25 | 26 | try: 27 | aws_secrets_manager = True 28 | import_aws_secrets_manager() 29 | import boto3 30 | 31 | os.environ['AWS_DEFAULT_REGION'] = os.environ.get('AWS_DEFAULT_REGION', 'us-east-1') 32 | except ImportError: 33 | aws_secrets_manager = False 34 | 35 | 36 | MODULE = 'pydantic_settings.sources' 37 | 38 | if not yaml: 39 | pytest.skip('PyYAML is not installed', allow_module_level=True) 40 | 41 | 42 | @pytest.mark.skipif(not aws_secrets_manager, reason='pydantic-settings[aws-secrets-manager] is not installed') 43 | class TestAWSSecretsManagerSettingsSource: 44 | """Test AWSSecretsManagerSettingsSource.""" 45 | 46 | @mock_aws 47 | def test_repr(self) -> None: 48 | client = boto3.client('secretsmanager') 49 | client.create_secret(Name='test-secret', SecretString='{}') 50 | 51 | source = AWSSecretsManagerSettingsSource(BaseSettings, 'test-secret') 52 | assert repr(source) == "AWSSecretsManagerSettingsSource(secret_id='test-secret', env_nested_delimiter='--')" 53 | 54 | @mock_aws 55 | def test___init__(self) -> None: 56 | """Test __init__.""" 57 | 58 | class AWSSecretsManagerSettings(BaseSettings): 59 | """AWSSecretsManager settings.""" 60 | 61 | client = boto3.client('secretsmanager') 62 | client.create_secret(Name='test-secret', SecretString='{}') 63 | 64 | AWSSecretsManagerSettingsSource(AWSSecretsManagerSettings, 'test-secret') 65 | 66 | @mock_aws 67 | def test___call__(self) -> None: 68 | """Test __call__.""" 69 | 70 | class SqlServer(BaseModel): 71 | password: str = Field(..., alias='Password') 72 | 73 | class AWSSecretsManagerSettings(BaseSettings): 74 | """AWSSecretsManager settings.""" 75 | 76 | sql_server_user: str = Field(..., alias='SqlServerUser') 77 | sql_server: SqlServer = Field(..., alias='SqlServer') 78 | 79 | secret_data = {'SqlServerUser': 'test-user', 'SqlServer--Password': 'test-password'} 80 | 81 | client = boto3.client('secretsmanager') 82 | client.create_secret(Name='test-secret', SecretString=json.dumps(secret_data)) 83 | 84 | obj = AWSSecretsManagerSettingsSource(AWSSecretsManagerSettings, 'test-secret') 85 | 86 | settings = obj() 87 | 88 | assert settings['SqlServerUser'] == 'test-user' 89 | assert settings['SqlServer']['Password'] == 'test-password' 90 | 91 | @mock_aws 92 | def test_aws_secrets_manager_settings_source(self) -> None: 93 | """Test AWSSecretsManagerSettingsSource.""" 94 | 95 | class SqlServer(BaseModel): 96 | password: str = Field(..., alias='Password') 97 | 98 | class AWSSecretsManagerSettings(BaseSettings): 99 | """AWSSecretsManager settings.""" 100 | 101 | sql_server_user: str = Field(..., alias='SqlServerUser') 102 | sql_server: SqlServer = Field(..., alias='SqlServer') 103 | 104 | @classmethod 105 | def settings_customise_sources( 106 | cls, 107 | settings_cls: type[BaseSettings], 108 | init_settings: PydanticBaseSettingsSource, 109 | env_settings: PydanticBaseSettingsSource, 110 | dotenv_settings: PydanticBaseSettingsSource, 111 | file_secret_settings: PydanticBaseSettingsSource, 112 | ) -> tuple[PydanticBaseSettingsSource, ...]: 113 | return (AWSSecretsManagerSettingsSource(settings_cls, 'test-secret'),) 114 | 115 | secret_data = {'SqlServerUser': 'test-user', 'SqlServer--Password': 'test-password'} 116 | 117 | client = boto3.client('secretsmanager') 118 | client.create_secret(Name='test-secret', SecretString=json.dumps(secret_data)) 119 | 120 | settings = AWSSecretsManagerSettings() # type: ignore 121 | 122 | assert settings.sql_server_user == 'test-user' 123 | assert settings.sql_server.password == 'test-password' 124 | -------------------------------------------------------------------------------- /tests/test_source_azure_key_vault.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test pydantic_settings.AzureKeyVaultSettingsSource. 3 | """ 4 | 5 | import pytest 6 | from pydantic import BaseModel, Field 7 | from pytest_mock import MockerFixture 8 | 9 | from pydantic_settings import ( 10 | AzureKeyVaultSettingsSource, 11 | BaseSettings, 12 | PydanticBaseSettingsSource, 13 | ) 14 | from pydantic_settings.sources.providers.azure import import_azure_key_vault 15 | 16 | try: 17 | azure_key_vault = True 18 | import_azure_key_vault() 19 | from azure.core.exceptions import ResourceNotFoundError 20 | from azure.identity import DefaultAzureCredential 21 | from azure.keyvault.secrets import KeyVaultSecret, SecretClient, SecretProperties 22 | except ImportError: 23 | azure_key_vault = False 24 | 25 | 26 | @pytest.mark.skipif(not azure_key_vault, reason='pydantic-settings[azure-key-vault] is not installed') 27 | class TestAzureKeyVaultSettingsSource: 28 | """Test AzureKeyVaultSettingsSource.""" 29 | 30 | def test___init__(self, mocker: MockerFixture) -> None: 31 | """Test __init__.""" 32 | 33 | class AzureKeyVaultSettings(BaseSettings): 34 | """AzureKeyVault settings.""" 35 | 36 | mocker.patch( 37 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.list_properties_of_secrets.__qualname__}', 38 | return_value=[], 39 | ) 40 | 41 | AzureKeyVaultSettingsSource( 42 | AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', DefaultAzureCredential() 43 | ) 44 | 45 | def test___call__(self, mocker: MockerFixture) -> None: 46 | """Test __call__.""" 47 | 48 | class SqlServer(BaseModel): 49 | password: str = Field(..., alias='Password') 50 | 51 | class AzureKeyVaultSettings(BaseSettings): 52 | """AzureKeyVault settings.""" 53 | 54 | SqlServerUser: str 55 | sql_server_user: str = Field(..., alias='SqlServerUser') 56 | sql_server: SqlServer = Field(..., alias='SqlServer') 57 | 58 | expected_secrets = [ 59 | type('', (), {'name': 'SqlServerUser', 'enabled': True}), 60 | type('', (), {'name': 'SqlServer--Password', 'enabled': True}), 61 | ] 62 | expected_secret_value = 'SecretValue' 63 | mocker.patch( 64 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.list_properties_of_secrets.__qualname__}', 65 | return_value=expected_secrets, 66 | ) 67 | mocker.patch( 68 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.get_secret.__qualname__}', 69 | side_effect=self._raise_resource_not_found_when_getting_parent_secret_name, 70 | ) 71 | obj = AzureKeyVaultSettingsSource( 72 | AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', DefaultAzureCredential() 73 | ) 74 | 75 | settings = obj() 76 | 77 | assert settings['SqlServerUser'] == expected_secret_value 78 | assert settings['SqlServer']['Password'] == expected_secret_value 79 | 80 | def test_do_not_load_disabled_secrets(self, mocker: MockerFixture) -> None: 81 | class AzureKeyVaultSettings(BaseSettings): 82 | """AzureKeyVault settings.""" 83 | 84 | SqlServerPassword: str 85 | DisabledSqlServerPassword: str 86 | 87 | disabled_secret_name = 'SqlServerPassword' 88 | expected_secrets = [ 89 | type('', (), {'name': disabled_secret_name, 'enabled': False}), 90 | ] 91 | mocker.patch( 92 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.list_properties_of_secrets.__qualname__}', 93 | return_value=expected_secrets, 94 | ) 95 | mocker.patch( 96 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.get_secret.__qualname__}', 97 | return_value=KeyVaultSecret(SecretProperties(), 'SecretValue'), 98 | ) 99 | obj = AzureKeyVaultSettingsSource( 100 | AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', DefaultAzureCredential() 101 | ) 102 | 103 | settings = obj() 104 | 105 | assert disabled_secret_name not in settings 106 | 107 | def test_azure_key_vault_settings_source(self, mocker: MockerFixture) -> None: 108 | """Test AzureKeyVaultSettingsSource.""" 109 | 110 | class SqlServer(BaseModel): 111 | password: str = Field(..., alias='Password') 112 | 113 | class AzureKeyVaultSettings(BaseSettings): 114 | """AzureKeyVault settings.""" 115 | 116 | SqlServerUser: str 117 | sql_server_user: str = Field(..., alias='SqlServerUser') 118 | sql_server: SqlServer = Field(..., alias='SqlServer') 119 | 120 | @classmethod 121 | def settings_customise_sources( 122 | cls, 123 | settings_cls: type[BaseSettings], 124 | init_settings: PydanticBaseSettingsSource, 125 | env_settings: PydanticBaseSettingsSource, 126 | dotenv_settings: PydanticBaseSettingsSource, 127 | file_secret_settings: PydanticBaseSettingsSource, 128 | ) -> tuple[PydanticBaseSettingsSource, ...]: 129 | return ( 130 | AzureKeyVaultSettingsSource( 131 | settings_cls, 'https://my-resource.vault.azure.net/', DefaultAzureCredential() 132 | ), 133 | ) 134 | 135 | expected_secrets = [ 136 | type('', (), {'name': 'SqlServerUser', 'enabled': True}), 137 | type('', (), {'name': 'SqlServer--Password', 'enabled': True}), 138 | ] 139 | expected_secret_value = 'SecretValue' 140 | mocker.patch( 141 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.list_properties_of_secrets.__qualname__}', 142 | return_value=expected_secrets, 143 | ) 144 | mocker.patch( 145 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.get_secret.__qualname__}', 146 | side_effect=self._raise_resource_not_found_when_getting_parent_secret_name, 147 | ) 148 | 149 | settings = AzureKeyVaultSettings() # type: ignore 150 | 151 | assert settings.SqlServerUser == expected_secret_value 152 | assert settings.sql_server_user == expected_secret_value 153 | assert settings.sql_server.password == expected_secret_value 154 | 155 | def _raise_resource_not_found_when_getting_parent_secret_name(self, secret_name: str): 156 | expected_secret_value = 'SecretValue' 157 | key_vault_secret = KeyVaultSecret(SecretProperties(), expected_secret_value) 158 | 159 | if secret_name == 'SqlServer': 160 | raise ResourceNotFoundError() 161 | 162 | return key_vault_secret 163 | 164 | def test_dash_to_underscore_translation(self, mocker: MockerFixture) -> None: 165 | """Test that dashes in secret names are mapped to underscores in field names.""" 166 | 167 | class AzureKeyVaultSettings(BaseSettings): 168 | my_field: str 169 | alias_field: str = Field(..., alias='Secret-Alias') 170 | 171 | @classmethod 172 | def settings_customise_sources( 173 | cls, 174 | settings_cls: type[BaseSettings], 175 | init_settings: PydanticBaseSettingsSource, 176 | env_settings: PydanticBaseSettingsSource, 177 | dotenv_settings: PydanticBaseSettingsSource, 178 | file_secret_settings: PydanticBaseSettingsSource, 179 | ) -> tuple[PydanticBaseSettingsSource, ...]: 180 | return ( 181 | AzureKeyVaultSettingsSource( 182 | settings_cls, 183 | 'https://my-resource.vault.azure.net/', 184 | DefaultAzureCredential(), 185 | dash_to_underscore=True, 186 | ), 187 | ) 188 | 189 | expected_secrets = [ 190 | type('', (), {'name': 'my-field', 'enabled': True}), 191 | type('', (), {'name': 'Secret-Alias', 'enabled': True}), 192 | ] 193 | expected_secret_value = 'SecretValue' 194 | 195 | mocker.patch( 196 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.list_properties_of_secrets.__qualname__}', 197 | return_value=expected_secrets, 198 | ) 199 | mocker.patch( 200 | f'{AzureKeyVaultSettingsSource.__module__}.{SecretClient.get_secret.__qualname__}', 201 | return_value=KeyVaultSecret(SecretProperties(), expected_secret_value), 202 | ) 203 | 204 | settings = AzureKeyVaultSettings() 205 | 206 | assert settings.my_field == expected_secret_value 207 | assert settings.alias_field == expected_secret_value 208 | -------------------------------------------------------------------------------- /tests/test_source_gcp_secret_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test pydantic_settings.GoogleSecretSettingsSource 3 | """ 4 | 5 | import pytest 6 | from pydantic import Field 7 | from pytest_mock import MockerFixture 8 | 9 | from pydantic_settings import BaseSettings, PydanticBaseSettingsSource 10 | from pydantic_settings.sources import GoogleSecretManagerSettingsSource 11 | from pydantic_settings.sources.providers.gcp import GoogleSecretManagerMapping, import_gcp_secret_manager 12 | 13 | try: 14 | gcp_secret_manager = True 15 | import_gcp_secret_manager() 16 | from google.cloud.secretmanager import SecretManagerServiceClient 17 | except ImportError: 18 | gcp_secret_manager = False 19 | 20 | 21 | SECRET_VALUES = {'test-secret': 'test-value'} 22 | 23 | 24 | @pytest.fixture 25 | def mock_secret_client(mocker: MockerFixture): 26 | client = mocker.Mock(spec=SecretManagerServiceClient) 27 | 28 | # Mock common_project_path 29 | client.common_project_path.return_value = 'projects/test-project' 30 | 31 | # Mock secret_version_path 32 | client.secret_version_path.return_value = 'projects/test-project/secrets/test-secret/versions/latest' 33 | 34 | client.parse_secret_path = SecretManagerServiceClient.parse_secret_path 35 | 36 | def mock_list_secrets(parent: str) -> list: 37 | # Mock list_secrets 38 | secret = mocker.Mock() 39 | secret.name = f'{parent}/secrets/test-secret' 40 | return [secret] 41 | 42 | client.list_secrets = mock_list_secrets 43 | 44 | secret_response = mocker.Mock() 45 | secret_response.payload.data.decode.return_value = 'test-value' 46 | 47 | def mock_access_secret_version(name: str): 48 | if name == 'projects/test-project/secrets/test-secret/versions/latest': 49 | return secret_response 50 | else: 51 | raise KeyError() 52 | 53 | client.access_secret_version = mock_access_secret_version 54 | 55 | return client 56 | 57 | 58 | @pytest.fixture 59 | def secret_manager_mapping(mock_secret_client): 60 | return GoogleSecretManagerMapping(mock_secret_client, 'test-project') 61 | 62 | 63 | @pytest.fixture 64 | def test_settings(): 65 | class TestSettings(BaseSettings): 66 | test_secret: str 67 | another_secret: str 68 | 69 | return TestSettings 70 | 71 | 72 | @pytest.fixture(autouse=True) 73 | def mock_google_auth(mocker: MockerFixture): 74 | mocker.patch( 75 | 'pydantic_settings.sources.providers.gcp.google_auth_default', return_value=(mocker.Mock(), 'default-project') 76 | ) 77 | 78 | 79 | @pytest.mark.skipif(not gcp_secret_manager, reason='pydantic-settings[gcp-secret-manager] is not installed') 80 | class TestGoogleSecretManagerSettingsSource: 81 | """Test GoogleSecretManagerSettingsSource.""" 82 | 83 | def test_secret_manager_mapping_init(self, secret_manager_mapping): 84 | assert secret_manager_mapping._project_id == 'test-project' 85 | assert len(secret_manager_mapping._loaded_secrets) == 0 86 | 87 | def test_secret_manager_mapping_gcp_project_path(self, secret_manager_mapping, mock_secret_client): 88 | secret_manager_mapping._gcp_project_path 89 | mock_secret_client.common_project_path.assert_called_once_with('test-project') 90 | 91 | def test_secret_manager_mapping_secret_names(self, secret_manager_mapping): 92 | names = secret_manager_mapping._secret_names 93 | assert names == ['test-secret'] 94 | 95 | def test_secret_manager_mapping_getitem_success(self, secret_manager_mapping): 96 | value = secret_manager_mapping['test-secret'] 97 | assert value == 'test-value' 98 | 99 | def test_secret_manager_mapping_getitem_nonexistent_key(self, secret_manager_mapping): 100 | with pytest.raises(KeyError): 101 | _ = secret_manager_mapping['nonexistent-secret'] 102 | 103 | def test_secret_manager_mapping_getitem_access_error(self, secret_manager_mapping, mocker): 104 | secret_manager_mapping._secret_client.access_secret_version = mocker.Mock( 105 | side_effect=Exception('Access denied') 106 | ) 107 | 108 | with pytest.raises(KeyError): 109 | _ = secret_manager_mapping['test-secret'] 110 | 111 | def test_secret_manager_mapping_iter(self, secret_manager_mapping): 112 | assert list(secret_manager_mapping) == ['test-secret'] 113 | 114 | def test_settings_source_init_with_defaults(self, mock_google_auth, test_settings): 115 | source = GoogleSecretManagerSettingsSource(test_settings) 116 | assert source._project_id == 'default-project' 117 | 118 | def test_settings_source_init_with_custom_values(self, mocker, test_settings): 119 | credentials = mocker.Mock() 120 | source = GoogleSecretManagerSettingsSource(test_settings, credentials=credentials, project_id='custom-project') 121 | assert source._project_id == 'custom-project' 122 | assert source._credentials == credentials 123 | 124 | def test_settings_source_init_with_custom_values_no_project_raises_error(self, mocker, test_settings): 125 | credentials = mocker.Mock() 126 | mocker.patch('pydantic_settings.sources.providers.gcp.google_auth_default', return_value=(mocker.Mock(), None)) 127 | 128 | with pytest.raises(AttributeError): 129 | _ = GoogleSecretManagerSettingsSource(test_settings, credentials=credentials) 130 | 131 | def test_settings_source_load_env_vars(self, mock_secret_client, mocker, test_settings): 132 | credentials = mocker.Mock() 133 | source = GoogleSecretManagerSettingsSource(test_settings, credentials=credentials, project_id='test-project') 134 | source._secret_client = mock_secret_client 135 | 136 | env_vars = source._load_env_vars() 137 | assert isinstance(env_vars, GoogleSecretManagerMapping) 138 | assert env_vars.get('test-secret') == 'test-value' 139 | assert env_vars.get('another_secret') is None 140 | 141 | def test_settings_source_repr(self, test_settings): 142 | source = GoogleSecretManagerSettingsSource(test_settings, project_id='test-project') 143 | assert 'test-project' in repr(source) 144 | assert 'GoogleSecretManagerSettingsSource' in repr(source) 145 | 146 | def test_pydantic_base_settings(self, mock_secret_client, monkeypatch, mocker): 147 | monkeypatch.setenv('ANOTHER_SECRET', 'yep_this_one') 148 | 149 | class Settings(BaseSettings, case_sensitive=False): 150 | test_secret: str = Field(..., alias='test-secret') 151 | another_secret: str = Field(..., alias='ANOTHER_SECRET') 152 | 153 | @classmethod 154 | def settings_customise_sources( 155 | cls, 156 | settings_cls: type[BaseSettings], 157 | init_settings: PydanticBaseSettingsSource, 158 | env_settings: PydanticBaseSettingsSource, 159 | dotenv_settings: PydanticBaseSettingsSource, 160 | file_secret_settings: PydanticBaseSettingsSource, 161 | ) -> tuple[PydanticBaseSettingsSource, ...]: 162 | google_secret_manager_settings = GoogleSecretManagerSettingsSource( 163 | settings_cls, secret_client=mock_secret_client 164 | ) 165 | return ( 166 | init_settings, 167 | env_settings, 168 | dotenv_settings, 169 | file_secret_settings, 170 | google_secret_manager_settings, 171 | ) 172 | 173 | settings = Settings() # type: ignore 174 | assert settings.another_secret == 'yep_this_one' 175 | assert settings.test_secret == 'test-value' 176 | 177 | def test_pydantic_base_settings_with_unknown_attribute(self, mock_secret_client, monkeypatch, mocker): 178 | from pydantic_core._pydantic_core import ValidationError 179 | 180 | class Settings(BaseSettings, case_sensitive=False): 181 | test_secret: str = Field(..., alias='test-secret') 182 | another_secret: str = Field(..., alias='ANOTHER_SECRET') 183 | 184 | @classmethod 185 | def settings_customise_sources( 186 | cls, 187 | settings_cls: type[BaseSettings], 188 | init_settings: PydanticBaseSettingsSource, 189 | env_settings: PydanticBaseSettingsSource, 190 | dotenv_settings: PydanticBaseSettingsSource, 191 | file_secret_settings: PydanticBaseSettingsSource, 192 | ) -> tuple[PydanticBaseSettingsSource, ...]: 193 | google_secret_manager_settings = GoogleSecretManagerSettingsSource( 194 | settings_cls, secret_client=mock_secret_client 195 | ) 196 | return ( 197 | init_settings, 198 | env_settings, 199 | dotenv_settings, 200 | file_secret_settings, 201 | google_secret_manager_settings, 202 | ) 203 | 204 | with pytest.raises(ValidationError): 205 | _ = Settings() 206 | -------------------------------------------------------------------------------- /tests/test_source_json.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test pydantic_settings.JsonConfigSettingsSource. 3 | """ 4 | 5 | import json 6 | from pathlib import Path 7 | from typing import Union 8 | 9 | from pydantic import BaseModel 10 | 11 | from pydantic_settings import ( 12 | BaseSettings, 13 | JsonConfigSettingsSource, 14 | PydanticBaseSettingsSource, 15 | SettingsConfigDict, 16 | ) 17 | 18 | 19 | def test_repr() -> None: 20 | source = JsonConfigSettingsSource(BaseSettings, Path('config.json')) 21 | assert repr(source) == 'JsonConfigSettingsSource(json_file=config.json)' 22 | 23 | 24 | def test_json_file(tmp_path): 25 | p = tmp_path / '.env' 26 | p.write_text( 27 | """ 28 | {"foobar": "Hello", "nested": {"nested_field": "world!"}, "null_field": null} 29 | """ 30 | ) 31 | 32 | class Nested(BaseModel): 33 | nested_field: str 34 | 35 | class Settings(BaseSettings): 36 | model_config = SettingsConfigDict(json_file=p) 37 | foobar: str 38 | nested: Nested 39 | null_field: Union[str, None] 40 | 41 | @classmethod 42 | def settings_customise_sources( 43 | cls, 44 | settings_cls: type[BaseSettings], 45 | init_settings: PydanticBaseSettingsSource, 46 | env_settings: PydanticBaseSettingsSource, 47 | dotenv_settings: PydanticBaseSettingsSource, 48 | file_secret_settings: PydanticBaseSettingsSource, 49 | ) -> tuple[PydanticBaseSettingsSource, ...]: 50 | return (JsonConfigSettingsSource(settings_cls),) 51 | 52 | s = Settings() 53 | assert s.foobar == 'Hello' 54 | assert s.nested.nested_field == 'world!' 55 | 56 | 57 | def test_json_no_file(): 58 | class Settings(BaseSettings): 59 | model_config = SettingsConfigDict(json_file=None) 60 | 61 | @classmethod 62 | def settings_customise_sources( 63 | cls, 64 | settings_cls: type[BaseSettings], 65 | init_settings: PydanticBaseSettingsSource, 66 | env_settings: PydanticBaseSettingsSource, 67 | dotenv_settings: PydanticBaseSettingsSource, 68 | file_secret_settings: PydanticBaseSettingsSource, 69 | ) -> tuple[PydanticBaseSettingsSource, ...]: 70 | return (JsonConfigSettingsSource(settings_cls),) 71 | 72 | s = Settings() 73 | assert s.model_dump() == {} 74 | 75 | 76 | def test_multiple_file_json(tmp_path): 77 | p5 = tmp_path / '.env.json5' 78 | p6 = tmp_path / '.env.json6' 79 | 80 | with open(p5, 'w') as f5: 81 | json.dump({'json5': 5}, f5) 82 | with open(p6, 'w') as f6: 83 | json.dump({'json6': 6}, f6) 84 | 85 | class Settings(BaseSettings): 86 | json5: int 87 | json6: int 88 | 89 | @classmethod 90 | def settings_customise_sources( 91 | cls, 92 | settings_cls: type[BaseSettings], 93 | init_settings: PydanticBaseSettingsSource, 94 | env_settings: PydanticBaseSettingsSource, 95 | dotenv_settings: PydanticBaseSettingsSource, 96 | file_secret_settings: PydanticBaseSettingsSource, 97 | ) -> tuple[PydanticBaseSettingsSource, ...]: 98 | return (JsonConfigSettingsSource(settings_cls, json_file=[p5, p6]),) 99 | 100 | s = Settings() 101 | assert s.model_dump() == {'json5': 5, 'json6': 6} 102 | -------------------------------------------------------------------------------- /tests/test_source_pyproject_toml.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test pydantic_settings.PyprojectTomlConfigSettingsSource. 3 | """ 4 | 5 | import sys 6 | from pathlib import Path 7 | from typing import Optional 8 | 9 | import pytest 10 | from pydantic import BaseModel 11 | from pytest_mock import MockerFixture 12 | 13 | from pydantic_settings import ( 14 | BaseSettings, 15 | PydanticBaseSettingsSource, 16 | PyprojectTomlConfigSettingsSource, 17 | SettingsConfigDict, 18 | ) 19 | 20 | try: 21 | import tomli 22 | except ImportError: 23 | tomli = None 24 | 25 | 26 | MODULE = 'pydantic_settings.sources.providers.pyproject' 27 | 28 | SOME_TOML_DATA = """ 29 | field = "top-level" 30 | 31 | [some] 32 | [some.table] 33 | field = "some" 34 | 35 | [other.table] 36 | field = "other" 37 | """ 38 | 39 | 40 | class SimpleSettings(BaseSettings): 41 | """Simple settings.""" 42 | 43 | model_config = SettingsConfigDict(pyproject_toml_depth=1, pyproject_toml_table_header=('some', 'table')) 44 | 45 | 46 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 47 | class TestPyprojectTomlConfigSettingsSource: 48 | """Test PyprojectTomlConfigSettingsSource.""" 49 | 50 | def test___init__(self, mocker: MockerFixture, tmp_path: Path) -> None: 51 | """Test __init__.""" 52 | mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path) 53 | pyproject = tmp_path / 'pyproject.toml' 54 | pyproject.write_text(SOME_TOML_DATA) 55 | obj = PyprojectTomlConfigSettingsSource(SimpleSettings) 56 | assert obj.toml_table_header == ('some', 'table') 57 | assert obj.toml_data == {'field': 'some'} 58 | assert obj.toml_file_path == tmp_path / 'pyproject.toml' 59 | 60 | def test___init___explicit(self, mocker: MockerFixture, tmp_path: Path) -> None: 61 | """Test __init__ explicit file.""" 62 | mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path) 63 | pyproject = tmp_path / 'child' / 'pyproject.toml' 64 | pyproject.parent.mkdir() 65 | pyproject.write_text(SOME_TOML_DATA) 66 | obj = PyprojectTomlConfigSettingsSource(SimpleSettings, pyproject) 67 | assert obj.toml_table_header == ('some', 'table') 68 | assert obj.toml_data == {'field': 'some'} 69 | assert obj.toml_file_path == pyproject 70 | 71 | def test___init___explicit_missing(self, mocker: MockerFixture, tmp_path: Path) -> None: 72 | """Test __init__ explicit file missing.""" 73 | mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path) 74 | pyproject = tmp_path / 'child' / 'pyproject.toml' 75 | obj = PyprojectTomlConfigSettingsSource(SimpleSettings, pyproject) 76 | assert obj.toml_table_header == ('some', 'table') 77 | assert not obj.toml_data 78 | assert obj.toml_file_path == pyproject 79 | 80 | @pytest.mark.parametrize('depth', [0, 99]) 81 | def test___init___no_file(self, depth: int, mocker: MockerFixture, tmp_path: Path) -> None: 82 | """Test __init__ no file.""" 83 | 84 | class Settings(BaseSettings): 85 | model_config = SettingsConfigDict(pyproject_toml_depth=depth) 86 | 87 | mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path / 'foo') 88 | obj = PyprojectTomlConfigSettingsSource(Settings) 89 | assert obj.toml_table_header == ('tool', 'pydantic-settings') 90 | assert not obj.toml_data 91 | assert obj.toml_file_path == tmp_path / 'foo' / 'pyproject.toml' 92 | 93 | def test___init___parent(self, mocker: MockerFixture, tmp_path: Path) -> None: 94 | """Test __init__ parent directory.""" 95 | mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path / 'child') 96 | pyproject = tmp_path / 'pyproject.toml' 97 | pyproject.write_text(SOME_TOML_DATA) 98 | obj = PyprojectTomlConfigSettingsSource(SimpleSettings) 99 | assert obj.toml_table_header == ('some', 'table') 100 | assert obj.toml_data == {'field': 'some'} 101 | assert obj.toml_file_path == tmp_path / 'pyproject.toml' 102 | 103 | 104 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 105 | def test_pyproject_toml_file(cd_tmp_path: Path): 106 | pyproject = cd_tmp_path / 'pyproject.toml' 107 | pyproject.write_text( 108 | """ 109 | [tool.pydantic-settings] 110 | foobar = "Hello" 111 | 112 | [tool.pydantic-settings.nested] 113 | nested_field = "world!" 114 | """ 115 | ) 116 | 117 | class Nested(BaseModel): 118 | nested_field: str 119 | 120 | class Settings(BaseSettings): 121 | foobar: str 122 | nested: Nested 123 | model_config = SettingsConfigDict() 124 | 125 | @classmethod 126 | def settings_customise_sources( 127 | cls, settings_cls: type[BaseSettings], **_kwargs: PydanticBaseSettingsSource 128 | ) -> tuple[PydanticBaseSettingsSource, ...]: 129 | return (PyprojectTomlConfigSettingsSource(settings_cls),) 130 | 131 | s = Settings() 132 | assert s.foobar == 'Hello' 133 | assert s.nested.nested_field == 'world!' 134 | 135 | 136 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 137 | def test_pyproject_toml_file_explicit(cd_tmp_path: Path): 138 | pyproject = cd_tmp_path / 'child' / 'grandchild' / 'pyproject.toml' 139 | pyproject.parent.mkdir(parents=True) 140 | pyproject.write_text( 141 | """ 142 | [tool.pydantic-settings] 143 | foobar = "Hello" 144 | 145 | [tool.pydantic-settings.nested] 146 | nested_field = "world!" 147 | """ 148 | ) 149 | (cd_tmp_path / 'pyproject.toml').write_text( 150 | """ 151 | [tool.pydantic-settings] 152 | foobar = "fail" 153 | 154 | [tool.pydantic-settings.nested] 155 | nested_field = "fail" 156 | """ 157 | ) 158 | 159 | class Nested(BaseModel): 160 | nested_field: str 161 | 162 | class Settings(BaseSettings): 163 | foobar: str 164 | nested: Nested 165 | model_config = SettingsConfigDict() 166 | 167 | @classmethod 168 | def settings_customise_sources( 169 | cls, settings_cls: type[BaseSettings], **_kwargs: PydanticBaseSettingsSource 170 | ) -> tuple[PydanticBaseSettingsSource, ...]: 171 | return (PyprojectTomlConfigSettingsSource(settings_cls, pyproject),) 172 | 173 | s = Settings() 174 | assert s.foobar == 'Hello' 175 | assert s.nested.nested_field == 'world!' 176 | 177 | 178 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 179 | def test_pyproject_toml_file_parent(mocker: MockerFixture, tmp_path: Path): 180 | cwd = tmp_path / 'child' / 'grandchild' / 'cwd' 181 | cwd.mkdir(parents=True) 182 | mocker.patch('pydantic_settings.sources.providers.toml.Path.cwd', return_value=cwd) 183 | (cwd.parent.parent / 'pyproject.toml').write_text( 184 | """ 185 | [tool.pydantic-settings] 186 | foobar = "Hello" 187 | 188 | [tool.pydantic-settings.nested] 189 | nested_field = "world!" 190 | """ 191 | ) 192 | (tmp_path / 'pyproject.toml').write_text( 193 | """ 194 | [tool.pydantic-settings] 195 | foobar = "fail" 196 | 197 | [tool.pydantic-settings.nested] 198 | nested_field = "fail" 199 | """ 200 | ) 201 | 202 | class Nested(BaseModel): 203 | nested_field: str 204 | 205 | class Settings(BaseSettings): 206 | foobar: str 207 | nested: Nested 208 | model_config = SettingsConfigDict(pyproject_toml_depth=2) 209 | 210 | @classmethod 211 | def settings_customise_sources( 212 | cls, settings_cls: type[BaseSettings], **_kwargs: PydanticBaseSettingsSource 213 | ) -> tuple[PydanticBaseSettingsSource, ...]: 214 | return (PyprojectTomlConfigSettingsSource(settings_cls),) 215 | 216 | s = Settings() 217 | assert s.foobar == 'Hello' 218 | assert s.nested.nested_field == 'world!' 219 | 220 | 221 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 222 | def test_pyproject_toml_file_header(cd_tmp_path: Path): 223 | pyproject = cd_tmp_path / 'subdir' / 'pyproject.toml' 224 | pyproject.parent.mkdir() 225 | pyproject.write_text( 226 | """ 227 | [tool.pydantic-settings] 228 | foobar = "Hello" 229 | 230 | [tool.pydantic-settings.nested] 231 | nested_field = "world!" 232 | 233 | [tool."my.tool".foo] 234 | status = "success" 235 | """ 236 | ) 237 | 238 | class Settings(BaseSettings): 239 | status: str 240 | model_config = SettingsConfigDict(extra='forbid', pyproject_toml_table_header=('tool', 'my.tool', 'foo')) 241 | 242 | @classmethod 243 | def settings_customise_sources( 244 | cls, settings_cls: type[BaseSettings], **_kwargs: PydanticBaseSettingsSource 245 | ) -> tuple[PydanticBaseSettingsSource, ...]: 246 | return (PyprojectTomlConfigSettingsSource(settings_cls, pyproject),) 247 | 248 | s = Settings() 249 | assert s.status == 'success' 250 | 251 | 252 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 253 | @pytest.mark.parametrize('depth', [0, 99]) 254 | def test_pyproject_toml_no_file(cd_tmp_path: Path, depth: int): 255 | class Settings(BaseSettings): 256 | model_config = SettingsConfigDict(pyproject_toml_depth=depth) 257 | 258 | @classmethod 259 | def settings_customise_sources( 260 | cls, settings_cls: type[BaseSettings], **_kwargs: PydanticBaseSettingsSource 261 | ) -> tuple[PydanticBaseSettingsSource, ...]: 262 | return (PyprojectTomlConfigSettingsSource(settings_cls),) 263 | 264 | s = Settings() 265 | assert s.model_dump() == {} 266 | 267 | 268 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 269 | def test_pyproject_toml_no_file_explicit(tmp_path: Path): 270 | pyproject = tmp_path / 'child' / 'pyproject.toml' 271 | (tmp_path / 'pyproject.toml').write_text('[tool.pydantic-settings]\nfield = "fail"') 272 | 273 | class Settings(BaseSettings): 274 | model_config = SettingsConfigDict() 275 | 276 | field: Optional[str] = None 277 | 278 | @classmethod 279 | def settings_customise_sources( 280 | cls, settings_cls: type[BaseSettings], **_kwargs: PydanticBaseSettingsSource 281 | ) -> tuple[PydanticBaseSettingsSource, ...]: 282 | return (PyprojectTomlConfigSettingsSource(settings_cls, pyproject),) 283 | 284 | s = Settings() 285 | assert s.model_dump() == {'field': None} 286 | 287 | 288 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 289 | @pytest.mark.parametrize('depth', [0, 1, 2]) 290 | def test_pyproject_toml_no_file_too_shallow(depth: int, mocker: MockerFixture, tmp_path: Path): 291 | cwd = tmp_path / 'child' / 'grandchild' / 'cwd' 292 | cwd.mkdir(parents=True) 293 | mocker.patch('pydantic_settings.sources.providers.toml.Path.cwd', return_value=cwd) 294 | (tmp_path / 'pyproject.toml').write_text( 295 | """ 296 | [tool.pydantic-settings] 297 | foobar = "fail" 298 | 299 | [tool.pydantic-settings.nested] 300 | nested_field = "fail" 301 | """ 302 | ) 303 | 304 | class Nested(BaseModel): 305 | nested_field: Optional[str] = None 306 | 307 | class Settings(BaseSettings): 308 | foobar: Optional[str] = None 309 | nested: Nested = Nested() 310 | model_config = SettingsConfigDict(pyproject_toml_depth=depth) 311 | 312 | @classmethod 313 | def settings_customise_sources( 314 | cls, settings_cls: type[BaseSettings], **_kwargs: PydanticBaseSettingsSource 315 | ) -> tuple[PydanticBaseSettingsSource, ...]: 316 | return (PyprojectTomlConfigSettingsSource(settings_cls),) 317 | 318 | s = Settings() 319 | assert not s.foobar 320 | assert not s.nested.nested_field 321 | -------------------------------------------------------------------------------- /tests/test_source_toml.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test pydantic_settings.TomlConfigSettingsSource. 3 | """ 4 | 5 | import sys 6 | from pathlib import Path 7 | 8 | import pytest 9 | from pydantic import BaseModel 10 | 11 | from pydantic_settings import ( 12 | BaseSettings, 13 | PydanticBaseSettingsSource, 14 | SettingsConfigDict, 15 | TomlConfigSettingsSource, 16 | ) 17 | 18 | try: 19 | import tomli 20 | except ImportError: 21 | tomli = None 22 | 23 | 24 | def test_repr() -> None: 25 | source = TomlConfigSettingsSource(BaseSettings, Path('config.toml')) 26 | assert repr(source) == 'TomlConfigSettingsSource(toml_file=config.toml)' 27 | 28 | 29 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 30 | def test_toml_file(tmp_path): 31 | p = tmp_path / '.env' 32 | p.write_text( 33 | """ 34 | foobar = "Hello" 35 | 36 | [nested] 37 | nested_field = "world!" 38 | """ 39 | ) 40 | 41 | class Nested(BaseModel): 42 | nested_field: str 43 | 44 | class Settings(BaseSettings): 45 | foobar: str 46 | nested: Nested 47 | model_config = SettingsConfigDict(toml_file=p) 48 | 49 | @classmethod 50 | def settings_customise_sources( 51 | cls, 52 | settings_cls: type[BaseSettings], 53 | init_settings: PydanticBaseSettingsSource, 54 | env_settings: PydanticBaseSettingsSource, 55 | dotenv_settings: PydanticBaseSettingsSource, 56 | file_secret_settings: PydanticBaseSettingsSource, 57 | ) -> tuple[PydanticBaseSettingsSource, ...]: 58 | return (TomlConfigSettingsSource(settings_cls),) 59 | 60 | s = Settings() 61 | assert s.foobar == 'Hello' 62 | assert s.nested.nested_field == 'world!' 63 | 64 | 65 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 66 | def test_toml_no_file(): 67 | class Settings(BaseSettings): 68 | model_config = SettingsConfigDict(toml_file=None) 69 | 70 | @classmethod 71 | def settings_customise_sources( 72 | cls, 73 | settings_cls: type[BaseSettings], 74 | init_settings: PydanticBaseSettingsSource, 75 | env_settings: PydanticBaseSettingsSource, 76 | dotenv_settings: PydanticBaseSettingsSource, 77 | file_secret_settings: PydanticBaseSettingsSource, 78 | ) -> tuple[PydanticBaseSettingsSource, ...]: 79 | return (TomlConfigSettingsSource(settings_cls),) 80 | 81 | s = Settings() 82 | assert s.model_dump() == {} 83 | 84 | 85 | @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') 86 | def test_multiple_file_toml(tmp_path): 87 | p1 = tmp_path / '.env.toml1' 88 | p2 = tmp_path / '.env.toml2' 89 | p1.write_text( 90 | """ 91 | toml1=1 92 | """ 93 | ) 94 | p2.write_text( 95 | """ 96 | toml2=2 97 | """ 98 | ) 99 | 100 | class Settings(BaseSettings): 101 | toml1: int 102 | toml2: int 103 | 104 | @classmethod 105 | def settings_customise_sources( 106 | cls, 107 | settings_cls: type[BaseSettings], 108 | init_settings: PydanticBaseSettingsSource, 109 | env_settings: PydanticBaseSettingsSource, 110 | dotenv_settings: PydanticBaseSettingsSource, 111 | file_secret_settings: PydanticBaseSettingsSource, 112 | ) -> tuple[PydanticBaseSettingsSource, ...]: 113 | return (TomlConfigSettingsSource(settings_cls, toml_file=[p1, p2]),) 114 | 115 | s = Settings() 116 | assert s.model_dump() == {'toml1': 1, 'toml2': 2} 117 | -------------------------------------------------------------------------------- /tests/test_source_yaml.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test pydantic_settings.YamlConfigSettingsSource. 3 | """ 4 | 5 | from pathlib import Path 6 | from typing import Union 7 | 8 | import pytest 9 | from pydantic import BaseModel 10 | 11 | from pydantic_settings import ( 12 | BaseSettings, 13 | PydanticBaseSettingsSource, 14 | SettingsConfigDict, 15 | YamlConfigSettingsSource, 16 | ) 17 | 18 | try: 19 | import yaml 20 | except ImportError: 21 | yaml = None 22 | 23 | 24 | def test_repr() -> None: 25 | source = YamlConfigSettingsSource(BaseSettings, Path('config.yaml')) 26 | assert repr(source) == 'YamlConfigSettingsSource(yaml_file=config.yaml)' 27 | 28 | 29 | @pytest.mark.skipif(yaml, reason='PyYAML is installed') 30 | def test_yaml_not_installed(tmp_path): 31 | p = tmp_path / '.env' 32 | p.write_text( 33 | """ 34 | foobar: "Hello" 35 | """ 36 | ) 37 | 38 | class Settings(BaseSettings): 39 | foobar: str 40 | model_config = SettingsConfigDict(yaml_file=p) 41 | 42 | @classmethod 43 | def settings_customise_sources( 44 | cls, 45 | settings_cls: type[BaseSettings], 46 | init_settings: PydanticBaseSettingsSource, 47 | env_settings: PydanticBaseSettingsSource, 48 | dotenv_settings: PydanticBaseSettingsSource, 49 | file_secret_settings: PydanticBaseSettingsSource, 50 | ) -> tuple[PydanticBaseSettingsSource, ...]: 51 | return (YamlConfigSettingsSource(settings_cls),) 52 | 53 | with pytest.raises(ImportError, match=r'^PyYAML is not installed, run `pip install pydantic-settings\[yaml\]`$'): 54 | Settings() 55 | 56 | 57 | @pytest.mark.skipif(yaml is None, reason='pyYaml is not installed') 58 | def test_yaml_file(tmp_path): 59 | p = tmp_path / '.env' 60 | p.write_text( 61 | """ 62 | foobar: "Hello" 63 | null_field: 64 | nested: 65 | nested_field: "world!" 66 | """ 67 | ) 68 | 69 | class Nested(BaseModel): 70 | nested_field: str 71 | 72 | class Settings(BaseSettings): 73 | foobar: str 74 | nested: Nested 75 | null_field: Union[str, None] 76 | model_config = SettingsConfigDict(yaml_file=p) 77 | 78 | @classmethod 79 | def settings_customise_sources( 80 | cls, 81 | settings_cls: type[BaseSettings], 82 | init_settings: PydanticBaseSettingsSource, 83 | env_settings: PydanticBaseSettingsSource, 84 | dotenv_settings: PydanticBaseSettingsSource, 85 | file_secret_settings: PydanticBaseSettingsSource, 86 | ) -> tuple[PydanticBaseSettingsSource, ...]: 87 | return (YamlConfigSettingsSource(settings_cls),) 88 | 89 | s = Settings() 90 | assert s.foobar == 'Hello' 91 | assert s.nested.nested_field == 'world!' 92 | 93 | 94 | @pytest.mark.skipif(yaml is None, reason='pyYaml is not installed') 95 | def test_yaml_no_file(): 96 | class Settings(BaseSettings): 97 | model_config = SettingsConfigDict(yaml_file=None) 98 | 99 | @classmethod 100 | def settings_customise_sources( 101 | cls, 102 | settings_cls: type[BaseSettings], 103 | init_settings: PydanticBaseSettingsSource, 104 | env_settings: PydanticBaseSettingsSource, 105 | dotenv_settings: PydanticBaseSettingsSource, 106 | file_secret_settings: PydanticBaseSettingsSource, 107 | ) -> tuple[PydanticBaseSettingsSource, ...]: 108 | return (YamlConfigSettingsSource(settings_cls),) 109 | 110 | s = Settings() 111 | assert s.model_dump() == {} 112 | 113 | 114 | @pytest.mark.skipif(yaml is None, reason='pyYaml is not installed') 115 | def test_yaml_empty_file(tmp_path): 116 | p = tmp_path / '.env' 117 | p.write_text('') 118 | 119 | class Settings(BaseSettings): 120 | model_config = SettingsConfigDict(yaml_file=p) 121 | 122 | @classmethod 123 | def settings_customise_sources( 124 | cls, 125 | settings_cls: type[BaseSettings], 126 | init_settings: PydanticBaseSettingsSource, 127 | env_settings: PydanticBaseSettingsSource, 128 | dotenv_settings: PydanticBaseSettingsSource, 129 | file_secret_settings: PydanticBaseSettingsSource, 130 | ) -> tuple[PydanticBaseSettingsSource, ...]: 131 | return (YamlConfigSettingsSource(settings_cls),) 132 | 133 | s = Settings() 134 | assert s.model_dump() == {} 135 | 136 | 137 | @pytest.mark.skipif(yaml is None, reason='pyYAML is not installed') 138 | def test_multiple_file_yaml(tmp_path): 139 | p3 = tmp_path / '.env.yaml3' 140 | p4 = tmp_path / '.env.yaml4' 141 | p3.write_text( 142 | """ 143 | yaml3: 3 144 | """ 145 | ) 146 | p4.write_text( 147 | """ 148 | yaml4: 4 149 | """ 150 | ) 151 | 152 | class Settings(BaseSettings): 153 | yaml3: int 154 | yaml4: int 155 | 156 | @classmethod 157 | def settings_customise_sources( 158 | cls, 159 | settings_cls: type[BaseSettings], 160 | init_settings: PydanticBaseSettingsSource, 161 | env_settings: PydanticBaseSettingsSource, 162 | dotenv_settings: PydanticBaseSettingsSource, 163 | file_secret_settings: PydanticBaseSettingsSource, 164 | ) -> tuple[PydanticBaseSettingsSource, ...]: 165 | return (YamlConfigSettingsSource(settings_cls, yaml_file=[p3, p4]),) 166 | 167 | s = Settings() 168 | assert s.model_dump() == {'yaml3': 3, 'yaml4': 4} 169 | 170 | 171 | @pytest.mark.skipif(yaml is None, reason='pyYAML is not installed') 172 | def test_yaml_config_section(tmp_path): 173 | p = tmp_path / '.env' 174 | p.write_text( 175 | """ 176 | foobar: "Hello" 177 | nested: 178 | nested_field: "world!" 179 | """ 180 | ) 181 | 182 | class Settings(BaseSettings): 183 | nested_field: str 184 | 185 | model_config = SettingsConfigDict(yaml_file=p, yaml_config_section='nested') 186 | 187 | @classmethod 188 | def settings_customise_sources( 189 | cls, 190 | settings_cls: type[BaseSettings], 191 | init_settings: PydanticBaseSettingsSource, 192 | env_settings: PydanticBaseSettingsSource, 193 | dotenv_settings: PydanticBaseSettingsSource, 194 | file_secret_settings: PydanticBaseSettingsSource, 195 | ) -> tuple[PydanticBaseSettingsSource, ...]: 196 | return (YamlConfigSettingsSource(settings_cls),) 197 | 198 | s = Settings() 199 | assert s.nested_field == 'world!' 200 | 201 | 202 | @pytest.mark.skipif(yaml is None, reason='pyYAML is not installed') 203 | def test_invalid_yaml_config_section(tmp_path): 204 | p = tmp_path / '.env' 205 | p.write_text( 206 | """ 207 | foobar: "Hello" 208 | nested: 209 | nested_field: "world!" 210 | """ 211 | ) 212 | 213 | class Settings(BaseSettings): 214 | nested_field: str 215 | 216 | model_config = SettingsConfigDict(yaml_file=p, yaml_config_section='invalid_key') 217 | 218 | @classmethod 219 | def settings_customise_sources( 220 | cls, 221 | settings_cls: type[BaseSettings], 222 | init_settings: PydanticBaseSettingsSource, 223 | env_settings: PydanticBaseSettingsSource, 224 | dotenv_settings: PydanticBaseSettingsSource, 225 | file_secret_settings: PydanticBaseSettingsSource, 226 | ) -> tuple[PydanticBaseSettingsSource, ...]: 227 | return (YamlConfigSettingsSource(settings_cls),) 228 | 229 | with pytest.raises(KeyError, match='yaml_config_section key "invalid_key" not found in .+'): 230 | Settings() 231 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings.utils import path_type_label 2 | 3 | 4 | def test_path_type_label(tmp_path): 5 | result = path_type_label(tmp_path) 6 | assert result == 'directory' 7 | --------------------------------------------------------------------------------