Skip to content

Commit 7c7ce20

Browse files
committed
feat #158: implement pretty-printing for pydantic validation errors
Signed-off-by: sushant-suse <[email protected]>
1 parent ad033a3 commit 7c7ce20

5 files changed

Lines changed: 215 additions & 39 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.

src/docbuild/cli/cmd_cli.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ..logging import setup_logging
2424
from ..models.config.app import AppConfig
2525
from ..models.config.env import EnvConfig
26+
from ..utils.errors import format_pydantic_error
2627
from ..utils.pidlock import LockAcquisitionError, PidFileLock
2728
from .cmd_build import build
2829
from .cmd_c14n import c14n
@@ -150,9 +151,16 @@ def cli(
150151
# Pydantic validation also handles placeholder replacement via @model_validator
151152
context.appconfig = AppConfig.from_dict(raw_appconfig)
152153
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)
154+
if isinstance(e, ValidationError):
155+
# FIXED: Added fallback for config files to avoid subscripting None
156+
config_file = str((context.appconfigfiles or ["unknown"])[0])
157+
format_pydantic_error(
158+
e, AppConfig, config_file, context.verbose
159+
)
160+
else:
161+
log.error("Application configuration failed validation:")
162+
log.error("Error in config file(s): %s", context.appconfigfiles)
163+
log.error(e)
156164
ctx.exit(1)
157165

158166
# 3. Setup logging using the validated config object
@@ -186,20 +194,27 @@ def cli(
186194
# The result is the validated Pydantic object, stored in context.envconfig
187195
context.envconfig = EnvConfig.from_dict(raw_envconfig)
188196
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-
)
197+
if isinstance(e, ValidationError):
198+
# FIXED: Added fallback for config files to avoid subscripting None
199+
config_file = str((context.envconfigfiles or ["unknown"])[0])
200+
format_pydantic_error(
201+
e, EnvConfig, config_file, context.verbose
202+
)
203+
else:
204+
log.error(
205+
"Environment configuration failed validation: "
206+
"Error in config file(s): %s %s",
207+
context.envconfigfiles,
208+
e,
209+
)
195210
ctx.exit(1)
196211

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

199214
# --- CONCURRENCY CONTROL: Use explicit __enter__ and cleanup registration ---
200215
if env_config_path:
201216
# 1. Instantiate the lock object
202-
ctx.obj.env_lock = PidFileLock(resource_path=env_config_path)
217+
ctx.obj.env_lock = PidFileLock(resource_path=cast(Path, env_config_path))
203218

204219
try:
205220
# 2. Acquire the lock by explicitly calling the __enter__ method.
@@ -217,8 +232,6 @@ def cli(
217232

218233
# 3. Register the lock's __exit__ method to be called when the context
219234
# 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.
222235
ctx.call_on_close(lambda: ctx.obj.env_lock.__exit__(None, None, None))
223236

224237

src/docbuild/utils/errors.py

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

tests/cli/test_cmd_cli.py

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,11 @@ def test_cli_config_validation_failure(
176176
mock_config_models,
177177
is_app_config_failure,
178178
):
179-
"""Test that the CLI handles Pydantic validation errors gracefully."""
179+
"""Test that the CLI handles Pydantic validation errors with the new formatter."""
180180
app_file = app_config_file
181181
app_file.write_text("bad data")
182182

183-
# 1. Mock the log.error function to check output
183+
# 1. Mock the log.error function
184184
mock_log_error = Mock()
185185
monkeypatch.setattr(cli_mod.log, "error", mock_log_error)
186186

@@ -196,19 +196,12 @@ def test_cli_config_validation_failure(
196196
],
197197
)
198198

199-
# Define the simple error structure that the CLI error formatting relies on:
200-
# MOCK_ERROR_DETAIL = {
201-
# 'loc': ('server', 'port'),
202-
# 'msg': 'value is not a valid integer (mocked)',
203-
# 'input': 'not_an_int'
204-
# }
205-
206199
if is_app_config_failure:
207200
mock_config_models["app_from_dict"].side_effect = mock_validation_error
208201
else:
209202
mock_config_models["env_from_dict"].side_effect = mock_validation_error
210203

