Skip to content

Commit 0c4af27

Browse files
committed
fix(cli) #175: handle TOML syntax errors gracefully
Signed-off-by: sushant-suse <[email protected]>
1 parent d5e4014 commit 0c4af27

4 files changed

Lines changed: 124 additions & 32 deletions

File tree

src/docbuild/cli/cmd_cli.py

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydantic import BaseModel, ValidationError
1111
import rich.console
1212
from rich.traceback import install as install_traceback
13+
import tomllib
1314

1415
from ..__about__ import __version__
1516
from ..config.load import handle_config
@@ -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,16 +69,16 @@ 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)
@@ -171,18 +172,26 @@ def cli(
171172
context.dry_run = dry_run
172173
context.debug = debug
173174

175+
# --- INITIALIZE TO AVOID UNBOUND ERRORS ---
176+
raw_appconfig: dict[str, Any] = {}
177+
raw_envconfig: dict[str, Any] = {}
178+
174179
# --- 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-
)
180+
try:
181+
# We cast the return of handle_config to the expected tuple type
182+
result = handle_config(
183+
app_config,
184+
CONFIG_PATHS,
185+
APP_CONFIG_BASENAMES + PROJECT_LEVEL_APP_CONFIG_FILENAMES,
186+
None,
187+
DEFAULT_APP_CONFIG,
188+
)
189+
context.appconfigfiles, raw_appconfig, context.appconfig_from_defaults = cast(
190+
tuple[tuple[Path, ...] | None, dict[str, Any], bool], result
191+
)
192+
except tomllib.TOMLDecodeError as e:
193+
files = (app_config,) if app_config else None
194+
handle_validation_error(e, AppConfig, files, verbose, ctx)
186195

187196
raw_appconfig = cast(dict[str, Any], raw_appconfig)
188197

@@ -200,18 +209,21 @@ def cli(
200209
)
201210
setup_logging(cliverbosity=verbose, user_config={"logging": logging_config})
202211

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-
)
212+
# --- PHASE 2: Load Environment Config ---
213+
try:
214+
result = handle_config(
215+
env_config,
216+
(PROJECT_DIR,),
217+
None,
218+
DEFAULT_ENV_CONFIG_FILENAME,
219+
DEFAULT_ENV_CONFIG,
220+
)
221+
context.envconfigfiles, raw_envconfig, context.envconfig_from_defaults = cast(
222+
tuple[tuple[Path, ...] | None, dict[str, Any], bool], result
223+
)
224+
except tomllib.TOMLDecodeError as e:
225+
files = (env_config,) if env_config else None
226+
handle_validation_error(e, EnvConfig, files, verbose, ctx)
215227

216228
raw_envconfig = cast(dict[str, Any], raw_envconfig)
217229

src/docbuild/utils/errors.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pydantic import BaseModel, ValidationError
55
from rich.console import Console
66
from rich.text import Text
7+
import tomllib
78

89
from ..constants import DEFAULT_ERROR_LIMIT
910

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

tests/cli/test_cmd_cli.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import click
77
from pydantic import ValidationError
88
import pytest
9+
import tomllib
910

1011
import docbuild.cli.cmd_cli as cli_mod
1112
from docbuild.cli.context import DocBuildContext
@@ -267,3 +268,32 @@ 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 (Issue #175)."""
281+
282+
# Define a resolver that raises TOMLDecodeError
283+
def resolver_with_syntax_error(user_path, *args, **kwargs):
284+
# We simulate the exact error raised by tomllib
285+
raise tomllib.TOMLDecodeError("Invalid value", "test.toml", 0)
286+
287+
fake_handle_config(resolver_with_syntax_error)
288+
289+
# Invoke the CLI. Whether it's app or env config, the handle_config
290+
# call is now wrapped in a try/except in cmd_cli.py
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+
# Verify our specific 'Note' from the formatter appears
297+
assert "Booleans must be lowercase" in result.output
298+
# Crucially, ensure no raw Python traceback is leaked to the user
299+
assert "Traceback (most recent call last)" not in result.output

tests/utils/test_errors.py

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

33
from typing import Any
4+
import tomllib
45

56
from pydantic import BaseModel, Field, IPvAnyAddress, ValidationError, create_model
67
from rich.console import Console
@@ -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)