Skip to content

Commit 6962424

Browse files
authored
Merge branch 'main' into issue-407
2 parents 90c7293 + fdd666b commit 6962424

File tree

4 files changed

+119
-23
lines changed

4 files changed

+119
-23
lines changed

docs/index.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,31 @@ sub_model options:
12071207
"""
12081208
```
12091209

1210+
#### Change the CLI Flag Prefix Character
1211+
1212+
Change The CLI flag prefix character used in CLI optional arguments by settings `cli_flag_prefix_char`.
1213+
1214+
```py
1215+
import sys
1216+
1217+
from pydantic import AliasChoices, Field
1218+
1219+
from pydantic_settings import BaseSettings
1220+
1221+
1222+
class Settings(BaseSettings, cli_parse_args=True, cli_flag_prefix_char='+'):
1223+
my_arg: str = Field(validation_alias=AliasChoices('m', 'my-arg'))
1224+
1225+
1226+
sys.argv = ['example.py', '++my-arg', 'hi']
1227+
print(Settings().model_dump())
1228+
#> {'my_arg': 'hi'}
1229+
1230+
sys.argv = ['example.py', '+m', 'hi']
1231+
print(Settings().model_dump())
1232+
#> {'my_arg': 'hi'}
1233+
```
1234+
12101235
### Integrating with Existing Parsers
12111236

12121237
A CLI settings source can be integrated with existing parsers by overriding the default CLI settings source with a user

pydantic_settings/main.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class SettingsConfigDict(ConfigDict, total=False):
4141
cli_use_class_docs_for_groups: bool
4242
cli_exit_on_error: bool
4343
cli_prefix: str
44+
cli_flag_prefix_char: str
4445
cli_implicit_flags: bool | None
4546
cli_ignore_unknown_args: bool | None
4647
secrets_dir: PathType | None
@@ -119,6 +120,7 @@ class BaseSettings(BaseModel):
119120
_cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs.
120121
Defaults to `True`.
121122
_cli_prefix: The root parser command line arguments prefix. Defaults to "".
123+
_cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'.
122124
_cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
123125
(e.g. --flag, --no-flag). Defaults to `False`.
124126
_cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
@@ -146,6 +148,7 @@ def __init__(
146148
_cli_use_class_docs_for_groups: bool | None = None,
147149
_cli_exit_on_error: bool | None = None,
148150
_cli_prefix: str | None = None,
151+
_cli_flag_prefix_char: str | None = None,
149152
_cli_implicit_flags: bool | None = None,
150153
_cli_ignore_unknown_args: bool | None = None,
151154
_secrets_dir: PathType | None = None,
@@ -174,6 +177,7 @@ def __init__(
174177
_cli_use_class_docs_for_groups=_cli_use_class_docs_for_groups,
175178
_cli_exit_on_error=_cli_exit_on_error,
176179
_cli_prefix=_cli_prefix,
180+
_cli_flag_prefix_char=_cli_flag_prefix_char,
177181
_cli_implicit_flags=_cli_implicit_flags,
178182
_cli_ignore_unknown_args=_cli_ignore_unknown_args,
179183
_secrets_dir=_secrets_dir,
@@ -228,6 +232,7 @@ def _settings_build_values(
228232
_cli_use_class_docs_for_groups: bool | None = None,
229233
_cli_exit_on_error: bool | None = None,
230234
_cli_prefix: str | None = None,
235+
_cli_flag_prefix_char: str | None = None,
231236
_cli_implicit_flags: bool | None = None,
232237
_cli_ignore_unknown_args: bool | None = None,
233238
_secrets_dir: PathType | None = None,
@@ -284,6 +289,11 @@ def _settings_build_values(
284289
_cli_exit_on_error if _cli_exit_on_error is not None else self.model_config.get('cli_exit_on_error')
285290
)
286291
cli_prefix = _cli_prefix if _cli_prefix is not None else self.model_config.get('cli_prefix')
292+
cli_flag_prefix_char = (
293+
_cli_flag_prefix_char
294+
if _cli_flag_prefix_char is not None
295+
else self.model_config.get('cli_flag_prefix_char')
296+
)
287297
cli_implicit_flags = (
288298
_cli_implicit_flags if _cli_implicit_flags is not None else self.model_config.get('cli_implicit_flags')
289299
)
@@ -350,6 +360,7 @@ def _settings_build_values(
350360
cli_use_class_docs_for_groups=cli_use_class_docs_for_groups,
351361
cli_exit_on_error=cli_exit_on_error,
352362
cli_prefix=cli_prefix,
363+
cli_flag_prefix_char=cli_flag_prefix_char,
353364
cli_implicit_flags=cli_implicit_flags,
354365
cli_ignore_unknown_args=cli_ignore_unknown_args,
355366
case_sensitive=case_sensitive,
@@ -400,6 +411,7 @@ def _settings_build_values(
400411
cli_use_class_docs_for_groups=False,
401412
cli_exit_on_error=True,
402413
cli_prefix='',
414+
cli_flag_prefix_char='-',
403415
cli_implicit_flags=False,
404416
cli_ignore_unknown_args=False,
405417
json_file=None,

pydantic_settings/sources.py

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -778,8 +778,9 @@ def _field_is_complex(self, field: FieldInfo) -> tuple[bool, bool]:
778778
# Default value of `case_sensitive` is `None`, because we don't want to break existing behavior.
779779
# We have to change the method to a non-static method and use
780780
# `self.case_sensitive` instead in V3.
781-
@staticmethod
782-
def next_field(field: FieldInfo | Any | None, key: str, case_sensitive: bool | None = None) -> FieldInfo | None:
781+
def next_field(
782+
self, field: FieldInfo | Any | None, key: str, case_sensitive: bool | None = None
783+
) -> FieldInfo | None:
783784
"""
784785
Find the field in a sub model by key(env name)
785786
@@ -815,21 +816,20 @@ class Cfg(BaseSettings):
815816
annotation = field.annotation if isinstance(field, FieldInfo) else field
816817
if origin_is_union(get_origin(annotation)) or isinstance(annotation, WithArgsTypes):
817818
for type_ in get_args(annotation):
818-
type_has_key = EnvSettingsSource.next_field(type_, key, case_sensitive)
819+
type_has_key = self.next_field(type_, key, case_sensitive)
819820
if type_has_key:
820821
return type_has_key
821822
elif is_model_class(annotation) or is_pydantic_dataclass(annotation):
822823
fields = _get_model_fields(annotation)
823824
# `case_sensitive is None` is here to be compatible with the old behavior.
824825
# Has to be removed in V3.
825826
for field_name, f in fields.items():
826-
if case_sensitive is None or case_sensitive:
827-
if (field_name == key) or (isinstance(f.validation_alias, str) and f.validation_alias == key):
827+
for _, env_name, _ in self._extract_field_info(f, field_name):
828+
if case_sensitive is None or case_sensitive:
829+
if field_name == key or env_name == key:
830+
return f
831+
elif field_name.lower() == key.lower() or env_name.lower() == key.lower():
828832
return f
829-
elif (field_name.lower() == key.lower()) or (
830-
isinstance(f.validation_alias, str) and f.validation_alias.lower() == key.lower()
831-
):
832-
return f
833833
return None
834834

