Skip to content

Commit 5c3a817

Browse files
authored
Cli fix default or none object help text (#364)
1 parent a293ada commit 5c3a817

File tree

2 files changed

+138
-13
lines changed

2 files changed

+138
-13
lines changed

pydantic_settings/sources.py

+40-13
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,7 @@ def _connect_root_parser(
13821382
subcommand_prefix=self.env_prefix,
13831383
group=None,
13841384
alias_prefixes=[],
1385+
model_default=PydanticUndefined,
13851386
)
13861387

13871388
def _add_parser_args(
@@ -1393,6 +1394,7 @@ def _add_parser_args(
13931394
subcommand_prefix: str,
13941395
group: Any,
13951396
alias_prefixes: list[str],
1397+
model_default: Any,
13961398
) -> ArgumentParser:
13971399
subparsers: Any = None
13981400
alias_path_args: dict[str, str] = {}
@@ -1425,16 +1427,19 @@ def _add_parser_args(
14251427
subcommand_prefix=f'{subcommand_prefix}{field_name}.',
14261428
group=None,
14271429
alias_prefixes=[],
1430+
model_default=PydanticUndefined,
14281431
)
14291432
else:
14301433
resolved_names, is_alias_path_only = self._get_resolved_names(field_name, field_info, alias_path_args)
14311434
arg_flag: str = '--'
14321435
kwargs: dict[str, Any] = {}
14331436
kwargs['default'] = SUPPRESS
1434-
kwargs['help'] = self._help_format(field_info)
1437+
kwargs['help'] = self._help_format(field_name, field_info, model_default)
14351438
kwargs['dest'] = f'{arg_prefix}{resolved_names[0]}'
14361439
kwargs['metavar'] = self._metavar_format(field_info.annotation)
1437-
kwargs['required'] = self.cli_enforce_required and field_info.is_required()
1440+
kwargs['required'] = (
1441+
self.cli_enforce_required and field_info.is_required() and model_default is PydanticUndefined
1442+
)
14381443
if kwargs['dest'] in added_args:
14391444
continue
14401445
if _annotation_contains_types(
@@ -1462,8 +1467,10 @@ def _add_parser_args(
14621467
arg_flag,
14631468
arg_names,
14641469
kwargs,
1470+
field_name,
14651471
field_info,
14661472
resolved_names,
1473+
model_default=model_default,
14671474
)
14681475
elif is_alias_path_only:
14691476
continue
@@ -1502,19 +1509,33 @@ def _add_parser_submodels(
15021509
arg_flag: str,
15031510
arg_names: list[str],
15041511
kwargs: dict[str, Any],
1512+
field_name: str,
15051513
field_info: FieldInfo,
15061514
resolved_names: tuple[str, ...],
1515+
model_default: Any,
15071516
) -> None:
15081517
model_group: Any = None
15091518
model_group_kwargs: dict[str, Any] = {}
15101519
model_group_kwargs['title'] = f'{arg_names[0]} options'
1511-
model_group_kwargs['description'] = (
1512-
None
1513-
if sub_models[0].__doc__ is None
1514-
else dedent(sub_models[0].__doc__)
1515-
if self.cli_use_class_docs_for_groups and len(sub_models) == 1
1516-
else field_info.description
1517-
)
1520+
model_group_kwargs['description'] = field_info.description
1521+
if self.cli_use_class_docs_for_groups and len(sub_models) == 1:
1522+
model_group_kwargs['description'] = None if sub_models[0].__doc__ is None else dedent(sub_models[0].__doc__)
1523+
1524+
if model_default not in (PydanticUndefined, None):
1525+
if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)):
1526+
model_default = getattr(model_default, field_name)
1527+
else:
1528+
if field_info.default is not PydanticUndefined:
1529+
model_default = field_info.default
1530+
elif field_info.default_factory is not None:
1531+
model_default = field_info.default_factory
1532+
if model_default is None:
1533+
desc_header = f'default: {self.cli_parse_none_str} (undefined)'
1534+
if model_group_kwargs['description'] is not None:
1535+
model_group_kwargs['description'] = dedent(f'{desc_header}\n{model_group_kwargs["description"]}')
1536+
else:
1537+
model_group_kwargs['description'] = desc_header
1538+
15181539
if not self.cli_avoid_json:
15191540
added_args.append(arg_names[0])
15201541
kwargs['help'] = f'set {arg_names[0]} from JSON string'
@@ -1529,6 +1550,7 @@ def _add_parser_submodels(
15291550
subcommand_prefix=subcommand_prefix,
15301551
group=model_group if model_group else model_group_kwargs,
15311552
alias_prefixes=[f'{arg_prefix}{name}.' for name in resolved_names[1:]],
1553+
model_default=model_default,
15321554
)
15331555

15341556
def _add_parser_alias_paths(
@@ -1618,14 +1640,19 @@ def _metavar_format_recurse(self, obj: Any) -> str:
16181640
def _metavar_format(self, obj: Any) -> str:
16191641
return self._metavar_format_recurse(obj).replace(', ', ',')
16201642

1621-
def _help_format(self, field_info: FieldInfo) -> str:
1643+
def _help_format(self, field_name: str, field_info: FieldInfo, model_default: Any) -> str:
16221644
_help = field_info.description if field_info.description else ''
1623-
if field_info.is_required():
1645+
if field_info.is_required() and model_default in (PydanticUndefined, None):
16241646
if _CliPositionalArg not in field_info.metadata:
1625-
_help += ' (required)' if _help else '(required)'
1647+
ifdef = 'ifdef: ' if model_default is None else ''
1648+
_help += f' ({ifdef}required)' if _help else f'({ifdef}required)'
16261649
else:
16271650
default = f'(default: {self.cli_parse_none_str})'
1628-
if field_info.default not in (PydanticUndefined, None):
1651+
if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)):
1652+
default = f'(default: {getattr(model_default, field_name)})'
1653+
elif model_default not in (PydanticUndefined, None) and callable(model_default):
1654+
default = f'(default factory: {self._metavar_format(model_default)})'
1655+
elif field_info.default not in (PydanticUndefined, None):
16291656
default = f'(default: {field_info.default})'
16301657
elif field_info.default_factory is not None:
16311658
default = f'(default: {field_info.default_factory})'

