Skip to content

Commit 9b73e92

Browse files
authored
Add cli_flag_prefix_char config option. (#418)
1 parent b67b335 commit 9b73e92

File tree

4 files changed

+95
-14
lines changed

4 files changed

+95
-14
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,
@@ -226,6 +230,7 @@ def _settings_build_values(
226230
_cli_use_class_docs_for_groups: bool | None = None,
227231
_cli_exit_on_error: bool | None = None,
228232
_cli_prefix: str | None = None,
233+
_cli_flag_prefix_char: str | None = None,
229234
_cli_implicit_flags: bool | None = None,
230235
_cli_ignore_unknown_args: bool | None = None,
231236
_secrets_dir: PathType | None = None,
@@ -282,6 +287,11 @@ def _settings_build_values(
282287
_cli_exit_on_error if _cli_exit_on_error is not None else self.model_config.get('cli_exit_on_error')
283288
)
284289
cli_prefix = _cli_prefix if _cli_prefix is not None else self.model_config.get('cli_prefix')
290+
cli_flag_prefix_char = (
291+
_cli_flag_prefix_char
292+
if _cli_flag_prefix_char is not None
293+
else self.model_config.get('cli_flag_prefix_char')
294+
)
285295
cli_implicit_flags = (
286296
_cli_implicit_flags if _cli_implicit_flags is not None else self.model_config.get('cli_implicit_flags')
287297
)
@@ -348,6 +358,7 @@ def _settings_build_values(
348358
cli_use_class_docs_for_groups=cli_use_class_docs_for_groups,
349359
cli_exit_on_error=cli_exit_on_error,
350360
cli_prefix=cli_prefix,
361+
cli_flag_prefix_char=cli_flag_prefix_char,
351362
cli_implicit_flags=cli_implicit_flags,
352363
cli_ignore_unknown_args=cli_ignore_unknown_args,
353364
case_sensitive=case_sensitive,
@@ -398,6 +409,7 @@ def _settings_build_values(
398409
cli_use_class_docs_for_groups=False,
399410
cli_exit_on_error=True,
400411
cli_prefix='',
412+
cli_flag_prefix_char='-',
401413
cli_implicit_flags=False,
402414
cli_ignore_unknown_args=False,
403415
json_file=None,

pydantic_settings/sources.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2694,18 +2694,39 @@ class BadCliPositionalArg(BaseSettings):
26942694

26952695
def test_cli_case_insensitive_arg():
26962696
class Cfg(BaseSettings, cli_exit_on_error=False):
2697-
Foo: str
2698-
Bar: str
2697+
foo: str = Field(validation_alias=AliasChoices('F', 'Foo'))
2698+
bar: str = Field(validation_alias=AliasChoices('B', 'Bar'))
26992699

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

27032718
cfg = Cfg(_cli_parse_args=['--Foo=--VAL', '--Bar', '"--VAL"'], _case_sensitive=True)
2704-
assert cfg.model_dump() == {'Foo': '--VAL', 'Bar': '"--VAL"'}
2719+
assert cfg.model_dump() == {'foo': '--VAL', 'bar': '"--VAL"'}
2720+
2721+
cfg = Cfg(_cli_parse_args=['-F=-V', '-B', '"-V"'], _case_sensitive=True)
2722+
assert cfg.model_dump() == {'foo': '-V', 'bar': '"-V"'}
27052723

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

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

@@ -3993,6 +4014,17 @@ class Cfg(BaseSettings, cli_ignore_unknown_args=True):
39934014
assert cfg.model_dump() == {'this': 'goodbye', 'that': 789}
39944015

39954016

4017+
def test_cli_flag_prefix_char():
4018+
class Cfg(BaseSettings, cli_flag_prefix_char='+'):
4019+
my_var: str = Field(validation_alias=AliasChoices('m', 'my-var'))
4020+
4021+
cfg = Cfg(_cli_parse_args=['++my-var=hello'])
4022+
assert cfg.model_dump() == {'my_var': 'hello'}
4023+
4024+
cfg = Cfg(_cli_parse_args=['+m=hello'])
4025+
assert cfg.model_dump() == {'my_var': 'hello'}
4026+
4027+
39964028
@pytest.mark.parametrize('parser_type', [pytest.Parser, argparse.ArgumentParser, CliDummyParser])
39974029
@pytest.mark.parametrize('prefix', ['', 'cfg'])
39984030
def test_cli_user_settings_source(parser_type, prefix):

0 commit comments

Comments
 (0)