pax_global_header00006660000000000000000000000064147600344540014521gustar00rootroot0000000000000052 comment=5f33b6205608ccd51828cf52656b9b37f6c79db8 pydantic-pydantic-settings-5f33b62/000077500000000000000000000000001476003445400173375ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/.github/000077500000000000000000000000001476003445400206775ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/.github/FUNDING.yml000066400000000000000000000000251476003445400225110ustar00rootroot00000000000000github: samuelcolvin pydantic-pydantic-settings-5f33b62/.github/workflows/000077500000000000000000000000001476003445400227345ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/.github/workflows/ci.yml000066400000000000000000000052641476003445400240610ustar00rootroot00000000000000name: CI on: push: branches: - main tags: - '**' pull_request: {} jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: set up python uses: actions/setup-python@v5 with: python-version: '3.10' - run: pip install -r requirements/linting.txt -r requirements/pyproject.txt - uses: pre-commit/action@v3.0.0 with: extra_args: --all-files test: name: test py${{ matrix.python }} on ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] python: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] env: PYTHON: ${{ matrix.python }} OS: ${{ matrix.os }} runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - name: set up python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - run: pip install -r requirements/testing.txt -r requirements/pyproject.txt - name: test run: make test env: COVERAGE_FILE: .coverage.${{ runner.os }}-py${{ matrix.python }} CONTEXT: ${{ runner.os }}-py${{ matrix.python }} - name: uninstall deps run: pip uninstall -y PyYAML - name: test without deps run: make test env: COVERAGE_FILE: .coverage.${{ runner.os }}-py${{ matrix.python }}-without-deps CONTEXT: ${{ runner.os }}-py${{ matrix.python }}-without-deps - run: coverage combine - run: coverage xml - uses: codecov/codecov-action@v4 with: file: ./coverage.xml env_vars: PYTHON,OS check: # This job does nothing and is only used for the branch protection if: always() needs: [lint, test] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed uses: re-actors/alls-green@release/v1 id: all-green with: jobs: ${{ toJSON(needs) }} release: name: Release needs: [check] if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest environment: release permissions: id-token: write steps: - uses: actions/checkout@v4 - name: set up python uses: actions/setup-python@v5 with: python-version: '3.10' - name: install run: pip install -U build - name: check GITHUB_REF matches package version uses: samuelcolvin/check-python-version@v3.1 with: version_file_path: pydantic_settings/version.py - name: build run: python -m build - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 pydantic-pydantic-settings-5f33b62/.gitignore000066400000000000000000000004331476003445400213270ustar00rootroot00000000000000.idea/ env/ .envrc venv/ .venv/ env3*/ Pipfile *.lock *.py[cod] *.egg-info/ /build/ dist/ .cache/ .mypy_cache/ test.py .coverage .hypothesis /htmlcov/ /site/ /site.zip .pytest_cache/ .python-version .vscode/ _build/ .auto-format /sandbox/ /.ghtopdep_cache/ /worktrees/ /.ruff_cache/ pydantic-pydantic-settings-5f33b62/.pre-commit-config.yaml000066400000000000000000000010701476003445400236160ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.3.0 hooks: - id: check-yaml args: ['--unsafe'] - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - repo: local hooks: - id: lint name: Lint entry: make lint types: [python] language: system pass_filenames: false - id: mypy name: Mypy entry: make mypy types: [python] language: system pass_filenames: false - id: pyupgrade name: Pyupgrade entry: pyupgrade --py38-plus types: [python] language: system pydantic-pydantic-settings-5f33b62/LICENSE000066400000000000000000000021171476003445400203450ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2022 Samuel Colvin and other contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. pydantic-pydantic-settings-5f33b62/Makefile000066400000000000000000000021611476003445400207770ustar00rootroot00000000000000.DEFAULT_GOAL := all sources = pydantic_settings tests .PHONY: install install: python -m pip install -U pip pip install -r requirements/all.txt pip install -e . .PHONY: refresh-lockfiles refresh-lockfiles: @echo "Updating requirements/*.txt files using pip-compile" find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete pip-compile -q --no-emit-index-url --resolver backtracking -o requirements/linting.txt requirements/linting.in pip-compile -q --no-emit-index-url --resolver backtracking -o requirements/testing.txt requirements/testing.in pip-compile -q --no-emit-index-url --resolver backtracking --extra toml --extra yaml --extra azure-key-vault -o requirements/pyproject.txt pyproject.toml pip install --dry-run -r requirements/all.txt .PHONY: format format: ruff check --fix $(sources) ruff format $(sources) .PHONY: lint lint: ruff check $(sources) ruff format --check $(sources) .PHONY: mypy mypy: mypy pydantic_settings .PHONY: test test: coverage run -m pytest --durations=10 .PHONY: testcov testcov: test @echo "building coverage html" @coverage html .PHONY: all all: lint mypy testcov pydantic-pydantic-settings-5f33b62/README.md000066400000000000000000000025751476003445400206270ustar00rootroot00000000000000# pydantic-settings [![CI](https://github.com/pydantic/pydantic-settings/workflows/CI/badge.svg?event=push)](https://github.com/pydantic/pydantic-settings/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) [![Coverage](https://codecov.io/gh/pydantic/pydantic-settings/branch/main/graph/badge.svg)](https://codecov.io/gh/pydantic/pydantic-settings) [![pypi](https://img.shields.io/pypi/v/pydantic-settings.svg)](https://pypi.python.org/pypi/pydantic-settings) [![license](https://img.shields.io/github/license/pydantic/pydantic-settings.svg)](https://github.com/pydantic/pydantic-settings/blob/main/LICENSE) [![downloads](https://static.pepy.tech/badge/pydantic-settings/month)](https://pepy.tech/project/pydantic-settings) [![versions](https://img.shields.io/pypi/pyversions/pydantic-settings.svg)](https://github.com/pydantic/pydantic-settings) Settings management using Pydantic, this is the new official home of Pydantic's `BaseSettings`. This package was kindly donated to the [Pydantic organisation](https://github.com/pydantic) by Daniel Daniels, see [pydantic/pydantic#4492](https://github.com/pydantic/pydantic/pull/4492) for discussion. For the old "Hipster-orgazmic tool to manage application settings" package, see [version 0.2.5](https://pypi.org/project/pydantic-settings/0.2.5/). See [documentation](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) for more details. pydantic-pydantic-settings-5f33b62/docs/000077500000000000000000000000001476003445400202675ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/docs/extra/000077500000000000000000000000001476003445400214125ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/docs/extra/terminal.css000066400000000000000000000010231476003445400237330ustar00rootroot00000000000000.terminal { background: #300a24; border-radius: 4px; padding: 5px 10px; } pre.terminal-content { display: inline-block; line-height: 1.3 !important; white-space: pre-wrap; word-wrap: break-word; background: #300a24 !important; color: #d0d0d0 !important; } .ansi2 { font-weight: lighter; } .ansi3 { font-style: italic; } .ansi32 { color: #00aa00; } .ansi34 { color: #5656fe; } .ansi35 { color: #E850A8; } .ansi38-1 { color: #cf0000; } .ansi38-5 { color: #E850A8; } .ansi38-68 { color: #2a54a8; } pydantic-pydantic-settings-5f33b62/docs/extra/tweaks.css000066400000000000000000000021011476003445400234140ustar00rootroot00000000000000.sponsors { display: flex; justify-content: center; flex-wrap: wrap; align-items: center; margin: 1rem 0; } .sponsors > div { text-align: center; width: 33%; padding-bottom: 20px; } .sponsors span { display: block; } @media screen and (max-width: 599px) { .sponsors span { display: none; } } .sponsors img { width: 65%; border-radius: 5px; } /*blog post*/ aside.blog { display: flex; align-items: center; } aside.blog img { width: 50px; height: 50px; border-radius: 25px; margin-right: 20px; } /* Define the company grid layout */ #grid-container { width: 100%; text-align: center; } #company-grid { display: inline-block; margin: 0 auto; gap: 10px; align-content: center; justify-content: center; grid-auto-flow: column; } [data-md-color-scheme="slate"] #company-grid { background-color: #ffffff; border-radius: .5rem; } .tile { display: flex; text-align: center; width: 120px; height: 120px; display: inline-block; margin: 10px; padding: 5px; border-radius: .5rem; } .tile img { width: 100px; } pydantic-pydantic-settings-5f33b62/docs/favicon.png000066400000000000000000000015731476003445400224300ustar00rootroot00000000000000PNG  IHDR sgAMA a cHRMz&u0`:pQ<bKGD̿ pHYs ǠtIME % *IDATHǝ?hAO vh*(E,8TER\t̪K NvuK5K0(mRڀZC4{i<7{sdmĔiaφo&fR%w\q*Y仺cmҼuU &ÉbHhVó򶏅[c[^6@x{7;`Uv¤kl9_#`p/| .qgM7k񩝚%:fludxn<2Ü/ы(.o\"uXE^:%K=ތHI QW_7A-Qu]xr>ݡjlװXSZd-oeh T+HP8{eF6=s*|zDv'u.hN%-v Oö(o, im W̛Nnem>+l5?U:g%tEXtdate:create2019-10-07T17:37:10+02:00m%tEXtdate:modify2019-10-07T17:37:10+02:0004tEXtSoftwarewww.inkscape.org<WzTXtRaw profile type iptcx qV((OIR# .c #K D4d#T ˀHJ.tB5IENDB`pydantic-pydantic-settings-5f33b62/docs/index.md000066400000000000000000002075221476003445400217300ustar00rootroot00000000000000 ## Installation Installation is as simple as: ```bash pip install pydantic-settings ``` ## Usage If you create a model that inherits from `BaseSettings`, the model initialiser will attempt to determine the values of any fields not passed as keyword arguments by reading from the environment. (Default values will still be used if the matching environment variable is not set.) This makes it easy to: * Create a clearly-defined, type-hinted application configuration class * Automatically read modifications to the configuration from environment variables * Manually override specific settings in the initialiser where desired (e.g. in unit tests) For example: ```py from typing import Any, Callable, Set from pydantic import ( AliasChoices, AmqpDsn, BaseModel, Field, ImportString, PostgresDsn, RedisDsn, ) from pydantic_settings import BaseSettings, SettingsConfigDict class SubModel(BaseModel): foo: str = 'bar' apple: int = 1 class Settings(BaseSettings): auth_key: str = Field(validation_alias='my_auth_key') # (1)! api_key: str = Field(alias='my_api_key') # (2)! redis_dsn: RedisDsn = Field( 'redis://user:pass@localhost:6379/1', validation_alias=AliasChoices('service_redis_dsn', 'redis_url'), # (3)! ) pg_dsn: PostgresDsn = 'postgres://user:pass@localhost:5432/foobar' amqp_dsn: AmqpDsn = 'amqp://user:pass@localhost:5672/' special_function: ImportString[Callable[[Any], Any]] = 'math.cos' # (4)! # to override domains: # export my_prefix_domains='["foo.com", "bar.com"]' domains: Set[str] = set() # to override more_settings: # export my_prefix_more_settings='{"foo": "x", "apple": 1}' more_settings: SubModel = SubModel() model_config = SettingsConfigDict(env_prefix='my_prefix_') # (5)! print(Settings().model_dump()) """ { 'auth_key': 'xxx', 'api_key': 'xxx', 'redis_dsn': Url('redis://user:pass@localhost:6379/1'), 'pg_dsn': MultiHostUrl('postgres://user:pass@localhost:5432/foobar'), 'amqp_dsn': Url('amqp://user:pass@localhost:5672/'), 'special_function': math.cos, 'domains': set(), 'more_settings': {'foo': 'bar', 'apple': 1}, } """ ``` 1. The environment variable name is overridden using `validation_alias`. In this case, the environment variable `my_auth_key` will be read instead of `auth_key`. Check the [`Field` documentation](fields.md) for more information. 2. The environment variable name is overridden using `alias`. In this case, the environment variable `my_api_key` will be used for both validation and serialization instead of `api_key`. Check the [`Field` documentation](fields.md#field-aliases) for more information. 3. The [`AliasChoices`][pydantic.AliasChoices] class allows to have multiple environment variable names for a single field. The first environment variable that is found will be used. Check the [documentation on alias choices](alias.md#aliaspath-and-aliaschoices) for more information. 4. The [`ImportString`][pydantic.types.ImportString] class allows to import an object from a string. In this case, the environment variable `special_function` will be read and the function [`math.cos`][] will be imported. 5. The `env_prefix` config setting allows to set a prefix for all environment variables. Check the [Environment variable names documentation](#environment-variable-names) for more information. ## Validation of default values Unlike pydantic `BaseModel`, default values of `BaseSettings` fields are validated by default. You can disable this behaviour by setting `validate_default=False` either in `model_config` or on field level by `Field(validate_default=False)`: ```py from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(validate_default=False) # default won't be validated foo: int = 'test' print(Settings()) #> foo='test' class Settings1(BaseSettings): # default won't be validated foo: int = Field('test', validate_default=False) print(Settings1()) #> foo='test' ``` Check the [validation of default values](fields.md#validate-default-values) for more information. ## Environment variable names By default, the environment variable name is the same as the field name. You can change the prefix for all environment variables by setting the `env_prefix` config setting, or via the `_env_prefix` keyword argument on instantiation: ```py from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix='my_prefix_') auth_key: str = 'xxx' # will be read from `my_prefix_auth_key` ``` !!! note The default `env_prefix` is `''` (empty string). `env_prefix` is not only for env settings but also for dotenv files, secrets, and other sources. If you want to change the environment variable name for a single field, you can use an alias. There are two ways to do this: * Using `Field(alias=...)` (see `api_key` above) * Using `Field(validation_alias=...)` (see `auth_key` above) Check the [`Field` aliases documentation](fields.md#field-aliases) for more information about aliases. `env_prefix` does not apply to fields with alias. It means the environment variable name is the same as field alias: ```py from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix='my_prefix_') foo: str = Field('xxx', alias='FooAlias') # (1)! ``` 1. `env_prefix` will be ignored and the value will be read from `FooAlias` environment variable. ### Case-sensitivity By default, environment variable names are case-insensitive. If you want to make environment variable names case-sensitive, you can set the `case_sensitive` config setting: ```py from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(case_sensitive=True) redis_host: str = 'localhost' ``` When `case_sensitive` is `True`, the environment variable names must match field names (optionally with a prefix), so in this example `redis_host` could only be modified via `export redis_host`. If you want to name environment variables all upper-case, you should name attribute all upper-case too. You can still name environment variables anything you like through `Field(validation_alias=...)`. Case-sensitivity can also be set via the `_case_sensitive` keyword argument on instantiation. In case of nested models, the `case_sensitive` setting will be applied to all nested models. ```py import os from pydantic import BaseModel, ValidationError from pydantic_settings import BaseSettings class RedisSettings(BaseModel): host: str port: int class Settings(BaseSettings, case_sensitive=True): redis: RedisSettings os.environ['redis'] = '{"host": "localhost", "port": 6379}' print(Settings().model_dump()) #> {'redis': {'host': 'localhost', 'port': 6379}} os.environ['redis'] = '{"HOST": "localhost", "port": 6379}' # (1)! try: Settings() except ValidationError as e: print(e) """ 1 validation error for Settings redis.host Field required [type=missing, input_value={'HOST': 'localhost', 'port': 6379}, input_type=dict] For further information visit https://errors.pydantic.dev/2/v/missing """ ``` 1. Note that the `host` field is not found because the environment variable name is `HOST` (all upper-case). !!! note On Windows, Python's `os` module always treats environment variables as case-insensitive, so the `case_sensitive` config setting will have no effect - settings will always be updated ignoring case. ## Parsing environment variable values By default environment variables are parsed verbatim, including if the value is empty. You can choose to ignore empty environment variables by setting the `env_ignore_empty` config setting to `True`. This can be useful if you would prefer to use the default value for a field rather than an empty value from the environment. For most simple field types (such as `int`, `float`, `str`, etc.), the environment variable value is parsed the same way it would be if passed directly to the initialiser (as a string). Complex types like `list`, `set`, `dict`, and sub-models are populated from the environment by treating the environment variable's value as a JSON-encoded string. Another way to populate nested complex variables is to configure your model with the `env_nested_delimiter` config setting, then use an environment variable with a name pointing to the nested module fields. What it does is simply explodes your variable into nested models or dicts. So if you define a variable `FOO__BAR__BAZ=123` it will convert it into `FOO={'BAR': {'BAZ': 123}}` If you have multiple variables with the same structure they will be merged. !!! note Sub model has to inherit from `pydantic.BaseModel`, Otherwise `pydantic-settings` will initialize sub model, collects values for sub model fields separately, and you may get unexpected results. As an example, given the following environment variables: ```bash # your environment export V0=0 export SUB_MODEL='{"v1": "json-1", "v2": "json-2"}' export SUB_MODEL__V2=nested-2 export SUB_MODEL__V3=3 export SUB_MODEL__DEEP__V4=v4 ``` You could load them into the following settings model: ```py from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict class DeepSubModel(BaseModel): # (1)! v4: str class SubModel(BaseModel): # (2)! v1: str v2: bytes v3: int deep: DeepSubModel class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__') v0: str sub_model: SubModel print(Settings().model_dump()) """ { 'v0': '0', 'sub_model': {'v1': 'json-1', 'v2': b'nested-2', 'v3': 3, 'deep': {'v4': 'v4'}}, } """ ``` 1. Sub model has to inherit from `pydantic.BaseModel`. 2. Sub model has to inherit from `pydantic.BaseModel`. `env_nested_delimiter` can be configured via the `model_config` as shown above, or via the `_env_nested_delimiter` keyword argument on instantiation. By default environment variables are split by `env_nested_delimiter` into arbitrarily deep nested fields. You can limit the depth of the nested fields with the `env_nested_max_split` config setting. A common use case this is particularly useful is for two-level deep settings, where the `env_nested_delimiter` (usually a single `_`) may be a substring of model field names. For example: ```bash # your environment export GENERATION_LLM_PROVIDER='anthropic' export GENERATION_LLM_API_KEY='your-api-key' export GENERATION_LLM_API_VERSION='2024-03-15' ``` You could load them into the following settings model: ```py from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict class LLMConfig(BaseModel): provider: str = 'openai' api_key: str api_type: str = 'azure' api_version: str = '2023-03-15-preview' class GenerationConfig(BaseSettings): model_config = SettingsConfigDict( env_nested_delimiter='_', env_nested_max_split=1, env_prefix='GENERATION_' ) llm: LLMConfig ... print(GenerationConfig().model_dump()) """ { 'llm': { 'provider': 'anthropic', 'api_key': 'your-api-key', 'api_type': 'azure', 'api_version': '2024-03-15', } } """ ``` Without `env_nested_max_split=1` set, `GENERATION_LLM_API_KEY` would be parsed as `llm.api.key` instead of `llm.api_key` and it would raise a `ValidationError`. Nested environment variables take precedence over the top-level environment variable JSON (e.g. in the example above, `SUB_MODEL__V2` trumps `SUB_MODEL`). You may also populate a complex type by providing your own source class. ```py import json import os from typing import Any, List, Tuple, Type from pydantic.fields import FieldInfo from pydantic_settings import ( BaseSettings, EnvSettingsSource, PydanticBaseSettingsSource, ) class MyCustomSource(EnvSettingsSource): def prepare_field_value( self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool ) -> Any: if field_name == 'numbers': return [int(x) for x in value.split(',')] return json.loads(value) class Settings(BaseSettings): numbers: List[int] @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (MyCustomSource(settings_cls),) os.environ['numbers'] = '1,2,3' print(Settings().model_dump()) #> {'numbers': [1, 2, 3]} ``` ### Disabling JSON parsing pydantic-settings by default parses complex types from environment variables as JSON strings. If you want to disable this behavior for a field and parse the value in your own validator, you can annotate the field with [`NoDecode`](../api/pydantic_settings.md#pydantic_settings.NoDecode): ```py import os from typing import List from pydantic import field_validator from typing_extensions import Annotated from pydantic_settings import BaseSettings, NoDecode class Settings(BaseSettings): numbers: Annotated[List[int], NoDecode] # (1)! @field_validator('numbers', mode='before') @classmethod def decode_numbers(cls, v: str) -> List[int]: return [int(x) for x in v.split(',')] os.environ['numbers'] = '1,2,3' print(Settings().model_dump()) #> {'numbers': [1, 2, 3]} ``` 1. The `NoDecode` annotation disables JSON parsing for the `numbers` field. The `decode_numbers` field validator will be called to parse the value. You can also disable JSON parsing for all fields by setting the `enable_decoding` config setting to `False`: ```py import os from typing import List from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(enable_decoding=False) numbers: List[int] @field_validator('numbers', mode='before') @classmethod def decode_numbers(cls, v: str) -> List[int]: return [int(x) for x in v.split(',')] os.environ['numbers'] = '1,2,3' print(Settings().model_dump()) #> {'numbers': [1, 2, 3]} ``` You can force JSON parsing for a field by annotating it with [`ForceDecode`](../api/pydantic_settings.md#pydantic_settings.ForceDecode). This will bypass the `enable_decoding` config setting: ```py import os from typing import List from pydantic import field_validator from typing_extensions import Annotated from pydantic_settings import BaseSettings, ForceDecode, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(enable_decoding=False) numbers: Annotated[List[int], ForceDecode] numbers1: List[int] # (1)! @field_validator('numbers1', mode='before') @classmethod def decode_numbers1(cls, v: str) -> List[int]: return [int(x) for x in v.split(',')] os.environ['numbers'] = '["1","2","3"]' os.environ['numbers1'] = '1,2,3' print(Settings().model_dump()) #> {'numbers': [1, 2, 3], 'numbers1': [1, 2, 3]} ``` 1. The `numbers1` field is not annotated with `ForceDecode`, so it will not be parsed as JSON. and we have to provide a custom validator to parse the value. ## Nested model default partial updates By default, Pydantic settings does not allow partial updates to nested model default objects. This behavior can be overriden by setting the `nested_model_default_partial_update` flag to `True`, which will allow partial updates on nested model default object fields. ```py import os from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict class SubModel(BaseModel): val: int = 0 flag: bool = False class SettingsPartialUpdate(BaseSettings): model_config = SettingsConfigDict( env_nested_delimiter='__', nested_model_default_partial_update=True ) nested_model: SubModel = SubModel(val=1) class SettingsNoPartialUpdate(BaseSettings): model_config = SettingsConfigDict( env_nested_delimiter='__', nested_model_default_partial_update=False ) nested_model: SubModel = SubModel(val=1) # Apply a partial update to the default object using environment variables os.environ['NESTED_MODEL__FLAG'] = 'True' # When partial update is enabled, the existing SubModel instance is updated # with nested_model.flag=True change assert SettingsPartialUpdate().model_dump() == { 'nested_model': {'val': 1, 'flag': True} } # When partial update is disabled, a new SubModel instance is instantiated # with nested_model.flag=True change assert SettingsNoPartialUpdate().model_dump() == { 'nested_model': {'val': 0, 'flag': True} } ``` ## Dotenv (.env) support Dotenv files (generally named `.env`) are a common pattern that make it easy to use environment variables in a platform-independent manner. A dotenv file follows the same general principles of all environment variables, and it looks like this: ```bash title=".env" # ignore comment ENVIRONMENT="production" REDIS_ADDRESS=localhost:6379 MEANING_OF_LIFE=42 MY_VAR='Hello world' ``` Once you have your `.env` file filled with variables, *pydantic* supports loading it in two ways: 1. Setting the `env_file` (and `env_file_encoding` if you don't want the default encoding of your OS) on `model_config` in the `BaseSettings` class: ````py hl_lines="4 5" from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') ```` 2. Instantiating the `BaseSettings` derived class with the `_env_file` keyword argument (and the `_env_file_encoding` if needed): ````py hl_lines="8" from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8') settings = Settings(_env_file='prod.env', _env_file_encoding='utf-8') ```` In either case, the value of the passed argument can be any valid path or filename, either absolute or relative to the current working directory. From there, *pydantic* will handle everything for you by loading in your variables and validating them. !!! note If a filename is specified for `env_file`, Pydantic will only check the current working directory and won't check any parent directories for the `.env` file. Even when using a dotenv file, *pydantic* will still read environment variables as well as the dotenv file, **environment variables will always take priority over values loaded from a dotenv file**. Passing a file path via the `_env_file` keyword argument on instantiation (method 2) will override the value (if any) set on the `model_config` class. If the above snippets were used in conjunction, `prod.env` would be loaded while `.env` would be ignored. If you need to load multiple dotenv files, you can pass multiple file paths as a tuple or list. The files will be loaded in order, with each file overriding the previous one. ```py from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict( # `.env.prod` takes priority over `.env` env_file=('.env', '.env.prod') ) ``` You can also use the keyword argument override to tell Pydantic not to load any file at all (even if one is set in the `model_config` class) by passing `None` as the instantiation keyword argument, e.g. `settings = Settings(_env_file=None)`. Because python-dotenv is used to parse the file, bash-like semantics such as `export` can be used which (depending on your OS and environment) may allow your dotenv file to also be used with `source`, see [python-dotenv's documentation](https://saurabh-kumar.com/python-dotenv/#usages) for more details. Pydantic settings consider `extra` config in case of dotenv file. It means if you set the `extra=forbid` (*default*) on `model_config` and your dotenv file contains an entry for a field that is not defined in settings model, it will raise `ValidationError` in settings construction. For compatibility with pydantic 1.x BaseSettings you should use `extra=ignore`: ```py from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file='.env', extra='ignore') ``` !!! note Pydantic settings loads all the values from dotenv file and passes it to the model, regardless of the model's `env_prefix`. So if you provide extra values in a dotenv file, whether they start with `env_prefix` or not, a `ValidationError` will be raised. ## Command Line Support Pydantic settings provides integrated CLI support, making it easy to quickly define CLI applications using Pydantic models. There are two primary use cases for Pydantic settings CLI: 1. When using a CLI to override fields in Pydantic models. 2. When using Pydantic models to define CLIs. By default, the experience is tailored towards use case #1 and builds on the foundations established in [parsing environment variables](#parsing-environment-variable-values). If your use case primarily falls into #2, you will likely want to enable most of the defaults outlined at the end of [creating CLI applications](#creating-cli-applications). ### The Basics To get started, let's revisit the example presented in [parsing environment variables](#parsing-environment-variable-values) but using a Pydantic settings CLI: ```py import sys from pydantic import BaseModel from pydantic_settings import BaseSettings, SettingsConfigDict class DeepSubModel(BaseModel): v4: str class SubModel(BaseModel): v1: str v2: bytes v3: int deep: DeepSubModel class Settings(BaseSettings): model_config = SettingsConfigDict(cli_parse_args=True) v0: str sub_model: SubModel sys.argv = [ 'example.py', '--v0=0', '--sub_model={"v1": "json-1", "v2": "json-2"}', '--sub_model.v2=nested-2', '--sub_model.v3=3', '--sub_model.deep.v4=v4', ] print(Settings().model_dump()) """ { 'v0': '0', 'sub_model': {'v1': 'json-1', 'v2': b'nested-2', 'v3': 3, 'deep': {'v4': 'v4'}}, } """ ``` To enable CLI parsing, we simply set the `cli_parse_args` flag to a valid value, which retains similar conotations as defined in `argparse`. Note that a CLI settings source is [**the topmost source**](#field-value-priority) by default unless its [priority value is customised](#customise-settings-sources): ```py import os import sys from typing import Tuple, Type from pydantic_settings import ( BaseSettings, CliSettingsSource, PydanticBaseSettingsSource, ) class Settings(BaseSettings): my_foo: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return env_settings, CliSettingsSource(settings_cls, cli_parse_args=True) os.environ['MY_FOO'] = 'from environment' sys.argv = ['example.py', '--my_foo=from cli'] print(Settings().model_dump()) #> {'my_foo': 'from environment'} ``` #### Lists CLI argument parsing of lists supports intermixing of any of the below three styles: * JSON style `--field='[1,2]'` * Argparse style `--field 1 --field 2` * Lazy style `--field=1,2` ```py import sys from typing import List from pydantic_settings import BaseSettings class Settings(BaseSettings, cli_parse_args=True): my_list: List[int] sys.argv = ['example.py', '--my_list', '[1,2]'] print(Settings().model_dump()) #> {'my_list': [1, 2]} sys.argv = ['example.py', '--my_list', '1', '--my_list', '2'] print(Settings().model_dump()) #> {'my_list': [1, 2]} sys.argv = ['example.py', '--my_list', '1,2'] print(Settings().model_dump()) #> {'my_list': [1, 2]} ``` #### Dictionaries CLI argument parsing of dictionaries supports intermixing of any of the below two styles: * JSON style `--field='{"k1": 1, "k2": 2}'` * Environment variable style `--field k1=1 --field k2=2` These can be used in conjunction with list forms as well, e.g: * `--field k1=1,k2=2 --field k3=3 --field '{"k4": 4}'` etc. ```py import sys from typing import Dict from pydantic_settings import BaseSettings class Settings(BaseSettings, cli_parse_args=True): my_dict: Dict[str, int] sys.argv = ['example.py', '--my_dict', '{"k1":1,"k2":2}'] print(Settings().model_dump()) #> {'my_dict': {'k1': 1, 'k2': 2}} sys.argv = ['example.py', '--my_dict', 'k1=1', '--my_dict', 'k2=2'] print(Settings().model_dump()) #> {'my_dict': {'k1': 1, 'k2': 2}} ``` #### Literals and Enums CLI argument parsing of literals and enums are converted into CLI choices. ```py import sys from enum import IntEnum from typing import Literal from pydantic_settings import BaseSettings class Fruit(IntEnum): pear = 0 kiwi = 1 lime = 2 class Settings(BaseSettings, cli_parse_args=True): fruit: Fruit pet: Literal['dog', 'cat', 'bird'] sys.argv = ['example.py', '--fruit', 'lime', '--pet', 'cat'] print(Settings().model_dump()) #> {'fruit': , 'pet': 'cat'} ``` #### Aliases Pydantic field aliases are added as CLI argument aliases. Aliases of length one are converted into short options. ```py import sys from pydantic import AliasChoices, AliasPath, Field from pydantic_settings import BaseSettings class User(BaseSettings, cli_parse_args=True): first_name: str = Field( validation_alias=AliasChoices('f', 'fname', AliasPath('name', 0)) ) last_name: str = Field( validation_alias=AliasChoices('l', 'lname', AliasPath('name', 1)) ) sys.argv = ['example.py', '--fname', 'John', '--lname', 'Doe'] print(User().model_dump()) #> {'first_name': 'John', 'last_name': 'Doe'} sys.argv = ['example.py', '-f', 'John', '-l', 'Doe'] print(User().model_dump()) #> {'first_name': 'John', 'last_name': 'Doe'} sys.argv = ['example.py', '--name', 'John,Doe'] print(User().model_dump()) #> {'first_name': 'John', 'last_name': 'Doe'} sys.argv = ['example.py', '--name', 'John', '--lname', 'Doe'] print(User().model_dump()) #> {'first_name': 'John', 'last_name': 'Doe'} ``` ### Subcommands and Positional Arguments Subcommands and positional arguments are expressed using the `CliSubCommand` and `CliPositionalArg` annotations. The subcommand annotation can only be applied to required fields (i.e. fields that do not have a default value). Furthermore, subcommands must be a valid type derived from either a pydantic `BaseModel` or pydantic.dataclasses `dataclass`. Parsed subcommands can be retrieved from model instances using the `get_subcommand` utility function. If a subcommand is not required, set the `is_required` flag to `False` to disable raising an error if no subcommand is found. !!! note CLI settings subcommands are limited to a single subparser per model. In other words, all subcommands for a model are grouped under a single subparser; it does not allow for multiple subparsers with each subparser having its own set of subcommands. For more information on subparsers, see [argparse subcommands](https://docs.python.org/3/library/argparse.html#sub-commands). !!! note `CliSubCommand` and `CliPositionalArg` are always case sensitive. ```py import sys from pydantic import BaseModel from pydantic_settings import ( BaseSettings, CliPositionalArg, CliSubCommand, SettingsError, get_subcommand, ) class Init(BaseModel): directory: CliPositionalArg[str] class Clone(BaseModel): repository: CliPositionalArg[str] directory: CliPositionalArg[str] class Git(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): clone: CliSubCommand[Clone] init: CliSubCommand[Init] # Run without subcommands sys.argv = ['example.py'] cmd = Git() assert cmd.model_dump() == {'clone': None, 'init': None} try: # Will raise an error since no subcommand was provided get_subcommand(cmd).model_dump() except SettingsError as err: assert str(err) == 'Error: CLI subcommand is required {clone, init}' # Will not raise an error since subcommand is not required assert get_subcommand(cmd, is_required=False) is None # Run the clone subcommand sys.argv = ['example.py', 'clone', 'repo', 'dest'] cmd = Git() assert cmd.model_dump() == { 'clone': {'repository': 'repo', 'directory': 'dest'}, 'init': None, } # Returns the subcommand model instance (in this case, 'clone') assert get_subcommand(cmd).model_dump() == { 'directory': 'dest', 'repository': 'repo', } ``` The `CliSubCommand` and `CliPositionalArg` annotations also support union operations and aliases. For unions of Pydantic models, it is important to remember the [nuances](https://docs.pydantic.dev/latest/concepts/unions/) that can arise during validation. Specifically, for unions of subcommands that are identical in content, it is recommended to break them out into separate `CliSubCommand` fields to avoid any complications. Lastly, the derived subcommand names from unions will be the names of the Pydantic model classes themselves. When assigning aliases to `CliSubCommand` or `CliPositionalArg` fields, only a single alias can be assigned. For non-union subcommands, aliasing will change the displayed help text and subcommand name. Conversely, for union subcommands, aliasing will have no tangible effect from the perspective of the CLI settings source. Lastly, for positional arguments, aliasing will change the CLI help text displayed for the field. ```py import sys from typing import Union from pydantic import BaseModel, Field from pydantic_settings import ( BaseSettings, CliPositionalArg, CliSubCommand, get_subcommand, ) class Alpha(BaseModel): """Apha Help""" cmd_alpha: CliPositionalArg[str] = Field(alias='alpha-cmd') class Beta(BaseModel): """Beta Help""" opt_beta: str = Field(alias='opt-beta') class Gamma(BaseModel): """Gamma Help""" opt_gamma: str = Field(alias='opt-gamma') class Root(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): alpha_or_beta: CliSubCommand[Union[Alpha, Beta]] = Field(alias='alpha-or-beta-cmd') gamma: CliSubCommand[Gamma] = Field(alias='gamma-cmd') sys.argv = ['example.py', 'Alpha', 'hello'] assert get_subcommand(Root()).model_dump() == {'cmd_alpha': 'hello'} sys.argv = ['example.py', 'Beta', '--opt-beta=hey'] assert get_subcommand(Root()).model_dump() == {'opt_beta': 'hey'} sys.argv = ['example.py', 'gamma-cmd', '--opt-gamma=hi'] assert get_subcommand(Root()).model_dump() == {'opt_gamma': 'hi'} ``` ### Creating CLI Applications The `CliApp` class provides two utility methods, `CliApp.run` and `CliApp.run_subcommand`, that can be used to run a Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application. Primarily, the methods provide structure for running `cli_cmd` methods associated with models. `CliApp.run` can be used in directly providing the `cli_args` to be parsed, and will run the model `cli_cmd` method (if defined) after instantiation: ```py from pydantic_settings import BaseSettings, CliApp class Settings(BaseSettings): this_foo: str def cli_cmd(self) -> None: # Print the parsed data print(self.model_dump()) #> {'this_foo': 'is such a foo'} # Update the parsed data showing cli_cmd ran self.this_foo = 'ran the foo cli cmd' s = CliApp.run(Settings, cli_args=['--this_foo', 'is such a foo']) print(s.model_dump()) #> {'this_foo': 'ran the foo cli cmd'} ``` Similarly, the `CliApp.run_subcommand` can be used in recursive fashion to run the `cli_cmd` method of a subcommand: ```py from pydantic import BaseModel from pydantic_settings import CliApp, CliPositionalArg, CliSubCommand class Init(BaseModel): directory: CliPositionalArg[str] def cli_cmd(self) -> None: print(f'git init "{self.directory}"') #> git init "dir" self.directory = 'ran the git init cli cmd' class Clone(BaseModel): repository: CliPositionalArg[str] directory: CliPositionalArg[str] def cli_cmd(self) -> None: print(f'git clone from "{self.repository}" into "{self.directory}"') self.directory = 'ran the clone cli cmd' class Git(BaseModel): clone: CliSubCommand[Clone] init: CliSubCommand[Init] def cli_cmd(self) -> None: CliApp.run_subcommand(self) cmd = CliApp.run(Git, cli_args=['init', 'dir']) assert cmd.model_dump() == { 'clone': None, 'init': {'directory': 'ran the git init cli cmd'}, } ``` !!! note Unlike `CliApp.run`, `CliApp.run_subcommand` requires the subcommand model to have a defined `cli_cmd` method. For `BaseModel` and `pydantic.dataclasses.dataclass` types, `CliApp.run` will internally use the following `BaseSettings` configuration defaults: * `nested_model_default_partial_update=True` * `case_sensitive=True` * `cli_hide_none_type=True` * `cli_avoid_json=True` * `cli_enforce_required=True` * `cli_implicit_flags=True` * `cli_kebab_case=True` ### Asynchronous CLI Commands Pydantic settings supports running asynchronous CLI commands via `CliApp.run` and `CliApp.run_subcommand`. With this feature, you can define async def methods within your Pydantic models (including subcommands) and have them executed just like their synchronous counterparts. Specifically: 1. Asynchronous methods are supported: You can now mark your cli_cmd or similar CLI entrypoint methods as async def and have CliApp execute them. 2. Subcommands may also be asynchronous: If you have nested CLI subcommands, the final (lowest-level) subcommand methods can likewise be asynchronous. 3. Limit asynchronous methods to final subcommands: Defining parent commands as asynchronous is not recommended, because it can result in additional threads and event loops being created. For best performance and to avoid unnecessary resource usage, only implement your deepest (child) subcommands as async def. Below is a simple example demonstrating an asynchronous top-level command: ```py from pydantic_settings import BaseSettings, CliApp class AsyncSettings(BaseSettings): async def cli_cmd(self) -> None: print('Hello from an async CLI method!') #> Hello from an async CLI method! # If an event loop is already running, a new thread will be used; # otherwise, asyncio.run() is used to execute this async method. assert CliApp.run(AsyncSettings, cli_args=[]).model_dump() == {} ``` #### Asynchronous Subcommands As mentioned above, you can also define subcommands as async. However, only do so for the leaf (lowest-level) subcommand to avoid spawning new threads and event loops unnecessarily in parent commands: ```py from pydantic import BaseModel from pydantic_settings import ( BaseSettings, CliApp, CliPositionalArg, CliSubCommand, ) class Clone(BaseModel): repository: CliPositionalArg[str] directory: CliPositionalArg[str] async def cli_cmd(self) -> None: # Perform async tasks here, e.g. network or I/O operations print(f'Cloning async from "{self.repository}" into "{self.directory}"') #> Cloning async from "repo" into "dir" class Git(BaseSettings): clone: CliSubCommand[Clone] def cli_cmd(self) -> None: # Run the final subcommand (clone/init). It is recommended to define async methods only at the deepest level. CliApp.run_subcommand(self) CliApp.run(Git, cli_args=['clone', 'repo', 'dir']).model_dump() == { 'repository': 'repo', 'directory': 'dir', } ``` When executing a subcommand with an asynchronous cli_cmd, Pydantic settings automatically detects whether the current thread already has an active event loop. If so, the async command is run in a fresh thread to avoid conflicts. Otherwise, it uses asyncio.run() in the current thread. This handling ensures your asynchronous subcommands “just work” without additional manual setup. ### Mutually Exclusive Groups CLI mutually exclusive groups can be created by inheriting from the `CliMutuallyExclusiveGroup` class. !!! note A `CliMutuallyExclusiveGroup` cannot be used in a union or contain nested models. ```py from typing import Optional from pydantic import BaseModel from pydantic_settings import CliApp, CliMutuallyExclusiveGroup, SettingsError class Circle(CliMutuallyExclusiveGroup): radius: Optional[float] = None diameter: Optional[float] = None perimeter: Optional[float] = None class Settings(BaseModel): circle: Circle try: CliApp.run( Settings, cli_args=['--circle.radius=1', '--circle.diameter=2'], cli_exit_on_error=False, ) except SettingsError as e: print(e) """ error parsing CLI: argument --circle.diameter: not allowed with argument --circle.radius """ ``` ### Customizing the CLI Experience The below flags can be used to customise the CLI experience to your needs. #### Change the Displayed Program Name Change the default program name displayed in the help text usage by setting `cli_prog_name`. By default, it will derive the name of the currently executing program from `sys.argv[0]`, just like argparse. ```py import sys from pydantic_settings import BaseSettings class Settings(BaseSettings, cli_parse_args=True, cli_prog_name='appdantic'): pass try: sys.argv = ['example.py', '--help'] Settings() except SystemExit as e: print(e) #> 0 """ usage: appdantic [-h] options: -h, --help show this help message and exit """ ``` #### CLI Boolean Flags Change whether boolean fields should be explicit or implicit by default using the `cli_implicit_flags` setting. By default, boolean fields are "explicit", meaning a boolean value must be explicitly provided on the CLI, e.g. `--flag=True`. Conversely, boolean fields that are "implicit" derive the value from the flag itself, e.g. `--flag,--no-flag`, which removes the need for an explicit value to be passed. Additionally, the provided `CliImplicitFlag` and `CliExplicitFlag` annotations can be used for more granular control when necessary. !!! note For `python < 3.9` the `--no-flag` option is not generated due to an underlying `argparse` limitation. !!! note For `python < 3.9` the `CliImplicitFlag` and `CliExplicitFlag` annotations can only be applied to optional boolean fields. ```py from pydantic_settings import BaseSettings, CliExplicitFlag, CliImplicitFlag class ExplicitSettings(BaseSettings, cli_parse_args=True): """Boolean fields are explicit by default.""" explicit_req: bool """ --explicit_req bool (required) """ explicit_opt: bool = False """ --explicit_opt bool (default: False) """ # Booleans are explicit by default, so must override implicit flags with annotation implicit_req: CliImplicitFlag[bool] """ --implicit_req, --no-implicit_req (required) """ implicit_opt: CliImplicitFlag[bool] = False """ --implicit_opt, --no-implicit_opt (default: False) """ class ImplicitSettings(BaseSettings, cli_parse_args=True, cli_implicit_flags=True): """With cli_implicit_flags=True, boolean fields are implicit by default.""" # Booleans are implicit by default, so must override explicit flags with annotation explicit_req: CliExplicitFlag[bool] """ --explicit_req bool (required) """ explicit_opt: CliExplicitFlag[bool] = False """ --explicit_opt bool (default: False) """ implicit_req: bool """ --implicit_req, --no-implicit_req (required) """ implicit_opt: bool = False """ --implicit_opt, --no-implicit_opt (default: False) """ ``` #### Ignore Unknown Arguments Change whether to ignore unknown CLI arguments and only parse known ones using `cli_ignore_unknown_args`. By default, the CLI does not ignore any args. ```py import sys from pydantic_settings import BaseSettings class Settings(BaseSettings, cli_parse_args=True, cli_ignore_unknown_args=True): good_arg: str sys.argv = ['example.py', '--bad-arg=bad', 'ANOTHER_BAD_ARG', '--good_arg=hello world'] print(Settings().model_dump()) #> {'good_arg': 'hello world'} ``` #### CLI Kebab Case for Arguments Change whether CLI arguments should use kebab case by enabling `cli_kebab_case`. ```py import sys from pydantic import Field from pydantic_settings import BaseSettings class Settings(BaseSettings, cli_parse_args=True, cli_kebab_case=True): my_option: str = Field(description='will show as kebab case on CLI') try: sys.argv = ['example.py', '--help'] Settings() except SystemExit as e: print(e) #> 0 """ usage: example.py [-h] [--my-option str] options: -h, --help show this help message and exit --my-option str will show as kebab case on CLI (required) """ ``` #### Change Whether CLI Should Exit on Error Change whether the CLI internal parser will exit on error or raise a `SettingsError` exception by using `cli_exit_on_error`. By default, the CLI internal parser will exit on error. ```py import sys from pydantic_settings import BaseSettings, SettingsError class Settings(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): ... try: sys.argv = ['example.py', '--bad-arg'] Settings() except SettingsError as e: print(e) #> error parsing CLI: unrecognized arguments: --bad-arg ``` #### Enforce Required Arguments at CLI Pydantic settings is designed to pull values in from various sources when instantating a model. This means a field that is required is not strictly required from any single source (e.g. the CLI). Instead, all that matters is that one of the sources provides the required value. However, if your use case [aligns more with #2](#command-line-support), using Pydantic models to define CLIs, you will likely want required fields to be _strictly required at the CLI_. We can enable this behavior by using `cli_enforce_required`. !!! note A required `CliPositionalArg` field is always strictly required (enforced) at the CLI. ```py import os import sys from pydantic import Field from pydantic_settings import BaseSettings, SettingsError class Settings( BaseSettings, cli_parse_args=True, cli_enforce_required=True, cli_exit_on_error=False, ): my_required_field: str = Field(description='a top level required field') os.environ['MY_REQUIRED_FIELD'] = 'hello from environment' try: sys.argv = ['example.py'] Settings() except SettingsError as e: print(e) #> error parsing CLI: the following arguments are required: --my_required_field ``` #### Change the None Type Parse String Change the CLI string value that will be parsed (e.g. "null", "void", "None", etc.) into `None` by setting `cli_parse_none_str`. By default it will use the `env_parse_none_str` value if set. Otherwise, it will default to "null" if `cli_avoid_json` is `False`, and "None" if `cli_avoid_json` is `True`. ```py import sys from typing import Optional from pydantic import Field from pydantic_settings import BaseSettings class Settings(BaseSettings, cli_parse_args=True, cli_parse_none_str='void'): v1: Optional[int] = Field(description='the top level v0 option') sys.argv = ['example.py', '--v1', 'void'] print(Settings().model_dump()) #> {'v1': None} ``` #### Hide None Type Values Hide `None` values from the CLI help text by enabling `cli_hide_none_type`. ```py import sys from typing import Optional from pydantic import Field from pydantic_settings import BaseSettings class Settings(BaseSettings, cli_parse_args=True, cli_hide_none_type=True): v0: Optional[str] = Field(description='the top level v0 option') try: sys.argv = ['example.py', '--help'] Settings() except SystemExit as e: print(e) #> 0 """ usage: example.py [-h] [--v0 str] options: -h, --help show this help message and exit --v0 str the top level v0 option (required) """ ``` #### Avoid Adding JSON CLI Options Avoid adding complex fields that result in JSON strings at the CLI by enabling `cli_avoid_json`. ```py import sys from pydantic import BaseModel, Field from pydantic_settings import BaseSettings class SubModel(BaseModel): v1: int = Field(description='the sub model v1 option') class Settings(BaseSettings, cli_parse_args=True, cli_avoid_json=True): sub_model: SubModel = Field( description='The help summary for SubModel related options' ) try: sys.argv = ['example.py', '--help'] Settings() except SystemExit as e: print(e) #> 0 """ usage: example.py [-h] [--sub_model.v1 int] options: -h, --help show this help message and exit sub_model options: The help summary for SubModel related options --sub_model.v1 int the sub model v1 option (required) """ ``` #### Use Class Docstring for Group Help Text By default, when populating the group help text for nested models it will pull from the field descriptions. Alternatively, we can also configure CLI settings to pull from the class docstring instead. !!! note If the field is a union of nested models the group help text will always be pulled from the field description; even if `cli_use_class_docs_for_groups` is set to `True`. ```py import sys from pydantic import BaseModel, Field from pydantic_settings import BaseSettings class SubModel(BaseModel): """The help text from the class docstring.""" v1: int = Field(description='the sub model v1 option') class Settings(BaseSettings, cli_parse_args=True, cli_use_class_docs_for_groups=True): """My application help text.""" sub_model: SubModel = Field(description='The help text from the field description') try: sys.argv = ['example.py', '--help'] Settings() except SystemExit as e: print(e) #> 0 """ usage: example.py [-h] [--sub_model JSON] [--sub_model.v1 int] My application help text. options: -h, --help show this help message and exit sub_model options: The help text from the class docstring. --sub_model JSON set sub_model from JSON string --sub_model.v1 int the sub model v1 option (required) """ ``` #### Change the CLI Flag Prefix Character Change The CLI flag prefix character used in CLI optional arguments by settings `cli_flag_prefix_char`. ```py import sys from pydantic import AliasChoices, Field from pydantic_settings import BaseSettings class Settings(BaseSettings, cli_parse_args=True, cli_flag_prefix_char='+'): my_arg: str = Field(validation_alias=AliasChoices('m', 'my-arg')) sys.argv = ['example.py', '++my-arg', 'hi'] print(Settings().model_dump()) #> {'my_arg': 'hi'} sys.argv = ['example.py', '+m', 'hi'] print(Settings().model_dump()) #> {'my_arg': 'hi'} ``` #### Suppressing Fields from CLI Help Text To suppress a field from the CLI help text, the `CliSuppress` annotation can be used for field types, or the `CLI_SUPPRESS` string constant can be used for field descriptions. ```py import sys from pydantic import Field from pydantic_settings import CLI_SUPPRESS, BaseSettings, CliSuppress class Settings(BaseSettings, cli_parse_args=True): """Suppress fields from CLI help text.""" field_a: CliSuppress[int] = 0 field_b: str = Field(default=1, description=CLI_SUPPRESS) try: sys.argv = ['example.py', '--help'] Settings() except SystemExit as e: print(e) #> 0 """ usage: example.py [-h] Suppress fields from CLI help text. options: -h, --help show this help message and exit """ ``` ### Integrating with Existing Parsers A CLI settings source can be integrated with existing parsers by overriding the default CLI settings source with a user defined one that specifies the `root_parser` object. ```py import sys from argparse import ArgumentParser from pydantic_settings import BaseSettings, CliApp, CliSettingsSource parser = ArgumentParser() parser.add_argument('--food', choices=['pear', 'kiwi', 'lime']) class Settings(BaseSettings): name: str = 'Bob' # Set existing `parser` as the `root_parser` object for the user defined settings source cli_settings = CliSettingsSource(Settings, root_parser=parser) # Parse and load CLI settings from the command line into the settings source. sys.argv = ['example.py', '--food', 'kiwi', '--name', 'waldo'] s = CliApp.run(Settings, cli_settings_source=cli_settings) print(s.model_dump()) #> {'name': 'waldo'} # Load CLI settings from pre-parsed arguments. i.e., the parsing occurs elsewhere and we # just need to load the pre-parsed args into the settings source. parsed_args = parser.parse_args(['--food', 'kiwi', '--name', 'ralph']) s = CliApp.run(Settings, cli_args=parsed_args, cli_settings_source=cli_settings) print(s.model_dump()) #> {'name': 'ralph'} ``` A `CliSettingsSource` connects with a `root_parser` object by using parser methods to add `settings_cls` fields as command line arguments. The `CliSettingsSource` internal parser representation is based on the `argparse` library, and therefore, requires parser methods that support the same attributes as their `argparse` counterparts. The available parser methods that can be customised, along with their argparse counterparts (the defaults), are listed below: * `parse_args_method` - (`argparse.ArgumentParser.parse_args`) * `add_argument_method` - (`argparse.ArgumentParser.add_argument`) * `add_argument_group_method` - (`argparse.ArgumentParser.add_argument_group`) * `add_parser_method` - (`argparse._SubParsersAction.add_parser`) * `add_subparsers_method` - (`argparse.ArgumentParser.add_subparsers`) * `formatter_class` - (`argparse.RawDescriptionHelpFormatter`) For a non-argparse parser the parser methods can be set to `None` if not supported. The CLI settings will only raise an error when connecting to the root parser if a parser method is necessary but set to `None`. !!! note The `formatter_class` is only applied to subcommands. The `CliSettingsSource` never touches or modifies any of the external parser settings to avoid breaking changes. Since subcommands reside on their own internal parser trees, we can safely apply the `formatter_class` settings without breaking the external parser logic. ## Secrets Placing secret values in files is a common pattern to provide sensitive configuration to an application. A secret file follows the same principal as a dotenv file except it only contains a single value and the file name is used as the key. A secret file will look like the following: ``` title="/var/run/database_password" super_secret_database_password ``` Once you have your secret files, *pydantic* supports loading it in two ways: 1. Setting the `secrets_dir` on `model_config` in a `BaseSettings` class to the directory where your secret files are stored. ````py hl_lines="4 5 6 7" from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(secrets_dir='/var/run') database_password: str ```` 2. Instantiating the `BaseSettings` derived class with the `_secrets_dir` keyword argument: ```` settings = Settings(_secrets_dir='/var/run') ```` In either case, the value of the passed argument can be any valid directory, either absolute or relative to the current working directory. **Note that a non existent directory will only generate a warning**. From there, *pydantic* will handle everything for you by loading in your variables and validating them. Even when using a secrets directory, *pydantic* will still read environment variables from a dotenv file or the environment, **a dotenv file and environment variables will always take priority over values loaded from the secrets directory**. Passing a file path via the `_secrets_dir` keyword argument on instantiation (method 2) will override the value (if any) set on the `model_config` class. If you need to load settings from multiple secrets directories, you can pass multiple paths as a tuple or list. Just like for `env_file`, values from subsequent paths override previous ones. ````python from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): # files in '/run/secrets' take priority over '/var/run' model_config = SettingsConfigDict(secrets_dir=('/var/run', '/run/secrets')) database_password: str ```` If any of `secrets_dir` is missing, it is ignored, and warning is shown. If any of `secrets_dir` is a file, error is raised. ### Use Case: Docker Secrets Docker Secrets can be used to provide sensitive configuration to an application running in a Docker container. To use these secrets in a *pydantic* application the process is simple. More information regarding creating, managing and using secrets in Docker see the official [Docker documentation](https://docs.docker.com/engine/reference/commandline/secret/). First, define your `Settings` class with a `SettingsConfigDict` that specifies the secrets directory. ```py hl_lines="4 5 6 7" from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(secrets_dir='/run/secrets') my_secret_data: str ``` !!! note By default [Docker uses `/run/secrets`](https://docs.docker.com/engine/swarm/secrets/#how-docker-manages-secrets) as the target mount point. If you want to use a different location, change `Config.secrets_dir` accordingly. Then, create your secret via the Docker CLI ```bash printf "This is a secret" | docker secret create my_secret_data - ``` Last, run your application inside a Docker container and supply your newly created secret ```bash docker service create --name pydantic-with-secrets --secret my_secret_data pydantic-app:latest ``` ## Azure Key Vault You must set two parameters: - `url`: For example, `https://my-resource.vault.azure.net/`. - `credential`: If you use `DefaultAzureCredential`, in local you can execute `az login` to get your identity credentials. The identity must have a role assignment (the recommended one is `Key Vault Secrets User`), so you can access the secrets. You must have the same naming convention in the field name as in the Key Vault secret name. For example, if the secret is named `SqlServerPassword`, the field name must be the same. You can use an alias too. In Key Vault, nested models are supported with the `--` separator. For example, `SqlServer--Password`. Key Vault arrays (e.g. `MySecret--0`, `MySecret--1`) are not supported. ```py import os from typing import Tuple, Type from azure.identity import DefaultAzureCredential from pydantic import BaseModel from pydantic_settings import ( AzureKeyVaultSettingsSource, BaseSettings, PydanticBaseSettingsSource, ) class SubModel(BaseModel): a: str class AzureKeyVaultSettings(BaseSettings): foo: str bar: int sub: SubModel @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: az_key_vault_settings = AzureKeyVaultSettingsSource( settings_cls, os.environ['AZURE_KEY_VAULT_URL'], DefaultAzureCredential(), ) return ( init_settings, env_settings, dotenv_settings, file_secret_settings, az_key_vault_settings, ) ``` ## Other settings source Other settings sources are available for common configuration files: - `JsonConfigSettingsSource` using `json_file` and `json_file_encoding` arguments - `PyprojectTomlConfigSettingsSource` using *(optional)* `pyproject_toml_depth` and *(optional)* `pyproject_toml_table_header` arguments - `TomlConfigSettingsSource` using `toml_file` argument - `YamlConfigSettingsSource` using `yaml_file` and yaml_file_encoding arguments You can also provide multiple files by providing a list of path: ```py toml_file = ['config.default.toml', 'config.custom.toml'] ``` To use them, you can use the same mechanism described [here](#customise-settings-sources) ```py from typing import Tuple, Type from pydantic import BaseModel from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource, ) class Nested(BaseModel): nested_field: str class Settings(BaseSettings): foobar: str nested: Nested model_config = SettingsConfigDict(toml_file='config.toml') @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (TomlConfigSettingsSource(settings_cls),) ``` This will be able to read the following "config.toml" file, located in your working directory: ```toml foobar = "Hello" [nested] nested_field = "world!" ``` ### pyproject.toml "pyproject.toml" is a standardized file for providing configuration values in Python projects. [PEP 518](https://peps.python.org/pep-0518/#tool-table) defines a `[tool]` table that can be used to provide arbitrary tool configuration. While encouraged to use the `[tool]` table, `PyprojectTomlConfigSettingsSource` can be used to load variables from any location with in "pyproject.toml" file. This is controlled by providing `SettingsConfigDict(pyproject_toml_table_header=tuple[str, ...])` where the value is a tuple of header parts. By default, `pyproject_toml_table_header=('tool', 'pydantic-settings')` which will load variables from the `[tool.pydantic-settings]` table. ```python from typing import Tuple, Type from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, PyprojectTomlConfigSettingsSource, SettingsConfigDict, ) class Settings(BaseSettings): """Example loading values from the table used by default.""" field: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls),) class SomeTableSettings(Settings): """Example loading values from a user defined table.""" model_config = SettingsConfigDict( pyproject_toml_table_header=('tool', 'some-table') ) class RootSettings(Settings): """Example loading values from the root of a pyproject.toml file.""" model_config = SettingsConfigDict(extra='ignore', pyproject_toml_table_header=()) ``` This will be able to read the following "pyproject.toml" file, located in your working directory, resulting in `Settings(field='default-table')`, `SomeTableSettings(field='some-table')`, & `RootSettings(field='root')`: ```toml field = "root" [tool.pydantic-settings] field = "default-table" [tool.some-table] field = "some-table" ``` By default, `PyprojectTomlConfigSettingsSource` will only look for a "pyproject.toml" in the your current working directory. However, there are two options to change this behavior. * `SettingsConfigDict(pyproject_toml_depth=)` can be provided to check `` number of directories **up** in the directory tree for a "pyproject.toml" if one is not found in the current working directory. By default, no parent directories are checked. * An explicit file path can be provided to the source when it is instantiated (e.g. `PyprojectTomlConfigSettingsSource(settings_cls, Path('~/.config').resolve() / 'pyproject.toml')`). If a file path is provided this way, it will be treated as absolute (no other locations are checked). ```python from pathlib import Path from typing import Tuple, Type from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, PyprojectTomlConfigSettingsSource, SettingsConfigDict, ) class DiscoverSettings(BaseSettings): """Example of discovering a pyproject.toml in parent directories in not in `Path.cwd()`.""" model_config = SettingsConfigDict(pyproject_toml_depth=2) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls),) class ExplicitFilePathSettings(BaseSettings): """Example of explicitly providing the path to the file to load.""" field: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return ( PyprojectTomlConfigSettingsSource( settings_cls, Path('~/.config').resolve() / 'pyproject.toml' ), ) ``` ## Field value priority In the case where a value is specified for the same `Settings` field in multiple ways, the selected value is determined as follows (in descending order of priority): 1. If `cli_parse_args` is enabled, arguments passed in at the CLI. 2. Arguments passed to the `Settings` class initialiser. 3. Environment variables, e.g. `my_prefix_special_function` as described above. 4. Variables loaded from a dotenv (`.env`) file. 5. Variables loaded from the secrets directory. 6. The default field values for the `Settings` model. ## Customise settings sources If the default order of priority doesn't match your needs, it's possible to change it by overriding the `settings_customise_sources` method of your `Settings` . `settings_customise_sources` takes four callables as arguments and returns any number of callables as a tuple. In turn these callables are called to build the inputs to the fields of the settings class. Each callable should take an instance of the settings class as its sole argument and return a `dict`. ### Changing Priority The order of the returned callables decides the priority of inputs; first item is the highest priority. ```py from typing import Tuple, Type from pydantic import PostgresDsn from pydantic_settings import BaseSettings, PydanticBaseSettingsSource class Settings(BaseSettings): database_dsn: PostgresDsn @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return env_settings, init_settings, file_secret_settings print(Settings(database_dsn='postgres://postgres@localhost:5432/kwargs_db')) #> database_dsn=MultiHostUrl('postgres://postgres@localhost:5432/kwargs_db') ``` By flipping `env_settings` and `init_settings`, environment variables now have precedence over `__init__` kwargs. ### Adding sources As explained earlier, *pydantic* ships with multiples built-in settings sources. However, you may occasionally need to add your own custom sources, `settings_customise_sources` makes this very easy: ```py import json from pathlib import Path from typing import Any, Dict, Tuple, Type from pydantic.fields import FieldInfo from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, ) class JsonConfigSettingsSource(PydanticBaseSettingsSource): """ A simple settings source class that loads variables from a JSON file at the project's root. Here we happen to choose to use the `env_file_encoding` from Config when reading `config.json` """ def get_field_value( self, field: FieldInfo, field_name: str ) -> Tuple[Any, str, bool]: encoding = self.config.get('env_file_encoding') file_content_json = json.loads( Path('tests/example_test_config.json').read_text(encoding) ) field_value = file_content_json.get(field_name) return field_value, field_name, False def prepare_field_value( self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool ) -> Any: return value def __call__(self) -> Dict[str, Any]: d: Dict[str, Any] = {} for field_name, field in self.settings_cls.model_fields.items(): field_value, field_key, value_is_complex = self.get_field_value( field, field_name ) field_value = self.prepare_field_value( field_name, field, field_value, value_is_complex ) if field_value is not None: d[field_key] = field_value return d class Settings(BaseSettings): model_config = SettingsConfigDict(env_file_encoding='utf-8') foobar: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return ( init_settings, JsonConfigSettingsSource(settings_cls), env_settings, file_secret_settings, ) print(Settings()) #> foobar='test' ``` #### Accesing the result of previous sources Each source of settings can access the output of the previous ones. ```python from typing import Any, Dict, Tuple from pydantic.fields import FieldInfo from pydantic_settings import PydanticBaseSettingsSource class MyCustomSource(PydanticBaseSettingsSource): def get_field_value( self, field: FieldInfo, field_name: str ) -> Tuple[Any, str, bool]: ... def __call__(self) -> Dict[str, Any]: # Retrieve the aggregated settings from previous sources current_state = self.current_state current_state.get('some_setting') # Retrive settings from all sources individually # self.settings_sources_data["SettingsSourceName"]: Dict[str, Any] settings_sources_data = self.settings_sources_data settings_sources_data['SomeSettingsSource'].get('some_setting') # Your code here... ``` ### Removing sources You might also want to disable a source: ```py from typing import Tuple, Type from pydantic import ValidationError from pydantic_settings import BaseSettings, PydanticBaseSettingsSource class Settings(BaseSettings): my_api_key: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: # here we choose to ignore arguments from init_settings return env_settings, file_secret_settings try: Settings(my_api_key='this is ignored') except ValidationError as exc_info: print(exc_info) """ 1 validation error for Settings my_api_key Field required [type=missing, input_value={}, input_type=dict] For further information visit https://errors.pydantic.dev/2/v/missing """ ``` ## In-place reloading In case you want to reload in-place an existing setting, you can do it by using its `__init__` method : ```py import os from pydantic import Field from pydantic_settings import BaseSettings class Settings(BaseSettings): foo: str = Field('foo') mutable_settings = Settings() print(mutable_settings.foo) #> foo os.environ['foo'] = 'bar' print(mutable_settings.foo) #> foo mutable_settings.__init__() print(mutable_settings.foo) #> bar os.environ.pop('foo') mutable_settings.__init__() print(mutable_settings.foo) #> foo ``` pydantic-pydantic-settings-5f33b62/docs/logo-white.svg000066400000000000000000000011321476003445400230630ustar00rootroot00000000000000 pydantic-pydantic-settings-5f33b62/docs/theme/000077500000000000000000000000001476003445400213715ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/docs/theme/main.html000066400000000000000000000004131476003445400232010ustar00rootroot00000000000000{% extends "base.html" %} {% block announce %} {% include 'announce.html' ignore missing %} {% endblock %} {% block content %} {{ super() }} {% endblock %} pydantic-pydantic-settings-5f33b62/mkdocs.yml000066400000000000000000000025121476003445400213420ustar00rootroot00000000000000site_name: pydantic site_description: Data validation using Python type hints strict: true site_url: https://docs.pydantic.dev/ theme: name: 'material' custom_dir: 'docs/theme' palette: - media: "(prefers-color-scheme: light)" scheme: default primary: pink accent: pink toggle: icon: material/lightbulb-outline name: "Switch to dark mode" - media: "(prefers-color-scheme: dark)" scheme: slate primary: pink accent: pink toggle: icon: material/lightbulb name: "Switch to light mode" features: - content.tabs.link - content.code.annotate - announce.dismiss - navigation.tabs logo: 'logo-white.svg' favicon: 'favicon.png' repo_name: pydantic/pydantic repo_url: https://github.com/pydantic/pydantic edit_uri: edit/main/docs/ extra_css: - 'extra/terminal.css' - 'extra/tweaks.css' nav: - index.md markdown_extensions: - tables - toc: permalink: true title: Page contents - admonition - pymdownx.highlight - pymdownx.extra - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg - pymdownx.tabbed: alternate_style: true extra: version: provider: mike plugins: - mike: alias_type: symlink canonical_version: latest - search - exclude: glob: - __pycache__/* pydantic-pydantic-settings-5f33b62/pydantic_settings/000077500000000000000000000000001476003445400230725ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/pydantic_settings/__init__.py000066400000000000000000000025021476003445400252020ustar00rootroot00000000000000from .main import BaseSettings, CliApp, SettingsConfigDict from .sources import ( CLI_SUPPRESS, AzureKeyVaultSettingsSource, CliExplicitFlag, CliImplicitFlag, CliMutuallyExclusiveGroup, CliPositionalArg, CliSettingsSource, CliSubCommand, CliSuppress, DotEnvSettingsSource, EnvSettingsSource, ForceDecode, InitSettingsSource, JsonConfigSettingsSource, NoDecode, PydanticBaseSettingsSource, PyprojectTomlConfigSettingsSource, SecretsSettingsSource, SettingsError, TomlConfigSettingsSource, YamlConfigSettingsSource, get_subcommand, ) from .version import VERSION __all__ = ( 'BaseSettings', 'DotEnvSettingsSource', 'EnvSettingsSource', 'CliApp', 'CliSettingsSource', 'CliSubCommand', 'CliSuppress', 'CLI_SUPPRESS', 'CliPositionalArg', 'CliExplicitFlag', 'CliImplicitFlag', 'CliMutuallyExclusiveGroup', 'InitSettingsSource', 'JsonConfigSettingsSource', 'NoDecode', 'ForceDecode', 'PyprojectTomlConfigSettingsSource', 'PydanticBaseSettingsSource', 'SecretsSettingsSource', 'SettingsConfigDict', 'SettingsError', 'TomlConfigSettingsSource', 'YamlConfigSettingsSource', 'AzureKeyVaultSettingsSource', 'get_subcommand', '__version__', ) __version__ = VERSION pydantic-pydantic-settings-5f33b62/pydantic_settings/main.py000066400000000000000000000667611476003445400244100ustar00rootroot00000000000000from __future__ import annotations as _annotations import asyncio import inspect import threading from argparse import Namespace from types import SimpleNamespace from typing import Any, ClassVar, TypeVar from pydantic import ConfigDict from pydantic._internal._config import config_keys from pydantic._internal._signature import _field_name_for_signature from pydantic._internal._utils import deep_update, is_model_class from pydantic.dataclasses import is_pydantic_dataclass from pydantic.main import BaseModel from .sources import ( ENV_FILE_SENTINEL, CliSettingsSource, DefaultSettingsSource, DotEnvSettingsSource, DotenvType, EnvSettingsSource, InitSettingsSource, PathType, PydanticBaseSettingsSource, PydanticModel, SecretsSettingsSource, SettingsError, get_subcommand, ) T = TypeVar('T') class SettingsConfigDict(ConfigDict, total=False): case_sensitive: bool nested_model_default_partial_update: bool | None env_prefix: str env_file: DotenvType | None env_file_encoding: str | None env_ignore_empty: bool env_nested_delimiter: str | None env_nested_max_split: int | None env_parse_none_str: str | None env_parse_enums: bool | None cli_prog_name: str | None cli_parse_args: bool | list[str] | tuple[str, ...] | None cli_parse_none_str: str | None cli_hide_none_type: bool cli_avoid_json: bool cli_enforce_required: bool cli_use_class_docs_for_groups: bool cli_exit_on_error: bool cli_prefix: str cli_flag_prefix_char: str cli_implicit_flags: bool | None cli_ignore_unknown_args: bool | None cli_kebab_case: bool | None secrets_dir: PathType | None json_file: PathType | None json_file_encoding: str | None yaml_file: PathType | None yaml_file_encoding: str | None pyproject_toml_depth: int """ Number of levels **up** from the current working directory to attempt to find a pyproject.toml file. This is only used when a pyproject.toml file is not found in the current working directory. """ pyproject_toml_table_header: tuple[str, ...] """ Header of the TOML table within a pyproject.toml file to use when filling variables. This is supplied as a `tuple[str, ...]` instead of a `str` to accommodate for headers containing a `.`. For example, `toml_table_header = ("tool", "my.tool", "foo")` can be used to fill variable values from a table with header `[tool."my.tool".foo]`. To use the root table, exclude this config setting or provide an empty tuple. """ toml_file: PathType | None enable_decoding: bool # Extend `config_keys` by pydantic settings config keys to # support setting config through class kwargs. # Pydantic uses `config_keys` in `pydantic._internal._config.ConfigWrapper.for_model` # to extract config keys from model kwargs, So, by adding pydantic settings keys to # `config_keys`, they will be considered as valid config keys and will be collected # by Pydantic. config_keys |= set(SettingsConfigDict.__annotations__.keys()) class BaseSettings(BaseModel): """ Base class for settings, allowing values to be overridden by environment variables. This is useful in production for secrets you do not wish to save in code, it plays nicely with docker(-compose), Heroku and any 12 factor app design. All the below attributes can be set via `model_config`. Args: _case_sensitive: Whether environment and CLI variable names should be read with case-sensitivity. Defaults to `None`. _nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields. Defaults to `False`. _env_prefix: Prefix for all environment variables. Defaults to `None`. _env_file: The env file(s) to load settings values from. Defaults to `Path('')`, which means that the value from `model_config['env_file']` should be used. You can also pass `None` to indicate that environment variables should not be loaded from an env file. _env_file_encoding: The env file encoding, e.g. `'latin-1'`. Defaults to `None`. _env_ignore_empty: Ignore environment variables where the value is an empty string. Default to `False`. _env_nested_delimiter: The nested env values delimiter. Defaults to `None`. _env_nested_max_split: The nested env values maximum nesting. Defaults to `None`, which means no limit. _env_parse_none_str: The env string value that should be parsed (e.g. "null", "void", "None", etc.) into `None` type(None). Defaults to `None` type(None), which means no parsing should occur. _env_parse_enums: Parse enum field names to values. Defaults to `None.`, which means no parsing should occur. _cli_prog_name: The CLI program name to display in help text. Defaults to `None` if _cli_parse_args is `None`. Otherwse, defaults to sys.argv[0]. _cli_parse_args: The list of CLI arguments to parse. Defaults to None. If set to `True`, defaults to sys.argv[1:]. _cli_settings_source: Override the default CLI settings source with a user defined instance. Defaults to None. _cli_parse_none_str: The CLI string value that should be parsed (e.g. "null", "void", "None", etc.) into `None` type(None). Defaults to _env_parse_none_str value if set. Otherwise, defaults to "null" if _cli_avoid_json is `False`, and "None" if _cli_avoid_json is `True`. _cli_hide_none_type: Hide `None` values in CLI help text. Defaults to `False`. _cli_avoid_json: Avoid complex JSON objects in CLI help text. Defaults to `False`. _cli_enforce_required: Enforce required fields at the CLI. Defaults to `False`. _cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions. Defaults to `False`. _cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs. Defaults to `True`. _cli_prefix: The root parser command line arguments prefix. Defaults to "". _cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'. _cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags. (e.g. --flag, --no-flag). Defaults to `False`. _cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`. _cli_kebab_case: CLI args use kebab case. Defaults to `False`. _secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`. """ def __init__( __pydantic_self__, _case_sensitive: bool | None = None, _nested_model_default_partial_update: bool | None = None, _env_prefix: str | None = None, _env_file: DotenvType | None = ENV_FILE_SENTINEL, _env_file_encoding: str | None = None, _env_ignore_empty: bool | None = None, _env_nested_delimiter: str | None = None, _env_nested_max_split: int | None = None, _env_parse_none_str: str | None = None, _env_parse_enums: bool | None = None, _cli_prog_name: str | None = None, _cli_parse_args: bool | list[str] | tuple[str, ...] | None = None, _cli_settings_source: CliSettingsSource[Any] | None = None, _cli_parse_none_str: str | None = None, _cli_hide_none_type: bool | None = None, _cli_avoid_json: bool | None = None, _cli_enforce_required: bool | None = None, _cli_use_class_docs_for_groups: bool | None = None, _cli_exit_on_error: bool | None = None, _cli_prefix: str | None = None, _cli_flag_prefix_char: str | None = None, _cli_implicit_flags: bool | None = None, _cli_ignore_unknown_args: bool | None = None, _cli_kebab_case: bool | None = None, _secrets_dir: PathType | None = None, **values: Any, ) -> None: super().__init__( **__pydantic_self__._settings_build_values( values, _case_sensitive=_case_sensitive, _nested_model_default_partial_update=_nested_model_default_partial_update, _env_prefix=_env_prefix, _env_file=_env_file, _env_file_encoding=_env_file_encoding, _env_ignore_empty=_env_ignore_empty, _env_nested_delimiter=_env_nested_delimiter, _env_nested_max_split=_env_nested_max_split, _env_parse_none_str=_env_parse_none_str, _env_parse_enums=_env_parse_enums, _cli_prog_name=_cli_prog_name, _cli_parse_args=_cli_parse_args, _cli_settings_source=_cli_settings_source, _cli_parse_none_str=_cli_parse_none_str, _cli_hide_none_type=_cli_hide_none_type, _cli_avoid_json=_cli_avoid_json, _cli_enforce_required=_cli_enforce_required, _cli_use_class_docs_for_groups=_cli_use_class_docs_for_groups, _cli_exit_on_error=_cli_exit_on_error, _cli_prefix=_cli_prefix, _cli_flag_prefix_char=_cli_flag_prefix_char, _cli_implicit_flags=_cli_implicit_flags, _cli_ignore_unknown_args=_cli_ignore_unknown_args, _cli_kebab_case=_cli_kebab_case, _secrets_dir=_secrets_dir, ) ) @classmethod def settings_customise_sources( cls, settings_cls: type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> tuple[PydanticBaseSettingsSource, ...]: """ Define the sources and their order for loading the settings values. Args: settings_cls: The Settings class. init_settings: The `InitSettingsSource` instance. env_settings: The `EnvSettingsSource` instance. dotenv_settings: The `DotEnvSettingsSource` instance. file_secret_settings: The `SecretsSettingsSource` instance. Returns: A tuple containing the sources and their order for loading the settings values. """ return init_settings, env_settings, dotenv_settings, file_secret_settings def _settings_build_values( self, init_kwargs: dict[str, Any], _case_sensitive: bool | None = None, _nested_model_default_partial_update: bool | None = None, _env_prefix: str | None = None, _env_file: DotenvType | None = None, _env_file_encoding: str | None = None, _env_ignore_empty: bool | None = None, _env_nested_delimiter: str | None = None, _env_nested_max_split: int | None = None, _env_parse_none_str: str | None = None, _env_parse_enums: bool | None = None, _cli_prog_name: str | None = None, _cli_parse_args: bool | list[str] | tuple[str, ...] | None = None, _cli_settings_source: CliSettingsSource[Any] | None = None, _cli_parse_none_str: str | None = None, _cli_hide_none_type: bool | None = None, _cli_avoid_json: bool | None = None, _cli_enforce_required: bool | None = None, _cli_use_class_docs_for_groups: bool | None = None, _cli_exit_on_error: bool | None = None, _cli_prefix: str | None = None, _cli_flag_prefix_char: str | None = None, _cli_implicit_flags: bool | None = None, _cli_ignore_unknown_args: bool | None = None, _cli_kebab_case: bool | None = None, _secrets_dir: PathType | None = None, ) -> dict[str, Any]: # Determine settings config values case_sensitive = _case_sensitive if _case_sensitive is not None else self.model_config.get('case_sensitive') env_prefix = _env_prefix if _env_prefix is not None else self.model_config.get('env_prefix') nested_model_default_partial_update = ( _nested_model_default_partial_update if _nested_model_default_partial_update is not None else self.model_config.get('nested_model_default_partial_update') ) env_file = _env_file if _env_file != ENV_FILE_SENTINEL else self.model_config.get('env_file') env_file_encoding = ( _env_file_encoding if _env_file_encoding is not None else self.model_config.get('env_file_encoding') ) env_ignore_empty = ( _env_ignore_empty if _env_ignore_empty is not None else self.model_config.get('env_ignore_empty') ) env_nested_delimiter = ( _env_nested_delimiter if _env_nested_delimiter is not None else self.model_config.get('env_nested_delimiter') ) env_nested_max_split = ( _env_nested_max_split if _env_nested_max_split is not None else self.model_config.get('env_nested_max_split') ) env_parse_none_str = ( _env_parse_none_str if _env_parse_none_str is not None else self.model_config.get('env_parse_none_str') ) env_parse_enums = _env_parse_enums if _env_parse_enums is not None else self.model_config.get('env_parse_enums') cli_prog_name = _cli_prog_name if _cli_prog_name is not None else self.model_config.get('cli_prog_name') cli_parse_args = _cli_parse_args if _cli_parse_args is not None else self.model_config.get('cli_parse_args') cli_settings_source = ( _cli_settings_source if _cli_settings_source is not None else self.model_config.get('cli_settings_source') ) cli_parse_none_str = ( _cli_parse_none_str if _cli_parse_none_str is not None else self.model_config.get('cli_parse_none_str') ) cli_parse_none_str = cli_parse_none_str if not env_parse_none_str else env_parse_none_str cli_hide_none_type = ( _cli_hide_none_type if _cli_hide_none_type is not None else self.model_config.get('cli_hide_none_type') ) cli_avoid_json = _cli_avoid_json if _cli_avoid_json is not None else self.model_config.get('cli_avoid_json') cli_enforce_required = ( _cli_enforce_required if _cli_enforce_required is not None else self.model_config.get('cli_enforce_required') ) cli_use_class_docs_for_groups = ( _cli_use_class_docs_for_groups if _cli_use_class_docs_for_groups is not None else self.model_config.get('cli_use_class_docs_for_groups') ) cli_exit_on_error = ( _cli_exit_on_error if _cli_exit_on_error is not None else self.model_config.get('cli_exit_on_error') ) cli_prefix = _cli_prefix if _cli_prefix is not None else self.model_config.get('cli_prefix') cli_flag_prefix_char = ( _cli_flag_prefix_char if _cli_flag_prefix_char is not None else self.model_config.get('cli_flag_prefix_char') ) cli_implicit_flags = ( _cli_implicit_flags if _cli_implicit_flags is not None else self.model_config.get('cli_implicit_flags') ) cli_ignore_unknown_args = ( _cli_ignore_unknown_args if _cli_ignore_unknown_args is not None else self.model_config.get('cli_ignore_unknown_args') ) cli_kebab_case = _cli_kebab_case if _cli_kebab_case is not None else self.model_config.get('cli_kebab_case') secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') # Configure built-in sources default_settings = DefaultSettingsSource( self.__class__, nested_model_default_partial_update=nested_model_default_partial_update ) init_settings = InitSettingsSource( self.__class__, init_kwargs=init_kwargs, nested_model_default_partial_update=nested_model_default_partial_update, ) env_settings = EnvSettingsSource( self.__class__, case_sensitive=case_sensitive, env_prefix=env_prefix, env_nested_delimiter=env_nested_delimiter, env_nested_max_split=env_nested_max_split, env_ignore_empty=env_ignore_empty, env_parse_none_str=env_parse_none_str, env_parse_enums=env_parse_enums, ) dotenv_settings = DotEnvSettingsSource( self.__class__, env_file=env_file, env_file_encoding=env_file_encoding, case_sensitive=case_sensitive, env_prefix=env_prefix, env_nested_delimiter=env_nested_delimiter, env_nested_max_split=env_nested_max_split, env_ignore_empty=env_ignore_empty, env_parse_none_str=env_parse_none_str, env_parse_enums=env_parse_enums, ) file_secret_settings = SecretsSettingsSource( self.__class__, secrets_dir=secrets_dir, case_sensitive=case_sensitive, env_prefix=env_prefix ) # Provide a hook to set built-in sources priority and add / remove sources sources = self.settings_customise_sources( self.__class__, init_settings=init_settings, env_settings=env_settings, dotenv_settings=dotenv_settings, file_secret_settings=file_secret_settings, ) + (default_settings,) if not any([source for source in sources if isinstance(source, CliSettingsSource)]): if isinstance(cli_settings_source, CliSettingsSource): sources = (cli_settings_source,) + sources elif cli_parse_args is not None: cli_settings = CliSettingsSource[Any]( self.__class__, cli_prog_name=cli_prog_name, cli_parse_args=cli_parse_args, cli_parse_none_str=cli_parse_none_str, cli_hide_none_type=cli_hide_none_type, cli_avoid_json=cli_avoid_json, cli_enforce_required=cli_enforce_required, cli_use_class_docs_for_groups=cli_use_class_docs_for_groups, cli_exit_on_error=cli_exit_on_error, cli_prefix=cli_prefix, cli_flag_prefix_char=cli_flag_prefix_char, cli_implicit_flags=cli_implicit_flags, cli_ignore_unknown_args=cli_ignore_unknown_args, cli_kebab_case=cli_kebab_case, case_sensitive=case_sensitive, ) sources = (cli_settings,) + sources if sources: state: dict[str, Any] = {} states: dict[str, dict[str, Any]] = {} for source in sources: if isinstance(source, PydanticBaseSettingsSource): source._set_current_state(state) source._set_settings_sources_data(states) source_name = source.__name__ if hasattr(source, '__name__') else type(source).__name__ source_state = source() states[source_name] = source_state state = deep_update(source_state, state) return state else: # no one should mean to do this, but I think returning an empty dict is marginally preferable # to an informative error and much better than a confusing error return {} model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( extra='forbid', arbitrary_types_allowed=True, validate_default=True, case_sensitive=False, env_prefix='', nested_model_default_partial_update=False, env_file=None, env_file_encoding=None, env_ignore_empty=False, env_nested_delimiter=None, env_nested_max_split=None, env_parse_none_str=None, env_parse_enums=None, cli_prog_name=None, cli_parse_args=None, cli_parse_none_str=None, cli_hide_none_type=False, cli_avoid_json=False, cli_enforce_required=False, cli_use_class_docs_for_groups=False, cli_exit_on_error=True, cli_prefix='', cli_flag_prefix_char='-', cli_implicit_flags=False, cli_ignore_unknown_args=False, cli_kebab_case=False, json_file=None, json_file_encoding=None, yaml_file=None, yaml_file_encoding=None, toml_file=None, secrets_dir=None, protected_namespaces=('model_validate', 'model_dump', 'settings_customise_sources'), enable_decoding=True, ) class CliApp: """ A utility class for running Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as CLI applications. """ @staticmethod def _run_cli_cmd(model: Any, cli_cmd_method_name: str, is_required: bool) -> Any: command = getattr(type(model), cli_cmd_method_name, None) if command is None: if is_required: raise SettingsError(f'Error: {type(model).__name__} class is missing {cli_cmd_method_name} entrypoint') return model # If the method is asynchronous, we handle its execution based on the current event loop status. if inspect.iscoroutinefunction(command): # For asynchronous methods, we have two execution scenarios: # 1. If no event loop is running in the current thread, run the coroutine directly with asyncio.run(). # 2. If an event loop is already running in the current thread, run the coroutine in a separate thread to avoid conflicts. try: # Check if an event loop is currently running in this thread. loop = asyncio.get_running_loop() except RuntimeError: loop = None if loop and loop.is_running(): # We're in a context with an active event loop (e.g., Jupyter Notebook). # Running asyncio.run() here would cause conflicts, so we use a separate thread. exception_container = [] def run_coro() -> None: try: # Execute the coroutine in a new event loop in this separate thread. asyncio.run(command(model)) except Exception as e: exception_container.append(e) thread = threading.Thread(target=run_coro) thread.start() thread.join() if exception_container: # Propagate exceptions from the separate thread. raise exception_container[0] else: # No event loop is running; safe to run the coroutine directly. asyncio.run(command(model)) else: # For synchronous methods, call them directly. command(model) return model @staticmethod def run( model_cls: type[T], cli_args: list[str] | Namespace | SimpleNamespace | dict[str, Any] | None = None, cli_settings_source: CliSettingsSource[Any] | None = None, cli_exit_on_error: bool | None = None, cli_cmd_method_name: str = 'cli_cmd', **model_init_data: Any, ) -> T: """ Runs a Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application. Running a model as a CLI application requires the `cli_cmd` method to be defined in the model class. Args: model_cls: The model class to run as a CLI application. cli_args: The list of CLI arguments to parse. If `cli_settings_source` is specified, this may also be a namespace or dictionary of pre-parsed CLI arguments. Defaults to `sys.argv[1:]`. cli_settings_source: Override the default CLI settings source with a user defined instance. Defaults to `None`. cli_exit_on_error: Determines whether this function exits on error. If model is subclass of `BaseSettings`, defaults to BaseSettings `cli_exit_on_error` value. Otherwise, defaults to `True`. cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd". model_init_data: The model init data. Returns: The ran instance of model. Raises: SettingsError: If model_cls is not subclass of `BaseModel` or `pydantic.dataclasses.dataclass`. SettingsError: If model_cls does not have a `cli_cmd` entrypoint defined. """ if not (is_pydantic_dataclass(model_cls) or is_model_class(model_cls)): raise SettingsError( f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass' ) cli_settings = None cli_parse_args = True if cli_args is None else cli_args if cli_settings_source is not None: if isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)): cli_settings = cli_settings_source(parsed_args=cli_parse_args) else: cli_settings = cli_settings_source(args=cli_parse_args) elif isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)): raise SettingsError('Error: `cli_args` must be list[str] or None when `cli_settings_source` is not used') model_init_data['_cli_parse_args'] = cli_parse_args model_init_data['_cli_exit_on_error'] = cli_exit_on_error model_init_data['_cli_settings_source'] = cli_settings if not issubclass(model_cls, BaseSettings): class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore model_config = SettingsConfigDict( nested_model_default_partial_update=True, case_sensitive=True, cli_hide_none_type=True, cli_avoid_json=True, cli_enforce_required=True, cli_implicit_flags=True, cli_kebab_case=True, ) model = CliAppBaseSettings(**model_init_data) model_init_data = {} for field_name, field_info in model.model_fields.items(): model_init_data[_field_name_for_signature(field_name, field_info)] = getattr(model, field_name) return CliApp._run_cli_cmd(model_cls(**model_init_data), cli_cmd_method_name, is_required=False) @staticmethod def run_subcommand( model: PydanticModel, cli_exit_on_error: bool | None = None, cli_cmd_method_name: str = 'cli_cmd' ) -> PydanticModel: """ Runs the model subcommand. Running a model subcommand requires the `cli_cmd` method to be defined in the nested model subcommand class. Args: model: The model to run the subcommand from. cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`. cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd". Returns: The ran subcommand model. Raises: SystemExit: When no subcommand is found and cli_exit_on_error=`True` (the default). SettingsError: When no subcommand is found and cli_exit_on_error=`False`. """ subcommand = get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error) return CliApp._run_cli_cmd(subcommand, cli_cmd_method_name, is_required=True) pydantic-pydantic-settings-5f33b62/pydantic_settings/py.typed000066400000000000000000000000001476003445400245570ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/pydantic_settings/sources.py000066400000000000000000003075171476003445400251440ustar00rootroot00000000000000from __future__ import annotations as _annotations import json import os import re import shlex import sys import typing import warnings from abc import ABC, abstractmethod if sys.version_info >= (3, 9): from argparse import BooleanOptionalAction from argparse import SUPPRESS, ArgumentParser, Namespace, RawDescriptionHelpFormatter, _SubParsersAction from collections import defaultdict, deque from dataclasses import asdict, is_dataclass from enum import Enum from pathlib import Path from textwrap import dedent from types import BuiltinFunctionType, FunctionType, SimpleNamespace from typing import ( TYPE_CHECKING, Any, Callable, Dict, Generic, Iterator, Mapping, NoReturn, Optional, Sequence, TypeVar, Union, cast, overload, ) import typing_extensions from dotenv import dotenv_values from pydantic import AliasChoices, AliasPath, BaseModel, Json, RootModel, Secret, TypeAdapter from pydantic._internal._repr import Representation from pydantic._internal._typing_extra import WithArgsTypes, origin_is_union, typing_base from pydantic._internal._utils import deep_update, is_model_class, lenient_issubclass from pydantic.dataclasses import is_pydantic_dataclass from pydantic.fields import FieldInfo from pydantic_core import PydanticUndefined from typing_extensions import Annotated, _AnnotatedAlias, get_args, get_origin from pydantic_settings.utils import path_type_label if TYPE_CHECKING: if sys.version_info >= (3, 11): import tomllib else: tomllib = None import tomli import yaml from pydantic._internal._dataclasses import PydanticDataclass from pydantic_settings.main import BaseSettings PydanticModel = TypeVar('PydanticModel', bound=PydanticDataclass | BaseModel) else: yaml = None tomllib = None tomli = None PydanticModel = Any def import_yaml() -> None: global yaml if yaml is not None: return try: import yaml except ImportError as e: raise ImportError('PyYAML is not installed, run `pip install pydantic-settings[yaml]`') from e def import_toml() -> None: global tomli global tomllib if sys.version_info < (3, 11): if tomli is not None: return try: import tomli except ImportError as e: raise ImportError('tomli is not installed, run `pip install pydantic-settings[toml]`') from e else: if tomllib is not None: return import tomllib def import_azure_key_vault() -> None: global TokenCredential global SecretClient global ResourceNotFoundError try: from azure.core.credentials import TokenCredential from azure.core.exceptions import ResourceNotFoundError from azure.keyvault.secrets import SecretClient except ImportError as e: raise ImportError( 'Azure Key Vault dependencies are not installed, run `pip install pydantic-settings[azure-key-vault]`' ) from e DotenvType = Union[Path, str, Sequence[Union[Path, str]]] PathType = Union[Path, str, Sequence[Union[Path, str]]] DEFAULT_PATH: PathType = Path('') # This is used as default value for `_env_file` in the `BaseSettings` class and # `env_file` in `DotEnvSettingsSource` so the default can be distinguished from `None`. # See the docstring of `BaseSettings` for more details. ENV_FILE_SENTINEL: DotenvType = Path('') class NoDecode: """Annotation to prevent decoding of a field value.""" pass class ForceDecode: """Annotation to force decoding of a field value.""" pass class SettingsError(ValueError): pass class _CliSubCommand: pass class _CliPositionalArg: pass class _CliImplicitFlag: pass class _CliExplicitFlag: pass class _CliInternalArgParser(ArgumentParser): def __init__(self, cli_exit_on_error: bool = True, **kwargs: Any) -> None: super().__init__(**kwargs) self._cli_exit_on_error = cli_exit_on_error def error(self, message: str) -> NoReturn: if not self._cli_exit_on_error: raise SettingsError(f'error parsing CLI: {message}') super().error(message) class CliMutuallyExclusiveGroup(BaseModel): pass T = TypeVar('T') CliSubCommand = Annotated[Union[T, None], _CliSubCommand] CliPositionalArg = Annotated[T, _CliPositionalArg] _CliBoolFlag = TypeVar('_CliBoolFlag', bound=bool) CliImplicitFlag = Annotated[_CliBoolFlag, _CliImplicitFlag] CliExplicitFlag = Annotated[_CliBoolFlag, _CliExplicitFlag] CLI_SUPPRESS = SUPPRESS CliSuppress = Annotated[T, CLI_SUPPRESS] def get_subcommand( model: PydanticModel, is_required: bool = True, cli_exit_on_error: bool | None = None ) -> Optional[PydanticModel]: """ Get the subcommand from a model. Args: model: The model to get the subcommand from. is_required: Determines whether a model must have subcommand set and raises error if not found. Defaults to `True`. cli_exit_on_error: Determines whether this function exits with error if no subcommand is found. Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`. Returns: The subcommand model if found, otherwise `None`. Raises: SystemExit: When no subcommand is found and is_required=`True` and cli_exit_on_error=`True` (the default). SettingsError: When no subcommand is found and is_required=`True` and cli_exit_on_error=`False`. """ model_cls = type(model) if cli_exit_on_error is None and is_model_class(model_cls): model_default = model_cls.model_config.get('cli_exit_on_error') if isinstance(model_default, bool): cli_exit_on_error = model_default if cli_exit_on_error is None: cli_exit_on_error = True subcommands: list[str] = [] for field_name, field_info in _get_model_fields(model_cls).items(): if _CliSubCommand in field_info.metadata: if getattr(model, field_name) is not None: return getattr(model, field_name) subcommands.append(field_name) if is_required: error_message = ( f'Error: CLI subcommand is required {{{", ".join(subcommands)}}}' if subcommands else 'Error: CLI subcommand is required but no subcommands were found.' ) raise SystemExit(error_message) if cli_exit_on_error else SettingsError(error_message) return None class EnvNoneType(str): pass class PydanticBaseSettingsSource(ABC): """ Abstract base class for settings sources, every settings source classes should inherit from it. """ def __init__(self, settings_cls: type[BaseSettings]): self.settings_cls = settings_cls self.config = settings_cls.model_config self._current_state: dict[str, Any] = {} self._settings_sources_data: dict[str, dict[str, Any]] = {} def _set_current_state(self, state: dict[str, Any]) -> None: """ Record the state of settings from the previous settings sources. This should be called right before __call__. """ self._current_state = state def _set_settings_sources_data(self, states: dict[str, dict[str, Any]]) -> None: """ Record the state of settings from all previous settings sources. This should be called right before __call__. """ self._settings_sources_data = states @property def current_state(self) -> dict[str, Any]: """ The current state of the settings, populated by the previous settings sources. """ return self._current_state @property def settings_sources_data(self) -> dict[str, dict[str, Any]]: """ The state of all previous settings sources. """ return self._settings_sources_data @abstractmethod def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: """ Gets the value, the key for model creation, and a flag to determine whether value is complex. This is an abstract method that should be overridden in every settings source classes. Args: field: The field. field_name: The field name. Returns: A tuple that contains the value, key and a flag to determine whether value is complex. """ pass def field_is_complex(self, field: FieldInfo) -> bool: """ Checks whether a field is complex, in which case it will attempt to be parsed as JSON. Args: field: The field. Returns: Whether the field is complex. """ return _annotation_is_complex(field.annotation, field.metadata) def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: """ Prepares the value of a field. Args: field_name: The field name. field: The field. value: The value of the field that has to be prepared. value_is_complex: A flag to determine whether value is complex. Returns: The prepared value. """ if value is not None and (self.field_is_complex(field) or value_is_complex): return self.decode_complex_value(field_name, field, value) return value def decode_complex_value(self, field_name: str, field: FieldInfo, value: Any) -> Any: """ Decode the value for a complex field Args: field_name: The field name. field: The field. value: The value of the field that has to be prepared. Returns: The decoded value for further preparation """ if field and ( NoDecode in field.metadata or (self.config.get('enable_decoding') is False and ForceDecode not in field.metadata) ): return value return json.loads(value) @abstractmethod def __call__(self) -> dict[str, Any]: pass class DefaultSettingsSource(PydanticBaseSettingsSource): """ Source class for loading default object values. Args: settings_cls: The Settings class. nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields. Defaults to `False`. """ def __init__(self, settings_cls: type[BaseSettings], nested_model_default_partial_update: bool | None = None): super().__init__(settings_cls) self.defaults: dict[str, Any] = {} self.nested_model_default_partial_update = ( nested_model_default_partial_update if nested_model_default_partial_update is not None else self.config.get('nested_model_default_partial_update', False) ) if self.nested_model_default_partial_update: for field_name, field_info in settings_cls.model_fields.items(): alias_names, *_ = _get_alias_names(field_name, field_info) preferred_alias = alias_names[0] if is_dataclass(type(field_info.default)): self.defaults[preferred_alias] = asdict(field_info.default) elif is_model_class(type(field_info.default)): self.defaults[preferred_alias] = field_info.default.model_dump() def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: # Nothing to do here. Only implement the return statement to make mypy happy return None, '', False def __call__(self) -> dict[str, Any]: return self.defaults def __repr__(self) -> str: return ( f'{self.__class__.__name__}(nested_model_default_partial_update={self.nested_model_default_partial_update})' ) class InitSettingsSource(PydanticBaseSettingsSource): """ Source class for loading values provided during settings class initialization. """ def __init__( self, settings_cls: type[BaseSettings], init_kwargs: dict[str, Any], nested_model_default_partial_update: bool | None = None, ): self.init_kwargs = {} init_kwarg_names = set(init_kwargs.keys()) for field_name, field_info in settings_cls.model_fields.items(): alias_names, *_ = _get_alias_names(field_name, field_info) init_kwarg_name = init_kwarg_names & set(alias_names) if init_kwarg_name: preferred_alias = alias_names[0] init_kwarg_names -= init_kwarg_name self.init_kwargs[preferred_alias] = init_kwargs[init_kwarg_name.pop()] self.init_kwargs.update({key: val for key, val in init_kwargs.items() if key in init_kwarg_names}) super().__init__(settings_cls) self.nested_model_default_partial_update = ( nested_model_default_partial_update if nested_model_default_partial_update is not None else self.config.get('nested_model_default_partial_update', False) ) def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: # Nothing to do here. Only implement the return statement to make mypy happy return None, '', False def __call__(self) -> dict[str, Any]: return ( TypeAdapter(Dict[str, Any]).dump_python(self.init_kwargs) if self.nested_model_default_partial_update else self.init_kwargs ) def __repr__(self) -> str: return f'{self.__class__.__name__}(init_kwargs={self.init_kwargs!r})' class PydanticBaseEnvSettingsSource(PydanticBaseSettingsSource): def __init__( self, settings_cls: type[BaseSettings], case_sensitive: bool | None = None, env_prefix: str | None = None, env_ignore_empty: bool | None = None, env_parse_none_str: str | None = None, env_parse_enums: bool | None = None, ) -> None: super().__init__(settings_cls) self.case_sensitive = case_sensitive if case_sensitive is not None else self.config.get('case_sensitive', False) self.env_prefix = env_prefix if env_prefix is not None else self.config.get('env_prefix', '') self.env_ignore_empty = ( env_ignore_empty if env_ignore_empty is not None else self.config.get('env_ignore_empty', False) ) self.env_parse_none_str = ( env_parse_none_str if env_parse_none_str is not None else self.config.get('env_parse_none_str') ) self.env_parse_enums = env_parse_enums if env_parse_enums is not None else self.config.get('env_parse_enums') def _apply_case_sensitive(self, value: str) -> str: return value.lower() if not self.case_sensitive else value def _extract_field_info(self, field: FieldInfo, field_name: str) -> list[tuple[str, str, bool]]: """ Extracts field info. This info is used to get the value of field from environment variables. It returns a list of tuples, each tuple contains: * field_key: The key of field that has to be used in model creation. * env_name: The environment variable name of the field. * value_is_complex: A flag to determine whether the value from environment variable is complex and has to be parsed. Args: field (FieldInfo): The field. field_name (str): The field name. Returns: list[tuple[str, str, bool]]: List of tuples, each tuple contains field_key, env_name, and value_is_complex. """ field_info: list[tuple[str, str, bool]] = [] if isinstance(field.validation_alias, (AliasChoices, AliasPath)): v_alias: str | list[str | int] | list[list[str | int]] | None = field.validation_alias.convert_to_aliases() else: v_alias = field.validation_alias if v_alias: if isinstance(v_alias, list): # AliasChoices, AliasPath for alias in v_alias: if isinstance(alias, str): # AliasPath field_info.append((alias, self._apply_case_sensitive(alias), True if len(alias) > 1 else False)) elif isinstance(alias, list): # AliasChoices first_arg = cast(str, alias[0]) # first item of an AliasChoices must be a str field_info.append( (first_arg, self._apply_case_sensitive(first_arg), True if len(alias) > 1 else False) ) else: # string validation alias field_info.append((v_alias, self._apply_case_sensitive(v_alias), False)) if not v_alias or self.config.get('populate_by_name', False): if origin_is_union(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata): field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), True)) else: field_info.append((field_name, self._apply_case_sensitive(self.env_prefix + field_name), False)) return field_info def _replace_field_names_case_insensitively(self, field: FieldInfo, field_values: dict[str, Any]) -> dict[str, Any]: """ Replace field names in values dict by looking in models fields insensitively. By having the following models: ```py class SubSubSub(BaseModel): VaL3: str class SubSub(BaseModel): Val2: str SUB_sub_SuB: SubSubSub class Sub(BaseModel): VAL1: str SUB_sub: SubSub class Settings(BaseSettings): nested: Sub model_config = SettingsConfigDict(env_nested_delimiter='__') ``` Then: _replace_field_names_case_insensitively( field, {"val1": "v1", "sub_SUB": {"VAL2": "v2", "sub_SUB_sUb": {"vAl3": "v3"}}} ) Returns {'VAL1': 'v1', 'SUB_sub': {'Val2': 'v2', 'SUB_sub_SuB': {'VaL3': 'v3'}}} """ values: dict[str, Any] = {} for name, value in field_values.items(): sub_model_field: FieldInfo | None = None annotation = field.annotation # If field is Optional, we need to find the actual type args = get_args(annotation) if origin_is_union(get_origin(field.annotation)) and len(args) == 2 and type(None) in args: for arg in args: if arg is not None: annotation = arg break # This is here to make mypy happy # Item "None" of "Optional[Type[Any]]" has no attribute "model_fields" if not annotation or not hasattr(annotation, 'model_fields'): values[name] = value continue # Find field in sub model by looking in fields case insensitively for sub_model_field_name, f in annotation.model_fields.items(): if not f.validation_alias and sub_model_field_name.lower() == name.lower(): sub_model_field = f break if not sub_model_field: values[name] = value continue if lenient_issubclass(sub_model_field.annotation, BaseModel) and isinstance(value, dict): values[sub_model_field_name] = self._replace_field_names_case_insensitively(sub_model_field, value) else: values[sub_model_field_name] = value return values def _replace_env_none_type_values(self, field_value: dict[str, Any]) -> dict[str, Any]: """ Recursively parse values that are of "None" type(EnvNoneType) to `None` type(None). """ values: dict[str, Any] = {} for key, value in field_value.items(): if not isinstance(value, EnvNoneType): values[key] = value if not isinstance(value, dict) else self._replace_env_none_type_values(value) else: values[key] = None return values def _get_resolved_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: """ Gets the value, the preferred alias key for model creation, and a flag to determine whether value is complex. Note: In V3, this method should either be made public, or, this method should be removed and the abstract method get_field_value should be updated to include a "use_preferred_alias" flag. Args: field: The field. field_name: The field name. Returns: A tuple that contains the value, preferred key and a flag to determine whether value is complex. """ field_value, field_key, value_is_complex = self.get_field_value(field, field_name) if not (value_is_complex or (self.config.get('populate_by_name', False) and (field_key == field_name))): field_infos = self._extract_field_info(field, field_name) preferred_key, *_ = field_infos[0] return field_value, preferred_key, value_is_complex return field_value, field_key, value_is_complex def __call__(self) -> dict[str, Any]: data: dict[str, Any] = {} for field_name, field in self.settings_cls.model_fields.items(): try: field_value, field_key, value_is_complex = self._get_resolved_field_value(field, field_name) except Exception as e: raise SettingsError( f'error getting value for field "{field_name}" from source "{self.__class__.__name__}"' ) from e try: field_value = self.prepare_field_value(field_name, field, field_value, value_is_complex) except ValueError as e: raise SettingsError( f'error parsing value for field "{field_name}" from source "{self.__class__.__name__}"' ) from e if field_value is not None: if self.env_parse_none_str is not None: if isinstance(field_value, dict): field_value = self._replace_env_none_type_values(field_value) elif isinstance(field_value, EnvNoneType): field_value = None if ( not self.case_sensitive # and lenient_issubclass(field.annotation, BaseModel) and isinstance(field_value, dict) ): data[field_key] = self._replace_field_names_case_insensitively(field, field_value) else: data[field_key] = field_value return data class SecretsSettingsSource(PydanticBaseEnvSettingsSource): """ Source class for loading settings values from secret files. """ def __init__( self, settings_cls: type[BaseSettings], secrets_dir: PathType | None = None, case_sensitive: bool | None = None, env_prefix: str | None = None, env_ignore_empty: bool | None = None, env_parse_none_str: str | None = None, env_parse_enums: bool | None = None, ) -> None: super().__init__( settings_cls, case_sensitive, env_prefix, env_ignore_empty, env_parse_none_str, env_parse_enums ) self.secrets_dir = secrets_dir if secrets_dir is not None else self.config.get('secrets_dir') def __call__(self) -> dict[str, Any]: """ Build fields from "secrets" files. """ secrets: dict[str, str | None] = {} if self.secrets_dir is None: return secrets secrets_dirs = [self.secrets_dir] if isinstance(self.secrets_dir, (str, os.PathLike)) else self.secrets_dir secrets_paths = [Path(p).expanduser() for p in secrets_dirs] self.secrets_paths = [] for path in secrets_paths: if not path.exists(): warnings.warn(f'directory "{path}" does not exist') else: self.secrets_paths.append(path) if not len(self.secrets_paths): return secrets for path in self.secrets_paths: if not path.is_dir(): raise SettingsError(f'secrets_dir must reference a directory, not a {path_type_label(path)}') return super().__call__() @classmethod def find_case_path(cls, dir_path: Path, file_name: str, case_sensitive: bool) -> Path | None: """ Find a file within path's directory matching filename, optionally ignoring case. Args: dir_path: Directory path. file_name: File name. case_sensitive: Whether to search for file name case sensitively. Returns: Whether file path or `None` if file does not exist in directory. """ for f in dir_path.iterdir(): if f.name == file_name: return f elif not case_sensitive and f.name.lower() == file_name.lower(): return f return None def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: """ Gets the value for field from secret file and a flag to determine whether value is complex. Args: field: The field. field_name: The field name. Returns: A tuple that contains the value (`None` if the file does not exist), key, and a flag to determine whether value is complex. """ for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name): # paths reversed to match the last-wins behaviour of `env_file` for secrets_path in reversed(self.secrets_paths): path = self.find_case_path(secrets_path, env_name, self.case_sensitive) if not path: # path does not exist, we currently don't return a warning for this continue if path.is_file(): return path.read_text().strip(), field_key, value_is_complex else: warnings.warn( f'attempted to load secret file "{path}" but found a {path_type_label(path)} instead.', stacklevel=4, ) return None, field_key, value_is_complex def __repr__(self) -> str: return f'{self.__class__.__name__}(secrets_dir={self.secrets_dir!r})' class EnvSettingsSource(PydanticBaseEnvSettingsSource): """ Source class for loading settings values from environment variables. """ def __init__( self, settings_cls: type[BaseSettings], case_sensitive: bool | None = None, env_prefix: str | None = None, env_nested_delimiter: str | None = None, env_nested_max_split: int | None = None, env_ignore_empty: bool | None = None, env_parse_none_str: str | None = None, env_parse_enums: bool | None = None, ) -> None: super().__init__( settings_cls, case_sensitive, env_prefix, env_ignore_empty, env_parse_none_str, env_parse_enums ) self.env_nested_delimiter = ( env_nested_delimiter if env_nested_delimiter is not None else self.config.get('env_nested_delimiter') ) self.env_nested_max_split = ( env_nested_max_split if env_nested_max_split is not None else self.config.get('env_nested_max_split') ) self.maxsplit = (self.env_nested_max_split or 0) - 1 self.env_prefix_len = len(self.env_prefix) self.env_vars = self._load_env_vars() def _load_env_vars(self) -> Mapping[str, str | None]: return parse_env_vars(os.environ, self.case_sensitive, self.env_ignore_empty, self.env_parse_none_str) def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: """ Gets the value for field from environment variables and a flag to determine whether value is complex. Args: field: The field. field_name: The field name. Returns: A tuple that contains the value (`None` if not found), key, and a flag to determine whether value is complex. """ env_val: str | None = None for field_key, env_name, value_is_complex in self._extract_field_info(field, field_name): env_val = self.env_vars.get(env_name) if env_val is not None: break return env_val, field_key, value_is_complex def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: """ Prepare value for the field. * Extract value for nested field. * Deserialize value to python object for complex field. Args: field: The field. field_name: The field name. Returns: A tuple contains prepared value for the field. Raises: ValuesError: When There is an error in deserializing value for complex field. """ is_complex, allow_parse_failure = self._field_is_complex(field) if self.env_parse_enums: enum_val = _annotation_enum_name_to_val(field.annotation, value) value = value if enum_val is None else enum_val if is_complex or value_is_complex: if isinstance(value, EnvNoneType): return value elif value is None: # field is complex but no value found so far, try explode_env_vars env_val_built = self.explode_env_vars(field_name, field, self.env_vars) if env_val_built: return env_val_built else: # field is complex and there's a value, decode that as JSON, then add explode_env_vars try: value = self.decode_complex_value(field_name, field, value) except ValueError as e: if not allow_parse_failure: raise e if isinstance(value, dict): return deep_update(value, self.explode_env_vars(field_name, field, self.env_vars)) else: return value elif value is not None: # simplest case, field is not complex, we only need to add the value if it was found return value def _field_is_complex(self, field: FieldInfo) -> tuple[bool, bool]: """ Find out if a field is complex, and if so whether JSON errors should be ignored """ if self.field_is_complex(field): allow_parse_failure = False elif origin_is_union(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata): allow_parse_failure = True else: return False, False return True, allow_parse_failure # Default value of `case_sensitive` is `None`, because we don't want to break existing behavior. # We have to change the method to a non-static method and use # `self.case_sensitive` instead in V3. def next_field( self, field: FieldInfo | Any | None, key: str, case_sensitive: bool | None = None ) -> FieldInfo | None: """ Find the field in a sub model by key(env name) By having the following models: ```py class SubSubModel(BaseSettings): dvals: Dict class SubModel(BaseSettings): vals: list[str] sub_sub_model: SubSubModel class Cfg(BaseSettings): sub_model: SubModel ``` Then: next_field(sub_model, 'vals') Returns the `vals` field of `SubModel` class next_field(sub_model, 'sub_sub_model') Returns `sub_sub_model` field of `SubModel` class Args: field: The field. key: The key (env name). case_sensitive: Whether to search for key case sensitively. Returns: Field if it finds the next field otherwise `None`. """ if not field: return None annotation = field.annotation if isinstance(field, FieldInfo) else field if origin_is_union(get_origin(annotation)) or isinstance(annotation, WithArgsTypes): for type_ in get_args(annotation): type_has_key = self.next_field(type_, key, case_sensitive) if type_has_key: return type_has_key elif is_model_class(annotation) or is_pydantic_dataclass(annotation): fields = _get_model_fields(annotation) # `case_sensitive is None` is here to be compatible with the old behavior. # Has to be removed in V3. for field_name, f in fields.items(): for _, env_name, _ in self._extract_field_info(f, field_name): if case_sensitive is None or case_sensitive: if field_name == key or env_name == key: return f elif field_name.lower() == key.lower() or env_name.lower() == key.lower(): return f return None def explode_env_vars(self, field_name: str, field: FieldInfo, env_vars: Mapping[str, str | None]) -> dict[str, Any]: """ Process env_vars and extract the values of keys containing env_nested_delimiter into nested dictionaries. This is applied to a single field, hence filtering by env_var prefix. Args: field_name: The field name. field: The field. env_vars: Environment variables. Returns: A dictionary contains extracted values from nested env values. """ if not self.env_nested_delimiter: return {} is_dict = lenient_issubclass(get_origin(field.annotation), dict) prefixes = [ f'{env_name}{self.env_nested_delimiter}' for _, env_name, _ in self._extract_field_info(field, field_name) ] result: dict[str, Any] = {} for env_name, env_val in env_vars.items(): try: prefix = next(prefix for prefix in prefixes if env_name.startswith(prefix)) except StopIteration: continue # we remove the prefix before splitting in case the prefix has characters in common with the delimiter env_name_without_prefix = env_name[len(prefix) :] *keys, last_key = env_name_without_prefix.split(self.env_nested_delimiter, self.maxsplit) env_var = result target_field: FieldInfo | None = field for key in keys: target_field = self.next_field(target_field, key, self.case_sensitive) if isinstance(env_var, dict): env_var = env_var.setdefault(key, {}) # get proper field with last_key target_field = self.next_field(target_field, last_key, self.case_sensitive) # check if env_val maps to a complex field and if so, parse the env_val if (target_field or is_dict) and env_val: if target_field: is_complex, allow_json_failure = self._field_is_complex(target_field) else: # nested field type is dict is_complex, allow_json_failure = True, True if is_complex: try: env_val = self.decode_complex_value(last_key, target_field, env_val) # type: ignore except ValueError as e: if not allow_json_failure: raise e if isinstance(env_var, dict): if last_key not in env_var or not isinstance(env_val, EnvNoneType) or env_var[last_key] == {}: env_var[last_key] = env_val return result def __repr__(self) -> str: return ( f'{self.__class__.__name__}(env_nested_delimiter={self.env_nested_delimiter!r}, ' f'env_prefix_len={self.env_prefix_len!r})' ) class DotEnvSettingsSource(EnvSettingsSource): """ Source class for loading settings values from env files. """ def __init__( self, settings_cls: type[BaseSettings], env_file: DotenvType | None = ENV_FILE_SENTINEL, env_file_encoding: str | None = None, case_sensitive: bool | None = None, env_prefix: str | None = None, env_nested_delimiter: str | None = None, env_nested_max_split: int | None = None, env_ignore_empty: bool | None = None, env_parse_none_str: str | None = None, env_parse_enums: bool | None = None, ) -> None: self.env_file = env_file if env_file != ENV_FILE_SENTINEL else settings_cls.model_config.get('env_file') self.env_file_encoding = ( env_file_encoding if env_file_encoding is not None else settings_cls.model_config.get('env_file_encoding') ) super().__init__( settings_cls, case_sensitive, env_prefix, env_nested_delimiter, env_nested_max_split, env_ignore_empty, env_parse_none_str, env_parse_enums, ) def _load_env_vars(self) -> Mapping[str, str | None]: return self._read_env_files() @staticmethod def _static_read_env_file( file_path: Path, *, encoding: str | None = None, case_sensitive: bool = False, ignore_empty: bool = False, parse_none_str: str | None = None, ) -> Mapping[str, str | None]: file_vars: dict[str, str | None] = dotenv_values(file_path, encoding=encoding or 'utf8') return parse_env_vars(file_vars, case_sensitive, ignore_empty, parse_none_str) def _read_env_file( self, file_path: Path, ) -> Mapping[str, str | None]: return self._static_read_env_file( file_path, encoding=self.env_file_encoding, case_sensitive=self.case_sensitive, ignore_empty=self.env_ignore_empty, parse_none_str=self.env_parse_none_str, ) def _read_env_files(self) -> Mapping[str, str | None]: env_files = self.env_file if env_files is None: return {} if isinstance(env_files, (str, os.PathLike)): env_files = [env_files] dotenv_vars: dict[str, str | None] = {} for env_file in env_files: env_path = Path(env_file).expanduser() if env_path.is_file(): dotenv_vars.update(self._read_env_file(env_path)) return dotenv_vars def __call__(self) -> dict[str, Any]: data: dict[str, Any] = super().__call__() is_extra_allowed = self.config.get('extra') != 'forbid' # As `extra` config is allowed in dotenv settings source, We have to # update data with extra env variables from dotenv file. for env_name, env_value in self.env_vars.items(): if not env_value or env_name in data: continue env_used = False for field_name, field in self.settings_cls.model_fields.items(): for _, field_env_name, _ in self._extract_field_info(field, field_name): if env_name == field_env_name or ( ( _annotation_is_complex(field.annotation, field.metadata) or ( origin_is_union(get_origin(field.annotation)) and _union_is_complex(field.annotation, field.metadata) ) ) and env_name.startswith(field_env_name) ): env_used = True break if env_used: break if not env_used: if is_extra_allowed and env_name.startswith(self.env_prefix): # env_prefix should be respected and removed from the env_name normalized_env_name = env_name[len(self.env_prefix) :] data[normalized_env_name] = env_value else: data[env_name] = env_value return data def __repr__(self) -> str: return ( f'{self.__class__.__name__}(env_file={self.env_file!r}, env_file_encoding={self.env_file_encoding!r}, ' f'env_nested_delimiter={self.env_nested_delimiter!r}, env_prefix_len={self.env_prefix_len!r})' ) class CliSettingsSource(EnvSettingsSource, Generic[T]): """ Source class for loading settings values from CLI. Note: A `CliSettingsSource` connects with a `root_parser` object by using the parser methods to add `settings_cls` fields as command line arguments. The `CliSettingsSource` internal parser representation is based upon the `argparse` parsing library, and therefore, requires the parser methods to support the same attributes as their `argparse` library counterparts. Args: cli_prog_name: The CLI program name to display in help text. Defaults to `None` if cli_parse_args is `None`. Otherwse, defaults to sys.argv[0]. cli_parse_args: The list of CLI arguments to parse. Defaults to None. If set to `True`, defaults to sys.argv[1:]. cli_parse_none_str: The CLI string value that should be parsed (e.g. "null", "void", "None", etc.) into `None` type(None). Defaults to "null" if cli_avoid_json is `False`, and "None" if cli_avoid_json is `True`. cli_hide_none_type: Hide `None` values in CLI help text. Defaults to `False`. cli_avoid_json: Avoid complex JSON objects in CLI help text. Defaults to `False`. cli_enforce_required: Enforce required fields at the CLI. Defaults to `False`. cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions. Defaults to `False`. cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs. Defaults to `True`. cli_prefix: Prefix for command line arguments added under the root parser. Defaults to "". cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'. cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags. (e.g. --flag, --no-flag). Defaults to `False`. cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`. cli_kebab_case: CLI args use kebab case. Defaults to `False`. case_sensitive: Whether CLI "--arg" names should be read with case-sensitivity. Defaults to `True`. Note: Case-insensitive matching is only supported on the internal root parser and does not apply to CLI subcommands. root_parser: The root parser object. parse_args_method: The root parser parse args method. Defaults to `argparse.ArgumentParser.parse_args`. add_argument_method: The root parser add argument method. Defaults to `argparse.ArgumentParser.add_argument`. add_argument_group_method: The root parser add argument group method. Defaults to `argparse.ArgumentParser.add_argument_group`. add_parser_method: The root parser add new parser (sub-command) method. Defaults to `argparse._SubParsersAction.add_parser`. add_subparsers_method: The root parser add subparsers (sub-commands) method. Defaults to `argparse.ArgumentParser.add_subparsers`. formatter_class: A class for customizing the root parser help text. Defaults to `argparse.RawDescriptionHelpFormatter`. """ def __init__( self, settings_cls: type[BaseSettings], cli_prog_name: str | None = None, cli_parse_args: bool | list[str] | tuple[str, ...] | None = None, cli_parse_none_str: str | None = None, cli_hide_none_type: bool | None = None, cli_avoid_json: bool | None = None, cli_enforce_required: bool | None = None, cli_use_class_docs_for_groups: bool | None = None, cli_exit_on_error: bool | None = None, cli_prefix: str | None = None, cli_flag_prefix_char: str | None = None, cli_implicit_flags: bool | None = None, cli_ignore_unknown_args: bool | None = None, cli_kebab_case: bool | None = None, case_sensitive: bool | None = True, root_parser: Any = None, parse_args_method: Callable[..., Any] | None = None, add_argument_method: Callable[..., Any] | None = ArgumentParser.add_argument, add_argument_group_method: Callable[..., Any] | None = ArgumentParser.add_argument_group, add_parser_method: Callable[..., Any] | None = _SubParsersAction.add_parser, add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers, formatter_class: Any = RawDescriptionHelpFormatter, ) -> None: self.cli_prog_name = ( cli_prog_name if cli_prog_name is not None else settings_cls.model_config.get('cli_prog_name', sys.argv[0]) ) self.cli_hide_none_type = ( cli_hide_none_type if cli_hide_none_type is not None else settings_cls.model_config.get('cli_hide_none_type', False) ) self.cli_avoid_json = ( cli_avoid_json if cli_avoid_json is not None else settings_cls.model_config.get('cli_avoid_json', False) ) if not cli_parse_none_str: cli_parse_none_str = 'None' if self.cli_avoid_json is True else 'null' self.cli_parse_none_str = cli_parse_none_str self.cli_enforce_required = ( cli_enforce_required if cli_enforce_required is not None else settings_cls.model_config.get('cli_enforce_required', False) ) self.cli_use_class_docs_for_groups = ( cli_use_class_docs_for_groups if cli_use_class_docs_for_groups is not None else settings_cls.model_config.get('cli_use_class_docs_for_groups', False) ) self.cli_exit_on_error = ( cli_exit_on_error if cli_exit_on_error is not None else settings_cls.model_config.get('cli_exit_on_error', True) ) self.cli_prefix = cli_prefix if cli_prefix is not None else settings_cls.model_config.get('cli_prefix', '') self.cli_flag_prefix_char = ( cli_flag_prefix_char if cli_flag_prefix_char is not None else settings_cls.model_config.get('cli_flag_prefix_char', '-') ) self._cli_flag_prefix = self.cli_flag_prefix_char * 2 if self.cli_prefix: if cli_prefix.startswith('.') or cli_prefix.endswith('.') or not cli_prefix.replace('.', '').isidentifier(): # type: ignore raise SettingsError(f'CLI settings source prefix is invalid: {cli_prefix}') self.cli_prefix += '.' self.cli_implicit_flags = ( cli_implicit_flags if cli_implicit_flags is not None else settings_cls.model_config.get('cli_implicit_flags', False) ) self.cli_ignore_unknown_args = ( cli_ignore_unknown_args if cli_ignore_unknown_args is not None else settings_cls.model_config.get('cli_ignore_unknown_args', False) ) self.cli_kebab_case = ( cli_kebab_case if cli_kebab_case is not None else settings_cls.model_config.get('cli_kebab_case', False) ) case_sensitive = case_sensitive if case_sensitive is not None else True if not case_sensitive and root_parser is not None: raise SettingsError('Case-insensitive matching is only supported on the internal root parser') super().__init__( settings_cls, env_nested_delimiter='.', env_parse_none_str=self.cli_parse_none_str, env_parse_enums=True, env_prefix=self.cli_prefix, case_sensitive=case_sensitive, ) root_parser = ( _CliInternalArgParser( cli_exit_on_error=self.cli_exit_on_error, prog=self.cli_prog_name, description=None if settings_cls.__doc__ is None else dedent(settings_cls.__doc__), formatter_class=formatter_class, prefix_chars=self.cli_flag_prefix_char, allow_abbrev=False, ) if root_parser is None else root_parser ) self._connect_root_parser( root_parser=root_parser, parse_args_method=parse_args_method, add_argument_method=add_argument_method, add_argument_group_method=add_argument_group_method, add_parser_method=add_parser_method, add_subparsers_method=add_subparsers_method, formatter_class=formatter_class, ) if cli_parse_args not in (None, False): if cli_parse_args is True: cli_parse_args = sys.argv[1:] elif not isinstance(cli_parse_args, (list, tuple)): raise SettingsError( f'cli_parse_args must be List[str] or Tuple[str, ...], recieved {type(cli_parse_args)}' ) self._load_env_vars(parsed_args=self._parse_args(self.root_parser, cli_parse_args)) @overload def __call__(self) -> dict[str, Any]: ... @overload def __call__(self, *, args: list[str] | tuple[str, ...] | bool) -> CliSettingsSource[T]: """ Parse and load the command line arguments list into the CLI settings source. Args: args: The command line arguments to parse and load. Defaults to `None`, which means do not parse command line arguments. If set to `True`, defaults to sys.argv[1:]. If set to `False`, does not parse command line arguments. Returns: CliSettingsSource: The object instance itself. """ ... @overload def __call__(self, *, parsed_args: Namespace | SimpleNamespace | dict[str, Any]) -> CliSettingsSource[T]: """ Loads parsed command line arguments into the CLI settings source. Note: The parsed args must be in `argparse.Namespace`, `SimpleNamespace`, or vars dictionary (e.g., vars(argparse.Namespace)) format. Args: parsed_args: The parsed args to load. Returns: CliSettingsSource: The object instance itself. """ ... def __call__( self, *, args: list[str] | tuple[str, ...] | bool | None = None, parsed_args: Namespace | SimpleNamespace | dict[str, list[str] | str] | None = None, ) -> dict[str, Any] | CliSettingsSource[T]: if args is not None and parsed_args is not None: raise SettingsError('`args` and `parsed_args` are mutually exclusive') elif args is not None: if args is False: return self._load_env_vars(parsed_args={}) if args is True: args = sys.argv[1:] return self._load_env_vars(parsed_args=self._parse_args(self.root_parser, args)) elif parsed_args is not None: return self._load_env_vars(parsed_args=parsed_args) else: return super().__call__() @overload def _load_env_vars(self) -> Mapping[str, str | None]: ... @overload def _load_env_vars(self, *, parsed_args: Namespace | SimpleNamespace | dict[str, Any]) -> CliSettingsSource[T]: """ Loads the parsed command line arguments into the CLI environment settings variables. Note: The parsed args must be in `argparse.Namespace`, `SimpleNamespace`, or vars dictionary (e.g., vars(argparse.Namespace)) format. Args: parsed_args: The parsed args to load. Returns: CliSettingsSource: The object instance itself. """ ... def _load_env_vars( self, *, parsed_args: Namespace | SimpleNamespace | dict[str, list[str] | str] | None = None ) -> Mapping[str, str | None] | CliSettingsSource[T]: if parsed_args is None: return {} if isinstance(parsed_args, (Namespace, SimpleNamespace)): parsed_args = vars(parsed_args) selected_subcommands: list[str] = [] for field_name, val in parsed_args.items(): if isinstance(val, list): parsed_args[field_name] = self._merge_parsed_list(val, field_name) elif field_name.endswith(':subcommand') and val is not None: subcommand_name = field_name.split(':')[0] + val subcommand_dest = self._cli_subcommands[field_name][subcommand_name] selected_subcommands.append(subcommand_dest) for subcommands in self._cli_subcommands.values(): for subcommand_dest in subcommands.values(): if subcommand_dest not in selected_subcommands: parsed_args[subcommand_dest] = self.cli_parse_none_str parsed_args = { key: val for key, val in parsed_args.items() if not key.endswith(':subcommand') and val is not PydanticUndefined } if selected_subcommands: last_selected_subcommand = max(selected_subcommands, key=len) if not any(field_name for field_name in parsed_args.keys() if f'{last_selected_subcommand}.' in field_name): parsed_args[last_selected_subcommand] = '{}' self.env_vars = parse_env_vars( cast(Mapping[str, str], parsed_args), self.case_sensitive, self.env_ignore_empty, self.cli_parse_none_str, ) return self def _get_merge_parsed_list_types( self, parsed_list: list[str], field_name: str ) -> tuple[Optional[type], Optional[type]]: merge_type = self._cli_dict_args.get(field_name, list) if ( merge_type is list or not origin_is_union(get_origin(merge_type)) or not any( type_ for type_ in get_args(merge_type) if type_ is not type(None) and get_origin(type_) not in (dict, Mapping) ) ): inferred_type = merge_type else: inferred_type = list if parsed_list and (len(parsed_list) > 1 or parsed_list[0].startswith('[')) else str return merge_type, inferred_type def _merge_parsed_list(self, parsed_list: list[str], field_name: str) -> str: try: merged_list: list[str] = [] is_last_consumed_a_value = False merge_type, inferred_type = self._get_merge_parsed_list_types(parsed_list, field_name) for val in parsed_list: if not isinstance(val, str): # If val is not a string, it's from an external parser and we can ignore parsing the rest of the # list. break val = val.strip() if val.startswith('[') and val.endswith(']'): val = val[1:-1].strip() while val: val = val.strip() if val.startswith(','): val = self._consume_comma(val, merged_list, is_last_consumed_a_value) is_last_consumed_a_value = False else: if val.startswith('{') or val.startswith('['): val = self._consume_object_or_array(val, merged_list) else: try: val = self._consume_string_or_number(val, merged_list, merge_type) except ValueError as e: if merge_type is inferred_type: raise e merge_type = inferred_type val = self._consume_string_or_number(val, merged_list, merge_type) is_last_consumed_a_value = True if not is_last_consumed_a_value: val = self._consume_comma(val, merged_list, is_last_consumed_a_value) if merge_type is str: return merged_list[0] elif merge_type is list: return f'[{",".join(merged_list)}]' else: merged_dict: dict[str, str] = {} for item in merged_list: merged_dict.update(json.loads(item)) return json.dumps(merged_dict) except Exception as e: raise SettingsError(f'Parsing error encountered for {field_name}: {e}') def _consume_comma(self, item: str, merged_list: list[str], is_last_consumed_a_value: bool) -> str: if not is_last_consumed_a_value: merged_list.append('""') return item[1:] def _consume_object_or_array(self, item: str, merged_list: list[str]) -> str: count = 1 close_delim = '}' if item.startswith('{') else ']' for consumed in range(1, len(item)): if item[consumed] in ('{', '['): count += 1 elif item[consumed] in ('}', ']'): count -= 1 if item[consumed] == close_delim and count == 0: merged_list.append(item[: consumed + 1]) return item[consumed + 1 :] raise SettingsError(f'Missing end delimiter "{close_delim}"') def _consume_string_or_number(self, item: str, merged_list: list[str], merge_type: type[Any] | None) -> str: consumed = 0 if merge_type is not str else len(item) is_find_end_quote = False while consumed < len(item): if item[consumed] == '"' and (consumed == 0 or item[consumed - 1] != '\\'): is_find_end_quote = not is_find_end_quote if not is_find_end_quote and item[consumed] == ',': break consumed += 1 if is_find_end_quote: raise SettingsError('Mismatched quotes') val_string = item[:consumed].strip() if merge_type in (list, str): try: float(val_string) except ValueError: if val_string == self.cli_parse_none_str: val_string = 'null' if val_string not in ('true', 'false', 'null') and not val_string.startswith('"'): val_string = f'"{val_string}"' merged_list.append(val_string) else: key, val = (kv for kv in val_string.split('=', 1)) if key.startswith('"') and not key.endswith('"') and not val.startswith('"') and val.endswith('"'): raise ValueError(f'Dictionary key=val parameter is a quoted string: {val_string}') key, val = key.strip('"'), val.strip('"') merged_list.append(json.dumps({key: val})) return item[consumed:] def _get_sub_models(self, model: type[BaseModel], field_name: str, field_info: FieldInfo) -> list[type[BaseModel]]: field_types: tuple[Any, ...] = ( (field_info.annotation,) if not get_args(field_info.annotation) else get_args(field_info.annotation) ) if self.cli_hide_none_type: field_types = tuple([type_ for type_ in field_types if type_ is not type(None)]) sub_models: list[type[BaseModel]] = [] for type_ in field_types: if _annotation_contains_types(type_, (_CliSubCommand,), is_include_origin=False): raise SettingsError(f'CliSubCommand is not outermost annotation for {model.__name__}.{field_name}') elif _annotation_contains_types(type_, (_CliPositionalArg,), is_include_origin=False): raise SettingsError(f'CliPositionalArg is not outermost annotation for {model.__name__}.{field_name}') if is_model_class(_strip_annotated(type_)) or is_pydantic_dataclass(_strip_annotated(type_)): sub_models.append(_strip_annotated(type_)) return sub_models def _verify_cli_flag_annotations(self, model: type[BaseModel], field_name: str, field_info: FieldInfo) -> None: if _CliImplicitFlag in field_info.metadata: cli_flag_name = 'CliImplicitFlag' elif _CliExplicitFlag in field_info.metadata: cli_flag_name = 'CliExplicitFlag' else: return if field_info.annotation is not bool: raise SettingsError(f'{cli_flag_name} argument {model.__name__}.{field_name} is not of type bool') elif sys.version_info < (3, 9) and ( field_info.default is PydanticUndefined and field_info.default_factory is None ): raise SettingsError( f'{cli_flag_name} argument {model.__name__}.{field_name} must have default for python versions < 3.9' ) def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]]: positional_variadic_arg = [] positional_args, subcommand_args, optional_args = [], [], [] for field_name, field_info in _get_model_fields(model).items(): if _CliSubCommand in field_info.metadata: if not field_info.is_required(): raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has a default value') else: alias_names, *_ = _get_alias_names(field_name, field_info) if len(alias_names) > 1: raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has multiple aliases') field_types = [type_ for type_ in get_args(field_info.annotation) if type_ is not type(None)] for field_type in field_types: if not (is_model_class(field_type) or is_pydantic_dataclass(field_type)): raise SettingsError( f'subcommand argument {model.__name__}.{field_name} has type not derived from BaseModel' ) subcommand_args.append((field_name, field_info)) elif _CliPositionalArg in field_info.metadata: alias_names, *_ = _get_alias_names(field_name, field_info) if len(alias_names) > 1: raise SettingsError(f'positional argument {model.__name__}.{field_name} has multiple aliases') is_append_action = _annotation_contains_types( field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True ) if not is_append_action: positional_args.append((field_name, field_info)) else: positional_variadic_arg.append((field_name, field_info)) else: self._verify_cli_flag_annotations(model, field_name, field_info) optional_args.append((field_name, field_info)) if positional_variadic_arg: if len(positional_variadic_arg) > 1: field_names = ', '.join([name for name, info in positional_variadic_arg]) raise SettingsError(f'{model.__name__} has multiple variadic positonal arguments: {field_names}') elif subcommand_args: field_names = ', '.join([name for name, info in positional_variadic_arg + subcommand_args]) raise SettingsError( f'{model.__name__} has variadic positonal arguments and subcommand arguments: {field_names}' ) return positional_args + positional_variadic_arg + subcommand_args + optional_args @property def root_parser(self) -> T: """The connected root parser instance.""" return self._root_parser def _connect_parser_method( self, parser_method: Callable[..., Any] | None, method_name: str, *args: Any, **kwargs: Any ) -> Callable[..., Any]: if ( parser_method is not None and self.case_sensitive is False and method_name == 'parse_args_method' and isinstance(self._root_parser, _CliInternalArgParser) ): def parse_args_insensitive_method( root_parser: _CliInternalArgParser, args: list[str] | tuple[str, ...] | None = None, namespace: Namespace | None = None, ) -> Any: insensitive_args = [] for arg in shlex.split(shlex.join(args)) if args else []: flag_prefix = rf'\{self.cli_flag_prefix_char}{{1,2}}' matched = re.match(rf'^({flag_prefix}[^\s=]+)(.*)', arg) if matched: arg = matched.group(1).lower() + matched.group(2) insensitive_args.append(arg) return parser_method(root_parser, insensitive_args, namespace) # type: ignore return parse_args_insensitive_method elif parser_method is None: def none_parser_method(*args: Any, **kwargs: Any) -> Any: raise SettingsError( f'cannot connect CLI settings source root parser: {method_name} is set to `None` but is needed for connecting' ) return none_parser_method else: return parser_method def _connect_group_method(self, add_argument_group_method: Callable[..., Any] | None) -> Callable[..., Any]: add_argument_group = self._connect_parser_method(add_argument_group_method, 'add_argument_group_method') def add_group_method(parser: Any, **kwargs: Any) -> Any: if not kwargs.pop('_is_cli_mutually_exclusive_group'): kwargs.pop('required') return add_argument_group(parser, **kwargs) else: main_group_kwargs = {arg: kwargs.pop(arg) for arg in ['title', 'description'] if arg in kwargs} main_group_kwargs['title'] += ' (mutually exclusive)' group = add_argument_group(parser, **main_group_kwargs) if not hasattr(group, 'add_mutually_exclusive_group'): raise SettingsError( 'cannot connect CLI settings source root parser: ' 'group object is missing add_mutually_exclusive_group but is needed for connecting' ) return group.add_mutually_exclusive_group(**kwargs) return add_group_method def _connect_root_parser( self, root_parser: T, parse_args_method: Callable[..., Any] | None, add_argument_method: Callable[..., Any] | None = ArgumentParser.add_argument, add_argument_group_method: Callable[..., Any] | None = ArgumentParser.add_argument_group, add_parser_method: Callable[..., Any] | None = _SubParsersAction.add_parser, add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers, formatter_class: Any = RawDescriptionHelpFormatter, ) -> None: def _parse_known_args(*args: Any, **kwargs: Any) -> Namespace: return ArgumentParser.parse_known_args(*args, **kwargs)[0] self._root_parser = root_parser if parse_args_method is None: parse_args_method = _parse_known_args if self.cli_ignore_unknown_args else ArgumentParser.parse_args self._parse_args = self._connect_parser_method(parse_args_method, 'parse_args_method') self._add_argument = self._connect_parser_method(add_argument_method, 'add_argument_method') self._add_group = self._connect_group_method(add_argument_group_method) self._add_parser = self._connect_parser_method(add_parser_method, 'add_parser_method') self._add_subparsers = self._connect_parser_method(add_subparsers_method, 'add_subparsers_method') self._formatter_class = formatter_class self._cli_dict_args: dict[str, type[Any] | None] = {} self._cli_subcommands: defaultdict[str, dict[str, str]] = defaultdict(dict) self._add_parser_args( parser=self.root_parser, model=self.settings_cls, added_args=[], arg_prefix=self.env_prefix, subcommand_prefix=self.env_prefix, group=None, alias_prefixes=[], model_default=PydanticUndefined, ) def _add_parser_args( self, parser: Any, model: type[BaseModel], added_args: list[str], arg_prefix: str, subcommand_prefix: str, group: Any, alias_prefixes: list[str], model_default: Any, ) -> ArgumentParser: subparsers: Any = None alias_path_args: dict[str, str] = {} # Ignore model default if the default is a model and not a subclass of the current model. model_default = ( None if ( (is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default))) and not issubclass(type(model_default), model) ) else model_default ) for field_name, field_info in self._sort_arg_fields(model): sub_models: list[type[BaseModel]] = self._get_sub_models(model, field_name, field_info) alias_names, is_alias_path_only = _get_alias_names( field_name, field_info, alias_path_args=alias_path_args, case_sensitive=self.case_sensitive ) preferred_alias = alias_names[0] if _CliSubCommand in field_info.metadata: for model in sub_models: subcommand_alias = self._check_kebab_name( model.__name__ if len(sub_models) > 1 else preferred_alias ) subcommand_name = f'{arg_prefix}{subcommand_alias}' subcommand_dest = f'{arg_prefix}{preferred_alias}' self._cli_subcommands[f'{arg_prefix}:subcommand'][subcommand_name] = subcommand_dest subcommand_help = None if len(sub_models) > 1 else field_info.description if self.cli_use_class_docs_for_groups: subcommand_help = None if model.__doc__ is None else dedent(model.__doc__) subparsers = ( self._add_subparsers( parser, title='subcommands', dest=f'{arg_prefix}:subcommand', description=field_info.description if len(sub_models) > 1 else None, ) if subparsers is None else subparsers ) if hasattr(subparsers, 'metavar'): subparsers.metavar = ( f'{subparsers.metavar[:-1]},{subcommand_alias}}}' if subparsers.metavar else f'{{{subcommand_alias}}}' ) self._add_parser_args( parser=self._add_parser( subparsers, subcommand_alias, help=subcommand_help, formatter_class=self._formatter_class, description=None if model.__doc__ is None else dedent(model.__doc__), ), model=model, added_args=[], arg_prefix=f'{arg_prefix}{preferred_alias}.', subcommand_prefix=f'{subcommand_prefix}{preferred_alias}.', group=None, alias_prefixes=[], model_default=PydanticUndefined, ) else: flag_prefix: str = self._cli_flag_prefix is_append_action = _annotation_contains_types( field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True ) is_parser_submodel = sub_models and not is_append_action kwargs: dict[str, Any] = {} kwargs['default'] = CLI_SUPPRESS kwargs['help'] = self._help_format(field_name, field_info, model_default) kwargs['metavar'] = self._metavar_format(field_info.annotation) kwargs['required'] = ( self.cli_enforce_required and field_info.is_required() and model_default is PydanticUndefined ) kwargs['dest'] = ( # Strip prefix if validation alias is set and value is not complex. # Related https://github.com/pydantic/pydantic-settings/pull/25 f'{arg_prefix}{preferred_alias}'[self.env_prefix_len :] if arg_prefix and field_info.validation_alias is not None and not is_parser_submodel else f'{arg_prefix}{preferred_alias}' ) arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, alias_names, added_args) if not arg_names or (kwargs['dest'] in added_args): continue if is_append_action: kwargs['action'] = 'append' if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True): self._cli_dict_args[kwargs['dest']] = field_info.annotation if _CliPositionalArg in field_info.metadata: arg_names, flag_prefix = self._convert_positional_arg( kwargs, field_info, preferred_alias, model_default ) self._convert_bool_flag(kwargs, field_info, model_default) if is_parser_submodel: self._add_parser_submodels( parser, model, sub_models, added_args, arg_prefix, subcommand_prefix, flag_prefix, arg_names, kwargs, field_name, field_info, alias_names, model_default=model_default, ) elif not is_alias_path_only: if group is not None: if isinstance(group, dict): group = self._add_group(parser, **group) added_args += list(arg_names) self._add_argument(group, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs) else: added_args += list(arg_names) self._add_argument( parser, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs ) self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group) return parser def _check_kebab_name(self, name: str) -> str: if self.cli_kebab_case: return name.replace('_', '-') return name def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None: if kwargs['metavar'] == 'bool': default = None if field_info.default is not PydanticUndefined: default = field_info.default if model_default is not PydanticUndefined: default = model_default if sys.version_info >= (3, 9) or isinstance(default, bool): if (self.cli_implicit_flags or _CliImplicitFlag in field_info.metadata) and ( _CliExplicitFlag not in field_info.metadata ): del kwargs['metavar'] kwargs['action'] = ( BooleanOptionalAction if sys.version_info >= (3, 9) else f'store_{str(not default).lower()}' ) def _convert_positional_arg( self, kwargs: dict[str, Any], field_info: FieldInfo, preferred_alias: str, model_default: Any ) -> tuple[list[str], str]: flag_prefix = '' arg_names = [kwargs['dest']] kwargs['default'] = PydanticUndefined kwargs['metavar'] = self._check_kebab_name(preferred_alias.upper()) # Note: CLI positional args are always strictly required at the CLI. Therefore, use field_info.is_required in # conjunction with model_default instead of the derived kwargs['required']. is_required = field_info.is_required() and model_default is PydanticUndefined if kwargs.get('action') == 'append': del kwargs['action'] kwargs['nargs'] = '+' if is_required else '*' elif not is_required: kwargs['nargs'] = '?' del kwargs['dest'] del kwargs['required'] return arg_names, flag_prefix def _get_arg_names( self, arg_prefix: str, subcommand_prefix: str, alias_prefixes: list[str], alias_names: tuple[str, ...], added_args: list[str], ) -> list[str]: arg_names: list[str] = [] for prefix in [arg_prefix] + alias_prefixes: for name in alias_names: arg_name = self._check_kebab_name( f'{prefix}{name}' if subcommand_prefix == self.env_prefix else f'{prefix.replace(subcommand_prefix, "", 1)}{name}' ) if arg_name not in added_args: arg_names.append(arg_name) return arg_names def _add_parser_submodels( self, parser: Any, model: type[BaseModel], sub_models: list[type[BaseModel]], added_args: list[str], arg_prefix: str, subcommand_prefix: str, flag_prefix: str, arg_names: list[str], kwargs: dict[str, Any], field_name: str, field_info: FieldInfo, alias_names: tuple[str, ...], model_default: Any, ) -> None: if issubclass(model, CliMutuallyExclusiveGroup): # Argparse has deprecated "calling add_argument_group() or add_mutually_exclusive_group() on a # mutually exclusive group" (https://docs.python.org/3/library/argparse.html#mutual-exclusion). # Since nested models result in a group add, raise an exception for nested models in a mutually # exclusive group. raise SettingsError('cannot have nested models in a CliMutuallyExclusiveGroup') model_group: Any = None model_group_kwargs: dict[str, Any] = {} model_group_kwargs['title'] = f'{arg_names[0]} options' model_group_kwargs['description'] = field_info.description model_group_kwargs['required'] = kwargs['required'] model_group_kwargs['_is_cli_mutually_exclusive_group'] = any( issubclass(model, CliMutuallyExclusiveGroup) for model in sub_models ) if model_group_kwargs['_is_cli_mutually_exclusive_group'] and len(sub_models) > 1: raise SettingsError('cannot use union with CliMutuallyExclusiveGroup') if self.cli_use_class_docs_for_groups and len(sub_models) == 1: model_group_kwargs['description'] = None if sub_models[0].__doc__ is None else dedent(sub_models[0].__doc__) if model_default is not PydanticUndefined: if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)): model_default = getattr(model_default, field_name) else: if field_info.default is not PydanticUndefined: model_default = field_info.default elif field_info.default_factory is not None: model_default = field_info.default_factory if model_default is None: desc_header = f'default: {self.cli_parse_none_str} (undefined)' if model_group_kwargs['description'] is not None: model_group_kwargs['description'] = dedent(f'{desc_header}\n{model_group_kwargs["description"]}') else: model_group_kwargs['description'] = desc_header preferred_alias = alias_names[0] if not self.cli_avoid_json: added_args.append(arg_names[0]) kwargs['help'] = f'set {arg_names[0]} from JSON string' model_group = self._add_group(parser, **model_group_kwargs) self._add_argument(model_group, *(f'{flag_prefix}{name}' for name in arg_names), **kwargs) for model in sub_models: self._add_parser_args( parser=parser, model=model, added_args=added_args, arg_prefix=f'{arg_prefix}{preferred_alias}.', subcommand_prefix=subcommand_prefix, group=model_group if model_group else model_group_kwargs, alias_prefixes=[f'{arg_prefix}{name}.' for name in alias_names[1:]], model_default=model_default, ) def _add_parser_alias_paths( self, parser: Any, alias_path_args: dict[str, str], added_args: list[str], arg_prefix: str, subcommand_prefix: str, group: Any, ) -> None: if alias_path_args: context = parser if group is not None: context = self._add_group(parser, **group) if isinstance(group, dict) else group is_nested_alias_path = arg_prefix.endswith('.') arg_prefix = arg_prefix[:-1] if is_nested_alias_path else arg_prefix for name, metavar in alias_path_args.items(): name = '' if is_nested_alias_path else name arg_name = ( f'{arg_prefix}{name}' if subcommand_prefix == self.env_prefix else f'{arg_prefix.replace(subcommand_prefix, "", 1)}{name}' ) kwargs: dict[str, Any] = {} kwargs['default'] = CLI_SUPPRESS kwargs['help'] = 'pydantic alias path' kwargs['dest'] = f'{arg_prefix}{name}' if metavar == 'dict' or is_nested_alias_path: kwargs['metavar'] = 'dict' else: kwargs['action'] = 'append' kwargs['metavar'] = 'list' if arg_name not in added_args: added_args.append(arg_name) self._add_argument(context, f'{self._cli_flag_prefix}{arg_name}', **kwargs) def _get_modified_args(self, obj: Any) -> tuple[str, ...]: if not self.cli_hide_none_type: return get_args(obj) else: return tuple([type_ for type_ in get_args(obj) if type_ is not type(None)]) def _metavar_format_choices(self, args: list[str], obj_qualname: str | None = None) -> str: if 'JSON' in args: args = args[: args.index('JSON') + 1] + [arg for arg in args[args.index('JSON') + 1 :] if arg != 'JSON'] metavar = ','.join(args) if obj_qualname: return f'{obj_qualname}[{metavar}]' else: return metavar if len(args) == 1 else f'{{{metavar}}}' def _metavar_format_recurse(self, obj: Any) -> str: """Pretty metavar representation of a type. Adapts logic from `pydantic._repr.display_as_type`.""" obj = _strip_annotated(obj) if _is_function(obj): # If function is locally defined use __name__ instead of __qualname__ return obj.__name__ if '' in obj.__qualname__ else obj.__qualname__ elif obj is ...: return '...' elif isinstance(obj, Representation): return repr(obj) elif isinstance(obj, typing_extensions.TypeAliasType): return str(obj) if not isinstance(obj, (typing_base, WithArgsTypes, type)): obj = obj.__class__ if origin_is_union(get_origin(obj)): return self._metavar_format_choices(list(map(self._metavar_format_recurse, self._get_modified_args(obj)))) elif get_origin(obj) in (typing_extensions.Literal, typing.Literal): return self._metavar_format_choices(list(map(str, self._get_modified_args(obj)))) elif lenient_issubclass(obj, Enum): return self._metavar_format_choices([val.name for val in obj]) elif isinstance(obj, WithArgsTypes): return self._metavar_format_choices( list(map(self._metavar_format_recurse, self._get_modified_args(obj))), obj_qualname=obj.__qualname__ if hasattr(obj, '__qualname__') else str(obj), ) elif obj is type(None): return self.cli_parse_none_str elif is_model_class(obj): return 'JSON' elif isinstance(obj, type): return obj.__qualname__ else: return repr(obj).replace('typing.', '').replace('typing_extensions.', '') def _metavar_format(self, obj: Any) -> str: return self._metavar_format_recurse(obj).replace(', ', ',') def _help_format(self, field_name: str, field_info: FieldInfo, model_default: Any) -> str: _help = field_info.description if field_info.description else '' if _help == CLI_SUPPRESS or CLI_SUPPRESS in field_info.metadata: return CLI_SUPPRESS if field_info.is_required() and model_default in (PydanticUndefined, None): if _CliPositionalArg not in field_info.metadata: ifdef = 'ifdef: ' if model_default is None else '' _help += f' ({ifdef}required)' if _help else f'({ifdef}required)' else: default = f'(default: {self.cli_parse_none_str})' if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)): default = f'(default: {getattr(model_default, field_name)})' elif model_default not in (PydanticUndefined, None) and _is_function(model_default): default = f'(default factory: {self._metavar_format(model_default)})' elif field_info.default not in (PydanticUndefined, None): enum_name = _annotation_enum_val_to_name(field_info.annotation, field_info.default) default = f'(default: {field_info.default if enum_name is None else enum_name})' elif field_info.default_factory is not None: default = f'(default factory: {self._metavar_format(field_info.default_factory)})' _help += f' {default}' if _help else default return _help.replace('%', '%%') if issubclass(type(self._root_parser), ArgumentParser) else _help class ConfigFileSourceMixin(ABC): def _read_files(self, files: PathType | None) -> dict[str, Any]: if files is None: return {} if isinstance(files, (str, os.PathLike)): files = [files] vars: dict[str, Any] = {} for file in files: file_path = Path(file).expanduser() if file_path.is_file(): vars.update(self._read_file(file_path)) return vars @abstractmethod def _read_file(self, path: Path) -> dict[str, Any]: pass class JsonConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): """ A source class that loads variables from a JSON file """ def __init__( self, settings_cls: type[BaseSettings], json_file: PathType | None = DEFAULT_PATH, json_file_encoding: str | None = None, ): self.json_file_path = json_file if json_file != DEFAULT_PATH else settings_cls.model_config.get('json_file') self.json_file_encoding = ( json_file_encoding if json_file_encoding is not None else settings_cls.model_config.get('json_file_encoding') ) self.json_data = self._read_files(self.json_file_path) super().__init__(settings_cls, self.json_data) def _read_file(self, file_path: Path) -> dict[str, Any]: with open(file_path, encoding=self.json_file_encoding) as json_file: return json.load(json_file) def __repr__(self) -> str: return f'{self.__class__.__name__}(json_file={self.json_file_path})' class TomlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): """ A source class that loads variables from a TOML file """ def __init__( self, settings_cls: type[BaseSettings], toml_file: PathType | None = DEFAULT_PATH, ): self.toml_file_path = toml_file if toml_file != DEFAULT_PATH else settings_cls.model_config.get('toml_file') self.toml_data = self._read_files(self.toml_file_path) super().__init__(settings_cls, self.toml_data) def _read_file(self, file_path: Path) -> dict[str, Any]: import_toml() with open(file_path, mode='rb') as toml_file: if sys.version_info < (3, 11): return tomli.load(toml_file) return tomllib.load(toml_file) def __repr__(self) -> str: return f'{self.__class__.__name__}(toml_file={self.toml_file_path})' class PyprojectTomlConfigSettingsSource(TomlConfigSettingsSource): """ A source class that loads variables from a `pyproject.toml` file. """ def __init__( self, settings_cls: type[BaseSettings], toml_file: Path | None = None, ) -> None: self.toml_file_path = self._pick_pyproject_toml_file( toml_file, settings_cls.model_config.get('pyproject_toml_depth', 0) ) self.toml_table_header: tuple[str, ...] = settings_cls.model_config.get( 'pyproject_toml_table_header', ('tool', 'pydantic-settings') ) self.toml_data = self._read_files(self.toml_file_path) for key in self.toml_table_header: self.toml_data = self.toml_data.get(key, {}) super(TomlConfigSettingsSource, self).__init__(settings_cls, self.toml_data) @staticmethod def _pick_pyproject_toml_file(provided: Path | None, depth: int) -> Path: """Pick a `pyproject.toml` file path to use. Args: provided: Explicit path provided when instantiating this class. depth: Number of directories up the tree to check of a pyproject.toml. """ if provided: return provided.resolve() rv = Path.cwd() / 'pyproject.toml' count = 0 if not rv.is_file(): child = rv.parent.parent / 'pyproject.toml' while count < depth: if child.is_file(): return child if str(child.parent) == rv.root: break # end discovery after checking system root once child = child.parent.parent / 'pyproject.toml' count += 1 return rv class YamlConfigSettingsSource(InitSettingsSource, ConfigFileSourceMixin): """ A source class that loads variables from a yaml file """ def __init__( self, settings_cls: type[BaseSettings], yaml_file: PathType | None = DEFAULT_PATH, yaml_file_encoding: str | None = None, ): self.yaml_file_path = yaml_file if yaml_file != DEFAULT_PATH else settings_cls.model_config.get('yaml_file') self.yaml_file_encoding = ( yaml_file_encoding if yaml_file_encoding is not None else settings_cls.model_config.get('yaml_file_encoding') ) self.yaml_data = self._read_files(self.yaml_file_path) super().__init__(settings_cls, self.yaml_data) def _read_file(self, file_path: Path) -> dict[str, Any]: import_yaml() with open(file_path, encoding=self.yaml_file_encoding) as yaml_file: return yaml.safe_load(yaml_file) or {} def __repr__(self) -> str: return f'{self.__class__.__name__}(yaml_file={self.yaml_file_path})' class AzureKeyVaultMapping(Mapping[str, Optional[str]]): _loaded_secrets: dict[str, str | None] _secret_client: SecretClient # type: ignore _secret_names: list[str] def __init__( self, secret_client: SecretClient, # type: ignore ) -> None: self._loaded_secrets = {} self._secret_client = secret_client self._secret_names: list[str] = [secret.name for secret in self._secret_client.list_properties_of_secrets()] def __getitem__(self, key: str) -> str | None: if key not in self._loaded_secrets: try: self._loaded_secrets[key] = self._secret_client.get_secret(key).value except Exception: raise KeyError(key) return self._loaded_secrets[key] def __len__(self) -> int: return len(self._secret_names) def __iter__(self) -> Iterator[str]: return iter(self._secret_names) class AzureKeyVaultSettingsSource(EnvSettingsSource): _url: str _credential: TokenCredential # type: ignore _secret_client: SecretClient # type: ignore def __init__( self, settings_cls: type[BaseSettings], url: str, credential: TokenCredential, # type: ignore env_prefix: str | None = None, env_parse_none_str: str | None = None, env_parse_enums: bool | None = None, ) -> None: import_azure_key_vault() self._url = url self._credential = credential super().__init__( settings_cls, case_sensitive=True, env_prefix=env_prefix, env_nested_delimiter='--', env_ignore_empty=False, env_parse_none_str=env_parse_none_str, env_parse_enums=env_parse_enums, ) def _load_env_vars(self) -> Mapping[str, Optional[str]]: secret_client = SecretClient(vault_url=self._url, credential=self._credential) # type: ignore return AzureKeyVaultMapping(secret_client) def __repr__(self) -> str: return f'{self.__class__.__name__}(url={self._url!r}, ' f'env_nested_delimiter={self.env_nested_delimiter!r})' def _get_env_var_key(key: str, case_sensitive: bool = False) -> str: return key if case_sensitive else key.lower() def _parse_env_none_str(value: str | None, parse_none_str: str | None = None) -> str | None | EnvNoneType: return value if not (value == parse_none_str and parse_none_str is not None) else EnvNoneType(value) def parse_env_vars( env_vars: Mapping[str, str | None], case_sensitive: bool = False, ignore_empty: bool = False, parse_none_str: str | None = None, ) -> Mapping[str, str | None]: return { _get_env_var_key(k, case_sensitive): _parse_env_none_str(v, parse_none_str) for k, v in env_vars.items() if not (ignore_empty and v == '') } def read_env_file( file_path: Path, *, encoding: str | None = None, case_sensitive: bool = False, ignore_empty: bool = False, parse_none_str: str | None = None, ) -> Mapping[str, str | None]: warnings.warn( 'read_env_file will be removed in the next version, use DotEnvSettingsSource._static_read_env_file if you must', DeprecationWarning, ) return DotEnvSettingsSource._static_read_env_file( file_path, encoding=encoding, case_sensitive=case_sensitive, ignore_empty=ignore_empty, parse_none_str=parse_none_str, ) def _annotation_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool: # If the model is a root model, the root annotation should be used to # evaluate the complexity. try: if annotation is not None and issubclass(annotation, RootModel): # In some rare cases (see test_root_model_as_field), # the root attribute is not available. For these cases, python 3.8 and 3.9 # return 'RootModelRootType'. root_annotation = annotation.__annotations__.get('root', None) if root_annotation is not None and root_annotation != 'RootModelRootType': annotation = root_annotation except TypeError: pass if any(isinstance(md, Json) for md in metadata): # type: ignore[misc] return False # Check if annotation is of the form Annotated[type, metadata]. if isinstance(annotation, _AnnotatedAlias): # Return result of recursive call on inner type. inner, *meta = get_args(annotation) return _annotation_is_complex(inner, meta) origin = get_origin(annotation) if origin is Secret: return False return ( _annotation_is_complex_inner(annotation) or _annotation_is_complex_inner(origin) or hasattr(origin, '__pydantic_core_schema__') or hasattr(origin, '__get_pydantic_core_schema__') ) def _annotation_is_complex_inner(annotation: type[Any] | None) -> bool: if lenient_issubclass(annotation, (str, bytes)): return False return lenient_issubclass(annotation, (BaseModel, Mapping, Sequence, tuple, set, frozenset, deque)) or is_dataclass( annotation ) def _union_is_complex(annotation: type[Any] | None, metadata: list[Any]) -> bool: return any(_annotation_is_complex(arg, metadata) for arg in get_args(annotation)) def _annotation_contains_types( annotation: type[Any] | None, types: tuple[Any, ...], is_include_origin: bool = True, is_strip_annotated: bool = False, ) -> bool: if is_strip_annotated: annotation = _strip_annotated(annotation) if is_include_origin is True and get_origin(annotation) in types: return True for type_ in get_args(annotation): if _annotation_contains_types(type_, types, is_include_origin=True, is_strip_annotated=is_strip_annotated): return True return annotation in types def _strip_annotated(annotation: Any) -> Any: while get_origin(annotation) == Annotated: annotation = get_args(annotation)[0] return annotation def _annotation_enum_val_to_name(annotation: type[Any] | None, value: Any) -> Optional[str]: for type_ in (annotation, get_origin(annotation), *get_args(annotation)): if lenient_issubclass(type_, Enum): if value in tuple(val.value for val in type_): return type_(value).name return None def _annotation_enum_name_to_val(annotation: type[Any] | None, name: Any) -> Any: for type_ in (annotation, get_origin(annotation), *get_args(annotation)): if lenient_issubclass(type_, Enum): if name in tuple(val.name for val in type_): return type_[name] return None def _get_model_fields(model_cls: type[Any]) -> dict[str, FieldInfo]: if is_pydantic_dataclass(model_cls) and hasattr(model_cls, '__pydantic_fields__'): return model_cls.__pydantic_fields__ if is_model_class(model_cls): return model_cls.model_fields raise SettingsError(f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass') def _get_alias_names( field_name: str, field_info: FieldInfo, alias_path_args: dict[str, str] = {}, case_sensitive: bool = True ) -> tuple[tuple[str, ...], bool]: alias_names: list[str] = [] is_alias_path_only: bool = True if not any((field_info.alias, field_info.validation_alias)): alias_names += [field_name] is_alias_path_only = False else: new_alias_paths: list[AliasPath] = [] for alias in (field_info.alias, field_info.validation_alias): if alias is None: continue elif isinstance(alias, str): alias_names.append(alias) is_alias_path_only = False elif isinstance(alias, AliasChoices): for name in alias.choices: if isinstance(name, str): alias_names.append(name) is_alias_path_only = False else: new_alias_paths.append(name) else: new_alias_paths.append(alias) for alias_path in new_alias_paths: name = cast(str, alias_path.path[0]) name = name.lower() if not case_sensitive else name alias_path_args[name] = 'dict' if len(alias_path.path) > 2 else 'list' if not alias_names and is_alias_path_only: alias_names.append(name) if not case_sensitive: alias_names = [alias_name.lower() for alias_name in alias_names] return tuple(dict.fromkeys(alias_names)), is_alias_path_only def _is_function(obj: Any) -> bool: return isinstance(obj, (FunctionType, BuiltinFunctionType)) pydantic-pydantic-settings-5f33b62/pydantic_settings/utils.py000066400000000000000000000010741476003445400246060ustar00rootroot00000000000000from pathlib import Path _PATH_TYPE_LABELS = { Path.is_dir: 'directory', Path.is_file: 'file', Path.is_mount: 'mount point', Path.is_symlink: 'symlink', Path.is_block_device: 'block device', Path.is_char_device: 'char device', Path.is_fifo: 'FIFO', Path.is_socket: 'socket', } def path_type_label(p: Path) -> str: """ Find out what sort of thing a path is. """ assert p.exists(), 'path does not exist' for method, name in _PATH_TYPE_LABELS.items(): if method(p): return name return 'unknown' pydantic-pydantic-settings-5f33b62/pydantic_settings/version.py000066400000000000000000000000221476003445400251230ustar00rootroot00000000000000VERSION = '2.8.1' pydantic-pydantic-settings-5f33b62/pyproject.toml000066400000000000000000000075751476003445400222710ustar00rootroot00000000000000[build-system] requires = ['hatchling'] build-backend = 'hatchling.build' [tool.hatch.version] path = 'pydantic_settings/version.py' [project] name = 'pydantic-settings' description = 'Settings management using Pydantic' authors = [ {name = 'Samuel Colvin', email = 's@muelcolvin.com'}, {name = 'Eric Jolibois', email = 'em.jolibois@gmail.com'}, {name = 'Hasan Ramezani', email = 'hasan.r67@gmail.com'}, ] license = 'MIT' readme = 'README.md' classifiers = [ 'Development Status :: 5 - Production/Stable', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Framework :: Pydantic', 'Framework :: Pydantic :: 2', 'Operating System :: Unix', 'Operating System :: POSIX :: Linux', 'Environment :: Console', 'Environment :: MacOS X', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Internet', ] requires-python = '>=3.8' dependencies = [ 'pydantic>=2.7.0', 'python-dotenv>=0.21.0', ] dynamic = ['version'] [project.optional-dependencies] yaml = ["pyyaml>=6.0.1"] toml = ["tomli>=2.0.1"] azure-key-vault = ["azure-keyvault-secrets>=4.8.0", "azure-identity>=1.16.0"] [project.urls] Homepage = 'https://github.com/pydantic/pydantic-settings' Funding = 'https://github.com/sponsors/samuelcolvin' Source = 'https://github.com/pydantic/pydantic-settings' Changelog = 'https://github.com/pydantic/pydantic-settings/releases' Documentation = 'https://docs.pydantic.dev/dev-v2/concepts/pydantic_settings/' [tool.pytest.ini_options] testpaths = 'tests' filterwarnings = [ 'error', 'ignore:This is a placeholder until pydantic-settings.*:UserWarning', ] [tool.coverage.run] source = ['pydantic_settings'] branch = true context = '${CONTEXT}' [tool.coverage.report] precision = 2 exclude_lines = [ 'pragma: no cover', 'raise NotImplementedError', 'raise NotImplemented', 'if TYPE_CHECKING:', '@overload', ] [tool.coverage.paths] source = [ 'pydantic_settings/', ] [tool.ruff] line-length = 120 target-version = 'py38' [tool.ruff.lint.pyupgrade] keep-runtime-typing = true [tool.ruff.lint] extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I'] flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} isort = { known-first-party = ['pydantic_settings', 'tests'] } mccabe = { max-complexity = 14 } pydocstyle = { convention = 'google' } [tool.ruff.format] quote-style = 'single' [tool.mypy] python_version = '3.10' show_error_codes = true follow_imports = 'silent' strict_optional = true warn_redundant_casts = true warn_unused_ignores = true disallow_any_generics = true check_untyped_defs = true no_implicit_reexport = true warn_unused_configs = true disallow_subclassing_any = true disallow_incomplete_defs = true disallow_untyped_decorators = true disallow_untyped_calls = true # for strict mypy: (this is the tricky one :-)) disallow_untyped_defs = true # remaining arguments from `mypy --strict` which cause errors # no_implicit_optional = true # warn_return_any = true # ansi2html and devtools are required to avoid the need to install these packages when running linting, # they're used in the docs build script [[tool.mypy.overrides]] module = [ 'dotenv.*', ] ignore_missing_imports = true # configuring https://github.com/pydantic/hooky [tool.hooky] assignees = ['samuelcolvin', 'dmontagu', 'hramezani'] reviewers = ['samuelcolvin', 'dmontagu', 'hramezani'] require_change_file = false pydantic-pydantic-settings-5f33b62/requirements/000077500000000000000000000000001476003445400220625ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/requirements/all.txt000066400000000000000000000000651476003445400233740ustar00rootroot00000000000000-r ./pyproject.txt -r ./linting.txt -r ./testing.txt pydantic-pydantic-settings-5f33b62/requirements/linting.in000066400000000000000000000001001476003445400240450ustar00rootroot00000000000000black ruff pyupgrade mypy types-PyYAML pyyaml==6.0.1 pre-commit pydantic-pydantic-settings-5f33b62/requirements/linting.txt000066400000000000000000000022451476003445400242720ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # pip-compile --no-emit-index-url --output-file=requirements/linting.txt requirements/linting.in # black==24.8.0 # via -r requirements/linting.in cfgv==3.4.0 # via pre-commit click==8.1.7 # via black distlib==0.3.8 # via virtualenv filelock==3.16.0 # via virtualenv identify==2.6.0 # via pre-commit mypy==1.11.2 # via -r requirements/linting.in mypy-extensions==1.0.0 # via # black # mypy nodeenv==1.9.1 # via pre-commit packaging==24.1 # via black pathspec==0.12.1 # via black platformdirs==4.3.2 # via # black # virtualenv pre-commit==3.5.0 # via -r requirements/linting.in pyupgrade==3.16.0 # via -r requirements/linting.in pyyaml==6.0.1 # via # -r requirements/linting.in # pre-commit ruff==0.6.4 # via -r requirements/linting.in tokenize-rt==6.0.0 # via pyupgrade tomli==2.0.1 # via # black # mypy types-pyyaml==6.0.12.20240808 # via -r requirements/linting.in typing-extensions==4.12.2 # via # black # mypy virtualenv==20.26.4 # via pre-commit pydantic-pydantic-settings-5f33b62/requirements/pyproject.txt000066400000000000000000000031521476003445400246430ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # pip-compile --extra=azure-key-vault --extra=toml --extra=yaml --no-emit-index-url --output-file=requirements/pyproject.txt pyproject.toml # annotated-types==0.7.0 # via pydantic azure-core==1.30.2 # via # azure-identity # azure-keyvault-secrets azure-identity==1.17.1 # via pydantic-settings (pyproject.toml) azure-keyvault-secrets==4.8.0 # via pydantic-settings (pyproject.toml) certifi==2024.8.30 # via requests cffi==1.17.1 # via cryptography charset-normalizer==3.3.2 # via requests cryptography==43.0.1 # via # azure-identity # msal # pyjwt idna==3.8 # via requests isodate==0.6.1 # via azure-keyvault-secrets msal==1.31.0 # via # azure-identity # msal-extensions msal-extensions==1.2.0 # via azure-identity portalocker==2.10.1 # via msal-extensions pycparser==2.22 # via cffi pydantic==2.9.1 # via pydantic-settings (pyproject.toml) pydantic-core==2.23.3 # via pydantic pyjwt[crypto]==2.9.0 # via # msal # pyjwt python-dotenv==1.0.1 # via pydantic-settings (pyproject.toml) pyyaml==6.0.1 # via pydantic-settings (pyproject.toml) requests==2.32.3 # via # azure-core # msal six==1.16.0 # via # azure-core # isodate tomli==2.0.1 # via pydantic-settings (pyproject.toml) typing-extensions==4.12.2 # via # annotated-types # azure-core # azure-identity # azure-keyvault-secrets # pydantic # pydantic-core urllib3==2.2.2 # via requests pydantic-pydantic-settings-5f33b62/requirements/testing.in000066400000000000000000000001001476003445400240560ustar00rootroot00000000000000coverage[toml] pytest pytest-mock pytest-examples pytest-pretty pydantic-pydantic-settings-5f33b62/requirements/testing.txt000066400000000000000000000022641476003445400243040ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.8 # by the following command: # # pip-compile --no-emit-index-url --output-file=requirements/testing.txt requirements/testing.in # black==24.8.0 # via pytest-examples click==8.1.7 # via black coverage[toml]==7.6.1 # via -r requirements/testing.in exceptiongroup==1.2.2 # via pytest iniconfig==2.0.0 # via pytest markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py mypy-extensions==1.0.0 # via black packaging==24.1 # via # black # pytest pathspec==0.12.1 # via black platformdirs==4.3.2 # via black pluggy==1.5.0 # via pytest pygments==2.18.0 # via rich pytest==8.3.3 # via # -r requirements/testing.in # pytest-examples # pytest-mock # pytest-pretty pytest-examples==0.0.13 # via -r requirements/testing.in pytest-mock==3.14.0 # via -r requirements/testing.in pytest-pretty==1.2.0 # via -r requirements/testing.in rich==13.8.0 # via pytest-pretty ruff==0.6.4 # via pytest-examples tomli==2.0.1 # via # black # coverage # pytest typing-extensions==4.12.2 # via # black # rich pydantic-pydantic-settings-5f33b62/tests/000077500000000000000000000000001476003445400205015ustar00rootroot00000000000000pydantic-pydantic-settings-5f33b62/tests/conftest.py000066400000000000000000000040751476003445400227060ustar00rootroot00000000000000from __future__ import annotations import os from pathlib import Path from typing import TYPE_CHECKING import pytest if TYPE_CHECKING: from collections.abc import Iterator class SetEnv: def __init__(self): self.envars = set() def set(self, name, value): self.envars.add(name) os.environ[name] = value def pop(self, name): self.envars.remove(name) os.environ.pop(name) def clear(self): for n in self.envars: os.environ.pop(n) @pytest.fixture def cd_tmp_path(tmp_path: Path) -> Iterator[Path]: """Change directory into the value of the ``tmp_path`` fixture. .. rubric:: Example .. code-block:: python from typing import TYPE_CHECKING if TYPE_CHECKING: from pathlib import Path def test_something(cd_tmp_path: Path) -> None: ... Returns: Value of the :fixture:`tmp_path` fixture (a :class:`~pathlib.Path` object). """ prev_dir = Path.cwd() os.chdir(tmp_path) try: yield tmp_path finally: os.chdir(prev_dir) @pytest.fixture def env(): setenv = SetEnv() yield setenv setenv.clear() @pytest.fixture def docs_test_env(): setenv = SetEnv() # envs for basic usage example setenv.set('my_auth_key', 'xxx') setenv.set('my_api_key', 'xxx') # envs for parsing environment variable values example setenv.set('V0', '0') setenv.set('SUB_MODEL', '{"v1": "json-1", "v2": "json-2"}') setenv.set('SUB_MODEL__V2', 'nested-2') setenv.set('SUB_MODEL__V3', '3') setenv.set('SUB_MODEL__DEEP__V4', 'v4') # envs for parsing environment variable values example with env_nested_max_split=1 setenv.set('GENERATION_LLM_PROVIDER', 'anthropic') setenv.set('GENERATION_LLM_API_KEY', 'your-api-key') setenv.set('GENERATION_LLM_API_VERSION', '2024-03-15') yield setenv setenv.clear() @pytest.fixture def cli_test_env(): setenv = SetEnv() # envs for reproducible cli tests setenv.set('COLUMNS', '80') yield setenv setenv.clear() pydantic-pydantic-settings-5f33b62/tests/example_test_config.json000066400000000000000000000000231476003445400254060ustar00rootroot00000000000000{"foobar": "test"} pydantic-pydantic-settings-5f33b62/tests/test_docs.py000066400000000000000000000077111476003445400230500ustar00rootroot00000000000000from __future__ import annotations as _annotations import platform import re import sys from pathlib import Path import pytest from pytest_examples import CodeExample, EvalExample, find_examples from pytest_examples.config import ExamplesConfig from pytest_examples.lint import black_format DOCS_ROOT = Path(__file__).parent.parent / 'docs' def skip_docs_tests(): if sys.platform not in {'linux', 'darwin'}: return 'not in linux or macos' if platform.python_implementation() != 'CPython': return 'not cpython' class GroupModuleGlobals: def __init__(self) -> None: self.name = None self.module_dict: dict[str, str] = {} def get(self, name: str | None): if name is not None and name == self.name: return self.module_dict def set(self, name: str | None, module_dict: dict[str, str]): self.name = name if self.name is None: self.module_dict = None else: self.module_dict = module_dict group_globals = GroupModuleGlobals() skip_reason = skip_docs_tests() def print_callback(print_statement: str) -> str: # make error display uniform s = re.sub(r'(https://errors.pydantic.dev)/.+?/', r'\1/2/', print_statement) # hack until https://github.com/pydantic/pytest-examples/issues/11 is fixed if '' in s: # avoid function repr breaking black formatting s = re.sub('', 'math.cos', s) return black_format(s, ExamplesConfig()).rstrip('\n') return s @pytest.mark.filterwarnings('ignore:(parse_obj_as|schema_json_of|schema_of) is deprecated.*:DeprecationWarning') @pytest.mark.skipif(bool(skip_reason), reason=skip_reason or 'not skipping') @pytest.mark.parametrize('example', find_examples(str(DOCS_ROOT), skip=sys.platform == 'win32'), ids=str) def test_docs_examples( # noqa C901 example: CodeExample, eval_example: EvalExample, tmp_path: Path, mocker, docs_test_env ): eval_example.print_callback = print_callback prefix_settings = example.prefix_settings() test_settings = prefix_settings.get('test') lint_settings = prefix_settings.get('lint') if test_settings == 'skip' and lint_settings == 'skip': pytest.skip('both test and lint skipped') requires_settings = prefix_settings.get('requires') if requires_settings: major, minor = map(int, requires_settings.split('.')) if sys.version_info < (major, minor): pytest.skip(f'requires python {requires_settings}') group_name = prefix_settings.get('group') if '# ignore-above' in example.source: eval_example.set_config(ruff_ignore=['E402']) if group_name: eval_example.set_config(ruff_ignore=['F821']) # eval_example.set_config(line_length=120) if lint_settings != 'skip': if eval_example.update_examples: eval_example.format(example) else: eval_example.lint(example) if test_settings == 'skip': return group_name = prefix_settings.get('group') d = group_globals.get(group_name) xfail = None if test_settings and test_settings.startswith('xfail'): xfail = test_settings[5:].lstrip(' -') rewrite_assertions = prefix_settings.get('rewrite_assert', 'true') == 'true' try: if test_settings == 'no-print-intercept': d2 = eval_example.run(example, module_globals=d, rewrite_assertions=rewrite_assertions) elif eval_example.update_examples: d2 = eval_example.run_print_update(example, module_globals=d, rewrite_assertions=rewrite_assertions) else: d2 = eval_example.run_print_check(example, module_globals=d, rewrite_assertions=rewrite_assertions) except BaseException as e: # run_print_check raises a BaseException if xfail: pytest.xfail(f'{xfail}, {type(e).__name__}: {e}') raise else: if xfail: pytest.fail('expected xfail') group_globals.set(group_name, d2) pydantic-pydantic-settings-5f33b62/tests/test_settings.py000066400000000000000000002532351476003445400237640ustar00rootroot00000000000000import dataclasses import json import os import pathlib import sys import uuid from datetime import date, datetime, timezone from enum import IntEnum from pathlib import Path from typing import Any, Callable, Dict, Generic, Hashable, List, Optional, Set, Tuple, Type, TypeVar, Union from unittest import mock import pytest from annotated_types import MinLen from pydantic import ( AliasChoices, AliasGenerator, AliasPath, BaseModel, Discriminator, Field, HttpUrl, Json, PostgresDsn, RootModel, Secret, SecretStr, Tag, ValidationError, field_validator, ) from pydantic import ( dataclasses as pydantic_dataclasses, ) from pydantic.fields import FieldInfo from typing_extensions import Annotated, Literal, override from pydantic_settings import ( BaseSettings, DotEnvSettingsSource, EnvSettingsSource, ForceDecode, InitSettingsSource, NoDecode, PydanticBaseSettingsSource, SecretsSettingsSource, SettingsConfigDict, ) from pydantic_settings.sources import DefaultSettingsSource, SettingsError try: import dotenv except ImportError: dotenv = None class FruitsEnum(IntEnum): pear = 0 kiwi = 1 lime = 2 class SimpleSettings(BaseSettings): apple: str class SettingWithIgnoreEmpty(BaseSettings): apple: str = 'default' model_config = SettingsConfigDict(env_ignore_empty=True) class SettingWithPopulateByName(BaseSettings): apple: str = Field('default', alias='pomo') model_config = SettingsConfigDict(populate_by_name=True) @pytest.fixture(autouse=True) def clean_env(): with mock.patch.dict(os.environ, clear=True): yield def test_sub_env(env): env.set('apple', 'hello') s = SimpleSettings() assert s.apple == 'hello' def test_sub_env_override(env): env.set('apple', 'hello') s = SimpleSettings(apple='goodbye') assert s.apple == 'goodbye' def test_sub_env_missing(): with pytest.raises(ValidationError) as exc_info: SimpleSettings() assert exc_info.value.errors(include_url=False) == [ {'type': 'missing', 'loc': ('apple',), 'msg': 'Field required', 'input': {}} ] def test_other_setting(): with pytest.raises(ValidationError): SimpleSettings(apple='a', foobar=42) def test_ignore_empty_when_empty_uses_default(env): env.set('apple', '') s = SettingWithIgnoreEmpty() assert s.apple == 'default' def test_ignore_empty_when_not_empty_uses_value(env): env.set('apple', 'a') s = SettingWithIgnoreEmpty() assert s.apple == 'a' def test_ignore_empty_with_dotenv_when_empty_uses_default(tmp_path): p = tmp_path / '.env' p.write_text('a=') class Settings(BaseSettings): a: str = 'default' model_config = SettingsConfigDict(env_file=p, env_ignore_empty=True) s = Settings() assert s.a == 'default' def test_ignore_empty_with_dotenv_when_not_empty_uses_value(tmp_path): p = tmp_path / '.env' p.write_text('a=b') class Settings(BaseSettings): a: str = 'default' model_config = SettingsConfigDict(env_file=p, env_ignore_empty=True) s = Settings() assert s.a == 'b' def test_populate_by_name_when_using_alias(env): env.set('pomo', 'bongusta') s = SettingWithPopulateByName() assert s.apple == 'bongusta' def test_populate_by_name_when_using_name(env): env.set('apple', 'honeycrisp') s = SettingWithPopulateByName() assert s.apple == 'honeycrisp' def test_populate_by_name_when_using_both(env): env.set('apple', 'honeycrisp') env.set('pomo', 'bongusta') s = SettingWithPopulateByName() assert s.apple == 'bongusta', 'Expected alias value to be prioritized.' def test_populate_by_name_with_alias_path_when_using_alias(env): env.set('fruits', '["empire", "honeycrisp"]') class Settings(BaseSettings): apple: str = Field('default', validation_alias=AliasPath('fruits', 0)) model_config = SettingsConfigDict(populate_by_name=True) s = Settings() assert s.apple == 'empire' def test_populate_by_name_with_alias_path_when_using_name(env): env.set('apple', 'jonathan gold') class Settings(BaseSettings): apple: str = Field('default', validation_alias=AliasPath('fruits', 0)) model_config = SettingsConfigDict(populate_by_name=True) s = Settings() assert s.apple == 'jonathan gold' @pytest.mark.parametrize( 'env_vars, expected_value', [ pytest.param({'pomo': 'pomo-chosen'}, 'pomo-chosen', id='pomo'), pytest.param({'pomme': 'pomme-chosen'}, 'pomme-chosen', id='pomme'), pytest.param({'manzano': 'manzano-chosen'}, 'manzano-chosen', id='manzano'), pytest.param( {'pomo': 'pomo-chosen', 'pomme': 'pomme-chosen', 'manzano': 'manzano-chosen'}, 'pomo-chosen', id='pomo-priority', ), pytest.param({'pomme': 'pomme-chosen', 'manzano': 'manzano-chosen'}, 'pomme-chosen', id='pomme-priority'), ], ) def test_populate_by_name_with_alias_choices_when_using_alias(env, env_vars: Dict[str, str], expected_value: str): for k, v in env_vars.items(): env.set(k, v) class Settings(BaseSettings): apple: str = Field('default', validation_alias=AliasChoices('pomo', 'pomme', 'manzano')) model_config = SettingsConfigDict(populate_by_name=True) s = Settings() assert s.apple == expected_value def test_populate_by_name_with_dotenv_when_using_alias(tmp_path): p = tmp_path / '.env' p.write_text('pomo=bongusta') class Settings(BaseSettings): apple: str = Field('default', alias='pomo') model_config = SettingsConfigDict(env_file=p, populate_by_name=True) s = Settings() assert s.apple == 'bongusta' def test_populate_by_name_with_dotenv_when_using_name(tmp_path): p = tmp_path / '.env' p.write_text('apple=honeycrisp') class Settings(BaseSettings): apple: str = Field('default', alias='pomo') model_config = SettingsConfigDict(env_file=p, populate_by_name=True) s = Settings() assert s.apple == 'honeycrisp' def test_populate_by_name_with_dotenv_when_using_both(tmp_path): p = tmp_path / '.env' p.write_text('apple=honeycrisp') p.write_text('pomo=bongusta') class Settings(BaseSettings): apple: str = Field('default', alias='pomo') model_config = SettingsConfigDict(env_file=p, populate_by_name=True) s = Settings() assert s.apple == 'bongusta', 'Expected alias value to be prioritized.' def test_with_prefix(env): class Settings(BaseSettings): apple: str model_config = SettingsConfigDict(env_prefix='foobar_') with pytest.raises(ValidationError): Settings() env.set('foobar_apple', 'has_prefix') s = Settings() assert s.apple == 'has_prefix' def test_nested_env_with_basemodel(env): class TopValue(BaseModel): apple: str banana: str class Settings(BaseSettings): top: TopValue with pytest.raises(ValidationError): Settings() env.set('top', '{"banana": "secret_value"}') s = Settings(top={'apple': 'value'}) assert s.top.apple == 'value' assert s.top.banana == 'secret_value' def test_merge_dict(env): class Settings(BaseSettings): top: Dict[str, str] with pytest.raises(ValidationError): Settings() env.set('top', '{"banana": "secret_value"}') s = Settings(top={'apple': 'value'}) assert s.top == {'apple': 'value', 'banana': 'secret_value'} def test_nested_env_delimiter(env): class SubSubValue(BaseSettings): v6: str class SubValue(BaseSettings): v4: str v5: int sub_sub: SubSubValue class TopValue(BaseSettings): v1: str v2: str v3: str sub: SubValue class Cfg(BaseSettings): v0: str v0_union: Union[SubValue, int] top: TopValue model_config = SettingsConfigDict(env_nested_delimiter='__') env.set('top', '{"v1": "json-1", "v2": "json-2", "sub": {"v5": "xx"}}') env.set('top__sub__v5', '5') env.set('v0', '0') env.set('top__v2', '2') env.set('top__v3', '3') env.set('v0_union', '0') env.set('top__sub__sub_sub__v6', '6') env.set('top__sub__v4', '4') cfg = Cfg() assert cfg.model_dump() == { 'v0': '0', 'v0_union': 0, 'top': { 'v1': 'json-1', 'v2': '2', 'v3': '3', 'sub': {'v4': '4', 'v5': 5, 'sub_sub': {'v6': '6'}}, }, } def test_nested_env_optional_json(env): class Child(BaseModel): num_list: Optional[List[int]] = None class Cfg(BaseSettings, env_nested_delimiter='__'): child: Optional[Child] = None env.set('CHILD__NUM_LIST', '[1,2,3]') cfg = Cfg() assert cfg.model_dump() == { 'child': { 'num_list': [1, 2, 3], }, } def test_nested_env_delimiter_with_prefix(env): class Subsettings(BaseSettings): banana: str class Settings(BaseSettings): subsettings: Subsettings model_config = SettingsConfigDict(env_nested_delimiter='_', env_prefix='myprefix_') env.set('myprefix_subsettings_banana', 'banana') s = Settings() assert s.subsettings.banana == 'banana' class Settings(BaseSettings): subsettings: Subsettings model_config = SettingsConfigDict(env_nested_delimiter='_', env_prefix='myprefix__') env.set('myprefix__subsettings_banana', 'banana') s = Settings() assert s.subsettings.banana == 'banana' def test_nested_env_delimiter_complex_required(env): class Cfg(BaseSettings): v: str = 'default' model_config = SettingsConfigDict(env_nested_delimiter='__') env.set('v__x', 'x') env.set('v__y', 'y') cfg = Cfg() assert cfg.model_dump() == {'v': 'default'} def test_nested_env_delimiter_aliases(env): class SubModel(BaseModel): v1: str v2: str class Cfg(BaseSettings): sub_model: SubModel = Field(validation_alias=AliasChoices('foo', 'bar')) model_config = SettingsConfigDict(env_nested_delimiter='__') env.set('foo__v1', '-1-') env.set('bar__v2', '-2-') assert Cfg().model_dump() == {'sub_model': {'v1': '-1-', 'v2': '-2-'}} @pytest.mark.parametrize('env_prefix', [None, 'prefix_', 'prefix__']) def test_nested_env_max_split(env, env_prefix): class Person(BaseModel): sex: Literal['M', 'F'] first_name: str date_of_birth: date class Cfg(BaseSettings): caregiver: Person significant_other: Optional[Person] = None next_of_kin: Optional[Person] = None model_config = SettingsConfigDict(env_nested_delimiter='_', env_nested_max_split=1) if env_prefix is not None: model_config['env_prefix'] = env_prefix env_prefix = env_prefix or '' env.set(env_prefix + 'caregiver_sex', 'M') env.set(env_prefix + 'caregiver_first_name', 'Joe') env.set(env_prefix + 'caregiver_date_of_birth', '1975-09-12') env.set(env_prefix + 'significant_other_sex', 'F') env.set(env_prefix + 'significant_other_first_name', 'Jill') env.set(env_prefix + 'significant_other_date_of_birth', '1998-04-19') env.set(env_prefix + 'next_of_kin_sex', 'M') env.set(env_prefix + 'next_of_kin_first_name', 'Jack') env.set(env_prefix + 'next_of_kin_date_of_birth', '1999-04-19') assert Cfg().model_dump() == { 'caregiver': {'sex': 'M', 'first_name': 'Joe', 'date_of_birth': date(1975, 9, 12)}, 'significant_other': {'sex': 'F', 'first_name': 'Jill', 'date_of_birth': date(1998, 4, 19)}, 'next_of_kin': {'sex': 'M', 'first_name': 'Jack', 'date_of_birth': date(1999, 4, 19)}, } class DateModel(BaseModel): pips: bool = False class ComplexSettings(BaseSettings): apples: List[str] = [] bananas: Set[int] = set() carrots: dict = {} date: DateModel = DateModel() def test_list(env): env.set('apples', '["russet", "granny smith"]') s = ComplexSettings() assert s.apples == ['russet', 'granny smith'] assert s.date.pips is False def test_annotated_list(env): class AnnotatedComplexSettings(BaseSettings): apples: Annotated[List[str], MinLen(2)] = [] env.set('apples', '["russet", "granny smith"]') s = AnnotatedComplexSettings() assert s.apples == ['russet', 'granny smith'] env.set('apples', '["russet"]') with pytest.raises(ValidationError) as exc_info: AnnotatedComplexSettings() assert exc_info.value.errors(include_url=False) == [ { 'ctx': {'actual_length': 1, 'field_type': 'List', 'min_length': 2}, 'input': ['russet'], 'loc': ('apples',), 'msg': 'List should have at least 2 items after validation, not 1', 'type': 'too_short', } ] def test_set_dict_model(env): env.set('bananas', '[1, 2, 3, 3]') env.set('CARROTS', '{"a": null, "b": 4}') env.set('daTE', '{"pips": true}') s = ComplexSettings() assert s.bananas == {1, 2, 3} assert s.carrots == {'a': None, 'b': 4} assert s.date.pips is True def test_invalid_json(env): env.set('apples', '["russet", "granny smith",]') with pytest.raises(SettingsError, match='error parsing value for field "apples" from source "EnvSettingsSource"'): ComplexSettings() def test_required_sub_model(env): class Settings(BaseSettings): foobar: DateModel with pytest.raises(ValidationError): Settings() env.set('FOOBAR', '{"pips": "TRUE"}') s = Settings() assert s.foobar.pips is True def test_non_class(env): class Settings(BaseSettings): foobar: Optional[str] env.set('FOOBAR', 'xxx') s = Settings() assert s.foobar == 'xxx' @pytest.mark.parametrize('dataclass_decorator', (pydantic_dataclasses.dataclass, dataclasses.dataclass)) def test_generic_dataclass(env, dataclass_decorator): T = TypeVar('T') @dataclass_decorator class GenericDataclass(Generic[T]): x: T class ComplexSettings(BaseSettings): field: GenericDataclass[int] env.set('field', '{"x": 1}') s = ComplexSettings() assert s.field.x == 1 env.set('field', '{"x": "a"}') with pytest.raises(ValidationError) as exc_info: ComplexSettings() assert exc_info.value.errors(include_url=False) == [ { 'input': 'a', 'loc': ('field', 'x'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'type': 'int_parsing', } ] def test_generic_basemodel(env): T = TypeVar('T') class GenericModel(BaseModel, Generic[T]): x: T class ComplexSettings(BaseSettings): field: GenericModel[int] env.set('field', '{"x": 1}') s = ComplexSettings() assert s.field.x == 1 env.set('field', '{"x": "a"}') with pytest.raises(ValidationError) as exc_info: ComplexSettings() assert exc_info.value.errors(include_url=False) == [ { 'input': 'a', 'loc': ('field', 'x'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'type': 'int_parsing', } ] def test_annotated(env): T = TypeVar('T') class GenericModel(BaseModel, Generic[T]): x: T class ComplexSettings(BaseSettings): field: GenericModel[int] env.set('field', '{"x": 1}') s = ComplexSettings() assert s.field.x == 1 env.set('field', '{"x": "a"}') with pytest.raises(ValidationError) as exc_info: ComplexSettings() assert exc_info.value.errors(include_url=False) == [ { 'input': 'a', 'loc': ('field', 'x'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'type': 'int_parsing', } ] def test_class_nested_model_default_partial_update(env): class NestedA(BaseModel): v0: bool v1: bool @pydantic_dataclasses.dataclass class NestedB: v0: bool v1: bool @dataclasses.dataclass class NestedC: v0: bool v1: bool class NestedD(BaseModel): v0: bool = False v1: bool = True class SettingsDefaultsA(BaseSettings, env_nested_delimiter='__', nested_model_default_partial_update=True): nested_a: NestedA = NestedA(v0=False, v1=True) nested_b: NestedB = NestedB(v0=False, v1=True) nested_d: NestedC = NestedC(v0=False, v1=True) nested_c: NestedD = NestedD() env.set('NESTED_A__V0', 'True') env.set('NESTED_B__V0', 'True') env.set('NESTED_C__V0', 'True') env.set('NESTED_D__V0', 'True') assert SettingsDefaultsA().model_dump() == { 'nested_a': {'v0': True, 'v1': True}, 'nested_b': {'v0': True, 'v1': True}, 'nested_c': {'v0': True, 'v1': True}, 'nested_d': {'v0': True, 'v1': True}, } def test_init_kwargs_nested_model_default_partial_update(env): class DeepSubModel(BaseModel): v4: str class SubModel(BaseModel): v1: str v2: bytes v3: int deep: DeepSubModel class Settings(BaseSettings, env_nested_delimiter='__', nested_model_default_partial_update=True): v0: str sub_model: SubModel @classmethod def settings_customise_sources( cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings ): return env_settings, dotenv_settings, init_settings, file_secret_settings env.set('SUB_MODEL__DEEP__V4', 'override-v4') s_final = {'v0': '0', 'sub_model': {'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': {'v4': 'override-v4'}}} s = Settings(v0='0', sub_model={'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': {'v4': 'init-v4'}}) assert s.model_dump() == s_final s = Settings(v0='0', sub_model=SubModel(v1='init-v1', v2=b'init-v2', v3=3, deep=DeepSubModel(v4='init-v4'))) assert s.model_dump() == s_final s = Settings(v0='0', sub_model=SubModel(v1='init-v1', v2=b'init-v2', v3=3, deep={'v4': 'init-v4'})) assert s.model_dump() == s_final s = Settings(v0='0', sub_model={'v1': 'init-v1', 'v2': b'init-v2', 'v3': 3, 'deep': DeepSubModel(v4='init-v4')}) assert s.model_dump() == s_final def test_alias_resolution_init_source(env): class Example(BaseSettings): model_config = SettingsConfigDict(env_prefix='PREFIX') name: str last_name: str = Field(validation_alias=AliasChoices('PREFIX_LAST_NAME', 'PREFIX_SURNAME')) env.set('PREFIX_SURNAME', 'smith') assert Example(name='john', PREFIX_SURNAME='doe').model_dump() == {'name': 'john', 'last_name': 'doe'} def test_alias_nested_model_default_partial_update(): class SubModel(BaseModel): v1: str = 'default' v2: bytes = b'hello' v3: int class Settings(BaseSettings): model_config = SettingsConfigDict( nested_model_default_partial_update=True, alias_generator=AliasGenerator(lambda s: s.replace('_', '-')) ) v0: str = 'ok' sub_model: SubModel = SubModel(v1='top default', v3=33) assert Settings(**{'sub-model': {'v1': 'cli'}}).model_dump() == { 'v0': 'ok', 'sub_model': {'v1': 'cli', 'v2': b'hello', 'v3': 33}, } def test_env_str(env): class Settings(BaseSettings): apple: str = Field(None, validation_alias='BOOM') env.set('BOOM', 'hello') assert Settings().apple == 'hello' def test_env_list(env): class Settings(BaseSettings): foobar: str = Field(validation_alias=AliasChoices('different1', 'different2')) env.set('different1', 'value 1') env.set('different2', 'value 2') s = Settings() assert s.foobar == 'value 1' def test_env_list_field(env): class Settings(BaseSettings): foobar: str = Field(validation_alias='foobar_env_name') env.set('FOOBAR_ENV_NAME', 'env value') s = Settings() assert s.foobar == 'env value' def test_env_list_last(env): class Settings(BaseSettings): foobar: str = Field(validation_alias=AliasChoices('different2')) env.set('different1', 'value 1') env.set('different2', 'value 2') s = Settings() assert s.foobar == 'value 2' def test_env_inheritance_field(env): class SettingsParent(BaseSettings): foobar: str = Field('parent default', validation_alias='foobar_env') class SettingsChild(SettingsParent): foobar: str = 'child default' assert SettingsParent().foobar == 'parent default' assert SettingsChild().foobar == 'child default' assert SettingsChild(foobar='abc').foobar == 'abc' env.set('foobar_env', 'env value') assert SettingsParent().foobar == 'env value' assert SettingsChild().foobar == 'child default' assert SettingsChild(foobar='abc').foobar == 'abc' def test_env_inheritance_config(env): env.set('foobar', 'foobar') env.set('prefix_foobar', 'prefix_foobar') env.set('foobar_parent_from_field', 'foobar_parent_from_field') env.set('prefix_foobar_parent_from_field', 'prefix_foobar_parent_from_field') env.set('foobar_parent_from_config', 'foobar_parent_from_config') env.set('foobar_child_from_config', 'foobar_child_from_config') env.set('foobar_child_from_field', 'foobar_child_from_field') # a. Child class config overrides prefix class Parent(BaseSettings): foobar: str = Field(None, validation_alias='foobar_parent_from_field') model_config = SettingsConfigDict(env_prefix='p_') class Child(Parent): model_config = SettingsConfigDict(env_prefix='prefix_') assert Child().foobar == 'foobar_parent_from_field' # b. Child class overrides field class Parent(BaseSettings): foobar: str = Field(None, validation_alias='foobar_parent_from_config') class Child(Parent): foobar: str = Field(None, validation_alias='foobar_child_from_config') assert Child().foobar == 'foobar_child_from_config' # . Child class overrides parent prefix and field class Parent(BaseSettings): foobar: Optional[str] model_config = SettingsConfigDict(env_prefix='p_') class Child(Parent): foobar: str = Field(None, validation_alias='foobar_child_from_field') model_config = SettingsConfigDict(env_prefix='prefix_') assert Child().foobar == 'foobar_child_from_field' def test_invalid_validation_alias(env): with pytest.raises( TypeError, match='Invalid `validation_alias` type. it should be `str`, `AliasChoices`, or `AliasPath`' ): class Settings(BaseSettings): foobar: str = Field(validation_alias=123) def test_validation_aliases(env): class Settings(BaseSettings): foobar: str = Field('default value', validation_alias='foobar_alias') assert Settings().foobar == 'default value' assert Settings(foobar_alias='42').foobar == '42' env.set('foobar_alias', 'xxx') assert Settings().foobar == 'xxx' assert Settings(foobar_alias='42').foobar == '42' def test_validation_aliases_alias_path(env): class Settings(BaseSettings): foobar: str = Field(validation_alias=AliasPath('foo', 'bar', 1)) env.set('foo', '{"bar": ["val0", "val1"]}') assert Settings().foobar == 'val1' def test_validation_aliases_alias_choices(env): class Settings(BaseSettings): foobar: str = Field(validation_alias=AliasChoices('foo', AliasPath('foo1', 'bar', 1), AliasPath('bar', 2))) env.set('foo', 'val1') assert Settings().foobar == 'val1' env.pop('foo') env.set('foo1', '{"bar": ["val0", "val2"]}') assert Settings().foobar == 'val2' env.pop('foo1') env.set('bar', '["val1", "val2", "val3"]') assert Settings().foobar == 'val3' def test_validation_alias_with_env_prefix(env): class Settings(BaseSettings): foobar: str = Field(validation_alias='foo') model_config = SettingsConfigDict(env_prefix='p_') env.set('p_foo', 'bar') with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ {'type': 'missing', 'loc': ('foo',), 'msg': 'Field required', 'input': {}} ] env.set('foo', 'bar') assert Settings().foobar == 'bar' def test_case_sensitive(monkeypatch): class Settings(BaseSettings): foo: str model_config = SettingsConfigDict(case_sensitive=True) # Need to patch os.environ to get build to work on Windows, where os.environ is case insensitive monkeypatch.setattr(os, 'environ', value={'Foo': 'foo'}) with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ {'type': 'missing', 'loc': ('foo',), 'msg': 'Field required', 'input': {}} ] @pytest.mark.parametrize('env_nested_delimiter', [None, '']) def test_case_sensitive_no_nested_delimiter(monkeypatch, env_nested_delimiter): class Subsettings(BaseSettings): foo: str class Settings(BaseSettings): subsettings: Subsettings model_config = SettingsConfigDict(case_sensitive=True, env_nested_delimiter=env_nested_delimiter) # Need to patch os.environ to get build to work on Windows, where os.environ is case insensitive monkeypatch.setattr(os, 'environ', value={'subsettingsNonefoo': '1'}) with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ {'type': 'missing', 'loc': ('subsettings',), 'msg': 'Field required', 'input': {}} ] def test_nested_dataclass(env): @pydantic_dataclasses.dataclass class DeepNestedDataclass: boo: int rar: str @pydantic_dataclasses.dataclass class MyDataclass: foo: int bar: str deep: DeepNestedDataclass class Settings(BaseSettings, env_nested_delimiter='__'): n: MyDataclass env.set('N', '{"foo": 123, "bar": "bar value"}') env.set('N__DEEP', '{"boo": 1, "rar": "eek"}') s = Settings() assert isinstance(s.n, MyDataclass) assert s.n.foo == 123 assert s.n.bar == 'bar value' def test_nested_vanila_dataclass(env): @dataclasses.dataclass class MyDataclass: value: str class NestedSettings(BaseSettings, MyDataclass): pass class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__') sub: NestedSettings env.set('SUB__VALUE', 'something') s = Settings() assert s.sub.value == 'something' def test_env_takes_precedence(env): class Settings(BaseSettings): foo: int bar: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return env_settings, init_settings env.set('BAR', 'env setting') s = Settings(foo='123', bar='argument') assert s.foo == 123 assert s.bar == 'env setting' def test_config_file_settings_nornir(env): """ See https://github.com/pydantic/pydantic/pull/341#issuecomment-450378771 """ def nornir_settings_source() -> Dict[str, Any]: return {'param_a': 'config a', 'param_b': 'config b', 'param_c': 'config c'} class Settings(BaseSettings): param_a: str param_b: str param_c: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return env_settings, init_settings, nornir_settings_source env.set('PARAM_C', 'env setting c') s = Settings(param_b='argument b', param_c='argument c') assert s.param_a == 'config a' assert s.param_b == 'argument b' assert s.param_c == 'env setting c' def test_env_union_with_complex_subfields_parses_json(env): class A(BaseModel): a: str class B(BaseModel): b: int class Settings(BaseSettings): content: Union[A, B, int] env.set('content', '{"a": "test"}') s = Settings() assert s.content == A(a='test') def test_env_union_with_complex_subfields_parses_plain_if_json_fails(env): class A(BaseModel): a: str class B(BaseModel): b: int class Settings(BaseSettings): content: Union[A, B, datetime] env.set('content', '{"a": "test"}') s = Settings() assert s.content == A(a='test') env.set('content', '2020-07-05T00:00:00Z') s = Settings() assert s.content == datetime(2020, 7, 5, 0, 0, tzinfo=timezone.utc) def test_env_union_without_complex_subfields_does_not_parse_json(env): class Settings(BaseSettings): content: Union[datetime, str] env.set('content', '2020-07-05T00:00:00Z') s = Settings() assert s.content == '2020-07-05T00:00:00Z' test_env_file = """\ # this is a comment A=good string # another one, followed by whitespace b='better string' c="best string" """ def test_env_file_config(env, tmp_path): p = tmp_path / '.env' p.write_text(test_env_file) class Settings(BaseSettings): a: str b: str c: str model_config = SettingsConfigDict(env_file=p) env.set('A', 'overridden var') s = Settings() assert s.a == 'overridden var' assert s.b == 'better string' assert s.c == 'best string' prefix_test_env_file = """\ # this is a comment prefix_A=good string # another one, followed by whitespace prefix_b='better string' prefix_c="best string" """ def test_env_file_with_env_prefix(env, tmp_path): p = tmp_path / '.env' p.write_text(prefix_test_env_file) class Settings(BaseSettings): a: str b: str c: str model_config = SettingsConfigDict(env_file=p, env_prefix='prefix_') env.set('prefix_A', 'overridden var') s = Settings() assert s.a == 'overridden var' assert s.b == 'better string' assert s.c == 'best string' prefix_test_env_invalid_file = """\ # this is a comment prefix_A=good string # another one, followed by whitespace prefix_b='better string' prefix_c="best string" f="random value" """ def test_env_file_with_env_prefix_invalid(tmp_path): p = tmp_path / '.env' p.write_text(prefix_test_env_invalid_file) class Settings(BaseSettings): a: str b: str c: str model_config = SettingsConfigDict(env_file=p, env_prefix='prefix_') with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ {'type': 'extra_forbidden', 'loc': ('f',), 'msg': 'Extra inputs are not permitted', 'input': 'random value'} ] def test_ignore_env_file_with_env_prefix_invalid(tmp_path): p = tmp_path / '.env' p.write_text(prefix_test_env_invalid_file) class Settings(BaseSettings): a: str b: str c: str model_config = SettingsConfigDict(env_file=p, env_prefix='prefix_', extra='ignore') s = Settings() assert s.a == 'good string' assert s.b == 'better string' assert s.c == 'best string' def test_env_file_config_case_sensitive(tmp_path): p = tmp_path / '.env' p.write_text(test_env_file) class Settings(BaseSettings): a: str b: str c: str model_config = SettingsConfigDict(env_file=p, case_sensitive=True, extra='ignore') with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ { 'type': 'missing', 'loc': ('a',), 'msg': 'Field required', 'input': {'b': 'better string', 'c': 'best string', 'A': 'good string'}, } ] def test_env_file_export(env, tmp_path): p = tmp_path / '.env' p.write_text( """\ export A='good string' export B=better-string export C="best string" """ ) class Settings(BaseSettings): a: str b: str c: str model_config = SettingsConfigDict(env_file=p) env.set('A', 'overridden var') s = Settings() assert s.a == 'overridden var' assert s.b == 'better-string' assert s.c == 'best string' def test_env_file_export_validation_alias(env, tmp_path): p = tmp_path / '.env' p.write_text("""export a='{"b": ["1", "2"]}'""") class Settings(BaseSettings): a: str = Field(validation_alias=AliasChoices(AliasPath('a', 'b', 1))) model_config = SettingsConfigDict(env_file=p) s = Settings() assert s.a == '2' def test_env_file_config_custom_encoding(tmp_path): p = tmp_path / '.env' p.write_text('pika=p!±@', encoding='latin-1') class Settings(BaseSettings): pika: str model_config = SettingsConfigDict(env_file=p, env_file_encoding='latin-1') s = Settings() assert s.pika == 'p!±@' @pytest.fixture def home_tmp(tmp_path, env): env.set('HOME', str(tmp_path)) env.set('USERPROFILE', str(tmp_path)) env.set('HOMEPATH', str(tmp_path)) tmp_filename = f'{uuid.uuid4()}.env' home_tmp_path = tmp_path / tmp_filename yield home_tmp_path, tmp_filename home_tmp_path.unlink() def test_env_file_home_directory(home_tmp): home_tmp_path, tmp_filename = home_tmp home_tmp_path.write_text('pika=baz') class Settings(BaseSettings): pika: str model_config = SettingsConfigDict(env_file=f'~/{tmp_filename}') assert Settings().pika == 'baz' def test_env_file_none(tmp_path): p = tmp_path / '.env' p.write_text('a') class Settings(BaseSettings): a: str = 'xxx' s = Settings(_env_file=p) assert s.a == 'xxx' def test_env_file_override_file(tmp_path): p1 = tmp_path / '.env' p1.write_text(test_env_file) p2 = tmp_path / '.env.prod' p2.write_text('A="new string"') class Settings(BaseSettings): a: str model_config = SettingsConfigDict(env_file=str(p1)) s = Settings(_env_file=p2) assert s.a == 'new string' def test_env_file_override_none(tmp_path): p = tmp_path / '.env' p.write_text(test_env_file) class Settings(BaseSettings): a: Optional[str] = None model_config = SettingsConfigDict(env_file=p) s = Settings(_env_file=None) assert s.a is None def test_env_file_not_a_file(env): class Settings(BaseSettings): a: str = None env.set('A', 'ignore non-file') s = Settings(_env_file='tests/') assert s.a == 'ignore non-file' def test_read_env_file_case_sensitive(tmp_path): p = tmp_path / '.env' p.write_text('a="test"\nB=123') assert DotEnvSettingsSource._static_read_env_file(p) == {'a': 'test', 'b': '123'} assert DotEnvSettingsSource._static_read_env_file(p, case_sensitive=True) == {'a': 'test', 'B': '123'} def test_read_env_file_syntax_wrong(tmp_path): p = tmp_path / '.env' p.write_text('NOT_AN_ASSIGNMENT') assert DotEnvSettingsSource._static_read_env_file(p, case_sensitive=True) == {'NOT_AN_ASSIGNMENT': None} def test_env_file_example(tmp_path): p = tmp_path / '.env' p.write_text( """\ # ignore comment ENVIRONMENT="production" REDIS_ADDRESS=localhost:6379 MEANING_OF_LIFE=42 MY_VAR='Hello world' """ ) class Settings(BaseSettings): environment: str redis_address: str meaning_of_life: int my_var: str s = Settings(_env_file=str(p)) assert s.model_dump() == { 'environment': 'production', 'redis_address': 'localhost:6379', 'meaning_of_life': 42, 'my_var': 'Hello world', } def test_env_file_custom_encoding(tmp_path): p = tmp_path / '.env' p.write_text('pika=p!±@', encoding='latin-1') class Settings(BaseSettings): pika: str with pytest.raises(UnicodeDecodeError): Settings(_env_file=str(p)) s = Settings(_env_file=str(p), _env_file_encoding='latin-1') assert s.model_dump() == {'pika': 'p!±@'} test_default_env_file = """\ debug_mode=true host=localhost Port=8000 """ test_prod_env_file = """\ debug_mode=false host=https://example.com/services """ def test_multiple_env_file(tmp_path): base_env = tmp_path / '.env' base_env.write_text(test_default_env_file) prod_env = tmp_path / '.env.prod' prod_env.write_text(test_prod_env_file) class Settings(BaseSettings): debug_mode: bool host: str port: int model_config = SettingsConfigDict(env_file=[base_env, prod_env]) s = Settings() assert s.debug_mode is False assert s.host == 'https://example.com/services' assert s.port == 8000 def test_model_env_file_override_model_config(tmp_path): base_env = tmp_path / '.env' base_env.write_text(test_default_env_file) prod_env = tmp_path / '.env.prod' prod_env.write_text(test_prod_env_file) class Settings(BaseSettings): debug_mode: bool host: str port: int model_config = SettingsConfigDict(env_file=prod_env) s = Settings(_env_file=base_env) assert s.debug_mode is True assert s.host == 'localhost' assert s.port == 8000 def test_multiple_env_file_encoding(tmp_path): base_env = tmp_path / '.env' base_env.write_text('pika=p!±@', encoding='latin-1') prod_env = tmp_path / '.env.prod' prod_env.write_text('pika=chu!±@', encoding='latin-1') class Settings(BaseSettings): pika: str s = Settings(_env_file=[base_env, prod_env], _env_file_encoding='latin-1') assert s.pika == 'chu!±@' def test_read_dotenv_vars(tmp_path): base_env = tmp_path / '.env' base_env.write_text(test_default_env_file) prod_env = tmp_path / '.env.prod' prod_env.write_text(test_prod_env_file) source = DotEnvSettingsSource( BaseSettings(), env_file=[base_env, prod_env], env_file_encoding='utf8', case_sensitive=False ) assert source._read_env_files() == { 'debug_mode': 'false', 'host': 'https://example.com/services', 'port': '8000', } source = DotEnvSettingsSource( BaseSettings(), env_file=[base_env, prod_env], env_file_encoding='utf8', case_sensitive=True ) assert source._read_env_files() == { 'debug_mode': 'false', 'host': 'https://example.com/services', 'Port': '8000', } def test_read_dotenv_vars_when_env_file_is_none(): assert ( DotEnvSettingsSource( BaseSettings(), env_file=None, env_file_encoding=None, case_sensitive=False )._read_env_files() == {} ) def test_dotenvsource_override(env): class StdinDotEnvSettingsSource(DotEnvSettingsSource): @override def _read_env_file(self, file_path: Path) -> Dict[str, str]: assert str(file_path) == '-' return {'foo': 'stdin_foo', 'bar': 'stdin_bar'} @override def _read_env_files(self) -> Dict[str, str]: return self._read_env_file(Path('-')) source = StdinDotEnvSettingsSource(BaseSettings()) assert source._read_env_files() == {'foo': 'stdin_foo', 'bar': 'stdin_bar'} # test that calling read_env_file issues a DeprecationWarning # TODO: remove this test once read_env_file is removed def test_read_env_file_deprecation(tmp_path): from pydantic_settings.sources import read_env_file base_env = tmp_path / '.env' base_env.write_text(test_default_env_file) with pytest.deprecated_call(): assert read_env_file(base_env) == { 'debug_mode': 'true', 'host': 'localhost', 'port': '8000', } def test_alias_set(env): class Settings(BaseSettings): foo: str = Field('default foo', validation_alias='foo_env') bar: str = 'bar default' assert Settings.model_fields['bar'].alias is None assert Settings.model_fields['bar'].validation_alias is None assert Settings.model_fields['foo'].alias is None assert Settings.model_fields['foo'].validation_alias == 'foo_env' class SubSettings(Settings): spam: str = 'spam default' assert SubSettings.model_fields['bar'].alias is None assert SubSettings.model_fields['bar'].validation_alias is None assert SubSettings.model_fields['foo'].alias is None assert SubSettings.model_fields['foo'].validation_alias == 'foo_env' assert SubSettings().model_dump() == {'foo': 'default foo', 'bar': 'bar default', 'spam': 'spam default'} env.set('foo_env', 'fff') assert SubSettings().model_dump() == {'foo': 'fff', 'bar': 'bar default', 'spam': 'spam default'} env.set('bar', 'bbb') assert SubSettings().model_dump() == {'foo': 'fff', 'bar': 'bbb', 'spam': 'spam default'} env.set('spam', 'sss') assert SubSettings().model_dump() == {'foo': 'fff', 'bar': 'bbb', 'spam': 'sss'} def test_prefix_on_parent(env): class MyBaseSettings(BaseSettings): var: str = 'old' class MySubSettings(MyBaseSettings): model_config = SettingsConfigDict(env_prefix='PREFIX_') assert MyBaseSettings().model_dump() == {'var': 'old'} assert MySubSettings().model_dump() == {'var': 'old'} env.set('PREFIX_VAR', 'new') assert MyBaseSettings().model_dump() == {'var': 'old'} assert MySubSettings().model_dump() == {'var': 'new'} def test_secrets_path(tmp_path): p = tmp_path / 'foo' p.write_text('foo_secret_value_str') class Settings(BaseSettings): foo: str model_config = SettingsConfigDict(secrets_dir=tmp_path) assert Settings().model_dump() == {'foo': 'foo_secret_value_str'} def test_secrets_path_multiple(tmp_path): d1 = tmp_path / 'dir1' d2 = tmp_path / 'dir2' d1.mkdir() d2.mkdir() (d1 / 'foo1').write_text('foo1_dir1_secret_value_str') (d1 / 'foo2').write_text('foo2_dir1_secret_value_str') (d2 / 'foo2').write_text('foo2_dir2_secret_value_str') (d2 / 'foo3').write_text('foo3_dir2_secret_value_str') class Settings(BaseSettings): foo1: str foo2: str foo3: str assert Settings(_secrets_dir=(d1, d2)).model_dump() == { 'foo1': 'foo1_dir1_secret_value_str', 'foo2': 'foo2_dir2_secret_value_str', # dir2 takes priority 'foo3': 'foo3_dir2_secret_value_str', } assert Settings(_secrets_dir=(d2, d1)).model_dump() == { 'foo1': 'foo1_dir1_secret_value_str', 'foo2': 'foo2_dir1_secret_value_str', # dir1 takes priority 'foo3': 'foo3_dir2_secret_value_str', } def test_secrets_path_with_validation_alias(tmp_path): p = tmp_path / 'foo' p.write_text('{"bar": ["test"]}') class Settings(BaseSettings): foo: str = Field(validation_alias=AliasChoices(AliasPath('foo', 'bar', 0))) model_config = SettingsConfigDict(secrets_dir=tmp_path) assert Settings().model_dump() == {'foo': 'test'} def test_secrets_case_sensitive(tmp_path): (tmp_path / 'SECRET_VAR').write_text('foo_env_value_str') class Settings(BaseSettings): secret_var: Optional[str] = None model_config = SettingsConfigDict(secrets_dir=tmp_path, case_sensitive=True) assert Settings().model_dump() == {'secret_var': None} def test_secrets_case_insensitive(tmp_path): (tmp_path / 'SECRET_VAR').write_text('foo_env_value_str') class Settings(BaseSettings): secret_var: Optional[str] model_config = SettingsConfigDict(secrets_dir=tmp_path, case_sensitive=False) settings = Settings().model_dump() assert settings == {'secret_var': 'foo_env_value_str'} def test_secrets_path_url(tmp_path): (tmp_path / 'foo').write_text('http://www.example.com') (tmp_path / 'bar').write_text('snap') class Settings(BaseSettings): foo: HttpUrl bar: SecretStr model_config = SettingsConfigDict(secrets_dir=tmp_path) settings = Settings() assert str(settings.foo) == 'http://www.example.com/' assert settings.bar == SecretStr('snap') def test_secrets_path_json(tmp_path): p = tmp_path / 'foo' p.write_text('{"a": "b"}') class Settings(BaseSettings): foo: Dict[str, str] model_config = SettingsConfigDict(secrets_dir=tmp_path) assert Settings().model_dump() == {'foo': {'a': 'b'}} def test_secrets_nested_optional_json(tmp_path): p = tmp_path / 'foo' p.write_text('{"a": 10}') class Foo(BaseModel): a: int class Settings(BaseSettings): foo: Optional[Foo] = None model_config = SettingsConfigDict(secrets_dir=tmp_path) assert Settings().model_dump() == {'foo': {'a': 10}} def test_secrets_path_invalid_json(tmp_path): p = tmp_path / 'foo' p.write_text('{"a": "b"') class Settings(BaseSettings): foo: Dict[str, str] model_config = SettingsConfigDict(secrets_dir=tmp_path) with pytest.raises(SettingsError, match='error parsing value for field "foo" from source "SecretsSettingsSource"'): Settings() def test_secrets_missing(tmp_path): class Settings(BaseSettings): foo: str bar: List[str] model_config = SettingsConfigDict(secrets_dir=tmp_path) with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ {'type': 'missing', 'loc': ('foo',), 'msg': 'Field required', 'input': {}}, {'input': {}, 'loc': ('bar',), 'msg': 'Field required', 'type': 'missing'}, ] def test_secrets_invalid_secrets_dir(tmp_path): p1 = tmp_path / 'foo' p1.write_text('foo_secret_value_str') class Settings(BaseSettings): foo: str model_config = SettingsConfigDict(secrets_dir=p1) with pytest.raises(SettingsError, match='secrets_dir must reference a directory, not a file'): Settings() def test_secrets_invalid_secrets_dir_multiple_all(tmp_path): class Settings(BaseSettings): foo: str (d1 := tmp_path / 'dir1').write_text('') (d2 := tmp_path / 'dir2').write_text('') with pytest.raises(SettingsError, match='secrets_dir must reference a directory, not a file'): Settings(_secrets_dir=[d1, d2]) def test_secrets_invalid_secrets_dir_multiple_one(tmp_path): class Settings(BaseSettings): foo: str (d1 := tmp_path / 'dir1').mkdir() (d2 := tmp_path / 'dir2').write_text('') with pytest.raises(SettingsError, match='secrets_dir must reference a directory, not a file'): Settings(_secrets_dir=[d1, d2]) @pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex') def test_secrets_missing_location(tmp_path): class Settings(BaseSettings): model_config = SettingsConfigDict(secrets_dir=tmp_path / 'does_not_exist') with pytest.warns(UserWarning, match=f'directory "{tmp_path}/does_not_exist" does not exist'): Settings() @pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex') def test_secrets_missing_location_multiple_all(tmp_path): class Settings(BaseSettings): foo: Optional[str] = None with pytest.warns() as record: Settings(_secrets_dir=[tmp_path / 'dir1', tmp_path / 'dir2']) assert len(record) == 2 assert record[0].category is UserWarning and record[1].category is UserWarning assert str(record[0].message) == f'directory "{tmp_path}/dir1" does not exist' assert str(record[1].message) == f'directory "{tmp_path}/dir2" does not exist' @pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex') def test_secrets_missing_location_multiple_one(tmp_path): class Settings(BaseSettings): foo: Optional[str] = None (d1 := tmp_path / 'dir1').mkdir() (d1 / 'foo').write_text('secret_value') with pytest.warns(UserWarning, match=f'directory "{tmp_path}/dir2" does not exist'): conf = Settings(_secrets_dir=[d1, tmp_path / 'dir2']) assert conf.foo == 'secret_value' # value obtained from first directory @pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex') def test_secrets_file_is_a_directory(tmp_path): p1 = tmp_path / 'foo' p1.mkdir() class Settings(BaseSettings): foo: Optional[str] = None model_config = SettingsConfigDict(secrets_dir=tmp_path) with pytest.warns(UserWarning, match=f'attempted to load secret file "{tmp_path}/foo" but found a directory inste'): Settings() @pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex') def test_secrets_file_is_a_directory_multiple_all(tmp_path): class Settings(BaseSettings): foo: Optional[str] = None (d1 := tmp_path / 'dir1').mkdir() (d2 := tmp_path / 'dir2').mkdir() (d1 / 'foo').mkdir() (d2 / 'foo').mkdir() with pytest.warns() as record: Settings(_secrets_dir=[d1, d2]) assert len(record) == 2 assert record[0].category is UserWarning and record[1].category is UserWarning # warnings are emitted in reverse order assert str(record[0].message) == f'attempted to load secret file "{d2}/foo" but found a directory instead.' assert str(record[1].message) == f'attempted to load secret file "{d1}/foo" but found a directory instead.' @pytest.mark.skipif(sys.platform.startswith('win'), reason='windows paths break regex') def test_secrets_file_is_a_directory_multiple_one(tmp_path): class Settings(BaseSettings): foo: Optional[str] = None (d1 := tmp_path / 'dir1').mkdir() (d2 := tmp_path / 'dir2').mkdir() (d1 / 'foo').write_text('secret_value') (d2 / 'foo').mkdir() with pytest.warns(UserWarning, match=f'attempted to load secret file "{d2}/foo" but found a directory instead.'): conf = Settings(_secrets_dir=[d1, d2]) assert conf.foo == 'secret_value' # value obtained from first directory def test_secrets_dotenv_precedence(tmp_path): s = tmp_path / 'foo' s.write_text('foo_secret_value_str') e = tmp_path / '.env' e.write_text('foo=foo_env_value_str') class Settings(BaseSettings): foo: str model_config = SettingsConfigDict(secrets_dir=tmp_path) assert Settings(_env_file=e).model_dump() == {'foo': 'foo_env_value_str'} def test_external_settings_sources_precedence(env): def external_source_0() -> Dict[str, str]: return {'apple': 'value 0', 'banana': 'value 2'} def external_source_1() -> Dict[str, str]: return {'apple': 'value 1', 'raspberry': 'value 3'} class Settings(BaseSettings): apple: str banana: str raspberry: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return ( init_settings, env_settings, dotenv_settings, file_secret_settings, external_source_0, external_source_1, ) env.set('banana', 'value 1') assert Settings().model_dump() == {'apple': 'value 0', 'banana': 'value 1', 'raspberry': 'value 3'} def test_external_settings_sources_filter_env_vars(): vault_storage = {'user:password': {'apple': 'value 0', 'banana': 'value 2'}} class VaultSettingsSource(PydanticBaseSettingsSource): def __init__(self, settings_cls: Type[BaseSettings], user: str, password: str): self.user = user self.password = password super().__init__(settings_cls) def get_field_value(self, field: FieldInfo, field_name: str) -> Any: pass def __call__(self) -> Dict[str, str]: vault_vars = vault_storage[f'{self.user}:{self.password}'] return { field_name: vault_vars[field_name] for field_name in self.settings_cls.model_fields.keys() if field_name in vault_vars } class Settings(BaseSettings): apple: str banana: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return ( init_settings, env_settings, dotenv_settings, file_secret_settings, VaultSettingsSource(settings_cls, user='user', password='password'), ) assert Settings().model_dump() == {'apple': 'value 0', 'banana': 'value 2'} def test_customise_sources_empty(): class Settings(BaseSettings): apple: str = 'default' banana: str = 'default' @classmethod def settings_customise_sources(cls, *args, **kwargs): return () assert Settings().model_dump() == {'apple': 'default', 'banana': 'default'} assert Settings(apple='xxx').model_dump() == {'apple': 'default', 'banana': 'default'} def test_builtins_settings_source_repr(): assert ( repr(DefaultSettingsSource(BaseSettings, nested_model_default_partial_update=True)) == 'DefaultSettingsSource(nested_model_default_partial_update=True)' ) assert ( repr(InitSettingsSource(BaseSettings, init_kwargs={'apple': 'value 0', 'banana': 'value 1'})) == "InitSettingsSource(init_kwargs={'apple': 'value 0', 'banana': 'value 1'})" ) assert ( repr(EnvSettingsSource(BaseSettings, env_nested_delimiter='__')) == "EnvSettingsSource(env_nested_delimiter='__', env_prefix_len=0)" ) assert repr(DotEnvSettingsSource(BaseSettings, env_file='.env', env_file_encoding='utf-8')) == ( "DotEnvSettingsSource(env_file='.env', env_file_encoding='utf-8', " 'env_nested_delimiter=None, env_prefix_len=0)' ) assert ( repr(SecretsSettingsSource(BaseSettings, secrets_dir='/secrets')) == "SecretsSettingsSource(secrets_dir='/secrets')" ) def _parse_custom_dict(value: str) -> Callable[[str], Dict[int, str]]: """A custom parsing function passed into env parsing test.""" res = {} for part in value.split(','): k, v = part.split('=') res[int(k)] = v return res class CustomEnvSettingsSource(EnvSettingsSource): def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: if not value: return None return _parse_custom_dict(value) def test_env_setting_source_custom_env_parse(env): class Settings(BaseSettings): top: Dict[int, str] @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (CustomEnvSettingsSource(settings_cls),) with pytest.raises(ValidationError): Settings() env.set('top', '1=apple,2=banana') s = Settings() assert s.top == {1: 'apple', 2: 'banana'} class BadCustomEnvSettingsSource(EnvSettingsSource): def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: """A custom parsing function passed into env parsing test.""" return int(value) def test_env_settings_source_custom_env_parse_is_bad(env): class Settings(BaseSettings): top: Dict[int, str] @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (BadCustomEnvSettingsSource(settings_cls),) env.set('top', '1=apple,2=banana') with pytest.raises( SettingsError, match='error parsing value for field "top" from source "BadCustomEnvSettingsSource"' ): Settings() class CustomSecretsSettingsSource(SecretsSettingsSource): def prepare_field_value(self, field_name: str, field: FieldInfo, value: Any, value_is_complex: bool) -> Any: if not value: return None return _parse_custom_dict(value) def test_secret_settings_source_custom_env_parse(tmp_path): p = tmp_path / 'top' p.write_text('1=apple,2=banana') class Settings(BaseSettings): top: Dict[int, str] model_config = SettingsConfigDict(secrets_dir=tmp_path) def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (CustomSecretsSettingsSource(settings_cls, tmp_path),) s = Settings() assert s.top == {1: 'apple', 2: 'banana'} class BadCustomSettingsSource(EnvSettingsSource): def get_field_value(self, field: FieldInfo, field_name: str) -> Any: raise ValueError('Error') def test_custom_source_get_field_value_error(env): class Settings(BaseSettings): top: Dict[int, str] @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (BadCustomSettingsSource(settings_cls),) with pytest.raises( SettingsError, match='error getting value for field "top" from source "BadCustomSettingsSource"' ): Settings() def test_nested_env_complex_values(env): class SubSubModel(BaseSettings): dvals: Dict class SubModel(BaseSettings): vals: List[str] sub_sub_model: SubSubModel class Cfg(BaseSettings): sub_model: SubModel model_config = SettingsConfigDict(env_prefix='cfg_', env_nested_delimiter='__') env.set('cfg_sub_model__vals', '["one", "two"]') env.set('cfg_sub_model__sub_sub_model__dvals', '{"three": 4}') assert Cfg().model_dump() == {'sub_model': {'vals': ['one', 'two'], 'sub_sub_model': {'dvals': {'three': 4}}}} env.set('cfg_sub_model__vals', 'invalid') with pytest.raises( SettingsError, match='error parsing value for field "sub_model" from source "EnvSettingsSource"' ): Cfg() def test_nested_env_nonexisting_field(env): class SubModel(BaseSettings): vals: List[str] class Cfg(BaseSettings): sub_model: SubModel model_config = SettingsConfigDict(env_prefix='cfg_', env_nested_delimiter='__') env.set('cfg_sub_model__foo_vals', '[]') with pytest.raises(ValidationError): Cfg() def test_nested_env_nonexisting_field_deep(env): class SubModel(BaseSettings): vals: List[str] class Cfg(BaseSettings): sub_model: SubModel model_config = SettingsConfigDict(env_prefix='cfg_', env_nested_delimiter='__') env.set('cfg_sub_model__vals__foo__bar__vals', '[]') with pytest.raises(ValidationError): Cfg() def test_nested_env_union_complex_values(env): class SubModel(BaseSettings): vals: Union[List[str], Dict[str, str]] class Cfg(BaseSettings): sub_model: SubModel model_config = SettingsConfigDict(env_prefix='cfg_', env_nested_delimiter='__') env.set('cfg_sub_model__vals', '["one", "two"]') assert Cfg().model_dump() == {'sub_model': {'vals': ['one', 'two']}} env.set('cfg_sub_model__vals', '{"three": "four"}') assert Cfg().model_dump() == {'sub_model': {'vals': {'three': 'four'}}} env.set('cfg_sub_model__vals', 'stringval') with pytest.raises(ValidationError): Cfg() env.set('cfg_sub_model__vals', '{"invalid": dict}') with pytest.raises(ValidationError): Cfg() def test_discriminated_union_with_callable_discriminator(env): class A(BaseModel): x: Literal['a'] = 'a' y: str class B(BaseModel): x: Literal['b'] = 'b' z: str def get_discriminator_value(v: Any) -> Hashable: if isinstance(v, dict): v0 = v.get('x') else: v0 = getattr(v, 'x', None) if v0 == 'a': return 'a' elif v0 == 'b': return 'b' else: return None class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__') # Discriminated union using a callable discriminator. a_or_b: Annotated[Union[Annotated[A, Tag('a')], Annotated[B, Tag('b')]], Discriminator(get_discriminator_value)] # Set up environment so that the discriminator is 'a'. env.set('a_or_b__x', 'a') env.set('a_or_b__y', 'foo') s = Settings() assert s.a_or_b.x == 'a' assert s.a_or_b.y == 'foo' def test_json_field_with_discriminated_union(env): class A(BaseModel): x: Literal['a'] = 'a' class B(BaseModel): x: Literal['b'] = 'b' A_OR_B = Annotated[Union[A, B], Field(discriminator='x')] class Settings(BaseSettings): a_or_b: Optional[Json[A_OR_B]] = None # Set up environment so that the discriminator is 'a'. env.set('a_or_b', '{"x": "a"}') s = Settings() assert s.a_or_b.x == 'a' def test_nested_model_case_insensitive(env): class SubSubSub(BaseModel): VaL3: str val4: str = Field(validation_alias='VAL4') class SubSub(BaseModel): Val2: str SUB_sub_SuB: SubSubSub class Sub(BaseModel): VAL1: str SUB_sub: SubSub class Settings(BaseSettings): nested: Sub model_config = SettingsConfigDict(env_nested_delimiter='__') env.set('nested', '{"val1": "v1", "sub_SUB": {"VAL2": "v2", "sub_SUB_sUb": {"vAl3": "v3", "VAL4": "v4"}}}') s = Settings() assert s.nested.VAL1 == 'v1' assert s.nested.SUB_sub.Val2 == 'v2' assert s.nested.SUB_sub.SUB_sub_SuB.VaL3 == 'v3' assert s.nested.SUB_sub.SUB_sub_SuB.val4 == 'v4' def test_dotenv_extra_allow(tmp_path): p = tmp_path / '.env' p.write_text('a=b\nx=y') class Settings(BaseSettings): a: str model_config = SettingsConfigDict(env_file=p, extra='allow') s = Settings() assert s.a == 'b' assert s.x == 'y' def test_dotenv_extra_forbid(tmp_path): p = tmp_path / '.env' p.write_text('a=b\nx=y') class Settings(BaseSettings): a: str model_config = SettingsConfigDict(env_file=p, extra='forbid') with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ {'type': 'extra_forbidden', 'loc': ('x',), 'msg': 'Extra inputs are not permitted', 'input': 'y'} ] def test_dotenv_extra_case_insensitive(tmp_path): p = tmp_path / '.env' p.write_text('a=b') class Settings(BaseSettings): A: str model_config = SettingsConfigDict(env_file=p, extra='forbid') s = Settings() assert s.A == 'b' def test_dotenv_extra_sub_model_case_insensitive(tmp_path): p = tmp_path / '.env' p.write_text('a=b\nSUB_model={"v": "v1"}') class SubModel(BaseModel): v: str class Settings(BaseSettings): A: str sub_MODEL: SubModel model_config = SettingsConfigDict(env_file=p, extra='forbid') s = Settings() assert s.A == 'b' assert s.sub_MODEL.v == 'v1' def test_nested_bytes_field(env): class SubModel(BaseModel): v1: str v2: bytes class Settings(BaseSettings): v0: str sub_model: SubModel model_config = SettingsConfigDict(env_nested_delimiter='__', env_prefix='TEST_') env.set('TEST_V0', 'v0') env.set('TEST_SUB_MODEL__V1', 'v1') env.set('TEST_SUB_MODEL__V2', 'v2') s = Settings() assert s.v0 == 'v0' assert s.sub_model.v1 == 'v1' assert s.sub_model.v2 == b'v2' def test_protected_namespace_defaults(): # pydantic default with pytest.warns( UserWarning, match='Field "model_dump_prefixed_field" in Model has conflict with protected namespace "model_dump"', ): class Model(BaseSettings): model_dump_prefixed_field: str # pydantic-settings default with pytest.warns( UserWarning, match='Field "settings_customise_sources_prefixed_field" in Model1 has conflict with protected namespace "settings_customise_sources"', ): class Model1(BaseSettings): settings_customise_sources_prefixed_field: str with pytest.raises( NameError, match=( 'Field "settings_customise_sources" conflicts with member > " 'of protected namespace "settings_customise_sources".' ), ): class Model2(BaseSettings): settings_customise_sources: str def test_case_sensitive_from_args(monkeypatch): class Settings(BaseSettings): foo: str # Need to patch os.environ to get build to work on Windows, where os.environ is case insensitive monkeypatch.setattr(os, 'environ', value={'Foo': 'foo'}) with pytest.raises(ValidationError) as exc_info: Settings(_case_sensitive=True) assert exc_info.value.errors(include_url=False) == [ {'type': 'missing', 'loc': ('foo',), 'msg': 'Field required', 'input': {}} ] def test_env_prefix_from_args(env): class Settings(BaseSettings): apple: str env.set('foobar_apple', 'has_prefix') s = Settings(_env_prefix='foobar_') assert s.apple == 'has_prefix' def test_env_json_field(env): class Settings(BaseSettings): x: Json env.set('x', '{"foo": "bar"}') s = Settings() assert s.x == {'foo': 'bar'} env.set('x', 'test') with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ { 'type': 'json_invalid', 'loc': ('x',), 'msg': 'Invalid JSON: expected ident at line 1 column 2', 'input': 'test', 'ctx': {'error': 'expected ident at line 1 column 2'}, } ] def test_env_parse_enums(env): class Settings(BaseSettings): fruit: FruitsEnum union_fruit: Optional[Union[int, FruitsEnum]] = None with pytest.raises(ValidationError) as exc_info: env.set('FRUIT', 'kiwi') env.set('UNION_FRUIT', 'kiwi') s = Settings() assert exc_info.value.errors(include_url=False) == [ { 'type': 'enum', 'loc': ('fruit',), 'msg': 'Input should be 0, 1 or 2', 'input': 'kiwi', 'ctx': {'expected': '0, 1 or 2'}, }, { 'input': 'kiwi', 'loc': ( 'union_fruit', 'int', ), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'type': 'int_parsing', }, { 'ctx': { 'expected': '0, 1 or 2', }, 'input': 'kiwi', 'loc': ( 'union_fruit', 'int-enum[FruitsEnum]', ), 'msg': 'Input should be 0, 1 or 2', 'type': 'enum', }, ] env.set('FRUIT', str(FruitsEnum.lime.value)) env.set('UNION_FRUIT', str(FruitsEnum.lime.value)) s = Settings() assert s.fruit == FruitsEnum.lime env.set('FRUIT', 'kiwi') env.set('UNION_FRUIT', 'kiwi') s = Settings(_env_parse_enums=True) assert s.fruit == FruitsEnum.kiwi env.set('FRUIT', str(FruitsEnum.lime.value)) env.set('UNION_FRUIT', str(FruitsEnum.lime.value)) s = Settings(_env_parse_enums=True) assert s.fruit == FruitsEnum.lime def test_env_parse_none_str(env): env.set('x', 'null') env.set('y', 'y_override') class Settings(BaseSettings): x: Optional[str] = 'x_default' y: Optional[str] = 'y_default' s = Settings() assert s.x == 'null' assert s.y == 'y_override' s = Settings(_env_parse_none_str='null') assert s.x is None assert s.y == 'y_override' env.set('nested__x', 'None') env.set('nested__y', 'y_override') env.set('nested__deep__z', 'None') class NestedBaseModel(BaseModel): x: Optional[str] = 'x_default' y: Optional[str] = 'y_default' deep: Optional[dict] = {'z': 'z_default'} keep: Optional[dict] = {'z': 'None'} class NestedSettings(BaseSettings, env_nested_delimiter='__'): nested: Optional[NestedBaseModel] = NestedBaseModel() s = NestedSettings() assert s.nested.x == 'None' assert s.nested.y == 'y_override' assert s.nested.deep['z'] == 'None' assert s.nested.keep['z'] == 'None' s = NestedSettings(_env_parse_none_str='None') assert s.nested.x is None assert s.nested.y == 'y_override' assert s.nested.deep['z'] is None assert s.nested.keep['z'] == 'None' env.set('nested__deep', 'None') with pytest.raises(ValidationError): s = NestedSettings() s = NestedSettings(_env_parse_none_str='None') assert s.nested.x is None assert s.nested.y == 'y_override' assert s.nested.deep['z'] is None assert s.nested.keep['z'] == 'None' env.pop('nested__deep__z') with pytest.raises(ValidationError): s = NestedSettings() s = NestedSettings(_env_parse_none_str='None') assert s.nested.x is None assert s.nested.y == 'y_override' assert s.nested.deep is None assert s.nested.keep['z'] == 'None' def test_env_json_field_dict(env): class Settings(BaseSettings): x: Json[Dict[str, int]] env.set('x', '{"foo": 1}') s = Settings() assert s.x == {'foo': 1} env.set('x', '{"foo": "bar"}') with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ { 'type': 'int_parsing', 'loc': ('x', 'foo'), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'bar', } ] def test_custom_env_source_default_values_from_config(): class CustomEnvSettingsSource(EnvSettingsSource): pass class Settings(BaseSettings): foo: str = 'test' model_config = SettingsConfigDict(env_prefix='prefix_', case_sensitive=True) s = Settings() assert s.model_config['env_prefix'] == 'prefix_' assert s.model_config['case_sensitive'] is True c = CustomEnvSettingsSource(Settings) assert c.env_prefix == 'prefix_' assert c.case_sensitive is True def test_model_config_through_class_kwargs(env): class Settings(BaseSettings, env_prefix='foobar_', title='Test Settings Model'): apple: str assert Settings.model_config['title'] == 'Test Settings Model' # pydantic config assert Settings.model_config['env_prefix'] == 'foobar_' # pydantic-settings config assert Settings.model_json_schema()['title'] == 'Test Settings Model' env.set('foobar_apple', 'has_prefix') s = Settings() assert s.apple == 'has_prefix' def test_root_model_as_field(env): class Foo(BaseModel): x: int y: Dict[str, int] FooRoot = RootModel[List[Foo]] class Settings(BaseSettings): z: FooRoot env.set('z', '[{"x": 1, "y": {"foo": 1}}, {"x": 2, "y": {"foo": 2}}]') s = Settings() assert s.model_dump() == {'z': [{'x': 1, 'y': {'foo': 1}}, {'x': 2, 'y': {'foo': 2}}]} def test_str_based_root_model(env): """Testing to pass string directly to root model.""" class Foo(RootModel[str]): root: str class Settings(BaseSettings): foo: Foo plain: str TEST_STR = 'hello world' env.set('foo', TEST_STR) env.set('plain', TEST_STR) s = Settings() assert s.model_dump() == {'foo': TEST_STR, 'plain': TEST_STR} def test_path_based_root_model(env): """Testing to pass path directly to root model.""" class Foo(RootModel[pathlib.PurePosixPath]): root: pathlib.PurePosixPath class Settings(BaseSettings): foo: Foo plain: pathlib.PurePosixPath TEST_PATH: str = '/hello/world' env.set('foo', TEST_PATH) env.set('plain', TEST_PATH) s = Settings() assert s.model_dump() == { 'foo': pathlib.PurePosixPath(TEST_PATH), 'plain': pathlib.PurePosixPath(TEST_PATH), } def test_optional_field_from_env(env): class Settings(BaseSettings): x: Optional[str] = None env.set('x', '123') s = Settings() assert s.x == '123' def test_dotenv_optional_json_field(tmp_path): p = tmp_path / '.env' p.write_text("""DATA='{"foo":"bar"}'""") class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=p) data: Optional[Json[Dict[str, str]]] = Field(default=None) s = Settings() assert s.data == {'foo': 'bar'} def test_dotenv_with_alias_and_env_prefix(tmp_path): p = tmp_path / '.env' p.write_text('xxx__foo=1\nxxx__bar=2') class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=p, env_prefix='xxx__') foo: str = '' bar_alias: str = Field('', validation_alias='xxx__bar') s = Settings() assert s.model_dump() == {'foo': '1', 'bar_alias': '2'} class Settings1(BaseSettings): model_config = SettingsConfigDict(env_file=p, env_prefix='xxx__') foo: str = '' bar_alias: str = Field('', alias='bar') with pytest.raises(ValidationError) as exc_info: Settings1() assert exc_info.value.errors(include_url=False) == [ {'type': 'extra_forbidden', 'loc': ('xxx__bar',), 'msg': 'Extra inputs are not permitted', 'input': '2'} ] def test_dotenv_with_alias_and_env_prefix_nested(tmp_path): p = tmp_path / '.env' p.write_text('xxx__bar=0\nxxx__nested__a=1\nxxx__nested__b=2') class NestedSettings(BaseModel): a: str = 'a' b: str = 'b' class Settings(BaseSettings): model_config = SettingsConfigDict(env_prefix='xxx__', env_nested_delimiter='__', env_file=p) foo: str = '' bar_alias: str = Field('', alias='xxx__bar') nested_alias: NestedSettings = Field(default_factory=NestedSettings, alias='xxx__nested') s = Settings() assert s.model_dump() == {'foo': '', 'bar_alias': '0', 'nested_alias': {'a': '1', 'b': '2'}} def test_dotenv_with_extra_and_env_prefix(tmp_path): p = tmp_path / '.env' p.write_text('xxx__foo=1\nxxx__extra_var=extra_value') class Settings(BaseSettings): model_config = SettingsConfigDict(extra='allow', env_file=p, env_prefix='xxx__') foo: str = '' s = Settings() assert s.model_dump() == {'foo': '1', 'extra_var': 'extra_value'} def test_nested_field_with_alias_init_source(): class NestedSettings(BaseModel): foo: str = Field(alias='fooAlias') class Settings(BaseSettings): nested_foo: NestedSettings s = Settings(nested_foo=NestedSettings(fooAlias='EXAMPLE')) assert s.model_dump() == {'nested_foo': {'foo': 'EXAMPLE'}} def test_nested_models_as_dict_value(env): class NestedSettings(BaseModel): foo: Dict[str, int] class Settings(BaseSettings): nested: NestedSettings sub_dict: Dict[str, NestedSettings] model_config = SettingsConfigDict(env_nested_delimiter='__') env.set('nested__foo', '{"a": 1}') env.set('sub_dict__bar__foo', '{"b": 2}') s = Settings() assert s.model_dump() == {'nested': {'foo': {'a': 1}}, 'sub_dict': {'bar': {'foo': {'b': 2}}}} def test_env_nested_dict_value(env): class Settings(BaseSettings): nested: Dict[str, Dict[str, Dict[str, str]]] model_config = SettingsConfigDict(env_nested_delimiter='__') env.set('nested__foo__a__b', 'bar') s = Settings() assert s.model_dump() == {'nested': {'foo': {'a': {'b': 'bar'}}}} def test_nested_models_leaf_vs_deeper_env_dict_assumed(env): class NestedSettings(BaseModel): foo: str class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__') nested: NestedSettings env.set('nested__foo', 'string') env.set( 'nested__foo__bar', 'this should not be evaluated, since foo is a string by annotation and not a dict', ) env.set( 'nested__foo__bar__baz', 'one more', ) s = Settings() assert s.model_dump() == {'nested': {'foo': 'string'}} def test_case_insensitive_nested_optional(env): class NestedSettings(BaseModel): FOO: str BaR: int class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__', case_sensitive=False) nested: Optional[NestedSettings] env.set('nested__FoO', 'string') env.set('nested__bar', '123') s = Settings() assert s.model_dump() == {'nested': {'BaR': 123, 'FOO': 'string'}} def test_case_insensitive_nested_list(env): class NestedSettings(BaseModel): FOO: List[str] class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__', case_sensitive=False) nested: Optional[NestedSettings] env.set('nested__FOO', '["string1", "string2"]') s = Settings() assert s.model_dump() == {'nested': {'FOO': ['string1', 'string2']}} def test_settings_source_current_state(env): class SettingsSource(PydanticBaseSettingsSource): def get_field_value(self, field: FieldInfo, field_name: str) -> Any: pass def __call__(self) -> Dict[str, Any]: current_state = self.current_state if current_state.get('one') == '1': return {'two': '1'} return {} class Settings(BaseSettings): one: bool = False two: bool = False @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (env_settings, SettingsSource(settings_cls)) env.set('one', '1') s = Settings() assert s.two is True def test_settings_source_settings_sources_data(env): class SettingsSource(PydanticBaseSettingsSource): def get_field_value(self, field: FieldInfo, field_name: str) -> Any: pass def __call__(self) -> Dict[str, Any]: settings_sources_data = self.settings_sources_data if settings_sources_data == { 'InitSettingsSource': {'one': True, 'two': True}, 'EnvSettingsSource': {'one': '1'}, 'function_settings_source': {'three': 'false'}, }: return {'four': '1'} return {} def function_settings_source(): return {'three': 'false'} class Settings(BaseSettings): one: bool = False two: bool = False three: bool = False four: bool = False @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (env_settings, init_settings, function_settings_source, SettingsSource(settings_cls)) env.set('one', '1') s = Settings(one=True, two=True) assert s.four is True def test_dotenv_extra_allow_similar_fields(tmp_path): p = tmp_path / '.env' p.write_text('POSTGRES_USER=postgres\nPOSTGRES_USER_2=postgres2\nPOSTGRES_NAME=name') class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=p, extra='allow') POSTGRES_USER: str s = Settings() assert s.POSTGRES_USER == 'postgres' assert s.model_dump() == {'POSTGRES_USER': 'postgres', 'postgres_name': 'name', 'postgres_user_2': 'postgres2'} @pytest.mark.skipif(sys.version_info < (3, 9), reason='requires python 3.9 or higher') def test_annotation_is_complex_root_model_check(): """Test for https://github.com/pydantic/pydantic-settings/issues/390""" class Settings(BaseSettings): foo: list[str] = [] Settings() def test_nested_model_field_with_alias(env): class NestedSettings(BaseModel): foo: List[str] = Field(alias='fooalias') class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__') nested: NestedSettings env.set('nested__fooalias', '["one", "two"]') s = Settings() assert s.model_dump() == {'nested': {'foo': ['one', 'two']}} def test_nested_model_field_with_alias_case_sensitive(monkeypatch): class NestedSettings(BaseModel): foo: List[str] = Field(alias='fooAlias') class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__', case_sensitive=True) nested: NestedSettings # Need to patch os.environ to get build to work on Windows, where os.environ is case insensitive monkeypatch.setattr(os, 'environ', value={'nested__fooalias': '["one", "two"]'}) with pytest.raises(ValidationError) as exc_info: Settings() assert exc_info.value.errors(include_url=False) == [ { 'type': 'missing', 'loc': ('nested', 'fooAlias'), 'msg': 'Field required', 'input': {'fooalias': '["one", "two"]'}, } ] monkeypatch.setattr(os, 'environ', value={'nested__fooAlias': '["one", "two"]'}) s = Settings() assert s.model_dump() == {'nested': {'foo': ['one', 'two']}} def test_nested_model_field_with_alias_choices(env): class NestedSettings(BaseModel): foo: List[str] = Field(alias=AliasChoices('fooalias', 'foo-alias')) class Settings(BaseSettings): model_config = SettingsConfigDict(env_nested_delimiter='__') nested: NestedSettings env.set('nested__fooalias', '["one", "two"]') s = Settings() assert s.model_dump() == {'nested': {'foo': ['one', 'two']}} def test_dotenv_optional_nested(tmp_path): p = tmp_path / '.env' p.write_text('not_nested=works\nNESTED__A=fails\nNESTED__b=2') class NestedSettings(BaseModel): A: str b: int class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=p, env_nested_delimiter='__', extra='forbid', ) not_nested: str NESTED: Optional[NestedSettings] s = Settings() assert s.model_dump() == {'not_nested': 'works', 'NESTED': {'A': 'fails', 'b': 2}} def test_dotenv_env_prefix_env_without_prefix(tmp_path): p = tmp_path / '.env' p.write_text('test_foo=test-foo\nfoo=foo') class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=p, env_prefix='TEST_', extra='ignore', ) foo: str s = Settings() assert s.model_dump() == {'foo': 'test-foo'} def test_parsing_secret_field(env): class Settings(BaseSettings): foo: Secret[int] bar: Secret[PostgresDsn] env.set('foo', '123') env.set('bar', 'postgres://user:password@localhost/dbname') s = Settings() assert s.foo.get_secret_value() == 123 assert s.bar.get_secret_value() == PostgresDsn('postgres://user:password@localhost/dbname') def test_field_annotated_no_decode(env): class Settings(BaseSettings): a: List[str] # this field will be decoded because of default `enable_decoding=True` b: Annotated[List[str], NoDecode] # decode the value here. the field value won't be decoded because of NoDecode @field_validator('b', mode='before') @classmethod def decode_b(cls, v: str) -> List[str]: return json.loads(v) env.set('a', '["one", "two"]') env.set('b', '["1", "2"]') s = Settings() assert s.model_dump() == {'a': ['one', 'two'], 'b': ['1', '2']} def test_field_annotated_no_decode_and_disable_decoding(env): class Settings(BaseSettings): model_config = SettingsConfigDict(enable_decoding=False) a: Annotated[List[str], NoDecode] # decode the value here. the field value won't be decoded because of NoDecode @field_validator('a', mode='before') @classmethod def decode_b(cls, v: str) -> List[str]: return json.loads(v) env.set('a', '["one", "two"]') s = Settings() assert s.model_dump() == {'a': ['one', 'two']} def test_field_annotated_disable_decoding(env): class Settings(BaseSettings): model_config = SettingsConfigDict(enable_decoding=False) a: List[str] # decode the value here. the field value won't be decoded because of `enable_decoding=False` @field_validator('a', mode='before') @classmethod def decode_b(cls, v: str) -> List[str]: return json.loads(v) env.set('a', '["one", "two"]') s = Settings() assert s.model_dump() == {'a': ['one', 'two']} def test_field_annotated_force_decode_disable_decoding(env): class Settings(BaseSettings): model_config = SettingsConfigDict(enable_decoding=False) a: Annotated[List[str], ForceDecode] env.set('a', '["one", "two"]') s = Settings() assert s.model_dump() == {'a': ['one', 'two']} pydantic-pydantic-settings-5f33b62/tests/test_source_azure_key_vault.py000066400000000000000000000111231476003445400267010ustar00rootroot00000000000000""" Test pydantic_settings.AzureKeyVaultSettingsSource. """ from typing import Tuple, Type import pytest from pydantic import BaseModel, Field from pytest_mock import MockerFixture from pydantic_settings import ( AzureKeyVaultSettingsSource, BaseSettings, PydanticBaseSettingsSource, ) from pydantic_settings.sources import import_azure_key_vault try: azure_key_vault = True import_azure_key_vault() from azure.core.exceptions import ResourceNotFoundError from azure.identity import DefaultAzureCredential from azure.keyvault.secrets import KeyVaultSecret, SecretProperties except ImportError: azure_key_vault = False MODULE = 'pydantic_settings.sources' @pytest.mark.skipif(not azure_key_vault, reason='pydantic-settings[azure-key-vault] is not installed') class TestAzureKeyVaultSettingsSource: """Test AzureKeyVaultSettingsSource.""" def test___init__(self, mocker: MockerFixture) -> None: """Test __init__.""" class AzureKeyVaultSettings(BaseSettings): """AzureKeyVault settings.""" mocker.patch(f'{MODULE}.SecretClient.list_properties_of_secrets', return_value=[]) AzureKeyVaultSettingsSource( AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', DefaultAzureCredential() ) def test___call__(self, mocker: MockerFixture) -> None: """Test __call__.""" class SqlServer(BaseModel): password: str = Field(..., alias='Password') class AzureKeyVaultSettings(BaseSettings): """AzureKeyVault settings.""" SqlServerUser: str sql_server_user: str = Field(..., alias='SqlServerUser') sql_server: SqlServer = Field(..., alias='SqlServer') expected_secrets = [type('', (), {'name': 'SqlServerUser'}), type('', (), {'name': 'SqlServer--Password'})] expected_secret_value = 'SecretValue' mocker.patch(f'{MODULE}.SecretClient.list_properties_of_secrets', return_value=expected_secrets) mocker.patch( f'{MODULE}.SecretClient.get_secret', side_effect=self._raise_resource_not_found_when_getting_parent_secret_name, ) obj = AzureKeyVaultSettingsSource( AzureKeyVaultSettings, 'https://my-resource.vault.azure.net/', DefaultAzureCredential() ) settings = obj() assert settings['SqlServerUser'] == expected_secret_value assert settings['SqlServer']['Password'] == expected_secret_value def test_azure_key_vault_settings_source(self, mocker: MockerFixture) -> None: """Test AzureKeyVaultSettingsSource.""" class SqlServer(BaseModel): password: str = Field(..., alias='Password') class AzureKeyVaultSettings(BaseSettings): """AzureKeyVault settings.""" SqlServerUser: str sql_server_user: str = Field(..., alias='SqlServerUser') sql_server: SqlServer = Field(..., alias='SqlServer') @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return ( AzureKeyVaultSettingsSource( settings_cls, 'https://my-resource.vault.azure.net/', DefaultAzureCredential() ), ) expected_secrets = [type('', (), {'name': 'SqlServerUser'}), type('', (), {'name': 'SqlServer--Password'})] expected_secret_value = 'SecretValue' mocker.patch(f'{MODULE}.SecretClient.list_properties_of_secrets', return_value=expected_secrets) mocker.patch( f'{MODULE}.SecretClient.get_secret', side_effect=self._raise_resource_not_found_when_getting_parent_secret_name, ) settings = AzureKeyVaultSettings() # type: ignore assert settings.SqlServerUser == expected_secret_value assert settings.sql_server_user == expected_secret_value assert settings.sql_server.password == expected_secret_value def _raise_resource_not_found_when_getting_parent_secret_name(self, secret_name: str): expected_secret_value = 'SecretValue' key_vault_secret = KeyVaultSecret(SecretProperties(), expected_secret_value) if secret_name == 'SqlServer': raise ResourceNotFoundError() return key_vault_secret pydantic-pydantic-settings-5f33b62/tests/test_source_cli.py000066400000000000000000002344331476003445400242520ustar00rootroot00000000000000import argparse import asyncio import re import sys import time import typing from enum import IntEnum from typing import Any, Dict, Generic, List, Optional, Tuple, Type, TypeVar, Union import pytest import typing_extensions from pydantic import ( AliasChoices, AliasGenerator, AliasPath, BaseModel, ConfigDict, DirectoryPath, Discriminator, Field, Tag, ValidationError, ) from pydantic import ( dataclasses as pydantic_dataclasses, ) from pydantic._internal._repr import Representation from typing_extensions import Annotated, Literal from pydantic_settings import ( BaseSettings, CliApp, PydanticBaseSettingsSource, SettingsConfigDict, ) from pydantic_settings.sources import ( CLI_SUPPRESS, CliExplicitFlag, CliImplicitFlag, CliMutuallyExclusiveGroup, CliPositionalArg, CliSettingsSource, CliSubCommand, CliSuppress, SettingsError, get_subcommand, ) ARGPARSE_OPTIONS_TEXT = 'options' if sys.version_info >= (3, 10) else 'optional arguments' @pytest.fixture(autouse=True) def cli_test_env_autouse(cli_test_env): pass def foobar(a, b, c=4): pass class FruitsEnum(IntEnum): pear = 0 kiwi = 1 lime = 2 T = TypeVar('T') class LoggedVar(Generic[T]): def get(self) -> T: ... class SimpleSettings(BaseSettings): apple: str class SettingWithIgnoreEmpty(BaseSettings): apple: str = 'default' model_config = SettingsConfigDict(env_ignore_empty=True) class CliDummyArgGroup(BaseModel, arbitrary_types_allowed=True): group: argparse._ArgumentGroup def add_argument(self, *args: Any, **kwargs: Any) -> None: self.group.add_argument(*args, **kwargs) class CliDummySubParsers(BaseModel, arbitrary_types_allowed=True): sub_parser: argparse._SubParsersAction def add_parser(self, *args: Any, **kwargs: Any) -> 'CliDummyParser': return CliDummyParser(parser=self.sub_parser.add_parser(*args, **kwargs)) class CliDummyParser(BaseModel, arbitrary_types_allowed=True): parser: argparse.ArgumentParser = Field(default_factory=lambda: argparse.ArgumentParser()) def add_argument(self, *args: Any, **kwargs: Any) -> None: self.parser.add_argument(*args, **kwargs) def add_argument_group(self, *args: Any, **kwargs: Any) -> CliDummyArgGroup: return CliDummyArgGroup(group=self.parser.add_argument_group(*args, **kwargs)) def add_subparsers(self, *args: Any, **kwargs: Any) -> CliDummySubParsers: return CliDummySubParsers(sub_parser=self.parser.add_subparsers(*args, **kwargs)) def parse_args(self, *args: Any, **kwargs: Any) -> argparse.Namespace: return self.parser.parse_args(*args, **kwargs) def test_cli_validation_alias_with_cli_prefix(): class Settings(BaseSettings, cli_exit_on_error=False): foobar: str = Field(validation_alias='foo') model_config = SettingsConfigDict(cli_prefix='p') with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: --foo bar'): CliApp.run(Settings, cli_args=['--foo', 'bar']) assert CliApp.run(Settings, cli_args=['--p.foo', 'bar']).foobar == 'bar' @pytest.mark.parametrize( 'alias_generator', [ AliasGenerator(validation_alias=lambda s: AliasChoices(s, s.replace('_', '-'))), AliasGenerator(validation_alias=lambda s: AliasChoices(s.replace('_', '-'), s)), ], ) def test_cli_alias_resolution_consistency_with_env(env, alias_generator): class SubModel(BaseModel): v1: str = 'model default' class Settings(BaseSettings): model_config = SettingsConfigDict( env_nested_delimiter='__', nested_model_default_partial_update=True, alias_generator=alias_generator, ) sub_model: SubModel = SubModel(v1='top default') assert CliApp.run(Settings, cli_args=[]).model_dump() == {'sub_model': {'v1': 'top default'}} env.set('SUB_MODEL__V1', 'env default') assert CliApp.run(Settings, cli_args=[]).model_dump() == {'sub_model': {'v1': 'env default'}} assert CliApp.run(Settings, cli_args=['--sub-model.v1=cli default']).model_dump() == { 'sub_model': {'v1': 'cli default'} } def test_cli_nested_arg(): class SubSubValue(BaseModel): v6: str class SubValue(BaseModel): v4: str v5: int sub_sub: SubSubValue class TopValue(BaseModel): v1: str v2: str v3: str sub: SubValue class Cfg(BaseSettings): v0: str v0_union: Union[SubValue, int] top: TopValue args: List[str] = [] args += ['--top', '{"v1": "json-1", "v2": "json-2", "sub": {"v5": "xx"}}'] args += ['--top.sub.v5', '5'] args += ['--v0', '0'] args += ['--top.v2', '2'] args += ['--top.v3', '3'] args += ['--v0_union', '0'] args += ['--top.sub.sub_sub.v6', '6'] args += ['--top.sub.v4', '4'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == { 'v0': '0', 'v0_union': 0, 'top': { 'v1': 'json-1', 'v2': '2', 'v3': '3', 'sub': {'v4': '4', 'v5': 5, 'sub_sub': {'v6': '6'}}, }, } def test_cli_source_prioritization(env): class CfgDefault(BaseSettings): foo: str class CfgPrioritized(BaseSettings): foo: str @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return env_settings, CliSettingsSource(settings_cls, cli_parse_args=['--foo', 'FOO FROM CLI']) env.set('FOO', 'FOO FROM ENV') cfg = CliApp.run(CfgDefault, cli_args=['--foo', 'FOO FROM CLI']) assert cfg.model_dump() == {'foo': 'FOO FROM CLI'} cfg = CfgPrioritized() assert cfg.model_dump() == {'foo': 'FOO FROM ENV'} def test_cli_alias_subcommand_and_positional_args(capsys, monkeypatch): class SubCmd(BaseModel): pos_arg: CliPositionalArg[str] = Field(validation_alias='pos-arg') class Cfg(BaseSettings): sub_cmd: CliSubCommand[SubCmd] = Field(validation_alias='sub-cmd') cfg = Cfg(**{'sub-cmd': {'pos-arg': 'howdy'}}) assert cfg.model_dump() == {'sub_cmd': {'pos_arg': 'howdy'}} cfg = CliApp.run(Cfg, cli_args=['sub-cmd', 'howdy']) assert cfg.model_dump() == {'sub_cmd': {'pos_arg': 'howdy'}} with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Cfg) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{sub-cmd}} ... {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit subcommands: {{sub-cmd}} sub-cmd """ ) m.setattr(sys, 'argv', ['example.py', 'sub-cmd', '--help']) with pytest.raises(SystemExit): CliApp.run(Cfg) assert ( capsys.readouterr().out == f"""usage: example.py sub-cmd [-h] POS-ARG positional arguments: POS-ARG {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit """ ) @pytest.mark.parametrize('avoid_json', [True, False]) def test_cli_alias_arg(capsys, monkeypatch, avoid_json): class Cfg(BaseSettings, cli_avoid_json=avoid_json): alias_choice_w_path: str = Field(validation_alias=AliasChoices('a', AliasPath('path0', 1))) alias_choice_w_only_path: str = Field(validation_alias=AliasChoices(AliasPath('path1', 1))) alias_choice_no_path: str = Field(validation_alias=AliasChoices('b', 'c')) alias_path: str = Field(validation_alias=AliasPath('path2', 'deep', 1)) alias_str: str = Field(validation_alias='str') cfg = CliApp.run( Cfg, cli_args=[ '-a', 'a', '-b', 'b', '--str', 'str', '--path0', 'a0,b0,c0', '--path1', 'a1,b1,c1', '--path2', '{"deep": ["a2","b2","c2"]}', ], ) assert cfg.model_dump() == { 'alias_choice_w_path': 'a', 'alias_choice_w_only_path': 'b1', 'alias_choice_no_path': 'b', 'alias_path': 'b2', 'alias_str': 'str', } @pytest.mark.parametrize('avoid_json', [True, False]) def test_cli_alias_nested_arg(capsys, monkeypatch, avoid_json): class Nested(BaseModel): alias_choice_w_path: str = Field(validation_alias=AliasChoices('a', AliasPath('path0', 1))) alias_choice_w_only_path: str = Field(validation_alias=AliasChoices(AliasPath('path1', 1))) alias_choice_no_path: str = Field(validation_alias=AliasChoices('b', 'c')) alias_path: str = Field(validation_alias=AliasPath('path2', 'deep', 1)) alias_str: str = Field(validation_alias='str') class Cfg(BaseSettings, cli_avoid_json=avoid_json): nest: Nested cfg = CliApp.run( Cfg, cli_args=[ '--nest.a', 'a', '--nest.b', 'b', '--nest.str', 'str', '--nest', '{"path0": ["a0","b0","c0"], "path1": ["a1","b1","c1"], "path2": {"deep": ["a2","b2","c2"]}}', ], ) assert cfg.model_dump() == { 'nest': { 'alias_choice_w_path': 'a', 'alias_choice_w_only_path': 'b1', 'alias_choice_no_path': 'b', 'alias_path': 'b2', 'alias_str': 'str', } } def test_cli_alias_exceptions(capsys, monkeypatch): with pytest.raises(SettingsError, match='subcommand argument BadCliSubCommand.foo has multiple aliases'): class SubCmd(BaseModel): v0: int class BadCliSubCommand(BaseSettings): foo: CliSubCommand[SubCmd] = Field(validation_alias=AliasChoices('bar', 'boo')) CliApp.run(BadCliSubCommand) with pytest.raises(SettingsError, match='positional argument BadCliPositionalArg.foo has multiple alias'): class BadCliPositionalArg(BaseSettings): foo: CliPositionalArg[int] = Field(validation_alias=AliasChoices('bar', 'boo')) CliApp.run(BadCliPositionalArg) def test_cli_case_insensitive_arg(): class Cfg(BaseSettings, cli_exit_on_error=False): foo: str = Field(validation_alias=AliasChoices('F', 'Foo')) bar: str = Field(validation_alias=AliasChoices('B', 'Bar')) cfg = CliApp.run( Cfg, cli_args=[ '--FOO=--VAL', '--BAR', '"--VAL"', ], ) assert cfg.model_dump() == {'foo': '--VAL', 'bar': '"--VAL"'} cfg = CliApp.run( Cfg, cli_args=[ '-f=-V', '-b', '"-V"', ], ) assert cfg.model_dump() == {'foo': '-V', 'bar': '"-V"'} cfg = Cfg(_cli_parse_args=['--Foo=--VAL', '--Bar', '"--VAL"'], _case_sensitive=True) assert cfg.model_dump() == {'foo': '--VAL', 'bar': '"--VAL"'} cfg = Cfg(_cli_parse_args=['-F=-V', '-B', '"-V"'], _case_sensitive=True) assert cfg.model_dump() == {'foo': '-V', 'bar': '"-V"'} with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: --FOO=--VAL --BAR "--VAL"'): Cfg(_cli_parse_args=['--FOO=--VAL', '--BAR', '"--VAL"'], _case_sensitive=True) with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: -f=-V -b "-V"'): Cfg(_cli_parse_args=['-f=-V', '-b', '"-V"'], _case_sensitive=True) with pytest.raises(SettingsError, match='Case-insensitive matching is only supported on the internal root parser'): CliSettingsSource(Cfg, root_parser=CliDummyParser(), case_sensitive=False) def test_cli_help_differentiation(capsys, monkeypatch): class Cfg(BaseSettings): foo: str bar: int = 123 boo: int = Field(default_factory=lambda: 456) with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Cfg) assert ( re.sub(r'0x\w+', '0xffffffff', capsys.readouterr().out, flags=re.MULTILINE) == f"""usage: example.py [-h] [--foo str] [--bar int] [--boo int] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --foo str (required) --bar int (default: 123) --boo int (default factory: ) """ ) def test_cli_help_string_format(capsys, monkeypatch): class Cfg(BaseSettings, cli_parse_args=True): date_str: str = '%Y-%m-%d' class MultilineDoc(BaseSettings, cli_parse_args=True): """ My Multiline Doc """ with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): Cfg() assert ( re.sub(r'0x\w+', '0xffffffff', capsys.readouterr().out, flags=re.MULTILINE) == f"""usage: example.py [-h] [--date_str str] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --date_str str (default: %Y-%m-%d) """ ) with pytest.raises(SystemExit): MultilineDoc() assert ( capsys.readouterr().out == f"""usage: example.py [-h] My Multiline Doc {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit """ ) with pytest.raises(SystemExit): cli_settings_source = CliSettingsSource(MultilineDoc, formatter_class=argparse.HelpFormatter) MultilineDoc(_cli_settings_source=cli_settings_source(args=True)) assert ( capsys.readouterr().out == f"""usage: example.py [-h] My Multiline Doc {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit """ ) def test_cli_help_union_of_models(capsys, monkeypatch): class Cat(BaseModel): meow: str = 'meow' class Dog(BaseModel): bark: str = 'bark' class Bird(BaseModel): caww: str = 'caww' tweet: str class Tiger(Cat): roar: str = 'roar' class Car(BaseSettings, cli_parse_args=True): driver: Union[Cat, Dog, Bird] = Tiger(meow='purr') with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): Car() assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--driver JSON] [--driver.meow str] [--driver.bark str] [--driver.caww str] [--driver.tweet str] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit driver options: --driver JSON set driver from JSON string --driver.meow str (default: purr) --driver.bark str (default: bark) --driver.caww str (default: caww) --driver.tweet str (ifdef: required) """ ) def test_cli_help_default_or_none_model(capsys, monkeypatch): class DeeperSubModel(BaseModel): flag: bool class DeepSubModel(BaseModel): flag: bool deeper: Optional[DeeperSubModel] = None class SubModel(BaseModel): flag: bool deep: DeepSubModel = DeepSubModel(flag=True) class Settings(BaseSettings, cli_parse_args=True): flag: bool = True sub_model: SubModel = SubModel(flag=False) opt_model: Optional[DeepSubModel] = Field(None, description='Group Doc') fact_model: SubModel = Field(default_factory=lambda: SubModel(flag=True)) with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): Settings() assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--flag bool] [--sub_model JSON] [--sub_model.flag bool] [--sub_model.deep JSON] [--sub_model.deep.flag bool] [--sub_model.deep.deeper {{JSON,null}}] [--sub_model.deep.deeper.flag bool] [--opt_model {{JSON,null}}] [--opt_model.flag bool] [--opt_model.deeper {{JSON,null}}] [--opt_model.deeper.flag bool] [--fact_model JSON] [--fact_model.flag bool] [--fact_model.deep JSON] [--fact_model.deep.flag bool] [--fact_model.deep.deeper {{JSON,null}}] [--fact_model.deep.deeper.flag bool] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --flag bool (default: True) sub_model options: --sub_model JSON set sub_model from JSON string --sub_model.flag bool (default: False) sub_model.deep options: --sub_model.deep JSON set sub_model.deep from JSON string --sub_model.deep.flag bool (default: True) sub_model.deep.deeper options: default: null (undefined) --sub_model.deep.deeper {{JSON,null}} set sub_model.deep.deeper from JSON string --sub_model.deep.deeper.flag bool (ifdef: required) opt_model options: default: null (undefined) Group Doc --opt_model {{JSON,null}} set opt_model from JSON string --opt_model.flag bool (ifdef: required) opt_model.deeper options: default: null (undefined) --opt_model.deeper {{JSON,null}} set opt_model.deeper from JSON string --opt_model.deeper.flag bool (ifdef: required) fact_model options: --fact_model JSON set fact_model from JSON string --fact_model.flag bool (default factory: ) fact_model.deep options: --fact_model.deep JSON set fact_model.deep from JSON string --fact_model.deep.flag bool (default factory: ) fact_model.deep.deeper options: --fact_model.deep.deeper {{JSON,null}} set fact_model.deep.deeper from JSON string --fact_model.deep.deeper.flag bool (default factory: ) """ ) def test_cli_nested_dataclass_arg(): @pydantic_dataclasses.dataclass class MyDataclass: foo: int bar: str class Settings(BaseSettings): n: MyDataclass s = CliApp.run(Settings, cli_args=['--n.foo', '123', '--n.bar', 'bar value']) assert isinstance(s.n, MyDataclass) assert s.n.foo == 123 assert s.n.bar == 'bar value' def no_add_cli_arg_spaces(arg_str: str, has_quote_comma: bool = False) -> str: return arg_str def add_cli_arg_spaces(arg_str: str, has_quote_comma: bool = False) -> str: arg_str = arg_str.replace('[', ' [ ') arg_str = arg_str.replace(']', ' ] ') arg_str = arg_str.replace('{', ' { ') arg_str = arg_str.replace('}', ' } ') arg_str = arg_str.replace(':', ' : ') if not has_quote_comma: arg_str = arg_str.replace(',', ' , ') else: arg_str = arg_str.replace('",', '" , ') return f' {arg_str} ' @pytest.mark.parametrize('arg_spaces', [no_add_cli_arg_spaces, add_cli_arg_spaces]) @pytest.mark.parametrize('prefix', ['', 'child.']) def test_cli_list_arg(prefix, arg_spaces): class Obj(BaseModel): val: int class Child(BaseModel): num_list: Optional[List[int]] = None obj_list: Optional[List[Obj]] = None str_list: Optional[List[str]] = None union_list: Optional[List[Union[Obj, int]]] = None class Cfg(BaseSettings): num_list: Optional[List[int]] = None obj_list: Optional[List[Obj]] = None union_list: Optional[List[Union[Obj, int]]] = None str_list: Optional[List[str]] = None child: Optional[Child] = None def check_answer(cfg, prefix, expected): if prefix: assert cfg.model_dump() == { 'num_list': None, 'obj_list': None, 'union_list': None, 'str_list': None, 'child': expected, } else: expected['child'] = None assert cfg.model_dump() == expected args: List[str] = [] args = [f'--{prefix}num_list', arg_spaces('[1,2]')] args += [f'--{prefix}num_list', arg_spaces('3,4')] args += [f'--{prefix}num_list', '5', f'--{prefix}num_list', '6'] cfg = CliApp.run(Cfg, cli_args=args) expected = { 'num_list': [1, 2, 3, 4, 5, 6], 'obj_list': None, 'union_list': None, 'str_list': None, } check_answer(cfg, prefix, expected) args = [f'--{prefix}obj_list', arg_spaces('[{"val":1},{"val":2}]')] args += [f'--{prefix}obj_list', arg_spaces('{"val":3},{"val":4}')] args += [f'--{prefix}obj_list', arg_spaces('{"val":5}'), f'--{prefix}obj_list', arg_spaces('{"val":6}')] cfg = CliApp.run(Cfg, cli_args=args) expected = { 'num_list': None, 'obj_list': [{'val': 1}, {'val': 2}, {'val': 3}, {'val': 4}, {'val': 5}, {'val': 6}], 'union_list': None, 'str_list': None, } check_answer(cfg, prefix, expected) args = [f'--{prefix}union_list', arg_spaces('[{"val":1},2]'), f'--{prefix}union_list', arg_spaces('[3,{"val":4}]')] args += [f'--{prefix}union_list', arg_spaces('{"val":5},6'), f'--{prefix}union_list', arg_spaces('7,{"val":8}')] args += [f'--{prefix}union_list', arg_spaces('{"val":9}'), f'--{prefix}union_list', '10'] cfg = CliApp.run(Cfg, cli_args=args) expected = { 'num_list': None, 'obj_list': None, 'union_list': [{'val': 1}, 2, 3, {'val': 4}, {'val': 5}, 6, 7, {'val': 8}, {'val': 9}, 10], 'str_list': None, } check_answer(cfg, prefix, expected) args = [f'--{prefix}str_list', arg_spaces('["0,0","1,1"]', has_quote_comma=True)] args += [f'--{prefix}str_list', arg_spaces('"2,2","3,3"', has_quote_comma=True)] args += [ f'--{prefix}str_list', arg_spaces('"4,4"', has_quote_comma=True), f'--{prefix}str_list', arg_spaces('"5,5"', has_quote_comma=True), ] cfg = CliApp.run(Cfg, cli_args=args) expected = { 'num_list': None, 'obj_list': None, 'union_list': None, 'str_list': ['0,0', '1,1', '2,2', '3,3', '4,4', '5,5'], } check_answer(cfg, prefix, expected) @pytest.mark.parametrize('arg_spaces', [no_add_cli_arg_spaces, add_cli_arg_spaces]) def test_cli_list_json_value_parsing(arg_spaces): class Cfg(BaseSettings): json_list: List[Union[str, bool, None]] assert CliApp.run( Cfg, cli_args=[ '--json_list', arg_spaces('true,"true"'), '--json_list', arg_spaces('false,"false"'), '--json_list', arg_spaces('null,"null"'), '--json_list', arg_spaces('hi,"bye"'), ], ).model_dump() == {'json_list': [True, 'true', False, 'false', None, 'null', 'hi', 'bye']} assert CliApp.run(Cfg, cli_args=['--json_list', '"","","",""']).model_dump() == {'json_list': ['', '', '', '']} assert CliApp.run(Cfg, cli_args=['--json_list', ',,,']).model_dump() == {'json_list': ['', '', '', '']} @pytest.mark.parametrize('arg_spaces', [no_add_cli_arg_spaces, add_cli_arg_spaces]) @pytest.mark.parametrize('prefix', ['', 'child.']) def test_cli_dict_arg(prefix, arg_spaces): class Child(BaseModel): check_dict: Dict[str, str] class Cfg(BaseSettings): check_dict: Optional[Dict[str, str]] = None child: Optional[Child] = None args: List[str] = [] args = [f'--{prefix}check_dict', arg_spaces('{"k1":"a","k2":"b"}')] args += [f'--{prefix}check_dict', arg_spaces('{"k3":"c"},{"k4":"d"}')] args += [f'--{prefix}check_dict', arg_spaces('{"k5":"e"}'), f'--{prefix}check_dict', arg_spaces('{"k6":"f"}')] args += [f'--{prefix}check_dict', arg_spaces('[k7=g,k8=h]')] args += [f'--{prefix}check_dict', arg_spaces('k9=i,k10=j')] args += [f'--{prefix}check_dict', arg_spaces('k11=k'), f'--{prefix}check_dict', arg_spaces('k12=l')] args += [ f'--{prefix}check_dict', arg_spaces('[{"k13":"m"},k14=n]'), f'--{prefix}check_dict', arg_spaces('[k15=o,{"k16":"p"}]'), ] args += [ f'--{prefix}check_dict', arg_spaces('{"k17":"q"},k18=r'), f'--{prefix}check_dict', arg_spaces('k19=s,{"k20":"t"}'), ] args += [f'--{prefix}check_dict', arg_spaces('{"k21":"u"},k22=v,{"k23":"w"}')] args += [f'--{prefix}check_dict', arg_spaces('k24=x,{"k25":"y"},k26=z')] args += [f'--{prefix}check_dict', arg_spaces('[k27="x,y",k28="x,y"]', has_quote_comma=True)] args += [f'--{prefix}check_dict', arg_spaces('k29="x,y",k30="x,y"', has_quote_comma=True)] args += [ f'--{prefix}check_dict', arg_spaces('k31="x,y"', has_quote_comma=True), f'--{prefix}check_dict', arg_spaces('k32="x,y"', has_quote_comma=True), ] cfg = CliApp.run(Cfg, cli_args=args) expected: Dict[str, Any] = { 'check_dict': { 'k1': 'a', 'k2': 'b', 'k3': 'c', 'k4': 'd', 'k5': 'e', 'k6': 'f', 'k7': 'g', 'k8': 'h', 'k9': 'i', 'k10': 'j', 'k11': 'k', 'k12': 'l', 'k13': 'm', 'k14': 'n', 'k15': 'o', 'k16': 'p', 'k17': 'q', 'k18': 'r', 'k19': 's', 'k20': 't', 'k21': 'u', 'k22': 'v', 'k23': 'w', 'k24': 'x', 'k25': 'y', 'k26': 'z', 'k27': 'x,y', 'k28': 'x,y', 'k29': 'x,y', 'k30': 'x,y', 'k31': 'x,y', 'k32': 'x,y', } } if prefix: expected = {'check_dict': None, 'child': expected} else: expected['child'] = None assert cfg.model_dump() == expected with pytest.raises(SettingsError, match=f'Parsing error encountered for {prefix}check_dict: Mismatched quotes'): cfg = CliApp.run(Cfg, cli_args=[f'--{prefix}check_dict', 'k9="i']) with pytest.raises(SettingsError, match=f'Parsing error encountered for {prefix}check_dict: Mismatched quotes'): cfg = CliApp.run(Cfg, cli_args=[f'--{prefix}check_dict', 'k9=i"']) def test_cli_union_dict_arg(): class Cfg(BaseSettings): union_str_dict: Union[str, Dict[str, Any]] with pytest.raises(ValidationError) as exc_info: args = ['--union_str_dict', 'hello world', '--union_str_dict', 'hello world'] cfg = CliApp.run(Cfg, cli_args=args) assert exc_info.value.errors(include_url=False) == [ { 'input': [ 'hello world', 'hello world', ], 'loc': ( 'union_str_dict', 'str', ), 'msg': 'Input should be a valid string', 'type': 'string_type', }, { 'input': [ 'hello world', 'hello world', ], 'loc': ( 'union_str_dict', 'dict[str,any]', ), 'msg': 'Input should be a valid dictionary', 'type': 'dict_type', }, ] args = ['--union_str_dict', 'hello world'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_str_dict': 'hello world'} args = ['--union_str_dict', '{"hello": "world"}'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_str_dict': {'hello': 'world'}} args = ['--union_str_dict', 'hello=world'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_str_dict': {'hello': 'world'}} args = ['--union_str_dict', '"hello=world"'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_str_dict': 'hello=world'} class Cfg(BaseSettings): union_list_dict: Union[List[str], Dict[str, Any]] with pytest.raises(ValidationError) as exc_info: args = ['--union_list_dict', 'hello,world'] cfg = CliApp.run(Cfg, cli_args=args) assert exc_info.value.errors(include_url=False) == [ { 'input': 'hello,world', 'loc': ( 'union_list_dict', 'list[str]', ), 'msg': 'Input should be a valid list', 'type': 'list_type', }, { 'input': 'hello,world', 'loc': ( 'union_list_dict', 'dict[str,any]', ), 'msg': 'Input should be a valid dictionary', 'type': 'dict_type', }, ] args = ['--union_list_dict', 'hello,world', '--union_list_dict', 'hello,world'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': ['hello', 'world', 'hello', 'world']} args = ['--union_list_dict', '[hello,world]'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': ['hello', 'world']} args = ['--union_list_dict', '{"hello": "world"}'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': {'hello': 'world'}} args = ['--union_list_dict', 'hello=world'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': {'hello': 'world'}} with pytest.raises(ValidationError) as exc_info: args = ['--union_list_dict', '"hello=world"'] cfg = CliApp.run(Cfg, cli_args=args) assert exc_info.value.errors(include_url=False) == [ { 'input': 'hello=world', 'loc': ( 'union_list_dict', 'list[str]', ), 'msg': 'Input should be a valid list', 'type': 'list_type', }, { 'input': 'hello=world', 'loc': ( 'union_list_dict', 'dict[str,any]', ), 'msg': 'Input should be a valid dictionary', 'type': 'dict_type', }, ] args = ['--union_list_dict', '["hello=world"]'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'union_list_dict': ['hello=world']} def test_cli_nested_dict_arg(): class Cfg(BaseSettings): check_dict: Dict[str, Any] args = ['--check_dict', '{"k1":{"a": 1}},{"k2":{"b": 2}}'] cfg = CliApp.run(Cfg, cli_args=args) assert cfg.model_dump() == {'check_dict': {'k1': {'a': 1}, 'k2': {'b': 2}}} with pytest.raises( SettingsError, match=re.escape('Parsing error encountered for check_dict: not enough values to unpack (expected 2, got 1)'), ): args = ['--check_dict', '{"k1":{"a": 1}},"k2":{"b": 2}}'] cfg = CliApp.run(Cfg, cli_args=args) with pytest.raises(SettingsError, match='Parsing error encountered for check_dict: Missing end delimiter "}"'): args = ['--check_dict', '{"k1":{"a": 1}},{"k2":{"b": 2}'] cfg = CliApp.run(Cfg, cli_args=args) def test_cli_subcommand_union(capsys, monkeypatch): class AlphaCmd(BaseModel): """Alpha Help""" a: str class BetaCmd(BaseModel): """Beta Help""" b: str class GammaCmd(BaseModel): """Gamma Help""" g: str class Root1(BaseSettings): """Root Help""" subcommand: CliSubCommand[Union[AlphaCmd, BetaCmd, GammaCmd]] = Field(description='Field Help') alpha = CliApp.run(Root1, cli_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}} beta = CliApp.run(Root1, cli_args=['BetaCmd', '-b=beta']) assert get_subcommand(beta).model_dump() == {'b': 'beta'} assert beta.model_dump() == {'subcommand': {'b': 'beta'}} gamma = CliApp.run(Root1, cli_args=['GammaCmd', '-g=gamma']) assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}} with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Root1) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{AlphaCmd,BetaCmd,GammaCmd}} ... Root Help {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit subcommands: Field Help {{AlphaCmd,BetaCmd,GammaCmd}} AlphaCmd BetaCmd GammaCmd """ ) with pytest.raises(SystemExit): Root1(_cli_parse_args=True, _cli_use_class_docs_for_groups=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{AlphaCmd,BetaCmd,GammaCmd}} ... Root Help {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit subcommands: Field Help {{AlphaCmd,BetaCmd,GammaCmd}} AlphaCmd Alpha Help BetaCmd Beta Help GammaCmd Gamma Help """ ) class Root2(BaseSettings): """Root Help""" subcommand: CliSubCommand[Union[AlphaCmd, GammaCmd]] = Field(description='Field Help') beta: CliSubCommand[BetaCmd] = Field(description='Field Beta Help') alpha = CliApp.run(Root2, cli_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}, 'beta': None} beta = CliApp.run(Root2, cli_args=['beta', '-b=beta']) assert get_subcommand(beta).model_dump() == {'b': 'beta'} assert beta.model_dump() == {'subcommand': None, 'beta': {'b': 'beta'}} gamma = CliApp.run(Root2, cli_args=['GammaCmd', '-g=gamma']) assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}, 'beta': None} with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Root2, cli_args=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{AlphaCmd,GammaCmd,beta}} ... Root Help {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit subcommands: Field Help {{AlphaCmd,GammaCmd,beta}} AlphaCmd GammaCmd beta Field Beta Help """ ) with pytest.raises(SystemExit): Root2(_cli_parse_args=True, _cli_use_class_docs_for_groups=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{AlphaCmd,GammaCmd,beta}} ... Root Help {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit subcommands: Field Help {{AlphaCmd,GammaCmd,beta}} AlphaCmd Alpha Help GammaCmd Gamma Help beta Beta Help """ ) class Root3(BaseSettings): """Root Help""" beta: CliSubCommand[BetaCmd] = Field(description='Field Beta Help') subcommand: CliSubCommand[Union[AlphaCmd, GammaCmd]] = Field(description='Field Help') alpha = CliApp.run(Root3, cli_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}, 'beta': None} beta = CliApp.run(Root3, cli_args=['beta', '-b=beta']) assert get_subcommand(beta).model_dump() == {'b': 'beta'} assert beta.model_dump() == {'subcommand': None, 'beta': {'b': 'beta'}} gamma = CliApp.run(Root3, cli_args=['GammaCmd', '-g=gamma']) assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}, 'beta': None} with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Root3) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{beta,AlphaCmd,GammaCmd}} ... Root Help {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit subcommands: {{beta,AlphaCmd,GammaCmd}} beta Field Beta Help AlphaCmd GammaCmd """ ) with pytest.raises(SystemExit): Root3(_cli_parse_args=True, _cli_use_class_docs_for_groups=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {{beta,AlphaCmd,GammaCmd}} ... Root Help {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit subcommands: {{beta,AlphaCmd,GammaCmd}} beta Beta Help AlphaCmd Alpha Help GammaCmd Gamma Help """ ) def test_cli_subcommand_with_positionals(): @pydantic_dataclasses.dataclass class FooPlugin: my_feature: bool = False @pydantic_dataclasses.dataclass class BarPlugin: my_feature: bool = False bar = BarPlugin() with pytest.raises(SystemExit, match='Error: CLI subcommand is required but no subcommands were found.'): get_subcommand(bar) with pytest.raises(SettingsError, match='Error: CLI subcommand is required but no subcommands were found.'): get_subcommand(bar, cli_exit_on_error=False) @pydantic_dataclasses.dataclass class Plugins: foo: CliSubCommand[FooPlugin] bar: CliSubCommand[BarPlugin] class Clone(BaseModel): repository: CliPositionalArg[str] directory: CliPositionalArg[str] local: bool = False shared: bool = False class Init(BaseModel): directory: CliPositionalArg[str] quiet: bool = False bare: bool = False class Git(BaseSettings): clone: CliSubCommand[Clone] init: CliSubCommand[Init] plugins: CliSubCommand[Plugins] git = CliApp.run(Git, cli_args=[]) assert git.model_dump() == { 'clone': None, 'init': None, 'plugins': None, } assert get_subcommand(git, is_required=False) is None with pytest.raises(SystemExit, match='Error: CLI subcommand is required {clone, init, plugins}'): get_subcommand(git) with pytest.raises(SettingsError, match='Error: CLI subcommand is required {clone, init, plugins}'): get_subcommand(git, cli_exit_on_error=False) git = CliApp.run(Git, cli_args=['init', '--quiet', 'true', 'dir/path']) assert git.model_dump() == { 'clone': None, 'init': {'directory': 'dir/path', 'quiet': True, 'bare': False}, 'plugins': None, } assert get_subcommand(git) == git.init assert get_subcommand(git, is_required=False) == git.init git = CliApp.run(Git, cli_args=['clone', 'repo', '.', '--shared', 'true']) assert git.model_dump() == { 'clone': {'repository': 'repo', 'directory': '.', 'local': False, 'shared': True}, 'init': None, 'plugins': None, } assert get_subcommand(git) == git.clone assert get_subcommand(git, is_required=False) == git.clone git = CliApp.run(Git, cli_args=['plugins', 'bar']) assert git.model_dump() == { 'clone': None, 'init': None, 'plugins': {'foo': None, 'bar': {'my_feature': False}}, } assert get_subcommand(git) == git.plugins assert get_subcommand(git, is_required=False) == git.plugins assert get_subcommand(get_subcommand(git)) == git.plugins.bar assert get_subcommand(get_subcommand(git), is_required=False) == git.plugins.bar class NotModel: ... with pytest.raises( SettingsError, match='Error: NotModel is not subclass of BaseModel or pydantic.dataclasses.dataclass' ): get_subcommand(NotModel()) class NotSettingsConfigDict(BaseModel): model_config = ConfigDict(cli_exit_on_error='not a bool') with pytest.raises(SystemExit, match='Error: CLI subcommand is required but no subcommands were found.'): get_subcommand(NotSettingsConfigDict()) with pytest.raises(SettingsError, match='Error: CLI subcommand is required but no subcommands were found.'): get_subcommand(NotSettingsConfigDict(), cli_exit_on_error=False) def test_cli_union_similar_sub_models(): class ChildA(BaseModel): name: str = 'child a' diff_a: str = 'child a difference' class ChildB(BaseModel): name: str = 'child b' diff_b: str = 'child b difference' class Cfg(BaseSettings): child: Union[ChildA, ChildB] cfg = CliApp.run(Cfg, cli_args=['--child.name', 'new name a', '--child.diff_a', 'new diff a']) assert cfg.model_dump() == {'child': {'name': 'new name a', 'diff_a': 'new diff a'}} def test_cli_optional_positional_arg(env): class Main(BaseSettings): model_config = SettingsConfigDict( cli_parse_args=True, cli_enforce_required=True, ) value: CliPositionalArg[int] = 123 assert CliApp.run(Main, cli_args=[]).model_dump() == {'value': 123} env.set('VALUE', '456') assert CliApp.run(Main, cli_args=[]).model_dump() == {'value': 456} assert CliApp.run(Main, cli_args=['789']).model_dump() == {'value': 789} def test_cli_variadic_positional_arg(env): class MainRequired(BaseSettings): model_config = SettingsConfigDict(cli_parse_args=True) values: CliPositionalArg[List[int]] class MainOptional(MainRequired): values: CliPositionalArg[List[int]] = [1, 2, 3] assert CliApp.run(MainOptional, cli_args=[]).model_dump() == {'values': [1, 2, 3]} with pytest.raises(SettingsError, match='error parsing CLI: the following arguments are required: VALUES'): CliApp.run(MainRequired, cli_args=[], cli_exit_on_error=False) env.set('VALUES', '[4,5,6]') assert CliApp.run(MainOptional, cli_args=[]).model_dump() == {'values': [4, 5, 6]} with pytest.raises(SettingsError, match='error parsing CLI: the following arguments are required: VALUES'): CliApp.run(MainRequired, cli_args=[], cli_exit_on_error=False) assert CliApp.run(MainOptional, cli_args=['7', '8', '9']).model_dump() == {'values': [7, 8, 9]} assert CliApp.run(MainRequired, cli_args=['7', '8', '9']).model_dump() == {'values': [7, 8, 9]} def test_cli_enums(capsys, monkeypatch): class Pet(IntEnum): dog = 0 cat = 1 bird = 2 class Cfg(BaseSettings): pet: Pet = Pet.dog union_pet: Union[Pet, int] = 43 cfg = CliApp.run(Cfg, cli_args=['--pet', 'cat', '--union_pet', 'dog']) assert cfg.model_dump() == {'pet': Pet.cat, 'union_pet': Pet.dog} with pytest.raises(ValidationError) as exc_info: CliApp.run(Cfg, cli_args=['--pet', 'rock']) assert exc_info.value.errors(include_url=False) == [ { 'type': 'enum', 'loc': ('pet',), 'msg': 'Input should be 0, 1 or 2', 'input': 'rock', 'ctx': {'expected': '0, 1 or 2'}, } ] with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Cfg) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--pet {{dog,cat,bird}}] [--union_pet {{{{dog,cat,bird}},int}}] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --pet {{dog,cat,bird}} (default: dog) --union_pet {{{{dog,cat,bird}},int}} (default: 43) """ ) def test_cli_literals(): class Cfg(BaseSettings): pet: Literal['dog', 'cat', 'bird'] cfg = CliApp.run(Cfg, cli_args=['--pet', 'cat']) assert cfg.model_dump() == {'pet': 'cat'} with pytest.raises(ValidationError) as exc_info: CliApp.run(Cfg, cli_args=['--pet', 'rock']) assert exc_info.value.errors(include_url=False) == [ { 'ctx': {'expected': "'dog', 'cat' or 'bird'"}, 'type': 'literal_error', 'loc': ('pet',), 'msg': "Input should be 'dog', 'cat' or 'bird'", 'input': 'rock', } ] def test_cli_annotation_exceptions(monkeypatch): class SubCmdAlt(BaseModel): pass class SubCmd(BaseModel): pass with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises( SettingsError, match='CliSubCommand is not outermost annotation for SubCommandNotOutermost.subcmd' ): class SubCommandNotOutermost(BaseSettings, cli_parse_args=True): subcmd: Union[int, CliSubCommand[SubCmd]] SubCommandNotOutermost() with pytest.raises(SettingsError, match='subcommand argument SubCommandHasDefault.subcmd has a default value'): class SubCommandHasDefault(BaseSettings, cli_parse_args=True): subcmd: CliSubCommand[SubCmd] = SubCmd() SubCommandHasDefault() with pytest.raises( SettingsError, match='subcommand argument SubCommandMultipleTypes.subcmd has type not derived from BaseModel', ): class SubCommandMultipleTypes(BaseSettings, cli_parse_args=True): subcmd: CliSubCommand[Union[SubCmd, str]] SubCommandMultipleTypes() with pytest.raises( SettingsError, match='subcommand argument SubCommandNotModel.subcmd has type not derived from BaseModel' ): class SubCommandNotModel(BaseSettings, cli_parse_args=True): subcmd: CliSubCommand[str] SubCommandNotModel() with pytest.raises( SettingsError, match='CliPositionalArg is not outermost annotation for PositionalArgNotOutermost.pos_arg' ): class PositionalArgNotOutermost(BaseSettings, cli_parse_args=True): pos_arg: Union[int, CliPositionalArg[str]] PositionalArgNotOutermost() with pytest.raises( SettingsError, match='MultipleVariadicPositionialArgs has multiple variadic positonal arguments: strings, numbers', ): class MultipleVariadicPositionialArgs(BaseSettings, cli_parse_args=True): strings: CliPositionalArg[List[str]] numbers: CliPositionalArg[List[int]] MultipleVariadicPositionialArgs() with pytest.raises( SettingsError, match='VariadicPositionialArgAndSubCommand has variadic positonal arguments and subcommand arguments: strings, sub_cmd', ): class VariadicPositionialArgAndSubCommand(BaseSettings, cli_parse_args=True): strings: CliPositionalArg[List[str]] sub_cmd: CliSubCommand[SubCmd] VariadicPositionialArgAndSubCommand() with pytest.raises( SettingsError, match=re.escape("cli_parse_args must be List[str] or Tuple[str, ...], recieved ") ): class InvalidCliParseArgsType(BaseSettings, cli_parse_args='invalid type'): val: int InvalidCliParseArgsType() with pytest.raises(SettingsError, match='CliExplicitFlag argument CliFlagNotBool.flag is not of type bool'): class CliFlagNotBool(BaseSettings, cli_parse_args=True): flag: CliExplicitFlag[int] = False CliFlagNotBool() if sys.version_info < (3, 9): with pytest.raises( SettingsError, match='CliImplicitFlag argument CliFlag38NotOpt.flag must have default for python versions < 3.9', ): class CliFlag38NotOpt(BaseSettings, cli_parse_args=True): flag: CliImplicitFlag[bool] CliFlag38NotOpt() @pytest.mark.parametrize('enforce_required', [True, False]) def test_cli_bool_flags(monkeypatch, enforce_required): if sys.version_info < (3, 9): class ExplicitSettings(BaseSettings, cli_enforce_required=enforce_required): explicit_req: bool explicit_opt: bool = False implicit_opt: CliImplicitFlag[bool] = False class ImplicitSettings(BaseSettings, cli_implicit_flags=True, cli_enforce_required=enforce_required): explicit_req: bool explicit_opt: CliExplicitFlag[bool] = False implicit_opt: bool = False expected = { 'explicit_req': True, 'explicit_opt': False, 'implicit_opt': False, } assert CliApp.run(ExplicitSettings, cli_args=['--explicit_req=True']).model_dump() == expected assert CliApp.run(ImplicitSettings, cli_args=['--explicit_req=True']).model_dump() == expected else: class ExplicitSettings(BaseSettings, cli_enforce_required=enforce_required): explicit_req: bool explicit_opt: bool = False implicit_req: CliImplicitFlag[bool] implicit_opt: CliImplicitFlag[bool] = False class ImplicitSettings(BaseSettings, cli_implicit_flags=True, cli_enforce_required=enforce_required): explicit_req: CliExplicitFlag[bool] explicit_opt: CliExplicitFlag[bool] = False implicit_req: bool implicit_opt: bool = False expected = { 'explicit_req': True, 'explicit_opt': False, 'implicit_req': True, 'implicit_opt': False, } assert CliApp.run(ExplicitSettings, cli_args=['--explicit_req=True', '--implicit_req']).model_dump() == expected assert CliApp.run(ImplicitSettings, cli_args=['--explicit_req=True', '--implicit_req']).model_dump() == expected def test_cli_avoid_json(capsys, monkeypatch): class SubModel(BaseModel): v1: int class Settings(BaseSettings): sub_model: SubModel model_config = SettingsConfigDict(cli_parse_args=True) with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): Settings(_cli_avoid_json=False) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--sub_model JSON] [--sub_model.v1 int] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit sub_model options: --sub_model JSON set sub_model from JSON string --sub_model.v1 int (required) """ ) with pytest.raises(SystemExit): Settings(_cli_avoid_json=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--sub_model.v1 int] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit sub_model options: --sub_model.v1 int (required) """ ) def test_cli_remove_empty_groups(capsys, monkeypatch): class SubModel(BaseModel): pass class Settings(BaseSettings): sub_model: SubModel model_config = SettingsConfigDict(cli_parse_args=True) with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): Settings(_cli_avoid_json=False) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--sub_model JSON] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit sub_model options: --sub_model JSON set sub_model from JSON string """ ) with pytest.raises(SystemExit): Settings(_cli_avoid_json=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit """ ) def test_cli_hide_none_type(capsys, monkeypatch): class Settings(BaseSettings): v0: Optional[str] model_config = SettingsConfigDict(cli_parse_args=True) with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): Settings(_cli_hide_none_type=False) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--v0 {{str,null}}] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --v0 {{str,null}} (required) """ ) with pytest.raises(SystemExit): Settings(_cli_hide_none_type=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--v0 str] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --v0 str (required) """ ) def test_cli_use_class_docs_for_groups(capsys, monkeypatch): class SubModel(BaseModel): """The help text from the class docstring""" v1: int class Settings(BaseSettings): """My application help text.""" sub_model: SubModel = Field(description='The help text from the field description') model_config = SettingsConfigDict(cli_parse_args=True) with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): Settings(_cli_use_class_docs_for_groups=False) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--sub_model JSON] [--sub_model.v1 int] My application help text. {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit sub_model options: The help text from the field description --sub_model JSON set sub_model from JSON string --sub_model.v1 int (required) """ ) with pytest.raises(SystemExit): Settings(_cli_use_class_docs_for_groups=True) assert ( capsys.readouterr().out == f"""usage: example.py [-h] [--sub_model JSON] [--sub_model.v1 int] My application help text. {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit sub_model options: The help text from the class docstring --sub_model JSON set sub_model from JSON string --sub_model.v1 int (required) """ ) def test_cli_enforce_required(env): class Settings(BaseSettings, cli_exit_on_error=False): my_required_field: str env.set('MY_REQUIRED_FIELD', 'hello from environment') assert Settings(_cli_parse_args=[], _cli_enforce_required=False).model_dump() == { 'my_required_field': 'hello from environment' } with pytest.raises( SettingsError, match='error parsing CLI: the following arguments are required: --my_required_field' ): Settings(_cli_parse_args=[], _cli_enforce_required=True).model_dump() def test_cli_exit_on_error(capsys, monkeypatch): class Settings(BaseSettings, cli_parse_args=True): ... with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--bad-arg']) with pytest.raises(SystemExit): Settings() assert ( capsys.readouterr().err == """usage: example.py [-h] example.py: error: unrecognized arguments: --bad-arg """ ) with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: --bad-arg'): CliApp.run(Settings, cli_exit_on_error=False) def test_cli_ignore_unknown_args(): class Cfg(BaseSettings, cli_ignore_unknown_args=True): this: str = 'hello' that: int = 123 cfg = CliApp.run(Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456']) assert cfg.model_dump() == {'this': 'hello', 'that': 123} cfg = CliApp.run( Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456', '--this=goodbye', '--that=789'] ) assert cfg.model_dump() == {'this': 'goodbye', 'that': 789} def test_cli_flag_prefix_char(): class Cfg(BaseSettings, cli_flag_prefix_char='+'): my_var: str = Field(validation_alias=AliasChoices('m', 'my-var')) cfg = CliApp.run(Cfg, cli_args=['++my-var=hello']) assert cfg.model_dump() == {'my_var': 'hello'} cfg = CliApp.run(Cfg, cli_args=['+m=hello']) assert cfg.model_dump() == {'my_var': 'hello'} @pytest.mark.parametrize('parser_type', [pytest.Parser, argparse.ArgumentParser, CliDummyParser]) @pytest.mark.parametrize('prefix', ['', 'cfg']) def test_cli_user_settings_source(parser_type, prefix): class Cfg(BaseSettings): pet: Literal['dog', 'cat', 'bird'] = 'bird' if parser_type is pytest.Parser: parser = pytest.Parser(_ispytest=True) parse_args = parser.parse add_arg = parser.addoption cli_cfg_settings = CliSettingsSource( Cfg, cli_prefix=prefix, root_parser=parser, parse_args_method=pytest.Parser.parse, add_argument_method=pytest.Parser.addoption, add_argument_group_method=pytest.Parser.getgroup, add_parser_method=None, add_subparsers_method=None, formatter_class=None, ) elif parser_type is CliDummyParser: parser = CliDummyParser() parse_args = parser.parse_args add_arg = parser.add_argument cli_cfg_settings = CliSettingsSource( Cfg, cli_prefix=prefix, root_parser=parser, parse_args_method=CliDummyParser.parse_args, add_argument_method=CliDummyParser.add_argument, add_argument_group_method=CliDummyParser.add_argument_group, add_parser_method=CliDummySubParsers.add_parser, add_subparsers_method=CliDummyParser.add_subparsers, ) else: parser = argparse.ArgumentParser() parse_args = parser.parse_args add_arg = parser.add_argument cli_cfg_settings = CliSettingsSource(Cfg, cli_prefix=prefix, root_parser=parser) add_arg('--fruit', choices=['pear', 'kiwi', 'lime']) add_arg('--num-list', action='append', type=int) add_arg('--num', type=int) args = ['--fruit', 'pear', '--num', '0', '--num-list', '1', '--num-list', '2', '--num-list', '3'] parsed_args = parse_args(args) assert CliApp.run(Cfg, cli_args=parsed_args, cli_settings_source=cli_cfg_settings).model_dump() == {'pet': 'bird'} assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == {'pet': 'bird'} assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == {'pet': 'bird'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == {'pet': 'bird'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=False)).model_dump() == {'pet': 'bird'} arg_prefix = f'{prefix}.' if prefix else '' args = [ '--fruit', 'kiwi', '--num', '0', '--num-list', '1', '--num-list', '2', '--num-list', '3', f'--{arg_prefix}pet', 'dog', ] parsed_args = parse_args(args) assert CliApp.run(Cfg, cli_args=parsed_args, cli_settings_source=cli_cfg_settings).model_dump() == {'pet': 'dog'} assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == {'pet': 'dog'} assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == {'pet': 'dog'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == {'pet': 'dog'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=False)).model_dump() == {'pet': 'bird'} parsed_args = parse_args( [ '--fruit', 'kiwi', '--num', '0', '--num-list', '1', '--num-list', '2', '--num-list', '3', f'--{arg_prefix}pet', 'cat', ] ) assert CliApp.run(Cfg, cli_args=vars(parsed_args), cli_settings_source=cli_cfg_settings).model_dump() == { 'pet': 'cat' } assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=vars(parsed_args))).model_dump() == {'pet': 'cat'} assert Cfg(_cli_settings_source=cli_cfg_settings(args=False)).model_dump() == {'pet': 'bird'} @pytest.mark.parametrize('prefix', ['', 'cfg']) def test_cli_dummy_user_settings_with_subcommand(prefix): class DogCommands(BaseModel): name: str = 'Bob' command: Literal['roll', 'bark', 'sit'] = 'sit' class Cfg(BaseSettings): pet: Literal['dog', 'cat', 'bird'] = 'bird' command: CliSubCommand[DogCommands] parser = CliDummyParser() cli_cfg_settings = CliSettingsSource( Cfg, root_parser=parser, cli_prefix=prefix, parse_args_method=CliDummyParser.parse_args, add_argument_method=CliDummyParser.add_argument, add_argument_group_method=CliDummyParser.add_argument_group, add_parser_method=CliDummySubParsers.add_parser, add_subparsers_method=CliDummyParser.add_subparsers, ) parser.add_argument('--fruit', choices=['pear', 'kiwi', 'lime']) args = ['--fruit', 'pear'] parsed_args = parser.parse_args(args) assert CliApp.run(Cfg, cli_args=parsed_args, cli_settings_source=cli_cfg_settings).model_dump() == { 'pet': 'bird', 'command': None, } assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == { 'pet': 'bird', 'command': None, } arg_prefix = f'{prefix}.' if prefix else '' args = ['--fruit', 'kiwi', f'--{arg_prefix}pet', 'dog'] parsed_args = parser.parse_args(args) assert CliApp.run(Cfg, cli_args=parsed_args, cli_settings_source=cli_cfg_settings).model_dump() == { 'pet': 'dog', 'command': None, } assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == { 'pet': 'dog', 'command': None, } parsed_args = parser.parse_args(['--fruit', 'kiwi', f'--{arg_prefix}pet', 'cat']) assert CliApp.run(Cfg, cli_args=vars(parsed_args), cli_settings_source=cli_cfg_settings).model_dump() == { 'pet': 'cat', 'command': None, } args = ['--fruit', 'kiwi', f'--{arg_prefix}pet', 'dog', 'command', '--name', 'ralph', '--command', 'roll'] parsed_args = parser.parse_args(args) assert CliApp.run(Cfg, cli_args=vars(parsed_args), cli_settings_source=cli_cfg_settings).model_dump() == { 'pet': 'dog', 'command': {'name': 'ralph', 'command': 'roll'}, } assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == { 'pet': 'dog', 'command': {'name': 'ralph', 'command': 'roll'}, } def test_cli_user_settings_source_exceptions(): class Cfg(BaseSettings): pet: Literal['dog', 'cat', 'bird'] = 'bird' with pytest.raises(SettingsError, match='`args` and `parsed_args` are mutually exclusive'): args = ['--pet', 'dog'] parsed_args = {'pet': 'dog'} cli_cfg_settings = CliSettingsSource(Cfg) Cfg(_cli_settings_source=cli_cfg_settings(args=args, parsed_args=parsed_args)) with pytest.raises(SettingsError, match='CLI settings source prefix is invalid: .cfg'): CliSettingsSource(Cfg, cli_prefix='.cfg') with pytest.raises(SettingsError, match='CLI settings source prefix is invalid: cfg.'): CliSettingsSource(Cfg, cli_prefix='cfg.') with pytest.raises(SettingsError, match='CLI settings source prefix is invalid: 123'): CliSettingsSource(Cfg, cli_prefix='123') class Food(BaseModel): fruit: FruitsEnum = FruitsEnum.kiwi class CfgWithSubCommand(BaseSettings): pet: Literal['dog', 'cat', 'bird'] = 'bird' food: CliSubCommand[Food] with pytest.raises( SettingsError, match='cannot connect CLI settings source root parser: add_subparsers_method is set to `None` but is needed for connecting', ): CliSettingsSource(CfgWithSubCommand, add_subparsers_method=None) @pytest.mark.parametrize( 'value,expected', [ (str, 'str'), ('foobar', 'str'), ('SomeForwardRefString', 'str'), # included to document current behavior; could be changed (List['SomeForwardRef'], "List[ForwardRef('SomeForwardRef')]"), # noqa: F821 (Union[str, int], '{str,int}'), (list, 'list'), (List, 'List'), ([1, 2, 3], 'list'), (List[Dict[str, int]], 'List[Dict[str,int]]'), (Tuple[str, int, float], 'Tuple[str,int,float]'), (Tuple[str, ...], 'Tuple[str,...]'), (Union[int, List[str], Tuple[str, int]], '{int,List[str],Tuple[str,int]}'), (foobar, 'foobar'), (LoggedVar, 'LoggedVar'), (LoggedVar(), 'LoggedVar'), (Representation(), 'Representation()'), (typing.Literal[1, 2, 3], '{1,2,3}'), (typing_extensions.Literal[1, 2, 3], '{1,2,3}'), (typing.Literal['a', 'b', 'c'], '{a,b,c}'), (typing_extensions.Literal['a', 'b', 'c'], '{a,b,c}'), (SimpleSettings, 'JSON'), (Union[SimpleSettings, SettingWithIgnoreEmpty], 'JSON'), (Union[SimpleSettings, str, SettingWithIgnoreEmpty], '{JSON,str}'), (Union[str, SimpleSettings, SettingWithIgnoreEmpty], '{str,JSON}'), (Annotated[SimpleSettings, 'annotation'], 'JSON'), (DirectoryPath, 'Path'), (FruitsEnum, '{pear,kiwi,lime}'), (time.time_ns, 'time_ns'), (foobar, 'foobar'), (CliDummyParser.add_argument, 'CliDummyParser.add_argument'), ], ) @pytest.mark.parametrize('hide_none_type', [True, False]) def test_cli_metavar_format(hide_none_type, value, expected): cli_settings = CliSettingsSource(SimpleSettings, cli_hide_none_type=hide_none_type) if hide_none_type: if value == [1, 2, 3] or isinstance(value, LoggedVar) or isinstance(value, Representation): pytest.skip() if value in ('foobar', 'SomeForwardRefString'): expected = f"ForwardRef('{value}')" # forward ref implicit cast if typing_extensions.get_origin(value) is Union: args = typing_extensions.get_args(value) value = Union[args + (None,) if args else (value, None)] else: value = Union[(value, None)] assert cli_settings._metavar_format(value) == expected @pytest.mark.skipif(sys.version_info < (3, 10), reason='requires python 3.10 or higher') @pytest.mark.parametrize( 'value_gen,expected', [ (lambda: str | int, '{str,int}'), (lambda: list[int], 'list[int]'), (lambda: List[int], 'List[int]'), (lambda: list[dict[str, int]], 'list[dict[str,int]]'), (lambda: list[Union[str, int]], 'list[{str,int}]'), (lambda: list[str | int], 'list[{str,int}]'), (lambda: LoggedVar[int], 'LoggedVar[int]'), (lambda: LoggedVar[Dict[int, str]], 'LoggedVar[Dict[int,str]]'), ], ) @pytest.mark.parametrize('hide_none_type', [True, False]) def test_cli_metavar_format_310(hide_none_type, value_gen, expected): value = value_gen() cli_settings = CliSettingsSource(SimpleSettings, cli_hide_none_type=hide_none_type) if hide_none_type: if typing_extensions.get_origin(value) is Union: args = typing_extensions.get_args(value) value = Union[args + (None,) if args else (value, None)] else: value = Union[(value, None)] assert cli_settings._metavar_format(value) == expected @pytest.mark.skipif(sys.version_info < (3, 12), reason='requires python 3.12 or higher') def test_cli_metavar_format_type_alias_312(): exec( """ type TypeAliasInt = int assert CliSettingsSource(SimpleSettings)._metavar_format(TypeAliasInt) == 'TypeAliasInt' """ ) def test_cli_app(): class Init(BaseModel): directory: CliPositionalArg[str] def cli_cmd(self) -> None: self.directory = 'ran Init.cli_cmd' def alt_cmd(self) -> None: self.directory = 'ran Init.alt_cmd' class Clone(BaseModel): repository: CliPositionalArg[str] directory: CliPositionalArg[str] def cli_cmd(self) -> None: self.repository = 'ran Clone.cli_cmd' def alt_cmd(self) -> None: self.repository = 'ran Clone.alt_cmd' class Git(BaseModel): clone: CliSubCommand[Clone] init: CliSubCommand[Init] def cli_cmd(self) -> None: CliApp.run_subcommand(self) def alt_cmd(self) -> None: CliApp.run_subcommand(self, cli_cmd_method_name='alt_cmd') assert CliApp.run(Git, cli_args=['init', 'dir']).model_dump() == { 'clone': None, 'init': {'directory': 'ran Init.cli_cmd'}, } assert CliApp.run(Git, cli_args=['init', 'dir'], cli_cmd_method_name='alt_cmd').model_dump() == { 'clone': None, 'init': {'directory': 'ran Init.alt_cmd'}, } assert CliApp.run(Git, cli_args=['clone', 'repo', 'dir']).model_dump() == { 'clone': {'repository': 'ran Clone.cli_cmd', 'directory': 'dir'}, 'init': None, } assert CliApp.run(Git, cli_args=['clone', 'repo', 'dir'], cli_cmd_method_name='alt_cmd').model_dump() == { 'clone': {'repository': 'ran Clone.alt_cmd', 'directory': 'dir'}, 'init': None, } def test_cli_app_async_method_no_existing_loop(): class Command(BaseSettings): called: bool = False async def cli_cmd(self) -> None: self.called = True assert CliApp.run(Command, cli_args=[]).called def test_cli_app_async_method_with_existing_loop(): class Command(BaseSettings): called: bool = False async def cli_cmd(self) -> None: self.called = True async def run_as_coro(): return CliApp.run(Command, cli_args=[]) assert asyncio.run(run_as_coro()).called def test_cli_app_exceptions(): with pytest.raises( SettingsError, match='Error: NotPydanticModel is not subclass of BaseModel or pydantic.dataclasses.dataclass' ): class NotPydanticModel: ... CliApp.run(NotPydanticModel) with pytest.raises( SettingsError, match=re.escape('Error: `cli_args` must be list[str] or None when `cli_settings_source` is not used'), ): class Cfg(BaseModel): ... CliApp.run(Cfg, cli_args={'my_arg': 'hello'}) with pytest.raises(SettingsError, match='Error: Child class is missing cli_cmd entrypoint'): class Child(BaseModel): val: str class Root(BaseModel): child: CliSubCommand[Child] def cli_cmd(self) -> None: CliApp.run_subcommand(self) CliApp.run(Root, cli_args=['child', '--val=hello']) def test_cli_suppress(capsys, monkeypatch): class Settings(BaseSettings, cli_parse_args=True): field_a: CliSuppress[int] = 0 field_b: str = Field(default=1, description=CLI_SUPPRESS) with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Settings) assert ( capsys.readouterr().out == f"""usage: example.py [-h] {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit """ ) def test_cli_mutually_exclusive_group(capsys, monkeypatch): class Circle(CliMutuallyExclusiveGroup): radius: Optional[float] = 21 diameter: Optional[float] = 22 perimeter: Optional[float] = 23 class Settings(BaseModel): circle_optional: Circle = Circle(radius=None, diameter=None, perimeter=24) circle_required: Circle CliApp.run(Settings, cli_args=['--circle-required.radius=1', '--circle-optional.radius=1']).model_dump() == { 'circle_optional': {'radius': 1, 'diameter': 22, 'perimeter': 24}, 'circle_required': {'radius': 1, 'diameter': 22, 'perimeter': 23}, } with pytest.raises(SystemExit): CliApp.run(Settings, cli_args=['--circle-required.radius=1', '--circle-required.diameter=2']) assert ( 'error: argument --circle-required.diameter: not allowed with argument --circle-required.radius' in capsys.readouterr().err ) with pytest.raises(SystemExit): CliApp.run( Settings, cli_args=['--circle-required.radius=1', '--circle-optional.radius=1', '--circle-optional.diameter=2'], ) assert ( 'error: argument --circle-optional.diameter: not allowed with argument --circle-optional.radius' in capsys.readouterr().err ) with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Settings) usage = ( """usage: example.py [-h] [--circle-optional.radius float | --circle-optional.diameter float | --circle-optional.perimeter float] (--circle-required.radius float | --circle-required.diameter float | --circle-required.perimeter float)""" if sys.version_info >= (3, 13) else """usage: example.py [-h] [--circle-optional.radius float | --circle-optional.diameter float | --circle-optional.perimeter float] (--circle-required.radius float | --circle-required.diameter float | --circle-required.perimeter float)""" ) assert ( capsys.readouterr().out == f"""{usage} {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit circle-optional options (mutually exclusive): --circle-optional.radius float (default: None) --circle-optional.diameter float (default: None) --circle-optional.perimeter float (default: 24.0) circle-required options (mutually exclusive): --circle-required.radius float (default: 21) --circle-required.diameter float (default: 22) --circle-required.perimeter float (default: 23) """ ) def test_cli_mutually_exclusive_group_exceptions(): class Circle(CliMutuallyExclusiveGroup): radius: Optional[float] = 21 diameter: Optional[float] = 22 perimeter: Optional[float] = 23 class Settings(BaseSettings): circle: Circle parser = CliDummyParser() with pytest.raises( SettingsError, match='cannot connect CLI settings source root parser: group object is missing add_mutually_exclusive_group but is needed for connecting', ): CliSettingsSource( Settings, root_parser=parser, parse_args_method=CliDummyParser.parse_args, add_argument_method=CliDummyParser.add_argument, add_argument_group_method=CliDummyParser.add_argument_group, add_parser_method=CliDummySubParsers.add_parser, add_subparsers_method=CliDummyParser.add_subparsers, ) class SubModel(BaseModel): pass class SettingsInvalidUnion(BaseSettings): union: Union[Circle, SubModel] with pytest.raises(SettingsError, match='cannot use union with CliMutuallyExclusiveGroup'): CliApp.run(SettingsInvalidUnion) class CircleInvalidSubModel(Circle): square: Optional[SubModel] = None class SettingsInvalidOptSubModel(BaseModel): circle: CircleInvalidSubModel = CircleInvalidSubModel() class SettingsInvalidReqSubModel(BaseModel): circle: CircleInvalidSubModel for settings in [SettingsInvalidOptSubModel, SettingsInvalidReqSubModel]: with pytest.raises(SettingsError, match='cannot have nested models in a CliMutuallyExclusiveGroup'): CliApp.run(settings) class CircleRequiredField(Circle): length: float class SettingsOptCircleReqField(BaseModel): circle: CircleRequiredField = CircleRequiredField(length=2) assert CliApp.run(SettingsOptCircleReqField, cli_args=[]).model_dump() == { 'circle': {'diameter': 22.0, 'length': 2.0, 'perimeter': 23.0, 'radius': 21.0} } class SettingsInvalidReqCircleReqField(BaseModel): circle: CircleRequiredField with pytest.raises(ValueError, match='mutually exclusive arguments must be optional'): CliApp.run(SettingsInvalidReqCircleReqField) def test_cli_invalid_abbrev(): class MySettings(BaseSettings): bacon: str = '' badger: str = '' with pytest.raises( SettingsError, match='error parsing CLI: unrecognized arguments: --bac cli abbrev are invalid for internal parser', ): CliApp.run( MySettings, cli_args=['--bac', 'cli abbrev are invalid for internal parser'], cli_exit_on_error=False ) def test_cli_submodels_strip_annotated(): class PolyA(BaseModel): a: int = 1 type: Literal['a'] = 'a' class PolyB(BaseModel): b: str = '2' type: Literal['b'] = 'b' def _get_type(model: Union[BaseModel, Dict]) -> str: if isinstance(model, dict): return model.get('type', 'a') return model.type # type: ignore Poly = Annotated[Union[Annotated[PolyA, Tag('a')], Annotated[PolyB, Tag('b')]], Discriminator(_get_type)] class WithUnion(BaseSettings): poly: Poly assert CliApp.run(WithUnion, ['--poly.type=a']).model_dump() == {'poly': {'a': 1, 'type': 'a'}} def test_cli_kebab_case(capsys, monkeypatch): class DeepSubModel(BaseModel): deep_pos_arg: CliPositionalArg[str] deep_arg: str class SubModel(BaseModel): sub_subcmd: CliSubCommand[DeepSubModel] sub_arg: str class Root(BaseModel): root_subcmd: CliSubCommand[SubModel] root_arg: str assert CliApp.run( Root, cli_args=[ '--root-arg=hi', 'root-subcmd', '--sub-arg=hello', 'sub-subcmd', 'hey', '--deep-arg=bye', ], ).model_dump() == { 'root_arg': 'hi', 'root_subcmd': { 'sub_arg': 'hello', 'sub_subcmd': {'deep_pos_arg': 'hey', 'deep_arg': 'bye'}, }, } with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) with pytest.raises(SystemExit): CliApp.run(Root) assert ( capsys.readouterr().out == f"""usage: example.py [-h] --root-arg str {{root-subcmd}} ... {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --root-arg str (required) subcommands: {{root-subcmd}} root-subcmd """ ) m.setattr(sys, 'argv', ['example.py', 'root-subcmd', '--help']) with pytest.raises(SystemExit): CliApp.run(Root) assert ( capsys.readouterr().out == f"""usage: example.py root-subcmd [-h] --sub-arg str {{sub-subcmd}} ... {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --sub-arg str (required) subcommands: {{sub-subcmd}} sub-subcmd """ ) m.setattr(sys, 'argv', ['example.py', 'root-subcmd', 'sub-subcmd', '--help']) with pytest.raises(SystemExit): CliApp.run(Root) assert ( capsys.readouterr().out == f"""usage: example.py root-subcmd sub-subcmd [-h] --deep-arg str DEEP-POS-ARG positional arguments: DEEP-POS-ARG {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit --deep-arg str (required) """ ) pydantic-pydantic-settings-5f33b62/tests/test_source_json.py000066400000000000000000000056301476003445400244470ustar00rootroot00000000000000""" Test pydantic_settings.JsonConfigSettingsSource. """ import json from pathlib import Path from typing import Tuple, Type, Union from pydantic import BaseModel from pydantic_settings import ( BaseSettings, JsonConfigSettingsSource, PydanticBaseSettingsSource, SettingsConfigDict, ) def test_repr() -> None: source = JsonConfigSettingsSource(BaseSettings(), Path('config.json')) assert repr(source) == 'JsonConfigSettingsSource(json_file=config.json)' def test_json_file(tmp_path): p = tmp_path / '.env' p.write_text( """ {"foobar": "Hello", "nested": {"nested_field": "world!"}, "null_field": null} """ ) class Nested(BaseModel): nested_field: str class Settings(BaseSettings): model_config = SettingsConfigDict(json_file=p) foobar: str nested: Nested null_field: Union[str, None] @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (JsonConfigSettingsSource(settings_cls),) s = Settings() assert s.foobar == 'Hello' assert s.nested.nested_field == 'world!' def test_json_no_file(): class Settings(BaseSettings): model_config = SettingsConfigDict(json_file=None) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (JsonConfigSettingsSource(settings_cls),) s = Settings() assert s.model_dump() == {} def test_multiple_file_json(tmp_path): p5 = tmp_path / '.env.json5' p6 = tmp_path / '.env.json6' with open(p5, 'w') as f5: json.dump({'json5': 5}, f5) with open(p6, 'w') as f6: json.dump({'json6': 6}, f6) class Settings(BaseSettings): json5: int json6: int @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (JsonConfigSettingsSource(settings_cls, json_file=[p5, p6]),) s = Settings() assert s.model_dump() == {'json5': 5, 'json6': 6} pydantic-pydantic-settings-5f33b62/tests/test_source_pyproject_toml.py000066400000000000000000000246301476003445400265510ustar00rootroot00000000000000""" Test pydantic_settings.PyprojectTomlConfigSettingsSource. """ import sys from pathlib import Path from typing import Optional, Tuple, Type import pytest from pydantic import BaseModel from pytest_mock import MockerFixture from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, PyprojectTomlConfigSettingsSource, SettingsConfigDict, ) try: import tomli except ImportError: tomli = None MODULE = 'pydantic_settings.sources' SOME_TOML_DATA = """ field = "top-level" [some] [some.table] field = "some" [other.table] field = "other" """ class SimpleSettings(BaseSettings): """Simple settings.""" model_config = SettingsConfigDict(pyproject_toml_depth=1, pyproject_toml_table_header=('some', 'table')) @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') class TestPyprojectTomlConfigSettingsSource: """Test PyprojectTomlConfigSettingsSource.""" def test___init__(self, mocker: MockerFixture, tmp_path: Path) -> None: """Test __init__.""" mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path) pyproject = tmp_path / 'pyproject.toml' pyproject.write_text(SOME_TOML_DATA) obj = PyprojectTomlConfigSettingsSource(SimpleSettings) assert obj.toml_table_header == ('some', 'table') assert obj.toml_data == {'field': 'some'} assert obj.toml_file_path == tmp_path / 'pyproject.toml' def test___init___explicit(self, mocker: MockerFixture, tmp_path: Path) -> None: """Test __init__ explicit file.""" mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path) pyproject = tmp_path / 'child' / 'pyproject.toml' pyproject.parent.mkdir() pyproject.write_text(SOME_TOML_DATA) obj = PyprojectTomlConfigSettingsSource(SimpleSettings, pyproject) assert obj.toml_table_header == ('some', 'table') assert obj.toml_data == {'field': 'some'} assert obj.toml_file_path == pyproject def test___init___explicit_missing(self, mocker: MockerFixture, tmp_path: Path) -> None: """Test __init__ explicit file missing.""" mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path) pyproject = tmp_path / 'child' / 'pyproject.toml' obj = PyprojectTomlConfigSettingsSource(SimpleSettings, pyproject) assert obj.toml_table_header == ('some', 'table') assert not obj.toml_data assert obj.toml_file_path == pyproject @pytest.mark.parametrize('depth', [0, 99]) def test___init___no_file(self, depth: int, mocker: MockerFixture, tmp_path: Path) -> None: """Test __init__ no file.""" class Settings(BaseSettings): model_config = SettingsConfigDict(pyproject_toml_depth=depth) mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path / 'foo') obj = PyprojectTomlConfigSettingsSource(Settings) assert obj.toml_table_header == ('tool', 'pydantic-settings') assert not obj.toml_data assert obj.toml_file_path == tmp_path / 'foo' / 'pyproject.toml' def test___init___parent(self, mocker: MockerFixture, tmp_path: Path) -> None: """Test __init__ parent directory.""" mocker.patch(f'{MODULE}.Path.cwd', return_value=tmp_path / 'child') pyproject = tmp_path / 'pyproject.toml' pyproject.write_text(SOME_TOML_DATA) obj = PyprojectTomlConfigSettingsSource(SimpleSettings) assert obj.toml_table_header == ('some', 'table') assert obj.toml_data == {'field': 'some'} assert obj.toml_file_path == tmp_path / 'pyproject.toml' @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') def test_pyproject_toml_file(cd_tmp_path: Path): pyproject = cd_tmp_path / 'pyproject.toml' pyproject.write_text( """ [tool.pydantic-settings] foobar = "Hello" [tool.pydantic-settings.nested] nested_field = "world!" """ ) class Nested(BaseModel): nested_field: str class Settings(BaseSettings): foobar: str nested: Nested model_config = SettingsConfigDict() @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], **_kwargs: PydanticBaseSettingsSource ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls),) s = Settings() assert s.foobar == 'Hello' assert s.nested.nested_field == 'world!' @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') def test_pyproject_toml_file_explicit(cd_tmp_path: Path): pyproject = cd_tmp_path / 'child' / 'grandchild' / 'pyproject.toml' pyproject.parent.mkdir(parents=True) pyproject.write_text( """ [tool.pydantic-settings] foobar = "Hello" [tool.pydantic-settings.nested] nested_field = "world!" """ ) (cd_tmp_path / 'pyproject.toml').write_text( """ [tool.pydantic-settings] foobar = "fail" [tool.pydantic-settings.nested] nested_field = "fail" """ ) class Nested(BaseModel): nested_field: str class Settings(BaseSettings): foobar: str nested: Nested model_config = SettingsConfigDict() @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], **_kwargs: PydanticBaseSettingsSource ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls, pyproject),) s = Settings() assert s.foobar == 'Hello' assert s.nested.nested_field == 'world!' @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') def test_pyproject_toml_file_parent(mocker: MockerFixture, tmp_path: Path): cwd = tmp_path / 'child' / 'grandchild' / 'cwd' cwd.mkdir(parents=True) mocker.patch('pydantic_settings.sources.Path.cwd', return_value=cwd) (cwd.parent.parent / 'pyproject.toml').write_text( """ [tool.pydantic-settings] foobar = "Hello" [tool.pydantic-settings.nested] nested_field = "world!" """ ) (tmp_path / 'pyproject.toml').write_text( """ [tool.pydantic-settings] foobar = "fail" [tool.pydantic-settings.nested] nested_field = "fail" """ ) class Nested(BaseModel): nested_field: str class Settings(BaseSettings): foobar: str nested: Nested model_config = SettingsConfigDict(pyproject_toml_depth=2) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], **_kwargs: PydanticBaseSettingsSource ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls),) s = Settings() assert s.foobar == 'Hello' assert s.nested.nested_field == 'world!' @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') def test_pyproject_toml_file_header(cd_tmp_path: Path): pyproject = cd_tmp_path / 'subdir' / 'pyproject.toml' pyproject.parent.mkdir() pyproject.write_text( """ [tool.pydantic-settings] foobar = "Hello" [tool.pydantic-settings.nested] nested_field = "world!" [tool."my.tool".foo] status = "success" """ ) class Settings(BaseSettings): status: str model_config = SettingsConfigDict(extra='forbid', pyproject_toml_table_header=('tool', 'my.tool', 'foo')) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], **_kwargs: PydanticBaseSettingsSource ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls, pyproject),) s = Settings() assert s.status == 'success' @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') @pytest.mark.parametrize('depth', [0, 99]) def test_pyproject_toml_no_file(cd_tmp_path: Path, depth: int): class Settings(BaseSettings): model_config = SettingsConfigDict(pyproject_toml_depth=depth) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], **_kwargs: PydanticBaseSettingsSource ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls),) s = Settings() assert s.model_dump() == {} @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') def test_pyproject_toml_no_file_explicit(tmp_path: Path): pyproject = tmp_path / 'child' / 'pyproject.toml' (tmp_path / 'pyproject.toml').write_text('[tool.pydantic-settings]\nfield = "fail"') class Settings(BaseSettings): model_config = SettingsConfigDict() field: Optional[str] = None @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], **_kwargs: PydanticBaseSettingsSource ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls, pyproject),) s = Settings() assert s.model_dump() == {'field': None} @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') @pytest.mark.parametrize('depth', [0, 1, 2]) def test_pyproject_toml_no_file_too_shallow(depth: int, mocker: MockerFixture, tmp_path: Path): cwd = tmp_path / 'child' / 'grandchild' / 'cwd' cwd.mkdir(parents=True) mocker.patch('pydantic_settings.sources.Path.cwd', return_value=cwd) (tmp_path / 'pyproject.toml').write_text( """ [tool.pydantic-settings] foobar = "fail" [tool.pydantic-settings.nested] nested_field = "fail" """ ) class Nested(BaseModel): nested_field: Optional[str] = None class Settings(BaseSettings): foobar: Optional[str] = None nested: Nested = Nested() model_config = SettingsConfigDict(pyproject_toml_depth=depth) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], **_kwargs: PydanticBaseSettingsSource ) -> Tuple[PydanticBaseSettingsSource, ...]: return (PyprojectTomlConfigSettingsSource(settings_cls),) s = Settings() assert not s.foobar assert not s.nested.nested_field pydantic-pydantic-settings-5f33b62/tests/test_source_toml.py000066400000000000000000000063231476003445400244510ustar00rootroot00000000000000""" Test pydantic_settings.TomlConfigSettingsSource. """ import sys from pathlib import Path from typing import Tuple, Type import pytest from pydantic import BaseModel from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource, ) try: import tomli except ImportError: tomli = None def test_repr() -> None: source = TomlConfigSettingsSource(BaseSettings(), Path('config.toml')) assert repr(source) == 'TomlConfigSettingsSource(toml_file=config.toml)' @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') def test_toml_file(tmp_path): p = tmp_path / '.env' p.write_text( """ foobar = "Hello" [nested] nested_field = "world!" """ ) class Nested(BaseModel): nested_field: str class Settings(BaseSettings): foobar: str nested: Nested model_config = SettingsConfigDict(toml_file=p) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (TomlConfigSettingsSource(settings_cls),) s = Settings() assert s.foobar == 'Hello' assert s.nested.nested_field == 'world!' @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') def test_toml_no_file(): class Settings(BaseSettings): model_config = SettingsConfigDict(toml_file=None) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (TomlConfigSettingsSource(settings_cls),) s = Settings() assert s.model_dump() == {} @pytest.mark.skipif(sys.version_info <= (3, 11) and tomli is None, reason='tomli/tomllib is not installed') def test_multiple_file_toml(tmp_path): p1 = tmp_path / '.env.toml1' p2 = tmp_path / '.env.toml2' p1.write_text( """ toml1=1 """ ) p2.write_text( """ toml2=2 """ ) class Settings(BaseSettings): toml1: int toml2: int @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (TomlConfigSettingsSource(settings_cls, toml_file=[p1, p2]),) s = Settings() assert s.model_dump() == {'toml1': 1, 'toml2': 2} pydantic-pydantic-settings-5f33b62/tests/test_source_yaml.py000066400000000000000000000114141476003445400244350ustar00rootroot00000000000000""" Test pydantic_settings.YamlConfigSettingsSource. """ from pathlib import Path from typing import Tuple, Type, Union import pytest from pydantic import BaseModel from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, YamlConfigSettingsSource, ) try: import yaml except ImportError: yaml = None def test_repr() -> None: source = YamlConfigSettingsSource(BaseSettings(), Path('config.yaml')) assert repr(source) == 'YamlConfigSettingsSource(yaml_file=config.yaml)' @pytest.mark.skipif(yaml, reason='PyYAML is installed') def test_yaml_not_installed(tmp_path): p = tmp_path / '.env' p.write_text( """ foobar: "Hello" """ ) class Settings(BaseSettings): foobar: str model_config = SettingsConfigDict(yaml_file=p) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (YamlConfigSettingsSource(settings_cls),) with pytest.raises(ImportError, match=r'^PyYAML is not installed, run `pip install pydantic-settings\[yaml\]`$'): Settings() @pytest.mark.skipif(yaml is None, reason='pyYaml is not installed') def test_yaml_file(tmp_path): p = tmp_path / '.env' p.write_text( """ foobar: "Hello" null_field: nested: nested_field: "world!" """ ) class Nested(BaseModel): nested_field: str class Settings(BaseSettings): foobar: str nested: Nested null_field: Union[str, None] model_config = SettingsConfigDict(yaml_file=p) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (YamlConfigSettingsSource(settings_cls),) s = Settings() assert s.foobar == 'Hello' assert s.nested.nested_field == 'world!' @pytest.mark.skipif(yaml is None, reason='pyYaml is not installed') def test_yaml_no_file(): class Settings(BaseSettings): model_config = SettingsConfigDict(yaml_file=None) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (YamlConfigSettingsSource(settings_cls),) s = Settings() assert s.model_dump() == {} @pytest.mark.skipif(yaml is None, reason='pyYaml is not installed') def test_yaml_empty_file(tmp_path): p = tmp_path / '.env' p.write_text('') class Settings(BaseSettings): model_config = SettingsConfigDict(yaml_file=p) @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (YamlConfigSettingsSource(settings_cls),) s = Settings() assert s.model_dump() == {} @pytest.mark.skipif(yaml is None, reason='pyYAML is not installed') def test_multiple_file_yaml(tmp_path): p3 = tmp_path / '.env.yaml3' p4 = tmp_path / '.env.yaml4' p3.write_text( """ yaml3: 3 """ ) p4.write_text( """ yaml4: 4 """ ) class Settings(BaseSettings): yaml3: int yaml4: int @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return (YamlConfigSettingsSource(settings_cls, yaml_file=[p3, p4]),) s = Settings() assert s.model_dump() == {'yaml3': 3, 'yaml4': 4} pydantic-pydantic-settings-5f33b62/tests/test_utils.py000066400000000000000000000002421476003445400232500ustar00rootroot00000000000000from pydantic_settings.utils import path_type_label def test_path_type_label(tmp_path): result = path_type_label(tmp_path) assert result == 'directory'