Skip to content

Commit e2cbb2d

Browse files
authored
CLI support for optional and variadic positional args (#519)
1 parent c989335 commit e2cbb2d

File tree

3 files changed

+114
-20
lines changed

3 files changed

+114
-20
lines changed

Diff for: docs/index.md

+7-3
Original file line numberDiff line numberDiff line change
@@ -842,9 +842,10 @@ print(User().model_dump())
842842

843843
### Subcommands and Positional Arguments
844844

845-
Subcommands and positional arguments are expressed using the `CliSubCommand` and `CliPositionalArg` annotations. These
846-
annotations can only be applied to required fields (i.e. fields that do not have a default value). Furthermore,
847-
subcommands must be a valid type derived from either a pydantic `BaseModel` or pydantic.dataclasses `dataclass`.
845+
Subcommands and positional arguments are expressed using the `CliSubCommand` and `CliPositionalArg` annotations. The
846+
subcommand annotation can only be applied to required fields (i.e. fields that do not have a default value).
847+
Furthermore, subcommands must be a valid type derived from either a pydantic `BaseModel` or pydantic.dataclasses
848+
`dataclass`.
848849

849850
Parsed subcommands can be retrieved from model instances using the `get_subcommand` utility function. If a subcommand is
850851
not required, set the `is_required` flag to `False` to disable raising an error if no subcommand is found.
@@ -1284,6 +1285,9 @@ However, if your use case [aligns more with #2](#command-line-support), using Py
12841285
likely want required fields to be _strictly required at the CLI_. We can enable this behavior by using
12851286
`cli_enforce_required`.
12861287

1288+
!!! note
1289+
A required `CliPositionalArg` field is always strictly required (enforced) at the CLI.
1290+
12871291
```py
12881292
import os
12891293
import sys

Diff for: pydantic_settings/sources.py

+51-13
Original file line numberDiff line numberDiff line change
@@ -1333,7 +1333,11 @@ def _load_env_vars(
13331333
if subcommand_dest not in selected_subcommands:
13341334
parsed_args[subcommand_dest] = self.cli_parse_none_str
13351335

1336-
parsed_args = {key: val for key, val in parsed_args.items() if not key.endswith(':subcommand')}
1336+
parsed_args = {
1337+
key: val
1338+
for key, val in parsed_args.items()
1339+
if not key.endswith(':subcommand') and val is not PydanticUndefined
1340+
}
13371341
if selected_subcommands:
13381342
last_selected_subcommand = max(selected_subcommands, key=len)
13391343
if not any(field_name for field_name in parsed_args.keys() if f'{last_selected_subcommand}.' in field_name):
@@ -1494,6 +1498,7 @@ def _verify_cli_flag_annotations(self, model: type[BaseModel], field_name: str,
14941498
)
14951499

14961500
def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]]:
1501+
positional_variadic_arg = []
14971502
positional_args, subcommand_args, optional_args = [], [], []
14981503
for field_name, field_info in _get_model_fields(model).items():
14991504
if _CliSubCommand in field_info.metadata:
@@ -1511,17 +1516,31 @@ def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]
15111516
)
15121517
subcommand_args.append((field_name, field_info))
15131518
elif _CliPositionalArg in field_info.metadata:
1514-
if not field_info.is_required():
1515-
raise SettingsError(f'positional argument {model.__name__}.{field_name} has a default value')
1519+
alias_names, *_ = _get_alias_names(field_name, field_info)
1520+
if len(alias_names) > 1:
1521+
raise SettingsError(f'positional argument {model.__name__}.{field_name} has multiple aliases')
1522+
is_append_action = _annotation_contains_types(
1523+
field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True
1524+
)
1525+
if not is_append_action:
1526+
positional_args.append((field_name, field_info))
15161527
else:
1517-
alias_names, *_ = _get_alias_names(field_name, field_info)
1518-
if len(alias_names) > 1:
1519-
raise SettingsError(f'positional argument {model.__name__}.{field_name} has multiple aliases')
1520-
positional_args.append((field_name, field_info))
1528+
positional_variadic_arg.append((field_name, field_info))
15211529
else:
15221530
self._verify_cli_flag_annotations(model, field_name, field_info)
15231531
optional_args.append((field_name, field_info))
1524-
return positional_args + subcommand_args + optional_args
1532+
1533+
if positional_variadic_arg:
1534+
if len(positional_variadic_arg) > 1:
1535+
field_names = ', '.join([name for name, info in positional_variadic_arg])
1536+
raise SettingsError(f'{model.__name__} has multiple variadic positonal arguments: {field_names}')
1537+
elif subcommand_args:
1538+
field_names = ', '.join([name for name, info in positional_variadic_arg + subcommand_args])
1539+
raise SettingsError(
1540+
f'{model.__name__} has variadic positonal arguments and subcommand arguments: {field_names}'
1541+
)
1542+
1543+
return positional_args + positional_variadic_arg + subcommand_args + optional_args
15251544

15261545
@property
15271546
def root_parser(self) -> T:
@@ -1727,11 +1746,9 @@ def _add_parser_args(
17271746
self._cli_dict_args[kwargs['dest']] = field_info.annotation
17281747

17291748
if _CliPositionalArg in field_info.metadata:
1730-
kwargs['metavar'] = self._check_kebab_name(preferred_alias.upper())
1731-
arg_names = [kwargs['dest']]
1732-
del kwargs['dest']
1733-
del kwargs['required']
1734-
flag_prefix = ''
1749+
arg_names, flag_prefix = self._convert_positional_arg(
1750+
kwargs, field_info, preferred_alias, model_default
1751+
)
17351752

17361753
self._convert_bool_flag(kwargs, field_info, model_default)
17371754

@@ -1787,6 +1804,27 @@ def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, mode
17871804
BooleanOptionalAction if sys.version_info >= (3, 9) else f'store_{str(not default).lower()}'
17881805
)
17891806

1807+
def _convert_positional_arg(
1808+
self, kwargs: dict[str, Any], field_info: FieldInfo, preferred_alias: str, model_default: Any
1809+
) -> tuple[list[str], str]:
1810+
flag_prefix = ''
1811+
arg_names = [kwargs['dest']]
1812+
kwargs['default'] = PydanticUndefined
1813+
kwargs['metavar'] = self._check_kebab_name(preferred_alias.upper())
1814+
1815+
# Note: CLI positional args are always strictly required at the CLI. Therefore, use field_info.is_required in
1816+
# conjunction with model_default instead of the derived kwargs['required'].
1817+
is_required = field_info.is_required() and model_default is PydanticUndefined
1818+
if kwargs.get('action') == 'append':
1819+
del kwargs['action']
1820+
kwargs['nargs'] = '+' if is_required else '*'
1821+
elif not is_required:
1822+
kwargs['nargs'] = '?'
1823+
1824+
del kwargs['dest']
1825+
del kwargs['required']
1826+
return arg_names, flag_prefix
1827+
17901828
def _get_arg_names(
17911829
self,
17921830
arg_prefix: str,

Diff for: tests/test_source_cli.py

+56-4
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,45 @@ class Cfg(BaseSettings):
12971297
assert cfg.model_dump() == {'child': {'name': 'new name a', 'diff_a': 'new diff a'}}
12981298

12991299

1300+
def test_cli_optional_positional_arg(env):
1301+
class Main(BaseSettings):
1302+
model_config = SettingsConfigDict(
1303+
cli_parse_args=True,
1304+
cli_enforce_required=True,
1305+
)
1306+
1307+
value: CliPositionalArg[int] = 123
1308+
1309+
assert CliApp.run(Main, cli_args=[]).model_dump() == {'value': 123}
1310+
1311+
env.set('VALUE', '456')
1312+
assert CliApp.run(Main, cli_args=[]).model_dump() == {'value': 456}
1313+
1314+
assert CliApp.run(Main, cli_args=['789']).model_dump() == {'value': 789}
1315+
1316+
1317+
def test_cli_variadic_positional_arg(env):
1318+
class MainRequired(BaseSettings):
1319+
model_config = SettingsConfigDict(cli_parse_args=True)
1320+
1321+
values: CliPositionalArg[List[int]]
1322+
1323+
class MainOptional(MainRequired):
1324+
values: CliPositionalArg[List[int]] = [1, 2, 3]
1325+
1326+
assert CliApp.run(MainOptional, cli_args=[]).model_dump() == {'values': [1, 2, 3]}
1327+
with pytest.raises(SettingsError, match='error parsing CLI: the following arguments are required: VALUES'):
1328+
CliApp.run(MainRequired, cli_args=[], cli_exit_on_error=False)
1329+
1330+
env.set('VALUES', '[4,5,6]')
1331+
assert CliApp.run(MainOptional, cli_args=[]).model_dump() == {'values': [4, 5, 6]}
1332+
with pytest.raises(SettingsError, match='error parsing CLI: the following arguments are required: VALUES'):
1333+
CliApp.run(MainRequired, cli_args=[], cli_exit_on_error=False)
1334+
1335+
assert CliApp.run(MainOptional, cli_args=['7', '8', '9']).model_dump() == {'values': [7, 8, 9]}
1336+
assert CliApp.run(MainRequired, cli_args=['7', '8', '9']).model_dump() == {'values': [7, 8, 9]}
1337+
1338+
13001339
def test_cli_enums(capsys, monkeypatch):
13011340
class Pet(IntEnum):
13021341
dog = 0
@@ -1416,13 +1455,26 @@ class PositionalArgNotOutermost(BaseSettings, cli_parse_args=True):
14161455
PositionalArgNotOutermost()
14171456

14181457
with pytest.raises(
1419-
SettingsError, match='positional argument PositionalArgHasDefault.pos_arg has a default value'
1458+
SettingsError,
1459+
match='MultipleVariadicPositionialArgs has multiple variadic positonal arguments: strings, numbers',
1460+
):
1461+
1462+
class MultipleVariadicPositionialArgs(BaseSettings, cli_parse_args=True):
1463+
strings: CliPositionalArg[List[str]]
1464+
numbers: CliPositionalArg[List[int]]
1465+
1466+
MultipleVariadicPositionialArgs()
1467+
1468+
with pytest.raises(
1469+
SettingsError,
1470+
match='VariadicPositionialArgAndSubCommand has variadic positonal arguments and subcommand arguments: strings, sub_cmd',
14201471
):
14211472

1422-
class PositionalArgHasDefault(BaseSettings, cli_parse_args=True):
1423-
pos_arg: CliPositionalArg[str] = 'bad'
1473+
class VariadicPositionialArgAndSubCommand(BaseSettings, cli_parse_args=True):
1474+
strings: CliPositionalArg[List[str]]
1475+
sub_cmd: CliSubCommand[SubCmd]
14241476

1425-
PositionalArgHasDefault()
1477+
VariadicPositionialArgAndSubCommand()
14261478

14271479
with pytest.raises(
14281480
SettingsError, match=re.escape("cli_parse_args must be List[str] or Tuple[str, ...], recieved <class 'str'>")

0 commit comments

Comments
 (0)