├── .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 | [](https://github.com/pydantic/pydantic-settings/actions/workflows/ci.yml?query=branch%3Amain)
4 | [](https://codecov.io/gh/pydantic/pydantic-settings)
5 | [](https://pypi.python.org/pypi/pydantic-settings)
6 | [](https://github.com/pydantic/pydantic-settings/blob/main/LICENSE)
7 | [](https://pepy.tech/project/pydantic-settings)
8 | [](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 |
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 |
--------------------------------------------------------------------------------