Skip to content

Commit

Permalink
Cli fix default or none object help text (#364)
Browse files Browse the repository at this point in the history
  • Loading branch information
kschwab authored Aug 9, 2024
1 parent a293ada commit 5c3a817
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 13 deletions.
53 changes: 40 additions & 13 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,7 @@ def _connect_root_parser(
subcommand_prefix=self.env_prefix,
group=None,
alias_prefixes=[],
model_default=PydanticUndefined,
)

def _add_parser_args(
Expand All @@ -1393,6 +1394,7 @@ def _add_parser_args(
subcommand_prefix: str,
group: Any,
alias_prefixes: list[str],
model_default: Any,
) -> ArgumentParser:
subparsers: Any = None
alias_path_args: dict[str, str] = {}
Expand Down Expand Up @@ -1425,16 +1427,19 @@ def _add_parser_args(
subcommand_prefix=f'{subcommand_prefix}{field_name}.',
group=None,
alias_prefixes=[],
model_default=PydanticUndefined,
)
else:
resolved_names, is_alias_path_only = self._get_resolved_names(field_name, field_info, alias_path_args)
arg_flag: str = '--'
kwargs: dict[str, Any] = {}
kwargs['default'] = SUPPRESS
kwargs['help'] = self._help_format(field_info)
kwargs['help'] = self._help_format(field_name, field_info, model_default)
kwargs['dest'] = f'{arg_prefix}{resolved_names[0]}'
kwargs['metavar'] = self._metavar_format(field_info.annotation)
kwargs['required'] = self.cli_enforce_required and field_info.is_required()
kwargs['required'] = (
self.cli_enforce_required and field_info.is_required() and model_default is PydanticUndefined
)
if kwargs['dest'] in added_args:
continue
if _annotation_contains_types(
Expand Down Expand Up @@ -1462,8 +1467,10 @@ def _add_parser_args(
arg_flag,
arg_names,
kwargs,
field_name,
field_info,
resolved_names,
model_default=model_default,
)
elif is_alias_path_only:
continue
Expand Down Expand Up @@ -1502,19 +1509,33 @@ def _add_parser_submodels(
arg_flag: str,
arg_names: list[str],
kwargs: dict[str, Any],
field_name: str,
field_info: FieldInfo,
resolved_names: tuple[str, ...],
model_default: Any,
) -> None:
model_group: Any = None
model_group_kwargs: dict[str, Any] = {}
model_group_kwargs['title'] = f'{arg_names[0]} options'
model_group_kwargs['description'] = (
None
if sub_models[0].__doc__ is None
else dedent(sub_models[0].__doc__)
if self.cli_use_class_docs_for_groups and len(sub_models) == 1
else field_info.description
)
model_group_kwargs['description'] = field_info.description
if self.cli_use_class_docs_for_groups and len(sub_models) == 1:
model_group_kwargs['description'] = None if sub_models[0].__doc__ is None else dedent(sub_models[0].__doc__)

if model_default not in (PydanticUndefined, None):
if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)):
model_default = getattr(model_default, field_name)
else:
if field_info.default is not PydanticUndefined:
model_default = field_info.default
elif field_info.default_factory is not None:
model_default = field_info.default_factory
if model_default is None:
desc_header = f'default: {self.cli_parse_none_str} (undefined)'
if model_group_kwargs['description'] is not None:
model_group_kwargs['description'] = dedent(f'{desc_header}\n{model_group_kwargs["description"]}')
else:
model_group_kwargs['description'] = desc_header

if not self.cli_avoid_json:
added_args.append(arg_names[0])
kwargs['help'] = f'set {arg_names[0]} from JSON string'
Expand All @@ -1529,6 +1550,7 @@ def _add_parser_submodels(
subcommand_prefix=subcommand_prefix,
group=model_group if model_group else model_group_kwargs,
alias_prefixes=[f'{arg_prefix}{name}.' for name in resolved_names[1:]],
model_default=model_default,
)

def _add_parser_alias_paths(
Expand Down Expand Up @@ -1618,14 +1640,19 @@ def _metavar_format_recurse(self, obj: Any) -> str:
def _metavar_format(self, obj: Any) -> str:
return self._metavar_format_recurse(obj).replace(', ', ',')

def _help_format(self, field_info: FieldInfo) -> str:
def _help_format(self, field_name: str, field_info: FieldInfo, model_default: Any) -> str:
_help = field_info.description if field_info.description else ''
if field_info.is_required():
if field_info.is_required() and model_default in (PydanticUndefined, None):
if _CliPositionalArg not in field_info.metadata:
_help += ' (required)' if _help else '(required)'
ifdef = 'ifdef: ' if model_default is None else ''
_help += f' ({ifdef}required)' if _help else f'({ifdef}required)'
else:
default = f'(default: {self.cli_parse_none_str})'
if field_info.default not in (PydanticUndefined, None):
if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)):
default = f'(default: {getattr(model_default, field_name)})'
elif model_default not in (PydanticUndefined, None) and callable(model_default):
default = f'(default factory: {self._metavar_format(model_default)})'
elif field_info.default not in (PydanticUndefined, None):
default = f'(default: {field_info.default})'
elif field_info.default_factory is not None:
default = f'(default: {field_info.default_factory})'
Expand Down
98 changes: 98 additions & 0 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2467,6 +2467,104 @@ class MultilineDoc(BaseSettings, cli_parse_args=True):
)


