From 28a6558714182cbf37eb93e562eb9baa433bc07c Mon Sep 17 00:00:00 2001 From: Mike Hukiewitz <70762838+MHHukiewitz@users.noreply.github.com> Date: Fri, 10 Nov 2023 15:54:12 +0100 Subject: [PATCH] Use latest post-v0.7.0 SDK - fix breaking changes (#173) * Internal: Add test for file upload * Fix: Unit test docstring * Problem: GH Action failing due to Python and Aleph SDK dependencies Solution: Restrict to Python 3.11 on macOS and use current 0.7.0 release of Aleph SDK * Problem: aleph-client was using main branch of aleph-sdk-python, which includes breaking changes relative to v0.7.0 Solution: Update usage of the SDK to reflect and accommodate breaking changes; use AsyncTyper class to implement async commands * Fix: aggregate.py and files.py to use async functions. * Replace subprocess calls with runner.invoke --------- Co-authored-by: 1yam --- .github/workflows/test-build.yml | 7 ++ src/aleph_client/__main__.py | 5 +- src/aleph_client/account.py | 0 src/aleph_client/commands/about.py | 4 +- src/aleph_client/commands/account.py | 3 +- src/aleph_client/commands/aggregate.py | 22 +++--- src/aleph_client/commands/files.py | 17 ++--- src/aleph_client/commands/message.py | 97 +++++++++++++------------- src/aleph_client/commands/program.py | 12 ++-- src/aleph_client/utils.py | 27 +++++++ tests/unit/test_commands.py | 89 +++++++++++++++-------- 11 files changed, 177 insertions(+), 106 deletions(-) create mode 100644 src/aleph_client/account.py diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index e9a7c28a..4c0ecd64 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -22,6 +22,13 @@ jobs: brew update brew tap cuber/homebrew-libsecp256k1 brew install libsecp256k1 + + - name: Set up Python for macOS + if: startsWith(matrix.os, 'macos') + uses: actions/setup-python@v2 + with: + python-version: 3.11 + - name: Install required system packages only for Ubuntu Linux if: startsWith(matrix.os, 'ubuntu-') diff --git a/src/aleph_client/__main__.py b/src/aleph_client/__main__.py index 161f974e..5300d6e0 100644 --- a/src/aleph_client/__main__.py +++ b/src/aleph_client/__main__.py @@ -4,9 +4,11 @@ import typer +from aleph_client.utils import AsyncTyper from .commands import about, account, aggregate, files, message, program -app = typer.Typer() +app = AsyncTyper() + @app.callback() def common( @@ -16,6 +18,7 @@ def common( ): pass + app.add_typer(account.app, name="account", help="Manage account") app.add_typer( aggregate.app, name="aggregate", help="Manage aggregate messages on aleph.im" diff --git a/src/aleph_client/account.py b/src/aleph_client/account.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aleph_client/commands/about.py b/src/aleph_client/commands/about.py index e0d00dd8..ad994236 100644 --- a/src/aleph_client/commands/about.py +++ b/src/aleph_client/commands/about.py @@ -1,7 +1,9 @@ import typer from pkg_resources import get_distribution -app = typer.Typer() +from aleph_client.utils import AsyncTyper + +app = AsyncTyper() def get_version(value: bool): diff --git a/src/aleph_client/commands/account.py b/src/aleph_client/commands/account.py index 530b2b5a..5be5c1f1 100644 --- a/src/aleph_client/commands/account.py +++ b/src/aleph_client/commands/account.py @@ -15,9 +15,10 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging +from aleph_client.utils import AsyncTyper logger = logging.getLogger(__name__) -app = typer.Typer() +app = AsyncTyper() @app.command() diff --git a/src/aleph_client/commands/aggregate.py b/src/aleph_client/commands/aggregate.py index e5583e97..7636dfcd 100644 --- a/src/aleph_client/commands/aggregate.py +++ b/src/aleph_client/commands/aggregate.py @@ -3,19 +3,21 @@ import typer from aleph.sdk.account import _load_account -from aleph.sdk.client import AuthenticatedAlephClient +from aleph.sdk.client import AuthenticatedAlephHttpClient from aleph.sdk.conf import settings as sdk_settings from aleph.sdk.types import AccountFromPrivateKey +from aleph.sdk.query.filters import MessageFilter from aleph_message.models import MessageType from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging +from aleph_client.utils import AsyncTyper -app = typer.Typer() +app = AsyncTyper() @app.command() -def forget( +async def forget( key: str = typer.Argument(..., help="Aggregate item hash to be removed."), reason: Optional[str] = typer.Option( None, help="A description of why the messages are being forgotten" @@ -35,14 +37,16 @@ def forget( account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - with AuthenticatedAlephClient( + async with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: - message_response = client.get_messages( - addresses=[account.get_address()], - message_type=MessageType.aggregate.value, - content_keys=[key], + message_response = await client.get_messages( + message_filter=MessageFilter( + addresses=[account.get_address()], + message_types=[MessageType.aggregate.value], + content_keys=[key], + ) ) hash_list = [message["item_hash"] for message in message_response.messages] - client.forget(hashes=hash_list, reason=reason, channel=channel) + await client.forget(hashes=hash_list, reason=reason, channel=channel) diff --git a/src/aleph_client/commands/files.py b/src/aleph_client/commands/files.py index 4c14c9ca..6f91514d 100644 --- a/src/aleph_client/commands/files.py +++ b/src/aleph_client/commands/files.py @@ -3,7 +3,7 @@ from typing import Optional import typer -from aleph.sdk import AuthenticatedAlephClient +from aleph.sdk import AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.conf import settings as sdk_settings from aleph.sdk.types import AccountFromPrivateKey, StorageEnum @@ -12,13 +12,14 @@ from aleph_client.commands import help_strings from aleph_client.commands.utils import setup_logging +from aleph_client.utils import AsyncTyper logger = logging.getLogger(__name__) -app = typer.Typer() +app = AsyncTyper() @app.command() -def pin( +async def pin( item_hash: str = typer.Argument(..., help="IPFS hash to pin on aleph.im"), channel: Optional[str] = typer.Option(default=None, help=help_strings.CHANNEL), private_key: Optional[str] = typer.Option( @@ -36,12 +37,12 @@ def pin( account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - with AuthenticatedAlephClient( + async with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: result: StoreMessage status: MessageStatus - result, status = client.create_store( + result, status = await client.create_store( file_hash=item_hash, storage_engine=StorageEnum.ipfs, channel=channel, @@ -52,7 +53,7 @@ def pin( @app.command() -def upload( +async def upload( path: Path = typer.Argument(..., help="Path of the file to upload"), channel: Optional[str] = typer.Option(default=None, help=help_strings.CHANNEL), private_key: Optional[str] = typer.Option( @@ -70,7 +71,7 @@ def upload( account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - with AuthenticatedAlephClient( + async with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: if not path.is_file(): @@ -89,7 +90,7 @@ def upload( logger.debug("Uploading file") result: StoreMessage status: MessageStatus - result, status = client.create_store( + result, status = await client.create_store( file_content=file_content, storage_engine=storage_engine, channel=channel, diff --git a/src/aleph_client/commands/message.py b/src/aleph_client/commands/message.py index 456c2419..6fb2b2bd 100644 --- a/src/aleph_client/commands/message.py +++ b/src/aleph_client/commands/message.py @@ -8,10 +8,11 @@ from typing import Dict, List, Optional import typer -from aleph.sdk import AlephClient, AuthenticatedAlephClient +from aleph.sdk import AlephHttpClient, AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.conf import settings as sdk_settings -from aleph.sdk.models import MessagesResponse +from aleph.sdk.query.responses import MessagesResponse +from aleph.sdk.query.filters import MessageFilter from aleph.sdk.types import AccountFromPrivateKey, StorageEnum from aleph_message.models import AlephMessage, ItemHash, MessageType, ProgramMessage @@ -23,24 +24,25 @@ setup_logging, str_to_datetime, ) +from aleph_client.utils import AsyncTyper -app = typer.Typer() +app = AsyncTyper() @app.command() -def get( +async def get( item_hash: str, ): - with AlephClient(api_server=sdk_settings.API_HOST) as client: - message = client.get_message(item_hash=ItemHash(item_hash)) + async with AlephHttpClient(api_server=sdk_settings.API_HOST) as client: + message = await client.get_message(item_hash=ItemHash(item_hash)) typer.echo(colorful_message_json(message)) @app.command() -def find( +async def find( pagination: int = 200, page: int = 1, - message_type: Optional[str] = None, + message_types: Optional[str] = None, content_types: Optional[str] = None, content_keys: Optional[str] = None, refs: Optional[str] = None, @@ -53,17 +55,7 @@ def find( end_date: Optional[str] = None, ignore_invalid_messages: bool = True, ): - message_type = MessageType(message_type) if message_type else None - - parsed_content_types: Optional[List[str]] = None - parsed_content_keys: Optional[List[str]] = None - parsed_refs: Optional[List[str]] = None - parsed_addresses: Optional[List[str]] = None - parsed_tags: Optional[List[str]] = None - parsed_hashes: Optional[List[str]] = None - parsed_channels: Optional[List[str]] = None - parsed_chains: Optional[List[str]] = None - + parsed_message_types = message_types.split(",") if message_types else None parsed_content_types = content_types.split(",") if content_types else None parsed_content_keys = content_keys.split(",") if content_keys else None parsed_refs = refs.split(",") if refs else None @@ -73,33 +65,37 @@ def find( parsed_channels = channels.split(",") if channels else None parsed_chains = chains.split(",") if chains else None - message_type = MessageType(message_type) if message_type else None + message_types = [ + MessageType(message_type) for message_type in parsed_message_types + ] if parsed_message_types else None start_time = str_to_datetime(start_date) end_time = str_to_datetime(end_date) - with AlephClient(api_server=sdk_settings.API_HOST) as client: - response: MessagesResponse = client.get_messages( - pagination=pagination, + async with AlephHttpClient(api_server=sdk_settings.API_HOST) as client: + response: MessagesResponse = await client.get_messages( + page_size=pagination, page=page, - message_type=message_type, - content_types=parsed_content_types, - content_keys=parsed_content_keys, - refs=parsed_refs, - addresses=parsed_addresses, - tags=parsed_tags, - hashes=parsed_hashes, - channels=parsed_channels, - chains=parsed_chains, - start_date=start_time, - end_date=end_time, + message_filter=MessageFilter( + message_types=message_types, + content_types=parsed_content_types, + content_keys=parsed_content_keys, + refs=parsed_refs, + addresses=parsed_addresses, + tags=parsed_tags, + hashes=parsed_hashes, + channels=parsed_channels, + chains=parsed_chains, + start_date=start_time, + end_date=end_time, + ), ignore_invalid_messages=ignore_invalid_messages, ) typer.echo(colorful_json(response.json(sort_keys=True, indent=4))) @app.command() -def post( +async def post( path: Optional[Path] = typer.Option( None, help="Path to the content you want to post. If omitted, you can input your content directly", @@ -149,10 +145,10 @@ def post( typer.echo("Not valid JSON") raise typer.Exit(code=2) - with AuthenticatedAlephClient( + async with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: - result, status = client.create_post( + result, status = await client.create_post( post_content=content, post_type=type, ref=ref, @@ -165,7 +161,7 @@ def post( @app.command() -def amend( +async def amend( item_hash: str = typer.Argument(..., help="Hash reference of the message to amend"), private_key: Optional[str] = typer.Option( sdk_settings.PRIVATE_KEY_STRING, help=help_strings.PRIVATE_KEY @@ -181,8 +177,8 @@ def amend( account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - with AlephClient(api_server=sdk_settings.API_HOST) as client: - existing_message: AlephMessage = client.get_message(item_hash=item_hash) + async with AlephHttpClient(api_server=sdk_settings.API_HOST) as client: + existing_message: AlephMessage = await client.get_message(item_hash=item_hash) editor: str = os.getenv("EDITOR", default="nano") with tempfile.NamedTemporaryFile(suffix="json") as fd: @@ -207,10 +203,10 @@ def amend( new_content.ref = existing_message.item_hash typer.echo(new_content) - with AuthenticatedAlephClient( + async with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: - message, _status = client.submit( + message, _status = await client.submit( content=new_content.dict(), message_type=existing_message.type, channel=existing_message.channel, @@ -219,7 +215,7 @@ def amend( @app.command() -def forget( +async def forget( hashes: str = typer.Argument( ..., help="Comma separated list of hash references of messages to forget" ), @@ -242,14 +238,14 @@ def forget( hash_list: List[str] = hashes.split(",") account: AccountFromPrivateKey = _load_account(private_key, private_key_file) - with AuthenticatedAlephClient( + async with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: - client.forget(hashes=hash_list, reason=reason, channel=channel) + await client.forget(hashes=hash_list, reason=reason, channel=channel) @app.command() -def watch( +async def watch( ref: str = typer.Argument(..., help="Hash reference of the message to watch"), indent: Optional[int] = typer.Option(None, help="Number of indents to use"), debug: bool = False, @@ -258,11 +254,12 @@ def watch( setup_logging(debug) - with AlephClient(api_server=sdk_settings.API_HOST) as client: - original: AlephMessage = client.get_message(item_hash=ref) - for message in client.watch_messages( + async with AlephHttpClient(api_server=sdk_settings.API_HOST) as client: + original: AlephMessage = await client.get_message(item_hash=ref) + async for message in client.watch_messages( + message_filter=MessageFilter( refs=[ref], addresses=[original.content.address] - ): + )): typer.echo(f"{message.json(indent=indent)}") diff --git a/src/aleph_client/commands/program.py b/src/aleph_client/commands/program.py index 82e9a0c4..d7650427 100644 --- a/src/aleph_client/commands/program.py +++ b/src/aleph_client/commands/program.py @@ -6,7 +6,7 @@ from zipfile import BadZipFile import typer -from aleph.sdk import AuthenticatedAlephClient +from aleph.sdk import AuthenticatedAlephHttpClient from aleph.sdk.account import _load_account from aleph.sdk.conf import settings as sdk_settings from aleph.sdk.types import AccountFromPrivateKey, StorageEnum @@ -28,10 +28,10 @@ yes_no_input, ) from aleph_client.conf import settings -from aleph_client.utils import create_archive +from aleph_client.utils import create_archive, AsyncTyper logger = logging.getLogger(__name__) -app = typer.Typer() +app = AsyncTyper() @app.command() @@ -147,7 +147,7 @@ def upload( else: subscriptions = None - with AuthenticatedAlephClient( + with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: # Upload the source code @@ -225,7 +225,7 @@ def update( account = _load_account(private_key, private_key_file) path = path.absolute() - with AuthenticatedAlephClient( + with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: program_message: ProgramMessage = client.get_message( @@ -283,7 +283,7 @@ def unpersist( account = _load_account(private_key, private_key_file) - with AuthenticatedAlephClient( + with AuthenticatedAlephHttpClient( account=account, api_server=sdk_settings.API_HOST ) as client: existing: MessagesResponse = client.get_messages(hashes=[item_hash]) diff --git a/src/aleph_client/utils.py b/src/aleph_client/utils.py index d040bae6..21dbc044 100644 --- a/src/aleph_client/utils.py +++ b/src/aleph_client/utils.py @@ -1,10 +1,14 @@ +import asyncio +import inspect import logging import os +from functools import wraps, partial from pathlib import Path from shutil import make_archive from typing import Tuple, Type from zipfile import BadZipFile, ZipFile +import typer from aleph.sdk.types import GenericMessage from aleph_message.models import MessageType from aleph_message.models.execution.program import Encoding @@ -60,3 +64,26 @@ def get_message_type_value(message_type: Type[GenericMessage]) -> MessageType: """Returns the value of the 'type' field of a message type class.""" type_literal = message_type.__annotations__["type"] return type_literal.__args__[0] # Get the value from a Literal + + +class AsyncTyper(typer.Typer): + @staticmethod + def maybe_run_async(decorator, f): + if inspect.iscoroutinefunction(f): + + @wraps(f) + def runner(*args, **kwargs): + return asyncio.run(f(*args, **kwargs)) + + decorator(runner) + else: + decorator(f) + return f + + def callback(self, *args, **kwargs): + decorator = super().callback(*args, **kwargs) + return partial(self.maybe_run_async, decorator) + + def command(self, *args, **kwargs): + decorator = super().command(*args, **kwargs) + return partial(self.maybe_run_async, decorator) diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py index d6593a29..29b05039 100644 --- a/tests/unit/test_commands.py +++ b/tests/unit/test_commands.py @@ -1,6 +1,7 @@ import json -import subprocess from pathlib import Path +from tempfile import NamedTemporaryFile + from aleph.sdk.chains.ethereum import ETHAccount from typer.testing import CliRunner @@ -52,27 +53,31 @@ def test_account_export_private_key(account_file: Path): assert len(result.stdout.strip()) == 66 +def test_account_path(): + result = runner.invoke(app, ["account", "path"]) + assert result.stdout.startswith("/") + + def test_message_get(): # 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 # a "no running event loop" error when running several tests back to back. - result = subprocess.run( + result = runner.invoke( + app, [ - "aleph", "message", "get", "bd79839bf96e595a06da5ac0b6ba51dea6f7e2591bb913deccded04d831d29f4", ], - capture_output=True, ) - assert result.returncode == 0 - assert b"0x101d8D16372dBf5f1614adaE95Ee5CCE61998Fc9" in result.stdout + assert result.exit_code == 0 + assert "0x101d8D16372dBf5f1614adaE95Ee5CCE61998Fc9" in result.stdout def test_message_find(): - result = subprocess.run( + result = runner.invoke( + app, [ - "aleph", "message", "find", "--pagination=1", @@ -81,12 +86,11 @@ def test_message_find(): "--chains=ETH", "--hashes=bd79839bf96e595a06da5ac0b6ba51dea6f7e2591bb913deccded04d831d29f4", ], - capture_output=True, ) - assert result.returncode == 0 - assert b"0x101d8D16372dBf5f1614adaE95Ee5CCE61998Fc9" in result.stdout + assert result.exit_code == 0 + assert "0x101d8D16372dBf5f1614adaE95Ee5CCE61998Fc9" in result.stdout assert ( - b"bd79839bf96e595a06da5ac0b6ba51dea6f7e2591bb913deccded04d831d29f4" + "bd79839bf96e595a06da5ac0b6ba51dea6f7e2591bb913deccded04d831d29f4" in result.stdout ) @@ -94,9 +98,9 @@ def test_message_find(): def test_sign_message(account_file): account = get_account(account_file) message = get_test_message(account) - result = subprocess.run( + result = runner.invoke( + app, [ - "aleph", "message", "sign", "--private-key-file", @@ -104,42 +108,67 @@ def test_sign_message(account_file): "--message", json.dumps(message), ], - capture_output=True, ) - assert result.returncode == 0 - assert b"signature" in result.stdout + assert result.exit_code == 0 + assert "signature" in result.stdout def test_sign_message_stdin(account_file): account = get_account(account_file) message = get_test_message(account) - cmd = f"""echo '{json.dumps(message)}' | aleph message sign --private-key-file {account_file}""" - result = subprocess.run(cmd, shell=True, capture_output=True) + result = runner.invoke( + app, + [ + "message", + "sign", + "--private-key-file", + str(account_file), + ], + input=json.dumps(message), + ) - assert result.returncode == 0 - assert b"signature" in result.stdout + assert result.exit_code == 0 + assert "signature" in result.stdout def test_sign_raw(): - result = subprocess.run( + result = runner.invoke( + app, [ - "aleph", "account", "sign-bytes", "--message", "some message", ], - capture_output=True, ) - assert result.returncode == 0 - assert b"0x" in result.stdout + assert result.exit_code == 0 + assert "0x" in result.stdout def test_sign_raw_stdin(): - cmd = 'echo "some message" | aleph account sign-bytes' - result = subprocess.run(cmd, shell=True, capture_output=True) + message = "some message" + result = runner.invoke( + app, + [ + "account", + "sign-bytes", + ], + input=message, + ) - assert result.returncode == 0 - assert b"0x" in result.stdout + assert result.exit_code == 0 + assert "0x" in result.stdout + + +def test_file_upload(): + # Test upload a file to aleph network by creating a file and upload it to an aleph node + with NamedTemporaryFile() as temp_file: + temp_file.write(b"Hello World \n") + result = runner.invoke( + app, + ["file", "upload", temp_file.name], + ) + assert result.exit_code == 0 + assert result.stdout is not None