Skip to content

Commit

Permalink
Add cli_flag_prefix_char config option. (#418)
Browse files Browse the repository at this point in the history
  • Loading branch information
kschwab authored Sep 22, 2024
1 parent b67b335 commit 9b73e92
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 14 deletions.
25 changes: 25 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,31 @@ sub_model options:
"""
```

#### Change the CLI Flag Prefix Character

Change The CLI flag prefix character used in CLI optional arguments by settings `cli_flag_prefix_char`.

```py
import sys

from pydantic import AliasChoices, Field

from pydantic_settings import BaseSettings


class Settings(BaseSettings, cli_parse_args=True, cli_flag_prefix_char='+'):
my_arg: str = Field(validation_alias=AliasChoices('m', 'my-arg'))


sys.argv = ['example.py', '++my-arg', 'hi']
print(Settings().model_dump())
#> {'my_arg': 'hi'}

sys.argv = ['example.py', '+m', 'hi']
print(Settings().model_dump())
#> {'my_arg': 'hi'}
```

### Integrating with Existing Parsers

A CLI settings source can be integrated with existing parsers by overriding the default CLI settings source with a user
Expand Down
12 changes: 12 additions & 0 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class SettingsConfigDict(ConfigDict, total=False):
cli_use_class_docs_for_groups: bool
cli_exit_on_error: bool
cli_prefix: str
cli_flag_prefix_char: str
cli_implicit_flags: bool | None
cli_ignore_unknown_args: bool | None
secrets_dir: PathType | None
Expand Down Expand Up @@ -119,6 +120,7 @@ class BaseSettings(BaseModel):
_cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs.
Defaults to `True`.
_cli_prefix: The root parser command line arguments prefix. Defaults to "".
_cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'.
_cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
(e.g. --flag, --no-flag). Defaults to `False`.
_cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
Expand Down Expand Up @@ -146,6 +148,7 @@ def __init__(
_cli_use_class_docs_for_groups: bool | None = None,
_cli_exit_on_error: bool | None = None,
_cli_prefix: str | None = None,
_cli_flag_prefix_char: str | None = None,
_cli_implicit_flags: bool | None = None,
_cli_ignore_unknown_args: bool | None = None,
_secrets_dir: PathType | None = None,
Expand Down Expand Up @@ -174,6 +177,7 @@ def __init__(
_cli_use_class_docs_for_groups=_cli_use_class_docs_for_groups,
_cli_exit_on_error=_cli_exit_on_error,
_cli_prefix=_cli_prefix,
_cli_flag_prefix_char=_cli_flag_prefix_char,
_cli_implicit_flags=_cli_implicit_flags,
_cli_ignore_unknown_args=_cli_ignore_unknown_args,
_secrets_dir=_secrets_dir,
Expand Down Expand Up @@ -226,6 +230,7 @@ def _settings_build_values(
_cli_use_class_docs_for_groups: bool | None = None,
_cli_exit_on_error: bool | None = None,
_cli_prefix: str | None = None,
_cli_flag_prefix_char: str | None = None,
_cli_implicit_flags: bool | None = None,
_cli_ignore_unknown_args: bool | None = None,
_secrets_dir: PathType | None = None,
Expand Down Expand Up @@ -282,6 +287,11 @@ def _settings_build_values(
_cli_exit_on_error if _cli_exit_on_error is not None else self.model_config.get('cli_exit_on_error')
)
cli_prefix = _cli_prefix if _cli_prefix is not None else self.model_config.get('cli_prefix')
cli_flag_prefix_char = (
_cli_flag_prefix_char
if _cli_flag_prefix_char is not None
else self.model_config.get('cli_flag_prefix_char')
)
cli_implicit_flags = (
_cli_implicit_flags if _cli_implicit_flags is not None else self.model_config.get('cli_implicit_flags')
)
Expand Down Expand Up @@ -348,6 +358,7 @@ def _settings_build_values(
cli_use_class_docs_for_groups=cli_use_class_docs_for_groups,
cli_exit_on_error=cli_exit_on_error,
cli_prefix=cli_prefix,
cli_flag_prefix_char=cli_flag_prefix_char,
cli_implicit_flags=cli_implicit_flags,
cli_ignore_unknown_args=cli_ignore_unknown_args,
case_sensitive=case_sensitive,
Expand Down Expand Up @@ -398,6 +409,7 @@ def _settings_build_values(
cli_use_class_docs_for_groups=False,
cli_exit_on_error=True,
cli_prefix='',
cli_flag_prefix_char='-',
cli_implicit_flags=False,
cli_ignore_unknown_args=False,
json_file=None,
Expand Down
30 changes: 21 additions & 9 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs.
Defaults to `True`.
cli_prefix: Prefix for command line arguments added under the root parser. Defaults to "".
cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'.
cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
(e.g. --flag, --no-flag). Defaults to `False`.
cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
Expand Down Expand Up @@ -1056,6 +1057,7 @@ def __init__(
cli_use_class_docs_for_groups: bool | None = None,
cli_exit_on_error: bool | None = None,
cli_prefix: str | None = None,
cli_flag_prefix_char: str | None = None,
cli_implicit_flags: bool | None = None,
cli_ignore_unknown_args: bool | None = None,
case_sensitive: bool | None = True,
Expand Down Expand Up @@ -1097,6 +1099,12 @@ def __init__(
else settings_cls.model_config.get('cli_exit_on_error', True)
)
self.cli_prefix = cli_prefix if cli_prefix is not None else settings_cls.model_config.get('cli_prefix', '')
self.cli_flag_prefix_char = (
cli_flag_prefix_char
if cli_flag_prefix_char is not None
else settings_cls.model_config.get('cli_flag_prefix_char', '-')
)
self._cli_flag_prefix = self.cli_flag_prefix_char * 2
if self.cli_prefix:
if cli_prefix.startswith('.') or cli_prefix.endswith('.') or not cli_prefix.replace('.', '').isidentifier(): # type: ignore
raise SettingsError(f'CLI settings source prefix is invalid: {cli_prefix}')
Expand Down Expand Up @@ -1131,6 +1139,7 @@ def __init__(
prog=self.cli_prog_name,
description=None if settings_cls.__doc__ is None else dedent(settings_cls.__doc__),
formatter_class=formatter_class,
prefix_chars=self.cli_flag_prefix_char,
)
if root_parser is None
else root_parser
Expand Down Expand Up @@ -1503,7 +1512,8 @@ def parse_args_insensitive_method(
) -> Any:
insensitive_args = []
for arg in shlex.split(shlex.join(args)) if args else []:
matched = re.match(r'^(--[^\s=]+)(.*)', arg)
flag_prefix = rf'\{self.cli_flag_prefix_char}{{1,2}}'
matched = re.match(rf'^({flag_prefix}[^\s=]+)(.*)', arg)
if matched:
arg = matched.group(1).lower() + matched.group(2)
insensitive_args.append(arg)
Expand Down Expand Up @@ -1621,7 +1631,7 @@ def _add_parser_args(
model_default=PydanticUndefined,
)
else:
arg_flag: str = '--'
flag_prefix: str = self._cli_flag_prefix
is_append_action = _annotation_contains_types(
field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True
)
Expand Down Expand Up @@ -1655,7 +1665,7 @@ def _add_parser_args(
arg_names = [kwargs['dest']]
del kwargs['dest']
del kwargs['required']
arg_flag = ''
flag_prefix = ''

self._convert_bool_flag(kwargs, field_info, model_default)

Expand All @@ -1666,7 +1676,7 @@ def _add_parser_args(
added_args,
arg_prefix,
subcommand_prefix,
arg_flag,
flag_prefix,
arg_names,
kwargs,
field_name,
Expand All @@ -1679,10 +1689,12 @@ def _add_parser_args(
if isinstance(group, dict):
group = self._add_argument_group(parser, **group)
added_args += list(arg_names)
self._add_argument(group, *(f'{arg_flag[:len(name)]}{name}' for name in arg_names), **kwargs)
self._add_argument(group, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs)
else:
added_args += list(arg_names)
self._add_argument(parser, *(f'{arg_flag[:len(name)]}{name}' for name in arg_names), **kwargs)
self._add_argument(
parser, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs
)

self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group)
return parser
Expand Down Expand Up @@ -1723,7 +1735,7 @@ def _add_parser_submodels(
added_args: list[str],
arg_prefix: str,
subcommand_prefix: str,
arg_flag: str,
flag_prefix: str,
arg_names: list[str],
kwargs: dict[str, Any],
field_name: str,
Expand Down Expand Up @@ -1758,7 +1770,7 @@ def _add_parser_submodels(
added_args.append(arg_names[0])
kwargs['help'] = f'set {arg_names[0]} from JSON string'
model_group = self._add_argument_group(parser, **model_group_kwargs)
self._add_argument(model_group, *(f'{arg_flag}{name}' for name in arg_names), **kwargs)
self._add_argument(model_group, *(f'{flag_prefix}{name}' for name in arg_names), **kwargs)
for model in sub_models:
self._add_parser_args(
parser=parser,
Expand Down Expand Up @@ -1804,7 +1816,7 @@ def _add_parser_alias_paths(
kwargs['metavar'] = 'list'
if arg_name not in added_args:
added_args.append(arg_name)
self._add_argument(context, f'--{arg_name}', **kwargs)
self._add_argument(context, f'{self._cli_flag_prefix}{arg_name}', **kwargs)

def _get_modified_args(self, obj: Any) -> tuple[str, ...]:
if not self.cli_hide_none_type:
Expand Down
42 changes: 37 additions & 5 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2694,18 +2694,39 @@ class BadCliPositionalArg(BaseSettings):

def test_cli_case_insensitive_arg():
class Cfg(BaseSettings, cli_exit_on_error=False):
Foo: str
Bar: str
foo: str = Field(validation_alias=AliasChoices('F', 'Foo'))
bar: str = Field(validation_alias=AliasChoices('B', 'Bar'))

cfg = Cfg(_cli_parse_args=['--FOO=--VAL', '--BAR', '"--VAL"'])
assert cfg.model_dump() == {'Foo': '--VAL', 'Bar': '"--VAL"'}
cfg = Cfg(
_cli_parse_args=[
'--FOO=--VAL',
'--BAR',
'"--VAL"',
]
)
assert cfg.model_dump() == {'foo': '--VAL', 'bar': '"--VAL"'}

cfg = Cfg(
_cli_parse_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"'}
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)

Expand Down Expand Up @@ -3993,6 +4014,17 @@ class Cfg(BaseSettings, cli_ignore_unknown_args=True):
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):
Expand Down

0 comments on commit 9b73e92

Please sign in to comment.