211-
# 3. Mock handle_config to return raw data successfully (no file read error)
204+
# 3. Mock handle_config to return raw data successfully
212205
def resolver(user_path, *a, **kw):
213206
if user_path == app_file:
214207
return (app_file,), {"raw_app_data": "x"}, False
@@ -217,6 +210,7 @@ def resolver(user_path, *a, **kw):
217210
fake_handle_config(resolver)
218211

219212
context = DocBuildContext()
213+
# Click runner captures stdout/stderr. Our rich formatter prints to stderr.
220214
result = runner.invoke(
221215
cli,
222216
["--app-config", str(app_file), "capture"],
@@ -227,23 +221,14 @@ def resolver(user_path, *a, **kw):
227221
# 4. Assertions
228222
assert result.exit_code == 1
229223

230-
if is_app_config_failure:
231-
assert (
232-
"Application configuration failed validation"
233-
in mock_log_error.call_args_list[0][0][0]
234-
)
235-
else:
236-
assert (
237-
"Environment configuration failed validation"
238-
in mock_log_error.call_args_list[0][0][0]
239-
)
240-
241-
# --- REMOVE FRAGILE ASSERTIONS ON LOG CALL COUNT ---
242-
# assert mock_log_error.call_count > 1
243-
# assert mock_log_error.call_count >= 2
244-
# assert any("Field: (" in call[0][0] for call in mock_log_error.call_args_list)
224+
# For ValidationErrors, the CLI now uses format_pydantic_error, NOT log.error
225+
assert mock_log_error.call_count == 0
245226

246-
assert mock_log_error.call_count >= 1
227+
# Check that our new pretty-printer output exists in the captured terminal output
228+
assert "Validation error" in result.output
229+
assert "server.port" in result.output
230+
# Check for the Pydantic error message
231+
assert "Input should be a valid integer" in result.output
247232

248233

249234
def test_cli_verbose_and_debug(

tests/utils/test_errors.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Tests for the Pydantic error formatting utility."""
2+
3+
from typing import Any
4+
5+
from pydantic import BaseModel, Field, ValidationError
6+
7+
from docbuild.utils.errors import format_pydantic_error
8+
9+
10+
class SubModel(BaseModel):
11+
"""A sub-model for testing nested validation."""
12+
13+
name: str = Field(title="Sub Name", description="A sub description")
14+
15+
16+
class MockModel(BaseModel):
17+
"""A mock model for testing top-level validation."""
18+
19+
age: int = Field(title="User Age")
20+
sub: SubModel
21+
22+
23+
def test_format_pydantic_error_smoke(capsys):
24+
"""Smoke test to ensure the formatter runs without crashing."""
25+
# Using Any to bypass static type checking for invalid data types
26+
invalid_data: dict[str, Any] = {"age": "not-an-int", "sub": {"name": 123}}
27+
28+
try:
29+
# Trigger a validation error
30+
MockModel(**invalid_data)
31+
except ValidationError as e:
32+
# Run the formatter
33+
format_pydantic_error(e, MockModel, "test.toml", verbose=1)
34+
35+
captured = capsys.readouterr()
36+
37+
# Check for key UI elements
38+
assert "Validation error" in captured.err
39+
assert "test.toml" in captured.err
40+
assert "User Age" in captured.err
41+
assert "A sub description" in captured.err
42+
assert "https://opensuse.github.io/docbuild/latest/errors/" in captured.err
43+
44+
45+
def test_format_pydantic_error_truncation(capsys):
46+
"""Verify that truncation message appears when many errors exist."""
47+
48+
class MultiModel(BaseModel):
49+
"""A model with many fields to trigger truncation."""
50+
51+
a: int
52+
b: int
53+
c: int
54+
d: int
55+
e: int
56+
f: int
57+
58+
# Cast to Any to bypass static type checking for the invalid input
59+
invalid_input: dict[str, Any] = {
60+
"a": "x",
61+
"b": "x",
62+
"c": "x",
63+
"d": "x",
64+
"e": "x",
65+
"f": "x",
66+
}
67+
68+
try:
69+
MultiModel(**invalid_input)
70+
except ValidationError as e:
71+
format_pydantic_error(e, MultiModel, "test.toml", verbose=0)
72+
73+
captured = capsys.readouterr()
74+
assert "... and 1 more errors" in captured.err

0 commit comments

Comments
 (0)