Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pydantic_settings/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
CliSettingsSource,
CliSubCommand,
CliSuppress,
CliUnknownArgs,
DotEnvSettingsSource,
EnvSettingsSource,
ForceDecode,
Expand Down Expand Up @@ -40,6 +41,7 @@
'CliSettingsSource',
'CliSubCommand',
'CliSuppress',
'CliUnknownArgs',
'DotEnvSettingsSource',
'EnvSettingsSource',
'ForceDecode',
Expand Down
2 changes: 2 additions & 0 deletions pydantic_settings/sources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
CliSettingsSource,
CliSubCommand,
CliSuppress,
CliUnknownArgs,
)
from .providers.dotenv import DotEnvSettingsSource, read_env_file
from .providers.env import EnvSettingsSource
Expand All @@ -41,6 +42,7 @@
'CliSettingsSource',
'CliSubCommand',
'CliSuppress',
'CliUnknownArgs',
'DefaultSettingsSource',
'DotEnvSettingsSource',
'DotenvType',
Expand Down
27 changes: 20 additions & 7 deletions pydantic_settings/sources/providers/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
)

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
Expand All @@ -47,7 +47,7 @@

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,
Expand Down Expand Up @@ -86,6 +86,7 @@ class CliMutuallyExclusiveGroup(BaseModel):
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]):
Expand Down Expand Up @@ -364,6 +365,8 @@ def _load_env_vars(
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,
Expand Down Expand Up @@ -630,8 +633,13 @@ def _connect_root_parser(
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:
Expand Down Expand Up @@ -756,10 +764,7 @@ def _add_parser_args(
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(
Expand All @@ -785,6 +790,8 @@ def _add_parser_args(
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):
Expand All @@ -807,6 +814,12 @@ def _check_kebab_name(self, name: str) -> str:
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 (
Expand Down
5 changes: 5 additions & 0 deletions pydantic_settings/sources/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ class _CliExplicitFlag:
pass


class _CliUnknownArgs:
pass


__all__ = [
'DEFAULT_PATH',
'ENV_FILE_SENTINEL',
Expand All @@ -70,4 +74,5 @@ class _CliExplicitFlag:
'_CliImplicitFlag',
'_CliPositionalArg',
'_CliSubCommand',
'_CliUnknownArgs',
]
17 changes: 15 additions & 2 deletions tests/test_source_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
CliSettingsSource,
CliSubCommand,
CliSuppress,
CliUnknownArgs,
get_subcommand,
)

Expand Down Expand Up @@ -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():
Expand Down