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`.
@@ -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