Skip to content

Commit b6d115d

Browse files
committed
Merge branch 'main' into default_settings_source
2 parents bcc628e + 5c3a817 commit b6d115d

File tree

8 files changed

+241
-68
lines changed

8 files changed

+241
-68
lines changed

.github/workflows/ci.yml

+8-20
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ jobs:
1313
runs-on: ubuntu-latest
1414

1515
steps:
16-
- uses: actions/checkout@v2
16+
- uses: actions/checkout@v4
1717

1818
- name: set up python
19-
uses: actions/setup-python@v4
19+
uses: actions/setup-python@v5
2020
with:
2121
python-version: '3.10'
2222

@@ -31,20 +31,8 @@ jobs:
3131
strategy:
3232
fail-fast: false
3333
matrix:
34-
os: [ubuntu-latest, macos-13, macos-latest, windows-latest]
34+
os: [ubuntu-latest, macos-latest, windows-latest]
3535
python: ['3.8', '3.9', '3.10', '3.11', '3.12']
36-
exclude:
37-
# Python 3.8 and 3.9 are not available on macOS 14
38-
- os: macos-13
39-
python: '3.10'
40-
- os: macos-13
41-
python: '3.11'
42-
- os: macos-13
43-
python: '3.12'
44-
- os: macos-latest
45-
python: '3.8'
46-
- os: macos-latest
47-
python: '3.9'
4836

4937
env:
5038
PYTHON: ${{ matrix.python }}
@@ -53,10 +41,10 @@ jobs:
5341
runs-on: ${{ matrix.os }}
5442

5543
steps:
56-
- uses: actions/checkout@v2
44+
- uses: actions/checkout@v4
5745

5846
- name: set up python
59-
uses: actions/setup-python@v4
47+
uses: actions/setup-python@v5
6048
with:
6149
python-version: ${{ matrix.python }}
6250

@@ -80,7 +68,7 @@ jobs:
8068
- run: coverage combine
8169
- run: coverage xml
8270

83-
- uses: codecov/codecov-action@v3
71+
- uses: codecov/codecov-action@v4
8472
with:
8573
file: ./coverage.xml
8674
env_vars: PYTHON,OS
@@ -108,10 +96,10 @@ jobs:
10896
id-token: write
10997

11098
steps:
111-
- uses: actions/checkout@v2
99+
- uses: actions/checkout@v4
112100

113101
- name: set up python
114-
uses: actions/setup-python@v4
102+
uses: actions/setup-python@v5
115103
with:
116104
python-version: '3.10'
117105