tests/test_settings.py

+98
Original file line numberDiff line numberDiff line change
@@ -2467,6 +2467,104 @@ class MultilineDoc(BaseSettings, cli_parse_args=True):
24672467
)
24682468

24692469

2470+
def test_cli_help_default_or_none_model(capsys, monkeypatch):
2471+
class DeeperSubModel(BaseModel):
2472+
flag: bool
2473+
2474+
class DeepSubModel(BaseModel):
2475+
flag: bool
2476+
deeper: Optional[DeeperSubModel] = None
2477+
2478+
class SubModel(BaseModel):
2479+
flag: bool
2480+
deep: DeepSubModel = DeepSubModel(flag=True)
2481+
2482+
class Settings(BaseSettings, cli_parse_args=True):
2483+
flag: bool = True
2484+
sub_model: SubModel = SubModel(flag=False)
2485+
opt_model: Optional[DeepSubModel] = Field(None, description='Group Doc')
2486+
fact_model: SubModel = Field(default_factory=lambda: SubModel(flag=True))
2487+
2488+
with monkeypatch.context() as m:
2489+
m.setattr(sys, 'argv', ['example.py', '--help'])
2490+
2491+
with pytest.raises(SystemExit):
2492+
Settings()
2493+
assert (
2494+
capsys.readouterr().out
2495+
== f"""usage: example.py [-h] [--flag bool] [--sub_model JSON]
2496+
[--sub_model.flag bool] [--sub_model.deep JSON]
2497+
[--sub_model.deep.flag bool]
2498+
[--sub_model.deep.deeper {{JSON,null}}]
2499+
[--sub_model.deep.deeper.flag bool]
2500+
[--opt_model {{JSON,null}}] [--opt_model.flag bool]
2501+
[--opt_model.deeper {{JSON,null}}]
2502+
[--opt_model.deeper.flag bool] [--fact_model JSON]
2503+
[--fact_model.flag bool] [--fact_model.deep JSON]
2504+
[--fact_model.deep.flag bool]
2505+
[--fact_model.deep.deeper {{JSON,null}}]
2506+
[--fact_model.deep.deeper.flag bool]
2507+
2508+
{ARGPARSE_OPTIONS_TEXT}:
2509+
-h, --help show this help message and exit
2510+
--flag bool (default: True)
2511+
2512+
sub_model options:
2513+
--sub_model JSON set sub_model from JSON string
2514+
--sub_model.flag bool
2515+
(default: False)
2516+
2517+
sub_model.deep options:
2518+
--sub_model.deep JSON
2519+
set sub_model.deep from JSON string
2520+
--sub_model.deep.flag bool
2521+
(default: True)
2522+
2523+
sub_model.deep.deeper options:
2524+
default: null (undefined)
2525+
2526+
--sub_model.deep.deeper {{JSON,null}}
2527+
set sub_model.deep.deeper from JSON string
2528+
--sub_model.deep.deeper.flag bool
2529+
(ifdef: required)
2530+
2531+
opt_model options:
2532+
default: null (undefined)
2533+
Group Doc
2534+
2535+
--opt_model {{JSON,null}}
2536+
set opt_model from JSON string
2537+
--opt_model.flag bool
2538+
(ifdef: required)
2539+
2540+
opt_model.deeper options:
2541+
default: null (undefined)
2542+
2543+
--opt_model.deeper {{JSON,null}}
2544+
set opt_model.deeper from JSON string
2545+
--opt_model.deeper.flag bool
2546+
(ifdef: required)
2547+
2548+
fact_model options:
2549+
--fact_model JSON set fact_model from JSON string
2550+
--fact_model.flag bool
2551+
(default factory: <lambda>)
2552+
2553+
fact_model.deep options:
2554+
--fact_model.deep JSON
2555+
set fact_model.deep from JSON string
2556+
--fact_model.deep.flag bool
2557+
(default factory: <lambda>)
2558+
2559+
fact_model.deep.deeper options:
2560+
--fact_model.deep.deeper {{JSON,null}}
2561+
set fact_model.deep.deeper from JSON string
2562+
--fact_model.deep.deeper.flag bool
2563+
(default factory: <lambda>)
2564+
"""
2565+
)
2566+
2567+
24702568
def test_cli_nested_dataclass_arg():
24712569
@pydantic_dataclasses.dataclass
24722570
class MyDataclass:

0 commit comments

Comments
 (0)