From 02f186e1ecdf0d0c4b2b0275d84bec8fb4b55d0c Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sun, 1 Dec 2024 08:33:01 -0700 Subject: [PATCH 1/4] Add support for CLI kebab case flag. --- docs/index.md | 40 +++++++++++++++++++++++++---- pydantic_settings/main.py | 12 +++++++-- pydantic_settings/sources.py | 7 +++++ tests/test_source_cli.py | 50 ++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 7 deletions(-) diff --git a/docs/index.md b/docs/index.md index 218da6a..b82c5a6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -957,17 +957,13 @@ assert cmd.model_dump() == { For `BaseModel` and `pydantic.dataclasses.dataclass` types, `CliApp.run` will internally use the following `BaseSettings` configuration defaults: -* `alias_generator=AliasGenerator(lambda s: s.replace('_', '-'))` * `nested_model_default_partial_update=True` * `case_sensitive=True` * `cli_hide_none_type=True` * `cli_avoid_json=True` * `cli_enforce_required=True` * `cli_implicit_flags=True` - -!!! note - The alias generator for kebab case does not propagate to subcommands or submodels and will have to be manually set - in these cases. +* `cli_kebab_case=True` ### Mutually Exclusive Groups @@ -1131,6 +1127,40 @@ print(Settings().model_dump()) #> {'good_arg': 'hello world'} ``` +#### CLI Kebab Case for Arguments + +Change whether CLI arguments should use kebab case by enabling `cli_kebab_case`. + +!!! note + CLI kebab case does not apply to subcommand or positional arguments, which must still use aliasing. + +```py +import sys + +from pydantic import Field + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings, cli_parse_args=True, cli_kebab_case=True): + my_option: str = Field(description='will show as kebab case on CLI') + + +try: + sys.argv = ['example.py', '--help'] + Settings() +except SystemExit as e: + print(e) + #> 0 +""" +usage: example.py [-h] [--my-option str] + +options: + -h, --help show this help message and exit + --my-option str will show as kebab case on CLI (required) +""" +``` + #### Change Whether CLI Should Exit on Error Change whether the CLI internal parser will exit on error or raise a `SettingsError` exception by using diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 723d6d5..f97a3e5 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -4,7 +4,7 @@ from types import SimpleNamespace from typing import Any, ClassVar, TypeVar -from pydantic import AliasGenerator, ConfigDict +from pydantic import ConfigDict from pydantic._internal._config import config_keys from pydantic._internal._signature import _field_name_for_signature from pydantic._internal._utils import deep_update, is_model_class @@ -52,6 +52,7 @@ class SettingsConfigDict(ConfigDict, total=False): cli_flag_prefix_char: str cli_implicit_flags: bool | None cli_ignore_unknown_args: bool | None + cli_kebab_case: bool | None secrets_dir: PathType | None json_file: PathType | None json_file_encoding: str | None @@ -133,6 +134,7 @@ class BaseSettings(BaseModel): _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`. + _cli_kebab_case: CLI args use kebab case. Defaults to `False`. _secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`. """ @@ -160,6 +162,7 @@ def __init__( _cli_flag_prefix_char: str | None = None, _cli_implicit_flags: bool | None = None, _cli_ignore_unknown_args: bool | None = None, + _cli_kebab_case: bool | None = None, _secrets_dir: PathType | None = None, **values: Any, ) -> None: @@ -189,6 +192,7 @@ def __init__( _cli_flag_prefix_char=_cli_flag_prefix_char, _cli_implicit_flags=_cli_implicit_flags, _cli_ignore_unknown_args=_cli_ignore_unknown_args, + _cli_kebab_case=_cli_kebab_case, _secrets_dir=_secrets_dir, ) ) @@ -242,6 +246,7 @@ def _settings_build_values( _cli_flag_prefix_char: str | None = None, _cli_implicit_flags: bool | None = None, _cli_ignore_unknown_args: bool | None = None, + _cli_kebab_case: bool | None = None, _secrets_dir: PathType | None = None, ) -> dict[str, Any]: # Determine settings config values @@ -309,6 +314,7 @@ def _settings_build_values( if _cli_ignore_unknown_args is not None else self.model_config.get('cli_ignore_unknown_args') ) + cli_kebab_case = _cli_kebab_case if _cli_kebab_case is not None else self.model_config.get('cli_kebab_case') secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir') @@ -371,6 +377,7 @@ def _settings_build_values( cli_flag_prefix_char=cli_flag_prefix_char, cli_implicit_flags=cli_implicit_flags, cli_ignore_unknown_args=cli_ignore_unknown_args, + cli_kebab_case=cli_kebab_case, case_sensitive=case_sensitive, ) sources = (cli_settings,) + sources @@ -418,6 +425,7 @@ def _settings_build_values( cli_flag_prefix_char='-', cli_implicit_flags=False, cli_ignore_unknown_args=False, + cli_kebab_case=False, json_file=None, json_file_encoding=None, yaml_file=None, @@ -497,13 +505,13 @@ def run( class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore model_config = SettingsConfigDict( - alias_generator=AliasGenerator(lambda s: s.replace('_', '-')), nested_model_default_partial_update=True, case_sensitive=True, cli_hide_none_type=True, cli_avoid_json=True, cli_enforce_required=True, cli_implicit_flags=True, + cli_kebab_case=True, ) model = CliAppBaseSettings(**model_init_data) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 66966e6..eed25e8 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1063,6 +1063,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]): 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`. + cli_kebab_case: CLI args use kebab case. Defaults to `False`. case_sensitive: Whether CLI "--arg" names should be read with case-sensitivity. Defaults to `True`. Note: Case-insensitive matching is only supported on the internal root parser and does not apply to CLI subcommands. @@ -1093,6 +1094,7 @@ def __init__( cli_flag_prefix_char: str | None = None, cli_implicit_flags: bool | None = None, cli_ignore_unknown_args: bool | None = None, + cli_kebab_case: bool | None = None, case_sensitive: bool | None = True, root_parser: Any = None, parse_args_method: Callable[..., Any] | None = None, @@ -1152,6 +1154,9 @@ def __init__( if cli_ignore_unknown_args is not None else settings_cls.model_config.get('cli_ignore_unknown_args', False) ) + self.cli_kebab_case = ( + cli_kebab_case if cli_kebab_case is not None else settings_cls.model_config.get('cli_kebab_case', False) + ) case_sensitive = case_sensitive if case_sensitive is not None else True if not case_sensitive and root_parser is not None: @@ -1753,6 +1758,8 @@ def _get_arg_names( if subcommand_prefix == self.env_prefix else f'{prefix.replace(subcommand_prefix, "", 1)}{name}' ) + if self.cli_kebab_case: + arg_names[-1] = arg_names[-1].replace('_', '-') return arg_names def _add_parser_submodels( diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py index fa623fc..ea39665 100644 --- a/tests/test_source_cli.py +++ b/tests/test_source_cli.py @@ -2268,3 +2268,53 @@ class MySettings(BaseSettings): CliApp.run( MySettings, cli_args=['--bac', 'cli abbrev are invalid for internal parser'], cli_exit_on_error=False ) + + +def test_cli_kebab_case(env, capsys, monkeypatch): + class SubModel(BaseModel): + v1: str = 'default' + v2: bytes = b'hello' + v3: int + + class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix='MYTEST_', + env_nested_delimiter='__', + nested_model_default_partial_update=True, + cli_parse_args=True, + cli_kebab_case=True, + ) + + v0: str = 'ok' + sub_model: SubModel = SubModel(v1='top default', v3=33) + + with monkeypatch.context() as m: + m.setattr(sys, 'argv', ['example.py', '--help']) + + with pytest.raises(SystemExit): + CliApp.run(Settings) + + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] [--v0 str] [--sub-model JSON] [--sub-model.v1 str] + [--sub-model.v2 bytes] [--sub-model.v3 int] + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + --v0 str (default: ok) + +sub-model options: + --sub-model JSON set sub-model from JSON string + --sub-model.v1 str (default: top default) + --sub-model.v2 bytes (default: b'hello') + --sub-model.v3 int (default: 33) +""" + ) + + env.set('MYTEST_V0', 'env with prefix') + env.set('MYTEST_SUB_MODEL__V1', 'env with prefix') + env.set('MYTEST_SUB_MODEL__V2', 'env with prefix') + assert CliApp.run(Settings, cli_args=['--sub-model.v1=cli']).model_dump() == { + 'v0': 'env with prefix', + 'sub_model': {'v1': 'cli', 'v2': b'env with prefix', 'v3': 33}, + } From 07d9b5ae013221406ef5f8b808fe01087c957bf4 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sun, 1 Dec 2024 12:06:08 -0700 Subject: [PATCH 2/4] Fix adding duplicate args. --- pydantic_settings/sources.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index eed25e8..ac785b1 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1682,7 +1682,8 @@ def _add_parser_args( else f'{arg_prefix}{preferred_alias}' ) - if kwargs['dest'] in added_args: + arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, alias_names, added_args) + if not arg_names or (kwargs['dest'] in added_args): continue if is_append_action: @@ -1690,7 +1691,6 @@ def _add_parser_args( if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True): self._cli_dict_args[kwargs['dest']] = field_info.annotation - arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, alias_names) if _CliPositionalArg in field_info.metadata: kwargs['metavar'] = preferred_alias.upper() arg_names = [kwargs['dest']] @@ -1748,18 +1748,25 @@ def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, mode ) def _get_arg_names( - self, arg_prefix: str, subcommand_prefix: str, alias_prefixes: list[str], alias_names: tuple[str, ...] + self, + arg_prefix: str, + subcommand_prefix: str, + alias_prefixes: list[str], + alias_names: tuple[str, ...], + added_args: list[str], ) -> list[str]: arg_names: list[str] = [] for prefix in [arg_prefix] + alias_prefixes: for name in alias_names: - arg_names.append( + arg_name = ( f'{prefix}{name}' if subcommand_prefix == self.env_prefix else f'{prefix.replace(subcommand_prefix, "", 1)}{name}' ) if self.cli_kebab_case: - arg_names[-1] = arg_names[-1].replace('_', '-') + arg_name = arg_name.replace('_', '-') + if arg_name not in added_args: + arg_names.append(arg_name) return arg_names def _add_parser_submodels( From c14aada203594ee77312ddb32666c09ff223defc Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Mon, 2 Dec 2024 08:05:11 -0700 Subject: [PATCH 3/4] Add kebab case to subcommands and positionals. --- docs/index.md | 3 -- pydantic_settings/sources.py | 15 ++++-- tests/test_source_cli.py | 99 +++++++++++++++++++++++++----------- 3 files changed, 78 insertions(+), 39 deletions(-) diff --git a/docs/index.md b/docs/index.md index b82c5a6..5e02981 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1131,9 +1131,6 @@ print(Settings().model_dump()) Change whether CLI arguments should use kebab case by enabling `cli_kebab_case`. -!!! note - CLI kebab case does not apply to subcommand or positional arguments, which must still use aliasing. - ```py import sys diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index ac785b1..d7f15b5 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1618,7 +1618,9 @@ def _add_parser_args( preferred_alias = alias_names[0] if _CliSubCommand in field_info.metadata: for model in sub_models: - subcommand_alias = model.__name__ if len(sub_models) > 1 else preferred_alias + subcommand_alias = self._check_kebab_name( + model.__name__ if len(sub_models) > 1 else preferred_alias + ) subcommand_name = f'{arg_prefix}{subcommand_alias}' subcommand_dest = f'{arg_prefix}{preferred_alias}' self._cli_subcommands[f'{arg_prefix}:subcommand'][subcommand_name] = subcommand_dest @@ -1692,7 +1694,7 @@ def _add_parser_args( self._cli_dict_args[kwargs['dest']] = field_info.annotation if _CliPositionalArg in field_info.metadata: - kwargs['metavar'] = preferred_alias.upper() + kwargs['metavar'] = self._check_kebab_name(preferred_alias.upper()) arg_names = [kwargs['dest']] del kwargs['dest'] del kwargs['required'] @@ -1731,6 +1733,11 @@ def _add_parser_args( self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group) return parser + def _check_kebab_name(self, name: str) -> str: + if self.cli_kebab_case: + return name.replace('_', '-') + return name + def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None: if kwargs['metavar'] == 'bool': default = None @@ -1758,13 +1765,11 @@ def _get_arg_names( arg_names: list[str] = [] for prefix in [arg_prefix] + alias_prefixes: for name in alias_names: - arg_name = ( + arg_name = self._check_kebab_name( f'{prefix}{name}' if subcommand_prefix == self.env_prefix else f'{prefix.replace(subcommand_prefix, "", 1)}{name}' ) - if self.cli_kebab_case: - arg_name = arg_name.replace('_', '-') if arg_name not in added_args: arg_names.append(arg_name) return arg_names diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py index ea39665..8b81621 100644 --- a/tests/test_source_cli.py +++ b/tests/test_source_cli.py @@ -2270,51 +2270,88 @@ class MySettings(BaseSettings): ) -def test_cli_kebab_case(env, capsys, monkeypatch): +def test_cli_kebab_case(capsys, monkeypatch): + class DeepSubModel(BaseModel): + deep_submodel_positional_arg: CliPositionalArg[str] + deep_submodel_arg: str + class SubModel(BaseModel): - v1: str = 'default' - v2: bytes = b'hello' - v3: int + submodel_subcommand: CliSubCommand[DeepSubModel] + submodel_arg: str - class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_prefix='MYTEST_', - env_nested_delimiter='__', - nested_model_default_partial_update=True, - cli_parse_args=True, - cli_kebab_case=True, - ) + class Root(BaseModel): + root_subcommand: CliSubCommand[SubModel] + root_arg: str - v0: str = 'ok' - sub_model: SubModel = SubModel(v1='top default', v3=33) + assert CliApp.run( + Root, + cli_args=[ + '--root-arg=hi', + 'root-subcommand', + '--submodel-arg=hello', + 'submodel-subcommand', + 'hey', + '--deep-submodel-arg=bye', + ], + ).model_dump() == { + 'root_arg': 'hi', + 'root_subcommand': { + 'submodel_arg': 'hello', + 'submodel_subcommand': {'deep_submodel_positional_arg': 'hey', 'deep_submodel_arg': 'bye'}, + }, + } with monkeypatch.context() as m: m.setattr(sys, 'argv', ['example.py', '--help']) - with pytest.raises(SystemExit): - CliApp.run(Settings) + CliApp.run(Root) + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] --root-arg str {{root-subcommand}} ... + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + --root-arg str (required) +subcommands: + {{root-subcommand}} + root-subcommand +""" + ) + + m.setattr(sys, 'argv', ['example.py', 'root-subcommand', '--help']) + with pytest.raises(SystemExit): + CliApp.run(Root) assert ( capsys.readouterr().out - == f"""usage: example.py [-h] [--v0 str] [--sub-model JSON] [--sub-model.v1 str] - [--sub-model.v2 bytes] [--sub-model.v3 int] + == f"""usage: example.py root-subcommand [-h] --submodel-arg str + {{submodel-subcommand}} ... {ARGPARSE_OPTIONS_TEXT}: -h, --help show this help message and exit - --v0 str (default: ok) + --submodel-arg str (required) -sub-model options: - --sub-model JSON set sub-model from JSON string - --sub-model.v1 str (default: top default) - --sub-model.v2 bytes (default: b'hello') - --sub-model.v3 int (default: 33) +subcommands: + {{submodel-subcommand}} + submodel-subcommand """ ) - env.set('MYTEST_V0', 'env with prefix') - env.set('MYTEST_SUB_MODEL__V1', 'env with prefix') - env.set('MYTEST_SUB_MODEL__V2', 'env with prefix') - assert CliApp.run(Settings, cli_args=['--sub-model.v1=cli']).model_dump() == { - 'v0': 'env with prefix', - 'sub_model': {'v1': 'cli', 'v2': b'env with prefix', 'v3': 33}, - } + m.setattr(sys, 'argv', ['example.py', 'root-subcommand', 'submodel-subcommand', '--help']) + with pytest.raises(SystemExit): + CliApp.run(Root) + assert ( + capsys.readouterr().out + == f"""usage: example.py root-subcommand submodel-subcommand [-h] + --deep-submodel-arg str + DEEP-SUBMODEL-POSITIONAL-ARG + +positional arguments: + DEEP-SUBMODEL-POSITIONAL-ARG + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + --deep-submodel-arg str + (required) +""" + ) From 2ce7e24b6d5cb8a4ce1c3704e71039dfe0cb9035 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Mon, 2 Dec 2024 22:40:36 -0700 Subject: [PATCH 4/4] Shorten args for test formatting between py 3.13 and earlier versions. --- tests/test_source_cli.py | 60 +++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py index 66e752d..35bfcda 100644 --- a/tests/test_source_cli.py +++ b/tests/test_source_cli.py @@ -2296,32 +2296,32 @@ class WithUnion(BaseSettings): def test_cli_kebab_case(capsys, monkeypatch): class DeepSubModel(BaseModel): - deep_submodel_positional_arg: CliPositionalArg[str] - deep_submodel_arg: str + deep_pos_arg: CliPositionalArg[str] + deep_arg: str class SubModel(BaseModel): - submodel_subcommand: CliSubCommand[DeepSubModel] - submodel_arg: str + sub_subcmd: CliSubCommand[DeepSubModel] + sub_arg: str class Root(BaseModel): - root_subcommand: CliSubCommand[SubModel] + root_subcmd: CliSubCommand[SubModel] root_arg: str assert CliApp.run( Root, cli_args=[ '--root-arg=hi', - 'root-subcommand', - '--submodel-arg=hello', - 'submodel-subcommand', + 'root-subcmd', + '--sub-arg=hello', + 'sub-subcmd', 'hey', - '--deep-submodel-arg=bye', + '--deep-arg=bye', ], ).model_dump() == { 'root_arg': 'hi', - 'root_subcommand': { - 'submodel_arg': 'hello', - 'submodel_subcommand': {'deep_submodel_positional_arg': 'hey', 'deep_submodel_arg': 'bye'}, + 'root_subcmd': { + 'sub_arg': 'hello', + 'sub_subcmd': {'deep_pos_arg': 'hey', 'deep_arg': 'bye'}, }, } @@ -2331,51 +2331,47 @@ class Root(BaseModel): CliApp.run(Root) assert ( capsys.readouterr().out - == f"""usage: example.py [-h] --root-arg str {{root-subcommand}} ... + == f"""usage: example.py [-h] --root-arg str {{root-subcmd}} ... {ARGPARSE_OPTIONS_TEXT}: - -h, --help show this help message and exit - --root-arg str (required) + -h, --help show this help message and exit + --root-arg str (required) subcommands: - {{root-subcommand}} - root-subcommand + {{root-subcmd}} + root-subcmd """ ) - m.setattr(sys, 'argv', ['example.py', 'root-subcommand', '--help']) + m.setattr(sys, 'argv', ['example.py', 'root-subcmd', '--help']) with pytest.raises(SystemExit): CliApp.run(Root) assert ( capsys.readouterr().out - == f"""usage: example.py root-subcommand [-h] --submodel-arg str - {{submodel-subcommand}} ... + == f"""usage: example.py root-subcmd [-h] --sub-arg str {{sub-subcmd}} ... {ARGPARSE_OPTIONS_TEXT}: - -h, --help show this help message and exit - --submodel-arg str (required) + -h, --help show this help message and exit + --sub-arg str (required) subcommands: - {{submodel-subcommand}} - submodel-subcommand + {{sub-subcmd}} + sub-subcmd """ ) - m.setattr(sys, 'argv', ['example.py', 'root-subcommand', 'submodel-subcommand', '--help']) + m.setattr(sys, 'argv', ['example.py', 'root-subcmd', 'sub-subcmd', '--help']) with pytest.raises(SystemExit): CliApp.run(Root) assert ( capsys.readouterr().out - == f"""usage: example.py root-subcommand submodel-subcommand [-h] - --deep-submodel-arg str - DEEP-SUBMODEL-POSITIONAL-ARG + == f"""usage: example.py root-subcmd sub-subcmd [-h] --deep-arg str DEEP-POS-ARG positional arguments: - DEEP-SUBMODEL-POSITIONAL-ARG + DEEP-POS-ARG {ARGPARSE_OPTIONS_TEXT}: - -h, --help show this help message and exit - --deep-submodel-arg str - (required) + -h, --help show this help message and exit + --deep-arg str (required) """ )