Skip to content

Commit 180e74e

Browse files
karta9821hramezani
andauthored
feat: Add cli_shortcuts to CLI settings (#624)
Co-authored-by: Hasan Ramezani <[email protected]>
1 parent e162908 commit 180e74e

File tree

4 files changed

+132
-2
lines changed

4 files changed

+132
-2
lines changed

docs/index.md

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
## Installation
32

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

1175-
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.
1174+
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.
11761175

11771176
### Mutually Exclusive Groups
11781177

@@ -1633,6 +1632,67 @@ options:
16331632
"""
16341633
```
16351634

1635+
#### CLI Shortcuts for Arguments
1636+
1637+
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.
1638+
1639+
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.
1640+
1641+
**Flat Example:**
1642+
1643+
```py
1644+
from pydantic import Field
1645+
1646+
from pydantic_settings import BaseSettings, SettingsConfigDict
1647+
1648+
1649+
class Settings(BaseSettings):
1650+
option: str = Field(default='foo')
1651+
list_option: str = Field(default='fizz')
1652+
1653+
model_config = SettingsConfigDict(
1654+
cli_shortcuts={'option': 'option2', 'list_option': ['list_option2']}
1655+
)
1656+
1657+
1658+
# Now you can use the shortcuts on the CLI:
1659+
# --option2 sets 'option', --list_option2 sets 'list_option'
1660+
```
1661+
1662+
**Nested Example:**
1663+
1664+
```py
1665+
from pydantic import BaseModel, Field
1666+
1667+
from pydantic_settings import BaseSettings, SettingsConfigDict
1668+
1669+
1670+
class TwiceNested(BaseModel):
1671+
option: str = Field(default='foo')
1672+
1673+
1674+
class Nested(BaseModel):
1675+
twice_nested_option: TwiceNested = TwiceNested()
1676+
option: str = Field(default='foo')
1677+
1678+
1679+
class Settings(BaseSettings):
1680+
nested: Nested = Nested()
1681+
model_config = SettingsConfigDict(
1682+
cli_shortcuts={
1683+
'nested.option': 'option2',
1684+
'nested.twice_nested_option.option': 'twice_nested_option',
1685+
}
1686+
)
1687+
1688+
1689+
# Now you can use --option2 to set nested.option and --twice_nested_option to set nested.twice_nested_option.option
1690+
```
1691+
1692+
If a shortcut collides (is mapped to multiple fields), it will apply to the first matching field in the model.
1693+
1694+
See the [test cases](../tests/test_source_cli.py) for more advanced usage and edge cases.
1695+
16361696
### Integrating with Existing Parsers
16371697

16381698
A CLI settings source can be integrated with existing parsers by overriding the default CLI settings source with a user

pydantic_settings/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import inspect
55
import threading
66
from argparse import Namespace
7+
from collections.abc import Mapping
78
from types import SimpleNamespace
89
from typing import Any, ClassVar, TypeVar
910

@@ -57,6 +58,7 @@ class SettingsConfigDict(ConfigDict, total=False):
5758
cli_implicit_flags: bool | None
5859
cli_ignore_unknown_args: bool | None
5960
cli_kebab_case: bool | None
61+
cli_shortcuts: Mapping[str, str | list[str]] | None
6062
secrets_dir: PathType | None
6163
json_file: PathType | None
6264
json_file_encoding: str | None
@@ -149,6 +151,7 @@ class BaseSettings(BaseModel):
149151
(e.g. --flag, --no-flag). Defaults to `False`.
150152
_cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
151153
_cli_kebab_case: CLI args use kebab case. Defaults to `False`.
154+
_cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`.
152155
_secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`.
153156
"""
154157

@@ -178,6 +181,7 @@ def __init__(
178181
_cli_implicit_flags: bool | None = None,
179182
_cli_ignore_unknown_args: bool | None = None,
180183
_cli_kebab_case: bool | None = None,
184+
_cli_shortcuts: Mapping[str, str | list[str]] | None = None,
181185
_secrets_dir: PathType | None = None,
182186
**values: Any,
183187
) -> None:
@@ -208,6 +212,7 @@ def __init__(
208212
_cli_implicit_flags=_cli_implicit_flags,
209213
_cli_ignore_unknown_args=_cli_ignore_unknown_args,
210214
_cli_kebab_case=_cli_kebab_case,
215+
_cli_shortcuts=_cli_shortcuts,
211216
_secrets_dir=_secrets_dir,
212217
)
213218
)
@@ -263,6 +268,7 @@ def _settings_build_values(
263268
_cli_implicit_flags: bool | None = None,
264269
_cli_ignore_unknown_args: bool | None = None,
265270
_cli_kebab_case: bool | None = None,
271+
_cli_shortcuts: Mapping[str, str | list[str]] | None = None,
266272
_secrets_dir: PathType | None = None,
267273
) -> dict[str, Any]:
268274
# Determine settings config values
@@ -336,6 +342,7 @@ def _settings_build_values(
336342
else self.model_config.get('cli_ignore_unknown_args')
337343
)
338344
cli_kebab_case = _cli_kebab_case if _cli_kebab_case is not None else self.model_config.get('cli_kebab_case')
345+
cli_shortcuts = _cli_shortcuts if _cli_shortcuts is not None else self.model_config.get('cli_shortcuts')
339346

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

