From 4fa71a9240dd6e9ea5ea5f49e6afdeef8d33b258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 28 Nov 2025 13:35:52 +0000 Subject: [PATCH 1/9] Apply multisign- replace sign_key argument with sign_keys --- .../perform_actions_on_transaction_command.py | 6 +- clive/__private/core/commands/commands.py | 10 ++- .../perform_actions_on_transaction.py | 86 +++++++++++++++---- .../save_transaction_to_file_dialog.py | 2 +- .../transaction_summary.py | 2 +- .../unit/commands/test_transaction_status.py | 2 +- 6 files changed, 80 insertions(+), 28 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..4cf6e74932 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 @@ -92,7 +92,9 @@ class PerformActionsOnTransactionCommand(WorldBasedCommand, ForceableCLICommand, @property def use_autosign(self) -> bool: - return self.is_autosign_explicitly_requested or (self.autosign is None and not self.is_sign_with_given) + return self.is_autosign_explicitly_requested or ( + self.autosign is None and not self.is_sign_with_given and not self.force_unsign + ) @property async def should_be_signed(self) -> bool: @@ -183,7 +185,7 @@ class PerformActionsOnTransactionCommand(WorldBasedCommand, ForceableCLICommand, transaction = ( await self.world.commands.perform_actions_on_transaction( content=self.transaction, - sign_key=self.sign_key, + sign_keys=self.sign_key, already_signed_mode=self.already_signed_mode, force_unsign=self.force_unsign, save_file_path=self.save_file_path, diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 68135c41fa..124c349e4f 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -300,9 +300,9 @@ class Commands[WorldT: World]: self, *, content: TransactionConvertibleType, - sign_key: PublicKey | None = None, + sign_keys: PublicKey | list[PublicKey] | None = None, autosign: bool = False, - already_signed_mode: AlreadySignedMode = ALREADY_SIGNED_MODE_DEFAULT, + already_signed_mode: AlreadySignedMode | None = None, force_unsign: bool = False, chain_id: str | None = None, save_file_path: Path | None = None, @@ -318,8 +318,10 @@ class Commands[WorldT: World]: content=content, app_state=self._world.app_state, node=self._world.node, - unlocked_wallet=self._world.beekeeper_manager.user_wallet if sign_key or autosign else None, - sign_key=sign_key, + unlocked_wallet=self._world.beekeeper_manager.user_wallet + if sign_keys is not None or autosign + else None, + sign_keys=sign_keys, already_signed_mode=already_signed_mode, force_unsign=force_unsign, chain_id=chain_id, diff --git a/clive/__private/core/commands/perform_actions_on_transaction.py b/clive/__private/core/commands/perform_actions_on_transaction.py index bbda27a35c..4a2062174f 100644 --- a/clive/__private/core/commands/perform_actions_on_transaction.py +++ b/clive/__private/core/commands/perform_actions_on_transaction.py @@ -15,6 +15,7 @@ from clive.__private.core.commands.save_transaction import SaveTransaction from clive.__private.core.commands.sign import Sign from clive.__private.core.commands.unsign import UnSign from clive.__private.core.constants.data_retrieval import ALREADY_SIGNED_MODE_DEFAULT +from clive.__private.core.keys import PublicKey from clive.__private.models.transaction import Transaction if TYPE_CHECKING: @@ -27,7 +28,6 @@ if TYPE_CHECKING: from pathlib import Path from clive.__private.core.ensure_transaction import TransactionConvertibleType - from clive.__private.core.keys import PublicKey from clive.__private.core.node import Node from clive.__private.core.types import AlreadySignedMode @@ -57,10 +57,11 @@ class PerformActionsOnTransaction(CommandWithResult[Transaction]): app_state: The app state. node: The node used for transaction broadcasting. unlocked_wallet: Required if the transaction needs to be signed. - sign_key: The private key to sign the transaction with. If not provided, the transaction will not be signed. + sign_keys: Single key or list of keys to sign the transaction with. + If a list with multiple keys, uses multisign mode. Cannot be used with autosign. autosign: Whether to automatically sign the transaction. already_signed_mode: How to handle already signed transactions. - force_unsign: Whether to remove the signature from the transaction. Even when sign_key is provided. + force_unsign: Whether to remove the signature from the transaction. Even when sign_keys is provided. chain_id: The chain ID to use when signing the transaction. If not provided, the one from the profile and then from the node get_config api will be used as fallback. save_file_path: The path to save the transaction to. If not provided, the transaction will not be saved. @@ -76,12 +77,16 @@ class PerformActionsOnTransaction(CommandWithResult[Transaction]): app_state: AppState node: Node unlocked_wallet: AsyncUnlockedWallet | None = None - """Required if transaction needs to be signed - when sign_key is provided.""" - sign_key: PublicKey | None = None + """Required if transaction needs to be signed - when sign_keys is provided.""" + sign_keys: PublicKey | list[PublicKey] | None = None autosign: bool = False - already_signed_mode: AlreadySignedMode = ALREADY_SIGNED_MODE_DEFAULT + already_signed_mode: AlreadySignedMode | None = None """ - How to handle already signed transaction. "strict" will just trigger a warning during autosign (will be skipped). + How to handle already signed transaction. + - If None (default): automatically determined based on context + - "multisign" if multiple sign_keys are provided and transaction is not signed yet + - "strict" (default) otherwise + - If explicitly set: uses the provided mode regardless of context """ force_unsign: bool = False chain_id: str | None = None @@ -92,9 +97,9 @@ class PerformActionsOnTransaction(CommandWithResult[Transaction]): async def _execute(self) -> None: transaction = await BuildTransaction(content=self.content, node=self.node).execute_with_result() - if not self.force_unsign and (self.sign_key or self.autosign): - assert self.unlocked_wallet is not None, "wallet is required when sign_key or autosign is provided" - assert not (self.sign_key and self.autosign), "only one of sign_key and autosign can be provided" + if not self.force_unsign and (self.sign_keys or self.autosign): + assert self.unlocked_wallet is not None, "wallet is required when sign_keys or autosign is provided" + assert not (self.sign_keys and self.autosign), "only one of sign_keys and autosign can be provided" if self.autosign: try: @@ -103,19 +108,27 @@ class PerformActionsOnTransaction(CommandWithResult[Transaction]): transaction=transaction, keys=self.app_state.world.profile.keys, chain_id=self.chain_id or await self.node.chain_id, - already_signed_mode=self.already_signed_mode, + already_signed_mode=self.already_signed_mode or ALREADY_SIGNED_MODE_DEFAULT, ).execute_with_result() except TransactionAlreadySignedAutoSignError: # We don't want to raise an error if the transaction is already signed, just skip the signing step. warnings.warn(AutoSignSkippedWarning(), stacklevel=1) - elif self.sign_key: - transaction = await Sign( - unlocked_wallet=self.unlocked_wallet, - transaction=transaction, - key=self.sign_key, - chain_id=self.chain_id or await self.node.chain_id, - already_signed_mode=self.already_signed_mode, - ).execute_with_result() + elif self.sign_keys is not None: + keys_to_sign = self._normalize_sign_keys(self.sign_keys) + + # Determine already_signed_mode - use dynamic evaluation if not explicitly provided + already_signed_mode = self._determine_already_signed_mode( + keys_to_sign=keys_to_sign, transaction=transaction + ) + + for key in keys_to_sign: + transaction = await Sign( + unlocked_wallet=self.unlocked_wallet, + transaction=transaction, + key=key, + chain_id=self.chain_id or await self.node.chain_id, + already_signed_mode=already_signed_mode, + ).execute_with_result() if self.force_unsign: transaction = await UnSign(transaction=transaction).execute_with_result() @@ -129,3 +142,38 @@ class PerformActionsOnTransaction(CommandWithResult[Transaction]): await Broadcast(node=self.node, transaction=transaction).execute() self._result = transaction + + def _determine_already_signed_mode( + self, keys_to_sign: list[PublicKey], transaction: Transaction + ) -> AlreadySignedMode: + """ + Determine the already_signed_mode to use. + + If already_signed_mode is explicitly set, use that value. + Otherwise, use dynamic evaluation: + - "multisign" if multiple keys are given + - default ("strict") otherwise + + Args: + keys_to_sign: The list of keys to sign with. + transaction: The transaction to check (currently not used in logic, but kept for future extensions). + + Returns: + The determined already_signed_mode. + """ + already_signed_mode = self.already_signed_mode + + if already_signed_mode is not None: + # use the explicitly given value + return already_signed_mode + + if len(keys_to_sign) > 1 and not transaction.is_signed: + # if multiple keys are given and transaction is not signed yet, we can safely sign it + return "multisign" + + # fallback to default + return ALREADY_SIGNED_MODE_DEFAULT + + def _normalize_sign_keys(self, keys: PublicKey | list[PublicKey]) -> list[PublicKey]: + """Normalize sign_keys to a list format.""" + return [keys] if isinstance(keys, PublicKey) else keys diff --git a/clive/__private/ui/dialogs/save_transaction_to_file_dialog.py b/clive/__private/ui/dialogs/save_transaction_to_file_dialog.py index e68643b4c4..a2d1af4e8d 100644 --- a/clive/__private/ui/dialogs/save_transaction_to_file_dialog.py +++ b/clive/__private/ui/dialogs/save_transaction_to_file_dialog.py @@ -90,7 +90,7 @@ class SaveTransactionToFileDialog(SaveFileBaseDialog): wrapper = await self.commands.perform_actions_on_transaction( content=transaction, - sign_key=self._sign_key, + sign_keys=self._sign_key, force_unsign=not should_be_signed, save_file_path=file_path, force_save_format="bin" if save_as_binary else "json", diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.py b/clive/__private/ui/screens/transaction_summary/transaction_summary.py index 034ab5b192..206d272484 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.py +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.py @@ -276,7 +276,7 @@ class TransactionSummary(BaseScreen): wrapper = await self.commands.perform_actions_on_transaction( content=transaction, - sign_key=sign_key, + sign_keys=sign_key, broadcast=True, ) if wrapper.error_occurred: diff --git a/tests/unit/commands/test_transaction_status.py b/tests/unit/commands/test_transaction_status.py index 800e0be610..4cb13a955b 100644 --- a/tests/unit/commands/test_transaction_status.py +++ b/tests/unit/commands/test_transaction_status.py @@ -29,7 +29,7 @@ async def test_transaction_status_in_blockchain( ).result_or_raise operation = TransferOperation(from_="initminer", to="null", amount=Asset.hive(2), memo="for testing") transaction = ( - await world.commands.perform_actions_on_transaction(content=operation, sign_key=pubkey, broadcast=True) + await world.commands.perform_actions_on_transaction(content=operation, sign_keys=pubkey, broadcast=True) ).result_or_raise init_node_extra_apis.wait_number_of_blocks(1) -- GitLab From cee79be3e0a142808d58cd84b73f1f3961e22e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Mon, 8 Dec 2025 14:22:16 +0000 Subject: [PATCH 2/9] ALREADY_SIGNED_MODES and ALREADY_SIGNED_MODE_DEFAULT to new transaction.py file --- .../abc/perform_actions_on_transaction_command.py | 2 +- clive/__private/cli/process/main.py | 2 +- clive/__private/core/commands/autosign.py | 2 +- clive/__private/core/commands/commands.py | 2 +- .../core/commands/perform_actions_on_transaction.py | 2 +- clive/__private/core/commands/sign.py | 2 +- clive/__private/core/constants/data_retrieval.py | 4 ---- clive/__private/core/constants/transaction.py | 8 ++++++++ 8 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 clive/__private/core/constants/transaction.py 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 4cf6e74932..8309b458d9 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 @@ -25,7 +25,7 @@ from clive.__private.cli.exceptions import ( from clive.__private.cli.print_cli import print_cli 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 +from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT from clive.__private.core.ensure_transaction import ensure_transaction from clive.__private.core.formatters.humanize import humanize_validation_result from clive.__private.core.keys.key_manager import MultipleKeysFoundError diff --git a/clive/__private/cli/process/main.py b/clive/__private/cli/process/main.py index 4a54635bca..e3c1d8540c 100644 --- a/clive/__private/cli/process/main.py +++ b/clive/__private/cli/process/main.py @@ -27,7 +27,7 @@ from clive.__private.cli.process.update_authority import get_update_authority_ty from clive.__private.cli.process.vote_proposal import vote_proposal from clive.__private.cli.process.vote_witness import vote_witness from clive.__private.cli.process.voting_rights import voting_rights -from clive.__private.core.constants.data_retrieval import ALREADY_SIGNED_MODE_DEFAULT +from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT from clive.__private.core.types import AlreadySignedMode # noqa: TC001 if TYPE_CHECKING: diff --git a/clive/__private/core/commands/autosign.py b/clive/__private/core/commands/autosign.py index ff9a8ff5c8..40121d6dbf 100644 --- a/clive/__private/core/commands/autosign.py +++ b/clive/__private/core/commands/autosign.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Final from clive.__private.core.commands.abc.command import Command, CommandError from clive.__private.core.commands.abc.command_in_unlocked import CommandInUnlocked from clive.__private.core.commands.abc.command_with_result import CommandWithResult -from clive.__private.core.constants.data_retrieval import ALREADY_SIGNED_MODE_DEFAULT +from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT from clive.__private.core.iwax import calculate_sig_digest from clive.__private.core.keys.key_manager import KeyManager, KeyNotFoundError, MultipleKeysFoundError from clive.__private.models.transaction import Transaction diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 124c349e4f..1da81d024d 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Any, Literal, overload from clive.__private.core.commands.abc.command_with_result import CommandResultT, CommandWithResult from clive.__private.core.commands.command_wrappers import CommandWithResultWrapper, CommandWrapper, NoOpWrapper from clive.__private.core.constants.data_retrieval import ( - ALREADY_SIGNED_MODE_DEFAULT, ORDER_DIRECTION_DEFAULT, PROPOSAL_ORDER_DEFAULT, PROPOSAL_STATUS_DEFAULT, @@ -13,6 +12,7 @@ from clive.__private.core.constants.data_retrieval import ( WITNESSES_SEARCH_MODE_DEFAULT, ) from clive.__private.core.constants.date import TRANSACTION_EXPIRATION_TIMEDELTA_DEFAULT +from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT from clive.__private.core.constants.wallet_recovery import ( USER_WALLET_RECOVERED_MESSAGE, USER_WALLET_RECOVERED_NOTIFICATION_LEVEL, diff --git a/clive/__private/core/commands/perform_actions_on_transaction.py b/clive/__private/core/commands/perform_actions_on_transaction.py index 4a2062174f..df47c5bd7e 100644 --- a/clive/__private/core/commands/perform_actions_on_transaction.py +++ b/clive/__private/core/commands/perform_actions_on_transaction.py @@ -14,7 +14,7 @@ from clive.__private.core.commands.build_transaction import BuildTransaction from clive.__private.core.commands.save_transaction import SaveTransaction from clive.__private.core.commands.sign import Sign from clive.__private.core.commands.unsign import UnSign -from clive.__private.core.constants.data_retrieval import ALREADY_SIGNED_MODE_DEFAULT +from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT from clive.__private.core.keys import PublicKey from clive.__private.models.transaction import Transaction diff --git a/clive/__private/core/commands/sign.py b/clive/__private/core/commands/sign.py index 28a395847b..db2c0fb1ed 100644 --- a/clive/__private/core/commands/sign.py +++ b/clive/__private/core/commands/sign.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Final from clive.__private.core.commands.abc.command import Command, CommandError from clive.__private.core.commands.abc.command_in_unlocked import CommandInUnlocked from clive.__private.core.commands.abc.command_with_result import CommandWithResult -from clive.__private.core.constants.data_retrieval import ALREADY_SIGNED_MODE_DEFAULT +from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT from clive.__private.core.iwax import calculate_sig_digest from clive.__private.models.transaction import Transaction diff --git a/clive/__private/core/constants/data_retrieval.py b/clive/__private/core/constants/data_retrieval.py index fb07c91166..178e1db6a8 100644 --- a/clive/__private/core/constants/data_retrieval.py +++ b/clive/__private/core/constants/data_retrieval.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Final, get_args from clive.__private.core.types import ( - AlreadySignedMode, OrderDirections, ProposalOrders, ProposalStatuses, @@ -21,6 +20,3 @@ PROPOSAL_STATUS_DEFAULT: Final[ProposalStatuses] = "votable" WITNESSES_SEARCH_MODE_DEFAULT: Final[WitnessesSearchModes] = "search_top_with_voted_first" WITNESSES_SEARCH_BY_PATTERN_LIMIT_DEFAULT: Final[int] = 50 - -ALREADY_SIGNED_MODES: Final[tuple[AlreadySignedMode, ...]] = get_args(AlreadySignedMode) -ALREADY_SIGNED_MODE_DEFAULT: Final[AlreadySignedMode] = "strict" diff --git a/clive/__private/core/constants/transaction.py b/clive/__private/core/constants/transaction.py new file mode 100644 index 0000000000..1c22f310db --- /dev/null +++ b/clive/__private/core/constants/transaction.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing import Final, get_args + +from clive.__private.core.types import AlreadySignedMode + +ALREADY_SIGNED_MODES: Final[tuple[AlreadySignedMode, ...]] = get_args(AlreadySignedMode) +ALREADY_SIGNED_MODE_DEFAULT: Final[AlreadySignedMode] = "strict" -- GitLab From a8913846c83d2b6413f3b689d4c29de2c5411f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 28 Nov 2025 13:37:07 +0000 Subject: [PATCH 3/9] Apply additional serialization_mode argument on actions performing on transaction --- clive/__private/core/commands/commands.py | 5 ++++- .../core/commands/perform_actions_on_transaction.py | 9 +++++++-- clive/__private/core/commands/save_transaction.py | 12 +++++++++++- clive/__private/core/constants/transaction.py | 5 ++++- clive/__private/core/types.py | 2 ++ 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 1da81d024d..de8f46c43f 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -12,7 +12,7 @@ from clive.__private.core.constants.data_retrieval import ( WITNESSES_SEARCH_MODE_DEFAULT, ) from clive.__private.core.constants.date import TRANSACTION_EXPIRATION_TIMEDELTA_DEFAULT -from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT +from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT, DEFAULT_SERIALIZATION_MODE from clive.__private.core.constants.wallet_recovery import ( USER_WALLET_RECOVERED_MESSAGE, USER_WALLET_RECOVERED_NOTIFICATION_LEVEL, @@ -58,6 +58,7 @@ if TYPE_CHECKING: OrderDirections, ProposalOrders, ProposalStatuses, + SerializationMode, WitnessesSearchModes, ) from clive.__private.core.world import World @@ -307,6 +308,7 @@ class Commands[WorldT: World]: chain_id: str | None = None, save_file_path: Path | None = None, force_save_format: Literal["json", "bin"] | None = None, + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE, broadcast: bool = False, ) -> CommandWithResultWrapper[Transaction]: from clive.__private.core.commands.perform_actions_on_transaction import ( # noqa: PLC0415 @@ -327,6 +329,7 @@ class Commands[WorldT: World]: chain_id=chain_id, save_file_path=save_file_path, force_save_format=force_save_format, + serialization_mode=serialization_mode, broadcast=broadcast, autosign=autosign, ) diff --git a/clive/__private/core/commands/perform_actions_on_transaction.py b/clive/__private/core/commands/perform_actions_on_transaction.py index df47c5bd7e..145d6e2224 100644 --- a/clive/__private/core/commands/perform_actions_on_transaction.py +++ b/clive/__private/core/commands/perform_actions_on_transaction.py @@ -14,7 +14,7 @@ from clive.__private.core.commands.build_transaction import BuildTransaction from clive.__private.core.commands.save_transaction import SaveTransaction from clive.__private.core.commands.sign import Sign from clive.__private.core.commands.unsign import UnSign -from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT +from clive.__private.core.constants.transaction import ALREADY_SIGNED_MODE_DEFAULT, DEFAULT_SERIALIZATION_MODE from clive.__private.core.keys import PublicKey from clive.__private.models.transaction import Transaction @@ -22,6 +22,7 @@ if TYPE_CHECKING: from beekeepy import AsyncUnlockedWallet from clive.__private.core.app_state import AppState + from clive.__private.core.types import SerializationMode if TYPE_CHECKING: @@ -92,6 +93,7 @@ class PerformActionsOnTransaction(CommandWithResult[Transaction]): chain_id: str | None = None save_file_path: Path | None = None force_save_format: Literal["json", "bin"] | None = None + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE broadcast: bool = False async def _execute(self) -> None: @@ -135,7 +137,10 @@ class PerformActionsOnTransaction(CommandWithResult[Transaction]): if path := self.save_file_path: await SaveTransaction( - transaction=transaction, file_path=path, force_format=self.force_save_format + transaction=transaction, + file_path=path, + force_format=self.force_save_format, + serialization_mode=self.serialization_mode, ).execute() if self.broadcast: diff --git a/clive/__private/core/commands/save_transaction.py b/clive/__private/core/commands/save_transaction.py index da3531ef29..b9031591d9 100644 --- a/clive/__private/core/commands/save_transaction.py +++ b/clive/__private/core/commands/save_transaction.py @@ -5,10 +5,12 @@ from typing import TYPE_CHECKING, Literal from clive.__private.core import iwax from clive.__private.core.commands.abc.command import Command +from clive.__private.core.constants.transaction import DEFAULT_SERIALIZATION_MODE if TYPE_CHECKING: from pathlib import Path + from clive.__private.core.types import SerializationMode from clive.__private.models.transaction import Transaction @@ -16,10 +18,14 @@ if TYPE_CHECKING: class SaveTransaction(Command): transaction: Transaction file_path: Path + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE force_format: Literal["json", "bin"] | None = None """If not provided, the format will be determined by the file extension automatically.""" async def _execute(self) -> None: + if self.serialization_mode == "legacy": + raise NotImplementedError("Legacy serialization mode is not yet implemented for saving transactions.") + if self.force_format == "json": self.__save_as_json() elif self.force_format == "bin": @@ -28,7 +34,11 @@ class SaveTransaction(Command): self.__save_as_binary() if self.__should_save_as_binary() else self.__save_as_json() def __save_as_json(self) -> None: - serialized = self.transaction.json(order="sorted", indent=4) + serialized = self.transaction.json( + order="sorted", + indent=4, + serialization_mode=self.serialization_mode if self.serialization_mode else "hf26", + ) self.file_path.write_text(serialized) def __save_as_binary(self) -> None: diff --git a/clive/__private/core/constants/transaction.py b/clive/__private/core/constants/transaction.py index 1c22f310db..3c9db47ad0 100644 --- a/clive/__private/core/constants/transaction.py +++ b/clive/__private/core/constants/transaction.py @@ -2,7 +2,10 @@ from __future__ import annotations from typing import Final, get_args -from clive.__private.core.types import AlreadySignedMode +from clive.__private.core.types import AlreadySignedMode, SerializationMode ALREADY_SIGNED_MODES: Final[tuple[AlreadySignedMode, ...]] = get_args(AlreadySignedMode) ALREADY_SIGNED_MODE_DEFAULT: Final[AlreadySignedMode] = "strict" + +SERIALIZATION_MODES: Final[tuple[SerializationMode, ...]] = get_args(SerializationMode) +DEFAULT_SERIALIZATION_MODE: Final[SerializationMode] = "hf26" diff --git a/clive/__private/core/types.py b/clive/__private/core/types.py index 82b833f62f..881af17e12 100644 --- a/clive/__private/core/types.py +++ b/clive/__private/core/types.py @@ -17,6 +17,8 @@ ProposalStatuses = Literal["all", "active", "inactive", "votable", "expired"] WitnessesSearchModes = Literal["search_by_pattern", "search_top_with_voted_first"] +SerializationMode = Literal["legacy", "hf26"] + AuthorityLevelRegular = Literal["owner", "active", "posting"] AuthorityLevelMemo = Literal["memo"] AuthorityLevel = Literal["owner", "active", "posting", "memo"] -- GitLab From 3eedc8c2d9b97f6d8dbab136a7ff6b1b3662ec23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 3 Oct 2025 13:18:32 +0000 Subject: [PATCH 4/9] Prepare python script interface for clive --- .../core/commands/save_transaction.py | 1 - clive/__private/si/__init__.py | 8 + clive/__private/si/base.py | 152 ++++++ clive/__private/si/configure.py | 22 + clive/__private/si/core/__init__.py | 0 clive/__private/si/core/base.py | 19 + clive/__private/si/core/generate.py | 24 + clive/__private/si/core/process/__init__.py | 0 .../si/core/process/authority_operations.py | 131 +++++ clive/__private/si/core/process/chaining.py | 467 ++++++++++++++++++ clive/__private/si/core/process/process.py | 408 +++++++++++++++ clive/__private/si/core/show.py | 134 +++++ clive/__private/si/data_classes.py | 79 +++ clive/__private/si/exceptions.py | 116 +++++ clive/__private/si/generate.py | 19 + clive/__private/si/process.py | 173 +++++++ clive/__private/si/show.py | 74 +++ clive/__private/si/validators.py | 134 +++++ clive/si/__init__.py | 67 +++ 19 files changed, 2027 insertions(+), 1 deletion(-) create mode 100644 clive/__private/si/__init__.py create mode 100644 clive/__private/si/base.py create mode 100644 clive/__private/si/configure.py create mode 100644 clive/__private/si/core/__init__.py create mode 100644 clive/__private/si/core/base.py create mode 100644 clive/__private/si/core/generate.py create mode 100644 clive/__private/si/core/process/__init__.py create mode 100644 clive/__private/si/core/process/authority_operations.py create mode 100644 clive/__private/si/core/process/chaining.py create mode 100644 clive/__private/si/core/process/process.py create mode 100644 clive/__private/si/core/show.py create mode 100644 clive/__private/si/data_classes.py create mode 100644 clive/__private/si/exceptions.py create mode 100644 clive/__private/si/generate.py create mode 100644 clive/__private/si/process.py create mode 100644 clive/__private/si/show.py create mode 100644 clive/__private/si/validators.py create mode 100644 clive/si/__init__.py diff --git a/clive/__private/core/commands/save_transaction.py b/clive/__private/core/commands/save_transaction.py index b9031591d9..111739dadd 100644 --- a/clive/__private/core/commands/save_transaction.py +++ b/clive/__private/core/commands/save_transaction.py @@ -37,7 +37,6 @@ class SaveTransaction(Command): serialized = self.transaction.json( order="sorted", indent=4, - serialization_mode=self.serialization_mode if self.serialization_mode else "hf26", ) self.file_path.write_text(serialized) diff --git a/clive/__private/si/__init__.py b/clive/__private/si/__init__.py new file mode 100644 index 0000000000..d3b202a3fd --- /dev/null +++ b/clive/__private/si/__init__.py @@ -0,0 +1,8 @@ +""" +Clive Script Interface (SI) - Internal implementation. + +This module contains the internal implementation of the Script Interface. +For public API, use: from clive.si import CliveSi, UnlockedCliveSi, clive_use_unlocked_profile +""" + +from __future__ import annotations diff --git a/clive/__private/si/base.py b/clive/__private/si/base.py new file mode 100644 index 0000000000..98a9a7615a --- /dev/null +++ b/clive/__private/si/base.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Self, override + +from clive.__private.before_launch import prepare_before_launch +from clive.__private.core.world import World +from clive.__private.si.configure import ConfigureInterface +from clive.__private.si.exceptions import SIContextManagerNotUsedError +from clive.__private.si.generate import GenerateInterface +from clive.__private.si.process import ProfileOperationsInterface +from clive.__private.si.show import ShowInterface, ShowInterfaceNoProfile + +if TYPE_CHECKING: + from types import TracebackType + + +class SIWorld(World): + """ + World specialized for Script Interface (SI) usage. + + Automatically loads unlocked profile during setup, similar to CLIWorld and TUIWorld. + This ensures that SI operations always have access to a loaded profile. + """ + + @override + async def _setup(self) -> None: + await super()._setup() + await self.load_profile_based_on_beekepeer() + + +def clive_use_unlocked_profile() -> UnlockedCliveSi: + """ + Factory function to create a Clive SI instance with an unlocked profile. + + IMPORTANT: UnlockedCliveSi MUST be used as an async context manager (with 'async with' statement). + This ensures proper initialization (profile loading) and cleanup (profile saving). + + Returns: + UnlockedCliveSi instance configured to use an already unlocked profile. + + Example: + async with clive_use_unlocked_profile() as clive: + await clive.process.transfer(...).broadcast() + """ + return UnlockedCliveSi() + + +class CliveSi: + """ + Main entry point for Clive Script Interface without profile context. + + Provides access to read-only operations that don't require a profile. + For operations requiring a profile, use UnlockedCliveSi instead. + + Example: + async with CliveSi() as clive: + profiles = await clive.show.profiles() + """ + + def __init__(self) -> None: + self.show = ShowInterfaceNoProfile() + self.generate = GenerateInterface() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, _: type[BaseException] | None, ex: BaseException | None, ___: TracebackType | None + ) -> None: + pass + + +class UnlockedCliveSi: + """ + Clive Script Interface with profile context. + + This class provides full access to Clive functionality including operations + that require an unlocked profile (transfers, authority updates, etc.). + + IMPORTANT: This class MUST be used as an async context manager (with 'async with' statement). + The context manager ensures: + - Profile is automatically loaded from the unlocked beekeeper wallet on entry (__aenter__) + - Profile is automatically saved to storage on exit (__aexit__) + - Proper cleanup of resources (node, wax interface, beekeeper) + + Use the factory function or context manager: + async with clive_use_unlocked_profile() as clive: + await clive.process.transfer(...).broadcast() + + Or directly: + async with UnlockedCliveSi() as clive: + await clive.process.transfer(...).broadcast() + + Note: Methods __aenter__ and __aexit__ delegate to setup() and close() respectively, + following the same pattern as CLIWorld and TUIWorld. + """ + + def __init__(self) -> None: + self._world = SIWorld() + self._is_setup_called = False + self.__show = ShowInterface(self) + self.__process = ProfileOperationsInterface(self) + self.__configure = ConfigureInterface(self) + self.generate = GenerateInterface() + self.__prepare_before_launch() + + async def __aenter__(self) -> Self: + await self.setup() + return self + + async def __aexit__( + self, _: type[BaseException] | None, ex: BaseException | None, ___: TracebackType | None + ) -> None: + await self.close() + + @property + def show(self) -> ShowInterface: + """Access to show operations. Requires context manager to be entered.""" + self._ensure_setup_called() + return self.__show + + @property + def process(self) -> ProfileOperationsInterface: + """Access to process operations. Requires context manager to be entered.""" + self._ensure_setup_called() + return self.__process + + @property + def configure(self) -> ConfigureInterface: + """Access to configure operations. Requires context manager to be entered.""" + self._ensure_setup_called() + return self.__configure + + async def setup(self) -> None: + """Initialize the SI world and load profile. Called automatically by context manager.""" + await self._world.setup() + self._is_setup_called = True + + async def close(self) -> None: + """Cleanup resources and save profile. Called automatically by context manager.""" + await self._world.close() + + def __prepare_before_launch(self) -> None: + prepare_before_launch() + + def _ensure_setup_called(self) -> None: + """Ensure setup was called by checking if context manager was used.""" + if not self._is_setup_called: + raise SIContextManagerNotUsedError( + "UnlockedCliveSi must be used as an async context manager. " + "Use 'async with UnlockedCliveSi() as clive:' or 'async with clive_use_unlocked_profile() as clive:'" + ) diff --git a/clive/__private/si/configure.py b/clive/__private/si/configure.py new file mode 100644 index 0000000000..387e711e3e --- /dev/null +++ b/clive/__private/si/configure.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from clive.__private.si.base import UnlockedCliveSi + + +class ConfigureInterface: + """Interface for profile configuration actions (create/load).""" + + def __init__(self, clive_instance: UnlockedCliveSi) -> None: + self.clive = clive_instance + + async def profile_load(self) -> None: + """ + Reload the currently unlocked profile from storage. + + This method is rarely needed as SIWorld automatically loads the unlocked profile during setup. + Use this only if you need to refresh the profile state after external changes. + """ + await self.clive._world.load_profile_based_on_beekepeer() diff --git a/clive/__private/si/core/__init__.py b/clive/__private/si/core/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/clive/__private/si/core/base.py b/clive/__private/si/core/base.py new file mode 100644 index 0000000000..4cde323fa4 --- /dev/null +++ b/clive/__private/si/core/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TypeVar + +T = TypeVar("T") + + +class CommandBase[T](ABC): + async def validate(self) -> None: # noqa: B027 + pass + + async def run(self) -> T: + await self.validate() + return await self._run() + + @abstractmethod + async def _run(self) -> T: + """Run the command logic.""" diff --git a/clive/__private/si/core/generate.py b/clive/__private/si/core/generate.py new file mode 100644 index 0000000000..842617c6ff --- /dev/null +++ b/clive/__private/si/core/generate.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from clive.__private.core.keys.keys import PrivateKey +from clive.__private.si.core.base import CommandBase +from clive.__private.si.data_classes import KeyPair +from clive.__private.si.validators import ( + KeyPairsNumberValidator, +) + + +class GenerateRandomKey(CommandBase[list[KeyPair]]): + def __init__(self, key_pairs: int) -> None: + self.key_pairs = key_pairs + + async def validate(self) -> None: + KeyPairsNumberValidator().validate(self.key_pairs) + + async def _run(self) -> list[KeyPair]: + key_pairs_list = [] + for _ in range(self.key_pairs): + private_key = PrivateKey.generate() + public_key = private_key.calculate_public_key() + key_pairs_list.append(KeyPair(private_key=private_key.value, public_key=public_key.value)) + return key_pairs_list diff --git a/clive/__private/si/core/process/__init__.py b/clive/__private/si/core/process/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/clive/__private/si/core/process/authority_operations.py b/clive/__private/si/core/process/authority_operations.py new file mode 100644 index 0000000000..ca28124369 --- /dev/null +++ b/clive/__private/si/core/process/authority_operations.py @@ -0,0 +1,131 @@ +"""Authority modification operations for ProcessAuthority.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.models.schemas import AccountName, AccountUpdate2Operation, Authority, HiveInt, PublicKey + +if TYPE_CHECKING: + from collections.abc import Callable + + from clive.__private.core.types import AuthorityLevelRegular + from clive.__private.models.schemas import Account + + +class AuthorityAlreadyExistsError(Exception): + """Raised when trying to add a key or account that already exists in authority.""" + + +class AuthorityNotFoundError(Exception): + """Raised when trying to remove or modify a key or account that doesn't exist in authority.""" + + +def set_threshold(auth: Authority, threshold: int) -> Authority: + """Set the weight threshold for an authority.""" + auth.weight_threshold = HiveInt(threshold) + return auth + + +def add_key(auth: Authority, key: str, weight: int) -> Authority: + """Add a key to the authority.""" + # Check if key already exists + if any(key == k for k, _ in auth.key_auths): + raise AuthorityAlreadyExistsError(f"Key {key} is already in authority") + + key_weight_tuple = (PublicKey(key), HiveInt(weight)) + auth.key_auths.append(key_weight_tuple) + return auth + + +def add_account(auth: Authority, account: str, weight: int) -> Authority: + """Add an account to the authority.""" + # Check if account already exists + if any(account == acc for acc, _ in auth.account_auths): + raise AuthorityAlreadyExistsError(f"Account {account} is already in authority") + + account_weight_tuple = (AccountName(account), HiveInt(weight)) + auth.account_auths.append(account_weight_tuple) + return auth + + +def remove_key(auth: Authority, key: str) -> Authority: + """Remove a key from the authority.""" + # Check if key exists + if not any(key == k for k, _ in auth.key_auths): + raise AuthorityNotFoundError(f"Key {key} is not in authority") + + auth.key_auths = [kw for kw in auth.key_auths if kw[0] != key] + return auth + + +def remove_account(auth: Authority, account: str) -> Authority: + """Remove an account from the authority.""" + # Check if account exists + if not any(account == acc for acc, _ in auth.account_auths): + raise AuthorityNotFoundError(f"Account {account} is not in authority") + + auth.account_auths = [aw for aw in auth.account_auths if aw[0] != account] + return auth + + +def modify_key(auth: Authority, key: str, weight: int) -> Authority: + """Modify the weight of a key in the authority.""" + auth = remove_key(auth, key) + return add_key(auth, key, weight) + + +def modify_account(auth: Authority, account: str, weight: int) -> Authority: + """Modify the weight of an account in the authority.""" + auth = remove_account(auth, account) + return add_account(auth, account, weight) + + +def build_account_update_operation( + account: Account, + authority_type: AuthorityLevelRegular, + callbacks: list[Callable[[Authority], Authority]], +) -> AccountUpdate2Operation: + """ + Build an account_update2_operation by applying callbacks to modify an authority. + + Args: + account: Current account state from blockchain + authority_type: Which authority to modify ('owner', 'active', 'posting') + callbacks: List of functions to apply to the authority + + Returns: + AccountUpdate2Operation with only the modified authority field set + """ + # Create operation from current account state + operation = AccountUpdate2Operation( + account=account.name, + owner=account.owner, + active=account.active, + posting=account.posting, + memo_key=account.memo_key, + json_metadata="", + posting_json_metadata="", + extensions=[], + ) + + # Get the authority we're modifying + authority = getattr(operation, authority_type) + + # Apply all callbacks to modify the authority + for callback in callbacks: + authority = callback(authority) + + # Set the modified authority back + setattr(operation, authority_type, authority) + + # Only include the modified authority field in the operation + # Set all other authority fields to None + for attr in ["owner", "active", "posting"]: + if attr != authority_type: + setattr(operation, attr, None) + + # Set memo_key to None (we don't modify it in authority updates) + operation.memo_key = None + + return operation diff --git a/clive/__private/si/core/process/chaining.py b/clive/__private/si/core/process/chaining.py new file mode 100644 index 0000000000..a5451d8a9d --- /dev/null +++ b/clive/__private/si/core/process/chaining.py @@ -0,0 +1,467 @@ +""" +Chaining interface implementation for Clive SI. + +This module implements a fluent API with method chaining for blockchain operations. +The interface is organized into groups of commands that can be called in sequence: + +1. Main commands (e.g., transfer, transaction, update_authority) +2. Sub-commands (optional, command-specific, e.g., add_key for update_authority) +3. Process chaining (.process property) - allows adding multiple operations to a single transaction +4. Signing/Finalizing commands (autosign, sign_with, broadcast, save_file, as_transaction_object) + +Key features: +- Groups can be called in flexible order +- Sub-commands (group 2) are specific to each main command and cannot be used with others +- Use .process to add another operation to the same transaction +- Finalizing commands can be called directly from main commands or after sub-commands +- Multiple operations are bundled into a single transaction when finalized + +Example: + double_transfer = await clive.process.transfer( + from_account="alice", + to_account="gtg", + amount="1.000 HIVE", + memo="Test transfer", + ).process.transfer( + from_account="alice", + to_account="bob", + amount="2.000 HIVE", + memo="Test transfer2", + ).sign_with(key="STM5...").as_transaction_object() +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Literal, Self + +from clive.__private.core.commands.broadcast import Broadcast +from clive.__private.core.commands.save_transaction import SaveTransaction +from clive.__private.core.constants.transaction import DEFAULT_SERIALIZATION_MODE +from clive.__private.core.world import World +from clive.__private.models.transaction import Transaction +from clive.__private.si.core.process.process import ( + AuthorityBuilder, + MultipleOperationsBuilder, + OperationBuilder, +) + +if TYPE_CHECKING: + from clive.__private.core.node import Node + from clive.__private.core.types import AuthorityLevelRegular, SerializationMode + from clive.__private.core.world import World + from clive.__private.si.process import ChainingOperationsInterface + + +async def broadcast_transaction( + node: Node, + transaction: Transaction, +) -> None: + """Broadcast the transaction to the blockchain.""" + await Broadcast(node=node, transaction=transaction).execute() + + +async def save_transaction_to_file( + transaction: Transaction, + file_path: str | Path, + file_format: Literal["json", "bin"] | None = None, + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE, +) -> None: + """Save the transaction to a file.""" + await SaveTransaction( + transaction=transaction, + file_path=Path(file_path), + force_format=file_format, + serialization_mode=serialization_mode, + ).execute() + + +class TransactionResult(Transaction): + """ + Transaction result with convenience methods for broadcasting and saving. + + This class extends the base Transaction with additional methods that allow + direct broadcasting to the blockchain or saving to a file without needing + to go through the full command infrastructure. + """ + + def __str__(self) -> str: + return self.json() + + async def broadcast(self) -> None: + """Broadcast the transaction to the blockchain.""" + await broadcast_transaction(node=self._get_world().node, transaction=self) + + async def save_file( + self, + path: str | Path, + file_format: Literal["json", "bin"] | None = None, + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE, + ) -> None: + """Save the transaction to a file.""" + await save_transaction_to_file( + transaction=self, file_path=Path(path), file_format=file_format, serialization_mode=serialization_mode + ) + + def _get_world(self) -> World: + raise NotImplementedError + + +class ChainError(Exception): + """Base exception for chaining interface errors.""" + + +class ChainInvalidSubcommandError(ChainError): + """Raised when a sub-command is used with incompatible main command.""" + + +class TransactionFinalizer: + """ + Base class providing transaction finalization methods. + + This mixin provides common methods for finalizing operations: + - broadcast(): Sign and broadcast to blockchain + - save_file(): Sign and save to file + - as_transaction_object(): Get signed transaction object + + Subclasses must implement _get_signing_configuration() to provide + signing keys and autosign preferences. + """ + + # These attributes are provided by subclasses + world: World + _current_operation: OperationBuilder | None + _all_operations: list[OperationBuilder] + + def __init__(self, world: World, all_operations: list[OperationBuilder]) -> None: + self.world = world + self._all_operations = all_operations + + async def broadcast(self) -> None: + """Broadcast the transaction to the blockchain.""" + sign_with, autosign = self._get_signing_configuration() + await self._build_and_execute_transaction( + world=self.world, + builders=self._all_operations, + sign_with=sign_with, + save_file=None, + broadcast=True, + autosign=autosign, + ) + + async def save_file( + self, + path: str | Path, + file_format: Literal["json", "bin"] = "json", + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE, + ) -> None: + """Save the transaction to a file.""" + sign_with, autosign = self._get_signing_configuration() + await self._build_and_execute_transaction( + world=self.world, + builders=self._all_operations, + sign_with=sign_with, + save_file=path, + file_format=file_format, + serialization_mode=serialization_mode, + broadcast=False, + autosign=autosign, + ) + + async def as_transaction_object(self) -> TransactionResult: + """Get the transaction without broadcasting or saving.""" + sign_with, autosign = self._get_signing_configuration() + transaction = await self._build_and_execute_transaction( + world=self.world, + builders=self._all_operations, + sign_with=sign_with, + save_file=None, + broadcast=False, + autosign=autosign, + ) + return create_transaction_result_class(world=self.world)(**transaction.dict()) + + def _get_signing_configuration(self) -> tuple[list[str], bool]: + """Return (signing_keys, autosign_flag) for transaction finalization.""" + return [], False + + async def _build_and_execute_transaction( # noqa: PLR0913 + self, + world: World, + builders: list[OperationBuilder], + sign_with: list[str], + save_file: str | Path | None, + file_format: Literal["json", "bin"] | None = None, + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE, + *, + broadcast: bool, + autosign: bool, + ) -> Transaction: + """ + Build transaction from operation builders and execute requested actions. + + Handles both single and multiple operations, automatically choosing the + appropriate finalization strategy. For multiple operations, combines them + into a single transaction. + + Args: + world: World instance providing blockchain context + builders: List of OperationBuilder instances to process + sign_with: List of key aliases to sign with (if any) + save_file: File path to save to (if any) + file_format: File format for saving (json or bin) + serialization_mode: Serialization mode (legacy or hf26) + broadcast: Whether to broadcast to blockchain + autosign: Whether to automatically sign with available keys + + Returns: + Finalized transaction + """ + if len(builders) == 1: + # Single operation - use existing run method + return await builders[0].run( + sign_with=sign_with, + save_file=save_file, + broadcast=broadcast, + autosign=autosign, + file_format=file_format, + serialization_mode=serialization_mode, + ) + # Multiple operations - combine into single transaction + + multi_builder = MultipleOperationsBuilder( + world=world, + builders=builders, + ) + return await multi_builder.run( + sign_with=sign_with, + save_file=save_file, + broadcast=broadcast, + autosign=autosign, + file_format=file_format, + serialization_mode=serialization_mode, + ) + + +class ChainableOperationBuilder(TransactionFinalizer): + """ + Represents a chain of blockchain operations being built. + + This class manages: + - Previously built operations from earlier chain steps + - The current operation being configured + - Access to finalization methods (broadcast, save_file, as_transaction_object) + - Access to the .process property for adding more operations + - Access to signing configuration (sign_with, autosign) + + The chain pattern allows fluent API like: + await clive.process.transfer(...).process.transfer(...).broadcast() + """ + + def __init__(self, world: World, previous_operations: list[OperationBuilder] | None = None) -> None: + self._previous_operations = previous_operations or [] + self._current_operation: OperationBuilder | None = None + super().__init__(world, all_operations=[]) # Will be provided via property + + @property + def _all_operations(self) -> list[OperationBuilder]: + """Get all operations including previous operations and current operation being built.""" + if self._current_operation is None: + return self._previous_operations + return [*self._previous_operations, self._current_operation] + + @_all_operations.setter + def _all_operations(self, value: list[OperationBuilder]) -> None: + """Setter required by parent class but not used.""" + # This setter exists only to satisfy parent class requirements + # The actual value is computed dynamically via the getter + + @property + def process(self) -> ChainingOperationsInterface: + """Return interface to add another operation to the chain.""" + from clive.__private.si.process import ChainingOperationsInterface # noqa: PLC0415 + + assert self._current_operation is not None, "Operation must be set before chaining" + + return ChainingOperationsInterface(world=self.world, all_operations=self._all_operations) + + def sign_with(self, key: str) -> SigningChain: + """Configure transaction to be signed with specific key. Can be chained for multiple signatures.""" + return SigningChain(self.world, self._all_operations, sign_keys=[key]) + + def autosign(self) -> AutoSignChain: + """Configure transaction to be auto-signed. Does NOT allow chaining with sign_with().""" + return AutoSignChain(self.world, self._all_operations) + + +def create_transaction_result_class(world: World) -> type[TransactionResult]: + """Factory function that creates a TransactionResult class with world context.""" + + class TransactionResultImplementation(TransactionResult): + def _get_world(self) -> World: + return world + + return TransactionResultImplementation + + +class SigningChain(TransactionFinalizer): + """ + Chain for configuring transaction signing with specific keys. + + Allows multiple sign_with() calls to add multiple signing keys. + Each call adds another key to the list of keys that will be used + to sign the transaction when finalized. + """ + + def __init__( + self, + world: World, + all_operations: list[OperationBuilder], + sign_keys: list[str], + ) -> None: + super().__init__(world, all_operations) + self._sign_keys = sign_keys + + def sign_with(self, key: str) -> Self: + """Add another key to sign the transaction with.""" + self._sign_keys.append(key) + return self + + def _get_signing_configuration(self) -> tuple[list[str], bool]: + """Provide signing parameters for multiple keys.""" + return self._sign_keys, False + + +class AutoSignChain(TransactionFinalizer): + """ + Chain for automatic transaction signing. + + Uses automatic signing - the system will determine and use the appropriate + keys from the profile. Does NOT allow manual sign_with() calls. + """ + + def _get_signing_configuration(self) -> tuple[list[str], bool]: + """Provide signing parameters for autosign.""" + return [], True + + +class AuthorityUpdateChain(ChainableOperationBuilder): + """ + Chain interface for building authority update operations. + + Provides methods to modify account authorities (owner, active, posting): + - add_key / remove_key / modify_key: Manage key-based authorities + - add_account / remove_account / modify_account: Manage account-based authorities + + All modifications are queued and applied when the transaction is finalized. + """ + + def __init__( + self, + world: World, + authority_type: AuthorityLevelRegular, + account_name: str, + threshold: int | None, + previous_operations: list[OperationBuilder] | None = None, + ) -> None: + super().__init__(world, previous_operations) + self._authority_type = authority_type + self._account_name = account_name + self._threshold = threshold + self._current_operation = self._create_authority_builder() + + def add_key(self, *, key: str, weight: int) -> Self: + """ + Schedule adding a key to the authority. + + Args: + key: Public key to add (e.g., 'STM5...' or alias from key manager) + weight: Weight/voting power for this key (typically 1) + + Returns: + Self for method chaining + """ + self._get_authority_builder().add_key(key=key, weight=weight) + return self + + def add_account(self, *, account_name: str, weight: int) -> Self: + """ + Schedule adding an account to the authority. + + Args: + account_name: Account name to add + weight: Weight/voting power for this account + + Returns: + Self for method chaining + """ + self._get_authority_builder().add_account(account_name=account_name, weight=weight) + return self + + def remove_key(self, *, key: str) -> Self: + """ + Schedule removing a key from the authority. + + Args: + key: Public key to remove + + Returns: + Self for method chaining + """ + self._get_authority_builder().remove_key(key=key) + return self + + def remove_account(self, *, account_name: str) -> Self: + """ + Schedule removing an account from the authority. + + Args: + account_name: Account name to remove + + Returns: + Self for method chaining + """ + self._get_authority_builder().remove_account(account_name=account_name) + return self + + def modify_key(self, *, key: str, weight: int) -> Self: + """ + Schedule modifying a key's weight in the authority. + + Args: + key: Public key to modify + weight: New weight for this key + + Returns: + Self for method chaining + """ + self._get_authority_builder().modify_key(key=key, weight=weight) + return self + + def modify_account(self, *, account: str, weight: int) -> Self: + """ + Schedule modifying an account's weight in the authority. + + Args: + account: Account name to modify + weight: New weight for this account + + Returns: + Self for method chaining + """ + self._get_authority_builder().modify_account(account=account, weight=weight) + return self + + def _create_authority_builder(self) -> AuthorityBuilder: + """Create AuthorityBuilder instance for this authority update.""" + return AuthorityBuilder( + world=self.world, + authority_type=self._authority_type, + account_name=self._account_name, + threshold=self._threshold, + ) + + def _get_authority_builder(self) -> AuthorityBuilder: + """Get the AuthorityBuilder instance, ensuring type safety.""" + assert isinstance(self._current_operation, AuthorityBuilder), "Current operation must be AuthorityBuilder" + return self._current_operation diff --git a/clive/__private/si/core/process/process.py b/clive/__private/si/core/process/process.py new file mode 100644 index 0000000000..1822ed62bc --- /dev/null +++ b/clive/__private/si/core/process/process.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, Self, cast + +from clive.__private.core.commands.load_transaction import LoadTransaction +from clive.__private.core.constants.transaction import DEFAULT_SERIALIZATION_MODE +from clive.__private.core.keys.key_manager import KeyNotFoundError +from clive.__private.core.keys.keys import PublicKey +from clive.__private.models.asset import Asset +from clive.__private.models.transaction import Transaction +from clive.__private.si.core.process import authority_operations +from clive.__private.si.exceptions import MissingFromFileOrFromObjectError +from clive.__private.si.validators import ( + AlreadySignedModeValidator, + LoadTransactionFromFileFromObjectValidator, + SignedTransactionValidator, +) +from clive.__private.validators.path_validator import PathValidator +from schemas.operations.transfer_operation import TransferOperation + +if TYPE_CHECKING: + from collections.abc import Callable + + from clive.__private.core.ensure_transaction import TransactionConvertibleType + from clive.__private.core.types import AlreadySignedMode, AuthorityLevelRegular, SerializationMode + from clive.__private.core.world import World + from clive.__private.models.schemas import AccountUpdate2Operation, Authority, OperationUnion + + +class OperationBuilder(ABC): + """ + Abstract base class for building individual blockchain operations. + + Each builder is responsible for: + 1. Storing operation parameters (e.g., from_account, to_account for transfers) + 2. Validating the configuration before execution + 3. Creating the actual blockchain operation (TransferOperation, etc.) + 4. Managing finalization parameters (signing keys, broadcast flags, save paths) + + Concrete implementations include: + - TransferBuilder: builds transfer operations + - TransactionBuilder: loads and processes existing transactions + - AuthorityBuilder: builds authority update operations + - MultipleOperationsBuilder: combines multiple operations into one transaction + """ + + def __init__(self, world: World) -> None: + super().__init__() + self.world = world + self._sign_with: str | None = None + self._save_file: str | Path | None = None + self._broadcast: bool = False + self._autosign: bool | None = None + + async def validate(self) -> None: # noqa: B027 + """Validate the process command configuration. Override in subclasses as needed.""" + + async def run( # noqa: PLR0913 + self, + sign_with: list[str] | str | None = None, + save_file: str | Path | None = None, + file_format: Literal["json", "bin"] | None = None, + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE, + *, + broadcast: bool = True, + autosign: bool | None = None, + force_unsign: bool | None = None, + already_signed_mode: AlreadySignedMode | None = None, + ) -> Transaction: + """Execute the operation with specified signing and broadcasting options.""" + sign_keys = self._normalize_sign_with(sign_with) + + # Set instance variables so they're available in _get_transaction_content() + self._sign_with = ( + sign_with[0] + if isinstance(sign_with, list) and sign_with + else sign_with + if isinstance(sign_with, str) + else None + ) + self._save_file = save_file + self._broadcast = broadcast + self._autosign = autosign + + await self.validate() + content = await self._get_transaction_content() + + # Normalize sign_keys to the format expected by perform_actions_on_transaction + normalized_sign_keys: PublicKey | list[PublicKey] | None = None + if sign_keys: + normalized_sign_keys = sign_keys[0] if len(sign_keys) == 1 else sign_keys + + kwargs: dict[str, Any] = { + "content": content, + "sign_keys": normalized_sign_keys, + "autosign": bool(autosign), + "save_file_path": Path(save_file) if save_file else None, + "force_save_format": file_format, + "serialization_mode": serialization_mode, + "broadcast": broadcast, + } + if force_unsign is not None: + kwargs["force_unsign"] = force_unsign + if already_signed_mode is not None: + kwargs["already_signed_mode"] = already_signed_mode + + return (await self.world.commands.perform_actions_on_transaction(**kwargs)).result_or_raise + + @abstractmethod + async def _create_operation(self) -> OperationUnion: + """Get the operation to be processed.""" + + async def _get_transaction_content(self) -> TransactionConvertibleType: + return await self._create_operation() + + def _resolve_key_or_alias(self, key_or_alias: str) -> PublicKey: + """ + Resolve a key or alias to a PublicKey. + + Args: + key_or_alias: Either a public key string or an alias to a key in the key manager. + + Returns: + PublicKey instance. + + Raises: + KeyNotFoundError: If the alias is not found in the key manager. + """ + try: + aliased_key = self.world.profile.keys.get_from_alias(key_or_alias) + return PublicKey(value=aliased_key.value) + except KeyNotFoundError: + return PublicKey(value=key_or_alias) + + def _normalize_sign_with(self, sign_with: list[str] | str | None) -> list[PublicKey]: + """Normalize sign_with parameter to a list of PublicKey objects.""" + if isinstance(sign_with, str): + sign_keys_or_aliases = [sign_with] + elif isinstance(sign_with, list): + sign_keys_or_aliases = sign_with + else: + sign_keys_or_aliases = [] + + return [self._resolve_key_or_alias(key_or_alias) for key_or_alias in sign_keys_or_aliases] + + +class TransferBuilder(OperationBuilder): + """Builds transfer operations for sending HIVE or HBD between accounts.""" + + def __init__( + self, + world: World, + from_account: str, + to_account: str, + amount: str | Asset.LiquidT, + memo: str = "", + ) -> None: + super().__init__(world) + self.from_account = from_account + self.to_account = to_account + self.amount = amount + self.memo = memo + + async def _create_operation(self) -> TransferOperation: + return TransferOperation( + from_=self.from_account, + to=self.to_account, + amount=self._normalize_amount(), + memo=self.memo, + ) + + def _normalize_amount(self) -> Asset.LiquidT: + """Convert amount to proper Asset.LiquidT type.""" + amount = Asset.from_legacy(self.amount) if isinstance(self.amount, str) else self.amount + assert not Asset.is_vests(amount), f"Invalid asset type. Given: {type(amount)}, Needs: {Asset.LiquidT}" + return cast("Asset.LiquidT", amount) + + +class TransactionBuilder(OperationBuilder): + """ + Builds operations by loading an existing transaction from file or object. + + This builder handles both signed and unsigned transactions, with options for + force-unsigning and controlling already-signed transaction behavior. + """ + + def __init__( # noqa: PLR0913 + self, + world: World, + already_signed_mode: AlreadySignedMode | None, + from_file: str | Path | None, + from_object: Transaction | None = None, + *, + force_unsign: bool, + force: bool, + ) -> None: + super().__init__(world) + self.from_file = from_file + self.from_object = from_object + self.force_unsign = force_unsign + self.already_signed_mode = already_signed_mode + self.force = force + + async def validate(self) -> None: + LoadTransactionFromFileFromObjectValidator( + from_file=self.from_file, + from_object=self.from_object, + ).validate() + if self.already_signed_mode is not None: + AlreadySignedModeValidator( + use_autosign=self._autosign, + already_signed_mode=self.already_signed_mode, + ).validate() + transaction = await self._get_transaction_content() + if transaction.is_signed: + SignedTransactionValidator( + sign_with=self._sign_with, + already_signed_mode=self.already_signed_mode, + ).validate() + if self.from_file is not None: + PathValidator(mode="is_file").validate( + value=str(self.from_file), + ) + + async def run( # noqa: PLR0913 + self, + sign_with: list[str] | str | None = None, + save_file: str | Path | None = None, + file_format: Literal["json", "bin"] | None = None, + serialization_mode: SerializationMode = DEFAULT_SERIALIZATION_MODE, + *, + broadcast: bool = True, + autosign: bool | None = None, + force_unsign: bool | None = None, + already_signed_mode: AlreadySignedMode | None = None, + ) -> Transaction: + """Execute the operation with specified signing and broadcasting options.""" + # Use instance values if parameters not provided + return await super().run( + sign_with=sign_with, + save_file=save_file, + file_format=file_format, + serialization_mode=serialization_mode, + broadcast=broadcast, + autosign=autosign, + force_unsign=force_unsign if force_unsign is not None else self.force_unsign, + already_signed_mode=already_signed_mode if already_signed_mode is not None else self.already_signed_mode, + ) + + async def _create_operation(self) -> OperationUnion: + """TransactionBuilder uses _get_transaction_content() instead.""" + raise NotImplementedError("TransactionBuilder uses _get_transaction_content() instead of _create_operation") + + async def _get_transaction_content(self) -> Transaction: + if self.from_file is not None: + return await LoadTransaction( + file_path=self.from_file if isinstance(self.from_file, Path) else Path(self.from_file) + ).execute_with_result() + if self.from_object is not None: + return self.from_object + raise MissingFromFileOrFromObjectError + + +class AuthorityBuilder(OperationBuilder): + """ + Builds authority update operations by managing callbacks that modify authority structures. + + This builder uses a callback pattern to queue modifications (add_key, remove_account, etc.) + that are applied when the operation is finalized. This allows chaining multiple + modifications before creating the final AccountUpdate2Operation. + """ + + def __init__( + self, + world: World, + authority_type: AuthorityLevelRegular, + account_name: str, + threshold: int | None = None, + ) -> None: + super().__init__(world) + self.authority_type = authority_type + self.account_name = account_name + self.threshold = threshold + self._callbacks: list[Callable[[Authority], Authority]] = [] + # Add threshold callback only if threshold is provided + if threshold is not None: + self._callbacks.append(partial(authority_operations.set_threshold, threshold=threshold)) + + def add_key(self, *, key: str, weight: int) -> Self: + """Add a key with specified weight to the authority.""" + self._callbacks.append(partial(authority_operations.add_key, key=key, weight=weight)) + return self + + def add_account(self, *, account_name: str, weight: int) -> Self: + """Add an account with specified weight to the authority.""" + self._callbacks.append(partial(authority_operations.add_account, account=account_name, weight=weight)) + return self + + def remove_key(self, *, key: str) -> Self: + """Remove a key from the authority.""" + self._callbacks.append(partial(authority_operations.remove_key, key=key)) + return self + + def remove_account(self, *, account_name: str) -> Self: + """Remove an account from the authority.""" + self._callbacks.append(partial(authority_operations.remove_account, account=account_name)) + return self + + def modify_key(self, *, key: str, weight: int) -> Self: + """Modify the weight of an existing key.""" + self._callbacks.append(partial(authority_operations.modify_key, key=key, weight=weight)) + return self + + def modify_account(self, *, account: str, weight: int) -> Self: + """Modify the weight of an existing account.""" + self._callbacks.append(partial(authority_operations.modify_account, account=account, weight=weight)) + return self + + async def _create_operation(self) -> AccountUpdate2Operation: + """Build the account_update2_operation by applying all callbacks.""" + # Fetch current account state + accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise + account = accounts[0] + + # Use authority_operations to build the operation + return authority_operations.build_account_update_operation( + account=account, + authority_type=self.authority_type, + callbacks=self._callbacks, + ) + + +class MultipleOperationsBuilder(OperationBuilder): + """ + Combines multiple operation builders into a single transaction. + + This builder is used when chaining operations with .process property. + It collects operations from all builders and merges them, preserving + transaction metadata if one of the builders is a TransactionBuilder. + """ + + def __init__( + self, + world: World, + builders: list[OperationBuilder], + ) -> None: + super().__init__(world) + self.builders = builders + + async def validate(self) -> None: + """Validate that signed transactions cannot have operations added to them.""" + from clive.__private.si.exceptions import CannotAddOperationToSignedTransactionError # noqa: PLC0415 + + # Check if any of the builders is a TransactionBuilder with a signed transaction + for builder in self.builders: + if isinstance(builder, TransactionBuilder): + # Get the transaction from TransactionBuilder + transaction_content = await builder._get_transaction_content() + if transaction_content.is_signed and not builder.force_unsign: + raise CannotAddOperationToSignedTransactionError + + await super().validate() + + async def _create_operation(self) -> OperationUnion: + """Not used for multiple operations - we override _get_transaction_content instead.""" + raise NotImplementedError("MultipleOperationsBuilder uses _get_transaction_content() instead") + + async def _get_transaction_content(self) -> TransactionConvertibleType: + """Get all operations from all builders and preserve transaction metadata.""" + operations: list[OperationUnion] = [] + base_transaction: Transaction | None = None + + for builder in self.builders: + # Get content from each builder + content = await builder._get_transaction_content() + # If content is already a Transaction (e.g., from TransactionBuilder), extract operations and metadata + if isinstance(content, Transaction): + # Store the first transaction we encounter to preserve its metadata + if base_transaction is None: + base_transaction = content + # Use operations_models to get OperationUnion objects instead of representations + operations.extend(content.operations_models) + # If content is a list of operations + elif isinstance(content, list): + operations.extend(content) + # Single operation (from _create_operation) + else: + operation = cast("OperationUnion", content) # required for type checker + operations.append(operation) + + # If we found a base transaction, preserve its metadata + if base_transaction is not None: + # Create a new transaction with the combined operations but preserve metadata from base transaction + return Transaction( + operations=Transaction.convert_operations(operations), + ref_block_num=base_transaction.ref_block_num, + ref_block_prefix=base_transaction.ref_block_prefix, + expiration=base_transaction.expiration, + extensions=base_transaction.extensions, + # Don't copy signatures - they will be invalid after adding operations + signatures=[], + ) + + # If no base transaction, return list of operations (will be converted to transaction with default metadata) + return operations diff --git a/clive/__private/si/core/show.py b/clive/__private/si/core/show.py new file mode 100644 index 0000000000..57f03d5e8b --- /dev/null +++ b/clive/__private/si/core/show.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.core.accounts.accounts import TrackedAccount +from clive.__private.core.commands.data_retrieval.witnesses_data import ( + WitnessData, + WitnessesData, + WitnessesDataRetrieval, +) +from clive.__private.core.profile import Profile +from clive.__private.si.core.base import CommandBase +from clive.__private.si.data_classes import Accounts, Authority, AuthorityInfo, Balances, Witness +from clive.__private.si.validators import AccountNameValidator, PageNumberValidator, PageSizeValidator + +if TYPE_CHECKING: + from clive.__private.core.types import AuthorityLevelRegular + from clive.__private.core.world import World + + +class ShowProfiles(CommandBase[list[str]]): + def __init__(self) -> None: + pass + + async def _run(self) -> list[str]: + return Profile.list_profiles() + + +class ShowBalances(CommandBase[Balances]): + def __init__(self, world: World, account_name: str) -> None: + self.world = world + self.account_name = account_name + + async def validate(self) -> None: + AccountNameValidator().validate(self.account_name) + + async def _run(self) -> Balances: + account = TrackedAccount(name=self.account_name) + await self.world.commands.update_node_data(accounts=[account]) + + return Balances( + hbd_liquid=account.data.hbd_balance, + hbd_savings=account.data.hbd_savings, + hbd_unclaimed=account.data.hbd_unclaimed, + hive_liquid=account.data.hive_balance, + hive_savings=account.data.hive_savings, + hive_unclaimed=account.data.hive_unclaimed, + ) + + +class ShowAccounts(CommandBase[Accounts]): + def __init__(self, world: World) -> None: + self.world = world + + async def _run(self) -> Accounts: + profile = self.world.profile + return Accounts( + working_account=profile.accounts.working.name if profile.accounts.has_working_account else None, + tracked_accounts=[account.name for account in profile.accounts.tracked], + known_accounts=[account.name for account in profile.accounts.known], + ) + + +class ShowWitnesses(CommandBase[list[Witness]]): + def __init__(self, world: World, account_name: str, page_size: int, page_no: int) -> None: + self.world = world + self.account_name = account_name + self.page_size = page_size + self.page_no = page_no + + async def validate(self) -> None: + AccountNameValidator().validate(self.account_name) + PageSizeValidator().validate(self.page_size) + PageNumberValidator().validate(self.page_no) + + async def _run(self) -> list[Witness]: + _witnesses_list_len, _proxy, witnesses_chunk = await self.get_witness_chunk() + return [ + Witness( + voted=witness.voted, + rank=witness.rank, + witness_name=witness.name, + votes=witness.votes, + created=witness.created, + missed_blocks=witness.missed_blocks, + last_block=witness.last_block, + price_feed=witness.price_feed, + version=witness.version, + ) + for witness in witnesses_chunk + ] + + async def get_witness_chunk(self) -> tuple[int, str, list[WitnessData]]: + accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise + proxy = accounts[0].proxy + + wrapper = await self.world.commands.retrieve_witnesses_data( + account_name=proxy if proxy else self.account_name, + mode=WitnessesDataRetrieval.DEFAULT_MODE, + witness_name_pattern=None, + search_by_pattern_limit=WitnessesDataRetrieval.DEFAULT_SEARCH_BY_PATTERN_LIMIT, + ) + witnesses_data: WitnessesData = wrapper.result_or_raise + start_index: int = self.page_no * self.page_size + end_index: int = start_index + self.page_size + witnesses_list: list[WitnessData] = list(witnesses_data.witnesses.values()) + witnesses_chunk: list[WitnessData] = witnesses_list[start_index:end_index] + + return len(witnesses_list), proxy, witnesses_chunk + + +class ShowAuthority(CommandBase[AuthorityInfo]): + def __init__(self, world: World, account_name: str, authority: AuthorityLevelRegular) -> None: + self.world = world + self.account_name = account_name + self.authority = authority + + async def validate(self) -> None: + AccountNameValidator().validate(self.account_name) + + async def _run(self) -> AuthorityInfo: + accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise + account = accounts[0] + + authorities = [] + for auth, weight in [*account[self.authority].key_auths, *account[self.authority].account_auths]: + authorities.append(Authority(account_or_public_key=auth, weight=weight)) + + return AuthorityInfo( + authority_owner_account_name=account.name, + authority_type=self.authority, + weight_threshold=account[self.authority].weight_threshold, + authorities=authorities, + ) diff --git a/clive/__private/si/data_classes.py b/clive/__private/si/data_classes.py new file mode 100644 index 0000000000..aa368025f9 --- /dev/null +++ b/clive/__private/si/data_classes.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime + + from clive.__private.models.asset import Asset + + +@dataclass +class Balances: + """Account balances for HBD and HIVE.""" + + hbd_liquid: Asset.Hbd + hbd_savings: Asset.Hbd + hbd_unclaimed: Asset.Hbd + hive_liquid: Asset.Hive + hive_savings: Asset.Hive + hive_unclaimed: Asset.Hive + + def __str__(self) -> str: + return ( + f"HBD: {self.hbd_liquid.pretty_amount()} (liquid), " + f"{self.hbd_savings.pretty_amount()} (savings), " + f"{self.hbd_unclaimed.pretty_amount()} (unclaimed) | " + f"HIVE: {self.hive_liquid.pretty_amount()} (liquid), " + f"{self.hive_savings.pretty_amount()} (savings), " + f"{self.hive_unclaimed.pretty_amount()} (unclaimed)" + ) + + +@dataclass +class Accounts: + """Account tracking information.""" + + working_account: str | None + tracked_accounts: list[str] + known_accounts: list[str] + + +@dataclass +class Authority: + """Authority structure for an account or key.""" + + account_or_public_key: str + weight: int + + +@dataclass +class AuthorityInfo: + """Detailed authority information for an account.""" + + authority_owner_account_name: str + authority_type: str + weight_threshold: int + authorities: list[Authority] + + +@dataclass +class KeyPair: + """Key pair (private/public).""" + + private_key: str | None + public_key: str | None + + +@dataclass +class Witness: + voted: bool + rank: int | None + witness_name: str + votes: str + created: datetime + missed_blocks: int + last_block: int + price_feed: str + version: str diff --git a/clive/__private/si/exceptions.py b/clive/__private/si/exceptions.py new file mode 100644 index 0000000000..f70a32b031 --- /dev/null +++ b/clive/__private/si/exceptions.py @@ -0,0 +1,116 @@ +"""Exceptions for the SI module.""" + +from __future__ import annotations + +from typing import Final + + +class SIContextManagerNotUsedError(Exception): + """Raised when UnlockedCliveSi is used without async context manager.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + + +class PasswordRequirementsNotMetError(Exception): + """Raised when the provided password does not meet the requirements.""" + + def __init__(self, description: str) -> None: + super().__init__(f"Password requirements not met: {description}") + + +class InvalidAccountNameError(Exception): + """Raised when the provided account name is invalid.""" + + def __init__(self, account_name: str) -> None: + super().__init__(f"Invalid account name: '{account_name}'.") + + +class InvalidProfileNameError(Exception): + """Raised when the provided profile name is invalid.""" + + def __init__(self, profile_name: str, description: str) -> None: + super().__init__(f"Invalid profile name: '{profile_name}'. {description}") + + +class InvalidPageNumberError(Exception): + """Raised when the provided page number is invalid.""" + + def __init__(self, page_number: int, min_number: int = 0) -> None: + super().__init__(f"Invalid page number: {page_number}. Page number must be greater or equal to {min_number}.") + + +class InvalidPageSizeError(Exception): + """Raised when the provided page size is invalid.""" + + def __init__(self, page_size: int, min_size: int) -> None: + super().__init__(f"Invalid page size: {page_size}. Page size must be greater than or equal to {min_size}.") + + +class InvalidNumberOfKeyPairsError(Exception): + """Raised when the provided number of key pairs is invalid.""" + + def __init__(self, number_of_key_pairs: int, min_size: int) -> None: + super().__init__( + f"Invalid number of key pairs: {number_of_key_pairs}. " + f"Number of key pairs must be greater than or equal to {min_size}." + ) + + +class CannotAddOperationToSignedTransactionError(Exception): + """Raised when trying to add an operation to an already signed transaction.""" + + def __init__(self) -> None: + super().__init__( + "Cannot add operations to an already signed transaction. " + "Use force_unsign=True parameter in the transaction() method to remove the signature first." + ) + + +class MissingFromFileOrFromObjectError(Exception): + """Raised when neither from_file nor from_object is provided.""" + + def __init__(self) -> None: + super().__init__("Either from_file or from_object must be provided.") + + +class BothArgumentsFromFileOrFromObjectPrividedError(Exception): + """Raised when both from_file and from_object are provided.""" + + def __init__(self) -> None: + super().__init__("Only one of from_file or from_object should be provided, not both.") + + +class WrongAlreadySignedModeAutoSignError(Exception): + """ + Raises when trying to use autosign together with already_signed_mode that is not 'error'. + + Attributes: + MESSAGE: A message to be shown to the user. + """ + + MESSAGE: Final[str] = ( + "Autosign cannot be used together with already-signed modes 'override' or 'multisign'. " + "Please choose another already-signed mode or disable autosign." + ) + + def __init__(self) -> None: + super().__init__(self.MESSAGE) + + +class TransactionAlreadySignedError(Exception): + """ + Raises when trying to sign a transaction that is already signed without proper already-signed-mode. + + Attributes: + MESSAGE: A message to be shown to the user. + """ + + MESSAGE: Final[str] = ( + "You cannot sign a transaction that is already signed.\n" + "Use 'already-signed-mode override' to override the existing signature(s) or " + "'already-signed-mode multisign' to add an additional signature." + ) + + def __init__(self) -> None: + super().__init__(self.MESSAGE) diff --git a/clive/__private/si/generate.py b/clive/__private/si/generate.py new file mode 100644 index 0000000000..b9b950cbc0 --- /dev/null +++ b/clive/__private/si/generate.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.si.core.generate import GenerateRandomKey + +if TYPE_CHECKING: + from clive.__private.si.data_classes import KeyPair + + +class GenerateInterface: + """Interface for generating keys and secret phrases.""" + + def __init__(self) -> None: + pass + + async def random_key(self, key_pairs: int = 1) -> list[KeyPair]: + """Generate one or more random key pairs.""" + return await GenerateRandomKey(key_pairs=key_pairs).run() diff --git a/clive/__private/si/process.py b/clive/__private/si/process.py new file mode 100644 index 0000000000..e1389e057b --- /dev/null +++ b/clive/__private/si/process.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.si.core.process.chaining import ( + AuthorityUpdateChain, + ChainableOperationBuilder, +) +from clive.__private.si.core.process.process import ( + OperationBuilder, + TransactionBuilder, + TransferBuilder, +) + +if TYPE_CHECKING: + from pathlib import Path + + from clive.__private.core.types import AlreadySignedMode, AuthorityLevelRegular + from clive.__private.core.world import World + from clive.__private.models.asset import Asset + from clive.__private.models.transaction import Transaction + from clive.__private.si.base import UnlockedCliveSi + from clive.__private.si.core.process.process import OperationBuilder + + +class OperationsBuilderInterface: + """ + Base interface for building blockchain operations. + + This class provides common methods for creating operations (transfers, authority updates) + and manages the chain of operations being built. It serves as foundation for both: + - Initial operation building (starting from a profile) + - Chaining context (continuing with existing operations) + + Each operation method returns a chain object that allows further configuration, + chaining additional operations, and finalizing (broadcast/save/get transaction). + """ + + def __init__(self, world: World, all_operations: list[OperationBuilder] | None = None) -> None: + self.world = world + self._all_operations: list[OperationBuilder] = all_operations or [] + + def transfer( + self, + from_account: str, + to_account: str, + amount: str | Asset.LiquidT, + memo: str = "", + ) -> ChainableOperationBuilder: + """Transfer funds between accounts.""" + chain = ChainableOperationBuilder(world=self.world, previous_operations=self._all_operations) + chain._current_operation = TransferBuilder( + world=self.world, + from_account=from_account, + to_account=to_account, + amount=amount, + memo=memo, + ) + return chain + + def update_owner_authority( + self, + account_name: str, + *, + threshold: int | None = None, + ) -> AuthorityUpdateChain: + """Update owner authority for an account.""" + return self._update_authority("owner", account_name, threshold) + + def update_active_authority( + self, + account_name: str, + *, + threshold: int | None = None, + ) -> AuthorityUpdateChain: + """Update active authority for an account.""" + return self._update_authority("active", account_name, threshold) + + def update_posting_authority( + self, + account_name: str, + *, + threshold: int | None = None, + ) -> AuthorityUpdateChain: + """Update posting authority for an account.""" + return self._update_authority("posting", account_name, threshold) + + def _update_authority( + self, + authority_type: AuthorityLevelRegular, + account_name: str, + threshold: int | None, + ) -> AuthorityUpdateChain: + """Common method for updating authority.""" + return AuthorityUpdateChain( + world=self.world, + authority_type=authority_type, + account_name=account_name, + threshold=threshold, + previous_operations=self._all_operations, + ) + + +class ProfileOperationsInterface(OperationsBuilderInterface): + """ + Interface for processing blockchain operations from a profile context. + + This is the main entry point for building operations when working with a profile. + Provides methods for: + - Transfers (transfer) + - Authority updates (update_owner_authority, update_active_authority, update_posting_authority) + - Transaction loading (transaction, transaction_from_object) + + All operations return chainable builders that can be configured, combined with + other operations (.process), and finalized (broadcast/save/as_transaction_object). + """ + + def __init__(self, clive_instance: UnlockedCliveSi) -> None: + super().__init__(world=clive_instance._world, all_operations=None) + self.clive = clive_instance + + def transaction( + self, + from_file: str | Path, + *, + force_unsign: bool = False, + already_signed_mode: AlreadySignedMode | None = None, + force: bool = False, + ) -> ChainableOperationBuilder: + """Process a transaction from a file. Can only be used at the start of a chain.""" + chain = ChainableOperationBuilder(world=self.world, previous_operations=self._all_operations) + chain._current_operation = TransactionBuilder( + world=self.world, + from_file=from_file, + force_unsign=force_unsign, + already_signed_mode=already_signed_mode, + force=force, + ) + return chain + + def transaction_from_object( + self, + from_object: Transaction | None, + *, + force_unsign: bool = False, + already_signed_mode: AlreadySignedMode | None = None, + force: bool = False, + ) -> ChainableOperationBuilder: + """Process a transaction from an object. Can only be used at the start of a chain.""" + chain = ChainableOperationBuilder(world=self.world, previous_operations=self._all_operations) + chain._current_operation = TransactionBuilder( + world=self.world, + from_file=None, + from_object=from_object, + force_unsign=force_unsign, + already_signed_mode=already_signed_mode, + force=force, + ) + return chain + + +class ChainingOperationsInterface(OperationsBuilderInterface): + """ + Interface for processing blockchain operations in a chaining context. + + This interface is returned by the .process property of chain objects, + allowing you to add more operations to an existing chain. It maintains + the list of previously built operations and provides the same operation + methods as ProfileOperationsInterface. + """ + + def __init__(self, world: World, all_operations: list[OperationBuilder]) -> None: + super().__init__(world=world, all_operations=all_operations) diff --git a/clive/__private/si/show.py b/clive/__private/si/show.py new file mode 100644 index 0000000000..8a47864c3f --- /dev/null +++ b/clive/__private/si/show.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.si.core.show import ShowAccounts, ShowAuthority, ShowBalances, ShowProfiles, ShowWitnesses + +if TYPE_CHECKING: + from clive.__private.si.base import UnlockedCliveSi + from clive.__private.si.data_classes import Accounts, AuthorityInfo, Balances, Witness + + +class ShowInterfaceNoProfile: + """Interface for show operations that do not require a profile.""" + + def __init__(self) -> None: + pass + + async def profiles(self) -> list[str]: + """List all available profiles.""" + return await ShowProfiles().run() + + +class ShowInterface(ShowInterfaceNoProfile): + """ + Main interface for show operations that require a profile. + + Keeps client usage unchanged (async, argument names, defaults). + """ + + def __init__(self, clive_instance: UnlockedCliveSi) -> None: + self.clive = clive_instance + + async def balances(self, account_name: str) -> Balances: + """Show balances for an account.""" + return await ShowBalances( + world=self.clive._world, + account_name=account_name, + ).run() + + async def accounts(self) -> Accounts: + """Show accounts information.""" + return await ShowAccounts( + world=self.clive._world, + ).run() + + async def witnesses(self, account_name: str, page_size: int = 30, page_no: int = 0) -> list[Witness]: + """Show witnesses for an account.""" + return await ShowWitnesses( + world=self.clive._world, account_name=account_name, page_size=page_size, page_no=page_no + ).run() + + async def owner_authority(self, account_name: str) -> AuthorityInfo: + """Show owner authority for an account.""" + return await ShowAuthority( + world=self.clive._world, + account_name=account_name, + authority="owner", + ).run() + + async def active_authority(self, account_name: str) -> AuthorityInfo: + """Show active authority for an account.""" + return await ShowAuthority( + world=self.clive._world, + account_name=account_name, + authority="active", + ).run() + + async def posting_authority(self, account_name: str) -> AuthorityInfo: + """Show posting authority for an account.""" + return await ShowAuthority( + world=self.clive._world, + account_name=account_name, + authority="posting", + ).run() diff --git a/clive/__private/si/validators.py b/clive/__private/si/validators.py new file mode 100644 index 0000000000..becc65f4aa --- /dev/null +++ b/clive/__private/si/validators.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Final + +from clive.__private.core.formatters.humanize import humanize_validation_result +from clive.__private.si.exceptions import ( + BothArgumentsFromFileOrFromObjectPrividedError, + InvalidAccountNameError, + InvalidNumberOfKeyPairsError, + InvalidPageNumberError, + InvalidPageSizeError, + InvalidProfileNameError, + MissingFromFileOrFromObjectError, + PasswordRequirementsNotMetError, + TransactionAlreadySignedError, + WrongAlreadySignedModeAutoSignError, +) +from clive.__private.validators.account_name_validator import AccountNameValidator as AccountNameValidatorImpl +from clive.__private.validators.profile_name_validator import ProfileNameValidator as ProfileNameValidatorImpl +from clive.__private.validators.set_password_validator import SetPasswordValidator as SetPasswordValidatorImpl + +if TYPE_CHECKING: + from pathlib import Path + + from clive.__private.core.types import AlreadySignedMode + from clive.__private.models.transaction import Transaction + + +class Validator(ABC): + """ + Abstract base class for all SI validators. + + All validators must implement the validate() method which should raise + an appropriate exception from clive.__private.si.exceptions if validation fails. + + Configuration should be passed via __init__, and validate() should accept only the value to validate. + """ + + @abstractmethod + def validate(self, value: object) -> None: + """ + Validate input data. + + Args: + value: The value to validate. + + Raises: + An appropriate exception from clive.__private.si.exceptions if validation fails. + """ + ... + + +class ProfileNameValidator(Validator): + def validate(self, value: object) -> None: + assert isinstance(value, str) + validation_result = ProfileNameValidatorImpl().validate(value) + if not validation_result.is_valid: + raise InvalidProfileNameError(value, humanize_validation_result(validation_result)) + + +class SetPasswordValidator(Validator): + def validate(self, value: object) -> None: + assert isinstance(value, str) + validation_result = SetPasswordValidatorImpl().validate(value) + if not validation_result.is_valid: + raise PasswordRequirementsNotMetError(humanize_validation_result(validation_result)) + + +class AccountNameValidator(Validator): + def validate(self, value: object) -> None: + assert isinstance(value, str) + validation_result = AccountNameValidatorImpl().validate(value) + if not validation_result.is_valid: + raise InvalidAccountNameError(value) + + +class PageNumberValidator(Validator): + MIN_NUMBER: Final[int] = 0 + + def validate(self, value: object) -> None: + assert isinstance(value, int) + if value < self.MIN_NUMBER: + raise InvalidPageNumberError(value, self.MIN_NUMBER) + + +class PageSizeValidator(Validator): + MIN_SIZE: Final[int] = 1 + + def validate(self, value: object) -> None: + assert isinstance(value, int) + if value < self.MIN_SIZE: + raise InvalidPageSizeError(value, self.MIN_SIZE) + + +class KeyPairsNumberValidator(Validator): + MIN_NUMBER: Final[int] = 1 + + def validate(self, value: object) -> None: + assert isinstance(value, int) + if value < self.MIN_NUMBER: + raise InvalidNumberOfKeyPairsError(value, self.MIN_NUMBER) + + +class LoadTransactionFromFileFromObjectValidator(Validator): + def __init__(self, from_file: str | Path | None, from_object: Transaction | None = None) -> None: + self.from_file = from_file + self.from_object = from_object + + def validate(self, value: object = None) -> None: # noqa: ARG002 + if self.from_file is None and self.from_object is None: + raise MissingFromFileOrFromObjectError + if self.from_file is not None and self.from_object is not None: + raise BothArgumentsFromFileOrFromObjectPrividedError + + +class AlreadySignedModeValidator(Validator): + def __init__(self, *, use_autosign: bool | None, already_signed_mode: AlreadySignedMode) -> None: + self.use_autosign = use_autosign + self.already_signed_mode = already_signed_mode + + def validate(self, value: object = None) -> None: # noqa: ARG002 + if self.use_autosign and self.already_signed_mode in ["override", "multisign"]: + raise WrongAlreadySignedModeAutoSignError + + +class SignedTransactionValidator(Validator): + def __init__(self, sign_with: str | None, already_signed_mode: AlreadySignedMode) -> None: + self.sign_with = sign_with + self.already_signed_mode = already_signed_mode + + def validate(self, value: object = None) -> None: # noqa: ARG002 + if self.already_signed_mode == "strict" and self.sign_with is not None: + raise TransactionAlreadySignedError diff --git a/clive/si/__init__.py b/clive/si/__init__.py new file mode 100644 index 0000000000..40f4c14acf --- /dev/null +++ b/clive/si/__init__.py @@ -0,0 +1,67 @@ +""" +Clive Script Interface (SI). + +This module provides a programmatic interface for interacting with Clive functionality. +It allows developers to write Python scripts that perform blockchain operations. + +Main classes: + CliveSi - Interface for read-only operations (no profile needed) + UnlockedCliveSi - Full interface with unlocked profile for all operations + +Factory function: + clive_use_unlocked_profile() - Convenient way to get UnlockedCliveSi instance + +IMPORTANT: UnlockedCliveSi and clive_use_unlocked_profile() MUST be used as async context managers. +The context manager handles: +- Profile loading from the unlocked beekeeper wallet on entry +- Profile saving to storage on exit +- Proper cleanup of all resources + +Example usage: + ```python + from clive.si import CliveSi, UnlockedCliveSi, clive_use_unlocked_profile + + # Basic transfer - ALWAYS use async with + async with clive_use_unlocked_profile() as clive: + await clive.process.transfer( + from_account="alice", + to_account="bob", + amount="1.000 HIVE", + memo="Payment" + ).broadcast() + + # Or directly - ALWAYS use async with + async with UnlockedCliveSi() as clive: + await clive.process.transfer( + from_account="alice", + to_account="bob", + amount="1.000 HIVE", + ).broadcast() + + # Multiple operations in one transaction + async with clive_use_unlocked_profile() as clive: + await clive.process.transfer( + from_account="alice", + to_account="bob", + amount="1.000 HIVE", + ).process.transfer( + from_account="alice", + to_account="charlie", + amount="2.000 HIVE", + ).sign_with("5J...").broadcast() + + # Query operations (no profile needed) - ALSO use async with + async with CliveSi() as clive: + profiles = await clive.show.profiles() + ``` +""" + +from __future__ import annotations + +from clive.__private.si.base import CliveSi, UnlockedCliveSi, clive_use_unlocked_profile + +__all__ = [ + "CliveSi", + "UnlockedCliveSi", + "clive_use_unlocked_profile", +] -- GitLab From 7a7ae35e8505070a45f4c10bfd8d845ffaf42fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 3 Oct 2025 13:19:09 +0000 Subject: [PATCH 5/9] Prepare simply script with interface usage --- script_interface_examples/__init__.py | 0 .../example_unlocked_profile.py | 70 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 script_interface_examples/__init__.py create mode 100644 script_interface_examples/example_unlocked_profile.py diff --git a/script_interface_examples/__init__.py b/script_interface_examples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/script_interface_examples/example_unlocked_profile.py b/script_interface_examples/example_unlocked_profile.py new file mode 100644 index 0000000000..795b4cd8e9 --- /dev/null +++ b/script_interface_examples/example_unlocked_profile.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from clive.si import clive_use_unlocked_profile + +""" +STEPS BEFORE RUNNING THIS SCRIPT: +1. Run clive beekeeper +2. Export env variables in active console +3. Create profile "alice" +4. Unlock profile "alice" +""" + + +async def example_script() -> None: + async with clive_use_unlocked_profile() as clive: + await clive.show.witnesses("bob") + await clive.show.owner_authority("alice") + await clive.show.active_authority("alice") + await clive.show.posting_authority("alice") + await clive.show.balances("alice") + await clive.show.profiles() + + transfer = await clive.process.transfer( + from_account="alice", to_account="bob", amount="1.000 HIVE", memo="Test transfer" + ).as_transaction_object() + + await ( + clive.process.transaction_from_object( + from_object=transfer, + already_signed_mode="override", + ) + .sign_with("alice_key") + .save_file(path=Path(__file__).parent / "transfer_tx.json") + ) + + await ( + clive.process.update_active_authority( + account_name="alice", + threshold=2, + ) + .add_key( + key="STM5iuVuYcsZmCzXHJT9VbVvHATPa28cLMrf5zKEkzqKc73e22Jtr", + weight=1, + ) + .as_transaction_object() + ) + + await ( + clive.process.transfer( + from_account="alice", + to_account="bob", + amount="1.000 HIVE", + memo="Test transfer", + ) + .process.transfer( + from_account="alice", + to_account="bob", + amount="2.000 HIVE", + memo="Test transfer2", + ) + .sign_with(key="alice_key") + .as_transaction_object() + ) + + +if __name__ == "__main__": + asyncio.run(example_script()) -- GitLab From 0c8408eb9267f75ab03dcd4030f46aad8f77ac97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 3 Oct 2025 13:19:35 +0000 Subject: [PATCH 6/9] Update pydoclint-errors-baseline.txt --- pydoclint-errors-baseline.txt | 228 ++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index cde6ffd639..92e73c674e 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -289,6 +289,13 @@ clive/__private/core/commands/data_retrieval/find_scheduled_transfers.py DOC103: Method `AccountScheduledTransferData._receiver_check`: 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: [receiver: str, scheduled_transfer: ScheduledTransfer]. DOC201: Method `AccountScheduledTransferData._receiver_check` does not have a return section in docstring -------------------- +clive/__private/core/commands/perform_actions_on_transaction.py + DOC601: Class `PerformActionsOnTransaction`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `PerformActionsOnTransaction`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [serialization_mode: SerializationMode]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC101: Method `PerformActionsOnTransaction._normalize_sign_keys`: Docstring contains fewer arguments than in function signature. + DOC103: Method `PerformActionsOnTransaction._normalize_sign_keys`: 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: [keys: PublicKey | list[PublicKey]]. + DOC201: Method `PerformActionsOnTransaction._normalize_sign_keys` does not have a return section in docstring +-------------------- clive/__private/core/commands/recover_wallets.py DOC501: Method `RecoverWallets._validate_recovery_attempt` has raise statements, but the docstring does not have a "Raises" section DOC503: Method `RecoverWallets._validate_recovery_attempt` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['CannotRecoverWalletsError']. @@ -340,6 +347,227 @@ clive/__private/models/asset.py clive/__private/models/transaction.py DOC201: Method `Transaction.__bool__` does not have a return section in docstring -------------------- +clive/__private/si/base.py + DOC501: Method `UnlockedCliveSi._ensure_setup_called` has raise statements, but the docstring does not have a "Raises" section + DOC503: Method `UnlockedCliveSi._ensure_setup_called` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['SIContextManagerNotUsedError']. +-------------------- +clive/__private/si/configure.py + DOC101: Method `ConfigureInterface.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ConfigureInterface.__init__`: 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: [clive_instance: UnlockedCliveSi]. +-------------------- +clive/__private/si/core/base.py + DOC201: Method `CommandBase._run` does not have a return section in docstring +-------------------- +clive/__private/si/core/process/authority_operations.py + DOC101: Function `set_threshold`: Docstring contains fewer arguments than in function signature. + DOC103: Function `set_threshold`: 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: [auth: Authority, threshold: int]. + DOC201: Function `set_threshold` does not have a return section in docstring + DOC101: Function `add_key`: Docstring contains fewer arguments than in function signature. + DOC103: Function `add_key`: 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: [auth: Authority, key: str, weight: int]. + DOC201: Function `add_key` does not have a return section in docstring + DOC501: Function `add_key` has raise statements, but the docstring does not have a "Raises" section + DOC503: Function `add_key` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['AuthorityAlreadyExistsError']. + DOC101: Function `add_account`: Docstring contains fewer arguments than in function signature. + DOC103: Function `add_account`: 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: [account: str, auth: Authority, weight: int]. + DOC201: Function `add_account` does not have a return section in docstring + DOC501: Function `add_account` has raise statements, but the docstring does not have a "Raises" section + DOC503: Function `add_account` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['AuthorityAlreadyExistsError']. + DOC101: Function `remove_key`: Docstring contains fewer arguments than in function signature. + DOC103: Function `remove_key`: 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: [auth: Authority, key: str]. + DOC201: Function `remove_key` does not have a return section in docstring + DOC501: Function `remove_key` has raise statements, but the docstring does not have a "Raises" section + DOC503: Function `remove_key` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['AuthorityNotFoundError']. + DOC101: Function `remove_account`: Docstring contains fewer arguments than in function signature. + DOC103: Function `remove_account`: 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: [account: str, auth: Authority]. + DOC201: Function `remove_account` does not have a return section in docstring + DOC501: Function `remove_account` has raise statements, but the docstring does not have a "Raises" section + DOC503: Function `remove_account` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['AuthorityNotFoundError']. + DOC101: Function `modify_key`: Docstring contains fewer arguments than in function signature. + DOC103: Function `modify_key`: 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: [auth: Authority, key: str, weight: int]. + DOC201: Function `modify_key` does not have a return section in docstring + DOC101: Function `modify_account`: Docstring contains fewer arguments than in function signature. + DOC103: Function `modify_account`: 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: [account: str, auth: Authority, weight: int]. + DOC201: Function `modify_account` does not have a return section in docstring +-------------------- +clive/__private/si/core/process/chaining.py + DOC101: Function `broadcast_transaction`: Docstring contains fewer arguments than in function signature. + DOC103: Function `broadcast_transaction`: 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: [node: Node, transaction: Transaction]. + DOC101: Function `save_transaction_to_file`: Docstring contains fewer arguments than in function signature. + DOC103: Function `save_transaction_to_file`: 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: [file_format: Literal['json', 'bin'] | None, file_path: str | Path, serialization_mode: SerializationMode, transaction: Transaction]. + DOC101: Method `TransactionResult.save_file`: Docstring contains fewer arguments than in function signature. + DOC103: Method `TransactionResult.save_file`: 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: [file_format: Literal['json', 'bin'] | None, path: str | Path, serialization_mode: SerializationMode]. + DOC601: Class `TransactionFinalizer`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `TransactionFinalizer`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [world: World]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC101: Method `TransactionFinalizer.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `TransactionFinalizer.__init__`: 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: [all_operations: list[OperationBuilder], world: World]. + DOC101: Method `TransactionFinalizer.save_file`: Docstring contains fewer arguments than in function signature. + DOC103: Method `TransactionFinalizer.save_file`: 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: [file_format: Literal['json', 'bin'], path: str | Path, serialization_mode: SerializationMode]. + DOC201: Method `TransactionFinalizer.as_transaction_object` does not have a return section in docstring + DOC201: Method `TransactionFinalizer._get_signing_configuration` does not have a return section in docstring + DOC101: Method `ChainableOperationBuilder.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ChainableOperationBuilder.__init__`: 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: [previous_operations: list[OperationBuilder] | None, world: World]. + DOC101: Method `ChainableOperationBuilder._all_operations`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ChainableOperationBuilder._all_operations`: 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: [value: list[OperationBuilder]]. + DOC101: Method `ChainableOperationBuilder.sign_with`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ChainableOperationBuilder.sign_with`: 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: [key: str]. + DOC201: Method `ChainableOperationBuilder.sign_with` does not have a return section in docstring + DOC201: Method `ChainableOperationBuilder.autosign` does not have a return section in docstring + DOC101: Function `create_transaction_result_class`: Docstring contains fewer arguments than in function signature. + DOC103: Function `create_transaction_result_class`: 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: [world: World]. + DOC201: Function `create_transaction_result_class` does not have a return section in docstring + DOC101: Method `SigningChain.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `SigningChain.__init__`: 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: [all_operations: list[OperationBuilder], sign_keys: list[str], world: World]. + DOC101: Method `SigningChain.sign_with`: Docstring contains fewer arguments than in function signature. + DOC103: Method `SigningChain.sign_with`: 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: [key: str]. + DOC201: Method `SigningChain.sign_with` does not have a return section in docstring + DOC201: Method `SigningChain._get_signing_configuration` does not have a return section in docstring + DOC201: Method `AutoSignChain._get_signing_configuration` does not have a return section in docstring + DOC101: Method `AuthorityUpdateChain.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `AuthorityUpdateChain.__init__`: 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: [account_name: str, authority_type: AuthorityLevelRegular, previous_operations: list[OperationBuilder] | None, threshold: int | None, world: World]. + DOC201: Method `AuthorityUpdateChain._create_authority_builder` does not have a return section in docstring + DOC201: Method `AuthorityUpdateChain._get_authority_builder` does not have a return section in docstring +-------------------- +clive/__private/si/core/process/process.py + DOC101: Method `OperationBuilder.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationBuilder.__init__`: 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: [world: World]. + DOC101: Method `OperationBuilder.finalize`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationBuilder.finalize`: 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: [already_signed_mode: AlreadySignedMode | None, autosign: bool | None, broadcast: bool, file_format: Literal['json', 'bin'] | None, force_unsign: bool | None, save_file: str | Path | None, serialization_mode: SerializationMode, sign_with: list[str] | str | None]. + DOC201: Method `OperationBuilder.finalize` does not have a return section in docstring + DOC201: Method `OperationBuilder._create_operation` does not have a return section in docstring + DOC502: Method `OperationBuilder._resolve_key_or_alias` has a "Raises" section in the docstring, but there are not "raise" statements in the body + DOC101: Method `OperationBuilder._normalize_sign_with`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationBuilder._normalize_sign_with`: 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: [sign_with: list[str] | str | None]. + DOC201: Method `OperationBuilder._normalize_sign_with` does not have a return section in docstring + DOC101: Method `TransferBuilder.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `TransferBuilder.__init__`: 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: [amount: str | Asset.LiquidT, from_account: str, memo: str, to_account: str, world: World]. + DOC201: Method `TransferBuilder._normalize_amount` does not have a return section in docstring + DOC101: Method `TransactionBuilder.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `TransactionBuilder.__init__`: 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: [already_signed_mode: AlreadySignedMode | None, force: bool, force_unsign: bool, from_file: str | Path | None, from_object: Transaction | None, world: World]. + DOC101: Method `TransactionBuilder.finalize`: Docstring contains fewer arguments than in function signature. + DOC103: Method `TransactionBuilder.finalize`: 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: [already_signed_mode: AlreadySignedMode | None, autosign: bool | None, broadcast: bool, file_format: Literal['json', 'bin'] | None, force_unsign: bool | None, save_file: str | Path | None, serialization_mode: SerializationMode, sign_with: list[str] | str | None]. + DOC201: Method `TransactionBuilder.finalize` does not have a return section in docstring + DOC201: Method `TransactionBuilder._create_operation` does not have a return section in docstring + DOC501: Method `TransactionBuilder._create_operation` has raise statements, but the docstring does not have a "Raises" section + DOC503: Method `TransactionBuilder._create_operation` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['NotImplementedError']. + DOC101: Method `AuthorityBuilder.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `AuthorityBuilder.__init__`: 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: [account_name: str, authority_type: AuthorityLevelRegular, threshold: int | None, world: World]. + DOC101: Method `AuthorityBuilder.add_key`: Docstring contains fewer arguments than in function signature. + DOC103: Method `AuthorityBuilder.add_key`: 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: [key: str, weight: int]. + DOC201: Method `AuthorityBuilder.add_key` does not have a return section in docstring + DOC101: Method `AuthorityBuilder.add_account`: Docstring contains fewer arguments than in function signature. + DOC103: Method `AuthorityBuilder.add_account`: 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: [account_name: str, weight: int]. + DOC201: Method `AuthorityBuilder.add_account` does not have a return section in docstring + DOC101: Method `AuthorityBuilder.remove_key`: Docstring contains fewer arguments than in function signature. + DOC103: Method `AuthorityBuilder.remove_key`: 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: [key: str]. + DOC201: Method `AuthorityBuilder.remove_key` does not have a return section in docstring + DOC101: Method `AuthorityBuilder.remove_account`: Docstring contains fewer arguments than in function signature. + DOC103: Method `AuthorityBuilder.remove_account`: 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: [account_name: str]. + DOC201: Method `AuthorityBuilder.remove_account` does not have a return section in docstring + DOC101: Method `AuthorityBuilder.modify_key`: Docstring contains fewer arguments than in function signature. + DOC103: Method `AuthorityBuilder.modify_key`: 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: [key: str, weight: int]. + DOC201: Method `AuthorityBuilder.modify_key` does not have a return section in docstring + DOC101: Method `AuthorityBuilder.modify_account`: Docstring contains fewer arguments than in function signature. + DOC103: Method `AuthorityBuilder.modify_account`: 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: [account: str, weight: int]. + DOC201: Method `AuthorityBuilder.modify_account` does not have a return section in docstring + DOC201: Method `AuthorityBuilder._create_operation` does not have a return section in docstring + DOC101: Method `MultipleOperationsBuilder.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `MultipleOperationsBuilder.__init__`: 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: [builders: list[OperationBuilder], world: World]. + DOC501: Method `MultipleOperationsBuilder.validate` has raise statements, but the docstring does not have a "Raises" section + DOC503: Method `MultipleOperationsBuilder.validate` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['CannotAddOperationToSignedTransactionError']. + DOC201: Method `MultipleOperationsBuilder._create_operation` does not have a return section in docstring + DOC501: Method `MultipleOperationsBuilder._create_operation` has raise statements, but the docstring does not have a "Raises" section + DOC503: Method `MultipleOperationsBuilder._create_operation` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['NotImplementedError']. + DOC201: Method `MultipleOperationsBuilder._get_transaction_content` does not have a return section in docstring +-------------------- +clive/__private/si/data_classes.py + DOC601: Class `Balances`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `Balances`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [hbd_liquid: Asset.Hbd, hbd_savings: Asset.Hbd, hbd_unclaimed: Asset.Hbd, hive_liquid: Asset.Hive, hive_savings: Asset.Hive, hive_unclaimed: Asset.Hive]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `Accounts`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `Accounts`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [known_accounts: list[str], tracked_accounts: list[str], working_account: str | None]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `Authority`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `Authority`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [account_or_public_key: str, weight: int]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `AuthorityInfo`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `AuthorityInfo`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [authorities: list[Authority], authority_owner_account_name: str, authority_type: str, weight_threshold: int]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `KeyPair`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `KeyPair`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [private_key: str | None, public_key: str | None]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) +-------------------- +clive/__private/si/exceptions.py + DOC101: Method `SIContextManagerNotUsedError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `SIContextManagerNotUsedError.__init__`: 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: [message: str]. + DOC101: Method `PasswordRequirementsNotMetError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `PasswordRequirementsNotMetError.__init__`: 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: [description: str]. + DOC101: Method `InvalidAccountNameError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidAccountNameError.__init__`: 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: [account_name: str]. + DOC101: Method `InvalidProfileNameError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidProfileNameError.__init__`: 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: [description: str, profile_name: str]. + DOC101: Method `InvalidPageNumberError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidPageNumberError.__init__`: 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: [min_number: int, page_number: int]. + DOC101: Method `InvalidPageSizeError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidPageSizeError.__init__`: 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: [min_size: int, page_size: int]. + DOC101: Method `InvalidNumberOfKeyPairsError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidNumberOfKeyPairsError.__init__`: 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: [min_size: int, number_of_key_pairs: int]. +-------------------- +clive/__private/si/generate.py + DOC101: Method `GenerateInterface.random_key`: Docstring contains fewer arguments than in function signature. + DOC103: Method `GenerateInterface.random_key`: 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: [key_pairs: int]. + DOC201: Method `GenerateInterface.random_key` does not have a return section in docstring +-------------------- +clive/__private/si/process.py + DOC101: Method `OperationsBuilderInterface.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationsBuilderInterface.__init__`: 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: [all_operations: list[OperationBuilder] | None, world: World]. + DOC101: Method `OperationsBuilderInterface.transfer`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationsBuilderInterface.transfer`: 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: [amount: str | Asset.LiquidT, from_account: str, memo: str, to_account: str]. + DOC201: Method `OperationsBuilderInterface.transfer` does not have a return section in docstring + DOC101: Method `OperationsBuilderInterface.update_owner_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationsBuilderInterface.update_owner_authority`: 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: [account_name: str, threshold: int | None]. + DOC201: Method `OperationsBuilderInterface.update_owner_authority` does not have a return section in docstring + DOC101: Method `OperationsBuilderInterface.update_active_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationsBuilderInterface.update_active_authority`: 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: [account_name: str, threshold: int | None]. + DOC201: Method `OperationsBuilderInterface.update_active_authority` does not have a return section in docstring + DOC101: Method `OperationsBuilderInterface.update_posting_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationsBuilderInterface.update_posting_authority`: 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: [account_name: str, threshold: int | None]. + DOC201: Method `OperationsBuilderInterface.update_posting_authority` does not have a return section in docstring + DOC101: Method `OperationsBuilderInterface._update_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationsBuilderInterface._update_authority`: 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: [account_name: str, authority_type: AuthorityLevelRegular, threshold: int | None]. + DOC201: Method `OperationsBuilderInterface._update_authority` does not have a return section in docstring + DOC101: Method `ProfileOperationsInterface.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ProfileOperationsInterface.__init__`: 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: [clive_instance: UnlockedCliveSi]. + DOC101: Method `ProfileOperationsInterface.transaction`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ProfileOperationsInterface.transaction`: 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: [already_signed_mode: AlreadySignedMode | None, force: bool, force_unsign: bool, from_file: str | Path]. + DOC201: Method `ProfileOperationsInterface.transaction` does not have a return section in docstring + DOC101: Method `ProfileOperationsInterface.transaction_from_object`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ProfileOperationsInterface.transaction_from_object`: 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: [already_signed_mode: AlreadySignedMode | None, force: bool, force_unsign: bool, from_object: Transaction | None]. + DOC201: Method `ProfileOperationsInterface.transaction_from_object` does not have a return section in docstring + DOC101: Method `ChainingOperationsInterface.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ChainingOperationsInterface.__init__`: 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: [all_operations: list[OperationBuilder], world: World]. +-------------------- +clive/__private/si/show.py + DOC201: Method `ShowInterfaceNoProfile.profiles` does not have a return section in docstring + DOC101: Method `ShowInterface.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.__init__`: 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: [clive_instance: UnlockedCliveSi]. + DOC101: Method `ShowInterface.balances`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.balances`: 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: [account_name: str]. + DOC201: Method `ShowInterface.balances` does not have a return section in docstring + DOC201: Method `ShowInterface.accounts` does not have a return section in docstring + DOC101: Method `ShowInterface.witnesses`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.witnesses`: 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: [account_name: str, page_no: int, page_size: int]. + DOC201: Method `ShowInterface.witnesses` does not have a return section in docstring + DOC101: Method `ShowInterface.owner_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.owner_authority`: 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: [account_name: str]. + DOC201: Method `ShowInterface.owner_authority` does not have a return section in docstring + DOC101: Method `ShowInterface.active_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.active_authority`: 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: [account_name: str]. + DOC201: Method `ShowInterface.active_authority` does not have a return section in docstring + DOC101: Method `ShowInterface.posting_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.posting_authority`: 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: [account_name: str]. + DOC201: Method `ShowInterface.posting_authority` does not have a return section in docstring +-------------------- +clive/__private/si/validators.py + DOC001: Function/method `validate`: Potential formatting errors in docstring. Error message: Expected a colon in 'An appropriate exception from clive.__private.si.exceptions if validation fails.'. (Note: DOC001 could trigger other unrelated violations under this function/method too. Please fix the docstring formatting first.) + DOC003: Function/method `validate`: Docstring style mismatch. (Please read more at https://jsh9.github.io/pydoclint/style_mismatch.html ). You specified "google" style, but the docstring is likely not written in this style. + DOC101: Method `Validator.validate`: Docstring contains fewer arguments than in function signature. + DOC103: Method `Validator.validate`: 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: [value: object]. +-------------------- clive/__private/storage/service/service.py DOC502: Method `PersistentStorageService.save_profile` has a "Raises" section in the docstring, but there are not "raise" statements in the body DOC502: Method `PersistentStorageService.load_profile` has a "Raises" section in the docstring, but there are not "raise" statements in the body -- GitLab From 7e2d09acb91b68a3ba121580fa1e7feec8745031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Wed, 8 Oct 2025 07:16:46 +0000 Subject: [PATCH 7/9] Use SI interface in CLI --- .../cli/commands/show/show_accounts.py | 15 ++++++------ .../cli/commands/show/show_authority.py | 12 +++++----- .../cli/commands/show/show_balances.py | 20 +++++++--------- .../cli/commands/show/show_profile.py | 2 +- .../cli/commands/show/show_profiles.py | 4 ++-- .../cli/commands/show/show_witnesses.py | 24 +++++-------------- 6 files changed, 31 insertions(+), 46 deletions(-) diff --git a/clive/__private/cli/commands/show/show_accounts.py b/clive/__private/cli/commands/show/show_accounts.py index 2b5b857957..0c9751a582 100644 --- a/clive/__private/cli/commands/show/show_accounts.py +++ b/clive/__private/cli/commands/show/show_accounts.py @@ -4,18 +4,19 @@ from dataclasses import dataclass from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.print_cli import print_cli +from clive.__private.si.core.show import ShowAccounts as ShowAccountsSi @dataclass(kw_only=True) class ShowAccounts(WorldBasedCommand): async def _run(self) -> None: - self._show_accounts_info() + await self._show_accounts_info() - def _show_accounts_info(self) -> None: - profile = self.profile - if profile.accounts.has_working_account: - print_cli(f"Working account: {profile.accounts.working.name}") + async def _show_accounts_info(self) -> None: + accounts = await ShowAccountsSi(world=self.world).run() + if accounts.working_account is not None: + print_cli(f"Working account: {accounts.working_account}") else: print_cli("Working account is not set.") - print_cli(f"Tracked accounts: {[account.name for account in profile.accounts.tracked]}") - print_cli(f"Known accounts: {[account.name for account in profile.accounts.known]}") + print_cli(f"Tracked accounts: {accounts.tracked_accounts}") + print_cli(f"Known accounts: {accounts.known_accounts}") diff --git a/clive/__private/cli/commands/show/show_authority.py b/clive/__private/cli/commands/show/show_authority.py index 8f036715da..6c025d3f20 100644 --- a/clive/__private/cli/commands/show/show_authority.py +++ b/clive/__private/cli/commands/show/show_authority.py @@ -7,6 +7,7 @@ from rich.table import Table from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.print_cli import print_cli +from clive.__private.si.core.show import ShowAuthority as ShowAuthoritySi if TYPE_CHECKING: from clive.__private.core.types import AuthorityLevelRegular @@ -18,18 +19,17 @@ class ShowAuthority(WorldBasedCommand): authority: AuthorityLevelRegular async def _run(self) -> None: - accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise - account = accounts[0] + authority_info = await ShowAuthoritySi(self.world, self.account_name, self.authority).run() title = ( - f"{self.authority} authority of `{account.name}` account," - f"\nweight threshold is {account[self.authority].weight_threshold}:" + f"{authority_info.authority_type} authority of `{authority_info.authority_owner_account_name}` account," + f"\nweight threshold is {authority_info.weight_threshold}:" ) table = Table(title=title) table.add_column("account or public key", min_width=53) table.add_column("weight", justify="right") - for auth, weight in [*account[self.authority].key_auths, *account[self.authority].account_auths]: - table.add_row(f"{auth}", f"{weight}") + for authority in authority_info.authorities: + table.add_row(f"{authority.account_or_public_key}", f"{authority.weight}") print_cli(table) diff --git a/clive/__private/cli/commands/show/show_balances.py b/clive/__private/cli/commands/show/show_balances.py index 713ead2659..f6627b1c79 100644 --- a/clive/__private/cli/commands/show/show_balances.py +++ b/clive/__private/cli/commands/show/show_balances.py @@ -6,8 +6,8 @@ from rich.table import Table from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.print_cli import print_cli -from clive.__private.core.accounts.accounts import TrackedAccount from clive.__private.models.asset import Asset +from clive.__private.si.core.show import ShowBalances as ShowBalancesSi @dataclass(kw_only=True) @@ -15,20 +15,16 @@ class ShowBalances(WorldBasedCommand): account_name: str async def _run(self) -> None: - account = TrackedAccount(name=self.account_name) - - await self.world.commands.update_node_data(accounts=[account]) - + balances = await ShowBalancesSi(world=self.world, account_name=self.account_name).run() table = Table(title=f"Balances of `{self.account_name}` account") table.add_column("Type", justify="left", style="cyan", no_wrap=True) table.add_column("Amount", justify="right", style="green", no_wrap=True) - data = account.data - table.add_row("HBD Liquid", f"{Asset.pretty_amount(data.hbd_balance)}") - table.add_row("HBD Savings", f"{Asset.pretty_amount(data.hbd_savings)}") - table.add_row("HBD Unclaimed", f"{Asset.pretty_amount(data.hbd_unclaimed)}") - table.add_row("HIVE Liquid", f"{Asset.pretty_amount(data.hive_balance)}") - table.add_row("HIVE Savings", f"{Asset.pretty_amount(data.hive_savings)}") - table.add_row("HIVE Unclaimed", f"{Asset.pretty_amount(data.hive_unclaimed)}") + table.add_row("HBD Liquid", f"{Asset.pretty_amount(balances.hbd_liquid)}") + table.add_row("HBD Savings", f"{Asset.pretty_amount(balances.hbd_savings)}") + table.add_row("HBD Unclaimed", f"{Asset.pretty_amount(balances.hbd_unclaimed)}") + table.add_row("HIVE Liquid", f"{Asset.pretty_amount(balances.hive_liquid)}") + table.add_row("HIVE Savings", f"{Asset.pretty_amount(balances.hive_savings)}") + table.add_row("HIVE Unclaimed", f"{Asset.pretty_amount(balances.hive_unclaimed)}") print_cli(table) diff --git a/clive/__private/cli/commands/show/show_profile.py b/clive/__private/cli/commands/show/show_profile.py index c811736da3..426e3af5f1 100644 --- a/clive/__private/cli/commands/show/show_profile.py +++ b/clive/__private/cli/commands/show/show_profile.py @@ -11,7 +11,7 @@ from clive.__private.core.formatters.humanize import humanize_bool class ShowProfile(ShowAccounts): async def _run(self) -> None: self._show_profile_info() - self._show_accounts_info() + await self._show_accounts_info() def _show_profile_info(self) -> None: profile = self.profile diff --git a/clive/__private/cli/commands/show/show_profiles.py b/clive/__private/cli/commands/show/show_profiles.py index 8eb4ec6ed5..481865a754 100644 --- a/clive/__private/cli/commands/show/show_profiles.py +++ b/clive/__private/cli/commands/show/show_profiles.py @@ -4,10 +4,10 @@ from dataclasses import dataclass from clive.__private.cli.commands.abc.external_cli_command import ExternalCLICommand from clive.__private.cli.print_cli import print_cli -from clive.__private.core.profile import Profile +from clive.__private.si.core.show import ShowProfiles as ShowProfilesSi @dataclass(kw_only=True) class ShowProfiles(ExternalCLICommand): async def _run(self) -> None: - print_cli(f"Stored profiles are: {Profile.list_profiles()}") + print_cli(f"Stored profiles are: {await ShowProfilesSi().run()}") diff --git a/clive/__private/cli/commands/show/show_witnesses.py b/clive/__private/cli/commands/show/show_witnesses.py index c39783fb45..a7665aaa6b 100644 --- a/clive/__private/cli/commands/show/show_witnesses.py +++ b/clive/__private/cli/commands/show/show_witnesses.py @@ -8,11 +8,11 @@ from rich.table import Table from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.print_cli import print_cli from clive.__private.cli.table_pagination_info import add_pagination_info_to_table_if_needed -from clive.__private.core.commands.data_retrieval.witnesses_data import WitnessesDataRetrieval from clive.__private.core.formatters.humanize import humanize_bool +from clive.__private.si.core.show import ShowWitnesses as ShowWitnessesSi if TYPE_CHECKING: - from clive.__private.core.commands.data_retrieval.witnesses_data import WitnessData, WitnessesData + from clive.__private.core.commands.data_retrieval.witnesses_data import WitnessData @dataclass(kw_only=True) @@ -22,21 +22,9 @@ class ShowWitnesses(WorldBasedCommand): page_no: int async def _run(self) -> None: - accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise - proxy = accounts[0].proxy - - wrapper = await self.world.commands.retrieve_witnesses_data( - account_name=proxy if proxy else self.account_name, - mode=WitnessesDataRetrieval.DEFAULT_MODE, - witness_name_pattern=None, - search_by_pattern_limit=WitnessesDataRetrieval.DEFAULT_SEARCH_BY_PATTERN_LIMIT, - ) - witnesses_data: WitnessesData = wrapper.result_or_raise - start_index: int = self.page_no * self.page_size - end_index: int = start_index + self.page_size - witnesses_list: list[WitnessData] = list(witnesses_data.witnesses.values()) - witnesses_chunk: list[WitnessData] = witnesses_list[start_index:end_index] - + witnesses_list_len, proxy, witnesses_chunk = await ShowWitnessesSi( + world=self.world, account_name=self.account_name, page_size=self.page_size, page_no=self.page_no + ).get_witness_chunk() proxy_name_message = f"`{self.account_name}`" if proxy: proxy_name_message += f" (proxy set to `{proxy}`)" @@ -67,7 +55,7 @@ class ShowWitnesses(WorldBasedCommand): ) add_pagination_info_to_table_if_needed( - table=table, page_no=self.page_no, page_size=self.page_size, all_entries=len(witnesses_list) + table=table, page_no=self.page_no, page_size=self.page_size, all_entries=witnesses_list_len ) print_cli(table) -- GitLab From 37cb2b151d0a4a179a0959876efdae1cde01d12b Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk Date: Wed, 19 Nov 2025 17:16:52 +0000 Subject: [PATCH 8/9] Prepare smoke tests for clive SI --- .gitlab-ci.yml | 9 + pydoclint-errors-baseline.txt | 12 +- tests/functional/conftest.py | 25 +- tests/functional/si/__init__.py | 0 tests/functional/si/conftest.py | 63 +++ tests/functional/si/process/__init__.py | 0 tests/functional/si/process/constants.py | 47 ++ .../si/process/test_authority_operations.py | 397 ++++++++++++++++ .../si/process/test_finalizations.py | 179 ++++++++ tests/functional/si/process/test_signing.py | 145 ++++++ .../si/process/test_transaction_loading.py | 431 ++++++++++++++++++ .../si/process/test_transfer_operations.py | 209 +++++++++ 12 files changed, 1498 insertions(+), 19 deletions(-) create mode 100644 tests/functional/si/__init__.py create mode 100644 tests/functional/si/conftest.py create mode 100644 tests/functional/si/process/__init__.py create mode 100644 tests/functional/si/process/constants.py create mode 100644 tests/functional/si/process/test_authority_operations.py create mode 100644 tests/functional/si/process/test_finalizations.py create mode 100644 tests/functional/si/process/test_signing.py create mode 100644 tests/functional/si/process/test_transaction_loading.py create mode 100644 tests/functional/si/process/test_transfer_operations.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c68a60c62e..e7584515a0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -262,6 +262,15 @@ testing_cli: - export PYTEST_ARGS=(tests/functional/cli -v) - !reference [.run-pytest, script] +testing_si: + extends: .testing + variables: + PYTEST_TIMEOUT_MINUTES: 10 + script: + - echo -e "${TXT_BLUE}Launching script interface tests...${TXT_CLEAR}" + - export PYTEST_ARGS=(tests/functional/si -v) + - !reference [.run-pytest, script] + testing_password_private_key_logging: stage: tests needs: diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index 92e73c674e..de61ecb00b 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -430,9 +430,9 @@ clive/__private/si/core/process/chaining.py clive/__private/si/core/process/process.py DOC101: Method `OperationBuilder.__init__`: Docstring contains fewer arguments than in function signature. DOC103: Method `OperationBuilder.__init__`: 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: [world: World]. - DOC101: Method `OperationBuilder.finalize`: Docstring contains fewer arguments than in function signature. - DOC103: Method `OperationBuilder.finalize`: 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: [already_signed_mode: AlreadySignedMode | None, autosign: bool | None, broadcast: bool, file_format: Literal['json', 'bin'] | None, force_unsign: bool | None, save_file: str | Path | None, serialization_mode: SerializationMode, sign_with: list[str] | str | None]. - DOC201: Method `OperationBuilder.finalize` does not have a return section in docstring + DOC101: Method `OperationBuilder.run`: Docstring contains fewer arguments than in function signature. + DOC103: Method `OperationBuilder.run`: 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: [already_signed_mode: AlreadySignedMode | None, autosign: bool | None, broadcast: bool, file_format: Literal['json', 'bin'] | None, force_unsign: bool | None, save_file: str | Path | None, serialization_mode: SerializationMode, sign_with: list[str] | str | None]. + DOC201: Method `OperationBuilder.run` does not have a return section in docstring DOC201: Method `OperationBuilder._create_operation` does not have a return section in docstring DOC502: Method `OperationBuilder._resolve_key_or_alias` has a "Raises" section in the docstring, but there are not "raise" statements in the body DOC101: Method `OperationBuilder._normalize_sign_with`: Docstring contains fewer arguments than in function signature. @@ -443,9 +443,9 @@ clive/__private/si/core/process/process.py DOC201: Method `TransferBuilder._normalize_amount` does not have a return section in docstring DOC101: Method `TransactionBuilder.__init__`: Docstring contains fewer arguments than in function signature. DOC103: Method `TransactionBuilder.__init__`: 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: [already_signed_mode: AlreadySignedMode | None, force: bool, force_unsign: bool, from_file: str | Path | None, from_object: Transaction | None, world: World]. - DOC101: Method `TransactionBuilder.finalize`: Docstring contains fewer arguments than in function signature. - DOC103: Method `TransactionBuilder.finalize`: 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: [already_signed_mode: AlreadySignedMode | None, autosign: bool | None, broadcast: bool, file_format: Literal['json', 'bin'] | None, force_unsign: bool | None, save_file: str | Path | None, serialization_mode: SerializationMode, sign_with: list[str] | str | None]. - DOC201: Method `TransactionBuilder.finalize` does not have a return section in docstring + DOC101: Method `TransactionBuilder.run`: Docstring contains fewer arguments than in function signature. + DOC103: Method `TransactionBuilder.run`: 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: [already_signed_mode: AlreadySignedMode | None, autosign: bool | None, broadcast: bool, file_format: Literal['json', 'bin'] | None, force_unsign: bool | None, save_file: str | Path | None, serialization_mode: SerializationMode, sign_with: list[str] | str | None]. + DOC201: Method `TransactionBuilder.run` does not have a return section in docstring DOC201: Method `TransactionBuilder._create_operation` does not have a return section in docstring DOC501: Method `TransactionBuilder._create_operation` has raise statements, but the docstring does not have a "Raises" section DOC503: Method `TransactionBuilder._create_operation` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['NotImplementedError']. diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 8a10bf7096..1b48ec41dd 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -41,7 +41,7 @@ def logger_configuration_factory() -> Callable[[], None]: @pytest.fixture async def beekeeper_local() -> AsyncGenerator[AsyncBeekeeper]: - """Tests are remotely connecting to a locally started beekeeper by this fixture.""" + """CLI tests are remotely connecting to a locally started beekeeper by this fixture.""" async with await bk.AsyncBeekeeper.factory( settings=safe_settings.beekeeper.settings_local_factory() ) as beekeeper_cm: @@ -49,7 +49,7 @@ async def beekeeper_local() -> AsyncGenerator[AsyncBeekeeper]: @pytest.fixture -async def world_with_remote_beekeeper(beekeeper_local: AsyncBeekeeper) -> AsyncGenerator[World]: +async def world_cli(beekeeper_local: AsyncBeekeeper) -> AsyncGenerator[World]: token = await (await beekeeper_local.session).token world = World() @@ -60,33 +60,32 @@ async def world_with_remote_beekeeper(beekeeper_local: AsyncBeekeeper) -> AsyncG @pytest.fixture -async def _prepare_profile_and_setup_wallet(world_with_remote_beekeeper: World) -> Profile: +async def prepare_profile_with_wallet_cli(world_cli: World) -> Profile: """Prepare profile and wallets using remote beekeeper.""" - world = world_with_remote_beekeeper - await world.create_new_profile_with_wallets( + await world_cli.create_new_profile_with_wallets( name=WORKING_ACCOUNT_NAME, password=WORKING_ACCOUNT_PASSWORD, working_account=WORKING_ACCOUNT_NAME, watched_accounts=WATCHED_ACCOUNTS_NAMES, known_accounts=KNOWN_ACCOUNT_NAMES, ) - await world.commands.sync_state_with_beekeeper() - world.profile.keys.add_to_import( + await world_cli.commands.sync_state_with_beekeeper() + world_cli.profile.keys.add_to_import( PrivateKeyAliased(value=WORKING_ACCOUNT_DATA.account.private_key, alias=f"{WORKING_ACCOUNT_KEY_ALIAS}") ) - await world.commands.sync_data_with_beekeeper() - await world.commands.save_profile() # required for saving imported keys aliases - return world.profile + await world_cli.commands.sync_data_with_beekeeper() + await world_cli.commands.save_profile() # required for saving imported keys aliases + return world_cli.profile @pytest.fixture async def node( node_address_env_context_factory: EnvContextFactory, - world_with_remote_beekeeper: World, - _prepare_profile_and_setup_wallet: Profile, + world_cli: World, + prepare_profile_with_wallet_cli: Profile, # noqa: ARG001 ) -> AsyncGenerator[tt.RawNode]: node = run_node() - await world_with_remote_beekeeper.set_address(node.http_endpoint) + await world_cli.set_address(node.http_endpoint) address = str(node.http_endpoint) with node_address_env_context_factory(address): yield node diff --git a/tests/functional/si/__init__.py b/tests/functional/si/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/functional/si/conftest.py b/tests/functional/si/conftest.py new file mode 100644 index 0000000000..228488df5e --- /dev/null +++ b/tests/functional/si/conftest.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from contextlib import ExitStack +from typing import TYPE_CHECKING + +import pytest + +from clive.__private.core.keys.keys import PrivateKeyAliased +from clive.si import UnlockedCliveSi +from clive_local_tools.data.constants import ALT_WORKING_ACCOUNT1_KEY_ALIAS +from clive_local_tools.testnet_block_log import ALT_WORKING_ACCOUNT1_DATA + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + import test_tools as tt + from beekeepy import AsyncBeekeeper + + from clive.__private.core.profile import Profile + from clive.__private.core.world import World + from clive_local_tools.types import EnvContextFactory + + +@pytest.fixture +async def _prepare_profile_with_wallet_cli_two_keys( + prepare_profile_with_wallet_cli: Profile, # noqa: ARG001 + world_cli: World, +) -> Profile: + """Add second key to prepared profile in previous fixture.""" + world_cli.profile.keys.add_to_import( + PrivateKeyAliased( + value=ALT_WORKING_ACCOUNT1_DATA.account.private_key, alias=f"{ALT_WORKING_ACCOUNT1_KEY_ALIAS}" + ) + ) + await world_cli.commands.sync_data_with_beekeeper() + await world_cli.commands.save_profile() # required for saving imported keys aliases + return world_cli.profile + + +@pytest.fixture +async def clive_si( + beekeeper_remote_address_env_context_factory: EnvContextFactory, + beekeeper_session_token_env_context_factory: EnvContextFactory, + beekeeper_local: AsyncBeekeeper, + node: tt.RawNode, # noqa: ARG001 +) -> AsyncGenerator[UnlockedCliveSi]: + """Will return unlocked clive script interface.""" + with ExitStack() as stack: + address = str(beekeeper_local.http_endpoint) + token = await (await beekeeper_local.session).token + stack.enter_context(beekeeper_remote_address_env_context_factory(address)) + stack.enter_context(beekeeper_session_token_env_context_factory(token)) + async with UnlockedCliveSi() as clive: + yield clive + + +@pytest.fixture +async def clive_si_with_two_keys_profile( + _prepare_profile_with_wallet_cli_two_keys: Profile, + clive_si: UnlockedCliveSi, +) -> UnlockedCliveSi: + """Will return unlocked clive script interface.""" + return clive_si diff --git a/tests/functional/si/process/__init__.py b/tests/functional/si/process/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/functional/si/process/constants.py b/tests/functional/si/process/constants.py new file mode 100644 index 0000000000..499da9a41e --- /dev/null +++ b/tests/functional/si/process/constants.py @@ -0,0 +1,47 @@ +"""Shared constants for Script Interface process tests.""" + +from __future__ import annotations + +from typing import Final + +import test_tools as tt + +from clive_local_tools.data.constants import ALT_WORKING_ACCOUNT1_KEY_ALIAS, WORKING_ACCOUNT_KEY_ALIAS +from clive_local_tools.testnet_block_log.constants import ( + WATCHED_ACCOUNTS_DATA, + WORKING_ACCOUNT_DATA, + WORKING_ACCOUNT_NAME, +) + +# Test accounts +RECEIVER: Final[str] = WATCHED_ACCOUNTS_DATA[0].account.name +SECOND_RECEIVER: Final[str] = WATCHED_ACCOUNTS_DATA[1].account.name + +# Test amounts +AMOUNT: Final[tt.Asset.HiveT] = tt.Asset.Hive(1) +AMOUNT2: Final[tt.Asset.HiveT] = tt.Asset.Hive(2) + +# Test memos +MEMO: Final[str] = "test-process-transfer-memo" +MEMO2: Final[str] = "test-process-transfer-memo-second" + +# Test keys +TEST_KEY: Final[str] = "STM5tE6iiVkizDrhPU6pAGxFuW38gWJS2Vemue1nYtZ3Zn9zh4Dhn" +TEST_KEY2: Final[str] = "STM7sw22HqsXbz7D2CmJfmMwt9rimJTmqGHHVuT8uN9MqKUVESTrU" + +# Re-export commonly used constants +__all__ = [ + "ALT_WORKING_ACCOUNT1_KEY_ALIAS", + "AMOUNT", + "AMOUNT2", + "MEMO", + "MEMO2", + "RECEIVER", + "SECOND_RECEIVER", + "TEST_KEY", + "TEST_KEY2", + "WATCHED_ACCOUNTS_DATA", + "WORKING_ACCOUNT_DATA", + "WORKING_ACCOUNT_KEY_ALIAS", + "WORKING_ACCOUNT_NAME", +] diff --git a/tests/functional/si/process/test_authority_operations.py b/tests/functional/si/process/test_authority_operations.py new file mode 100644 index 0000000000..d3749f266c --- /dev/null +++ b/tests/functional/si/process/test_authority_operations.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.models.schemas import Authority +from clive_local_tools.testnet_block_log.constants import WORKING_ACCOUNT_DATA +from schemas.operations.account_update2_operation import AccountUpdate2Operation + +from .constants import AMOUNT, MEMO, RECEIVER, TEST_KEY, WORKING_ACCOUNT_NAME + +if TYPE_CHECKING: + import test_tools as tt + + from clive.si import UnlockedCliveSi + + +async def test_authority_update_add_key( + clive_si: UnlockedCliveSi, +) -> None: + """Test updating active authority by adding a key.""" + # ACT + transaction = ( + await clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=1, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert operation.account == WORKING_ACCOUNT_NAME + assert operation.active is not None + assert operation.active.weight_threshold == 1 + + +async def test_authority_update_add_and_remove_key( + clive_si: UnlockedCliveSi, +) -> None: + """Test updating authority by adding and then removing a key.""" + # ACT + transaction = ( + await clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=2, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .remove_key( + key=TEST_KEY, + ) + .as_transaction_object() + ) + + # ASSERT + expected_weight_threshold = 2 + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert isinstance(operation.active, Authority) + assert operation.active.weight_threshold == expected_weight_threshold + + +async def test_authority_update_modify_account( + clive_si: UnlockedCliveSi, +) -> None: + """Test updating active authority by adding an account.""" + # ACT + transaction = ( + await clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=1, + ) + .add_account( + account_name=RECEIVER, + weight=1, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert operation.active is not None + + +async def test_authority_update_modify_key( + clive_si: UnlockedCliveSi, +) -> None: + """Test modifying an existing key weight in authority.""" + # ACT + transaction = ( + await clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=1, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .modify_key( + key=TEST_KEY, + weight=2, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + + +async def test_authority_update_posting( + clive_si: UnlockedCliveSi, +) -> None: + """Test updating posting authority.""" + # ACT + transaction = ( + await clive_si.process.update_posting_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=1, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert operation.posting is not None + + +async def test_authority_update_owner( + clive_si: UnlockedCliveSi, +) -> None: + """Test updating owner authority.""" + # ACT + transaction = ( + await clive_si.process.update_owner_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=1, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert operation.owner is not None + + +async def test_authority_with_transfer_in_one_transaction( + clive_si: UnlockedCliveSi, +) -> None: + """Test combining authority update and transfer in one transaction.""" + # ACT + transaction = ( + await clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=1, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .as_transaction_object() + ) + + # ASSERT + expected_operations_count = 2 + assert len(transaction.operations) == expected_operations_count + + +async def test_authority_update_add_key_without_threshold( + clive_si: UnlockedCliveSi, +) -> None: + """Test updating active authority by adding a key without changing threshold.""" + # ACT + transaction = ( + await clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert operation.account == WORKING_ACCOUNT_NAME + assert operation.active is not None + # Threshold should remain unchanged from the original account authority + assert operation.active.weight_threshold == 1 + + +async def test_authority_update_modify_key_without_threshold( + clive_si: UnlockedCliveSi, +) -> None: + """Test modifying an existing key weight without changing threshold.""" + # ACT + transaction = ( + await clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .modify_key( + key=TEST_KEY, + weight=3, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert operation.active is not None + # Threshold should remain unchanged + assert operation.active.weight_threshold == 1 + + +async def test_authority_update_posting_without_threshold( + clive_si: UnlockedCliveSi, +) -> None: + """Test updating posting authority without setting threshold.""" + # ACT + transaction = ( + await clive_si.process.update_posting_authority( + account_name=WORKING_ACCOUNT_NAME, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert operation.posting is not None + # Threshold should remain unchanged from original + assert operation.posting.weight_threshold == 1 + + +async def test_authority_update_owner_without_threshold( + clive_si: UnlockedCliveSi, +) -> None: + """Test updating owner authority without setting threshold.""" + # ACT + transaction = ( + await clive_si.process.update_owner_authority( + account_name=WORKING_ACCOUNT_NAME, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, AccountUpdate2Operation) + assert operation.owner is not None + # Threshold should remain unchanged from original + assert operation.owner.weight_threshold == 1 + + +async def test_authority_update_broadcast_add_key( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting authority update with new key.""" + # ACT + await ( + clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=1, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .autosign() + .broadcast() + ) + + # ASSERT - verify the key was added to the account + account = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME) + assert account is not None + # The authority should now contain TEST_KEY + key_found = any(TEST_KEY in str(key_auth) for key_auth in account.active.key_auths) + assert key_found, f"Key {TEST_KEY} not found in active authority" + + +async def test_authority_update_broadcast_without_threshold( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting authority update without changing threshold.""" + # ARRANGE - get original threshold + account_before = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME) + assert account_before is not None, "Account should exist" + original_threshold = account_before.posting.weight_threshold + + # ACT + await ( + clive_si.process.update_posting_authority( + account_name=WORKING_ACCOUNT_NAME, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .autosign() + .broadcast() + ) + + # ASSERT - verify threshold unchanged and key added + account_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME) + assert account_after is not None, "Account should exist" + assert account_after.posting.weight_threshold == original_threshold + key_found = any(TEST_KEY in str(key_auth) for key_auth in account_after.posting.key_auths) + assert key_found, f"Key {TEST_KEY} not found in posting authority" + + +async def test_authority_update_broadcast_with_transfer( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting combined authority update and transfer in one transaction.""" + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # ACT + await ( + clive_si.process.update_active_authority( + account_name=WORKING_ACCOUNT_NAME, + threshold=1, + ) + .add_key( + key=TEST_KEY, + weight=1, + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .autosign() + .broadcast() + ) + + # ASSERT - verify both operations were executed + account = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME) + assert account is not None, "Account should exist" + + # Verify key was added to active authority + key_found = any(TEST_KEY in str(key_auth) for key_auth in account.active.key_auths) + assert key_found, f"Key {TEST_KEY} not found in active authority" + + # Verify transfer was executed by checking balance + balance_after = account.balance + assert balance_after == expected_balance_after diff --git a/tests/functional/si/process/test_finalizations.py b/tests/functional/si/process/test_finalizations.py new file mode 100644 index 0000000000..245b0d4fb4 --- /dev/null +++ b/tests/functional/si/process/test_finalizations.py @@ -0,0 +1,179 @@ +"""Tests for transaction finalization methods (broadcast, save_file, as_transaction_object).""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Literal + +import pytest + +from clive_local_tools.helpers import create_transaction_filepath +from schemas.operations.transfer_operation import TransferOperation + +from .constants import AMOUNT, MEMO, RECEIVER, WORKING_ACCOUNT_DATA, WORKING_ACCOUNT_NAME + +if TYPE_CHECKING: + import test_tools as tt + + from clive.si import UnlockedCliveSi + + +def verify_transfer_operation(json_: dict[str, Any], from_: str, to: str, amount: tt.Asset.HiveT, memo: str) -> None: + """Verify that a JSON transaction contains the expected transfer operation.""" + assert "operations" in json_ + assert len(json_["operations"]) == 1 + saved_operation = json_["operations"][0] + + # Verify the structure: {"type": "transfer_operation", "value": { ... }} + assert saved_operation["type"] == "transfer_operation" + assert saved_operation["value"]["from"] == from_ + assert saved_operation["value"]["to"] == to + assert saved_operation["value"]["amount"] == amount + assert saved_operation["value"]["memo"] == memo + + +async def test_broadcast( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting a transfer transaction with autosign.""" + # ARRANGE + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # ACT + await ( + clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .autosign() + .broadcast() + ) + + # ASSERT + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + +async def test_broadcast_on_object( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting a transfer transaction with autosign.""" + # ARRANGE + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # ACT + transfer_transaction = await ( + clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .autosign() + .as_transaction_object() + ) + await transfer_transaction.broadcast() + # ASSERT + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + +@pytest.mark.parametrize("file_format", ["json", "bin"]) +@pytest.mark.parametrize( + "serialization_mode", + [ + pytest.param("legacy", marks=pytest.mark.skip(reason="Legacy not supported")), + "hf26", + ], +) +async def test_save_to_file_direct( + clive_si: UnlockedCliveSi, + file_format: Literal["json", "bin"], + serialization_mode: Literal["legacy", "hf26"], +) -> None: + """Test saving transaction to file directly (without creating transaction object first).""" + # ARRANGE + file_path = create_transaction_filepath() + + # ACT + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).save_file(path=file_path, file_format=file_format, serialization_mode=serialization_mode) + + # ASSERT + assert file_path.exists() + + if file_format == "json": + with file_path.open() as f: + saved_transaction = json.load(f) + + verify_transfer_operation(saved_transaction, WORKING_ACCOUNT_NAME, RECEIVER, AMOUNT, MEMO) + else: + # For binary format, just verify the file exists and has content + assert file_path.stat().st_size > 0, "Binary file should have content" + + +@pytest.mark.parametrize("file_format", ["json", "bin"]) +@pytest.mark.parametrize( + "serialization_mode", + [ + pytest.param("legacy", marks=pytest.mark.skip(reason="Legacy not supported")), + "hf26", + ], +) +async def test_save_to_file_on_object( + clive_si: UnlockedCliveSi, + file_format: Literal["json", "bin"], + serialization_mode: Literal["legacy", "hf26"], +) -> None: + """Test saving transaction to file on transaction object (after calling as_transaction_object).""" + # ARRANGE + file_path = create_transaction_filepath() + + # ACT + transaction = await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).as_transaction_object() + await transaction.save_file(path=file_path, file_format=file_format, serialization_mode=serialization_mode) + + # ASSERT + assert file_path.exists() + + if file_format == "json": + with file_path.open() as f: + saved_transaction = json.load(f) + + verify_transfer_operation(saved_transaction, WORKING_ACCOUNT_NAME, RECEIVER, AMOUNT, MEMO) + else: + # For binary format, just verify the file exists and has content + assert file_path.stat().st_size > 0, "Binary file should have content" + + +async def test_as_transaction_object(clive_si: UnlockedCliveSi) -> None: + """Test creating a transaction object without broadcasting or saving.""" + # ACT + transaction = await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).as_transaction_object() + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, TransferOperation) + assert operation.from_ == WORKING_ACCOUNT_NAME + assert operation.to == RECEIVER + assert operation.amount == AMOUNT + assert operation.memo == MEMO diff --git a/tests/functional/si/process/test_signing.py b/tests/functional/si/process/test_signing.py new file mode 100644 index 0000000000..f0a89e992c --- /dev/null +++ b/tests/functional/si/process/test_signing.py @@ -0,0 +1,145 @@ +"""Tests for transaction signing operations (autosign, sign_with, multisign).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive_local_tools.helpers import create_transaction_filepath + +from .constants import ( + ALT_WORKING_ACCOUNT1_KEY_ALIAS, + AMOUNT, + MEMO, + RECEIVER, + WORKING_ACCOUNT_KEY_ALIAS, + WORKING_ACCOUNT_NAME, +) + +if TYPE_CHECKING: + from clive.si import UnlockedCliveSi + + +async def test_autosign( + clive_si: UnlockedCliveSi, +) -> None: + """Test transaction with autosign.""" + # ACT + transaction = ( + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .autosign() + .as_transaction_object() + ) + + # ASSERT + assert len(transaction.signatures) == 1 + + +async def test_sign_with_key_alias( + clive_si: UnlockedCliveSi, +) -> None: + """Test signing transaction with a specific key alias.""" + # ACT + transfer = ( + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .as_transaction_object() + ) + + # ASSERT + assert len(transfer.signatures) == 1 + + +async def test_multisign( + clive_si_with_two_keys_profile: UnlockedCliveSi, +) -> None: + """Test adding multiple signatures to a transaction.""" + # Create transaction with first signature + transfer = ( + await clive_si_with_two_keys_profile.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .sign_with(key=ALT_WORKING_ACCOUNT1_KEY_ALIAS) + .as_transaction_object() + ) + + expected_signatures_count = 2 + assert len(transfer.signatures) == expected_signatures_count + + +async def test_multisign_from_object( + clive_si_with_two_keys_profile: UnlockedCliveSi, +) -> None: + """Test adding multiple signatures to a transaction.""" + # Create transaction with first signature + transfer_to_load = ( + await clive_si_with_two_keys_profile.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .as_transaction_object() + ) + + # ACT - Add second signature + multisigned_transfer = ( + await clive_si_with_two_keys_profile.process.transaction_from_object( + from_object=transfer_to_load, + already_signed_mode="multisign", + ) + .sign_with(key=ALT_WORKING_ACCOUNT1_KEY_ALIAS) + .as_transaction_object() + ) + + # ASSERT + expected_signatures_count = 2 + assert len(multisigned_transfer.signatures) == expected_signatures_count + + +async def test_multisign_from_file( + clive_si_with_two_keys_profile: UnlockedCliveSi, +) -> None: + """Test adding signature to transaction loaded from file.""" + # ARRANGE + file_path = create_transaction_filepath() + + # Save transaction with first signature + await ( + clive_si_with_two_keys_profile.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .save_file(path=file_path) + ) + + # ACT - Load and add second signature + multisigned_transfer = ( + await clive_si_with_two_keys_profile.process.transaction( + from_file=file_path, + already_signed_mode="multisign", + ) + .sign_with(key=ALT_WORKING_ACCOUNT1_KEY_ALIAS) + .as_transaction_object() + ) + + # ASSERT + expected_signatures_count = 2 + assert len(multisigned_transfer.signatures) == expected_signatures_count diff --git a/tests/functional/si/process/test_transaction_loading.py b/tests/functional/si/process/test_transaction_loading.py new file mode 100644 index 0000000000..a4c20d830a --- /dev/null +++ b/tests/functional/si/process/test_transaction_loading.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive_local_tools.helpers import create_transaction_filepath +from schemas.operations.transfer_operation import TransferOperation + +from .constants import ( + ALT_WORKING_ACCOUNT1_KEY_ALIAS, + AMOUNT, + AMOUNT2, + MEMO, + MEMO2, + RECEIVER, + SECOND_RECEIVER, + WORKING_ACCOUNT_DATA, + WORKING_ACCOUNT_KEY_ALIAS, + WORKING_ACCOUNT_NAME, +) + +if TYPE_CHECKING: + import test_tools as tt + + from clive.si import UnlockedCliveSi + + +async def test_transaction_from_file_multisign( + clive_si_with_two_keys_profile: UnlockedCliveSi, +) -> None: + """Test loading transaction from file and adding another signature.""" + # ARRANGE + file_path = create_transaction_filepath() + + # Create and save first transaction with one signature + await ( + clive_si_with_two_keys_profile.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .save_file(path=file_path) + ) + + # ACT - Load and add second signature + multisigned_transaction = ( + await clive_si_with_two_keys_profile.process.transaction( + from_file=file_path, + already_signed_mode="multisign", + ) + .sign_with(ALT_WORKING_ACCOUNT1_KEY_ALIAS) + .as_transaction_object() + ) + + # ASSERT + expected_signatures_count = 2 + assert len(multisigned_transaction.signatures) == expected_signatures_count + + +async def test_transaction_from_file_add_operation( + clive_si: UnlockedCliveSi, +) -> None: + """Test loading transaction from file and adding another operation.""" + # ARRANGE + file_path = create_transaction_filepath() + + # Save first transaction + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).save_file(path=file_path) + + # ACT - Load from file and add second operation + combined_transaction = ( + await clive_si.process.transaction( + from_file=file_path, + force_unsign=True, + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=SECOND_RECEIVER, + amount=AMOUNT2, + memo=MEMO2, + ) + .as_transaction_object() + ) + + # ASSERT + expected_operations_count = 2 + assert len(combined_transaction.operations) == expected_operations_count + operation_0 = combined_transaction.operations[0].value + operation_1 = combined_transaction.operations[1].value + assert isinstance(operation_0, TransferOperation) + assert isinstance(operation_1, TransferOperation) + assert operation_0.to == RECEIVER + assert operation_1.to == SECOND_RECEIVER + + +async def test_broadcast_transaction_from_file_add_operation( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test loading transaction from file, adding another operation and broadcasting.""" + # ARRANGE + file_path = create_transaction_filepath() + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT - AMOUNT2 + + # Save first transaction + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).save_file(path=file_path) + + # ACT - Load from file, add second operation and broadcast + await ( + clive_si.process.transaction( + from_file=file_path, + force_unsign=True, + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=SECOND_RECEIVER, + amount=AMOUNT2, + memo=MEMO2, + ) + .autosign() + .broadcast() + ) + + # ASSERT + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + +async def test_transaction_from_object_multisign( + clive_si_with_two_keys_profile: UnlockedCliveSi, +) -> None: + """Test loading transaction from object and adding another signature.""" + # Create transaction with one signature + transfer = ( + await clive_si_with_two_keys_profile.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .as_transaction_object() + ) + + # ACT - Add second signature + multisigned_transfer = ( + await clive_si_with_two_keys_profile.process.transaction_from_object( + from_object=transfer, + already_signed_mode="multisign", + ) + .sign_with(ALT_WORKING_ACCOUNT1_KEY_ALIAS) + .as_transaction_object() + ) + + # ASSERT + expected_signatures_count = 2 + assert len(multisigned_transfer.signatures) == expected_signatures_count + + +async def test_transaction_from_object_signature_override( + clive_si_with_two_keys_profile: UnlockedCliveSi, +) -> None: + """Test loading transaction from object and overriding signatures.""" + # Create transaction with one signature + transfer = ( + await clive_si_with_two_keys_profile.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .as_transaction_object() + ) + + # ACT - Override with new signature + overridden_transfer = ( + await clive_si_with_two_keys_profile.process.transaction_from_object( + from_object=transfer, + already_signed_mode="override", + ) + .sign_with(ALT_WORKING_ACCOUNT1_KEY_ALIAS) + .as_transaction_object() + ) + + # ASSERT + assert len(overridden_transfer.signatures) == 1 + + +async def test_transaction_from_object_add_operation( + clive_si: UnlockedCliveSi, +) -> None: + """Test loading transaction from object and adding another operation.""" + # Create first transaction + transaction1 = await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).as_transaction_object() + + # ACT - Load and add second operation + combined_transaction = ( + await clive_si.process.transaction_from_object( + from_object=transaction1, + already_signed_mode="strict", + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=SECOND_RECEIVER, + amount=AMOUNT2, + memo=MEMO2, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .as_transaction_object() + ) + + # ASSERT + expected_operations_count = 2 + assert len(combined_transaction.operations) == expected_operations_count + operation_0 = combined_transaction.operations[0].value + operation_1 = combined_transaction.operations[1].value + assert isinstance(operation_0, TransferOperation) + assert isinstance(operation_1, TransferOperation) + assert operation_0.to == RECEIVER + assert operation_1.to == SECOND_RECEIVER + + +async def test_transaction_from_file_with_add_transfer_original_tapos( + clive_si: UnlockedCliveSi, +) -> None: + """Test loading transaction from file and adding another operation. - verification of original TAPOS preserved.""" + # ARRANGE + file_path = create_transaction_filepath() + + # Save first transaction + transfer_transaction = ( + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .autosign() + .as_transaction_object() + ) + + await transfer_transaction.save_file(path=file_path) + + # ACT - Load from file and add second operation + combined_transaction = ( + await clive_si.process.transaction( + from_file=file_path, + force_unsign=True, + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=SECOND_RECEIVER, + amount=AMOUNT2, + memo=MEMO2, + ) + .as_transaction_object() + ) + + # ASSERT + assert transfer_transaction.ref_block_num == combined_transaction.ref_block_num + assert transfer_transaction.ref_block_prefix == combined_transaction.ref_block_prefix + assert transfer_transaction.expiration == combined_transaction.expiration + assert transfer_transaction.signatures != combined_transaction.signatures + + +async def test_broadcast_signed_transaction_from_file_with_strict_mode( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting a signed transaction loaded from file with strict mode (no additional signing).""" + file_path = create_transaction_filepath() + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # Create and save signed transaction + await ( + clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .save_file(path=file_path) + ) + + # Load and broadcast with original signature (strict mode is default) + await clive_si.process.transaction(from_file=file_path).broadcast() + + # Verify transaction was broadcasted successfully + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + +async def test_broadcast_transaction_from_file_with_signature_override( + clive_si_with_two_keys_profile: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting a transaction where original signature is overridden.""" + file_path = create_transaction_filepath() + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # Create and save transaction with signature + await ( + clive_si_with_two_keys_profile.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .save_file(path=file_path) + ) + + # Load and override signature with same key + await ( + clive_si_with_two_keys_profile.process.transaction(from_file=file_path, already_signed_mode="override") + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .broadcast() + ) + + # Verify transaction was broadcasted successfully + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + +async def test_broadcast_signed_transaction_from_object_with_strict_mode( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting a signed transaction loaded from object with strict mode (no additional signing).""" + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # Create signed transaction + transaction = await ( + clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .as_transaction_object() + ) + + # Load from object and broadcast with original signature (strict mode is default) + await clive_si.process.transaction_from_object(from_object=transaction).broadcast() + + # Verify transaction was broadcasted successfully + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + +async def test_broadcast_unsigned_transaction_from_object_with_signing( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting an unsigned transaction loaded from object by signing it.""" + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # Create unsigned transaction + transaction = await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).as_transaction_object() + + # Load from object and sign during broadcast + await ( + clive_si.process.transaction_from_object(from_object=transaction, already_signed_mode="override") + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .broadcast() + ) + + # Verify transaction was broadcasted successfully + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + +async def test_broadcast_transaction_from_object_with_signature_override_same_key( + clive_si_with_two_keys_profile: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test broadcasting a transaction from object where original signature is overridden.""" + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # Create transaction with signature + transaction_1 = await ( + clive_si_with_two_keys_profile.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .sign_with(key=ALT_WORKING_ACCOUNT1_KEY_ALIAS) + .as_transaction_object() + ) + signature_1 = transaction_1.signatures + # Load from object and override signature with same key + transaction2 = await ( + clive_si_with_two_keys_profile.process.transaction_from_object( + from_object=transaction_1, + already_signed_mode="override", + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .as_transaction_object() + ) + await transaction2.broadcast() + # Verify transaction was broadcasted successfully + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + # Verify that transaction has overridden signature + assert signature_1 != transaction2.signatures diff --git a/tests/functional/si/process/test_transfer_operations.py b/tests/functional/si/process/test_transfer_operations.py new file mode 100644 index 0000000000..3a90c20137 --- /dev/null +++ b/tests/functional/si/process/test_transfer_operations.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import test_tools as tt + +from clive.__private.models.schemas import ValidationError +from clive_local_tools.checkers.blockchain_checkers import assert_operations_placed_in_blockchain +from clive_local_tools.helpers import create_transaction_filepath +from schemas.operations.transfer_operation import TransferOperation + +from .constants import ( + AMOUNT, + AMOUNT2, + MEMO, + MEMO2, + RECEIVER, + SECOND_RECEIVER, + WORKING_ACCOUNT_DATA, + WORKING_ACCOUNT_KEY_ALIAS, + WORKING_ACCOUNT_NAME, +) + +if TYPE_CHECKING: + from clive.si import UnlockedCliveSi + + +async def test_transfer_simple( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test simple transfer operation with autosign and broadcast.""" + # ARRANGE + expected_balance_after = WORKING_ACCOUNT_DATA.hives_liquid - AMOUNT + + # ACT + await ( + clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .autosign() + .broadcast() + ) + + # ASSERT + balance_after = node.api.wallet_bridge.get_account(WORKING_ACCOUNT_NAME).balance # type: ignore[union-attr] + assert balance_after == expected_balance_after + + +async def test_transfer_as_transaction_object( + clive_si: UnlockedCliveSi, +) -> None: + """Test creating transfer as transaction object without broadcasting.""" + # ACT + transaction = await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).as_transaction_object() + + # ASSERT + assert len(transaction.operations) == 1 + operation = transaction.operations[0].value + assert isinstance(operation, TransferOperation) + assert operation.from_ == WORKING_ACCOUNT_NAME + assert operation.to == RECEIVER + assert operation.amount == AMOUNT + assert operation.memo == MEMO + + +async def test_transfer_sign_and_broadcast_separately( + clive_si: UnlockedCliveSi, + node: tt.RawNode, +) -> None: + """Test creating transfer as object, then signing and broadcasting separately.""" + # ACT + transaction = await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).as_transaction_object() + + await ( + clive_si.process.transaction_from_object( + from_object=transaction, + already_signed_mode="override", + ) + .sign_with(key=WORKING_ACCOUNT_KEY_ALIAS) + .broadcast() + ) + + # ASSERT + transaction_id = transaction.with_hash().transaction_id + assert_operations_placed_in_blockchain(node, transaction_id, *transaction.operations_models) + + +async def test_transfer_double_in_one_transaction( + clive_si: UnlockedCliveSi, +) -> None: + """Test chaining two transfers in one transaction.""" + # ACT + transaction = ( + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=SECOND_RECEIVER, + amount=AMOUNT2, + memo=MEMO2, + ) + .as_transaction_object() + ) + + # ASSERT + expected_operations_count = 2 + assert len(transaction.operations) == expected_operations_count + operation_0 = transaction.operations[0].value + operation_1 = transaction.operations[1].value + assert isinstance(operation_0, TransferOperation) + assert isinstance(operation_1, TransferOperation) + assert operation_0.to == RECEIVER + assert operation_1.to == SECOND_RECEIVER + + +async def test_transfer_triple_in_one_transaction( + clive_si: UnlockedCliveSi, +) -> None: + """Test chaining three transfers in one transaction.""" + # ACT + transaction = ( + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=SECOND_RECEIVER, + amount=AMOUNT2, + memo=MEMO2, + ) + .process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=tt.Asset.Hive(3), + memo="Third transfer", + ) + .as_transaction_object() + ) + + # ASSERT + expected_operations_count = 3 + assert len(transaction.operations) == expected_operations_count + operation_0 = transaction.operations[0].value + operation_1 = transaction.operations[1].value + operation_2 = transaction.operations[2].value + assert isinstance(operation_0, TransferOperation) + assert isinstance(operation_1, TransferOperation) + assert isinstance(operation_2, TransferOperation) + assert operation_0.to == RECEIVER + assert operation_1.to == SECOND_RECEIVER + assert operation_2.to == RECEIVER + + +async def test_transfer_save_to_file( + clive_si: UnlockedCliveSi, +) -> None: + """Test saving transfer transaction to file.""" + # ARRANGE + file_path = create_transaction_filepath() + + # ACT + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=RECEIVER, + amount=AMOUNT, + memo=MEMO, + ).save_file(path=file_path) + + # ASSERT + assert file_path.exists() + + +async def test_transfer_validation_error( + clive_si: UnlockedCliveSi, +) -> None: + """Test that transfer validation properly catches invalid account names.""" + # ARRANGE - account name too short (minimum is 3 characters) + invalid_account = "ab" + + # ACT & ASSERT + with pytest.raises(ValidationError, match="length"): + await clive_si.process.transfer( + from_account=WORKING_ACCOUNT_NAME, + to_account=invalid_account, + amount=AMOUNT, + memo=MEMO, + ).as_transaction_object() -- GitLab From 540123c6e33a34536a6d6e36be85b37c0339e7e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Tue, 9 Dec 2025 12:49:35 +0000 Subject: [PATCH 9/9] Apply inheriting from CommandBase in OperationBuilder --- clive/__private/si/core/process/process.py | 8 +++++--- pydoclint-errors-baseline.txt | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/clive/__private/si/core/process/process.py b/clive/__private/si/core/process/process.py index 1822ed62bc..0c5577ff4b 100644 --- a/clive/__private/si/core/process/process.py +++ b/clive/__private/si/core/process/process.py @@ -11,6 +11,7 @@ from clive.__private.core.keys.key_manager import KeyNotFoundError from clive.__private.core.keys.keys import PublicKey from clive.__private.models.asset import Asset from clive.__private.models.transaction import Transaction +from clive.__private.si.core.base import CommandBase from clive.__private.si.core.process import authority_operations from clive.__private.si.exceptions import MissingFromFileOrFromObjectError from clive.__private.si.validators import ( @@ -30,7 +31,7 @@ if TYPE_CHECKING: from clive.__private.models.schemas import AccountUpdate2Operation, Authority, OperationUnion -class OperationBuilder(ABC): +class OperationBuilder(CommandBase[Transaction], ABC): """ Abstract base class for building individual blockchain operations. @@ -55,8 +56,9 @@ class OperationBuilder(ABC): self._broadcast: bool = False self._autosign: bool | None = None - async def validate(self) -> None: # noqa: B027 - """Validate the process command configuration. Override in subclasses as needed.""" + async def _run(self) -> Transaction: + """Not used - OperationBuilder uses run() with parameters instead.""" + raise NotImplementedError("OperationBuilder uses run() with parameters, not _run()") async def run( # noqa: PLR0913 self, diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index de61ecb00b..ee766ac704 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -430,6 +430,9 @@ clive/__private/si/core/process/chaining.py clive/__private/si/core/process/process.py DOC101: Method `OperationBuilder.__init__`: Docstring contains fewer arguments than in function signature. DOC103: Method `OperationBuilder.__init__`: 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: [world: World]. + DOC201: Method `OperationBuilder._run` does not have a return section in docstring + DOC501: Method `OperationBuilder._run` has raise statements, but the docstring does not have a "Raises" section + DOC503: Method `OperationBuilder._run` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['NotImplementedError']. DOC101: Method `OperationBuilder.run`: Docstring contains fewer arguments than in function signature. DOC103: Method `OperationBuilder.run`: 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: [already_signed_mode: AlreadySignedMode | None, autosign: bool | None, broadcast: bool, file_format: Literal['json', 'bin'] | None, force_unsign: bool | None, save_file: str | Path | None, serialization_mode: SerializationMode, sign_with: list[str] | str | None]. DOC201: Method `OperationBuilder.run` does not have a return section in docstring -- GitLab