Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
40 changes: 35 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,40 @@ 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`.

!!! note
CLI kebab case does not apply to subcommand or positional arguments, which must still use aliasing.

```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
7 changes: 7 additions & 0 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 @@ -1753,6 +1758,8 @@ def _get_arg_names(
if subcommand_prefix == self.env_prefix
else f'{prefix.replace(subcommand_prefix, "", 1)}{name}'
)
if self.cli_kebab_case:
arg_names[-1] = arg_names[-1].replace('_', '-')
return arg_names

def _add_parser_submodels(
Expand Down
50 changes: 50 additions & 0 deletions tests/test_source_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2268,3 +2268,53 @@ class MySettings(BaseSettings):
CliApp.run(
MySettings, cli_args=['--bac', 'cli abbrev are invalid for internal parser'], cli_exit_on_error=False
)


def test_cli_kebab_case(env, capsys, monkeypatch):
class SubModel(BaseModel):
v1: str = 'default'
v2: bytes = b'hello'
v3: int

class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix='MYTEST_',
env_nested_delimiter='__',
nested_model_default_partial_update=True,
cli_parse_args=True,
cli_kebab_case=True,
)

v0: str = 'ok'
sub_model: SubModel = SubModel(v1='top default', v3=33)

with monkeypatch.context() as m:
m.setattr(sys, 'argv', ['example.py', '--help'])

with pytest.raises(SystemExit):
CliApp.run(Settings)

assert (
capsys.readouterr().out
== f"""usage: example.py [-h] [--v0 str] [--sub-model JSON] [--sub-model.v1 str]
[--sub-model.v2 bytes] [--sub-model.v3 int]

{ARGPARSE_OPTIONS_TEXT}:
-h, --help show this help message and exit
--v0 str (default: ok)

sub-model options:
--sub-model JSON set sub-model from JSON string
--sub-model.v1 str (default: top default)
--sub-model.v2 bytes (default: b'hello')
--sub-model.v3 int (default: 33)
"""
)

env.set('MYTEST_V0', 'env with prefix')
env.set('MYTEST_SUB_MODEL__V1', 'env with prefix')
env.set('MYTEST_SUB_MODEL__V2', 'env with prefix')
assert CliApp.run(Settings, cli_args=['--sub-model.v1=cli']).model_dump() == {
'v0': 'env with prefix',
'sub_model': {'v1': 'cli', 'v2': b'env with prefix', 'v3': 33},
}
Loading