def test_cli_help_default_or_none_model(capsys, monkeypatch):
class DeeperSubModel(BaseModel):
flag: bool

class DeepSubModel(BaseModel):
flag: bool
deeper: Optional[DeeperSubModel] = None

class SubModel(BaseModel):
flag: bool
deep: DeepSubModel = DeepSubModel(flag=True)

class Settings(BaseSettings, cli_parse_args=True):
flag: bool = True
sub_model: SubModel = SubModel(flag=False)
opt_model: Optional[DeepSubModel] = Field(None, description='Group Doc')
fact_model: SubModel = Field(default_factory=lambda: SubModel(flag=True))

with monkeypatch.context() as m:
m.setattr(sys, 'argv', ['example.py', '--help'])

with pytest.raises(SystemExit):
Settings()
assert (
capsys.readouterr().out
== f"""usage: example.py [-h] [--flag bool] [--sub_model JSON]
[--sub_model.flag bool] [--sub_model.deep JSON]
[--sub_model.deep.flag bool]
[--sub_model.deep.deeper {{JSON,null}}]
[--sub_model.deep.deeper.flag bool]
[--opt_model {{JSON,null}}] [--opt_model.flag bool]
[--opt_model.deeper {{JSON,null}}]
[--opt_model.deeper.flag bool] [--fact_model JSON]
[--fact_model.flag bool] [--fact_model.deep JSON]
[--fact_model.deep.flag bool]
[--fact_model.deep.deeper {{JSON,null}}]
[--fact_model.deep.deeper.flag bool]
{ARGPARSE_OPTIONS_TEXT}:
-h, --help show this help message and exit
--flag bool (default: True)
sub_model options:
--sub_model JSON set sub_model from JSON string
--sub_model.flag bool
(default: False)
sub_model.deep options:
--sub_model.deep JSON
set sub_model.deep from JSON string
--sub_model.deep.flag bool
(default: True)
sub_model.deep.deeper options:
default: null (undefined)
--sub_model.deep.deeper {{JSON,null}}
set sub_model.deep.deeper from JSON string
--sub_model.deep.deeper.flag bool
(ifdef: required)
opt_model options:
default: null (undefined)
Group Doc
--opt_model {{JSON,null}}
set opt_model from JSON string
--opt_model.flag bool
(ifdef: required)
opt_model.deeper options:
default: null (undefined)
--opt_model.deeper {{JSON,null}}
set opt_model.deeper from JSON string
--opt_model.deeper.flag bool
(ifdef: required)
fact_model options:
--fact_model JSON set fact_model from JSON string
--fact_model.flag bool
(default factory: <lambda>)
fact_model.deep options:
--fact_model.deep JSON
set fact_model.deep from JSON string
--fact_model.deep.flag bool
(default factory: <lambda>)
fact_model.deep.deeper options:
--fact_model.deep.deeper {{JSON,null}}
set fact_model.deep.deeper from JSON string
--fact_model.deep.deeper.flag bool
(default factory: <lambda>)
"""
)


def test_cli_nested_dataclass_arg():
@pydantic_dataclasses.dataclass
class MyDataclass:
Expand Down

0 comments on commit 5c3a817

Please sign in to comment.