diff --git a/docs/index.md b/docs/index.md index 218da6a..5e02981 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,37 @@ 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`. + +```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 4903ffc..8f6fdbc 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 9fe3f03..2bf870b 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: @@ -1613,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 @@ -1677,7 +1684,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: @@ -1685,9 +1693,8 @@ 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() + kwargs['metavar'] = self._check_kebab_name(preferred_alias.upper()) arg_names = [kwargs['dest']] del kwargs['dest'] del kwargs['required'] @@ -1726,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 @@ -1743,16 +1755,23 @@ 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 = self._check_kebab_name( f'{prefix}{name}' if subcommand_prefix == self.env_prefix else f'{prefix.replace(subcommand_prefix, "", 1)}{name}' ) + if arg_name not in added_args: + arg_names.append(arg_name) return arg_names def _add_parser_submodels( diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py index da332b0..35bfcda 100644 --- a/tests/test_source_cli.py +++ b/tests/test_source_cli.py @@ -2292,3 +2292,86 @@ class WithUnion(BaseSettings): poly: Poly assert CliApp.run(WithUnion, ['--poly.type=a']).model_dump() == {'poly': {'a': 1, 'type': 'a'}} + + +def test_cli_kebab_case(capsys, monkeypatch): + class DeepSubModel(BaseModel): + deep_pos_arg: CliPositionalArg[str] + deep_arg: str + + class SubModel(BaseModel): + sub_subcmd: CliSubCommand[DeepSubModel] + sub_arg: str + + class Root(BaseModel): + root_subcmd: CliSubCommand[SubModel] + root_arg: str + + assert CliApp.run( + Root, + cli_args=[ + '--root-arg=hi', + 'root-subcmd', + '--sub-arg=hello', + 'sub-subcmd', + 'hey', + '--deep-arg=bye', + ], + ).model_dump() == { + 'root_arg': 'hi', + 'root_subcmd': { + 'sub_arg': 'hello', + 'sub_subcmd': {'deep_pos_arg': 'hey', 'deep_arg': 'bye'}, + }, + } + + with monkeypatch.context() as m: + m.setattr(sys, 'argv', ['example.py', '--help']) + with pytest.raises(SystemExit): + CliApp.run(Root) + assert ( + capsys.readouterr().out + == 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) + +subcommands: + {{root-subcmd}} + root-subcmd +""" + ) + + 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-subcmd [-h] --sub-arg str {{sub-subcmd}} ... + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + --sub-arg str (required) + +subcommands: + {{sub-subcmd}} + sub-subcmd +""" + ) + + 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-subcmd sub-subcmd [-h] --deep-arg str DEEP-POS-ARG + +positional arguments: + DEEP-POS-ARG + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + --deep-arg str (required) +""" + )