Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CLI Settings Source #214

Merged
merged 65 commits into from
Jun 3, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
0aedfa2
Initial commit.
kschwab Jan 18, 2024
9d22a39
Draft complete. Needs testing.
kschwab Jan 19, 2024
2ef8473
Subcommand union discrimination not strong enough.
kschwab Jan 20, 2024
0ce72ac
Updated subcommands.
kschwab Jan 21, 2024
ce2c425
Initial tests.
kschwab Jan 21, 2024
5795c7c
Remove use_attribute_docstrings.
kschwab Jan 22, 2024
3215797
Various updates.
kschwab Jan 23, 2024
6085008
Merge branch 'main' into support_cli_source
kschwab Jan 27, 2024
dd5bf6e
Docs with various fixes and updates.
kschwab Jan 28, 2024
abc1095
Use Union.
kschwab Jan 28, 2024
fcc4d2d
Test and doc updates.
kschwab Jan 29, 2024
783d1c9
Python 3.8 and 3.9 argparse help text fixes.
kschwab Jan 29, 2024
ff5b7ed
More Python 3.8 and 3.9 test fixes.
kschwab Jan 29, 2024
7ea4c97
More Python 3.8 and 3.9 fixes.
kschwab Jan 29, 2024
d55d699
Python 3.8 dict union fix.
kschwab Jan 29, 2024
cb3b250
Mypy lint fix?
kschwab Jan 29, 2024
d1692a3
Add test case for nested dictionaries.
kschwab Jan 30, 2024
b107d91
Additional test cases.
kschwab Jan 30, 2024
7e7713e
Python 3.8 and 3.9 format fixes.
kschwab Jan 30, 2024
ca39690
Fix for typing vs typing_extensions Literal.
kschwab Jan 30, 2024
09bdce2
Add test case for typing vs typing_extensions Literal.
kschwab Jan 30, 2024
20a83f1
Mypy fix for _metavar_format function update.
kschwab Jan 31, 2024
f4bf3ee
Update pydantic_settings/sources.py
hramezani Jan 31, 2024
3a4949c
Handle Representation from pydantic._internal and pydantic.v1.
kschwab Jan 31, 2024
0483380
Fix for _cli_parse_args to cli_parse_args.
kschwab Jan 31, 2024
ff018ce
Complex test cases and fixes for env parse none str.
kschwab Jan 31, 2024
a8d15b1
Remove empty groups from parsing and help text.
kschwab Feb 6, 2024
90403ee
Lint fix.
kschwab Feb 6, 2024
61a4745
Lint and formatting.
kschwab Feb 6, 2024
c38ed6a
Lint again.
kschwab Feb 6, 2024
cb9c1c3
Enum support and strip annotations.
kschwab Feb 7, 2024
a984f32
Update pydantic_settings/main.py
kschwab Feb 17, 2024
8470930
Initial updates for external parser support.
kschwab Mar 4, 2024
0eaba11
Doc updates.
kschwab Mar 4, 2024
336108f
Add tuple type.
kschwab Mar 4, 2024
ae6fa73
Doc and test prep for literals and enums.
kschwab Mar 5, 2024
128f94e
Merge branch 'main' into support_cli_source
kschwab Mar 12, 2024
9679c10
Enable CLI enum support.
kschwab Mar 12, 2024
197114d
Exception validation and skip doc tests using --help.
kschwab Mar 12, 2024
431bcb1
Python 3.8 fix.
kschwab Mar 12, 2024
0eeee79
Lint fixes.
kschwab Mar 12, 2024
e04fa93
Lint fixes.
kschwab Mar 12, 2024
7d735ab
Mypy fix.
kschwab Mar 12, 2024
1ce348a
Move integration doc section down.
kschwab Mar 12, 2024
16feca7
Fix unioned dicts and hide_none_type metavar formatting.
kschwab Mar 24, 2024
309f1c6
Test case updates.
kschwab Mar 24, 2024
04c51ca
Add string inference.
kschwab Mar 24, 2024
0263f0d
Merge branch 'main' into support_cli_source
hramezani Mar 28, 2024
385b770
Remove v1 import.
kschwab Mar 29, 2024
4ee2bbd
Docs fix.
kschwab Mar 29, 2024
58a3d8f
Add support for alias fields.
kschwab May 19, 2024
1b41f17
Add support for pydantic dataclasses.
kschwab May 23, 2024
8606f78
Add support for CLISettingsSource prioritization.
kschwab May 23, 2024
488489e
Merge branch 'main' into support_cli_source
kschwab May 23, 2024
155ffe3
Fixes.
kschwab May 23, 2024
5f430b0
Fixes.
kschwab May 23, 2024
53163a8
Lint.
kschwab May 23, 2024
1f62254
Lint.
kschwab May 23, 2024
0016a5e
Lint.
kschwab May 23, 2024
b555ad1
Add support for case-insensitive matching.
kschwab May 24, 2024
3c27f27
Lint.
kschwab May 24, 2024
a3fe71c
Lint.
kschwab May 24, 2024
a1688c7
Add CliSettingsSource prioritization doc.
kschwab May 24, 2024
56146e9
Update help text and formalize cli_parse_none_str.
kschwab Jun 2, 2024
d3f2de2
Add help text for default factory.
kschwab Jun 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 0 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,6 @@ class Settings(BaseSettings):
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
cli_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
Expand Down Expand Up @@ -576,7 +575,6 @@ class Settings(BaseSettings):
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
cli_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
Expand Down Expand Up @@ -659,7 +657,6 @@ class Settings(BaseSettings):
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
cli_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
Expand Down Expand Up @@ -696,7 +693,6 @@ class Settings(BaseSettings):
cls,
settings_cls: Type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
cli_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
Expand Down
19 changes: 15 additions & 4 deletions pydantic_settings/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class SettingsConfigDict(ConfigDict, total=False):
cli_parse_args: bool | list[str] | None
cli_hide_none_type: bool
cli_avoid_json: bool
cli_enforce_required: bool
secrets_dir: str | Path | None


