|
33 | 33 | CLI_SUPPRESS, |
34 | 34 | CliExplicitFlag, |
35 | 35 | CliImplicitFlag, |
| 36 | + CliMutuallyExclusiveGroup, |
36 | 37 | CliPositionalArg, |
37 | 38 | CliSettingsSource, |
38 | 39 | CliSubCommand, |
@@ -79,30 +80,30 @@ class SettingWithIgnoreEmpty(BaseSettings): |
79 | 80 | class CliDummyArgGroup(BaseModel, arbitrary_types_allowed=True): |
80 | 81 | group: argparse._ArgumentGroup |
81 | 82 |
|
82 | | - def add_argument(self, *args, **kwargs) -> None: |
| 83 | + def add_argument(self, *args: Any, **kwargs: Any) -> None: |
83 | 84 | self.group.add_argument(*args, **kwargs) |
84 | 85 |
|
85 | 86 |
|
86 | 87 | class CliDummySubParsers(BaseModel, arbitrary_types_allowed=True): |
87 | 88 | sub_parser: argparse._SubParsersAction |
88 | 89 |
|
89 | | - def add_parser(self, *args, **kwargs) -> 'CliDummyParser': |
| 90 | + def add_parser(self, *args: Any, **kwargs: Any) -> 'CliDummyParser': |
90 | 91 | return CliDummyParser(parser=self.sub_parser.add_parser(*args, **kwargs)) |
91 | 92 |
|
92 | 93 |
|
93 | 94 | class CliDummyParser(BaseModel, arbitrary_types_allowed=True): |
94 | 95 | parser: argparse.ArgumentParser = Field(default_factory=lambda: argparse.ArgumentParser()) |
95 | 96 |
|
96 | | - def add_argument(self, *args, **kwargs) -> None: |
| 97 | + def add_argument(self, *args: Any, **kwargs: Any) -> None: |
97 | 98 | self.parser.add_argument(*args, **kwargs) |
98 | 99 |
|
99 | | - def add_argument_group(self, *args, **kwargs) -> CliDummyArgGroup: |
| 100 | + def add_argument_group(self, *args: Any, **kwargs: Any) -> CliDummyArgGroup: |
100 | 101 | return CliDummyArgGroup(group=self.parser.add_argument_group(*args, **kwargs)) |
101 | 102 |
|
102 | | - def add_subparsers(self, *args, **kwargs) -> CliDummySubParsers: |
| 103 | + def add_subparsers(self, *args: Any, **kwargs: Any) -> CliDummySubParsers: |
103 | 104 | return CliDummySubParsers(sub_parser=self.parser.add_subparsers(*args, **kwargs)) |
104 | 105 |
|
105 | | - def parse_args(self, *args, **kwargs) -> argparse.Namespace: |
| 106 | + def parse_args(self, *args: Any, **kwargs: Any) -> argparse.Namespace: |
106 | 107 | return self.parser.parse_args(*args, **kwargs) |
107 | 108 |
|
108 | 109 |
|
@@ -1786,40 +1787,40 @@ class Cfg(BaseSettings): |
1786 | 1787 |
|
1787 | 1788 | args = ['--fruit', 'pear'] |
1788 | 1789 | 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() == { |
1790 | 1791 | 'pet': 'bird', |
1791 | 1792 | 'command': None, |
1792 | 1793 | } |
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() == { |
1794 | 1795 | 'pet': 'bird', |
1795 | 1796 | 'command': None, |
1796 | 1797 | } |
1797 | 1798 |
|
1798 | 1799 | arg_prefix = f'{prefix}.' if prefix else '' |
1799 | 1800 | args = ['--fruit', 'kiwi', f'--{arg_prefix}pet', 'dog'] |
1800 | 1801 | 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() == { |
1802 | 1803 | 'pet': 'dog', |
1803 | 1804 | 'command': None, |
1804 | 1805 | } |
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() == { |
1806 | 1807 | 'pet': 'dog', |
1807 | 1808 | 'command': None, |
1808 | 1809 | } |
1809 | 1810 |
|
1810 | 1811 | 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() == { |
1812 | 1813 | 'pet': 'cat', |
1813 | 1814 | 'command': None, |
1814 | 1815 | } |
1815 | 1816 |
|
1816 | 1817 | args = ['--fruit', 'kiwi', f'--{arg_prefix}pet', 'dog', 'command', '--name', 'ralph', '--command', 'roll'] |
1817 | 1818 | 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() == { |
1819 | 1820 | 'pet': 'dog', |
1820 | 1821 | 'command': {'name': 'ralph', 'command': 'roll'}, |
1821 | 1822 | } |
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() == { |
1823 | 1824 | 'pet': 'dog', |
1824 | 1825 | 'command': {'name': 'ralph', 'command': 'roll'}, |
1825 | 1826 | } |
@@ -2045,3 +2046,131 @@ class Settings(BaseSettings, cli_parse_args=True): |
2045 | 2046 | -h, --help show this help message and exit |
2046 | 2047 | """ |
2047 | 2048 | ) |
| 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