Skip to content

Commit 2013c3f

Browse files
authored
Fix #88: Implement Pydantic validation for App Configuration (#91)
* Fix #88: Implement Pydantic validation for App Configuration Signed-off-by: sushant-suse <[email protected]> * fix: #88: moved the model to models folder Signed-off-by: sushant-suse <[email protected]> * fix: #88: added more verbose in the Field call Signed-off-by: sushant-suse <[email protected]> * fix #88: Implemented strict Pydantic validation for the Application Configuration (AppConfig) Signed-off-by: sushant-suse <[email protected]> * fix: #88: updated imports Signed-off-by: sushant-suse <[email protected]> * Fix: #88: updated app.py and added a new test file Signed-off-by: sushant-suse <[email protected]> * Fix: #88: Added CROSS-REFERENCE VALIDATION TESTS Signed-off-by: sushant-suse <[email protected]> * Fix #88: Update failing tests Signed-off-by: sushant-suse <[email protected]> * Fix: #88: covered the issue in pidlock.py file Signed-off-by: sushant-suse <[email protected]> * Fix: #88: reverted back pidlock Signed-off-by: sushant-suse <[email protected]> --------- Signed-off-by: sushant-suse <[email protected]>
1 parent 58dd622 commit 2013c3f

7 files changed

Lines changed: 442 additions & 43 deletions

File tree

changelog.d/91.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Implemented strict Pydantic validation for the Application Configuration (AppConfig), including a precise, nested schema for all logging parameters (formatters, handlers, loggers). This prevents runtime errors due to configuration typos and ensures data integrity at startup.

src/docbuild/cli/cmd_cli.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
import rich.logging
99
from rich.pretty import install
1010
from rich.traceback import install as install_traceback
11+
from pydantic import ValidationError
1112