Expand Down Expand Up @@ -70,6 +71,7 @@ class BaseSettings(BaseModel):
If set to `True`, defaults to sys.argv[1:].
_cli_hide_none_type: Hide NoneType values in CLI help text. Defaults to `False`.
kschwab marked this conversation as resolved.
Show resolved Hide resolved
_cli_avoid_json: Avoid complex JSON objects in CLI help text. Defaults to `False`.
_cli_enforce_required: Enforce required fields at the CLI. Defaults to `False`.
_secrets_dir: The secret files directory. Defaults to `None`.
"""

Expand All @@ -86,6 +88,7 @@ def __init__(
_cli_parse_args: bool | list[str] | None = None,
_cli_hide_none_type: bool | None = None,
_cli_avoid_json: bool | None = None,
_cli_enforce_required: bool | None = None,
_secrets_dir: str | Path | None = None,
**values: Any,
) -> None:
Expand All @@ -104,6 +107,7 @@ def __init__(
_cli_parse_args=_cli_parse_args,
_cli_hide_none_type=_cli_hide_none_type,
_cli_avoid_json=_cli_avoid_json,
_cli_enforce_required=_cli_enforce_required,
_secrets_dir=_secrets_dir,
)
)
Expand All @@ -113,7 +117,6 @@ def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
cli_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
Expand All @@ -124,15 +127,14 @@ def settings_customise_sources(
Args:
settings_cls: The Settings class.
init_settings: The `InitSettingsSource` instance.
cli_settings: The `CliSettingsSource` instance.
env_settings: The `EnvSettingsSource` instance.
dotenv_settings: The `DotEnvSettingsSource` instance.
file_secret_settings: The `SecretsSettingsSource` instance.

Returns:
A tuple containing the sources and their order for loading the settings values.
"""
return init_settings, cli_settings, env_settings, dotenv_settings, file_secret_settings
return init_settings, env_settings, dotenv_settings, file_secret_settings

