Repository: pydantic/pydantic-settings

Issue:
Unexpected behaviour in `CliSettingsSource` when `cli_parse_args=True` in `model_config`
Suppose I want to create a simple CLI tool, where the only allowed source is from the command line.
Something along the lines of:

```python
import sys

from pydantic_settings import (
    BaseSettings,
    CliSettingsSource,
    PydanticBaseSettingsSource,
    SettingsConfigDict,
)


class Settings(BaseSettings):
    model_config = SettingsConfigDict(cli_parse_args=True)

    value: int

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        """Set up config priority."""
        return (CliSettingsSource(settings_cls),)


sys.argv = ["example.py", "--value", "3"]

print(Settings().model_dump())
```

which, for me, will output the following:

```bash
pydantic_core._pydantic_core.ValidationError: 1 validation error for Settings
value
  Field required [type=missing, input_value={}, input_type=dict]
    For further information visit https://errors.pydantic.dev/2.11/v/missing
```

whereas I would expect `{'value': 3}`.

I understand that in this particular case, I can easily fix this by using `CliSettingsSource(settings_cls, cli_parse_args=True)` instead of using a `model_config`.
However, this requirement is not consistent with other CLI-related config variables (e.g. `cli_prog_name` or `cli_kebab_case`), which will be read from `settings_cls.model_config` if not provided in the `CliSettingsSource` constructor.

My suspicion is that this is due to the check [here](https://github.com/pydantic/pydantic-settings/blob/main/pydantic_settings/sources/providers/cli.py#L251), where both `None` and `False` are used to represent "do not parse command line arguments".
This can very well be the intended behaviour, and there's probably some use case I'm not being properly aware of. In that case, feel free to say so, and I'll happily close this issue. Otherwise, I'm also more than happy to contribute and submit a PR if you want.

Hints from issue comments:
@hramezani: @kschwab Do you have time to take a look?@kschwab: @trygve-baerland @hramezani looking closer at the example, the reason the above was not working is `CliSettingsSource` requires the `cli_parse_args` to be set explicitly. i.e, refer to the example in [the basics](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#the-basics) about setting priority. 

Whereas other configurations get pulled implicitly from `settings_cls`, `cli_parse_args` is unique in that instructs the `CliSettingsSource` to parse the cli args during instantiation if `True`. When integrating with other parsers, this may not be desired.

For the case above, it should just be:
```python
        """Set up config priority."""
        return (CliSettingsSource(settings_cls, cli_parse_args=True),)
```

I am suggesting the PR be reverted as it breaks backwards compatibility.@hramezani: Here is the revert PR https://github.com/pydantic/pydantic-settings/pull/655@trygve-baerland: Great work with the investigation @kschwab ! That wasn't a case I was thinking of when writing up this issue, but it makes sense, so thanks for adding the comment here :)@trygve-baerland: I agree with what you're saying in #654, that #611 causes the actual parsing of command line arguments to happen in `CliSettingsSource.__init__` when `cli_parse_args=True`, but could a different approach be to not have the call to `_load_env_var` in the `__init__` method? (https://github.com/pydantic/pydantic-settings/blob/main/pydantic_settings/sources/providers/cli.py#L269)
Actually, it seems like `CliSettingsSource(..., cli_parse_args=True)` eagerly parses the command line arguments in earlier versions as well. What's the reason for this, Instead of doing the parsing during the settings collection (Which I'm guessing is the `BaseSettings.__call__` method)?

Having tested a little bit, it seems to be a little more involved than just removing that line, but I still think the overall point I made in #610 is reasonable: If `cli_parse_args` is set in the `model_config`, and not in the constructor call to `CliSettingsSource`, the value from the `model_config` should be used, similar to other cli-related arguments.

In summary, I think reverting #611 is a good idea, to maintain backwards compatability, but I would like to have a look at other solutions to this "issue", if that's OK? :)

PS: I realize I may be on some thin ice here, as I'm not too familiar with the code base yet, so please correct where I'm making some bad assumptions.

EDIT: I created a PR as an alternative to solution to reverting #611 . Cf. #656 

Solution patch:
diff --git a/pydantic_settings/sources/providers/cli.py b/pydantic_settings/sources/providers/cli.py
index f043978..a2d6f83 100644
--- a/pydantic_settings/sources/providers/cli.py
+++ b/pydantic_settings/sources/providers/cli.py
@@ -162,6 +162,9 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
         self.cli_prog_name = (
             cli_prog_name if cli_prog_name is not None else settings_cls.model_config.get('cli_prog_name', sys.argv[0])
         )
+        self.cli_parse_args = (
+            cli_parse_args if cli_parse_args is not None else settings_cls.model_config.get('cli_parse_args', None)
+        )
         self.cli_hide_none_type = (
             cli_hide_none_type
             if cli_hide_none_type is not None
@@ -247,15 +250,14 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
             add_subparsers_method=add_subparsers_method,
             formatter_class=formatter_class,
         )
-
-        if cli_parse_args not in (None, False):
-            if cli_parse_args is True:
-                cli_parse_args = sys.argv[1:]
-            elif not isinstance(cli_parse_args, (list, tuple)):
+        if self.cli_parse_args not in (None, False):
+            if self.cli_parse_args is True:
+                self.cli_parse_args = sys.argv[1:]
+            elif not isinstance(self.cli_parse_args, (list, tuple)):
                 raise SettingsError(
-                    f'cli_parse_args must be a list or tuple of strings, received {type(cli_parse_args)}'
+                    f'cli_parse_args must be a list or tuple of strings, received {type(self.cli_parse_args)}'
                 )
-            self._load_env_vars(parsed_args=self._parse_args(self.root_parser, cli_parse_args))
+            self._load_env_vars(parsed_args=self._parse_args(self.root_parser, self.cli_parse_args))
 
     @overload
     def __call__(self) -> dict[str, Any]: ...


Test patch:
diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py
index 4f1b6ff..98c4b0a 100644
--- a/tests/test_source_cli.py
+++ b/tests/test_source_cli.py
@@ -2464,3 +2464,30 @@ def test_cli_json_optional_default():
     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}}
+
+
+def test_cli_parse_args_from_model_config_is_respected_with_settings_customise_sources(
+    monkeypatch: pytest.MonkeyPatch,
+):
+    class MySettings(BaseSettings):
+        model_config = SettingsConfigDict(cli_parse_args=True)
+
+        foo: str
+
+        @classmethod
+        def settings_customise_sources(
+            cls,
+            settings_cls: type[BaseSettings],
+            init_settings: PydanticBaseSettingsSource,
+            env_settings: PydanticBaseSettingsSource,
+            dotenv_settings: PydanticBaseSettingsSource,
+            file_secret_settings: PydanticBaseSettingsSource,
+        ) -> tuple[PydanticBaseSettingsSource, ...]:
+            return (CliSettingsSource(settings_cls),)
+
+    with monkeypatch.context() as m:
+        m.setattr(sys, 'argv', ['example.py', '--foo', 'bar'])
+
+        cfg = CliApp.run(MySettings)
+
+        assert cfg.model_dump() == {'foo': 'bar'}