1111from pathlib import Path
1212from typing import Any , Literal
1313
14- from pydantic import AliasChoices , BaseModel , Field , field_validator
14+ from pydantic import AliasChoices , BaseModel , ConfigDict , Field , field_validator
1515from pydantic_settings import (
1616 BaseSettings ,
1717 PydanticBaseSettingsSource ,
4040]
4141
4242
43+ # https://github.com/python/cpython/blob/v3.13.9/Lib/logging/config.py#L480-L492
44+ class CallableFactoryConfig (BaseModel ):
45+ model_config = ConfigDict (extra = "allow" , populate_by_name = True )
46+
47+ callable : str = Field (
48+ description = "Custom callable" ,
49+ validation_alias = AliasChoices ("callable" , "()" ),
50+ serialization_alias = "()" ,
51+ )
52+
53+
4354# Formatter Models
55+ # https://github.com/python/cpython/blob/v3.13.9/Lib/logging/config.py#L688
4456class FormatterConfig (BaseModel ):
4557 """Configuration for a logging formatter."""
46-
58+
4759 format : str | None = Field (
4860 default = "%(levelname)s:%(name)s:%(message)s" ,
4961 description = "Format string for log messages"
@@ -74,6 +86,7 @@ class FormatterConfig(BaseModel):
7486
7587
7688# Filter Models
89+ # https://github.com/python/cpython/blob/v3.13.9/Lib/logging/config.py#L732
7790class FilterConfig (BaseModel ):
7891 """Configuration for a logging filter."""
7992
@@ -90,11 +103,12 @@ class FilterConfig(BaseModel):
90103
91104
92105# Handler Models
106+ # https://github.com/python/cpython/blob/v3.13.9/Lib/logging/config.py#L768
93107class BaseHandlerConfig (BaseModel ):
94108 """Base configuration for all handlers."""
95-
96- model_config = { " extra" : " allow"}
97-
109+
110+ model_config = ConfigDict ( extra = " allow")
111+
98112 class_ : str = Field (
99113 validation_alias = AliasChoices ("class_" , "class" ),
100114 serialization_alias = "class" ,
@@ -389,7 +403,7 @@ class QueueHandlerConfig(BaseHandlerConfig):
389403 validation_alias = AliasChoices ("class_" , "class" ),
390404 serialization_alias = "class"
391405 )
392- queue : str = Field (
406+ queue : str | CallableFactoryConfig = Field (
393407 description = "Queue object reference"
394408 )
395409
@@ -402,7 +416,7 @@ class QueueListenerConfig(BaseHandlerConfig):
402416 validation_alias = AliasChoices ("class_" , "class" ),
403417 serialization_alias = "class"
404418 )
405- queue : str = Field (
419+ queue : str | CallableFactoryConfig = Field (
406420 description = "Queue object reference"
407421 )
408422 handlers : list [str ] = Field (
@@ -421,7 +435,7 @@ class QueueListenerConfig(BaseHandlerConfig):
421435# Logger configuration
422436class LoggerConfig (BaseModel ):
423437 """Configuration for a logger."""
424-
438+
425439 level : str | None = Field (
426440 default = None ,
427441 description = "Logging level"
@@ -478,7 +492,7 @@ def _load_file(self) -> dict[str, Any]:
478492
479493 with open (self .toml_file , "rb" ) as f :
480494 data = tomllib .load (f )
481-
495+
482496 # Navigate to specified table if provided
483497 if self .toml_table :
484498 for key in self .toml_table :
@@ -508,11 +522,11 @@ def __init__(
508522 super ().__init__ (settings_cls )
509523 self .json_file = json_file
510524 self ._data = self ._load_file ()
511-
525+
512526 def _load_file (self ) -> dict [str , Any ]:
513527 if not self .json_file or not Path (self .json_file ).exists ():
514528 return {}
515-
529+
516530 with open (self .json_file , "r" ) as f :
517531 return json .load (f )
518532
@@ -565,7 +579,7 @@ def _convert_ini_to_dictconfig(self, config: ConfigParser) -> dict[str, Any]:
565579 if key and config .has_section (f"formatter_{ key } " ):
566580 result ["formatters" ][key ] = self ._parse_formatter_section (config , f"formatter_{ key } " )
567581
568- # Parse handlers
582+ # Parse handlers
569583 if config .has_section ("handlers" ):
570584 handler_keys = config .get ("handlers" , "keys" , fallback = "" ).split ("," )
571585 for key in handler_keys :
@@ -583,7 +597,7 @@ def _convert_ini_to_dictconfig(self, config: ConfigParser) -> dict[str, Any]:
583597 result ["root" ] = self ._parse_logger_section (config , f"logger_{ key } " , is_root = True )
584598 else :
585599 result ["loggers" ][key ] = self ._parse_logger_section (config , f"logger_{ key } " , is_root = False )
586-
600+
587601 # Parse global settings if they exist
588602 if config .has_option ("DEFAULT" , "disable_existing_loggers" ):
589603 result ["disable_existing_loggers" ] = config .getboolean ("DEFAULT" , "disable_existing_loggers" )
@@ -593,7 +607,7 @@ def _convert_ini_to_dictconfig(self, config: ConfigParser) -> dict[str, Any]:
593607 def _parse_formatter_section (self , config : ConfigParser , section : str ) -> dict [str , Any ]:
594608 """Parse a formatter section."""
595609 formatter = {}
596-
610+
597611 if config .has_option (section , "format" ):
598612 formatter ["format" ] = config .get (section , "format" )
599613 if config .has_option (section , "datefmt" ):
@@ -606,7 +620,24 @@ def _parse_formatter_section(self, config: ConfigParser, section: str) -> dict[s
606620 formatter ["validate" ] = config .getboolean (section , "validate" )
607621 if config .has_option (section , "class" ):
608622 formatter ["class" ] = config .get (section , "class" )
609-
623+
624+ # Add any other options as extra fields
625+ for option in config .options (section ):
626+ if option not in ["format" , "datefmt" , "style" , "validate" , "class" ]:
627+ value = config .get (section , option )
628+ # Try to convert to appropriate type
629+ try :
630+ # Try boolean
631+ if value .lower () in ["true" , "false" ]:
632+ formatter [option ] = config .getboolean (section , option )
633+ # Try integer
634+ elif value .isdigit ():
635+ formatter [option ] = config .getint (section , option )
636+ else :
637+ formatter [option ] = value
638+ except (ValueError , AttributeError ):
639+ formatter [option ] = value
640+
610641 return formatter
611642
612643 def _parse_handler_section (self , config : ConfigParser , section : str ) -> dict [str , Any ]:
@@ -639,15 +670,15 @@ def _parse_handler_section(self, config: ConfigParser, section: str) -> dict[str
639670 # Handle args - this is complex as it can be a Python expression
640671 args_str = config .get (section , "args" )
641672 # For basic cases, try to parse as a simple tuple
642- if args_str == "()" or args_str == "()" :
673+ if args_str == "()" :
643674 handler ["args" ] = []
644675 elif args_str .startswith ("(" ) and args_str .endswith (")" ):
645676 # Store as string - the logging module will evaluate it
646677 handler ["args" ] = args_str
647-
678+
648679 # Add any other options as extra fields
649680 for option in config .options (section ):
650- if option not in ["class" , "level" , "formatter" , "stream" , "filename" , "mode" ,
681+ if option not in ["class" , "level" , "formatter" , "stream" , "filename" , "mode" ,
651682 "maxBytes" , "backupCount" , "when" , "interval" , "utc" , "args" ]:
652683 value = config .get (section , option )
653684 # Try to convert to appropriate type
@@ -704,7 +735,7 @@ class LoggingSettings(BaseSettings):
704735 4. logging.toml - TOML configuration file
705736 5. logging.ini - INI configuration file (logging.config.fileConfig format)
706737 6. pyproject.toml [tool.logging] section (lowest priority)
707-
738+
708739 The model_dump() method returns a dictionary that can be passed
709740 directly to logging.config.dictConfig().
710741 """
@@ -713,15 +744,15 @@ class LoggingSettings(BaseSettings):
713744 default = 1 ,
714745 description = "Configuration schema version"
715746 )
716- formatters : dict [str , FormatterConfig ] = Field (
747+ formatters : dict [str , FormatterConfig | CallableFactoryConfig ] = Field (
717748 default_factory = dict ,
718749 description = "Formatter configurations"
719750 )
720- filters : dict [str , FilterConfig ] = Field (
751+ filters : dict [str , FilterConfig | CallableFactoryConfig ] = Field (
721752 default_factory = dict ,
722753 description = "Filter configurations"
723754 )
724- handlers : dict [str , Any ] = Field (
755+ handlers : dict [str , HandlerConfig | CallableFactoryConfig ] = Field (
725756 default_factory = dict ,
726757 description = "Handler configurations"
727758 )
@@ -804,7 +835,7 @@ def settings_customise_sources(
804835 sources .append (JsonConfigSettingsSource (settings_cls , json_file = json_file ))
805836 elif Path ("logging.json" ).exists ():
806837 sources .append (JsonConfigSettingsSource (settings_cls , json_file = "logging.json" ))
807-
838+
808839 if toml_file and Path (toml_file ).exists ():
809840 sources .append (TomlConfigSettingsSource (settings_cls , toml_file = toml_file ))
810841 elif Path ("logging.toml" ).exists ():
@@ -814,7 +845,7 @@ def settings_customise_sources(
814845 sources .append (IniConfigSettingsSource (settings_cls , ini_file = ini_file ))
815846 elif Path ("logging.ini" ).exists ():
816847 sources .append (IniConfigSettingsSource (settings_cls , ini_file = "logging.ini" ))
817-
848+
818849 # Add pyproject.toml source (lowest priority file)
819850 if Path ("pyproject.toml" ).exists ():
820851 sources .append (
@@ -938,7 +969,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
938969 else :
939970 handlers [name ] = handler
940971 data ["handlers" ] = handlers
941-
972+
942973 # Convert formatter models to dicts
943974 if "formatters" in data :
944975 formatters = {}
@@ -958,7 +989,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
958989 else :
959990 filters [name ] = filter_config
960991 data ["filters" ] = filters
961-
992+
962993 # Convert logger models to dicts
963994 if "loggers" in data :
964995 loggers = {}
@@ -968,7 +999,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
968999 else :
9691000 loggers [name ] = logger
9701001 data ["loggers" ] = loggers
971-
1002+
9721003 # Convert root logger to dict
9731004 if "root" in data and isinstance (data ["root" ], BaseModel ):
9741005 data ["root" ] = data ["root" ].model_dump (exclude_none = True )
0 commit comments