Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 96 additions & 61 deletions src/docbuild/cli/cmd_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import logging
from pathlib import Path
import sys
import tomllib
from typing import Any, cast

import click
Expand All @@ -24,7 +25,7 @@
from ..logging import setup_logging
from ..models.config.app import AppConfig
from ..models.config.env import EnvConfig
from ..utils.errors import format_pydantic_error
from ..utils.errors import format_pydantic_error, format_toml_error
from ..utils.pidlock import LockAcquisitionError, PidFileLock
from .cmd_build import build
from .cmd_c14n import c14n
Expand Down Expand Up @@ -68,21 +69,98 @@ def handle_validation_error(
:param ctx: The Click context, used to exit the CLI with an appropriate
status code after handling the error.
"""
config_label = "Application" if model_class == AppConfig else "Environment"
# Determine which file we were working on
config_file = str((config_files or ["unknown"])[0])

if isinstance(e, ValidationError):
# Safely extract the first config file name for the error header
config_file = str((config_files or ["unknown"])[0])
format_pydantic_error(
e, model_class, config_file, verbose, console=CONSOLE
)
if isinstance(e, tomllib.TOMLDecodeError):
format_toml_error(e, config_file, console=CONSOLE)
elif isinstance(e, ValidationError):
format_pydantic_error(e, model_class, config_file, verbose, console=CONSOLE)
else:
log.error("%s configuration failed validation:", config_label)
config_label = "Application" if model_class == AppConfig else "Environment"
log.error("%s configuration failed:", config_label)
log.error("Error in config file(s): %s", config_files)
log.error(e)
ctx.exit(1)


def _load_app_config(
Comment thread
sushant-suse marked this conversation as resolved.
Outdated
ctx: click.Context,
app_config: Path,
verbose: int,
max_workers: str | None
) -> None:
"""Load and validate Application configuration.

Args:
ctx: The Click context object.
app_config: The path to the application config file provided via CLI.
verbose: The verbosity level from CLI options.
max_workers: The max_workers value from CLI options.

"""
context = ctx.obj
# Initialize to satisfy Pylance control-flow analysis
raw_appconfig: dict[str, Any] = {}

try:
result = handle_config(
app_config,
CONFIG_PATHS,
APP_CONFIG_BASENAMES + PROJECT_LEVEL_APP_CONFIG_FILENAMES,
None,
DEFAULT_APP_CONFIG,
)
context.appconfigfiles, raw_appconfig, context.appconfig_from_defaults = cast(
tuple[tuple[Path, ...] | None, dict[str, Any], bool], result
)
except tomllib.TOMLDecodeError as e:
files = (app_config,) if app_config else None
handle_validation_error(e, AppConfig, files, verbose, ctx)

if max_workers is not None:
raw_appconfig["max_workers"] = max_workers

try:
context.appconfig = AppConfig.from_dict(raw_appconfig)
except (ValueError, ValidationError) as e:
handle_validation_error(e, AppConfig, context.appconfigfiles, verbose, ctx)


def _load_env_config(ctx: click.Context, env_config: Path, verbose: int) -> None:
Comment thread
sushant-suse marked this conversation as resolved.
Outdated
"""Load and validate Environment configuration.

Args:
ctx: The Click context object.
env_config: The path to the environment config file provided via CLI.
Comment thread
sushant-suse marked this conversation as resolved.
Outdated
verbose: The verbosity level from CLI options.

"""
context = ctx.obj
# Initialize to satisfy Pylance control-flow analysis
raw_envconfig: dict[str, Any] = {}

try:
result = handle_config(
env_config,
(PROJECT_DIR,),
None,
DEFAULT_ENV_CONFIG_FILENAME,
DEFAULT_ENV_CONFIG,
)
context.envconfigfiles, raw_envconfig, context.envconfig_from_defaults = cast(
tuple[tuple[Path, ...] | None, dict[str, Any], bool], result
)
except tomllib.TOMLDecodeError as e:
files = (env_config,) if env_config else None
handle_validation_error(e, EnvConfig, files, verbose, ctx)

try:
context.envconfig = EnvConfig.from_dict(raw_envconfig)
except (ValueError, ValidationError) as e:
handle_validation_error(e, EnvConfig, context.envconfigfiles, verbose, ctx)


@click.group(
name=APP_NAME,
context_settings={"show_default": True, "help_option_names": ["-h", "--help"]},
Expand Down Expand Up @@ -167,76 +245,33 @@ def cli(
ctx.ensure_object(DocBuildContext)

context = ctx.obj
context.verbose = verbose
context.dry_run = dry_run
context.debug = debug

# --- PHASE 1: Load and Validate Application Config ---
(
context.appconfigfiles,
raw_appconfig,
context.appconfig_from_defaults,
) = handle_config(
app_config,
CONFIG_PATHS,
APP_CONFIG_BASENAMES + PROJECT_LEVEL_APP_CONFIG_FILENAMES,
None,
DEFAULT_APP_CONFIG,
)

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

if max_workers is not None:
raw_appconfig["max_workers"] = max_workers
context.verbose, context.dry_run, context.debug = verbose, dry_run, debug

try:
context.appconfig = AppConfig.from_dict(raw_appconfig)
except (ValueError, ValidationError) as e:
handle_validation_error(e, AppConfig, context.appconfigfiles, verbose, ctx)
# 1. Load Application Config
_load_app_config(ctx, app_config, verbose, max_workers)

# 3. Setup logging using the validated config object
logging_config = context.appconfig.logging.model_dump(
by_alias=True, exclude_none=True
)
# 2. Setup logging
logging_config = context.appconfig.logging.model_dump(by_alias=True, exclude_none=True)
setup_logging(cliverbosity=verbose, user_config={"logging": logging_config})

# --- PHASE 2: Load Environment Config, Validate, and Acquire Lock ---
(
context.envconfigfiles,
raw_envconfig,
context.envconfig_from_defaults,
) = handle_config(
env_config,
(PROJECT_DIR,),
None,
DEFAULT_ENV_CONFIG_FILENAME,
DEFAULT_ENV_CONFIG,
)

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

try:
context.envconfig = EnvConfig.from_dict(raw_envconfig)
except (ValueError, ValidationError) as e:
handle_validation_error(e, EnvConfig, context.envconfigfiles, verbose, ctx)
# 3. Load Environment Config
_load_env_config(ctx, env_config, verbose)

# 4. Setup Concurrency Lock
env_config_path = (context.envconfigfiles or [None])[0]

# --- CONCURRENCY CONTROL ---
if env_config_path:
ctx.obj.env_lock = PidFileLock(resource_path=cast(Path, env_config_path))
try:
ctx.obj.env_lock.__enter__()
log.info("Acquired lock for environment config: %r", env_config_path.name)
ctx.call_on_close(lambda: ctx.obj.env_lock.__exit__(None, None, None))
except LockAcquisitionError as e:
log.error(str(e))
ctx.exit(1)
except Exception as e:
log.error("Failed to set up environment lock: %s", e)
ctx.exit(1)

ctx.call_on_close(lambda: ctx.obj.env_lock.__exit__(None, None, None))


# Add subcommands
cli.add_command(build)
Expand Down
29 changes: 29 additions & 0 deletions src/docbuild/utils/errors.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Utilities for handling and formatting application errors."""


import tomllib

from pydantic import BaseModel, ValidationError
from rich.console import Console
from rich.text import Text
Expand Down Expand Up @@ -112,3 +114,30 @@ def format_pydantic_error(
f"[dim]... and {error_count - max_display} more errors. "
"Use '-vv' to see all errors.[/dim]\n"
)

def format_toml_error(
Comment thread
sushant-suse marked this conversation as resolved.
error: tomllib.TOMLDecodeError,
config_file: str,
console: Console | None = None,
) -> None:
"""Format TOML syntax errors using Rich.

:param error: The caught TOMLDecodeError object.
:param config_file: The name/path of the config file with the syntax error.
:param console: Optional Rich console object.
"""
con = console or Console(stderr=True)

header = Text.assemble(
("Syntax error ", "bold red"),
("in config file ", "white"),
(f"'{config_file}'", "bold cyan"),
(":", "white")
)
con.print(header)

# tomllib error messages include the line and column info naturally
con.print(f" [red]{error}[/red]")
con.print()
con.print(" [dim]Please verify that the file is a valid TOML file.[/dim]")
con.print(" [dim]Note: Booleans must be lowercase (true/false) in TOML.[/dim]")
30 changes: 30 additions & 0 deletions tests/cli/test_cmd_cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for the main CLI entry point and configuration loading flow."""

from pathlib import Path
import tomllib
from unittest.mock import Mock, patch

import click
Expand Down Expand Up @@ -267,3 +268,32 @@ def resolver(user_path, *args, **kwargs):
assert result.exit_code == 0
assert context.verbose == 3
assert context.debug is True


@pytest.mark.parametrize("is_app_config_failure", [True, False])
def test_cli_toml_syntax_error(
runner,
fake_handle_config,
mock_config_models,
is_app_config_failure,
):
"""Verify that the CLI handles TOML syntax errors gracefully (Issue #175)."""

# Define a resolver that raises TOMLDecodeError
def resolver_with_syntax_error(user_path, *args, **kwargs):
# We simulate the exact error raised by tomllib
raise tomllib.TOMLDecodeError("Invalid value", "test.toml", 0)

fake_handle_config(resolver_with_syntax_error)

# Invoke the CLI. Whether it's app or env config, the handle_config
# call is now wrapped in a try/except in cmd_cli.py
result = runner.invoke(cli, ["capture"])

assert result.exit_code == 1
assert "Syntax error in config file" in result.output
assert "Invalid value" in result.output
# Verify our specific 'Note' from the formatter appears
assert "Booleans must be lowercase" in result.output
# Crucially, ensure no raw Python traceback is leaked to the user
assert "Traceback (most recent call last)" not in result.output
21 changes: 21 additions & 0 deletions tests/utils/test_errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for the Pydantic error formatting utility."""

import tomllib
from typing import Any

from pydantic import BaseModel, Field, IPvAnyAddress, ValidationError, create_model
Expand Down Expand Up @@ -94,3 +95,23 @@ class UnionModel(BaseModel):
assert "In 'host':" in captured.err
# Verify no bracketed pydantic internals leaked in the path part
assert "[" not in captured.err.split("In '")[1].split("':")[0]


def test_format_toml_error_smoke(capsys):
"""Verify that the TOML syntax error formatter prints correctly."""
from docbuild.utils.errors import format_toml_error

# 1. Manually trigger a TOML syntax error
bad_toml_content = "enable_mail = True" # Invalid: Capital T
try:
tomllib.loads(bad_toml_content)
except tomllib.TOMLDecodeError as e:
# 2. Call our new formatter
format_toml_error(e, "env.devel.toml")

captured = capsys.readouterr()

# 3. Assertions to verify the "Rich" output content
assert "Syntax error in config file 'env.devel.toml'" in captured.err
assert "Invalid value" in captured.err
assert "Booleans must be lowercase" in captured.err
Loading