1213
from ..__about__ import __version__
1314
from ..config.app import replace_placeholders
1415
from ..config.load import handle_config
16+
from ..models.config_model.app import AppConfig
1517
from ..constants import (
1618
APP_CONFIG_BASENAMES,
1719
APP_NAME,
@@ -124,10 +126,13 @@ def cli(
124126
context.verbose = verbose
125127
context.dry_run = dry_run
126128
context.debug = debug
127-
# Set the defaults. Will be overridden if config files are provided.
129+
130+
# --- PHASE 1: Load and Validate Application Config ---
131+
132+
# 1. Load the raw application config dictionary
128133
(
129134
context.appconfigfiles,
130-
context.appconfig,
135+
raw_appconfig, # Store config as raw dictionary
131136
context.appconfig_from_defaults,
132137
) = handle_config(
133138
app_config,
@@ -136,12 +141,25 @@ def cli(
136141
None,
137142
DEFAULT_APP_CONFIG,
138143
)
139-
context.appconfig = replace_placeholders(context.appconfig)
140144

141-
# Phase 2: Advanced logging setup with user configuration.
142-
logging_config = context.appconfig.get('logging', {})
145+
# 2. Validate the raw config dictionary using Pydantic
146+
try:
147+
# Pydantic validation also handles placeholder replacement via @model_validator
148+
context.appconfig = AppConfig.from_dict(raw_appconfig)
149+
except (ValueError, ValidationError) as e:
150+
log.error("Application configuration failed validation:")
151+
log.error("Error in config file(s): %s", context.appconfigfiles)
152+
log.error(e)
153+
ctx.exit(1)
154+
155+
# 3. Setup logging using the validated config object
156+
# Use model_dump(by_alias=True) to ensure the 'class' alias is used.
157+
logging_config = context.appconfig.logging.model_dump(by_alias=True, exclude_none=True)
143158
setup_logging(cliverbosity=verbose, user_config={'logging': logging_config})
144159

160+
# --- PHASE 2: Load Environment Config and Acquire Lock ---
161+
162+
# Load Environment Config (still returns raw dict)
145163
(
146164
context.envconfigfiles,
147165
context.envconfig,
@@ -153,8 +171,6 @@ def cli(
153171
DEFAULT_ENV_CONFIG_FILENAME,
154172
DEFAULT_ENV_CONFIG,
155173
)
156-
# The environment config file path is the resource ID for the lock.
157-
# The path is in context.envconfigfiles, which is a tuple of paths.
158174

159175
env_config_path = context.envconfigfiles[0] if context.envconfigfiles else None
160176

@@ -177,6 +193,7 @@ def cli(
177193
ctx.exit(1)
178194

179195
# Final config processing must happen outside the lock acquisition check
196+
# Note: Placeholder replacement is still necessary here because EnvConfig is not validated yet.
180197
context.envconfig = replace_placeholders(
181198
context.envconfig,
182199
)

src/docbuild/config/load.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Load and process configuration files.""" ''
1+
"""Load and process configuration files."""
22

33
from collections.abc import Iterable, Sequence
44
from itertools import product
@@ -7,15 +7,18 @@
77
from typing import Any
88

99
from ..constants import DEFAULT_ENV_CONFIG_FILENAME
10-
from .app import replace_placeholders
1110
from .merge import deep_merge
1211

1312

1413
def process_envconfig(envconfigfile: str | Path | None) -> tuple[Path, dict[str, Any]]:
1514
"""Process the env config.
1615
16+
Note: This function now returns the raw dictionary. Validation and
17+
placeholder replacement should be done by the caller using a Pydantic model
18+
(e.g., EnvConfig).
19+
1720
:param envconfigfile: Path to the env TOML config file.
18-
:return: Tuple of the env config file path and the config object.
21+
:return: Tuple of the env config file path and the config object (raw dict).
1922
:raise ValueError: If neither envconfigfile nor role is provided.
2023
"""
2124
if envconfigfile:
@@ -32,7 +35,7 @@ def process_envconfig(envconfigfile: str | Path | None) -> tuple[Path, dict[str,
3235
)
3336

3437
rawconfig = load_single_config(envconfigfile)
35-
return envconfigfile, replace_placeholders(rawconfig)
38+
return envconfigfile, rawconfig
3639

3740

3841
def load_single_config(configfile: str | Path) -> dict[str, Any]:
@@ -62,7 +65,7 @@ def load_and_merge_configs(
6265
:param defaults: a sequence of base filenames (without path!) to look for
6366
in the paths
6467
:param paths: the paths to look for config files (without the filename!)
65-
:return: the found config files and the merged dictionary
68+
:return: the found config files and the merged dictionary (raw dict)
6669
"""
6770
configs: Sequence[dict[str, Any]] = []
6871
configfiles: Sequence[Path] = []
@@ -95,6 +98,9 @@ def handle_config(
9598
) -> tuple[tuple[Path, ...] | None, object | dict, bool]:
9699
"""Return (config_files, config, from_defaults) for config file handling.
97100
101+
Note: The returned configuration is the **raw loaded dictionary**. No
102+
placeholder replacement or validation has been performed on it.
103+
98104
:param user_path: Path to the user-defined config file, if any.
99105
:param search_dirs: Iterable of directories to search for config files.
100106
:param basenames: Iterable of base filenames to search for.
@@ -122,4 +128,4 @@ def handle_config(
122128
if candidate.exists():
123129
return (candidate,), load_single_config(candidate), False
124130
# Not found, use defaults
125-
return None, default_config, True
131+
return None, default_config, True

src/docbuild/logging.py

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@
99
import queue
1010
import time
1111
from pathlib import Path
12-
from typing import Any
12+
from typing import Any, Sequence
1313

1414
from .constants import APP_NAME, BASE_LOG_DIR, GITLOGGER_NAME
1515

1616
# --- Default Logging Configuration ---
17-
# This dictionary provides a flexible, default setup that can be easily
18-
# overridden by a user's configuration file.
17+
# This dictionary provides the minimal required configuration structure.
1918
DEFAULT_LOGGING_CONFIG = {
2019
"version": 1,
2120
"disable_existing_loggers": False,
@@ -48,7 +47,7 @@
4847
"level": "DEBUG",
4948
"propagate": False,
5049
},
51-
GITLOGGER_NAME: { # Use the constant here
50+
GITLOGGER_NAME: {
5251
"handlers": ["console", "file"],
5352
"level": "DEBUG",
5453
"propagate": False,
@@ -68,10 +67,9 @@
6867

6968
def create_base_log_dir(base_log_dir: str | Path = BASE_LOG_DIR) -> Path:
7069
"""Create the base log directory if it doesn't exist.
71-
72-
This directory is typically located at :file:`~/.local/state/docbuild/logs`
70+
71+
This directory is typically located at :file:`~/.local/state/docbuild/logs`
7372
as per the XDG Base Directory Specification.
74-
7573
:param base_log_dir: The base directory where logs should be stored.
7674
Considers the `XDG_STATE_HOME` environment variable if set.
7775
:return: The path to the base log directory.
@@ -92,14 +90,14 @@ def setup_logging(
9290
) -> None:
9391
"""Sets up a non-blocking, configurable logging system.
9492
95-
:param cliverbosity: ...
96-
:param user_config: ...
93+
This function merges the default logging configuration with the validated
94+
user configuration and sets up the asynchronous handlers.
9795
"""
9896
config = copy.deepcopy(DEFAULT_LOGGING_CONFIG)
9997

10098
if user_config and "logging" in user_config:
10199
# Use a more robust deep merge approach
102-
def deep_merge(target, source):
100+
def deep_merge(target: dict, source: dict) -> None:
103101
for k, v in source.items():
104102
if k in target and isinstance(target[k], dict) and isinstance(v, dict):
105103
deep_merge(target[k], v)
@@ -109,31 +107,63 @@ def deep_merge(target, source):
109107
deep_merge(config, user_config.get("logging", {}))
110108

111109
# --- Verbosity & Log File Path Setup ---
112-
# The handler's level determines what gets printed/written.
113110
verbosity_level = LOGLEVELS.get(min(cliverbosity, 2), logging.WARNING)
114111
config["handlers"]["console"]["level"] = logging.getLevelName(verbosity_level)
115112

116113
log_dir = create_base_log_dir()
117-
# Use a timestamp to generate a unique filename for each run.
118114
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S")
119115
log_filename = f"{APP_NAME}_{timestamp}.log"
120116
log_path = log_dir / log_filename
121117
config["handlers"]["file"]["filename"] = str(log_path)
122118

119+
built_handlers = []
120+
123121
# --- Handler and Listener Initialization ---
124122
built_handlers = []
123+
124+
# Required keys to ignore when instantiating a handler (they are for dictConfig lookup)
125+
HANDLER_INTERNAL_KEYS = ["class", "formatter", "level", "class_name"]
126+
FORMATTER_INTERNAL_KEYS = ["class", "formatter", "level", "class_name", "validate"] # Added 'validate'
127+
125128
for hname, hconf in config["handlers"].items():
126129
cls = _resolve_class(hconf["class"])
130+
127131
handler_args = {
128-
k: v for k, v in hconf.items() if k not in ["class", "formatter", "level"]
132+
k: v for k, v in hconf.items() if k not in HANDLER_INTERNAL_KEYS
129133
}
134+
130135
handler = cls(**handler_args)
131136

132137
handler.setLevel(hconf.get("level", "NOTSET"))
133-
fmt_conf = config["formatters"][hconf["formatter"]]
134-
handler.setFormatter(logging.Formatter(fmt_conf["format"], fmt_conf.get("datefmt")))
138+
139+
formatter_name = hconf.get("formatter")
140+
if formatter_name and formatter_name in config["formatters"]:
141+
fmt_conf = config["formatters"][formatter_name]
142+
143+
# --- FIX APPLIED HERE ---
144+
# Filter out keys that are Pydantic metadata/aliases (like 'class_name')
145+
# We specifically filter out 'format' because the Formatter.__init__
146+
# expects 'fmt' or uses 'style', not 'format' directly as a kwarg.
147+
formatter_kwargs = {
148+
k: v for k, v in fmt_conf.items()
149+
if k not in FORMATTER_INTERNAL_KEYS and k not in ['format']
150+
}
151+
152+
# Pass 'fmt' instead of 'format' to the standard Formatter constructor
153+
formatter_kwargs['fmt'] = fmt_conf.get("format")
154+
formatter_kwargs['datefmt'] = fmt_conf.get("datefmt")
155+
formatter_kwargs['style'] = fmt_conf.get("style") # Should pass style if present
156+
157+
# Remove keys that are None
158+
formatter_kwargs = {k: v for k, v in formatter_kwargs.items() if v is not None}
159+
160+
fmt_cls = _resolve_class(fmt_conf.get("class", "logging.Formatter"))
161+
162+
handler.setFormatter(fmt_cls(**formatter_kwargs))
163+
135164
built_handlers.append(handler)
136165

166+
# --- Asynchronous Queue Setup ---
137167
log_queue = queue.Queue(-1)
138168
queue_handler = logging.handlers.QueueHandler(log_queue)
139169
listener = logging.handlers.QueueListener(
@@ -143,7 +173,6 @@ def deep_merge(target, source):
143173
atexit.register(listener.stop)
144174

145175
# --- Logger Initialization ---
146-
# Attach the QueueHandler to all loggers and set their levels.
147176
for lname, lconf in config["loggers"].items():
148177
logger = logging.getLogger(lname)
149178
logger.setLevel(lconf["level"])

0 commit comments

Comments
 (0)