Skip to content

Commit cff7488

Browse files
committed
Add tests and doc.
1 parent ccb0d6f commit cff7488

File tree

3 files changed

+198
-26
lines changed

3 files changed

+198
-26
lines changed

docs/index.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -969,6 +969,44 @@ For `BaseModel` and `pydantic.dataclasses.dataclass` types, `CliApp.run` will in
969969
The alias generator for kebab case does not propagate to subcommands or submodels and will have to be manually set
970970
in these cases.
971971

972+
### Mutually Exclusive Groups
973+
974+
CLI mutually exclusive groups can be created by inheriting from the `CliMutuallyExclusiveGroup` class.
975+
976+
!!! note
977+
A `CliMutuallyExclusiveGroup` cannot be used in a union or contain nested models.
978+
979+
```py
980+
from typing import Optional
981+
982+
from pydantic import BaseModel
983+
984+
from pydantic_settings import CliApp, CliMutuallyExclusiveGroup, SettingsError
985+
986+
987+
class Circle(CliMutuallyExclusiveGroup):
988+
radius: Optional[float] = None
989+
diameter: Optional[float] = None
990+
perimeter: Optional[float] = None
991+
992+
993+
class Settings(BaseModel):
994+
circle: Circle
995+
996+
997+
try:
998+
CliApp.run(
999+
Settings,
1000+
cli_args=['--circle.radius=1', '--circle.diameter=2'],
1001+
cli_exit_on_error=False,
1002+
)
1003+
except SettingsError as e:
1004+
print(e)
1005+
"""
1006+
error parsing CLI: argument --circle.diameter: not allowed with argument --circle.radius
1007+
"""
1008+
```
1009+
9721010
### Customizing the CLI Experience
9731011

9741012
The below flags can be used to customise the CLI experience to your needs.

