From 8f7653caca4421ca9577211ef9be1b2377c69574 Mon Sep 17 00:00:00 2001 From: Michael Makukha Date: Mon, 30 Sep 2024 20:10:56 +0300 Subject: [PATCH] Put tests for non-default sources in separate files (#429) Co-authored-by: Hasan Ramezani --- tests/test_settings.py | 2527 +---------------- ...rces.py => test_source_azure_key_vault.py} | 107 +- tests/test_source_cli.py | 2019 +++++++++++++ tests/test_source_json.py | 95 + tests/test_source_pyproject_toml.py | 320 +++ tests/test_source_toml.py | 111 + tests/test_source_yaml.py | 162 ++ 7 files changed, 2718 insertions(+), 2623 deletions(-) rename tests/{test_sources.py => test_source_azure_key_vault.py} (53%) create mode 100644 tests/test_source_cli.py create mode 100644 tests/test_source_json.py create mode 100644 tests/test_source_pyproject_toml.py create mode 100644 tests/test_source_toml.py create mode 100644 tests/test_source_yaml.py diff --git a/tests/test_settings.py b/tests/test_settings.py index 93067d9..c5d6406 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,12 +1,7 @@ -import argparse import dataclasses -import json import os import pathlib -import re import sys -import time -import typing import uuid from datetime import datetime, timezone from enum import IntEnum @@ -14,14 +9,11 @@ from typing import Any, Callable, Dict, Generic, Hashable, List, Optional, Set, Tuple, Type, TypeVar, Union import pytest -import typing_extensions from annotated_types import MinLen from pydantic import ( AliasChoices, AliasPath, BaseModel, - ConfigDict, - DirectoryPath, Discriminator, Field, HttpUrl, @@ -36,56 +28,24 @@ from pydantic import ( dataclasses as pydantic_dataclasses, ) -from pydantic._internal._repr import Representation from pydantic.fields import FieldInfo -from pytest_mock import MockerFixture from typing_extensions import Annotated, Literal, override from pydantic_settings import ( BaseSettings, - CliApp, DotEnvSettingsSource, EnvSettingsSource, InitSettingsSource, - JsonConfigSettingsSource, PydanticBaseSettingsSource, - PyprojectTomlConfigSettingsSource, SecretsSettingsSource, SettingsConfigDict, - TomlConfigSettingsSource, - YamlConfigSettingsSource, -) -from pydantic_settings.sources import ( - CliExplicitFlag, - CliImplicitFlag, - CliPositionalArg, - CliSettingsSource, - CliSubCommand, - DefaultSettingsSource, - SettingsError, - get_subcommand, ) +from pydantic_settings.sources import DefaultSettingsSource, SettingsError try: import dotenv except ImportError: dotenv = None -try: - import yaml -except ImportError: - yaml = None -try: - import tomli -except ImportError: - tomli = None - - -def foobar(a, b, c=4): - pass - - -T = TypeVar('T') -ARGPARSE_OPTIONS_TEXT = 'options' if sys.version_info >= (3, 10) else 'optional arguments' class FruitsEnum(IntEnum): @@ -94,40 +54,6 @@ class FruitsEnum(IntEnum): lime = 2 -class CliDummyArgGroup(BaseModel, arbitrary_types_allowed=True): - group: argparse._ArgumentGroup - - def add_argument(self, *args, **kwargs) -> None: - self.group.add_argument(*args, **kwargs) - - -class CliDummySubParsers(BaseModel, arbitrary_types_allowed=True): - sub_parser: argparse._SubParsersAction - - def add_parser(self, *args, **kwargs) -> '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, **kwargs) -> None: - self.parser.add_argument(*args, **kwargs) - - def add_argument_group(self, *args, **kwargs) -> CliDummyArgGroup: - return CliDummyArgGroup(group=self.parser.add_argument_group(*args, **kwargs)) - - def add_subparsers(self, *args, **kwargs) -> CliDummySubParsers: - return CliDummySubParsers(sub_parser=self.parser.add_subparsers(*args, **kwargs)) - - def parse_args(self, *args, **kwargs) -> argparse.Namespace: - return self.parser.parse_args(*args, **kwargs) - - -class LoggedVar(Generic[T]): - def get(self) -> T: ... - - class SimpleSettings(BaseSettings): apple: str @@ -741,18 +667,6 @@ class Settings(BaseSettings): assert Settings().foobar == 'bar' -def test_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' - - def test_case_sensitive(monkeypatch): class Settings(BaseSettings): foo: str @@ -1338,34 +1252,6 @@ def test_read_env_file_deprecation(tmp_path): } -@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() - - def test_alias_set(env): class Settings(BaseSettings): foo: str = Field('default foo', validation_alias='foo_env') @@ -2491,2417 +2377,6 @@ class Settings(BaseSettings): assert s.data == {'foo': 'bar'} -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_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_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='positional argument PositionalArgHasDefault.pos_arg has a default value' - ): - - class PositionalArgHasDefault(BaseSettings, cli_parse_args=True): - pos_arg: CliPositionalArg[str] = 'bad' - - PositionalArgHasDefault() - - 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 = Cfg(_cli_parse_args=['++my-var=hello']) - assert cfg.model_dump() == {'my_var': 'hello'} - - cfg = Cfg(_cli_parse_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 Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == { - 'pet': 'bird', - 'command': None, - } - assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).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 Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == { - 'pet': 'dog', - 'command': None, - } - assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == { - 'pet': 'dog', - 'command': None, - } - - parsed_args = parser.parse_args(['--fruit', 'kiwi', f'--{arg_prefix}pet', 'cat']) - assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=vars(parsed_args))).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 Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=vars(parsed_args))).model_dump() == { - 'pet': 'dog', - 'command': {'name': 'ralph', 'command': 'roll'}, - } - assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).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_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_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() == {} - - -@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(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_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 - - -@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} - - -@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} - - -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} - - def test_dotenv_with_alias_and_env_prefix(tmp_path): p = tmp_path / '.env' p.write_text('xxx__foo=1\nxxx__bar=2') diff --git a/tests/test_sources.py b/tests/test_source_azure_key_vault.py similarity index 53% rename from tests/test_sources.py rename to tests/test_source_azure_key_vault.py index f467c05..bd4ddaa 100644 --- a/tests/test_sources.py +++ b/tests/test_source_azure_key_vault.py @@ -1,26 +1,19 @@ -"""Test pydantic_settings.sources.""" - -from __future__ import annotations +""" +Test pydantic_settings.AzureKeyVaultSettingsSource. +""" -import sys -from typing import TYPE_CHECKING +from typing import Tuple, Type import pytest from pydantic import BaseModel, Field +from pytest_mock import MockerFixture -from pydantic_settings.main import BaseSettings, SettingsConfigDict -from pydantic_settings.sources import ( +from pydantic_settings import ( AzureKeyVaultSettingsSource, + BaseSettings, PydanticBaseSettingsSource, - PyprojectTomlConfigSettingsSource, - import_azure_key_vault, ) - -try: - import tomli -except ImportError: - tomli = None - +from pydantic_settings.sources import import_azure_key_vault try: azure_key_vault = True @@ -31,89 +24,9 @@ except ImportError: azure_key_vault = False -if TYPE_CHECKING: - from pathlib import Path - - from pytest_mock import MockerFixture - 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(not azure_key_vault, reason='pydantic-settings[azure-key-vault] is not installed') class TestAzureKeyVaultSettingsSource: @@ -176,12 +89,12 @@ class AzureKeyVaultSettings(BaseSettings): @classmethod def settings_customise_sources( cls, - settings_cls: type[BaseSettings], + settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, - ) -> tuple[PydanticBaseSettingsSource, ...]: + ) -> Tuple[PydanticBaseSettingsSource, ...]: return ( AzureKeyVaultSettingsSource( settings_cls, 'https://my-resource.vault.azure.net/', DefaultAzureCredential() diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py new file mode 100644 index 0000000..517ae7a --- /dev/null +++ b/tests/test_source_cli.py @@ -0,0 +1,2019 @@ +import argparse +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, + AliasPath, + BaseModel, + ConfigDict, + DirectoryPath, + Field, + 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 ( + CliExplicitFlag, + CliImplicitFlag, + CliPositionalArg, + CliSettingsSource, + CliSubCommand, + SettingsError, + get_subcommand, +) + +ARGPARSE_OPTIONS_TEXT = 'options' if sys.version_info >= (3, 10) else 'optional arguments' + + +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, **kwargs) -> None: + self.group.add_argument(*args, **kwargs) + + +class CliDummySubParsers(BaseModel, arbitrary_types_allowed=True): + sub_parser: argparse._SubParsersAction + + def add_parser(self, *args, **kwargs) -> '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, **kwargs) -> None: + self.parser.add_argument(*args, **kwargs) + + def add_argument_group(self, *args, **kwargs) -> CliDummyArgGroup: + return CliDummyArgGroup(group=self.parser.add_argument_group(*args, **kwargs)) + + def add_subparsers(self, *args, **kwargs) -> CliDummySubParsers: + return CliDummySubParsers(sub_parser=self.parser.add_subparsers(*args, **kwargs)) + + def parse_args(self, *args, **kwargs) -> argparse.Namespace: + return self.parser.parse_args(*args, **kwargs) + + +def test_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' + + +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_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_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='positional argument PositionalArgHasDefault.pos_arg has a default value' + ): + + class PositionalArgHasDefault(BaseSettings, cli_parse_args=True): + pos_arg: CliPositionalArg[str] = 'bad' + + PositionalArgHasDefault() + + 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 = Cfg(_cli_parse_args=['++my-var=hello']) + assert cfg.model_dump() == {'my_var': 'hello'} + + cfg = Cfg(_cli_parse_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 Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == { + 'pet': 'bird', + 'command': None, + } + assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).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 Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == { + 'pet': 'dog', + 'command': None, + } + assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == { + 'pet': 'dog', + 'command': None, + } + + parsed_args = parser.parse_args(['--fruit', 'kiwi', f'--{arg_prefix}pet', 'cat']) + assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=vars(parsed_args))).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 Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=vars(parsed_args))).model_dump() == { + 'pet': 'dog', + 'command': {'name': 'ralph', 'command': 'roll'}, + } + assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).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_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']) diff --git a/tests/test_source_json.py b/tests/test_source_json.py new file mode 100644 index 0000000..e348a6b --- /dev/null +++ b/tests/test_source_json.py @@ -0,0 +1,95 @@ +""" +Test pydantic_settings.JsonConfigSettingsSource. +""" + +import json +from typing import Tuple, Type, Union + +from pydantic import BaseModel + +from pydantic_settings import ( + BaseSettings, + JsonConfigSettingsSource, + PydanticBaseSettingsSource, + SettingsConfigDict, +) + + +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} diff --git a/tests/test_source_pyproject_toml.py b/tests/test_source_pyproject_toml.py new file mode 100644 index 0000000..5468d5b --- /dev/null +++ b/tests/test_source_pyproject_toml.py @@ -0,0 +1,320 @@ +""" +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 diff --git a/tests/test_source_toml.py b/tests/test_source_toml.py new file mode 100644 index 0000000..2918601 --- /dev/null +++ b/tests/test_source_toml.py @@ -0,0 +1,111 @@ +""" +Test pydantic_settings.TomlConfigSettingsSource. +""" + +import sys +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 + + +@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} diff --git a/tests/test_source_yaml.py b/tests/test_source_yaml.py new file mode 100644 index 0000000..fd25de6 --- /dev/null +++ b/tests/test_source_yaml.py @@ -0,0 +1,162 @@ +""" +Test pydantic_settings.YamlConfigSettingsSource. +""" + +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 + + +@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}