Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add cli_flag_prefix_char config option. #418

Merged
merged 4 commits into from
Sep 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading