Skip to content

Commit bd294a4

Browse files
kschwabhramezani
andauthored
Add CliSettingsSource alias handling for AliasChoices and AliasPath. (#313)
Co-authored-by: Hasan Ramezani <[email protected]>
1 parent abe7cc5 commit bd294a4

File tree

3 files changed

+294
-74
lines changed

3 files changed

+294
-74
lines changed

docs/index.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,38 @@ print(Settings().model_dump())
669669
#> {'fruit': <Fruit.lime: 2>, 'pet': 'cat'}
670670
```
671671

672+
#### Aliases
673+
674+
Pydantic field aliases are added as CLI argument aliases.
675+
676+
```py
677+
import sys
678+
679+
from pydantic import AliasChoices, AliasPath, Field
680+
681+
from pydantic_settings import BaseSettings
682+
683+
684+
class User(BaseSettings, cli_parse_args=True):
685+
first_name: str = Field(
686+
validation_alias=AliasChoices('fname', AliasPath('name', 0))
687+
)
688+
last_name: str = Field(validation_alias=AliasChoices('lname', AliasPath('name', 1)))
689+
690+
691+
sys.argv = ['example.py', '--fname', 'John', '--lname', 'Doe']
692+
print(User().model_dump())
693+
#> {'first_name': 'John', 'last_name': 'Doe'}
694+
695+
sys.argv = ['example.py', '--name', 'John,Doe']
696+
print(User().model_dump())
697+
#> {'first_name': 'John', 'last_name': 'Doe'}
698+
699+
sys.argv = ['example.py', '--name', 'John', '--lname', 'Doe']
700+
print(User().model_dump())
701+
#> {'first_name': 'John', 'last_name': 'Doe'}
702+
```
703+
672704
### Subcommands and Positional Arguments
673705

674706
Subcommands and positional arguments are expressed using the `CliSubCommand` and `CliPositionalArg` annotations. These
@@ -681,6 +713,9 @@ subcommands must be a valid type derived from the pydantic `BaseModel` class.
681713
set of subcommands. For more information on subparsers, see [argparse
682714
subcommands](https://docs.python.org/3/library/argparse.html#sub-commands).
683715

716+
!!! note
717+
`CliSubCommand` and `CliPositionalArg` are always case sensitive and do not support aliases.
718+
684719
```py
685720
import sys
686721

pydantic_settings/sources.py

Lines changed: 163 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1157,30 +1157,67 @@ def _get_sub_models(self, model: type[BaseModel], field_name: str, field_info: F
11571157
sub_models.append(type_) # type: ignore
11581158
return sub_models
11591159

1160-
def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, str, FieldInfo]]:
1160+
def _get_resolved_names(
1161+
self, field_name: str, field_info: FieldInfo, alias_path_args: dict[str, str]
1162+
) -> tuple[tuple[str, ...], bool]:
1163+
resolved_names: list[str] = []
1164+
is_alias_path_only: bool = True
1165+
if not any((field_info.alias, field_info.validation_alias)):
1166+
resolved_names += [field_name]
1167+
is_alias_path_only = False
1168+
else:
1169+
new_alias_paths: list[AliasPath] = []
1170+
for alias in (field_info.alias, field_info.validation_alias):
1171+
if alias is None:
1172+
continue
1173+
elif isinstance(alias, str):
1174+
resolved_names.append(alias)
1175+
is_alias_path_only = False
1176+
elif isinstance(alias, AliasChoices):
1177+
for name in alias.choices:
1178+
if isinstance(name, str):
1179+
resolved_names.append(name)
1180+
is_alias_path_only = False
1181+
else:
1182+
new_alias_paths.append(name)
1183+
else:
1184+
new_alias_paths.append(alias)
1185+
for alias_path in new_alias_paths:
1186+
name = cast(str, alias_path.path[0])
1187+
name = name.lower() if not self.case_sensitive else name
1188+
alias_path_args[name] = 'dict' if len(alias_path.path) > 2 else 'list'
1189+
if not resolved_names and is_alias_path_only:
1190+
resolved_names.append(name)
1191+
if not self.case_sensitive:
1192+
resolved_names = [resolved_name.lower() for resolved_name in resolved_names]
1193+
return tuple(dict.fromkeys(resolved_names)), is_alias_path_only
1194+
1195+
def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]]:
11611196
positional_args, subcommand_args, optional_args = [], [], []
11621197
fields = model.__pydantic_fields__ if is_pydantic_dataclass(model) else model.model_fields
11631198
for field_name, field_info in fields.items():
1164-
resolved_name = field_name if field_info.alias is None else field_info.alias
1165-
resolved_name = resolved_name.lower() if not self.case_sensitive else resolved_name
11661199
if _CliSubCommand in field_info.metadata:
11671200
if not field_info.is_required():
11681201
raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has a default value')
1202+
elif any((field_info.alias, field_info.validation_alias)):
1203+
raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has an alias')
11691204
else:
11701205
field_types = [type_ for type_ in get_args(field_info.annotation) if type_ is not type(None)]
11711206
if len(field_types) != 1:
11721207
raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has multiple types')
11731208
elif not is_model_class(field_types[0]):
11741209
raise SettingsError(
1175-
f'subcommand argument {model.__name__}.{resolved_name} is not derived from BaseModel'
1210+
f'subcommand argument {model.__name__}.{field_name} is not derived from BaseModel'
11761211
)
1177-
subcommand_args.append((field_name, resolved_name, field_info))
1212+
subcommand_args.append((field_name, field_info))
11781213
elif _CliPositionalArg in field_info.metadata:
11791214
if not field_info.is_required():
11801215
raise SettingsError(f'positional argument {model.__name__}.{field_name} has a default value')
1181-
positional_args.append((field_name, resolved_name, field_info))
1216+
elif any((field_info.alias, field_info.validation_alias)):
1217+
raise SettingsError(f'positional argument {model.__name__}.{field_name} has an alias')
1218+
positional_args.append((field_name, field_info))
11821219
else:
1183-
optional_args.append((field_name, resolved_name, field_info))
1220+
optional_args.append((field_name, field_info))
11841221
return positional_args + subcommand_args + optional_args
11851222

11861223
@property
@@ -1251,6 +1288,7 @@ def _connect_root_parser(
12511288
arg_prefix=self.env_prefix,
12521289
subcommand_prefix=self.env_prefix,
12531290
group=None,
1291+
alias_prefixes=[],
12541292
)
12551293

12561294
def _add_parser_args(
@@ -1261,18 +1299,20 @@ def _add_parser_args(
12611299
arg_prefix: str,
12621300
subcommand_prefix: str,
12631301
group: Any,
1302+
alias_prefixes: list[str],
12641303
) -> ArgumentParser:
12651304
subparsers: Any = None
1266-
for field_name, resolved_name, field_info in self._sort_arg_fields(model):
1305+
alias_path_args: dict[str, str] = {}
1306+
for field_name, field_info in self._sort_arg_fields(model):
12671307
sub_models: list[type[BaseModel]] = self._get_sub_models(model, field_name, field_info)
12681308
if _CliSubCommand in field_info.metadata:
12691309
if subparsers is None:
12701310
subparsers = self._add_subparsers(
12711311
parser, title='subcommands', dest=f'{arg_prefix}:subcommand', required=self.cli_enforce_required
12721312
)
1273-
self._cli_subcommands[f'{arg_prefix}:subcommand'] = [f'{arg_prefix}{resolved_name}']
1313+
self._cli_subcommands[f'{arg_prefix}:subcommand'] = [f'{arg_prefix}{field_name}']
12741314
else:
1275-
self._cli_subcommands[f'{arg_prefix}:subcommand'].append(f'{arg_prefix}{resolved_name}')
1315+
self._cli_subcommands[f'{arg_prefix}:subcommand'].append(f'{arg_prefix}{field_name}')
12761316
if hasattr(subparsers, 'metavar'):
12771317
metavar = ','.join(self._cli_subcommands[f'{arg_prefix}:subcommand'])
12781318
subparsers.metavar = f'{{{metavar}}}'
@@ -1281,23 +1321,25 @@ def _add_parser_args(
12811321
self._add_parser_args(
12821322
parser=self._add_parser(
12831323
subparsers,
1284-
resolved_name,
1324+
field_name,
12851325
help=field_info.description,
12861326
formatter_class=self._formatter_class,
12871327
description=model.__doc__,
12881328
),
12891329
model=model,
12901330
added_args=[],
1291-
arg_prefix=f'{arg_prefix}{resolved_name}.',
1292-
subcommand_prefix=f'{subcommand_prefix}{resolved_name}.',
1331+
arg_prefix=f'{arg_prefix}{field_name}.',
1332+
subcommand_prefix=f'{subcommand_prefix}{field_name}.',
12931333
group=None,
1334+
alias_prefixes=[],
12941335
)
12951336
else:
1337+
resolved_names, is_alias_path_only = self._get_resolved_names(field_name, field_info, alias_path_args)
12961338
arg_flag: str = '--'
12971339
kwargs: dict[str, Any] = {}
12981340
kwargs['default'] = SUPPRESS
12991341
kwargs['help'] = self._help_format(field_info)
1300-
kwargs['dest'] = f'{arg_prefix}{resolved_name}'
1342+
kwargs['dest'] = f'{arg_prefix}{resolved_names[0]}'
13011343
kwargs['metavar'] = self._metavar_format(field_info.annotation)
13021344
kwargs['required'] = self.cli_enforce_required and field_info.is_required()
13031345
if kwargs['dest'] in added_args:
@@ -1309,51 +1351,126 @@ def _add_parser_args(
13091351
if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True):
13101352
self._cli_dict_args[kwargs['dest']] = field_info.annotation
13111353

1312-
arg_name = (
1313-
f'{arg_prefix}{resolved_name}'
1314-
if subcommand_prefix == self.env_prefix
1315-
else f'{arg_prefix.replace(subcommand_prefix, "", 1)}{resolved_name}'
1316-
)
1354+
arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, resolved_names)
13171355
if _CliPositionalArg in field_info.metadata:
1318-
kwargs['metavar'] = resolved_name.upper()
1319-
arg_name = kwargs['dest']
1356+
kwargs['metavar'] = resolved_names[0].upper()
1357+
arg_names = [kwargs['dest']]
13201358
del kwargs['dest']
13211359
del kwargs['required']
13221360
arg_flag = ''
13231361

13241362
if sub_models and kwargs.get('action') != 'append':
1325-
model_group: Any = None
1326-
model_group_kwargs: dict[str, Any] = {}
1327-
model_group_kwargs['title'] = f'{arg_name} options'
1328-
model_group_kwargs['description'] = (
1329-
sub_models[0].__doc__
1330-
if self.cli_use_class_docs_for_groups and len(sub_models) == 1
1331-
else field_info.description
1363+
self._add_parser_submodels(
1364+
parser,
1365+
sub_models,
1366+
added_args,
1367+
arg_prefix,
1368+
subcommand_prefix,
1369+
arg_flag,
1370+
arg_names,
1371+
kwargs,
1372+
field_info,
1373+
resolved_names,
13321374
)
1333-
if not self.cli_avoid_json:
1334-
added_args.append(arg_name)
1335-
kwargs['help'] = f'set {arg_name} from JSON string'
1336-
model_group = self._add_argument_group(parser, **model_group_kwargs)
1337-
self._add_argument(model_group, f'{arg_flag}{arg_name}', **kwargs)
1338-
for model in sub_models:
1339-
self._add_parser_args(
1340-
parser=parser,
1341-
model=model,
1342-
added_args=added_args,
1343-
arg_prefix=f'{arg_prefix}{resolved_name}.',
1344-
subcommand_prefix=subcommand_prefix,
1345-
group=model_group if model_group else model_group_kwargs,
1346-
)
1375+
elif is_alias_path_only:
1376+
continue
13471377
elif group is not None:
13481378
if isinstance(group, dict):
13491379
group = self._add_argument_group(parser, **group)
1350-
added_args.append(arg_name)
1351-
self._add_argument(group, f'{arg_flag}{arg_name}', **kwargs)
1380+
added_args += list(arg_names)
1381+
self._add_argument(group, *(f'{arg_flag}{name}' for name in arg_names), **kwargs)
13521382
else:
1353-
added_args.append(arg_name)
1354-
self._add_argument(parser, f'{arg_flag}{arg_name}', **kwargs)
1383+
added_args += list(arg_names)
1384+
self._add_argument(parser, *(f'{arg_flag}{name}' for name in arg_names), **kwargs)
1385+
1386+
self._add_parser_alias_paths(parser, alias_path_args, added_args, arg_prefix, subcommand_prefix, group)
13551387
return parser
13561388

1389+
def _get_arg_names(
1390+
self, arg_prefix: str, subcommand_prefix: str, alias_prefixes: list[str], resolved_names: tuple[str, ...]
1391+
) -> list[str]:
1392+
arg_names: list[str] = []
1393+
for prefix in [arg_prefix] + alias_prefixes:
1394+
for name in resolved_names:
1395+
arg_names.append(
1396+
f'{prefix}{name}'
1397+
if subcommand_prefix == self.env_prefix
1398+
else f'{prefix.replace(subcommand_prefix, "", 1)}{name}'
1399+
)
1400+
return arg_names
1401+
1402+
def _add_parser_submodels(
1403+
self,
1404+
parser: Any,
1405+
sub_models: list[type[BaseModel]],
1406+
added_args: list[str],
1407+
arg_prefix: str,
1408+
subcommand_prefix: str,
1409+
arg_flag: str,
1410+
arg_names: list[str],
1411+
kwargs: dict[str, Any],
1412+
field_info: FieldInfo,
1413+
resolved_names: tuple[str, ...],
1414+
) -> None:
1415+
model_group: Any = None
1416+
model_group_kwargs: dict[str, Any] = {}
1417+
model_group_kwargs['title'] = f'{arg_names[0]} options'
1418+
model_group_kwargs['description'] = (
1419+
sub_models[0].__doc__
1420+
if self.cli_use_class_docs_for_groups and len(sub_models) == 1
1421+
else field_info.description
1422+
)
1423+
if not self.cli_avoid_json:
1424+
added_args.append(arg_names[0])
1425+
kwargs['help'] = f'set {arg_names[0]} from JSON string'
1426+
model_group = self._add_argument_group(parser, **model_group_kwargs)
1427+
self._add_argument(model_group, *(f'{arg_flag}{name}' for name in arg_names), **kwargs)
1428+
for model in sub_models:
1429+
self._add_parser_args(
1430+
parser=parser,
1431+
model=model,
1432+
added_args=added_args,
1433+
arg_prefix=f'{arg_prefix}{resolved_names[0]}.',
1434+
subcommand_prefix=subcommand_prefix,
1435+
group=model_group if model_group else model_group_kwargs,
1436+
alias_prefixes=[f'{arg_prefix}{name}.' for name in resolved_names[1:]],
1437+
)
1438+
1439+
def _add_parser_alias_paths(
1440+
self,
1441+
parser: Any,
1442+
alias_path_args: dict[str, str],
1443+
added_args: list[str],
1444+
arg_prefix: str,
1445+
subcommand_prefix: str,
1446+
group: Any,
1447+
) -> None:
1448+
if alias_path_args:
1449+
context = parser
1450+
if group is not None:
1451+
context = self._add_argument_group(parser, **group) if isinstance(group, dict) else group
1452+
is_nested_alias_path = arg_prefix.endswith('.')
1453+
arg_prefix = arg_prefix[:-1] if is_nested_alias_path else arg_prefix
1454+
for name, metavar in alias_path_args.items():
1455+
name = '' if is_nested_alias_path else name
1456+
arg_name = (
1457+
f'{arg_prefix}{name}'
1458+
if subcommand_prefix == self.env_prefix
1459+
else f'{arg_prefix.replace(subcommand_prefix, "", 1)}{name}'
1460+
)
1461+
kwargs: dict[str, Any] = {}
1462+
kwargs['default'] = SUPPRESS
1463+
kwargs['help'] = 'pydantic alias path'
1464+
kwargs['dest'] = f'{arg_prefix}{name}'
1465+
if metavar == 'dict' or is_nested_alias_path:
1466+
kwargs['metavar'] = 'dict'
1467+
else:
1468+
kwargs['action'] = 'append'
1469+
kwargs['metavar'] = 'list'
1470+
if arg_name not in added_args:
1471+
added_args.append(arg_name)
1472+
self._add_argument(context, f'--{arg_name}', **kwargs)
1473+
13571474
def _get_modified_args(self, obj: Any) -> tuple[str, ...]:
13581475
if not self.cli_hide_none_type:
13591476
return get_args(obj)

0 commit comments

Comments
 (0)