diff --git a/pyproject.toml b/pyproject.toml index 37282041..dbf6e74d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,17 +31,20 @@ dependencies = [ "aiodns==3.2", "aiohttp==3.11.13", "aleph-message>=1.0.5", - "aleph-sdk-python>=2.1", - "base58==2.1.1", # Needed now as default with _load_account changement + #"aleph-sdk-python>=2.1", + "aleph-sdk-python @ git+https://github.com/aleph-im/aleph-sdk-python@andres-feature-implement_ledger_wallet", + "base58==2.1.1", # Needed now as default with _load_account changement "click<8.2", - "py-sr25519-bindings==0.2", # Needed for DOT signatures + "ledgerblue>=0.1.48", + "ledgereth>=0.10", + "py-sr25519-bindings==0.2", # Needed for DOT signatures "pydantic>=2", "pygments==2.19.1", - "pynacl==1.5", # Needed now as default with _load_account changement + "pynacl==1.5", # Needed now as default with _load_account changement "python-magic==0.4.27", "rich==13.9.*", "setuptools>=65.5", - "substrate-interface==1.7.11", # Needed for DOT signatures + "substrate-interface==1.7.11", # Needed for DOT signatures "textual==0.73", "typer==0.15.2", ] diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 18157ff4..8e8d1800 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -9,11 +9,12 @@ import aiohttp import typer -from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.chains.common import generate_key from aleph.sdk.chains.solana import parse_private_key as parse_solana_private_key +from aleph.sdk.client import AlephHttpClient from aleph.sdk.conf import ( + AccountType, MainConfiguration, load_main_configuration, save_main_configuration, @@ -24,8 +25,11 @@ get_chains_with_super_token, get_compatible_chains, ) +from aleph.sdk.types import AccountFromPrivateKey from aleph.sdk.utils import bytes_from_hex, displayable_amount +from aleph.sdk.wallets.ledger import LedgerETHAccount from aleph_message.models import Chain +from ledgereth.exceptions import LedgerError from rich import box from rich.console import Console from rich.panel import Panel @@ -39,10 +43,17 @@ from aleph_client.commands.utils import ( input_multiline, setup_logging, + validate_non_interactive_args_config, validated_prompt, yes_no_input, ) -from aleph_client.utils import AsyncTyper, list_unlinked_keys +from aleph_client.utils import ( + AsyncTyper, + get_first_ledger_name, + list_unlinked_keys, + load_account, + wait_for_ledger_connection, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -145,26 +156,27 @@ async def create( @app.command(name="address") def display_active_address( - private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, - private_key_file: Annotated[ - Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) - ] = settings.PRIVATE_KEY_FILE, + private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, + private_key_file: Annotated[Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)] = None, ): """ Display your public address(es). """ - if private_key is not None: - private_key_file = None - elif private_key_file and not private_key_file.exists(): - typer.secho("No private key available", fg=RED) - raise typer.Exit(code=1) + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None - evm_address = _load_account(private_key, private_key_file, chain=Chain.ETH).get_address() - sol_address = _load_account(private_key, private_key_file, chain=Chain.SOL).get_address() + if not account_type or account_type == AccountType.IMPORTED: + evm_address = load_account(private_key, private_key_file, chain=Chain.ETH).get_address() + sol_address = load_account(private_key, private_key_file, chain=Chain.SOL).get_address() + else: + evm_address = config.address if config else "Not available" + sol_address = "Not available (using Ledger device)" + account_type_str = " (Ledger)" if account_type == AccountType.HARDWARE else "" console.print( - "✉ [bold italic blue]Addresses for Active Account[/bold italic blue] ✉\n\n" + f"✉ [bold italic blue]Addresses for Active Account{account_type_str}[/bold italic blue] ✉\n\n" f"[italic]EVM[/italic]: [cyan]{evm_address}[/cyan]\n" f"[italic]SOL[/italic]: [magenta]{sol_address}[/magenta]\n" ) @@ -229,16 +241,31 @@ def export_private_key( """ Display your private key. """ + # Check if we're using a Ledger account + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + + if config and config.type == AccountType.HARDWARE: + typer.secho("Cannot export private key from a Ledger hardware wallet", fg=RED) + typer.secho("The private key remains securely stored on your Ledger device", fg=RED) + raise typer.Exit(code=1) + # Normal private key handling if private_key: private_key_file = None elif private_key_file and not private_key_file.exists(): typer.secho("No private key available", fg=RED) raise typer.Exit(code=1) - evm_pk = _load_account(private_key, private_key_file, chain=Chain.ETH).export_private_key() - sol_pk = _load_account(private_key, private_key_file, chain=Chain.SOL).export_private_key() + eth_account = _load_account(private_key, private_key_file, chain=Chain.ETH) + sol_account = _load_account(private_key, private_key_file, chain=Chain.SOL) + evm_pk = "Not Available" + if isinstance(eth_account, AccountFromPrivateKey): + evm_pk = eth_account.export_private_key() + sol_pk = "Not Available" + if isinstance(sol_account, AccountFromPrivateKey): + sol_pk = sol_account.export_private_key() console.print( "⚠️ [bold italic red]Private Keys for Active Account[/bold italic red] ⚠️\n\n" f"[italic]EVM[/italic]: [cyan]{evm_pk}[/cyan]\n" @@ -261,7 +288,7 @@ def sign_bytes( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account = load_account(private_key, private_key_file, chain=chain) if not message: message = input_multiline() @@ -296,14 +323,21 @@ async def balance( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display your ALEPH balance and basic voucher information.""" - account = _load_account(private_key, private_key_file, chain=chain) + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None - if account and not address: - address = account.get_address() + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file, chain=chain) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address if address: try: - async with AlephHttpClient() as client: + async with AlephHttpClient(settings.API_HOST) as client: balance_data = await client.get_balances(address) available = balance_data.balance - balance_data.locked_amount infos = [ @@ -342,7 +376,7 @@ async def balance( ] # Get vouchers and add them to Account Info panel - async with AuthenticatedAlephHttpClient(account=account) as client: + async with AlephHttpClient(api_server=settings.API_HOST) as client: vouchers = await client.voucher.get_vouchers(address=address) if vouchers: voucher_names = [voucher.name for voucher in vouchers] @@ -381,9 +415,23 @@ async def list_accounts(): table.add_column("Active", no_wrap=True) active_chain = None - if config: + if config and config.path and config.path != Path("None"): active_chain = config.chain table.add_row(config.path.stem, str(config.path), "[bold green]*[/bold green]") + elif config and config.address and config.type == AccountType.HARDWARE: + active_chain = config.chain + + ledger_connected = False + try: + ledger_accounts = LedgerETHAccount.get_accounts() + if ledger_accounts: + ledger_connected = True + except Exception: + ledger_connected = False + + # Only show the config entry if no Ledger is connected + if not ledger_connected: + table.add_row(f"Ledger ({config.address})", "External (Ledger)", "[bold green]*[/bold green]") else: console.print( "[red]No private key path selected in the config file.[/red]\nTo set it up, use: [bold " @@ -395,13 +443,36 @@ async def list_accounts(): if key_file.stem != "default": table.add_row(key_file.stem, str(key_file), "[bold red]-[/bold red]") + active_ledger_address = None + if config and config.type == AccountType.HARDWARE and config.address: + active_ledger_address = config.address.lower() + + try: + ledger_accounts = LedgerETHAccount.get_accounts() + if ledger_accounts: + for idx, ledger_acc in enumerate(ledger_accounts): + if not ledger_acc.address: + continue + + current_address = ledger_acc.address.lower() + is_active = active_ledger_address and current_address == active_ledger_address + status = "[bold green]*[/bold green]" if is_active else "[bold red]-[/bold red]" + + table.add_row(f"Ledger #{idx}", ledger_acc.address, status) + + except Exception: + logger.debug("No ledger detected or error communicating with Ledger") + hold_chains = [*get_chains_with_holding(), Chain.SOL.value] payg_chains = get_chains_with_super_token() active_address = None - if config and config.path and active_chain: - account = _load_account(private_key_path=config.path, chain=active_chain) - active_address = account.get_address() + if config and active_chain: + if config.path: + account = _load_account(private_key_path=config.path, chain=active_chain) + active_address = account.get_address() + elif config.address and config.type == AccountType.HARDWARE: + active_address = config.address console.print( "🌐 [bold italic blue]Chain Infos[/bold italic blue] 🌐\n" @@ -425,14 +496,21 @@ async def vouchers( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display detailed information about your vouchers.""" - account = _load_account(private_key, private_key_file, chain=chain) + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None - if account and not address: - address = account.get_address() + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file, chain=chain) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address if address: try: - async with AuthenticatedAlephHttpClient(account=account) as client: + async with AlephHttpClient(settings.API_HOST) as client: vouchers = await client.voucher.get_vouchers(address=address) if vouchers: voucher_table = Table(title="", show_header=True, box=box.ROUNDED) @@ -476,11 +554,41 @@ async def vouchers( async def configure( private_key_file: Annotated[Optional[Path], typer.Option(help="New path to the private key file")] = None, chain: Annotated[Optional[Chain], typer.Option(help="New active chain")] = None, + address: Annotated[Optional[str], typer.Option(help="New active address")] = None, + account_type: Annotated[Optional[AccountType], typer.Option(help="Account type")] = None, + derivation_path: Annotated[ + Optional[str], typer.Option(help="Derivation path for ledger (e.g. \"44'/60'/0'/0/0\")") + ] = None, + no: Annotated[bool, typer.Option("--no", help="Non-interactive mode. Only apply provided options.")] = False, ): """Configure current private key file and active chain (default selection)""" + if settings.CONFIG_HOME: + settings.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + private_keys_dir = Path(settings.CONFIG_HOME, "private-keys") + private_keys_dir.mkdir(parents=True, exist_ok=True) + unlinked_keys, config = await list_unlinked_keys() + if no: + validate_non_interactive_args_config(config, account_type, private_key_file, address, chain, derivation_path) + + new_chain = chain or config.chain + new_type = account_type or config.type + new_address = address or config.address + new_key = private_key_file or (Path(config.path) if hasattr(config, "path") else None) + new_derivation_path = derivation_path or getattr(config, "derivation_path", None) + + config = MainConfiguration( + path=new_key, chain=new_chain, address=new_address, type=new_type, derivation_path=new_derivation_path + ) + save_main_configuration(settings.CONFIG_FILE, config) + typer.secho("Configuration updated (non-interactive).", fg=typer.colors.GREEN) + return + + current_device = f"{get_first_ledger_name()}" if config.type == AccountType.HARDWARE else f"File: {config.path}" + current_derivation_path = getattr(config, "derivation_path", None) + # Fixes private key file path if private_key_file: if not private_key_file.name.endswith(".key"): @@ -493,16 +601,45 @@ async def configure( typer.secho(f"Private key file not found: {private_key_file}", fg=typer.colors.RED) raise typer.Exit() - # Configures active private key file - if not private_key_file and config and hasattr(config, "path") and Path(config.path).exists(): - if not yes_no_input( - f"Active private key file: [bright_cyan]{config.path}[/bright_cyan]\n[yellow]Keep current active private " - "key?[/yellow]", - default="y", - ): - unlinked_keys = list(filter(lambda key_file: key_file.stem != "default", unlinked_keys)) + console.print(f"Current account type: [bright_cyan]{config.type}[/bright_cyan] - {current_device}") + if current_derivation_path: + console.print(f"Current derivation path: [bright_cyan]{current_derivation_path}[/bright_cyan]") + + if yes_no_input("Do you want to change the account type?", default="n"): + account_type = AccountType( + Prompt.ask("Select new account type", choices=list(AccountType), default=config.type) + ) + else: + account_type = config.type + + address = None + if config.type == AccountType.IMPORTED: + current_key = Path(config.path) if hasattr(config, "path") else None + current_account = _load_account(None, current_key) + address = current_account.get_address() + else: + address = config.address + + console.print(f"Current address: {address}") + + if account_type == AccountType.IMPORTED: + # Determine if we need to ask about keeping or picking a key + current_key = Path(config.path) if getattr(config, "path", None) else None + + if config.type == AccountType.IMPORTED: + change_key = not yes_no_input("[yellow]Keep current private key?[/yellow]", default="y") + else: + console.print( + "[yellow]Switching from a hardware account to an imported one.[/yellow]\n" + "You need to select a private key file to use." + ) + change_key = True + + # If user wants to change key or we must pick one + if change_key: + unlinked_keys = [k for k in unlinked_keys if k.stem != "default"] if not unlinked_keys: - typer.secho("No unlinked private keys found.", fg=typer.colors.GREEN) + typer.secho("No unlinked private keys found.", fg=typer.colors.YELLOW) raise typer.Exit() console.print("[bold cyan]Available unlinked private keys:[/bold cyan]") @@ -511,21 +648,110 @@ async def configure( key_choice = Prompt.ask("Choose a private key by index") if key_choice.isdigit(): - key_index = int(key_choice) - 1 - if 0 <= key_index < len(unlinked_keys): - private_key_file = unlinked_keys[key_index] - if not private_key_file: - typer.secho("Invalid file index.", fg=typer.colors.RED) + idx = int(key_choice) - 1 + if 0 <= idx < len(unlinked_keys): + private_key_file = unlinked_keys[idx] + else: + typer.secho("Invalid index.", fg=typer.colors.RED) + raise typer.Exit() + else: + typer.secho("Invalid input.", fg=typer.colors.RED) raise typer.Exit() - else: # No change - private_key_file = Path(config.path) + else: + private_key_file = current_key + + # Clear derivation path when switching to imported + derivation_path = None + + if account_type == AccountType.HARDWARE: + # Handle derivation path for hardware wallet + if derivation_path: + console.print(f"Using provided derivation path: [bright_cyan]{derivation_path}[/bright_cyan]") + elif current_derivation_path and not yes_no_input( + f"Current derivation path: [bright_cyan]{current_derivation_path}[/bright_cyan]\n" + f"[yellow]Keep current derivation path?[/yellow]", + default="y", + ): + derivation_path = Prompt.ask("Enter new derivation path", default="44'/60'/0'/0/0") + elif not current_derivation_path: + if yes_no_input("Do you want to specify a derivation path?", default="n"): + derivation_path = Prompt.ask("Enter derivation path", default="44'/60'/0'/0/0") + else: + derivation_path = None + else: + derivation_path = current_derivation_path - if not private_key_file: - typer.secho("No private key file provided or found.", fg=typer.colors.RED) - raise typer.Exit() + # If the current config is hardware, show its current address + if config.type == AccountType.HARDWARE and not derivation_path: + change_address = not yes_no_input("[yellow]Keep current Ledger address?[/yellow]", default="y") + else: + # Switching from imported → hardware, must choose an address + console.print( + "[yellow]Switching from an imported account to a hardware one.[/yellow]\n" + "You'll need to select a Ledger address to use." + ) + change_address = True + + if change_address: + try: + # Wait for ledger being UP before continue anythings + wait_for_ledger_connection() + + if derivation_path: + console.print(f"Using derivation path: [bright_cyan]{derivation_path}[/bright_cyan]") + try: + ledger_account = LedgerETHAccount.from_path(derivation_path) + address = ledger_account.get_address() + console.print(f"Derived address: [bright_cyan]{address}[/bright_cyan]") + except Exception as e: + logger.warning(f"Error getting account from path: {e}") + raise typer.Exit(code=1) from e + else: + # Normal flow - show available accounts and let user choose + accounts = LedgerETHAccount.get_accounts() + addresses = [acc.address for acc in accounts] + + console.print(f"[bold cyan]Available addresses on {get_first_ledger_name()}:[/bold cyan]") + for idx, addr in enumerate(addresses, start=1): + console.print(f"[{idx}] {addr}") + + key_choice = Prompt.ask("Choose an address by index") + if key_choice.isdigit(): + key_index = int(key_choice) - 1 + if 0 <= key_index < len(addresses): + address = addresses[key_index] + else: + typer.secho("Invalid address index.", fg=typer.colors.RED) + raise typer.Exit() + else: + typer.secho("Invalid input.", fg=typer.colors.RED) + raise typer.Exit() + + except LedgerError as e: + logger.warning(f"Ledger Error: {getattr(e, 'message', str(e))}") + typer.secho( + "Failed to communicate with Ledger device. Make sure it's unlocked with the Ethereum app open.", + fg=RED, + ) + raise typer.Exit(code=1) from e + except OSError as e: + logger.warning(f"OS Error accessing Ledger: {e!s}") + typer.secho( + "Please ensure Udev rules are set to use Ledger and you have proper USB permissions.", fg=RED + ) + raise typer.Exit(code=1) from e + except BaseException as e: + logger.warning(f"Unexpected error with Ledger: {e!s}") + typer.secho("An unexpected error occurred while communicating with the Ledger device.", fg=RED) + typer.secho("Please ensure your device is connected and working correctly.", fg=RED) + raise typer.Exit(code=1) from e + else: + address = config.address - # Configure active chain - if not chain and config and hasattr(config, "chain"): + # If chain is specified via command line, prioritize it + if chain: + pass + elif config and hasattr(config, "chain"): if not yes_no_input( f"Active chain: [bright_cyan]{config.chain}[/bright_cyan]\n[yellow]Keep current active chain?[/yellow]", default="y", @@ -544,12 +770,26 @@ async def configure( typer.secho("No chain provided.", fg=typer.colors.RED) raise typer.Exit() + if not account_type: + account_type = AccountType.IMPORTED + try: - config = MainConfiguration(path=private_key_file, chain=chain) + config = MainConfiguration( + path=private_key_file, chain=chain, address=address, type=account_type, derivation_path=derivation_path + ) save_main_configuration(settings.CONFIG_FILE, config) + + # Display appropriate configuration details based on account type + if account_type == AccountType.HARDWARE: + config_details = f"{config.address}" + if derivation_path: + config_details += f" (derivation path: {derivation_path})" + else: + config_details = f"{config.path}" + console.print( - f"New Default Configuration: [italic bright_cyan]{config.path}[/italic bright_cyan] with [italic " - f"bright_cyan]{config.chain}[/italic bright_cyan]", + f"New Default Configuration: [italic bright_cyan]{config_details}" + f"[/italic bright_cyan] with [italic bright_cyan]{config.chain}[/italic bright_cyan]", style=typer.colors.GREEN, ) except ValueError as e: diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index c2848e33..eeb4b104 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -8,10 +8,8 @@ import typer from aiohttp import ClientResponseError, ClientSession -from aleph.sdk.account import _load_account -from aleph.sdk.client import AuthenticatedAlephHttpClient -from aleph.sdk.conf import settings -from aleph.sdk.types import AccountFromPrivateKey +from aleph.sdk.client import AlephHttpClient, AuthenticatedAlephHttpClient +from aleph.sdk.conf import AccountType, load_main_configuration, settings from aleph.sdk.utils import extended_json_encoder from aleph_message.models import Chain, MessageType from aleph_message.status import MessageStatus @@ -21,7 +19,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper, sanitize_url +from aleph_client.utils import AccountTypes, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -59,7 +57,7 @@ async def forget( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) address = account.get_address() if address is None else address if key == "security" and not is_same_context(): @@ -132,7 +130,7 @@ async def post( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) address = account.get_address() if address is None else address if key == "security" and not is_same_context(): @@ -194,10 +192,19 @@ async def get( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - address = account.get_address() if address is None else address + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None - async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address + + async with AlephHttpClient(api_server=settings.API_HOST) as client: aggregates = None try: aggregates = await client.fetch_aggregate(address=address, key=key) @@ -229,9 +236,17 @@ async def list_aggregates( """Display all aggregates associated to an account""" setup_logging(debug) - - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - address = account.get_address() if address is None else address + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" async with ClientSession() as session: @@ -304,7 +319,7 @@ async def authorize( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) data = await get( key="security", @@ -378,7 +393,7 @@ async def revoke( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) data = await get( key="security", @@ -433,8 +448,17 @@ async def permissions( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - address = account.get_address() if address is None else address + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address data = await get( key="security", diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 54b8dcde..566a8186 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -5,9 +5,7 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient -from aleph.sdk.account import _load_account -from aleph.sdk.conf import settings -from aleph.sdk.types import AccountFromPrivateKey +from aleph.sdk.conf import AccountType, load_main_configuration, settings from aleph.sdk.utils import displayable_amount from rich import box from rich.console import Console @@ -17,7 +15,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -41,10 +39,17 @@ async def show( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None - if account and not address: - address = account.get_address() + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address if address: async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -87,10 +92,17 @@ async def history( ): setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - - if account and not address: - address = account.get_address() + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address try: # Comment the original API call for testing diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 538bdcbd..9161b190 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -6,7 +6,6 @@ from typing import Annotated, Optional, cast import typer -from aleph.sdk.account import _load_account from aleph.sdk.client import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.conf import settings from aleph.sdk.domain import ( @@ -18,7 +17,6 @@ ) from aleph.sdk.exceptions import DomainConfigurationError from aleph.sdk.query.filters import MessageFilter -from aleph.sdk.types import AccountFromPrivateKey from aleph_message.models import AggregateMessage from aleph_message.models.base import MessageType from rich.console import Console @@ -27,7 +25,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import is_environment_interactive -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) @@ -65,7 +63,7 @@ async def check_domain_records(fqdn, target, owner): async def attach_resource( - account: AccountFromPrivateKey, + account, fqdn: Hostname, item_hash: Optional[str] = None, catch_all_path: Optional[str] = None, @@ -137,7 +135,7 @@ async def attach_resource( ) -async def detach_resource(account: AccountFromPrivateKey, fqdn: Hostname, interactive: Optional[bool] = None): +async def detach_resource(account: AccountTypes, fqdn: Hostname, interactive: Optional[bool] = None): domain_info = await get_aggregate_domain_info(account, fqdn) interactive = is_environment_interactive() if interactive is None else interactive @@ -187,7 +185,7 @@ async def add( ] = settings.PRIVATE_KEY_FILE, ): """Add and link a Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) interactive = False if (not ask) else is_environment_interactive() console = Console() @@ -272,7 +270,7 @@ async def attach( ] = settings.PRIVATE_KEY_FILE, ): """Attach resource to a Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) await attach_resource( account, @@ -294,7 +292,7 @@ async def detach( ] = settings.PRIVATE_KEY_FILE, ): """Unlink Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) await detach_resource(account, Hostname(fqdn), interactive=False if (not ask) else None) raise typer.Exit() @@ -309,7 +307,7 @@ async def info( ] = settings.PRIVATE_KEY_FILE, ): """Show Custom Domain Details.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) console = Console() domain_validator = DomainValidator() diff --git a/src/aleph_client/commands/files.py b/src/aleph_client/commands/files.py index bad66bcb..ed00594e 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -10,9 +10,8 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.account import _load_account -from aleph.sdk.conf import settings -from aleph.sdk.types import AccountFromPrivateKey, StorageEnum, StoredContent +from aleph.sdk.conf import AccountType, load_main_configuration, settings +from aleph.sdk.types import StorageEnum, StoredContent from aleph.sdk.utils import safe_getattr from aleph_message.models import ItemHash, StoreMessage from aleph_message.status import MessageStatus @@ -23,7 +22,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -44,7 +43,7 @@ async def pin( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: result: StoreMessage @@ -75,7 +74,7 @@ async def upload( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: if not path.is_file(): @@ -181,7 +180,7 @@ async def forget( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) hashes = [ItemHash(item_hash) for item_hash in item_hash.split(",")] @@ -270,10 +269,17 @@ async def list_files( json: Annotated[bool, typer.Option(help="Print as json instead of rich table")] = False, ): """List all files for a given address""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - - if account and not address: - address = account.get_address() + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address if address: # Build the query parameters diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index bed8b2d5..5a4d810b 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -11,13 +11,12 @@ import aiohttp import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.account import _load_account -from aleph.sdk.chains.ethereum import ETHAccount +from aleph.sdk.chains.ethereum import BaseEthAccount from aleph.sdk.client.services.crn import NetworkGPUS from aleph.sdk.client.services.pricing import Price from aleph.sdk.client.vm_client import VmClient from aleph.sdk.client.vm_confidential_client import VmConfidentialClient -from aleph.sdk.conf import load_main_configuration, settings +from aleph.sdk.conf import AccountType, load_main_configuration, settings from aleph.sdk.evm_utils import ( FlowUpdate, get_chains_with_holding, @@ -82,7 +81,7 @@ yes_no_input, ) from aleph_client.models import CRNInfo -from aleph_client.utils import AsyncTyper, sanitize_url +from aleph_client.utils import AccountTypes, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -154,6 +153,11 @@ async def create( setup_logging(debug) console = Console() + # Start CRN list fetch as a background task + crn_list_future = call_program_crn_list() + crn_list_future.set_name("crn-list") + await asyncio.sleep(0.0) # Yield control to let the task start + # Loads ssh pubkey try: ssh_pubkey_file = validate_ssh_pubkey_file(ssh_pubkey_file) @@ -167,13 +171,9 @@ async def create( ssh_pubkey: str = ssh_pubkey_file.read_text(encoding="utf-8").strip() # Populates account / address - account = _load_account(private_key, private_key_file, chain=payment_chain) - address = address or settings.ADDRESS_TO_USE or account.get_address() + account: AccountTypes = load_account(private_key, private_key_file, chain=payment_chain) - # Start the fetch in the background (async_lru_cache already returns a future) - # We'll await this at the point we need it - crn_list_future = call_program_crn_list() - crn_list = None + address = address or settings.ADDRESS_TO_USE or account.get_address() # Loads default configuration if no chain is set if payment_chain is None: @@ -316,8 +316,11 @@ async def create( if not firmware_message: raise typer.Exit(code=1) - if not crn_list: - crn_list = await crn_list_future + # Now we need the CRN list data, so await the future + if crn_list_future.done(): + crn_list = crn_list_future.result() + else: + crn_list = await asyncio.wait_for(crn_list_future, timeout=None) # Filter and prepare the list of available GPUs found_gpu_models: Optional[NetworkGPUS] = None @@ -395,7 +398,7 @@ async def create( disk_size = specs.disk_mib gpu_model = specs.gpu_model - disk_size_info = f"Rootfs Size: {round(disk_size/1024, 2)} GiB (defaulted to included storage in tier)" + disk_size_info = f"Rootfs Size: {round(disk_size / 1024, 2)} GiB (defaulted to included storage in tier)" if not isinstance(rootfs_size, int): rootfs_size = validated_int_prompt( "Custom Rootfs Size (MiB)", @@ -405,7 +408,7 @@ async def create( ) if rootfs_size > disk_size: disk_size = rootfs_size - disk_size_info = f"Rootfs Size: {round(rootfs_size/1024, 2)} GiB (extended from included storage in tier)" + disk_size_info = f"Rootfs Size: {round(rootfs_size / 1024, 2)} GiB (extended from included storage in tier)" echo(disk_size_info) volumes = [] if any([persistent_volume, ephemeral_volume, immutable_volume]) or not skip_volume: @@ -421,12 +424,13 @@ async def create( async with AlephHttpClient(api_server=settings.API_HOST) as client: balance_response = await client.get_balances(address) available_amount = balance_response.balance - balance_response.locked_amount - available_funds = Decimal(0 if is_stream else available_amount) + available_funds = Decimal(available_amount) try: # Get compute_unit price from PricingPerEntity - if is_stream and isinstance(account, ETHAccount): + if is_stream and isinstance(account, BaseEthAccount): if account.CHAIN != payment_chain: account.switch_chain(payment_chain) + if safe_getattr(account, "superfluid_connector"): if isinstance(compute_unit_price, Price) and compute_unit_price.payg: payg_price = Decimal(str(compute_unit_price.payg)) * tier.compute_units @@ -478,11 +482,6 @@ async def create( raise typer.Exit(1) from e if crn_url or crn_hash: - if not gpu: - echo("Fetching compute resource node's list...") - if not crn_list: - crn_list = await crn_list_future - crn = crn_list.find_crn( address=crn_url, crn_hash=crn_hash, @@ -504,8 +503,6 @@ async def create( raise typer.Exit(1) while not crn_info: - if not crn_list: - crn_list = await crn_list_future filtered_crns = crn_list.filter_crn( latest_crn_version=True, @@ -539,7 +536,7 @@ async def create( ) requirements, trusted_execution, gpu_requirement, tac_accepted = None, None, None, None - if crn and crn_info: + if crn_info: if is_stream and not crn_info.stream_reward_address: echo("Selected CRN does not have a defined or valid receiver address.") raise typer.Exit(1) @@ -639,7 +636,7 @@ async def create( raise typer.Exit(code=1) from e try: - if is_stream and isinstance(account, ETHAccount): + if is_stream and isinstance(account, BaseEthAccount): account.can_start_flow(required_tokens) elif available_funds < required_tokens: raise InsufficientFundsError(TokenType.ALEPH, float(required_tokens), float(available_funds)) @@ -690,7 +687,7 @@ async def create( await wait_for_processed_instance(session, item_hash) # Pay-As-You-Go - if is_stream and isinstance(account, ETHAccount): + if is_stream and isinstance(account, BaseEthAccount): # Start the flows echo("Starting the flows...") fetched_settings = await fetch_settings() @@ -719,9 +716,9 @@ async def create( f"[orange3]{key}[/orange3]: {value}" for key, value in { "$ALEPH": f"[violet]{displayable_amount(required_tokens, decimals=8)}/sec" - f" | {displayable_amount(3600*required_tokens, decimals=3)}/hour" - f" | {displayable_amount(86400*required_tokens, decimals=3)}/day" - f" | {displayable_amount(2628000*required_tokens, decimals=3)}/month[/violet]", + f" | {displayable_amount(3600 * required_tokens, decimals=3)}/hour" + f" | {displayable_amount(86400 * required_tokens, decimals=3)}/day" + f" | {displayable_amount(2628000 * required_tokens, decimals=3)}/month[/violet]", "Flow Distribution": "\n[bright_cyan]80% ➜ CRN wallet[/bright_cyan]" f"\n Address: {crn_info.stream_reward_address}\n Tx: {flow_hash_crn}" f"\n[bright_cyan]20% ➜ Community wallet[/bright_cyan]" @@ -830,7 +827,7 @@ async def delete( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: try: existing_message: InstanceMessage = await client.get_message( @@ -882,7 +879,12 @@ async def delete( echo("No CRN information available for this instance. Skipping VM erasure.") # Check for streaming payment and eventually stop it - if payment and payment.type == PaymentType.superfluid and payment.receiver and isinstance(account, ETHAccount): + if ( + payment + and payment.type == PaymentType.superfluid + and payment.receiver + and isinstance(account, BaseEthAccount) + ): if account.CHAIN != payment.chain: account.switch_chain(payment.chain) if safe_getattr(account, "superfluid_connector") and price: @@ -942,8 +944,18 @@ async def list_instances( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) - address = address or settings.ADDRESS_TO_USE or account.get_address() + # Load config to check account type + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address async with AlephHttpClient(api_server=settings.API_HOST) as client: instances: list[InstanceMessage] = await client.instance.get_instances(address=address) @@ -979,7 +991,7 @@ async def reboot( or Prompt.ask("URL of the CRN (Compute node) on which the VM is running") ) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: status, result = await manager.reboot_instance(vm_id=vm_id) @@ -1012,7 +1024,7 @@ async def allocate( or Prompt.ask("URL of the CRN (Compute node) on which the VM will be allocated") ) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: status, result = await manager.start_instance(vm_id=vm_id) @@ -1040,7 +1052,7 @@ async def logs( domain = (domain and sanitize_url(domain)) or await find_crn_of_vm(vm_id) or Prompt.ask(help_strings.PROMPT_CRN_URL) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: try: @@ -1071,7 +1083,7 @@ async def stop( domain = (domain and sanitize_url(domain)) or await find_crn_of_vm(vm_id) or Prompt.ask(help_strings.PROMPT_CRN_URL) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: status, result = await manager.stop_instance(vm_id=vm_id) @@ -1110,7 +1122,7 @@ async def confidential_init_session( or Prompt.ask("URL of the CRN (Compute node) on which the session will be initialized") ) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() @@ -1187,7 +1199,7 @@ async def confidential_start( session_dir.mkdir(exist_ok=True, parents=True) vm_hash = ItemHash(vm_id) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() domain = ( diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index 8c274e93..bef7da31 100644 --- a/src/aleph_client/commands/instance/network.py +++ b/src/aleph_client/commands/instance/network.py @@ -23,18 +23,17 @@ latest_crn_version_link = "https://api.github.com/repos/aleph-im/aleph-vm/releases/latest" settings_link = ( - f"{sanitize_url(settings.API_HOST)}" - "/api/v0/aggregates/0xFba561a84A537fCaa567bb7A2257e7142701ae2A.json?keys=settings" + f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/0xFba561a84A537fCaa567bb7A2257e7142701ae2A.json?keys=settings" ) @async_lru_cache -async def call_program_crn_list() -> CrnList: +async def call_program_crn_list(only_active: bool = False) -> CrnList: """Call program to fetch the compute resource node list.""" error = None try: async with AlephHttpClient() as client: - return await client.crn.get_crns_list(False) + return await client.crn.get_crns_list(only_active) except InvalidURL as e: error = f"Invalid URL: {settings.CRN_LIST_URL}: {e}" except TimeoutError as e: diff --git a/src/aleph_client/commands/instance/port_forwarder.py b/src/aleph_client/commands/instance/port_forwarder.py index 58421402..23c84c9f 100644 --- a/src/aleph_client/commands/instance/port_forwarder.py +++ b/src/aleph_client/commands/instance/port_forwarder.py @@ -7,7 +7,6 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.account import _load_account from aleph.sdk.conf import settings from aleph.sdk.exceptions import MessageNotProcessed, NotAuthorize from aleph.sdk.types import InstanceManual, PortFlags, Ports @@ -21,7 +20,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -42,7 +41,7 @@ async def list_ports( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file) address = address or settings.ADDRESS_TO_USE or account.get_address() async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -160,7 +159,7 @@ async def create( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file) # Create the port flags port_flags = PortFlags(tcp=tcp, udp=udp) @@ -213,7 +212,7 @@ async def update( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain) # First check if the port forward exists async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -293,7 +292,7 @@ async def delete( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain) # First check if the port forward exists async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -376,7 +375,7 @@ async def refresh( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain) try: async with AuthenticatedAlephHttpClient(api_server=settings.API_HOST, account=account) as client: diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index a7a48d60..94ccd68e 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -11,7 +11,6 @@ import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.account import _load_account from aleph.sdk.conf import settings from aleph.sdk.exceptions import ( ForgottenMessageError, @@ -20,7 +19,7 @@ ) from aleph.sdk.query.filters import MessageFilter from aleph.sdk.query.responses import MessagesResponse -from aleph.sdk.types import AccountFromPrivateKey, StorageEnum +from aleph.sdk.types import StorageEnum from aleph.sdk.utils import extended_json_encoder from aleph_message.models import AlephMessage, ProgramMessage from aleph_message.models.base import MessageType @@ -35,7 +34,7 @@ setup_logging, str_to_datetime, ) -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper, load_account app = AsyncTyper(no_args_is_help=True) @@ -138,7 +137,7 @@ async def post( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) storage_engine: StorageEnum content: dict @@ -188,7 +187,7 @@ async def amend( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) async with AlephHttpClient(api_server=settings.API_HOST) as client: existing_message: Optional[AlephMessage] = None @@ -253,7 +252,7 @@ async def forget( hash_list: list[ItemHash] = [ItemHash(h) for h in hashes.split(",")] - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: await client.forget(hashes=hash_list, reason=reason, channel=channel) @@ -296,7 +295,7 @@ def sign( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) if message is None: message = input_multiline() diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 4105656f..3df2a564 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -15,7 +15,7 @@ from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.client.vm_client import VmClient -from aleph.sdk.conf import load_main_configuration, settings +from aleph.sdk.conf import AccountType, load_main_configuration, settings from aleph.sdk.evm_utils import get_chains_with_holding from aleph.sdk.exceptions import ( ForgottenMessageError, @@ -24,7 +24,7 @@ ) from aleph.sdk.query.filters import MessageFilter from aleph.sdk.query.responses import PriceResponse -from aleph.sdk.types import AccountFromPrivateKey, StorageEnum, TokenType +from aleph.sdk.types import StorageEnum, TokenType from aleph.sdk.utils import displayable_amount, make_program_content, safe_getattr from aleph_message.models import ( Chain, @@ -58,7 +58,7 @@ validated_prompt, yes_no_input, ) -from aleph_client.utils import AsyncTyper, create_archive, sanitize_url +from aleph_client.utils import AsyncTyper, create_archive, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -127,7 +127,7 @@ async def upload( typer.echo("No such file or directory") raise typer.Exit(code=4) from error - account: AccountFromPrivateKey = _load_account(private_key, private_key_file, chain=payment_chain) + account = _load_account(private_key, private_key_file, chain=payment_chain) address = address or settings.ADDRESS_TO_USE or account.get_address() # Loads default configuration if no chain is set @@ -339,7 +339,7 @@ async def update( typer.echo("No such file or directory") raise typer.Exit(code=4) from error - account: AccountFromPrivateKey = _load_account(private_key, private_key_file, chain=chain) + account = _load_account(private_key, private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: try: @@ -502,8 +502,23 @@ async def list_programs( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) - address = address or settings.ADDRESS_TO_USE or account.get_address() + # Load config to check account type + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + # Avoid connecting to ledger + if not account_type or account_type == AccountType.IMPORTED: + account = load_account(private_key, private_key_file) + if account and not address: + address = account.get_address() + elif not address and config and config.address: + address = config.address + + # Ensure we have an address to query + if not address: + typer.echo("Error: No address found. Please provide an address or use a private key.") + raise typer.Exit(code=1) async with AlephHttpClient(api_server=settings.API_HOST) as client: resp = await client.get_messages( @@ -615,7 +630,7 @@ async def list_programs( f"Updatable: {'[green]Yes[/green]' if message.content.allow_amend else '[orange3]Code only[/orange3]'}", ] specifications = Text.from_markup("".join(specs)) - config = Text.assemble( + config_info = Text.assemble( Text.from_markup( f"Runtime: [bright_cyan][link={settings.API_HOST}/api/v0/messages/{message.content.runtime.ref}]" f"{message.content.runtime.ref}[/link][/bright_cyan]\n" @@ -625,7 +640,7 @@ async def list_programs( ), Text.from_markup(display_mounted_volumes(message)), ) - table.add_row(program, specifications, config) + table.add_row(program, specifications, config_info) table.add_section() console = Console() diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index b4174724..52a63c1e 100644 --- a/src/aleph_client/commands/utils.py +++ b/src/aleph_client/commands/utils.py @@ -10,14 +10,21 @@ from pathlib import Path from typing import Any, Callable, Optional, TypeVar, Union, get_args +import typer from aiohttp import ClientSession from aleph.sdk import AlephHttpClient from aleph.sdk.chains.ethereum import ETHAccount -from aleph.sdk.conf import settings +from aleph.sdk.conf import AccountType, settings from aleph.sdk.exceptions import ForgottenMessageError, MessageNotFoundError from aleph.sdk.types import GenericMessage from aleph.sdk.utils import safe_getattr -from aleph_message.models import AlephMessage, InstanceMessage, ItemHash, ProgramMessage +from aleph_message.models import ( + AlephMessage, + Chain, + InstanceMessage, + ItemHash, + ProgramMessage, +) from aleph_message.models.execution.volume import ( EphemeralVolumeSize, PersistentVolumeSizeMib, @@ -406,3 +413,103 @@ def find_sevctl_or_exit() -> Path: echo("Instructions for setup https://docs.aleph.im/computing/confidential/requirements/") raise Exit(code=1) return Path(sevctl_path) + + +def validate_non_interactive_args_config( + config, + account_type: Optional[AccountType], + private_key_file: Optional[Path], + address: Optional[str], + chain: Optional[Chain], + derivation_path: Optional[str] = None, +) -> None: + """ + Validate argument combinations when running in non-interactive (--no) mode. + + This function enforces logical consistency for non-interactive configuration + updates, ensuring that only valid combinations of arguments are accepted + when prompts are disabled. + + Validation Rules + ---------------- + 1. Hardware accounts require an address OR a derivation path. + `--account-type hardware --address 0xABC --no` + `--account-type hardware --derivation-path "44'/60'/0'/0/0" --no` + + 2. Imported accounts require a private key file. + `--account-type imported --no` + `--account-type imported --private-key-file my.key --no` + + 3. Private key file and address cannot be combined. + `--address 0xABC --private-key-file key.key --no` + + 4. Private key files are invalid for hardware accounts. + Applies both when the *new* or *existing* account type is hardware. + + 5. Addresses are invalid for imported accounts. + Applies both when the *new* or *existing* account type is imported. + + 6. Derivation paths are invalid for imported accounts. + Applies both when the *new* or *existing* account type is imported. + + 7. Chain updates are always allowed. + `--chain ETH --no` + + 8. If no arguments are provided with `--no`, the command performs no changes + and simply keeps the existing configuration. + + Parameters + ---------- + config : MainConfiguration + The currently loaded configuration object. + account_type : Optional[AccountType] + The new account type to set (e.g. HARDWARE, IMPORTED). + private_key_file : Optional[Path] + A path to a private key file (for imported accounts only). + address : Optional[str] + The account address (for hardware accounts only). + chain : Optional[Chain] + The blockchain chain to switch to. + derivation_path : Optional[str] + The derivation path for ledger hardware wallets. + + Raises + ------ + typer.Exit + If an invalid argument combination is detected. + """ + + # 1. Hardware requires address or derivation path + if account_type == AccountType.HARDWARE and not (address or derivation_path): + typer.secho("--no mode: hardware accounts require either --address or --derivation-path.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 2. Imported requires private key file + if account_type == AccountType.IMPORTED and not private_key_file: + typer.secho("--no mode: imported accounts require --private-key-file.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 3. Both address + private key provided + if private_key_file and address: + typer.secho("Cannot specify both --address and --private-key-file.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 4. Private key invalid for hardware + if private_key_file and (account_type == AccountType.HARDWARE or (config and config.type == AccountType.HARDWARE)): + typer.secho("Cannot use private key file for hardware accounts.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 5. Address invalid for imported + if address and (account_type == AccountType.IMPORTED or (config and config.type == AccountType.IMPORTED)): + typer.secho("Cannot use address for imported accounts.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 6. Derivation path invalid for imported + if derivation_path and (account_type == AccountType.IMPORTED or (config and config.type == AccountType.IMPORTED)): + typer.secho("Cannot use derivation path for imported accounts.", fg=typer.colors.RED) + raise typer.Exit(1) + + # 8. No arguments provided = no-op + if not any([private_key_file, chain, address, account_type, derivation_path]): + typer.secho("No changes provided. Keeping existing configuration.", fg=typer.colors.YELLOW) + raise typer.Exit(0) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index cc3c5aaa..08022756 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -7,6 +7,7 @@ import re import subprocess import sys +import time from asyncio import ensure_future from functools import lru_cache, partial, wraps from pathlib import Path @@ -16,14 +17,25 @@ from zipfile import BadZipFile, ZipFile import aiohttp +import hid import typer from aiohttp import ClientSession -from aleph.sdk.conf import MainConfiguration, load_main_configuration, settings +from aleph.sdk.account import AccountTypes, _load_account +from aleph.sdk.conf import ( + AccountType, + MainConfiguration, + load_main_configuration, + settings, +) from aleph.sdk.types import GenericMessage +from aleph.sdk.wallets.ledger import LedgerETHAccount +from aleph_message.models import Chain from aleph_message.models.base import MessageType from aleph_message.models.execution.base import Encoding +from ledgereth.exceptions import LedgerError logger = logging.getLogger(__name__) +LEDGER_VENDOR_ID = 0x2C97 try: import magic @@ -190,3 +202,178 @@ def cached_async_function(*args, **kwargs): return ensure_future(async_function(*args, **kwargs)) return cached_async_function + + +def load_account( + private_key_str: Optional[str], private_key_file: Optional[Path], chain: Optional[Chain] = None +) -> AccountTypes: + """ + Two Case Possible + - Account from private key + - Hardware account (ledger) + + We first try to load configurations, if no configurations we fallback to private_key_str / private_key_file. + """ + + # 1st Check for configurations + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + + # If no config we try to load private_key_str / private_key_file + if not config: + logger.warning("No config detected fallback to private key") + if private_key_str is not None: + private_key_file = None + + elif private_key_file and not private_key_file.exists(): + logger.error("No account could be retrieved please use `aleph account create` or `aleph account configure`") + raise typer.Exit(code=1) + + if not chain and config: + chain = config.chain + + if config and config.type and config.type == AccountType.HARDWARE: + try: + wait_for_ledger_connection() + return _load_account(None, None, chain=chain) + except LedgerError as err: + raise typer.Exit(code=1) from err + except OSError as err: + raise typer.Exit(code=1) from err + else: + return _load_account(private_key_str, private_key_file, chain=chain) + + +def list_ledger_dongles(unique_only: bool = True): + """ + Enumerate Ledger devices, optionally filtering duplicates (multi-interface entries). + Returns list of dicts with 'path' and 'product_string'. + """ + devices = [] + seen_serials = set() + + for dev in hid.enumerate(): + if dev.get("vendor_id") != LEDGER_VENDOR_ID: + continue + + product = dev.get("product_string") or "Ledger" + path = dev.get("path") + serial = dev.get("serial_number") or f"{dev.get('vendor_id')}:{dev.get('product_id')}" + + # Filter out duplicate interfaces + if unique_only and serial in seen_serials: + continue + + seen_serials.add(serial) + devices.append( + { + "path": path, + "product_string": product, + "vendor_id": dev.get("vendor_id"), + "product_id": dev.get("product_id"), + "serial_number": serial, + } + ) + + # Prefer :1.0 interface if multiple + devices = [d for d in devices if not str(d["path"]).endswith(":1.1")] + + return devices + + +def get_ledger_name(device_info: dict) -> str: + """ + Return a human-readable name for a Ledger dongle. + Example: "Ledger Nano X (0001:0023)" or "Ledger (unknown)". + """ + if not device_info: + return "Unknown Ledger" + + name = device_info.get("product_string") or "Ledger" + raw_path = device_info.get("path") + if isinstance(raw_path, bytes): + raw_path = raw_path.decode(errors="ignore") + + # derive a short, friendly ID + short_id = None + if raw_path: + short_id = raw_path.split("#")[-1][:8] if "#" in raw_path else raw_path[-8:] + return f"{name} ({short_id})" if short_id else name + + +def get_first_ledger_name() -> str: + """Return the name of the first connected Ledger, or 'No Ledger found'.""" + devices = list_ledger_dongles() + if not devices: + return "No Ledger found" + return get_ledger_name(devices[0]) + + +def wait_for_ledger_connection(poll_interval: float = 1.0) -> None: + """ + Wait until a Ledger device is connected and ready. + + Uses HID to detect physical connection, then confirms communication + by calling LedgerETHAccount.get_accounts(). Handles permission errors + gracefully and allows the user to cancel (Ctrl+C). + + Parameters + ---------- + poll_interval : float + Seconds between checks (default: 1). + """ + + vendor_id = 0x2C97 # Ledger vendor ID + + # Check if ledger is already connected and ready + try: + accounts = LedgerETHAccount.get_accounts() + if accounts: + typer.secho("Ledger connected and ready!", fg=typer.colors.GREEN) + return + except Exception as e: + # Continue with the normal flow if not ready + logger.debug(f"Ledger not ready: {e}") + + typer.secho("\nPlease connect your Ledger device and unlock it.", fg=typer.colors.CYAN) + typer.echo(" (Open the Ethereum app if required.)") + typer.echo(" Press Ctrl+C to cancel.\n") + + # No longer using this variable, removed + while True: + try: + # Detect via HID + devices = hid.enumerate(vendor_id, 0) + if not devices: + typer.echo("Waiting for Ledger device connection...", err=True) + time.sleep(poll_interval) + continue + + # Try to communicate (device connected but may be locked) + try: + accounts = LedgerETHAccount.get_accounts() + if accounts: + typer.secho("Ledger connected and ready!", fg=typer.colors.GREEN) + return + except LedgerError: + typer.echo("Ledger detected but locked or wrong app open.", err=True) + time.sleep(poll_interval) + continue + except BaseException as e: + typer.echo(f"Communication error with Ledger: {str(e)[:50]}... Retrying...", err=True) + time.sleep(poll_interval) + continue + + except OSError as err: + # Typically means missing permissions or udev rules + typer.secho( + f"OS error while accessing Ledger ({err}).\n" + "Please ensure you have proper USB permissions (udev rules).", + fg=typer.colors.RED, + ) + raise typer.Exit(1) from err + except KeyboardInterrupt as err: + typer.secho("\nCancelled by user.", fg=typer.colors.YELLOW) + raise typer.Exit(1) from err + + time.sleep(poll_interval) diff --git a/tests/unit/test_account_transact.py b/tests/unit/test_account_transact.py index 81a59b1b..3faba2da 100644 --- a/tests/unit/test_account_transact.py +++ b/tests/unit/test_account_transact.py @@ -26,7 +26,7 @@ def test_account_can_transact_success(mock_account): assert mock_account.can_transact() is True -@patch("aleph_client.commands.account._load_account") +@patch("aleph_client.commands.account.load_account") def test_account_can_transact_error_handling(mock_load_account): """Test that error is handled properly when account.can_transact() fails.""" # Setup mock account that will raise InsufficientFundsError diff --git a/tests/unit/test_aggregate.py b/tests/unit/test_aggregate.py index dc03988f..9f0791ba 100644 --- a/tests/unit/test_aggregate.py +++ b/tests/unit/test_aggregate.py @@ -52,6 +52,15 @@ def create_mock_auth_client(return_fetch=FAKE_AGGREGATE_DATA): return mock_auth_client_class, mock_auth_client +def create_mock_client(return_fetch=FAKE_AGGREGATE_DATA): + mock_auth_client = AsyncMock( + fetch_aggregate=AsyncMock(return_value=return_fetch), + ) + mock_auth_client_class = MagicMock() + mock_auth_client_class.return_value.__aenter__ = AsyncMock(return_value=mock_auth_client) + return mock_auth_client_class, mock_auth_client + + @pytest.mark.parametrize( ids=["by_key_only", "by_key_and_subkey", "by_key_and_subkeys"], argnames="args", @@ -67,7 +76,7 @@ async def test_forget(capsys, args): mock_list_aggregates = AsyncMock(return_value=FAKE_AGGREGATE_DATA) mock_auth_client_class, mock_auth_client = create_mock_auth_client() - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.list_aggregates", mock_list_aggregates) @patch("aleph_client.commands.aggregate.AuthenticatedAlephHttpClient", mock_auth_client_class) async def run_forget(aggr_spec): @@ -101,7 +110,7 @@ async def test_post(capsys, args): mock_load_account = create_mock_load_account() mock_auth_client_class, mock_auth_client = create_mock_auth_client() - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.AuthenticatedAlephHttpClient", mock_auth_client_class) async def run_post(aggr_spec): print() # For better display when pytest -v -s @@ -133,17 +142,17 @@ async def run_post(aggr_spec): @pytest.mark.asyncio async def test_get(capsys, args, expected): mock_load_account = create_mock_load_account() - mock_auth_client_class, mock_auth_client = create_mock_auth_client(return_fetch=FAKE_AGGREGATE_DATA["AI"]) + mock_auth_class, mock__client = create_mock_auth_client(return_fetch=FAKE_AGGREGATE_DATA["AI"]) - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) - @patch("aleph_client.commands.aggregate.AuthenticatedAlephHttpClient", mock_auth_client_class) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.AlephHttpClient", mock_auth_class) async def run_get(aggr_spec): print() # For better display when pytest -v -s return await get(**aggr_spec) aggregate = await run_get(args) mock_load_account.assert_called_once() - mock_auth_client.fetch_aggregate.assert_called_once() + mock__client.fetch_aggregate.assert_called_once() captured = capsys.readouterr() assert aggregate == expected and expected == json.loads(captured.out) @@ -152,7 +161,7 @@ async def run_get(aggr_spec): async def test_list_aggregates(): mock_load_account = create_mock_load_account() - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch.object(aiohttp.ClientSession, "get", mock_client_session_get) async def run_list_aggregates(): print() # For better display when pytest -v -s @@ -169,7 +178,7 @@ async def test_authorize(capsys): mock_get = AsyncMock(return_value=FAKE_AGGREGATE_DATA["security"]) mock_post = AsyncMock(return_value=True) - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.get", mock_get) @patch("aleph_client.commands.aggregate.post", mock_post) async def run_authorize(): @@ -190,7 +199,7 @@ async def test_revoke(capsys): mock_get = AsyncMock(return_value=FAKE_AGGREGATE_DATA["security"]) mock_post = AsyncMock(return_value=True) - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.get", mock_get) @patch("aleph_client.commands.aggregate.post", mock_post) async def run_revoke(): @@ -210,7 +219,7 @@ async def test_permissions(): mock_load_account = create_mock_load_account() mock_get = AsyncMock(return_value=FAKE_AGGREGATE_DATA["security"]) - @patch("aleph_client.commands.aggregate._load_account", mock_load_account) + @patch("aleph_client.commands.aggregate.load_account", mock_load_account) @patch("aleph_client.commands.aggregate.get", mock_get) async def run_permissions(): print() # For better display when pytest -v -s diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 6199d343..469289cd 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -6,7 +6,7 @@ import pytest from aleph.sdk.chains.ethereum import ETHAccount -from aleph.sdk.conf import settings +from aleph.sdk.conf import AccountType, MainConfiguration, settings from aleph.sdk.exceptions import ( ForgottenMessageError, MessageNotFoundError, @@ -14,7 +14,7 @@ ) from aleph.sdk.query.responses import MessagesResponse from aleph.sdk.types import StorageEnum, StoredContent -from aleph_message.models import PostMessage, StoreMessage +from aleph_message.models import Chain, PostMessage, StoreMessage from typer.testing import CliRunner from aleph_client.__main__ import app @@ -202,12 +202,35 @@ def test_account_import_sol(env_files): assert new_key != old_key -def test_account_address(env_files): +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_account_address(mock_get_accounts, env_files): settings.CONFIG_FILE = env_files[1] result = runner.invoke(app, ["account", "address", "--private-key-file", str(env_files[0])]) assert result.exit_code == 0 assert result.stdout.startswith("✉ Addresses for Active Account ✉\n\nEVM: 0x") + # Test with ledger device + mock_ledger_account = MagicMock() + mock_ledger_account.address = "0xdeadbeef1234567890123456789012345678beef" + mock_ledger_account.get_address.return_value = "0xdeadbeef1234567890123456789012345678beef" + mock_get_accounts.return_value = [mock_ledger_account] + + # Create a ledger config + ledger_config = MainConfiguration( + path=None, chain=Chain.ETH, type=AccountType.HARDWARE, address=mock_ledger_account.address + ) + + with patch("aleph_client.commands.account.load_main_configuration", return_value=ledger_config): + with patch( + "aleph_client.commands.account.load_account", + side_effect=lambda _, __, chain: ( + mock_ledger_account if chain == Chain.ETH else Exception("Ledger doesn't support SOL") + ), + ): + result = runner.invoke(app, ["account", "address"]) + assert result.exit_code == 0 + assert result.stdout.startswith("✉ Addresses for Active Account (Ledger) ✉\n\nEVM: 0x") + def test_account_chain(env_files): settings.CONFIG_FILE = env_files[1] @@ -236,6 +259,22 @@ def test_account_export_private_key(env_files): assert result.stdout.startswith("⚠️ Private Keys for Active Account ⚠️\n\nEVM: 0x") +def test_account_export_private_key_ledger(): + """Test that export-private-key fails for Ledger devices.""" + # Create a ledger config + ledger_config = MainConfiguration( + path=None, chain=Chain.ETH, type=AccountType.HARDWARE, address="0xdeadbeef1234567890123456789012345678beef" + ) + + with patch("aleph_client.commands.account.load_main_configuration", return_value=ledger_config): + result = runner.invoke(app, ["account", "export-private-key"]) + + # Command should fail with appropriate message + assert result.exit_code == 1 + assert "Cannot export private key from a Ledger hardware wallet" in result.stdout + assert "The private key remains securely stored on your Ledger device" in result.stdout + + def test_account_list(env_files): settings.CONFIG_FILE = env_files[1] result = runner.invoke(app, ["account", "list"]) @@ -243,6 +282,43 @@ def test_account_list(env_files): assert result.stdout.startswith("🌐 Chain Infos 🌐") +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_account_list_with_ledger(mock_get_accounts): + """Test that account list shows Ledger devices when available.""" + # Create mock Ledger accounts + mock_account1 = MagicMock() + mock_account1.address = "0xdeadbeef1234567890123456789012345678beef" + mock_account2 = MagicMock() + mock_account2.address = "0xcafebabe5678901234567890123456789012cafe" + mock_get_accounts.return_value = [mock_account1, mock_account2] + + # Test with no configuration first + with patch("aleph_client.commands.account.load_main_configuration", return_value=None): + result = runner.invoke(app, ["account", "list"]) + assert result.exit_code == 0 + + # Check that the ledger accounts are listed + assert "Ledger #0" in result.stdout + assert "Ledger #1" in result.stdout + assert mock_account1.address in result.stdout + assert mock_account2.address in result.stdout + + # Test with a ledger account that's active in configuration + ledger_config = MainConfiguration( + path=None, chain=Chain.ETH, type=AccountType.HARDWARE, address=mock_account1.address + ) + + with patch("aleph_client.commands.account.load_main_configuration", return_value=ledger_config): + result = runner.invoke(app, ["account", "list"]) + assert result.exit_code == 0 + + # Check that the active ledger account is marked + assert "Ledger" in result.stdout + assert mock_account1.address in result.stdout + # Just check for asterisk since rich formatting tags may not be visible in test output + assert "*" in result.stdout + + def test_account_sign_bytes(env_files): settings.CONFIG_FILE = env_files[1] result = runner.invoke(app, ["account", "sign-bytes", "--message", "test", "--chain", "ETH"]) @@ -260,9 +336,7 @@ def test_account_balance(mocker, env_files, mock_voucher_service, mock_get_balan mock_client.voucher = mock_voucher_service - # Replace both client types with our mock implementation mocker.patch("aleph_client.commands.account.AlephHttpClient", mock_client_class) - mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient", mock_client_class) result = runner.invoke( app, ["account", "balance", "--address", "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe", "--chain", "ETH"] @@ -275,24 +349,20 @@ def test_account_balance(mocker, env_files, mock_voucher_service, mock_get_balan assert "EVM Test Voucher" in result.stdout -def test_account_balance_error(mocker, env_files, mock_voucher_empty): +def test_account_balance_error(mocker, env_files, mock_voucher_empty, mock_get_balances): """Test error handling in the account balance command when API returns an error.""" settings.CONFIG_FILE = env_files[1] - mock_client_class = MagicMock() - mock_client = MagicMock() - mock_client.__aenter__.return_value = mock_client - mock_client.__aexit__.return_value = None + mock_client_class, mock_client = create_mock_client(None, None, mock_get_balances=mock_get_balances) + mock_client.get_balances = AsyncMock( side_effect=Exception( "Failed to retrieve balance for address 0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe. Status code: 404" ) ) mock_client.voucher = mock_voucher_empty - mock_client_class.return_value = mock_client mocker.patch("aleph_client.commands.account.AlephHttpClient", mock_client_class) - mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient", mock_client_class) # Test with an address directly result = runner.invoke( @@ -311,7 +381,7 @@ def test_account_vouchers_display(mocker, env_files, mock_voucher_service): # Mock the HTTP client mock_client = mocker.AsyncMock() mock_client.voucher = mock_voucher_service - mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient.__aenter__", return_value=mock_client) + mocker.patch("aleph_client.commands.account.AlephHttpClient.__aenter__", return_value=mock_client) # Create a test address test_address = "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe" @@ -355,7 +425,7 @@ def test_account_vouchers_no_vouchers(mocker, env_files): # Mock the HTTP client mock_client = mocker.AsyncMock() mock_client.voucher = mock_voucher_service - mocker.patch("aleph_client.commands.account.AuthenticatedAlephHttpClient.__aenter__", return_value=mock_client) + mocker.patch("aleph_client.commands.account.AlephHttpClient.__aenter__", return_value=mock_client) # Create a test address test_address = "0xCAfEcAfeCAfECaFeCaFecaFecaFECafECafeCaFe" @@ -371,10 +441,58 @@ def test_account_vouchers_no_vouchers(mocker, env_files): def test_account_config(env_files): - settings.CONFIG_FILE = env_files[1] - result = runner.invoke(app, ["account", "config", "--private-key-file", str(env_files[0]), "--chain", "ETH"]) - assert result.exit_code == 0 - assert result.stdout.startswith("New Default Configuration: ") + with patch("aleph_client.commands.account.save_main_configuration") as mock_save_config: + # Make sure the config can be saved + mock_save_config.return_value = None + + settings.CONFIG_FILE = env_files[1] + result = runner.invoke( + app, ["account", "config", "--private-key-file", str(env_files[0]), "--chain", "ETH", "--no"] + ) + assert result.exit_code == 0 + + +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_account_config_with_ledger(mock_get_accounts): + """Test configuring account with a Ledger device.""" + # Create mock Ledger accounts + mock_account1 = MagicMock() + mock_account1.address = "0xdeadbeef1234567890123456789012345678beef" + mock_account2 = MagicMock() + mock_account2.address = "0xcafebabe5678901234567890123456789012cafe" + mock_get_accounts.return_value = [mock_account1, mock_account2] + + # Create a temporary config file + with runner.isolated_filesystem(): + config_dir = Path("test_config") + config_dir.mkdir() + config_file = config_dir / "config.json" + + with ( + patch("aleph.sdk.conf.settings.CONFIG_FILE", config_file), + patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), + patch("aleph_client.commands.account.Prompt.ask", return_value="1"), + patch("aleph_client.commands.account.yes_no_input", return_value=True), + patch("aleph_client.commands.account.save_main_configuration"), + patch("aleph_client.utils.list_unlinked_keys", return_value=([], None)), + ): + # Use --no to skip interactive mode + result = runner.invoke( + app, + [ + "account", + "config", + "--account-type", + "hardware", + "--chain", + "ETH", + "--address", + "0xdeadbeef1234567890123456789012345678beef", + "--no", + ], + ) + + assert result.exit_code == 0 def test_message_get(mocker, store_message_fixture): diff --git a/tests/unit/test_credits.py b/tests/unit/test_credits.py index e1bfe87e..9228a642 100644 --- a/tests/unit/test_credits.py +++ b/tests/unit/test_credits.py @@ -133,11 +133,11 @@ async def run(mock_get): @pytest.mark.asyncio -async def test_show_with_account(mock_credit_balance_response): +async def test_show_with_account(mock_credit_balance_response, capsys): """Test the show command using account-derived address.""" @patch("aiohttp.ClientSession.get") - @patch("aleph_client.commands.credit._load_account") + @patch("aleph_client.commands.credit.load_account") async def run(mock_load_account, mock_get): mock_get.return_value = mock_credit_balance_response @@ -154,35 +154,8 @@ async def run(mock_load_account, mock_get): json=False, debug=False, ) - - # Verify the account was loaded and its address used - mock_load_account.assert_called_once() - mock_account.get_address.assert_called_once() - - await run() - - -@pytest.mark.asyncio -async def test_show_no_address_no_account(capsys): - """Test the show command with no address and no account.""" - - @patch("aleph_client.commands.credit._load_account") - async def run(mock_load_account): - # Setup the mock account to return None (no account found) - mock_load_account.return_value = None - - # Run the show command without address and without account - await show( - address="", - private_key=None, - private_key_file=None, - json=False, - debug=False, - ) - - await run() - captured = capsys.readouterr() - assert "Error: Please provide either a private key, private key file, or an address." in captured.out + captured = capsys.readouterr() + assert "0x1234567890123456789012345678901234567890" in captured.out @pytest.mark.asyncio diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index a050c1fd..3f5fbb5e 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -531,7 +531,7 @@ async def test_create_instance(args, expected, mock_crn_list_obj, mock_pricing_i # Setup all required patches with ( patch("aleph_client.commands.instance.validate_ssh_pubkey_file", mock_validate_ssh_pubkey_file), - patch("aleph_client.commands.instance._load_account", mock_load_account), + patch("aleph_client.commands.instance.load_account", mock_load_account), patch("aleph_client.commands.instance.AlephHttpClient", mock_client_class), patch("aleph_client.commands.pricing.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class), @@ -620,7 +620,7 @@ async def test_list_instances(mock_crn_list_obj, mock_pricing_info_response, moc ) # Setup all patches - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.fetch_latest_crn_version", mock_fetch_latest_crn_version) @patch("aleph_client.commands.files.AlephHttpClient", mock_client_class) @patch("aleph_client.commands.instance.AlephHttpClient", mock_auth_client_class) @@ -657,7 +657,7 @@ async def test_delete_instance(mock_api_response): # We need to mock that there is no CRN information to skip VM erasure mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=MagicMock(root={}))) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) @patch("aleph_client.commands.instance.fetch_settings", mock_fetch_settings) @@ -709,7 +709,7 @@ async def test_delete_instance_with_insufficient_funds(): } ) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) @patch("aleph_client.commands.instance.fetch_settings", mock_fetch_settings) @@ -753,7 +753,7 @@ async def test_delete_instance_with_detailed_insufficient_funds_error(capsys, mo } ) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.AuthenticatedAlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) @patch("aleph_client.commands.instance.fetch_settings", mock_fetch_settings) @@ -794,7 +794,7 @@ async def test_reboot_instance(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) async def reboot_instance(): @@ -826,7 +826,7 @@ async def test_allocate_instance(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) async def allocate_instance(): @@ -858,7 +858,7 @@ async def test_logs_instance(capsys): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) async def logs_instance(): @@ -892,7 +892,7 @@ async def test_stop_instance(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.instance.VmClient", mock_vm_client_class) async def stop_instance(): @@ -925,7 +925,7 @@ async def test_confidential_init_session(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch("aleph_client.commands.utils.shutil", mock_shutil) @patch("aleph_client.commands.instance.shutil", mock_shutil) @@ -967,7 +967,7 @@ async def test_confidential_start(): # Add the mock to the auth client mock_auth_client.instance = MagicMock(get_instances_allocations=AsyncMock(return_value=mock_allocation)) - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.utils.shutil", mock_shutil) @patch("aleph_client.commands.instance.network.AlephHttpClient", mock_auth_client_class) @patch.object(Path, "exists", MagicMock(return_value=True)) @@ -1076,7 +1076,9 @@ async def gpu_instance(): @pytest.mark.asyncio -async def test_gpu_create_no_gpus_available(mock_crn_list_obj, mock_pricing_info_response, mock_settings_info): +async def test_gpu_create_no_gpus_available( + mock_crn_list_obj, mock_pricing_info_response, mock_settings_info, mock_get_balances +): """Test creating a GPU instance when no GPUs are available on the network. This test verifies that typer.Exit is raised when no GPUs are available, @@ -1085,12 +1087,12 @@ async def test_gpu_create_no_gpus_available(mock_crn_list_obj, mock_pricing_info mock_load_account = create_mock_load_account() mock_validate_ssh_pubkey_file = create_mock_validate_ssh_pubkey_file() mock_client_class, mock_client = create_mock_client( - mock_crn_list_obj, mock_pricing_info_response, mock_settings_info, payment_type="superfluid" + mock_crn_list_obj, mock_pricing_info_response, mock_get_balances, payment_type="superfluid" ) mock_fetch_latest_crn_version = create_mock_fetch_latest_crn_version() mock_validated_prompt = MagicMock(return_value="1") - @patch("aleph_client.commands.instance._load_account", mock_load_account) + @patch("aleph_client.commands.instance.load_account", mock_load_account) @patch("aleph_client.commands.instance.validate_ssh_pubkey_file", mock_validate_ssh_pubkey_file) @patch("aleph_client.commands.instance.AlephHttpClient", mock_client_class) @patch("aleph_client.commands.pricing.AlephHttpClient", mock_client_class) diff --git a/tests/unit/test_load_account.py b/tests/unit/test_load_account.py new file mode 100644 index 00000000..4d0ab627 --- /dev/null +++ b/tests/unit/test_load_account.py @@ -0,0 +1,171 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer +from aleph.sdk.conf import AccountType, MainConfiguration +from aleph_message.models import Chain +from ledgereth.exceptions import LedgerError + +from aleph_client.utils import load_account + + +@pytest.fixture +def mock_config_internal(): + """Create a mock internal configuration.""" + return MainConfiguration(path=Path("/fake/path.key"), chain=Chain.ETH) + + +@pytest.fixture +def mock_config_external(): + """Create a mock external (ledger) configuration.""" + return MainConfiguration(path=None, chain=Chain.ETH, address="0xdeadbeef1234567890123456789012345678beef") + + +@pytest.fixture +def mock_config_hardware(): + """Create a mock hardware (ledger) configuration.""" + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xdeadbeef1234567890123456789012345678beef", + type=AccountType.HARDWARE, + ) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_with_internal_config(mock_load_account, mock_load_config, mock_config_internal): + """Test load_account with an internal configuration.""" + mock_load_config.return_value = mock_config_internal + + load_account(None, None) + + # Verify _load_account was called with the correct parameters for internal account + mock_load_account.assert_called_with(None, None, chain=Chain.ETH) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils.wait_for_ledger_connection") +@patch("aleph_client.utils._load_account") +def test_load_account_with_external_config(mock_load_account, mock_load_config, mock_config_external): + """Test load_account with an external (ledger) configuration.""" + mock_load_config.return_value = mock_config_external + + load_account(None, None) + + # Verify _load_account was called with some chain parameter + assert mock_load_account.call_args is not None + + # For this test, we don't need to validate the exact mock object identity + # Just make sure the method was called with the proper args + mock_load_account.assert_called_once() + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_with_override_chain(mock_load_account, mock_load_config, mock_config_internal): + """Test load_account with an explicit chain parameter that overrides the config.""" + mock_load_config.return_value = mock_config_internal + + load_account(None, None, chain=Chain.SOL) + + # Verify explicit chain was used instead of config chain + mock_load_account.assert_called_with(None, None, chain=Chain.SOL) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_fallback_to_private_key(mock_load_account, mock_load_config): + """Test load_account falling back to private key when no config exists.""" + mock_load_config.return_value = None + + load_account("0xdeadbeef", None) + + # Verify private key string was used + mock_load_account.assert_called_with("0xdeadbeef", None, chain=None) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_fallback_to_private_key_file(mock_load_account, mock_load_config): + """Test load_account falling back to private key file when no config exists.""" + mock_load_config.return_value = None + + private_key_file = MagicMock() + private_key_file.exists.return_value = True + + load_account(None, private_key_file) + + # Verify private key file was used + mock_load_account.assert_called_with(None, private_key_file, chain=None) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils._load_account") +def test_load_account_nonexistent_file_raises_error(mock_load_account, mock_load_config): + """Test that load_account raises an error when file doesn't exist and no config exists.""" + mock_load_config.return_value = None + + private_key_file = MagicMock() + private_key_file.exists.return_value = False + + with pytest.raises(typer.Exit): + load_account(None, private_key_file) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils.wait_for_ledger_connection") +@patch("aleph_client.utils._load_account") +def test_ledger_config(mock_load_account, mock_wait_for_ledger, mock_load_config, mock_config_hardware): + """Test load_account with a hardware ledger configuration.""" + mock_load_config.return_value = mock_config_hardware + mock_wait_for_ledger.return_value = None + + load_account(None, None) + + # Verify wait_for_ledger_connection was called + mock_wait_for_ledger.assert_called_once() + # Verify _load_account was called with the correct parameters for hardware account + mock_load_account.assert_called_with(None, None, chain=Chain.ETH) + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils.wait_for_ledger_connection") +@patch("aleph_client.utils._load_account") +def test_ledger_failure(mock_load_account, mock_wait_for_ledger, mock_load_config, mock_config_hardware): + """Test load_account with a hardware ledger configuration when connection fails.""" + + mock_load_config.return_value = mock_config_hardware + + mock_wait_for_ledger.side_effect = LedgerError("Cannot connect to ledger") + + # Check that typer.Exit is raised + with pytest.raises(typer.Exit): + load_account(None, None) + + # Verify wait_for_ledger_connection was called + mock_wait_for_ledger.assert_called_once() + + # Verify _load_account was not called + mock_load_account.assert_not_called() + + +@patch("aleph_client.utils.load_main_configuration") +@patch("aleph_client.utils.wait_for_ledger_connection") +@patch("aleph_client.utils._load_account") +def test_ledger_os_error(mock_load_account, mock_wait_for_ledger, mock_load_config, mock_config_hardware): + """Test load_account with a hardware ledger configuration when an OS error occurs.""" + mock_load_config.return_value = mock_config_hardware + + # Simulate an OS error (permission issues, etc) + mock_wait_for_ledger.side_effect = OSError("Permission denied") + + # Check that typer.Exit is raised + with pytest.raises(typer.Exit): + load_account(None, None) + + # Verify wait_for_ledger_connection was called + mock_wait_for_ledger.assert_called_once() + # Verify _load_account was not called + mock_load_account.assert_not_called() diff --git a/tests/unit/test_port_forwarder.py b/tests/unit/test_port_forwarder.py index a7387e64..5c782313 100644 --- a/tests/unit/test_port_forwarder.py +++ b/tests/unit/test_port_forwarder.py @@ -98,7 +98,7 @@ async def test_list_ports(mock_auth_setup): mock_console = MagicMock() with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.Console", return_value=mock_console), ): @@ -118,7 +118,7 @@ async def test_list_ports(mock_auth_setup): ) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, patch("aleph_client.commands.instance.port_forwarder.typer.Exit", side_effect=SystemExit), @@ -142,7 +142,7 @@ async def test_create_port(mock_auth_setup): mock_client_class = mock_auth_setup["mock_client_class"] with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, ): @@ -177,7 +177,7 @@ async def test_update_port(mock_auth_setup): mock_client.port_forwarder.get_ports.return_value = mock_existing_ports with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -211,7 +211,7 @@ async def test_delete_port(mock_auth_setup): mock_client.port_forwarder.get_ports.return_value = mock_existing_ports with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -236,7 +236,7 @@ async def test_delete_port(mock_auth_setup): mock_client.port_forwarder.delete_ports.reset_mock() with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -268,7 +268,7 @@ async def test_delete_port_last_port(mock_auth_setup): mock_client.port_forwarder.update_ports = None with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -310,7 +310,7 @@ async def test_refresh_port(mock_auth_setup): mock_client.instance.get_instance_allocation_info.return_value = (None, mock_allocation) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, ): @@ -340,7 +340,7 @@ async def test_refresh_port_no_allocation(mock_auth_setup): mock_client.instance.get_instance_allocation_info.return_value = (None, None) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, patch("aleph_client.commands.instance.port_forwarder.typer.Exit", side_effect=SystemExit), @@ -376,7 +376,7 @@ async def test_refresh_port_scheduler_allocation(mock_auth_setup): mock_client.instance.get_instance_allocation_info.return_value = (None, mock_allocation) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, ): @@ -415,7 +415,7 @@ async def test_non_processed_message_statuses(): mock_http_client.port_forwarder.get_ports = AsyncMock(return_value=mock_existing_ports) with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_http_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_auth_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -432,7 +432,7 @@ async def test_non_processed_message_statuses(): mock_echo.reset_mock() with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_http_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_auth_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo, @@ -450,7 +450,7 @@ async def test_non_processed_message_statuses(): mock_echo.reset_mock() with ( - patch("aleph_client.commands.instance.port_forwarder._load_account", mock_load_account), + patch("aleph_client.commands.instance.port_forwarder.load_account", mock_load_account), patch("aleph_client.commands.instance.port_forwarder.AlephHttpClient", mock_http_client_class), patch("aleph_client.commands.instance.port_forwarder.AuthenticatedAlephHttpClient", mock_auth_client_class), patch("aleph_client.commands.instance.port_forwarder.typer.echo") as mock_echo,