@@ -401,6 +408,7 @@ def _settings_build_values(
401408
cli_implicit_flags=cli_implicit_flags,
402409
cli_ignore_unknown_args=cli_ignore_unknown_args,
403410
cli_kebab_case=cli_kebab_case,
411+
cli_shortcuts=cli_shortcuts,
404412
case_sensitive=case_sensitive,
405413
)
406414
sources = (cli_settings,) + sources
@@ -450,6 +458,7 @@ def _settings_build_values(
450458
cli_implicit_flags=False,
451459
cli_ignore_unknown_args=False,
452460
cli_kebab_case=False,
461+
cli_shortcuts=None,
453462
json_file=None,
454463
json_file_encoding=None,
455464
yaml_file=None,

pydantic_settings/sources/providers/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
119119
(e.g. --flag, --no-flag). Defaults to `False`.
120120
cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
121121
cli_kebab_case: CLI args use kebab case. Defaults to `False`.
122+
cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`.
122123
case_sensitive: Whether CLI "--arg" names should be read with case-sensitivity. Defaults to `True`.
123124
Note: Case-insensitive matching is only supported on the internal root parser and does not apply to CLI
124125
subcommands.
@@ -150,6 +151,7 @@ def __init__(
150151
cli_implicit_flags: bool | None = None,
151152
cli_ignore_unknown_args: bool | None = None,
152153
cli_kebab_case: bool | None = None,
154+
cli_shortcuts: Mapping[str, str | list[str]] | None = None,
153155
case_sensitive: bool | None = True,
154156
root_parser: Any = None,
155157
parse_args_method: Callable[..., Any] | None = None,
@@ -215,6 +217,9 @@ def __init__(
215217
self.cli_kebab_case = (
216218
cli_kebab_case if cli_kebab_case is not None else settings_cls.model_config.get('cli_kebab_case', False)
217219
)
220+
self.cli_shortcuts = (
221+
cli_shortcuts if cli_shortcuts is not None else settings_cls.model_config.get('cli_shortcuts', None)
222+
)
218223

219224
case_sensitive = case_sensitive if case_sensitive is not None else True
220225
if not case_sensitive and root_parser is not None:
@@ -874,6 +879,13 @@ def _get_arg_names(
874879
)
875880
if arg_name not in added_args:
876881
arg_names.append(arg_name)
882+
883+
if self.cli_shortcuts:
884+
for target, aliases in self.cli_shortcuts.items():
885+
if target in arg_names:
886+
alias_list = [aliases] if isinstance(aliases, str) else aliases
887+
arg_names.extend(alias for alias in alias_list if alias not in added_args)
888+
877889
return arg_names
878890

879891
def _add_parser_submodels(

tests/test_source_cli.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2525,3 +2525,52 @@ def settings_customise_sources(
25252525
cfg = CliApp.run(MySettings)
25262526

25272527
assert cfg.model_dump() == {'foo': 'bar'}
2528+
2529+
2530+
def test_cli_shortcuts_on_flat_object():
2531+
class Settings(BaseSettings):
2532+
option: str = Field(default='foo')
2533+
list_option: str = Field(default='fizz')
2534+
2535+
model_config = SettingsConfigDict(cli_shortcuts={'option': 'option2', 'list_option': ['list_option2']})
2536+
2537+
assert CliApp.run(Settings, cli_args=['--option2', 'bar', '--list_option2', 'buzz']).model_dump() == {
2538+
'option': 'bar',
2539+
'list_option': 'buzz',
2540+
}
2541+
2542+
2543+
def test_cli_shortcuts_on_nested_object():
2544+
class TwiceNested(BaseModel):
2545+
option: str = Field(default='foo')
2546+
2547+
class Nested(BaseModel):
2548+
twice_nested_option: TwiceNested = TwiceNested()
2549+
option: str = Field(default='foo')
2550+
2551+
class Settings(BaseSettings):
2552+
nested: Nested = Nested()
2553+
2554+
model_config = SettingsConfigDict(
2555+
cli_shortcuts={'nested.option': 'option2', 'nested.twice_nested_option.option': 'twice_nested_option'}
2556+
)
2557+
2558+
assert CliApp.run(Settings, cli_args=['--option2', 'bar', '--twice_nested_option', 'baz']).model_dump() == {
2559+
'nested': {'option': 'bar', 'twice_nested_option': {'option': 'baz'}}
2560+
}
2561+
2562+
2563+
def test_cli_shortcuts_alias_collision_applies_to_first_target_field():
2564+
class Nested(BaseModel):
2565+
option: str = Field(default='foo')
2566+
2567+
class Settings(BaseSettings):
2568+
nested: Nested = Nested()
2569+
option2: str = Field(default='foo2')
2570+
2571+
model_config = SettingsConfigDict(cli_shortcuts={'option2': 'abc', 'nested.option': 'abc'})
2572+
2573+
assert CliApp.run(Settings, cli_args=['--abc', 'bar']).model_dump() == {
2574+
'nested': {'option': 'bar'},
2575+
'option2': 'foo2',
2576+
}

0 commit comments

Comments
 (0)