Merge pull request #847 from PR0M3TH3AN/codex/introduce-seedpasserror-and-replace-sys.exit-calls

Use SeedPassError instead of sys.exit
This commit is contained in:
thePR0M3TH3AN
2025-08-22 10:08:38 -04:00
committed by GitHub
7 changed files with 88 additions and 39 deletions

View File

@@ -9,6 +9,7 @@ from typing import Optional
import typer import typer
from .common import _get_services from .common import _get_services
from seedpass.core.errors import SeedPassError
app = typer.Typer( app = typer.Typer(
help="SeedPass command line interface", help="SeedPass command line interface",
@@ -49,6 +50,15 @@ app.add_typer(util.app, name="util")
app.add_typer(api.app, name="api") 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: def _gui_backend_available() -> bool:
"""Return True if a platform-specific BeeWare backend is installed.""" """Return True if a platform-specific BeeWare backend is installed."""
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"): for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
@@ -173,4 +183,4 @@ def gui(
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
app() run()

View File

@@ -25,7 +25,6 @@ except Exception: # pragma: no cover - fallback when orjson is missing
USE_ORJSON = False USE_ORJSON = False
import logging import logging
import hashlib import hashlib
import sys
import shutil import shutil
import time import time
from typing import Optional, Tuple, Dict, Any, List from typing import Optional, Tuple, Dict, Any, List
@@ -48,6 +47,7 @@ from utils.key_validation import (
from .vault import Vault from .vault import Vault
from .backup import BackupManager from .backup import BackupManager
from .errors import SeedPassError
# Instantiate the logger # Instantiate the logger
@@ -148,7 +148,7 @@ class EntryManager:
except Exception as e: except Exception as e:
logger.error(f"Error determining next index: {e}", exc_info=True) logger.error(f"Error determining next index: {e}", exc_info=True)
print(colored(f"Error determining next index: {e}", "red")) 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( def add_entry(
self, self,
@@ -238,7 +238,7 @@ class EntryManager:
except Exception as e: except Exception as e:
logger.error(f"Failed to add entry: {e}", exc_info=True) logger.error(f"Failed to add entry: {e}", exc_info=True)
print(colored(f"Error: Failed to add entry: {e}", "red")) 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: def get_next_totp_index(self) -> int:
"""Return the next available derivation index for TOTP secrets.""" """Return the next available derivation index for TOTP secrets."""

View File

@@ -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"]

View File

@@ -38,6 +38,7 @@ from .backup import BackupManager
from .vault import Vault from .vault import Vault
from .portable_backup import export_backup, import_backup, PortableMode from .portable_backup import export_backup, import_backup, PortableMode
from cryptography.fernet import InvalidToken from cryptography.fernet import InvalidToken
from .errors import SeedPassError
from .totp import TotpManager from .totp import TotpManager
from .entry_types import EntryType from .entry_types import EntryType
from .pubsub import bus from .pubsub import bus
@@ -559,7 +560,7 @@ class PasswordManager:
print( print(
colored(f"Error: Failed to initialize FingerprintManager: {e}", "red") 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: def setup_parent_seed(self) -> None:
""" """
@@ -601,7 +602,7 @@ class PasswordManager:
choice = input("Select a seed profile by number: ").strip() choice = input("Select a seed profile by number: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints) + 1): if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints) + 1):
print(colored("Invalid selection. Exiting.", "red")) print(colored("Invalid selection. Exiting.", "red"))
sys.exit(1) raise SeedPassError("Invalid selection.")
choice = int(choice) choice = int(choice)
if choice == len(fingerprints) + 1: if choice == len(fingerprints) + 1:
@@ -615,7 +616,7 @@ class PasswordManager:
except Exception as e: except Exception as e:
logger.error(f"Error during seed profile selection: {e}", exc_info=True) logger.error(f"Error during seed profile selection: {e}", exc_info=True)
print(colored(f"Error: Failed to select seed profile: {e}", "red")) 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): def add_new_fingerprint(self):
""" """
@@ -638,7 +639,7 @@ class PasswordManager:
fingerprint = self.generate_new_seed() fingerprint = self.generate_new_seed()
else: else:
print(colored("Invalid choice. Exiting.", "red")) print(colored("Invalid choice. Exiting.", "red"))
sys.exit(1) raise SeedPassError("Invalid choice.")
if not fingerprint: if not fingerprint:
return None return None
@@ -661,7 +662,7 @@ class PasswordManager:
except Exception as e: except Exception as e:
logger.error(f"Error adding new seed profile: {e}", exc_info=True) logger.error(f"Error adding new seed profile: {e}", exc_info=True)
print(colored(f"Error: Failed to add new seed profile: {e}", "red")) 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( def select_fingerprint(
self, fingerprint: str, *, password: Optional[str] = None self, fingerprint: str, *, password: Optional[str] = None
@@ -678,7 +679,9 @@ class PasswordManager:
"red", "red",
) )
) )
sys.exit(1) raise SeedPassError(
f"Seed profile directory for {fingerprint} not found."
)
# Setup the encryption manager and load parent seed # Setup the encryption manager and load parent seed
self.setup_encryption_manager(self.fingerprint_dir, password) self.setup_encryption_manager(self.fingerprint_dir, password)
# Initialize BIP85 and other managers # Initialize BIP85 and other managers
@@ -692,7 +695,7 @@ class PasswordManager:
) )
else: else:
print(colored(f"Error: Seed profile {fingerprint} not found.", "red")) 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( def setup_encryption_manager(
self, self,
@@ -784,10 +787,10 @@ class PasswordManager:
logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True) logger.error(f"Failed to set up EncryptionManager: {e}", exc_info=True)
print(colored(f"Error: Failed to set up encryption: {e}", "red")) print(colored(f"Error: Failed to set up encryption: {e}", "red"))
if exit_on_fail: if exit_on_fail:
sys.exit(1) raise SeedPassError(f"Failed to set up encryption: {e}") from e
return False return False
if exit_on_fail: if exit_on_fail:
sys.exit(1) raise SeedPassError("Failed to set up encryption")
return False return False
def load_parent_seed( def load_parent_seed(
@@ -829,7 +832,7 @@ class PasswordManager:
except Exception as e: except Exception as e:
logger.error(f"Failed to load parent seed: {e}", exc_info=True) logger.error(f"Failed to load parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to load parent seed: {e}", "red")) 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 @requires_unlocked
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool: def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
@@ -913,7 +916,9 @@ class PasswordManager:
"red", "red",
) )
) )
sys.exit(1) raise SeedPassError(
"No seed profiles available. Please add a seed profile first."
)
print(colored("Available Seed Profiles:", "cyan")) print(colored("Available Seed Profiles:", "cyan"))
for idx, fp in enumerate(fingerprints, start=1): for idx, fp in enumerate(fingerprints, start=1):
@@ -927,7 +932,7 @@ class PasswordManager:
choice = input("Select a seed profile by number: ").strip() choice = input("Select a seed profile by number: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)): if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
print(colored("Invalid selection. Exiting.", "red")) print(colored("Invalid selection. Exiting.", "red"))
sys.exit(1) raise SeedPassError("Invalid selection.")
selected_fingerprint = fingerprints[int(choice) - 1] selected_fingerprint = fingerprints[int(choice) - 1]
self.current_fingerprint = selected_fingerprint self.current_fingerprint = selected_fingerprint
@@ -936,7 +941,7 @@ class PasswordManager:
) )
if not fingerprint_dir: if not fingerprint_dir:
print(colored("Error: Seed profile directory not found.", "red")) 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 # Derive encryption key from password using selected fingerprint
iterations = ( iterations = (
@@ -966,14 +971,14 @@ class PasswordManager:
if not self.validate_bip85_seed(self.parent_seed): if not self.validate_bip85_seed(self.parent_seed):
logging.error("Decrypted seed is invalid. Exiting.") logging.error("Decrypted seed is invalid. Exiting.")
print(colored("Error: Decrypted seed is invalid.", "red")) print(colored("Error: Decrypted seed is invalid.", "red"))
sys.exit(1) raise SeedPassError("Decrypted seed is invalid.")
self.initialize_bip85() self.initialize_bip85()
logging.debug("Parent seed decrypted and validated successfully.") logging.debug("Parent seed decrypted and validated successfully.")
except Exception as e: except Exception as e:
logging.error(f"Failed to decrypt parent seed: {e}", exc_info=True) logging.error(f"Failed to decrypt parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to decrypt parent seed: {e}", "red")) 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: def handle_new_seed_setup(self) -> None:
""" """
@@ -1013,7 +1018,7 @@ class PasswordManager:
return return
else: else:
print(colored("Invalid choice. Exiting.", "red")) print(colored("Invalid choice. Exiting.", "red"))
sys.exit(1) raise SeedPassError("Invalid choice.")
# Some seed loading paths may not initialize managers; ensure they exist # Some seed loading paths may not initialize managers; ensure they exist
if getattr(self, "config_manager", None) is None: if getattr(self, "config_manager", None) is None:
@@ -1050,13 +1055,13 @@ class PasswordManager:
if not self.validate_bip85_seed(parent_seed): if not self.validate_bip85_seed(parent_seed):
logging.error("Invalid BIP-85 seed phrase. Exiting.") logging.error("Invalid BIP-85 seed phrase. Exiting.")
print(colored("Error: Invalid BIP-85 seed phrase.", "red")) 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) fingerprint = self._finalize_existing_seed(parent_seed, password=password)
return fingerprint return fingerprint
except KeyboardInterrupt: except KeyboardInterrupt:
logging.info("Operation cancelled by user.") logging.info("Operation cancelled by user.")
self.notify("Operation cancelled by user.", level="WARNING") self.notify("Operation cancelled by user.", level="WARNING")
sys.exit(0) raise SeedPassError("Operation cancelled by user.")
def setup_existing_seed_word_by_word( def setup_existing_seed_word_by_word(
self, *, seed: Optional[str] = None, password: Optional[str] = None self, *, seed: Optional[str] = None, password: Optional[str] = None
@@ -1089,7 +1094,9 @@ class PasswordManager:
"red", "red",
) )
) )
sys.exit(1) raise SeedPassError(
"Failed to generate seed profile for the provided seed."
)
fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory( fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory(
fingerprint fingerprint
@@ -1098,7 +1105,7 @@ class PasswordManager:
print( print(
colored("Error: Failed to retrieve seed profile directory.", "red") 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.current_fingerprint = fingerprint
self.fingerprint_manager.current_fingerprint = fingerprint self.fingerprint_manager.current_fingerprint = fingerprint
@@ -1152,7 +1159,7 @@ class PasswordManager:
else: else:
logging.error("Invalid BIP-85 seed phrase. Exiting.") logging.error("Invalid BIP-85 seed phrase. Exiting.")
print(colored("Error: Invalid BIP-85 seed phrase.", "red")) print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
sys.exit(1) raise SeedPassError("Invalid BIP-85 seed phrase.")
@requires_unlocked @requires_unlocked
def generate_new_seed(self) -> Optional[str]: def generate_new_seed(self) -> Optional[str]:
@@ -1197,7 +1204,7 @@ class PasswordManager:
"red", "red",
) )
) )
sys.exit(1) raise SeedPassError("Failed to generate seed profile for the new seed.")
fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory( fingerprint_dir = self.fingerprint_manager.get_fingerprint_directory(
fingerprint fingerprint
@@ -1206,7 +1213,7 @@ class PasswordManager:
print( print(
colored("Error: Failed to retrieve seed profile directory.", "red") 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 # Persist the assigned account index for the new profile
try: try:
@@ -1234,7 +1241,7 @@ class PasswordManager:
return fingerprint # Return the generated fingerprint return fingerprint # Return the generated fingerprint
else: else:
self.notify("Seed generation cancelled. Exiting.", level="WARNING") self.notify("Seed generation cancelled. Exiting.", level="WARNING")
sys.exit(0) raise SeedPassError("Seed generation cancelled.")
def validate_bip85_seed(self, seed: str) -> bool: def validate_bip85_seed(self, seed: str) -> bool:
""" """
@@ -1271,11 +1278,11 @@ class PasswordManager:
except Bip85Error as e: except Bip85Error as e:
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True) 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")) 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: except Exception as e:
logging.error(f"Failed to generate BIP-85 seed: {e}", exc_info=True) 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")) 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 @requires_unlocked
def save_and_encrypt_seed( def save_and_encrypt_seed(
@@ -1348,7 +1355,7 @@ class PasswordManager:
except Exception as e: except Exception as e:
logging.error(f"Failed to encrypt and save parent seed: {e}", exc_info=True) 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")) 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): def initialize_bip85(self):
""" """
@@ -1377,7 +1384,7 @@ class PasswordManager:
except Exception as e: except Exception as e:
logging.error(f"Failed to initialize BIP-85: {e}", exc_info=True) logging.error(f"Failed to initialize BIP-85: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize BIP-85: {e}", "red")) 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: def initialize_managers(self) -> None:
""" """
@@ -1417,7 +1424,7 @@ class PasswordManager:
) )
except RuntimeError as exc: except RuntimeError as exc:
print(colored(str(exc), "red")) print(colored(str(exc), "red"))
sys.exit(1) raise SeedPassError(str(exc))
self.entry_manager = EntryManager( self.entry_manager = EntryManager(
vault=self.vault, vault=self.vault,
@@ -1519,7 +1526,7 @@ class PasswordManager:
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize managers: {e}", exc_info=True) logger.error(f"Failed to initialize managers: {e}", exc_info=True)
print(colored(f"Error: Failed to initialize managers: {e}", "red")) 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: async def sync_index_from_nostr_async(self) -> None:
"""Always fetch the latest vault data from Nostr and update the local index.""" """Always fetch the latest vault data from Nostr and update the local index."""

View File

@@ -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.""" """Raised when an operation requires an unlocked vault."""
pass def __init__(self, message: str = "Vault is locked") -> None:
super().__init__(message)
__all__ = ["VaultLockedError", "SeedPassError"]

View File

@@ -14,6 +14,7 @@ import gzip
from seedpass.core.manager import PasswordManager, EncryptionMode from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.vault import Vault from seedpass.core.vault import Vault
from seedpass.core.errors import SeedPassError
def test_legacy_index_migrates(monkeypatch, tmp_path: Path): 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) monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
with pytest.raises(SystemExit): with pytest.raises(SeedPassError):
pm.initialize_managers() pm.initialize_managers()
assert calls["confirm"] == 0 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) monkeypatch.setattr("seedpass.core.manager.confirm_action", fake_confirm)
with pytest.raises(SystemExit): with pytest.raises(SeedPassError):
pm.initialize_managers() pm.initialize_managers()
assert calls["confirm"] == 0 assert calls["confirm"] == 0

View File

@@ -4,6 +4,7 @@ from types import SimpleNamespace
import pytest import pytest
import seedpass.core.manager as manager_module import seedpass.core.manager as manager_module
from seedpass.core.errors import SeedPassError
from helpers import TEST_SEED from helpers import TEST_SEED
from utils import seed_prompt 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(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(inputs)) monkeypatch.setattr(builtins, "input", lambda *_: next(inputs))
with pytest.raises(SystemExit): with pytest.raises(SeedPassError):
pm.add_new_fingerprint() pm.add_new_fingerprint()
assert pm.fingerprint_manager.current_fingerprint is None assert pm.fingerprint_manager.current_fingerprint is None