Skip to content

Commit 84cab2b

Browse files
authored
Add CLI App Support (#389)
1 parent fdd666b commit 84cab2b

File tree

5 files changed

+432
-135
lines changed

5 files changed

+432
-135
lines changed

docs/index.md

Lines changed: 101 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -507,8 +507,7 @@ models. There are two primary use cases for Pydantic settings CLI:
507507

508508
By default, the experience is tailored towards use case #1 and builds on the foundations established in [parsing
509509
environment variables](#parsing-environment-variable-values). If your use case primarily falls into #2, you will likely
510-
want to enable [enforcing required arguments at the CLI](#enforce-required-arguments-at-cli) and [nested model default
511-
partial updates](#nested-model-default-partial-updates).
510+
want to enable most of the defaults outlined at the end of [creating CLI applications](#creating-cli-applications).
512511

513512
### The Basics
514513

@@ -560,19 +559,7 @@ print(Settings().model_dump())
560559
```
561560

562561
To enable CLI parsing, we simply set the `cli_parse_args` flag to a valid value, which retains similar conotations as
563-
defined in `argparse`. Alternatively, we can also directly provide the args to parse at time of instantiation:
564-
565-
```py
566-
from pydantic_settings import BaseSettings
567-
568-
569-
class Settings(BaseSettings):
570-
this_foo: str
571-
572-
573-
print(Settings(_cli_parse_args=['--this_foo', 'is such a foo']).model_dump())
574-
#> {'this_foo': 'is such a foo'}
575-
```
562+
defined in `argparse`.
576563

577564
Note that a CLI settings source is [**the topmost source**](#field-value-priority) by default unless its [priority value
578565
is customised](#customise-settings-sources):
@@ -875,6 +862,95 @@ sys.argv = ['example.py', 'gamma-cmd', '--opt-gamma=hi']
875862
assert get_subcommand(Root()).model_dump() == {'opt_gamma': 'hi'}
876863
```
877864

865+
### Creating CLI Applications
866+
867+
The `CliApp` class provides two utility methods, `CliApp.run` and `CliApp.run_subcommand`, that can be used to run a
868+
Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application. Primarily, the methods
869+
provide structure for running `cli_cmd` methods associated with models.
870+
871+
`CliApp.run` can be used in directly providing the `cli_args` to be parsed, and will run the model `cli_cmd` method (if
872+
defined) after instantiation:
873+
874+
```py
875+
from pydantic_settings import BaseSettings, CliApp
876+
877+
878+
class Settings(BaseSettings):
879+
this_foo: str
880+
881+
def cli_cmd(self) -> None:
882+
# Print the parsed data
883+
print(self.model_dump())
884+
#> {'this_foo': 'is such a foo'}
885+
886+
# Update the parsed data showing cli_cmd ran
887+
self.this_foo = 'ran the foo cli cmd'
888+
889+
890+
s = CliApp.run(Settings, cli_args=['--this_foo', 'is such a foo'])
891+
print(s.model_dump())
892+
#> {'this_foo': 'ran the foo cli cmd'}
893+
```
894+
895+
Similarly, the `CliApp.run_subcommand` can be used in recursive fashion to run the `cli_cmd` method of a subcommand:
896+
897+
```py
898+
from pydantic import BaseModel
899+
900+
from pydantic_settings import CliApp, CliPositionalArg, CliSubCommand
901+
902+
903+
class Init(BaseModel):
904+
directory: CliPositionalArg[str]
905+
906+
def cli_cmd(self) -> None:
907+
print(f'git init "{self.directory}"')
908+
#> git init "dir"
909+
self.directory = 'ran the git init cli cmd'
910+
911+
912+
class Clone(BaseModel):
913+
repository: CliPositionalArg[str]
914+
directory: CliPositionalArg[str]
915+
916+
def cli_cmd(self) -> None:
917+
print(f'git clone from "{self.repository}" into "{self.directory}"')
918+
self.directory = 'ran the clone cli cmd'
919+
920+
921+
class Git(BaseModel):
922+
clone: CliSubCommand[Clone]
923+
init: CliSubCommand[Init]
924+
925+
def cli_cmd(self) -> None:
926+
CliApp.run_subcommand(self)
927+
928+
929+
cmd = CliApp.run(Git, cli_args=['init', 'dir'])
930+
assert cmd.model_dump() == {
931+
'clone': None,
932+
'init': {'directory': 'ran the git init cli cmd'},
933+
}
934+
```
935+
936+
!!! note
937+
Unlike `CliApp.run`, `CliApp.run_subcommand` requires the subcommand model to have a defined `cli_cmd` method.
938+
939+
For `BaseModel` and `pydantic.dataclasses.dataclass` types, `CliApp.run` will internally use the following
940+
`BaseSettings` configuration defaults:
941+
942+
* `alias_generator=AliasGenerator(lambda s: s.replace('_', '-'))`
943+
* `nested_model_default_partial_update=True`
944+
* `case_sensitive=True`
945+
* `cli_hide_none_type=True`
946+
* `cli_avoid_json=True`
947+
* `cli_enforce_required=True`
948+
* `cli_implicit_flags=True`
949+
950+
!!! note
951+
The alias generator for kebab case does not propagate to subcommands or submodels and will have to be manually set
952+
in these cases.
953+
878954
### Customizing the CLI Experience
879955

880956
The below flags can be used to customise the CLI experience to your needs.
@@ -1241,7 +1317,7 @@ defined one that specifies the `root_parser` object.
12411317
import sys
12421318
from argparse import ArgumentParser
12431319

1244-
from pydantic_settings import BaseSettings, CliSettingsSource
1320+
from pydantic_settings import BaseSettings, CliApp, CliSettingsSource
12451321

12461322
parser = ArgumentParser()
12471323
parser.add_argument('--food', choices=['pear', 'kiwi', 'lime'])
@@ -1256,13 +1332,15 @@ cli_settings = CliSettingsSource(Settings, root_parser=parser)
12561332

12571333
# Parse and load CLI settings from the command line into the settings source.
12581334
sys.argv = ['example.py', '--food', 'kiwi', '--name', 'waldo']
1259-
print(Settings(_cli_settings_source=cli_settings(args=True)).model_dump())
1335+
s = CliApp.run(Settings, cli_settings_source=cli_settings)
1336+
print(s.model_dump())
12601337
#> {'name': 'waldo'}
12611338

12621339
# Load CLI settings from pre-parsed arguments. i.e., the parsing occurs elsewhere and we
12631340
# just need to load the pre-parsed args into the settings source.
12641341
parsed_args = parser.parse_args(['--food', 'kiwi', '--name', 'ralph'])
1265-
print(Settings(_cli_settings_source=cli_settings(parsed_args=parsed_args)).model_dump())
1342+
s = CliApp.run(Settings, cli_args=parsed_args, cli_settings_source=cli_settings)
1343+
print(s.model_dump())
12661344
#> {'name': 'ralph'}
12671345
```
12681346

@@ -1281,6 +1359,11 @@ parser methods that can be customised, along with their argparse counterparts (t
12811359
For a non-argparse parser the parser methods can be set to `None` if not supported. The CLI settings will only raise an
12821360
error when connecting to the root parser if a parser method is necessary but set to `None`.
12831361

1362+
!!! note
1363+
The `formatter_class` is only applied to subcommands. The `CliSettingsSource` never touches or modifies any of the
1364+
external parser settings to avoid breaking changes. Since subcommands reside on their own internal parser trees, we
1365+
can safely apply the `formatter_class` settings without breaking the external parser logic.
1366+
12841367
## Secrets
12851368

12861369
Placing secret values in files is a common pattern to provide sensitive configuration to an application.

pydantic_settings/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .main import BaseSettings, SettingsConfigDict
1+
from .main import BaseSettings, CliApp, SettingsConfigDict
22
from .sources import (
33
AzureKeyVaultSettingsSource,
44
CliExplicitFlag,
@@ -24,6 +24,7 @@
2424
'BaseSettings',
2525
'DotEnvSettingsSource',
2626
'EnvSettingsSource',
27+
'CliApp',
2728
'CliSettingsSource',
2829
'CliSubCommand',
2930
'CliPositionalArg',

pydantic_settings/main.py

Lines changed: 143 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
from __future__ import annotations as _annotations
22

3-
from typing import Any, ClassVar
3+
from argparse import Namespace
4+
from types import SimpleNamespace
5+
from typing import Any, ClassVar, TypeVar
46

5-
from pydantic import ConfigDict
7+
from pydantic import AliasGenerator, ConfigDict
68
from pydantic._internal._config import config_keys
7-
from pydantic._internal._utils import deep_update
9+
from pydantic._internal._signature import _field_name_for_signature
10+
from pydantic._internal._utils import deep_update, is_model_class
11+
from pydantic.dataclasses import is_pydantic_dataclass
812
from pydantic.main import BaseModel
913

1014
from .sources import (
@@ -17,9 +21,14 @@
1721
InitSettingsSource,
1822
PathType,
1923
PydanticBaseSettingsSource,
24+
PydanticModel,
2025
SecretsSettingsSource,
26+
SettingsError,
27+
get_subcommand,
2128
)
2229

30+
T = TypeVar('T')
31+
2332

2433
class SettingsConfigDict(ConfigDict, total=False):
2534
case_sensitive: bool
@@ -33,7 +42,6 @@ class SettingsConfigDict(ConfigDict, total=False):
3342
env_parse_enums: bool | None
3443
cli_prog_name: str | None
3544
cli_parse_args: bool | list[str] | tuple[str, ...] | None
36-
cli_settings_source: CliSettingsSource[Any] | None
3745
cli_parse_none_str: str | None
3846
cli_hide_none_type: bool
3947
cli_avoid_json: bool
@@ -91,7 +99,8 @@ class BaseSettings(BaseModel):
9199
All the below attributes can be set via `model_config`.
92100
93101
Args:
94-
_case_sensitive: Whether environment variables names should be read with case-sensitivity. Defaults to `None`.
102+
_case_sensitive: Whether environment and CLI variable names should be read with case-sensitivity.
103+
Defaults to `None`.
95104
_nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields.
96105
Defaults to `False`.
97106
_env_prefix: Prefix for all environment variables. Defaults to `None`.
@@ -345,26 +354,24 @@ def _settings_build_values(
345354
file_secret_settings=file_secret_settings,
346355
) + (default_settings,)
347356
if not any([source for source in sources if isinstance(source, CliSettingsSource)]):
348-
if cli_parse_args is not None or cli_settings_source is not None:
349-
cli_settings = (
350-
CliSettingsSource(
351-
self.__class__,
352-
cli_prog_name=cli_prog_name,
353-
cli_parse_args=cli_parse_args,
354-
cli_parse_none_str=cli_parse_none_str,
355-
cli_hide_none_type=cli_hide_none_type,
356-
cli_avoid_json=cli_avoid_json,
357-
cli_enforce_required=cli_enforce_required,
358-
cli_use_class_docs_for_groups=cli_use_class_docs_for_groups,
359-
cli_exit_on_error=cli_exit_on_error,
360-
cli_prefix=cli_prefix,
361-
cli_flag_prefix_char=cli_flag_prefix_char,
362-
cli_implicit_flags=cli_implicit_flags,
363-
cli_ignore_unknown_args=cli_ignore_unknown_args,
364-
case_sensitive=case_sensitive,
365-
)
366-
if cli_settings_source is None
367-
else cli_settings_source
357+
if isinstance(cli_settings_source, CliSettingsSource):
358+
sources = (cli_settings_source,) + sources
359+
elif cli_parse_args is not None:
360+
cli_settings = CliSettingsSource[Any](
361+
self.__class__,
362+
cli_prog_name=cli_prog_name,
363+
cli_parse_args=cli_parse_args,
364+
cli_parse_none_str=cli_parse_none_str,
365+
cli_hide_none_type=cli_hide_none_type,
366+
cli_avoid_json=cli_avoid_json,
367+
cli_enforce_required=cli_enforce_required,
368+
cli_use_class_docs_for_groups=cli_use_class_docs_for_groups,
369+
cli_exit_on_error=cli_exit_on_error,
370+
cli_prefix=cli_prefix,
371+
cli_flag_prefix_char=cli_flag_prefix_char,
372+
cli_implicit_flags=cli_implicit_flags,
373+
cli_ignore_unknown_args=cli_ignore_unknown_args,
374+
case_sensitive=case_sensitive,
368375
)
369376
sources = (cli_settings,) + sources
370377
if sources:
@@ -401,7 +408,6 @@ def _settings_build_values(
401408
env_parse_enums=None,
402409
cli_prog_name=None,
403410
cli_parse_args=None,
404-
cli_settings_source=None,
405411
cli_parse_none_str=None,
406412
cli_hide_none_type=False,
407413
cli_avoid_json=False,
@@ -420,3 +426,114 @@ def _settings_build_values(
420426
secrets_dir=None,
421427
protected_namespaces=('model_', 'settings_'),
422428
)
429+
430+
431+
class CliApp:
432+
"""
433+
A utility class for running Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as
434+
CLI applications.
435+
"""
436+
437+
@staticmethod
438+
def _run_cli_cmd(model: Any, cli_cmd_method_name: str, is_required: bool) -> Any:
439+
if hasattr(type(model), cli_cmd_method_name):
440+
getattr(type(model), cli_cmd_method_name)(model)
441+
elif is_required:
442+
raise SettingsError(f'Error: {type(model).__name__} class is missing {cli_cmd_method_name} entrypoint')
443+
return model
444+
445+
@staticmethod
446+
def run(
447+
model_cls: type[T],
448+
cli_args: list[str] | Namespace | SimpleNamespace | dict[str, Any] | None = None,
449+
cli_settings_source: CliSettingsSource[Any] | None = None,
450+
cli_exit_on_error: bool | None = None,
451+
cli_cmd_method_name: str = 'cli_cmd',
452+
**model_init_data: Any,
453+
) -> T:
454+
"""
455+
Runs a Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application.
456+
Running a model as a CLI application requires the `cli_cmd` method to be defined in the model class.
457+
458+
Args:
459+
model_cls: The model class to run as a CLI application.
460+
cli_args: The list of CLI arguments to parse. If `cli_settings_source` is specified, this may
461+
also be a namespace or dictionary of pre-parsed CLI arguments. Defaults to `sys.argv[1:]`.
462+
cli_settings_source: Override the default CLI settings source with a user defined instance.
463+
Defaults to `None`.
464+
cli_exit_on_error: Determines whether this function exits on error. If model is subclass of
465+
`BaseSettings`, defaults to BaseSettings `cli_exit_on_error` value. Otherwise, defaults to
466+
`True`.
467+
cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd".
468+
model_init_data: The model init data.
469+
470+
Returns:
471+
The ran instance of model.
472+
473+
Raises:
474+
SettingsError: If model_cls is not subclass of `BaseModel` or `pydantic.dataclasses.dataclass`.
475+
SettingsError: If model_cls does not have a `cli_cmd` entrypoint defined.
476+
"""
477+
478+
if not (is_pydantic_dataclass(model_cls) or is_model_class(model_cls)):
479+
raise SettingsError(
480+
f'Error: {model_cls.__name__} is not subclass of BaseModel or pydantic.dataclasses.dataclass'
481+
)
482+
483+
cli_settings = None
484+
cli_parse_args = True if cli_args is None else cli_args
485+
if cli_settings_source is not None:
486+
if isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)):
487+
cli_settings = cli_settings_source(parsed_args=cli_parse_args)
488+
else:
489+
cli_settings = cli_settings_source(args=cli_parse_args)
490+
elif isinstance(cli_parse_args, (Namespace, SimpleNamespace, dict)):
491+
raise SettingsError('Error: `cli_args` must be list[str] or None when `cli_settings_source` is not used')
492+
493+
model_init_data['_cli_parse_args'] = cli_parse_args
494+
model_init_data['_cli_exit_on_error'] = cli_exit_on_error
495+
model_init_data['_cli_settings_source'] = cli_settings
496+
if not issubclass(model_cls, BaseSettings):
497+
498+
class CliAppBaseSettings(BaseSettings, model_cls): # type: ignore
499+
model_config = SettingsConfigDict(
500+
alias_generator=AliasGenerator(lambda s: s.replace('_', '-')),
501+
nested_model_default_partial_update=True,
502+
case_sensitive=True,
503+
cli_hide_none_type=True,
504+
cli_avoid_json=True,
505+
cli_enforce_required=True,
506+
cli_implicit_flags=True,
507+
)
508+
509+
model = CliAppBaseSettings(**model_init_data)
510+
model_init_data = {}
511+
for field_name, field_info in model.model_fields.items():
512+
model_init_data[_field_name_for_signature(field_name, field_info)] = getattr(model, field_name)
513+
514+
return CliApp._run_cli_cmd(model_cls(**model_init_data), cli_cmd_method_name, is_required=False)
515+
516+
@staticmethod
517+
def run_subcommand(
518+
model: PydanticModel, cli_exit_on_error: bool | None = None, cli_cmd_method_name: str = 'cli_cmd'
519+
) -> PydanticModel:
520+
"""
521+
Runs the model subcommand. Running a model subcommand requires the `cli_cmd` method to be defined in
522+
the nested model subcommand class.
523+
524+
Args:
525+
model: The model to run the subcommand from.
526+
cli_exit_on_error: Determines whether this function exits with error if no subcommand is found.
527+
Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`.
528+
cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd".
529+
530+
Returns:
531+
The ran subcommand model.
532+
533+
Raises:
534+
SystemExit: When no subcommand is found and cli_exit_on_error=`True` (the default).
535+
SettingsError: When no subcommand is found and cli_exit_on_error=`False`.
536+
"""
537+
538+
subcommand = get_subcommand(model, is_required=True, cli_exit_on_error=cli_exit_on_error)
539+
return CliApp._run_cli_cmd(subcommand, cli_cmd_method_name, is_required=True)

0 commit comments

Comments
 (0)