Skip to content

Commit a169de5

Browse files
committed
refactor #112: reorganize config CLI and fix help-mode crash
Signed-off-by: sushant-suse <[email protected]>
1 parent 2309ed5 commit a169de5

12 files changed

Lines changed: 260 additions & 137 deletions

File tree

src/docbuild/cli/cmd_cli.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,13 @@ def cli(
219219
context = ctx.obj
220220
context.verbose, context.dry_run, context.debug = verbose, dry_run, debug
221221

222-
# State tracking for centralized error handling
222+
# --- THE TRUE LAZY FIX ---
223+
# If the user is just asking for help, STOP HERE.
224+
# Click will handle the help display for the subcommands automatically.
225+
if ctx.resilient_parsing or "--help" in sys.argv or "-h" in sys.argv:
226+
return
227+
228+
# 2. Only load config if we aren't displaying help
223229
current_model: type[BaseModel] = AppConfig
224230
current_files: Sequence[Path] | None = None
225231

@@ -229,11 +235,12 @@ def cli(
229235
current_files = (app_config,) if app_config else None
230236
load_app_config(ctx, app_config, max_workers)
231237

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})
238+
# Access logging safely
239+
if context.appconfig and context.appconfig.logging:
240+
logging_config = context.appconfig.logging.model_dump(
241+
by_alias=True, exclude_none=True
242+
)
243+
setup_logging(cliverbosity=verbose, user_config={"logging": logging_config})
237244

238245
# --- PHASE 2: Load Environment Config ---
239246
current_model = EnvConfig
Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
1-
"""CLI interface to shows config files how docbuild sees it."""
1+
"""CLI interface for docbuild configuration management."""
22

33
import click
44

5-
from .application import app
6-
from .environment import env
5+
from .list import list_config
6+
from .validate import validate_config
77

88

99
@click.group(
1010
name="config",
11-
help=__doc__,
11+
help="CLI interface to manage and verify configuration files.",
1212
)
1313
@click.pass_context
1414
def config(ctx: click.Context) -> None:
15-
"""Subcommand to show the configuration files and their content."""
15+
"""Subcommand to manage docbuild configuration (TOML and XML)."""
1616
pass
1717

1818

19-
# Register the subcommands for the config group
20-
config.add_command(env)
21-
config.add_command(app)
19+
# Register the task-oriented subcommands
20+
config.add_command(list_config)
21+
config.add_command(validate_config)

src/docbuild/cli/cmd_config/application.py

Lines changed: 0 additions & 23 deletions
This file was deleted.

