Skip to content
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
64 changes: 62 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

## Installation

Installation is as simple as:
Expand Down Expand Up @@ -1172,7 +1171,7 @@ CliApp.run(Git, cli_args=['clone', 'repo', 'dir']).model_dump() == {
}
```

When executing a subcommand with an asynchronous cli_cmd, Pydantic settings automatically detects whether the current thread already has an active event loop. If so, the async command is run in a fresh thread to avoid conflicts. Otherwise, it uses asyncio.run() in the current thread. This handling ensures your asynchronous subcommands just work without additional manual setup.
When executing a subcommand with an asynchronous cli_cmd, Pydantic settings automatically detects whether the current thread already has an active event loop. If so, the async command is run in a fresh thread to avoid conflicts. Otherwise, it uses asyncio.run() in the current thread. This handling ensures your asynchronous subcommands "just work" without additional manual setup.

### Mutually Exclusive Groups

Expand Down Expand Up @@ -1633,6 +1632,67 @@ options:
"""
```

#### CLI Shortcuts for Arguments

Add alternative CLI argument names (shortcuts) for fields using the `cli_shortcuts` option in `SettingsConfigDict`. This allows you to define additional names for CLI arguments, which can be especially useful for providing more user-friendly or shorter aliases for deeply nested or verbose field names.

The `cli_shortcuts` option takes a dictionary mapping the target field name (using dot notation for nested fields) to one or more shortcut names. If multiple fields share the same shortcut, the first matching field will take precedence.

**Flat Example:**

```py
from pydantic import Field

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
option: str = Field(default='foo')
list_option: str = Field(default='fizz')

model_config = SettingsConfigDict(
cli_shortcuts={'option': 'option2', 'list_option': ['list_option2']}
)


# Now you can use the shortcuts on the CLI:
# --option2 sets 'option', --list_option2 sets 'list_option'
```

**Nested Example:**

```py
from pydantic import BaseModel, Field

from pydantic_settings import BaseSettings, SettingsConfigDict


class TwiceNested(BaseModel):
option: str = Field(default='foo')


class Nested(BaseModel):
twice_nested_option: TwiceNested = TwiceNested()
option: str = Field(default='foo')


class Settings(BaseSettings):
nested: Nested = Nested()
model_config = SettingsConfigDict(
cli_shortcuts={
'nested.option': 'option2',
'nested.twice_nested_option.option': 'twice_nested_option',
}
)


# Now you can use --option2 to set nested.option and --twice_nested_option to set nested.twice_nested_option.option
```

If a shortcut collides (is mapped to multiple fields), it will apply to the first matching field in the model.

See the [test cases](../tests/test_source_cli.py) for more advanced usage and edge cases.

### Integrating with Existing Parsers

A CLI settings source can be integrated with existing parsers by overriding the default CLI settings source with a user
Expand Down
9 changes: 9 additions & 0 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import inspect
import threading
from argparse import Namespace
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Any, ClassVar, TypeVar