def _settings_build_values(
self,
Expand All @@ -148,6 +150,7 @@ def _settings_build_values(
_cli_parse_args: bool | list[str] | None = None,
_cli_hide_none_type: bool | None = None,
_cli_avoid_json: bool | None = None,
_cli_enforce_required: bool | None = None,
_secrets_dir: str | Path | None = None,
) -> dict[str, Any]:
# Determine settings config values
Expand Down Expand Up @@ -175,6 +178,11 @@ def _settings_build_values(
_cli_hide_none_type if _cli_hide_none_type is not None else self.model_config.get('cli_hide_none_type')
)
cli_avoid_json = _cli_avoid_json if _cli_avoid_json is not None else self.model_config.get('cli_avoid_json')
cli_enforce_required = (
_cli_enforce_required
if _cli_enforce_required is not None
else self.model_config.get('cli_enforce_required')
)

secrets_dir = _secrets_dir if _secrets_dir is not None else self.model_config.get('secrets_dir')

Expand All @@ -187,6 +195,7 @@ def _settings_build_values(
cli_parse_none_str=env_parse_none_str,
cli_hide_none_type=cli_hide_none_type,
cli_avoid_json=cli_avoid_json,
cli_enforce_required=cli_enforce_required,
)
env_settings = EnvSettingsSource(
self.__class__,
Expand Down Expand Up @@ -214,11 +223,12 @@ def _settings_build_values(
sources = self.settings_customise_sources(
self.__class__,
init_settings=init_settings,
cli_settings=cli_settings,
env_settings=env_settings,
dotenv_settings=dotenv_settings,
file_secret_settings=file_secret_settings,
)
if _cli_parse_args:
sources = (cli_settings,) + sources
kschwab marked this conversation as resolved.
Show resolved Hide resolved
if sources:
return deep_update(*reversed([source() for source in sources]))
else:
Expand All @@ -241,6 +251,7 @@ def _settings_build_values(
cli_parse_args=None,
cli_hide_none_type=False,
cli_avoid_json=False,
cli_enforce_required=False,
secrets_dir=None,
protected_namespaces=('model_', 'settings_'),
)
76 changes: 51 additions & 25 deletions pydantic_settings/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from abc import ABC, abstractmethod
from collections import deque
from dataclasses import is_dataclass
from inspect import isclass
from pathlib import Path
from types import FunctionType
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Mapping, Sequence, Tuple, TypeVar, Union, cast
Expand All @@ -16,7 +15,7 @@
from pydantic import AliasChoices, AliasPath, BaseModel, Json, TypeAdapter
from pydantic._internal._repr import Representation
from pydantic._internal._typing_extra import WithArgsTypes, origin_is_union, typing_base
from pydantic._internal._utils import deep_update, lenient_issubclass
from pydantic._internal._utils import deep_update, is_model_class, lenient_issubclass
from pydantic.fields import FieldInfo
from typing_extensions import Annotated, TypeAliasType, get_args, get_origin

Expand Down Expand Up @@ -515,7 +514,7 @@
return True, allow_parse_failure

@staticmethod
def next_field(field: FieldInfo | None, key: str) -> FieldInfo | None:
def next_field(field: FieldInfo | Any | None, key: str) -> FieldInfo | None:
kschwab marked this conversation as resolved.
Show resolved Hide resolved
"""
Find the field in a sub model by key(env name)

Expand Down Expand Up @@ -544,11 +543,25 @@
Returns:
Field if it finds the next field otherwise `None`.
"""
if not field or origin_is_union(get_origin(field.annotation)):
# no support for Unions of complex BaseSettings fields
if not field:
return None
elif field.annotation and hasattr(field.annotation, 'model_fields') and field.annotation.model_fields.get(key):
return field.annotation.model_fields[key]
if isinstance(field, FieldInfo):
if not hasattr(field, 'annotation'):
return None

Check warning on line 550 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L550

Added line #L550 was not covered by tests
annotation = field.annotation
else:
annotation = field

if origin_is_union(get_origin(annotation)) or isinstance(annotation, WithArgsTypes):
type_ = get_origin(annotation)
if is_model_class(type_) and type_.model_fields.get(key):
return type_.model_fields.get(key)

Check warning on line 558 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L558

Added line #L558 was not covered by tests
for type_ in get_args(annotation):
type_has_key = EnvSettingsSource.next_field(type_, key)
if type_has_key:
return type_has_key
elif is_model_class(annotation) and annotation.model_fields.get(key):
return annotation.model_fields[key]

return None

Expand Down Expand Up @@ -697,20 +710,24 @@
cli_parse_none_str: str | None = None,
cli_hide_none_type: bool | None = None,
cli_avoid_json: bool | None = None,
cli_enforce_required: bool | None = None,
) -> None:
self.cli_prog_name = sys.argv[0] if cli_prog_name is None else cli_prog_name
self.cli_parse_args = cli_parse_args
if self.cli_parse_args not in (None, False):
if self.cli_parse_args is True:
self.cli_parse_args = sys.argv[1:]

Check warning on line 719 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L719

Added line #L719 was not covered by tests
elif not isinstance(self.cli_parse_args, list):
raise SettingsError(f'cli_parse_args must be List[str], recieved {type(self.cli_parse_args)}')

Check warning on line 721 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L721

Added line #L721 was not covered by tests
self.cli_hide_none_type = (
cli_hide_none_type if cli_hide_none_type is not None else self.config.get('cli_hide_none_type', False)
)
self.cli_avoid_json = cli_avoid_json if cli_avoid_json is not None else self.config.get('cli_avoid_json', False)
if cli_parse_none_str is None:
cli_parse_none_str = 'None' if self.cli_avoid_json is True else 'null'
self.cli_enforce_required = (
cli_enforce_required if cli_enforce_required is not None else self.config.get('cli_enforce_required', False)
)
super().__init__(settings_cls, env_nested_delimiter='.', env_parse_none_str=cli_parse_none_str)

def _load_env_vars(self) -> Mapping[str, str | None]:
Expand All @@ -729,25 +746,32 @@
)

parsed_args: dict[str, list[str] | str] = vars(parser.parse_args(self.cli_parse_args)) # type: ignore
if any(key for key in parsed_args.keys() if not key.endswith(':subcommand')):
for field, val in parsed_args.items():
if isinstance(val, list):
merge_list = []
for sub_val in val:
if sub_val.startswith('[') and sub_val.endswith(']'):
sub_val = sub_val[1:-1]
merge_list.append(sub_val)
parsed_args[field] = (
f'[{",".join(merge_list)}]'
if field not in self._cli_dict_arg_names
else self._merge_json_key_val_list_str(f'[{",".join(merge_list)}]')
)
elif field.endswith(':subcommand'):
self._cli_subcommands[field].remove(field.split(':')[0] + val)
selected_subcommands: list[str] = []
for field_name, val in parsed_args.items():
if isinstance(val, list):
merge_list = []
for sub_val in val:
if sub_val.startswith('[') and sub_val.endswith(']'):
sub_val = sub_val[1:-1]
merge_list.append(sub_val)
parsed_args[field_name] = (
f'[{",".join(merge_list)}]'
if field_name not in self._cli_dict_arg_names
else self._merge_json_key_val_list_str(f'[{",".join(merge_list)}]')
)
elif field_name.endswith(':subcommand'):
selected_subcommands.append(field_name.split(':')[0] + val)

for subcommands in self._cli_subcommands.values():
for subcommand in subcommands:
parsed_args[subcommand] = self.env_parse_none_str # type: ignore
if subcommand not in selected_subcommands:
parsed_args[subcommand] = self.env_parse_none_str # type: ignore

parsed_args = {key: val for key, val in parsed_args.items() if not key.endswith(':subcommand')}
if selected_subcommands:
last_selected_subcommand = max(selected_subcommands, key=len)
if not any(field_name for field_name in parsed_args.keys() if f'{last_selected_subcommand}.' in field_name):
parsed_args[last_selected_subcommand] = '{}'

Check warning on line 774 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L774

Added line #L774 was not covered by tests

return parse_env_vars(
parsed_args, self.case_sensitive, self.env_ignore_empty, self.env_parse_none_str # type: ignore
Expand All @@ -759,7 +783,7 @@
obj_count = 0
while key_val_list_str:
if obj_count != 0:
raise SettingsError(f'Parsing error encountered on JSON object {orig_key_val_list_str}')

Check warning on line 786 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L786

Added line #L786 was not covered by tests
for i in range(len(key_val_list_str)):
if key_val_list_str[i] == '{':
obj_count += 1
Expand Down Expand Up @@ -800,7 +824,7 @@
raise SettingsError(
f'CliPositionalArg is not outermost annotation for {model.__name__}.{field_name}'
)
if isclass(type_) and issubclass(type_, BaseModel):
if is_model_class(type_):
sub_models.append(type_)
return sub_models

Expand All @@ -814,7 +838,7 @@
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 (isclass(field_types[0]) and issubclass(field_types[0], BaseModel)):
elif not is_model_class(field_types[0]):
raise SettingsError(
f'subcommand argument {model.__name__}.{field_name} is not derived from BaseModel'
)
Expand Down Expand Up @@ -867,6 +891,7 @@
kwargs['help'] = field_info.description
kwargs['dest'] = f'{arg_prefix}{field_name}'
kwargs['metavar'] = self._format_metavar(field_info.annotation)
kwargs['required'] = self.cli_enforce_required and field_info.is_required()
if kwargs['dest'] in added_args:
continue
if _annotation_contains_types(field_info.annotation, (list, set, dict, Sequence, Mapping)):
Expand All @@ -879,6 +904,7 @@
kwargs['metavar'] = field_name.upper()
arg_name = kwargs['dest']
del kwargs['dest']
del kwargs['required']
arg_flag = ''

if sub_models and kwargs.get('action') != 'append':
Expand Down Expand Up @@ -913,36 +939,36 @@
def _format_metavar(self, obj: Any) -> str:
"""Pretty metavar representation of a type. Adapts logic from `pydantic._repr.display_as_type`."""
if isinstance(obj, FunctionType):
return obj.__name__

Check warning on line 942 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L942

Added line #L942 was not covered by tests
elif obj is ...:
return '...'

Check warning on line 944 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L944

Added line #L944 was not covered by tests
elif isinstance(obj, Representation):
return repr(obj)

Check warning on line 946 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L946

Added line #L946 was not covered by tests
elif isinstance(obj, TypeAliasType):
return str(obj)

Check warning on line 948 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L948

Added line #L948 was not covered by tests

if not isinstance(obj, (typing_base, WithArgsTypes, type)):
obj = obj.__class__

Check warning on line 951 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L951

Added line #L951 was not covered by tests

if origin_is_union(get_origin(obj)):
args = ','.join(map(self._format_metavar, self._get_modified_args(obj)))
return f'{{{args}}}' if ',' in args else args
elif isinstance(obj, WithArgsTypes):
if get_origin(obj) == Literal:
args = ','.join(map(repr, self._get_modified_args(obj)))
return f'{{{args}}}' if ',' in args else args

Check warning on line 959 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L958-L959

Added lines #L958 - L959 were not covered by tests
else:
args = ','.join(map(self._format_metavar, self._get_modified_args(obj)))
try:
return f'{obj.__qualname__}[{args}]'
except AttributeError:
return str(obj) # handles TypeAliasType in 3.12

Check warning on line 965 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L964-L965

Added lines #L964 - L965 were not covered by tests
elif obj is type(None):
return self.env_parse_none_str
elif isinstance(obj, type):
return obj.__qualname__
else:
return repr(obj).replace('typing.', '').replace('typing_extensions.', '')

Check warning on line 971 in pydantic_settings/sources.py

View check run for this annotation

Codecov / codecov/patch

pydantic_settings/sources.py#L971

Added line #L971 was not covered by tests


def _get_env_var_key(key: str, case_sensitive: bool = False) -> str:
Expand Down