Repository: pydantic/pydantic-settings

Issue:
How to accept additional arguments
Hi,

I can't seem to find how to handle this; I'd like my subcommand to just accept all additional arguments into a List[str], something like:

```python
class StartJobCommand(BaseSettings):
    module: str
    args: List[str]

    def cli_cmd(self) -> None:
        print("Starting job", self.module, self.args)

class ClusterCli(BaseSettings):
    start_job: CliSubCommand[StartJobCommand]
    def cli_cmd(self) -> None:
        CliApp.run_subcommand(self)

if __name__ == "__main__":
    CliApp.run(ClusterCli)
```

which I'd like to run with `python -m cluster start_job testy --any extra --arguments here`

Discussion:
@hramezani: @kschwab Does `pydantic-settings` offer this?@kschwab: @FredrikNoren @hramezani currently we do not support this. We would need to add a new annotation or function that collects "unknown args". This would need to be used with the [ignore_unknown_arguments](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#ignore-unknown-arguments) setting. I don't think this is much to add, but will take a look.@kschwab: @hramezani @FredrikNoren opened #588 for resolution.

```python
from pydantic import BaseModel

from pydantic_settings import BaseSettings, CliSubCommand, CliApp, CliUnknownArgs

class StartJobCommand(BaseModel):
    module: str
    args: CliUnknownArgs

    def cli_cmd(self) -> None:
        print('Starting job', self.module, self.args)

class ClusterCli(BaseSettings, cli_ignore_unknown_args=True):
    start_job: CliSubCommand[StartJobCommand]
    def cli_cmd(self) -> None:
        CliApp.run_subcommand(self)

if __name__ == '__main__':
    CliApp.run(ClusterCli)

"""
$ python -m cluster start_job --module testy --any extra --arguments here
Starting job testy ['--any', 'extra', '--arguments', 'here']
"""
```@FredrikNoren: @kschwab Nice! That's perfect :D 

Solution patch:
diff --git a/docs/index.md b/docs/index.md
index eb30a09..76696df 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1309,24 +1309,25 @@ class ImplicitSettings(BaseSettings, cli_parse_args=True, cli_implicit_flags=Tru
     """
 ```
 
-#### Ignore Unknown Arguments
+#### Ignore and Retrieve Unknown Arguments
 
 Change whether to ignore unknown CLI arguments and only parse known ones using `cli_ignore_unknown_args`. By default, the CLI
-does not ignore any args.
+does not ignore any args. Ignored arguments can then be retrieved using the `CliUnknownArgs` annotation.
 
 ```py
 import sys
 
-from pydantic_settings import BaseSettings
+from pydantic_settings import BaseSettings, CliUnknownArgs
 
 
 class Settings(BaseSettings, cli_parse_args=True, cli_ignore_unknown_args=True):
     good_arg: str
+    ignored_args: CliUnknownArgs
 
 
 sys.argv = ['example.py', '--bad-arg=bad', 'ANOTHER_BAD_ARG', '--good_arg=hello world']
 print(Settings().model_dump())
-#> {'good_arg': 'hello world'}
+#> {'good_arg': 'hello world', 'ignored_args': ['--bad-arg=bad', 'ANOTHER_BAD_ARG']}
 ```
 
 #### CLI Kebab Case for Arguments
diff --git a/pydantic_settings/__init__.py b/pydantic_settings/__init__.py
index 739afef..60990a8 100644
--- a/pydantic_settings/__init__.py
+++ b/pydantic_settings/__init__.py
@@ -11,6 +11,7 @@ from .sources import (
     CliSettingsSource,
     CliSubCommand,
     CliSuppress,
+    CliUnknownArgs,
     DotEnvSettingsSource,
     EnvSettingsSource,
     ForceDecode,
@@ -40,6 +41,7 @@ __all__ = (
     'CliSettingsSource',
     'CliSubCommand',
     'CliSuppress',
+    'CliUnknownArgs',
     'DotEnvSettingsSource',
     'EnvSettingsSource',
     'ForceDecode',
diff --git a/pydantic_settings/sources/__init__.py b/pydantic_settings/sources/__init__.py
index 5c7d3d8..42027af 100644
--- a/pydantic_settings/sources/__init__.py
+++ b/pydantic_settings/sources/__init__.py
@@ -18,6 +18,7 @@ from .providers.cli import (
     CliSettingsSource,
     CliSubCommand,
     CliSuppress,
+    CliUnknownArgs,
 )
 from .providers.dotenv import DotEnvSettingsSource, read_env_file
 from .providers.env import EnvSettingsSource
@@ -41,6 +42,7 @@ __all__ = [
     'CliSettingsSource',
     'CliSubCommand',
     'CliSuppress',
+    'CliUnknownArgs',
     'DefaultSettingsSource',
     'DotEnvSettingsSource',
     'DotenvType',
diff --git a/pydantic_settings/sources/providers/cli.py b/pydantic_settings/sources/providers/cli.py
index 1577385..ac08c77 100644
--- a/pydantic_settings/sources/providers/cli.py
+++ b/pydantic_settings/sources/providers/cli.py
@@ -35,7 +35,7 @@ from typing import (
 )
 
 import typing_extensions
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
 from pydantic._internal._repr import Representation
 from pydantic._internal._utils import is_model_class
 from pydantic.dataclasses import is_pydantic_dataclass
@@ -47,7 +47,7 @@ from typing_inspection.introspection import is_union_origin
 
 from ...exceptions import SettingsError
 from ...utils import _lenient_issubclass, _WithArgsTypes
-from ..types import _CliExplicitFlag, _CliImplicitFlag, _CliPositionalArg, _CliSubCommand
+from ..types import NoDecode, _CliExplicitFlag, _CliImplicitFlag, _CliPositionalArg, _CliSubCommand, _CliUnknownArgs
 from ..utils import (
     _annotation_contains_types,
     _annotation_enum_val_to_name,
@@ -86,6 +86,7 @@ CliImplicitFlag = Annotated[_CliBoolFlag, _CliImplicitFlag]
 CliExplicitFlag = Annotated[_CliBoolFlag, _CliExplicitFlag]
 CLI_SUPPRESS = SUPPRESS
 CliSuppress = Annotated[T, CLI_SUPPRESS]
+CliUnknownArgs = Annotated[list[str], Field(default=[]), _CliUnknownArgs, NoDecode]
 
 
 class CliSettingsSource(EnvSettingsSource, Generic[T]):
@@ -364,6 +365,8 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
             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] = '{}'
 
+        parsed_args.update(self._cli_unknown_args)
+
         self.env_vars = parse_env_vars(
             cast(Mapping[str, str], parsed_args),
             self.case_sensitive,
@@ -630,8 +633,13 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
         add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers,
         formatter_class: Any = RawDescriptionHelpFormatter,
     ) -> None:
+        self._cli_unknown_args: dict[str, list[str]] = {}
+
         def _parse_known_args(*args: Any, **kwargs: Any) -> Namespace:
-            return ArgumentParser.parse_known_args(*args, **kwargs)[0]
+            args, unknown_args = ArgumentParser.parse_known_args(*args, **kwargs)
+            for dest in self._cli_unknown_args:
+                self._cli_unknown_args[dest] = unknown_args
+            return cast(Namespace, args)
 
         self._root_parser = root_parser
         if parse_args_method is None:
@@ -756,10 +764,7 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
                 if not arg_names or (kwargs['dest'] in added_args):
                     continue
 
-                if is_append_action:
-                    kwargs['action'] = 'append'
-                    if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True):
-                        self._cli_dict_args[kwargs['dest']] = field_info.annotation
+                self._convert_append_action(kwargs, field_info, is_append_action)
 
                 if _CliPositionalArg in field_info.metadata:
                     arg_names, flag_prefix = self._convert_positional_arg(
@@ -785,6 +790,8 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
                         model_default=model_default,
                         is_model_suppressed=is_model_suppressed,
                     )
+                elif _CliUnknownArgs in field_info.metadata:
+                    self._cli_unknown_args[kwargs['dest']] = []
                 elif not is_alias_path_only:
                     if group is not None:
                         if isinstance(group, dict):
@@ -807,6 +814,12 @@ class CliSettingsSource(EnvSettingsSource, Generic[T]):
             return name.replace('_', '-')
         return name
 
+    def _convert_append_action(self, kwargs: dict[str, Any], field_info: FieldInfo, is_append_action: bool) -> None:
+        if is_append_action:
+            kwargs['action'] = 'append'
+            if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True):
+                self._cli_dict_args[kwargs['dest']] = field_info.annotation
+
     def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None:
         if kwargs['metavar'] == 'bool':
             if (self.cli_implicit_flags or _CliImplicitFlag in field_info.metadata) and (
diff --git a/pydantic_settings/sources/types.py b/pydantic_settings/sources/types.py
index a210481..2c00d0e 100644
--- a/pydantic_settings/sources/types.py
+++ b/pydantic_settings/sources/types.py
@@ -57,6 +57,10 @@ class _CliExplicitFlag:
     pass
 
 
+class _CliUnknownArgs:
+    pass
+
+
 __all__ = [
     'DEFAULT_PATH',
     'ENV_FILE_SENTINEL',
@@ -70,4 +74,5 @@ __all__ = [
     '_CliImplicitFlag',
     '_CliPositionalArg',
     '_CliSubCommand',
+    '_CliUnknownArgs',
 ]


Test patch:
diff --git a/tests/test_source_cli.py b/tests/test_source_cli.py
index c6b25d6..6c523ff 100644
--- a/tests/test_source_cli.py
+++ b/tests/test_source_cli.py
@@ -36,6 +36,7 @@ from pydantic_settings.sources import (
     CliSettingsSource,
     CliSubCommand,
     CliSuppress,
+    CliUnknownArgs,
     get_subcommand,
 )
 
@@ -1730,14 +1731,26 @@ def test_cli_ignore_unknown_args():
     class Cfg(BaseSettings, cli_ignore_unknown_args=True):
         this: str = 'hello'
         that: int = 123
+        ignored_args: CliUnknownArgs
+
+    cfg = CliApp.run(Cfg, cli_args=['--this=hi', '--that=456'])
+    assert cfg.model_dump() == {'this': 'hi', 'that': 456, 'ignored_args': []}
 
     cfg = CliApp.run(Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456'])
-    assert cfg.model_dump() == {'this': 'hello', 'that': 123}
+    assert cfg.model_dump() == {
+        'this': 'hello',
+        'that': 123,
+        'ignored_args': ['not_my_positional_arg', '--not-my-optional-arg=456'],
+    }
 
     cfg = CliApp.run(
         Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456', '--this=goodbye', '--that=789']
     )
-    assert cfg.model_dump() == {'this': 'goodbye', 'that': 789}
+    assert cfg.model_dump() == {
+        'this': 'goodbye',
+        'that': 789,
+        'ignored_args': ['not_my_positional_arg', '--not-my-optional-arg=456'],
+    }
 
 
 def test_cli_flag_prefix_char():