pydantic_settings/sources.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,7 +1487,7 @@ def _connect_parser_method(
14871487
if (
14881488
parser_method is not None
14891489
and self.case_sensitive is False
1490-
and method_name == 'parsed_args_method'
1490+
and method_name == 'parse_args_method'
14911491
and isinstance(self._root_parser, _CliInternalArgParser)
14921492
):
14931493

@@ -1522,17 +1522,18 @@ def none_parser_method(*args: Any, **kwargs: Any) -> Any:
15221522
def _connect_group_method(self, add_argument_group_method: Callable[..., Any] | None) -> Callable[..., Any]:
15231523
add_argument_group = self._connect_parser_method(add_argument_group_method, 'add_argument_group_method')
15241524

1525-
def add_group_method(parser: Any, model: type[BaseModel], **kwargs: Any) -> Any:
1526-
if not issubclass(model, CliMutuallyExclusiveGroup):
1525+
def add_group_method(parser: Any, **kwargs: Any) -> Any:
1526+
if not kwargs.pop('_is_cli_mutually_exclusive_group'):
15271527
kwargs.pop('required')
15281528
return add_argument_group(parser, **kwargs)
15291529
else:
1530-
group = add_argument_group(
1531-
parser, **{arg: kwargs.pop(arg) for arg in ['title', 'description'] if arg in kwargs}
1532-
)
1530+
main_group_kwargs = {arg: kwargs.pop(arg) for arg in ['title', 'description'] if arg in kwargs}
1531+
main_group_kwargs['title'] += ' (mutually exclusive)'
1532+
group = add_argument_group(parser, **main_group_kwargs)
15331533
if not hasattr(group, 'add_mutually_exclusive_group'):
15341534
raise SettingsError(
1535-
'cannot connect CLI settings source root parser: add_mutually_exclusive_group is set to `None` but is needed for connecting'
1535+
'cannot connect CLI settings source root parser: '
1536+
'group object is missing add_mutually_exclusive_group but is needed for connecting'
15361537
)
15371538
return group.add_mutually_exclusive_group(**kwargs)
15381539

@@ -1554,7 +1555,7 @@ def _parse_known_args(*args: Any, **kwargs: Any) -> Namespace:
15541555
self._root_parser = root_parser
15551556
if parse_args_method is None:
15561557
parse_args_method = _parse_known_args if self.cli_ignore_unknown_args else ArgumentParser.parse_args
1557-
self._parse_args = self._connect_parser_method(parse_args_method, 'parsed_args_method')
1558+
self._parse_args = self._connect_parser_method(parse_args_method, 'parse_args_method')
15581559
self._add_argument = self._connect_parser_method(add_argument_method, 'add_argument_method')
15591560
self._add_group = self._connect_group_method(add_argument_group_method)
15601561
self._add_parser = self._connect_parser_method(add_parser_method, 'add_parser_method')
@@ -1695,7 +1696,7 @@ def _add_parser_args(
16951696
elif not is_alias_path_only:
16961697
if group is not None:
16971698
if isinstance(group, dict):
1698-
group = self._add_group(parser, model, **group)
1699+
group = self._add_group(parser, **group)
16991700
added_args += list(arg_names)
17001701
self._add_argument(group, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs)
17011702
else:
@@ -1704,7 +1705,7 @@ def _add_parser_args(
17041705
parser, *(f'{flag_prefix[:len(name)]}{name}' for name in arg_names), **kwargs
17051706
)
17061707

1707-
self._add_parser_alias_paths(parser, model, alias_path_args, added_args, arg_prefix, subcommand_prefix, group)
1708+
self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group)
17081709
return parser
17091710

17101711
def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None:
@@ -1764,6 +1765,11 @@ def _add_parser_submodels(
17641765
model_group_kwargs['title'] = f'{arg_names[0]} options'
17651766
model_group_kwargs['description'] = field_info.description
17661767
model_group_kwargs['required'] = kwargs['required']
1768+
model_group_kwargs['_is_cli_mutually_exclusive_group'] = any(
1769+
issubclass(model, CliMutuallyExclusiveGroup) for model in sub_models
1770+
)
1771+
if model_group_kwargs['_is_cli_mutually_exclusive_group'] and len(sub_models) > 1:
1772+
raise SettingsError('cannot use union with CliMutuallyExclusiveGroup')
17671773
if self.cli_use_class_docs_for_groups and len(sub_models) == 1:
17681774
model_group_kwargs['description'] = None if sub_models[0].__doc__ is None else dedent(sub_models[0].__doc__)
17691775

@@ -1786,7 +1792,7 @@ def _add_parser_submodels(
17861792
if not self.cli_avoid_json:
17871793
added_args.append(arg_names[0])
17881794
kwargs['help'] = f'set {arg_names[0]} from JSON string'
1789-
model_group = self._add_group(parser, model, **model_group_kwargs)
1795+
model_group = self._add_group(parser, **model_group_kwargs)
17901796
self._add_argument(model_group, *(f'{flag_prefix}{name}' for name in arg_names), **kwargs)
17911797
for model in sub_models:
17921798
self._add_parser_args(
@@ -1803,7 +1809,6 @@ def _add_parser_submodels(
18031809
def _add_parser_alias_paths(
18041810
self,
18051811
parser: Any,
1806-
model: type[BaseModel],
18071812
alias_path_args: dict[str, str],
18081813
added_args: list[str],
18091814
arg_prefix: str,
@@ -1813,7 +1818,7 @@ def _add_parser_alias_paths(
18131818
if alias_path_args:
18141819
context = parser
18151820
if group is not None:
1816-
context = self._add_group(parser, model, **group) if isinstance(group, dict) else group
1821+
context = self._add_group(parser, **group) if isinstance(group, dict) else group
18171822
is_nested_alias_path = arg_prefix.endswith('.')
18181823
arg_prefix = arg_prefix[:-1] if is_nested_alias_path else arg_prefix
18191824
for name, metavar in alias_path_args.items():

tests/test_source_cli.py

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
CLI_SUPPRESS,
3434
CliExplicitFlag,
3535
CliImplicitFlag,
36+
CliMutuallyExclusiveGroup,
3637
CliPositionalArg,
3738
CliSettingsSource,
3839
CliSubCommand,
@@ -79,30 +80,30 @@ class SettingWithIgnoreEmpty(BaseSettings):
7980
class CliDummyArgGroup(BaseModel, arbitrary_types_allowed=True):
8081
group: argparse._ArgumentGroup
8182

82-
def add_argument(self, *args, **kwargs) -> None:
83+
def add_argument(self, *args: Any, **kwargs: Any) -> None:
8384
self.group.add_argument(*args, **kwargs)
8485

8586

8687
class CliDummySubParsers(BaseModel, arbitrary_types_allowed=True):
8788
sub_parser: argparse._SubParsersAction
8889

89-
def add_parser(self, *args, **kwargs) -> 'CliDummyParser':
90+
def add_parser(self, *args: Any, **kwargs: Any) -> 'CliDummyParser':
9091
return CliDummyParser(parser=self.sub_parser.add_parser(*args, **kwargs))
9192

9293

9394
class CliDummyParser(BaseModel, arbitrary_types_allowed=True):
9495
parser: argparse.ArgumentParser = Field(default_factory=lambda: argparse.ArgumentParser())
9596

96-
def add_argument(self, *args, **kwargs) -> None:
97+
def add_argument(self, *args: Any, **kwargs: Any) -> None:
9798
self.parser.add_argument(*args, **kwargs)
9899

99-
def add_argument_group(self, *args, **kwargs) -> CliDummyArgGroup:
100+
def add_argument_group(self, *args: Any, **kwargs: Any) -> CliDummyArgGroup:
100101
return CliDummyArgGroup(group=self.parser.add_argument_group(*args, **kwargs))
101102

102-
def add_subparsers(self, *args, **kwargs) -> CliDummySubParsers:
103+
def add_subparsers(self, *args: Any, **kwargs: Any) -> CliDummySubParsers:
103104
return CliDummySubParsers(sub_parser=self.parser.add_subparsers(*args, **kwargs))
104105

105-
def parse_args(self, *args, **kwargs) -> argparse.Namespace:
106+
def parse_args(self, *args: Any, **kwargs: Any) -> argparse.Namespace:
106107
return self.parser.parse_args(*args, **kwargs)
107108

108109

@@ -1786,40 +1787,40 @@ class Cfg(BaseSettings):
17861787

17871788
args = ['--fruit', 'pear']
17881789
parsed_args = parser.parse_args(args)
1789-
assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == {
1790+
assert CliApp.run(Cfg, cli_args=parsed_args, cli_settings_source=cli_cfg_settings).model_dump() == {
17901791
'pet': 'bird',
17911792
'command': None,
17921793
}
1793-
assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == {
1794+
assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == {
17941795
'pet': 'bird',
17951796
'command': None,
17961797
}
17971798

17981799
arg_prefix = f'{prefix}.' if prefix else ''
17991800
args = ['--fruit', 'kiwi', f'--{arg_prefix}pet', 'dog']
18001801
parsed_args = parser.parse_args(args)
1801-
assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=parsed_args)).model_dump() == {
1802+
assert CliApp.run(Cfg, cli_args=parsed_args, cli_settings_source=cli_cfg_settings).model_dump() == {
18021803
'pet': 'dog',
18031804
'command': None,
18041805
}
1805-
assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == {
1806+
assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == {
18061807
'pet': 'dog',
18071808
'command': None,
18081809
}
18091810

18101811
parsed_args = parser.parse_args(['--fruit', 'kiwi', f'--{arg_prefix}pet', 'cat'])
1811-
assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=vars(parsed_args))).model_dump() == {
1812+
assert CliApp.run(Cfg, cli_args=vars(parsed_args), cli_settings_source=cli_cfg_settings).model_dump() == {
18121813
'pet': 'cat',
18131814
'command': None,
18141815
}
18151816

18161817
args = ['--fruit', 'kiwi', f'--{arg_prefix}pet', 'dog', 'command', '--name', 'ralph', '--command', 'roll']
18171818
parsed_args = parser.parse_args(args)
1818-
assert Cfg(_cli_settings_source=cli_cfg_settings(parsed_args=vars(parsed_args))).model_dump() == {
1819+
assert CliApp.run(Cfg, cli_args=vars(parsed_args), cli_settings_source=cli_cfg_settings).model_dump() == {
18191820
'pet': 'dog',
18201821
'command': {'name': 'ralph', 'command': 'roll'},
18211822
}
1822-
assert Cfg(_cli_settings_source=cli_cfg_settings(args=args)).model_dump() == {
1823+
assert CliApp.run(Cfg, cli_args=args, cli_settings_source=cli_cfg_settings).model_dump() == {
18231824
'pet': 'dog',
18241825
'command': {'name': 'ralph', 'command': 'roll'},
18251826
}
@@ -2045,3 +2046,131 @@ class Settings(BaseSettings, cli_parse_args=True):
20452046
-h, --help show this help message and exit
20462047
"""
20472048
)
2049+
2050+
2051+
def test_cli_mutually_exclusive_group(capsys):
2052+
class Circle(CliMutuallyExclusiveGroup):
2053+
radius: Optional[float] = 21
2054+
diameter: Optional[float] = 22
2055+
perimeter: Optional[float] = 23
2056+
2057+
class Settings(BaseModel):
2058+
circle_optional: Circle = Circle(radius=None, diameter=None, perimeter=24)
2059+
circle_required: Circle
2060+
2061+
CliApp.run(Settings, cli_args=['--circle-required.radius=1', '--circle-optional.radius=1']).model_dump() == {
2062+
'circle_optional': {'radius': 1, 'diameter': 22, 'perimeter': 24},
2063+
'circle_required': {'radius': 1, 'diameter': 22, 'perimeter': 23},
2064+
}
2065+
2066+
with pytest.raises(SystemExit):
2067+
CliApp.run(Settings, cli_args=['--circle-required.radius=1', '--circle-required.diameter=2'])
2068+
assert (
2069+
'error: argument --circle-required.diameter: not allowed with argument --circle-required.radius'
2070+
in capsys.readouterr().err
2071+
)
2072+
2073+
with pytest.raises(SystemExit):
2074+
CliApp.run(
2075+
Settings,
2076+
cli_args=['--circle-required.radius=1', '--circle-optional.radius=1', '--circle-optional.diameter=2'],
2077+
)
2078+
assert (
2079+
'error: argument --circle-optional.diameter: not allowed with argument --circle-optional.radius'
2080+
in capsys.readouterr().err
2081+
)
2082+
2083+
with pytest.raises(SystemExit):
2084+
CliApp.run(Settings, cli_args=['--help'])
2085+
assert (
2086+
capsys.readouterr().out
2087+
== f"""usage: example.py [-h] [--circle-optional.radius float |
2088+
--circle-optional.diameter float |
2089+
--circle-optional.perimeter float]
2090+
(--circle-required.radius float |
2091+
--circle-required.diameter float |
2092+
--circle-required.perimeter float)
2093+
2094+
{ARGPARSE_OPTIONS_TEXT}:
2095+
-h, --help show this help message and exit
2096+
2097+
circle-optional options (mutually exclusive):
2098+
--circle-optional.radius float
2099+
(default: None)
2100+
--circle-optional.diameter float
2101+
(default: None)
2102+
--circle-optional.perimeter float
2103+
(default: 24.0)
2104+
2105+
circle-required options (mutually exclusive):
2106+
--circle-required.radius float
2107+
(default: 21)
2108+
--circle-required.diameter float
2109+
(default: 22)
2110+
--circle-required.perimeter float
2111+
(default: 23)
2112+
"""
2113+
)
2114+
2115+
2116+
def test_cli_mutually_exclusive_group_exceptions():
2117+
class Circle(CliMutuallyExclusiveGroup):
2118+
radius: Optional[float] = 21
2119+
diameter: Optional[float] = 22
2120+
perimeter: Optional[float] = 23
2121+
2122+
class Settings(BaseSettings):
2123+
circle: Circle
2124+
2125+
parser = CliDummyParser()
2126+
with pytest.raises(
2127+
SettingsError,
2128+
match='cannot connect CLI settings source root parser: group object is missing add_mutually_exclusive_group but is needed for connecting',
2129+
):
2130+
CliSettingsSource(
2131+
Settings,
2132+
root_parser=parser,
2133+
parse_args_method=CliDummyParser.parse_args,
2134+
add_argument_method=CliDummyParser.add_argument,
2135+
add_argument_group_method=CliDummyParser.add_argument_group,
2136+
add_parser_method=CliDummySubParsers.add_parser,
2137+
add_subparsers_method=CliDummyParser.add_subparsers,
2138+
)
2139+
2140+
class SubModel(BaseModel):
2141+
pass
2142+
2143+
class SettingsInvalidUnion(BaseSettings):
2144+
union: Union[Circle, SubModel]
2145+
2146+
with pytest.raises(SettingsError, match='cannot use union with CliMutuallyExclusiveGroup'):
2147+
CliApp.run(SettingsInvalidUnion)
2148+
2149+
class CircleInvalidSubModel(Circle):
2150+
square: Optional[SubModel] = None
2151+
2152+
class SettingsInvalidOptSubModel(BaseModel):
2153+
circle: CircleInvalidSubModel = CircleInvalidSubModel()
2154+
2155+
class SettingsInvalidReqSubModel(BaseModel):
2156+
circle: CircleInvalidSubModel
2157+
2158+
for settings in [SettingsInvalidOptSubModel, SettingsInvalidReqSubModel]:
2159+
with pytest.raises(SettingsError, match='cannot have nested models in a CliMutuallyExclusiveGroup'):
2160+
CliApp.run(settings)
2161+
2162+
class CircleRequiredField(Circle):
2163+
length: float
2164+
2165+
class SettingsOptCircleReqField(BaseModel):
2166+
circle: CircleRequiredField = CircleRequiredField(length=2)
2167+
2168+
assert CliApp.run(SettingsOptCircleReqField, cli_args=[]).model_dump() == {
2169+
'circle': {'diameter': 22.0, 'length': 2.0, 'perimeter': 23.0, 'radius': 21.0}
2170+
}
2171+
2172+
class SettingsInvalidReqCircleReqField(BaseModel):
2173+
circle: CircleRequiredField
2174+
2175+
with pytest.raises(ValueError, match='mutually exclusive arguments must be optional'):
2176+
CliApp.run(SettingsInvalidReqCircleReqField)

0 commit comments

Comments
 (0)