From 8fe60c57bc990b456cbb0d8c40db5b7bf11f5fcb Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Tue, 2 Dec 2025 14:39:54 +0000 Subject: [PATCH 1/9] Add api packages to project depencencies --- poetry.lock | 173 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 28 +++++++- 2 files changed, 198 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index a2069a0708..d2b479a90f 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/pyproject.toml b/pyproject.toml index 1cf9baec4f..751770a324 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 } -- GitLab From 2866a1c188f3ead351b686d722a6ac165c316c15 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Tue, 2 Dec 2025 14:43:10 +0000 Subject: [PATCH 2/9] Add CamelCase conversion to formatters --- clive/__private/core/formatters/case.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/clive/__private/core/formatters/case.py b/clive/__private/core/formatters/case.py index ff1be66fef..b73c1f0e3b 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) -- GitLab From eb5536127500f1056b9c372e1c71eed93ea686fd Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Tue, 2 Dec 2025 14:44:10 +0000 Subject: [PATCH 3/9] Helper for printing raw json on stdout --- .../commands/abc/perform_actions_on_transaction_command.py | 6 ++---- clive/__private/cli/print_cli.py | 5 +++++ 2 files changed, 7 insertions(+), 4 deletions(-) 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 a93ad7a0bd..4026869c9d 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/print_cli.py b/clive/__private/cli/print_cli.py index 51690b6e9f..c1c6f8a9d1 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) -- GitLab From d2db6c15855ad901f15af6b0af72703f0c0c1998 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Tue, 16 Dec 2025 16:02:52 +0000 Subject: [PATCH 4/9] Introduce abstract command type NodeAddressCLICommand --- .../commands/abc/node_address_cli_command.py | 33 +++++++++++++++++++ clive/__private/cli/exceptions.py | 16 +++++++++ 2 files changed, 49 insertions(+) create mode 100644 clive/__private/cli/commands/abc/node_address_cli_command.py 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 0000000000..e120ba3263 --- /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/exceptions.py b/clive/__private/cli/exceptions.py index 72942aa03b..d941f4b6a4 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) -- GitLab From c7b6e7f9235657fceb421dfc055b801f77676750 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Tue, 16 Dec 2025 16:03:04 +0000 Subject: [PATCH 5/9] Command `clive call` for calling api methods --- clive/__private/cli/commands/call_api.py | 248 +++++++++++++++++++++++ clive/__private/cli/main.py | 51 +++++ pydoclint-errors-baseline.txt | 2 + 3 files changed, 301 insertions(+) create mode 100644 clive/__private/cli/commands/call_api.py diff --git a/clive/__private/cli/commands/call_api.py b/clive/__private/cli/commands/call_api.py new file mode 100644 index 0000000000..b388f06db3 --- /dev/null +++ b/clive/__private/cli/commands/call_api.py @@ -0,0 +1,248 @@ +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) + + +class RequestedApiCollection: + """API collection class for dynamically extending wax interface with a single API.""" + + _api_name: str + _api_class: type + + def __init__(self) -> None: + setattr(self, self._api_name, self._api_class) + + +@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[RequestedApiCollection]: + class ConfiguredApiCollection(RequestedApiCollection): + _api_name = self.api_name + _api_class = self._api_class + + return ConfiguredApiCollection + + 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/main.py b/clive/__private/cli/main.py index 56675ccc77..9a3fee3687 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/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index cde6ffd639..6f795ae30b 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. -- GitLab From 7324e9135611f4f61a633b5fab004759bda18d07 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Tue, 16 Dec 2025 16:03:13 +0000 Subject: [PATCH 6/9] Unit tests for api packages import --- tests/unit/test_imports.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/test_imports.py b/tests/unit/test_imports.py index 1025ef2e0c..21763213b5 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}" + ) -- GitLab From 10914d8a6c6951c388bf8881c4e1659fcd9847e2 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Tue, 16 Dec 2025 16:03:20 +0000 Subject: [PATCH 7/9] Add market_history_api in node config used in tests --- .../testnet_block_log/block_log_with_config/config.ini | 1 + 1 file changed, 1 insertion(+) 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 9685d470e2..b3d6f6b817 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 -- GitLab From 03681d99691134aa0250f6fd1448bf73c942c85a Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Tue, 16 Dec 2025 16:03:28 +0000 Subject: [PATCH 8/9] Tests for command `clive call` --- .gitlab-ci.yml | 11 +- .../clive_local_tools/cli/checkers.py | 9 + .../clive_local_tools/cli/cli_tester.py | 7 + .../clive_local_tools/data/constants.py | 19 ++ tests/functional/cli/calling_api/__init__.py | 0 tests/functional/cli/calling_api/test_call.py | 187 ++++++++++++++++++ 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 tests/functional/cli/calling_api/__init__.py create mode 100644 tests/functional/cli/calling_api/test_call.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c68a60c62e..ad177ad007 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/tests/clive-local-tools/clive_local_tools/cli/checkers.py b/tests/clive-local-tools/clive_local_tools/cli/checkers.py index 9b2817649e..79fb2ad9b9 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 0efa119635..6e7cdfa189 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 91f20d97ff..6706ba81fe 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/functional/cli/calling_api/__init__.py b/tests/functional/cli/calling_api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 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 0000000000..d8519312b0 --- /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) -- GitLab From 41465516d3238cb2c0ed1ede02b0f7c2e257152e Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Wed, 17 Dec 2025 14:28:24 +0000 Subject: [PATCH 9/9] fixup! Command `clive call` for calling api methods --- clive/__private/cli/commands/call_api.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/clive/__private/cli/commands/call_api.py b/clive/__private/cli/commands/call_api.py index b388f06db3..2da25aec41 100644 --- a/clive/__private/cli/commands/call_api.py +++ b/clive/__private/cli/commands/call_api.py @@ -121,16 +121,6 @@ class CLICallAPIParamNotAJSONContainerError(CLIPrettyError): super().__init__(message, errno.EINVAL) -class RequestedApiCollection: - """API collection class for dynamically extending wax interface with a single API.""" - - _api_name: str - _api_class: type - - def __init__(self) -> None: - setattr(self, self._api_name, self._api_class) - - @dataclass(kw_only=True) class _CallAPICommon(ExternalCLICommand, ABC): """ @@ -153,12 +143,15 @@ class _CallAPICommon(ExternalCLICommand, ABC): def wax_interface(self) -> IHiveChainInterface: """Return the wax interface to use for API calls.""" - def build_extended_api_collection(self) -> type[RequestedApiCollection]: - class ConfiguredApiCollection(RequestedApiCollection): - _api_name = self.api_name - _api_class = self._api_class + 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 ConfiguredApiCollection + return RequestedApiCollection async def call_api_and_print_result(self) -> None: collection_type = self.build_extended_api_collection() -- GitLab