Skip to content

Commit 02f186e

Browse files
committed
Add support for CLI kebab case flag.
1 parent a0924bc commit 02f186e

File tree

4 files changed

+102
-7
lines changed

4 files changed

+102
-7
lines changed

docs/index.md

Lines changed: 35 additions & 5 deletions
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,40 @@ 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+
!!! note
1135+
CLI kebab case does not apply to subcommand or positional arguments, which must still use aliasing.
1136+
1137+
```py
1138+
import sys
1139+
1140+
from pydantic import Field
1141+
1142+
from pydantic_settings import BaseSettings
1143+
1144+
1145+
class Settings(BaseSettings, cli_parse_args=True, cli_kebab_case=True):
1146+
my_option: str = Field(description='will show as kebab case on CLI')
1147+
1148+
1149+
try:
1150+
sys.argv = ['example.py', '--help']
1151+
Settings()
1152+
except SystemExit as e:
1153+
print(e)
1154+
#> 0
1155+
"""
1156+
usage: example.py [-h] [--my-option str]
1157+
1158+
options:
1159+
-h, --help show this help message and exit
1160+
--my-option str will show as kebab case on CLI (required)
1161+
"""
1162+
```
1163+
11341164
#### Change Whether CLI Should Exit on Error
11351165

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

pydantic_settings/main.py

Lines changed: 10 additions & 2 deletions
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

Lines changed: 7 additions & 0 deletions
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:
@@ -1753,6 +1758,8 @@ def _get_arg_names(
17531758
if subcommand_prefix == self.env_prefix
17541759
else f'{prefix.replace(subcommand_prefix, "", 1)}{name}'
17551760
)
1761+
if self.cli_kebab_case:
1762+
arg_names[-1] = arg_names[-1].replace('_', '-')
17561763
return arg_names
17571764

17581765
def _add_parser_submodels(

tests/test_source_cli.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2268,3 +2268,53 @@ class MySettings(BaseSettings):
22682268
CliApp.run(
22692269
MySettings, cli_args=['--bac', 'cli abbrev are invalid for internal parser'], cli_exit_on_error=False
22702270
)
2271+
2272+
2273+
def test_cli_kebab_case(env, capsys, monkeypatch):
2274+
class SubModel(BaseModel):
2275+
v1: str = 'default'
2276+
v2: bytes = b'hello'
2277+
v3: int
2278+
2279+
class Settings(BaseSettings):
2280+
model_config = SettingsConfigDict(
2281+
env_prefix='MYTEST_',
2282+
env_nested_delimiter='__',
2283+
nested_model_default_partial_update=True,
2284+
cli_parse_args=True,
2285+
cli_kebab_case=True,
2286+
)
2287+
2288+
v0: str = 'ok'
2289+
sub_model: SubModel = SubModel(v1='top default', v3=33)
2290+
2291+
with monkeypatch.context() as m:
2292+
m.setattr(sys, 'argv', ['example.py', '--help'])
2293+
2294+
with pytest.raises(SystemExit):
2295+
CliApp.run(Settings)
2296+
2297+
assert (
2298+
capsys.readouterr().out
2299+
== f"""usage: example.py [-h] [--v0 str] [--sub-model JSON] [--sub-model.v1 str]
2300+
[--sub-model.v2 bytes] [--sub-model.v3 int]
2301+
2302+
{ARGPARSE_OPTIONS_TEXT}:
2303+
-h, --help show this help message and exit
2304+
--v0 str (default: ok)
2305+
2306+
sub-model options:
2307+
--sub-model JSON set sub-model from JSON string
2308+
--sub-model.v1 str (default: top default)
2309+
--sub-model.v2 bytes (default: b'hello')
2310+
--sub-model.v3 int (default: 33)
2311+
"""
2312+
)
2313+
2314+
env.set('MYTEST_V0', 'env with prefix')
2315+
env.set('MYTEST_SUB_MODEL__V1', 'env with prefix')
2316+
env.set('MYTEST_SUB_MODEL__V2', 'env with prefix')
2317+
assert CliApp.run(Settings, cli_args=['--sub-model.v1=cli']).model_dump() == {
2318+
'v0': 'env with prefix',
2319+
'sub_model': {'v1': 'cli', 'v2': b'env with prefix', 'v3': 33},
2320+
}

0 commit comments

Comments
 (0)