From 2e361a6eb287fe1b3bfa8676eb24dff233fa8f26 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Fri, 2 Aug 2024 15:38:16 -0600 Subject: [PATCH 01/11] Initial commit. --- pydantic_settings/sources.py | 107 +++++++++++++++++++++-------------- tests/test_settings.py | 9 --- 2 files changed, 65 insertions(+), 51 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 14f83a7..77a04d1 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -13,6 +13,7 @@ from dataclasses import is_dataclass from enum import Enum from pathlib import Path +from textwrap import dedent from types import FunctionType from typing import ( TYPE_CHECKING, @@ -136,7 +137,7 @@ def error(self, message: str) -> NoReturn: T = TypeVar('T') CliSubCommand = Annotated[Union[T, None], _CliSubCommand] -CliPositionalArg = Annotated[T, _CliPositionalArg] +CliPositionalArg = Annotated[Union[T, None], _CliPositionalArg] class EnvNoneType(str): @@ -1103,12 +1104,14 @@ def _load_env_vars( if isinstance(val, list): parsed_args[field_name] = self._merge_parsed_list(val, field_name) elif field_name.endswith(':subcommand') and val is not None: - selected_subcommands.append(field_name.split(':')[0] + val) + subcommand_name = field_name.split(':')[0] + val + subcommand_dest = self._cli_subcommands[field_name][subcommand_name] + selected_subcommands.append(subcommand_dest) for subcommands in self._cli_subcommands.values(): - for subcommand in subcommands: - if subcommand not in selected_subcommands: - parsed_args[subcommand] = self.cli_parse_none_str + for subcommand_dest in subcommands.values(): + if subcommand_dest not in selected_subcommands: + parsed_args[subcommand_dest] = self.cli_parse_none_str parsed_args = {key: val for key, val in parsed_args.items() if not key.endswith(':subcommand')} if selected_subcommands: @@ -1288,22 +1291,24 @@ def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo] if _CliSubCommand in field_info.metadata: if not field_info.is_required(): raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has a default value') - elif any((field_info.alias, field_info.validation_alias)): - raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has an alias') else: + resolved_names, *_ = self._get_resolved_names(field_name, field_info, {}) + if len(resolved_names) > 1: + raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has multiple aliases') field_types = [type_ for type_ in get_args(field_info.annotation) if type_ is not type(None)] - if len(field_types) != 1: - raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has multiple types') - elif not (is_model_class(field_types[0]) or is_pydantic_dataclass(field_types[0])): - raise SettingsError( - f'subcommand argument {model.__name__}.{field_name} is not derived from BaseModel' - ) + for field_type in field_types: + if not (is_model_class(field_type) or is_pydantic_dataclass(field_type)): + raise SettingsError( + f'subcommand argument {model.__name__}.{field_name} is not derived from BaseModel' + ) subcommand_args.append((field_name, field_info)) elif _CliPositionalArg in field_info.metadata: if not field_info.is_required(): raise SettingsError(f'positional argument {model.__name__}.{field_name} has a default value') - elif any((field_info.alias, field_info.validation_alias)): - raise SettingsError(f'positional argument {model.__name__}.{field_name} has an alias') + else: + resolved_names, *_ = self._get_resolved_names(field_name, field_info, {}) + if len(resolved_names) > 1: + raise SettingsError(f'positional argument {model.__name__}.{field_name} has multiple aliases') positional_args.append((field_name, field_info)) else: optional_args.append((field_name, field_info)) @@ -1369,7 +1374,7 @@ def _connect_root_parser( self._add_subparsers = self._connect_parser_method(add_subparsers_method, 'add_subparsers_method') self._formatter_class = formatter_class self._cli_dict_args: dict[str, type[Any] | None] = {} - self._cli_subcommands: dict[str, list[str]] = {} + self._cli_subcommands: dict[str, dict[str, str]] = {} self._add_parser_args( parser=self.root_parser, model=self.settings_cls, @@ -1395,33 +1400,51 @@ def _add_parser_args( for field_name, field_info in self._sort_arg_fields(model): sub_models: list[type[BaseModel]] = self._get_sub_models(model, field_name, field_info) if _CliSubCommand in field_info.metadata: - if subparsers is None: - subparsers = self._add_subparsers( - parser, title='subcommands', dest=f'{arg_prefix}:subcommand', required=self.cli_enforce_required + for model in sub_models: + derived_name = model.__name__ if len(sub_models) > 1 else field_name + subcommand_name = f'{arg_prefix}{derived_name}' + subcommand_dest = f'{arg_prefix}{field_name}' + subcommand_help = ( + None if model.__doc__ is None else dedent(model.__doc__) + if self.cli_use_class_docs_for_groups + else field_info.description + if len(sub_models) == 1 + else None + ) + if subparsers is None: + subparsers = self._add_subparsers( + parser, + title='subcommands', + dest=f'{arg_prefix}:subcommand', + required=self.cli_enforce_required, + description=field_info.description if len(sub_models) > 1 else None, + ) + self._cli_subcommands[f'{arg_prefix}:subcommand'] = {subcommand_name: subcommand_dest} + else: + self._cli_subcommands[f'{arg_prefix}:subcommand'][subcommand_name] = subcommand_dest + + if hasattr(subparsers, 'metavar'): + subparsers.metavar = ( + f'{subparsers.metavar[:-1]},{derived_name}}}' + if subparsers.metavar + else f'{{{derived_name}}}' + ) + + self._add_parser_args( + parser=self._add_parser( + subparsers, + derived_name, + help=subcommand_help, + formatter_class=self._formatter_class, + description=model.__doc__, + ), + model=model, + added_args=[], + arg_prefix=f'{arg_prefix}{field_name}.', + subcommand_prefix=f'{subcommand_prefix}{field_name}.', + group=None, + alias_prefixes=[], ) - self._cli_subcommands[f'{arg_prefix}:subcommand'] = [f'{arg_prefix}{field_name}'] - else: - self._cli_subcommands[f'{arg_prefix}:subcommand'].append(f'{arg_prefix}{field_name}') - if hasattr(subparsers, 'metavar'): - metavar = ','.join(self._cli_subcommands[f'{arg_prefix}:subcommand']) - subparsers.metavar = f'{{{metavar}}}' - - model = sub_models[0] - self._add_parser_args( - parser=self._add_parser( - subparsers, - field_name, - help=field_info.description, - formatter_class=self._formatter_class, - description=model.__doc__, - ), - model=model, - added_args=[], - arg_prefix=f'{arg_prefix}{field_name}.', - subcommand_prefix=f'{subcommand_prefix}{field_name}.', - group=None, - alias_prefixes=[], - ) else: resolved_names, is_alias_path_only = self._get_resolved_names(field_name, field_info, alias_path_args) arg_flag: str = '--' diff --git a/tests/test_settings.py b/tests/test_settings.py index db82d38..3c911eb 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2904,15 +2904,6 @@ class SubCommandHasDefault(BaseSettings, cli_parse_args=True): SubCommandHasDefault() - with pytest.raises( - SettingsError, match='subcommand argument SubCommandMultipleTypes.subcmd has multiple types' - ): - - class SubCommandMultipleTypes(BaseSettings, cli_parse_args=True): - subcmd: CliSubCommand[Union[SubCmd, SubCmdAlt]] - - SubCommandMultipleTypes() - with pytest.raises( SettingsError, match='subcommand argument SubCommandNotModel.subcmd is not derived from BaseModel' ): From a6daf43fdbb2360c92edabbdd68cda9963918fd9 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Wed, 28 Aug 2024 09:36:32 -0600 Subject: [PATCH 02/11] Updates. --- pydantic_settings/sources.py | 91 ++++++++++++++++++------------------ tests/test_settings.py | 8 ++-- 2 files changed, 50 insertions(+), 49 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 1550af4..46e130a 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -13,7 +13,7 @@ if sys.version_info >= (3, 9): from argparse import BooleanOptionalAction from argparse import SUPPRESS, ArgumentParser, Namespace, RawDescriptionHelpFormatter, _SubParsersAction -from collections import deque +from collections import defaultdict, deque from dataclasses import asdict, is_dataclass from enum import Enum from pathlib import Path @@ -1367,13 +1367,13 @@ def _get_sub_models(self, model: type[BaseModel], field_name: str, field_info: F sub_models.append(type_) # type: ignore return sub_models - def _get_resolved_names( + def _get_alias_names( self, field_name: str, field_info: FieldInfo, alias_path_args: dict[str, str] ) -> tuple[tuple[str, ...], bool]: - resolved_names: list[str] = [] + alias_names: list[str] = [] is_alias_path_only: bool = True if not any((field_info.alias, field_info.validation_alias)): - resolved_names += [field_name] + alias_names += [field_name] is_alias_path_only = False else: new_alias_paths: list[AliasPath] = [] @@ -1381,12 +1381,12 @@ def _get_resolved_names( if alias is None: continue elif isinstance(alias, str): - resolved_names.append(alias) + alias_names.append(alias) is_alias_path_only = False elif isinstance(alias, AliasChoices): for name in alias.choices: if isinstance(name, str): - resolved_names.append(name) + alias_names.append(name) is_alias_path_only = False else: new_alias_paths.append(name) @@ -1396,11 +1396,11 @@ def _get_resolved_names( name = cast(str, alias_path.path[0]) name = name.lower() if not self.case_sensitive else name alias_path_args[name] = 'dict' if len(alias_path.path) > 2 else 'list' - if not resolved_names and is_alias_path_only: - resolved_names.append(name) + if not alias_names and is_alias_path_only: + alias_names.append(name) if not self.case_sensitive: - resolved_names = [resolved_name.lower() for resolved_name in resolved_names] - return tuple(dict.fromkeys(resolved_names)), is_alias_path_only + alias_names = [alias_name.lower() for alias_name in alias_names] + return tuple(dict.fromkeys(alias_names)), is_alias_path_only def _verify_cli_flag_annotations(self, model: type[BaseModel], field_name: str, field_info: FieldInfo) -> None: if _CliImplicitFlag in field_info.metadata: @@ -1426,8 +1426,8 @@ def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo] if not field_info.is_required(): raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has a default value') else: - resolved_names, *_ = self._get_resolved_names(field_name, field_info, {}) - if len(resolved_names) > 1: + alias_names, *_ = self._get_alias_names(field_name, field_info, {}) + if len(alias_names) > 1: raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has multiple aliases') field_types = [type_ for type_ in get_args(field_info.annotation) if type_ is not type(None)] for field_type in field_types: @@ -1440,8 +1440,8 @@ def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo] if not field_info.is_required(): raise SettingsError(f'positional argument {model.__name__}.{field_name} has a default value') else: - resolved_names, *_ = self._get_resolved_names(field_name, field_info, {}) - if len(resolved_names) > 1: + alias_names, *_ = self._get_alias_names(field_name, field_info, {}) + if len(alias_names) > 1: raise SettingsError(f'positional argument {model.__name__}.{field_name} has multiple aliases') positional_args.append((field_name, field_info)) else: @@ -1509,7 +1509,7 @@ def _connect_root_parser( self._add_subparsers = self._connect_parser_method(add_subparsers_method, 'add_subparsers_method') self._formatter_class = formatter_class self._cli_dict_args: dict[str, type[Any] | None] = {} - self._cli_subcommands: dict[str, dict[str, str]] = {} + self._cli_subcommands: defaultdict[str, dict[str, str]] = defaultdict(dict) self._add_parser_args( parser=self.root_parser, model=self.settings_cls, @@ -1536,59 +1536,59 @@ def _add_parser_args( alias_path_args: dict[str, str] = {} for field_name, field_info in self._sort_arg_fields(model): sub_models: list[type[BaseModel]] = self._get_sub_models(model, field_name, field_info) + alias_names, is_alias_path_only = self._get_alias_names(field_name, field_info, alias_path_args) + preferred_alias = alias_names[0] if _CliSubCommand in field_info.metadata: for model in sub_models: - derived_name = model.__name__ if len(sub_models) > 1 else field_name - subcommand_name = f'{arg_prefix}{derived_name}' - subcommand_dest = f'{arg_prefix}{field_name}' - subcommand_help = ( - None if model.__doc__ is None else dedent(model.__doc__) - if self.cli_use_class_docs_for_groups - else field_info.description - if len(sub_models) == 1 - else None - ) - if subparsers is None: - subparsers = self._add_subparsers( + subcommand_alias = model.__name__ if len(sub_models) > 1 else preferred_alias + subcommand_name = f'{arg_prefix}{subcommand_alias}' + subcommand_dest = f'{arg_prefix}{preferred_alias}' + self._cli_subcommands[f'{arg_prefix}:subcommand'][subcommand_name] = subcommand_dest + + subcommand_help = None if len(sub_models) > 1 else field_info.description + if self.cli_use_class_docs_for_groups: + subcommand_help = None if model.__doc__ is None else dedent(model.__doc__) + + subparsers = ( + self._add_subparsers( parser, title='subcommands', dest=f'{arg_prefix}:subcommand', description=field_info.description if len(sub_models) > 1 else None, ) - self._cli_subcommands[f'{arg_prefix}:subcommand'] = {subcommand_name: subcommand_dest} - else: - self._cli_subcommands[f'{arg_prefix}:subcommand'][subcommand_name] = subcommand_dest + if subparsers is None + else subparsers + ) if hasattr(subparsers, 'metavar'): subparsers.metavar = ( - f'{subparsers.metavar[:-1]},{derived_name}}}' + f'{subparsers.metavar[:-1]},{subcommand_alias}}}' if subparsers.metavar - else f'{{{derived_name}}}' + else f'{{{subcommand_alias}}}' ) self._add_parser_args( parser=self._add_parser( subparsers, - derived_name, + subcommand_alias, help=subcommand_help, formatter_class=self._formatter_class, description=None if model.__doc__ is None else dedent(model.__doc__), ), model=model, added_args=[], - arg_prefix=f'{arg_prefix}{field_name}.', - subcommand_prefix=f'{subcommand_prefix}{field_name}.', + arg_prefix=f'{arg_prefix}{preferred_alias}.', + subcommand_prefix=f'{subcommand_prefix}{preferred_alias}.', 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_name, field_info, model_default) - kwargs['dest'] = f'{arg_prefix}{resolved_names[0]}' + kwargs['dest'] = f'{arg_prefix}{preferred_alias}' kwargs['metavar'] = self._metavar_format(field_info.annotation) kwargs['required'] = ( self.cli_enforce_required and field_info.is_required() and model_default is PydanticUndefined @@ -1602,9 +1602,9 @@ def _add_parser_args( if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True): self._cli_dict_args[kwargs['dest']] = field_info.annotation - arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, resolved_names) + arg_names = self._get_arg_names(arg_prefix, subcommand_prefix, alias_prefixes, alias_names) if _CliPositionalArg in field_info.metadata: - kwargs['metavar'] = resolved_names[0].upper() + kwargs['metavar'] = preferred_alias.upper() arg_names = [kwargs['dest']] del kwargs['dest'] del kwargs['required'] @@ -1624,7 +1624,7 @@ def _add_parser_args( kwargs, field_name, field_info, - resolved_names, + alias_names, model_default=model_default, ) elif is_alias_path_only: @@ -1658,11 +1658,11 @@ def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, mode ) def _get_arg_names( - self, arg_prefix: str, subcommand_prefix: str, alias_prefixes: list[str], resolved_names: tuple[str, ...] + self, arg_prefix: str, subcommand_prefix: str, alias_prefixes: list[str], alias_names: tuple[str, ...] ) -> list[str]: arg_names: list[str] = [] for prefix in [arg_prefix] + alias_prefixes: - for name in resolved_names: + for name in alias_names: arg_names.append( f'{prefix}{name}' if subcommand_prefix == self.env_prefix @@ -1682,7 +1682,7 @@ def _add_parser_submodels( kwargs: dict[str, Any], field_name: str, field_info: FieldInfo, - resolved_names: tuple[str, ...], + alias_names: tuple[str, ...], model_default: Any, ) -> None: model_group: Any = None @@ -1707,6 +1707,7 @@ def _add_parser_submodels( else: model_group_kwargs['description'] = desc_header + preferred_alias = alias_names[0] if not self.cli_avoid_json: added_args.append(arg_names[0]) kwargs['help'] = f'set {arg_names[0]} from JSON string' @@ -1717,10 +1718,10 @@ def _add_parser_submodels( parser=parser, model=model, added_args=added_args, - arg_prefix=f'{arg_prefix}{resolved_names[0]}.', + arg_prefix=f'{arg_prefix}{preferred_alias}.', 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:]], + alias_prefixes=[f'{arg_prefix}{name}.' for name in alias_names[1:]], model_default=model_default, ) diff --git a/tests/test_settings.py b/tests/test_settings.py index 356c58e..f191018 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2500,20 +2500,20 @@ class Cfg(BaseSettings, cli_avoid_json=avoid_json): def test_cli_alias_exceptions(capsys, monkeypatch): - with pytest.raises(SettingsError, match='subcommand argument BadCliSubCommand.foo has an alias'): + with pytest.raises(SettingsError, match='subcommand argument BadCliSubCommand.foo has multiple aliases'): class SubCmd(BaseModel): v0: int class BadCliSubCommand(BaseSettings): - foo: CliSubCommand[SubCmd] = Field(alias='bar') + foo: CliSubCommand[SubCmd] = Field(validation_alias=AliasChoices('bar', 'boo')) BadCliSubCommand(_cli_parse_args=True) - with pytest.raises(SettingsError, match='positional argument BadCliPositionalArg.foo has an alias'): + with pytest.raises(SettingsError, match='positional argument BadCliPositionalArg.foo has multiple alias'): class BadCliPositionalArg(BaseSettings): - foo: CliPositionalArg[int] = Field(alias='bar') + foo: CliPositionalArg[int] = Field(validation_alias=AliasChoices('bar', 'boo')) BadCliPositionalArg(_cli_parse_args=True) From 9feb26dda5e4a8dd6b6fbb839eaa2eb0ff96a4f1 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Thu, 29 Aug 2024 19:05:34 -0600 Subject: [PATCH 03/11] Fix for pre-parsed args not tied to model. --- pydantic_settings/sources.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 46e130a..31f419e 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1268,6 +1268,8 @@ def _merge_parsed_list(self, parsed_list: list[str], field_name: str) -> str: list if parsed_list and (len(parsed_list) > 1 or parsed_list[0].startswith('[')) else str ) for val in parsed_list: + if not isinstance(val, str): + break val = val.strip() if val.startswith('[') and val.endswith(']'): val = val[1:-1].strip() From 45c1f698c7df6654eb48f11c254004413d56bd9a Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sun, 1 Sep 2024 10:30:05 -0600 Subject: [PATCH 04/11] Lint. --- pydantic_settings/sources.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 31f419e..794bc71 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1248,25 +1248,30 @@ def _load_env_vars( return self + def _get_merge_parsed_list_types( + self, parsed_list: list[str], field_name: str + ) -> tuple[Optional[type], Optional[type]]: + merge_type = self._cli_dict_args.get(field_name, list) + if ( + merge_type is list + or not origin_is_union(get_origin(merge_type)) + or not any( + type_ + for type_ in get_args(merge_type) + if type_ is not type(None) and get_origin(type_) not in (dict, Mapping) + ) + ): + inferred_type = merge_type + else: + inferred_type = list if parsed_list and (len(parsed_list) > 1 or parsed_list[0].startswith('[')) else str + + return merge_type, inferred_type + def _merge_parsed_list(self, parsed_list: list[str], field_name: str) -> str: try: merged_list: list[str] = [] is_last_consumed_a_value = False - merge_type = self._cli_dict_args.get(field_name, list) - if ( - merge_type is list - or not origin_is_union(get_origin(merge_type)) - or not any( - type_ - for type_ in get_args(merge_type) - if type_ is not type(None) and get_origin(type_) not in (dict, Mapping) - ) - ): - inferred_type = merge_type - else: - inferred_type = ( - list if parsed_list and (len(parsed_list) > 1 or parsed_list[0].startswith('[')) else str - ) + merge_type, inferred_type = self._get_merge_parsed_list_types(parsed_list, field_name) for val in parsed_list: if not isinstance(val, str): break From 8d4430413bdd0c0c941f5a2d80bf3ece2ec1167f Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sun, 1 Sep 2024 11:10:19 -0600 Subject: [PATCH 05/11] Initial test case. --- pydantic_settings/sources.py | 2 +- tests/test_settings.py | 65 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 794bc71..9fdcec3 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -150,7 +150,7 @@ def error(self, message: str) -> NoReturn: T = TypeVar('T') CliSubCommand = Annotated[Union[T, None], _CliSubCommand] -CliPositionalArg = Annotated[Union[T, None], _CliPositionalArg] +CliPositionalArg = Annotated[T, _CliPositionalArg] _CliBoolFlag = TypeVar('_CliBoolFlag', bound=bool) CliImplicitFlag = Annotated[_CliBoolFlag, _CliImplicitFlag] CliExplicitFlag = Annotated[_CliBoolFlag, _CliExplicitFlag] diff --git a/tests/test_settings.py b/tests/test_settings.py index f191018..efa16af 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3088,6 +3088,71 @@ class Cfg(BaseSettings): cfg = Cfg(_cli_parse_args=args) +def test_cli_subcommand_union(): + class AlphaCmd(BaseModel): + a: str + + class BetaCmd(BaseModel): + b: str + + class GammaCmd(BaseModel): + g: str + + class Root1(BaseSettings, cli_parse_args=True): + subcommand: CliSubCommand[AlphaCmd | BetaCmd | GammaCmd] + + alpha = Root1(_cli_parse_args=['AlphaCmd', '-a=alpha']) + assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} + assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}} + beta = Root1(_cli_parse_args=['BetaCmd', '-b=beta']) + assert get_subcommand(beta).model_dump() == {'b': 'beta'} + assert beta.model_dump() == {'subcommand': {'b': 'beta'}} + gamma = Root1(_cli_parse_args=['GammaCmd', '-g=gamma']) + assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} + assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}} + + class Root2(BaseSettings, cli_parse_args=True): + subcommand: CliSubCommand[AlphaCmd | GammaCmd] + beta: CliSubCommand[BetaCmd] + + alpha = Root2(_cli_parse_args=['AlphaCmd', '-a=alpha']) + assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} + assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}, 'beta': None} + beta = Root2(_cli_parse_args=['beta', '-b=beta']) + assert get_subcommand(beta).model_dump() == {'b': 'beta'} + assert beta.model_dump() == {'subcommand': None, 'beta': {'b': 'beta'}} + gamma = Root2(_cli_parse_args=['GammaCmd', '-g=gamma']) + assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} + assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}, 'beta': None} + + class Root3(BaseSettings, cli_parse_args=True): + alpha: CliSubCommand[AlphaCmd] + beta: CliSubCommand[BetaCmd] + gamma: CliSubCommand[GammaCmd] + + alpha = Root3(_cli_parse_args=['alpha', '-a=alpha']) + assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} + assert alpha.model_dump() == { + 'alpha': {'a': 'alpha'}, + 'beta': None, + 'gamma': None, + } + beta = Root3(_cli_parse_args=['beta', '-b=beta']) + assert get_subcommand(beta).model_dump() == {'b': 'beta'} + assert beta.model_dump() == { + 'alpha': None, + 'beta': {'b': 'beta'}, + 'gamma': None, + } + gamma = Root3(_cli_parse_args=['gamma', '-g=gamma']) + assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} + assert gamma.model_dump() == { + 'alpha': None, + 'beta': None, + 'gamma': {'g': 'gamma'}, + } + + def test_cli_subcommand_with_positionals(): @pydantic_dataclasses.dataclass class FooPlugin: From cc42d7edec64a3ec9bc42ec9673e1efff9e20091 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Mon, 2 Sep 2024 08:19:18 -0600 Subject: [PATCH 06/11] Revert and bring this fix in separately. --- pydantic_settings/sources.py | 37 +++++++++++++++--------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 9fdcec3..d169301 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1248,33 +1248,26 @@ def _load_env_vars( return self - def _get_merge_parsed_list_types( - self, parsed_list: list[str], field_name: str - ) -> tuple[Optional[type], Optional[type]]: - merge_type = self._cli_dict_args.get(field_name, list) - if ( - merge_type is list - or not origin_is_union(get_origin(merge_type)) - or not any( - type_ - for type_ in get_args(merge_type) - if type_ is not type(None) and get_origin(type_) not in (dict, Mapping) - ) - ): - inferred_type = merge_type - else: - inferred_type = list if parsed_list and (len(parsed_list) > 1 or parsed_list[0].startswith('[')) else str - - return merge_type, inferred_type - def _merge_parsed_list(self, parsed_list: list[str], field_name: str) -> str: try: merged_list: list[str] = [] is_last_consumed_a_value = False - merge_type, inferred_type = self._get_merge_parsed_list_types(parsed_list, field_name) + merge_type = self._cli_dict_args.get(field_name, list) + if ( + merge_type is list + or not origin_is_union(get_origin(merge_type)) + or not any( + type_ + for type_ in get_args(merge_type) + if type_ is not type(None) and get_origin(type_) not in (dict, Mapping) + ) + ): + inferred_type = merge_type + else: + inferred_type = ( + list if parsed_list and (len(parsed_list) > 1 or parsed_list[0].startswith('[')) else str + ) for val in parsed_list: - if not isinstance(val, str): - break val = val.strip() if val.startswith('[') and val.endswith(']'): val = val[1:-1].strip() From c2fd129cf9195af3f57f0292e9742474ca691a4e Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Mon, 2 Sep 2024 09:41:14 -0600 Subject: [PATCH 07/11] Completed test cases. --- pydantic_settings/sources.py | 2 +- tests/test_settings.py | 243 +++++++++++++++++++++++++++++++---- 2 files changed, 216 insertions(+), 29 deletions(-) diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index d169301..703b49b 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1433,7 +1433,7 @@ def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo] for field_type in field_types: if not (is_model_class(field_type) or is_pydantic_dataclass(field_type)): raise SettingsError( - f'subcommand argument {model.__name__}.{field_name} is not derived from BaseModel' + f'subcommand argument {model.__name__}.{field_name} has type not derived from BaseModel' ) subcommand_args.append((field_name, field_info)) elif _CliPositionalArg in field_info.metadata: diff --git a/tests/test_settings.py b/tests/test_settings.py index efa16af..e815831 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -2430,6 +2430,53 @@ def settings_customise_sources( assert cfg.model_dump() == {'foo': 'FOO FROM ENV'} +def test_cli_alias_subcommand_and_positional_args(capsys, monkeypatch): + class SubCmd(BaseModel): + pos_arg: CliPositionalArg[str] = Field(validation_alias='pos-arg') + + class Cfg(BaseSettings): + sub_cmd: CliSubCommand[SubCmd] = Field(validation_alias='sub-cmd') + + cfg = Cfg(**{'sub-cmd': {'pos-arg': 'howdy'}}) + assert cfg.model_dump() == {'sub_cmd': {'pos_arg': 'howdy'}} + + cfg = Cfg(_cli_parse_args=['sub-cmd', 'howdy']) + assert cfg.model_dump() == {'sub_cmd': {'pos_arg': 'howdy'}} + + with monkeypatch.context() as m: + m.setattr(sys, 'argv', ['example.py', '--help']) + + with pytest.raises(SystemExit): + Cfg(_cli_parse_args=True) + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] {{sub-cmd}} ... + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + +subcommands: + {{sub-cmd}} + sub-cmd +""" + ) + m.setattr(sys, 'argv', ['example.py', 'sub-cmd', '--help']) + + with pytest.raises(SystemExit): + Cfg(_cli_parse_args=True) + assert ( + capsys.readouterr().out + == f"""usage: example.py sub-cmd [-h] POS-ARG + +positional arguments: + POS-ARG + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit +""" + ) + + @pytest.mark.parametrize('avoid_json', [True, False]) def test_cli_alias_arg(capsys, monkeypatch, avoid_json): class Cfg(BaseSettings, cli_avoid_json=avoid_json): @@ -3088,18 +3135,26 @@ class Cfg(BaseSettings): cfg = Cfg(_cli_parse_args=args) -def test_cli_subcommand_union(): +def test_cli_subcommand_union(capsys, monkeypatch): class AlphaCmd(BaseModel): + """Alpha Help""" + a: str class BetaCmd(BaseModel): + """Beta Help""" + b: str class GammaCmd(BaseModel): + """Gamma Help""" + g: str - class Root1(BaseSettings, cli_parse_args=True): - subcommand: CliSubCommand[AlphaCmd | BetaCmd | GammaCmd] + class Root1(BaseSettings): + """Root Help""" + + subcommand: CliSubCommand[AlphaCmd | BetaCmd | GammaCmd] = Field(description='Field Help') alpha = Root1(_cli_parse_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} @@ -3111,9 +3166,56 @@ class Root1(BaseSettings, cli_parse_args=True): assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}} - class Root2(BaseSettings, cli_parse_args=True): - subcommand: CliSubCommand[AlphaCmd | GammaCmd] - beta: CliSubCommand[BetaCmd] + with monkeypatch.context() as m: + m.setattr(sys, 'argv', ['example.py', '--help']) + + with pytest.raises(SystemExit): + Root1(_cli_parse_args=True) + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] {{AlphaCmd,BetaCmd,GammaCmd}} ... + +Root Help + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + +subcommands: + Field Help + + {{AlphaCmd,BetaCmd,GammaCmd}} + AlphaCmd + BetaCmd + GammaCmd +""" + ) + + with pytest.raises(SystemExit): + Root1(_cli_parse_args=True, _cli_use_class_docs_for_groups=True) + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] {{AlphaCmd,BetaCmd,GammaCmd}} ... + +Root Help + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + +subcommands: + Field Help + + {{AlphaCmd,BetaCmd,GammaCmd}} + AlphaCmd Alpha Help + BetaCmd Beta Help + GammaCmd Gamma Help +""" + ) + + class Root2(BaseSettings): + """Root Help""" + + subcommand: CliSubCommand[AlphaCmd | GammaCmd] = Field(description='Field Help') + beta: CliSubCommand[BetaCmd] = Field(description='Field Beta Help') alpha = Root2(_cli_parse_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} @@ -3125,32 +3227,107 @@ class Root2(BaseSettings, cli_parse_args=True): assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}, 'beta': None} - class Root3(BaseSettings, cli_parse_args=True): - alpha: CliSubCommand[AlphaCmd] - beta: CliSubCommand[BetaCmd] - gamma: CliSubCommand[GammaCmd] + with monkeypatch.context() as m: + m.setattr(sys, 'argv', ['example.py', '--help']) + + with pytest.raises(SystemExit): + Root2(_cli_parse_args=True) + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] {{AlphaCmd,GammaCmd,beta}} ... + +Root Help + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + +subcommands: + Field Help + + {{AlphaCmd,GammaCmd,beta}} + AlphaCmd + GammaCmd + beta Field Beta Help +""" + ) + + with pytest.raises(SystemExit): + Root2(_cli_parse_args=True, _cli_use_class_docs_for_groups=True) + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] {{AlphaCmd,GammaCmd,beta}} ... + +Root Help + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + +subcommands: + Field Help + + {{AlphaCmd,GammaCmd,beta}} + AlphaCmd Alpha Help + GammaCmd Gamma Help + beta Beta Help +""" + ) + + class Root3(BaseSettings): + """Root Help""" - alpha = Root3(_cli_parse_args=['alpha', '-a=alpha']) + beta: CliSubCommand[BetaCmd] = Field(description='Field Beta Help') + subcommand: CliSubCommand[AlphaCmd | GammaCmd] = Field(description='Field Help') + + alpha = Root3(_cli_parse_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} - assert alpha.model_dump() == { - 'alpha': {'a': 'alpha'}, - 'beta': None, - 'gamma': None, - } + assert alpha.model_dump() == {'subcommand': {'a': 'alpha'}, 'beta': None} beta = Root3(_cli_parse_args=['beta', '-b=beta']) assert get_subcommand(beta).model_dump() == {'b': 'beta'} - assert beta.model_dump() == { - 'alpha': None, - 'beta': {'b': 'beta'}, - 'gamma': None, - } - gamma = Root3(_cli_parse_args=['gamma', '-g=gamma']) + assert beta.model_dump() == {'subcommand': None, 'beta': {'b': 'beta'}} + gamma = Root3(_cli_parse_args=['GammaCmd', '-g=gamma']) assert get_subcommand(gamma).model_dump() == {'g': 'gamma'} - assert gamma.model_dump() == { - 'alpha': None, - 'beta': None, - 'gamma': {'g': 'gamma'}, - } + assert gamma.model_dump() == {'subcommand': {'g': 'gamma'}, 'beta': None} + + with monkeypatch.context() as m: + m.setattr(sys, 'argv', ['example.py', '--help']) + + with pytest.raises(SystemExit): + Root3(_cli_parse_args=True) + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] {{beta,AlphaCmd,GammaCmd}} ... + +Root Help + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + +subcommands: + {{beta,AlphaCmd,GammaCmd}} + beta Field Beta Help + AlphaCmd + GammaCmd +""" + ) + + with pytest.raises(SystemExit): + Root3(_cli_parse_args=True, _cli_use_class_docs_for_groups=True) + assert ( + capsys.readouterr().out + == f"""usage: example.py [-h] {{beta,AlphaCmd,GammaCmd}} ... + +Root Help + +{ARGPARSE_OPTIONS_TEXT}: + -h, --help show this help message and exit + +subcommands: + {{beta,AlphaCmd,GammaCmd}} + beta Beta Help + AlphaCmd Alpha Help + GammaCmd Gamma Help +""" + ) def test_cli_subcommand_with_positionals(): @@ -3354,7 +3531,17 @@ class SubCommandHasDefault(BaseSettings, cli_parse_args=True): SubCommandHasDefault() with pytest.raises( - SettingsError, match='subcommand argument SubCommandNotModel.subcmd is not derived from BaseModel' + SettingsError, + match='subcommand argument SubCommandMultipleTypes.subcmd has type not derived from BaseModel', + ): + + class SubCommandMultipleTypes(BaseSettings, cli_parse_args=True): + subcmd: CliSubCommand[Union[SubCmd, str]] + + SubCommandMultipleTypes() + + with pytest.raises( + SettingsError, match='subcommand argument SubCommandNotModel.subcmd has type not derived from BaseModel' ): class SubCommandNotModel(BaseSettings, cli_parse_args=True): From bd011b461df3022640737f4e678452cc163bd3a0 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Tue, 3 Sep 2024 08:52:26 -0600 Subject: [PATCH 08/11] Initial docs. --- docs/index.md | 68 +++++++++++++++++++++++++++++++++--- pydantic_settings/sources.py | 24 ++++++++----- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/docs/index.md b/docs/index.md index 5e3cf28..134ad9e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -757,7 +757,7 @@ not required, set the `is_required` flag to `False` to disable raising an error subcommands](https://docs.python.org/3/library/argparse.html#sub-commands). !!! note - `CliSubCommand` and `CliPositionalArg` are always case sensitive and do not support aliases. + `CliSubCommand` and `CliPositionalArg` are always case sensitive. ```py import sys @@ -817,6 +817,65 @@ assert get_subcommand(cmd).model_dump() == { } ``` +The `CliSubCommand` and `CliPositionalArg` annotations also support union operations and aliases. For unions of Pydantic +models, it is important to remember the [nuances](https://docs.pydantic.dev/latest/concepts/unions/) that can arise +during validation. Specifically, for unions of subcommands that are identical in content, it is recommended to break +them out into separate `CliSubCommand` fields to avoid any complications. Lastly, the derived sub command names from +unions will be the names of the Pydantic model classes themselves. + +When assigning aliases to `CliSubCommand` or `CliPositionalArg` fields, only a single alias can be assigned. For +non-union subcommands, aliasing will change the displayed help text and subcommand name. Conversely, for union +subcommands, aliasing will have no tangible effect from the perspective of the CLI settings source. Lastly, for +positional arguments, aliasing will change the CLI help text displayed for the field. + +```py +import sys + +from pydantic import BaseModel, Field + +from pydantic_settings import ( + BaseSettings, + CliPositionalArg, + CliSubCommand, +) + + +class Alpha(BaseModel): + """Apha Help""" + + cmd_alpha: CliPositionalArg[str] = Field(alias='alpha-cmd') + + +class Beta(BaseModel): + """Beta Help""" + + opt_beta: str = Field(alias='opt-beta') + + +class Gamma(BaseModel): + """Beta Help""" + + opt_gamma: str = Field(alias='opt-gamma') + + +class Root(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): + subcommand: CliSubCommand[Alpha | Beta] = Field(alias='sub-command') + gamma: CliSubCommand[Gamma] = Field(alias='gamma-cmd') + + +sys.argv = ['example.py', 'Alpha', 'hello'] +print(Root().model_dump()) +#> {'subcommand': {'cmd_alpha': 'hello'}, 'gamma': None} + +sys.argv = ['example.py', 'Beta', '--opt-beta=hey'] +print(Root().model_dump()) +#> {'subcommand': {'opt_beta': 'hey'}, 'gamma': None} + +sys.argv = ['example.py', 'gamma-cmd', '--opt-gamma=hi'] +print(Root().model_dump()) +#> {'subcommand': None, 'gamma': {'opt_gamma': 'hi'}} +``` + ### Customizing the CLI Experience The below flags can be used to customise the CLI experience to your needs. @@ -861,9 +920,10 @@ Additionally, the provided `CliImplicitFlag` and `CliExplicitFlag` annotations c when necessary. !!! note -For `python < 3.9`: - * The `--no-flag` option is not generated due to an underlying `argparse` limitation. - * The `CliImplicitFlag` and `CliExplicitFlag` annotations can only be applied to optional bool fields. + For `python < 3.9` the `--no-flag` option is not generated due to an underlying `argparse` limitation. + +!!! note + For `python < 3.9` the `CliImplicitFlag` and `CliExplicitFlag` annotations can only be applied to optional bool fields. ```py from pydantic_settings import BaseSettings, CliExplicitFlag, CliImplicitFlag diff --git a/pydantic_settings/sources.py b/pydantic_settings/sources.py index 13e6ee9..74fff77 100644 --- a/pydantic_settings/sources.py +++ b/pydantic_settings/sources.py @@ -1585,19 +1585,29 @@ def _add_parser_args( ) else: arg_flag: str = '--' + is_append_action = _annotation_contains_types( + field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True + ) + is_parser_submodel = sub_models and not is_append_action kwargs: dict[str, Any] = {} kwargs['default'] = SUPPRESS kwargs['help'] = self._help_format(field_name, field_info, model_default) - kwargs['dest'] = f'{arg_prefix}{preferred_alias}' kwargs['metavar'] = self._metavar_format(field_info.annotation) kwargs['required'] = ( self.cli_enforce_required and field_info.is_required() and model_default is PydanticUndefined ) + kwargs['dest'] = ( + # Strip prefix if validation alias is set and value is not complex. + # Related https://github.com/pydantic/pydantic-settings/pull/25 + f'{arg_prefix}{preferred_alias}'[self.env_prefix_len :] + if arg_prefix and field_info.validation_alias is not None and not is_parser_submodel + else f'{arg_prefix}{preferred_alias}' + ) + if kwargs['dest'] in added_args: continue - if _annotation_contains_types( - field_info.annotation, (list, set, dict, Sequence, Mapping), is_strip_annotated=True - ): + + if is_append_action: kwargs['action'] = 'append' if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True): self._cli_dict_args[kwargs['dest']] = field_info.annotation @@ -1612,7 +1622,7 @@ def _add_parser_args( self._convert_bool_flag(kwargs, field_info, model_default) - if sub_models and kwargs.get('action') != 'append': + if is_parser_submodel: self._add_parser_submodels( parser, sub_models, @@ -1628,10 +1638,6 @@ def _add_parser_args( model_default=model_default, ) elif not is_alias_path_only: - if arg_prefix and field_info.validation_alias is not None: - # Strip prefix if validation alias is set and value is not complex. - # Related https://github.com/pydantic/pydantic-settings/pull/25 - kwargs['dest'] = kwargs['dest'][self.env_prefix_len :] if group is not None: if isinstance(group, dict): group = self._add_argument_group(parser, **group) From d2578d1a7402d947149ef8bb559e886c03df32b3 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Tue, 3 Sep 2024 08:58:10 -0600 Subject: [PATCH 09/11] Use get_subcommand in docs. --- docs/index.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/index.md b/docs/index.md index 134ad9e..82c39bf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -837,6 +837,7 @@ from pydantic_settings import ( BaseSettings, CliPositionalArg, CliSubCommand, + get_subcommand, ) @@ -864,16 +865,13 @@ class Root(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): sys.argv = ['example.py', 'Alpha', 'hello'] -print(Root().model_dump()) -#> {'subcommand': {'cmd_alpha': 'hello'}, 'gamma': None} +assert get_subcommand(Root()).model_dump() == {'cmd_alpha': 'hello'} sys.argv = ['example.py', 'Beta', '--opt-beta=hey'] -print(Root().model_dump()) -#> {'subcommand': {'opt_beta': 'hey'}, 'gamma': None} +assert get_subcommand(Root()).model_dump() == {'opt_beta': 'hey'} sys.argv = ['example.py', 'gamma-cmd', '--opt-gamma=hi'] -print(Root().model_dump()) -#> {'subcommand': None, 'gamma': {'opt_gamma': 'hi'}} +assert get_subcommand(Root()).model_dump() == {'opt_gamma': 'hi'} ``` ### Customizing the CLI Experience From 700dbb2a569ce2b897c2ab9cf7256fad6b6e226d Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Tue, 3 Sep 2024 09:06:19 -0600 Subject: [PATCH 10/11] Py 3.8 union fixes. --- docs/index.md | 3 ++- tests/test_settings.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index 82c39bf..55cc204 100644 --- a/docs/index.md +++ b/docs/index.md @@ -830,6 +830,7 @@ positional arguments, aliasing will change the CLI help text displayed for the f ```py import sys +from typing import Union from pydantic import BaseModel, Field @@ -860,7 +861,7 @@ class Gamma(BaseModel): class Root(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): - subcommand: CliSubCommand[Alpha | Beta] = Field(alias='sub-command') + subcommand: CliSubCommand[Union[Alpha, Beta]] = Field(alias='sub-command') gamma: CliSubCommand[Gamma] = Field(alias='gamma-cmd') diff --git a/tests/test_settings.py b/tests/test_settings.py index 20214e1..388d468 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3166,7 +3166,7 @@ class GammaCmd(BaseModel): class Root1(BaseSettings): """Root Help""" - subcommand: CliSubCommand[AlphaCmd | BetaCmd | GammaCmd] = Field(description='Field Help') + subcommand: CliSubCommand[Union[AlphaCmd, BetaCmd, GammaCmd]] = Field(description='Field Help') alpha = Root1(_cli_parse_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} @@ -3226,7 +3226,7 @@ class Root1(BaseSettings): class Root2(BaseSettings): """Root Help""" - subcommand: CliSubCommand[AlphaCmd | GammaCmd] = Field(description='Field Help') + subcommand: CliSubCommand[Union[AlphaCmd, GammaCmd]] = Field(description='Field Help') beta: CliSubCommand[BetaCmd] = Field(description='Field Beta Help') alpha = Root2(_cli_parse_args=['AlphaCmd', '-a=alpha']) @@ -3288,7 +3288,7 @@ class Root3(BaseSettings): """Root Help""" beta: CliSubCommand[BetaCmd] = Field(description='Field Beta Help') - subcommand: CliSubCommand[AlphaCmd | GammaCmd] = Field(description='Field Help') + subcommand: CliSubCommand[Union[AlphaCmd, GammaCmd]] = Field(description='Field Help') alpha = Root3(_cli_parse_args=['AlphaCmd', '-a=alpha']) assert get_subcommand(alpha).model_dump() == {'a': 'alpha'} From 3b7c7f7f7d37b92f593ce9e2a7ecd2a1fc869968 Mon Sep 17 00:00:00 2001 From: Kyle Schwab Date: Sun, 8 Sep 2024 08:18:47 -0600 Subject: [PATCH 11/11] Nits. --- docs/index.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/index.md b/docs/index.md index 55cc204..bcdc7d1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -855,13 +855,13 @@ class Beta(BaseModel): class Gamma(BaseModel): - """Beta Help""" + """Gamma Help""" opt_gamma: str = Field(alias='opt-gamma') class Root(BaseSettings, cli_parse_args=True, cli_exit_on_error=False): - subcommand: CliSubCommand[Union[Alpha, Beta]] = Field(alias='sub-command') + alpha_or_beta: CliSubCommand[Union[Alpha, Beta]] = Field(alias='alpha-or-beta-cmd') gamma: CliSubCommand[Gamma] = Field(alias='gamma-cmd') @@ -922,7 +922,8 @@ when necessary. For `python < 3.9` the `--no-flag` option is not generated due to an underlying `argparse` limitation. !!! note - For `python < 3.9` the `CliImplicitFlag` and `CliExplicitFlag` annotations can only be applied to optional bool fields. + For `python < 3.9` the `CliImplicitFlag` and `CliExplicitFlag` annotations can only be applied to optional boolean + fields. ```py from pydantic_settings import BaseSettings, CliExplicitFlag, CliImplicitFlag