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
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()

View File

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

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 .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."""

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."""
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.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

View File

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