Repository: pydantic/pydantic-settings

Issue:
CLI: creating an optional nested model with all default values
I've been trying (and failing) to find a way to allow nested models with default values for every field to be optional by default or signal creation of a default instance.  For example, given:

```python
class Nested(BaseModel):
    foo: int = 1
    bar: int = 2

class Options(BaseSettings):
    nested: Optional[Nested] = None
```

I'd like to enable three invocations:
```bash
$ options   # nested is None, app ignores it

$ options --nested   # create a fully default nested

$ options --nested.foo 3   # create modified nested
```

It appears the best I can do is require someone pass at least one default value directly or allow json and pass an empty dict:

```bash
$ options --nested.foo 1   # same as default, works but redundant, error prone

$ options --nested {}   # works but not intuitive for users.  can the braces be eliminated?
```

Is there a way to do this I'm overlooking or could it be a possible enhancement? 
Thanks.

Hints from issue comments:
@hramezani: @kschwab could you please take a look?@kschwab: @rredburn this is a nice idea:

> $ options --nested   # create a fully default nested

Will take a look and see how difficult it is to add. I also suspect we might have to watch out for backwards compatibility.@kschwab: @hramezani @rredburn opened #581 for resolution.

Solution patch:
diff --git a/pydantic_settings/main.py b/pydantic_settings/main.py
index 8a6e2f2..d233849 100644
--- a/pydantic_settings/main.py
+++ b/pydantic_settings/main.py
@@ -559,6 +559,7 @@ class CliApp:
         if not issubclass(model_cls, BaseSettings):
 
             class CliAppBaseSettings(BaseSettings, model_cls):  # type: ignore
