diff --git a/docs/index.md b/docs/index.md index 117e37f2..089bb75c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,3 @@ - ## Installation Installation is as simple as: @@ -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 @@ -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 diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py index 66cfbe90..2f5f8d18 100644 --- a/pydantic_settings/main.py +++ b/pydantic_settings/main.py @@ -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 @@ -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 @@ -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`. """ @@ -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: @@ -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, ) ) @@ -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 @@ -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') @@ -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 @@ -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, diff --git a/pydantic_settings/sources/providers/cli.py b/pydantic_settings/sources/providers/cli.py index ae426155..9e99ebcf 100644 --- a/pydantic_settings/sources/providers/cli.py +++ b/pydantic_settings/sources/providers/cli.py @@ -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. @@ -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, @@ -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: @@ -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( diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py index 5d711fe1..5602061c 100644 --- a/tests/test_source_cli.py +++ b/tests/test_source_cli.py @@ -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', + }