Skip to content

Commit 2309ed5

Browse files
authored
fix(cli): handle TOML syntax errors gracefully (#221)
Improved error handling for configuration loading. Malformed TOML files now trigger a formatted diagnostic message with line and column details instead of a Python traceback. Signed-off-by: sushant-suse <[email protected]>
1 parent 562349f commit 2309ed5

5 files changed

Lines changed: 179 additions & 59 deletions

File tree

changelog.d/175.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improved error handling for configuration loading. Malformed TOML files now trigger a formatted diagnostic message with line and column details instead of a Python traceback.

src/docbuild/cli/cmd_cli.py

Lines changed: 81 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
from pathlib import Path
66
import sys
7+
import tomllib
78
from typing import Any, cast
89

910
import click
@@ -24,7 +25,7 @@
2425
from ..logging import setup_logging
2526
from ..models.config.app import AppConfig
2627
from ..models.config.env import EnvConfig
27-
from ..utils.errors import format_pydantic_error
28+
from ..utils.errors import format_pydantic_error, format_toml_error
2829
from ..utils.pidlock import LockAcquisitionError, PidFileLock
2930
from .cmd_build import build
3031
from .cmd_c14n import c14n
@@ -68,21 +69,70 @@ def handle_validation_error(
6869
:param ctx: The Click context, used to exit the CLI with an appropriate
6970
status code after handling the error.
7071
"""
71-
config_label = "Application" if model_class == AppConfig else "Environment"
72+
# Determine which file we were working on
73+
config_file = str((config_files or ["unknown"])[0])
7274

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-
)
75+
if isinstance(e, tomllib.TOMLDecodeError):
76+
format_toml_error(e, config_file, console=CONSOLE)
77+
elif isinstance(e, ValidationError):
78+
format_pydantic_error(e, model_class, config_file, verbose, console=CONSOLE)
7979
else:
80-
log.error("%s configuration failed validation:", config_label)
80+
config_label = "Application" if model_class == AppConfig else "Environment"
81+
log.error("%s configuration failed:", config_label)
8182
log.error("Error in config file(s): %s", config_files)
8283
log.error(e)
8384
ctx.exit(1)
8485

8586

87+
def load_app_config(
88+
ctx: click.Context,
89+
app_config: Path,
90+
max_workers: str | None
91+
) -> None:
92+
"""Load and validate Application configuration.
93+
94+
:param ctx: The Click context object. The result will be added to ``ctx.obj.appconfig``.
95+
:param app_config: The path to the application config file provided via CLI.
96+
:param max_workers: The max_workers value from CLI options.
97+
"""
98+
context = ctx.obj
99+
result = handle_config(
100+
app_config,
101+
CONFIG_PATHS,
102+
APP_CONFIG_BASENAMES + PROJECT_LEVEL_APP_CONFIG_FILENAMES,
103+
None,
104+
DEFAULT_APP_CONFIG,
105+
)
106+
context.appconfigfiles, raw_appconfig, context.appconfig_from_defaults = cast(
107+
tuple[tuple[Path, ...] | None, dict[str, Any], bool], result
108+
)
109+
110+
if max_workers is not None:
111+
raw_appconfig["max_workers"] = max_workers
112+
113+
context.appconfig = AppConfig.from_dict(raw_appconfig)
114+
115+
116+
def load_env_config(ctx: click.Context, env_config: Path) -> None:
117+
"""Load and validate Environment configuration.
118+
119+
:param ctx: The Click context object. The result will be added to ``ctx.obj.envconfig``.
120+
:param env_config: The path to the environment config file provided via CLI.
121+
"""
122+
context = ctx.obj
123+
result = handle_config(
124+
env_config,
125+
(PROJECT_DIR,),
126+
None,
127+
DEFAULT_ENV_CONFIG_FILENAME,
128+
DEFAULT_ENV_CONFIG,
129+
)
130+
context.envconfigfiles, raw_envconfig, context.envconfig_from_defaults = cast(
131+
tuple[tuple[Path, ...] | None, dict[str, Any], bool], result
132+
)
133+
134+
context.envconfig = EnvConfig.from_dict(raw_envconfig)
135+
86136
@click.group(
87137
name=APP_NAME,
88138
context_settings={"show_default": True, "help_option_names": ["-h", "--help"]},
@@ -167,76 +217,48 @@ def cli(
167217
ctx.ensure_object(DocBuildContext)
168218

169219
context = ctx.obj
170-
context.verbose = verbose
171-
context.dry_run = dry_run
172-
context.debug = debug
173-
174-
# --- PHASE 1: Load and Validate Application Config ---
175-
(
176-
context.appconfigfiles,
177-
raw_appconfig,
178-
context.appconfig_from_defaults,
179-
) = handle_config(
180-
app_config,
181-
CONFIG_PATHS,
182-
APP_CONFIG_BASENAMES + PROJECT_LEVEL_APP_CONFIG_FILENAMES,
183-
None,
184-
DEFAULT_APP_CONFIG,
185-
)
186-
187-
raw_appconfig = cast(dict[str, Any], raw_appconfig)
220+
context.verbose, context.dry_run, context.debug = verbose, dry_run, debug
188221

189-
if max_workers is not None:
190-
raw_appconfig["max_workers"] = max_workers
222+
# State tracking for centralized error handling
223+
current_model: type[BaseModel] = AppConfig
224+
current_files: Sequence[Path] | None = None
191225

192226
try:
193-
context.appconfig = AppConfig.from_dict(raw_appconfig)
194-
except (ValueError, ValidationError) as e:
195-
handle_validation_error(e, AppConfig, context.appconfigfiles, verbose, ctx)
227+
# --- PHASE 1: Load Application Config ---
228+
current_model = AppConfig
229+
current_files = (app_config,) if app_config else None
230+
load_app_config(ctx, app_config, max_workers)
196231

197-
# 3. Setup logging using the validated config object
198-
logging_config = context.appconfig.logging.model_dump(
199-
by_alias=True, exclude_none=True
200-
)
201-
setup_logging(cliverbosity=verbose, user_config={"logging": logging_config})
202-
203-
# --- PHASE 2: Load Environment Config, Validate, and Acquire Lock ---
204-
(
205-
context.envconfigfiles,
206-
raw_envconfig,
207-
context.envconfig_from_defaults,
208-
) = handle_config(
209-
env_config,
210-
(PROJECT_DIR,),
211-
None,
212-
DEFAULT_ENV_CONFIG_FILENAME,
213-
DEFAULT_ENV_CONFIG,
214-
)
232+
# Setup logging
233+
logging_config = context.appconfig.logging.model_dump(
234+
by_alias=True, exclude_none=True
235+
)
236+
setup_logging(cliverbosity=verbose, user_config={"logging": logging_config})
215237

216-
raw_envconfig = cast(dict[str, Any], raw_envconfig)
238+
# --- PHASE 2: Load Environment Config ---
239+
current_model = EnvConfig
240+
current_files = (env_config,) if env_config else None
241+
load_env_config(ctx, env_config)
217242

218-
try:
219-
context.envconfig = EnvConfig.from_dict(raw_envconfig)
220-
except (ValueError, ValidationError) as e:
221-
handle_validation_error(e, EnvConfig, context.envconfigfiles, verbose, ctx)
243+
except (ValueError, ValidationError, tomllib.TOMLDecodeError) as e:
244+
handle_validation_error(e, current_model, current_files, verbose, ctx)
222245

246+
# --- PHASE 3: Setup Concurrency Lock ---
247+
# (Remains outside the try block as it has its own specialized error handling)
223248
env_config_path = (context.envconfigfiles or [None])[0]
224-
225-
# --- CONCURRENCY CONTROL ---
226249
if env_config_path:
227250
ctx.obj.env_lock = PidFileLock(resource_path=cast(Path, env_config_path))
228251
try:
229252
ctx.obj.env_lock.__enter__()
230253
log.info("Acquired lock for environment config: %r", env_config_path.name)
254+
ctx.call_on_close(lambda: ctx.obj.env_lock.__exit__(None, None, None))
231255
except LockAcquisitionError as e:
232256
log.error(str(e))
233257
ctx.exit(1)
234258
except Exception as e:
235259
log.error("Failed to set up environment lock: %s", e)
236260
ctx.exit(1)
237261

238-
ctx.call_on_close(lambda: ctx.obj.env_lock.__exit__(None, None, None))
239-
240262

241263
# Add subcommands
242264
cli.add_command(build)

src/docbuild/utils/errors.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Utilities for handling and formatting application errors."""
22

33

4+
import tomllib
5+
46
from pydantic import BaseModel, ValidationError
57
from rich.console import Console
68
from rich.text import Text
@@ -112,3 +114,30 @@ def format_pydantic_error(
112114
f"[dim]... and {error_count - max_display} more errors. "
113115
"Use '-vv' to see all errors.[/dim]\n"
114116
)
117+
118+
def format_toml_error(
119+
error: tomllib.TOMLDecodeError,
120+
config_file: str,
121+
console: Console | None = None,
122+
) -> None:
123+
"""Format TOML syntax errors using Rich.
124+
125+
:param error: The caught TOMLDecodeError object.
126+
:param config_file: The name/path of the config file with the syntax error.
127+
:param console: Optional Rich console object.
128+
"""
129+
con = console or Console(stderr=True)
130+
131+
header = Text.assemble(
132+
("Syntax error ", "bold red"),
133+
("in config file ", "white"),
134+
(f"'{config_file}'", "bold cyan"),
135+
(":", "white")
136+
)
137+
con.print(header)
138+
139+
# tomllib error messages include the line and column info naturally
140+
con.print(f" [red]{error}[/red]")
141+
con.print()
142+
con.print(" [dim]Please verify that the file is a valid TOML file.[/dim]")
143+
con.print(" [dim]Note: Booleans must be lowercase (true/false) in TOML.[/dim]")

tests/cli/test_cmd_cli.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for the main CLI entry point and configuration loading flow."""
22

33
from pathlib import Path
4+
import tomllib
45
from unittest.mock import Mock, patch
56

67
import click
@@ -267,3 +268,49 @@ def resolver(user_path, *args, **kwargs):
267268
assert result.exit_code == 0
268269
assert context.verbose == 3
269270
assert context.debug is True
271+
272+
273+
@pytest.mark.parametrize("is_app_config_failure", [True, False])
274+
def test_cli_toml_syntax_error(
275+
runner,
276+
fake_handle_config,
277+
mock_config_models,
278+
is_app_config_failure,
279+
):
280+
"""Verify that the CLI handles TOML syntax errors gracefully."""
281+
282+
def resolver_with_syntax_error(user_path, *args, **kwargs):
283+
# If we are testing Env failure, let the App config pass first
284+
if not is_app_config_failure and "default_app" in str(user_path):
285+
return (Path("app.toml"),), {"logging": {"version": 1}}, False
286+
287+
raise tomllib.TOMLDecodeError("Invalid value", "test.toml", 0)
288+
289+
fake_handle_config(resolver_with_syntax_error)
290+
291+
result = runner.invoke(cli, ["capture"])
292+
293+
assert result.exit_code == 1
294+
assert "Syntax error in config file" in result.output
295+
assert "Invalid value" in result.output
296+
assert "Booleans must be lowercase" in result.output
297+
assert "Traceback (most recent call last)" not in result.output
298+
299+
300+
def test_load_config_helpers_populate_context(fake_handle_config, mock_config_models):
301+
"""Verify that the refactored helper functions correctly update the context object."""
302+
from docbuild.cli.cmd_cli import load_app_config, load_env_config
303+
304+
# Setup mock resolver
305+
fake_handle_config(lambda *a, **k: ((Path("test.toml"),), {"key": "val"}, False))
306+
307+
mock_ctx = Mock()
308+
mock_ctx.obj = DocBuildContext()
309+
310+
# Test App Loader
311+
load_app_config(mock_ctx, Path("app.toml"), max_workers="5")
312+
assert mock_ctx.obj.appconfig is not None
313+
314+
# Test Env Loader
315+
load_env_config(mock_ctx, Path("env.toml"))
316+
assert mock_ctx.obj.envconfig is not None

tests/utils/test_errors.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the Pydantic error formatting utility."""
22

3+
import tomllib
34
from typing import Any
45

56
from pydantic import BaseModel, Field, IPvAnyAddress, ValidationError, create_model
@@ -94,3 +95,23 @@ class UnionModel(BaseModel):
9495
assert "In 'host':" in captured.err
9596
# Verify no bracketed pydantic internals leaked in the path part
9697
assert "[" not in captured.err.split("In '")[1].split("':")[0]
98+
99+
100+
def test_format_toml_error_smoke(capsys):
101+
"""Verify that the TOML syntax error formatter prints correctly."""
102+
from docbuild.utils.errors import format_toml_error
103+
104+
# 1. Manually trigger a TOML syntax error
105+
bad_toml_content = "enable_mail = True" # Invalid: Capital T
106+
try:
107+
tomllib.loads(bad_toml_content)
108+
except tomllib.TOMLDecodeError as e:
109+
# 2. Call our new formatter
110+
format_toml_error(e, "env.devel.toml")
111+
112+
captured = capsys.readouterr()
113+
114+
# 3. Assertions to verify the "Rich" output content
115+
assert "Syntax error in config file 'env.devel.toml'" in captured.err
116+
assert "Invalid value" in captured.err
117+
assert "Booleans must be lowercase" in captured.err

0 commit comments

Comments
 (0)