diff --git a/omop_alchemy/config.py b/omop_alchemy/config.py index 1f8f0c9..8839fe0 100644 --- a/omop_alchemy/config.py +++ b/omop_alchemy/config.py @@ -86,8 +86,19 @@ def get_cdm_context() -> tuple[OmopAlchemyConfig, ResolvedResource]: The resource is taken from tools.omop_alchemy.default_resource when set; otherwise falls back to the canonical CDM_DB resource name. + + Raises + ------ + RuntimeError + If no oa-configurator stack config file exists yet. """ - stack = load_stack_config() + try: + stack = load_stack_config() + except FileNotFoundError as exc: + raise RuntimeError( + "No omop-alchemy configuration found. " + "Run `omop-config configure omop_alchemy` to set it up." + ) from exc pkg_config = OmopAlchemyConfig.from_stack(stack) tool = stack.tools.get(OmopAlchemyConfig.tool_name) resource_name = (tool.default_resource if tool else None) or OmopAlchemyConfig.CDM_DB.semantic_name diff --git a/omop_alchemy/maintenance/cli_backup.py b/omop_alchemy/maintenance/cli_backup.py index 8adbda6..abb28d5 100644 --- a/omop_alchemy/maintenance/cli_backup.py +++ b/omop_alchemy/maintenance/cli_backup.py @@ -83,6 +83,11 @@ def create_database_backup( raise RuntimeError( "Database backup failed via `pg_dump`." + (f" {stderr}" if stderr else "") ) from exc + except FileNotFoundError as exc: + raise RuntimeError( + f"`pg_dump` executable not found at {tool_path!r}. " + "It may have been removed from PATH after being resolved." + ) from exc return BackupResult( file_path=str(resolved_output_path), @@ -123,6 +128,11 @@ def restore_database_backup( raise RuntimeError( "Database restore failed." + (f" {stderr}" if stderr else "") ) from exc + except FileNotFoundError as exc: + raise RuntimeError( + f"Restore executable not found at {tool_path!r}. " + "It may have been removed from PATH after being resolved." + ) from exc return BackupResult( file_path=str(resolved_input_path), diff --git a/tests/test_cli_error_handling.py b/tests/test_cli_error_handling.py new file mode 100644 index 0000000..0916979 --- /dev/null +++ b/tests/test_cli_error_handling.py @@ -0,0 +1,74 @@ +"""Tests for missing-config error handling. + +get_cdm_context() converts a missing oa-configurator stack file into a clear +RuntimeError at the source (config.py), rather than handle_error() pattern- +matching on the raw FileNotFoundError type. This keeps handle_error's +FileNotFoundError-shaped catch from also swallowing unrelated missing-file +errors raised elsewhere inside a command body (e.g. a missing pg_dump binary). +""" + +import pytest +import typer + +from omop_alchemy.config import get_cdm_context +from omop_alchemy.maintenance._cli_utils import handle_error +from omop_alchemy.maintenance.ui import console + + +@pytest.fixture(autouse=True) +def _wide_console(): + """Rich wraps/truncates Panel text at the console's default 80-column + width, which is narrower than this message under pytest's captured, + non-tty output. Widen it so assertions can match the full text.""" + original_width = console.width + console.width = 200 + yield + console.width = original_width + + +def _raise_file_not_found(): + raise FileNotFoundError("Config file not found: /home/cava/.config/omop/config.toml") + + +def test_get_cdm_context_raises_runtime_error_when_config_missing(monkeypatch): + monkeypatch.setattr("omop_alchemy.config.load_stack_config", _raise_file_not_found) + with pytest.raises(RuntimeError, match="omop-config configure omop_alchemy"): + get_cdm_context() + + +def test_get_cdm_context_runtime_error_does_not_leak_raw_path(monkeypatch): + """The friendly message should not surface the raw exception text.""" + monkeypatch.setattr("omop_alchemy.config.load_stack_config", _raise_file_not_found) + with pytest.raises(RuntimeError) as exc_info: + get_cdm_context() + assert "/home/cava/.config/omop/config.toml" not in str(exc_info.value) + + +def test_get_cdm_context_runtime_error_chains_original_exception(monkeypatch): + monkeypatch.setattr("omop_alchemy.config.load_stack_config", _raise_file_not_found) + with pytest.raises(RuntimeError) as exc_info: + get_cdm_context() + assert isinstance(exc_info.value.__cause__, FileNotFoundError) + + +def test_handle_error_runtime_error_exits_with_code_1_and_prints_hint(capsys): + """The RuntimeError from get_cdm_context is handled cleanly, same as any other.""" + with pytest.raises(typer.Exit) as exc_info: + handle_error( + RuntimeError( + "No omop-alchemy configuration found. " + "Run `omop-config configure omop_alchemy` to set it up." + ) + ) + assert exc_info.value.exit_code == 1 + captured = capsys.readouterr() + assert "omop-config configure omop_alchemy" in captured.out + + +def test_handle_error_does_not_special_case_file_not_found(): + """A stray FileNotFoundError from elsewhere in a command body must not be + mislabeled as a missing-configuration error — it should propagate as-is.""" + exc = FileNotFoundError("pg_dump: No such file or directory") + with pytest.raises(FileNotFoundError) as exc_info: + handle_error(exc) + assert exc_info.value is exc