From f82c6b30df79d11fe20ff2c7421ea41069158d1a Mon Sep 17 00:00:00 2001 From: Hugo Herter Date: Tue, 4 Mar 2025 12:12:44 +0100 Subject: [PATCH] WIP: Fix: Command was too slow to launch Solution: Use lazy imports to reduce import time Typer becomes the main dependency not lazy loaded. This makes the code more complex, but in my observation the CLI could launch in 0.4 seconds instead of almost 2 seconds. --- src/aleph_client/__init__.py | 8 -- src/aleph_client/__main__.py | 2 +- src/aleph_client/commands/about.py | 4 +- src/aleph_client/commands/account.py | 119 ++++++++++++------ src/aleph_client/commands/aggregate.py | 18 ++- src/aleph_client/commands/files.py | 5 +- .../commands/instance/__init__.py | 23 ++-- src/aleph_client/commands/instance/display.py | 19 ++- src/aleph_client/commands/instance/network.py | 18 +-- src/aleph_client/commands/message.py | 29 +++-- src/aleph_client/commands/node.py | 5 +- src/aleph_client/commands/pricing.py | 5 +- src/aleph_client/commands/program.py | 8 +- src/aleph_client/commands/utils.py | 11 +- src/aleph_client/models.py | 3 +- src/aleph_client/utils.py | 49 +++++--- tests/unit/test_init.py | 5 - 17 files changed, 213 insertions(+), 118 deletions(-) delete mode 100644 tests/unit/test_init.py diff --git a/src/aleph_client/__init__.py b/src/aleph_client/__init__.py index a5ab5dd2..f37c29b0 100644 --- a/src/aleph_client/__init__.py +++ b/src/aleph_client/__init__.py @@ -1,11 +1,3 @@ -from importlib.metadata import PackageNotFoundError, version - -try: - # Change here if project is renamed and does not equal the package name - __version__ = version("aleph-client") -except PackageNotFoundError: - __version__ = "unknown" - # Deprecation check moved_types = ["__version__", "AlephClient", "AuthenticatedAlephClient", "synchronous", "asynchronous"] diff --git a/src/aleph_client/__main__.py b/src/aleph_client/__main__.py index c86b1f15..a55a805e 100644 --- a/src/aleph_client/__main__.py +++ b/src/aleph_client/__main__.py @@ -16,7 +16,7 @@ ) from aleph_client.utils import AsyncTyper -app = AsyncTyper(no_args_is_help=True) +app = AsyncTyper(no_args_is_help=True, pretty_exceptions_enable=False) app.add_typer(account.app, name="account", help="Manage accounts") app.add_typer( diff --git a/src/aleph_client/commands/about.py b/src/aleph_client/commands/about.py index 942312f3..734a2196 100644 --- a/src/aleph_client/commands/about.py +++ b/src/aleph_client/commands/about.py @@ -1,7 +1,5 @@ from __future__ import annotations -from importlib.metadata import version as importlib_version - import typer from aleph_client.utils import AsyncTyper @@ -10,6 +8,8 @@ def get_version(value: bool): + from importlib.metadata import version as importlib_version + __version__ = "NaN" dist_name = "aleph-client" if value: diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 85c20657..2eec9e06 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -1,28 +1,10 @@ from __future__ import annotations -import asyncio import logging from pathlib import Path from typing import Annotated, Optional -import aiohttp import typer -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.conf import ( - MainConfiguration, - load_main_configuration, - save_main_configuration, - settings, -) -from aleph.sdk.evm_utils import ( - get_chains_with_holding, - get_chains_with_super_token, - get_compatible_chains, -) -from aleph.sdk.utils import bytes_from_hex, displayable_amount -from aleph_message.models import Chain from rich.console import Console from rich.panel import Panel from rich.prompt import Prompt @@ -30,13 +12,8 @@ from rich.text import Text from typer.colors import GREEN, RED +from aleph_message.models.base import Chain from aleph_client.commands import help_strings -from aleph_client.commands.utils import ( - input_multiline, - setup_logging, - validated_prompt, - yes_no_input, -) from aleph_client.utils import AsyncTyper, list_unlinked_keys logger = logging.getLogger(__name__) @@ -54,6 +31,10 @@ async def create( debug: Annotated[bool, typer.Option()] = False, ): """Create or import a private key.""" + from aleph.sdk.conf import MainConfiguration, save_main_configuration, settings + from aleph_message.models import Chain + + from aleph_client.commands.utils import setup_logging, validated_prompt setup_logging(debug) @@ -82,10 +63,18 @@ async def create( ) ) if chain == Chain.SOL: + from aleph.sdk.chains.solana import ( + parse_private_key as parse_solana_private_key, + ) + private_key_bytes = parse_solana_private_key(private_key) else: + from aleph.sdk.utils import bytes_from_hex + private_key_bytes = bytes_from_hex(private_key) else: + from aleph.sdk.chains.common import generate_key + private_key_bytes = generate_key() if not private_key_bytes: typer.secho("An unexpected error occurred!", fg=RED) @@ -120,14 +109,17 @@ async def create( @app.command(name="address") def display_active_address( - private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, - private_key_file: Annotated[ - Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) - ] = settings.PRIVATE_KEY_FILE, + private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, + private_key_file: Annotated[Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)] = None, ): """ Display your public address(es). """ + from aleph.sdk.conf import settings + from aleph_message.models import Chain + + private_key = private_key or settings.PRIVATE_KEY_STRING + private_key_file = private_key_file or settings.PRIVATE_KEY_FILE if private_key is not None: private_key_file = None @@ -135,6 +127,8 @@ def display_active_address( typer.secho("No private key available", fg=RED) raise typer.Exit(code=1) + from aleph.sdk.account import _load_account + 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() @@ -150,6 +144,13 @@ def display_active_chain(): """ Display the currently active chain. """ + from aleph.sdk.conf import load_main_configuration, settings + from aleph.sdk.evm_utils import ( + get_chains_with_holding, + get_chains_with_super_token, + get_compatible_chains, + ) + from aleph_message.models import Chain config_file_path = Path(settings.CONFIG_FILE) config = load_main_configuration(config_file_path) @@ -180,18 +181,23 @@ def display_active_chain(): @app.command(name="path") def path_directory(): """Display the directory path where your private keys, config file, and other settings are stored.""" + from aleph.sdk.conf import settings + console.print(f"Aleph Home directory: [yellow]{settings.CONFIG_HOME}[/yellow]") @app.command() def show( - private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, - private_key_file: Annotated[ - Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) - ] = settings.PRIVATE_KEY_FILE, + private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, + private_key_file: Annotated[Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)] = None, ): """Display current configuration.""" + from aleph.sdk.conf import settings + + private_key = private_key or settings.PRIVATE_KEY_STRING + private_key_file = private_key_file or settings.PRIVATE_KEY_FILE + display_active_address(private_key=private_key, private_key_file=private_key_file) display_active_chain() @@ -204,6 +210,7 @@ def export_private_key( """ Display your private key. """ + from aleph_message.models import Chain if private_key: private_key_file = None @@ -211,6 +218,8 @@ def export_private_key( typer.secho("No private key available", fg=RED) raise typer.Exit(code=1) + from aleph.sdk.account import _load_account + 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() @@ -225,15 +234,23 @@ def export_private_key( @app.command("sign-bytes") def sign_bytes( message: Annotated[Optional[str], typer.Option(help="Message to sign")] = None, - private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, - private_key_file: Annotated[ - Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) - ] = settings.PRIVATE_KEY_FILE, + private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, + private_key_file: Annotated[Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)] = None, chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, debug: Annotated[bool, typer.Option()] = False, ): """Sign a message using your private key.""" + import asyncio + + from aleph.sdk.account import _load_account + from aleph.sdk.conf import settings + + from aleph_client.commands.utils import input_multiline, setup_logging + + private_key = private_key or settings.PRIVATE_KEY_STRING + private_key_file = private_key_file or settings.PRIVATE_KEY_FILE + setup_logging(debug) account = _load_account(private_key, private_key_file, chain=chain) @@ -248,9 +265,13 @@ def sign_bytes( async def get_balance(address: str) -> dict: + from aleph.sdk.conf import settings + balance_data: dict = {} uri = f"{settings.API_HOST}/api/v0/addresses/{address}/balance" - async with aiohttp.ClientSession() as session: + from aiohttp import ClientSession + + async with ClientSession() as session: response = await session.get(uri) if response.status == 200: balance_data = await response.json() @@ -264,19 +285,25 @@ async def get_balance(address: str) -> dict: @app.command() async def balance( address: Annotated[Optional[str], typer.Option(help="Address")] = None, - private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = settings.PRIVATE_KEY_STRING, - private_key_file: Annotated[ - Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE) - ] = settings.PRIVATE_KEY_FILE, + private_key: Annotated[Optional[str], typer.Option(help=help_strings.PRIVATE_KEY)] = None, + private_key_file: Annotated[Optional[Path], typer.Option(help=help_strings.PRIVATE_KEY_FILE)] = None, chain: Annotated[Optional[Chain], typer.Option(help=help_strings.ADDRESS_CHAIN)] = None, ): """Display your ALEPH balance.""" + from aleph.sdk.account import _load_account + from aleph.sdk.conf import settings + + private_key = private_key or settings.PRIVATE_KEY_STRING + private_key_file = private_key_file or settings.PRIVATE_KEY_FILE + account = _load_account(private_key, private_key_file, chain=chain) if account and not address: address = account.get_address() if address: + from aleph.sdk.utils import displayable_amount + try: balance_data = await get_balance(address) infos = [ @@ -325,6 +352,9 @@ async def balance( async def list_accounts(): """Display available private keys, along with currenlty active chain and account (from config file).""" + from aleph.sdk.conf import load_main_configuration, settings + from aleph.sdk.evm_utils import get_chains_with_holding, get_chains_with_super_token + config_file_path = Path(settings.CONFIG_FILE) config = load_main_configuration(config_file_path) unlinked_keys, _ = await list_unlinked_keys() @@ -354,6 +384,8 @@ async def list_accounts(): active_address = None if config and config.path and active_chain: + from aleph.sdk.account import _load_account + account = _load_account(private_key_path=config.path, chain=active_chain) active_address = account.get_address() @@ -375,6 +407,9 @@ async def configure( chain: Annotated[Optional[Chain], typer.Option(help="New active chain")] = None, ): """Configure current private key file and active chain (default selection)""" + from aleph.sdk.conf import MainConfiguration, save_main_configuration, settings + + from aleph_client.commands.utils import yes_no_input unlinked_keys, config = await list_unlinked_keys() @@ -423,6 +458,8 @@ async def configure( # Configure active chain if not chain and config and hasattr(config, "chain"): + from aleph_message.models import 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 118109dc..b4a9d98c 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -7,10 +7,7 @@ from typing import Annotated, Optional 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.conf import settings from aleph.sdk.types import AccountFromPrivateKey from aleph.sdk.utils import extended_json_encoder from aleph_message.models import Chain, MessageType @@ -57,8 +54,11 @@ async def forget( ) -> bool: """Delete an aggregate by key or subkeys""" + from aleph.sdk.client import AuthenticatedAlephHttpClient + setup_logging(debug) + from aleph.sdk.account import _load_account account: AccountFromPrivateKey = _load_account(private_key, private_key_file) address = account.get_address() if address is None else address @@ -129,9 +129,11 @@ async def post( debug: bool = False, ) -> bool: """Create or update an aggregate by key or subkey""" + from aleph.sdk.client import AuthenticatedAlephHttpClient setup_logging(debug) + from aleph.sdk.account import _load_account account: AccountFromPrivateKey = _load_account(private_key, private_key_file) address = account.get_address() if address is None else address @@ -191,6 +193,9 @@ async def get( debug: bool = False, ) -> Optional[dict]: """Fetch an aggregate by key or subkeys""" + from aiohttp.client_exceptions import ClientResponseError + from aleph.sdk.account import _load_account + from aleph.sdk.client import AuthenticatedAlephHttpClient setup_logging(debug) @@ -227,6 +232,8 @@ async def list_aggregates( debug: bool = False, ) -> Optional[dict]: """Display all aggregates associated to an account""" + from aiohttp.client import ClientSession + from aleph.sdk.account import _load_account setup_logging(debug) @@ -302,6 +309,7 @@ async def authorize( ): """Grant specific publishing permissions to an address to act on behalf of this account""" + from aleph.sdk.account import _load_account setup_logging(debug) account: AccountFromPrivateKey = _load_account(private_key, private_key_file) @@ -376,6 +384,7 @@ async def revoke( ): """Revoke all publishing permissions from an address acting on behalf of this account""" + from aleph.sdk.account import _load_account setup_logging(debug) account: AccountFromPrivateKey = _load_account(private_key, private_key_file) @@ -431,6 +440,7 @@ async def permissions( ) -> Optional[dict]: """Display all permissions emitted by an account""" + from aleph.sdk.account import _load_account setup_logging(debug) account: AccountFromPrivateKey = _load_account(private_key, private_key_file) diff --git a/src/aleph_client/commands/files.py b/src/aleph_client/commands/files.py index f991394c..a71de0ea 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import Annotated, Optional -import aiohttp import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account @@ -263,7 +262,9 @@ async def list_files( query_params = GetAccountFilesQueryParams(pagination=pagination, page=page, sort_order=sort_order) uri = f"{settings.API_HOST}/api/v0/addresses/{address}/files" - async with aiohttp.ClientSession() as session: + from aiohttp.client import ClientSession + + async with ClientSession() as session: response = await session.get(uri, params=query_params.dict()) if response.status == 200: files_data = await response.json() diff --git a/src/aleph_client/commands/instance/__init__.py b/src/aleph_client/commands/instance/__init__.py index fd55e395..4added06 100644 --- a/src/aleph_client/commands/instance/__init__.py +++ b/src/aleph_client/commands/instance/__init__.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import builtins import json import logging @@ -9,7 +8,6 @@ from pathlib import Path from typing import Annotated, Any, Optional, Union, cast -import aiohttp import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account @@ -56,7 +54,6 @@ from aleph_client.commands import help_strings from aleph_client.commands.account import get_balance -from aleph_client.commands.instance.display import CRNTable from aleph_client.commands.instance.network import ( fetch_crn_info, fetch_crn_list, @@ -364,9 +361,11 @@ async def create( crn = None if is_stream or confidential or gpu: if crn_url: + from aiohttp.client_exceptions import InvalidURL + try: crn_url = sanitize_url(crn_url) - except aiohttp.InvalidURL as e: + except InvalidURL as e: echo(f"Invalid URL provided: {crn_url}") raise typer.Exit(1) from e @@ -390,6 +389,8 @@ async def create( except Exception as e: raise typer.Exit(1) from e + from aleph_client.commands.instance.display import CRNTable + while not crn: crn_table = CRNTable( only_latest_crn_version=True, @@ -563,7 +564,9 @@ async def create( return item_hash, crn_url, payment_chain # Wait for the instance message to be processed - async with aiohttp.ClientSession() as session: + from aiohttp.client import ClientSession + + async with ClientSession() as session: await wait_for_processed_instance(session, item_hash) # Pay-As-You-Go @@ -579,6 +582,7 @@ async def create( update_type=FlowUpdate.INCREASE, ) if flow_hash_crn: + import asyncio await asyncio.sleep(5) # 2nd flow tx fails if no delay flow_hash_community = await account.manage_flow( receiver=community_wallet_address, @@ -779,6 +783,7 @@ async def delete( if flow_hash_crn: echo(f"CRN flow has been deleted successfully (Tx: {flow_hash_crn})") if flow_com_percent > Decimal("0"): + import asyncio await asyncio.sleep(5) flow_hash_community = await account.manage_flow( community_wallet_address, @@ -805,6 +810,7 @@ async def _show_instances(messages: builtins.list[InstanceMessage]): table.add_column("Logs", style="blue", overflow="fold") await fetch_crn_list() # Precache CRN list + import asyncio scheduler_responses = dict(await asyncio.gather(*[fetch_vm_info(message) for message in messages])) uninitialized_confidential_found = False for message in messages: @@ -1078,15 +1084,17 @@ async def logs( account = _load_account(private_key, private_key_file, chain=chain) + from aiohttp.client_exceptions import ClientConnectorError, ClientResponseError + async with VmClient(account, domain) as manager: try: async for log in manager.get_logs(vm_id=vm_id): log_data = json.loads(log) if "message" in log_data: echo(log_data["message"]) - except aiohttp.ClientConnectorError as e: + except ClientConnectorError as e: echo(f"Unable to connect to domain: {domain}\nError: {e}") - except aiohttp.ClientResponseError: + except ClientResponseError: echo(f"No VM associated with {vm_id} are currently running on {domain}") @@ -1448,6 +1456,7 @@ async def confidential_create( return 1 # Safe delay to ensure instance is starting and is ready + import asyncio echo("Waiting 10sec before to start...") await asyncio.sleep(10) diff --git a/src/aleph_client/commands/instance/display.py b/src/aleph_client/commands/instance/display.py index 2d6e9dcb..0032cec0 100644 --- a/src/aleph_client/commands/instance/display.py +++ b/src/aleph_client/commands/instance/display.py @@ -1,15 +1,15 @@ from __future__ import annotations -import asyncio import logging +import typing from typing import Optional +if typing.TYPE_CHECKING: + from textual.widgets import DataTable, Label, ProgressBar + from textual.widgets._data_table import RowKey + from textual.app import App -from textual.containers import Horizontal -from textual.css.query import NoMatches from textual.reactive import reactive -from textual.widgets import DataTable, Footer, Label, ProgressBar -from textual.widgets._data_table import RowKey from aleph_client.commands.instance.network import ( fetch_crn_list, @@ -72,6 +72,9 @@ def __init__( def compose(self): """Create child widgets for the app.""" + from textual.containers import Horizontal + from textual.widgets import DataTable, Footer, Label, ProgressBar + self.table = DataTable(cursor_type="row", name="Select CRN") self.table.add_column("Score", key="score") self.table.add_column("Name", key="name") @@ -98,6 +101,7 @@ def compose(self): async def on_mount(self): self.table.styles.height = "95%" + import asyncio task = asyncio.create_task(self.fetch_node_list()) self.tasks.add(task) task.add_done_callback(self.tasks.discard) @@ -113,6 +117,7 @@ async def fetch_node_list(self): self.tasks = set() # Fetch all CRNs + import asyncio for crn in list(self.crns.values()): task = asyncio.create_task(self.add_crn_info(crn)) self.tasks.add(task) @@ -180,6 +185,8 @@ async def add_crn_info(self, crn: CRNInfo): def make_progress(self, task): """Called automatically to advance the progress bar.""" + from textual.css.query import NoMatches + try: self.progress_bar.advance(1) self.loader_label_end.update(f" Available: {self.active_crns} Match: {self.filtered_crns}") @@ -203,6 +210,8 @@ def sort_reverse(self, sort_type: str) -> bool: return reverse def sort_by(self, column, sort_func=lambda row: row.lower(), invert=False): + from textual.widgets import DataTable + table = self.query_one(DataTable) reverse = self.sort_reverse(column) table.sort( diff --git a/src/aleph_client/commands/instance/network.py b/src/aleph_client/commands/instance/network.py index bc57580b..309289c2 100644 --- a/src/aleph_client/commands/instance/network.py +++ b/src/aleph_client/commands/instance/network.py @@ -5,13 +5,6 @@ from json import JSONDecodeError from typing import Optional -from aiohttp import ( - ClientConnectorError, - ClientResponseError, - ClientSession, - ClientTimeout, - InvalidURL, -) from aleph.sdk import AlephHttpClient from aleph.sdk.conf import settings from aleph.sdk.exceptions import ForgottenMessageError, MessageNotFoundError @@ -57,6 +50,12 @@ async def call_program_crn_list() -> Optional[dict]: Returns: dict: Dictionary containing the compute resource node list. """ + from aiohttp.client import ClientSession, ClientTimeout + from aiohttp.client_exceptions import ( + ClientConnectorError, + ClientResponseError, + InvalidURL, + ) try: async with ClientSession(timeout=ClientTimeout(total=60)) as session: @@ -88,6 +87,7 @@ async def fetch_latest_crn_version() -> str: Returns: str: Latest crn version as x.x.x. """ + from aiohttp.client import ClientSession async with ClientSession() as session: try: @@ -172,6 +172,7 @@ async def fetch_vm_info(message: InstanceMessage) -> tuple[str, dict[str, str]]: Returns: VM information. """ + from aiohttp.client import ClientSession async with ClientSession() as session: chain = safe_getattr(message, "content.payment.chain.value") @@ -198,6 +199,8 @@ async def fetch_vm_info(message: InstanceMessage) -> tuple[str, dict[str, str]]: "tac_url": "", "tac_accepted": "", } + from aiohttp.client_exceptions import ClientConnectorError, ClientResponseError + try: # Fetch from the scheduler API directly if no payment or no receiver (hold-tier non-confidential) if is_hold and not is_confidential and not has_gpu: @@ -275,6 +278,7 @@ async def fetch_settings() -> dict: Returns: dict: Dictionary containing the settings. """ + from aiohttp.client import ClientSession async with ClientSession() as session: try: diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index 7b62a20e..916f9ea2 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import json import os.path import subprocess @@ -24,14 +23,6 @@ from aleph_message.status import MessageStatus from aleph_client.commands import help_strings -from aleph_client.commands.utils import ( - colorful_json, - colorful_message_json, - colorized_status, - input_multiline, - setup_logging, - str_to_datetime, -) from aleph_client.utils import AsyncTyper app = AsyncTyper(no_args_is_help=True) @@ -41,6 +32,12 @@ async def get( item_hash: Annotated[str, typer.Argument(help="Item hash of the message")], ): + from aleph_client.commands.utils import ( + colorful_json, + colorful_message_json, + colorized_status, + ) + async with AlephHttpClient(api_server=settings.API_HOST) as client: message: Optional[AlephMessage] = None try: @@ -75,6 +72,8 @@ async def find( end_date: Annotated[Optional[str], typer.Option()] = None, ignore_invalid_messages: Annotated[bool, typer.Option()] = True, ): + from aleph_client.commands.utils import colorful_json, str_to_datetime + parsed_message_types = ( [MessageType(message_type) for message_type in message_types.split(",")] if message_types else None ) @@ -129,6 +128,8 @@ async def post( ): """Post a message on aleph.im.""" + from aleph_client.commands.utils import input_multiline, setup_logging + setup_logging(debug) account: AccountFromPrivateKey = _load_account(private_key, private_key_file) @@ -179,6 +180,8 @@ async def amend( ): """Amend an existing aleph.im message.""" + from aleph_client.commands.utils import setup_logging + setup_logging(debug) account: AccountFromPrivateKey = _load_account(private_key, private_key_file) @@ -242,6 +245,8 @@ async def forget( ): """Forget an existing aleph.im message.""" + from aleph_client.commands.utils import setup_logging + setup_logging(debug) hash_list: list[ItemHash] = [ItemHash(h) for h in hashes.split(",")] @@ -259,6 +264,8 @@ async def watch( ): """Watch a hash for amends and print amend hashes""" + from aleph_client.commands.utils import setup_logging + setup_logging(debug) async with AlephHttpClient(api_server=settings.API_HOST) as client: @@ -287,6 +294,10 @@ def sign( ): """Sign an aleph message with a private key. If no --message is provided, the message will be read from stdin.""" + import asyncio + + from aleph_client.commands.utils import input_multiline, setup_logging + setup_logging(debug) account: AccountFromPrivateKey = _load_account(private_key, private_key_file) diff --git a/src/aleph_client/commands/node.py b/src/aleph_client/commands/node.py index f71b966e..5dbc06c7 100644 --- a/src/aleph_client/commands/node.py +++ b/src/aleph_client/commands/node.py @@ -7,7 +7,6 @@ import unicodedata from typing import Annotated, Optional -import aiohttp import typer from aleph.sdk.conf import settings from rich import text @@ -38,7 +37,9 @@ def __init__(self, **kwargs): async def _fetch_nodes() -> NodeInfo: """Fetch node aggregates and format it as NodeInfo""" - async with aiohttp.ClientSession() as session: + from aiohttp.client import ClientSession + + async with ClientSession() as session: async with session.get(node_link) as resp: if resp.status != 200: logger.error("Unable to fetch node information") diff --git a/src/aleph_client/commands/pricing.py b/src/aleph_client/commands/pricing.py index c72f3a85..046f4162 100644 --- a/src/aleph_client/commands/pricing.py +++ b/src/aleph_client/commands/pricing.py @@ -5,7 +5,6 @@ from enum import Enum from typing import Annotated, Optional -import aiohttp import typer from aleph.sdk.conf import settings from aleph.sdk.utils import displayable_amount, safe_getattr @@ -310,7 +309,9 @@ def display_table_for( async def fetch_pricing() -> Pricing: """Fetch pricing aggregate and format it as Pricing""" - async with aiohttp.ClientSession() as session: + from aiohttp.client import ClientSession + + async with ClientSession() as session: async with session.get(pricing_link) as resp: if resp.status != 200: logger.error("Unable to fetch pricing aggregate") diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 3974048a..d2a26a85 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -10,7 +10,6 @@ from typing import Annotated, Any, Optional, cast from zipfile import BadZipFile -import aiohttp import typer from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account @@ -845,9 +844,12 @@ async def runtime_checker( program_url = settings.VM_URL_PATH.format(hash=program_hash) versions: dict echo("Query runtime checker to retrieve versions...") + + from aiohttp.client import ClientSession, ClientTimeout + try: - timeout = aiohttp.ClientTimeout(total=settings.HTTP_REQUEST_TIMEOUT) - async with aiohttp.ClientSession(timeout=timeout) as session: + timeout = ClientTimeout(total=settings.HTTP_REQUEST_TIMEOUT) + async with ClientSession(timeout=timeout) as session: async with session.get(program_url) as resp: resp.raise_for_status() versions = await resp.json() diff --git a/src/aleph_client/commands/utils.py b/src/aleph_client/commands/utils.py index dc428bfe..8c49205e 100644 --- a/src/aleph_client/commands/utils.py +++ b/src/aleph_client/commands/utils.py @@ -1,15 +1,17 @@ from __future__ import annotations -import asyncio import logging import os import shutil import sys +import typing from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Optional, TypeVar, Union -from aiohttp import ClientSession +if typing.TYPE_CHECKING: + from aiohttp import ClientSession + from aleph.sdk import AlephHttpClient from aleph.sdk.chains.ethereum import ETHAccount from aleph.sdk.conf import settings @@ -275,6 +277,8 @@ def is_environment_interactive() -> bool: async def wait_for_processed_instance(session: ClientSession, item_hash: ItemHash): """Wait for a message to be processed by CCN""" + import asyncio + while True: url = f"{settings.API_HOST.rstrip('/')}/api/v0/messages/{item_hash}" message = await fetch_json(session, url) @@ -290,10 +294,13 @@ async def wait_for_processed_instance(session: ClientSession, item_hash: ItemHas async def wait_for_confirmed_flow(account: ETHAccount, receiver: str): """Wait for a flow to be confirmed on-chain""" + import asyncio + while True: flow = await account.get_flow(receiver) if flow: return + echo("Flow transaction is still pending, waiting 10sec...") await asyncio.sleep(10) diff --git a/src/aleph_client/models.py b/src/aleph_client/models.py index 93214127..79125b8d 100644 --- a/src/aleph_client/models.py +++ b/src/aleph_client/models.py @@ -1,7 +1,6 @@ from datetime import datetime from typing import Any, Optional -from aiohttp import InvalidURL from aleph.sdk.types import StoredContent from aleph_message.models import ItemHash from aleph_message.models.execution.environment import CpuProperties, GpuDeviceClass @@ -157,6 +156,8 @@ def from_unsanitized_input( machine_usage = MachineUsage.parse_obj(system_usage) if system_usage else None ipv6_check = crn.get("ipv6_check") ipv6 = bool(ipv6_check and all(ipv6_check.values())) + from aiohttp.client_exceptions import InvalidURL + try: url = sanitize_url(crn["address"]) except InvalidURL: diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index cc3c5aaa..7eba51ca 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -1,13 +1,12 @@ from __future__ import annotations -import asyncio import inspect import logging import os import re import subprocess import sys -from asyncio import ensure_future +import typing from functools import lru_cache, partial, wraps from pathlib import Path from shutil import make_archive @@ -15,21 +14,16 @@ from urllib.parse import ParseResult, urlparse from zipfile import BadZipFile, ZipFile -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_message.models.base import MessageType -from aleph_message.models.execution.base import Encoding -logger = logging.getLogger(__name__) +if typing.TYPE_CHECKING: + from aiohttp.client import ClientSession + from aleph.sdk.conf import MainConfiguration + from aleph.sdk.types import GenericMessage + from aleph_message.models.base import MessageType + from aleph_message.models.execution.base import Encoding -try: - import magic -except ImportError: - logger.info("Could not import library 'magic', MIME type detection disabled") - magic = None # type:ignore +logger = logging.getLogger(__name__) def try_open_zip(path: Path) -> None: @@ -44,7 +38,11 @@ def try_open_zip(path: Path) -> None: def create_archive(path: Path) -> tuple[Path, Encoding]: """Create a zip archive from a directory""" + from aleph_message.models.execution.base import Encoding + if os.path.isdir(path): + from aleph.sdk.conf import settings + if settings.CODE_USES_SQUASHFS: logger.debug("Creating squashfs archive...") archive_path = Path(f"{path}.squashfs") @@ -57,6 +55,9 @@ def create_archive(path: Path) -> tuple[Path, Encoding]: archive_path = Path(f"{path}.zip") return archive_path, Encoding.zip elif os.path.isfile(path): + from aleph.sdk.utils import import_magic + + magic = import_magic() if path.suffix == ".squashfs" or (magic and magic.from_file(path).startswith("Squashfs filesystem")): return path, Encoding.squashfs else: @@ -77,6 +78,7 @@ class AsyncTyper(typer.Typer): @staticmethod def maybe_run_async(decorator, f): if inspect.iscoroutinefunction(f): + import asyncio @wraps(f) def runner(*args, **kwargs): @@ -120,6 +122,8 @@ async def list_unlinked_keys() -> tuple[list[Path], Optional[MainConfiguration]] - A list of unlinked private key files as Path objects. - The active MainConfiguration object (the single account in the config file). """ + from aleph.sdk.conf import load_main_configuration, settings + config_home: Union[str, Path] = settings.CONFIG_HOME if settings.CONFIG_HOME else Path.home() private_key_dir = Path(config_home, "private-keys") @@ -158,6 +162,16 @@ async def list_unlinked_keys() -> tuple[list[Path], Optional[MainConfiguration]] ] +def raise_invalid_url(msg: str) -> None: + """ + Raise an InvalidURL exception with the given message. + Imports aiohttp lazily to improve response time when the URL is valid. + """ + from aiohttp.client_exceptions import InvalidURL + + raise InvalidURL(msg) + + def sanitize_url(url: str) -> str: """Ensure that the URL is valid and not obviously irrelevant. @@ -168,22 +182,23 @@ def sanitize_url(url: str) -> str: """ if not url: msg = "Empty URL" - raise aiohttp.InvalidURL(msg) + raise_invalid_url(msg) parsed_url: ParseResult = urlparse(url) if parsed_url.scheme not in ["http", "https"]: msg = f"Invalid URL scheme: {parsed_url.scheme}" - raise aiohttp.InvalidURL(msg) + raise_invalid_url(msg) if parsed_url.hostname in FORBIDDEN_HOSTS: logger.debug( f"Invalid URL {url} hostname {parsed_url.hostname} is in the forbidden host list " f"({', '.join(FORBIDDEN_HOSTS)})" ) msg = "Invalid URL host" - raise aiohttp.InvalidURL(msg) + raise_invalid_url(msg) return url.strip("/") def async_lru_cache(async_function): + from asyncio import ensure_future @lru_cache(maxsize=0 if "pytest" in sys.modules else 1) def cached_async_function(*args, **kwargs): diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py deleted file mode 100644 index 8422fb81..00000000 --- a/tests/unit/test_init.py +++ /dev/null @@ -1,5 +0,0 @@ -from aleph_client import __version__ - - -def test_version(): - assert __version__ != ""