11from __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
68from 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
812from pydantic .main import BaseModel
913
1014from .sources import (
1721 InitSettingsSource ,
1822 PathType ,
1923 PydanticBaseSettingsSource ,
24+ PydanticModel ,
2025 SecretsSettingsSource ,
26+ SettingsError ,
27+ get_subcommand ,
2128)
2229
30+ T = TypeVar ('T' )
31+
2332
2433class 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`.
@@ -347,26 +356,24 @@ def _settings_build_values(
347356 file_secret_settings = file_secret_settings ,
348357 ) + (default_settings ,)
349358 if not any ([source for source in sources if isinstance (source , CliSettingsSource )]):
350- if cli_parse_args is not None or cli_settings_source is not None :
351- cli_settings = (
352- CliSettingsSource (
353- self .__class__ ,
354- cli_prog_name = cli_prog_name ,
355- cli_parse_args = cli_parse_args ,
356- cli_parse_none_str = cli_parse_none_str ,
357- cli_hide_none_type = cli_hide_none_type ,
358- cli_avoid_json = cli_avoid_json ,
359- cli_enforce_required = cli_enforce_required ,
360- cli_use_class_docs_for_groups = cli_use_class_docs_for_groups ,
361- cli_exit_on_error = cli_exit_on_error ,
362- cli_prefix = cli_prefix ,
363- cli_flag_prefix_char = cli_flag_prefix_char ,
364- cli_implicit_flags = cli_implicit_flags ,
365- cli_ignore_unknown_args = cli_ignore_unknown_args ,
366- case_sensitive = case_sensitive ,
367- )
368- if cli_settings_source is None
369- else cli_settings_source
359+ if isinstance (cli_settings_source , CliSettingsSource ):
360+ sources = (cli_settings_source ,) + sources
361+ elif cli_parse_args is not None :
362+ cli_settings = CliSettingsSource [Any ](
363+ self .__class__ ,
364+ cli_prog_name = cli_prog_name ,
365+ cli_parse_args = cli_parse_args ,
366+ cli_parse_none_str = cli_parse_none_str ,
367+ cli_hide_none_type = cli_hide_none_type ,
368+ cli_avoid_json = cli_avoid_json ,
369+ cli_enforce_required = cli_enforce_required ,
370+ cli_use_class_docs_for_groups = cli_use_class_docs_for_groups ,
371+ cli_exit_on_error = cli_exit_on_error ,
372+ cli_prefix = cli_prefix ,
373+ cli_flag_prefix_char = cli_flag_prefix_char ,
374+ cli_implicit_flags = cli_implicit_flags ,
375+ cli_ignore_unknown_args = cli_ignore_unknown_args ,
376+ case_sensitive = case_sensitive ,
370377 )
371378 sources = (cli_settings ,) + sources
372379 if sources :
@@ -403,7 +410,6 @@ def _settings_build_values(
403410 env_parse_enums = None ,
404411 cli_prog_name = None ,
405412 cli_parse_args = None ,
406- cli_settings_source = None ,
407413 cli_parse_none_str = None ,
408414 cli_hide_none_type = False ,
409415 cli_avoid_json = False ,
@@ -422,3 +428,114 @@ def _settings_build_values(
422428 secrets_dir = None ,
423429 protected_namespaces = ('model_' , 'settings_' ),
424430 )
431+
432+
433+ class CliApp :
434+ """
435+ A utility class for running Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as
436+ CLI applications.
437+ """
438+
439+ @staticmethod
440+ def _run_cli_cmd (model : Any , cli_cmd_method_name : str , is_required : bool ) -> Any :
441+ if hasattr (type (model ), cli_cmd_method_name ):
442+ getattr (type (model ), cli_cmd_method_name )(model )
443+ elif is_required :
444+ raise SettingsError (f'Error: { type (model ).__name__ } class is missing { cli_cmd_method_name } entrypoint' )
445+ return model
446+
447+ @staticmethod
448+ def run (
449+ model_cls : type [T ],
450+ cli_args : list [str ] | Namespace | SimpleNamespace | dict [str , Any ] | None = None ,
451+ cli_settings_source : CliSettingsSource [Any ] | None = None ,
452+ cli_exit_on_error : bool | None = None ,
453+ cli_cmd_method_name : str = 'cli_cmd' ,
454+ ** model_init_data : Any ,
455+ ) -> T :
456+ """
457+ Runs a Pydantic `BaseSettings`, `BaseModel`, or `pydantic.dataclasses.dataclass` as a CLI application.
458+ Running a model as a CLI application requires the `cli_cmd` method to be defined in the model class.
459+
460+ Args:
461+ model_cls: The model class to run as a CLI application.
462+ cli_args: The list of CLI arguments to parse. If `cli_settings_source` is specified, this may
463+ also be a namespace or dictionary of pre-parsed CLI arguments. Defaults to `sys.argv[1:]`.
464+ cli_settings_source: Override the default CLI settings source with a user defined instance.
465+ Defaults to `None`.
466+ cli_exit_on_error: Determines whether this function exits on error. If model is subclass of
467+ `BaseSettings`, defaults to BaseSettings `cli_exit_on_error` value. Otherwise, defaults to
468+ `True`.
469+ cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd".
470+ model_init_data: The model init data.
471+
472+ Returns:
473+ The ran instance of model.
474+
475+ Raises:
476+ SettingsError: If model_cls is not subclass of `BaseModel` or `pydantic.dataclasses.dataclass`.
477+ SettingsError: If model_cls does not have a `cli_cmd` entrypoint defined.
478+ """
479+
480+ if not (is_pydantic_dataclass (model_cls ) or is_model_class (model_cls )):
481+ raise SettingsError (
482+ f'Error: { model_cls .__name__ } is not subclass of BaseModel or pydantic.dataclasses.dataclass'
483+ )
484+
485+ cli_settings = None
486+ cli_parse_args = True if cli_args is None else cli_args
487+ if cli_settings_source is not None :
488+ if isinstance (cli_parse_args , (Namespace , SimpleNamespace , dict )):
489+ cli_settings = cli_settings_source (parsed_args = cli_parse_args )
490+ else :
491+ cli_settings = cli_settings_source (args = cli_parse_args )
492+ elif isinstance (cli_parse_args , (Namespace , SimpleNamespace , dict )):
493+ raise SettingsError ('Error: `cli_args` must be list[str] or None when `cli_settings_source` is not used' )
494+
495+ model_init_data ['_cli_parse_args' ] = cli_parse_args
496+ model_init_data ['_cli_exit_on_error' ] = cli_exit_on_error
497+ model_init_data ['_cli_settings_source' ] = cli_settings
498+ if not issubclass (model_cls , BaseSettings ):
499+
500+ class CliAppBaseSettings (BaseSettings , model_cls ): # type: ignore
501+ model_config = SettingsConfigDict (
502+ alias_generator = AliasGenerator (lambda s : s .replace ('_' , '-' )),
503+ nested_model_default_partial_update = True ,
504+ case_sensitive = True ,
505+ cli_hide_none_type = True ,
506+ cli_avoid_json = True ,
507+ cli_enforce_required = True ,
508+ cli_implicit_flags = True ,
509+ )
510+
511+ model = CliAppBaseSettings (** model_init_data )
512+ model_init_data = {}
513+ for field_name , field_info in model .model_fields .items ():
514+ model_init_data [_field_name_for_signature (field_name , field_info )] = getattr (model , field_name )
515+
516+ return CliApp ._run_cli_cmd (model_cls (** model_init_data ), cli_cmd_method_name , is_required = False )
517+
518+ @staticmethod
519+ def run_subcommand (
520+ model : PydanticModel , cli_exit_on_error : bool | None = None , cli_cmd_method_name : str = 'cli_cmd'
521+ ) -> PydanticModel :
522+ """
523+ Runs the model subcommand. Running a model subcommand requires the `cli_cmd` method to be defined in
524+ the nested model subcommand class.
525+
526+ Args:
527+ model: The model to run the subcommand from.
528+ cli_exit_on_error: Determines whether this function exits with error if no subcommand is found.
529+ Defaults to model_config `cli_exit_on_error` value if set. Otherwise, defaults to `True`.
530+ cli_cmd_method_name: The CLI command method name to run. Defaults to "cli_cmd".
531+
532+ Returns:
533+ The ran subcommand model.
534+
535+ Raises:
536+ SystemExit: When no subcommand is found and cli_exit_on_error=`True` (the default).
537+ SettingsError: When no subcommand is found and cli_exit_on_error=`False`.
538+ """
539+
540+ subcommand = get_subcommand (model , is_required = True , cli_exit_on_error = cli_exit_on_error )
541+ return CliApp ._run_cli_cmd (subcommand , cli_cmd_method_name , is_required = True )
0 commit comments