Expand Down Expand Up @@ -57,6 +58,7 @@ class SettingsConfigDict(ConfigDict, total=False):
cli_implicit_flags: bool | None
cli_ignore_unknown_args: bool | None
cli_kebab_case: bool | None
cli_shortcuts: Mapping[str, str | list[str]] | None
secrets_dir: PathType | None
json_file: PathType | None
json_file_encoding: str | None
Expand Down Expand Up @@ -149,6 +151,7 @@ class BaseSettings(BaseModel):
(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`.
_cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`.
_secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`.
"""

Expand Down Expand Up @@ -178,6 +181,7 @@ def __init__(
_cli_implicit_flags: bool | None = None,
_cli_ignore_unknown_args: bool | None = None,
_cli_kebab_case: bool | None = None,
_cli_shortcuts: Mapping[str, str | list[str]] | None = None,
_secrets_dir: PathType | None = None,
**values: Any,
) -> None:
Expand Down Expand Up @@ -208,6 +212,7 @@ def __init__(
_cli_implicit_flags=_cli_implicit_flags,
_cli_ignore_unknown_args=_cli_ignore_unknown_args,
_cli_kebab_case=_cli_kebab_case,
_cli_shortcuts=_cli_shortcuts,
_secrets_dir=_secrets_dir,
)
)
Expand Down Expand Up @@ -263,6 +268,7 @@ def _settings_build_values(
_cli_implicit_flags: bool | None = None,
_cli_ignore_unknown_args: bool | None = None,
_cli_kebab_case: bool | None = None,
_cli_shortcuts: Mapping[str, str | list[str]] | None = None,
_secrets_dir: PathType | None = None,
) -> dict[str, Any]:
# Determine settings config values
Expand Down Expand Up @@ -336,6 +342,7 @@ def _settings_build_values(
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')
cli_shortcuts = _cli_shortcuts if _cli_shortcuts is not None else self.model_config.get('cli_shortcuts')

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

Expand Down Expand Up @@ -401,6 +408,7 @@ def _settings_build_values(
cli_implicit_flags=cli_implicit_flags,
cli_ignore_unknown_args=cli_ignore_unknown_args,
cli_kebab_case=cli_kebab_case,
cli_shortcuts=cli_shortcuts,
case_sensitive=case_sensitive,
)
sources = (cli_settings,) + sources
Expand Down Expand Up @@ -450,6 +458,7 @@ def _settings_build_values(
cli_implicit_flags=False,
cli_ignore_unknown_args=False,
cli_kebab_case=False,
cli_shortcuts=None,
json_file=None,
json_file_encoding=None,
yaml_file=None,
Expand Down
12 changes: 12 additions & 0 deletions pydantic_settings/sources/providers/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
(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`.
cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`.
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 @@ -150,6 +151,7 @@ def __init__(
cli_implicit_flags: bool | None = None,
cli_ignore_unknown_args: bool | None = None,
cli_kebab_case: bool | None = None,
cli_shortcuts: Mapping[str, str | list[str]] | None = None,
case_sensitive: bool | None = True,
root_parser: Any = None,
parse_args_method: Callable[..., Any] | None = None,
Expand Down Expand Up @@ -215,6 +217,9 @@ def __init__(
self.cli_kebab_case = (
cli_kebab_case if cli_kebab_case is not None else settings_cls.model_config.get('cli_kebab_case', False)
)
self.cli_shortcuts = (
cli_shortcuts if cli_shortcuts is not None else settings_cls.model_config.get('cli_shortcuts', None)
)

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 @@ -874,6 +879,13 @@ def _get_arg_names(
)
if arg_name not in added_args:
arg_names.append(arg_name)

if self.cli_shortcuts:
for target, aliases in self.cli_shortcuts.items():
if target in arg_names:
alias_list = [aliases] if isinstance(aliases, str) else aliases
arg_names.extend(alias for alias in alias_list if alias not in added_args)

return arg_names

def _add_parser_submodels(
Expand Down
49 changes: 49 additions & 0 deletions tests/test_source_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2525,3 +2525,52 @@ def settings_customise_sources(
cfg = CliApp.run(MySettings)

assert cfg.model_dump() == {'foo': 'bar'}


def test_cli_shortcuts_on_flat_object():
class Settings(BaseSettings):
option: str = Field(default='foo')
list_option: str = Field(default='fizz')

model_config = SettingsConfigDict(cli_shortcuts={'option': 'option2', 'list_option': ['list_option2']})

assert CliApp.run(Settings, cli_args=['--option2', 'bar', '--list_option2', 'buzz']).model_dump() == {
'option': 'bar',
'list_option': 'buzz',
}


def test_cli_shortcuts_on_nested_object():
class TwiceNested(BaseModel):
option: str = Field(default='foo')

class Nested(BaseModel):
twice_nested_option: TwiceNested = TwiceNested()
option: str = Field(default='foo')

class Settings(BaseSettings):
nested: Nested = Nested()

model_config = SettingsConfigDict(
cli_shortcuts={'nested.option': 'option2', 'nested.twice_nested_option.option': 'twice_nested_option'}
)

assert CliApp.run(Settings, cli_args=['--option2', 'bar', '--twice_nested_option', 'baz']).model_dump() == {
'nested': {'option': 'bar', 'twice_nested_option': {'option': 'baz'}}
}


def test_cli_shortcuts_alias_collision_applies_to_first_target_field():
class Nested(BaseModel):
option: str = Field(default='foo')

class Settings(BaseSettings):
nested: Nested = Nested()
option2: str = Field(default='foo2')

model_config = SettingsConfigDict(cli_shortcuts={'option2': 'abc', 'nested.option': 'abc'})

assert CliApp.run(Settings, cli_args=['--abc', 'bar']).model_dump() == {
'nested': {'option': 'bar'},
'option2': 'foo2',
}