1
1
from __future__ import annotations as _annotations
2
2
3
- from typing import Any , ClassVar
3
+ from argparse import Namespace
4
+ from types import SimpleNamespace
5
+ from typing import Any , ClassVar , TypeVar
4
6
5
- from pydantic import ConfigDict
7
+ from pydantic import AliasGenerator , ConfigDict
6
8
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
8
12
from pydantic .main import BaseModel
9
13
10
14
from .sources import (
17
21
InitSettingsSource ,
18
22
PathType ,
19
23
PydanticBaseSettingsSource ,
24
+ PydanticModel ,
20
25
SecretsSettingsSource ,
26
+ SettingsError ,
27
+ get_subcommand ,
21
28
)
22
29
30
+ T = TypeVar ('T' )
31
+
23
32
24
33
class SettingsConfigDict (ConfigDict , total = False ):
25
34
case_sensitive : bool
@@ -33,7 +42,6 @@ class SettingsConfigDict(ConfigDict, total=False):
33
42
env_parse_enums : bool | None
34
43
cli_prog_name : str | None
35
44
cli_parse_args : bool | list [str ] | tuple [str , ...] | None
36
- cli_settings_source : CliSettingsSource [Any ] | None
37
45
cli_parse_none_str : str | None
38
46
cli_hide_none_type : bool
39
47
cli_avoid_json : bool
@@ -91,7 +99,8 @@ class BaseSettings(BaseModel):
91
99
All the below attributes can be set via `model_config`.
92
100
93
101
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`.
95
104
_nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields.
96
105
Defaults to `False`.
97
106
_env_prefix: Prefix for all environment variables. Defaults to `None`.
@@ -345,26 +354,24 @@ def _settings_build_values(
345
354
file_secret_settings = file_secret_settings ,
346
355
) + (default_settings ,)
347
356
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 ,
368
375
)
369
376
sources = (cli_settings ,) + sources
370
377
if sources :
@@ -401,7 +408,6 @@ def _settings_build_values(
401
408
env_parse_enums = None ,
402
409
cli_prog_name = None ,
403
410
cli_parse_args = None ,
404
- cli_settings_source = None ,
405
411
cli_parse_none_str = None ,
406
412
cli_hide_none_type = False ,
407
413
cli_avoid_json = False ,
@@ -420,3 +426,114 @@ def _settings_build_values(
420
426
secrets_dir = None ,
421
427
protected_namespaces = ('model_' , 'settings_' ),
422
428
)
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