Skip to content

Commit 845e5a3

Browse files
committed
Add support for (): 'class' syntax
1 parent e141088 commit 845e5a3

File tree

5 files changed

+117
-35
lines changed

5 files changed

+117
-35
lines changed

src/pydantic_settings_logging/__init__.py

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from pathlib import Path
1212
from typing import Any, Literal
1313

14-
from pydantic import AliasChoices, BaseModel, Field, field_validator
14+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
1515
from pydantic_settings import (
1616
BaseSettings,
1717
PydanticBaseSettingsSource,
@@ -40,10 +40,22 @@
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
4456
class 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
7790
class 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
93107
class 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
422436
class 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)

tests/test_complex_scenarios.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,41 @@ def test_filters_configuration():
7676
assert config_dict["filters"]["myfilter"]["name"] == "myapp.specific"
7777
assert config_dict["filters"]["custom"]["class"] == "myapp.filters.CustomFilter"
7878
assert config_dict["handlers"]["filtered"]["filters"] == ["myfilter", "custom"]
79+
80+
81+
def test_callable_configuration():
82+
"""Test configuration with filters."""
83+
settings = LoggingSettings(
84+
filters={
85+
"myfilter": {
86+
"()": "myapp.filter",
87+
"arg1": "val1"
88+
},
89+
},
90+
formatters={
91+
"myformatter": {
92+
"()": "myapp.formatter",
93+
"arg2": "val2"
94+
},
95+
},
96+
handlers={
97+
"myhandler": {
98+
"()": "myapp.handler",
99+
"arg3": "val3"
100+
}
101+
}
102+
)
103+
104+
config_dict = settings.model_dump()
105+
106+
assert "myfilter" in config_dict["filters"]
107+
assert config_dict["filters"]["myfilter"]["()"] == "myapp.filter"
108+
assert config_dict["filters"]["myfilter"]["arg1"] == "val1"
109+
110+
assert "myformatter" in config_dict["formatters"]
111+
assert config_dict["formatters"]["myformatter"]["()"] == "myapp.formatter"
112+
assert config_dict["formatters"]["myformatter"]["arg2"] == "val2"
113+
114+
assert "myhandler" in config_dict["handlers"]
115+
assert config_dict["handlers"]["myhandler"]["()"] == "myapp.handler"
116+
assert config_dict["handlers"]["myhandler"]["arg3"] == "val3"

tests/test_direct_instantiation.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Test direct instantiation of models with class_ field."""
22

33
import pytest
4-
from pydantic import ValidationError
54

65
from pydantic_settings_logging import (
76
BaseHandlerConfig,
@@ -134,7 +133,7 @@ def test_mixed_instantiation_styles():
134133
"delay": False
135134
}
136135
assert data == expected
137-
136+
138137
# Recreate from dict (should use aliases)
139138
handler2 = FileHandlerConfig.model_validate(data)
140139
assert handler2.class_ == "logging.FileHandler"

tests/test_ini_compatibility.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
def test_complex_ini_configuration(tmp_logging_dir, multilogger_ini_config, config_file_factory):
88
"""Test loading complex INI configuration with multiple loggers."""
99
config_file_factory("logging.ini", multilogger_ini_config, "ini")
10-
10+
1111
with change_directory(tmp_logging_dir):
1212
settings = LoggingSettings()
1313
config_dict = settings.model_dump()
@@ -41,10 +41,10 @@ def test_ini_edge_cases(tmp_logging_dir, config_file_factory):
4141
keys=root
4242
4343
[handlers]
44-
keys=rotatingHandler
44+
keys=rotatingHandler,customHandler
4545
4646
[formatters]
47-
keys=detailedFormatter
47+
keys=detailedFormatter,customFormatter
4848
4949
[logger_root]
5050
level=DEBUG
@@ -60,10 +60,18 @@ def test_ini_edge_cases(tmp_logging_dir, config_file_factory):
6060
mode=a
6161
encoding=utf-8
6262
63+
[handler_customHandler]
64+
()=myapp.handler_factory
65+
arg1=val1
66+
6367
[formatter_detailedFormatter]
6468
format=%(asctime)s [%(process)d] %(name)s %(levelname)s: %(message)s
6569
datefmt=%Y-%m-%d %H:%M:%S
6670
validate=true
71+
72+
[formatter_customFormatter]
73+
()=myapp.formatter_factory
74+
arg2=val2
6775
"""
6876
config_file_factory("logging.ini", ini_content, "ini")
6977

@@ -78,9 +86,17 @@ def test_ini_edge_cases(tmp_logging_dir, config_file_factory):
7886
assert handler["backupCount"] == 3
7987
assert handler["mode"] == "a"
8088
assert handler["encoding"] == "utf-8"
81-
89+
90+
handler = config_dict["handlers"]["customHandler"]
91+
assert handler["()"] == "myapp.handler_factory"
92+
assert handler["arg1"] == "val1"
93+
8294
# Check formatter with validation
8395
formatter = config_dict["formatters"]["detailedFormatter"]
8496
assert formatter["format"] == "%(asctime)s [%(process)d] %(name)s %(levelname)s: %(message)s"
8597
assert formatter["datefmt"] == "%Y-%m-%d %H:%M:%S"
8698
assert formatter["validate"] is True
99+
100+
formatter = config_dict["formatters"]["customFormatter"]
101+
assert formatter["()"] == "myapp.formatter_factory"
102+
assert formatter["arg2"] == "val2"

tests/test_priority_order.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Tests for configuration source priority order."""
22

3-
import json
4-
53
from conftest import change_directory
64
from pydantic_settings_logging import LoggingSettings
75

0 commit comments

Comments
 (0)