+                __doc__ = model_cls.__doc__
                 model_config = SettingsConfigDict(
                     nested_model_default_partial_update=True,
                     case_sensitive=True,
diff --git a/pydantic_settings/sources/providers/cli.py b/pydantic_settings/sources/providers/cli.py
index 775a7ce..26d17e4 100644
--- a/pydantic_settings/sources/providers/cli.py
+++ b/pydantic_settings/sources/providers/cli.py
@@ -908,7 +908,9 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
         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'
+            kwargs['nargs'] = '?'
+            kwargs['const'] = '{}'
+            kwargs['help'] = f'set {arg_names[0]} from JSON string (default: {{}})'
             model_group = self._add_group(parser, **model_group_kwargs)
             self._add_argument(model_group, *(f'{flag_prefix}{name}' for name in arg_names), **kwargs)
         for model in sub_models:


Test patch:
diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py
index 681e974..8a50091 100644
--- a/tests/test_source_cli.py
+++ b/tests/test_source_cli.py
@@ -499,14 +499,14 @@ def test_cli_help_union_of_models(capsys, monkeypatch):
             Car()
         assert (
             capsys.readouterr().out
-            == f"""usage: example.py [-h] [--driver JSON] [--driver.meow str] [--driver.bark str]
-                  [--driver.caww str] [--driver.tweet str]
+            == f"""usage: example.py [-h] [--driver [JSON]] [--driver.meow str]
+                  [--driver.bark str] [--driver.caww str] [--driver.tweet str]
 
 {ARGPARSE_OPTIONS_TEXT}:
   -h, --help          show this help message and exit
 
 driver options:
-  --driver JSON       set driver from JSON string
+  --driver [JSON]     set driver from JSON string (default: {{}})
   --driver.meow str   (default: purr)
   --driver.bark str   (default: bark)
   --driver.caww str   (default: caww)
@@ -540,17 +540,17 @@ def test_cli_help_default_or_none_model(capsys, monkeypatch):
             Settings()
         assert (
             capsys.readouterr().out
-            == f"""usage: example.py [-h] [--flag bool] [--sub_model JSON]
-                  [--sub_model.flag bool] [--sub_model.deep JSON]
+            == f"""usage: example.py [-h] [--flag bool] [--sub_model [JSON]]
+                  [--sub_model.flag bool] [--sub_model.deep [JSON]]
                   [--sub_model.deep.flag bool]
-                  [--sub_model.deep.deeper {{JSON,null}}]
+                  [--sub_model.deep.deeper [{{JSON,null}}]]
                   [--sub_model.deep.deeper.flag bool]
-                  [--opt_model {{JSON,null}}] [--opt_model.flag bool]
-                  [--opt_model.deeper {{JSON,null}}]
-                  [--opt_model.deeper.flag bool] [--fact_model JSON]
-                  [--fact_model.flag bool] [--fact_model.deep JSON]
+                  [--opt_model [{{JSON,null}}]] [--opt_model.flag bool]
+                  [--opt_model.deeper [{{JSON,null}}]]
+                  [--opt_model.deeper.flag bool] [--fact_model [JSON]]
+                  [--fact_model.flag bool] [--fact_model.deep [JSON]]
                   [--fact_model.deep.flag bool]
-                  [--fact_model.deep.deeper {{JSON,null}}]
+                  [--fact_model.deep.deeper [{{JSON,null}}]]
                   [--fact_model.deep.deeper.flag bool]
 
 {ARGPARSE_OPTIONS_TEXT}:
@@ -558,21 +558,22 @@ def test_cli_help_default_or_none_model(capsys, monkeypatch):
   --flag bool           (default: True)
 
 sub_model options:
-  --sub_model JSON      set sub_model from JSON string
+  --sub_model [JSON]    set sub_model from JSON string (default: {{}})
   --sub_model.flag bool
                         (default: False)
 
 sub_model.deep options:
-  --sub_model.deep JSON
-                        set sub_model.deep from JSON string
+  --sub_model.deep [JSON]
+                        set sub_model.deep from JSON string (default: {{}})
   --sub_model.deep.flag bool
                         (default: True)
 
 sub_model.deep.deeper options:
   default: null (undefined)
 
-  --sub_model.deep.deeper {{JSON,null}}
-                        set sub_model.deep.deeper from JSON string
+  --sub_model.deep.deeper [{{JSON,null}}]
+                        set sub_model.deep.deeper from JSON string (default:
+                        {{}})
   --sub_model.deep.deeper.flag bool
                         (ifdef: required)
 
@@ -580,33 +581,34 @@ opt_model options:
   default: null (undefined)
   Group Doc
 
-  --opt_model {{JSON,null}}
-                        set opt_model from JSON string
+  --opt_model [{{JSON,null}}]
+                        set opt_model from JSON string (default: {{}})
   --opt_model.flag bool
                         (ifdef: required)
 
 opt_model.deeper options:
   default: null (undefined)
 
-  --opt_model.deeper {{JSON,null}}
-                        set opt_model.deeper from JSON string
+  --opt_model.deeper [{{JSON,null}}]
+                        set opt_model.deeper from JSON string (default: {{}})
   --opt_model.deeper.flag bool
                         (ifdef: required)
 
 fact_model options:
-  --fact_model JSON     set fact_model from JSON string
+  --fact_model [JSON]   set fact_model from JSON string (default: {{}})
   --fact_model.flag bool
                         (default factory: <lambda>)
 
 fact_model.deep options:
-  --fact_model.deep JSON
-                        set fact_model.deep from JSON string
+  --fact_model.deep [JSON]
+                        set fact_model.deep from JSON string (default: {{}})
   --fact_model.deep.flag bool
                         (default factory: <lambda>)
 
 fact_model.deep.deeper options:
-  --fact_model.deep.deeper {{JSON,null}}
-                        set fact_model.deep.deeper from JSON string
+  --fact_model.deep.deeper [{{JSON,null}}]
+                        set fact_model.deep.deeper from JSON string (default:
+                        {{}})
   --fact_model.deep.deeper.flag bool
                         (default factory: <lambda>)
 """
@@ -1529,13 +1531,13 @@ def test_cli_avoid_json(capsys, monkeypatch):
 
         assert (
             capsys.readouterr().out
-            == f"""usage: example.py [-h] [--sub_model JSON] [--sub_model.v1 int]
+            == f"""usage: example.py [-h] [--sub_model [JSON]] [--sub_model.v1 int]
 
 {ARGPARSE_OPTIONS_TEXT}:
   -h, --help          show this help message and exit
 
 sub_model options:
-  --sub_model JSON    set sub_model from JSON string
+  --sub_model [JSON]  set sub_model from JSON string (default: {{}})
   --sub_model.v1 int  (required)
 """
         )
@@ -1573,13 +1575,13 @@ def test_cli_remove_empty_groups(capsys, monkeypatch):
 
         assert (
             capsys.readouterr().out
-            == f"""usage: example.py [-h] [--sub_model JSON]
+            == f"""usage: example.py [-h] [--sub_model [JSON]]
 
 {ARGPARSE_OPTIONS_TEXT}:
-  -h, --help        show this help message and exit
+  -h, --help          show this help message and exit
 
 sub_model options:
-  --sub_model JSON  set sub_model from JSON string
+  --sub_model [JSON]  set sub_model from JSON string (default: {{}})
 """
         )
 
@@ -1653,7 +1655,7 @@ def test_cli_use_class_docs_for_groups(capsys, monkeypatch):
 
         assert (
             capsys.readouterr().out
-            == f"""usage: example.py [-h] [--sub_model JSON] [--sub_model.v1 int]
+            == f"""usage: example.py [-h] [--sub_model [JSON]] [--sub_model.v1 int]
 
 My application help text.
 
@@ -1663,7 +1665,7 @@ My application help text.
 sub_model options:
   The help text from the field description
 
-  --sub_model JSON    set sub_model from JSON string
+  --sub_model [JSON]  set sub_model from JSON string (default: {{}})
   --sub_model.v1 int  (required)
 """
         )
@@ -1673,7 +1675,7 @@ sub_model options:
 
         assert (
             capsys.readouterr().out
-            == f"""usage: example.py [-h] [--sub_model JSON] [--sub_model.v1 int]
+            == f"""usage: example.py [-h] [--sub_model [JSON]] [--sub_model.v1 int]
 
 My application help text.
 
@@ -1683,7 +1685,7 @@ My application help text.
 sub_model options:
   The help text from the class docstring
 
-  --sub_model JSON    set sub_model from JSON string
+  --sub_model [JSON]  set sub_model from JSON string (default: {{}})
   --sub_model.v1 int  (required)
 """
         )
@@ -2411,3 +2413,17 @@ positional arguments:
   --deep-arg str  (required)
 """
         )
+
+
+def test_cli_json_optional_default():
+    class Nested(BaseModel):
+        foo: int = 1
+        bar: int = 2
+
+    class Options(BaseSettings):
+        nested: Nested = Nested(foo=3, bar=4)
+
+    assert CliApp.run(Options, cli_args=[]).model_dump() == {'nested': {'foo': 3, 'bar': 4}}
+    assert CliApp.run(Options, cli_args=['--nested']).model_dump() == {'nested': {'foo': 1, 'bar': 2}}
+    assert CliApp.run(Options, cli_args=['--nested={}']).model_dump() == {'nested': {'foo': 1, 'bar': 2}}
+    assert CliApp.run(Options, cli_args=['--nested.foo=5']).model_dump() == {'nested': {'foo': 5, 'bar': 2}}