Skip to content

Commit 70ef515

Browse files
committed
refactor #158: address review comments on pydantic error formatting
Signed-off-by: sushant-suse <[email protected]>
1 parent 7c7ce20 commit 70ef515

5 files changed

Lines changed: 123 additions & 113 deletions

File tree

src/docbuild/cli/cmd_cli.py

Lines changed: 37 additions & 63 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

@@ -47,6 +48,33 @@ def _setup_console() -> None:
4748
install_traceback(console=CONSOLE, show_locals=True)
4849

4950

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+
"""
63+
config_label = "Application" if model_class == AppConfig else "Environment"
64+
65+
if isinstance(e, ValidationError):
66+
# Safely extract the first config file name for the error header
67+
config_file = str((config_files or ["unknown"])[0])
68+
format_pydantic_error(
69+
e, model_class, config_file, verbose, console=CONSOLE
70+
)
71+
else:
72+
log.error("%s configuration failed validation:", config_label)
73+
log.error("Error in config file(s): %s", config_files)
74+
log.error(e)
75+
ctx.exit(1)
76+
77+
5078
@click.group(
5179
name=APP_NAME,
5280
context_settings={"show_default": True, "help_option_names": ["-h", "--help"]},
@@ -103,37 +131,24 @@ def cli(
103131
env_config: Path,
104132
**kwargs: dict,
105133
) -> None:
106-
"""Acts as a main entry point for CLI tool.
107-
108-
:param ctx: The Click context object.
109-
:param verbose: The verbosity level.
110-
:param dry_run: If set, just pretend to run the command without making any changes.
111-
:param debug: If set, enable debug mode.
112-
:param app_config: Filename to the application TOML config file.
113-
:param env_config: Filename to a environment's TOML config file.
114-
:param kwargs: Additional keyword arguments.
115-
"""
134+
"""Acts as a main entry point for CLI tool."""
116135
if ctx.invoked_subcommand is None:
117-
# If no subcommand is invoked, show the help message
118136
click.echo(10 * "-")
119137
click.echo(ctx.get_help())
120138
ctx.exit(0)
121139

122140
if ctx.obj is None:
123141
ctx.ensure_object(DocBuildContext)
124142

125-
# Build the context object
126143
context = ctx.obj
127144
context.verbose = verbose
128145
context.dry_run = dry_run
129146
context.debug = debug
130147

131-
# --- PHASE 1: Load and Validate Application Config (and setup logging) ---
132-
#
133-
# 1. Load the raw application config dictionary
148+
# --- PHASE 1: Load and Validate Application Config ---
134149
(
135150
context.appconfigfiles,
136-
raw_appconfig, # Store config as raw dictionary
151+
raw_appconfig,
137152
context.appconfig_from_defaults,
138153
) = handle_config(
139154
app_config,
@@ -143,39 +158,23 @@ def cli(
143158
DEFAULT_APP_CONFIG,
144159
)
145160

146-
# Explicitly cast the raw_appconfig type to silence Pylance
147161
raw_appconfig = cast(dict[str, Any], raw_appconfig)
148162

149-
# 2. Validate the raw config dictionary using Pydantic
150163
try:
151-
# Pydantic validation also handles placeholder replacement via @model_validator
152164
context.appconfig = AppConfig.from_dict(raw_appconfig)
153165
except (ValueError, ValidationError) as 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)
164-
ctx.exit(1)
166+
_handle_validation_error(e, AppConfig, context.appconfigfiles, verbose, ctx)
165167

166168
# 3. Setup logging using the validated config object
167-
# Use model_dump(by_alias=True) to ensure the 'class' alias is used.
168169
logging_config = context.appconfig.logging.model_dump(
169170
by_alias=True, exclude_none=True
170171
)
171172
setup_logging(cliverbosity=verbose, user_config={"logging": logging_config})
172173

173174
# --- PHASE 2: Load Environment Config, Validate, and Acquire Lock ---
174-
#
175-
# 1. Load raw Environment Config
176175
(
177176
context.envconfigfiles,
178-
raw_envconfig, # Renaming context.envconfig to raw_envconfig locally
177+
raw_envconfig,
179178
context.envconfig_from_defaults,
180179
) = handle_config(
181180
env_config,
@@ -185,57 +184,32 @@ def cli(
185184
DEFAULT_ENV_CONFIG,
186185
)
187186

188-
# Explicitly cast the raw_envconfig type to silence Pylance
189187
raw_envconfig = cast(dict[str, Any], raw_envconfig)
190188

191-
# 2. VALIDATE the raw environment config dictionary using Pydantic
192189
try:
193-
# Pydantic validation handles placeholder replacement via @model_validator
194-
# The result is the validated Pydantic object, stored in context.envconfig
195190
context.envconfig = EnvConfig.from_dict(raw_envconfig)
196191
except (ValueError, ValidationError) as e:
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-
)
210-
ctx.exit(1)
192+
_handle_validation_error(e, EnvConfig, context.envconfigfiles, verbose, ctx)
211193

212194
env_config_path = (context.envconfigfiles or [None])[0]
213195

214-
# --- CONCURRENCY CONTROL: Use explicit __enter__ and cleanup registration ---
196+
# --- CONCURRENCY CONTROL ---
215197
if env_config_path:
216-
# 1. Instantiate the lock object
217198
ctx.obj.env_lock = PidFileLock(resource_path=cast(Path, env_config_path))
218-
219199
try:
220-
# 2. Acquire the lock by explicitly calling the __enter__ method.
221200
ctx.obj.env_lock.__enter__()
222201
log.info("Acquired lock for environment config: %r", env_config_path.name)
223-
224202
except LockAcquisitionError as e:
225-
# Handles lock contention
226203
log.error(str(e))
227204
ctx.exit(1)
228205
except Exception as e:
229-
# Handles critical errors
230206
log.error("Failed to set up environment lock: %s", e)
231207
ctx.exit(1)
232208

233-
# 3. Register the lock's __exit__ method to be called when the context
234-
# terminates.
235209
ctx.call_on_close(lambda: ctx.obj.env_lock.__exit__(None, None, None))
236210

237211

238-
# Add subcommand
212+
# Add subcommands
239213
cli.add_command(build)
240214
cli.add_command(c14n)
241215
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: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
11
"""Utilities for handling and formatting application errors."""
22

3+
34
from pydantic import BaseModel, ValidationError
45
from rich.console import Console
56
from rich.text import Text
67

8+
from ..constants import DEFAULT_ERROR_LIMIT
9+
710

811
def format_pydantic_error(
912
error: ValidationError,
1013
model_class: type[BaseModel],
1114
config_file: str,
12-
verbose: int = 0
15+
verbose: int = 0,
16+
console: Console | None = None,
1317
) -> None:
1418
"""Centralized formatter for Pydantic ValidationErrors using Rich.
1519
1620
:param error: The caught ValidationError object.
1721
:param model_class: The Pydantic model class that failed validation.
1822
:param config_file: The name/path of the config file being processed.
1923
:param verbose: Verbosity level to control error detail.
24+
:param console: Optional Rich console object. If None, creates a stderr console.
2025
"""
21-
console = Console(stderr=True)
26+
# Use provided console or fall back to default (Dependency Injection)
27+
con = console or Console(stderr=True)
28+
2229
errors = error.errors()
2330
error_count = len(errors)
2431

@@ -29,16 +36,24 @@ def format_pydantic_error(
2936
(f"'{config_file}'", "bold cyan"),
3037
(":", "white")
3138
)
32-
console.print(header)
33-
console.print()
39+
con.print(header)
40+
con.print()
3441

35-
# Smart Truncation: Show only first 5 unless verbose
36-
max_display = 5 if verbose < 2 else error_count
42+
# Smart Truncation: Use the constant from constants.py
43+
max_display = DEFAULT_ERROR_LIMIT if verbose < 2 else error_count
3744
display_errors = errors[:max_display]
3845

3946
for i, err in enumerate(display_errors, 1):
4047
# 1. Resolve Location and Field Info
41-
loc_path = ".".join(str(v) for v in err["loc"])
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+
4257
err_type = err["type"]
4358
msg = err["msg"]
4459

@@ -47,21 +62,17 @@ def format_pydantic_error(
4762
current_model = model_class
4863

4964
for part in err["loc"]:
50-
# Check if current_model is a Pydantic class and contains the field
5165
if (isinstance(current_model, type) and
5266
issubclass(current_model, BaseModel) and
5367
part in current_model.model_fields):
5468

5569
field_info = current_model.model_fields[part]
5670

57-
# Move deeper into the tree if the annotation is another model
5871
annotation = field_info.annotation
5972
if (isinstance(annotation, type) and
6073
issubclass(annotation, BaseModel)):
6174
current_model = annotation
6275
else:
63-
# We have reached a leaf node or a complex type (List, etc.)
64-
# Stop traversing but keep the field_info
6576
current_model = None
6677
else:
6778
field_info = None
@@ -92,12 +103,12 @@ def format_pydantic_error(
92103
style="link underline blue"
93104
)
94105

95-
console.print(error_panel)
96-
console.print()
106+
con.print(error_panel)
107+
con.print()
97108

98109
# Footer for Truncation
99110
if error_count > max_display:
100-
console.print(
111+
con.print(
101112
f"[dim]... and {error_count - max_display} more errors. "
102113
"Use '-vv' to see all errors.[/dim]\n"
103114
)

tests/cli/test_cmd_cli.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ 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 with the new formatter."""
179+
"""Verify 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

@@ -201,7 +201,7 @@ def test_cli_config_validation_failure(
201201
else:
202202
mock_config_models["env_from_dict"].side_effect = mock_validation_error
203203

204-
# 3. Mock handle_config to return raw data successfully
204+
# 3. Mock handle_config
205205
def resolver(user_path, *a, **kw):
206206
if user_path == app_file:
207207
return (app_file,), {"raw_app_data": "x"}, False
@@ -210,7 +210,6 @@ def resolver(user_path, *a, **kw):
210210
fake_handle_config(resolver)
211211

212212
context = DocBuildContext()
213-
# Click runner captures stdout/stderr. Our rich formatter prints to stderr.
214213
result = runner.invoke(
215214
cli,
216215
["--app-config", str(app_file), "capture"],
@@ -226,9 +225,8 @@ def resolver(user_path, *a, **kw):
226225

227226
# Check that our new pretty-printer output exists in the captured terminal output
228227
assert "Validation error" in result.output
228+
# Check that the specific field causing the issue is mentioned
229229
assert "server.port" in result.output
230-
# Check for the Pydantic error message
231-
assert "Input should be a valid integer" in result.output
232230

233231

234232
def test_cli_verbose_and_debug(

0 commit comments

Comments
 (0)