Skip to content

Commit

Permalink
Add support for CLI kebab case flag. (#489)
Browse files Browse the repository at this point in the history
  • Loading branch information
kschwab authored Dec 3, 2024
1 parent 0b3e73d commit 7bcb6ed
Show file tree
Hide file tree
Showing 4 changed files with 150 additions and 13 deletions.
37 changes: 32 additions & 5 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`.
"""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 25 additions & 6 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1677,17 +1684,17 @@ 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:
kwargs['action'] = 'append'
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']
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
83 changes: 83 additions & 0 deletions tests/test_source_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
)

0 comments on commit 7bcb6ed

Please sign in to comment.