835835
def explode_env_vars(self, field_name: str, field: FieldInfo, env_vars: Mapping[str, str | None]) -> dict[str, Any]:
@@ -1026,6 +1026,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
10261026
cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs.
10271027
Defaults to `True`.
10281028
cli_prefix: Prefix for command line arguments added under the root parser. Defaults to "".
1029+
cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'.
10291030
cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
10301031
(e.g. --flag, --no-flag). Defaults to `False`.
10311032
cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
@@ -1056,6 +1057,7 @@ def __init__(
10561057
cli_use_class_docs_for_groups: bool | None = None,
10571058
cli_exit_on_error: bool | None = None,
10581059
cli_prefix: str | None = None,
1060+
cli_flag_prefix_char: str | None = None,
10591061
cli_implicit_flags: bool | None = None,
10601062
cli_ignore_unknown_args: bool | None = None,
10611063
case_sensitive: bool | None = True,
@@ -1097,6 +1099,12 @@ def __init__(
10971099
else settings_cls.model_config.get('cli_exit_on_error', True)
10981100
)
10991101
self.cli_prefix = cli_prefix if cli_prefix is not None else settings_cls.model_config.get('cli_prefix', '')
1102+
self.cli_flag_prefix_char = (
1103+
cli_flag_prefix_char
1104+
if cli_flag_prefix_char is not None
1105+
else settings_cls.model_config.get('cli_flag_prefix_char', '-')
1106+
)
1107+
self._cli_flag_prefix = self.cli_flag_prefix_char * 2
11001108
if self.cli_prefix:
11011109
if cli_prefix.startswith('.') or cli_prefix.endswith('.') or not cli_prefix.replace('.', '').isidentifier(): # type: ignore
11021110
raise SettingsError(f'CLI settings source prefix is invalid: {cli_prefix}')
@@ -1131,6 +1139,7 @@ def __init__(
11311139
prog=self.cli_prog_name,
11321140
description=None if settings_cls.__doc__ is None else dedent(settings_cls.__doc__),
11331141
formatter_class=formatter_class,
1142+
prefix_chars=self.cli_flag_prefix_char,
11341143
)
11351144
if root_parser is None
11361145
else root_parser
@@ -1503,7 +1512,8 @@ def parse_args_insensitive_method(
15031512
) -> Any:
15041513
insensitive_args = []
15051514
for arg in shlex.split(shlex.join(args)) if args else []:
1506-
matched = re.match(r'^(--[^\s=]+)(.*)', arg)
1515+
flag_prefix = rf'\{self.cli_flag_prefix_char}{{1,2}}'
1516+
matched = re.match(rf'^({flag_prefix}[^\s=]+)(.*)', arg)
15071517
if matched:
15081518
arg = matched.group(1).lower() + matched.group(2)
15091519
insensitive_args.append(arg)
@@ -1621,7 +1631,7 @@ def _add_parser_args(
16211631
model_default=PydanticUndefined,
16221632
)
16231633
else:
1624-
arg_flag: str = '--'
1634+
flag_prefix: str = self._cli_flag_prefix
16251635
is_append_action = _annotation_contains_types(
16261636
field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True
16271637
)
@@ -1655,7 +1665,7 @@ def _add_parser_args(
16551665
arg_names = [kwargs['dest']]
16561666
del kwargs['dest']
16571667
del kwargs['required']
1658-
arg_flag = ''
1668+
flag_prefix = ''
16591669

16601670
self._convert_bool_flag(kwargs, field_info, model_default)
16611671

@@ -1666,7 +1676,7 @@ def _add_parser_args(
16661676
added_args,
16671677
arg_prefix,
16681678
subcommand_prefix,
1669-
arg_flag,
1679+
flag_prefix,
16701680
arg_names,
16711681
kwargs,
16721682
field_name,
@@ -1679,10 +1689,12 @@ def _add_parser_args(
16791689
if isinstance(group, dict):
16801690
group = self._add_argument_group(parser, **group)
16811691
added_args += list(arg_names)
1682-
self._add_argument(group, *(f'{arg_flag[:len(name)]}{name}' for name in arg_names), **kwargs)
1692+
self._add_argument(group, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs)
16831693
else:
16841694
added_args += list(arg_names)
1685-
self._add_argument(parser, *(f'{arg_flag[:len(name)]}{name}' for name in arg_names), **kwargs)
1695+
self._add_argument(
1696+
parser, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs
1697+
)
16861698

16871699
self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group)
16881700
return parser
@@ -1723,7 +1735,7 @@ def _add_parser_submodels(
17231735
added_args: list[str],
17241736
arg_prefix: str,
17251737
subcommand_prefix: str,
1726-
arg_flag: str,
1738+
flag_prefix: str,
17271739
arg_names: list[str],
17281740
kwargs: dict[str, Any],
17291741
field_name: str,
@@ -1758,7 +1770,7 @@ def _add_parser_submodels(
17581770
added_args.append(arg_names[0])
17591771
kwargs['help'] = f'set {arg_names[0]} from JSON string'
17601772
model_group = self._add_argument_group(parser, **model_group_kwargs)
1761-
self._add_argument(model_group, *(f'{arg_flag}{name}' for name in arg_names), **kwargs)
1773+
self._add_argument(model_group, *(f'{flag_prefix}{name}' for name in arg_names), **kwargs)
17621774
for model in sub_models:
17631775
self._add_parser_args(
17641776
parser=parser,
@@ -1804,7 +1816,7 @@ def _add_parser_alias_paths(
18041816
kwargs['metavar'] = 'list'
18051817
if arg_name not in added_args:
18061818
added_args.append(arg_name)
1807-
self._add_argument(context, f'--{arg_name}', **kwargs)
1819+
self._add_argument(context, f'{self._cli_flag_prefix}{arg_name}', **kwargs)
18081820

18091821
def _get_modified_args(self, obj: Any) -> tuple[str, ...]:
18101822
if not self.cli_hide_none_type:

tests/test_settings.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2696,18 +2696,39 @@ class BadCliPositionalArg(BaseSettings):
26962696

26972697
def test_cli_case_insensitive_arg():
26982698
class Cfg(BaseSettings, cli_exit_on_error=False):
2699-
Foo: str
2700-
Bar: str
2699+
foo: str = Field(validation_alias=AliasChoices('F', 'Foo'))
2700+
bar: str = Field(validation_alias=AliasChoices('B', 'Bar'))
27012701

2702-
cfg = Cfg(_cli_parse_args=['--FOO=--VAL', '--BAR', '"--VAL"'])
2703-
assert cfg.model_dump() == {'Foo': '--VAL', 'Bar': '"--VAL"'}
2702+
cfg = Cfg(
2703+
_cli_parse_args=[
2704+
'--FOO=--VAL',
2705+
'--BAR',
2706+
'"--VAL"',
2707+
]
2708+
)
2709+
assert cfg.model_dump() == {'foo': '--VAL', 'bar': '"--VAL"'}
2710+
2711+
cfg = Cfg(
2712+
_cli_parse_args=[
2713+
'-f=-V',
2714+
'-b',
2715+
'"-V"',
2716+
]
2717+
)
2718+
assert cfg.model_dump() == {'foo': '-V', 'bar': '"-V"'}
27042719

27052720
cfg = Cfg(_cli_parse_args=['--Foo=--VAL', '--Bar', '"--VAL"'], _case_sensitive=True)
2706-
assert cfg.model_dump() == {'Foo': '--VAL', 'Bar': '"--VAL"'}
2721+
assert cfg.model_dump() == {'foo': '--VAL', 'bar': '"--VAL"'}
2722+
2723+
cfg = Cfg(_cli_parse_args=['-F=-V', '-B', '"-V"'], _case_sensitive=True)
2724+
assert cfg.model_dump() == {'foo': '-V', 'bar': '"-V"'}
27072725

27082726
with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: --FOO=--VAL --BAR "--VAL"'):
27092727
Cfg(_cli_parse_args=['--FOO=--VAL', '--BAR', '"--VAL"'], _case_sensitive=True)
27102728

2729+
with pytest.raises(SettingsError, match='error parsing CLI: unrecognized arguments: -f=-V -b "-V"'):
2730+
Cfg(_cli_parse_args=['-f=-V', '-b', '"-V"'], _case_sensitive=True)
2731+
27112732
with pytest.raises(SettingsError, match='Case-insensitive matching is only supported on the internal root parser'):
27122733
CliSettingsSource(Cfg, root_parser=CliDummyParser(), case_sensitive=False)
27132734

@@ -3995,6 +4016,17 @@ class Cfg(BaseSettings, cli_ignore_unknown_args=True):
39954016
assert cfg.model_dump() == {'this': 'goodbye', 'that': 789}
39964017

39974018

4019+
def test_cli_flag_prefix_char():
4020+
class Cfg(BaseSettings, cli_flag_prefix_char='+'):
4021+
my_var: str = Field(validation_alias=AliasChoices('m', 'my-var'))
4022+
4023+
cfg = Cfg(_cli_parse_args=['++my-var=hello'])
4024+
assert cfg.model_dump() == {'my_var': 'hello'}
4025+
4026+
cfg = Cfg(_cli_parse_args=['+m=hello'])
4027+
assert cfg.model_dump() == {'my_var': 'hello'}
4028+
4029+
39984030
@pytest.mark.parametrize('parser_type', [pytest.Parser, argparse.ArgumentParser, CliDummyParser])
39994031
@pytest.mark.parametrize('prefix', ['', 'cfg'])
40004032
def test_cli_user_settings_source(parser_type, prefix):
@@ -5076,3 +5108,18 @@ def test_validator(cls, v: str, info: ValidationInfo):
50765108
s = Settings.model_validate({'foo': 'foo bar'}, context={'foo': 'bar'})
50775109
assert s.foo == 'foo bar'
50785110
assert s.model_dump() == {'foo': 'foo bar'}
5111+
5112+
5113+
def test_nested_model_field_with_alias_choices(env):
5114+
class NestedSettings(BaseModel):
5115+
foo: List[str] = Field(alias=AliasChoices('fooalias', 'foo-alias'))
5116+
5117+
class Settings(BaseSettings):
5118+
model_config = SettingsConfigDict(env_nested_delimiter='__')
5119+
5120+
nested: NestedSettings
5121+
5122+
env.set('nested__fooalias', '["one", "two"]')
5123+
5124+
s = Settings()
5125+
assert s.model_dump() == {'nested': {'foo': ['one', 'two']}}

0 commit comments

Comments
 (0)