From 9b73e924cab136d876907af0c6836dcca09ac35c Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sun, 22 Sep 2024 10:51:50 -0600 Subject: [PATCH] Add `cli_flag_prefix_char` config option. (#418) --- docs/index.md | 25 +++++++++++++++++++++ pydantic_settings/main.py | 12 +++++++++++ pydantic_settings/sources.py | 30 ++++++++++++++++++-------- tests/test_settings.py | 42 +++++++++++++++++++++++++++++++----- 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/docs/index.md b/docs/index.md index dcf6597..95fe510 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 2370ed1..fd5e361 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -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 @@ -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`. @@ -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, @@ -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, @@ -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, @@ -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') ) @@ -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, @@ -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, diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index ed10353..d79577f 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -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`. @@ -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, @@ -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}') @@ -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 @@ -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) @@ -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 ) @@ -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) @@ -1666,7 +1676,7 @@ def _add_parser_args( added_args, arg_prefix, subcommand_prefix, - arg_flag, + flag_prefix, arg_names, kwargs, field_name, @@ -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 @@ -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, @@ -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, @@ -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: diff --git a/tests/test_settings.py b/tests/test_settings.py index c10d470..17f3c00 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -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) @@ -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):