Skip to content

Commit cb48bb5

Browse files
committed
Add CLI support for variadic positional args.
1 parent eca638b commit cb48bb5

File tree

3 files changed

+78
-7
lines changed

3 files changed

+78
-7
lines changed

docs/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,6 +1285,9 @@ However, if your use case [aligns more with #2](#command-line-support), using Py
12851285
likely want required fields to be _strictly required at the CLI_. We can enable this behavior by using
12861286
`cli_enforce_required`.
12871287

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

pydantic_settings/sources.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,7 @@ def _verify_cli_flag_annotations(self, model: type[BaseModel], field_name: str,
14981498
)
14991499

15001500
def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]]:
1501+
positional_variadic_arg = []
15011502
positional_args, subcommand_args, optional_args = [], [], []
15021503
for field_name, field_info in _get_model_fields(model).items():
15031504
if _CliSubCommand in field_info.metadata:
@@ -1518,11 +1519,28 @@ def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]
15181519
alias_names, *_ = _get_alias_names(field_name, field_info)
15191520
if len(alias_names) > 1:
15201521
raise SettingsError(f'positional argument {model.__name__}.{field_name} has multiple aliases')
1521-
positional_args.append((field_name, field_info))
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))
1527+
else:
1528+
positional_variadic_arg.append((field_name, field_info))
15221529
else:
15231530
self._verify_cli_flag_annotations(model, field_name, field_info)
15241531
optional_args.append((field_name, field_info))
1525-
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
15261544

15271545
@property
15281546
def root_parser(self) -> T:
@@ -1728,7 +1746,9 @@ def _add_parser_args(
17281746
self._cli_dict_args[kwargs['dest']] = field_info.annotation
17291747

17301748
if _CliPositionalArg in field_info.metadata:
1731-
arg_names, flag_prefix = self._convert_positional_arg(kwargs, field_info, preferred_alias)
1749+
arg_names, flag_prefix = self._convert_positional_arg(
1750+
kwargs, field_info, preferred_alias, model_default
1751+
)
17321752

17331753
self._convert_bool_flag(kwargs, field_info, model_default)
17341754

@@ -1785,16 +1805,20 @@ def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, mode
17851805
)
17861806

17871807
def _convert_positional_arg(
1788-
self, kwargs: dict[str, Any], field_info: FieldInfo, preferred_alias: str
1808+
self, kwargs: dict[str, Any], field_info: FieldInfo, preferred_alias: str, model_default: Any
17891809
) -> tuple[list[str], str]:
17901810
flag_prefix = ''
17911811
arg_names = [kwargs['dest']]
17921812
kwargs['default'] = PydanticUndefined
17931813
kwargs['metavar'] = self._check_kebab_name(preferred_alias.upper())
17941814

1795-
# Note: For positional args, we must strictly look at field_info.is_required instead of our derived
1796-
# kwargs['required'].
1797-
if not field_info.is_required():
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:
17981822
kwargs['nargs'] = '?'
17991823

18001824
del kwargs['dest']

tests/test_source_cli.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,28 @@ class Main(BaseSettings):
13141314
assert CliApp.run(Main, cli_args=['789']).model_dump() == {'value': 789}
13151315

13161316

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+
13171339
def test_cli_enums(capsys, monkeypatch):
13181340
class Pet(IntEnum):
13191341
dog = 0
@@ -1432,6 +1454,28 @@ class PositionalArgNotOutermost(BaseSettings, cli_parse_args=True):
14321454

14331455
PositionalArgNotOutermost()
14341456

1457+
with pytest.raises(
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',
1471+
):
1472+
1473+
class VariadicPositionialArgAndSubCommand(BaseSettings, cli_parse_args=True):
1474+
strings: CliPositionalArg[list[str]]
1475+
sub_cmd: CliSubCommand[SubCmd]
1476+
1477+
VariadicPositionialArgAndSubCommand()
1478+
14351479
with pytest.raises(
14361480
SettingsError, match=re.escape("cli_parse_args must be List[str] or Tuple[str, ...], recieved <class 'str'>")
14371481
):

0 commit comments

Comments
 (0)