docs/index.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1120,7 +1120,7 @@ parser methods that can be customised, along with their argparse counterparts (t
11201120
* `add_argument_group_method` - (`argparse.ArgumentParser.add_argument_group`)
11211121
* `add_parser_method` - (`argparse._SubParsersAction.add_parser`)
11221122
* `add_subparsers_method` - (`argparse.ArgumentParser.add_subparsers`)
1123-
* `formatter_class` - (`argparse.HelpFormatter`)
1123+
* `formatter_class` - (`argparse.RawDescriptionHelpFormatter`)
11241124

11251125
For a non-argparse parser the parser methods can be set to `None` if not supported. The CLI settings will only raise an
11261126
error when connecting to the root parser if a parser method is necessary but set to `None`.

pydantic_settings/sources.py

+58-21
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
import typing
99
import warnings
1010
from abc import ABC, abstractmethod
11-
from argparse import SUPPRESS, ArgumentParser, HelpFormatter, Namespace, _SubParsersAction
11+
from argparse import SUPPRESS, ArgumentParser, Namespace, RawDescriptionHelpFormatter, _SubParsersAction
1212
from collections import deque
1313
from dataclasses import asdict, is_dataclass
1414
from enum import Enum
1515
from pathlib import Path
16+
from textwrap import dedent
1617
from types import FunctionType
1718
from typing import (
1819
TYPE_CHECKING,
@@ -424,7 +425,7 @@ class Settings(BaseSettings):
424425
args = get_args(annotation)
425426
if origin_is_union(get_origin(field.annotation)) and len(args) == 2 and type(None) in args:
426427
for arg in args:
427-
if arg != type(None):
428+
if arg is not None:
428429
annotation = arg
429430
break
430431

@@ -748,7 +749,7 @@ class Cfg(BaseSettings):
748749
elif is_model_class(annotation) or is_pydantic_dataclass(annotation):
749750
fields = (
750751
annotation.__pydantic_fields__
751-
if is_pydantic_dataclass(annotation)
752+
if is_pydantic_dataclass(annotation) and hasattr(annotation, '__pydantic_fields__')
752753
else cast(BaseModel, annotation).model_fields
753754
)
754755
# `case_sensitive is None` is here to be compatible with the old behavior.
@@ -812,7 +813,7 @@ def explode_env_vars(self, field_name: str, field: FieldInfo, env_vars: Mapping[
812813
if not allow_json_failure:
813814
raise e
814815
if isinstance(env_var, dict):
815-
if last_key not in env_var or not isinstance(env_val, EnvNoneType) or env_var[last_key] is {}:
816+
if last_key not in env_var or not isinstance(env_val, EnvNoneType) or env_var[last_key] == {}:
816817
env_var[last_key] = env_val
817818

818819
return result
@@ -966,7 +967,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
966967
Defaults to `argparse._SubParsersAction.add_parser`.
967968
add_subparsers_method: The root parser add subparsers (sub-commands) method.
968969
Defaults to `argparse.ArgumentParser.add_subparsers`.
969-
formatter_class: A class for customizing the root parser help text. Defaults to `argparse.HelpFormatter`.
970+
formatter_class: A class for customizing the root parser help text. Defaults to `argparse.RawDescriptionHelpFormatter`.
970971
"""
971972

972973
def __init__(
@@ -988,7 +989,7 @@ def __init__(
988989
add_argument_group_method: Callable[..., Any] | None = ArgumentParser.add_argument_group,
989990
add_parser_method: Callable[..., Any] | None = _SubParsersAction.add_parser,
990991
add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers,
991-
formatter_class: Any = HelpFormatter,
992+
formatter_class: Any = RawDescriptionHelpFormatter,
992993
) -> None:
993994
self.cli_prog_name = (
994995
cli_prog_name if cli_prog_name is not None else settings_cls.model_config.get('cli_prog_name', sys.argv[0])
@@ -1040,7 +1041,10 @@ def __init__(
10401041

10411042
root_parser = (
10421043
_CliInternalArgParser(
1043-
cli_exit_on_error=self.cli_exit_on_error, prog=self.cli_prog_name, description=settings_cls.__doc__
1044+
cli_exit_on_error=self.cli_exit_on_error,
1045+
prog=self.cli_prog_name,
1046+
description=None if settings_cls.__doc__ is None else dedent(settings_cls.__doc__),
1047+
formatter_class=formatter_class,
10441048
)
10451049
if root_parser is None
10461050
else root_parser
@@ -1329,7 +1333,11 @@ def _get_resolved_names(
13291333

13301334
def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]]:
13311335
positional_args, subcommand_args, optional_args = [], [], []
1332-
fields = model.__pydantic_fields__ if is_pydantic_dataclass(model) else model.model_fields
1336+
fields = (
1337+
model.__pydantic_fields__
1338+
if hasattr(model, '__pydantic_fields__') and is_pydantic_dataclass(model)
1339+
else model.model_fields
1340+
)
13331341
for field_name, field_info in fields.items():
13341342
if _CliSubCommand in field_info.metadata:
13351343
if not field_info.is_required():
@@ -1405,7 +1413,7 @@ def _connect_root_parser(
14051413
add_argument_group_method: Callable[..., Any] | None = ArgumentParser.add_argument_group,
14061414
add_parser_method: Callable[..., Any] | None = _SubParsersAction.add_parser,
14071415
add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers,
1408-
formatter_class: Any = HelpFormatter,
1416+
formatter_class: Any = RawDescriptionHelpFormatter,
14091417
) -> None:
14101418
self._root_parser = root_parser
14111419
self._parse_args = self._connect_parser_method(parse_args_method, 'parsed_args_method')
@@ -1424,6 +1432,7 @@ def _connect_root_parser(
14241432
subcommand_prefix=self.env_prefix,
14251433
group=None,
14261434
alias_prefixes=[],
1435+
model_default=PydanticUndefined,
14271436
)
14281437

14291438
def _add_parser_args(
@@ -1435,6 +1444,7 @@ def _add_parser_args(
14351444
subcommand_prefix: str,
14361445
group: Any,
14371446
alias_prefixes: list[str],
1447+
model_default: Any,
14381448
) -> ArgumentParser:
14391449
subparsers: Any = None
14401450
alias_path_args: dict[str, str] = {}
@@ -1459,24 +1469,27 @@ def _add_parser_args(
14591469
field_name,
14601470
help=field_info.description,
14611471
formatter_class=self._formatter_class,
1462-
description=model.__doc__,
1472+
description=None if model.__doc__ is None else dedent(model.__doc__),
14631473
),
14641474
model=model,
14651475
added_args=[],
14661476
arg_prefix=f'{arg_prefix}{field_name}.',
14671477
subcommand_prefix=f'{subcommand_prefix}{field_name}.',
14681478
group=None,
14691479
alias_prefixes=[],
1480+
model_default=PydanticUndefined,
14701481
)
14711482
else:
14721483
resolved_names, is_alias_path_only = self._get_resolved_names(field_name, field_info, alias_path_args)
14731484
arg_flag: str = '--'
14741485
kwargs: dict[str, Any] = {}
14751486
kwargs['default'] = SUPPRESS
1476-
kwargs['help'] = self._help_format(field_info)
1487+
kwargs['help'] = self._help_format(field_name, field_info, model_default)
14771488
kwargs['dest'] = f'{arg_prefix}{resolved_names[0]}'
14781489
kwargs['metavar'] = self._metavar_format(field_info.annotation)
1479-
kwargs['required'] = self.cli_enforce_required and field_info.is_required()
1490+
kwargs['required'] = (
1491+
self.cli_enforce_required and field_info.is_required() and model_default is PydanticUndefined
1492+
)
14801493
if kwargs['dest'] in added_args:
14811494
continue
14821495
if _annotation_contains_types(
@@ -1504,8 +1517,10 @@ def _add_parser_args(
15041517
arg_flag,
15051518
arg_names,
15061519
kwargs,
1520+
field_name,
15071521
field_info,
15081522
resolved_names,
1523+
model_default=model_default,
15091524
)
15101525
elif is_alias_path_only:
15111526
continue
@@ -1544,17 +1559,33 @@ def _add_parser_submodels(
15441559
arg_flag: str,
15451560
arg_names: list[str],
15461561
kwargs: dict[str, Any],
1562+
field_name: str,
15471563
field_info: FieldInfo,
15481564
resolved_names: tuple[str, ...],
1565+
model_default: Any,
15491566
) -> None:
15501567
model_group: Any = None
15511568
model_group_kwargs: dict[str, Any] = {}
15521569
model_group_kwargs['title'] = f'{arg_names[0]} options'
1553-
model_group_kwargs['description'] = (
1554-
sub_models[0].__doc__
1555-
if self.cli_use_class_docs_for_groups and len(sub_models) == 1
1556-
else field_info.description
1557-
)
1570+
model_group_kwargs['description'] = field_info.description
1571+
if self.cli_use_class_docs_for_groups and len(sub_models) == 1:
1572+
model_group_kwargs['description'] = None if sub_models[0].__doc__ is None else dedent(sub_models[0].__doc__)
1573+
1574+
if model_default not in (PydanticUndefined, None):
1575+
if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)):
1576+
model_default = getattr(model_default, field_name)
1577+
else:
1578+
if field_info.default is not PydanticUndefined:
1579+
model_default = field_info.default
1580+
elif field_info.default_factory is not None:
1581+
model_default = field_info.default_factory
1582+
if model_default is None:
1583+
desc_header = f'default: {self.cli_parse_none_str} (undefined)'
1584+
if model_group_kwargs['description'] is not None:
1585+
model_group_kwargs['description'] = dedent(f'{desc_header}\n{model_group_kwargs["description"]}')
1586+
else:
1587+
model_group_kwargs['description'] = desc_header
1588+
15581589
if not self.cli_avoid_json:
15591590
added_args.append(arg_names[0])
15601591
kwargs['help'] = f'set {arg_names[0]} from JSON string'
@@ -1569,6 +1600,7 @@ def _add_parser_submodels(
15691600
subcommand_prefix=subcommand_prefix,
15701601
group=model_group if model_group else model_group_kwargs,
15711602
alias_prefixes=[f'{arg_prefix}{name}.' for name in resolved_names[1:]],
1603+
model_default=model_default,
15721604
)
15731605

15741606
def _add_parser_alias_paths(
@@ -1658,14 +1690,19 @@ def _metavar_format_recurse(self, obj: Any) -> str:
16581690
def _metavar_format(self, obj: Any) -> str:
16591691
return self._metavar_format_recurse(obj).replace(', ', ',')
16601692

1661-
def _help_format(self, field_info: FieldInfo) -> str:
1693+
def _help_format(self, field_name: str, field_info: FieldInfo, model_default: Any) -> str:
16621694
_help = field_info.description if field_info.description else ''
1663-
if field_info.is_required():
1695+
if field_info.is_required() and model_default in (PydanticUndefined, None):
16641696
if _CliPositionalArg not in field_info.metadata:
1665-
_help += ' (required)' if _help else '(required)'
1697+
ifdef = 'ifdef: ' if model_default is None else ''
1698+
_help += f' ({ifdef}required)' if _help else f'({ifdef}required)'
16661699
else:
16671700
default = f'(default: {self.cli_parse_none_str})'
1668-
if field_info.default not in (PydanticUndefined, None):
1701+
if is_model_class(type(model_default)) or is_pydantic_dataclass(type(model_default)):
1702+
default = f'(default: {getattr(model_default, field_name)})'
1703+
elif model_default not in (PydanticUndefined, None) and callable(model_default):
1704+
default = f'(default factory: {self._metavar_format(model_default)})'
1705+
elif field_info.default not in (PydanticUndefined, None):
16691706
default = f'(default: {field_info.default})'
16701707
elif field_info.default_factory is not None:
16711708
default = f'(default: {field_info.default_factory})'

pydantic_settings/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
VERSION = '2.3.4'
1+
VERSION = '2.4.0'

requirements/linting.txt

+6-6
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ click==8.1.7
1212
# via black
1313
distlib==0.3.8
1414
# via virtualenv
15-
filelock==3.15.3
15+
filelock==3.15.4
1616
# via virtualenv
17-
identify==2.5.36
17+
identify==2.6.0
1818
# via pre-commit
19-
mypy==1.10.0
19+
mypy==1.11.1
2020
# via -r requirements/linting.in
2121
mypy-extensions==1.0.0
2222
# via
@@ -40,19 +40,19 @@ pyyaml==6.0.1
4040
# via
4141
# -r requirements/linting.in
4242
# pre-commit
43-
ruff==0.4.10
43+
ruff==0.5.5
4444
# via -r requirements/linting.in
4545
tokenize-rt==5.2.0
4646
# via pyupgrade
4747
tomli==2.0.1
4848
# via
4949
# black
5050
# mypy
51-
types-pyyaml==6.0.12.20240311
51+
types-pyyaml==6.0.12.20240724
5252
# via -r requirements/linting.in
5353
typing-extensions==4.12.2
5454
# via
5555
# black
5656
# mypy
57-
virtualenv==20.26.2
57+
virtualenv==20.26.3
5858
# via pre-commit

requirements/pyproject.txt

+8-10
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ azure-core==1.30.2
1010
# via
1111
# azure-identity
1212
# azure-keyvault-secrets
13-
azure-identity==1.17.0
13+
azure-identity==1.17.1
1414
# via pydantic-settings (pyproject.toml)
1515
azure-keyvault-secrets==4.8.0
1616
# via pydantic-settings (pyproject.toml)
17-
certifi==2024.6.2
17+
certifi==2024.7.4
1818
# via requests
1919
cffi==1.16.0
2020
# via cryptography
2121
charset-normalizer==3.3.2
2222
# via requests
23-
cryptography==42.0.8
23+
cryptography==43.0.0
2424
# via
2525
# azure-identity
2626
# msal
@@ -29,21 +29,19 @@ idna==3.7
2929
# via requests
3030
isodate==0.6.1
3131
# via azure-keyvault-secrets
32-
msal==1.28.1
32+
msal==1.30.0
3333
# via
3434
# azure-identity
3535
# msal-extensions
36-
msal-extensions==1.1.0
36+
msal-extensions==1.2.0
3737
# via azure-identity
38-
packaging==24.1
39-
# via msal-extensions
40-
portalocker==2.8.2
38+
portalocker==2.10.1
4139
# via msal-extensions
4240
pycparser==2.22
4341
# via cffi
44-
pydantic==2.7.4
42+
pydantic==2.8.2
4543
# via pydantic-settings (pyproject.toml)
46-
pydantic-core==2.18.4
44+
pydantic-core==2.20.1
4745
# via pydantic
4846
pyjwt[crypto]==2.8.0
4947
# via

0 commit comments

Comments
 (0)