diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c68a60c62e873bf136f152f279a5d22e90b82a62..ad177ad007fcbb32273e811958051766e440434d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -259,7 +259,16 @@ testing_cli: PYTEST_TIMEOUT_MINUTES: 10 script: - echo -e "${TXT_BLUE}Launching cli commands tests...${TXT_CLEAR}" - - export PYTEST_ARGS=(tests/functional/cli -v) + - export PYTEST_ARGS=(tests/functional/cli --ignore tests/functional/cli/calling_api -v) + - !reference [.run-pytest, script] + +testing_cli_call: + extends: .testing + variables: + PYTEST_TIMEOUT_MINUTES: 10 + script: + - echo -e "${TXT_BLUE}Launching cli call tests...${TXT_CLEAR}" + - export PYTEST_ARGS=(tests/functional/cli/calling_api -v) - !reference [.run-pytest, script] testing_password_private_key_logging: diff --git a/clive/__private/cli/commands/abc/node_address_cli_command.py b/clive/__private/cli/commands/abc/node_address_cli_command.py new file mode 100644 index 0000000000000000000000000000000000000000..e120ba326319f00c142401d075e6dda9ed6ef2c9 --- /dev/null +++ b/clive/__private/cli/commands/abc/node_address_cli_command.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from abc import ABC +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from clive.__private.cli.commands.abc.external_cli_command import ExternalCLICommand +from clive.__private.cli.exceptions import CLIChainIdFromSettingsNotAvailableError +from clive.__private.settings import safe_settings +from wax.wax_factory import create_hive_chain +from wax.wax_options import WaxChainOptions + +if TYPE_CHECKING: + from wax import IHiveChainInterface + + +@dataclass(kw_only=True) +class NodeAddressCLICommand(ExternalCLICommand, ABC): + """ + A command that requires a node address to be provided explicitly. + + Attributes: + node_address: The HTTP endpoint URL of the Hive node to connect to. + """ + + node_address: str + + def _build_wax_interface(self) -> IHiveChainInterface: + chain_id = safe_settings.node.chain_id + if chain_id is None: + raise CLIChainIdFromSettingsNotAvailableError + wax_chain_options = WaxChainOptions(chain_id=chain_id, endpoint_url=self.node_address) + return create_hive_chain(wax_chain_options) diff --git a/clive/__private/cli/commands/abc/perform_actions_on_transaction_command.py b/clive/__private/cli/commands/abc/perform_actions_on_transaction_command.py index a93ad7a0bdd35cfceccc0f36e465c60f67e1177c..4026869c9dea6999f5b9d5d166bece12fc7fd519 100644 --- a/clive/__private/cli/commands/abc/perform_actions_on_transaction_command.py +++ b/clive/__private/cli/commands/abc/perform_actions_on_transaction_command.py @@ -6,8 +6,6 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Final, cast -import rich - from clive.__private.cli.commands.abc.forceable_cli_command import ForceableCLICommand from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.exceptions import ( @@ -22,7 +20,7 @@ from clive.__private.cli.exceptions import ( CLITransactionUnknownAccountError, CLIWrongAlreadySignedModeAutoSignError, ) -from clive.__private.cli.print_cli import print_cli +from clive.__private.cli.print_cli import print_cli, print_json from clive.__private.cli.warnings import typer_echo_warnings from clive.__private.core.commands.perform_actions_on_transaction import AutoSignSkippedWarning from clive.__private.core.constants.data_retrieval import ALREADY_SIGNED_MODE_DEFAULT @@ -280,7 +278,7 @@ class PerformActionsOnTransactionCommand(WorldBasedCommand, ForceableCLICommand, transaction_json = transaction.json(order="sorted") message = self._get_transaction_created_message().capitalize() print_cli(f"{message} transaction:") - rich.print_json(transaction_json) + print_json(transaction_json) def _print_dry_run_message_if_needed(self) -> None: if not self.should_broadcast and not self.is_save_file_given: diff --git a/clive/__private/cli/commands/call_api.py b/clive/__private/cli/commands/call_api.py new file mode 100644 index 0000000000000000000000000000000000000000..2da25aec41aa07dd5ce8379342ed4f9e60730735 --- /dev/null +++ b/clive/__private/cli/commands/call_api.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import errno +import importlib +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import msgspec + +from clive.__private.cli.commands.abc.external_cli_command import ExternalCLICommand +from clive.__private.cli.commands.abc.node_address_cli_command import NodeAddressCLICommand +from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand +from clive.__private.cli.exceptions import ( + CLIBeekeeperRemoteAddressIsNotSetError, + CLIBeekeeperSessionTokenNotSetError, + CLINoProfileUnlockedError, + CLIPrettyError, +) +from clive.__private.cli.print_cli import print_json +from clive.__private.core.formatters.case import camelize +from clive.__private.models.schemas import DecodeError, JsonString, ValidationError + +if TYPE_CHECKING: + from typing import Any + + from wax import IHiveChainInterface + + +def get_api_class_name(api_name: str) -> str: + return camelize(api_name) + + +def get_api_client_module_path(api_name: str) -> str: + return f"{api_name}.{api_name}_client" + + +class CLICallAPINoNodeAddressError(CLIPrettyError): + """Raise when no node address is provided and there is no unlocked profile.""" + + def __init__(self) -> None: + super().__init__( + "Option '--node-address' must be provided or profile must be unlocked to load configured node address.", + errno.EINVAL, + ) + + +class CLICallAPIPackageNotFoundError(CLIPrettyError): + """ + Raise when api package for requested api is not installed. + + Args: + api_name: Requested api that was not found. + """ + + def __init__(self, api_name: str) -> None: + message = f"Package for api `{api_name}` not found." + super().__init__(message, errno.EINVAL) + + +class CLICallAPIClientModuleNotFoundError(CLIPrettyError): + """ + Raise when client module in requested api was not found. + + Args: + api_client_module_name: Requested client module that was not found. + api_name: Requested api. + """ + + def __init__(self, api_client_module_name: str, api_name: str) -> None: + message = ( + f"Module `{api_client_module_name}` for api `{api_name}` not found. " + f"This might be a problem with installed `{api_name}`." + ) + super().__init__(message, errno.EINVAL) + + +class CLICallAPIClassDefinitionNotFoundError(CLIPrettyError): + """ + Raise when in client module there was not found api class definition. + + Args: + api_class_name: Requested api definition class that was not found. + api_name: Requested api. + """ + + def __init__(self, api_class_name: str, api_name: str) -> None: + message = ( + f"Api class `{api_class_name}` for api `{api_name}` not found. " + f"This might be a problem with installed `{api_name}`." + ) + super().__init__(message, errno.EINVAL) + + +class CLICallAPIMethodNotFoundError(CLIPrettyError): + """ + Raise when api package is installed but requested method was not found. + + Args: + method_name: Requested api method that was not found. + api_name: Requested api. + """ + + def __init__(self, method_name: str, api_name: str) -> None: + message = ( + f"No method `{method_name}` found in api `{api_name}`. This might be a problem with installed `{api_name}`." + ) + super().__init__(message, errno.EINVAL) + + +class CLICallAPIParamNotAJSONContainerError(CLIPrettyError): + """ + Raise when passed parameters are not dict or list. + + Args: + raw_params: Parameters before parsing. + """ + + def __init__(self, raw_params: str) -> None: + message = f"Params must be a json string representing a dict ('{{..}}'). Received `{raw_params}`" + super().__init__(message, errno.EINVAL) + + +@dataclass(kw_only=True) +class _CallAPICommon(ExternalCLICommand, ABC): + """ + Common data and logic shared between CallAPIUseProfile and CallAPIUseNodeAddress. + + Attributes: + api_name: The name of the API to call (e.g., "database_api", "block_api"). + method_name: The name of the method to call on the API. + params: JSON string containing the parameters to pass to the API method. + """ + + api_name: str + method_name: str + params: str + _api_class: type = field(init=False) + _parsed_params: dict[str, Any] = field(init=False) + + @property + @abstractmethod + def wax_interface(self) -> IHiveChainInterface: + """Return the wax interface to use for API calls.""" + + def build_extended_api_collection(self) -> type: + api_name = self.api_name + api_class = self._api_class + + class RequestedApiCollection: + def __init__(self) -> None: + setattr(self, api_name, api_class) + + return RequestedApiCollection + + async def call_api_and_print_result(self) -> None: + collection_type = self.build_extended_api_collection() + extended_chain = self.wax_interface.extends(collection_type) + + api_instance = getattr(extended_chain.api, self.api_name) + api_method_awaitable = getattr(api_instance, self.method_name) + + # currently we don't support condenser_api + result = await api_method_awaitable(**self.get_keyword_params()) + + print_json(msgspec.json.encode(result).decode()) + + def get_keyword_params(self) -> dict[str, Any]: + return self._parsed_params + + async def validate(self) -> None: + self.validate_api_call() + await super().validate() + + def validate_api_call(self) -> None: + self._validate_api_name() + self._validate_api_method_name() + self._validate_api_call_params() + + async def _run(self) -> None: + await self.call_api_and_print_result() + + def _validate_api_call_params(self) -> None: + try: + self._parsed_params = JsonString(self.params).value + except (DecodeError, ValidationError) as err: + raise CLICallAPIParamNotAJSONContainerError(self.params) from err + if not isinstance(self._parsed_params, dict): + raise CLICallAPIParamNotAJSONContainerError(self.params) + + def _validate_api_method_name(self) -> None: + if not hasattr(self._api_class, self.method_name): + raise CLICallAPIMethodNotFoundError(self.method_name, self.api_name) + + def _validate_api_name(self) -> None: + api_client_module_name = get_api_client_module_path(self.api_name) + api_class_name = get_api_class_name(self.api_name) + try: + importlib.import_module(self.api_name) + except Exception as err: + raise CLICallAPIPackageNotFoundError(self.api_name) from err + try: + client_module = importlib.import_module(api_client_module_name) + except Exception as err: + raise CLICallAPIClientModuleNotFoundError(api_client_module_name, self.api_name) from err + try: + api_class = getattr(client_module, api_class_name) + except Exception as err: + raise CLICallAPIClassDefinitionNotFoundError(api_class_name, self.api_name) from err + self._api_class = api_class + + +@dataclass(kw_only=True) +class CallAPIUseProfile(_CallAPICommon, WorldBasedCommand): + """Call API method using node address from the unlocked profile.""" + + @property + def wax_interface(self) -> IHiveChainInterface: + return self.world.wax_interface + + async def run(self) -> None: + try: + await super().run() + except ( + CLIBeekeeperRemoteAddressIsNotSetError, + CLIBeekeeperSessionTokenNotSetError, + CLINoProfileUnlockedError, + ) as err: + raise CLICallAPINoNodeAddressError from err + + def _print_launching_beekeeper(self) -> None: + """Override WorldBasedCommand implementation so this command prints only raw json.""" + + +@dataclass(kw_only=True) +class CallAPIUseNodeAddress(_CallAPICommon, NodeAddressCLICommand): + """Call API method using explicitly provided node address.""" + + @property + def wax_interface(self) -> IHiveChainInterface: + return self._build_wax_interface() diff --git a/clive/__private/cli/exceptions.py b/clive/__private/cli/exceptions.py index 72942aa03bc4c6a96bc5ca6ff1eaf86d3a923937..d941f4b6a459bfea355b77817c17873df0a972b9 100644 --- a/clive/__private/cli/exceptions.py +++ b/clive/__private/cli/exceptions.py @@ -545,3 +545,19 @@ class CLIChangeRecoveryAccountValidationError(CLIPrettyError): def __init__(self, name: str, reason: str) -> None: message = f"Account `{name}` can't be used. Reason: {reason}" super().__init__(message, errno.EINVAL) + + +class CLIChainIdFromSettingsNotAvailableError(CLIPrettyError): + """Raise when chain id is not available in settings.""" + + def __init__(self) -> None: + from clive.__private.core.constants.setting_identifiers import ( # noqa: PLC0415 + NODE_CHAIN_ID, + ) + from clive.__private.settings.clive_prefixed_envvar import clive_prefixed_envvar # noqa: PLC0415 + + message = ( + "There is no configured chain id in settings." + f" You can override settings by environment variable {clive_prefixed_envvar(NODE_CHAIN_ID)}" + ) + super().__init__(message, errno.EINVAL) diff --git a/clive/__private/cli/main.py b/clive/__private/cli/main.py index 56675ccc77540fab6913a45897a7398bcdde3817..9a3fee3687f7e72d956de181a03812d4a8310886 100644 --- a/clive/__private/cli/main.py +++ b/clive/__private/cli/main.py @@ -93,3 +93,54 @@ async def init() -> None: from clive.__private.cli.commands.init import Init # noqa: PLC0415 await Init().run() + + +@cli.command(name="call") +async def call_api( + api_name: str = typer.Argument( + ..., + help="Look in api description generated by swagger (Legacy Hive JSON-RPC API).", + ), + method_name: str = typer.Argument( + ..., + help="Look in methods description generated by swagger (Legacy Hive JSON-RPC API).", + ), + params: str = typer.Argument( + "{}", + help=stylized_help( + "Parameters to include in jsonrpc call. This should be a json string representing a list of parameters" + ", possibly single/double quoted appropriately for your shell.", + ), + metavar="TEXT", + ), + node_address: str | None = typer.Option( + None, + help=stylized_help( + "Node address to use.", default="node configured in profile, default requires unlocked profile" + ), + ), +) -> None: + """ + Call the node API with clive. + + Look at swagger at `https://api.hive.blog/` section "Legacy Hive JSON-RPC API" for available APIs and methods. + + Example: + 1) clive call database_api get_dynamic_global_properties + 2) clive call account_history_api get_account_history '{"account": "temp"}' + """ + from clive.__private.cli.commands.call_api import CallAPIUseNodeAddress, CallAPIUseProfile # noqa: PLC0415 + + if node_address is None: + await CallAPIUseProfile( + api_name=api_name, + method_name=method_name, + params=params, + ).run() + else: + await CallAPIUseNodeAddress( + api_name=api_name, + method_name=method_name, + params=params, + node_address=node_address, + ).run() diff --git a/clive/__private/cli/print_cli.py b/clive/__private/cli/print_cli.py index 51690b6e9fb7e7442b15aa3cadcffd7ca18cb96c..c1c6f8a9d16233f18e08cfa7751b69a757066da1 100644 --- a/clive/__private/cli/print_cli.py +++ b/clive/__private/cli/print_cli.py @@ -42,3 +42,8 @@ def print_error(message: str, *, prefix: bool = True, console: Console | None = prefix_ = "[b]Error:[/] " if prefix else "" full_message = colorize_error(prefix_ + message) print_cli(full_message, console=console) + + +def print_json(message: str, *, console: Console | None = None) -> None: + console_ = Console() if console is None else console + console_.print_json(message) diff --git a/clive/__private/core/formatters/case.py b/clive/__private/core/formatters/case.py index ff1be66fef4f0175f33f848e00eefa6d5d5b2355..b73c1f0e3b590814ddf0a144069c37aa816e0938 100644 --- a/clive/__private/core/formatters/case.py +++ b/clive/__private/core/formatters/case.py @@ -5,10 +5,10 @@ import inflection def underscore(string: str) -> str: """ - Convert string from CamelCase to snake_case. + Convert string from CamelCase or kebab-case to snake_case. Args: - string: The input string in CamelCase format. + string: The input string in CamelCase or kebab-case format. Returns: The converted string in snake_case format. @@ -33,3 +33,16 @@ def dasherize(string: str) -> str: The converted string. """ return inflection.dasherize(string) + + +def camelize(string: str) -> str: + """ + Convert strings to CamelCase. + + Args: + string: The input string in snake_case or pascalCase format. + + Returns: + The converted string in CamelCase format. + """ + return inflection.camelize(string) diff --git a/poetry.lock b/poetry.lock index a2069a0708c457048ae8222d47f6b91bc1904d33..d2b479a90fe7d6634990adc4fe365728a1fa28bd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -622,6 +622,44 @@ files = [ hpack = ">=4.1,<5" hyperframe = ">=6.1,<7" +[[package]] +name = "hiveio-account-by-key-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_account_by_key_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:916e9ab5e2a28d57cce4b7327ffd0dc13e1fb652b0c4f5a674d4705b98024031"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + +[[package]] +name = "hiveio-account-history-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_account_history_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:df561b90e6c45116cc21c6f87a5bea709bd9a3355e01e48ee8f225f3346749a0"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + [[package]] name = "hiveio-beekeepy" version = "1.28.0" @@ -646,6 +684,25 @@ type = "legacy" url = "https://gitlab.syncad.com/api/v4/projects/434/packages/pypi/simple" reference = "gitlab-beekeepy" +[[package]] +name = "hiveio-block-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_block_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:cf7d8586fb6ef7069f833fc230321eb5e1f28ca339270be1f0a7b9e327211a54"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + [[package]] name = "hiveio-database-api" version = "1.28.2.dev15+42d819556" @@ -665,6 +722,63 @@ type = "legacy" url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" reference = "gitlab-hive" +[[package]] +name = "hiveio-debug-node-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_debug_node_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:afd5c12b63c7cfbd3d5ed2fce6512bca03b590d1dcf6c9c9e416984cd780df8f"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + +[[package]] +name = "hiveio-follow-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_follow_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:4f07854dd0af7bae1a93aca6ea071c922b64462cf9747b495003840b6d0cc050"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + +[[package]] +name = "hiveio-market-history-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_market_history_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:5a1920f7268f048dcb90f7666412b7c7b9e584ba7981ff8de41feea80bccfe3e"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + [[package]] name = "hiveio-network-broadcast-api" version = "1.28.2.dev15+42d819556" @@ -703,6 +817,25 @@ type = "legacy" url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" reference = "gitlab-hive" +[[package]] +name = "hiveio-reputation-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_reputation_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:fe84df40700dc5f92dec7616d1d3c71e694dd635b7c8d599a3f72b95b32a3503"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + [[package]] name = "hiveio-schemas" version = "1.28.0" @@ -722,6 +855,44 @@ type = "legacy" url = "https://gitlab.syncad.com/api/v4/projects/362/packages/pypi/simple" reference = "gitlab-schemas" +[[package]] +name = "hiveio-search-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_search_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:589c9f956546bb7785c9c32974944420969906a8d48df74298a53abed50efcf1"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + +[[package]] +name = "hiveio-transaction-status-api" +version = "1.28.2.dev15+42d819556" +description = "" +optional = false +python-versions = ">=3.12,<4.0" +groups = ["main"] +files = [ + {file = "hiveio_transaction_status_api-1.28.2.dev15+42d819556-py3-none-any.whl", hash = "sha256:04bdb409977c3104cce998092326ac42ef58401130060dab7ead285f7f8ed3e5"}, +] + +[package.dependencies] +hiveio-beekeepy = "1.28.0" + +[package.source] +type = "legacy" +url = "https://gitlab.syncad.com/api/v4/projects/198/packages/pypi/simple" +reference = "gitlab-hive" + [[package]] name = "hiveio-wax" version = "1.27.12rc4.dev39+bd40f689" @@ -2553,4 +2724,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = ">=3.12,<3.13" -content-hash = "f10b44f10f4b6604d155366d6b455a8786c04f52cc682606892899ca7bc759ca" +content-hash = "38bcd0d7697d36639116144156d6084e93cdaec10fcb202424193d751a43a2c9" diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index cde6ffd639fff6aed4180dd9ac642532eece73c2..6f795ae30b90f596f96aa653722088d9a5c91bfa 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -100,6 +100,8 @@ clive/__private/cli/generate/main.py clive/__private/cli/main.py DOC101: Function `unlock`: Docstring contains fewer arguments than in function signature. DOC103: Function `unlock`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [include_create_new_profile: bool, profile_name: str | None, profile_name_option: str | None, unlock_time_mins: int | None]. + DOC101: Function `call_api`: Docstring contains fewer arguments than in function signature. + DOC103: Function `call_api`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [api_name: str, method_name: str, node_address: str | None, params: str]. -------------------- clive/__private/cli/process/claim.py DOC101: Function `process_claim_new_account_token`: Docstring contains fewer arguments than in function signature. diff --git a/pyproject.toml b/pyproject.toml index 1cf9baec4f998c564af4725183c466c72ab7cdb3..751770a3247d9e4943aeee9e908c3d8467f40548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,9 +21,21 @@ dependencies = [ 'humanize (==4.12.2)', 'toml (==0.10.2)', "pathvalidate (==3.3.1)", - 'hiveio-schemas (==1.28.0)', 'hiveio-beekeepy (==1.28.0)', + 'hiveio-schemas (==1.28.0)', 'hiveio-wax (==1.27.12rc4.dev39+bd40f689)', + 'hiveio-account-by-key-api (==1.28.2.dev15+42d819556)', + 'hiveio-account-history-api (==1.28.2.dev15+42d819556)', + 'hiveio-block-api (==1.28.2.dev15+42d819556)', + 'hiveio-database-api (==1.28.2.dev15+42d819556)', + 'hiveio-debug-node-api (==1.28.2.dev15+42d819556)', + 'hiveio-follow-api (==1.28.2.dev15+42d819556)', + 'hiveio-market-history-api (==1.28.2.dev15+42d819556)', + 'hiveio-network-broadcast-api (==1.28.2.dev15+42d819556)', + 'hiveio-rc-api (==1.28.2.dev15+42d819556)', + 'hiveio-reputation-api (==1.28.2.dev15+42d819556)', + 'hiveio-search-api (==1.28.2.dev15+42d819556)', + 'hiveio-transaction-status-api (==1.28.2.dev15+42d819556)', ] [project.urls] @@ -52,9 +64,21 @@ poetry-dynamic-versioning = { version = ">=1.0.0,<2.0.0", extras = ["plugin"] } poetry-plugin-freeze = { version = "1.2.0" } [tool.poetry.dependencies] +hiveio-beekeepy = { source = "gitlab-beekeepy" } hiveio-schemas = { source = "gitlab-schemas" } hiveio-wax = { source = "gitlab-wax" } -hiveio-beekeepy = { source = "gitlab-beekeepy" } +hiveio-account-by-key-api = { source = "gitlab-hive" } +hiveio-account-history-api = { source = "gitlab-hive" } +hiveio-block-api = { source = "gitlab-hive" } +hiveio-database-api = { source = "gitlab-hive" } +hiveio-debug-node-api = { source = "gitlab-hive" } +hiveio-follow-api = { source = "gitlab-hive" } +hiveio-market-history-api = { source = "gitlab-hive" } +hiveio-network-broadcast-api = { source = "gitlab-hive" } +hiveio-rc-api = { source = "gitlab-hive" } +hiveio-reputation-api = { source = "gitlab-hive" } +hiveio-search-api = { source = "gitlab-hive" } +hiveio-transaction-status-api = { source = "gitlab-hive" } [tool.poetry.group.embeddedtestnet.dependencies] clive-local-tools = { path = "tests/clive-local-tools", develop = true } diff --git a/tests/clive-local-tools/clive_local_tools/cli/checkers.py b/tests/clive-local-tools/clive_local_tools/cli/checkers.py index 9b2817649e6a18cbb1bf55695f7b897305c529a9..79fb2ad9b9c54c9bfa3e30333143b70555d9e041 100644 --- a/tests/clive-local-tools/clive_local_tools/cli/checkers.py +++ b/tests/clive-local-tools/clive_local_tools/cli/checkers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from json import JSONDecodeError, loads from typing import TYPE_CHECKING import pytest @@ -304,3 +305,11 @@ def assert_contains_transaction_saved_to_file_message(file_path: str | Path, mes normalized = rest.replace("\n", "") assert str(file_path) in normalized, f"Transaction was saved but looks like to a wrong file: {normalized}" + + +def assert_result_contains_valid_json(result: CLITestResult) -> None: + """Asserts that the provided content is valid JSON.""" + try: + loads(result.output) + except JSONDecodeError: + pytest.fail(f"Expected valid JSON content.\n{result.info}") diff --git a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py index 0efa11963501d1a2aa94eaf198fe031448fb8984..6e7cdfa18904ec4e50a4ea66e0f24628e7e11fb0 100644 --- a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py +++ b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py @@ -687,3 +687,10 @@ class CLITester: return self.__invoke_command_with_options( ["show", "pending", "decline-voting-rights"], account_name=account_name ) + + def call( + self, + *args: StringConvertibleOptionTypes, + node_address: str | None = None, + ) -> CLITestResult: + return self.__invoke_command_with_options(["call"], args, **extract_params(locals(), "args")) diff --git a/tests/clive-local-tools/clive_local_tools/data/constants.py b/tests/clive-local-tools/clive_local_tools/data/constants.py index 91f20d97fff7360d0e81adcebafaf9a906d048a5..6706ba81fe69cf6d69ea0407f8ffd4b29c2e7fff 100644 --- a/tests/clive-local-tools/clive_local_tools/data/constants.py +++ b/tests/clive-local-tools/clive_local_tools/data/constants.py @@ -53,3 +53,22 @@ TRANSACTION_CREATED_MESSAGE: Final[str] = "Transaction was successfully created. TRANSACTION_LOADED_MESSAGE: Final[str] = "Transaction was successfully loaded." TRANSACTION_BROADCASTED_MESSAGE: Final[str] = "Transaction was successfully broadcasted." TRANSACTION_SAVED_MESSAGE_PREFIX: Final[str] = "Transaction was saved to " + +# search-api and follow_api need hivemind, condenser_api is not included +HIVED_API_NAMES: Final[tuple[str, ...]] = ( + "account_by_key_api", + "account_history_api", + "block_api", + "database_api", + "debug_node_api", + "market_history_api", + "network_broadcast_api", + "rc_api", + "reputation_api", + "transaction_status_api", +) +ADDITIONAL_API_NAMES: Final[tuple[str, ...]] = ( + "search_api", + "follow_api", +) +ALL_API_NAMES: Final[tuple[str, ...]] = (*HIVED_API_NAMES, *ADDITIONAL_API_NAMES) diff --git a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini index 9685d470e2e60115663905338438de8d64a8ae93..b3d6f6b8170f5261e215350878ccf101d8ff8ea8 100644 --- a/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini +++ b/tests/clive-local-tools/clive_local_tools/testnet_block_log/block_log_with_config/config.ini @@ -16,6 +16,7 @@ plugin = reputation_api plugin = state_snapshot plugin = transaction_status_api plugin = wallet_bridge_api +plugin = market_history_api plugin = witness private-key = 5JNHfZYKGaomSFvd4NUdQ9qMcEAC43kujbfjueTHpVapX1Kzq2n private-key = 5K9A216jHar6MEbGdPikeB7VJR5QmE28gwxPfjFH4m3pwQK4Gfh diff --git a/tests/functional/cli/calling_api/__init__.py b/tests/functional/cli/calling_api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/functional/cli/calling_api/test_call.py b/tests/functional/cli/calling_api/test_call.py new file mode 100644 index 0000000000000000000000000000000000000000..d8519312b055572752f63195968ab2c7298fc670 --- /dev/null +++ b/tests/functional/cli/calling_api/test_call.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import importlib +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final + +import pytest +import test_tools as tt + +from clive.__private.cli.commands.call_api import ( + CLICallAPIMethodNotFoundError, + CLICallAPINoNodeAddressError, + CLICallAPIPackageNotFoundError, + CLICallAPIParamNotAJSONContainerError, +) +from clive_local_tools.cli.checkers import assert_result_contains_valid_json +from clive_local_tools.cli.exceptions import CLITestCommandError +from clive_local_tools.data.constants import HIVED_API_NAMES +from clive_local_tools.helpers import create_transaction_filepath, get_formatted_error_message +from clive_local_tools.testnet_block_log.constants import WORKING_ACCOUNT_NAME + +if TYPE_CHECKING: + from clive_local_tools.cli.cli_tester import CLITester + +FAILING_API: Final[tuple[str, ...]] = ( + "database_api.get_config", + "database_api.get_version", + "debug_node_api.debug_get_head_block", +) + + +@dataclass +class ApiMethodInfo: + api_name: str + method_name: str + are_params_required: bool + + def __str__(self) -> str: + return f"{self.api_name}.{self.method_name}" + + +def list_api_method_info() -> list[ApiMethodInfo]: + result: list[ApiMethodInfo] = [] + for api_name in HIVED_API_NAMES: + module = importlib.import_module(f"{api_name}.{api_name}_description") + description = getattr(module, f"{api_name}_description") + for api_method_name in description[api_name]: + are_params_required: bool = description[api_name][api_method_name]["params"] is not None + info = ApiMethodInfo(api_name, api_method_name, are_params_required) + if str(info) in FAILING_API: + continue + result.append(ApiMethodInfo(api_name, api_method_name, are_params_required)) + return result + + +@pytest.mark.parametrize( + "api_method_info", + [pytest.param(info, id=str(info)) for info in list_api_method_info() if not info.are_params_required], +) +def test_call_methods_no_params(cli_tester: CLITester, api_method_info: ApiMethodInfo) -> None: + """Should call api method and give json as command stdout.""" + # ARRANGE + api_name = api_method_info.api_name + method_name = api_method_info.method_name + + # ACT + result = cli_tester.call(api_name, method_name) + + # ASSERT + assert_result_contains_valid_json(result) + + +@pytest.mark.parametrize( + ("api_name", "method_name", "params"), + [ + # remove xfail after block_api get_block return fix in generated api packages + pytest.param("block_api", "get_block", '{"block_num": 10}', marks=pytest.mark.xfail), + pytest.param("block_api", "get_block", '{"block_num": "10"}', marks=pytest.mark.xfail), + ("account_history_api", "get_ops_in_block", None), + ("account_history_api", "get_ops_in_block", "{}"), + ("account_history_api", "get_ops_in_block", '{"block_num": 10}'), + ("account_history_api", "get_ops_in_block", '{"block_num": "10"}'), + ("account_history_api", "get_ops_in_block", '{"block_num": 10, "only_virtual": true}'), + ("account_history_api", "get_ops_in_block", '{"block_num": 10, "include_reversible": true}'), + ("database_api", "get_feed_history", None), + ("database_api", "get_active_witnesses", None), + ("database_api", "get_active_witnesses", '{"include_future": true}'), + ], +) +def test_common_api_calls(cli_tester: CLITester, api_name: str, method_name: str, params: str | None) -> None: + # ACT + result = ( + cli_tester.call(api_name, method_name, params) if params is not None else cli_tester.call(api_name, method_name) + ) + + # ASSERT + assert_result_contains_valid_json(result) + + +def test_broadcast_transaction(cli_tester: CLITester) -> None: + """Test broadcasting a transaction with complex parameters.""" + # ARRANGE + api_name = "network_broadcast_api" + method_name = "broadcast_transaction" + memo = '"' + '"' + "\\" + '"' + "\\" + "'" + "\\" + "\\" # after escaping ""\"\'\\ + transaction_filepath = create_transaction_filepath() + cli_tester.process_transfer( + to=WORKING_ACCOUNT_NAME, amount=tt.Asset.Hive(1), memo=memo, save_file=transaction_filepath + ) + with transaction_filepath.open() as saved_transaction: + params = '{"trx": ' + str(saved_transaction.read()) + "}" + + # ACT & ASSERT + cli_tester.call(api_name, method_name, params) + + +def test_call_locked(cli_tester_locked: CLITester, node: tt.RawNode) -> None: + """Test call api when locked and custom node address must be set.""" + # ARRANGE + api_name = "database_api" + method_name = "get_dynamic_global_properties" + + # ACT + result = cli_tester_locked.call(api_name, method_name, node_address=str(node.http_endpoint)) + + # ASSERT + assert_result_contains_valid_json(result) + + +@pytest.mark.parametrize( + ("cli_tester_variant"), + ["locked", "without remote address", "without session token"], + indirect=["cli_tester_variant"], +) +def test_negative_call_locked_without_node_address(cli_tester_variant: CLITester) -> None: + # ARRANGE + api_name = "database_api" + method_name = "get_dynamic_global_properties" + expected_error = get_formatted_error_message(CLICallAPINoNodeAddressError()) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=expected_error): + cli_tester_variant.call(api_name, method_name) + + +def test_negative_invalid_api_name(cli_tester: CLITester) -> None: + # ARRANGE + invalid_api_name = "database-api" + method_name = "get-dynamic-global-properties" + expected_error = get_formatted_error_message(CLICallAPIPackageNotFoundError(invalid_api_name)) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=expected_error): + cli_tester.call(invalid_api_name, method_name) + + +def test_negative_invalid_method_name(cli_tester: CLITester) -> None: + # ARRANGE + api_name = "database_api" + invalid_method_name = "get-dynamic-global-properties" + expected_error = get_formatted_error_message(CLICallAPIMethodNotFoundError(invalid_method_name, api_name)) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=expected_error): + cli_tester.call(api_name, invalid_method_name) + + +def test_negative_invalid_params_decode(cli_tester: CLITester) -> None: + """Params are not valid json.""" + # ARRANGE + raw_params = "{" # invalid json + expected_error = get_formatted_error_message(CLICallAPIParamNotAJSONContainerError(raw_params)) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=expected_error): + cli_tester.call("database_api", "get_dynamic_global_properties", raw_params) + + +def test_negative_invalid_params_format(cli_tester: CLITester) -> None: + """Params are valid json but we expect dict or list.""" + # ARRANGE + raw_params = '""' # valid json but not a container + expected_error = get_formatted_error_message(CLICallAPIParamNotAJSONContainerError(raw_params)) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=expected_error): + cli_tester.call("database_api", "get_dynamic_global_properties", raw_params) diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index 1025ef2e0c82b3720337dd8aa3173c786ca9ff7a..21763213b50da79979dea77dbdb135689112bb36 100644 --- a/tests/unit/test_imports.py +++ b/tests/unit/test_imports.py @@ -10,8 +10,10 @@ import pytest from textual import __name__ as textual_package_name import clive.__private.models.schemas as schemas_models_module +from clive.__private.cli.commands.call_api import get_api_class_name, get_api_client_module_path from clive.__private.ui import __name__ as ui_package_name from clive_local_tools.cli.imports import get_cli_help_imports_tree +from clive_local_tools.data.constants import ALL_API_NAMES from wax import __name__ as wax_package_name if TYPE_CHECKING: @@ -19,6 +21,12 @@ if TYPE_CHECKING: from types import ModuleType +def get_tested_api_names() -> list[str]: + """Get list of api names to be tested for import.""" + invalid_apis = ["search_api"] # APIs that are not properly generated, fix is already merged in hived + return [api_name for api_name in ALL_API_NAMES if api_name not in invalid_apis] + + @contextlib.contextmanager def reload_module_in_type_checking_mode(module_name: str) -> Iterator[ModuleType]: with pytest.MonkeyPatch.context() as monkeypatch: @@ -71,3 +79,25 @@ def test_schemas_imports_runtime_match_type_checking(name: str) -> None: assert runtime_object is typechecking_object, ( f"Runtime `{runtime_object}` and type checking `{typechecking_object}` objects do not match" ) + + +@pytest.mark.parametrize("api_name", get_tested_api_names()) +def test_import_api_package(api_name: str) -> None: + """Import api package given as argument `api_name`.""" + # ARRANGE + client_module_name = get_api_client_module_path(api_name) + api_class_name = get_api_class_name(api_name) + + # ACT & ASSERT + try: + importlib.import_module(api_name) + except Exception as error: # noqa: BLE001 + pytest.fail(f"API package {api_name} couldn't be imported: {error}") + try: + client_module = importlib.import_module(client_module_name) + except Exception as error: # noqa: BLE001 + pytest.fail(f"API client module {client_module_name} couldn't be imported: {error}") + assert hasattr(client_module, api_class_name), ( + f"In api package {api_name} in module {client_module_name} there should be" + f" definition of api class {api_class_name}" + )