Skip to content

Commit 150bfc4

Browse files
authored
feat: Implement pretty-printing for Pydantic validation errors (#182)
* feat #158: implement pretty-printing for pydantic validation errors Signed-off-by: sushant-suse <[email protected]> * refactor #158: address review comments on pydantic error formatting Signed-off-by: sushant-suse <[email protected]> * testing #182: Increase cmd_cli.py coverage to 98% by adding edge-case tests. Signed-off-by: sushant-suse <[email protected]> --------- Signed-off-by: sushant-suse <[email protected]>
1 parent 3c41cee commit 150bfc4

7 files changed

Lines changed: 406 additions & 168 deletions

File tree

changelog.d/158.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added a rich-formatted Pydantic validation error reporter for the CLI, providing field descriptions, expected values, and documentation links.

docs/source/user/config.rst

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ to the user running the script.
4848
Validating the Configuration
4949
-----------------------------
5050

51-
Before docbuild executes any commands, it validates the provided configuration
51+
Before ``docbuild`` executes any commands, it validates the provided configuration
5252
file against a predefined :term:`Pydantic` model.
5353

5454
The validation checks for different aspects of the configuration, such as:
@@ -59,11 +59,43 @@ The validation checks for different aspects of the configuration, such as:
5959
required.
6060
* Correct use of placeholders.
6161

62+
Detailed Validation Feedback
63+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
6264

63-
..
64-
TODO: Add a link to the TOML env config reference, add an example of
65-
a validation error message, and explain how to fix common issues.
66-
a validation error message, and explain how to fix common issues.
65+
If the configuration is invalid, the application provides a structured,
66+
color-coded error report to help you identify and fix the issues. Each error
67+
includes:
68+
69+
* **Location**: The exact path in the TOML file (e.g., ``server.host``).
70+
* **Field Info**: A human-readable title and description of the field's purpose.
71+
* **Error Detail**: A specific message explaining why the value failed validation.
72+
* **Documentation Link**: A direct URL to a reference page with more details
73+
on how to resolve that specific error type.
74+
75+
Example error output:
76+
77+
.. code-block:: text
78+
79+
1 Validation error in config file 'env.devel.toml':
80+
81+
(1) In 'server.enable_mail':
82+
Input should be a valid boolean, unable to interpret input
83+
Expected: Enable Email
84+
Description: Flag to enable email sending features.
85+
See: https://opensuse.github.io/docbuild/latest/errors/bool_parsing.html
86+
87+
Fixing Common Issues
88+
~~~~~~~~~~~~~~~~~~~~
89+
90+
If you encounter validation errors, check the following common causes:
91+
92+
* **Missing Keys**: Ensure all mandatory fields (like ``paths.root_config_dir``) are defined.
93+
* **Typing Errors**: Ensure booleans (``true``/``false``) and integers are not enclosed
94+
in quotes.
95+
* **Permission Issues**: If a path error occurs, verify that the user running
96+
``docbuild`` has the necessary read/write permissions for the specified directory.
97+
* **Circular Placeholders**: Ensure that your static placeholders do not point
98+
to each other in a loop (e.g., Key A referencing Key B, which references Key A).
6799

68100

69101
.. _config-placeholders:
@@ -120,7 +152,6 @@ Static placeholders follow a specific syntax:
120152
[paths]
121153
base_cache_dir = "/tmp/cache"
122154
base_server_cache_dir = "{base_cache_dir}/{server.name}"
123-
base_server_cache_dir = "{base_cache_dir}/{server.name}"
124155
125156
In this example, the key ``base_server_cache_dir`` uses the
126157
static placeholder ``{server.name}`` to reference the value of the
@@ -161,4 +192,4 @@ between configurations without needing to remember the exact command syntax.
161192
162193
alias docbuild-prod='docbuild --env-config env.production.toml'
163194
alias docbuild-test='docbuild --env-config env.testing.toml'
164-
alias docbuild-dev='docbuild --env-config env.devel.toml'
195+
alias docbuild-dev='docbuild --env-config env.devel.toml'

src/docbuild/cli/cmd_cli.py

Lines changed: 47 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Main CLI tool for document operations."""
22

3+
from collections.abc import Sequence
34
import logging
45
from pathlib import Path
56
import sys
67
from typing import Any, cast
78

89
import click
9-
from pydantic import ValidationError
10+
from pydantic import BaseModel, ValidationError
1011
import rich.console
1112
from rich.traceback import install as install_traceback
1213

@@ -23,6 +24,7 @@
2324
from ..logging import setup_logging
2425
from ..models.config.app import AppConfig
2526
from ..models.config.env import EnvConfig
27+
from ..utils.errors import format_pydantic_error
2628
from ..utils.pidlock import LockAcquisitionError, PidFileLock
2729
from .cmd_build import build
2830
from .cmd_c14n import c14n
@@ -46,6 +48,41 @@ def _setup_console() -> None:
4648
install_traceback(console=CONSOLE, show_locals=True)
4749

4850

51+
def handle_validation_error(
52+
e: Exception,
53+
model_class: type[BaseModel],
54+
config_files: Sequence[Path] | None,
55+
verbose: int,
56+
ctx: click.Context,
57+
) -> None:
58+
"""Format validation errors and exit the CLI.
59+
60+
Outsourced logic to avoid code duplication between App and Env config phases.
61+
Using Sequence[Path] ensures compatibility with both lists and tuples.
62+
:param e: The exception that was raised during validation.
63+
:param model_class: The Pydantic model class that was being validated
64+
(AppConfig or EnvConfig).
65+
:param config_files: The list of config files that were attempted to be loaded, used for error context.
66+
:param verbose: The verbosity level from the CLI options, which can be
67+
used to control the level of detail in the error output.
68+
:param ctx: The Click context, used to exit the CLI with an appropriate
69+
status code after handling the error.
70+
"""
71+
config_label = "Application" if model_class == AppConfig else "Environment"
72+
73+
if isinstance(e, ValidationError):
74+
# Safely extract the first config file name for the error header
75+
config_file = str((config_files or ["unknown"])[0])
76+
format_pydantic_error(
77+
e, model_class, config_file, verbose, console=CONSOLE
78+
)
79+
else:
80+
log.error("%s configuration failed validation:", config_label)
81+
log.error("Error in config file(s): %s", config_files)
82+
log.error(e)
83+
ctx.exit(1)
84+
85+
4986
@click.group(
5087
name=APP_NAME,
5188
context_settings={"show_default": True, "help_option_names": ["-h", "--help"]},
@@ -113,26 +150,22 @@ def cli(
113150
:param kwargs: Additional keyword arguments.
114151
"""
115152
if ctx.invoked_subcommand is None:
116-
# If no subcommand is invoked, show the help message
117153
click.echo(10 * "-")
118154
click.echo(ctx.get_help())
119155
ctx.exit(0)
120156

121157
if ctx.obj is None:
122158
ctx.ensure_object(DocBuildContext)
123159

124-
# Build the context object
125160
context = ctx.obj
126161
context.verbose = verbose
127162
context.dry_run = dry_run
128163
context.debug = debug
129164

130-
# --- PHASE 1: Load and Validate Application Config (and setup logging) ---
131-
#
132-
# 1. Load the raw application config dictionary
165+
# --- PHASE 1: Load and Validate Application Config ---
133166
(
134167
context.appconfigfiles,
135-
raw_appconfig, # Store config as raw dictionary
168+
raw_appconfig,
136169
context.appconfig_from_defaults,
137170
) = handle_config(
138171
app_config,
@@ -142,32 +175,23 @@ def cli(
142175
DEFAULT_APP_CONFIG,
143176
)
144177

145-
# Explicitly cast the raw_appconfig type to silence Pylance
146178
raw_appconfig = cast(dict[str, Any], raw_appconfig)
147179

148-
# 2. Validate the raw config dictionary using Pydantic
149180
try:
150-
# Pydantic validation also handles placeholder replacement via @model_validator
151181
context.appconfig = AppConfig.from_dict(raw_appconfig)
152182
except (ValueError, ValidationError) as e:
153-
log.error("Application configuration failed validation:")
154-
log.error("Error in config file(s): %s", context.appconfigfiles)
155-
log.error(e)
156-
ctx.exit(1)
183+
handle_validation_error(e, AppConfig, context.appconfigfiles, verbose, ctx)
157184

158185
# 3. Setup logging using the validated config object
159-
# Use model_dump(by_alias=True) to ensure the 'class' alias is used.
160186
logging_config = context.appconfig.logging.model_dump(
161187
by_alias=True, exclude_none=True
162188
)
163189
setup_logging(cliverbosity=verbose, user_config={"logging": logging_config})
164190

165191
# --- PHASE 2: Load Environment Config, Validate, and Acquire Lock ---
166-
#
167-
# 1. Load raw Environment Config
168192
(
169193
context.envconfigfiles,
170-
raw_envconfig, # Renaming context.envconfig to raw_envconfig locally
194+
raw_envconfig,
171195
context.envconfig_from_defaults,
172196
) = handle_config(
173197
env_config,
@@ -177,52 +201,32 @@ def cli(
177201
DEFAULT_ENV_CONFIG,
178202
)
179203

180-
# Explicitly cast the raw_envconfig type to silence Pylance
181204
raw_envconfig = cast(dict[str, Any], raw_envconfig)
182205

183-
# 2. VALIDATE the raw environment config dictionary using Pydantic
184206
try:
185-
# Pydantic validation handles placeholder replacement via @model_validator
186-
# The result is the validated Pydantic object, stored in context.envconfig
187207
context.envconfig = EnvConfig.from_dict(raw_envconfig)
188208
except (ValueError, ValidationError) as e:
189-
log.error(
190-
"Environment configuration failed validation: "
191-
"Error in config file(s): %s %s",
192-
context.envconfigfiles,
193-
e,
194-
)
195-
ctx.exit(1)
209+
handle_validation_error(e, EnvConfig, context.envconfigfiles, verbose, ctx)
196210

197-
env_config_path = context.envconfigfiles[0] if context.envconfigfiles else None
211+
env_config_path = (context.envconfigfiles or [None])[0]
198212

199-
# --- CONCURRENCY CONTROL: Use explicit __enter__ and cleanup registration ---
213+
# --- CONCURRENCY CONTROL ---
200214
if env_config_path:
201-
# 1. Instantiate the lock object
202-
ctx.obj.env_lock = PidFileLock(resource_path=env_config_path)
203-
215+
ctx.obj.env_lock = PidFileLock(resource_path=cast(Path, env_config_path))
204216
try:
205-
# 2. Acquire the lock by explicitly calling the __enter__ method.
206217
ctx.obj.env_lock.__enter__()
207218
log.info("Acquired lock for environment config: %r", env_config_path.name)
208-
209219
except LockAcquisitionError as e:
210-
# Handles lock contention
211220
log.error(str(e))
212221
ctx.exit(1)
213222
except Exception as e:
214-
# Handles critical errors
215223
log.error("Failed to set up environment lock: %s", e)
216224
ctx.exit(1)
217225

218-
# 3. Register the lock's __exit__ method to be called when the context
219-
# terminates.
220-
# We use a lambda to supply the three mandatory positional arguments (None)
221-
# expected by __exit__, satisfying the click.call_on_close requirement.
222226
ctx.call_on_close(lambda: ctx.obj.env_lock.__exit__(None, None, None))
223227

224228

225-
# Add subcommand
229+
# Add subcommands
226230
cli.add_command(build)
227231
cli.add_command(c14n)
228232
cli.add_command(config)

src/docbuild/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,8 @@
164164

165165
XMLDATADIR = Path(__file__).parent / "config" / "xml" / "data"
166166
"""Directory where additional files (RNC, XSLT) for XML processing are stored."""
167+
168+
# --- UI and Error Reporting Constants ---
169+
170+
DEFAULT_ERROR_LIMIT = 5
171+
"""The maximum number of validation errors to display before truncating the output."""

src/docbuild/utils/errors.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Utilities for handling and formatting application errors."""
2+
3+
4+
from pydantic import BaseModel, ValidationError
5+
from rich.console import Console
6+
from rich.text import Text
7+
8+
from ..constants import DEFAULT_ERROR_LIMIT
9+
10+
11+
def format_pydantic_error(
12+
error: ValidationError,
13+
model_class: type[BaseModel],
14+
config_file: str,
15+
verbose: int = 0,
16+
console: Console | None = None,
17+
) -> None:
18+
"""Centralized formatter for Pydantic ValidationErrors using Rich.
19+
20+
:param error: The caught ValidationError object.
21+
:param model_class: The Pydantic model class that failed validation.
22+
:param config_file: The name/path of the config file being processed.
23+
:param verbose: Verbosity level to control error detail.
24+
:param console: Optional Rich console object. If None, creates a stderr console.
25+
"""
26+
# Use provided console or fall back to default (Dependency Injection)
27+
con = console or Console(stderr=True)
28+
29+
errors = error.errors()
30+
error_count = len(errors)
31+
32+
# Header
33+
header = Text.assemble(
34+
(f"{error_count} Validation error{'s' if error_count > 1 else ''} ", "bold red"),
35+
("in config file ", "white"),
36+
(f"'{config_file}'", "bold cyan"),
37+
(":", "white")
38+
)
39+
con.print(header)
40+
con.print()
41+
42+
# Smart Truncation: Use the constant from constants.py
43+
max_display = DEFAULT_ERROR_LIMIT if verbose < 2 else error_count
44+
display_errors = errors[:max_display]
45+
46+
for i, err in enumerate(display_errors, 1):
47+
# 1. Resolve Location and Field Info
48+
# Filter out internal Pydantic tags (strings with '[' or '-')
49+
# to keep the path clean for end users.
50+
clean_loc = [
51+
str(v) for v in err["loc"]
52+
if not (isinstance(v, str) and ("[" in v or "-" in v))
53+
and not isinstance(v, int)
54+
]
55+
loc_path = ".".join(clean_loc)
56+
57+
err_type = err["type"]
58+
msg = err["msg"]
59+
60+
# 2. Extract Field Metadata from the Model Class
61+
field_info = None
62+
current_model = model_class
63+
64+
for part in err["loc"]:
65+
if (isinstance(current_model, type) and
66+
issubclass(current_model, BaseModel) and
67+
part in current_model.model_fields):
68+
69+
field_info = current_model.model_fields[part]
70+
71+
annotation = field_info.annotation
72+
if (isinstance(annotation, type) and
73+
issubclass(annotation, BaseModel)):
74+
current_model = annotation
75+
else:
76+
current_model = None
77+
else:
78+
field_info = None
79+
break
80+
81+
# 3. Build the Display
82+
error_panel = Text()
83+
error_panel.append(f"({i}) In '", style="white")
84+
error_panel.append(loc_path, style="bold yellow")
85+
error_panel.append("':\n", style="white")
86+
87+
# Error detail
88+
error_panel.append(f" {msg}\n", style="red")
89+
90+
# Helpful context from Field metadata
91+
if field_info:
92+
if field_info.title:
93+
error_panel.append(" Expected: ", style="dim")
94+
error_panel.append(f"{field_info.title}\n", style="italic green")
95+
if verbose > 0 and field_info.description:
96+
error_panel.append(" Description: ", style="dim")
97+
error_panel.append(f"{field_info.description}\n", style="dim italic")
98+
99+
# Documentation Link
100+
error_panel.append(" See: ", style="dim")
101+
error_panel.append(
102+
f"https://opensuse.github.io/docbuild/latest/errors/{err_type}.html",
103+
style="link underline blue"
104+
)
105+
106+
con.print(error_panel)
107+
con.print()
108+
109+
# Footer for Truncation
110+
if error_count > max_display:
111+
con.print(
112+
f"[dim]... and {error_count - max_display} more errors. "
113+
"Use '-vv' to see all errors.[/dim]\n"
114+
)

0 commit comments

Comments
 (0)