Skip to content

Commit 20640b0

Browse files
kschwabhramezani
andauthored
Cli retrieve unknown args (#588)
Co-authored-by: Hasan Ramezani <[email protected]>
1 parent ed7fd42 commit 20640b0

File tree

6 files changed

+49
-13
lines changed

6 files changed

+49
-13
lines changed

docs/index.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -1309,24 +1309,25 @@ class ImplicitSettings(BaseSettings, cli_parse_args=True, cli_implicit_flags=Tru
13091309
"""
13101310
```
13111311

1312-
#### Ignore Unknown Arguments
1312+
#### Ignore and Retrieve Unknown Arguments
13131313

13141314
Change whether to ignore unknown CLI arguments and only parse known ones using `cli_ignore_unknown_args`. By default, the CLI
1315-
does not ignore any args.
1315+
does not ignore any args. Ignored arguments can then be retrieved using the `CliUnknownArgs` annotation.
13161316

13171317
```py
13181318
import sys
13191319

1320-
from pydantic_settings import BaseSettings
1320+
from pydantic_settings import BaseSettings, CliUnknownArgs
13211321

13221322

13231323
class Settings(BaseSettings, cli_parse_args=True, cli_ignore_unknown_args=True):
13241324
good_arg: str
1325+
ignored_args: CliUnknownArgs
13251326

13261327

13271328
sys.argv = ['example.py', '--bad-arg=bad', 'ANOTHER_BAD_ARG', '--good_arg=hello world']
13281329
print(Settings().model_dump())
1329-
#> {'good_arg': 'hello world'}
1330+
#> {'good_arg': 'hello world', 'ignored_args': ['--bad-arg=bad', 'ANOTHER_BAD_ARG']}
13301331
```
13311332

13321333
#### CLI Kebab Case for Arguments

pydantic_settings/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
CliSettingsSource,
1212
CliSubCommand,
1313
CliSuppress,
14+
CliUnknownArgs,
1415
DotEnvSettingsSource,
1516
EnvSettingsSource,
1617
ForceDecode,
@@ -40,6 +41,7 @@
4041
'CliSettingsSource',
4142
'CliSubCommand',
4243
'CliSuppress',
44+
'CliUnknownArgs',
4345
'DotEnvSettingsSource',
4446
'EnvSettingsSource',
4547
'ForceDecode',

pydantic_settings/sources/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
CliSettingsSource,
1919
CliSubCommand,
2020
CliSuppress,
21+
CliUnknownArgs,
2122
)
2223
from .providers.dotenv import DotEnvSettingsSource, read_env_file
2324
from .providers.env import EnvSettingsSource
@@ -41,6 +42,7 @@
4142
'CliSettingsSource',
4243
'CliSubCommand',
4344
'CliSuppress',
45+
'CliUnknownArgs',
4446
'DefaultSettingsSource',
4547
'DotEnvSettingsSource',
4648
'DotenvType',

pydantic_settings/sources/providers/cli.py

+20-7
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
)
3636

3737
import typing_extensions
38-
from pydantic import BaseModel
38+
from pydantic import BaseModel, Field
3939
from pydantic._internal._repr import Representation
4040
from pydantic._internal._utils import is_model_class
4141
from pydantic.dataclasses import is_pydantic_dataclass
@@ -47,7 +47,7 @@
4747

4848
from ...exceptions import SettingsError
4949
from ...utils import _lenient_issubclass, _WithArgsTypes
50-
from ..types import _CliExplicitFlag, _CliImplicitFlag, _CliPositionalArg, _CliSubCommand
50+
from ..types import NoDecode, _CliExplicitFlag, _CliImplicitFlag, _CliPositionalArg, _CliSubCommand, _CliUnknownArgs
5151
from ..utils import (
5252
_annotation_contains_types,
5353
_annotation_enum_val_to_name,
@@ -86,6 +86,7 @@ class CliMutuallyExclusiveGroup(BaseModel):
8686
CliExplicitFlag = Annotated[_CliBoolFlag, _CliExplicitFlag]
8787
CLI_SUPPRESS = SUPPRESS
8888
CliSuppress = Annotated[T, CLI_SUPPRESS]
89+
CliUnknownArgs = Annotated[list[str], Field(default=[]), _CliUnknownArgs, NoDecode]
8990

9091

9192
class CliSettingsSource(EnvSettingsSource, Generic[T]):
@@ -364,6 +365,8 @@ def _load_env_vars(
364365
if not any(field_name for field_name in parsed_args.keys() if f'{last_selected_subcommand}.' in field_name):
365366
parsed_args[last_selected_subcommand] = '{}'
366367

368+
parsed_args.update(self._cli_unknown_args)
369+
367370
self.env_vars = parse_env_vars(
368371
cast(Mapping[str, str], parsed_args),
369372
self.case_sensitive,
@@ -630,8 +633,13 @@ def _connect_root_parser(
630633
add_subparsers_method: Callable[..., Any] | None = ArgumentParser.add_subparsers,
631634
formatter_class: Any = RawDescriptionHelpFormatter,
632635
) -> None:
636+
self._cli_unknown_args: dict[str, list[str]] = {}
637+
633638
def _parse_known_args(*args: Any, **kwargs: Any) -> Namespace:
634-
return ArgumentParser.parse_known_args(*args, **kwargs)[0]
639+
args, unknown_args = ArgumentParser.parse_known_args(*args, **kwargs)
640+
for dest in self._cli_unknown_args:
641+
self._cli_unknown_args[dest] = unknown_args
642+
return cast(Namespace, args)
635643

636644
self._root_parser = root_parser
637645
if parse_args_method is None:
@@ -756,10 +764,7 @@ def _add_parser_args(
756764
if not arg_names or (kwargs['dest'] in added_args):
757765
continue
758766

759-
if is_append_action:
760-
kwargs['action'] = 'append'
761-
if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True):
762-
self._cli_dict_args[kwargs['dest']] = field_info.annotation
767+
self._convert_append_action(kwargs, field_info, is_append_action)
763768

764769
if _CliPositionalArg in field_info.metadata:
765770
arg_names, flag_prefix = self._convert_positional_arg(
@@ -785,6 +790,8 @@ def _add_parser_args(
785790
model_default=model_default,
786791
is_model_suppressed=is_model_suppressed,
787792
)
793+
elif _CliUnknownArgs in field_info.metadata:
794+
self._cli_unknown_args[kwargs['dest']] = []
788795
elif not is_alias_path_only:
789796
if group is not None:
790797
if isinstance(group, dict):
@@ -807,6 +814,12 @@ def _check_kebab_name(self, name: str) -> str:
807814
return name.replace('_', '-')
808815
return name
809816

817+
def _convert_append_action(self, kwargs: dict[str, Any], field_info: FieldInfo, is_append_action: bool) -> None:
818+
if is_append_action:
819+
kwargs['action'] = 'append'
820+
if _annotation_contains_types(field_info.annotation, (dict, Mapping), is_strip_annotated=True):
821+
self._cli_dict_args[kwargs['dest']] = field_info.annotation
822+
810823
def _convert_bool_flag(self, kwargs: dict[str, Any], field_info: FieldInfo, model_default: Any) -> None:
811824
if kwargs['metavar'] == 'bool':
812825
if (self.cli_implicit_flags or _CliImplicitFlag in field_info.metadata) and (

pydantic_settings/sources/types.py

+5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ class _CliExplicitFlag:
5757
pass
5858

5959

60+
class _CliUnknownArgs:
61+
pass
62+
63+
6064
__all__ = [
6165
'DEFAULT_PATH',
6266
'ENV_FILE_SENTINEL',
@@ -70,4 +74,5 @@ class _CliExplicitFlag:
7074
'_CliImplicitFlag',
7175
'_CliPositionalArg',
7276
'_CliSubCommand',
77+
'_CliUnknownArgs',
7378
]

tests/test_source_cli.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
CliSettingsSource,
3737
CliSubCommand,
3838
CliSuppress,
39+
CliUnknownArgs,
3940
get_subcommand,
4041
)
4142

@@ -1730,14 +1731,26 @@ def test_cli_ignore_unknown_args():
17301731
class Cfg(BaseSettings, cli_ignore_unknown_args=True):
17311732
this: str = 'hello'
17321733
that: int = 123
1734+
ignored_args: CliUnknownArgs
1735+
1736+
cfg = CliApp.run(Cfg, cli_args=['--this=hi', '--that=456'])
1737+
assert cfg.model_dump() == {'this': 'hi', 'that': 456, 'ignored_args': []}
17331738

17341739
cfg = CliApp.run(Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456'])
1735-
assert cfg.model_dump() == {'this': 'hello', 'that': 123}
1740+
assert cfg.model_dump() == {
1741+
'this': 'hello',
1742+
'that': 123,
1743+
'ignored_args': ['not_my_positional_arg', '--not-my-optional-arg=456'],
1744+
}
17361745

17371746
cfg = CliApp.run(
17381747
Cfg, cli_args=['not_my_positional_arg', '--not-my-optional-arg=456', '--this=goodbye', '--that=789']
17391748
)
1740-
assert cfg.model_dump() == {'this': 'goodbye', 'that': 789}
1749+
assert cfg.model_dump() == {
1750+
'this': 'goodbye',
1751+
'that': 789,
1752+
'ignored_args': ['not_my_positional_arg', '--not-my-optional-arg=456'],
1753+
}
17411754

17421755

17431756
def test_cli_flag_prefix_char():

0 commit comments

Comments
 (0)