Skip to content

Commit 7bcb6ed

Browse files
authored
Add support for CLI kebab case flag. (#489)
1 parent 0b3e73d commit 7bcb6ed

File tree

4 files changed

+150
-13
lines changed

4 files changed

+150
-13
lines changed

docs/index.md

+32-5
Original file line numberDiff line numberDiff line change
@@ -957,17 +957,13 @@ assert cmd.model_dump() == {
957957
For `BaseModel` and `pydantic.dataclasses.dataclass` types, `CliApp.run` will internally use the following
958958
`BaseSettings` configuration defaults:
959959

960-
* `alias_generator=AliasGenerator(lambda s: s.replace('_', '-'))`
961960
* `nested_model_default_partial_update=True`
962961
* `case_sensitive=True`
963962
* `cli_hide_none_type=True`
964963
* `cli_avoid_json=True`
965964
* `cli_enforce_required=True`
966965
* `cli_implicit_flags=True`
967-
968-
!!! note
969-
The alias generator for kebab case does not propagate to subcommands or submodels and will have to be manually set
970-
in these cases.
966+
* `cli_kebab_case=True`
971967

972968
### Mutually Exclusive Groups
973969

@@ -1131,6 +1127,37 @@ print(Settings().model_dump())
11311127
#> {'good_arg': 'hello world'}
11321128
```
11331129

1130+
#### CLI Kebab Case for Arguments
1131+
1132+
Change whether CLI arguments should use kebab case by enabling `cli_kebab_case`.
1133+
1134+
```py
1135+
import sys
1136+
1137+
from pydantic import Field
1138+
1139+
from pydantic_settings import BaseSettings
1140+
1141+
1142+
class Settings(BaseSettings, cli_parse_args=True, cli_kebab_case=True):
1143+
my_option: str = Field(description='will show as kebab case on CLI')
1144+
1145+
1146+
try:
1147+
sys.argv = ['example.py', '--help']
1148+
Settings()
1149+
except SystemExit as e:
1150+
print(e)
1151+
#> 0
1152+
"""
1153+
usage: example.py [-h] [--my-option str]
1154+
1155+
options:
1156+
-h, --help show this help message and exit
1157+
--my-option str will show as kebab case on CLI (required)
1158+
"""
1159+
```
1160+
11341161
#### Change Whether CLI Should Exit on Error
11351162

11361163
Change whether the CLI internal parser will exit on error or raise a `SettingsError` exception by using

pydantic_settings/main.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from types import SimpleNamespace
55
from typing import Any, ClassVar, TypeVar
66

7-
from pydantic import AliasGenerator, ConfigDict
7+
from pydantic import ConfigDict
88
from pydantic._internal._config import config_keys
99
from pydantic._internal._signature import _field_name_for_signature
1010
from pydantic._internal._utils import deep_update, is_model_class
@@ -52,6 +52,7 @@ class SettingsConfigDict(ConfigDict, total=False):
5252
cli_flag_prefix_char: str
5353
cli_implicit_flags: bool | None
5454
cli_ignore_unknown_args: bool | None
55+
cli_kebab_case: bool | None
5556
secrets_dir: PathType | None
5657
json_file: PathType | None
5758
json_file_encoding: str | None
@@ -133,6 +134,7 @@ class BaseSettings(BaseModel):
133134
_cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
134135
(e.g. --flag, --no-flag). Defaults to `False`.
135136
_cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
137+
_cli_kebab_case: CLI args use kebab case. Defaults to `False`.
136138
_secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`.
137139
"""
138140

@@ -160,6 +162,7 @@ def __init__(
160162
_cli_flag_prefix_char: str | None = None,
161163
_cli_implicit_flags: bool | None = None,
162164
_cli_ignore_unknown_args: bool | None = None,
165+
_cli_kebab_case: bool | None = None,
163166
_secrets_dir: PathType | None = None,
164167
**values: Any,
165168
) -> None:
@@ -189,6 +192,7 @@ def __init__(
189192
_cli_flag_prefix_char=_cli_flag_prefix_char,
190193
_cli_implicit_flags=_cli_implicit_flags,
191194
_cli_ignore_unknown_args=_cli_ignore_unknown_args,
195+
_cli_kebab_case=_cli_kebab_case,
192196
_secrets_dir=_secrets_dir,
193197
)
194198
)
@@ -242,6 +246,7 @@ def _settings_build_values(
242246
_cli_flag_prefix_char: str | None = None,
243247
_cli_implicit_flags: bool | None = None,
244248
_cli_ignore_unknown_args: bool | None = None,
249+
_cli_kebab_case: bool | None = None,
245250
_secrets_dir: PathType | None = None,
246251
) -> dict[str, Any]:
247252
# Determine settings config values
@@ -309,6 +314,7 @@ def _settings_build_values(
309314
if _cli_ignore_unknown_args is not None
310315
else self.model_config.get('cli_ignore_unknown_args')
311316
)
317+
cli_kebab_case = _cli_kebab_case if _cli_kebab_case is not None else self.model_config.get('cli_kebab_case')
312318

313319
secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir')
314320

@@ -371,6 +377,7 @@ def _settings_build_values(
371377
cli_flag_prefix_char=cli_flag_prefix_char,
372378
cli_implicit_flags=cli_implicit_flags,
373379
cli_ignore_unknown_args=cli_ignore_unknown_args,
380+
cli_kebab_case=cli_kebab_case,
374381
case_sensitive=case_sensitive,
375382
)
376383
sources = (cli_settings,) + sources
@@ -418,6 +425,7 @@ def _settings_build_values(
418425
cli_flag_prefix_char='-',
419426
cli_implicit_flags=False,
420427
cli_ignore_unknown_args=False,
428+
cli_kebab_case=False,
421429
json_file=None,
422430
json_file_encoding=None,
423431
yaml_file=None,
@@ -497,13 +505,13 @@ def run(
497505

498506
class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore
499507
model_config = SettingsConfigDict(
500-
alias_generator=AliasGenerator(lambda s: s.replace('_', '-')),
501508
nested_model_default_partial_update=True,
502509
case_sensitive=True,
503510
cli_hide_none_type=True,
504511
cli_avoid_json=True,
505512
cli_enforce_required=True,
506513
cli_implicit_flags=True,
514+
cli_kebab_case=True,
507515
)
508516

509517
model = CliAppBaseSettings(**model_init_data)

pydantic_settings/sources.py

+25-6
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
10631063
cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
10641064
(e.g. --flag, --no-flag). Defaults to `False`.
10651065
cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
1066+
cli_kebab_case: CLI args use kebab case. Defaults to `False`.
10661067
case_sensitive: Whether CLI "--arg" names should be read with case-sensitivity. Defaults to `True`.
10671068
Note: Case-insensitive matching is only supported on the internal root parser and does not apply to CLI
10681069
subcommands.
@@ -1093,6 +1094,7 @@ def __init__(
10931094
cli_flag_prefix_char: str | None = None,
10941095
cli_implicit_flags: bool | None = None,
10951096
cli_ignore_unknown_args: bool | None = None,
1097+
cli_kebab_case: bool | None = None,
10961098
case_sensitive: bool | None = True,
10971099
root_parser: Any = None,
10981100
parse_args_method: Callable[..., Any] | None = None,
@@ -1152,6 +1154,9 @@ def __init__(
11521154
if cli_ignore_unknown_args is not None
11531155
else settings_cls.model_config.get('cli_ignore_unknown_args', False)
11541156
)
1157+
self.cli_kebab_case = (
1158+
cli_kebab_case if cli_kebab_case is not None else settings_cls.model_config.get('cli_kebab_case', False)
1159+
)
11551160

11561161
case_sensitive = case_sensitive if case_sensitive is not None else True
11571162
if not case_sensitive and root_parser is not None:
@@ -1613,7 +1618,9 @@ def _add_parser_args(
16131618
preferred_alias = alias_names[0]
16141619
if _CliSubCommand in field_info.metadata:
16151620
for model in sub_models:
1616-
subcommand_alias = model.__name__ if len(sub_models) > 1 else preferred_alias
1621+
subcommand_alias = self._check_kebab_name(
1622+
model.__name__ if len(sub_models) > 1 else preferred_alias
1623+
)
16171624
subcommand_name = f'{arg_prefix}{subcommand_alias}'
16181625
subcommand_dest = f'{arg_prefix}{preferred_alias}'
16191626
self._cli_subcommands[f'{arg_prefix}:subcommand'][subcommand_name] = subcommand_dest
@@ -1677,17 +1684,17 @@ def _add_parser_args(
16771684
else f'{arg_prefix}{preferred_alias}'
16781685
)
16791686

1680-
if kwargs['dest'] in added_args:
1687+
arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, alias_names, added_args)
1688+
if not arg_names or (kwargs['dest'] in added_args):
16811689
continue
16821690

16831691
if is_append_action:
16841692
kwargs['action'] = 'append'
16851693
if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True):
16861694
self._cli_dict_args[kwargs['dest']] = field_info.annotation
16871695

1688-
arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, alias_names)
16891696
if _CliPositionalArg in field_info.metadata:
1690-
kwargs['metavar'] = preferred_alias.upper()
1697+
kwargs['metavar'] = self._check_kebab_name(preferred_alias.upper())
16911698
arg_names = [kwargs['dest']]
16921699
del kwargs['dest']
16931700
del kwargs['required']
@@ -1726,6 +1733,11 @@ def _add_parser_args(
17261733
self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group)
17271734
return parser
17281735

1736+
def _check_kebab_name(self, name: str) -> str:
1737+
if self.cli_kebab_case:
1738+
return name.replace('_', '-')
1739+
return name
1740+
17291741
def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None:
17301742
if kwargs['metavar'] == 'bool':
17311743
default = None
@@ -1743,16 +1755,23 @@ def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, mode
17431755
)
17441756

17451757
def _get_arg_names(
1746-
self, arg_prefix: str, subcommand_prefix: str, alias_prefixes: list[str], alias_names: tuple[str, ...]
1758+
self,
1759+
arg_prefix: str,
1760+
subcommand_prefix: str,
1761+
alias_prefixes: list[str],
1762+
alias_names: tuple[str, ...],
1763+
added_args: list[str],
17471764
) -> list[str]:
17481765
arg_names: list[str] = []
17491766
for prefix in [arg_prefix] + alias_prefixes:
17501767
for name in alias_names:
1751-
arg_names.append(
1768+
arg_name = self._check_kebab_name(
17521769
f'{prefix}{name}'
17531770
if subcommand_prefix == self.env_prefix
17541771
else f'{prefix.replace(subcommand_prefix, "", 1)}{name}'
17551772
)
1773+
if arg_name not in added_args:
1774+
arg_names.append(arg_name)
17561775
return arg_names
17571776

17581777
def _add_parser_submodels(

tests/test_source_cli.py

+83
Original file line numberDiff line numberDiff line change
@@ -2292,3 +2292,86 @@ class WithUnion(BaseSettings):
22922292
poly: Poly
22932293

22942294
assert CliApp.run(WithUnion, ['--poly.type=a']).model_dump() == {'poly': {'a': 1, 'type': 'a'}}
2295+
2296+
2297+
def test_cli_kebab_case(capsys, monkeypatch):
2298+
class DeepSubModel(BaseModel):
2299+
deep_pos_arg: CliPositionalArg[str]
2300+
deep_arg: str
2301+
2302+
class SubModel(BaseModel):
2303+
sub_subcmd: CliSubCommand[DeepSubModel]
2304+
sub_arg: str
2305+
2306+
class Root(BaseModel):
2307+
root_subcmd: CliSubCommand[SubModel]
2308+
root_arg: str
2309+
2310+
assert CliApp.run(
2311+
Root,
2312+
cli_args=[
2313+
'--root-arg=hi',
2314+
'root-subcmd',
2315+
'--sub-arg=hello',
2316+
'sub-subcmd',
2317+
'hey',
2318+
'--deep-arg=bye',
2319+
],
2320+
).model_dump() == {
2321+
'root_arg': 'hi',
2322+
'root_subcmd': {
2323+
'sub_arg': 'hello',
2324+
'sub_subcmd': {'deep_pos_arg': 'hey', 'deep_arg': 'bye'},
2325+
},
2326+
}
2327+
2328+
with monkeypatch.context() as m:
2329+
m.setattr(sys, 'argv', ['example.py', '--help'])
2330+
with pytest.raises(SystemExit):
2331+
CliApp.run(Root)
2332+
assert (
2333+
capsys.readouterr().out
2334+
== f"""usage: example.py [-h] --root-arg str {{root-subcmd}} ...
2335+
2336+
{ARGPARSE_OPTIONS_TEXT}:
2337+
-h, --help show this help message and exit
2338+
--root-arg str (required)
2339+
2340+
subcommands:
2341+
{{root-subcmd}}
2342+
root-subcmd
2343+
"""
2344+
)
2345+
2346+
m.setattr(sys, 'argv', ['example.py', 'root-subcmd', '--help'])
2347+
with pytest.raises(SystemExit):
2348+
CliApp.run(Root)
2349+
assert (
2350+
capsys.readouterr().out
2351+
== f"""usage: example.py root-subcmd [-h] --sub-arg str {{sub-subcmd}} ...
2352+
2353+
{ARGPARSE_OPTIONS_TEXT}:
2354+
-h, --help show this help message and exit
2355+
--sub-arg str (required)
2356+
2357+
subcommands:
2358+
{{sub-subcmd}}
2359+
sub-subcmd
2360+
"""
2361+
)
2362+
2363+
m.setattr(sys, 'argv', ['example.py', 'root-subcmd', 'sub-subcmd', '--help'])
2364+
with pytest.raises(SystemExit):
2365+
CliApp.run(Root)
2366+
assert (
2367+
capsys.readouterr().out
2368+
== f"""usage: example.py root-subcmd sub-subcmd [-h] --deep-arg str DEEP-POS-ARG
2369+
2370+
positional arguments:
2371+
DEEP-POS-ARG
2372+
2373+
{ARGPARSE_OPTIONS_TEXT}:
2374+
-h, --help show this help message and exit
2375+
--deep-arg str (required)
2376+
"""
2377+
)

0 commit comments

Comments
 (0)