From cbb066090dc14e5feb640104bc9b44ca8776406a Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 14:20:11 +0200 Subject: [PATCH 01/78] Problem: Ledger wallet users cannot use Aleph to send transactions. Solution: Implement Ledger use on CLI to allow using them. Do it importing a specific branch of the SDK. --- pyproject.toml | 5 +- src/aleph_client/commands/account.py | 47 +++++++++++++++---- .../commands/instance/__init__.py | 1 + 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 37282041..594c8194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ "aiodns==3.2", "aiohttp==3.11.13", "aleph-message>=1.0.5", - "aleph-sdk-python>=2.1", + #"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 @@ -44,6 +45,8 @@ dependencies = [ "substrate-interface==1.7.11", # Needed for DOT signatures "textual==0.73", "typer==0.15.2", + "ledgerblue>=0.1.48", + "ledgereth>=0.10.0", ] optional-dependencies.cosmos = [ "cosmospy==6" ] optional-dependencies.docs = [ "sphinxcontrib-plantuml==0.30" ] diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 18157ff4..df48980f 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -15,6 +15,7 @@ from aleph.sdk.chains.solana import parse_private_key as parse_solana_private_key from aleph.sdk.conf import ( MainConfiguration, + AccountType, load_main_configuration, save_main_configuration, settings, @@ -25,6 +26,7 @@ get_compatible_chains, ) from aleph.sdk.utils import bytes_from_hex, displayable_amount +from aleph.sdk.wallets.ledger import LedgerETHAccount from aleph_message.models import Chain from rich import box from rich.console import Console @@ -145,10 +147,10 @@ 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: 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) - ] = settings.PRIVATE_KEY_FILE, + ] = None, ): """ Display your public address(es). @@ -476,11 +478,16 @@ 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, ): """Configure current private key file and active chain (default selection)""" unlinked_keys, config = await list_unlinked_keys() + if not account_type: + account_type = AccountType.INTERNAL + # Fixes private key file path if private_key_file: if not private_key_file.name.endswith(".key"): @@ -494,7 +501,7 @@ async def configure( 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 account_type and 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]", @@ -521,8 +528,32 @@ async def configure( private_key_file = Path(config.path) if not private_key_file: - typer.secho("No private key file provided or found.", fg=typer.colors.RED) - raise typer.Exit() + if yes_no_input( + f"[bright_cyan]No private key file found.[/bright_cyan] [yellow]" + f"Do you want to import from Ledger?[/yellow]", + default="y", + ): + accounts = LedgerETHAccount.get_accounts() + account_addresses = [acc.address for acc in accounts] + + console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]") + for idx, account_address in enumerate(account_addresses, start=1): + console.print(f"[{idx}] {account_address}") + + key_choice = Prompt.ask("Choose a address by index") + if key_choice.isdigit(): + key_index = int(key_choice) - 1 + selected_address = account_addresses[key_index] + + if not selected_address: + typer.secho("No valid address selected.", fg=typer.colors.RED) + raise typer.Exit() + + address = selected_address + account_type = AccountType.EXTERNAL + else: + typer.secho("No private key file provided or found.", fg=typer.colors.RED) + raise typer.Exit() # Configure active chain if not chain and config and hasattr(config, "chain"): @@ -545,11 +576,11 @@ async def configure( raise typer.Exit() try: - config = MainConfiguration(path=private_key_file, chain=chain) + config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) save_main_configuration(settings.CONFIG_FILE, config) 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.path or config.address}" + 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/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index bed8b2d5..22d064ff 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -168,6 +168,7 @@ async def create( # 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() # Start the fetch in the background (async_lru_cache already returns a future) From 4e8c9b670b597d8656e1720ec0af8b615b0889a5 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 14:37:25 +0200 Subject: [PATCH 02/78] Fix: Solve code quality issues. --- pyproject.toml | 12 ++++++------ src/aleph_client/commands/account.py | 14 ++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 594c8194..dbf6e74d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,20 +33,20 @@ dependencies = [ "aleph-message>=1.0.5", #"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 + "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", - "ledgerblue>=0.1.48", - "ledgereth>=0.10.0", ] optional-dependencies.cosmos = [ "cosmospy==6" ] optional-dependencies.docs = [ "sphinxcontrib-plantuml==0.30" ] diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index df48980f..8b9dc45e 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -14,8 +14,8 @@ 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.conf import ( - MainConfiguration, AccountType, + MainConfiguration, load_main_configuration, save_main_configuration, settings, @@ -148,9 +148,7 @@ async def create( @app.command(name="address") def display_active_address( 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, + private_key_file: Annotated[Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)] = None, ): """ Display your public address(es). @@ -383,7 +381,7 @@ async def list_accounts(): table.add_column("Active", no_wrap=True) active_chain = None - if config: + if config and config.path: active_chain = config.chain table.add_row(config.path.stem, str(config.path), "[bold green]*[/bold green]") else: @@ -529,9 +527,9 @@ async def configure( if not private_key_file: if yes_no_input( - f"[bright_cyan]No private key file found.[/bright_cyan] [yellow]" - f"Do you want to import from Ledger?[/yellow]", - default="y", + "[bright_cyan]No private key file found.[/bright_cyan] [yellow]" + "Do you want to import from Ledger?[/yellow]", + default="y", ): accounts = LedgerETHAccount.get_accounts() account_addresses = [acc.address for acc in accounts] From f4c0f2af261f0fc4603a34e5200013d3ca0ebb38 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 15:53:37 +0200 Subject: [PATCH 03/78] Fix: Solve issue loading the good configuration. --- src/aleph_client/commands/account.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 8b9dc45e..e835af84 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -160,8 +160,14 @@ def display_active_address( typer.secho("No private key available", fg=RED) raise typer.Exit(code=1) - 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() + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + if config and config.type and config.type == AccountType.EXTERNAL: + evm_address = _load_account(None, None, chain=Chain.ETH).get_address() + sol_address = _load_account(None, None, chain=Chain.SOL).get_address() + else: + 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() console.print( "✉ [bold italic blue]Addresses for Active Account[/bold italic blue] ✉\n\n" @@ -483,9 +489,6 @@ async def configure( unlinked_keys, config = await list_unlinked_keys() - if not account_type: - account_type = AccountType.INTERNAL - # Fixes private key file path if private_key_file: if not private_key_file.name.endswith(".key"): @@ -499,7 +502,8 @@ async def configure( raise typer.Exit() # Configures active private key file - if not account_type and not private_key_file and config and hasattr(config, "path") and Path(config.path).exists(): + if (not account_type or account_type == AccountType.INTERNAL and + 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]", @@ -525,9 +529,9 @@ async def configure( else: # No change private_key_file = Path(config.path) - if not private_key_file: + if not private_key_file and account_type == AccountType.EXTERNAL: if yes_no_input( - "[bright_cyan]No private key file found.[/bright_cyan] [yellow]" + "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]" "Do you want to import from Ledger?[/yellow]", default="y", ): @@ -573,6 +577,9 @@ async def configure( typer.secho("No chain provided.", fg=typer.colors.RED) raise typer.Exit() + if not account_type: + account_type = AccountType.INTERNAL.value + try: config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) save_main_configuration(settings.CONFIG_FILE, config) From c52f9dc9fe07169119746f61dfa41d802408c49a Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 16:27:47 +0200 Subject: [PATCH 04/78] Fix: Solved definitively the wallet selection issue and also solved another issue fetching instances from scheduler. --- src/aleph_client/commands/account.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index e835af84..163724ea 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -502,8 +502,13 @@ async def configure( raise typer.Exit() # Configures active private key file - if (not account_type or account_type == AccountType.INTERNAL and - not private_key_file and config and hasattr(config, "path") and Path(config.path).exists()): + if not account_type or ( + account_type == AccountType.INTERNAL + and 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]", @@ -531,8 +536,7 @@ async def configure( if not private_key_file and account_type == AccountType.EXTERNAL: if yes_no_input( - "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]" - "Do you want to import from Ledger?[/yellow]", + "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]", default="y", ): accounts = LedgerETHAccount.get_accounts() @@ -578,7 +582,7 @@ async def configure( raise typer.Exit() if not account_type: - account_type = AccountType.INTERNAL.value + account_type = AccountType.INTERNAL try: config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) From b994c79d6db660420caf742c9e2f9a42e26c9fde Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 16:35:47 +0200 Subject: [PATCH 05/78] Fix: Solved code-quality issue. --- src/aleph_client/commands/aggregate.py | 15 +++++++-------- src/aleph_client/commands/domain.py | 17 ++++++++++------- src/aleph_client/commands/files.py | 10 +++++----- src/aleph_client/commands/message.py | 10 +++++----- src/aleph_client/commands/program.py | 6 +++--- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index c2848e33..c2c25c13 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -11,7 +11,6 @@ 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.utils import extended_json_encoder from aleph_message.models import Chain, MessageType from aleph_message.status import MessageStatus @@ -59,7 +58,7 @@ async def forget( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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 +131,7 @@ async def post( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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,7 +193,7 @@ async def get( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) address = account.get_address() if address is None else address async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: @@ -230,7 +229,7 @@ async def list_aggregates( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) address = account.get_address() if address is None else address aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" @@ -304,7 +303,7 @@ async def authorize( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) data = await get( key="security", @@ -378,7 +377,7 @@ async def revoke( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) data = await get( key="security", @@ -433,7 +432,7 @@ async def permissions( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) address = account.get_address() if address is None else address data = await get( diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 538bdcbd..295e6ce9 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -3,7 +3,7 @@ import logging from pathlib import Path from time import sleep -from typing import Annotated, Optional, cast +from typing import Annotated, Optional, Union, cast import typer from aleph.sdk.account import _load_account @@ -19,6 +19,7 @@ from aleph.sdk.exceptions import DomainConfigurationError from aleph.sdk.query.filters import MessageFilter from aleph.sdk.types import AccountFromPrivateKey +from aleph.sdk.wallets.ledger import LedgerETHAccount from aleph_message.models import AggregateMessage from aleph_message.models.base import MessageType from rich.console import Console @@ -65,7 +66,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 +138,9 @@ async def attach_resource( ) -async def detach_resource(account: AccountFromPrivateKey, fqdn: Hostname, interactive: Optional[bool] = None): +async def detach_resource( + account: Union[AccountFromPrivateKey, LedgerETHAccount], 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 +190,7 @@ async def add( ] = settings.PRIVATE_KEY_FILE, ): """Add and link a Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) interactive = False if (not ask) else is_environment_interactive() console = Console() @@ -272,7 +275,7 @@ async def attach( ] = settings.PRIVATE_KEY_FILE, ): """Attach resource to a Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) await attach_resource( account, @@ -294,7 +297,7 @@ async def detach( ] = settings.PRIVATE_KEY_FILE, ): """Unlink Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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 +312,7 @@ async def info( ] = settings.PRIVATE_KEY_FILE, ): """Show Custom Domain Details.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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..f63f455f 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -12,7 +12,7 @@ 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.types import StorageEnum, StoredContent from aleph.sdk.utils import safe_getattr from aleph_message.models import ItemHash, StoreMessage from aleph_message.status import MessageStatus @@ -44,7 +44,7 @@ async def pin( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: result: StoreMessage @@ -75,7 +75,7 @@ async def upload( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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 +181,7 @@ async def forget( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) hashes = [ItemHash(item_hash) for item_hash in item_hash.split(",")] @@ -270,7 +270,7 @@ 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) + account = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index a7a48d60..07582587 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -20,7 +20,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 @@ -138,7 +138,7 @@ async def post( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) storage_engine: StorageEnum content: dict @@ -188,7 +188,7 @@ async def amend( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) async with AlephHttpClient(api_server=settings.API_HOST) as client: existing_message: Optional[AlephMessage] = None @@ -253,7 +253,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 = _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 +296,7 @@ def sign( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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..becdc246 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -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, @@ -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: From cb798b519392528d2dccef5d94c5aed06ad15a24 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 10:51:04 +0100 Subject: [PATCH 06/78] Feature: cli load_account to handle account selections --- src/aleph_client/utils.py | 49 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index cc3c5aaa..560ab3ac 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -18,11 +18,22 @@ import aiohttp import typer from aiohttp import ClientSession -from aleph.sdk.conf import MainConfiguration, load_main_configuration, settings -from aleph.sdk.types import GenericMessage +from aleph.sdk.account import _load_account +from aleph.sdk.conf import ( + AccountType, + MainConfiguration, + load_main_configuration, + settings, +) +from aleph.sdk.types import AccountFromPrivateKey, 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 +# Type alias for account types +AlephAccount = Union[AccountFromPrivateKey, LedgerETHAccount] + logger = logging.getLogger(__name__) try: @@ -190,3 +201,37 @@ 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 +) -> AlephAccount: + """ + Two Case Possible + - Account from private key + - External 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.EXTERNAL: + return _load_account(None, None, chain=chain) + else: + return _load_account(private_key_str, private_key_file, chain=chain) From 9c347b56d9335af1b9d95cface940c4bf13c7a07 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 10:51:40 +0100 Subject: [PATCH 07/78] Fix: using load_account instead of _load_account --- src/aleph_client/commands/account.py | 47 ++++++++----------- src/aleph_client/commands/aggregate.py | 17 ++++--- src/aleph_client/commands/credit.py | 7 ++- src/aleph_client/commands/domain.py | 19 +++----- src/aleph_client/commands/files.py | 11 ++--- .../commands/instance/__init__.py | 21 ++++----- .../commands/instance/port_forwarder.py | 13 +++-- src/aleph_client/commands/message.py | 11 ++--- 8 files changed, 64 insertions(+), 82 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 163724ea..1efcdb7b 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -44,7 +44,12 @@ validated_prompt, yes_no_input, ) -from aleph_client.utils import AsyncTyper, list_unlinked_keys +from aleph_client.utils import ( + AlephAccount, + AsyncTyper, + list_unlinked_keys, + load_account, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -154,20 +159,8 @@ def display_active_address( 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) - if config and config.type and config.type == AccountType.EXTERNAL: - evm_address = _load_account(None, None, chain=Chain.ETH).get_address() - sol_address = _load_account(None, None, chain=Chain.SOL).get_address() - else: - 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() + 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() console.print( "✉ [bold italic blue]Addresses for Active Account[/bold italic blue] ✉\n\n" @@ -267,7 +260,7 @@ def sign_bytes( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AlephAccount = load_account(private_key, private_key_file, chain=chain) if not message: message = input_multiline() @@ -302,7 +295,7 @@ 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) + account: AlephAccount = load_account(private_key, private_key_file, chain=chain) if account and not address: address = account.get_address() @@ -431,7 +424,7 @@ 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) + account = load_account(private_key, private_key_file, chain=chain) if account and not address: address = account.get_address() @@ -501,13 +494,11 @@ 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 account_type or ( - account_type == AccountType.INTERNAL - and not private_key_file - and config - and hasattr(config, "path") - and Path(config.path).exists() + # If private_key_file is specified via command line, prioritize it + if private_key_file: + pass + elif not account_type or ( + account_type == AccountType.INTERNAL 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 " @@ -561,8 +552,10 @@ async def configure( typer.secho("No private key file provided or found.", fg=typer.colors.RED) raise typer.Exit() - # 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", diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index c2c25c13..6ec0cd07 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -8,7 +8,6 @@ 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.utils import extended_json_encoder @@ -20,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 AlephAccount, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -58,7 +57,7 @@ async def forget( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = 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(): @@ -131,7 +130,7 @@ async def post( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = 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(): @@ -193,7 +192,7 @@ async def get( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) address = account.get_address() if address is None else address async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: @@ -229,7 +228,7 @@ async def list_aggregates( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) address = account.get_address() if address is None else address aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" @@ -303,7 +302,7 @@ async def authorize( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) data = await get( key="security", @@ -377,7 +376,7 @@ async def revoke( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) data = await get( key="security", @@ -432,7 +431,7 @@ async def permissions( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) address = account.get_address() if address is None else address data = await get( diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 54b8dcde..70a95bf3 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -7,7 +7,6 @@ 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.utils import displayable_amount from rich import box from rich.console import Console @@ -17,7 +16,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 AlephAccount, AsyncTyper logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -41,7 +40,7 @@ async def show( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AlephAccount = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() @@ -87,7 +86,7 @@ async def history( ): setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AlephAccount = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 295e6ce9..86c9e801 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -3,10 +3,9 @@ import logging from pathlib import Path from time import sleep -from typing import Annotated, Optional, Union, cast +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,8 +17,6 @@ ) from aleph.sdk.exceptions import DomainConfigurationError from aleph.sdk.query.filters import MessageFilter -from aleph.sdk.types import AccountFromPrivateKey -from aleph.sdk.wallets.ledger import LedgerETHAccount from aleph_message.models import AggregateMessage from aleph_message.models.base import MessageType from rich.console import Console @@ -28,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 AlephAccount, AsyncTyper, load_account logger = logging.getLogger(__name__) @@ -138,9 +135,7 @@ async def attach_resource( ) -async def detach_resource( - account: Union[AccountFromPrivateKey, LedgerETHAccount], fqdn: Hostname, interactive: Optional[bool] = None -): +async def detach_resource(account: AlephAccount, 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 @@ -190,7 +185,7 @@ async def add( ] = settings.PRIVATE_KEY_FILE, ): """Add and link a Custom Domain.""" - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) interactive = False if (not ask) else is_environment_interactive() console = Console() @@ -275,7 +270,7 @@ async def attach( ] = settings.PRIVATE_KEY_FILE, ): """Attach resource to a Custom Domain.""" - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) await attach_resource( account, @@ -297,7 +292,7 @@ async def detach( ] = settings.PRIVATE_KEY_FILE, ): """Unlink Custom Domain.""" - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) await detach_resource(account, Hostname(fqdn), interactive=False if (not ask) else None) raise typer.Exit() @@ -312,7 +307,7 @@ async def info( ] = settings.PRIVATE_KEY_FILE, ): """Show Custom Domain Details.""" - account = _load_account(private_key, private_key_file) + account: AlephAccount = 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 f63f455f..586fff85 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -10,7 +10,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.types import StorageEnum, StoredContent from aleph.sdk.utils import safe_getattr @@ -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 AlephAccount, 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 = _load_account(private_key, private_key_file) + account: AlephAccount = 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 = _load_account(private_key, private_key_file) + account: AlephAccount = 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 = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) hashes = [ItemHash(item_hash) for item_hash in item_hash.split(",")] @@ -270,7 +269,7 @@ 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 = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 22d064ff..0cda53eb 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -11,7 +11,6 @@ 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.client.services.crn import NetworkGPUS from aleph.sdk.client.services.pricing import Price @@ -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 AlephAccount, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -167,7 +166,7 @@ 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) + account: AlephAccount = load_account(private_key, private_key_file, chain=payment_chain) address = address or settings.ADDRESS_TO_USE or account.get_address() @@ -831,7 +830,7 @@ async def delete( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AlephAccount = 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( @@ -943,7 +942,7 @@ async def list_instances( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AlephAccount = load_account(private_key, private_key_file, chain=chain) address = address or settings.ADDRESS_TO_USE or account.get_address() async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -980,7 +979,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: AlephAccount = 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) @@ -1013,7 +1012,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: AlephAccount = 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) @@ -1041,7 +1040,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: AlephAccount = load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: try: @@ -1072,7 +1071,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: AlephAccount = 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) @@ -1111,7 +1110,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: AlephAccount = load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() @@ -1188,7 +1187,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: AlephAccount = load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() domain = ( diff --git a/src/aleph_client/commands/instance/port_forwarder.py b/src/aleph_client/commands/instance/port_forwarder.py index 58421402..bbbb6518 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 AlephAccount, 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: AlephAccount = 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: AlephAccount = 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: AlephAccount = 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: AlephAccount = 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: AlephAccount = 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 07582587..36285e00 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, @@ -35,7 +34,7 @@ setup_logging, str_to_datetime, ) -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AlephAccount, AsyncTyper, load_account app = AsyncTyper(no_args_is_help=True) @@ -138,7 +137,7 @@ async def post( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) storage_engine: StorageEnum content: dict @@ -188,7 +187,7 @@ async def amend( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = 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 = _load_account(private_key, private_key_file) + account: AlephAccount = 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 = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) if message is None: message = input_multiline() From f770d0fa923e3125bcd9a78d4168bf2c3bbb7ba9 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 10:51:57 +0100 Subject: [PATCH 08/78] fix: unit test mock --- tests/unit/test_account_transact.py | 2 +- tests/unit/test_aggregate.py | 14 +++++++------- tests/unit/test_commands.py | 1 + tests/unit/test_instance.py | 24 ++++++++++++------------ tests/unit/test_port_forwarder.py | 26 +++++++++++++------------- 5 files changed, 34 insertions(+), 33 deletions(-) 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..97c75f06 100644 --- a/tests/unit/test_aggregate.py +++ b/tests/unit/test_aggregate.py @@ -67,7 +67,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 +101,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 @@ -135,7 +135,7 @@ 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"]) - @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_get(aggr_spec): print() # For better display when pytest -v -s @@ -152,7 +152,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 +169,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 +190,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 +210,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..308129ef 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -373,6 +373,7 @@ 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"]) + print(result.output) assert result.exit_code == 0 assert result.stdout.startswith("New Default Configuration: ") diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index a050c1fd..ae335171 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)) @@ -1090,7 +1090,7 @@ async def test_gpu_create_no_gpus_available(mock_crn_list_obj, mock_pricing_info 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_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, From 677799d1198c9ba544f5a3e89b1e06bca4e97ccf Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 11:23:36 +0100 Subject: [PATCH 09/78] Feature: aleph account init to create based config for new user using ledger --- src/aleph_client/commands/account.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 1efcdb7b..f10c73f0 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -75,6 +75,26 @@ def decode_private_key(private_key: str, encoding: KeyEncoding) -> bytes: raise ValueError(INVALID_KEY_FORMAT.format(encoding)) +@app.command() +async def init(): + """Initialize base configuration file.""" + config = MainConfiguration(path=None, chain=Chain.ETH) + + # Create the parent directory and private-keys subdirectory if they don't exist + 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) + save_main_configuration(settings.CONFIG_FILE, config) + + typer.echo( + "Configuration initialized.\n" + "Next steps:\n" + " • Run `aleph account create` to add a private key, or\n" + " • Run `aleph account config --account-type external` to add a ledger account." + ) + + @app.command() async def create( private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, From 725e042f1e71bd33fe62d2c263ddd9dd9ea2fac3 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 11:24:58 +0100 Subject: [PATCH 10/78] fix: aleph account list now handle ledger device --- src/aleph_client/commands/account.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index f10c73f0..448e88f6 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -403,6 +403,9 @@ async def list_accounts(): if config and config.path: 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.EXTERNAL: + active_chain = config.chain + table.add_row(f"Ledger ({config.address[:8]}...)", "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 " @@ -414,13 +417,27 @@ async def list_accounts(): if key_file.stem != "default": table.add_row(key_file.stem, str(key_file), "[bold red]-[/bold red]") + # Try to detect Ledger devices + try: + ledger_accounts = LedgerETHAccount.get_accounts() + if ledger_accounts: + for idx, ledger_acc in enumerate(ledger_accounts): + is_active = config and config.type == AccountType.EXTERNAL and config.address == ledger_acc.address + status = "[bold green]*[/bold green]" if is_active else "[bold red]-[/bold red]" + table.add_row(f"Ledger #{idx}", f"{ledger_acc.address}", status) + except Exception: + logger.info("No ledger detected") + 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.EXTERNAL: + active_address = config.address console.print( "🌐 [bold italic blue]Chain Infos[/bold italic blue] 🌐\n" From 051b0e48c46cb555438d1a0689acc2c7d8867810 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 11:25:25 +0100 Subject: [PATCH 11/78] fix: aleph account address now handle ledger device --- src/aleph_client/commands/account.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 448e88f6..c974328d 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -178,12 +178,25 @@ def display_active_address( """ Display your public address(es). """ + # For regular accounts and Ledger accounts + evm_account = load_account(private_key, private_key_file, chain=Chain.ETH) + evm_address = evm_account.get_address() - 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() + # For Ledger accounts, the SOL address might not be available + try: + sol_address = load_account(private_key, private_key_file, chain=Chain.SOL).get_address() + except Exception: + sol_address = "Not available (using Ledger device)" + + # Detect if it's a Ledger account + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + account_type_str = " (Ledger)" if account_type == AccountType.EXTERNAL 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" ) From 55308d645826fd6e550cc16157ca1583940d187e Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 11:26:55 +0100 Subject: [PATCH 12/78] fix: aleph account export-private-key handle ledger case (can't export private key) --- src/aleph_client/commands/account.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index c974328d..0774ce45 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -261,7 +261,16 @@ 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.EXTERNAL: + 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(): From d433012991c17c332f29e1e1eb182bfe1260f64d Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 3 Nov 2025 12:06:22 +0100 Subject: [PATCH 13/78] Feature: missing unit test for ledger --- tests/unit/test_commands.py | 163 +++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 308129ef..f32d5e24 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,86 @@ def test_account_import_sol(env_files): assert new_key != old_key -def test_account_address(env_files): +def test_account_init(env_files): + """Test the new account init command.""" + settings.CONFIG_FILE = env_files[1] + + # First ensure the config directory exists but is empty + config_dir = env_files[1].parent + # Removing unused variable + + with ( + patch("aleph.sdk.conf.settings.CONFIG_FILE", env_files[1]), + patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), + patch("pathlib.Path.mkdir") as mock_mkdir, + ): + + result = runner.invoke(app, ["account", "init"]) + assert result.exit_code == 0 + assert "Configuration initialized." in result.stdout + assert "Run `aleph account create`" in result.stdout + assert "Run `aleph account config --account-type external`" in result.stdout + + # Check that directories were created + mock_mkdir.assert_any_call(parents=True, exist_ok=True) + + +@patch("aleph.sdk.wallets.ledger.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.EXTERNAL, 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_init_with_isolated_filesystem(): + """Test the new account init command that creates base configuration for new users.""" + # Set up a test directory for config + with runner.isolated_filesystem(): + config_dir = Path("test_config") + config_file = config_dir / "config.json" + + # Create the directory first + config_dir.mkdir(parents=True, exist_ok=True) + + with ( + patch("aleph.sdk.conf.settings.CONFIG_FILE", config_file), + patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), + ): + + result = runner.invoke(app, ["account", "init"]) + + # Verify command executed successfully + assert result.exit_code == 0 + assert "Configuration initialized." in result.stdout + assert "Run `aleph account create`" in result.stdout + assert "Run `aleph account config --account-type external`" in result.stdout + + # Verify the config file was created + assert config_file.exists() + def test_account_chain(env_files): settings.CONFIG_FILE = env_files[1] @@ -236,6 +310,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.EXTERNAL, 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 +333,43 @@ def test_account_list(env_files): assert result.stdout.startswith("🌐 Chain Infos 🌐") +@patch("aleph.sdk.wallets.ledger.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.EXTERNAL, 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"]) @@ -378,6 +505,36 @@ def test_account_config(env_files): assert result.stdout.startswith("New Default Configuration: ") +@patch("aleph.sdk.wallets.ledger.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), + ): + + result = runner.invoke(app, ["account", "config", "--account-type", "external", "--chain", "ETH"]) + + assert result.exit_code == 0 + assert "New Default Configuration" in result.stdout + assert mock_account1.address in result.stdout + + def test_message_get(mocker, store_message_fixture): # Use subprocess to avoid border effects between tests caused by the initialisation # of the aiohttp client session out of an async context in the SDK. This avoids From f61e49760db114062568c4c118de2b89f5eeaabe Mon Sep 17 00:00:00 2001 From: Reza Rahemtola <49811529+RezaRahemtola@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:08:31 +0100 Subject: [PATCH 14/78] fix: Update aleph.im & twentysix to aleph.cloud (#414) Update broken doc links and references to old brand names (aleph.im, twentysix.cloud) to aleph.cloud --- README.md | 62 +++++++++++++------ docs/conf.py | 2 +- docs/content/account.rst | 4 +- docs/content/async_notes.rst | 7 +-- docs/content/cli.rst | 18 +++--- docs/content/introduction.rst | 10 +-- docs/content/programs.rst | 6 +- pyproject.toml | 44 ++++--------- src/aleph_client/__main__.py | 20 +++--- src/aleph_client/commands/account.py | 2 +- src/aleph_client/commands/domain.py | 4 +- src/aleph_client/commands/files.py | 10 +-- src/aleph_client/commands/help_strings.py | 10 +-- .../commands/instance/__init__.py | 21 ++++--- src/aleph_client/commands/instance/display.py | 4 +- src/aleph_client/commands/instance/network.py | 4 +- src/aleph_client/commands/message.py | 20 +++--- src/aleph_client/commands/pricing.py | 2 +- src/aleph_client/commands/program.py | 32 +++++----- tests/unit/test_commands.py | 6 +- 20 files changed, 148 insertions(+), 140 deletions(-) diff --git a/README.md b/README.md index 4a43f3c9..9a43b222 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # aleph-client -Python Client for the [aleph.im network](https://www.aleph.im), next generation network of +Python Client for the [Aleph Cloud network](https://www.aleph.cloud), next generation network of decentralized big data applications. Development follows the [Aleph Whitepaper](https://github.com/aleph-im/aleph-whitepaper). ## Documentation -Documentation can be found on https://docs.aleph.im/tools/aleph-client/ +Documentation can be found on https://docs.aleph.cloud/devhub/sdks-and-tools/aleph-cli/ ## Requirements @@ -15,12 +15,16 @@ Documentation can be found on https://docs.aleph.im/tools/aleph-client/ Some cryptographic functionalities use curve secp256k1 and require installing [libsecp256k1](https://github.com/bitcoin-core/secp256k1). -> apt-get install -y python3-pip libsecp256k1-dev squashfs-tools +```sh +apt-get install -y python3-pip libsecp256k1-dev squashfs-tools +``` ### macOs -> brew tap cuber/homebrew-libsecp256k1 -> brew install libsecp256k1 +```sh +brew tap cuber/homebrew-libsecp256k1 +brew install libsecp256k1 +``` ### Windows @@ -32,13 +36,17 @@ We recommend using [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) Using pip and [PyPI](https://pypi.org/project/aleph-client/): -> pip install aleph-client +```sh +pip install aleph-client +``` ### Using a container Use the Aleph client and it\'s CLI from within Docker or Podman with: -> docker run --rm -ti -v $(pwd)/ ghcr.io/aleph-im/aleph-client/aleph-client:master --help +```sh +docker run --rm -ti -v $(pwd)/ ghcr.io/aleph-im/aleph-client/aleph-client:master --help +``` Warning: This will use an ephemeral key pair that will be discarded when stopping the container @@ -47,40 +55,56 @@ stopping the container We recommend using [hatch](https://hatch.pypa.io/) for development. -Hatch is a modern, extensible Python project manager. +Hatch is a modern, extensible Python project manager. It creates a virtual environment for each project and manages dependencies. -> pip install hatch - +```sh +pip install hatch +``` + ### Running tests -> hatch test +```sh +hatch test +``` or -> hatch run testing:cov +```sh +hatch run testing:cov +``` ### Formatting code -> hatch run linting:fmt +```sh +hatch run linting:fmt +``` ### Checking types -> hatch run linting:typing +``` +hatch run linting:typing +``` ## Publish to PyPI -> hatch build -> hatch upload +```sh +hatch build +hatch upload +``` If you want NULS2 support you will need to install nuls2-python (currently only available on github): -> pip install aleph-sdk-python[nuls2] +``` +pip install aleph-sdk-python[nuls2] +``` To install from source and still be able to modify the source code: -> pip install -e . +```sh +pip install -e . +``` ## Updating the User Documentation @@ -95,4 +119,4 @@ command to generate updated documentation: --output ../aleph-docs/docs/tools/aleph-client/usage.md ``` -Then, open a Pull Request (PR) on the [aleph-docs](https://github.com/aleph-im/aleph-docs/pulls) repository with your changes. \ No newline at end of file +Then, open a Pull Request (PR) on the [aleph-docs](https://github.com/aleph-im/aleph-docs/pulls) repository with your changes. diff --git a/docs/conf.py b/docs/conf.py index f097f948..68ae7201 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -258,7 +258,7 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ("index", "user_guide.tex", "aleph-client Documentation", "Aleph.im", "manual"), + ("index", "user_guide.tex", "aleph-client Documentation", "Aleph Cloud", "manual"), ] # The name of an image file (relative to this directory) to place at the top of diff --git a/docs/content/account.rst b/docs/content/account.rst index c8efaaf9..0d8d673b 100644 --- a/docs/content/account.rst +++ b/docs/content/account.rst @@ -1,7 +1,7 @@ Accounts ======== -To send data to the aleph.im network, you need to have an account. +To send data to the Aleph Cloud network, you need to have an account. This account can be made using any of the supported providers. Common @@ -111,4 +111,4 @@ From a private key: ... bytes.fromhex( ... "0000000000000000000000000000000000000000000000000000000000000001")) >>> account.get_address() - 'NULSd6Hgb53vAd7ZMoA2E17DUTT4C1nGrJVpn' \ No newline at end of file + 'NULSd6Hgb53vAd7ZMoA2E17DUTT4C1nGrJVpn' diff --git a/docs/content/async_notes.rst b/docs/content/async_notes.rst index 2b3747cf..889bc364 100644 --- a/docs/content/async_notes.rst +++ b/docs/content/async_notes.rst @@ -2,7 +2,7 @@ Async vs Sync ============= -At aleph.im we really like coding using asyncio, +At Aleph Cloud we really like coding using asyncio, using async/await construct on Python 3. That being said, we totally understand that you might not @@ -15,7 +15,7 @@ calling the async code behind your back (sneaky!) so you might be careful if you are calling it in an environment where you already have an asyncio loop used. -Most chain specific code is synchronous, and core aleph.im interaction +Most chain specific code is synchronous, and core Aleph Cloud interaction might by async. Sync code have to be imported from :py:mod:`aleph_client.synchronous`, @@ -44,5 +44,4 @@ Example: ... "0x06DE0C46884EbFF46558Cd1a9e7DA6B1c3E9D0a8", ... "profile", session=session) ... - {"bio": "tester", "name": "Moshe on Ethereum"} - + {"bio": "tester", "name": "Moshe on Ethereum"} diff --git a/docs/content/cli.rst b/docs/content/cli.rst index e805e7d7..a86cc1c8 100644 --- a/docs/content/cli.rst +++ b/docs/content/cli.rst @@ -4,7 +4,7 @@ Command-line Interface ======== -Aleph-client can be used as a command-line interface to some Aleph.im +Aleph-client can be used as a command-line interface to some Aleph Cloud functionalities. The following commands are available: @@ -12,7 +12,7 @@ The following commands are available: Post ---- -Post a message on Aleph.im. +Post a message on Aleph Cloud. The content must be JSON encoded and is obtained either from a file or from a user prompt. @@ -21,7 +21,7 @@ or from a user prompt. python3 -m aleph_client post [OPTIONS] - Post a message on Aleph.im. + Post a message on Aleph Cloud. Options: --path TEXT @@ -35,13 +35,13 @@ or from a user prompt. Upload ------ -Upload and store a file on Aleph.im. +Upload and store a file on Aleph Cloud. .. code-block:: bash python3 -m aleph_client upload [OPTIONS] PATH - Upload and store a file on Aleph.im. + Upload and store a file on Aleph Cloud. Arguments: PATH [required] @@ -55,13 +55,13 @@ Upload and store a file on Aleph.im. Pin --- -Persist a file from IPFS on Aleph.im. +Persist a file from IPFS on Aleph Cloud. .. code-block:: bash python3 -m aleph_client pin [OPTIONS] HASH - Persist a file from IPFS on Aleph.im. + Persist a file from IPFS on Aleph Cloud. Arguments: HASH [required] @@ -75,13 +75,13 @@ Persist a file from IPFS on Aleph.im. Program ------- -Register a program to run on Aleph.im virtual machines from a zip archive. +Register a program to run on Aleph Cloud virtual machines from a zip archive. .. code-block:: bash python3 -m aleph_client program [OPTIONS] PATH ENTRYPOINT - Register a program to run on Aleph.im virtual machines from a zip archive. + Register a program to run on Aleph Cloud virtual machines from a zip archive. Arguments: PATH [required] diff --git a/docs/content/introduction.rst b/docs/content/introduction.rst index 4f5df758..cb438d27 100644 --- a/docs/content/introduction.rst +++ b/docs/content/introduction.rst @@ -1,7 +1,7 @@ -Introduction to Aleph.im +Introduction to Aleph Cloud ======================== -The Aleph.im network can be accessed from any API server. +The Aleph Cloud network can be accessed from any API server. To run one yourself, you will need to install `PyAleph `_. @@ -17,7 +17,7 @@ data type). Data structures --------------- -All data transferred over the aleph.im network are aleph messages. +All data transferred over the Aleph Cloud network are aleph messages. .. uml:: @@ -56,8 +56,8 @@ Actual content sent by regular users can currently be of two types: - AGGREGATE: a key-value storage specific to an address - POST: unique data posts (unique data points, events -.. uml:: - +.. uml:: + @startuml object Message { ... diff --git a/docs/content/programs.rst b/docs/content/programs.rst index e5e93420..b6277429 100644 --- a/docs/content/programs.rst +++ b/docs/content/programs.rst @@ -4,9 +4,9 @@ Programs ======== -Programs are special entries that define code to run on Aleph.im virtual machines. +Programs are special entries that define code to run on Aleph Cloud virtual machines. -Aleph.im currently supports programs written in Python that follow the +Aleph Cloud currently supports programs written in Python that follow the `ASGI interface `_. In practice, the easiest approach is to use an @@ -21,7 +21,7 @@ Creating a program Follow the `FastAPI Tutorial `_ to create your first program and test it using uvicorn. -Running on Aleph.im +Running on Aleph Cloud ------------------- Use the :ref:`cli` to upload your program. diff --git a/pyproject.toml b/pyproject.toml index 37282041..2061e7a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,13 +5,11 @@ requires = [ "hatch-vcs", "hatchling" ] [project] name = "aleph-client" -description = "Python Client library for the Aleph.im network" +description = "Python Client library for the Aleph Cloud network" readme = "README.md" -keywords = [ "Aleph.im", "Client", "Library", "Python" ] +keywords = [ "Aleph Cloud", "Client", "Library", "Python" ] license = { file = "LICENSE.txt" } -authors = [ - { name = "Aleph.im Team", email = "hello@aleph.im" }, -] +authors = [ { name = "Aleph Cloud Team", email = "hello@aleph.cloud" } ] requires-python = ">=3.9" classifiers = [ "Development Status :: 4 - Beta", @@ -52,8 +50,8 @@ optional-dependencies.nuls2 = [ "aleph-nuls2==0.1" ] optional-dependencies.polkadot = [ "substrate-interface==1.7.11" ] optional-dependencies.solana = [ "base58==2.1.1", "pynacl==1.5" ] optional-dependencies.tezos = [ "aleph-pytezos==3.13.4", "pynacl==1.5" ] -urls.Discussions = "https://community.aleph.im/" -urls.Documentation = "https://docs.aleph.im/tools/aleph-client/" +urls.Discussions = "https://community.aleph.cloud/" +urls.Documentation = "https://docs.aleph.cloud/devhub/sdks-and-tools/aleph-cli/" urls.Issues = "https://github.com/aleph-im/aleph-client/issues" urls.Source = "https://github.com/aleph-im/aleph-client" scripts.aleph = "aleph_client.__main__:app" @@ -63,9 +61,7 @@ readme-content-type = "text/x-rst; charset=UTF-8" allow-direct-references = true [tool.hatch.build.targets.sdist] -include = [ - "src/aleph_client", -] +include = [ "src/aleph_client" ] [tool.hatch.build.targets.wheel] packages = [ "src/aleph_client" ] @@ -105,13 +101,8 @@ dependencies = [ [tool.hatch.envs.testing.scripts] test = "pytest {args:} ./src/aleph_client/ ./tests/" test-cov = "pytest --cov {args:} ./src/aleph_client/ ./tests/ --cov-report=xml --cov-report=term ./tests/" -cov-report = [ - "coverage report", -] -cov = [ - "test-cov", - "cov-report", -] +cov-report = [ "coverage report" ] +cov = [ "test-cov", "cov-report" ] [[tool.hatch.envs.all.matrix]] python = [ "3.9", "3.10", "3.11", "3.12" ] @@ -146,10 +137,7 @@ fmt = [ "pyproject-fmt pyproject.toml", "style", ] -all = [ - "style", - "typing", -] +all = [ "style", "typing" ] [tool.black] line-length = 120 @@ -227,12 +215,8 @@ lint.per-file-ignores."tests/unit/*" = [ "T201" ] lint.per-file-ignores."tests/unit/test_instance.py" = [ "S106", "T201" ] [tool.pytest.ini_options] -pythonpath = [ - "src", -] -testpaths = [ - "tests", -] +pythonpath = [ "src" ] +testpaths = [ "tests" ] asyncio_default_fixture_loop_scope = "function" [tool.coverage.run] @@ -245,11 +229,7 @@ aleph_client = [ "src/aleph_client" ] tests = [ "tests" ] [tool.coverage.report] -exclude_lines = [ - "no cov", - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", -] +exclude_lines = [ "no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:" ] [tool.mypy] python_version = "3.9" diff --git a/src/aleph_client/__main__.py b/src/aleph_client/__main__.py index c3d72259..a7d71cb9 100644 --- a/src/aleph_client/__main__.py +++ b/src/aleph_client/__main__.py @@ -23,18 +23,16 @@ app.add_typer( message.app, name="message", - help="Manage messages (post, amend, watch and forget) on aleph.im & twentysix.cloud", + help="Manage messages (post, amend, watch and forget) on Aleph Cloud", ) -app.add_typer( - aggregate.app, name="aggregate", help="Manage aggregate messages and permissions on aleph.im & twentysix.cloud" -) -app.add_typer(files.app, name="file", help="Manage files (upload and pin on IPFS) on aleph.im & twentysix.cloud") -app.add_typer(program.app, name="program", help="Manage programs (micro-VMs) on aleph.im & twentysix.cloud") -app.add_typer(instance.app, name="instance", help="Manage instances (VMs) on aleph.im & twentysix.cloud") -app.add_typer(credit.app, name="credits", help="Credits commmands on aleph.im") -app.add_typer(domain.app, name="domain", help="Manage custom domain (DNS) on aleph.im & twentysix.cloud") -app.add_typer(node.app, name="node", help="Get node info on aleph.im & twentysix.cloud") -app.add_typer(about.app, name="about", help="Display the informations of Aleph CLI") +app.add_typer(aggregate.app, name="aggregate", help="Manage aggregate messages and permissions on Aleph Cloud") +app.add_typer(files.app, name="file", help="Manage files (upload and pin on IPFS) on Aleph Cloud") +app.add_typer(program.app, name="program", help="Manage programs (micro-VMs) on Aleph Cloud") +app.add_typer(instance.app, name="instance", help="Manage instances (VMs) on Aleph Cloud") +app.add_typer(credit.app, name="credits", help="Credits commands oAleph Cloud") +app.add_typer(domain.app, name="domain", help="Manage custom domain (DNS) on Aleph Cloud") +app.add_typer(node.app, name="node", help="Get node info on Aleph Cloud") +app.add_typer(about.app, name="about", help="Display the information of Aleph CLI") app.command("pricing")(pricing.prices_for_service) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 18157ff4..e226102f 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -243,7 +243,7 @@ def export_private_key( "⚠️ [bold italic red]Private Keys for Active Account[/bold italic red] ⚠️\n\n" f"[italic]EVM[/italic]: [cyan]{evm_pk}[/cyan]\n" f"[italic]SOL[/italic]: [magenta]{sol_pk}[/magenta]\n\n" - "[bold italic red]Note: Aleph.im team will NEVER ask for them.[/bold italic red]" + "[bold italic red]Note: Aleph Cloud team will NEVER ask for them.[/bold italic red]" ) diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 538bdcbd..8e69dc2e 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -133,7 +133,7 @@ async def attach_resource( console.log("[green bold]Resource attached!") console.log( - f"Visualise on: https://explorer.aleph.im/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" + f"Visualise on: https://explorer.aleph.cloud/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" ) @@ -170,7 +170,7 @@ async def detach_resource(account: AccountFromPrivateKey, fqdn: Hostname, intera console.log("[green bold]Resource detached!") console.log( - f"Visualise on: https://explorer.aleph.im/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" + f"Visualise on: https://explorer.aleph.cloud/address/ETH/{account.get_address()}/message/AGGREGATE/{aggregate_message.item_hash}" ) diff --git a/src/aleph_client/commands/files.py b/src/aleph_client/commands/files.py index bad66bcb..cb0ef87b 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -31,7 +31,7 @@ @app.command() async def pin( - item_hash: Annotated[str, typer.Argument(help="IPFS hash to pin on aleph.im")], + item_hash: Annotated[str, typer.Argument(help="IPFS hash to pin on Aleph Cloud")], channel: Annotated[Optional[str], typer.Option(help=help_strings.CHANNEL)] = settings.DEFAULT_CHANNEL, private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, private_key_file: Annotated[ @@ -40,7 +40,7 @@ async def pin( ref: Annotated[Optional[str], typer.Option(help=help_strings.REF)] = None, debug: Annotated[bool, typer.Option()] = False, ): - """Persist a file from IPFS on aleph.im.""" + """Persist a file from IPFS on Aleph Cloud.""" setup_logging(debug) @@ -71,7 +71,7 @@ async def upload( ref: Annotated[Optional[str], typer.Option(help=help_strings.REF)] = None, debug: Annotated[bool, typer.Option()] = False, ): - """Upload and store a file on aleph.im.""" + """Upload and store a file on Aleph Cloud.""" setup_logging(debug) @@ -127,7 +127,7 @@ async def download( verbose: Annotated[bool, typer.Option()] = True, debug: Annotated[bool, typer.Option()] = False, ) -> Optional[StoredContent]: - """Download a file from aleph.im or display related infos.""" + """Download a file from Aleph Cloud or display related infos.""" setup_logging(debug) @@ -177,7 +177,7 @@ async def forget( ] = settings.PRIVATE_KEY_FILE, debug: Annotated[bool, typer.Option()] = False, ): - """forget a file and his message on aleph.im.""" + """Forget a file and his message on Aleph Cloud.""" setup_logging(debug) diff --git a/src/aleph_client/commands/help_strings.py b/src/aleph_client/commands/help_strings.py index de6a862a..ee5d2faa 100644 --- a/src/aleph_client/commands/help_strings.py +++ b/src/aleph_client/commands/help_strings.py @@ -1,17 +1,17 @@ IPFS_HASH = "IPFS Content identifier (CID)" -CHANNEL = "Aleph.im network channel where the message is or will be broadcasted" +CHANNEL = "Aleph Cloud network channel where the message is or will be broadcasted" PRIVATE_KEY = "Your private key. Cannot be used with --private-key-file" PRIVATE_KEY_FILE = "Path to your private key file" REF = "Item hash of the message to update" SIGNABLE_MESSAGE = "Message to sign" CUSTOM_DOMAIN_TARGET_TYPES = "IPFS|PROGRAM|INSTANCE" CUSTOM_DOMAIN_OWNER_ADDRESS = "Owner address. Defaults to current account address" -CUSTOM_DOMAIN_NAME = "Domain name. ex: aleph.im" +CUSTOM_DOMAIN_NAME = "Domain name. ex: aleph.cloud" CUSTOM_DOMAIN_ITEM_HASH = "Item hash" SKIP_VOLUME = "Skip prompt to attach more volumes" -PERSISTENT_VOLUME = "Persistent volumes are allocated on the host machine and are not deleted when the VM is stopped.\nRequires at least `name`, `mount` path, and `size_mib`. To add multiple, reuse the same argument.\nExample: --persistent-volume name=data,mount=/opt/data,size_mib=1000.\nFor more info, see the docs: https://docs.aleph.im/computing/volumes/persistent/" +PERSISTENT_VOLUME = "Persistent volumes are allocated on the host machine and are not deleted when the VM is stopped.\nRequires at least `name`, `mount` path, and `size_mib`. To add multiple, reuse the same argument.\nExample: --persistent-volume name=data,mount=/opt/data,size_mib=1000.\nFor more info, see the docs: https://docs.aleph.cloud/devhub/building-applications/data-storage/types-of-storage/persistent-storage.html" EPHEMERAL_VOLUME = "Ephemeral volumes are allocated on the host machine when the VM is started and deleted when the VM is stopped.\nRequires at least `mount` path and `size_mib`. To add multiple, reuse the same argument.\nExample: --ephemeral-volume mount=/opt/tmp,size_mib=100" -IMMUTABLE_VOLUME = "Immutable volumes are pinned on the network and can be used by multiple VMs at the same time. They are read-only and useful for setting up libraries or other dependencies.\nRequires at least `mount` path and `ref` (volume message hash). `use_latest` is True by default, to use the latest version of the volume, if it has been amended. To add multiple, reuse the same argument.\nExample: --immutable-volume mount=/opt/packages,ref=25a3...8d94.\nFor more info, see the docs: https://docs.aleph.im/computing/volumes/immutable/" +IMMUTABLE_VOLUME = "Immutable volumes are pinned on the network and can be used by multiple VMs at the same time. They are read-only and useful for setting up libraries or other dependencies.\nRequires at least `mount` path and `ref` (volume message hash). `use_latest` is True by default, to use the latest version of the volume, if it has been amended. To add multiple, reuse the same argument.\nExample: --immutable-volume mount=/opt/packages,ref=25a3...8d94.\nFor more info, see the docs: https://docs.aleph.cloud/devhub/building-applications/data-storage/types-of-storage/immutable-volume.html" SKIP_ENV_VAR = "Skip prompt to set environment variables" ENVIRONMENT_VARIABLES = "Environment variables to pass. They will be public and visible in the message, so don't include secrets. Must be a comma separated list. Example: `KEY=value` or `KEY=value,KEY=value`" ASK_FOR_CONFIRMATION = "Prompt user for confirmation" @@ -53,7 +53,7 @@ PAYMENT_CHAIN_PROGRAM_USED = "Chain you are using to pay for your program" ORIGIN_CHAIN = "Chain of origin of your private key (ensuring correct parsing)" ADDRESS_CHAIN = "Chain for the address" -ADDRESS_PAYER = "Address of the payer. In order to delegate the payment, your account must be authorized beforehand to publish on the behalf of this address. See the docs for more info: https://docs.aleph.im/protocol/permissions/" +ADDRESS_PAYER = "Address of the payer. In order to delegate the payment, your account must be authorized beforehand to publish on the behalf of this address. See the docs for more info: https://docs.aleph.cloud/devhub/building-applications/messaging/permissions.html#message-permissions" CREATE_REPLACE = "Overwrites private key file if it already exists" CREATE_ACTIVE = "Loads the new private key after creation" PROMPT_CRN_URL = "URL of the CRN (Compute node) on which the instance is running" diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index bed8b2d5..aca2e9ed 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -150,7 +150,7 @@ async def create( verbose: Annotated[bool, typer.Option(help="Display additional information")] = True, debug: Annotated[bool, typer.Option(help="Enable debug logging")] = False, ) -> tuple[ItemHash, Optional[str], Chain]: - """Create and register a new instance on aleph.im""" + """Create and register a new instance on aleph.cloud""" setup_logging(debug) console = Console() @@ -295,9 +295,9 @@ async def create( try: rootfs_message = await client.get_message(item_hash=rootfs, message_type=StoreMessage) except MessageNotFoundError: - echo(f"Given rootfs volume {rootfs} does not exist on aleph.im") + echo(f"Given rootfs volume {rootfs} does not exist on aleph.cloud") except ForgottenMessageError: - echo(f"Given rootfs volume {rootfs} has been deleted on aleph.im") + echo(f"Given rootfs volume {rootfs} has been deleted on aleph.cloud") if not rootfs_message: raise typer.Exit(code=1) @@ -310,9 +310,9 @@ async def create( try: firmware_message = await client.get_message(item_hash=confidential_firmware, message_type=StoreMessage) except MessageNotFoundError: - echo("Confidential Firmware hash does not exist on aleph.im") + echo("Confidential Firmware hash does not exist on aleph.cloud") except ForgottenMessageError: - echo("Confidential Firmware hash has been deleted on aleph.im") + echo("Confidential Firmware hash has been deleted on aleph.cloud") if not firmware_message: raise typer.Exit(code=1) @@ -535,7 +535,7 @@ async def create( elif crn_url or crn_hash: logger.debug( "`--crn-url` and/or `--crn-hash` arguments have been ignored.\nHold-tier regular " - "instances are scheduled automatically on available CRNs by the Aleph.im network." + "instances are scheduled automatically on available CRNs by the Aleph Cloud network." ) requirements, trusted_execution, gpu_requirement, tac_accepted = None, None, None, None @@ -747,7 +747,9 @@ async def create( return item_hash, crn_url, payment_chain infos += [ - Text.from_markup(f"Your instance [bright_cyan]{item_hash}[/bright_cyan] has been deployed on aleph.im.") + Text.from_markup( + f"Your instance [bright_cyan]{item_hash}[/bright_cyan] has been deployed on aleph.cloud." + ) ] if verbose: # PAYG-tier non-confidential instances @@ -781,7 +783,8 @@ async def create( else: infos += [ Text.from_markup( - f"Your instance [bright_cyan]{item_hash}[/bright_cyan] is registered to be deployed on aleph.im.\n" + f"Your instance [bright_cyan]{item_hash}[/bright_cyan] is registered" + "to be deployed on aleph.cloud.\n" "The scheduler usually takes a few minutes to set it up and start it.\n" ) ] @@ -1470,7 +1473,7 @@ async def gpu_create( verbose: Annotated[bool, typer.Option(help="Display additional information")] = True, debug: Annotated[bool, typer.Option(help="Enable debug logging")] = False, ): - """Create and register a new GPU instance on aleph.im + """Create and register a new GPU instance on aleph.cloud Only compatible with Pay-As-You-Go""" diff --git a/src/aleph_client/commands/instance/display.py b/src/aleph_client/commands/instance/display.py index 28919711..3cd512cd 100644 --- a/src/aleph_client/commands/instance/display.py +++ b/src/aleph_client/commands/instance/display.py @@ -251,7 +251,9 @@ async def _prepare_instance_column(self): name = Text.assemble(name, "\t", status_badge) # Item hash with explorer link - link = f"https://explorer.aleph.im/address/ETH/{self.message.sender}/message/INSTANCE/{self.message.item_hash}" + link = ( + f"https://explorer.aleph.cloud/address/ETH/{self.message.sender}/message/INSTANCE/{self.message.item_hash}" + ) item_hash_link = Text.from_markup(f"[link={link}]{self.message.item_hash}[/link]", style="bright_cyan") # Payment information diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index 8c274e93..2c73925c 100644 --- a/src/aleph_client/commands/instance/network.py +++ b/src/aleph_client/commands/instance/network.py @@ -152,9 +152,9 @@ async def find_crn_of_vm(vm_id: str) -> Optional[str]: # This is InstanceWithScheduler return info.allocations.node.url except MessageNotFoundError: - echo("Instance does not exist on aleph.im") + echo("Instance does not exist on aleph.cloud") except ForgottenMessageError: - echo("Instance has been deleted on aleph.im") + echo("Instance has been deleted on aleph.cloud") return None diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index a7a48d60..62a72d3f 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -49,11 +49,11 @@ async def get( try: message, status = await client.get_message(item_hash=ItemHash(item_hash), with_status=True) except MessageNotFoundError: - typer.echo("Message does not exist on aleph.im") + typer.echo("Message does not exist on aleph.cloud") except ForgottenMessageError: - typer.echo("Message has been forgotten on aleph.im") + typer.echo("Message has been forgotten on aleph.cloud") except RemovedMessageError: - typer.echo("Message has been removed on aleph.im") + typer.echo("Message has been removed on aleph.cloud") if message: typer.echo(f"Message Status: {colorized_status(status)}") if status == MessageStatus.REJECTED: @@ -134,7 +134,7 @@ async def post( ] = settings.PRIVATE_KEY_FILE, debug: Annotated[bool, typer.Option()] = False, ): - """Post a message on aleph.im.""" + """Post a message on aleph.cloud.""" setup_logging(debug) @@ -184,7 +184,7 @@ async def amend( ] = settings.PRIVATE_KEY_FILE, debug: Annotated[bool, typer.Option()] = False, ): - """Amend an existing aleph.im message.""" + """Amend an existing aleph.cloud message.""" setup_logging(debug) @@ -195,9 +195,9 @@ async def amend( try: existing_message = await client.get_message(item_hash=item_hash) except MessageNotFoundError: - typer.echo("Message does not exist on aleph.im") + typer.echo("Message does not exist on aleph.cloud") except ForgottenMessageError: - typer.echo("Message has been forgotten on aleph.im") + typer.echo("Message has been forgotten on aleph.cloud") if existing_message: editor: str = os.getenv("EDITOR", default="nano") with tempfile.NamedTemporaryFile(suffix="json") as fd: @@ -247,7 +247,7 @@ async def forget( ] = settings.PRIVATE_KEY_FILE, debug: Annotated[bool, typer.Option()] = False, ): - """Forget an existing aleph.im message.""" + """Forget an existing aleph.cloud message.""" setup_logging(debug) @@ -273,9 +273,9 @@ async def watch( try: original = await client.get_message(item_hash=ref) except MessageNotFoundError: - typer.echo("Message does not exist on aleph.im") + typer.echo("Message does not exist on aleph.cloud") except ForgottenMessageError: - typer.echo("Message has been forgotten on aleph.im") + typer.echo("Message has been forgotten on aleph.cloud") if original: async for message in client.watch_messages( message_filter=MessageFilter(refs=[ref], addresses=[original.content.address]) diff --git a/src/aleph_client/commands/pricing.py b/src/aleph_client/commands/pricing.py index 06a0789d..a1e2ba00 100644 --- a/src/aleph_client/commands/pricing.py +++ b/src/aleph_client/commands/pricing.py @@ -406,7 +406,7 @@ async def prices_for_service( ] = False, debug: bool = False, ): - """Display pricing for services available on aleph.im & twentysix.cloud""" + """Display pricing for services available on aleph.cloud""" setup_logging(debug) diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 4105656f..23a394e0 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -110,9 +110,9 @@ async def upload( verbose: Annotated[bool, typer.Option(help="Display additional information")] = True, debug: Annotated[bool, typer.Option(help="Enable debug logging")] = False, ) -> Optional[str]: - """Register a program to run on aleph.im (create/upload are aliases) + """Register a program to run on aleph.cloud (create/upload are aliases) - For more information, see https://docs.aleph.im/computing""" + For more information, see https://docs.aleph.cloud/devhub/compute-resources/""" setup_logging(debug) console = Console() @@ -280,7 +280,9 @@ async def upload( func_url_1 = f"{settings.VM_URL_PATH.format(hash=item_hash)}" func_url_2 = f"{settings.VM_URL_HOST.format(hash_base32=hash_base32)}" infos = [ - Text.from_markup(f"Your program [bright_cyan]{item_hash}[/bright_cyan] has been uploaded on aleph.im."), + Text.from_markup( + f"Your program [bright_cyan]{item_hash}[/bright_cyan] has been uploaded on aleph.cloud." + ), Text.assemble( "\n\nAvailable on:\n", Text.from_markup( @@ -293,7 +295,7 @@ async def upload( ), "\n\nVisualise on:\n", Text.from_markup( - f"[blue]https://explorer.aleph.im/address/{message.chain.value}/{message.sender}/message/PROGRAM/{item_hash}[/blue]" + f"[blue]https://explorer.aleph.cloud/address/{message.chain.value}/{message.sender}/message/PROGRAM/{item_hash}[/blue]" ), ), ] @@ -345,10 +347,10 @@ async def update( try: program_message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) except MessageNotFoundError: - typer.echo("Program does not exist on aleph.im") + typer.echo("Program does not exist on aleph.cloud") return 1 except ForgottenMessageError: - typer.echo("Program has been deleted on aleph.im") + typer.echo("Program has been deleted on aleph.cloud") return 1 if program_message.sender != account.get_address(): typer.echo("You are not the owner of this program") @@ -358,10 +360,10 @@ async def update( try: code_message: StoreMessage = await client.get_message(item_hash=code_ref, message_type=StoreMessage) except MessageNotFoundError: - typer.echo("Code volume does not exist on aleph.im") + typer.echo("Code volume does not exist on aleph.cloud") return 1 except ForgottenMessageError: - typer.echo("Code volume has been deleted on aleph.im") + typer.echo("Code volume has been deleted on aleph.cloud") return 1 if encoding != program_message.content.code.encoding: logger.error( @@ -449,10 +451,10 @@ async def delete( item_hash=item_hash, message_type=ProgramMessage ) except MessageNotFoundError: - typer.echo("Program does not exist on aleph.im") + typer.echo("Program does not exist on aleph.cloud") return 1 except ForgottenMessageError: - typer.echo("Program has been already deleted on aleph.im") + typer.echo("Program has been already deleted on aleph.cloud") return 1 if existing_message.sender != account.get_address(): typer.echo("You are not the owner of this program") @@ -542,7 +544,7 @@ async def list_programs( ), style="magenta3", ) - msg_link = f"https://explorer.aleph.im/address/ETH/{message.sender}/message/PROGRAM/{message.item_hash}" + msg_link = f"https://explorer.aleph.cloud/address/ETH/{message.sender}/message/PROGRAM/{message.item_hash}" item_hash_link = Text.from_markup(f"[link={msg_link}]{message.item_hash}[/link]", style="bright_cyan") payment_type = safe_getattr(message.content, "payment.type", PaymentType.hold) payment = Text.assemble( @@ -673,10 +675,10 @@ async def persist( try: message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) except MessageNotFoundError: - typer.echo("Program does not exist on aleph.im") + typer.echo("Program does not exist on aleph.cloud") return None except ForgottenMessageError: - typer.echo("Program has been deleted on aleph.im") + typer.echo("Program has been deleted on aleph.cloud") return None if message.sender != account.get_address(): typer.echo("You are not the owner of this program") @@ -770,10 +772,10 @@ async def unpersist( try: message: ProgramMessage = await client.get_message(item_hash=item_hash, message_type=ProgramMessage) except MessageNotFoundError: - typer.echo("Program does not exist on aleph.im") + typer.echo("Program does not exist on alepcloud") return None except ForgottenMessageError: - typer.echo("Program has been deleted on aleph.im") + typer.echo("Program has been deleted on aleph.cloud") return None if message.sender != account.get_address(): typer.echo("You are not the owner of this program") diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 6199d343..e3f76ceb 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -440,7 +440,7 @@ def test_message_get_with_forgotten(mocker): # Verify output matches expected response for forgotten messages assert result.exit_code == 0 - assert "Message has been forgotten on aleph.im" in result.stdout + assert "Message has been forgotten on aleph.cloud" in result.stdout def test_message_get_not_found(mocker): @@ -461,7 +461,7 @@ def test_message_get_not_found(mocker): # Verify output matches expected response for not found messages assert result.exit_code == 0 - assert "Message does not exist on aleph.im" in result.stdout + assert "Message does not exist on aleph.cloud" in result.stdout def test_message_get_removed(mocker): @@ -483,7 +483,7 @@ def test_message_get_removed(mocker): # Verify output matches expected response for removed messages assert result.exit_code == 0 - assert "Message has been removed on aleph.im" in result.stdout + assert "Message has been removed on aleph.cloud" in result.stdout def test_message_find(mocker, store_message_fixture): From def2144d9bd9e50d57880aeec922b3e7a19a7e23 Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 4 Nov 2025 13:35:27 +0100 Subject: [PATCH 15/78] Fix: handle common error using ledger (OsError / LedgerError) --- src/aleph_client/commands/account.py | 45 +++++++++++++++++----------- src/aleph_client/utils.py | 8 ++++- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 0774ce45..7065e341 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -28,6 +28,7 @@ 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 @@ -589,24 +590,32 @@ async def configure( "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]", default="y", ): - accounts = LedgerETHAccount.get_accounts() - account_addresses = [acc.address for acc in accounts] - - console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]") - for idx, account_address in enumerate(account_addresses, start=1): - console.print(f"[{idx}] {account_address}") - - key_choice = Prompt.ask("Choose a address by index") - if key_choice.isdigit(): - key_index = int(key_choice) - 1 - selected_address = account_addresses[key_index] - - if not selected_address: - typer.secho("No valid address selected.", fg=typer.colors.RED) - raise typer.Exit() - - address = selected_address - account_type = AccountType.EXTERNAL + try: + + accounts = LedgerETHAccount.get_accounts() + account_addresses = [acc.address for acc in accounts] + + console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]") + for idx, account_address in enumerate(account_addresses, start=1): + console.print(f"[{idx}] {account_address}") + + key_choice = Prompt.ask("Choose a address by index") + if key_choice.isdigit(): + key_index = int(key_choice) - 1 + selected_address = account_addresses[key_index] + + if not selected_address: + typer.secho("No valid address selected.", fg=typer.colors.RED) + raise typer.Exit() + + address = selected_address + account_type = AccountType.EXTERNAL + except LedgerError as e: + logger.warning(f"Ledger Error : {e.message}") + raise typer.Exit(code=1) from e + except OSError as err: + logger.warning("Please ensure Udev rules are set to use Ledger") + raise typer.Exit(code=1) from err else: typer.secho("No private key file provided or found.", fg=typer.colors.RED) raise typer.Exit() diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 560ab3ac..0f0d6ab0 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -30,6 +30,7 @@ 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 # Type alias for account types AlephAccount = Union[AccountFromPrivateKey, LedgerETHAccount] @@ -232,6 +233,11 @@ def load_account( chain = config.chain if config and config.type and config.type == AccountType.EXTERNAL: - return _load_account(None, None, chain=chain) + try: + 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) From 46bbbdf29ecd86acef45794422892b7d141919c1 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:41:48 +0100 Subject: [PATCH 16/78] Fix: handle change from account on sdk side --- src/aleph_client/commands/account.py | 40 +++++++++++++++------------- src/aleph_client/utils.py | 14 ++++------ tests/unit/test_commands.py | 15 +++++------ 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 7065e341..155e00d4 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -25,6 +25,7 @@ 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 @@ -45,12 +46,7 @@ validated_prompt, yes_no_input, ) -from aleph_client.utils import ( - AlephAccount, - AsyncTyper, - list_unlinked_keys, - load_account, -) +from aleph_client.utils import AsyncTyper, list_unlinked_keys, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -194,7 +190,7 @@ def display_active_address( config = load_main_configuration(config_file_path) account_type = config.type if config else None - account_type_str = " (Ledger)" if account_type == AccountType.EXTERNAL else "" + account_type_str = " (Ledger)" if account_type == AccountType.HARDWARE else "" console.print( f"✉ [bold italic blue]Addresses for Active Account{account_type_str}[/bold italic blue] ✉\n\n" @@ -266,7 +262,7 @@ def export_private_key( config_file_path = Path(settings.CONFIG_FILE) config = load_main_configuration(config_file_path) - if config and config.type == AccountType.EXTERNAL: + 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) @@ -278,9 +274,15 @@ def export_private_key( 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" @@ -303,7 +305,7 @@ def sign_bytes( setup_logging(debug) - account: AlephAccount = 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() @@ -338,7 +340,7 @@ async def balance( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display your ALEPH balance and basic voucher information.""" - account: AlephAccount = load_account(private_key, private_key_file, chain=chain) + account = load_account(private_key, private_key_file, chain=chain) if account and not address: address = account.get_address() @@ -426,7 +428,7 @@ async def list_accounts(): if config and config.path: 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.EXTERNAL: + elif config and config.address and config.type == AccountType.HARDWARE: active_chain = config.chain table.add_row(f"Ledger ({config.address[:8]}...)", "External (Ledger)", "[bold green]*[/bold green]") else: @@ -445,7 +447,7 @@ async def list_accounts(): ledger_accounts = LedgerETHAccount.get_accounts() if ledger_accounts: for idx, ledger_acc in enumerate(ledger_accounts): - is_active = config and config.type == AccountType.EXTERNAL and config.address == ledger_acc.address + is_active = config and config.type == AccountType.HARDWARE and config.address == ledger_acc.address status = "[bold green]*[/bold green]" if is_active else "[bold red]-[/bold red]" table.add_row(f"Ledger #{idx}", f"{ledger_acc.address}", status) except Exception: @@ -459,7 +461,7 @@ async def list_accounts(): 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.EXTERNAL: + elif config.address and config.type == AccountType.HARDWARE: active_address = config.address console.print( @@ -558,7 +560,7 @@ async def configure( if private_key_file: pass elif not account_type or ( - account_type == AccountType.INTERNAL and config and hasattr(config, "path") and Path(config.path).exists() + account_type == AccountType.IMPORTED 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 " @@ -585,7 +587,7 @@ async def configure( else: # No change private_key_file = Path(config.path) - if not private_key_file and account_type == AccountType.EXTERNAL: + if not private_key_file and account_type == AccountType.HARDWARE: if yes_no_input( "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]", default="y", @@ -609,7 +611,7 @@ async def configure( raise typer.Exit() address = selected_address - account_type = AccountType.EXTERNAL + account_type = AccountType.HARDWARE except LedgerError as e: logger.warning(f"Ledger Error : {e.message}") raise typer.Exit(code=1) from e @@ -643,7 +645,7 @@ async def configure( raise typer.Exit() if not account_type: - account_type = AccountType.INTERNAL + account_type = AccountType.IMPORTED try: config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 0f0d6ab0..4bd0f937 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -18,23 +18,19 @@ import aiohttp import typer from aiohttp import ClientSession -from aleph.sdk.account import _load_account +from aleph.sdk.account import AccountLike, _load_account from aleph.sdk.conf import ( AccountType, MainConfiguration, load_main_configuration, settings, ) -from aleph.sdk.types import AccountFromPrivateKey, GenericMessage -from aleph.sdk.wallets.ledger import LedgerETHAccount +from aleph.sdk.types import GenericMessage 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 -# Type alias for account types -AlephAccount = Union[AccountFromPrivateKey, LedgerETHAccount] - logger = logging.getLogger(__name__) try: @@ -206,11 +202,11 @@ def cached_async_function(*args, **kwargs): def load_account( private_key_str: Optional[str], private_key_file: Optional[Path], chain: Optional[Chain] = None -) -> AlephAccount: +) -> AccountLike: """ Two Case Possible - Account from private key - - External account (ledger) + - Hardware account (ledger) We first try to load configurations, if no configurations we fallback to private_key_str / private_key_file. """ @@ -232,7 +228,7 @@ def load_account( if not chain and config: chain = config.chain - if config and config.type and config.type == AccountType.EXTERNAL: + if config and config.type and config.type == AccountType.HARDWARE: try: return _load_account(None, None, chain=chain) except LedgerError as err: diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index f32d5e24..6b3545b4 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -226,7 +226,7 @@ def test_account_init(env_files): mock_mkdir.assert_any_call(parents=True, exist_ok=True) -@patch("aleph.sdk.wallets.ledger.LedgerETHAccount.get_accounts") +@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])]) @@ -241,7 +241,7 @@ def test_account_address(mock_get_accounts, env_files): # Create a ledger config ledger_config = MainConfiguration( - path=None, chain=Chain.ETH, type=AccountType.EXTERNAL, address=mock_ledger_account.address + 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): @@ -314,7 +314,7 @@ 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.EXTERNAL, address="0xdeadbeef1234567890123456789012345678beef" + path=None, chain=Chain.ETH, type=AccountType.HARDWARE, address="0xdeadbeef1234567890123456789012345678beef" ) with patch("aleph_client.commands.account.load_main_configuration", return_value=ledger_config): @@ -333,7 +333,7 @@ def test_account_list(env_files): assert result.stdout.startswith("🌐 Chain Infos 🌐") -@patch("aleph.sdk.wallets.ledger.LedgerETHAccount.get_accounts") +@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 @@ -356,7 +356,7 @@ def test_account_list_with_ledger(mock_get_accounts): # Test with a ledger account that's active in configuration ledger_config = MainConfiguration( - path=None, chain=Chain.ETH, type=AccountType.EXTERNAL, address=mock_account1.address + 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): @@ -500,12 +500,11 @@ 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"]) - print(result.output) assert result.exit_code == 0 assert result.stdout.startswith("New Default Configuration: ") -@patch("aleph.sdk.wallets.ledger.LedgerETHAccount.get_accounts") +@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 @@ -528,7 +527,7 @@ def test_account_config_with_ledger(mock_get_accounts): patch("aleph_client.commands.account.yes_no_input", return_value=True), ): - result = runner.invoke(app, ["account", "config", "--account-type", "external", "--chain", "ETH"]) + result = runner.invoke(app, ["account", "config", "--account-type", "hardware", "--chain", "ETH"]) assert result.exit_code == 0 assert "New Default Configuration" in result.stdout From 96ee9cd41962a4ace499748491b7563a57d00e89 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 17:12:05 +0100 Subject: [PATCH 17/78] Fix: remove init commands and ensure that config file/folder and subfolder are created --- src/aleph_client/commands/account.py | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 155e00d4..b9226c68 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -72,26 +72,6 @@ def decode_private_key(private_key: str, encoding: KeyEncoding) -> bytes: raise ValueError(INVALID_KEY_FORMAT.format(encoding)) -@app.command() -async def init(): - """Initialize base configuration file.""" - config = MainConfiguration(path=None, chain=Chain.ETH) - - # Create the parent directory and private-keys subdirectory if they don't exist - 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) - save_main_configuration(settings.CONFIG_FILE, config) - - typer.echo( - "Configuration initialized.\n" - "Next steps:\n" - " • Run `aleph account create` to add a private key, or\n" - " • Run `aleph account config --account-type external` to add a ledger account." - ) - - @app.command() async def create( private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, @@ -542,6 +522,11 @@ async def configure( ): """Configure current private key file and active chain (default selection)""" + if settings.CONFIG_HOME: + settings.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) # Ensure config file is created + private_keys_dir = Path(settings.CONFIG_HOME, "private-keys") # ensure private-keys folder created + private_keys_dir.mkdir(parents=True, exist_ok=True) + unlinked_keys, config = await list_unlinked_keys() # Fixes private key file path From edd5f07b20c5d4dd63b2b2da94dd2aca0640b02d Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 17:13:05 +0100 Subject: [PATCH 18/78] Fix: AccountLike renamed to AccountTypes --- src/aleph_client/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 4bd0f937..997c0fa2 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -18,7 +18,7 @@ import aiohttp import typer from aiohttp import ClientSession -from aleph.sdk.account import AccountLike, _load_account +from aleph.sdk.account import AccountTypes, _load_account from aleph.sdk.conf import ( AccountType, MainConfiguration, @@ -202,7 +202,7 @@ def cached_async_function(*args, **kwargs): def load_account( private_key_str: Optional[str], private_key_file: Optional[Path], chain: Optional[Chain] = None -) -> AccountLike: +) -> AccountTypes: """ Two Case Possible - Account from private key From ffc634e1916be33be850f8beebf2e2012736b566 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 17:23:11 +0100 Subject: [PATCH 19/78] fix: AlephAccount should bez AccountTypes --- src/aleph_client/commands/aggregate.py | 16 +++++++-------- src/aleph_client/commands/credit.py | 6 +++--- src/aleph_client/commands/domain.py | 12 +++++------ src/aleph_client/commands/files.py | 10 +++++----- .../commands/instance/__init__.py | 20 +++++++++---------- .../commands/instance/port_forwarder.py | 12 +++++------ src/aleph_client/commands/message.py | 10 +++++----- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index 6ec0cd07..bdcadc94 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -19,7 +19,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AlephAccount, AsyncTyper, load_account, sanitize_url +from aleph_client.utils import AccountTypes, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -57,7 +57,7 @@ async def forget( setup_logging(debug) - account: AlephAccount = 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(): @@ -130,7 +130,7 @@ async def post( setup_logging(debug) - account: AlephAccount = 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(): @@ -192,7 +192,7 @@ async def get( setup_logging(debug) - account: AlephAccount = 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 async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: @@ -228,7 +228,7 @@ async def list_aggregates( setup_logging(debug) - account: AlephAccount = 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 aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" @@ -302,7 +302,7 @@ async def authorize( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) data = await get( key="security", @@ -376,7 +376,7 @@ async def revoke( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) data = await get( key="security", @@ -431,7 +431,7 @@ async def permissions( setup_logging(debug) - account: AlephAccount = 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 data = await get( diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 70a95bf3..8bf4f56b 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -16,7 +16,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AlephAccount, AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -40,7 +40,7 @@ async def show( setup_logging(debug) - account: AlephAccount = _load_account(private_key, private_key_file) + account: AccountTypes = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() @@ -86,7 +86,7 @@ async def history( ): setup_logging(debug) - account: AlephAccount = _load_account(private_key, private_key_file) + account: AccountTypes = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 86c9e801..9161b190 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -25,7 +25,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import is_environment_interactive -from aleph_client.utils import AlephAccount, AsyncTyper, load_account +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) @@ -135,7 +135,7 @@ async def attach_resource( ) -async def detach_resource(account: AlephAccount, 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 @@ -185,7 +185,7 @@ async def add( ] = settings.PRIVATE_KEY_FILE, ): """Add and link a Custom Domain.""" - account: AlephAccount = 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() @@ -270,7 +270,7 @@ async def attach( ] = settings.PRIVATE_KEY_FILE, ): """Attach resource to a Custom Domain.""" - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) await attach_resource( account, @@ -292,7 +292,7 @@ async def detach( ] = settings.PRIVATE_KEY_FILE, ): """Unlink Custom Domain.""" - account: AlephAccount = 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() @@ -307,7 +307,7 @@ async def info( ] = settings.PRIVATE_KEY_FILE, ): """Show Custom Domain Details.""" - account: AlephAccount = 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 586fff85..5ef3a949 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -22,7 +22,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AlephAccount, AsyncTyper, load_account +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -43,7 +43,7 @@ async def pin( setup_logging(debug) - account: AlephAccount = 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 @@ -74,7 +74,7 @@ async def upload( setup_logging(debug) - account: AlephAccount = 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(): @@ -180,7 +180,7 @@ async def forget( setup_logging(debug) - account: AlephAccount = 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(",")] @@ -269,7 +269,7 @@ 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: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 0cda53eb..ec0705b4 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -81,7 +81,7 @@ yes_no_input, ) from aleph_client.models import CRNInfo -from aleph_client.utils import AlephAccount, AsyncTyper, load_account, sanitize_url +from aleph_client.utils import AccountTypes, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -166,7 +166,7 @@ async def create( ssh_pubkey: str = ssh_pubkey_file.read_text(encoding="utf-8").strip() # Populates account / address - account: AlephAccount = load_account(private_key, private_key_file, chain=payment_chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=payment_chain) address = address or settings.ADDRESS_TO_USE or account.get_address() @@ -830,7 +830,7 @@ async def delete( setup_logging(debug) - account: AlephAccount = 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( @@ -942,7 +942,7 @@ async def list_instances( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) address = address or settings.ADDRESS_TO_USE or account.get_address() async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -979,7 +979,7 @@ async def reboot( or Prompt.ask("URL of the CRN (Compute node) on which the VM is running") ) - account: AlephAccount = 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 +1012,7 @@ async def allocate( or Prompt.ask("URL of the CRN (Compute node) on which the VM will be allocated") ) - account: AlephAccount = 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 +1040,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: AlephAccount = 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 +1071,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: AlephAccount = 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 +1110,7 @@ async def confidential_init_session( or Prompt.ask("URL of the CRN (Compute node) on which the session will be initialized") ) - account: AlephAccount = 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 +1187,7 @@ async def confidential_start( session_dir.mkdir(exist_ok=True, parents=True) vm_hash = ItemHash(vm_id) - account: AlephAccount = 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/port_forwarder.py b/src/aleph_client/commands/instance/port_forwarder.py index bbbb6518..23c84c9f 100644 --- a/src/aleph_client/commands/instance/port_forwarder.py +++ b/src/aleph_client/commands/instance/port_forwarder.py @@ -20,7 +20,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AlephAccount, AsyncTyper, load_account +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -41,7 +41,7 @@ async def list_ports( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + 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: @@ -159,7 +159,7 @@ async def create( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) # Create the port flags port_flags = PortFlags(tcp=tcp, udp=udp) @@ -212,7 +212,7 @@ async def update( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account: AlephAccount = load_account(private_key, private_key_file, 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: @@ -292,7 +292,7 @@ async def delete( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account: AlephAccount = load_account(private_key, private_key_file, 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: @@ -375,7 +375,7 @@ async def refresh( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file, 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 36285e00..94ccd68e 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -34,7 +34,7 @@ setup_logging, str_to_datetime, ) -from aleph_client.utils import AlephAccount, AsyncTyper, load_account +from aleph_client.utils import AccountTypes, AsyncTyper, load_account app = AsyncTyper(no_args_is_help=True) @@ -137,7 +137,7 @@ async def post( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) storage_engine: StorageEnum content: dict @@ -187,7 +187,7 @@ async def amend( setup_logging(debug) - account: AlephAccount = 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 @@ -252,7 +252,7 @@ async def forget( hash_list: list[ItemHash] = [ItemHash(h) for h in hashes.split(",")] - account: AlephAccount = 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) @@ -295,7 +295,7 @@ def sign( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) if message is None: message = input_multiline() From 5e66ce23a91ef885a9a29e80b217d5c52d71ccda Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 17:23:48 +0100 Subject: [PATCH 20/78] fix: account init commands unit test should be removed since not usefull anymore --- tests/unit/test_commands.py | 51 ------------------------------------- 1 file changed, 51 deletions(-) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 6b3545b4..ee574935 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -202,30 +202,6 @@ def test_account_import_sol(env_files): assert new_key != old_key -def test_account_init(env_files): - """Test the new account init command.""" - settings.CONFIG_FILE = env_files[1] - - # First ensure the config directory exists but is empty - config_dir = env_files[1].parent - # Removing unused variable - - with ( - patch("aleph.sdk.conf.settings.CONFIG_FILE", env_files[1]), - patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), - patch("pathlib.Path.mkdir") as mock_mkdir, - ): - - result = runner.invoke(app, ["account", "init"]) - assert result.exit_code == 0 - assert "Configuration initialized." in result.stdout - assert "Run `aleph account create`" in result.stdout - assert "Run `aleph account config --account-type external`" in result.stdout - - # Check that directories were created - mock_mkdir.assert_any_call(parents=True, exist_ok=True) - - @patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") def test_account_address(mock_get_accounts, env_files): settings.CONFIG_FILE = env_files[1] @@ -256,33 +232,6 @@ def test_account_address(mock_get_accounts, env_files): assert result.stdout.startswith("✉ Addresses for Active Account (Ledger) ✉\n\nEVM: 0x") -def test_account_init_with_isolated_filesystem(): - """Test the new account init command that creates base configuration for new users.""" - # Set up a test directory for config - with runner.isolated_filesystem(): - config_dir = Path("test_config") - config_file = config_dir / "config.json" - - # Create the directory first - config_dir.mkdir(parents=True, exist_ok=True) - - with ( - patch("aleph.sdk.conf.settings.CONFIG_FILE", config_file), - patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), - ): - - result = runner.invoke(app, ["account", "init"]) - - # Verify command executed successfully - assert result.exit_code == 0 - assert "Configuration initialized." in result.stdout - assert "Run `aleph account create`" in result.stdout - assert "Run `aleph account config --account-type external`" in result.stdout - - # Verify the config file was created - assert config_file.exists() - - def test_account_chain(env_files): settings.CONFIG_FILE = env_files[1] result = runner.invoke(app, ["account", "chain"]) From bb5e3be35f6ab44c939dbb05a31788ff99d435f3 Mon Sep 17 00:00:00 2001 From: 1yam <40899431+1yam@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:26:16 +0100 Subject: [PATCH 21/78] Fix: use arguments instead of get_closest_tier (#416) * Fix: use arguments instead of get_closest_tier * Fix: ensure that hold can't use --vcpus or --memory or --rootfs_size upper than tier limit * fix: wrong display of vcpus on pricing display --- src/aleph_client/commands/instance/__init__.py | 17 +++++++++-------- src/aleph_client/commands/pricing.py | 5 ++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index aca2e9ed..670e89fc 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -390,12 +390,13 @@ async def create( name = name or validated_prompt("Instance name", lambda x: x and len(x) < 65) specs = pricing.data[pricing_entity].get_services_specs(tier) - vcpus = specs.vcpus - memory = specs.memory_mib - disk_size = specs.disk_mib + vcpus = vcpus if vcpus and payment_type != PaymentType.hold else specs.vcpus + memory = memory if memory and payment_type != PaymentType.hold else specs.memory_mib + disk_size = rootfs_size if rootfs_size and payment_type != PaymentType.hold else 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 +406,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: @@ -719,9 +720,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]" diff --git a/src/aleph_client/commands/pricing.py b/src/aleph_client/commands/pricing.py index a1e2ba00..e333e70c 100644 --- a/src/aleph_client/commands/pricing.py +++ b/src/aleph_client/commands/pricing.py @@ -86,7 +86,7 @@ def build_storage_and_website( infos.append( Text.from_markup( "Service & Availability (Holding): [orange1]" - f"{displayable_amount(price_dict.get('fixed'),decimals=3)}" + f"{displayable_amount(price_dict.get('fixed'), decimals=3)}" " tokens[/orange1]\n" ) ) @@ -172,7 +172,7 @@ def fill_tier( row = [ tier_id, str(tier.compute_units), - str(entity_info.compute_unit.vcpus), + str(entity_info.compute_unit.vcpus * tier.compute_units), f"{entity_info.compute_unit.memory_mib * tier.compute_units / 1024:.0f}", f"{entity_info.compute_unit.disk_mib * tier.compute_units / 1024:.0f}", ] @@ -416,7 +416,6 @@ async def prices_for_service( # Fetch Current availibity network_gpu = None if (service in [GroupEntity.GPU, GroupEntity.ALL]) and with_current_availability: - crn_lists = await call_program_crn_list() network_gpu = crn_lists.find_gpu_on_network() if json: From 25b31efef5165ae4013682e203feb44a00bcd771 Mon Sep 17 00:00:00 2001 From: 1yam <40899431+1yam@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:22:46 +0100 Subject: [PATCH 22/78] =?UTF-8?q?Fix:=20instance=20create=20crn=20is=20non?= =?UTF-8?q?e=20when=20when=20giving=20--crn-hash=20or=20--crn=E2=80=A6=20(?= =?UTF-8?q?#417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: instance create crn is none when when giving --crn-hash or --crn-url * fix: linting --- src/aleph_client/commands/instance/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 670e89fc..f78bca13 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -540,7 +540,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) From 681b59d4c04b52a87ef10853486020ad1c488015 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 19:35:52 +0100 Subject: [PATCH 23/78] Feature: utils functions for ledger --- src/aleph_client/utils.py | 139 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 997c0fa2..53dcebdc 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,6 +17,7 @@ from zipfile import BadZipFile, ZipFile import aiohttp +import hid import typer from aiohttp import ClientSession from aleph.sdk.account import AccountTypes, _load_account @@ -26,12 +28,14 @@ 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 @@ -237,3 +241,138 @@ def load_account( 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) From 47e5128bd8aa4f2e4e511cafb9047331f2649d80 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 19:47:05 +0100 Subject: [PATCH 24/78] Fix: ensure ledger is connected before loading ledger account --- src/aleph_client/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 53dcebdc..08022756 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -234,6 +234,7 @@ def load_account( 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 From 41b7da6dfe1d7845a2a8f8d80dfc887fbfae936f Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 19:59:17 +0100 Subject: [PATCH 25/78] Fix: avoid connecting to ledger when not needed --- src/aleph_client/commands/account.py | 46 ++++++++++++------- src/aleph_client/commands/aggregate.py | 46 +++++++++++++++---- src/aleph_client/commands/credit.py | 33 +++++++++---- src/aleph_client/commands/files.py | 17 +++++-- .../commands/instance/__init__.py | 14 +++++- src/aleph_client/commands/program.py | 23 ++++++++-- 6 files changed, 132 insertions(+), 47 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index b9226c68..67d43d11 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -155,23 +155,19 @@ def display_active_address( """ Display your public address(es). """ - # For regular accounts and Ledger accounts - evm_account = load_account(private_key, private_key_file, chain=Chain.ETH) - evm_address = evm_account.get_address() - # For Ledger accounts, the SOL address might not be available - try: - sol_address = load_account(private_key, private_key_file, chain=Chain.SOL).get_address() - except Exception: - sol_address = "Not available (using Ledger device)" - - # Detect if it's a Ledger account config_file_path = Path(settings.CONFIG_FILE) config = load_main_configuration(config_file_path) account_type = config.type if config else None - account_type_str = " (Ledger)" if account_type == AccountType.HARDWARE else "" + 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( 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" @@ -320,10 +316,17 @@ 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: @@ -405,12 +408,23 @@ async def list_accounts(): table.add_column("Active", no_wrap=True) active_chain = None - if config and config.path: + 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 - table.add_row(f"Ledger ({config.address[:8]}...)", "External (Ledger)", "[bold green]*[/bold green]") + + 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 " diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index bdcadc94..eeb4b104 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -8,8 +8,8 @@ import typer from aiohttp import ClientResponseError, ClientSession -from aleph.sdk.client import AuthenticatedAlephHttpClient -from aleph.sdk.conf import settings +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 @@ -192,10 +192,19 @@ async def get( setup_logging(debug) - account: AccountTypes = 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) @@ -227,9 +236,17 @@ async def list_aggregates( """Display all aggregates associated to an account""" setup_logging(debug) - - account: AccountTypes = 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: @@ -431,8 +448,17 @@ async def permissions( setup_logging(debug) - account: AccountTypes = 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 8bf4f56b..566a8186 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -5,8 +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.conf import AccountType, load_main_configuration, settings from aleph.sdk.utils import displayable_amount from rich import box from rich.console import Console @@ -16,7 +15,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AccountTypes, AsyncTyper +from aleph_client.utils import AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -40,10 +39,17 @@ async def show( setup_logging(debug) - account: AccountTypes = _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: @@ -86,10 +92,17 @@ async def history( ): setup_logging(debug) - account: AccountTypes = _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/files.py b/src/aleph_client/commands/files.py index 5ef3a949..ed00594e 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -10,7 +10,7 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.conf import settings +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 @@ -269,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: AccountTypes = 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 ec0705b4..ce318710 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -942,8 +942,18 @@ async def list_instances( setup_logging(debug) - account: AccountTypes = 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) diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index becdc246..9f450d26 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, @@ -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) @@ -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( From 3bdc3b36f98a6b7883f7a93781e4b38a525bc903 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:02:09 +0100 Subject: [PATCH 26/78] Fix: use BaseEthAccount instead of EthAccount in instance create and prefetch crn list --- .../commands/instance/__init__.py | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index ce318710..5a4d810b 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -11,12 +11,12 @@ import aiohttp import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -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, @@ -153,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) @@ -170,11 +175,6 @@ async def create( address = address or settings.ADDRESS_TO_USE or account.get_address() - # 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 - # Loads default configuration if no chain is set if payment_chain is None: config = load_main_configuration(settings.CONFIG_FILE) @@ -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]" @@ -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: From 91165526f3112e0922642b897c00584d35b8b91d Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:03:01 +0100 Subject: [PATCH 27/78] fix: refactor aleph account configure and list to handle ledger --- src/aleph_client/commands/account.py | 204 +++++++++++++++++++-------- 1 file changed, 148 insertions(+), 56 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 67d43d11..a4df856b 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -9,10 +9,10 @@ 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, @@ -43,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, load_account +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) @@ -330,7 +337,7 @@ async def balance( 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 = [ @@ -369,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] @@ -436,16 +443,25 @@ async def list_accounts(): if key_file.stem != "default": table.add_row(key_file.stem, str(key_file), "[bold red]-[/bold red]") - # Try to detect Ledger devices + 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): - is_active = config and config.type == AccountType.HARDWARE and config.address == ledger_acc.address + 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}", f"{ledger_acc.address}", status) + + table.add_row(f"Ledger #{idx}", ledger_acc.address, status) + except Exception: - logger.info("No ledger detected") + 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() @@ -480,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) @@ -533,16 +556,37 @@ async def configure( 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, + 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) # Ensure config file is created - private_keys_dir = Path(settings.CONFIG_HOME, "private-keys") # ensure private-keys folder created + 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) + + 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) + + config = MainConfiguration( + path=new_key, + chain=new_chain, + address=new_address, + type=new_type, + ) + 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}" + # Fixes private key file path if private_key_file: if not private_key_file.name.endswith(".key"): @@ -555,20 +599,43 @@ async def configure( typer.secho(f"Private key file not found: {private_key_file}", fg=typer.colors.RED) raise typer.Exit() - # If private_key_file is specified via command line, prioritize it - if private_key_file: - pass - elif not account_type or ( - account_type == AccountType.IMPORTED 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 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"Currents 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]") @@ -577,49 +644,74 @@ 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 - if not private_key_file and account_type == AccountType.HARDWARE: - if yes_no_input( - "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]", - default="y", - ): + if account_type == AccountType.HARDWARE: + # If the current config is hardware, show its current address + if config.type == AccountType.HARDWARE: + 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() accounts = LedgerETHAccount.get_accounts() - account_addresses = [acc.address for acc in accounts] + addresses = [acc.address for acc in accounts] - console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]") - for idx, account_address in enumerate(account_addresses, start=1): - console.print(f"[{idx}] {account_address}") + 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 a address by index") + key_choice = Prompt.ask("Choose an address by index") if key_choice.isdigit(): key_index = int(key_choice) - 1 - selected_address = account_addresses[key_index] - - if not selected_address: - typer.secho("No valid address selected.", fg=typer.colors.RED) + 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() - address = selected_address - account_type = AccountType.HARDWARE except LedgerError as e: - logger.warning(f"Ledger Error : {e.message}") + 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 - except OSError as err: - logger.warning("Please ensure Udev rules are set to use Ledger") - raise typer.Exit(code=1) from err else: - typer.secho("No private key file provided or found.", fg=typer.colors.RED) - raise typer.Exit() + address = config.address # If chain is specified via command line, prioritize it if chain: From f884163949e18c482392fad4fbdf52dac4e74574 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:04:07 +0100 Subject: [PATCH 28/78] Fix: call_program_crn_list can now filter node when fetching if they active or not --- src/aleph_client/commands/instance/network.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) 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: From 8db08177c43823be59beb22aebaa7e9da84c9713 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:04:19 +0100 Subject: [PATCH 29/78] Fix: unit test --- tests/unit/test_aggregate.py | 15 ++++++++--- tests/unit/test_commands.py | 50 ++++++++++++++++++++++-------------- tests/unit/test_credits.py | 35 +++---------------------- tests/unit/test_instance.py | 6 +++-- 4 files changed, 51 insertions(+), 55 deletions(-) diff --git a/tests/unit/test_aggregate.py b/tests/unit/test_aggregate.py index 97c75f06..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", @@ -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.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) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index ee574935..469289cd 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -336,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"] @@ -351,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( @@ -387,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" @@ -431,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" @@ -447,10 +441,15 @@ 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") @@ -474,13 +473,26 @@ def test_account_config_with_ledger(mock_get_accounts): 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)), ): - - result = runner.invoke(app, ["account", "config", "--account-type", "hardware", "--chain", "ETH"]) + # 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 - assert "New Default Configuration" in result.stdout - assert mock_account1.address in result.stdout 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 ae335171..3f5fbb5e 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -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,7 +1087,7 @@ 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") From c79a79537a0dfa0f4a55fbab42ba275adab5f744 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:04:47 +0100 Subject: [PATCH 30/78] Feature: --no args for aleph account configure --- src/aleph_client/commands/utils.py | 100 ++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index b4174724..257e559e 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,92 @@ 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], +) -> 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. + `--account-type hardware --no` + `--account-type hardware --address 0xABC --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. Chain updates are always allowed. + `--chain ETH --no` + + 7. 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. + + Raises + ------ + typer.Exit + If an invalid argument combination is detected. + """ + + # 1. Hardware requires address + if account_type == AccountType.HARDWARE and not address: + typer.secho("--no mode: hardware accounts require --address.", 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) + + # 7. No arguments provided = no-op + if not any([private_key_file, chain, address, account_type]): + typer.secho("No changes provided. Keeping existing configuration.", fg=typer.colors.YELLOW) + raise typer.Exit(0) From 703cb47fa89e4337a11571fccc2124795cc586b6 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:09:20 +0100 Subject: [PATCH 31/78] fix: linting issue --- src/aleph_client/commands/program.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 9f450d26..3df2a564 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -630,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" @@ -640,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() From 2a41932965a8247eb7aaf1ff983900cb7eb665d6 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:10:12 +0100 Subject: [PATCH 32/78] Feature: load acount unit test --- tests/unit/test_load_account.py | 171 ++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 tests/unit/test_load_account.py 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() From 53868826a95a201162ff659a65dd90ee8e58f460 Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 14:26:17 +0100 Subject: [PATCH 33/78] Feature: ledger can be load from derivation path --- src/aleph_client/commands/account.py | 96 ++++++++++++++++++++-------- src/aleph_client/commands/utils.py | 29 ++++++--- 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index a4df856b..8e8d1800 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -556,6 +556,9 @@ async def configure( 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)""" @@ -568,24 +571,23 @@ async def configure( unlinked_keys, config = await list_unlinked_keys() if no: - validate_non_interactive_args_config(config, account_type, private_key_file, address, chain) + 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, + 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: @@ -600,6 +602,8 @@ async def configure( raise typer.Exit() 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( @@ -616,7 +620,7 @@ async def configure( else: address = config.address - console.print(f"Currents address : {address}") + console.print(f"Current address: {address}") if account_type == AccountType.IMPORTED: # Determine if we need to ask about keeping or picking a key @@ -656,9 +660,29 @@ async def configure( 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 the current config is hardware, show its current address - if config.type == AccountType.HARDWARE: + 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 @@ -673,24 +697,35 @@ async def configure( # Wait for ledger being UP before continue anythings wait_for_ledger_connection() - 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] + 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 address index.", fg=typer.colors.RED) + typer.secho("Invalid input.", 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))}") @@ -739,10 +774,21 @@ async def configure( account_type = AccountType.IMPORTED try: - config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) + 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 or config.address}" + 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, ) diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index 257e559e..52a63c1e 100644 --- a/src/aleph_client/commands/utils.py +++ b/src/aleph_client/commands/utils.py @@ -421,6 +421,7 @@ def validate_non_interactive_args_config( 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. @@ -431,9 +432,9 @@ def validate_non_interactive_args_config( Validation Rules ---------------- - 1. Hardware accounts require an address. - `--account-type hardware --no` + 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` @@ -448,10 +449,13 @@ def validate_non_interactive_args_config( 5. Addresses are invalid for imported accounts. Applies both when the *new* or *existing* account type is imported. - 6. Chain updates are always allowed. + 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` - 7. If no arguments are provided with `--no`, the command performs no changes + 8. If no arguments are provided with `--no`, the command performs no changes and simply keeps the existing configuration. Parameters @@ -466,6 +470,8 @@ def validate_non_interactive_args_config( 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 ------ @@ -473,9 +479,9 @@ def validate_non_interactive_args_config( If an invalid argument combination is detected. """ - # 1. Hardware requires address - if account_type == AccountType.HARDWARE and not address: - typer.secho("--no mode: hardware accounts require --address.", fg=typer.colors.RED) + # 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 @@ -498,7 +504,12 @@ def validate_non_interactive_args_config( typer.secho("Cannot use address for imported accounts.", fg=typer.colors.RED) raise typer.Exit(1) - # 7. No arguments provided = no-op - if not any([private_key_file, chain, address, account_type]): + # 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) From c96ed549efc3f94ee1ef394412b8a694aef0df29 Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 16:00:00 +0100 Subject: [PATCH 34/78] Unit: test_aggregate.py for ledger --- tests/unit/test_aggregate.py | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/unit/test_aggregate.py b/tests/unit/test_aggregate.py index 9f0791ba..b9c36ab9 100644 --- a/tests/unit/test_aggregate.py +++ b/tests/unit/test_aggregate.py @@ -6,6 +6,8 @@ import aiohttp import pytest +from aleph.sdk.conf import AccountType, MainConfiguration +from aleph_message.models import Chain from aleph_client.commands.aggregate import ( authorize, @@ -157,6 +159,33 @@ async def run_get(aggr_spec): assert aggregate == expected and expected == json.loads(captured.out) +@pytest.mark.asyncio +async def test_get_with_ledger(): + """Test get aggregate using a Ledger hardware wallet.""" + # Mock configuration for Ledger device + ledger_config = MainConfiguration( + path=None, + chain=Chain.ETH, + type=AccountType.HARDWARE, + address="0xdeadbeef1234567890123456789012345678beef", + ) + + mock_client_class, mock_client = create_mock_client(return_fetch=FAKE_AGGREGATE_DATA["AI"]) + + async def run_get_with_ledger(): + with patch("aleph_client.commands.aggregate.load_main_configuration", return_value=ledger_config): + with patch("aleph_client.commands.aggregate.AlephHttpClient", mock_client_class): + return await get(key="AI") + + # Call the function + aggregate = await run_get_with_ledger() + + # Verify result + assert aggregate == FAKE_AGGREGATE_DATA["AI"] + # Verify that fetch_aggregate was called with the correct ledger address + mock_client.fetch_aggregate.assert_called_with(address="0xdeadbeef1234567890123456789012345678beef", key="AI") + + @pytest.mark.asyncio async def test_list_aggregates(): mock_load_account = create_mock_load_account() @@ -172,6 +201,29 @@ async def run_list_aggregates(): assert aggregates == FAKE_AGGREGATE_DATA +@pytest.mark.asyncio +async def test_list_aggregates_with_ledger(): + """Test listing aggregates using a Ledger hardware wallet.""" + # Mock configuration for Ledger device + ledger_config = MainConfiguration( + path=None, + chain=Chain.ETH, + type=AccountType.HARDWARE, + address="0xdeadbeef1234567890123456789012345678beef", + ) + + async def run_list_aggregates_with_ledger(): + with patch("aleph_client.commands.aggregate.load_main_configuration", return_value=ledger_config): + with patch.object(aiohttp.ClientSession, "get", mock_client_session_get): + return await list_aggregates() + + # Call the function + aggregates = await run_list_aggregates_with_ledger() + + # Verify result + assert aggregates == FAKE_AGGREGATE_DATA + + @pytest.mark.asyncio async def test_authorize(capsys): mock_load_account = create_mock_load_account() From 263021a156e1b6792498f6230def8b0390f0aaf2 Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 16:37:23 +0100 Subject: [PATCH 35/78] Unit: new tests for utils func around ledger (wait_for_ledger_connection, ...) --- tests/unit/test_ledger_utils.py | 316 ++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 tests/unit/test_ledger_utils.py diff --git a/tests/unit/test_ledger_utils.py b/tests/unit/test_ledger_utils.py new file mode 100644 index 00000000..035233f5 --- /dev/null +++ b/tests/unit/test_ledger_utils.py @@ -0,0 +1,316 @@ +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 ( + get_first_ledger_name, + list_ledger_dongles, + load_account, + wait_for_ledger_connection, +) + +PATCH_LEDGER_ACCOUNTS = "aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts" +PATCH_HID_ENUM = "hid.enumerate" +PATCH_SLEEP = "time.sleep" +PATCH_WAIT_LEDGER = "aleph_client.utils.wait_for_ledger_connection" +PATCH_LOAD_CONFIG = "aleph_client.utils.load_main_configuration" +PATCH_LOAD_ACCOUNT_INTERNAL = "aleph_client.utils._load_account" + + +@pytest.fixture +def mock_get_accounts(): + with patch(PATCH_LEDGER_ACCOUNTS) as p: + yield p + + +@pytest.fixture +def mock_hid_enum(): + with patch(PATCH_HID_ENUM) as p: + yield p + + +@pytest.fixture +def no_sleep(): + with patch(PATCH_SLEEP): + yield + + +@pytest.fixture +def mock_ledger_config(): + """Create a mock ledger hardware wallet configuration.""" + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xdeadbeef1234567890123456789012345678beef", + type=AccountType.HARDWARE, + ) + + +@pytest.fixture +def mock_ledger_config_with_path(): + """Create a mock ledger hardware wallet configuration with derivation path.""" + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xdeadbeef1234567890123456789012345678beef", + type=AccountType.HARDWARE, + derivation_path="44'/60'/0'/0/0", + ) + + +@pytest.fixture +def mock_imported_config(): + """Create a mock imported wallet configuration.""" + return MainConfiguration( + path=Path("/home/user/.aleph/private-keys/test.key"), + chain=Chain.ETH, + address=None, + type=AccountType.IMPORTED, + ) + + +@pytest.fixture +def mock_ledger_accounts(): + """Create mock ledger accounts.""" + mock_account1 = MagicMock() + mock_account1.address = "0xdeadbeef1234567890123456789012345678beef" + mock_account1.get_address = MagicMock(return_value=mock_account1.address) + mock_account2 = MagicMock() + mock_account2.address = "0xcafebabe5678901234567890123456789012cafe" + mock_account2.get_address = MagicMock(return_value=mock_account2.address) + return [mock_account1, mock_account2] + + +def test_load_account_with_ledger(mock_get_accounts, mock_ledger_config, mock_ledger_accounts): + mock_get_accounts.return_value = mock_ledger_accounts + + with ( + patch(PATCH_LOAD_CONFIG, return_value=mock_ledger_config), + patch(PATCH_LOAD_ACCOUNT_INTERNAL, return_value=mock_ledger_accounts[0]), + patch(PATCH_WAIT_LEDGER), + ): + account = load_account(None, None) + + assert account.get_address() == mock_ledger_accounts[0].address + + +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_list_ledger_dongles(mock_get_accounts, mock_ledger_accounts): + """Test listing Ledger devices.""" + mock_get_accounts.return_value = mock_ledger_accounts + + with patch("hid.enumerate") as mock_enumerate: + # Set up mock HID devices + mock_enumerate.return_value = [ + { + "vendor_id": 0x2C97, + "product_id": 0x0001, + "path": b"usb-123", + "product_string": "Ledger Nano X", + "serial_number": "1234567890", + }, + { + "vendor_id": 0x2C97, + "product_id": 0x0001, + "path": b"usb-123:1.0", + "product_string": "Ledger Nano X", + "serial_number": "1234567890", + }, + { + "vendor_id": 0x2C97, + "product_id": 0x0002, + "path": b"usb-456", + "product_string": "Ledger Nano S", + "serial_number": "0987654321", + }, + { + "vendor_id": 0x1234, # Non-Ledger device + "product_id": 0x5678, + "path": b"usb-789", + "product_string": "Not a Ledger", + "serial_number": "11223344", + }, + ] + + # Test with unique_only=True (default) + dongles = list_ledger_dongles() + assert len(dongles) == 2 # Should filter out duplicates and non-Ledger devices + assert dongles[0]["product_string"] == "Ledger Nano X" + assert dongles[1]["product_string"] == "Ledger Nano S" + + # Test with unique_only=False + dongles = list_ledger_dongles(unique_only=False) + assert len(dongles) == 3 # Should include duplicates but not non-Ledger devices + + +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_get_first_ledger_name(mock_get_accounts, mock_ledger_accounts): + """Test getting the name of the first connected Ledger device.""" + mock_get_accounts.return_value = mock_ledger_accounts + + with patch("aleph_client.utils.list_ledger_dongles") as mock_list_dongles: + # Test with a connected device + mock_list_dongles.return_value = [ + { + "path": b"usb-123", + "product_string": "Ledger Nano X", + } + ] + name = get_first_ledger_name() + assert name == "Ledger Nano X (usb-123)" + + # Test with no connected devices + mock_list_dongles.return_value = [] + name = get_first_ledger_name() + assert name == "No Ledger found" + + +def test_wait_for_ledger_already_connected(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Ledger already connected & have eth app open + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.return_value = ["0xabc"] + + wait_for_ledger_connection() + + mock_get_accounts.assert_called_once() + mock_hid_enum.assert_not_called() + + +def test_wait_for_ledger_device_appears(mock_get_accounts, mock_hid_enum, no_sleep): + """ + No device detected -> continue loop -> device appears -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + Exception("still no"), # first loop + ["0xabc"], # second loop -> success + ] + + mock_hid_enum.side_effect = [ + [], # first iteration -> no device + [{}], # second iteration -> device present + [{}], # third iteration (just in case) + ] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_locked_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Ledger locked -> LedgerError -> retry -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + LedgerError("locked"), # first loop + ["0xabc"], # next loop -> success + ] + + mock_hid_enum.return_value = [{"id": 1}] # device always present + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_comm_error_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Generic communication error -> retry -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("top-level fail"), + Exception("comm error"), + ["0xabc"], + ] + + mock_hid_enum.return_value = [{"id": 1}] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_oserror(mock_get_accounts, mock_hid_enum, no_sleep): + """ + OS error from hid.enumerate -> should exit via typer.Exit(1) + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = Exception("not ready") + mock_hid_enum.side_effect = OSError("permission denied") + + with pytest.raises(typer.Exit) as exc: + wait_for_ledger_connection() + + assert exc.value.exit_code == 1 + + +def test_wait_for_ledger_keyboard_interrupt(mock_get_accounts, mock_hid_enum, no_sleep): + """ + KeyboardInterrupt raised inside loop -> should exit via typer.Exit(1) + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = Exception("not ready") + mock_hid_enum.side_effect = KeyboardInterrupt + + with pytest.raises(typer.Exit) as exc: + wait_for_ledger_connection() + + assert exc.value.exit_code == 1 + + +def test_wait_for_ledger_locked_once_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Device present immediately, but first get_accounts raises LedgerError + (wrong app), then success next iteration + + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + LedgerError("locked"), # loop 1 + ["0xabc"], # loop 2 -> success + ] + + mock_hid_enum.return_value = [{"id": 1}] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 From 9ee1acbbd6b788c34111b162f26e55d87e107094 Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 16:49:12 +0100 Subject: [PATCH 36/78] Unit: new tests for non interactive account config --- tests/unit/test_utils.py | 177 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 996a390d..f599f45e 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,5 +1,11 @@ +from pathlib import Path + +import pytest +import typer +from aleph.sdk.conf import AccountType, MainConfiguration from aleph_message.models import ( AggregateMessage, + Chain, ForgetMessage, PostMessage, ProgramMessage, @@ -7,6 +13,7 @@ ) from aleph_message.models.base import MessageType +from aleph_client.commands.utils import validate_non_interactive_args_config from aleph_client.utils import get_message_type_value @@ -16,3 +23,173 @@ def test_get_message_type_value(): assert get_message_type_value(StoreMessage) == MessageType.store assert get_message_type_value(ProgramMessage) == MessageType.program assert get_message_type_value(ForgetMessage) == MessageType.forget + + +@pytest.fixture +def hardware_config(): + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xHARDWARE", + type=AccountType.HARDWARE, + ) + + +@pytest.fixture +def imported_config(): + return MainConfiguration( + path=Path("/tmp/existing.key"), # noqa: S108 + chain=Chain.ETH, + address=None, + type=AccountType.IMPORTED, + ) + + +@pytest.mark.parametrize( + "kwargs,exit_code", + [ + # RULE 1: hardware requires address or derivation path + ( + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 2: imported requires private key + ( + { + "config": None, + "account_type": AccountType.IMPORTED, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 3: cannot specify address + private key + ( + { + "config": None, + "account_type": None, + "private_key_file": Path("fake.key"), + "address": "0x123", + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 8: no args - exit(0) + ( + { + "config": None, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 0, + ), + ], +) +def test_validate_non_interactive_negative_cases(kwargs, exit_code): + with pytest.raises(typer.Exit) as exc: + validate_non_interactive_args_config(**kwargs) + assert exc.value.exit_code == exit_code + + +@pytest.mark.parametrize( + "override_kwargs,exit_code", + [ + # RULE 4: private key invalid for hardware (existing HW config) + ({"private_key_file": Path("k.key")}, 1), + # RULE 5: address invalid for imported config + ({"address": "0x123"}, 1), + # RULE 6: derivation path invalid for imported config + ({"derivation_path": "44'/60'/0'/0/0"}, 1), + ], +) +def test_validate_non_interactive_invalid_with_existing_config( + override_kwargs, exit_code, hardware_config, imported_config +): + """ + This test runs twice: + - once with hardware_config + - once with imported_config + + And applies the override on top. + """ + + # HW-config cases: only RULE 4 applies + if override_kwargs.get("private_key_file"): + config = hardware_config + else: + config = imported_config + + base_kwargs = { + "config": config, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + } + + kwargs = {**base_kwargs, **override_kwargs} + + with pytest.raises(typer.Exit) as exc: + validate_non_interactive_args_config(**kwargs) + + assert exc.value.exit_code == exit_code + + +@pytest.mark.parametrize( + "kwargs", + [ + # Hardware OK with address + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": "0x123", + "chain": None, + "derivation_path": None, + }, + # Hardware OK with derivation path + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": "44'/60'/0'/0/0", + }, + # Imported OK with private key + { + "config": None, + "account_type": AccountType.IMPORTED, + "private_key_file": Path("/tmp/key.key"), # noqa: S108 + "address": None, + "chain": None, + "derivation_path": None, + }, + # Chain updates always allowed + { + "config": None, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": Chain.ETH, + "derivation_path": None, + }, + ], +) +def test_validate_non_interactive_valid_cases(kwargs): + """These should not raise.""" + validate_non_interactive_args_config(**kwargs) From b86480626ebb336d4d8307c1af448f99f33cc5c4 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 14:20:11 +0200 Subject: [PATCH 37/78] Problem: Ledger wallet users cannot use Aleph to send transactions. Solution: Implement Ledger use on CLI to allow using them. Do it importing a specific branch of the SDK. --- pyproject.toml | 5 +- src/aleph_client/commands/account.py | 47 +++++++++++++++---- .../commands/instance/__init__.py | 1 + 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2061e7a1..243d4484 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ dependencies = [ "aiodns==3.2", "aiohttp==3.11.13", "aleph-message>=1.0.5", - "aleph-sdk-python>=2.1", + #"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 @@ -42,6 +43,8 @@ dependencies = [ "substrate-interface==1.7.11", # Needed for DOT signatures "textual==0.73", "typer==0.15.2", + "ledgerblue>=0.1.48", + "ledgereth>=0.10.0", ] optional-dependencies.cosmos = [ "cosmospy==6" ] optional-dependencies.docs = [ "sphinxcontrib-plantuml==0.30" ] diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index e226102f..a08aa1a8 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -15,6 +15,7 @@ from aleph.sdk.chains.solana import parse_private_key as parse_solana_private_key from aleph.sdk.conf import ( MainConfiguration, + AccountType, load_main_configuration, save_main_configuration, settings, @@ -25,6 +26,7 @@ get_compatible_chains, ) from aleph.sdk.utils import bytes_from_hex, displayable_amount +from aleph.sdk.wallets.ledger import LedgerETHAccount from aleph_message.models import Chain from rich import box from rich.console import Console @@ -145,10 +147,10 @@ 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: 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) - ] = settings.PRIVATE_KEY_FILE, + ] = None, ): """ Display your public address(es). @@ -476,11 +478,16 @@ 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, ): """Configure current private key file and active chain (default selection)""" unlinked_keys, config = await list_unlinked_keys() + if not account_type: + account_type = AccountType.INTERNAL + # Fixes private key file path if private_key_file: if not private_key_file.name.endswith(".key"): @@ -494,7 +501,7 @@ async def configure( 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 account_type and 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]", @@ -521,8 +528,32 @@ async def configure( private_key_file = Path(config.path) if not private_key_file: - typer.secho("No private key file provided or found.", fg=typer.colors.RED) - raise typer.Exit() + if yes_no_input( + f"[bright_cyan]No private key file found.[/bright_cyan] [yellow]" + f"Do you want to import from Ledger?[/yellow]", + default="y", + ): + accounts = LedgerETHAccount.get_accounts() + account_addresses = [acc.address for acc in accounts] + + console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]") + for idx, account_address in enumerate(account_addresses, start=1): + console.print(f"[{idx}] {account_address}") + + key_choice = Prompt.ask("Choose a address by index") + if key_choice.isdigit(): + key_index = int(key_choice) - 1 + selected_address = account_addresses[key_index] + + if not selected_address: + typer.secho("No valid address selected.", fg=typer.colors.RED) + raise typer.Exit() + + address = selected_address + account_type = AccountType.EXTERNAL + else: + typer.secho("No private key file provided or found.", fg=typer.colors.RED) + raise typer.Exit() # Configure active chain if not chain and config and hasattr(config, "chain"): @@ -545,11 +576,11 @@ async def configure( raise typer.Exit() try: - config = MainConfiguration(path=private_key_file, chain=chain) + config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) save_main_configuration(settings.CONFIG_FILE, config) 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.path or config.address}" + 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/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index f78bca13..23402119 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -168,6 +168,7 @@ async def create( # 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() # Start the fetch in the background (async_lru_cache already returns a future) From 3db1512726ce161da5dc7c15e9c8d8df4966fa6d Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 14:37:25 +0200 Subject: [PATCH 38/78] Fix: Solve code quality issues. --- pyproject.toml | 12 ++++++------ src/aleph_client/commands/account.py | 14 ++++++-------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 243d4484..1f3dba30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,20 +31,20 @@ dependencies = [ "aleph-message>=1.0.5", #"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 + "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", - "ledgerblue>=0.1.48", - "ledgereth>=0.10.0", ] optional-dependencies.cosmos = [ "cosmospy==6" ] optional-dependencies.docs = [ "sphinxcontrib-plantuml==0.30" ] diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index a08aa1a8..adcecff4 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -14,8 +14,8 @@ 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.conf import ( - MainConfiguration, AccountType, + MainConfiguration, load_main_configuration, save_main_configuration, settings, @@ -148,9 +148,7 @@ async def create( @app.command(name="address") def display_active_address( 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, + private_key_file: Annotated[Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)] = None, ): """ Display your public address(es). @@ -383,7 +381,7 @@ async def list_accounts(): table.add_column("Active", no_wrap=True) active_chain = None - if config: + if config and config.path: active_chain = config.chain table.add_row(config.path.stem, str(config.path), "[bold green]*[/bold green]") else: @@ -529,9 +527,9 @@ async def configure( if not private_key_file: if yes_no_input( - f"[bright_cyan]No private key file found.[/bright_cyan] [yellow]" - f"Do you want to import from Ledger?[/yellow]", - default="y", + "[bright_cyan]No private key file found.[/bright_cyan] [yellow]" + "Do you want to import from Ledger?[/yellow]", + default="y", ): accounts = LedgerETHAccount.get_accounts() account_addresses = [acc.address for acc in accounts] From dd9f4697924178efa8d33eb07dc5d5e0acea4494 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 15:53:37 +0200 Subject: [PATCH 39/78] Fix: Solve issue loading the good configuration. --- src/aleph_client/commands/account.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index adcecff4..b7b7da55 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -160,8 +160,14 @@ def display_active_address( typer.secho("No private key available", fg=RED) raise typer.Exit(code=1) - 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() + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + if config and config.type and config.type == AccountType.EXTERNAL: + evm_address = _load_account(None, None, chain=Chain.ETH).get_address() + sol_address = _load_account(None, None, chain=Chain.SOL).get_address() + else: + 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() console.print( "✉ [bold italic blue]Addresses for Active Account[/bold italic blue] ✉\n\n" @@ -483,9 +489,6 @@ async def configure( unlinked_keys, config = await list_unlinked_keys() - if not account_type: - account_type = AccountType.INTERNAL - # Fixes private key file path if private_key_file: if not private_key_file.name.endswith(".key"): @@ -499,7 +502,8 @@ async def configure( raise typer.Exit() # Configures active private key file - if not account_type and not private_key_file and config and hasattr(config, "path") and Path(config.path).exists(): + if (not account_type or account_type == AccountType.INTERNAL and + 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]", @@ -525,9 +529,9 @@ async def configure( else: # No change private_key_file = Path(config.path) - if not private_key_file: + if not private_key_file and account_type == AccountType.EXTERNAL: if yes_no_input( - "[bright_cyan]No private key file found.[/bright_cyan] [yellow]" + "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]" "Do you want to import from Ledger?[/yellow]", default="y", ): @@ -573,6 +577,9 @@ async def configure( typer.secho("No chain provided.", fg=typer.colors.RED) raise typer.Exit() + if not account_type: + account_type = AccountType.INTERNAL.value + try: config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) save_main_configuration(settings.CONFIG_FILE, config) From 5640671d9d2278c435ac49fc2d061e4542219ef5 Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 16:27:47 +0200 Subject: [PATCH 40/78] Fix: Solved definitively the wallet selection issue and also solved another issue fetching instances from scheduler. --- src/aleph_client/commands/account.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index b7b7da55..24272ebe 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -502,8 +502,13 @@ async def configure( raise typer.Exit() # Configures active private key file - if (not account_type or account_type == AccountType.INTERNAL and - not private_key_file and config and hasattr(config, "path") and Path(config.path).exists()): + if not account_type or ( + account_type == AccountType.INTERNAL + and 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]", @@ -531,8 +536,7 @@ async def configure( if not private_key_file and account_type == AccountType.EXTERNAL: if yes_no_input( - "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]" - "Do you want to import from Ledger?[/yellow]", + "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]", default="y", ): accounts = LedgerETHAccount.get_accounts() @@ -578,7 +582,7 @@ async def configure( raise typer.Exit() if not account_type: - account_type = AccountType.INTERNAL.value + account_type = AccountType.INTERNAL try: config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) From dea3db70589e2c783c731c51ccc85ecfd3922c1f Mon Sep 17 00:00:00 2001 From: "Andres D. Molins" Date: Wed, 10 Sep 2025 16:35:47 +0200 Subject: [PATCH 41/78] Fix: Solved code-quality issue. --- src/aleph_client/commands/aggregate.py | 15 +++++++-------- src/aleph_client/commands/domain.py | 17 ++++++++++------- src/aleph_client/commands/files.py | 10 +++++----- src/aleph_client/commands/message.py | 10 +++++----- src/aleph_client/commands/program.py | 6 +++--- 5 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index c2848e33..c2c25c13 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -11,7 +11,6 @@ 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.utils import extended_json_encoder from aleph_message.models import Chain, MessageType from aleph_message.status import MessageStatus @@ -59,7 +58,7 @@ async def forget( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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 +131,7 @@ async def post( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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,7 +193,7 @@ async def get( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) address = account.get_address() if address is None else address async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: @@ -230,7 +229,7 @@ async def list_aggregates( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) address = account.get_address() if address is None else address aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" @@ -304,7 +303,7 @@ async def authorize( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) data = await get( key="security", @@ -378,7 +377,7 @@ async def revoke( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) data = await get( key="security", @@ -433,7 +432,7 @@ async def permissions( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) address = account.get_address() if address is None else address data = await get( diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 8e69dc2e..41da2914 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -3,7 +3,7 @@ import logging from pathlib import Path from time import sleep -from typing import Annotated, Optional, cast +from typing import Annotated, Optional, Union, cast import typer from aleph.sdk.account import _load_account @@ -19,6 +19,7 @@ from aleph.sdk.exceptions import DomainConfigurationError from aleph.sdk.query.filters import MessageFilter from aleph.sdk.types import AccountFromPrivateKey +from aleph.sdk.wallets.ledger import LedgerETHAccount from aleph_message.models import AggregateMessage from aleph_message.models.base import MessageType from rich.console import Console @@ -65,7 +66,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 +138,9 @@ async def attach_resource( ) -async def detach_resource(account: AccountFromPrivateKey, fqdn: Hostname, interactive: Optional[bool] = None): +async def detach_resource( + account: Union[AccountFromPrivateKey, LedgerETHAccount], 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 +190,7 @@ async def add( ] = settings.PRIVATE_KEY_FILE, ): """Add and link a Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) interactive = False if (not ask) else is_environment_interactive() console = Console() @@ -272,7 +275,7 @@ async def attach( ] = settings.PRIVATE_KEY_FILE, ): """Attach resource to a Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) await attach_resource( account, @@ -294,7 +297,7 @@ async def detach( ] = settings.PRIVATE_KEY_FILE, ): """Unlink Custom Domain.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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 +312,7 @@ async def info( ] = settings.PRIVATE_KEY_FILE, ): """Show Custom Domain Details.""" - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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 cb0ef87b..8400bc68 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -12,7 +12,7 @@ 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.types import StorageEnum, StoredContent from aleph.sdk.utils import safe_getattr from aleph_message.models import ItemHash, StoreMessage from aleph_message.status import MessageStatus @@ -44,7 +44,7 @@ async def pin( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: result: StoreMessage @@ -75,7 +75,7 @@ async def upload( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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 +181,7 @@ async def forget( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) hashes = [ItemHash(item_hash) for item_hash in item_hash.split(",")] @@ -270,7 +270,7 @@ 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) + account = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index 62a72d3f..4f39f3a1 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -20,7 +20,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 @@ -138,7 +138,7 @@ async def post( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) storage_engine: StorageEnum content: dict @@ -188,7 +188,7 @@ async def amend( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _load_account(private_key, private_key_file) async with AlephHttpClient(api_server=settings.API_HOST) as client: existing_message: Optional[AlephMessage] = None @@ -253,7 +253,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 = _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 +296,7 @@ def sign( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account = _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 23a394e0..5dedad1f 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -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, @@ -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 @@ -341,7 +341,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: From 679ea564da6df317b3a6d24adba1a4efa1390a21 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 10:51:04 +0100 Subject: [PATCH 42/78] Feature: cli load_account to handle account selections --- src/aleph_client/utils.py | 49 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index cc3c5aaa..560ab3ac 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -18,11 +18,22 @@ import aiohttp import typer from aiohttp import ClientSession -from aleph.sdk.conf import MainConfiguration, load_main_configuration, settings -from aleph.sdk.types import GenericMessage +from aleph.sdk.account import _load_account +from aleph.sdk.conf import ( + AccountType, + MainConfiguration, + load_main_configuration, + settings, +) +from aleph.sdk.types import AccountFromPrivateKey, 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 +# Type alias for account types +AlephAccount = Union[AccountFromPrivateKey, LedgerETHAccount] + logger = logging.getLogger(__name__) try: @@ -190,3 +201,37 @@ 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 +) -> AlephAccount: + """ + Two Case Possible + - Account from private key + - External 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.EXTERNAL: + return _load_account(None, None, chain=chain) + else: + return _load_account(private_key_str, private_key_file, chain=chain) From 14805fff1ef07ef3ddc248bab83b2a0417d2afbf Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 10:51:40 +0100 Subject: [PATCH 43/78] Fix: using load_account instead of _load_account --- src/aleph_client/commands/account.py | 47 ++++++++----------- src/aleph_client/commands/aggregate.py | 17 ++++--- src/aleph_client/commands/credit.py | 7 ++- src/aleph_client/commands/domain.py | 19 +++----- src/aleph_client/commands/files.py | 11 ++--- .../commands/instance/__init__.py | 21 ++++----- .../commands/instance/port_forwarder.py | 13 +++-- src/aleph_client/commands/message.py | 11 ++--- 8 files changed, 64 insertions(+), 82 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 24272ebe..7e3f9bfd 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -44,7 +44,12 @@ validated_prompt, yes_no_input, ) -from aleph_client.utils import AsyncTyper, list_unlinked_keys +from aleph_client.utils import ( + AlephAccount, + AsyncTyper, + list_unlinked_keys, + load_account, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -154,20 +159,8 @@ def display_active_address( 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) - if config and config.type and config.type == AccountType.EXTERNAL: - evm_address = _load_account(None, None, chain=Chain.ETH).get_address() - sol_address = _load_account(None, None, chain=Chain.SOL).get_address() - else: - 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() + 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() console.print( "✉ [bold italic blue]Addresses for Active Account[/bold italic blue] ✉\n\n" @@ -267,7 +260,7 @@ def sign_bytes( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AlephAccount = load_account(private_key, private_key_file, chain=chain) if not message: message = input_multiline() @@ -302,7 +295,7 @@ 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) + account: AlephAccount = load_account(private_key, private_key_file, chain=chain) if account and not address: address = account.get_address() @@ -431,7 +424,7 @@ 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) + account = load_account(private_key, private_key_file, chain=chain) if account and not address: address = account.get_address() @@ -501,13 +494,11 @@ 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 account_type or ( - account_type == AccountType.INTERNAL - and not private_key_file - and config - and hasattr(config, "path") - and Path(config.path).exists() + # If private_key_file is specified via command line, prioritize it + if private_key_file: + pass + elif not account_type or ( + account_type == AccountType.INTERNAL 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 " @@ -561,8 +552,10 @@ async def configure( typer.secho("No private key file provided or found.", fg=typer.colors.RED) raise typer.Exit() - # 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", diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index c2c25c13..6ec0cd07 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -8,7 +8,6 @@ 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.utils import extended_json_encoder @@ -20,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 AlephAccount, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -58,7 +57,7 @@ async def forget( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = 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(): @@ -131,7 +130,7 @@ async def post( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = 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(): @@ -193,7 +192,7 @@ async def get( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) address = account.get_address() if address is None else address async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: @@ -229,7 +228,7 @@ async def list_aggregates( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) address = account.get_address() if address is None else address aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" @@ -303,7 +302,7 @@ async def authorize( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) data = await get( key="security", @@ -377,7 +376,7 @@ async def revoke( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) data = await get( key="security", @@ -432,7 +431,7 @@ async def permissions( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) address = account.get_address() if address is None else address data = await get( diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 54b8dcde..70a95bf3 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -7,7 +7,6 @@ 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.utils import displayable_amount from rich import box from rich.console import Console @@ -17,7 +16,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 AlephAccount, AsyncTyper logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -41,7 +40,7 @@ async def show( setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AlephAccount = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() @@ -87,7 +86,7 @@ async def history( ): setup_logging(debug) - account: AccountFromPrivateKey = _load_account(private_key, private_key_file) + account: AlephAccount = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 41da2914..83222db9 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -3,10 +3,9 @@ import logging from pathlib import Path from time import sleep -from typing import Annotated, Optional, Union, cast +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,8 +17,6 @@ ) from aleph.sdk.exceptions import DomainConfigurationError from aleph.sdk.query.filters import MessageFilter -from aleph.sdk.types import AccountFromPrivateKey -from aleph.sdk.wallets.ledger import LedgerETHAccount from aleph_message.models import AggregateMessage from aleph_message.models.base import MessageType from rich.console import Console @@ -28,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 AlephAccount, AsyncTyper, load_account logger = logging.getLogger(__name__) @@ -138,9 +135,7 @@ async def attach_resource( ) -async def detach_resource( - account: Union[AccountFromPrivateKey, LedgerETHAccount], fqdn: Hostname, interactive: Optional[bool] = None -): +async def detach_resource(account: AlephAccount, 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 @@ -190,7 +185,7 @@ async def add( ] = settings.PRIVATE_KEY_FILE, ): """Add and link a Custom Domain.""" - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) interactive = False if (not ask) else is_environment_interactive() console = Console() @@ -275,7 +270,7 @@ async def attach( ] = settings.PRIVATE_KEY_FILE, ): """Attach resource to a Custom Domain.""" - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) await attach_resource( account, @@ -297,7 +292,7 @@ async def detach( ] = settings.PRIVATE_KEY_FILE, ): """Unlink Custom Domain.""" - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) await detach_resource(account, Hostname(fqdn), interactive=False if (not ask) else None) raise typer.Exit() @@ -312,7 +307,7 @@ async def info( ] = settings.PRIVATE_KEY_FILE, ): """Show Custom Domain Details.""" - account = _load_account(private_key, private_key_file) + account: AlephAccount = 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 8400bc68..20c4cd19 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -10,7 +10,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.types import StorageEnum, StoredContent from aleph.sdk.utils import safe_getattr @@ -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 AlephAccount, 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 = _load_account(private_key, private_key_file) + account: AlephAccount = 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 = _load_account(private_key, private_key_file) + account: AlephAccount = 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 = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) hashes = [ItemHash(item_hash) for item_hash in item_hash.split(",")] @@ -270,7 +269,7 @@ 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 = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 23402119..13dd49b6 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -11,7 +11,6 @@ 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.client.services.crn import NetworkGPUS from aleph.sdk.client.services.pricing import Price @@ -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 AlephAccount, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -167,7 +166,7 @@ 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) + account: AlephAccount = load_account(private_key, private_key_file, chain=payment_chain) address = address or settings.ADDRESS_TO_USE or account.get_address() @@ -835,7 +834,7 @@ async def delete( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AlephAccount = 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( @@ -947,7 +946,7 @@ async def list_instances( setup_logging(debug) - account = _load_account(private_key, private_key_file, chain=chain) + account: AlephAccount = load_account(private_key, private_key_file, chain=chain) address = address or settings.ADDRESS_TO_USE or account.get_address() async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -984,7 +983,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: AlephAccount = 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) @@ -1017,7 +1016,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: AlephAccount = 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) @@ -1045,7 +1044,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: AlephAccount = load_account(private_key, private_key_file, chain=chain) async with VmClient(account, domain) as manager: try: @@ -1076,7 +1075,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: AlephAccount = 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) @@ -1115,7 +1114,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: AlephAccount = load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() @@ -1192,7 +1191,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: AlephAccount = load_account(private_key, private_key_file, chain=chain) sevctl_path = find_sevctl_or_exit() domain = ( diff --git a/src/aleph_client/commands/instance/port_forwarder.py b/src/aleph_client/commands/instance/port_forwarder.py index 58421402..bbbb6518 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 AlephAccount, 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: AlephAccount = 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: AlephAccount = 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: AlephAccount = 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: AlephAccount = 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: AlephAccount = 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 4f39f3a1..ec474015 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, @@ -35,7 +34,7 @@ setup_logging, str_to_datetime, ) -from aleph_client.utils import AsyncTyper +from aleph_client.utils import AlephAccount, AsyncTyper, load_account app = AsyncTyper(no_args_is_help=True) @@ -138,7 +137,7 @@ async def post( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) storage_engine: StorageEnum content: dict @@ -188,7 +187,7 @@ async def amend( setup_logging(debug) - account = _load_account(private_key, private_key_file) + account: AlephAccount = 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 = _load_account(private_key, private_key_file) + account: AlephAccount = 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 = _load_account(private_key, private_key_file) + account: AlephAccount = load_account(private_key, private_key_file) if message is None: message = input_multiline() From a140f15e9e6383aa7a436386564c268c4849aaf2 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 10:51:57 +0100 Subject: [PATCH 44/78] fix: unit test mock --- tests/unit/test_account_transact.py | 2 +- tests/unit/test_aggregate.py | 14 +++++++------- tests/unit/test_commands.py | 1 + tests/unit/test_instance.py | 24 ++++++++++++------------ tests/unit/test_port_forwarder.py | 26 +++++++++++++------------- 5 files changed, 34 insertions(+), 33 deletions(-) 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..97c75f06 100644 --- a/tests/unit/test_aggregate.py +++ b/tests/unit/test_aggregate.py @@ -67,7 +67,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 +101,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 @@ -135,7 +135,7 @@ 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"]) - @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_get(aggr_spec): print() # For better display when pytest -v -s @@ -152,7 +152,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 +169,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 +190,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 +210,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 e3f76ceb..0a8b5c07 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -373,6 +373,7 @@ 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"]) + print(result.output) assert result.exit_code == 0 assert result.stdout.startswith("New Default Configuration: ") diff --git a/tests/unit/test_instance.py b/tests/unit/test_instance.py index a050c1fd..ae335171 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)) @@ -1090,7 +1090,7 @@ async def test_gpu_create_no_gpus_available(mock_crn_list_obj, mock_pricing_info 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_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, From 7d588475956e3ba022dfb5d52b4c04702802c26e Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 11:23:36 +0100 Subject: [PATCH 45/78] Feature: aleph account init to create based config for new user using ledger --- src/aleph_client/commands/account.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 7e3f9bfd..91940e44 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -75,6 +75,26 @@ def decode_private_key(private_key: str, encoding: KeyEncoding) -> bytes: raise ValueError(INVALID_KEY_FORMAT.format(encoding)) +@app.command() +async def init(): + """Initialize base configuration file.""" + config = MainConfiguration(path=None, chain=Chain.ETH) + + # Create the parent directory and private-keys subdirectory if they don't exist + 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) + save_main_configuration(settings.CONFIG_FILE, config) + + typer.echo( + "Configuration initialized.\n" + "Next steps:\n" + " • Run `aleph account create` to add a private key, or\n" + " • Run `aleph account config --account-type external` to add a ledger account." + ) + + @app.command() async def create( private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, From a9184035ab7239d8a7c7c9c31f9fc94b2deaf523 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 11:24:58 +0100 Subject: [PATCH 46/78] fix: aleph account list now handle ledger device --- src/aleph_client/commands/account.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 91940e44..672a4dce 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -403,6 +403,9 @@ async def list_accounts(): if config and config.path: 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.EXTERNAL: + active_chain = config.chain + table.add_row(f"Ledger ({config.address[:8]}...)", "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 " @@ -414,13 +417,27 @@ async def list_accounts(): if key_file.stem != "default": table.add_row(key_file.stem, str(key_file), "[bold red]-[/bold red]") + # Try to detect Ledger devices + try: + ledger_accounts = LedgerETHAccount.get_accounts() + if ledger_accounts: + for idx, ledger_acc in enumerate(ledger_accounts): + is_active = config and config.type == AccountType.EXTERNAL and config.address == ledger_acc.address + status = "[bold green]*[/bold green]" if is_active else "[bold red]-[/bold red]" + table.add_row(f"Ledger #{idx}", f"{ledger_acc.address}", status) + except Exception: + logger.info("No ledger detected") + 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.EXTERNAL: + active_address = config.address console.print( "🌐 [bold italic blue]Chain Infos[/bold italic blue] 🌐\n" From f57a4d32162257b5337ebc6d17e2bc6eaa1ab6cb Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 11:25:25 +0100 Subject: [PATCH 47/78] fix: aleph account address now handle ledger device --- src/aleph_client/commands/account.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 672a4dce..6248335b 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -178,12 +178,25 @@ def display_active_address( """ Display your public address(es). """ + # For regular accounts and Ledger accounts + evm_account = load_account(private_key, private_key_file, chain=Chain.ETH) + evm_address = evm_account.get_address() - 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() + # For Ledger accounts, the SOL address might not be available + try: + sol_address = load_account(private_key, private_key_file, chain=Chain.SOL).get_address() + except Exception: + sol_address = "Not available (using Ledger device)" + + # Detect if it's a Ledger account + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + account_type_str = " (Ledger)" if account_type == AccountType.EXTERNAL 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" ) From 9900347b37f9aede9237e7d01fdd2c4ccea46355 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 31 Oct 2025 11:26:55 +0100 Subject: [PATCH 48/78] fix: aleph account export-private-key handle ledger case (can't export private key) --- src/aleph_client/commands/account.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 6248335b..56e6923d 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -261,7 +261,16 @@ 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.EXTERNAL: + 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(): From 47d2d9fbf5d3af0d3d7b9466c189dbc81c8ae49a Mon Sep 17 00:00:00 2001 From: 1yam Date: Mon, 3 Nov 2025 12:06:22 +0100 Subject: [PATCH 49/78] Feature: missing unit test for ledger --- tests/unit/test_commands.py | 163 +++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 0a8b5c07..81abfa5a 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,86 @@ def test_account_import_sol(env_files): assert new_key != old_key -def test_account_address(env_files): +def test_account_init(env_files): + """Test the new account init command.""" + settings.CONFIG_FILE = env_files[1] + + # First ensure the config directory exists but is empty + config_dir = env_files[1].parent + # Removing unused variable + + with ( + patch("aleph.sdk.conf.settings.CONFIG_FILE", env_files[1]), + patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), + patch("pathlib.Path.mkdir") as mock_mkdir, + ): + + result = runner.invoke(app, ["account", "init"]) + assert result.exit_code == 0 + assert "Configuration initialized." in result.stdout + assert "Run `aleph account create`" in result.stdout + assert "Run `aleph account config --account-type external`" in result.stdout + + # Check that directories were created + mock_mkdir.assert_any_call(parents=True, exist_ok=True) + + +@patch("aleph.sdk.wallets.ledger.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.EXTERNAL, 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_init_with_isolated_filesystem(): + """Test the new account init command that creates base configuration for new users.""" + # Set up a test directory for config + with runner.isolated_filesystem(): + config_dir = Path("test_config") + config_file = config_dir / "config.json" + + # Create the directory first + config_dir.mkdir(parents=True, exist_ok=True) + + with ( + patch("aleph.sdk.conf.settings.CONFIG_FILE", config_file), + patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), + ): + + result = runner.invoke(app, ["account", "init"]) + + # Verify command executed successfully + assert result.exit_code == 0 + assert "Configuration initialized." in result.stdout + assert "Run `aleph account create`" in result.stdout + assert "Run `aleph account config --account-type external`" in result.stdout + + # Verify the config file was created + assert config_file.exists() + def test_account_chain(env_files): settings.CONFIG_FILE = env_files[1] @@ -236,6 +310,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.EXTERNAL, 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 +333,43 @@ def test_account_list(env_files): assert result.stdout.startswith("🌐 Chain Infos 🌐") +@patch("aleph.sdk.wallets.ledger.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.EXTERNAL, 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"]) @@ -378,6 +505,36 @@ def test_account_config(env_files): assert result.stdout.startswith("New Default Configuration: ") +@patch("aleph.sdk.wallets.ledger.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), + ): + + result = runner.invoke(app, ["account", "config", "--account-type", "external", "--chain", "ETH"]) + + assert result.exit_code == 0 + assert "New Default Configuration" in result.stdout + assert mock_account1.address in result.stdout + + def test_message_get(mocker, store_message_fixture): # Use subprocess to avoid border effects between tests caused by the initialisation # of the aiohttp client session out of an async context in the SDK. This avoids From 18f094055055dfd3ebfd5681a19323681ae8b9ef Mon Sep 17 00:00:00 2001 From: 1yam Date: Tue, 4 Nov 2025 13:35:27 +0100 Subject: [PATCH 50/78] Fix: handle common error using ledger (OsError / LedgerError) --- src/aleph_client/commands/account.py | 45 +++++++++++++++++----------- src/aleph_client/utils.py | 8 ++++- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 56e6923d..96beceec 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -28,6 +28,7 @@ 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 @@ -589,24 +590,32 @@ async def configure( "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]", default="y", ): - accounts = LedgerETHAccount.get_accounts() - account_addresses = [acc.address for acc in accounts] - - console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]") - for idx, account_address in enumerate(account_addresses, start=1): - console.print(f"[{idx}] {account_address}") - - key_choice = Prompt.ask("Choose a address by index") - if key_choice.isdigit(): - key_index = int(key_choice) - 1 - selected_address = account_addresses[key_index] - - if not selected_address: - typer.secho("No valid address selected.", fg=typer.colors.RED) - raise typer.Exit() - - address = selected_address - account_type = AccountType.EXTERNAL + try: + + accounts = LedgerETHAccount.get_accounts() + account_addresses = [acc.address for acc in accounts] + + console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]") + for idx, account_address in enumerate(account_addresses, start=1): + console.print(f"[{idx}] {account_address}") + + key_choice = Prompt.ask("Choose a address by index") + if key_choice.isdigit(): + key_index = int(key_choice) - 1 + selected_address = account_addresses[key_index] + + if not selected_address: + typer.secho("No valid address selected.", fg=typer.colors.RED) + raise typer.Exit() + + address = selected_address + account_type = AccountType.EXTERNAL + except LedgerError as e: + logger.warning(f"Ledger Error : {e.message}") + raise typer.Exit(code=1) from e + except OSError as err: + logger.warning("Please ensure Udev rules are set to use Ledger") + raise typer.Exit(code=1) from err else: typer.secho("No private key file provided or found.", fg=typer.colors.RED) raise typer.Exit() diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 560ab3ac..0f0d6ab0 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -30,6 +30,7 @@ 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 # Type alias for account types AlephAccount = Union[AccountFromPrivateKey, LedgerETHAccount] @@ -232,6 +233,11 @@ def load_account( chain = config.chain if config and config.type and config.type == AccountType.EXTERNAL: - return _load_account(None, None, chain=chain) + try: + 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) From 764a5f62330e377d929bd6f36a66bb50a5cab4ce Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 14:41:48 +0100 Subject: [PATCH 51/78] Fix: handle change from account on sdk side --- src/aleph_client/commands/account.py | 40 +++++++++++++++------------- src/aleph_client/utils.py | 14 ++++------ tests/unit/test_commands.py | 15 +++++------ 3 files changed, 33 insertions(+), 36 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 96beceec..81d3167f 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -25,6 +25,7 @@ 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 @@ -45,12 +46,7 @@ validated_prompt, yes_no_input, ) -from aleph_client.utils import ( - AlephAccount, - AsyncTyper, - list_unlinked_keys, - load_account, -) +from aleph_client.utils import AsyncTyper, list_unlinked_keys, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -194,7 +190,7 @@ def display_active_address( config = load_main_configuration(config_file_path) account_type = config.type if config else None - account_type_str = " (Ledger)" if account_type == AccountType.EXTERNAL else "" + account_type_str = " (Ledger)" if account_type == AccountType.HARDWARE else "" console.print( f"✉ [bold italic blue]Addresses for Active Account{account_type_str}[/bold italic blue] ✉\n\n" @@ -266,7 +262,7 @@ def export_private_key( config_file_path = Path(settings.CONFIG_FILE) config = load_main_configuration(config_file_path) - if config and config.type == AccountType.EXTERNAL: + 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) @@ -278,9 +274,15 @@ def export_private_key( 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" @@ -303,7 +305,7 @@ def sign_bytes( setup_logging(debug) - account: AlephAccount = 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() @@ -338,7 +340,7 @@ async def balance( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display your ALEPH balance and basic voucher information.""" - account: AlephAccount = load_account(private_key, private_key_file, chain=chain) + account = load_account(private_key, private_key_file, chain=chain) if account and not address: address = account.get_address() @@ -426,7 +428,7 @@ async def list_accounts(): if config and config.path: 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.EXTERNAL: + elif config and config.address and config.type == AccountType.HARDWARE: active_chain = config.chain table.add_row(f"Ledger ({config.address[:8]}...)", "External (Ledger)", "[bold green]*[/bold green]") else: @@ -445,7 +447,7 @@ async def list_accounts(): ledger_accounts = LedgerETHAccount.get_accounts() if ledger_accounts: for idx, ledger_acc in enumerate(ledger_accounts): - is_active = config and config.type == AccountType.EXTERNAL and config.address == ledger_acc.address + is_active = config and config.type == AccountType.HARDWARE and config.address == ledger_acc.address status = "[bold green]*[/bold green]" if is_active else "[bold red]-[/bold red]" table.add_row(f"Ledger #{idx}", f"{ledger_acc.address}", status) except Exception: @@ -459,7 +461,7 @@ async def list_accounts(): 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.EXTERNAL: + elif config.address and config.type == AccountType.HARDWARE: active_address = config.address console.print( @@ -558,7 +560,7 @@ async def configure( if private_key_file: pass elif not account_type or ( - account_type == AccountType.INTERNAL and config and hasattr(config, "path") and Path(config.path).exists() + account_type == AccountType.IMPORTED 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 " @@ -585,7 +587,7 @@ async def configure( else: # No change private_key_file = Path(config.path) - if not private_key_file and account_type == AccountType.EXTERNAL: + if not private_key_file and account_type == AccountType.HARDWARE: if yes_no_input( "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]", default="y", @@ -609,7 +611,7 @@ async def configure( raise typer.Exit() address = selected_address - account_type = AccountType.EXTERNAL + account_type = AccountType.HARDWARE except LedgerError as e: logger.warning(f"Ledger Error : {e.message}") raise typer.Exit(code=1) from e @@ -643,7 +645,7 @@ async def configure( raise typer.Exit() if not account_type: - account_type = AccountType.INTERNAL + account_type = AccountType.IMPORTED try: config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 0f0d6ab0..4bd0f937 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -18,23 +18,19 @@ import aiohttp import typer from aiohttp import ClientSession -from aleph.sdk.account import _load_account +from aleph.sdk.account import AccountLike, _load_account from aleph.sdk.conf import ( AccountType, MainConfiguration, load_main_configuration, settings, ) -from aleph.sdk.types import AccountFromPrivateKey, GenericMessage -from aleph.sdk.wallets.ledger import LedgerETHAccount +from aleph.sdk.types import GenericMessage 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 -# Type alias for account types -AlephAccount = Union[AccountFromPrivateKey, LedgerETHAccount] - logger = logging.getLogger(__name__) try: @@ -206,11 +202,11 @@ def cached_async_function(*args, **kwargs): def load_account( private_key_str: Optional[str], private_key_file: Optional[Path], chain: Optional[Chain] = None -) -> AlephAccount: +) -> AccountLike: """ Two Case Possible - Account from private key - - External account (ledger) + - Hardware account (ledger) We first try to load configurations, if no configurations we fallback to private_key_str / private_key_file. """ @@ -232,7 +228,7 @@ def load_account( if not chain and config: chain = config.chain - if config and config.type and config.type == AccountType.EXTERNAL: + if config and config.type and config.type == AccountType.HARDWARE: try: return _load_account(None, None, chain=chain) except LedgerError as err: diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 81abfa5a..411af3da 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -226,7 +226,7 @@ def test_account_init(env_files): mock_mkdir.assert_any_call(parents=True, exist_ok=True) -@patch("aleph.sdk.wallets.ledger.LedgerETHAccount.get_accounts") +@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])]) @@ -241,7 +241,7 @@ def test_account_address(mock_get_accounts, env_files): # Create a ledger config ledger_config = MainConfiguration( - path=None, chain=Chain.ETH, type=AccountType.EXTERNAL, address=mock_ledger_account.address + 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): @@ -314,7 +314,7 @@ 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.EXTERNAL, address="0xdeadbeef1234567890123456789012345678beef" + path=None, chain=Chain.ETH, type=AccountType.HARDWARE, address="0xdeadbeef1234567890123456789012345678beef" ) with patch("aleph_client.commands.account.load_main_configuration", return_value=ledger_config): @@ -333,7 +333,7 @@ def test_account_list(env_files): assert result.stdout.startswith("🌐 Chain Infos 🌐") -@patch("aleph.sdk.wallets.ledger.LedgerETHAccount.get_accounts") +@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 @@ -356,7 +356,7 @@ def test_account_list_with_ledger(mock_get_accounts): # Test with a ledger account that's active in configuration ledger_config = MainConfiguration( - path=None, chain=Chain.ETH, type=AccountType.EXTERNAL, address=mock_account1.address + 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): @@ -500,12 +500,11 @@ 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"]) - print(result.output) assert result.exit_code == 0 assert result.stdout.startswith("New Default Configuration: ") -@patch("aleph.sdk.wallets.ledger.LedgerETHAccount.get_accounts") +@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 @@ -528,7 +527,7 @@ def test_account_config_with_ledger(mock_get_accounts): patch("aleph_client.commands.account.yes_no_input", return_value=True), ): - result = runner.invoke(app, ["account", "config", "--account-type", "external", "--chain", "ETH"]) + result = runner.invoke(app, ["account", "config", "--account-type", "hardware", "--chain", "ETH"]) assert result.exit_code == 0 assert "New Default Configuration" in result.stdout From 8b9ad59cbb627dd473238f965c9c9b7aeb09fd4d Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 17:12:05 +0100 Subject: [PATCH 52/78] Fix: remove init commands and ensure that config file/folder and subfolder are created --- src/aleph_client/commands/account.py | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 81d3167f..65bfdb5d 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -72,26 +72,6 @@ def decode_private_key(private_key: str, encoding: KeyEncoding) -> bytes: raise ValueError(INVALID_KEY_FORMAT.format(encoding)) -@app.command() -async def init(): - """Initialize base configuration file.""" - config = MainConfiguration(path=None, chain=Chain.ETH) - - # Create the parent directory and private-keys subdirectory if they don't exist - 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) - save_main_configuration(settings.CONFIG_FILE, config) - - typer.echo( - "Configuration initialized.\n" - "Next steps:\n" - " • Run `aleph account create` to add a private key, or\n" - " • Run `aleph account config --account-type external` to add a ledger account." - ) - - @app.command() async def create( private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, @@ -542,6 +522,11 @@ async def configure( ): """Configure current private key file and active chain (default selection)""" + if settings.CONFIG_HOME: + settings.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) # Ensure config file is created + private_keys_dir = Path(settings.CONFIG_HOME, "private-keys") # ensure private-keys folder created + private_keys_dir.mkdir(parents=True, exist_ok=True) + unlinked_keys, config = await list_unlinked_keys() # Fixes private key file path From 332f2cc7ddac8e17029cc6591a27805ed46cc3d3 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 17:13:05 +0100 Subject: [PATCH 53/78] Fix: AccountLike renamed to AccountTypes --- src/aleph_client/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 4bd0f937..997c0fa2 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -18,7 +18,7 @@ import aiohttp import typer from aiohttp import ClientSession -from aleph.sdk.account import AccountLike, _load_account +from aleph.sdk.account import AccountTypes, _load_account from aleph.sdk.conf import ( AccountType, MainConfiguration, @@ -202,7 +202,7 @@ def cached_async_function(*args, **kwargs): def load_account( private_key_str: Optional[str], private_key_file: Optional[Path], chain: Optional[Chain] = None -) -> AccountLike: +) -> AccountTypes: """ Two Case Possible - Account from private key From 51a2cac2e5e01bb877c0564c4718dbe0caa42605 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 17:23:11 +0100 Subject: [PATCH 54/78] fix: AlephAccount should bez AccountTypes --- src/aleph_client/commands/aggregate.py | 16 +++++++-------- src/aleph_client/commands/credit.py | 6 +++--- src/aleph_client/commands/domain.py | 12 +++++------ src/aleph_client/commands/files.py | 10 +++++----- .../commands/instance/__init__.py | 20 +++++++++---------- .../commands/instance/port_forwarder.py | 12 +++++------ src/aleph_client/commands/message.py | 10 +++++----- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index 6ec0cd07..bdcadc94 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -19,7 +19,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AlephAccount, AsyncTyper, load_account, sanitize_url +from aleph_client.utils import AccountTypes, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -57,7 +57,7 @@ async def forget( setup_logging(debug) - account: AlephAccount = 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(): @@ -130,7 +130,7 @@ async def post( setup_logging(debug) - account: AlephAccount = 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(): @@ -192,7 +192,7 @@ async def get( setup_logging(debug) - account: AlephAccount = 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 async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: @@ -228,7 +228,7 @@ async def list_aggregates( setup_logging(debug) - account: AlephAccount = 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 aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" @@ -302,7 +302,7 @@ async def authorize( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) data = await get( key="security", @@ -376,7 +376,7 @@ async def revoke( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) data = await get( key="security", @@ -431,7 +431,7 @@ async def permissions( setup_logging(debug) - account: AlephAccount = 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 data = await get( diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 70a95bf3..8bf4f56b 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -16,7 +16,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AlephAccount, AsyncTyper +from aleph_client.utils import AccountTypes, AsyncTyper logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -40,7 +40,7 @@ async def show( setup_logging(debug) - account: AlephAccount = _load_account(private_key, private_key_file) + account: AccountTypes = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() @@ -86,7 +86,7 @@ async def history( ): setup_logging(debug) - account: AlephAccount = _load_account(private_key, private_key_file) + account: AccountTypes = _load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/domain.py b/src/aleph_client/commands/domain.py index 83222db9..85713a39 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -25,7 +25,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import is_environment_interactive -from aleph_client.utils import AlephAccount, AsyncTyper, load_account +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) @@ -135,7 +135,7 @@ async def attach_resource( ) -async def detach_resource(account: AlephAccount, 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 @@ -185,7 +185,7 @@ async def add( ] = settings.PRIVATE_KEY_FILE, ): """Add and link a Custom Domain.""" - account: AlephAccount = 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() @@ -270,7 +270,7 @@ async def attach( ] = settings.PRIVATE_KEY_FILE, ): """Attach resource to a Custom Domain.""" - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) await attach_resource( account, @@ -292,7 +292,7 @@ async def detach( ] = settings.PRIVATE_KEY_FILE, ): """Unlink Custom Domain.""" - account: AlephAccount = 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() @@ -307,7 +307,7 @@ async def info( ] = settings.PRIVATE_KEY_FILE, ): """Show Custom Domain Details.""" - account: AlephAccount = 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 20c4cd19..8934d306 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -22,7 +22,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AlephAccount, AsyncTyper, load_account +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -43,7 +43,7 @@ async def pin( setup_logging(debug) - account: AlephAccount = 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 @@ -74,7 +74,7 @@ async def upload( setup_logging(debug) - account: AlephAccount = 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(): @@ -180,7 +180,7 @@ async def forget( setup_logging(debug) - account: AlephAccount = 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(",")] @@ -269,7 +269,7 @@ 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: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) if account and not address: address = account.get_address() diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 13dd49b6..77f3641f 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -81,7 +81,7 @@ yes_no_input, ) from aleph_client.models import CRNInfo -from aleph_client.utils import AlephAccount, AsyncTyper, load_account, sanitize_url +from aleph_client.utils import AccountTypes, AsyncTyper, load_account, sanitize_url logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -166,7 +166,7 @@ async def create( ssh_pubkey: str = ssh_pubkey_file.read_text(encoding="utf-8").strip() # Populates account / address - account: AlephAccount = load_account(private_key, private_key_file, chain=payment_chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=payment_chain) address = address or settings.ADDRESS_TO_USE or account.get_address() @@ -834,7 +834,7 @@ async def delete( setup_logging(debug) - account: AlephAccount = 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( @@ -946,7 +946,7 @@ async def list_instances( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file, chain=chain) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) address = address or settings.ADDRESS_TO_USE or account.get_address() async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -983,7 +983,7 @@ async def reboot( or Prompt.ask("URL of the CRN (Compute node) on which the VM is running") ) - account: AlephAccount = 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) @@ -1016,7 +1016,7 @@ async def allocate( or Prompt.ask("URL of the CRN (Compute node) on which the VM will be allocated") ) - account: AlephAccount = 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) @@ -1044,7 +1044,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: AlephAccount = 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: @@ -1075,7 +1075,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: AlephAccount = 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) @@ -1114,7 +1114,7 @@ async def confidential_init_session( or Prompt.ask("URL of the CRN (Compute node) on which the session will be initialized") ) - account: AlephAccount = 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() @@ -1191,7 +1191,7 @@ async def confidential_start( session_dir.mkdir(exist_ok=True, parents=True) vm_hash = ItemHash(vm_id) - account: AlephAccount = 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/port_forwarder.py b/src/aleph_client/commands/instance/port_forwarder.py index bbbb6518..23c84c9f 100644 --- a/src/aleph_client/commands/instance/port_forwarder.py +++ b/src/aleph_client/commands/instance/port_forwarder.py @@ -20,7 +20,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AlephAccount, AsyncTyper, load_account +from aleph_client.utils import AccountTypes, AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -41,7 +41,7 @@ async def list_ports( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + 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: @@ -159,7 +159,7 @@ async def create( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) # Create the port flags port_flags = PortFlags(tcp=tcp, udp=udp) @@ -212,7 +212,7 @@ async def update( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account: AlephAccount = load_account(private_key, private_key_file, 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: @@ -292,7 +292,7 @@ async def delete( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account: AlephAccount = load_account(private_key, private_key_file, 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: @@ -375,7 +375,7 @@ async def refresh( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file, 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 ec474015..da4a42eb 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -34,7 +34,7 @@ setup_logging, str_to_datetime, ) -from aleph_client.utils import AlephAccount, AsyncTyper, load_account +from aleph_client.utils import AccountTypes, AsyncTyper, load_account app = AsyncTyper(no_args_is_help=True) @@ -137,7 +137,7 @@ async def post( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) storage_engine: StorageEnum content: dict @@ -187,7 +187,7 @@ async def amend( setup_logging(debug) - account: AlephAccount = 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 @@ -252,7 +252,7 @@ async def forget( hash_list: list[ItemHash] = [ItemHash(h) for h in hashes.split(",")] - account: AlephAccount = 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) @@ -295,7 +295,7 @@ def sign( setup_logging(debug) - account: AlephAccount = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file) if message is None: message = input_multiline() From 2e9c044e2590d3682b8a8923ec0ddaf88d2461b3 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 7 Nov 2025 17:23:48 +0100 Subject: [PATCH 55/78] fix: account init commands unit test should be removed since not usefull anymore --- tests/unit/test_commands.py | 51 ------------------------------------- 1 file changed, 51 deletions(-) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 411af3da..30fd2bb7 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -202,30 +202,6 @@ def test_account_import_sol(env_files): assert new_key != old_key -def test_account_init(env_files): - """Test the new account init command.""" - settings.CONFIG_FILE = env_files[1] - - # First ensure the config directory exists but is empty - config_dir = env_files[1].parent - # Removing unused variable - - with ( - patch("aleph.sdk.conf.settings.CONFIG_FILE", env_files[1]), - patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), - patch("pathlib.Path.mkdir") as mock_mkdir, - ): - - result = runner.invoke(app, ["account", "init"]) - assert result.exit_code == 0 - assert "Configuration initialized." in result.stdout - assert "Run `aleph account create`" in result.stdout - assert "Run `aleph account config --account-type external`" in result.stdout - - # Check that directories were created - mock_mkdir.assert_any_call(parents=True, exist_ok=True) - - @patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") def test_account_address(mock_get_accounts, env_files): settings.CONFIG_FILE = env_files[1] @@ -256,33 +232,6 @@ def test_account_address(mock_get_accounts, env_files): assert result.stdout.startswith("✉ Addresses for Active Account (Ledger) ✉\n\nEVM: 0x") -def test_account_init_with_isolated_filesystem(): - """Test the new account init command that creates base configuration for new users.""" - # Set up a test directory for config - with runner.isolated_filesystem(): - config_dir = Path("test_config") - config_file = config_dir / "config.json" - - # Create the directory first - config_dir.mkdir(parents=True, exist_ok=True) - - with ( - patch("aleph.sdk.conf.settings.CONFIG_FILE", config_file), - patch("aleph.sdk.conf.settings.CONFIG_HOME", str(config_dir)), - ): - - result = runner.invoke(app, ["account", "init"]) - - # Verify command executed successfully - assert result.exit_code == 0 - assert "Configuration initialized." in result.stdout - assert "Run `aleph account create`" in result.stdout - assert "Run `aleph account config --account-type external`" in result.stdout - - # Verify the config file was created - assert config_file.exists() - - def test_account_chain(env_files): settings.CONFIG_FILE = env_files[1] result = runner.invoke(app, ["account", "chain"]) From a14a73e76e26ae2dd0547cefc86015e9585bf8c0 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 19:35:52 +0100 Subject: [PATCH 56/78] Feature: utils functions for ledger --- src/aleph_client/utils.py | 139 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 997c0fa2..53dcebdc 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,6 +17,7 @@ from zipfile import BadZipFile, ZipFile import aiohttp +import hid import typer from aiohttp import ClientSession from aleph.sdk.account import AccountTypes, _load_account @@ -26,12 +28,14 @@ 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 @@ -237,3 +241,138 @@ def load_account( 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) From 1b9a7edbeb391508e8f98494b76019c4b31bb9da Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 19:47:05 +0100 Subject: [PATCH 57/78] Fix: ensure ledger is connected before loading ledger account --- src/aleph_client/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 53dcebdc..08022756 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -234,6 +234,7 @@ def load_account( 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 From db0a9e8ab2d3c2b71c7ac74c4bc4baa3daea1803 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 19:59:17 +0100 Subject: [PATCH 58/78] Fix: avoid connecting to ledger when not needed --- src/aleph_client/commands/account.py | 46 ++++++++++++------- src/aleph_client/commands/aggregate.py | 46 +++++++++++++++---- src/aleph_client/commands/credit.py | 33 +++++++++---- src/aleph_client/commands/files.py | 17 +++++-- .../commands/instance/__init__.py | 14 +++++- src/aleph_client/commands/program.py | 23 ++++++++-- 6 files changed, 132 insertions(+), 47 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 65bfdb5d..85f0472e 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -155,23 +155,19 @@ def display_active_address( """ Display your public address(es). """ - # For regular accounts and Ledger accounts - evm_account = load_account(private_key, private_key_file, chain=Chain.ETH) - evm_address = evm_account.get_address() - # For Ledger accounts, the SOL address might not be available - try: - sol_address = load_account(private_key, private_key_file, chain=Chain.SOL).get_address() - except Exception: - sol_address = "Not available (using Ledger device)" - - # Detect if it's a Ledger account config_file_path = Path(settings.CONFIG_FILE) config = load_main_configuration(config_file_path) account_type = config.type if config else None - account_type_str = " (Ledger)" if account_type == AccountType.HARDWARE else "" + 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( 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" @@ -320,10 +316,17 @@ 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: @@ -405,12 +408,23 @@ async def list_accounts(): table.add_column("Active", no_wrap=True) active_chain = None - if config and config.path: + 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 - table.add_row(f"Ledger ({config.address[:8]}...)", "External (Ledger)", "[bold green]*[/bold green]") + + 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 " diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index bdcadc94..eeb4b104 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -8,8 +8,8 @@ import typer from aiohttp import ClientResponseError, ClientSession -from aleph.sdk.client import AuthenticatedAlephHttpClient -from aleph.sdk.conf import settings +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 @@ -192,10 +192,19 @@ async def get( setup_logging(debug) - account: AccountTypes = 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) @@ -227,9 +236,17 @@ async def list_aggregates( """Display all aggregates associated to an account""" setup_logging(debug) - - account: AccountTypes = 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: @@ -431,8 +448,17 @@ async def permissions( setup_logging(debug) - account: AccountTypes = 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 8bf4f56b..566a8186 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -5,8 +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.conf import AccountType, load_main_configuration, settings from aleph.sdk.utils import displayable_amount from rich import box from rich.console import Console @@ -16,7 +15,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AccountTypes, AsyncTyper +from aleph_client.utils import AsyncTyper, load_account logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -40,10 +39,17 @@ async def show( setup_logging(debug) - account: AccountTypes = _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: @@ -86,10 +92,17 @@ async def history( ): setup_logging(debug) - account: AccountTypes = _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/files.py b/src/aleph_client/commands/files.py index 8934d306..21ab9c20 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -10,7 +10,7 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.conf import settings +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 @@ -269,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: AccountTypes = 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 77f3641f..2a08c62b 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -946,8 +946,18 @@ async def list_instances( setup_logging(debug) - account: AccountTypes = 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) diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 5dedad1f..d6f556b7 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, @@ -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) @@ -504,8 +504,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( From bdfd6f248dbe90aec92d192c3ca523f18317fd75 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:02:09 +0100 Subject: [PATCH 59/78] Fix: use BaseEthAccount instead of EthAccount in instance create and prefetch crn list --- .../commands/instance/__init__.py | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index 2a08c62b..ef917256 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -11,12 +11,12 @@ import aiohttp import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -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, @@ -153,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) @@ -170,11 +175,6 @@ async def create( address = address or settings.ADDRESS_TO_USE or account.get_address() - # 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 - # Loads default configuration if no chain is set if payment_chain is None: config = load_main_configuration(settings.CONFIG_FILE) @@ -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 @@ -422,12 +425,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 @@ -479,11 +483,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, @@ -505,8 +504,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, @@ -640,7 +637,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)) @@ -691,7 +688,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() @@ -886,7 +883,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: From 96edd861aacae128f8842109d4a705d22426a971 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:03:01 +0100 Subject: [PATCH 60/78] fix: refactor aleph account configure and list to handle ledger --- src/aleph_client/commands/account.py | 204 +++++++++++++++++++-------- 1 file changed, 148 insertions(+), 56 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 85f0472e..1345c7f9 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -9,10 +9,10 @@ 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, @@ -43,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, load_account +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) @@ -330,7 +337,7 @@ async def balance( 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 = [ @@ -369,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] @@ -436,16 +443,25 @@ async def list_accounts(): if key_file.stem != "default": table.add_row(key_file.stem, str(key_file), "[bold red]-[/bold red]") - # Try to detect Ledger devices + 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): - is_active = config and config.type == AccountType.HARDWARE and config.address == ledger_acc.address + 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}", f"{ledger_acc.address}", status) + + table.add_row(f"Ledger #{idx}", ledger_acc.address, status) + except Exception: - logger.info("No ledger detected") + 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() @@ -480,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) @@ -533,16 +556,37 @@ async def configure( 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, + 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) # Ensure config file is created - private_keys_dir = Path(settings.CONFIG_HOME, "private-keys") # ensure private-keys folder created + 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) + + 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) + + config = MainConfiguration( + path=new_key, + chain=new_chain, + address=new_address, + type=new_type, + ) + 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}" + # Fixes private key file path if private_key_file: if not private_key_file.name.endswith(".key"): @@ -555,20 +599,43 @@ async def configure( typer.secho(f"Private key file not found: {private_key_file}", fg=typer.colors.RED) raise typer.Exit() - # If private_key_file is specified via command line, prioritize it - if private_key_file: - pass - elif not account_type or ( - account_type == AccountType.IMPORTED 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 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"Currents 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]") @@ -577,49 +644,74 @@ 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 - if not private_key_file and account_type == AccountType.HARDWARE: - if yes_no_input( - "[bright_cyan]Loading External keys.[/bright_cyan] [yellow]Do you want to import from Ledger?[/yellow]", - default="y", - ): + if account_type == AccountType.HARDWARE: + # If the current config is hardware, show its current address + if config.type == AccountType.HARDWARE: + 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() accounts = LedgerETHAccount.get_accounts() - account_addresses = [acc.address for acc in accounts] + addresses = [acc.address for acc in accounts] - console.print("[bold cyan]Available addresses on Ledger:[/bold cyan]") - for idx, account_address in enumerate(account_addresses, start=1): - console.print(f"[{idx}] {account_address}") + 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 a address by index") + key_choice = Prompt.ask("Choose an address by index") if key_choice.isdigit(): key_index = int(key_choice) - 1 - selected_address = account_addresses[key_index] - - if not selected_address: - typer.secho("No valid address selected.", fg=typer.colors.RED) + 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() - address = selected_address - account_type = AccountType.HARDWARE except LedgerError as e: - logger.warning(f"Ledger Error : {e.message}") + 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 - except OSError as err: - logger.warning("Please ensure Udev rules are set to use Ledger") - raise typer.Exit(code=1) from err else: - typer.secho("No private key file provided or found.", fg=typer.colors.RED) - raise typer.Exit() + address = config.address # If chain is specified via command line, prioritize it if chain: From 7170a85833c60263c63de862f722b58784c9d05b Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:04:07 +0100 Subject: [PATCH 61/78] Fix: call_program_crn_list can now filter node when fetching if they active or not --- src/aleph_client/commands/instance/network.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index 2c73925c..db3459a3 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: From 285723cdd84b549d6d3359eb418b0ed08b757353 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:04:19 +0100 Subject: [PATCH 62/78] Fix: unit test --- tests/unit/test_aggregate.py | 15 ++++++++--- tests/unit/test_commands.py | 50 ++++++++++++++++++++++-------------- tests/unit/test_credits.py | 35 +++---------------------- tests/unit/test_instance.py | 6 +++-- 4 files changed, 51 insertions(+), 55 deletions(-) diff --git a/tests/unit/test_aggregate.py b/tests/unit/test_aggregate.py index 97c75f06..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", @@ -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.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) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 30fd2bb7..47dc603e 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -336,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"] @@ -351,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( @@ -387,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" @@ -431,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" @@ -447,10 +441,15 @@ 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") @@ -474,13 +473,26 @@ def test_account_config_with_ledger(mock_get_accounts): 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)), ): - - result = runner.invoke(app, ["account", "config", "--account-type", "hardware", "--chain", "ETH"]) + # 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 - assert "New Default Configuration" in result.stdout - assert mock_account1.address in result.stdout 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 ae335171..3f5fbb5e 100644 --- a/tests/unit/test_instance.py +++ b/tests/unit/test_instance.py @@ -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,7 +1087,7 @@ 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") From 94c626a65b85ea9c338c58c632218810794bb1be Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:04:47 +0100 Subject: [PATCH 63/78] Feature: --no args for aleph account configure --- src/aleph_client/commands/utils.py | 100 ++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index b4174724..257e559e 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,92 @@ 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], +) -> 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. + `--account-type hardware --no` + `--account-type hardware --address 0xABC --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. Chain updates are always allowed. + `--chain ETH --no` + + 7. 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. + + Raises + ------ + typer.Exit + If an invalid argument combination is detected. + """ + + # 1. Hardware requires address + if account_type == AccountType.HARDWARE and not address: + typer.secho("--no mode: hardware accounts require --address.", 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) + + # 7. No arguments provided = no-op + if not any([private_key_file, chain, address, account_type]): + typer.secho("No changes provided. Keeping existing configuration.", fg=typer.colors.YELLOW) + raise typer.Exit(0) From e96afe1cd85dcbd5bc31c05b121bd8edd155bf33 Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:09:20 +0100 Subject: [PATCH 64/78] fix: linting issue --- src/aleph_client/commands/program.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index d6f556b7..935549d4 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -632,7 +632,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" @@ -642,7 +642,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() From bc68f0cac093e4f03cc27b2de41666da99b92dae Mon Sep 17 00:00:00 2001 From: 1yam Date: Wed, 19 Nov 2025 20:10:12 +0100 Subject: [PATCH 65/78] Feature: load acount unit test --- tests/unit/test_load_account.py | 171 ++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 tests/unit/test_load_account.py 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() From 3203b21fb4dc7a2b4fcdbb86c0c6bb04a286c632 Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 14:26:17 +0100 Subject: [PATCH 66/78] Feature: ledger can be load from derivation path --- src/aleph_client/commands/account.py | 96 ++++++++++++++++++++-------- src/aleph_client/commands/utils.py | 29 ++++++--- 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 1345c7f9..60f3832b 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -556,6 +556,9 @@ async def configure( 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)""" @@ -568,24 +571,23 @@ async def configure( unlinked_keys, config = await list_unlinked_keys() if no: - validate_non_interactive_args_config(config, account_type, private_key_file, address, chain) + 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, + 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: @@ -600,6 +602,8 @@ async def configure( raise typer.Exit() 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( @@ -616,7 +620,7 @@ async def configure( else: address = config.address - console.print(f"Currents address : {address}") + console.print(f"Current address: {address}") if account_type == AccountType.IMPORTED: # Determine if we need to ask about keeping or picking a key @@ -656,9 +660,29 @@ async def configure( 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 the current config is hardware, show its current address - if config.type == AccountType.HARDWARE: + 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 @@ -673,24 +697,35 @@ async def configure( # Wait for ledger being UP before continue anythings wait_for_ledger_connection() - 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] + 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 address index.", fg=typer.colors.RED) + typer.secho("Invalid input.", 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))}") @@ -739,10 +774,21 @@ async def configure( account_type = AccountType.IMPORTED try: - config = MainConfiguration(path=private_key_file, chain=chain, address=address, type=account_type) + 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 or config.address}" + 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, ) diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index 257e559e..52a63c1e 100644 --- a/src/aleph_client/commands/utils.py +++ b/src/aleph_client/commands/utils.py @@ -421,6 +421,7 @@ def validate_non_interactive_args_config( 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. @@ -431,9 +432,9 @@ def validate_non_interactive_args_config( Validation Rules ---------------- - 1. Hardware accounts require an address. - `--account-type hardware --no` + 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` @@ -448,10 +449,13 @@ def validate_non_interactive_args_config( 5. Addresses are invalid for imported accounts. Applies both when the *new* or *existing* account type is imported. - 6. Chain updates are always allowed. + 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` - 7. If no arguments are provided with `--no`, the command performs no changes + 8. If no arguments are provided with `--no`, the command performs no changes and simply keeps the existing configuration. Parameters @@ -466,6 +470,8 @@ def validate_non_interactive_args_config( 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 ------ @@ -473,9 +479,9 @@ def validate_non_interactive_args_config( If an invalid argument combination is detected. """ - # 1. Hardware requires address - if account_type == AccountType.HARDWARE and not address: - typer.secho("--no mode: hardware accounts require --address.", fg=typer.colors.RED) + # 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 @@ -498,7 +504,12 @@ def validate_non_interactive_args_config( typer.secho("Cannot use address for imported accounts.", fg=typer.colors.RED) raise typer.Exit(1) - # 7. No arguments provided = no-op - if not any([private_key_file, chain, address, account_type]): + # 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) From 4527f17f4d571ed22776fcb757c6097dab3a34db Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 16:00:00 +0100 Subject: [PATCH 67/78] Unit: test_aggregate.py for ledger --- tests/unit/test_aggregate.py | 52 ++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/unit/test_aggregate.py b/tests/unit/test_aggregate.py index 9f0791ba..b9c36ab9 100644 --- a/tests/unit/test_aggregate.py +++ b/tests/unit/test_aggregate.py @@ -6,6 +6,8 @@ import aiohttp import pytest +from aleph.sdk.conf import AccountType, MainConfiguration +from aleph_message.models import Chain from aleph_client.commands.aggregate import ( authorize, @@ -157,6 +159,33 @@ async def run_get(aggr_spec): assert aggregate == expected and expected == json.loads(captured.out) +@pytest.mark.asyncio +async def test_get_with_ledger(): + """Test get aggregate using a Ledger hardware wallet.""" + # Mock configuration for Ledger device + ledger_config = MainConfiguration( + path=None, + chain=Chain.ETH, + type=AccountType.HARDWARE, + address="0xdeadbeef1234567890123456789012345678beef", + ) + + mock_client_class, mock_client = create_mock_client(return_fetch=FAKE_AGGREGATE_DATA["AI"]) + + async def run_get_with_ledger(): + with patch("aleph_client.commands.aggregate.load_main_configuration", return_value=ledger_config): + with patch("aleph_client.commands.aggregate.AlephHttpClient", mock_client_class): + return await get(key="AI") + + # Call the function + aggregate = await run_get_with_ledger() + + # Verify result + assert aggregate == FAKE_AGGREGATE_DATA["AI"] + # Verify that fetch_aggregate was called with the correct ledger address + mock_client.fetch_aggregate.assert_called_with(address="0xdeadbeef1234567890123456789012345678beef", key="AI") + + @pytest.mark.asyncio async def test_list_aggregates(): mock_load_account = create_mock_load_account() @@ -172,6 +201,29 @@ async def run_list_aggregates(): assert aggregates == FAKE_AGGREGATE_DATA +@pytest.mark.asyncio +async def test_list_aggregates_with_ledger(): + """Test listing aggregates using a Ledger hardware wallet.""" + # Mock configuration for Ledger device + ledger_config = MainConfiguration( + path=None, + chain=Chain.ETH, + type=AccountType.HARDWARE, + address="0xdeadbeef1234567890123456789012345678beef", + ) + + async def run_list_aggregates_with_ledger(): + with patch("aleph_client.commands.aggregate.load_main_configuration", return_value=ledger_config): + with patch.object(aiohttp.ClientSession, "get", mock_client_session_get): + return await list_aggregates() + + # Call the function + aggregates = await run_list_aggregates_with_ledger() + + # Verify result + assert aggregates == FAKE_AGGREGATE_DATA + + @pytest.mark.asyncio async def test_authorize(capsys): mock_load_account = create_mock_load_account() From e3e84be9b331863982a75b4c9bbf1c3ce1d9cffc Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 16:37:23 +0100 Subject: [PATCH 68/78] Unit: new tests for utils func around ledger (wait_for_ledger_connection, ...) --- tests/unit/test_ledger_utils.py | 316 ++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 tests/unit/test_ledger_utils.py diff --git a/tests/unit/test_ledger_utils.py b/tests/unit/test_ledger_utils.py new file mode 100644 index 00000000..035233f5 --- /dev/null +++ b/tests/unit/test_ledger_utils.py @@ -0,0 +1,316 @@ +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 ( + get_first_ledger_name, + list_ledger_dongles, + load_account, + wait_for_ledger_connection, +) + +PATCH_LEDGER_ACCOUNTS = "aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts" +PATCH_HID_ENUM = "hid.enumerate" +PATCH_SLEEP = "time.sleep" +PATCH_WAIT_LEDGER = "aleph_client.utils.wait_for_ledger_connection" +PATCH_LOAD_CONFIG = "aleph_client.utils.load_main_configuration" +PATCH_LOAD_ACCOUNT_INTERNAL = "aleph_client.utils._load_account" + + +@pytest.fixture +def mock_get_accounts(): + with patch(PATCH_LEDGER_ACCOUNTS) as p: + yield p + + +@pytest.fixture +def mock_hid_enum(): + with patch(PATCH_HID_ENUM) as p: + yield p + + +@pytest.fixture +def no_sleep(): + with patch(PATCH_SLEEP): + yield + + +@pytest.fixture +def mock_ledger_config(): + """Create a mock ledger hardware wallet configuration.""" + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xdeadbeef1234567890123456789012345678beef", + type=AccountType.HARDWARE, + ) + + +@pytest.fixture +def mock_ledger_config_with_path(): + """Create a mock ledger hardware wallet configuration with derivation path.""" + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xdeadbeef1234567890123456789012345678beef", + type=AccountType.HARDWARE, + derivation_path="44'/60'/0'/0/0", + ) + + +@pytest.fixture +def mock_imported_config(): + """Create a mock imported wallet configuration.""" + return MainConfiguration( + path=Path("/home/user/.aleph/private-keys/test.key"), + chain=Chain.ETH, + address=None, + type=AccountType.IMPORTED, + ) + + +@pytest.fixture +def mock_ledger_accounts(): + """Create mock ledger accounts.""" + mock_account1 = MagicMock() + mock_account1.address = "0xdeadbeef1234567890123456789012345678beef" + mock_account1.get_address = MagicMock(return_value=mock_account1.address) + mock_account2 = MagicMock() + mock_account2.address = "0xcafebabe5678901234567890123456789012cafe" + mock_account2.get_address = MagicMock(return_value=mock_account2.address) + return [mock_account1, mock_account2] + + +def test_load_account_with_ledger(mock_get_accounts, mock_ledger_config, mock_ledger_accounts): + mock_get_accounts.return_value = mock_ledger_accounts + + with ( + patch(PATCH_LOAD_CONFIG, return_value=mock_ledger_config), + patch(PATCH_LOAD_ACCOUNT_INTERNAL, return_value=mock_ledger_accounts[0]), + patch(PATCH_WAIT_LEDGER), + ): + account = load_account(None, None) + + assert account.get_address() == mock_ledger_accounts[0].address + + +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_list_ledger_dongles(mock_get_accounts, mock_ledger_accounts): + """Test listing Ledger devices.""" + mock_get_accounts.return_value = mock_ledger_accounts + + with patch("hid.enumerate") as mock_enumerate: + # Set up mock HID devices + mock_enumerate.return_value = [ + { + "vendor_id": 0x2C97, + "product_id": 0x0001, + "path": b"usb-123", + "product_string": "Ledger Nano X", + "serial_number": "1234567890", + }, + { + "vendor_id": 0x2C97, + "product_id": 0x0001, + "path": b"usb-123:1.0", + "product_string": "Ledger Nano X", + "serial_number": "1234567890", + }, + { + "vendor_id": 0x2C97, + "product_id": 0x0002, + "path": b"usb-456", + "product_string": "Ledger Nano S", + "serial_number": "0987654321", + }, + { + "vendor_id": 0x1234, # Non-Ledger device + "product_id": 0x5678, + "path": b"usb-789", + "product_string": "Not a Ledger", + "serial_number": "11223344", + }, + ] + + # Test with unique_only=True (default) + dongles = list_ledger_dongles() + assert len(dongles) == 2 # Should filter out duplicates and non-Ledger devices + assert dongles[0]["product_string"] == "Ledger Nano X" + assert dongles[1]["product_string"] == "Ledger Nano S" + + # Test with unique_only=False + dongles = list_ledger_dongles(unique_only=False) + assert len(dongles) == 3 # Should include duplicates but not non-Ledger devices + + +@patch("aleph.sdk.wallets.ledger.ethereum.LedgerETHAccount.get_accounts") +def test_get_first_ledger_name(mock_get_accounts, mock_ledger_accounts): + """Test getting the name of the first connected Ledger device.""" + mock_get_accounts.return_value = mock_ledger_accounts + + with patch("aleph_client.utils.list_ledger_dongles") as mock_list_dongles: + # Test with a connected device + mock_list_dongles.return_value = [ + { + "path": b"usb-123", + "product_string": "Ledger Nano X", + } + ] + name = get_first_ledger_name() + assert name == "Ledger Nano X (usb-123)" + + # Test with no connected devices + mock_list_dongles.return_value = [] + name = get_first_ledger_name() + assert name == "No Ledger found" + + +def test_wait_for_ledger_already_connected(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Ledger already connected & have eth app open + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.return_value = ["0xabc"] + + wait_for_ledger_connection() + + mock_get_accounts.assert_called_once() + mock_hid_enum.assert_not_called() + + +def test_wait_for_ledger_device_appears(mock_get_accounts, mock_hid_enum, no_sleep): + """ + No device detected -> continue loop -> device appears -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + Exception("still no"), # first loop + ["0xabc"], # second loop -> success + ] + + mock_hid_enum.side_effect = [ + [], # first iteration -> no device + [{}], # second iteration -> device present + [{}], # third iteration (just in case) + ] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_locked_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Ledger locked -> LedgerError -> retry -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + LedgerError("locked"), # first loop + ["0xabc"], # next loop -> success + ] + + mock_hid_enum.return_value = [{"id": 1}] # device always present + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_comm_error_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Generic communication error -> retry -> success + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("top-level fail"), + Exception("comm error"), + ["0xabc"], + ] + + mock_hid_enum.return_value = [{"id": 1}] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 + + +def test_wait_for_ledger_oserror(mock_get_accounts, mock_hid_enum, no_sleep): + """ + OS error from hid.enumerate -> should exit via typer.Exit(1) + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = Exception("not ready") + mock_hid_enum.side_effect = OSError("permission denied") + + with pytest.raises(typer.Exit) as exc: + wait_for_ledger_connection() + + assert exc.value.exit_code == 1 + + +def test_wait_for_ledger_keyboard_interrupt(mock_get_accounts, mock_hid_enum, no_sleep): + """ + KeyboardInterrupt raised inside loop -> should exit via typer.Exit(1) + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = Exception("not ready") + mock_hid_enum.side_effect = KeyboardInterrupt + + with pytest.raises(typer.Exit) as exc: + wait_for_ledger_connection() + + assert exc.value.exit_code == 1 + + +def test_wait_for_ledger_locked_once_then_ready(mock_get_accounts, mock_hid_enum, no_sleep): + """ + Device present immediately, but first get_accounts raises LedgerError + (wrong app), then success next iteration + + + :param mock_get_accounts: + :param mock_hid_enum: + :param no_sleep: + :return: + """ + mock_get_accounts.side_effect = [ + Exception("not ready"), # top-level + LedgerError("locked"), # loop 1 + ["0xabc"], # loop 2 -> success + ] + + mock_hid_enum.return_value = [{"id": 1}] + + wait_for_ledger_connection() + + assert mock_get_accounts.call_count == 3 From d46623106914212cc93a2d66d294c8fc906fbed8 Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 16:49:12 +0100 Subject: [PATCH 69/78] Unit: new tests for non interactive account config --- tests/unit/test_utils.py | 177 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 996a390d..f599f45e 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,5 +1,11 @@ +from pathlib import Path + +import pytest +import typer +from aleph.sdk.conf import AccountType, MainConfiguration from aleph_message.models import ( AggregateMessage, + Chain, ForgetMessage, PostMessage, ProgramMessage, @@ -7,6 +13,7 @@ ) from aleph_message.models.base import MessageType +from aleph_client.commands.utils import validate_non_interactive_args_config from aleph_client.utils import get_message_type_value @@ -16,3 +23,173 @@ def test_get_message_type_value(): assert get_message_type_value(StoreMessage) == MessageType.store assert get_message_type_value(ProgramMessage) == MessageType.program assert get_message_type_value(ForgetMessage) == MessageType.forget + + +@pytest.fixture +def hardware_config(): + return MainConfiguration( + path=None, + chain=Chain.ETH, + address="0xHARDWARE", + type=AccountType.HARDWARE, + ) + + +@pytest.fixture +def imported_config(): + return MainConfiguration( + path=Path("/tmp/existing.key"), # noqa: S108 + chain=Chain.ETH, + address=None, + type=AccountType.IMPORTED, + ) + + +@pytest.mark.parametrize( + "kwargs,exit_code", + [ + # RULE 1: hardware requires address or derivation path + ( + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 2: imported requires private key + ( + { + "config": None, + "account_type": AccountType.IMPORTED, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 3: cannot specify address + private key + ( + { + "config": None, + "account_type": None, + "private_key_file": Path("fake.key"), + "address": "0x123", + "chain": None, + "derivation_path": None, + }, + 1, + ), + # RULE 8: no args - exit(0) + ( + { + "config": None, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + }, + 0, + ), + ], +) +def test_validate_non_interactive_negative_cases(kwargs, exit_code): + with pytest.raises(typer.Exit) as exc: + validate_non_interactive_args_config(**kwargs) + assert exc.value.exit_code == exit_code + + +@pytest.mark.parametrize( + "override_kwargs,exit_code", + [ + # RULE 4: private key invalid for hardware (existing HW config) + ({"private_key_file": Path("k.key")}, 1), + # RULE 5: address invalid for imported config + ({"address": "0x123"}, 1), + # RULE 6: derivation path invalid for imported config + ({"derivation_path": "44'/60'/0'/0/0"}, 1), + ], +) +def test_validate_non_interactive_invalid_with_existing_config( + override_kwargs, exit_code, hardware_config, imported_config +): + """ + This test runs twice: + - once with hardware_config + - once with imported_config + + And applies the override on top. + """ + + # HW-config cases: only RULE 4 applies + if override_kwargs.get("private_key_file"): + config = hardware_config + else: + config = imported_config + + base_kwargs = { + "config": config, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": None, + } + + kwargs = {**base_kwargs, **override_kwargs} + + with pytest.raises(typer.Exit) as exc: + validate_non_interactive_args_config(**kwargs) + + assert exc.value.exit_code == exit_code + + +@pytest.mark.parametrize( + "kwargs", + [ + # Hardware OK with address + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": "0x123", + "chain": None, + "derivation_path": None, + }, + # Hardware OK with derivation path + { + "config": None, + "account_type": AccountType.HARDWARE, + "private_key_file": None, + "address": None, + "chain": None, + "derivation_path": "44'/60'/0'/0/0", + }, + # Imported OK with private key + { + "config": None, + "account_type": AccountType.IMPORTED, + "private_key_file": Path("/tmp/key.key"), # noqa: S108 + "address": None, + "chain": None, + "derivation_path": None, + }, + # Chain updates always allowed + { + "config": None, + "account_type": None, + "private_key_file": None, + "address": None, + "chain": Chain.ETH, + "derivation_path": None, + }, + ], +) +def test_validate_non_interactive_valid_cases(kwargs): + """These should not raise.""" + validate_non_interactive_args_config(**kwargs) From 6802a2bd1444484abbf8150340b02121cf5d691f Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 17:37:50 +0100 Subject: [PATCH 70/78] fix: allow user to specify how many ledger account they want to load (default: 5) --- src/aleph_client/commands/account.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 60f3832b..26a89ad7 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -402,7 +402,11 @@ async def balance( @app.command(name="list") -async def list_accounts(): +async def list_accounts( + ledger_count: Annotated[ + Optional[int], typer.Option(help="Number of ledger account you want to get (default: 5)") + ] = 5, +): """Display available private keys, along with currenlty active chain and account (from config file).""" config_file_path = Path(settings.CONFIG_FILE) @@ -423,7 +427,7 @@ async def list_accounts(): ledger_connected = False try: - ledger_accounts = LedgerETHAccount.get_accounts() + ledger_accounts = LedgerETHAccount.get_accounts(count=ledger_count) if ledger_accounts: ledger_connected = True except Exception: @@ -448,7 +452,7 @@ async def list_accounts(): active_ledger_address = config.address.lower() try: - ledger_accounts = LedgerETHAccount.get_accounts() + ledger_accounts = LedgerETHAccount.get_accounts(count=ledger_count) if ledger_accounts: for idx, ledger_acc in enumerate(ledger_accounts): if not ledger_acc.address: From 0ede7063258fc7f22dcc209365bdc7e91b2f7250 Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 17:38:21 +0100 Subject: [PATCH 71/78] fix: use the already existing args chain to load_account --- src/aleph_client/commands/instance/port_forwarder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/commands/instance/port_forwarder.py b/src/aleph_client/commands/instance/port_forwarder.py index 23c84c9f..cceb266b 100644 --- a/src/aleph_client/commands/instance/port_forwarder.py +++ b/src/aleph_client/commands/instance/port_forwarder.py @@ -41,7 +41,7 @@ async def list_ports( setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file, chain=chain) address = address or settings.ADDRESS_TO_USE or account.get_address() async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -159,7 +159,7 @@ async def create( typer.echo("Error: Port must be between 1 and 65535") raise typer.Exit(code=1) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key, private_key_file, chain) # Create the port flags port_flags = PortFlags(tcp=tcp, udp=udp) From 1588839bd8a1be7ef1a2aa94590a03e7d06366b4 Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 17:53:25 +0100 Subject: [PATCH 72/78] fix: lint issue --- src/aleph_client/commands/account.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 26a89ad7..ebb25ea2 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -403,9 +403,7 @@ async def balance( @app.command(name="list") async def list_accounts( - ledger_count: Annotated[ - Optional[int], typer.Option(help="Number of ledger account you want to get (default: 5)") - ] = 5, + ledger_count: Annotated[int, typer.Option(help="Number of ledger account you want to get (default: 5)")] = 5, ): """Display available private keys, along with currenlty active chain and account (from config file).""" From d608925c8f7dc0d6d8cff9eaae67f317d637542b Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 17:55:13 +0100 Subject: [PATCH 73/78] Refactor: remove code duplication --- src/aleph_client/commands/account.py | 25 ++-------- src/aleph_client/commands/aggregate.py | 46 +++++-------------- src/aleph_client/commands/credit.py | 28 ++--------- src/aleph_client/commands/files.py | 21 ++++----- .../commands/instance/__init__.py | 23 ++++------ src/aleph_client/commands/program.py | 22 ++++----- src/aleph_client/utils.py | 39 ++++++++++++++++ 7 files changed, 82 insertions(+), 122 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index ebb25ea2..aa6d72be 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -49,6 +49,7 @@ ) from aleph_client.utils import ( AsyncTyper, + get_account_and_address, get_first_ledger_name, list_unlinked_keys, load_account, @@ -323,17 +324,7 @@ async def balance( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display your ALEPH balance and basic voucher information.""" - 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, chain=chain) - if account and not address: - address = account.get_address() - elif not address and config and config.address: - address = config.address + account, address = get_account_and_address(private_key, private_key_file, address, chain) if address: try: @@ -498,17 +489,7 @@ async def vouchers( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display detailed information about your vouchers.""" - 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, chain=chain) - if account and not address: - address = account.get_address() - elif not address and config and config.address: - address = config.address + account, address = get_account_and_address(private_key, private_key_file, address, chain) if address: try: diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index eeb4b104..a16bbd10 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -9,7 +9,7 @@ import typer from aiohttp import ClientResponseError, ClientSession from aleph.sdk.client import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.conf import AccountType, load_main_configuration, settings +from aleph.sdk.conf import settings from aleph.sdk.utils import extended_json_encoder from aleph_message.models import Chain, MessageType from aleph_message.status import MessageStatus @@ -19,7 +19,13 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AccountTypes, AsyncTyper, load_account, sanitize_url +from aleph_client.utils import ( + AccountTypes, + AsyncTyper, + get_account_and_address, + load_account, + sanitize_url, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -192,17 +198,7 @@ async def get( setup_logging(debug) - 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 + _, address = get_account_and_address(private_key, private_key_file, address) async with AlephHttpClient(api_server=settings.API_HOST) as client: aggregates = None @@ -236,17 +232,7 @@ async def list_aggregates( """Display all aggregates associated to an account""" setup_logging(debug) - 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 + _, address = get_account_and_address(private_key, private_key_file, address) aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" async with ClientSession() as session: @@ -448,17 +434,7 @@ async def permissions( setup_logging(debug) - 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 + _, address = get_account_and_address(private_key, private_key_file, address) data = await get( key="security", diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 566a8186..4744855f 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -5,7 +5,7 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient -from aleph.sdk.conf import AccountType, load_main_configuration, settings +from aleph.sdk.conf import settings from aleph.sdk.utils import displayable_amount from rich import box from rich.console import Console @@ -15,7 +15,7 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AsyncTyper, load_account +from aleph_client.utils import AsyncTyper, get_account_and_address logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -39,17 +39,7 @@ async def show( setup_logging(debug) - 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 + _, address = get_account_and_address(private_key, private_key_file, address) if address: async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -92,17 +82,7 @@ async def history( ): setup_logging(debug) - 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 + _, address = get_account_and_address(private_key, private_key_file, address) try: # Comment the original API call for testing diff --git a/src/aleph_client/commands/files.py b/src/aleph_client/commands/files.py index 21ab9c20..c3538ed7 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -10,7 +10,7 @@ import typer from aiohttp import ClientResponseError from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient -from aleph.sdk.conf import AccountType, load_main_configuration, settings +from aleph.sdk.conf import settings from aleph.sdk.types import StorageEnum, StoredContent from aleph.sdk.utils import safe_getattr from aleph_message.models import ItemHash, StoreMessage @@ -22,7 +22,12 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging -from aleph_client.utils import AccountTypes, AsyncTyper, load_account +from aleph_client.utils import ( + AccountTypes, + AsyncTyper, + get_account_and_address, + load_account, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -269,17 +274,7 @@ 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""" - 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 + account, address = get_account_and_address(private_key, private_key_file, 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 ef917256..fe725cf4 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -16,7 +16,7 @@ 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 AccountType, load_main_configuration, settings +from aleph.sdk.conf import load_main_configuration, settings from aleph.sdk.evm_utils import ( FlowUpdate, get_chains_with_holding, @@ -81,7 +81,13 @@ yes_no_input, ) from aleph_client.models import CRNInfo -from aleph_client.utils import AccountTypes, AsyncTyper, load_account, sanitize_url +from aleph_client.utils import ( + AccountTypes, + AsyncTyper, + get_account_and_address, + load_account, + sanitize_url, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -948,18 +954,7 @@ async def list_instances( setup_logging(debug) - # 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 + account, address = get_account_and_address(private_key, private_key_file, address, chain) async with AlephHttpClient(api_server=settings.API_HOST) as client: instances: list[InstanceMessage] = await client.instance.get_instances(address=address) diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 935549d4..c4d32f1a 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 AccountType, load_main_configuration, settings +from aleph.sdk.conf import load_main_configuration, settings from aleph.sdk.evm_utils import get_chains_with_holding from aleph.sdk.exceptions import ( ForgottenMessageError, @@ -58,7 +58,12 @@ validated_prompt, yes_no_input, ) -from aleph_client.utils import AsyncTyper, create_archive, load_account, sanitize_url +from aleph_client.utils import ( + AsyncTyper, + create_archive, + get_account_and_address, + sanitize_url, +) logger = logging.getLogger(__name__) app = AsyncTyper(no_args_is_help=True) @@ -504,18 +509,7 @@ async def list_programs( setup_logging(debug) - # 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 + account, address = get_account_and_address(private_key, private_key_file, address) # Ensure we have an address to query if not address: diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index 08022756..791489d3 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -309,6 +309,45 @@ def get_first_ledger_name() -> str: return get_ledger_name(devices[0]) +def get_account_and_address( + private_key: Optional[str], + private_key_file: Optional[Path], + address: Optional[str] = None, + chain: Optional[Chain] = None, +) -> tuple[Optional[AccountTypes], Optional[str]]: + """ + Gets the account and address based on configuration and provided parameters. + + This utility function handles the common pattern of loading an account and address + from either a configuration file or private key/file, avoiding ledger connections + when not needed. + + Args: + private_key: Optional private key string + private_key_file: Optional private key file path + address: Optional address (will be returned if provided) + chain: Optional chain for account loading + + Returns: + A tuple of (account, address) where either or both may be None + """ + config_file_path = Path(settings.CONFIG_FILE) + config = load_main_configuration(config_file_path) + account_type = config.type if config else None + + account = None + + # 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 + + return account, address + + def wait_for_ledger_connection(poll_interval: float = 1.0) -> None: """ Wait until a Ledger device is connected and ready. From a5a83b6ccc11d8fc6a8fa8a2f8ecb9e88af8ec5c Mon Sep 17 00:00:00 2001 From: 1yam Date: Thu, 20 Nov 2025 18:09:01 +0100 Subject: [PATCH 74/78] Fix: aggregate unit test --- tests/unit/test_aggregate.py | 46 ++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/tests/unit/test_aggregate.py b/tests/unit/test_aggregate.py index b9c36ab9..05bf2efb 100644 --- a/tests/unit/test_aggregate.py +++ b/tests/unit/test_aggregate.py @@ -6,8 +6,6 @@ import aiohttp import pytest -from aleph.sdk.conf import AccountType, MainConfiguration -from aleph_message.models import Chain from aleph_client.commands.aggregate import ( authorize, @@ -146,14 +144,16 @@ async def test_get(capsys, args, expected): mock_load_account = create_mock_load_account() 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.get_account_and_address", + return_value=(mock_load_account.return_value, "test_address"), + ) @patch("aleph_client.commands.aggregate.AlephHttpClient", mock_auth_class) - async def run_get(aggr_spec): + async def run_get(aggr_spec, mock_get_account): 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__client.fetch_aggregate.assert_called_once() captured = capsys.readouterr() assert aggregate == expected and expected == json.loads(captured.out) @@ -163,17 +163,12 @@ async def run_get(aggr_spec): async def test_get_with_ledger(): """Test get aggregate using a Ledger hardware wallet.""" # Mock configuration for Ledger device - ledger_config = MainConfiguration( - path=None, - chain=Chain.ETH, - type=AccountType.HARDWARE, - address="0xdeadbeef1234567890123456789012345678beef", - ) + ledger_address = "0xdeadbeef1234567890123456789012345678beef" mock_client_class, mock_client = create_mock_client(return_fetch=FAKE_AGGREGATE_DATA["AI"]) async def run_get_with_ledger(): - with patch("aleph_client.commands.aggregate.load_main_configuration", return_value=ledger_config): + with patch("aleph_client.commands.aggregate.get_account_and_address", return_value=(None, ledger_address)): with patch("aleph_client.commands.aggregate.AlephHttpClient", mock_client_class): return await get(key="AI") @@ -183,21 +178,23 @@ async def run_get_with_ledger(): # Verify result assert aggregate == FAKE_AGGREGATE_DATA["AI"] # Verify that fetch_aggregate was called with the correct ledger address - mock_client.fetch_aggregate.assert_called_with(address="0xdeadbeef1234567890123456789012345678beef", key="AI") + mock_client.fetch_aggregate.assert_called_with(address=ledger_address, key="AI") @pytest.mark.asyncio 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.get_account_and_address", + return_value=(mock_load_account.return_value, FAKE_ADDRESS_EVM), + ) @patch.object(aiohttp.ClientSession, "get", mock_client_session_get) - async def run_list_aggregates(): + async def run_list_aggregates(mock_get_account): print() # For better display when pytest -v -s return await list_aggregates(address=FAKE_ADDRESS_EVM) aggregates = await run_list_aggregates() - mock_load_account.assert_called_once() assert aggregates == FAKE_AGGREGATE_DATA @@ -205,15 +202,10 @@ async def run_list_aggregates(): async def test_list_aggregates_with_ledger(): """Test listing aggregates using a Ledger hardware wallet.""" # Mock configuration for Ledger device - ledger_config = MainConfiguration( - path=None, - chain=Chain.ETH, - type=AccountType.HARDWARE, - address="0xdeadbeef1234567890123456789012345678beef", - ) + ledger_address = "0xdeadbeef1234567890123456789012345678beef" async def run_list_aggregates_with_ledger(): - with patch("aleph_client.commands.aggregate.load_main_configuration", return_value=ledger_config): + with patch("aleph_client.commands.aggregate.get_account_and_address", return_value=(None, ledger_address)): with patch.object(aiohttp.ClientSession, "get", mock_client_session_get): return await list_aggregates() @@ -271,13 +263,15 @@ 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.get_account_and_address", + return_value=(mock_load_account.return_value, FAKE_ADDRESS_EVM), + ) @patch("aleph_client.commands.aggregate.get", mock_get) - async def run_permissions(): + async def run_permissions(mock_get_account): print() # For better display when pytest -v -s return await permissions(address=FAKE_ADDRESS_EVM, json=True) authorizations = await run_permissions() - mock_load_account.assert_called_once() mock_get.assert_called_once() assert authorizations == FAKE_AGGREGATE_DATA["security"]["authorizations"] # type: ignore From 225474d73773c8363f9d49deae39a75196900477 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 21 Nov 2025 14:35:07 +0100 Subject: [PATCH 75/78] Fix: non-evm account couldn't be used using --private-key or --private-key-file --- src/aleph_client/commands/account.py | 10 +++++++--- src/aleph_client/commands/aggregate.py | 26 +++++++++++++++++++------- src/aleph_client/commands/credit.py | 11 +++++++++-- src/aleph_client/commands/domain.py | 14 +++++++++----- src/aleph_client/commands/files.py | 16 +++++++++++----- src/aleph_client/commands/message.py | 14 +++++++++----- src/aleph_client/commands/program.py | 4 +++- 7 files changed, 67 insertions(+), 28 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index aa6d72be..88edf716 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -324,7 +324,9 @@ async def balance( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display your ALEPH balance and basic voucher information.""" - account, address = get_account_and_address(private_key, private_key_file, address, chain) + account, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) if address: try: @@ -489,7 +491,9 @@ async def vouchers( chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display detailed information about your vouchers.""" - account, address = get_account_and_address(private_key, private_key_file, address, chain) + account, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) if address: try: @@ -598,7 +602,7 @@ async def configure( 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) + current_account = _load_account(private_key_str=None, private_key_path=current_key, chain=chain) address = current_account.get_address() else: address = config.address diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index a16bbd10..98d30c9f 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -55,6 +55,7 @@ async def forget( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, print_message: bool = False, verbose: bool = True, debug: bool = False, @@ -63,7 +64,7 @@ async def forget( setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) address = account.get_address() if address is None else address if key == "security" and not is_same_context(): @@ -128,6 +129,7 @@ async def post( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, print_message: bool = False, verbose: bool = True, debug: bool = False, @@ -136,7 +138,7 @@ async def post( setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) address = account.get_address() if address is None else address if key == "security" and not is_same_context(): @@ -191,6 +193,7 @@ async def get( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, verbose: bool = True, debug: bool = False, ) -> Optional[dict]: @@ -198,7 +201,9 @@ async def get( setup_logging(debug) - _, address = get_account_and_address(private_key, private_key_file, address) + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) async with AlephHttpClient(api_server=settings.API_HOST) as client: aggregates = None @@ -225,6 +230,7 @@ async def list_aggregates( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, json: Annotated[bool, typer.Option(help="Print as json instead of rich table")] = False, verbose: bool = True, debug: bool = False, @@ -232,7 +238,9 @@ async def list_aggregates( """Display all aggregates associated to an account""" setup_logging(debug) - _, address = get_account_and_address(private_key, private_key_file, address) + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) aggr_link = f"{sanitize_url(settings.API_HOST)}/api/v0/aggregates/{address}.json" async with ClientSession() as session: @@ -305,7 +313,7 @@ async def authorize( setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) data = await get( key="security", @@ -371,6 +379,7 @@ async def revoke( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, print_message: bool = False, verbose: bool = True, debug: bool = False, @@ -379,7 +388,7 @@ async def revoke( setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_file=private_key, private_key_str=private_key_file, chain=chain) data = await get( key="security", @@ -426,6 +435,7 @@ async def permissions( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, json: Annotated[bool, typer.Option(help="Print as json instead of rich table")] = False, verbose: bool = True, debug: bool = False, @@ -434,7 +444,9 @@ async def permissions( setup_logging(debug) - _, address = get_account_and_address(private_key, private_key_file, address) + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) data = await get( key="security", diff --git a/src/aleph_client/commands/credit.py b/src/aleph_client/commands/credit.py index 4744855f..ab3e9f48 100644 --- a/src/aleph_client/commands/credit.py +++ b/src/aleph_client/commands/credit.py @@ -7,6 +7,7 @@ from aleph.sdk import AlephHttpClient from aleph.sdk.conf import settings from aleph.sdk.utils import displayable_amount +from aleph_message.models import Chain from rich import box from rich.console import Console from rich.panel import Panel @@ -32,6 +33,7 @@ async def show( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, json: Annotated[bool, typer.Option(help="Display as json")] = False, debug: Annotated[bool, typer.Option()] = False, ): @@ -39,7 +41,9 @@ async def show( setup_logging(debug) - _, address = get_account_and_address(private_key, private_key_file, address) + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=address + ) if address: async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -75,6 +79,7 @@ async def history( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, page_size: Annotated[int, typer.Option(help="Numbers of element per page")] = 100, page: Annotated[int, typer.Option(help="Current Page")] = 1, json: Annotated[bool, typer.Option(help="Display as json")] = False, @@ -82,7 +87,9 @@ async def history( ): setup_logging(debug) - _, address = get_account_and_address(private_key, private_key_file, address) + _, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, chain=chain, address=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 85713a39..1cf12cfc 100644 --- a/src/aleph_client/commands/domain.py +++ b/src/aleph_client/commands/domain.py @@ -17,7 +17,7 @@ ) from aleph.sdk.exceptions import DomainConfigurationError from aleph.sdk.query.filters import MessageFilter -from aleph_message.models import AggregateMessage +from aleph_message.models import AggregateMessage, Chain from aleph_message.models.base import MessageType from rich.console import Console from rich.prompt import Confirm, Prompt @@ -183,9 +183,10 @@ async def add( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Add and link a Custom Domain.""" - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) interactive = False if (not ask) else is_environment_interactive() console = Console() @@ -268,9 +269,10 @@ async def attach( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Attach resource to a Custom Domain.""" - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) await attach_resource( account, @@ -290,9 +292,10 @@ async def detach( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Unlink Custom Domain.""" - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) await detach_resource(account, Hostname(fqdn), interactive=False if (not ask) else None) raise typer.Exit() @@ -305,9 +308,10 @@ async def info( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Show Custom Domain Details.""" - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) console = Console() domain_validator = DomainValidator() diff --git a/src/aleph_client/commands/files.py b/src/aleph_client/commands/files.py index c3538ed7..581b74a2 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -13,7 +13,7 @@ from aleph.sdk.conf import 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.models import Chain, ItemHash, StoreMessage from aleph_message.status import MessageStatus from pydantic import BaseModel, Field from rich import box @@ -41,6 +41,7 @@ async def pin( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ref: Annotated[Optional[str], typer.Option(help=help_strings.REF)] = None, debug: Annotated[bool, typer.Option()] = False, ): @@ -48,7 +49,7 @@ async def pin( setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: result: StoreMessage @@ -72,6 +73,7 @@ async def upload( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ref: Annotated[Optional[str], typer.Option(help=help_strings.REF)] = None, debug: Annotated[bool, typer.Option()] = False, ): @@ -79,7 +81,7 @@ async def upload( setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: if not path.is_file(): @@ -179,13 +181,14 @@ async def forget( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): """Forget a file and his message on Aleph Cloud.""" setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) hashes = [ItemHash(item_hash) for item_hash in item_hash.split(",")] @@ -262,6 +265,7 @@ async def list_files( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, pagination: Annotated[int, typer.Option(help="Maximum number of files to return.")] = 100, page: Annotated[int, typer.Option(help="Offset in pages.")] = 1, sort_order: Annotated[ @@ -274,7 +278,9 @@ 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, address = get_account_and_address(private_key, private_key_file, address) + account, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, address=address, chain=chain + ) if address: # Build the query parameters diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index da4a42eb..326c6d84 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -21,7 +21,7 @@ from aleph.sdk.query.responses import MessagesResponse 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 import AlephMessage, Chain, ProgramMessage from aleph_message.models.base import MessageType from aleph_message.models.item_hash import ItemHash from aleph_message.status import MessageStatus @@ -131,13 +131,14 @@ async def post( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): """Post a message on aleph.cloud.""" setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) storage_engine: StorageEnum content: dict @@ -181,13 +182,14 @@ async def amend( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): """Amend an existing aleph.cloud message.""" setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) async with AlephHttpClient(api_server=settings.API_HOST) as client: existing_message: Optional[AlephMessage] = None @@ -244,6 +246,7 @@ async def forget( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): """Forget an existing aleph.cloud message.""" @@ -252,7 +255,7 @@ async def forget( hash_list: list[ItemHash] = [ItemHash(h) for h in hashes.split(",")] - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) async with AuthenticatedAlephHttpClient(account=account, api_server=settings.API_HOST) as client: await client.forget(hashes=hash_list, reason=reason, channel=channel) @@ -289,13 +292,14 @@ def sign( private_key_file: Annotated[ Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) ] = settings.PRIVATE_KEY_FILE, + chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): """Sign an aleph message with a private key. If no --message is provided, the message will be read from stdin.""" setup_logging(debug) - account: AccountTypes = load_account(private_key, private_key_file) + account: AccountTypes = load_account(private_key_str=private_key, private_key_file=private_key_file, chain=chain) if message is None: message = input_multiline() diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index c4d32f1a..21db2324 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -509,7 +509,9 @@ async def list_programs( setup_logging(debug) - account, address = get_account_and_address(private_key, private_key_file, address) + account, address = get_account_and_address( + private_key=private_key, private_key_file=private_key_file, address=address, chain=chain + ) # Ensure we have an address to query if not address: From fbb442a296a77d552d0a0fe4fb11edce5d85694c Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 21 Nov 2025 14:53:15 +0100 Subject: [PATCH 76/78] fix: rename --no of configure commands to --non-it --- src/aleph_client/commands/account.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 88edf716..c72f371f 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -546,7 +546,7 @@ async def configure( 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, + non_it: Annotated[bool, typer.Option("--non-it", help="Non-interactive mode. Only apply provided options.")] = False, ): """Configure current private key file and active chain (default selection)""" @@ -557,7 +557,7 @@ async def configure( unlinked_keys, config = await list_unlinked_keys() - if no: + if non_it: validate_non_interactive_args_config(config, account_type, private_key_file, address, chain, derivation_path) new_chain = chain or config.chain From 21caad60de88451b5611c13e8ff98564a213a152 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 21 Nov 2025 15:02:12 +0100 Subject: [PATCH 77/78] fix: non interactive args for aleph account config is --non-it not --no --- src/aleph_client/commands/account.py | 4 +++- tests/unit/test_commands.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index c72f371f..c5047bf2 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -546,7 +546,9 @@ async def configure( derivation_path: Annotated[ Optional[str], typer.Option(help="Derivation path for ledger (e.g. \"44'/60'/0'/0/0\")") ] = None, - non_it: Annotated[bool, typer.Option("--non-it", help="Non-interactive mode. Only apply provided options.")] = False, + non_it: Annotated[ + bool, typer.Option("--non-it", help="Non-interactive mode. Only apply provided options.") + ] = False, ): """Configure current private key file and active chain (default selection)""" diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index 47dc603e..58fd560b 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -447,7 +447,7 @@ 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", "--no"] + app, ["account", "config", "--private-key-file", str(env_files[0]), "--chain", "ETH", "--non-it"] ) assert result.exit_code == 0 @@ -488,7 +488,7 @@ def test_account_config_with_ledger(mock_get_accounts): "ETH", "--address", "0xdeadbeef1234567890123456789012345678beef", - "--no", + "--non-it", ], ) From 5686f45bb35f049877bfb3d73d87b0b4153da468 Mon Sep 17 00:00:00 2001 From: 1yam Date: Fri, 21 Nov 2025 16:23:30 +0100 Subject: [PATCH 78/78] Fix: allow user to increase number of ledger account fetch using aleph account configure --ledger-count --- src/aleph_client/commands/account.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index c5047bf2..0d5f7cfb 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -546,6 +546,7 @@ async def configure( derivation_path: Annotated[ Optional[str], typer.Option(help="Derivation path for ledger (e.g. \"44'/60'/0'/0/0\")") ] = None, + ledger_count: Annotated[int, typer.Option(help="Number of ledger account you want to fetch (default: 5)")] = 5, non_it: Annotated[ bool, typer.Option("--non-it", help="Non-interactive mode. Only apply provided options.") ] = False, @@ -697,7 +698,7 @@ async def configure( raise typer.Exit(code=1) from e else: # Normal flow - show available accounts and let user choose - accounts = LedgerETHAccount.get_accounts() + accounts = LedgerETHAccount.get_accounts(count=ledger_count) addresses = [acc.address for acc in accounts] console.print(f"[bold cyan]Available addresses on {get_first_ledger_name()}:[/bold cyan]")