Skip to content

Commit ffb3ac6

Browse files
authored
CliToggleFlag and CliDualFlag (#717)
1 parent c313e06 commit ffb3ac6

File tree

8 files changed

+224
-47
lines changed

8 files changed

+224
-47
lines changed

docs/index.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,71 @@ class ImplicitSettings(BaseSettings, cli_parse_args=True, cli_implicit_flags=Tru
13311331
"""
13321332
```
13331333

1334+
Implicit flag behavior can be further refined using the "toggle" or "dual" mode settings. Similarly, the provided
1335+
`CliToggleFlag` and `CliDualFlag` annotations can be used for more granular control.
1336+
1337+
For "toggle" flags, if default=`False`, `--flag` will store `True`. Otherwise, if default=`True`, `--no-flag` will store
1338+
`False`.
1339+
1340+
```py
1341+
from pydantic_settings import BaseSettings, CliDualFlag, CliToggleFlag
1342+
1343+
1344+
class ImplicitDualSettings(
1345+
BaseSettings, cli_parse_args=True, cli_implicit_flags='dual'
1346+
):
1347+
"""With cli_implicit_flags='dual', implicit flags are dual by default."""
1348+
1349+
implicit_req: bool
1350+
"""
1351+
--implicit_req, --no-implicit_req (required)
1352+
"""
1353+
1354+
implicit_dual_opt: bool = False
1355+
"""
1356+
--implicit_dual_opt, --no-implicit_dual_opt (default: False)
1357+
"""
1358+
1359+
# Implicit flags are dual by default, so must override toggle flags with annotation
1360+
flag_a: CliToggleFlag[bool] = False
1361+
"""
1362+
--flag_a (default: False)
1363+
"""
1364+
1365+
# Implicit flags are dual by default, so must override toggle flags with annotation
1366+
flag_b: CliToggleFlag[bool] = True
1367+
"""
1368+
--no-flag_b (default: True)
1369+
"""
1370+
1371+
1372+
class ImplicitToggleSettings(
1373+
BaseSettings, cli_parse_args=True, cli_implicit_flags='toggle'
1374+
):
1375+
"""With cli_implicit_flags='toggle', implicit flags are toggle by default."""
1376+
1377+
implicit_req: bool
1378+
"""
1379+
--implicit_req, --no-implicit_req (required)
1380+
"""
1381+
1382+
# Implicit flags are toggle by default, so must override dual flags with annotation
1383+
implicit_dual_opt: CliDualFlag[bool] = False
1384+
"""
1385+
--implicit_dual_opt, --no-implicit_dual_opt (default: False)
1386+
"""
1387+
1388+
flag_a: bool = False
1389+
"""
1390+
--flag_a (default: False)
1391+
"""
1392+
1393+
flag_b: bool = True
1394+
"""
1395+
--no-flag_b (default: True)
1396+
"""
1397+
```
1398+
13341399
#### Ignore and Retrieve Unknown Arguments
13351400

13361401
Change whether to ignore unknown CLI arguments and only parse known ones using `cli_ignore_unknown_args`. By default, the CLI

pydantic_settings/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
CLI_SUPPRESS,
55
AWSSecretsManagerSettingsSource,
66
AzureKeyVaultSettingsSource,
7+
CliDualFlag,
78
CliExplicitFlag,
89
CliImplicitFlag,
910
CliMutuallyExclusiveGroup,
1011
CliPositionalArg,
1112
CliSettingsSource,
1213
CliSubCommand,
1314
CliSuppress,
15+
CliToggleFlag,
1416
CliUnknownArgs,
1517
DotEnvSettingsSource,
1618
EnvSettingsSource,
@@ -37,6 +39,8 @@
3739
'CliApp',
3840
'CliExplicitFlag',
3941
'CliImplicitFlag',
42+
'CliToggleFlag',
43+
'CliDualFlag',
4044
'CliMutuallyExclusiveGroup',
4145
'CliPositionalArg',
4246
'CliSettingsSource',

pydantic_settings/main.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class SettingsConfigDict(ConfigDict, total=False):
6161
cli_exit_on_error: bool
6262
cli_prefix: str
6363
cli_flag_prefix_char: str
64-
cli_implicit_flags: bool | None
64+
cli_implicit_flags: bool | Literal['dual', 'toggle'] | None
6565
cli_ignore_unknown_args: bool | None
6666
cli_kebab_case: bool | Literal['all', 'no_enums'] | None
6767
cli_shortcuts: Mapping[str, str | list[str]] | None
@@ -153,8 +153,12 @@ class BaseSettings(BaseModel):
153153
Defaults to `True`.
154154
_cli_prefix: The root parser command line arguments prefix. Defaults to "".
155155
_cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'.
156-
_cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
157-
(e.g. --flag, --no-flag). Defaults to `False`.
156+
_cli_implicit_flags: Controls how `bool` fields are exposed as CLI flags.
157+
158+
- False (default): no implicit flags are generated; booleans must be set explicitly (e.g. --flag=true).
159+
- True / 'dual': optional boolean fields generate both positive and negative forms (--flag and --no-flag).
160+
- 'toggle': required boolean fields remain in 'dual' mode, while optional boolean fields generate a single
161+
flag aligned with the default value (if default=False, expose --flag; if default=True, expose --no-flag).
158162
_cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
159163
_cli_kebab_case: CLI args use kebab case. Defaults to `False`.
160164
_cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`.
@@ -184,7 +188,7 @@ def __init__(
184188
_cli_exit_on_error: bool | None = None,
185189
_cli_prefix: str | None = None,
186190
_cli_flag_prefix_char: str | None = None,
187-
_cli_implicit_flags: bool | None = None,
191+
_cli_implicit_flags: bool | Literal['dual', 'toggle'] | None = None,
188192
_cli_ignore_unknown_args: bool | None = None,
189193
_cli_kebab_case: bool | Literal['all', 'no_enums'] | None = None,
190194
_cli_shortcuts: Mapping[str, str | list[str]] | None = None,
@@ -271,7 +275,7 @@ def _settings_build_values(
271275
_cli_exit_on_error: bool | None = None,
272276
_cli_prefix: str | None = None,
273277
_cli_flag_prefix_char: str | None = None,
274-
_cli_implicit_flags: bool | None = None,
278+
_cli_implicit_flags: bool | Literal['dual', 'toggle'] | None = None,
275279
_cli_ignore_unknown_args: bool | None = None,
276280
_cli_kebab_case: bool | Literal['all', 'no_enums'] | None = None,
277281
_cli_shortcuts: Mapping[str, str | list[str]] | None = None,

pydantic_settings/sources/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
from .providers.azure import AzureKeyVaultSettingsSource
1313
from .providers.cli import (
1414
CLI_SUPPRESS,
15+
CliDualFlag,
1516
CliExplicitFlag,
1617
CliImplicitFlag,
1718
CliMutuallyExclusiveGroup,
1819
CliPositionalArg,
1920
CliSettingsSource,
2021
CliSubCommand,
2122
CliSuppress,
23+
CliToggleFlag,
2224
CliUnknownArgs,
2325
)
2426
from .providers.dotenv import DotEnvSettingsSource, read_env_file
@@ -40,6 +42,8 @@
4042
'AzureKeyVaultSettingsSource',
4143
'CliExplicitFlag',
4244
'CliImplicitFlag',
45+
'CliToggleFlag',
46+
'CliDualFlag',
4347
'CliMutuallyExclusiveGroup',
4448
'CliPositionalArg',
4549
'CliSettingsSource',

pydantic_settings/sources/providers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
from .aws import AWSSecretsManagerSettingsSource
44
from .azure import AzureKeyVaultSettingsSource
55
from .cli import (
6+
CliDualFlag,
67
CliExplicitFlag,
78
CliImplicitFlag,
89
CliMutuallyExclusiveGroup,
910
CliPositionalArg,
1011
CliSettingsSource,
1112
CliSubCommand,
1213
CliSuppress,
14+
CliToggleFlag,
1315
)
1416
from .dotenv import DotEnvSettingsSource
1517
from .env import EnvSettingsSource
@@ -25,6 +27,8 @@
2527
'AzureKeyVaultSettingsSource',
2628
'CliExplicitFlag',
2729
'CliImplicitFlag',
30+
'CliToggleFlag',
31+
'CliDualFlag',
2832
'CliMutuallyExclusiveGroup',
2933
'CliPositionalArg',
3034
'CliSettingsSource',

pydantic_settings/sources/providers/cli.py

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,12 @@
5151
ForceDecode,
5252
NoDecode,
5353
PydanticModel,
54+
_CliDualFlag,
5455
_CliExplicitFlag,
5556
_CliImplicitFlag,
5657
_CliPositionalArg,
5758
_CliSubCommand,
59+
_CliToggleFlag,
5860
_CliUnknownArgs,
5961
)
6062
from ..utils import (
@@ -239,6 +241,8 @@ def is_no_decode(self) -> bool:
239241
_CliBoolFlag = TypeVar('_CliBoolFlag', bound=bool)
240242
CliImplicitFlag = Annotated[_CliBoolFlag, _CliImplicitFlag]
241243
CliExplicitFlag = Annotated[_CliBoolFlag, _CliExplicitFlag]
244+
CliToggleFlag = Annotated[_CliBoolFlag, _CliToggleFlag]
245+
CliDualFlag = Annotated[_CliBoolFlag, _CliDualFlag]
242246
CLI_SUPPRESS = SUPPRESS
243247
CliSuppress = Annotated[T, CLI_SUPPRESS]
244248
CliUnknownArgs = Annotated[list[str], Field(default=[]), _CliUnknownArgs, NoDecode]
@@ -270,8 +274,12 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
270274
Defaults to `True`.
271275
cli_prefix: Prefix for command line arguments added under the root parser. Defaults to "".
272276
cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'.
273-
cli_implicit_flags: Whether `bool` fields should be implicitly converted into CLI boolean flags.
274-
(e.g. --flag, --no-flag). Defaults to `False`.
277+
cli_implicit_flags: Controls how `bool` fields are exposed as CLI flags.
278+
279+
- False (default): no implicit flags are generated; booleans must be set explicitly (e.g. --flag=true).
280+
- True / 'dual': optional boolean fields generate both positive and negative forms (--flag and --no-flag).
281+
- 'toggle': required boolean fields remain in 'dual' mode, while optional boolean fields generate a single
282+
flag aligned with the default value (if default=False, expose --flag; if default=True, expose --no-flag).
275283
cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to `False`.
276284
cli_kebab_case: CLI args use kebab case. Defaults to `False`.
277285
cli_shortcuts: Mapping of target field name to alias names. Defaults to `None`.
@@ -303,7 +311,7 @@ def __init__(
303311
cli_exit_on_error: bool | None = None,
304312
cli_prefix: str | None = None,
305313
cli_flag_prefix_char: str | None = None,
306-
cli_implicit_flags: bool | None = None,
314+
cli_implicit_flags: bool | Literal['dual', 'toggle'] | None = None,
307315
cli_ignore_unknown_args: bool | None = None,
308316
cli_kebab_case: bool | Literal['all', 'no_enums'] | None = None,
309317
cli_shortcuts: Mapping[str, str | list[str]] | None = None,
@@ -721,6 +729,14 @@ def _verify_cli_flag_annotations(self, model: type[BaseModel], field_name: str,
721729
cli_flag_name = 'CliImplicitFlag'
722730
elif _CliExplicitFlag in field_info.metadata:
723731
cli_flag_name = 'CliExplicitFlag'
732+
elif _CliToggleFlag in field_info.metadata:
733+
cli_flag_name = 'CliToggleFlag'
734+
if not isinstance(field_info.default, bool):
735+
raise SettingsError(
736+
f'{cli_flag_name} argument {model.__name__}.{field_name} must have a default bool value'
737+
)
738+
elif _CliDualFlag in field_info.metadata:
739+
cli_flag_name = 'CliDualFlag'
724740
else:
725741
return
726742

@@ -1003,7 +1019,9 @@ def _add_parser_args(
10031019
if isinstance(group, dict):
10041020
group = self._add_group(parser, **group)
10051021
context = parser if group is None else group
1006-
arg.args = [f'{flag_prefix[: len(name)]}{name}' for name in arg_names]
1022+
if arg.kwargs.get('action') == 'store_false':
1023+
flag_prefix += 'no-'
1024+
arg.args = [f'{flag_prefix[: 1 if len(name) == 1 else None]}{name}' for name in arg_names]
10071025
self._add_argument(context, *arg.args, **arg.kwargs)
10081026
added_args += list(arg_names)
10091027

@@ -1018,11 +1036,25 @@ def _convert_append_action(self, kwargs: dict[str, Any], field_info: FieldInfo,
10181036

10191037
def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None:
10201038
if kwargs['metavar'] == 'bool':
1021-
if (self.cli_implicit_flags or _CliImplicitFlag in field_info.metadata) and (
1022-
_CliExplicitFlag not in field_info.metadata
1023-
):
1024-
del kwargs['metavar']
1025-
kwargs['action'] = BooleanOptionalAction
1039+
meta_bool_flags = [
1040+
meta for meta in field_info.metadata if issubclass(meta, _CliImplicitFlag | _CliExplicitFlag)
1041+
]
1042+
if not meta_bool_flags and self.cli_implicit_flags:
1043+
meta_bool_flags = [_CliImplicitFlag]
1044+
if meta_bool_flags:
1045+
bool_flag = meta_bool_flags.pop()
1046+
if bool_flag is _CliImplicitFlag:
1047+
bool_flag = (
1048+
_CliToggleFlag
1049+
if self.cli_implicit_flags == 'toggle' and isinstance(field_info.default, bool)
1050+
else _CliDualFlag
1051+
)
1052+
if bool_flag is _CliDualFlag:
1053+
del kwargs['metavar']
1054+
kwargs['action'] = BooleanOptionalAction
1055+
elif bool_flag is _CliToggleFlag:
1056+
del kwargs['metavar']
1057+
kwargs['action'] = 'store_false' if field_info.default else 'store_true'
10261058

10271059
def _convert_positional_arg(
10281060
self, kwargs: dict[str, Any], field_info: FieldInfo, preferred_alias: str, model_default: Any
@@ -1348,7 +1380,7 @@ def _serialized_args(self, model: PydanticModel, _is_submodel: bool = False) ->
13481380
optional_args.append(f'{flag_chars}{arg_name}')
13491381

13501382
# If implicit bool flag, do not add a value
1351-
if arg.kwargs.get('action') != BooleanOptionalAction:
1383+
if arg.kwargs.get('action') not in (BooleanOptionalAction, 'store_true', 'store_false'):
13521384
optional_args.append(value)
13531385

13541386
serialized_args: list[str] = []

pydantic_settings/sources/types.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ class _CliImplicitFlag:
5353
pass
5454

5555

56+
class _CliToggleFlag(_CliImplicitFlag):
57+
pass
58+
59+
60+
class _CliDualFlag(_CliImplicitFlag):
61+
pass
62+
63+
5664
class _CliExplicitFlag:
5765
pass
5866

@@ -72,6 +80,8 @@ class _CliUnknownArgs:
7280
'PydanticModel',
7381
'_CliExplicitFlag',
7482
'_CliImplicitFlag',
83+
'_CliToggleFlag',
84+
'_CliDualFlag',
7585
'_CliPositionalArg',
7686
'_CliSubCommand',
7787
'_CliUnknownArgs',

0 commit comments

Comments
 (0)