Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for CLI kebab case flag. #489

Merged
merged 6 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
"""
)
Loading