From bc9db1ff89350e128473025a417de45035cf76a7 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Tue, 26 May 2026 22:44:08 +0530 Subject: [PATCH 1/4] cli: add foundation wiring and kg build with legacy build parity - add CLI runtime context, global config/log-level handling, and click-safe error wrapping - implement kg build as a thin wrapper over existing orchestrator build flow - keep hidden legacy build alias and route both build handlers through shared internal path - add focused CLI tests for help UX, config flag compatibility, alias parity, and clean error output - keep tests lightweight by mocking heavy build execution paths --- semantica/cli.py | 255 ++++++++++++++++++++++++++++++----- tests/test_cli_foundation.py | 163 ++++++++++++++++++++++ 2 files changed, 385 insertions(+), 33 deletions(-) create mode 100644 tests/test_cli_foundation.py diff --git a/semantica/cli.py b/semantica/cli.py index c5c6de9f..c1a23748 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -5,51 +5,240 @@ This module provides the command-line interface for the Semantica framework, enabling users to interact with the framework via terminal commands. """ +from dataclasses import dataclass +import json +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Optional, Sequence + +import yaml + import click from rich.console import Console from rich.table import Table from . import __version__ -from .core.orchestrator import Semantica +from .core.config_manager import Config, ConfigManager +from .utils.exceptions import SemanticaError from .utils.logging import setup_logging +if TYPE_CHECKING: + from .core.orchestrator import Semantica + console = Console() -@click.group() -@click.version_option(version=__version__) -def main(): - """Semantica - Semantic Layer & Knowledge Engineering Framework""" - setup_logging() -@main.command() -def info(): - """Display information about Semantica.""" - console.print(f"[bold blue]Semantica Framework[/bold blue] v{__version__}") - console.print("A comprehensive Python framework for transforming unstructured data into semantic layers.") - - table = Table(title="Framework Components") - table.add_column("Component", style="cyan") - table.add_column("Status", style="green") - - table.add_row("Core Orchestrator", "Active") - table.add_row("Knowledge Graph Engine", "Active") - table.add_row("Pipeline Execution", "Active") - table.add_row("Vector Store Integration", "Active") - - console.print(table) +@dataclass +class CLIContext: + """Shared runtime context for all CLI commands.""" -@main.command() -@click.option("--source", "-s", multiple=True, help="Data sources to process.") -@click.option("--config", "-c", help="Path to configuration file.") -def build(source, config): - """Build a knowledge base from sources.""" - console.print(f"Initializing Semantica with {len(source)} sources...") + config_path: Optional[str] + config: Config + log_level: str + framework: Optional["Semantica"] = None + + +def _run_with_error_handling(action: Callable[[], None]) -> None: + """Run a CLI action with consistent user-facing error formatting.""" try: - framework = Semantica(config=config) - # framework.build_knowledge_base(sources=list(source)) - console.print("[bold green]Success:[/bold green] Knowledge base construction initiated.") - except Exception as e: - console.print(f"[bold red]Error:[/bold red] {str(e)}") + action() + except click.ClickException: + raise + except SemanticaError as exc: + raise click.ClickException(str(exc)) from exc + except Exception as exc: # pragma: no cover - fallback guard + raise click.ClickException(f"Unexpected error: {exc}") from exc + + +def _build_runtime_config(config_path: Optional[str], log_level: Optional[str]) -> Config: + """Resolve CLI config from file plus global flag overrides.""" + config_manager = ConfigManager() + + if config_path: + file_path = Path(config_path) + suffix = file_path.suffix.lower() + if suffix in (".yaml", ".yml"): + with file_path.open("r", encoding="utf-8") as handle: + config_data = yaml.safe_load(handle) or {} + elif suffix == ".json": + with file_path.open("r", encoding="utf-8") as handle: + config_data = json.load(handle) + else: + raise click.ClickException( + "Unsupported configuration file format: " + f"{suffix}. Supported formats: .yaml, .yml, .json" + ) + else: + config_data = {} + + if log_level: + config_data.setdefault("logging", {})["level"] = log_level.upper() + + # Keep validation disabled at CLI bootstrap to avoid blocking unrelated commands. + return config_manager.load_from_dict(config_data, validate=False) + + +def _get_framework(cli_ctx: CLIContext) -> "Semantica": + """Lazily initialize framework only when a command needs it.""" + if cli_ctx.framework is None: + from .core.orchestrator import Semantica + + cli_ctx.framework = Semantica(config=cli_ctx.config.to_dict()) + return cli_ctx.framework + + +def _run_build(cli_ctx: CLIContext, sources: Sequence[str]) -> None: + """Thin wrapper around existing build orchestration flow.""" + if not sources: + raise click.UsageError( + "At least one source is required. Use --source/-s one or more times." + ) + + framework = _get_framework(cli_ctx) + console.print(f"Initializing Semantica with {len(sources)} sources...") + result = framework.build_knowledge_base(sources=list(sources)) + + stats = result.get("statistics", {}) if isinstance(result, dict) else {} + processed = stats.get("sources_processed") + if processed is not None: + console.print( + f"[bold green]Success:[/bold green] Knowledge base build completed for {processed} source(s)." + ) + else: + console.print("[bold green]Success:[/bold green] Knowledge base build completed.") + + +def _run_build_command( + cli_ctx: CLIContext, + source: Sequence[str], + command_config_path: Optional[str], +) -> None: + """Execute build command path with optional command-level config override.""" + if command_config_path: + command_ctx = CLIContext( + config_path=command_config_path, + config=_build_runtime_config(command_config_path, cli_ctx.log_level), + log_level=cli_ctx.log_level, + ) + _run_build(command_ctx, source) + else: + _run_build(cli_ctx, source) + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.version_option(version=__version__) +@click.option( + "--config", + "config_path", + type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=str), + default=None, + help="Path to YAML/JSON config file.", +) +@click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False), + default=None, + help="Override logging level for this CLI invocation.", +) +@click.pass_context +def main(ctx: click.Context, config_path: Optional[str], log_level: Optional[str]): + """Semantica - Semantic Layer & Knowledge Engineering Framework""" + try: + config = _build_runtime_config(config_path=config_path, log_level=log_level) + setup_logging(config=config.get("logging", {})) + ctx.obj = CLIContext( + config_path=config_path, + config=config, + log_level=config.get("logging.level", "INFO"), + ) + except click.ClickException: + raise + except SemanticaError as exc: + raise click.ClickException(str(exc)) from exc + except Exception as exc: + raise click.ClickException(f"Failed to initialize CLI: {exc}") from exc + + +@main.group(invoke_without_command=True) +@click.pass_context +def kg(ctx: click.Context) -> None: + """Knowledge graph and semantic build commands.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@main.group(invoke_without_command=True) +@click.pass_context +def pipeline(ctx: click.Context) -> None: + """Pipeline command group (foundation placeholder).""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@main.group(invoke_without_command=True) +@click.pass_context +def serve(ctx: click.Context) -> None: + """Service command group (foundation placeholder).""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@main.group(name="config", invoke_without_command=True) +@click.pass_context +def config_group(ctx: click.Context) -> None: + """Configuration command group.""" + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + +@main.command() +@click.pass_obj +def info(cli_ctx: CLIContext): + """Display information about Semantica.""" + def _action() -> None: + console.print(f"[bold blue]Semantica Framework[/bold blue] v{__version__}") + console.print( + "A comprehensive Python framework for transforming unstructured data into semantic layers." + ) + + table = Table(title="Framework Components") + table.add_column("Component", style="cyan") + table.add_column("Status", style="green") + + table.add_row("Core Orchestrator", "Active") + table.add_row("Knowledge Graph Engine", "Active") + table.add_row("Pipeline Execution", "Active") + table.add_row("Vector Store Integration", "Active") + table.add_row("CLI Config File", cli_ctx.config_path or "(none)") + table.add_row("CLI Log Level", cli_ctx.log_level) + + console.print(table) + + _run_with_error_handling(_action) + + +@kg.command("build") +@click.option("--source", "-s", multiple=True, help="Data sources to process.") +@click.option("-c", "--config", "command_config_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=str), default=None, help="Path to YAML/JSON config file.") +@click.pass_obj +def kg_build(cli_ctx: CLIContext, source: Sequence[str], command_config_path: Optional[str]): + """Build a knowledge base from sources.""" + def _action() -> None: + _run_build_command(cli_ctx, source, command_config_path) + + _run_with_error_handling(_action) + + +@main.command("build", hidden=True) +@click.option("--source", "-s", multiple=True, help="Data sources to process.") +@click.option("-c", "--config", "command_config_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=str), default=None, help="Path to YAML/JSON config file.") +@click.pass_obj +def build_alias(cli_ctx: CLIContext, source: Sequence[str], command_config_path: Optional[str]): + """Backward-compatible alias for 'kg build'.""" + def _action() -> None: + _run_build_command(cli_ctx, source, command_config_path) + + _run_with_error_handling(_action) + if __name__ == "__main__": main() diff --git a/tests/test_cli_foundation.py b/tests/test_cli_foundation.py new file mode 100644 index 00000000..181d9dbc --- /dev/null +++ b/tests/test_cli_foundation.py @@ -0,0 +1,163 @@ +import pytest +from click.testing import CliRunner + +import semantica.cli as cli_module + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_root_help_shows_expected_groups(runner): + result = runner.invoke(cli_module.main, ["--help"]) + + assert result.exit_code == 0 + assert "Semantica - Semantic Layer & Knowledge Engineering Framework" in result.output + assert "kg" in result.output + assert "pipeline" in result.output + assert "serve" in result.output + + +def test_kg_group_help_shows_build_command(runner): + result = runner.invoke(cli_module.main, ["kg", "--help"]) + + assert result.exit_code == 0 + assert "Knowledge graph and semantic build commands." in result.output + assert "build" in result.output + + +def test_kg_build_help_shows_source_and_config_flags(runner): + result = runner.invoke(cli_module.main, ["kg", "build", "--help"]) + + assert result.exit_code == 0 + assert "--source" in result.output + assert "-s" in result.output + assert "--config" in result.output + assert "-c" in result.output + + +@pytest.mark.parametrize( + "argv", + [ + ["kg", "build", "-s", "README.md"], + ["build", "-s", "README.md"], + ], +) +def test_build_paths_invoke_shared_wrapper(runner, monkeypatch, argv): + captured = {} + + def fake_run_build(cli_ctx, sources): + captured["config_path"] = cli_ctx.config_path + captured["sources"] = list(sources) + + monkeypatch.setattr(cli_module, "_run_build", fake_run_build) + + result = runner.invoke(cli_module.main, argv) + + assert result.exit_code == 0 + assert captured["sources"] == ["README.md"] + + +@pytest.mark.parametrize( + "argv", + [ + ["kg", "build", "-s", "README.md", "-c", "cfg.yml"], + ["kg", "build", "-s", "README.md", "--config", "cfg.yml"], + ["build", "-s", "README.md", "-c", "cfg.yml"], + ["build", "-s", "README.md", "--config", "cfg.yml"], + ], +) +def test_build_config_short_and_long_flags_are_compatible(runner, monkeypatch, argv): + captured = {} + + def fake_run_build(cli_ctx, sources): + captured["config_path"] = cli_ctx.config_path + captured["sources"] = list(sources) + + monkeypatch.setattr(cli_module, "_run_build", fake_run_build) + + with runner.isolated_filesystem(): + with open("cfg.yml", "w", encoding="utf-8") as handle: + handle.write("logging:\n level: INFO\n") + + result = runner.invoke(cli_module.main, argv) + + assert result.exit_code == 0 + assert captured["sources"] == ["README.md"] + assert captured["config_path"] is not None + + +@pytest.mark.parametrize( + "argv", + [ + ["kg", "build"], + ["build"], + ], +) +def test_missing_input_errors_are_clean_and_click_safe(runner, argv): + result = runner.invoke(cli_module.main, argv) + + assert result.exit_code != 0 + assert "At least one source is required" in result.output + assert "Traceback" not in result.output + + +def test_invalid_root_config_error_is_clean_and_click_safe(runner): + with runner.isolated_filesystem(): + with open("bad.md", "w", encoding="utf-8") as handle: + handle.write("not-a-config") + + result = runner.invoke(cli_module.main, ["--config", "bad.md", "info"]) + + assert result.exit_code != 0 + assert "Unsupported configuration file format" in result.output + assert "Traceback" not in result.output + + +def test_invalid_command_config_error_is_clean_and_click_safe(runner): + with runner.isolated_filesystem(): + with open("bad.md", "w", encoding="utf-8") as handle: + handle.write("not-a-config") + + result = runner.invoke( + cli_module.main, + ["kg", "build", "-s", "README.md", "-c", "bad.md"], + ) + + assert result.exit_code != 0 + assert "Unsupported configuration file format" in result.output + assert "Traceback" not in result.output + + +@pytest.mark.parametrize( + "argv", + [ + ["--help"], + ["kg", "--help"], + ["kg", "build", "--help"], + ["build", "--help"], + ], +) +def test_help_calls_do_not_initialize_framework(runner, monkeypatch, argv): + def fail_get_framework(_): + raise AssertionError("framework initialization must not happen on help") + + monkeypatch.setattr(cli_module, "_get_framework", fail_get_framework) + + result = runner.invoke(cli_module.main, argv) + + assert result.exit_code == 0 + + +def test_runtime_errors_are_click_safe_without_traceback(runner, monkeypatch): + def boom(_cli_ctx, _sources): + raise RuntimeError("boom") + + monkeypatch.setattr(cli_module, "_run_build", boom) + + result = runner.invoke(cli_module.main, ["kg", "build", "-s", "README.md"]) + + assert result.exit_code != 0 + assert "Unexpected error: boom" in result.output + assert "Traceback" not in result.output From b54d885bf2a84a72deea4df002253e17c11d1b59 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Tue, 26 May 2026 23:06:00 +0530 Subject: [PATCH 2/4] cli: harden config parsing and logging override handling - keep command-level config from overriding logging unless --log-level is set - validate YAML/JSON config roots and surface parse failures as Click errors - tighten CLI tests around isolation and cleanup --- semantica/cli.py | 96 ++++++++++++++++++++++++++++-------- tests/test_cli_foundation.py | 59 +++++++++++++++++++++- 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/semantica/cli.py b/semantica/cli.py index c1a23748..fbc4ace5 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -5,10 +5,10 @@ This module provides the command-line interface for the Semantica framework, enabling users to interact with the framework via terminal commands. """ -from dataclasses import dataclass import json +from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Callable, Optional, Sequence +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Sequence import yaml @@ -34,6 +34,7 @@ class CLIContext: config_path: Optional[str] config: Config log_level: str + log_level_override: Optional[str] = None framework: Optional["Semantica"] = None @@ -49,16 +50,13 @@ def _run_with_error_handling(action: Callable[[], None]) -> None: raise click.ClickException(f"Unexpected error: {exc}") from exc -def _build_runtime_config(config_path: Optional[str], log_level: Optional[str]) -> Config: - """Resolve CLI config from file plus global flag overrides.""" - config_manager = ConfigManager() - - if config_path: - file_path = Path(config_path) - suffix = file_path.suffix.lower() +def _load_config_data(file_path: Path) -> Dict[str, Any]: + """Load and validate raw YAML/JSON config data.""" + suffix = file_path.suffix.lower() + try: if suffix in (".yaml", ".yml"): with file_path.open("r", encoding="utf-8") as handle: - config_data = yaml.safe_load(handle) or {} + config_data = yaml.safe_load(handle) elif suffix == ".json": with file_path.open("r", encoding="utf-8") as handle: config_data = json.load(handle) @@ -67,6 +65,28 @@ def _build_runtime_config(config_path: Optional[str], log_level: Optional[str]) "Unsupported configuration file format: " f"{suffix}. Supported formats: .yaml, .yml, .json" ) + except (json.JSONDecodeError, yaml.YAMLError, UnicodeDecodeError) as exc: + raise click.ClickException( + f"Failed to parse configuration file '{file_path}': {exc}" + ) from exc + + if not isinstance(config_data, dict): + raise click.ClickException( + "Configuration file must contain a mapping/object at the root." + ) + + return config_data + + +def _build_runtime_config( + config_path: Optional[str], + log_level: Optional[str], +) -> Config: + """Resolve CLI config from file plus global flag overrides.""" + config_manager = ConfigManager() + + if config_path: + config_data = _load_config_data(Path(config_path)) else: config_data = {} @@ -101,10 +121,13 @@ def _run_build(cli_ctx: CLIContext, sources: Sequence[str]) -> None: processed = stats.get("sources_processed") if processed is not None: console.print( - f"[bold green]Success:[/bold green] Knowledge base build completed for {processed} source(s)." + "[bold green]Success:[/bold green] Knowledge base build completed " + f"for {processed} source(s)." ) else: - console.print("[bold green]Success:[/bold green] Knowledge base build completed.") + console.print( + "[bold green]Success:[/bold green] Knowledge base build completed." + ) def _run_build_command( @@ -116,7 +139,9 @@ def _run_build_command( if command_config_path: command_ctx = CLIContext( config_path=command_config_path, - config=_build_runtime_config(command_config_path, cli_ctx.log_level), + config=_build_runtime_config( + command_config_path, cli_ctx.log_level_override + ), log_level=cli_ctx.log_level, ) _run_build(command_ctx, source) @@ -135,7 +160,10 @@ def _run_build_command( ) @click.option( "--log-level", - type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False), + type=click.Choice( + ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + case_sensitive=False, + ), default=None, help="Override logging level for this CLI invocation.", ) @@ -145,10 +173,12 @@ def main(ctx: click.Context, config_path: Optional[str], log_level: Optional[str try: config = _build_runtime_config(config_path=config_path, log_level=log_level) setup_logging(config=config.get("logging", {})) + effective_log_level = config.get("logging.level", "INFO") ctx.obj = CLIContext( config_path=config_path, config=config, - log_level=config.get("logging.level", "INFO"), + log_level=effective_log_level, + log_level_override=log_level.upper() if log_level else None, ) except click.ClickException: raise @@ -194,10 +224,12 @@ def config_group(ctx: click.Context) -> None: @click.pass_obj def info(cli_ctx: CLIContext): """Display information about Semantica.""" + def _action() -> None: console.print(f"[bold blue]Semantica Framework[/bold blue] v{__version__}") console.print( - "A comprehensive Python framework for transforming unstructured data into semantic layers." + "A comprehensive Python framework for transforming unstructured data " + "into semantic layers." ) table = Table(title="Framework Components") @@ -218,10 +250,22 @@ def info(cli_ctx: CLIContext): @kg.command("build") @click.option("--source", "-s", multiple=True, help="Data sources to process.") -@click.option("-c", "--config", "command_config_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=str), default=None, help="Path to YAML/JSON config file.") +@click.option( + "-c", + "--config", + "command_config_path", + type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=str), + default=None, + help="Path to YAML/JSON config file.", +) @click.pass_obj -def kg_build(cli_ctx: CLIContext, source: Sequence[str], command_config_path: Optional[str]): +def kg_build( + cli_ctx: CLIContext, + source: Sequence[str], + command_config_path: Optional[str], +): """Build a knowledge base from sources.""" + def _action() -> None: _run_build_command(cli_ctx, source, command_config_path) @@ -230,10 +274,22 @@ def kg_build(cli_ctx: CLIContext, source: Sequence[str], command_config_path: Op @main.command("build", hidden=True) @click.option("--source", "-s", multiple=True, help="Data sources to process.") -@click.option("-c", "--config", "command_config_path", type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=str), default=None, help="Path to YAML/JSON config file.") +@click.option( + "-c", + "--config", + "command_config_path", + type=click.Path(exists=True, dir_okay=False, resolve_path=True, path_type=str), + default=None, + help="Path to YAML/JSON config file.", +) @click.pass_obj -def build_alias(cli_ctx: CLIContext, source: Sequence[str], command_config_path: Optional[str]): +def build_alias( + cli_ctx: CLIContext, + source: Sequence[str], + command_config_path: Optional[str], +): """Backward-compatible alias for 'kg build'.""" + def _action() -> None: _run_build_command(cli_ctx, source, command_config_path) diff --git a/tests/test_cli_foundation.py b/tests/test_cli_foundation.py index 181d9dbc..3401b86b 100644 --- a/tests/test_cli_foundation.py +++ b/tests/test_cli_foundation.py @@ -13,7 +13,10 @@ def test_root_help_shows_expected_groups(runner): result = runner.invoke(cli_module.main, ["--help"]) assert result.exit_code == 0 - assert "Semantica - Semantic Layer & Knowledge Engineering Framework" in result.output + assert ( + "Semantica - Semantic Layer & Knowledge Engineering Framework" + in result.output + ) assert "kg" in result.output assert "pipeline" in result.output assert "serve" in result.output @@ -37,6 +40,29 @@ def test_kg_build_help_shows_source_and_config_flags(runner): assert "-c" in result.output +def test_command_config_keeps_own_logging_without_global_override(runner, monkeypatch): + captured = {} + + def fake_run_build(cli_ctx, sources): + captured["logging_level"] = cli_ctx.config.get("logging.level") + captured["sources"] = list(sources) + + monkeypatch.setattr(cli_module, "_run_build", fake_run_build) + + with runner.isolated_filesystem(): + with open("cfg.yml", "w", encoding="utf-8") as handle: + handle.write("logging:\n level: DEBUG\n") + + result = runner.invoke( + cli_module.main, + ["kg", "build", "-s", "README.md", "-c", "cfg.yml"], + ) + + assert result.exit_code == 0 + assert captured["sources"] == ["README.md"] + assert captured["logging_level"] == "DEBUG" + + @pytest.mark.parametrize( "argv", [ @@ -130,6 +156,37 @@ def test_invalid_command_config_error_is_clean_and_click_safe(runner): assert "Traceback" not in result.output +@pytest.mark.parametrize( + "file_name, config_text, expected_error", + [ + ("cfg.json", "{not-json", "Failed to parse configuration file"), + ( + "cfg.yml", + "- item\n- item2\n", + "Configuration file must contain a mapping/object", + ), + ], +) +def test_command_config_parse_errors_are_clean_and_click_safe( + runner, + file_name, + config_text, + expected_error, +): + with runner.isolated_filesystem(): + with open(file_name, "w", encoding="utf-8") as handle: + handle.write(config_text) + + result = runner.invoke( + cli_module.main, + ["kg", "build", "-s", "README.md", "-c", file_name], + ) + + assert result.exit_code != 0 + assert expected_error in result.output + assert "Traceback" not in result.output + + @pytest.mark.parametrize( "argv", [ From c447bf5934fef00803975aaa3ac159c735ce2986 Mon Sep 17 00:00:00 2001 From: Sameer6305 Date: Wed, 27 May 2026 13:57:18 +0530 Subject: [PATCH 3/4] cli: harden config parsing and isolate CLI test logging --- semantica/cli.py | 15 ++++++++++++++- tests/test_cli_foundation.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/semantica/cli.py b/semantica/cli.py index fbc4ace5..176622ba 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -70,6 +70,9 @@ def _load_config_data(file_path: Path) -> Dict[str, Any]: f"Failed to parse configuration file '{file_path}': {exc}" ) from exc + if config_data is None: + config_data = {} + if not isinstance(config_data, dict): raise click.ClickException( "Configuration file must contain a mapping/object at the root." @@ -90,8 +93,18 @@ def _build_runtime_config( else: config_data = {} + logging_config = config_data.get("logging") + if logging_config is not None and not isinstance(logging_config, dict): + raise click.ClickException( + "Logging configuration section must contain a mapping/object." + ) + if log_level: - config_data.setdefault("logging", {})["level"] = log_level.upper() + if logging_config is None: + logging_config = {} + config_data["logging"] = logging_config + + logging_config["level"] = log_level.upper() # Keep validation disabled at CLI bootstrap to avoid blocking unrelated commands. return config_manager.load_from_dict(config_data, validate=False) diff --git a/tests/test_cli_foundation.py b/tests/test_cli_foundation.py index 3401b86b..e3bd69cb 100644 --- a/tests/test_cli_foundation.py +++ b/tests/test_cli_foundation.py @@ -9,6 +9,11 @@ def runner(): return CliRunner() +@pytest.fixture(autouse=True) +def disable_cli_logging(monkeypatch): + monkeypatch.setattr(cli_module, "setup_logging", lambda *args, **kwargs: None) + + def test_root_help_shows_expected_groups(runner): result = runner.invoke(cli_module.main, ["--help"]) @@ -156,6 +161,34 @@ def test_invalid_command_config_error_is_clean_and_click_safe(runner): assert "Traceback" not in result.output +def test_empty_yaml_config_is_accepted(runner): + with runner.isolated_filesystem(): + with open("cfg.yml", "w", encoding="utf-8") as handle: + handle.write("") + + result = runner.invoke(cli_module.main, ["--config", "cfg.yml", "info"]) + + assert result.exit_code == 0 + + +def test_malformed_logging_section_is_clean_and_click_safe(runner): + with runner.isolated_filesystem(): + with open("cfg.yml", "w", encoding="utf-8") as handle: + handle.write("logging: []\n") + + result = runner.invoke( + cli_module.main, + ["--config", "cfg.yml", "--log-level", "INFO", "info"], + ) + + assert result.exit_code != 0 + assert ( + "Logging configuration section must contain a mapping/object" + in result.output + ) + assert "Traceback" not in result.output + + @pytest.mark.parametrize( "file_name, config_text, expected_error", [ From 8feb8c00c614e60b4a6c0723aa412778061f9059 Mon Sep 17 00:00:00 2001 From: KaifAhmad1 Date: Wed, 27 May 2026 15:29:39 +0530 Subject: [PATCH 4/4] fix(cli): address review findings from PR #576 - Remove incorrect # pragma: no cover from _run_with_error_handling generic Exception branch (test_runtime_errors_are_click_safe already covers it via the monkeypatched RuntimeError path) - Add _require_ctx() guard: converts None ctx.obj into a clean ClickException instead of an AttributeError (protects standalone_mode=False / library-use callers); apply to info, kg_build, build_alias commands - Rename serve group -> services to avoid collision with the future `semantica server` flat command specified in issue #568; update docstring to document planned subcommand layout - Fix command-level config logging: re-call setup_logging() with the command-level config logging section when -c is used (setup_logging clears handlers before adding, so no accumulation risk) - Fix missing log_level_override in command_ctx: global --log-level was silently dropped when a per-command -c config was present, breaking the override chain for any nested _build_runtime_config calls - Add return-shape docstring on _run_build documenting the expected build_knowledge_base() return dict structure - Add type annotation to runner fixture (-> CliRunner) so Pylance correctly types runner.invoke() -> Result across all test functions - Expand test suite: 25 -> 32 tests * test_info_command_shows_framework_components * test_info_command_shows_config_path_when_supplied * test_log_level_global_override_stores_in_context * test_command_config_preserves_global_log_level_override * test_build_result_with_stats_shows_source_count * test_build_result_without_stats_shows_generic_success * test_build_result_none_shows_generic_success * test_require_ctx_raises_click_exception_on_none * test_require_ctx_returns_ctx_unchanged Co-Authored-By: Claude Sonnet 4.6 --- semantica/cli.py | 67 +++++++++-- tests/test_cli_foundation.py | 215 +++++++++++++++++++++++++++++++++-- 2 files changed, 262 insertions(+), 20 deletions(-) diff --git a/semantica/cli.py b/semantica/cli.py index 176622ba..0d001b81 100644 --- a/semantica/cli.py +++ b/semantica/cli.py @@ -38,6 +38,20 @@ class CLIContext: framework: Optional["Semantica"] = None +def _require_ctx(cli_ctx: Optional[CLIContext]) -> CLIContext: + """Guard against uninitialized CLI context. + + Under normal Click operation (standalone_mode=True) ctx.obj is always set + before a subcommand runs. This guard protects embedded/library usage where + standalone_mode=False might leave ctx.obj as None. + """ + if cli_ctx is None: + raise click.ClickException( + "CLI context is uninitialized — this is a bug, please report it." + ) + return cli_ctx + + def _run_with_error_handling(action: Callable[[], None]) -> None: """Run a CLI action with consistent user-facing error formatting.""" try: @@ -46,7 +60,7 @@ def _run_with_error_handling(action: Callable[[], None]) -> None: raise except SemanticaError as exc: raise click.ClickException(str(exc)) from exc - except Exception as exc: # pragma: no cover - fallback guard + except Exception as exc: raise click.ClickException(f"Unexpected error: {exc}") from exc @@ -120,7 +134,21 @@ def _get_framework(cli_ctx: CLIContext) -> "Semantica": def _run_build(cli_ctx: CLIContext, sources: Sequence[str]) -> None: - """Thin wrapper around existing build orchestration flow.""" + """Thin wrapper around existing build orchestration flow. + + build_knowledge_base is expected to return a dict of the form:: + + { + "statistics": { + "sources_processed": , + ... + }, + ... + } + + Both the top-level dict and the "statistics" key are optional; the + function degrades gracefully when either is absent or None. + """ if not sources: raise click.UsageError( "At least one source is required. Use --source/-s one or more times." @@ -148,14 +176,25 @@ def _run_build_command( source: Sequence[str], command_config_path: Optional[str], ) -> None: - """Execute build command path with optional command-level config override.""" + """Execute build command path with optional command-level config override. + + When a per-command config file is supplied, the logging configuration from + that file is re-applied (setup_logging clears existing handlers before + adding new ones, so there is no handler accumulation risk). + """ if command_config_path: + cmd_config = _build_runtime_config( + command_config_path, cli_ctx.log_level_override + ) + # Re-apply logging so the command-level logging section takes effect. + setup_logging(config=cmd_config.get("logging", {})) command_ctx = CLIContext( config_path=command_config_path, - config=_build_runtime_config( - command_config_path, cli_ctx.log_level_override - ), + config=cmd_config, log_level=cli_ctx.log_level, + # Preserve the global --log-level override so nested config + # lookups further down the call chain respect it. + log_level_override=cli_ctx.log_level_override, ) _run_build(command_ctx, source) else: @@ -217,10 +256,17 @@ def pipeline(ctx: click.Context) -> None: click.echo(ctx.get_help()) -@main.group(invoke_without_command=True) +@main.group(name="services", invoke_without_command=True) @click.pass_context -def serve(ctx: click.Context) -> None: - """Service command group (foundation placeholder).""" +def services(ctx: click.Context) -> None: + """Service management commands (server, explorer, mcp — foundation placeholder). + + Subcommands will follow the spec layout:: + + semantica services server start|stop|status + semantica services explorer start|stop|status + semantica services mcp start|stop|status + """ if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) @@ -237,6 +283,7 @@ def config_group(ctx: click.Context) -> None: @click.pass_obj def info(cli_ctx: CLIContext): """Display information about Semantica.""" + cli_ctx = _require_ctx(cli_ctx) def _action() -> None: console.print(f"[bold blue]Semantica Framework[/bold blue] v{__version__}") @@ -278,6 +325,7 @@ def kg_build( command_config_path: Optional[str], ): """Build a knowledge base from sources.""" + cli_ctx = _require_ctx(cli_ctx) def _action() -> None: _run_build_command(cli_ctx, source, command_config_path) @@ -302,6 +350,7 @@ def build_alias( command_config_path: Optional[str], ): """Backward-compatible alias for 'kg build'.""" + cli_ctx = _require_ctx(cli_ctx) def _action() -> None: _run_build_command(cli_ctx, source, command_config_path) diff --git a/tests/test_cli_foundation.py b/tests/test_cli_foundation.py index e3bd69cb..e22992cb 100644 --- a/tests/test_cli_foundation.py +++ b/tests/test_cli_foundation.py @@ -1,11 +1,16 @@ +import click import pytest -from click.testing import CliRunner +from click.testing import CliRunner, Result import semantica.cli as cli_module @pytest.fixture -def runner(): +def runner() -> CliRunner: + # Click's CliRunner captures all output in result.output by default; error + # messages from ClickException / UsageError are included. If the project + # ever moves to a Click version that separates stderr (mix_stderr=False), + # update assertions that check error text to use result.stderr instead. return CliRunner() @@ -14,6 +19,11 @@ def disable_cli_logging(monkeypatch): monkeypatch.setattr(cli_module, "setup_logging", lambda *args, **kwargs: None) +# --------------------------------------------------------------------------- +# Help surfaces +# --------------------------------------------------------------------------- + + def test_root_help_shows_expected_groups(runner): result = runner.invoke(cli_module.main, ["--help"]) @@ -24,7 +34,7 @@ def test_root_help_shows_expected_groups(runner): ) assert "kg" in result.output assert "pipeline" in result.output - assert "serve" in result.output + assert "services" in result.output def test_kg_group_help_shows_build_command(runner): @@ -45,6 +55,90 @@ def test_kg_build_help_shows_source_and_config_flags(runner): assert "-c" in result.output +# --------------------------------------------------------------------------- +# info command +# --------------------------------------------------------------------------- + + +def test_info_command_shows_framework_components(runner): + result = runner.invoke(cli_module.main, ["info"]) + + assert result.exit_code == 0 + assert "Semantica Framework" in result.output + assert "Core Orchestrator" in result.output + assert "CLI Log Level" in result.output + + +def test_info_command_shows_config_path_when_supplied(runner): + # click.Path(resolve_path=True) turns cfg.yml into an absolute path, and + # Rich's table may truncate long paths with '…'. Assert the row is present + # and the "(none)" placeholder has been replaced by some path value. + with runner.isolated_filesystem(): + with open("cfg.yml", "w", encoding="utf-8") as handle: + handle.write("logging:\n level: INFO\n") + + result: Result = runner.invoke( + cli_module.main, ["--config", "cfg.yml", "info"] + ) + + assert result.exit_code == 0 + assert "CLI Config File" in result.output + assert "(none)" not in result.output + + +# --------------------------------------------------------------------------- +# Global --log-level override +# --------------------------------------------------------------------------- + + +def test_log_level_global_override_stores_in_context(runner, monkeypatch): + """--log-level at the root level propagates into CLIContext.""" + captured = {} + + def fake_run_build(cli_ctx, sources): + captured["log_level_override"] = cli_ctx.log_level_override + captured["log_level"] = cli_ctx.log_level + + monkeypatch.setattr(cli_module, "_run_build", fake_run_build) + + result = runner.invoke( + cli_module.main, + ["--log-level", "DEBUG", "kg", "build", "-s", "src.txt"], + ) + + assert result.exit_code == 0 + assert captured["log_level_override"] == "DEBUG" + + +# --------------------------------------------------------------------------- +# Command-level config +# --------------------------------------------------------------------------- + + +def test_command_config_preserves_global_log_level_override( + runner: CliRunner, monkeypatch: pytest.MonkeyPatch +) -> None: + """Global --log-level is forwarded into command_ctx.log_level_override.""" + captured: dict[str, object] = {} + + def fake_run_build(cli_ctx: cli_module.CLIContext, _: object) -> None: + captured["log_level_override"] = cli_ctx.log_level_override + + monkeypatch.setattr(cli_module, "_run_build", fake_run_build) + + with runner.isolated_filesystem(): + with open("cfg.yml", "w", encoding="utf-8") as handle: + handle.write("logging:\n level: INFO\n") + + result = runner.invoke( + cli_module.main, + ["--log-level", "DEBUG", "kg", "build", "-s", "src.txt", "-c", "cfg.yml"], + ) + + assert result.exit_code == 0 + assert captured["log_level_override"] == "DEBUG" + + def test_command_config_keeps_own_logging_without_global_override(runner, monkeypatch): captured = {} @@ -68,6 +162,11 @@ def test_command_config_keeps_own_logging_without_global_override(runner, monkey assert captured["logging_level"] == "DEBUG" +# --------------------------------------------------------------------------- +# Legacy / new command parity +# --------------------------------------------------------------------------- + + @pytest.mark.parametrize( "argv", [ @@ -119,6 +218,72 @@ def test_build_config_short_and_long_flags_are_compatible(runner, monkeypatch, a assert captured["config_path"] is not None +# --------------------------------------------------------------------------- +# build_knowledge_base result handling +# --------------------------------------------------------------------------- + + +class _MockFramework: + """Lightweight stand-in for Semantica used to test _run_build result paths.""" + + def __init__(self, return_value): + self._return_value = return_value + + def build_knowledge_base(self, sources): + return self._return_value + + +def test_build_result_with_stats_shows_source_count(runner, monkeypatch): + """When build returns statistics.sources_processed, that count appears in output.""" + monkeypatch.setattr( + cli_module, + "_get_framework", + lambda _: _MockFramework({"statistics": {"sources_processed": 3}}), + ) + + result = runner.invoke( + cli_module.main, + ["kg", "build", "-s", "a.txt", "-s", "b.txt", "-s", "c.txt"], + ) + + assert result.exit_code == 0 + assert "3 source(s)" in result.output + + +def test_build_result_without_stats_shows_generic_success(runner, monkeypatch): + """When build result has no statistics key, generic success message is shown.""" + monkeypatch.setattr( + cli_module, + "_get_framework", + lambda _: _MockFramework({}), + ) + + result = runner.invoke(cli_module.main, ["kg", "build", "-s", "src.txt"]) + + assert result.exit_code == 0 + assert "Knowledge base build completed" in result.output + assert "source(s)" not in result.output + + +def test_build_result_none_shows_generic_success(runner, monkeypatch): + """When build_knowledge_base returns None, generic success message is shown.""" + monkeypatch.setattr( + cli_module, + "_get_framework", + lambda _: _MockFramework(None), + ) + + result = runner.invoke(cli_module.main, ["kg", "build", "-s", "src.txt"]) + + assert result.exit_code == 0 + assert "Knowledge base build completed" in result.output + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + @pytest.mark.parametrize( "argv", [ @@ -220,6 +385,24 @@ def test_command_config_parse_errors_are_clean_and_click_safe( assert "Traceback" not in result.output +def test_runtime_errors_are_click_safe_without_traceback(runner, monkeypatch): + def boom(_cli_ctx, _sources): + raise RuntimeError("boom") + + monkeypatch.setattr(cli_module, "_run_build", boom) + + result = runner.invoke(cli_module.main, ["kg", "build", "-s", "README.md"]) + + assert result.exit_code != 0 + assert "Unexpected error: boom" in result.output + assert "Traceback" not in result.output + + +# --------------------------------------------------------------------------- +# Lazy initialization +# --------------------------------------------------------------------------- + + @pytest.mark.parametrize( "argv", [ @@ -240,14 +423,24 @@ def test_help_calls_do_not_initialize_framework(runner, monkeypatch, argv): assert result.exit_code == 0 -def test_runtime_errors_are_click_safe_without_traceback(runner, monkeypatch): - def boom(_cli_ctx, _sources): - raise RuntimeError("boom") +# --------------------------------------------------------------------------- +# _require_ctx guard +# --------------------------------------------------------------------------- - monkeypatch.setattr(cli_module, "_run_build", boom) - result = runner.invoke(cli_module.main, ["kg", "build", "-s", "README.md"]) +def test_require_ctx_raises_click_exception_on_none(): + """_require_ctx converts None ctx into a clean ClickException.""" + with pytest.raises( + click.ClickException, + match="CLI context is uninitialized", + ): + cli_module._require_ctx(None) - assert result.exit_code != 0 - assert "Unexpected error: boom" in result.output - assert "Traceback" not in result.output + +def test_require_ctx_returns_ctx_unchanged(): + """_require_ctx is a pass-through when ctx is valid.""" + from semantica.core.config_manager import Config, ConfigManager + + cfg = ConfigManager().load_from_dict({}, validate=False) + ctx = cli_module.CLIContext(config_path=None, config=cfg, log_level="INFO") + assert cli_module._require_ctx(ctx) is ctx