Repository: pydantic/pydantic-settings

Issue:
Top-level CLI alias for nested model
We are looking to replace `traitlets.config` with pydantic_settings and one feature we are missing that is important to us is the ability to define short, top-level command line options for things that are defined deeper in the hierarchy.


E.g. consider:

```python
from pydantic_settings import BaseSettings, CliApp


class SubSettings(BaseSettings):
    option: str = "foo"


class Settings(BaseSettings):
    sub: SubSettings

    def cli_cmd(self) -> None:
        print(self.model_dump())


CliApp.run(Settings)
```

In this example, I'd like to expose `--sub.option` just as `--option`, e.g. by defining an alias mapping.

Discussion:
@hramezani: Thanks @maxnoe for this issue.

@kschwab Do you have time to check this issue?@maxnoe: Looking at the code, maybe adding a field `cli_aliases` to the `SettingsConfigDict` would be a way here?

So that the code would look like this:

```python
from pydantic_settings import BaseSettings, CliApp, SettingsConfigDict


class SubSettings(BaseSettings):
    option: str = "foo"

class Settings(BaseSettings):
    sub: SubSettings = SubSettings()

    model_config = SettingsConfigDict(
        cli_aliases={
            "alias": "sub.option",
        },
    )

    def cli_cmd(self) -> None:
        print(self.model_dump())


CliApp.run(Settings)
```

And then `python example.py --alias="bar"` would set `sub.option="bar"`
@karta9821: It should rather be:

```python
model_config = SettingsConfigDict(
    cli_aliases={
        "sub.option": "alias",
    },
)
```

What is the expected behavior in the following case?

```python
from pydantic_settings import BaseSettings, CliApp, SettingsConfigDict
from pydantic import Field

class SubSettings(BaseSettings):
    option: str = "foo"

class Settings(BaseSettings):
    sub: SubSettings = SubSettings()
    option: str = "bar"
    option2: str = Field("foobar", alias="option")

    model_config = SettingsConfigDict(
        cli_aliases={
            "sub.option": "option",
        },
    )
```@maxnoe: Making the mapping from the alias to the options trivially prevents duplicated alias definitions, so I found that the more natural mapping.

Concerning your example, I'd say raise an error like "Settings.option conflicts with cli_alias definition option: sub.optoin".@hramezani: Fixed in https://github.com/pydantic/pydantic-settings/commit/180e74e324d8418e22b5dcb196fd6390fa964537

Solution patch:
diff --git a/docs/index.md b/docs/index.md
index 117e37f..089bb75 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 66cfbe9..2f5f8d1 100644
--- a/pydantic_settings/main.py
+++ b/pydantic_settings/main.py
@@ -4,6 +4,7 @@ import asyncio
 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 @@ class BaseSettings(BaseModel):
         _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 @@ class BaseSettings(BaseModel):
                 _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 @@ class BaseSettings(BaseModel):
         _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 @@ class BaseSettings(BaseModel):
             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 @@ class BaseSettings(BaseModel):
                     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 @@ class BaseSettings(BaseModel):
         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 ae42615..9e99ebc 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 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
         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 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
         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 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
                 )
                 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(


Test patch:
diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py
index 5d711fe..5602061 100644
--- a/tests/test_source_cli.py
+++ b/tests/test_source_cli.py
@@ -2525,3 +2525,52 @@ def test_cli_parse_args_from_model_config_is_respected_with_settings_customise_s
         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',
+    }