src/docbuild/cli/cmd_config/environment.py

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""CLI interface to list the configuration."""
2+
3+
from typing import Any
4+
5+
import click
6+
from rich import print_json
7+
from rich.console import Console
8+
9+
console = Console()
10+
11+
def flatten_dict(d: dict[str, Any], prefix: str = "") -> dict[str, Any]:
12+
"""Flatten a nested dictionary into dotted keys (e.g., app.logging.level)."""
13+
items: list[tuple[str, Any]] = []
14+
for k, v in d.items():
15+
new_key = f"{prefix}.{k}" if prefix else k
16+
if isinstance(v, dict):
17+
items.extend(flatten_dict(v, new_key).items())
18+
else:
19+
items.append((new_key, v))
20+
return dict(items)
21+
22+
def _print_section(title: str, data: dict[str, Any], prefix: str, flat: bool, color: str) -> None:
23+
"""Print the Application and Environment configuration sections."""
24+
if flat:
25+
for k, v in flatten_dict(data, prefix).items():
26+
console.print(f"[bold {color}]{k}[/bold {color}] = [green]{v}[/green]")
27+
else:
28+
console.print(f"\n# {title}", style="blue")
29+
print_json(data=data)
30+
31+
def _print_portal(doctypes: list[Any], flat: bool) -> None:
32+
"""Print the Portal and Doctype metadata section."""
33+
if not flat:
34+
console.print("\n# Portal/Doctype Metadata", style="blue")
35+
36+
for doctype in doctypes:
37+
name = getattr(doctype, "name", "Unknown")
38+
path = str(getattr(doctype, "path", "N/A"))
39+
if flat:
40+
console.print(f"[bold magenta]portal.{name}[/bold magenta] = [green]{path}[/green]")
41+
else:
42+
console.print(f" - [bold]{name}[/bold]: {path}")
43+
44+
@click.command(name="list")
45+
@click.option("--app", is_flag=True, help="Show only application configuration")
46+
@click.option("--env", is_flag=True, help="Show only environment configuration")
47+
@click.option("--portal", is_flag=True, help="Show only portal/doctype metadata")
48+
@click.option("--flat", is_flag=True, help="Output in flat dotted format (git-style)")
49+
@click.pass_context
50+
def list_config(ctx: click.Context, app: bool, env: bool, portal: bool, flat: bool) -> None:
51+
"""List the configuration as JSON or flat text."""
52+
context = ctx.obj
53+
show_all = not (app or env or portal)
54+
55+
if (app or show_all) and context.appconfig:
56+
_print_section("Application Configuration", context.appconfig.model_dump(), "app", flat, "cyan")
57+
58+
if (env or show_all) and context.envconfig:
59+
_print_section("Environment Configuration", context.envconfig.model_dump(), "env", flat, "yellow")
60+
61+
if (portal or show_all) and context.doctypes:
62+
_print_portal(context.doctypes, flat)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""CLI interface to validate the configuration files."""
2+
3+
import click
4+
from rich.console import Console
5+
from rich.panel import Panel
6+
7+
8+
@click.command(name="validate")
9+
@click.pass_context
10+
def validate_config(ctx: click.Context) -> None:
11+
"""Validate all configuration files (TOML and XML).
12+
13+
This command performs a full check of the application settings,
14+
environment overrides, and portal doctypes.
15+
"""
16+
context = ctx.obj
17+
console = Console()
18+
19+
# Since this command is reached only if the main CLI loader
20+
# didn't exit with an error, we know the TOML files are
21+
# syntactically correct and match the Pydantic models.
22+
23+
console.print("[bold blue]Running Configuration Validation...[/bold blue]\n")
24+
25+
# 1. App Config Status
26+
if context.appconfig:
27+
console.print("✅ [bold]Application Configuration:[/bold] Valid")
28+
if context.appconfigfiles:
29+
for f in context.appconfigfiles:
30+
console.print(f" [dim]- {f}[/dim]")
31+
32+
# 2. Env Config Status
33+
if context.envconfig:
34+
console.print("\n✅ [bold]Environment Configuration:[/bold] Valid")
35+
if context.envconfigfiles:
36+
for f in context.envconfigfiles:
37+
console.print(f" [dim]- {f}[/dim]")
38+
elif context.envconfig_from_defaults:
39+
console.print(" [dim]- Using internal defaults[/dim]")
40+
41+
# 3. Portal/Doctype Status
42+
if context.doctypes:
43+
console.print(f"\n✅ [bold]Portals/Doctypes:[/bold] {len(context.doctypes)} discovered")
44+
for doctype in context.doctypes:
45+
name = getattr(doctype, "name", "Unknown")
46+
console.print(f" [dim]- {name}[/dim]")
47+
else:
48+
console.print("\n⚠️ [bold yellow]Portals/Doctypes:[/bold yellow] None discovered")
49+
50+
console.print(
51+
Panel(
52+
"[bold green]Configuration is valid![/bold green]\n"
53+
"All TOML files match the required schema and portals are reachable.",
54+
border_style="green",
55+
expand=False
56+
)
57+
)

tests/cli/cmd_config/test_application.py

Lines changed: 0 additions & 58 deletions
This file was deleted.

tests/cli/cmd_config/test_config.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

