diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 2f5fe11..3b76f6b 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -9,6 +9,7 @@ from typing import Optional import typer from .common import _get_services +from seedpass.core.errors import SeedPassError app = typer.Typer( help="SeedPass command line interface", @@ -49,6 +50,15 @@ app.add_typer(util.app, name="util") app.add_typer(api.app, name="api") +def run() -> None: + """Invoke the CLI, handling SeedPass errors gracefully.""" + try: + app() + except SeedPassError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + + def _gui_backend_available() -> bool: """Return True if a platform-specific BeeWare backend is installed.""" for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"): @@ -173,4 +183,4 @@ def gui( if __name__ == "__main__": # pragma: no cover - app() + run() diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index 69aedf8..f953fbd 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -25,7 +25,6 @@ except Exception: # pragma: no cover - fallback when orjson is missing USE_ORJSON = False import logging import hashlib -import sys import shutil import time from typing import Optional, Tuple, Dict, Any, List @@ -48,6 +47,7 @@ from utils.key_validation import ( from .vault import Vault from .backup import BackupManager +from .errors import SeedPassError # Instantiate the logger @@ -148,7 +148,7 @@ class EntryManager: except Exception as e: logger.error(f"Error determining next index: {e}", exc_info=True) print(colored(f"Error determining next index: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Error determining next index: {e}") from e def add_entry( self, @@ -238,7 +238,7 @@ class EntryManager: except Exception as e: logger.error(f"Failed to add entry: {e}", exc_info=True) print(colored(f"Error: Failed to add entry: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to add entry: {e}") from e def get_next_totp_index(self) -> int: """Return the next available derivation index for TOTP secrets.""" diff --git a/src/seedpass/core/errors.py b/src/seedpass/core/errors.py new file mode 100644 index 0000000..d84bb54 --- /dev/null +++ b/src/seedpass/core/errors.py @@ -0,0 +1,21 @@ +"""Custom exceptions for SeedPass core modules. + +This module defines :class:`SeedPassError`, a base exception used across the +core modules. Library code should raise this error instead of terminating the +process with ``sys.exit`` so that callers can handle failures gracefully. + +When raised inside the CLI, :class:`SeedPassError` behaves like a Click +exception, displaying a friendly message and exiting with code ``1``. +""" + +from click import ClickException + + +class SeedPassError(ClickException): + """Base exception for SeedPass-related errors.""" + + def __init__(self, message: str): + super().__init__(message) + + +__all__ = ["SeedPassError"] diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 7e0e514..66fe9aa 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -38,6 +38,7 @@ from .backup import BackupManager from .vault import Vault from .portable_backup import export_backup, import_backup, PortableMode from cryptography.fernet import InvalidToken +from .errors import SeedPassError from .totp import TotpManager from .entry_types import EntryType from .pubsub import bus @@ -559,7 +560,7 @@ class PasswordManager: print( colored(f"Error: Failed to initialize FingerprintManager: {e}", "red") ) - sys.exit(1) + raise SeedPassError(f"Failed to initialize FingerprintManager: {e}") from e def setup_parent_seed(self) -> None: """ @@ -601,7 +602,7 @@ class PasswordManager: choice = input("Select a seed profile by number: ").strip() if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints) + 1): print(colored("Invalid selection. Exiting.", "red")) - sys.exit(1) + raise SeedPassError("Invalid selection.") choice = int(choice) if choice == len(fingerprints) + 1: @@ -615,7 +616,7 @@ class PasswordManager: except Exception as e: logger.error(f"Error during seed profile selection: {e}", exc_info=True) print(colored(f"Error: Failed to select seed profile: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to select seed profile: {e}") from e def add_new_fingerprint(self): """ @@ -638,7 +639,7 @@ class PasswordManager: fingerprint = self.generate_new_seed() else: print(colored("Invalid choice. Exiting.", "red")) - sys.exit(1) + raise SeedPassError("Invalid choice.") if not fingerprint: return None @@ -661,7 +662,7 @@ class PasswordManager: except Exception as e: logger.error(f"Error adding new seed profile: {e}", exc_info=True) print(colored(f"Error: Failed to add new seed profile: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to add new seed profile: {e}") from e def select_fingerprint( self, fingerprint: str, *, password: Optional[str] = None @@ -678,7 +679,9 @@ class PasswordManager: "red", ) ) - sys.exit(1) + raise SeedPassError( + f"Seed profile directory for {fingerprint} not found." + ) # Setup the encryption manager and load parent seed self.setup_encryption_manager(self.fingerprint_dir, password) # Initialize BIP85 and other managers @@ -692,7 +695,7 @@ class PasswordManager: ) else: print(colored(f"Error: Seed profile {fingerprint} not found.", "red")) - sys.exit(1) + raise SeedPassError(f"Seed profile {fingerprint} not found.") def setup_encryption_manager( self, @@ -784,10 +787,10 @@ class PasswordManager: logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True) print(colored(f"Error: Failed to set up encryption: {e}", "red")) if exit_on_fail: - sys.exit(1) + raise SeedPassError(f"Failed to set up encryption: {e}") from e return False if exit_on_fail: - sys.exit(1) + raise SeedPassError("Failed to set up encryption") return False def load_parent_seed( @@ -829,7 +832,7 @@ class PasswordManager: except Exception as e: logger.error(f"Failed to load parent seed: {e}", exc_info=True) print(colored(f"Error: Failed to load parent seed: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to load parent seed: {e}") from e @requires_unlocked def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool: @@ -913,7 +916,9 @@ class PasswordManager: "red", ) ) - sys.exit(1) + raise SeedPassError( + "No seed profiles available. Please add a seed profile first." + ) print(colored("Available Seed Profiles:", "cyan")) for idx, fp in enumerate(fingerprints, start=1): @@ -927,7 +932,7 @@ class PasswordManager: choice = input("Select a seed profile by number: ").strip() if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)): print(colored("Invalid selection. Exiting.", "red")) - sys.exit(1) + raise SeedPassError("Invalid selection.") selected_fingerprint = fingerprints[int(choice) - 1] self.current_fingerprint = selected_fingerprint @@ -936,7 +941,7 @@ class PasswordManager: ) if not fingerprint_dir: print(colored("Error: Seed profile directory not found.", "red")) - sys.exit(1) + raise SeedPassError("Seed profile directory not found.") # Derive encryption key from password using selected fingerprint iterations = ( @@ -966,14 +971,14 @@ class PasswordManager: if not self.validate_bip85_seed(self.parent_seed): logging.error("Decrypted seed is invalid. Exiting.") print(colored("Error: Decrypted seed is invalid.", "red")) - sys.exit(1) + raise SeedPassError("Decrypted seed is invalid.") self.initialize_bip85() logging.debug("Parent seed decrypted and validated successfully.") except Exception as e: logging.error(f"Failed to decrypt parent seed: {e}", exc_info=True) print(colored(f"Error: Failed to decrypt parent seed: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to decrypt parent seed: {e}") from e def handle_new_seed_setup(self) -> None: """ @@ -1013,7 +1018,7 @@ class PasswordManager: return else: print(colored("Invalid choice. Exiting.", "red")) - sys.exit(1) + raise SeedPassError("Invalid choice.") # Some seed loading paths may not initialize managers; ensure they exist if getattr(self, "config_manager", None) is None: @@ -1050,13 +1055,13 @@ class PasswordManager: if not self.validate_bip85_seed(parent_seed): logging.error("Invalid BIP-85 seed phrase. Exiting.") print(colored("Error: Invalid BIP-85 seed phrase.", "red")) - sys.exit(1) + raise SeedPassError("Invalid BIP-85 seed phrase.") fingerprint = self._finalize_existing_seed(parent_seed, password=password) return fingerprint except KeyboardInterrupt: logging.info("Operation cancelled by user.") self.notify("Operation cancelled by user.", level="WARNING") - sys.exit(0) + raise SeedPassError("Operation cancelled by user.") def setup_existing_seed_word_by_word( self, *, seed: Optional[str] = None, password: Optional[str] = None @@ -1089,7 +1094,9 @@ class PasswordManager: "red", ) ) - sys.exit(1) + raise SeedPassError( + "Failed to generate seed profile for the provided seed." + ) fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory( fingerprint @@ -1098,7 +1105,7 @@ class PasswordManager: print( colored("Error: Failed to retrieve seed profile directory.", "red") ) - sys.exit(1) + raise SeedPassError("Failed to retrieve seed profile directory.") self.current_fingerprint = fingerprint self.fingerprint_manager.current_fingerprint = fingerprint @@ -1152,7 +1159,7 @@ class PasswordManager: else: logging.error("Invalid BIP-85 seed phrase. Exiting.") print(colored("Error: Invalid BIP-85 seed phrase.", "red")) - sys.exit(1) + raise SeedPassError("Invalid BIP-85 seed phrase.") @requires_unlocked def generate_new_seed(self) -> Optional[str]: @@ -1197,7 +1204,7 @@ class PasswordManager: "red", ) ) - sys.exit(1) + raise SeedPassError("Failed to generate seed profile for the new seed.") fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory( fingerprint @@ -1206,7 +1213,7 @@ class PasswordManager: print( colored("Error: Failed to retrieve seed profile directory.", "red") ) - sys.exit(1) + raise SeedPassError("Failed to retrieve seed profile directory.") # Persist the assigned account index for the new profile try: @@ -1234,7 +1241,7 @@ class PasswordManager: return fingerprint # Return the generated fingerprint else: self.notify("Seed generation cancelled. Exiting.", level="WARNING") - sys.exit(0) + raise SeedPassError("Seed generation cancelled.") def validate_bip85_seed(self, seed: str) -> bool: """ @@ -1271,11 +1278,11 @@ class PasswordManager: except Bip85Error as e: logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True) print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to generate BIP-85 seed: {e}") from e except Exception as e: logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True) print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to generate BIP-85 seed: {e}") from e @requires_unlocked def save_and_encrypt_seed( @@ -1348,7 +1355,7 @@ class PasswordManager: except Exception as e: logging.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True) print(colored(f"Error: Failed to encrypt and save parent seed: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to encrypt and save parent seed: {e}") from e def initialize_bip85(self): """ @@ -1377,7 +1384,7 @@ class PasswordManager: except Exception as e: logging.error(f"Failed to initialize BIP-85: {e}", exc_info=True) print(colored(f"Error: Failed to initialize BIP-85: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to initialize BIP-85: {e}") from e def initialize_managers(self) -> None: """ @@ -1417,7 +1424,7 @@ class PasswordManager: ) except RuntimeError as exc: print(colored(str(exc), "red")) - sys.exit(1) + raise SeedPassError(str(exc)) self.entry_manager = EntryManager( vault=self.vault, @@ -1519,7 +1526,7 @@ class PasswordManager: except Exception as e: logger.error(f"Failed to initialize managers: {e}", exc_info=True) print(colored(f"Error: Failed to initialize managers: {e}", "red")) - sys.exit(1) + raise SeedPassError(f"Failed to initialize managers: {e}") from e async def sync_index_from_nostr_async(self) -> None: """Always fetch the latest vault data from Nostr and update the local index.""" diff --git a/src/seedpass/errors.py b/src/seedpass/errors.py index 1bf3b11..3334c68 100644 --- a/src/seedpass/errors.py +++ b/src/seedpass/errors.py @@ -1,4 +1,13 @@ -class VaultLockedError(Exception): +"""Compatibility layer for historic exception types.""" + +from .core.errors import SeedPassError + + +class VaultLockedError(SeedPassError): """Raised when an operation requires an unlocked vault.""" - pass + def __init__(self, message: str = "Vault is locked") -> None: + super().__init__(message) + + +__all__ = ["VaultLockedError", "SeedPassError"] diff --git a/src/tests/test_legacy_migration.py b/src/tests/test_legacy_migration.py index 5f8645a..0c7d8d5 100644 --- a/src/tests/test_legacy_migration.py +++ b/src/tests/test_legacy_migration.py @@ -14,6 +14,7 @@ import gzip from seedpass.core.manager import PasswordManager, EncryptionMode from seedpass.core.vault import Vault +from seedpass.core.errors import SeedPassError def test_legacy_index_migrates(monkeypatch, tmp_path: Path): @@ -386,7 +387,7 @@ def test_declined_migration_no_sync_prompt(monkeypatch, tmp_path: Path): monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm) - with pytest.raises(SystemExit): + with pytest.raises(SeedPassError): pm.initialize_managers() assert calls["confirm"] == 0 @@ -425,7 +426,7 @@ def test_failed_migration_no_sync_prompt(monkeypatch, tmp_path: Path): monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm) - with pytest.raises(SystemExit): + with pytest.raises(SeedPassError): pm.initialize_managers() assert calls["confirm"] == 0 diff --git a/src/tests/test_seed_word_by_word_flow.py b/src/tests/test_seed_word_by_word_flow.py index 597b15f..3f5a658 100644 --- a/src/tests/test_seed_word_by_word_flow.py +++ b/src/tests/test_seed_word_by_word_flow.py @@ -4,6 +4,7 @@ from types import SimpleNamespace import pytest import seedpass.core.manager as manager_module +from seedpass.core.errors import SeedPassError from helpers import TEST_SEED from utils import seed_prompt @@ -86,7 +87,7 @@ def test_add_new_fingerprint_words_flow_invalid_phrase(monkeypatch): monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None) monkeypatch.setattr(builtins, "input", lambda *_: next(inputs)) - with pytest.raises(SystemExit): + with pytest.raises(SeedPassError): pm.add_new_fingerprint() assert pm.fingerprint_manager.current_fingerprint is None