Skip to content

Commit 7ede0ec

Browse files
authored
Nested pydantic dataclasses and doc fixes. (#317)
1 parent c2d44a7 commit 7ede0ec

File tree

3 files changed

+99
-73
lines changed

3 files changed

+99
-73
lines changed

docs/index.md

Lines changed: 75 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -473,13 +473,13 @@ models. There are two primary use cases for Pydantic settings CLI:
473473
2. When using Pydantic models to define CLIs.
474474

475475
By default, the experience is tailored towards use case #1 and builds on the foundations established in [parsing
476-
environment variables](#parsing-environment-variables). If your use case primarily falls into #2, you will likely want
477-
to enable [enforcing required arguments at the CLI](#enforce-required-arguments-at-cli).
476+
environment variables](#parsing-environment-variable-values). If your use case primarily falls into #2, you will likely
477+
want to enable [enforcing required arguments at the CLI](#enforce-required-arguments-at-cli).
478478

479479
### The Basics
480480

481-
To get started, let's revisit the example presented in [parsing environment variables](#parsing-environment-variables)
482-
but using a Pydantic settings CLI:
481+
To get started, let's revisit the example presented in [parsing environment
482+
variables](#parsing-environment-variable-values) but using a Pydantic settings CLI:
483483

484484
```py
485485
import sys
@@ -528,16 +528,16 @@ print(Settings().model_dump())
528528
To enable CLI parsing, we simply set the `cli_parse_args` flag to a valid value, which retains similar conotations as
529529
defined in `argparse`. Alternatively, we can also directly provided the args to parse at time of instantiation:
530530

531-
```py test="skip" lint="skip"
532-
Settings(
533-
_cli_parse_args=[
534-
'--v0=0',
535-
'--sub_model={"v1": "json-1", "v2": "json-2"}',
536-
'--sub_model.v2=nested-2',
537-
'--sub_model.v3=3',
538-
'--sub_model.deep.v4=v4',
539-
]
540-
)
531+
```py
532+
from pydantic_settings import BaseSettings
533+
534+
535+
class Settings(BaseSettings):
536+
this_foo: str
537+
538+
539+
print(Settings(_cli_parse_args=['--this_foo', 'is such a foo']).model_dump())
540+
#> {'this_foo': 'is such a foo'}
541541
```
542542

543543
Note that a CLI settings source is [**the topmost source**](#field-value-priority) by default unless its [priority value
@@ -705,7 +705,7 @@ print(User().model_dump())
705705

706706
Subcommands and positional arguments are expressed using the `CliSubCommand` and `CliPositionalArg` annotations. These
707707
annotations can only be applied to required fields (i.e. fields that do not have a default value). Furthermore,
708-
subcommands must be a valid type derived from the pydantic `BaseModel` class.
708+
subcommands must be a valid type derived from either a pydantic `BaseModel` or pydantic.dataclasses `dataclass`.
709709

710710
!!! note
711711
CLI settings subcommands are limited to a single subparser per model. In other words, all subcommands for a model
@@ -720,6 +720,7 @@ subcommands must be a valid type derived from the pydantic `BaseModel` class.
720720
import sys
721721

722722
from pydantic import BaseModel, Field
723+
from pydantic.dataclasses import dataclass
723724

724725
from pydantic_settings import (
725726
BaseSettings,
@@ -728,51 +729,45 @@ from pydantic_settings import (
728729
)
729730

730731

731-
class FooPlugin(BaseModel):
732+
@dataclass
733+
class FooPlugin:
732734
"""git-plugins-foo - Extra deep foo plugin command"""
733735

734-
my_feature: bool = Field(
735-
default=False, description='Enable my feature on foo plugin'
736-
)
736+
x_feature: bool = Field(default=False, description='Enable "X" feature')
737737

738738

739-
class BarPlugin(BaseModel):
739+
@dataclass
740+
class BarPlugin:
740741
"""git-plugins-bar - Extra deep bar plugin command"""
741742

742-
my_feature: bool = Field(
743-
default=False, description='Enable my feature on bar plugin'
744-
)
743+
y_feature: bool = Field(default=False, description='Enable "Y" feature')
745744

746745

747-
class Plugins(BaseModel):
746+
@dataclass
747+
class Plugins:
748748
"""git-plugins - Fake plugins for GIT"""
749749

750750
foo: CliSubCommand[FooPlugin] = Field(description='Foo is fake plugin')
751751

752-
bar: CliSubCommand[BarPlugin] = Field(description='Bar is also a fake plugin')
752+
bar: CliSubCommand[BarPlugin] = Field(description='Bar is fake plugin')
753753

754754

755755
class Clone(BaseModel):
756756
"""git-clone - Clone a repository into a new directory"""
757757

758-
repository: CliPositionalArg[str] = Field(description='The repository to clone')
758+
repository: CliPositionalArg[str] = Field(description='The repo ...')
759759

760-
directory: CliPositionalArg[str] = Field(description='The directory to clone into')
760+
directory: CliPositionalArg[str] = Field(description='The dir ...')
761761

762-
local: bool = Field(
763-
default=False,
764-
description='When the resposity to clone from is on a local machine, bypass ...',
765-
)
762+
local: bool = Field(default=False, description='When the repo ...')
766763

767764

768765
class Git(BaseSettings, cli_parse_args=True, cli_prog_name='git'):
769766
"""git - The stupid content tracker"""
770767

771-
clone: CliSubCommand[Clone] = Field(
772-
description='Clone a repository into a new directory'
773-
)
768+
clone: CliSubCommand[Clone] = Field(description='Clone a repo ...')
774769

775-
plugins: CliSubCommand[Plugins] = Field(description='Fake GIT plugin commands')
770+
plugins: CliSubCommand[Plugins] = Field(description='Fake GIT plugins')
776771

777772

778773
try:
@@ -787,12 +782,12 @@ usage: git [-h] {clone,plugins} ...
787782
git - The stupid content tracker
788783
789784
options:
790-
-h, --help show this help message and exit
785+
-h, --help show this help message and exit
791786
792787
subcommands:
793788
{clone,plugins}
794-
clone Clone a repository into a new directory
795-
plugins Fake GIT plugin commands
789+
clone Clone a repo ...
790+
plugins Fake GIT plugins
796791
"""
797792

798793

@@ -808,12 +803,12 @@ usage: git clone [-h] [--local bool] [--shared bool] REPOSITORY DIRECTORY
808803
git-clone - Clone a repository into a new directory
809804
810805
positional arguments:
811-
REPOSITORY The repository to clone
812-
DIRECTORY The directory to clone into
806+
REPOSITORY The repo ...
807+
DIRECTORY The dir ...
813808
814809
options:
815-
-h, --help show this help message and exit
816-
--local bool When the resposity to clone from is on a local machine, bypass ... (default: False)
810+
-h, --help show this help message and exit
811+
--local bool When the repo ... (default: False)
817812
"""
818813

819814

@@ -829,8 +824,8 @@ usage: git plugins bar [-h] [--my_feature bool]
829824
git-plugins-bar - Extra deep bar plugin command
830825
831826
options:
832-
-h, --help show this help message and exit
833-
--my_feature bool Enable my feature on bar plugin (default: False)
827+
-h, --help show this help message and exit
828+
--y_feature bool Enable "Y" feature (default: False)
834829
"""
835830
```
836831

@@ -843,7 +838,7 @@ The below flags can be used to customise the CLI experience to your needs.
843838
Change the default program name displayed in the help text usage by setting `cli_prog_name`. By default, it will derive
844839
the name of the currently executing program from `sys.argv[0]`, just like argparse.
845840

846-
```py test="skip"
841+
```py
847842
import sys
848843

849844
from pydantic_settings import BaseSettings
@@ -853,8 +848,12 @@ class Settings(BaseSettings, cli_parse_args=True, cli_prog_name='appdantic'):
853848
pass
854849

855850

856-
sys.argv = ['example.py', '--help']
857-
Settings()
851+
try:
852+
sys.argv = ['example.py', '--help']
853+
Settings()
854+
except SystemExit as e:
855+
print(e)
856+
#> 0
858857
"""
859858
usage: appdantic [-h]
860859
@@ -870,7 +869,7 @@ is required is not strictly required from any single source (e.g. the CLI). Inst
870869
sources provides the required value.
871870

872871
However, if your use case [aligns more with #2](#command-line-support), using Pydantic models to define CLIs, you will
873-
likely want required fields to be _strictly required at the CLI_. We can enable this behavior by using the
872+
likely want required fields to be _strictly required at the CLI_. We can enable this behavior by using
874873
`cli_enforce_required`.
875874

876875
```py
@@ -902,7 +901,7 @@ example.py: error: the following arguments are required: --my_required_field
902901

903902
#### Change the None Type Parse String
904903

905-
Change the CLI string value that will be parsed (e.g. "null", "void", "None", etc.) into `None` type(None) by setting
904+
Change the CLI string value that will be parsed (e.g. "null", "void", "None", etc.) into `None` by setting
906905
`cli_parse_none_str`. By default it will use the `env_parse_none_str` value if set. Otherwise, it will default to "null"
907906
if `cli_avoid_json` is `False`, and "None" if `cli_avoid_json` is `True`.
908907

@@ -928,7 +927,7 @@ print(Settings().model_dump())
928927

929928
Hide `None` values from the CLI help text by enabling `cli_hide_none_type`.
930929

931-
```py test="skip"
930+
```py
932931
import sys
933932
from typing import Optional
934933

@@ -941,8 +940,12 @@ class Settings(BaseSettings, cli_parse_args=True, cli_hide_none_type=True):
941940
v0: Optional[str] = Field(description='the top level v0 option')
942941

943942

944-
sys.argv = ['example.py', '--help']
945-
Settings()
943+
try:
944+
sys.argv = ['example.py', '--help']
945+
Settings()
946+
except SystemExit as e:
947+
print(e)
948+
#> 0
946949
"""
947950
usage: example.py [-h] [--v0 str]
948951
@@ -956,7 +959,7 @@ options:
956959

957960
Avoid adding complex fields that result in JSON strings at the CLI by enabling `cli_avoid_json`.
958961

959-
```py test="skip"
962+
```py
960963
import sys
961964

962965
from pydantic import BaseModel, Field
@@ -974,8 +977,12 @@ class Settings(BaseSettings, cli_parse_args=True, cli_avoid_json=True):
974977
)
975978

976979

977-
sys.argv = ['example.py', '--help']
978-
Settings()
980+
try:
981+
sys.argv = ['example.py', '--help']
982+
Settings()
983+
except SystemExit as e:
984+
print(e)
985+
#> 0
979986
"""
980987
usage: example.py [-h] [--sub_model.v1 int]
981988
@@ -998,7 +1005,7 @@ Alternatively, we can also configure CLI settings to pull from the class docstri
9981005
If the field is a union of nested models the group help text will always be pulled from the field description;
9991006
even if `cli_use_class_docs_for_groups` is set to `True`.
10001007

1001-
```py test="skip"
1008+
```py
10021009
import sys
10031010

10041011
from pydantic import BaseModel, Field
@@ -1018,8 +1025,12 @@ class Settings(BaseSettings, cli_parse_args=True, cli_use_class_docs_for_groups=
10181025
sub_model: SubModel = Field(description='The help text from the field description')
10191026

10201027

1021-
sys.argv = ['example.py', '--help']
1022-
Settings()
1028+
try:
1029+
sys.argv = ['example.py', '--help']
1030+
Settings()
1031+
except SystemExit as e:
1032+
print(e)
1033+
#> 0
10231034
"""
10241035
usage: example.py [-h] [--sub_model JSON] [--sub_model.v1 int]
10251036
@@ -1075,12 +1086,12 @@ command line arguments. The `CliSettingsSource` internal parser representation i
10751086
therefore, requires parser methods that support the same attributes as their `argparse` counterparts. The available
10761087
parser methods that can be customised, along with their argparse counterparts (the defaults), are listed below:
10771088

1078-
* `parse_args_method` - argparse.ArgumentParser.parse_args
1079-
* `add_argument_method` - argparse.ArgumentParser.add_argument
1080-
* `add_argument_group_method` - argparse.ArgumentParser.add\_argument_group
1081-
* `add_parser_method` - argparse.\_SubParsersAction.add_parser
1082-
* `add_subparsers_method` - argparse.ArgumentParser.add_subparsers
1083-
* `formatter_class` - argparse.HelpFormatter
1089+
* `parse_args_method` - (`argparse.ArgumentParser.parse_args`)
1090+
* `add_argument_method` - (`argparse.ArgumentParser.add_argument`)
1091+
* `add_argument_group_method` - (`argparse.ArgumentParser.add_argument_group`)
1092+
* `add_parser_method` - (`argparse._SubParsersAction.add_parser`)
1093+
* `add_subparsers_method` - (`argparse.ArgumentParser.add_subparsers`)
1094+
* `formatter_class` - (`argparse.HelpFormatter`)
10841095

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

pydantic_settings/sources.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -640,13 +640,18 @@ class Cfg(BaseSettings):
640640
type_has_key = EnvSettingsSource.next_field(type_, key, case_sensitive)
641641
if type_has_key:
642642
return type_has_key
643-
elif is_model_class(annotation):
643+
elif is_model_class(annotation) or is_pydantic_dataclass(annotation):
644+
fields = (
645+
annotation.__pydantic_fields__
646+
if is_pydantic_dataclass(annotation)
647+
else cast(BaseModel, annotation).model_fields
648+
)
644649
# `case_sensitive is None` is here to be compatible with the old behavior.
645650
# Has to be removed in V3.
646-
if (case_sensitive is None or case_sensitive) and annotation.model_fields.get(key):
647-
return annotation.model_fields[key]
651+
if (case_sensitive is None or case_sensitive) and fields.get(key):
652+
return fields[key]
648653
elif not case_sensitive:
649-
for field_name, f in annotation.model_fields.items():
654+
for field_name, f in fields.items():
650655
if field_name.lower() == key.lower():
651656
return f
652657

@@ -1205,7 +1210,7 @@ def _sort_arg_fields(self, model: type[BaseModel]) -> list[tuple[str, FieldInfo]
12051210
field_types = [type_ for type_ in get_args(field_info.annotation) if type_ is not type(None)]
12061211
if len(field_types) != 1:
12071212
raise SettingsError(f'subcommand argument {model.__name__}.{field_name} has multiple types')
1208-
elif not is_model_class(field_types[0]):
1213+
elif not (is_model_class(field_types[0]) or is_pydantic_dataclass(field_types[0])):
12091214
raise SettingsError(
12101215
f'subcommand argument {model.__name__}.{field_name} is not derived from BaseModel'
12111216
)

tests/test_settings.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -668,15 +668,22 @@ class Settings(BaseSettings):
668668

669669

670670
def test_nested_dataclass(env):
671+
@pydantic_dataclasses.dataclass
672+
class DeepNestedDataclass:
673+
boo: int
674+
rar: str
675+
671676
@pydantic_dataclasses.dataclass
672677
class MyDataclass:
673678
foo: int
674679
bar: str
680+
deep: DeepNestedDataclass
675681

676-
class Settings(BaseSettings):
682+
class Settings(BaseSettings, env_nested_delimiter='__'):
677683
n: MyDataclass
678684

679685
env.set('N', '{"foo": 123, "bar": "bar value"}')
686+
env.set('N__DEEP', '{"boo": 1, "rar": "eek"}')
680687
s = Settings()
681688
assert isinstance(s.n, MyDataclass)
682689
assert s.n.foo == 123
@@ -2717,13 +2724,16 @@ class Cfg(BaseSettings):
27172724

27182725

27192726
def test_cli_subcommand_with_positionals():
2720-
class FooPlugin(BaseModel):
2727+
@pydantic_dataclasses.dataclass
2728+
class FooPlugin:
27212729
my_feature: bool = False
27222730

2723-
class BarPlugin(BaseModel):
2731+
@pydantic_dataclasses.dataclass
2732+
class BarPlugin:
27242733
my_feature: bool = False
27252734

2726-
class Plugins(BaseModel):
2735+
@pydantic_dataclasses.dataclass
2736+
class Plugins:
27272737
foo: CliSubCommand[FooPlugin]
27282738
bar: CliSubCommand[BarPlugin]
27292739

0 commit comments

Comments
 (0)