tests/cli/cmd_config/test_list.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from pathlib import Path
2+
from unittest.mock import Mock
3+
4+
import pytest
5+
6+
from docbuild.cli.cmd_cli import cli
7+
from docbuild.models.config.app import AppConfig
8+
from docbuild.models.config.env import EnvConfig
9+
10+
11+
@pytest.fixture
12+
def mock_models(monkeypatch):
13+
"""Local fixture to mock config models for list command tests."""
14+
mock_app = Mock(spec=AppConfig)
15+
# 1. Provide the logging attribute the CLI expects
16+
mock_app.logging = Mock()
17+
mock_app.logging.model_dump.return_value = {"version": 1}
18+
# 2. Provide the data for our 'list' command
19+
mock_app.model_dump.return_value = {"key": "value", "logging": {"level": "info"}}
20+
21+
mock_env = Mock(spec=EnvConfig)
22+
mock_env.model_dump.return_value = {"env_key": "env_val"}
23+
24+
monkeypatch.setattr(AppConfig, "from_dict", Mock(return_value=mock_app))
25+
monkeypatch.setattr(EnvConfig, "from_dict", Mock(return_value=mock_env))
26+
return mock_app
27+
28+
def test_config_list_json(runner, mock_models, fake_handle_config):
29+
"""Test that config list shows the expected JSON output."""
30+
fake_handle_config(lambda *a, **k: ((Path("test.toml"),), {"key": "value"}, False))
31+
result = runner.invoke(cli, ["config", "list"])
32+
assert result.exit_code == 0
33+
assert '"key": "value"' in result.output
34+
35+
def test_config_list_flat(runner, mock_models, fake_handle_config):
36+
"""Test that config list with --flat shows flattened keys."""
37+
fake_handle_config(lambda *a, **k: ((Path("test.toml"),), {"logging": {"level": "info"}}, False))
38+
result = runner.invoke(cli, ["config", "list", "--flat"])
39+
assert result.exit_code == 0
40+
assert "app.logging.level = info" in result.output
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from pathlib import Path
2+
from unittest.mock import MagicMock, Mock
3+
4+
import pytest
5+
6+
from docbuild.cli.cmd_cli import cli
7+
from docbuild.cli.context import DocBuildContext
8+
from docbuild.models.config.app import AppConfig
9+
from docbuild.models.config.env import EnvConfig
10+
11+
12+
@pytest.fixture
13+
def mock_models(monkeypatch):
14+
"""Local fixture to mock config models for validate command tests."""
15+
mock_app = Mock(spec=AppConfig)
16+
mock_app.logging = Mock()
17+
mock_app.logging.model_dump.return_value = {"version": 1}
18+
19+
mock_env = Mock(spec=EnvConfig)
20+
21+
monkeypatch.setattr(AppConfig, "from_dict", Mock(return_value=mock_app))
22+
monkeypatch.setattr(EnvConfig, "from_dict", Mock(return_value=mock_env))
23+
return mock_app
24+
25+
def test_config_validate_success(runner, mock_models, fake_handle_config):
26+
"""Test that config validate succeeds with valid configuration."""
27+
# Setup context attributes so they don't default to Mocks
28+
# Manually create the context with real lists/tuples to avoid Mock iteration errors
29+
ctx_obj = DocBuildContext()
30+
ctx_obj.appconfig = mock_models
31+
ctx_obj.appconfigfiles = (Path("app.toml"),)
32+
ctx_obj.envconfig = MagicMock() # Mock for EnvConfig
33+
ctx_obj.envconfigfiles = (Path("env.toml"),)
34+
ctx_obj.doctypes = [] # Explicitly empty list
35+
36+
fake_handle_config(lambda *a, **k: ((Path("app.toml"),), {"key": "val"}, False))
37+
38+
# We use 'standalone_mode=False' so we can see the actual error if it crashes
39+
result = runner.invoke(cli, ["config", "validate"], obj=ctx_obj)
40+
41+
assert result.exit_code == 0
42+
# Use 'in' to check for text without worrying about exact formatting/colors
43+
assert "Configuration is valid" in result.output
44+
assert "Application Configuration" in result.output

0 commit comments

Comments
 (0)