Skip to content

Commit 2546a23

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

File tree

5 files changed

+112
-34
lines changed

5 files changed

+112
-34
lines changed

src/pydantic_settings_logging/__init__.py

Lines changed: 53 additions & 26 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,6 +40,16 @@
4040
]
4141

4242

43+
class CallableFactoryConfig(BaseModel):
44+
model_config = ConfigDict(extra="allow", populate_by_name=True)
45+
46+
callable: str = Field(
47+
description="Custom callable",
48+
validation_alias=AliasChoices("callable", "()"),
49+
serialization_alias="()",
50+
)
51+
52+
4353
# Formatter Models
4454
class FormatterConfig(BaseModel):
4555
"""Configuration for a logging formatter."""
@@ -92,9 +102,9 @@ class FilterConfig(BaseModel):
92102
# Handler Models
93103
class BaseHandlerConfig(BaseModel):
94104
"""Base configuration for all handlers."""
95-
96-
model_config = {"extra": "allow"}
97-
105+
106+
model_config = ConfigDict(extra="allow")
107+
98108
class_: str = Field(
99109
validation_alias=AliasChoices("class_", "class"),
100110
serialization_alias="class",
@@ -389,7 +399,7 @@ class QueueHandlerConfig(BaseHandlerConfig):
389399
validation_alias=AliasChoices("class_", "class"),
390400
serialization_alias="class"
391401
)
392-
queue: str = Field(
402+
queue: str | CallableFactoryConfig = Field(
393403
description="Queue object reference"
394404
)
395405

@@ -402,7 +412,7 @@ class QueueListenerConfig(BaseHandlerConfig):
402412
validation_alias=AliasChoices("class_", "class"),
403413
serialization_alias="class"
404414
)
405-
queue: str = Field(
415+
queue: str | CallableFactoryConfig = Field(
406416
description="Queue object reference"
407417
)
408418
handlers: list[str] = Field(
@@ -421,7 +431,7 @@ class QueueListenerConfig(BaseHandlerConfig):
421431
# Logger configuration
422432
class LoggerConfig(BaseModel):
423433
"""Configuration for a logger."""
424-
434+
425435
level: str | None = Field(
426436
default=None,
427437
description="Logging level"
@@ -478,7 +488,7 @@ def _load_file(self) -> dict[str, Any]:
478488

479489
with open(self.toml_file, "rb") as f:
480490
data = tomllib.load(f)
481-
491+
482492
# Navigate to specified table if provided
483493
if self.toml_table:
484494
for key in self.toml_table:
@@ -508,11 +518,11 @@ def __init__(
508518
super().__init__(settings_cls)
509519
self.json_file = json_file
510520
self._data = self._load_file()
511-
521+
512522
def _load_file(self) -> dict[str, Any]:
513523
if not self.json_file or not Path(self.json_file).exists():
514524
return {}
515-
525+
516526
with open(self.json_file, "r") as f:
517527
return json.load(f)
518528

@@ -565,7 +575,7 @@ def _convert_ini_to_dictconfig(self, config: ConfigParser) -> dict[str, Any]:
565575
if key and config.has_section(f"formatter_{key}"):
566576
result["formatters"][key] = self._parse_formatter_section(config, f"formatter_{key}")
567577

568-
# Parse handlers
578+
# Parse handlers
569579
if config.has_section("handlers"):
570580
handler_keys = config.get("handlers", "keys", fallback="").split(",")
571581
for key in handler_keys:
@@ -583,7 +593,7 @@ def _convert_ini_to_dictconfig(self, config: ConfigParser) -> dict[str, Any]:
583593
result["root"] = self._parse_logger_section(config, f"logger_{key}", is_root=True)
584594
else:
585595
result["loggers"][key] = self._parse_logger_section(config, f"logger_{key}", is_root=False)
586-
596+
587597
# Parse global settings if they exist
588598
if config.has_option("DEFAULT", "disable_existing_loggers"):
589599
result["disable_existing_loggers"] = config.getboolean("DEFAULT", "disable_existing_loggers")
@@ -593,7 +603,7 @@ def _convert_ini_to_dictconfig(self, config: ConfigParser) -> dict[str, Any]:
593603
def _parse_formatter_section(self, config: ConfigParser, section: str) -> dict[str, Any]:
594604
"""Parse a formatter section."""
595605
formatter = {}
596-
606+
597607
if config.has_option(section, "format"):
598608
formatter["format"] = config.get(section, "format")
599609
if config.has_option(section, "datefmt"):
@@ -606,7 +616,24 @@ def _parse_formatter_section(self, config: ConfigParser, section: str) -> dict[s
606616
formatter["validate"] = config.getboolean(section, "validate")
607617
if config.has_option(section, "class"):
608618
formatter["class"] = config.get(section, "class")
609-
619+
620+
# Add any other options as extra fields
621+
for option in config.options(section):
622+
if option not in ["format", "datefmt", "style", "validate", "class"]:
623+
value = config.get(section, option)
624+
# Try to convert to appropriate type
625+
try:
626+
# Try boolean
627+
if value.lower() in ["true", "false"]:
628+
formatter[option] = config.getboolean(section, option)
629+
# Try integer
630+
elif value.isdigit():
631+
formatter[option] = config.getint(section, option)
632+
else:
633+
formatter[option] = value
634+
except (ValueError, AttributeError):
635+
formatter[option] = value
636+
610637
return formatter
611638

612639
def _parse_handler_section(self, config: ConfigParser, section: str) -> dict[str, Any]:
@@ -639,15 +666,15 @@ def _parse_handler_section(self, config: ConfigParser, section: str) -> dict[str
639666
# Handle args - this is complex as it can be a Python expression
640667
args_str = config.get(section, "args")
641668
# For basic cases, try to parse as a simple tuple
642-
if args_str == "()" or args_str == "()":
669+
if args_str == "()":
643670
handler["args"] = []
644671
elif args_str.startswith("(") and args_str.endswith(")"):
645672
# Store as string - the logging module will evaluate it
646673
handler["args"] = args_str
647-
674+
648675
# Add any other options as extra fields
649676
for option in config.options(section):
650-
if option not in ["class", "level", "formatter", "stream", "filename", "mode",
677+
if option not in ["class", "level", "formatter", "stream", "filename", "mode",
651678
"maxBytes", "backupCount", "when", "interval", "utc", "args"]:
652679
value = config.get(section, option)
653680
# Try to convert to appropriate type
@@ -704,7 +731,7 @@ class LoggingSettings(BaseSettings):
704731
4. logging.toml - TOML configuration file
705732
5. logging.ini - INI configuration file (logging.config.fileConfig format)
706733
6. pyproject.toml [tool.logging] section (lowest priority)
707-
734+
708735
The model_dump() method returns a dictionary that can be passed
709736
directly to logging.config.dictConfig().
710737
"""
@@ -713,15 +740,15 @@ class LoggingSettings(BaseSettings):
713740
default=1,
714741
description="Configuration schema version"
715742
)
716-
formatters: dict[str, FormatterConfig] = Field(
743+
formatters: dict[str, FormatterConfig | CallableFactoryConfig] = Field(
717744
default_factory=dict,
718745
description="Formatter configurations"
719746
)
720-
filters: dict[str, FilterConfig] = Field(
747+
filters: dict[str, FilterConfig | CallableFactoryConfig] = Field(
721748
default_factory=dict,
722749
description="Filter configurations"
723750
)
724-
handlers: dict[str, Any] = Field(
751+
handlers: dict[str, HandlerConfig | CallableFactoryConfig] = Field(
725752
default_factory=dict,
726753
description="Handler configurations"
727754
)
@@ -804,7 +831,7 @@ def settings_customise_sources(
804831
sources.append(JsonConfigSettingsSource(settings_cls, json_file=json_file))
805832
elif Path("logging.json").exists():
806833
sources.append(JsonConfigSettingsSource(settings_cls, json_file="logging.json"))
807-
834+
808835
if toml_file and Path(toml_file).exists():
809836
sources.append(TomlConfigSettingsSource(settings_cls, toml_file=toml_file))
810837
elif Path("logging.toml").exists():
@@ -814,7 +841,7 @@ def settings_customise_sources(
814841
sources.append(IniConfigSettingsSource(settings_cls, ini_file=ini_file))
815842
elif Path("logging.ini").exists():
816843
sources.append(IniConfigSettingsSource(settings_cls, ini_file="logging.ini"))
817-
844+
818845
# Add pyproject.toml source (lowest priority file)
819846
if Path("pyproject.toml").exists():
820847
sources.append(
@@ -938,7 +965,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
938965
else:
939966
handlers[name] = handler
940967
data["handlers"] = handlers
941-
968+
942969
# Convert formatter models to dicts
943970
if "formatters" in data:
944971
formatters = {}
@@ -958,7 +985,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
958985
else:
959986
filters[name] = filter_config
960987
data["filters"] = filters
961-
988+
962989
# Convert logger models to dicts
963990
if "loggers" in data:
964991
loggers = {}
@@ -968,7 +995,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]:
968995
else:
969996
loggers[name] = logger
970997
data["loggers"] = loggers
971-
998+
972999
# Convert root logger to dict
9731000
if "root" in data and isinstance(data["root"], BaseModel):
9741001
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)