From fe2b970d377b7f0e85aa89f06d7ed994242ef6cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Tue, 28 Jan 2025 13:16:59 +0100 Subject: [PATCH 001/192] Modify UNLOCK_CREATE_PROFILE_HELP message. --- clive/__private/core/constants/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clive/__private/core/constants/cli.py b/clive/__private/core/constants/cli.py index b2bf8452af..53f0d350f5 100644 --- a/clive/__private/core/constants/cli.py +++ b/clive/__private/core/constants/cli.py @@ -13,7 +13,8 @@ LOOK_INTO_ARGUMENT_OPTION_HELP: Final[str] = ( OPERATION_COMMON_OPTIONS_PANEL_TITLE: Final[str] = "Operation common options" UNLOCK_CREATE_PROFILE_HELP: Final[str] = ( - "If you want to create a new profile, please enter the following command:\n" + "There are no profiles to unlock.\n" + "To create a new profile, please enter the following command:\n" "`clive configure profile add --profile-name PROFILE_NAME` and pass password to the standard input." ) UNLOCK_CREATE_PROFILE_SELECT: Final[str] = "create a new profile" -- GitLab From 98fc7df3dc75af09a73873ac8d075572a6d51156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Tue, 28 Jan 2025 13:17:38 +0100 Subject: [PATCH 002/192] Display information about creating profile, when try to unlock clive when there are no profiles to unlock. Previously we gave a prompt that show single selection with: create new profile. Now clive will only display message with create profile command. --- clive/__private/cli/commands/unlock.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/clive/__private/cli/commands/unlock.py b/clive/__private/cli/commands/unlock.py index 52f1f943eb..169aba3fb5 100644 --- a/clive/__private/cli/commands/unlock.py +++ b/clive/__private/cli/commands/unlock.py @@ -37,9 +37,13 @@ class Unlock(BeekeeperBasedCommand): await super().validate_inside_context_manager() async def _run(self) -> None: + if self._should_display_profile_creation_help(): + self._display_create_profile_help_info() + return + profile_name = self._get_profile_name() if profile_name is None: - typer.echo(UNLOCK_CREATE_PROFILE_HELP) + self._display_create_profile_help_info() return if sys.stdin.isatty(): await self._unlock_in_tty_mode(profile_name) @@ -127,3 +131,9 @@ class Unlock(BeekeeperBasedCommand): async def _unlock_in_non_tty_mode(self, profile_name: str) -> None: password = sys.stdin.readline().rstrip() await self._unlock_profile(profile_name, password) + + def _should_display_profile_creation_help(self) -> bool: + return not Profile.is_any_profile_saved() + + def _display_create_profile_help_info(self) -> None: + typer.echo(UNLOCK_CREATE_PROFILE_HELP) -- GitLab From b13730865993f9b095ff0dec9091572e15a5fa56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 18 Feb 2025 18:16:36 +0100 Subject: [PATCH 003/192] Add missing testing_cli artifacts needs for testing_password_private_key_logging purpose --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 09343d5ab3..4769c9d5d1 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -192,6 +192,8 @@ testing_password_private_key_logging: artifacts: true - job: testing_tui artifacts: true + - job: testing_cli + artifacts: true script: - cd "${CI_PROJECT_DIR}/tests" - bash "${CI_PROJECT_DIR}/scripts/check_is_private_key_nor_password_is_not_logged.bash" -- GitLab From c41fda9211ba3e855fe1096440211827f11ee8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 18 Feb 2025 18:33:15 +0100 Subject: [PATCH 004/192] Refactor check_is_private_key_nor_password_is_not_logged - extract exclude_patterns local list --- .../check_is_private_key_nor_password_is_not_logged.bash | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/check_is_private_key_nor_password_is_not_logged.bash b/scripts/check_is_private_key_nor_password_is_not_logged.bash index 63d0203758..b772659a47 100755 --- a/scripts/check_is_private_key_nor_password_is_not_logged.bash +++ b/scripts/check_is_private_key_nor_password_is_not_logged.bash @@ -3,12 +3,17 @@ set -euo pipefail function find_password_private_keys() { + local exclude_patterns=( + "Error in response from url" + "Problem occurred during communication with" + "CI_" + ) + find . -path "*/clive/*/latest.log" -print0 | xargs -0 \ grep --with-filename --line-number --ignore-case --word-regexp --extended-regexp \ '(pass(word)?|[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{51})' | - grep "$@" --invert-match --extended-regexp \ - 'Error in response from url|Problem occurred during communication with|CI_' || true + grep --invert-match --extended-regexp "$(IFS='|'; echo "${exclude_patterns[*]}")" || true } amount_of_occurrences=$(find_password_private_keys --count) -- GitLab From f807103ff243b69b28c5169d714d715d25db5dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 18 Feb 2025 18:42:38 +0100 Subject: [PATCH 005/192] Ignore clive_local_tools logs during check_is_private_key_nor_password_is_not_logged --- scripts/check_is_private_key_nor_password_is_not_logged.bash | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check_is_private_key_nor_password_is_not_logged.bash b/scripts/check_is_private_key_nor_password_is_not_logged.bash index b772659a47..b1b79d2784 100755 --- a/scripts/check_is_private_key_nor_password_is_not_logged.bash +++ b/scripts/check_is_private_key_nor_password_is_not_logged.bash @@ -7,6 +7,7 @@ function find_password_private_keys() { "Error in response from url" "Problem occurred during communication with" "CI_" + "clive_local_tools" ) find . -path "*/clive/*/latest.log" -print0 | -- GitLab From c1887e6da6dac835f9e7577e9d0d0ff0c2c028df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 18 Feb 2025 18:45:34 +0100 Subject: [PATCH 006/192] Remove old exclude pattern from check_is_private_key_nor_password_is_not_logged --- scripts/check_is_private_key_nor_password_is_not_logged.bash | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/check_is_private_key_nor_password_is_not_logged.bash b/scripts/check_is_private_key_nor_password_is_not_logged.bash index b1b79d2784..e300061afc 100755 --- a/scripts/check_is_private_key_nor_password_is_not_logged.bash +++ b/scripts/check_is_private_key_nor_password_is_not_logged.bash @@ -4,7 +4,6 @@ set -euo pipefail function find_password_private_keys() { local exclude_patterns=( - "Error in response from url" "Problem occurred during communication with" "CI_" "clive_local_tools" -- GitLab From babbe1424358b628df30508b3ecb9e1d34e71848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 20 Feb 2025 08:13:07 +0100 Subject: [PATCH 007/192] Rename check_is_private_key_nor_password_is_not_logged -> check_for_private_key_or_password_logging --- .gitlab-ci.yml | 2 +- ...gged.bash => check_for_private_key_or_password_logging.bash} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename scripts/{check_is_private_key_nor_password_is_not_logged.bash => check_for_private_key_or_password_logging.bash} (100%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4769c9d5d1..fb860daf46 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -196,7 +196,7 @@ testing_password_private_key_logging: artifacts: true script: - cd "${CI_PROJECT_DIR}/tests" - - bash "${CI_PROJECT_DIR}/scripts/check_is_private_key_nor_password_is_not_logged.bash" + - bash "${CI_PROJECT_DIR}/scripts/check_for_private_key_or_password_logging.bash" #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<| TESTS |<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< diff --git a/scripts/check_is_private_key_nor_password_is_not_logged.bash b/scripts/check_for_private_key_or_password_logging.bash similarity index 100% rename from scripts/check_is_private_key_nor_password_is_not_logged.bash rename to scripts/check_for_private_key_or_password_logging.bash -- GitLab From c83ddc14fd1e30942f570318a58a90d2a417f5ff Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Mon, 10 Feb 2025 15:30:08 +0100 Subject: [PATCH 008/192] Add default weight and height SafeSelect --- clive/__private/ui/widgets/select/safe_select.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clive/__private/ui/widgets/select/safe_select.py b/clive/__private/ui/widgets/select/safe_select.py index 1aa54187b4..fee72d8fcd 100644 --- a/clive/__private/ui/widgets/select/safe_select.py +++ b/clive/__private/ui/widgets/select/safe_select.py @@ -45,6 +45,8 @@ class SafeSelect(CliveWidget, Generic[SelectType]): DEFAULT_CSS = """ SafeSelect { + width: 1fr; + height: auto; min-height: 3; align: center middle; } -- GitLab From 7ce935ed312c9f25cdf116099a942c3d7218be6d Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Mon, 10 Feb 2025 14:50:57 +0100 Subject: [PATCH 009/192] Fix displaying long key aliases in transaction summary --- .../transaction_summary/transaction_summary.scss | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.scss b/clive/__private/ui/screens/transaction_summary/transaction_summary.scss index cc7ab8ee30..54f05ab045 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.scss +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.scss @@ -64,11 +64,11 @@ TransactionSummary { #actions-container { margin: 1 0; - height: 3; + height: auto; ButtonContainer { - align: right top; - min-width: 71; + width: auto; + height: auto; CliveButton { width: 23; @@ -85,11 +85,8 @@ TransactionSummary { KeyContainer { align: left top; - margin-left: 1; - - SelectKey { - max-width: 32%; - } + margin-right: 1; + height: auto; KeyHint { text-style: bold; -- GitLab From c2ca1e7d128443c096a558a814b99bdadd982af7 Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Mon, 10 Feb 2025 16:00:55 +0100 Subject: [PATCH 010/192] Make action buttons column width static --- .../transaction_summary/transaction_summary.scss | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.scss b/clive/__private/ui/screens/transaction_summary/transaction_summary.scss index 54f05ab045..ebfed5ed99 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.scss +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.scss @@ -45,20 +45,16 @@ TransactionSummary { } .actions { - width: 3fr; + width: 35; } - ButtonMoveDown { - margin: 0 1; + CliveButton { + margin-left: 1; + min-width: 8; } ButtonRawJson { - margin-right: 1; - } - - CliveButton { - width: 24%; /* solution described here: https://gitlab.syncad.com/hive/clive/-/merge_requests/388#note_169849 */ - min-width: 1; + margin-left: 0; } } -- GitLab From 54f858e3646fb178822b2e400a7fcc29ebfefdc2 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Tue, 4 Feb 2025 12:46:47 +0100 Subject: [PATCH 011/192] Don't require working account when adding key via cli --- clive/__private/cli/commands/configure/key.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/clive/__private/cli/commands/configure/key.py b/clive/__private/cli/commands/configure/key.py index b85e94b989..80cb7675af 100644 --- a/clive/__private/cli/commands/configure/key.py +++ b/clive/__private/cli/commands/configure/key.py @@ -45,16 +45,10 @@ class AddKey(WorldBasedCommand): return self.alias if self.alias else private_key.calculate_public_key().value async def validate_inside_context_manager(self) -> None: - await self._validate_has_working_account() await self._validate_key_alias() await self._validate_private_key() await super().validate_inside_context_manager() - async def _validate_has_working_account(self) -> None: - profile = self.profile - if not profile.accounts.has_working_account: - raise CLIWorkingAccountIsNotSetError(profile) - async def _validate_key_alias(self) -> None: key_manager = self.profile.keys alias_result = PublicKeyAliasValidator(key_manager, validate_like_adding_new=True).validate( -- GitLab From 7bac351a828c3fa209defdf9a52dfb74f06277f2 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 6 Feb 2025 15:40:04 +0100 Subject: [PATCH 012/192] Don't require working account on cli commands show keys and configuree key remove --- clive/__private/cli/commands/configure/key.py | 8 +------- clive/__private/cli/commands/show/show_keys.py | 4 ---- clive/__private/cli/exceptions.py | 10 ---------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/clive/__private/cli/commands/configure/key.py b/clive/__private/cli/commands/configure/key.py index 80cb7675af..5169f999a0 100644 --- a/clive/__private/cli/commands/configure/key.py +++ b/clive/__private/cli/commands/configure/key.py @@ -5,7 +5,7 @@ from pathlib import Path import typer from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand -from clive.__private.cli.exceptions import CLIPrettyError, CLIWorkingAccountIsNotSetError +from clive.__private.cli.exceptions import CLIPrettyError from clive.__private.core.formatters.humanize import humanize_validation_result from clive.__private.core.keys import ( PrivateKey, @@ -77,14 +77,8 @@ class RemoveKey(WorldBasedCommand): """Indicates whether to remove the key from the Beekeeper as well or just the alias association from the profile.""" async def validate_inside_context_manager(self) -> None: - await self._validate_working_account() await super().validate_inside_context_manager() - async def _validate_working_account(self) -> None: - profile = self.profile - if not profile.accounts.has_working_account: - raise CLIWorkingAccountIsNotSetError(profile) - async def _run(self) -> None: typer.echo(f"Removing a key aliased with `{self.alias}`...") public_key = self.profile.keys.get(self.alias) diff --git a/clive/__private/cli/commands/show/show_keys.py b/clive/__private/cli/commands/show/show_keys.py index 28d51a4758..7b8b2378d7 100644 --- a/clive/__private/cli/commands/show/show_keys.py +++ b/clive/__private/cli/commands/show/show_keys.py @@ -3,7 +3,6 @@ from dataclasses import dataclass import typer from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand -from clive.__private.cli.exceptions import CLIWorkingAccountIsNotSetError @dataclass(kw_only=True) @@ -11,8 +10,5 @@ class ShowKeys(WorldBasedCommand): async def _run(self) -> None: profile_name = self.profile.name - if not self.profile.accounts.has_working_account: - raise CLIWorkingAccountIsNotSetError(self.profile) - public_keys = list(self.profile.keys) typer.echo(f"{profile_name}, your keys are:\n{public_keys}") diff --git a/clive/__private/cli/exceptions.py b/clive/__private/cli/exceptions.py index 212b83bae7..596ca33c61 100644 --- a/clive/__private/cli/exceptions.py +++ b/clive/__private/cli/exceptions.py @@ -41,16 +41,6 @@ class CLIPrettyError(ClickException): self.exit_code = exit_code -class CLIWorkingAccountIsNotSetError(CLIPrettyError): - def __init__(self, profile: Profile | None = None) -> None: - self.profile = profile - message = ( - f"Working account is not set{f' for the `{profile.name}` profile' if profile else ''}.\n" - "Please check the `clive configure working-account add -h` command first." - ) - super().__init__(message, errno.ENOENT) - - class CLIWorkingAccountIsAlreadySetError(CLIPrettyError): def __init__(self, profile: Profile | None = None) -> None: self.profile = profile -- GitLab From 309aac5a9419769a50dfd83cf0a590ebe19c77ba Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Fri, 24 Jan 2025 14:55:17 +0100 Subject: [PATCH 013/192] Allow to enter manage key aliases without working account --- clive/__private/ui/screens/config/config.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/clive/__private/ui/screens/config/config.py b/clive/__private/ui/screens/config/config.py index 234cefcfae..b959a6f3b5 100644 --- a/clive/__private/ui/screens/config/config.py +++ b/clive/__private/ui/screens/config/config.py @@ -42,11 +42,4 @@ class Config(BaseScreen): @on(CliveButton.Pressed, "#manage-key-aliases") async def push_manage_key_aliases_screen(self) -> None: - if not self._has_working_account(): - self.notify("Cannot manage key aliases without working account", severity="error") - return - await self.app.push_screen(ManageKeyAliases()) - - def _has_working_account(self) -> bool: - return self.profile.accounts.has_working_account -- GitLab From 17305511f68047e991746baebbae0a2d1eb5d336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Tue, 11 Feb 2025 11:11:46 +0100 Subject: [PATCH 014/192] Add two options : 1) page_size - number of entries to display on single page with minimum available value of PAGE_SIZE_OPTION_MINIMAL_VALUE(1), 2) page_no - number of page to display with minimum available value of PAGE_NUMBER_OPTION_MINIMAL_VALUE(0). --- .../__private/cli/common/parameters/options.py | 18 +++++++++++++++++- clive/__private/core/constants/cli.py | 3 +++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/clive/__private/cli/common/parameters/options.py b/clive/__private/cli/common/parameters/options.py index dc109190ff..ca46e41e0f 100644 --- a/clive/__private/cli/common/parameters/options.py +++ b/clive/__private/cli/common/parameters/options.py @@ -18,7 +18,11 @@ from clive.__private.cli.common.parsers import ( liquid_asset, voting_asset, ) -from clive.__private.core.constants.cli import PERFORM_WORKING_ACCOUNT_LOAD +from clive.__private.core.constants.cli import ( + PAGE_NUMBER_OPTION_MINIMAL_VALUE, + PAGE_SIZE_OPTION_MINIMAL_VALUE, + PERFORM_WORKING_ACCOUNT_LOAD, +) working_account_template = typer.Option( PERFORM_WORKING_ACCOUNT_LOAD, # we don't know if account_name_option is required until the profile is loaded @@ -91,3 +95,15 @@ memo_value = typer.Option( help="The memo to attach to the transfer.", ) memo_value_optional = modified_param(memo_value, default=None) + +page_size = typer.Option( + 10, + min=PAGE_SIZE_OPTION_MINIMAL_VALUE, + help="The number of entries presented on a single page.", +) + +page_no = typer.Option( + 0, + min=PAGE_NUMBER_OPTION_MINIMAL_VALUE, + help="Page number to display, considering the given page size.", +) diff --git a/clive/__private/core/constants/cli.py b/clive/__private/core/constants/cli.py index 53f0d350f5..9f7ebe2689 100644 --- a/clive/__private/core/constants/cli.py +++ b/clive/__private/core/constants/cli.py @@ -18,3 +18,6 @@ UNLOCK_CREATE_PROFILE_HELP: Final[str] = ( "`clive configure profile add --profile-name PROFILE_NAME` and pass password to the standard input." ) UNLOCK_CREATE_PROFILE_SELECT: Final[str] = "create a new profile" + +PAGE_SIZE_OPTION_MINIMAL_VALUE: Final[int] = 1 +PAGE_NUMBER_OPTION_MINIMAL_VALUE: Final[int] = 0 -- GitLab From 56a54584673ba56f3c0e4035f4832c4821fe17bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Tue, 28 Jan 2025 11:24:36 +0100 Subject: [PATCH 015/192] Add table_pagination_info.py with add_pagination_info_to_table_if_needed function. This function will append information to the table, that there are more results on next page(s). --- clive/__private/cli/table_pagination_info.py | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 clive/__private/cli/table_pagination_info.py diff --git a/clive/__private/cli/table_pagination_info.py b/clive/__private/cli/table_pagination_info.py new file mode 100644 index 0000000000..1323749817 --- /dev/null +++ b/clive/__private/cli/table_pagination_info.py @@ -0,0 +1,26 @@ +import math + +from rich.table import Table + + +def add_pagination_info_to_table_if_needed(table: Table, page_no: int, page_size: int, all_entries: int) -> None: + """Add information about current displayed page of table.""" + assert page_no >= 0, "Page number must be greater or equal to 0." + assert page_size > 0, "Page size must be greater than 0." + assert table.caption is None, "The table's caption should be None before setting a new one to avoid overwriting." + + if page_no == 0 and page_size >= all_entries: + return + + last_page_no = math.ceil(all_entries / page_size) - 1 # -1 as pages are 0-indexed + + if page_no == 0: + page_info = "There are more on the next page(s)." + elif page_no >= last_page_no: + page_info = "There are more on the previous page(s)." + else: + page_info = "There are more on the next/previous page(s)." + + # Setting caption + table.caption = page_info + table.caption_style = "default" # Set text background color to default - it will be the same as terminal -- GitLab From f0cd170af7aefa54f3ebc34db3083f86ab1fb299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Mon, 27 Jan 2025 11:06:49 +0100 Subject: [PATCH 016/192] Add number of page for clive show witnesses command. --- clive/__private/cli/commands/show/show_witnesses.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/clive/__private/cli/commands/show/show_witnesses.py b/clive/__private/cli/commands/show/show_witnesses.py index 54d2cdea96..b2c1b59227 100644 --- a/clive/__private/cli/commands/show/show_witnesses.py +++ b/clive/__private/cli/commands/show/show_witnesses.py @@ -5,6 +5,7 @@ from rich.console import Console from rich.table import Table from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand +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 @@ -62,5 +63,10 @@ class ShowWitnesses(WorldBasedCommand): f"{witness.price_feed}", f"{witness.version}", ) + + add_pagination_info_to_table_if_needed( + table=table, page_no=self.page_no, page_size=self.page_size, all_entries=len(witnesses_list) + ) + console = Console() console.print(table) -- GitLab From fba8ef7a2f562f4833aaa5f91254dddefc3779b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Mon, 27 Jan 2025 12:01:08 +0100 Subject: [PATCH 017/192] Add number of page for clive show proposals command. --- clive/__private/cli/commands/show/show_proposals.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/clive/__private/cli/commands/show/show_proposals.py b/clive/__private/cli/commands/show/show_proposals.py index 4adf333bea..ab4c24da5a 100644 --- a/clive/__private/cli/commands/show/show_proposals.py +++ b/clive/__private/cli/commands/show/show_proposals.py @@ -5,6 +5,7 @@ from rich.console import Console from rich.table import Table from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand +from clive.__private.cli.table_pagination_info import add_pagination_info_to_table_if_needed from clive.__private.core.commands.data_retrieval.proposals_data import ProposalsDataRetrieval from clive.__private.core.formatters.humanize import humanize_bool @@ -60,5 +61,10 @@ class ShowProposals(WorldBasedCommand): f"{proposal.pretty_start_date}", f"{proposal.pretty_end_date}", ) + + add_pagination_info_to_table_if_needed( + table=table, page_no=self.page_no, page_size=self.page_size, all_entries=len(proposals_data.proposals) + ) + console = Console() console.print(table) -- GitLab From ecf280d416d971ac8c5d0e6d2d025ab861f568f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Tue, 11 Feb 2025 11:43:27 +0100 Subject: [PATCH 018/192] Use page_size and page_no in clive show witnesses/proposals. --- .../cli/common/parameters/options.py | 4 +- clive/__private/cli/show/main.py | 37 ++++++++++--------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/clive/__private/cli/common/parameters/options.py b/clive/__private/cli/common/parameters/options.py index ca46e41e0f..8c3b780592 100644 --- a/clive/__private/cli/common/parameters/options.py +++ b/clive/__private/cli/common/parameters/options.py @@ -10,9 +10,7 @@ from __future__ import annotations import typer -from clive.__private.cli.common.parameters.modified_param import ( - modified_param, -) +from clive.__private.cli.common.parameters.modified_param import modified_param from clive.__private.cli.common.parsers import ( decimal_percent, liquid_asset, diff --git a/clive/__private/cli/show/main.py b/clive/__private/cli/show/main.py index 3b299907a7..ab10688880 100644 --- a/clive/__private/cli/show/main.py +++ b/clive/__private/cli/show/main.py @@ -5,11 +5,12 @@ import typer from clive.__private.cli.clive_typer import CliveTyper from clive.__private.cli.common import WorldOptionsGroup -from clive.__private.cli.common.parameters import argument_related_options, arguments +from clive.__private.cli.common.parameters import argument_related_options, arguments, options from clive.__private.cli.common.parameters.ensure_single_value import ( EnsureSingleAccountNameValue, EnsureSingleValue, ) +from clive.__private.cli.common.parameters.modified_param import modified_param from clive.__private.cli.completion import is_tab_completion_active from clive.__private.cli.show.pending import pending from clive.__private.core.constants.cli import REQUIRED_AS_ARG_OR_OPTION @@ -140,19 +141,21 @@ async def show_proxy( ).run() +witnesses_page_size = modified_param( + options.page_size, default=30, help="The number of witnesses presented on a single page." +) +witnesses_page_no = modified_param( + options.page_no, help="Page number of the witnesses list, considering the given page size." +) + + @show.command(name="witnesses", param_groups=[WorldOptionsGroup]) async def show_witnesses( ctx: typer.Context, # noqa: ARG001 account_name: str = arguments.account_name, account_name_option: Optional[str] = argument_related_options.account_name, - page_size: int = typer.Option( - 30, - help="The number of witnesses presented on a single page.", - ), - page_no: int = typer.Option( - 0, - help="Page number of the witnesses list, considering the given page size.", - ), + page_size: int = witnesses_page_size, + page_no: int = witnesses_page_no, ) -> None: """List witnesses and votes of selected account.""" from clive.__private.cli.commands.show.show_witnesses import ShowWitnesses @@ -185,6 +188,12 @@ async def show_witness( ).run() +proposals_page_size = modified_param(options.page_size, help="The number of proposals presented on a single page.") +proposals_page_no = modified_param( + options.page_no, help="Page number of the proposals list, considering the given page size." +) + + @show.command(name="proposals", param_groups=[WorldOptionsGroup]) async def show_proposals( # noqa: PLR0913 ctx: typer.Context, # noqa: ARG001 @@ -202,14 +211,8 @@ async def show_proposals( # noqa: PLR0913 DEFAULT_STATUS, help="Proposals can be filtered by status.", ), - page_size: int = typer.Option( - 10, - help="The number of proposals presented on a single page.", - ), - page_no: int = typer.Option( - 0, - help="Page number of the proposals list, considering the given page size.", - ), + page_size: int = proposals_page_size, + page_no: int = proposals_page_no, ) -> None: """List proposals filtered by status.""" from clive.__private.cli.commands.show.show_proposals import ShowProposals -- GitLab From 24f15015519fc8091aa4135b05d075816f2576a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Wed, 19 Feb 2025 09:05:52 +0100 Subject: [PATCH 019/192] Fix TUI node switching mechanism There were not properly handled cases like - our current node is offline - new node is offline Also it's better to use Clive Node instead of direct usage of AsyncHived from helpy. Clive's Node adds useful mechanism for checking online/offline status and caching basic node data. --- clive/__private/ui/widgets/node_widgets.py | 33 ++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/clive/__private/ui/widgets/node_widgets.py b/clive/__private/ui/widgets/node_widgets.py index 1b8a4a07c4..a56d85539e 100644 --- a/clive/__private/ui/widgets/node_widgets.py +++ b/clive/__private/ui/widgets/node_widgets.py @@ -2,12 +2,13 @@ from __future__ import annotations from typing import TYPE_CHECKING -from helpy import AsyncHived, HttpUrl -from helpy import Settings as HelpySettings +from helpy import HttpUrl from textual.containers import Container from textual.reactive import reactive from textual.widgets import Static +from clive.__private.core.node import Node +from clive.__private.core.profile import Profile from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.widgets.clive_basic.clive_select import CliveSelect @@ -15,8 +16,6 @@ if TYPE_CHECKING: from rich.console import RenderableType from textual.app import ComposeResult - from clive.__private.core.node import Node - class SelectedNodeAddress(Static): """The currently selected node address.""" @@ -61,17 +60,29 @@ class NodesList(Container, CliveWidget): yield NodeSelector() async def save_selected_node_address(self) -> bool: + async def set_address(address: HttpUrl) -> None: + await self.node.set_address(address) + self.app.trigger_node_watchers() + self.notify(f"Node address set to `{address}`.") + new_address = self.query_exactly_one(NodeSelector).value_ensure + temp_profile = Profile.create("temporary", node_address=new_address) - async with AsyncHived(settings=HelpySettings(http_endpoint=new_address)) as temp_node: - new_network_type = (await temp_node.api.database.get_version()).node_type + async with Node(temp_profile) as temp_node: + is_new_node_online = await temp_node.cached.online + new_network_type = temp_node.cached.network_type_or_none - current_network_type = await self.node.cached.network_type + if not is_new_node_online: + self.notify("Cannot connect to an offline node.", severity="error") + return False - if new_network_type == current_network_type: - await self.node.set_address(new_address) - self.app.trigger_node_watchers() - self.notify(f"Node address set to `{self._node.http_endpoint}`.") + is_current_node_online = await self.node.cached.online + current_network_type = self.node.cached.network_type_or_none + + if not is_current_node_online or new_network_type == current_network_type: + # When we have no connection, just set the address without comparing network types. + # When both nodes are online and have the same network type, we can just switch the address. + await set_address(new_address) return True # block possibility to stay connected to node with different network type -- GitLab From d36035a0f8a8a2fe68a1a3581ff17940d248eded Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Thu, 30 Jan 2025 14:46:40 +0100 Subject: [PATCH 020/192] Add permanent argument to SetTimeout and CreateWallet, change seconds to timedelta Also change default value of permanent argument in unlock to true --- clive/__private/core/commands/commands.py | 31 +++++++++++++++---- .../__private/core/commands/create_wallet.py | 8 +++++ clive/__private/core/commands/set_timeout.py | 19 ++++++++++-- clive/__private/core/commands/unlock.py | 17 ++-------- tests/functional/commands/test_locking.py | 2 +- 5 files changed, 53 insertions(+), 24 deletions(-) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 2248d2ced7..db02bbf3ba 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -106,7 +106,12 @@ class Commands(Generic[WorldT_co]): self.__exception_handlers = [*(exception_handlers or [])] async def create_wallet( - self, *, name: str | None = None, password: str | None = None + self, + *, + name: str | None = None, + password: str | None = None, + unlock_time: timedelta | None = None, + permanent_unlock: bool = True, ) -> CommandWithResultWrapper[CreateWalletResult]: """ Create a beekeeper wallet. @@ -115,6 +120,9 @@ class Commands(Generic[WorldT_co]): ---- name: Name of the new wallet. If None, the world profile_name will be unlocked. password: Password later used to unlock the wallet. If None, will be generated by the beekeeper. + unlock_time: The time after which the wallet will be automatically locked. Do not need to pass when unlocking + permanently. + permanent_unlock: Whether to unlock the wallet permanently. Will take precedence when `unlock_time` is also set. """ return await self.__surround_with_exception_handlers( CreateWallet( @@ -122,6 +130,8 @@ class Commands(Generic[WorldT_co]): session=self._world._session_ensure, wallet_name=name if name is not None else self._world.profile.name, password=password, + unlock_time=unlock_time, + permanent_unlock=permanent_unlock, ) ) @@ -149,7 +159,7 @@ class Commands(Generic[WorldT_co]): ) async def unlock( - self, *, profile_name: str | None = None, password: str, time: timedelta | None = None, permanent: bool = False + self, *, profile_name: str | None = None, password: str, time: timedelta | None = None, permanent: bool = True ) -> CommandWrapper: """ Return a CommandWrapper instance to unlock the profile-related wallets (user keys and encryption key). @@ -158,8 +168,9 @@ class Commands(Generic[WorldT_co]): ---- profile_name: Name of the wallet to unlock. If None, the world wallet will be unlocked. password: Password to unlock the wallet. - time: Time to unlock the wallet. Do not need to pass when unlocking permanently. - permanent: Whether to unlock the wallet permanently. + time: The time after which the wallet will be automatically locked. Do not need to pass when unlocking + permanently. + permanent: Whether to unlock the wallet permanently. Will take precedence when `time` is also set. """ return await self.__surround_with_exception_handlers( Unlock( @@ -217,9 +228,17 @@ class Commands(Generic[WorldT_co]): ) ) - async def set_timeout(self, *, seconds: int) -> CommandWrapper: + async def set_timeout(self, *, time: timedelta | None = None, permanent: bool = False) -> CommandWrapper: + """ + Set timeout for beekeeper session. It means the time after all wallets in this session will be locked. + + Args: + ---- + time: The time after which the wallet will be automatically locked. Do not need to pass when `permanent` is set. + permanent: Whether to keep the wallets unlocked permanently. Will take precedence when `time` is also set. + """ return await self.__surround_with_exception_handlers( - SetTimeout(session=self._world._session_ensure, seconds=seconds) + SetTimeout(session=self._world._session_ensure, time=time, permanent=permanent) ) async def perform_actions_on_transaction( # noqa: PLR0913 diff --git a/clive/__private/core/commands/create_wallet.py b/clive/__private/core/commands/create_wallet.py index 333a968763..58e13f41bc 100644 --- a/clive/__private/core/commands/create_wallet.py +++ b/clive/__private/core/commands/create_wallet.py @@ -4,10 +4,13 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from clive.__private.core.commands.abc.command_with_result import CommandWithResult +from clive.__private.core.commands.set_timeout import SetTimeout from clive.__private.core.encryption import EncryptionService from clive.__private.core.wallet_container import WalletContainer if TYPE_CHECKING: + from datetime import timedelta + from beekeepy import AsyncSession, AsyncUnlockedWallet from clive.__private.core.app_state import AppState @@ -28,6 +31,9 @@ class CreateWallet(CommandWithResult[CreateWalletResult]): session: AsyncSession wallet_name: str password: str | None + unlock_time: timedelta | None = None + permanent_unlock: bool = True + """Will take precedence when `unlock_time` is also set.""" async def _execute(self) -> None: result = await self.session.create_wallet(name=self.wallet_name, password=self.password) @@ -55,3 +61,5 @@ class CreateWallet(CommandWithResult[CreateWalletResult]): if self.app_state: wallets = WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet) await self.app_state.unlock(wallets) + + await SetTimeout(session=self.session, time=self.unlock_time, permanent=self.permanent_unlock).execute() diff --git a/clive/__private/core/commands/set_timeout.py b/clive/__private/core/commands/set_timeout.py index 5097e82fa2..f9e8c3f39b 100644 --- a/clive/__private/core/commands/set_timeout.py +++ b/clive/__private/core/commands/set_timeout.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import timedelta from typing import TYPE_CHECKING from clive.__private.core.commands.abc.command import Command @@ -13,8 +14,20 @@ if TYPE_CHECKING: @dataclass(kw_only=True) class SetTimeout(Command): session: AsyncSession - seconds: int + time: timedelta | None = None + permanent: bool = False + """Will take precedence when `time` is also set.""" async def _execute(self) -> None: - await self.session.set_timeout(seconds=self.seconds) - logger.info(f"Timeout set to {self.seconds} s.") + timeout_in_seconds = self._determine_timeout().total_seconds() + await self.session.set_timeout(seconds=int(timeout_in_seconds)) + logger.info(f"Timeout set to {timeout_in_seconds} s.") + + def _determine_timeout(self) -> timedelta: + if self.permanent: + # beekeeper does not support permanent timeout in a convenient way, we have to pass a very big number + # which is uint32 max value + return timedelta(seconds=2**32 - 1) + + assert self.time is not None, "`time` can't be none if `permanent` is set to False." + return self.time diff --git a/clive/__private/core/commands/unlock.py b/clive/__private/core/commands/unlock.py index 9db30df98a..407ad111ea 100644 --- a/clive/__private/core/commands/unlock.py +++ b/clive/__private/core/commands/unlock.py @@ -23,12 +23,12 @@ class Unlock(CommandPasswordSecured): profile_name: str session: AsyncSession time: timedelta | None = None - permanent: bool = False + permanent: bool = True + """Will take precedence when `time` is also set.""" app_state: AppState | None = None async def _execute(self) -> None: - if unlock_seconds := self.__get_unlock_seconds(): - await SetTimeout(session=self.session, seconds=unlock_seconds).execute() + await SetTimeout(session=self.session, time=self.time, permanent=self.permanent).execute() user_keys_wallet = await (await self.session.open_wallet(name=self.profile_name)).unlock(password=self.password) encryption_key_wallet = await ( @@ -38,14 +38,3 @@ class Unlock(CommandPasswordSecured): if self.app_state is not None: wallets = WalletContainer(user_keys_wallet, encryption_key_wallet) await self.app_state.unlock(wallets) - - def __get_unlock_seconds(self) -> int | None: - if self.permanent: - # beekeeper does not support permanent unlock in a convenient way, we have to pass a very big number - # which is uint32 max value - return 2**32 - 1 - - if self.time is None: - return None - - return int(self.time.total_seconds()) diff --git a/tests/functional/commands/test_locking.py b/tests/functional/commands/test_locking.py index 0d8fa07a37..610f272009 100644 --- a/tests/functional/commands/test_locking.py +++ b/tests/functional/commands/test_locking.py @@ -76,7 +76,7 @@ async def test_lock_after_given_time( world.profile.skip_saving() # cannot save profile when it is locked because encryption is not possible # ACT - await world.commands.unlock(password=wallet_password, time=time_to_sleep) + await world.commands.unlock(password=wallet_password, time=time_to_sleep, permanent=False) assert world.app_state.is_unlocked await asyncio.sleep(time_to_sleep.total_seconds() + 1) # extra second for notification -- GitLab From 49f9ee3370449bfae3007546038245342cd95fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 08:40:09 +0100 Subject: [PATCH 021/192] Fix issue with random crash when pressing "UNLOCKED" to lock and sync state happens meantime --- clive/__private/core/commands/commands.py | 2 +- .../commands/sync_state_with_beekeeper.py | 26 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index db02bbf3ba..35cd59210b 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -369,7 +369,7 @@ class Commands(Generic[WorldT_co]): async def sync_state_with_beekeeper(self, source: LockSource = "unknown") -> CommandWrapper: return await self.__surround_with_exception_handlers( SyncStateWithBeekeeper( - wallets=self._world.wallets._content, + session=self._world._session_ensure, app_state=self._world.app_state, source=source, ) diff --git a/clive/__private/core/commands/sync_state_with_beekeeper.py b/clive/__private/core/commands/sync_state_with_beekeeper.py index 266a7c6604..71e29e35ce 100644 --- a/clive/__private/core/commands/sync_state_with_beekeeper.py +++ b/clive/__private/core/commands/sync_state_with_beekeeper.py @@ -4,10 +4,13 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Final from clive.__private.core.commands.abc.command import Command, CommandError +from clive.__private.core.encryption import EncryptionService +from clive.__private.core.wallet_container import WalletContainer if TYPE_CHECKING: + from beekeepy import AsyncSession + from clive.__private.core.app_state import AppState, LockSource - from clive.__private.core.wallet_container import WalletContainer class InvalidWalletStateError(CommandError): @@ -22,7 +25,7 @@ class InvalidWalletStateError(CommandError): @dataclass(kw_only=True) class SyncStateWithBeekeeper(Command): - wallets: WalletContainer + session: AsyncSession app_state: AppState source: LockSource = "unknown" @@ -30,13 +33,18 @@ class SyncStateWithBeekeeper(Command): await self.__sync_state() async def __sync_state(self) -> None: - wallets = self.wallets - is_user_wallet_unlocked = await wallets.user_wallet.is_unlocked() - is_encryption_wallet_unlocked = await wallets.encryption_wallet.is_unlocked() - - if is_user_wallet_unlocked and is_encryption_wallet_unlocked: - await self.app_state.unlock(wallets) - elif not is_user_wallet_unlocked and not is_encryption_wallet_unlocked: + wallets = await self.session.wallets_unlocked + + user_wallet = next( + (wallet for wallet in wallets if not EncryptionService.is_encryption_wallet_name(wallet.name)), None + ) + encryption_wallet = next( + (wallet for wallet in wallets if EncryptionService.is_encryption_wallet_name(wallet.name)), None + ) + + if user_wallet and encryption_wallet: + await self.app_state.unlock(WalletContainer(user_wallet, encryption_wallet)) + elif not user_wallet and not encryption_wallet: self.app_state.lock(self.source) else: raise InvalidWalletStateError(self) -- GitLab From e4bdaca582f171a8a78edb20901408ecbfe3c680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 12:10:27 +0100 Subject: [PATCH 022/192] Raise InvalidWalletAmountError from SyncStateWithBeekeeper --- .../core/commands/sync_state_with_beekeeper.py | 14 ++++++++++++++ clive/__private/core/constants/env.py | 3 +++ 2 files changed, 17 insertions(+) diff --git a/clive/__private/core/commands/sync_state_with_beekeeper.py b/clive/__private/core/commands/sync_state_with_beekeeper.py index 71e29e35ce..d98c766acc 100644 --- a/clive/__private/core/commands/sync_state_with_beekeeper.py +++ b/clive/__private/core/commands/sync_state_with_beekeeper.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Final from clive.__private.core.commands.abc.command import Command, CommandError +from clive.__private.core.constants.env import WALLETS_AMOUNT_PER_PROFILE from clive.__private.core.encryption import EncryptionService from clive.__private.core.wallet_container import WalletContainer @@ -13,6 +14,16 @@ if TYPE_CHECKING: from clive.__private.core.app_state import AppState, LockSource +class InvalidWalletAmountError(CommandError): + MESSAGE: Final[str] = ( + "The amount of wallets is invalid. " + f"Profile can have either 0 (if not created yet) or {WALLETS_AMOUNT_PER_PROFILE} wallets." + ) + + def __init__(self, command: Command) -> None: + super().__init__(command, self.MESSAGE) + + class InvalidWalletStateError(CommandError): MESSAGE: Final[str] = ( "The user wallet, containing its keys, and the encryption wallet must BOTH be unlocked or locked. " @@ -35,6 +46,9 @@ class SyncStateWithBeekeeper(Command): async def __sync_state(self) -> None: wallets = await self.session.wallets_unlocked + if len(wallets) not in [0, WALLETS_AMOUNT_PER_PROFILE]: + raise InvalidWalletAmountError(self) + user_wallet = next( (wallet for wallet in wallets if not EncryptionService.is_encryption_wallet_name(wallet.name)), None ) diff --git a/clive/__private/core/constants/env.py b/clive/__private/core/constants/env.py index f462714210..ac06f510a3 100644 --- a/clive/__private/core/constants/env.py +++ b/clive/__private/core/constants/env.py @@ -16,3 +16,6 @@ KNOWN_FIRST_PARTY_PACKAGES: Final[list[str]] = [ "test_tools", "wax", ] + +WALLETS_AMOUNT_PER_PROFILE: Final[int] = 2 +"""Each profile can have up to 2 wallets: user wallet and encryption wallet.""" -- GitLab From 32503cc2f4d87d49c204962c1941d977c2e69061 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 3 Feb 2025 14:20:17 +0100 Subject: [PATCH 023/192] Fix logs are placed in std output during CLI tests --- tests/conftest.py | 57 +++++++++++++++++++++++--------- tests/functional/cli/conftest.py | 11 +++++- tests/tui/conftest.py | 11 +++++- 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f5d240de7a..ef7587c7b4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,21 +4,26 @@ import os import shutil from contextlib import contextmanager from functools import wraps -from typing import TYPE_CHECKING, Generator +from typing import TYPE_CHECKING, Callable, Generator import pytest import test_tools as tt from beekeepy import AsyncBeekeeper from test_tools.__private.scope.scope_fixtures import * # noqa: F403 -from clive.__private.before_launch import prepare_before_launch +from clive.__private.before_launch import ( + _create_clive_data_directory, + _disable_schemas_extra_fields_check, + _initialize_user_settings, +) from clive.__private.core import iwax from clive.__private.core._thread import thread_pool from clive.__private.core.commands.create_wallet import CreateWallet from clive.__private.core.commands.import_key import ImportKey from clive.__private.core.constants.setting_identifiers import DATA_PATH, LOG_LEVEL_1ST_PARTY, LOG_LEVELS, LOG_PATH from clive.__private.core.world import World -from clive.__private.settings import settings +from clive.__private.logger import logger +from clive.__private.settings import safe_settings, settings from clive_local_tools.data.constants import ( BEEKEEPER_REMOTE_ADDRESS_ENV_NAME, BEEKEEPER_SESSION_TOKEN_ENV_NAME, @@ -45,17 +50,14 @@ def manage_thread_pool() -> Iterator[None]: yield -@pytest.fixture -def testnet_chain_id_env_context(generic_env_context_factory: GenericEnvContextFactory) -> Generator[None]: - chain_id_env_context_factory = generic_env_context_factory(NODE_CHAIN_ID_ENV_NAME) - with chain_id_env_context_factory(TESTNET_CHAIN_ID): - yield - - -@pytest.fixture(autouse=True) -def run_prepare_before_launch(testnet_chain_id_env_context: None) -> None: # noqa: ARG001 +def _prepare_settings() -> None: settings.reload() + working_directory = tt.context.get_current_directory() / "clive" + settings.set(DATA_PATH, working_directory) + + _create_clive_data_directory() + _initialize_user_settings() beekeeper_directory = working_directory / "beekeeper" if beekeeper_directory.exists(): @@ -65,14 +67,37 @@ def run_prepare_before_launch(testnet_chain_id_env_context: None) -> None: # no if profile_data_directory.exists(): shutil.rmtree(profile_data_directory) - settings.set(DATA_PATH, working_directory) - log_path = working_directory / "logs" - settings.set(LOG_PATH, log_path) + settings.set(LOG_PATH, working_directory / "logs") settings.set(LOG_LEVELS, ["DEBUG"]) settings.set(LOG_LEVEL_1ST_PARTY, "DEBUG") - prepare_before_launch(enable_stream_handlers=True) + safe_settings.validate() + + +@pytest.fixture +def testnet_chain_id_env_context(generic_env_context_factory: GenericEnvContextFactory) -> Generator[None]: + chain_id_env_context_factory = generic_env_context_factory(NODE_CHAIN_ID_ENV_NAME) + with chain_id_env_context_factory(TESTNET_CHAIN_ID): + yield + + +@pytest.fixture +def logger_configuration_factory() -> Callable[[], None]: + def _logger_configuration_factory() -> None: + logger.setup(enable_textual=False, enable_stream_handlers=True) + + return _logger_configuration_factory + + +@pytest.fixture(autouse=True) +def prepare_before_launch( + testnet_chain_id_env_context: None, # noqa: ARG001 + logger_configuration_factory: Callable[[], None], +) -> None: + _prepare_settings() + _disable_schemas_extra_fields_check() + logger_configuration_factory() @pytest.fixture diff --git a/tests/functional/cli/conftest.py b/tests/functional/cli/conftest.py index 236a073a7e..bd7c52d41e 100644 --- a/tests/functional/cli/conftest.py +++ b/tests/functional/cli/conftest.py @@ -1,7 +1,7 @@ from __future__ import annotations from contextlib import ExitStack -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable import pytest from beekeepy import AsyncBeekeeper @@ -11,6 +11,7 @@ from clive.__private.core.accounts.accounts import WatchedAccount, WorkingAccoun from clive.__private.core.constants.terminal import TERMINAL_WIDTH from clive.__private.core.keys.keys import PrivateKeyAliased from clive.__private.core.world import World +from clive.__private.logger import logger from clive.__private.settings import safe_settings from clive_local_tools.cli.cli_tester import CLITester from clive_local_tools.data.constants import ( @@ -28,6 +29,14 @@ if TYPE_CHECKING: from clive_local_tools.types import EnvContextFactory +@pytest.fixture +def logger_configuration_factory() -> Callable[[], None]: + def _logger_configuration_factory() -> None: + logger.setup(enable_textual=False) + + return _logger_configuration_factory + + @pytest.fixture async def beekeeper_local() -> AsyncGenerator[AsyncBeekeeper]: """CLI tests are remotely connecting to a locally started beekeeper by this fixture.""" diff --git a/tests/tui/conftest.py b/tests/tui/conftest.py index a9aef35f52..d8d2ae6f71 100644 --- a/tests/tui/conftest.py +++ b/tests/tui/conftest.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import partialmethod -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable import pytest import test_tools as tt @@ -10,6 +10,7 @@ from clive.__private.core.accounts.accounts import WatchedAccount, WorkingAccoun from clive.__private.core.constants.setting_identifiers import SECRETS_NODE_ADDRESS from clive.__private.core.keys.keys import PrivateKeyAliased from clive.__private.core.world import World +from clive.__private.logger import logger from clive.__private.settings import settings from clive.__private.ui.app import Clive from clive.__private.ui.screens.dashboard import Dashboard @@ -36,6 +37,14 @@ def _patch_notification_timeout(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(Clive, "notify", partialmethod(Clive.notify, timeout=TUI_TESTS_PATCHED_NOTIFICATION_TIMEOUT)) +@pytest.fixture +def logger_configuration_factory() -> Callable[[], None]: + def _logger_configuration_factory() -> None: + logger.setup(enable_textual=False) + + return _logger_configuration_factory + + @pytest.fixture async def _prepare_profile_with_wallet_tui() -> None: """Prepare profile and wallets using locally spawned beekeeper.""" -- GitLab From f062712faa1d31ff9412f188639c016e9d40844c Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Tue, 25 Feb 2025 13:26:18 +0100 Subject: [PATCH 024/192] Add global bindings for going to transaction summary and dashboard --- clive/__private/ui/app.py | 15 +++++++++++++++ clive/__private/ui/global_help.md | 15 +++++++++------ .../ui/widgets/clive_basic/clive_header.py | 16 ++-------------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 4f87d29059..698922561f 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -57,6 +57,8 @@ class Clive(App[int]): Binding("ctrl+x", "push_screen('quit')", "Quit", show=False), Binding("c", "clear_notifications", "Clear notifications", show=False), Binding("f1", "help", "Help", show=False), + Binding("f7", "go_to_transaction_summary", "Transaction summary", show=False), + Binding("f8", "go_to_dashboard", "Dashboard", show=False), ] SCREENS = { @@ -198,6 +200,19 @@ class Clive(App[int]): def action_clear_notifications(self) -> None: self.clear_notifications() + def action_go_to_dashboard(self) -> None: + self.get_screen_from_current_stack(Dashboard).pop_until_active() + + async def action_go_to_transaction_summary(self) -> None: + from clive.__private.ui.screens.transaction_summary import TransactionSummary + + if isinstance(self.screen, TransactionSummary): + return + + if not self.world.profile.transaction.is_signed: + await self.world.commands.update_transaction_metadata(transaction=self.world.profile.transaction) + await self.push_screen(TransactionSummary()) + def pause_refresh_alarms_data_interval(self) -> None: self._refresh_alarms_data_interval.pause() self.workers.cancel_group(self, "alarms_data") diff --git a/clive/__private/ui/global_help.md b/clive/__private/ui/global_help.md index f8a4a0816f..b7c7721056 100644 --- a/clive/__private/ui/global_help.md +++ b/clive/__private/ui/global_help.md @@ -4,12 +4,15 @@ ## Global bindings: -| Binding | Action | -|:--------:|---------------------| -| `F1` | Show help | -| `Ctrl+X` | Quit | -| `Ctrl+S` | Screenshot | -| `C` | Clear notifications | +| Binding | Action | +|:--------:|---------------------------| +| `F1` | Show help | +| `F7` | Go to transaction summary | +| `F8` | Go to dashboard | +| `Ctrl+X` | Quit | +| `Ctrl+S` | Screenshot | +| `C` | Clear notifications | + ## How to select, copy and paste text inside TUI app like Clive? diff --git a/clive/__private/ui/widgets/clive_basic/clive_header.py b/clive/__private/ui/widgets/clive_basic/clive_header.py index ef58941b76..9d5bcb0702 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_header.py +++ b/clive/__private/ui/widgets/clive_basic/clive_header.py @@ -96,14 +96,7 @@ class CartStatus(DynamicOneLineButtonUnfocusable): @on(OneLineButton.Pressed) async def go_to_transaction_summary(self) -> None: - from clive.__private.ui.screens.transaction_summary import TransactionSummary - - if isinstance(self.app.screen, TransactionSummary): - return - - if not self.profile.transaction.is_signed: - await self.commands.update_transaction_metadata(transaction=self.profile.transaction) - await self.app.push_screen(TransactionSummary()) + await self.app.action_go_to_transaction_summary() class DashboardButton(OneLineButtonUnfocusable): @@ -113,12 +106,7 @@ class DashboardButton(OneLineButtonUnfocusable): @on(OneLineButton.Pressed) def go_to_dashboard(self) -> None: - from clive.__private.ui.screens.dashboard import Dashboard - - if isinstance(self.app.screen, Dashboard): - return - - self.app.get_screen_from_current_stack(Dashboard).pop_until_active() + self.app.action_go_to_dashboard() class WorkingAccountButton(DynamicOneLineButtonUnfocusable): -- GitLab From 07b31287ba32f212d0cd6129e5288366062ff694 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Tue, 4 Feb 2025 15:44:26 +0100 Subject: [PATCH 025/192] Cleanup of argument to removed command for setting default profile --- clive/__private/cli/configure/profile.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/clive/__private/cli/configure/profile.py b/clive/__private/cli/configure/profile.py index 96a9d7e6ff..4460d088bc 100644 --- a/clive/__private/cli/configure/profile.py +++ b/clive/__private/cli/configure/profile.py @@ -37,11 +37,6 @@ async def create_profile( ).run() -_profile_name_set_default_argument = modified_param( - _profile_name_create_argument, help=f"The name of the profile to switch to. ({REQUIRED_AS_ARG_OR_OPTION})" -) - - _profile_name_delete_argument = modified_param( _profile_name_create_argument, help=f"The name of the profile to delete. ({REQUIRED_AS_ARG_OR_OPTION})" ) -- GitLab From 353763297e39598a017bd2b7be432640d1c79990 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Tue, 4 Feb 2025 12:43:00 +0100 Subject: [PATCH 026/192] Require to repeat password when creating new profile via cli --- clive/__private/cli/commands/configure/profile.py | 15 ++++++++++++--- clive/__private/cli/configure/profile.py | 6 +++++- clive/__private/cli/exceptions.py | 9 +++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/clive/__private/cli/commands/configure/profile.py b/clive/__private/cli/commands/configure/profile.py index 83b37e3a78..df99ea2f97 100644 --- a/clive/__private/cli/commands/configure/profile.py +++ b/clive/__private/cli/commands/configure/profile.py @@ -7,7 +7,11 @@ from helpy.exceptions import CommunicationError from clive.__private.cli.commands.abc.beekeeper_based_command import BeekeeperBasedCommand from clive.__private.cli.commands.abc.external_cli_command import ExternalCLICommand -from clive.__private.cli.exceptions import CLICreatingProfileCommunicationError, CLIPrettyError +from clive.__private.cli.exceptions import ( + CLICreatingProfileCommunicationError, + CLIInvalidPasswordRepeatError, + CLIPrettyError, +) from clive.__private.core.commands.create_wallet import CreateWallet from clive.__private.core.commands.save_profile import SaveProfile from clive.__private.core.formatters.humanize import humanize_validation_result @@ -65,8 +69,13 @@ class CreateProfile(BeekeeperBasedCommand): return password def _get_password_input_in_tty_mode(self) -> str: - prompt = f"Enter password for profile `{self.profile_name}`: " - return getpass(prompt) + prompt = "Set a new password: " + password = getpass(prompt) + prompt_repeat = "Repeat password: " + password_repeat = getpass(prompt_repeat) + if password != password_repeat: + raise CLIInvalidPasswordRepeatError + return password def _get_password_input_in_non_tty_mode(self) -> str: return sys.stdin.readline().rstrip() diff --git a/clive/__private/cli/configure/profile.py b/clive/__private/cli/configure/profile.py index 4460d088bc..6e0e7215cf 100644 --- a/clive/__private/cli/configure/profile.py +++ b/clive/__private/cli/configure/profile.py @@ -26,7 +26,11 @@ async def create_profile( None, help="The name of the working account.", show_default=False ), ) -> None: - """Create a new profile. Password for new profile is provided by stdin.""" + """ + Create a new profile. Password for new profile is provided by stdin. + + If new password is entered in terminal it must be repeated. + """ from clive.__private.cli.commands.configure.profile import CreateProfile common = BeekeeperOptionsGroup.get_instance() diff --git a/clive/__private/cli/exceptions.py b/clive/__private/cli/exceptions.py index 596ca33c61..d283b814cb 100644 --- a/clive/__private/cli/exceptions.py +++ b/clive/__private/cli/exceptions.py @@ -174,6 +174,15 @@ class CLIInvalidPasswordError(CLIPrettyError): super().__init__(message, errno.EPERM) +class CLIInvalidPasswordRepeatError(CLIPrettyError): + def __init__(self) -> None: + message = ( + "Repeated password doesn't match previously entered password." + " The profile was not created. Please try again." + ) + super().__init__(message, errno.EPERM) + + class CLISessionNotLockedError(CLIPrettyError): def __init__(self) -> None: message = "All wallets in session should be locked." -- GitLab From 43d067d3331f40d6d9dadf1ae8ea7b0f6960ad1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Thu, 19 Dec 2024 09:39:52 +0100 Subject: [PATCH 027/192] Add command clive configure known-account add/remove that will manage profiles known accounts. --- .../cli/commands/configure/known_account.py | 40 +++++++++++++++ .../__private/cli/configure/known_account.py | 49 +++++++++++++++++++ clive/__private/cli/configure/main.py | 2 + 3 files changed, 91 insertions(+) create mode 100644 clive/__private/cli/commands/configure/known_account.py create mode 100644 clive/__private/cli/configure/known_account.py diff --git a/clive/__private/cli/commands/configure/known_account.py b/clive/__private/cli/commands/configure/known_account.py new file mode 100644 index 0000000000..670e39dbe1 --- /dev/null +++ b/clive/__private/cli/commands/configure/known_account.py @@ -0,0 +1,40 @@ +import errno +from dataclasses import dataclass + +from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand +from clive.__private.cli.exceptions import CLIPrettyError +from clive.__private.core.formatters.humanize import humanize_validation_result +from clive.__private.validators.set_known_account_validator import SetKnownAccountValidator + + +@dataclass(kw_only=True) +class AddKnownAccount(WorldBasedCommand): + account_name: str + + async def validate_inside_context_manager(self) -> None: + self._validate_known_account() + await super().validate_inside_context_manager() + + def _validate_known_account(self) -> None: + result = SetKnownAccountValidator(self.profile).validate(self.account_name) + if not result.is_valid: + raise CLIPrettyError(f"Can't add this account: {humanize_validation_result(result)}", errno.EINVAL) + + async def _run(self) -> None: + self.profile.accounts.known.add(self.account_name) + + +@dataclass(kw_only=True) +class RemoveKnownAccount(WorldBasedCommand): + account_name: str + + async def validate_inside_context_manager(self) -> None: + self._validate_known_account_exists() + await super().validate_inside_context_manager() + + def _validate_known_account_exists(self) -> None: + if not self.profile.accounts.is_account_known(self.account_name): + raise CLIPrettyError(f"Known account {self.account_name} not found.") + + async def _run(self) -> None: + self.profile.accounts.known.remove(self.account_name) diff --git a/clive/__private/cli/configure/known_account.py b/clive/__private/cli/configure/known_account.py new file mode 100644 index 0000000000..e84e32b90e --- /dev/null +++ b/clive/__private/cli/configure/known_account.py @@ -0,0 +1,49 @@ +from typing import Optional + +import typer + +from clive.__private.cli.clive_typer import CliveTyper +from clive.__private.cli.common import WorldOptionsGroup, argument_related_options, modified_param +from clive.__private.cli.common.parameters.ensure_single_value import EnsureSingleAccountNameValue +from clive.__private.core.constants.cli import REQUIRED_AS_ARG_OR_OPTION + +known_account = CliveTyper(name="known-account", help="Manage your known account(s).") + +_account_name_add_argument = typer.Argument( + None, help=f"The name of the known account to add. ({REQUIRED_AS_ARG_OR_OPTION})", show_default=False +) + + +@known_account.command(name="add", param_groups=[WorldOptionsGroup]) +async def add_known_account( + ctx: typer.Context, # noqa: ARG001 + account_name: Optional[str] = _account_name_add_argument, + account_name_option: Optional[str] = argument_related_options.account_name, +) -> None: + """Add an account to the known accounts.""" + from clive.__private.cli.commands.configure.known_account import AddKnownAccount + + common = WorldOptionsGroup.get_instance() + await AddKnownAccount( + **common.as_dict(), account_name=EnsureSingleAccountNameValue().of(account_name, account_name_option) + ).run() + + +_account_name_remove_argument = modified_param( + _account_name_add_argument, help=f"The name of the known account to remove. ({REQUIRED_AS_ARG_OR_OPTION})" +) + + +@known_account.command(name="remove", param_groups=[WorldOptionsGroup]) +async def remove_known_account( + ctx: typer.Context, # noqa: ARG001 + account_name: Optional[str] = _account_name_remove_argument, + account_name_option: Optional[str] = argument_related_options.account_name, +) -> None: + """Remove an account from the known accounts.""" + from clive.__private.cli.commands.configure.known_account import RemoveKnownAccount + + common = WorldOptionsGroup.get_instance() + await RemoveKnownAccount( + **common.as_dict(), account_name=EnsureSingleAccountNameValue().of(account_name, account_name_option) + ).run() diff --git a/clive/__private/cli/configure/main.py b/clive/__private/cli/configure/main.py index 931e8b60ed..520e3f7801 100644 --- a/clive/__private/cli/configure/main.py +++ b/clive/__private/cli/configure/main.py @@ -1,6 +1,7 @@ from clive.__private.cli.clive_typer import CliveTyper from clive.__private.cli.configure.chain_id import chain_id from clive.__private.cli.configure.key import key +from clive.__private.cli.configure.known_account import known_account from clive.__private.cli.configure.node import node from clive.__private.cli.configure.profile import profile from clive.__private.cli.configure.tracked_account import tracked_account @@ -14,3 +15,4 @@ configure.add_typer(node) configure.add_typer(profile) configure.add_typer(tracked_account) configure.add_typer(working_account) +configure.add_typer(known_account) -- GitLab From f4c28ba9bf91de7cdef1362e77bbc2f1b22818b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20K=C4=99dzierski?= <wkedzierski@syncad.com> Date: Thu, 19 Dec 2024 13:32:27 +0100 Subject: [PATCH 028/192] Add tests for clive configure know-account command. --- .../clive_local_tools/cli/cli_tester.py | 6 ++ .../configure/test_configure_known_account.py | 66 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/functional/cli/configure/test_configure_known_account.py diff --git a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py index 4b60a07c4c..2c59ce0e61 100644 --- a/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py +++ b/tests/clive-local-tools/clive_local_tools/cli/cli_tester.py @@ -419,3 +419,9 @@ class CLITester: def show_profile(self) -> Result: return self.__invoke_command_with_options(["show", "profile"], **extract_params(locals())) + + def configure_known_account_add(self, *, account_name: str) -> Result: + return self.__invoke_command_with_options(["configure", "known-account", "add"], **extract_params(locals())) + + def configure_known_account_remove(self, *, account_name: str) -> Result: + return self.__invoke_command_with_options(["configure", "known-account", "remove"], **extract_params(locals())) diff --git a/tests/functional/cli/configure/test_configure_known_account.py b/tests/functional/cli/configure/test_configure_known_account.py new file mode 100644 index 0000000000..ca25439bc9 --- /dev/null +++ b/tests/functional/cli/configure/test_configure_known_account.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +import pytest + +from clive_local_tools.checkers.profile_accounts_checker import ProfileAccountsChecker +from clive_local_tools.cli.exceptions import CLITestCommandError +from clive_local_tools.testnet_block_log.constants import ( + ALT_WORKING_ACCOUNT1_NAME, + WATCHED_ACCOUNTS_NAMES, +) + +if TYPE_CHECKING: + from clive_local_tools.cli.cli_tester import CLITester + +ACCOUNT_TO_REMOVE: Final[str] = WATCHED_ACCOUNTS_NAMES[0] + + +async def test_configure_known_account_add(cli_tester: CLITester) -> None: + """Check clive configure known-account add command.""" + # ARRANGE + account_to_add = ALT_WORKING_ACCOUNT1_NAME + profile_checker = ProfileAccountsChecker(cli_tester.world.profile.name, cli_tester.world.wallets._content) + + # ACT + cli_tester.configure_known_account_add(account_name=account_to_add) + + # ASSERT + await profile_checker.assert_in_known_accounts(account_names=[account_to_add]) + + +async def test_configure_known_account_add_already_known_account(cli_tester: CLITester) -> None: + """Check clive configure known-account add command with already known account.""" + # ARRANGE + account_to_add = ALT_WORKING_ACCOUNT1_NAME + message = "Can't add this account: This account is already known." + cli_tester.configure_known_account_add(account_name=account_to_add) + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=message): + cli_tester.configure_known_account_add(account_name=account_to_add) + + +async def test_configure_known_account_remove(cli_tester: CLITester) -> None: + """Check clive configure known-account remove command.""" + # ARRANGE + profile_checker = ProfileAccountsChecker(cli_tester.world.profile.name, cli_tester.world.wallets._content) + cli_tester.configure_known_account_add(account_name=ACCOUNT_TO_REMOVE) + await profile_checker.assert_in_known_accounts(account_names=[ACCOUNT_TO_REMOVE]) + + # ACT + cli_tester.configure_known_account_remove(account_name=ACCOUNT_TO_REMOVE) + + # ASSERT + await profile_checker.assert_not_in_known_accounts(account_names=[ACCOUNT_TO_REMOVE]) + + +async def test_configure_known_account_remove_not_known_account(cli_tester: CLITester) -> None: + """Check clive configure known-account remove command on not known account.""" + # ARRANGE + message = f"Known account {ACCOUNT_TO_REMOVE} not found." + + # ACT & ASSERT + with pytest.raises(CLITestCommandError, match=message): + cli_tester.configure_known_account_remove(account_name=ACCOUNT_TO_REMOVE) -- GitLab From c94af2d9c54e3bea60c858fe37e09194141a77aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 13:23:30 +0100 Subject: [PATCH 029/192] Create encryption wallet before regular wallet --- clive/__private/core/commands/create_wallet.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/clive/__private/core/commands/create_wallet.py b/clive/__private/core/commands/create_wallet.py index 58e13f41bc..47decd57cc 100644 --- a/clive/__private/core/commands/create_wallet.py +++ b/clive/__private/core/commands/create_wallet.py @@ -36,21 +36,23 @@ class CreateWallet(CommandWithResult[CreateWalletResult]): """Will take precedence when `unlock_time` is also set.""" async def _execute(self) -> None: - result = await self.session.create_wallet(name=self.wallet_name, password=self.password) + result = await self.session.create_wallet( + name=EncryptionService.get_encryption_wallet_name(self.wallet_name), password=self.password + ) + if isinstance(result, tuple): - unlocked_user_wallet, password = result + unlocked_encryption_wallet, password = result is_password_generated = True else: - unlocked_user_wallet = result + unlocked_encryption_wallet = result assert self.password is not None, "When no password is generated, it means it was provided." password = self.password is_password_generated = False - unlocked_encryption_wallet = await self.session.create_wallet( - name=EncryptionService.get_encryption_wallet_name(self.wallet_name), password=password - ) await unlocked_encryption_wallet.generate_key() + unlocked_user_wallet = await self.session.create_wallet(name=self.wallet_name, password=password) + self._result = CreateWalletResult( unlocked_user_wallet=unlocked_user_wallet, unlocked_encryption_wallet=unlocked_encryption_wallet, @@ -59,7 +61,6 @@ class CreateWallet(CommandWithResult[CreateWalletResult]): ) if self.app_state: - wallets = WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet) - await self.app_state.unlock(wallets) + await self.app_state.unlock(WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet)) await SetTimeout(session=self.session, time=self.unlock_time, permanent=self.permanent_unlock).execute() -- GitLab From 7bd79bd42d7d8864fefb96a58542ecc2084d1ea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 13:28:36 +0100 Subject: [PATCH 030/192] When Unlock failed because there is no user wallet, create it --- clive/__private/core/commands/unlock.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/clive/__private/core/commands/unlock.py b/clive/__private/core/commands/unlock.py index 407ad111ea..36021efe35 100644 --- a/clive/__private/core/commands/unlock.py +++ b/clive/__private/core/commands/unlock.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING +from beekeepy.exceptions import NoWalletWithSuchNameError + from clive.__private.core.commands.abc.command_secured import CommandPasswordSecured from clive.__private.core.commands.set_timeout import SetTimeout from clive.__private.core.encryption import EncryptionService @@ -30,11 +32,17 @@ class Unlock(CommandPasswordSecured): async def _execute(self) -> None: await SetTimeout(session=self.session, time=self.time, permanent=self.permanent).execute() - user_keys_wallet = await (await self.session.open_wallet(name=self.profile_name)).unlock(password=self.password) - encryption_key_wallet = await ( + unlocked_encryption_wallet = await ( await self.session.open_wallet(name=EncryptionService.get_encryption_wallet_name(self.profile_name)) ).unlock(password=self.password) + try: + user_wallet = await self.session.open_wallet(name=self.profile_name) + except NoWalletWithSuchNameError: + # create the user wallet if it's missing + unlocked_user_wallet = await self.session.create_wallet(name=self.profile_name, password=self.password) + else: + unlocked_user_wallet = await user_wallet.unlock(password=self.password) + if self.app_state is not None: - wallets = WalletContainer(user_keys_wallet, encryption_key_wallet) - await self.app_state.unlock(wallets) + await self.app_state.unlock(WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet)) -- GitLab From 753b9afe3c5c74c4ff04025f01a28a9e8c30f6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 14:53:45 +0100 Subject: [PATCH 031/192] Remove the unused feature of auto password generation during create_wallet beekeeper api call --- clive/__private/core/commands/commands.py | 4 +-- .../__private/core/commands/create_wallet.py | 26 +++---------------- clive/__private/core/world.py | 10 +++---- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 35cd59210b..86dc5662d9 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -109,7 +109,7 @@ class Commands(Generic[WorldT_co]): self, *, name: str | None = None, - password: str | None = None, + password: str, unlock_time: timedelta | None = None, permanent_unlock: bool = True, ) -> CommandWithResultWrapper[CreateWalletResult]: @@ -119,7 +119,7 @@ class Commands(Generic[WorldT_co]): Args: ---- name: Name of the new wallet. If None, the world profile_name will be unlocked. - password: Password later used to unlock the wallet. If None, will be generated by the beekeeper. + password: Password later used to unlock the wallet. unlock_time: The time after which the wallet will be automatically locked. Do not need to pass when unlocking permanently. permanent_unlock: Whether to unlock the wallet permanently. Will take precedence when `unlock_time` is also set. diff --git a/clive/__private/core/commands/create_wallet.py b/clive/__private/core/commands/create_wallet.py index 47decd57cc..bfadb4c343 100644 --- a/clive/__private/core/commands/create_wallet.py +++ b/clive/__private/core/commands/create_wallet.py @@ -20,9 +20,6 @@ if TYPE_CHECKING: class CreateWalletResult: unlocked_user_wallet: AsyncUnlockedWallet unlocked_encryption_wallet: AsyncUnlockedWallet - password: str - is_password_generated: bool - """Will be set if was generated because no password was provided when creating wallet.""" @dataclass(kw_only=True) @@ -30,35 +27,20 @@ class CreateWallet(CommandWithResult[CreateWalletResult]): app_state: AppState | None = None session: AsyncSession wallet_name: str - password: str | None + password: str unlock_time: timedelta | None = None permanent_unlock: bool = True """Will take precedence when `unlock_time` is also set.""" async def _execute(self) -> None: - result = await self.session.create_wallet( + unlocked_encryption_wallet = await self.session.create_wallet( name=EncryptionService.get_encryption_wallet_name(self.wallet_name), password=self.password ) - - if isinstance(result, tuple): - unlocked_encryption_wallet, password = result - is_password_generated = True - else: - unlocked_encryption_wallet = result - assert self.password is not None, "When no password is generated, it means it was provided." - password = self.password - is_password_generated = False - await unlocked_encryption_wallet.generate_key() - unlocked_user_wallet = await self.session.create_wallet(name=self.wallet_name, password=password) + unlocked_user_wallet = await self.session.create_wallet(name=self.wallet_name, password=self.password) - self._result = CreateWalletResult( - unlocked_user_wallet=unlocked_user_wallet, - unlocked_encryption_wallet=unlocked_encryption_wallet, - password=password, - is_password_generated=is_password_generated, - ) + self._result = CreateWalletResult(unlocked_user_wallet, unlocked_encryption_wallet) if self.app_state: await self.app_state.unlock(WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet)) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index ff44bc7952..5424112865 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -183,12 +183,12 @@ class World: async def create_new_profile_with_beekeeper_wallet( self, name: str, - password: str | None = None, + password: str, working_account: str | WorkingAccount | None = None, watched_accounts: Iterable[WatchedAccount] | None = None, - ) -> str: + ) -> None: """ - Create a new profile and a. wallet in beekeeper in one-go. + Create a new profile and wallets in beekeeper in one-go. Since beekeeper wallet will be created, profile will be also saved. If beekeeper wallet creation fails, profile will not be saved. @@ -202,10 +202,6 @@ class World: await self.wallets.set_wallets(WalletContainer(result.unlocked_user_wallet, result.unlocked_encryption_wallet)) await self.commands.save_profile() - generated_password = result.password - actual_password = password or generated_password - return actual_password # noqa: RET504 - async def load_profile_based_on_beekepeer(self) -> None: unlocked_user_wallet = (await self.commands.get_unlocked_user_wallet()).result_or_raise unlocked_encryption_wallet = (await self.commands.get_unlocked_encryption_wallet()).result_or_raise -- GitLab From 86e0276d58a848e513f7052bd6a1ed75e97e7076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 14:59:06 +0100 Subject: [PATCH 032/192] Differentiate between CreateProfileWallets, CreateEncryptionWallet, CreateUserWallet --- .../cli/commands/configure/profile.py | 6 ++--- clive/__private/core/commands/commands.py | 14 +++++----- .../core/commands/create_encryption_wallet.py | 26 +++++++++++++++++++ ...te_wallet.py => create_profile_wallets.py} | 22 +++++++++------- .../core/commands/create_user_wallet.py | 21 +++++++++++++++ clive/__private/core/world.py | 2 +- .../create_profile_form_screen.py | 2 +- tests/conftest.py | 6 ++--- tests/unit/profile/test_profile_loading.py | 6 ++--- tests/unit/test_storage_revision.py | 6 ++--- 10 files changed, 80 insertions(+), 31 deletions(-) create mode 100644 clive/__private/core/commands/create_encryption_wallet.py rename clive/__private/core/commands/{create_wallet.py => create_profile_wallets.py} (59%) create mode 100644 clive/__private/core/commands/create_user_wallet.py diff --git a/clive/__private/cli/commands/configure/profile.py b/clive/__private/cli/commands/configure/profile.py index df99ea2f97..043eaa26ca 100644 --- a/clive/__private/cli/commands/configure/profile.py +++ b/clive/__private/cli/commands/configure/profile.py @@ -12,7 +12,7 @@ from clive.__private.cli.exceptions import ( CLIInvalidPasswordRepeatError, CLIPrettyError, ) -from clive.__private.core.commands.create_wallet import CreateWallet +from clive.__private.core.commands.create_profile_wallets import CreateProfileWallets from clive.__private.core.commands.save_profile import SaveProfile from clive.__private.core.formatters.humanize import humanize_validation_result from clive.__private.core.profile import Profile @@ -42,8 +42,8 @@ class CreateProfile(BeekeeperBasedCommand): profile = Profile.create(self.profile_name, self.working_account_name) try: - result = await CreateWallet( - session=await self.beekeeper.session, wallet_name=profile.name, password=password + result = await CreateProfileWallets( + session=await self.beekeeper.session, profile_name=profile.name, password=password ).execute_with_result() except CommunicationError as error: if is_in_dev_mode(): diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 86dc5662d9..84bbe43ee4 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -6,7 +6,7 @@ from clive.__private.core.commands.abc.command_with_result import CommandResultT from clive.__private.core.commands.broadcast import Broadcast from clive.__private.core.commands.build_transaction import BuildTransaction from clive.__private.core.commands.command_wrappers import CommandWithResultWrapper, CommandWrapper, NoOpWrapper -from clive.__private.core.commands.create_wallet import CreateWallet, CreateWalletResult +from clive.__private.core.commands.create_profile_wallets import CreateProfileWallets, CreateProfileWalletsResult from clive.__private.core.commands.data_retrieval.chain_data import ChainData, ChainDataRetrieval from clive.__private.core.commands.data_retrieval.find_scheduled_transfers import ( AccountScheduledTransferData, @@ -105,30 +105,30 @@ class Commands(Generic[WorldT_co]): self._world = world self.__exception_handlers = [*(exception_handlers or [])] - async def create_wallet( + async def create_profile_wallets( self, *, - name: str | None = None, + profile_name: str | None = None, password: str, unlock_time: timedelta | None = None, permanent_unlock: bool = True, - ) -> CommandWithResultWrapper[CreateWalletResult]: + ) -> CommandWithResultWrapper[CreateProfileWalletsResult]: """ Create a beekeeper wallet. Args: ---- - name: Name of the new wallet. If None, the world profile_name will be unlocked. + profile_name: Name of the new wallets. If None, the world profile_name will be used. password: Password later used to unlock the wallet. unlock_time: The time after which the wallet will be automatically locked. Do not need to pass when unlocking permanently. permanent_unlock: Whether to unlock the wallet permanently. Will take precedence when `unlock_time` is also set. """ return await self.__surround_with_exception_handlers( - CreateWallet( + CreateProfileWallets( app_state=self._world.app_state, session=self._world._session_ensure, - wallet_name=name if name is not None else self._world.profile.name, + profile_name=profile_name if profile_name is not None else self._world.profile.name, password=password, unlock_time=unlock_time, permanent_unlock=permanent_unlock, diff --git a/clive/__private/core/commands/create_encryption_wallet.py b/clive/__private/core/commands/create_encryption_wallet.py new file mode 100644 index 0000000000..dd08f92207 --- /dev/null +++ b/clive/__private/core/commands/create_encryption_wallet.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from beekeepy import AsyncUnlockedWallet + +from clive.__private.core.commands.abc.command_with_result import CommandWithResult +from clive.__private.core.encryption import EncryptionService + +if TYPE_CHECKING: + from beekeepy import AsyncSession + + +@dataclass(kw_only=True) +class CreateEncryptionWallet(CommandWithResult[AsyncUnlockedWallet]): + session: AsyncSession + profile_name: str + password: str + + async def _execute(self) -> None: + unlocked_encryption_wallet = await self.session.create_wallet( + name=EncryptionService.get_encryption_wallet_name(self.profile_name), password=self.password + ) + await unlocked_encryption_wallet.generate_key() + self._result = unlocked_encryption_wallet diff --git a/clive/__private/core/commands/create_wallet.py b/clive/__private/core/commands/create_profile_wallets.py similarity index 59% rename from clive/__private/core/commands/create_wallet.py rename to clive/__private/core/commands/create_profile_wallets.py index bfadb4c343..659693395f 100644 --- a/clive/__private/core/commands/create_wallet.py +++ b/clive/__private/core/commands/create_profile_wallets.py @@ -4,8 +4,9 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from clive.__private.core.commands.abc.command_with_result import CommandWithResult +from clive.__private.core.commands.create_encryption_wallet import CreateEncryptionWallet +from clive.__private.core.commands.create_user_wallet import CreateUserWallet from clive.__private.core.commands.set_timeout import SetTimeout -from clive.__private.core.encryption import EncryptionService from clive.__private.core.wallet_container import WalletContainer if TYPE_CHECKING: @@ -17,30 +18,31 @@ if TYPE_CHECKING: @dataclass -class CreateWalletResult: +class CreateProfileWalletsResult: unlocked_user_wallet: AsyncUnlockedWallet unlocked_encryption_wallet: AsyncUnlockedWallet @dataclass(kw_only=True) -class CreateWallet(CommandWithResult[CreateWalletResult]): +class CreateProfileWallets(CommandWithResult[CreateProfileWalletsResult]): app_state: AppState | None = None session: AsyncSession - wallet_name: str + profile_name: str password: str unlock_time: timedelta | None = None permanent_unlock: bool = True """Will take precedence when `unlock_time` is also set.""" async def _execute(self) -> None: - unlocked_encryption_wallet = await self.session.create_wallet( - name=EncryptionService.get_encryption_wallet_name(self.wallet_name), password=self.password - ) - await unlocked_encryption_wallet.generate_key() + unlocked_encryption_wallet = await CreateEncryptionWallet( + session=self.session, profile_name=self.profile_name, password=self.password + ).execute_with_result() - unlocked_user_wallet = await self.session.create_wallet(name=self.wallet_name, password=self.password) + unlocked_user_wallet = await CreateUserWallet( + session=self.session, profile_name=self.profile_name, password=self.password + ).execute_with_result() - self._result = CreateWalletResult(unlocked_user_wallet, unlocked_encryption_wallet) + self._result = CreateProfileWalletsResult(unlocked_user_wallet, unlocked_encryption_wallet) if self.app_state: await self.app_state.unlock(WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet)) diff --git a/clive/__private/core/commands/create_user_wallet.py b/clive/__private/core/commands/create_user_wallet.py new file mode 100644 index 0000000000..0568b1626e --- /dev/null +++ b/clive/__private/core/commands/create_user_wallet.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from beekeepy import AsyncUnlockedWallet + +from clive.__private.core.commands.abc.command_with_result import CommandWithResult + +if TYPE_CHECKING: + from beekeepy import AsyncSession + + +@dataclass(kw_only=True) +class CreateUserWallet(CommandWithResult[AsyncUnlockedWallet]): + session: AsyncSession + profile_name: str + password: str + + async def _execute(self) -> None: + self._result = await self.session.create_wallet(name=self.profile_name, password=self.password) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 5424112865..487f69985a 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -195,7 +195,7 @@ class World: """ await self.create_new_profile(name, working_account, watched_accounts) - create_wallet_wrapper = await self.commands.create_wallet(password=password) + create_wallet_wrapper = await self.commands.create_profile_wallets(password=password) result = create_wallet_wrapper.result_or_raise if create_wallet_wrapper.error_occurred: self.profile.delete() diff --git a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py index efca795f1a..a454e16503 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py @@ -72,7 +72,7 @@ class CreateProfileFormScreen(BaseScreen, FormScreen[CreateProfileContext]): profile.name = profile_name async def create_wallet() -> None: - await self.world.commands.create_wallet(name=profile_name, password=password) + await self.world.commands.create_profile_wallets(profile_name=profile_name, password=password) async def sync_data() -> None: await self.world.commands.sync_data_with_beekeeper(profile=profile) diff --git a/tests/conftest.py b/tests/conftest.py index ef7587c7b4..7aad89080f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,7 @@ from clive.__private.before_launch import ( ) from clive.__private.core import iwax from clive.__private.core._thread import thread_pool -from clive.__private.core.commands.create_wallet import CreateWallet +from clive.__private.core.commands.create_profile_wallets import CreateProfileWallets from clive.__private.core.commands.import_key import ImportKey from clive.__private.core.constants.setting_identifiers import DATA_PATH, LOG_LEVEL_1ST_PARTY, LOG_LEVELS, LOG_PATH from clive.__private.core.world import World @@ -175,10 +175,10 @@ def setup_wallets(world: World) -> SetupWalletsFactory: for i in range(count) ] for wallet in wallets: - await CreateWallet( + await CreateProfileWallets( app_state=world.app_state, session=world._session_ensure, - wallet_name=wallet.name, + profile_name=wallet.name, password=wallet.password, ).execute() diff --git a/tests/unit/profile/test_profile_loading.py b/tests/unit/profile/test_profile_loading.py index 44b1b9ff23..3a9028aa6c 100644 --- a/tests/unit/profile/test_profile_loading.py +++ b/tests/unit/profile/test_profile_loading.py @@ -6,7 +6,7 @@ import pytest from beekeepy import AsyncBeekeeper from clive.__private.cli.exceptions import CLINoProfileUnlockedError -from clive.__private.core.commands.create_wallet import CreateWallet +from clive.__private.core.commands.create_profile_wallets import CreateProfileWallets from clive.__private.core.commands.get_unlocked_user_wallet import GetUnlockedUserWallet from clive.__private.core.commands.save_profile import SaveProfile from clive.__private.core.profile import Profile @@ -24,8 +24,8 @@ async def beekeeper() -> AsyncIterator[AsyncBeekeeper]: async def create_profile_and_wallet(beekeeper: AsyncBeekeeper, profile_name: str, *, lock: bool = False) -> None: - result = await CreateWallet( - session=await beekeeper.session, wallet_name=profile_name, password=profile_name + result = await CreateProfileWallets( + session=await beekeeper.session, profile_name=profile_name, password=profile_name ).execute_with_result() profile = Profile.create(profile_name) await SaveProfile( diff --git a/tests/unit/test_storage_revision.py b/tests/unit/test_storage_revision.py index 8bf8935099..a128f3573d 100644 --- a/tests/unit/test_storage_revision.py +++ b/tests/unit/test_storage_revision.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Final import test_tools as tt from beekeepy import AsyncBeekeeper -from clive.__private.core.commands.create_wallet import CreateWallet +from clive.__private.core.commands.create_profile_wallets import CreateProfileWallets from clive.__private.core.commands.save_profile import SaveProfile from clive.__private.core.profile import Profile from clive.__private.settings import safe_settings @@ -21,8 +21,8 @@ FIRST_PROFILE_NAME: Final[str] = "first" async def create_and_save_profile(profile_name: str) -> None: async with await AsyncBeekeeper.factory(settings=safe_settings.beekeeper.settings_local_factory()) as beekeeper: - result = await CreateWallet( - session=await beekeeper.session, wallet_name=profile_name, password=profile_name + result = await CreateProfileWallets( + session=await beekeeper.session, profile_name=profile_name, password=profile_name ).execute_with_result() profile = Profile.create(profile_name) await SaveProfile( -- GitLab From 9a644caa43e0c0760d19293b8dcb69e944b8dbaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 14:51:18 +0100 Subject: [PATCH 033/192] Handle the situation either encryption wallet or regular wallet can be missing --- .../core/commands/create_encryption_wallet.py | 5 +- clive/__private/core/commands/unlock.py | 53 ++++++++++++++----- .../general_error_notificator.py | 2 + clive/__private/core/iwax.py | 9 ++++ 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/clive/__private/core/commands/create_encryption_wallet.py b/clive/__private/core/commands/create_encryption_wallet.py index dd08f92207..93eef3dd8f 100644 --- a/clive/__private/core/commands/create_encryption_wallet.py +++ b/clive/__private/core/commands/create_encryption_wallet.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING from beekeepy import AsyncUnlockedWallet +from clive.__private.core import iwax from clive.__private.core.commands.abc.command_with_result import CommandWithResult from clive.__private.core.encryption import EncryptionService @@ -22,5 +23,7 @@ class CreateEncryptionWallet(CommandWithResult[AsyncUnlockedWallet]): unlocked_encryption_wallet = await self.session.create_wallet( name=EncryptionService.get_encryption_wallet_name(self.profile_name), password=self.password ) - await unlocked_encryption_wallet.generate_key() + await unlocked_encryption_wallet.import_key( + private_key=iwax.generate_password_based_private_key(self.password, account_name=self.profile_name).value + ) self._result = unlocked_encryption_wallet diff --git a/clive/__private/core/commands/unlock.py b/clive/__private/core/commands/unlock.py index 36021efe35..c9cd9d9fe8 100644 --- a/clive/__private/core/commands/unlock.py +++ b/clive/__private/core/commands/unlock.py @@ -1,11 +1,14 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from beekeepy.exceptions import NoWalletWithSuchNameError +from clive.__private.core.commands.abc.command import Command, CommandError from clive.__private.core.commands.abc.command_secured import CommandPasswordSecured +from clive.__private.core.commands.create_encryption_wallet import CreateEncryptionWallet +from clive.__private.core.commands.create_user_wallet import CreateUserWallet from clive.__private.core.commands.set_timeout import SetTimeout from clive.__private.core.encryption import EncryptionService from clive.__private.core.wallet_container import WalletContainer @@ -13,11 +16,18 @@ from clive.__private.core.wallet_container import WalletContainer if TYPE_CHECKING: from datetime import timedelta - from beekeepy import AsyncSession + from beekeepy import AsyncSession, AsyncUnlockedWallet from clive.__private.core.app_state import AppState +class CannotRecoverWalletsDuringUnlockError(CommandError): + MESSAGE: Final[str] = "Looks like beekeeper wallets no longer exist and we cannot do the recovery process." + + def __init__(self, command: Command) -> None: + super().__init__(command, self.MESSAGE) + + @dataclass(kw_only=True) class Unlock(CommandPasswordSecured): """Unlock the profile-related wallets (user keys and encryption key) managed by the beekeeper.""" @@ -32,17 +42,34 @@ class Unlock(CommandPasswordSecured): async def _execute(self) -> None: await SetTimeout(session=self.session, time=self.time, permanent=self.permanent).execute() - unlocked_encryption_wallet = await ( - await self.session.open_wallet(name=EncryptionService.get_encryption_wallet_name(self.profile_name)) - ).unlock(password=self.password) + encryption_wallet = await self._unlock_wallet(EncryptionService.get_encryption_wallet_name(self.profile_name)) + user_wallet = await self._unlock_wallet(self.profile_name) - try: - user_wallet = await self.session.open_wallet(name=self.profile_name) - except NoWalletWithSuchNameError: - # create the user wallet if it's missing - unlocked_user_wallet = await self.session.create_wallet(name=self.profile_name, password=self.password) - else: - unlocked_user_wallet = await user_wallet.unlock(password=self.password) + if not encryption_wallet and not user_wallet: + # we should not recreate both wallets during the unlock process + # because when both wallets are deleted, we don't know what the previous password was + # so this could lead to a situation profile password will be changed when wallets are deleted + raise CannotRecoverWalletsDuringUnlockError(self) + + if not encryption_wallet: + encryption_wallet = await CreateEncryptionWallet( + session=self.session, profile_name=self.profile_name, password=self.password + ).execute_with_result() + + if not user_wallet: + user_wallet = await CreateUserWallet( + session=self.session, profile_name=self.profile_name, password=self.password + ).execute_with_result() + + assert user_wallet is not None, "User wallet should be created at this point" + assert encryption_wallet is not None, "Encryption wallet should be created at this point" if self.app_state is not None: - await self.app_state.unlock(WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet)) + await self.app_state.unlock(WalletContainer(user_wallet, encryption_wallet)) + + async def _unlock_wallet(self, name: str) -> AsyncUnlockedWallet | None: + try: + wallet = await self.session.open_wallet(name=name) + except NoWalletWithSuchNameError: + return None + return await wallet.unlock(password=self.password) diff --git a/clive/__private/core/error_handlers/general_error_notificator.py b/clive/__private/core/error_handlers/general_error_notificator.py index 0240e2c61b..871a62f218 100644 --- a/clive/__private/core/error_handlers/general_error_notificator.py +++ b/clive/__private/core/error_handlers/general_error_notificator.py @@ -4,6 +4,7 @@ from typing import Final, TypeGuard from beekeepy.exceptions import InvalidPasswordError, NoWalletWithSuchNameError +from clive.__private.core.commands.unlock import CannotRecoverWalletsDuringUnlockError from clive.__private.core.error_handlers.abc.error_notificator import ErrorNotificator from clive.__private.storage.service import ProfileEncryptionError @@ -15,6 +16,7 @@ class GeneralErrorNotificator(ErrorNotificator[Exception]): InvalidPasswordError: "The password you entered is incorrect. Please try again.", NoWalletWithSuchNameError: "Wallet with this name was not found on the beekeeper. Please try again.", ProfileEncryptionError: "Profile encryption failed which means profile cannot be saved or loaded.", + CannotRecoverWalletsDuringUnlockError: CannotRecoverWalletsDuringUnlockError.MESSAGE, } def __init__(self) -> None: diff --git a/clive/__private/core/iwax.py b/clive/__private/core/iwax.py index 9c725a48d0..2aaa87f44a 100644 --- a/clive/__private/core/iwax.py +++ b/clive/__private/core/iwax.py @@ -217,3 +217,12 @@ def calculate_witness_votes_hp(votes: int, data: TotalVestingProtocol) -> Asset. total_vesting_shares=to_python_json_asset(data.total_vesting_shares), ) return cast("Asset.Hive", from_python_json_asset(result)) + + +def generate_password_based_private_key( + password: str, role: str = "memo", account_name: str = "anything" +) -> PrivateKey: + from clive.__private.core.keys import PrivateKey + + result = wax.generate_password_based_private_key(account_name.encode(), role.encode(), password.encode()) + return PrivateKey(value=result.wif_private_key.decode()) -- GitLab From 999b78d2929820ebde5cbc55f87933427376ee99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 15:33:23 +0100 Subject: [PATCH 034/192] Rename create_new_profile_with_beekeeper_wallet -> create_new_profile_with_wallets --- clive/__private/core/world.py | 2 +- testnet_node.py | 2 +- tests/conftest.py | 2 +- tests/functional/cli/conftest.py | 2 +- tests/functional/cli/test_locking.py | 4 +--- tests/tui/conftest.py | 2 +- 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 487f69985a..e13a2e3a59 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -180,7 +180,7 @@ class World: profile = Profile.create(name, working_account, watched_accounts) await self.switch_profile(profile) - async def create_new_profile_with_beekeeper_wallet( + async def create_new_profile_with_wallets( self, name: str, password: str, diff --git a/testnet_node.py b/testnet_node.py index 893bc710ac..226851c6f8 100644 --- a/testnet_node.py +++ b/testnet_node.py @@ -85,7 +85,7 @@ async def _create_profile_with_wallet( ) -> None: async with World() as world_cm: password = profile_name * 2 - await world_cm.create_new_profile_with_beekeeper_wallet(profile_name, password, working_account_name) + await world_cm.create_new_profile_with_wallets(profile_name, password, working_account_name) tt.logger.info(f"password for profile `{profile_name}` is: `{password}`") world_cm.profile.keys.add_to_import(PrivateKeyAliased(value=private_key, alias=key_alias)) await world_cm.commands.sync_data_with_beekeeper() diff --git a/tests/conftest.py b/tests/conftest.py index 7aad89080f..5fb0c04c4a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -125,7 +125,7 @@ async def world() -> AsyncIterator[World]: @pytest.fixture async def prepare_profile_with_wallet(world: World, wallet_name: str, wallet_password: str) -> Profile: - await world.create_new_profile_with_beekeeper_wallet(wallet_name, wallet_password) + await world.create_new_profile_with_wallets(wallet_name, wallet_password) return world.profile diff --git a/tests/functional/cli/conftest.py b/tests/functional/cli/conftest.py index bd7c52d41e..b2142bceff 100644 --- a/tests/functional/cli/conftest.py +++ b/tests/functional/cli/conftest.py @@ -58,7 +58,7 @@ async def world_cli(beekeeper_local: AsyncBeekeeper) -> AsyncGenerator[World]: @pytest.fixture async def _prepare_profile_with_wallet_cli(world_cli: World) -> Profile: """Prepare profile and wallets using remote beekeeper.""" - await world_cli.create_new_profile_with_beekeeper_wallet( + await world_cli.create_new_profile_with_wallets( name=WORKING_ACCOUNT_DATA.account.name, password=WORKING_ACCOUNT_PASSWORD, working_account=WorkingAccount(name=WORKING_ACCOUNT_DATA.account.name), diff --git a/tests/functional/cli/test_locking.py b/tests/functional/cli/test_locking.py index 7f3fa67a69..cbec6023e5 100644 --- a/tests/functional/cli/test_locking.py +++ b/tests/functional/cli/test_locking.py @@ -31,9 +31,7 @@ if TYPE_CHECKING: @pytest.fixture async def cli_tester_locked_with_second_profile(cli_tester_locked: CLITester) -> CLITester: async with World() as world_cm: - await world_cm.create_new_profile_with_beekeeper_wallet( - ALT_WORKING_ACCOUNT1_NAME, ALT_WORKING_ACCOUNT1_PASSWORD - ) + await world_cm.create_new_profile_with_wallets(ALT_WORKING_ACCOUNT1_NAME, ALT_WORKING_ACCOUNT1_PASSWORD) world_cm.profile.keys.add_to_import( PrivateKeyAliased( value=ALT_WORKING_ACCOUNT1_DATA.account.private_key, alias=f"{ALT_WORKING_ACCOUNT1_KEY_ALIAS}" diff --git a/tests/tui/conftest.py b/tests/tui/conftest.py index d8d2ae6f71..483b394429 100644 --- a/tests/tui/conftest.py +++ b/tests/tui/conftest.py @@ -49,7 +49,7 @@ def logger_configuration_factory() -> Callable[[], None]: async def _prepare_profile_with_wallet_tui() -> None: """Prepare profile and wallets using locally spawned beekeeper.""" async with World() as world_cm: - await world_cm.create_new_profile_with_beekeeper_wallet( + await world_cm.create_new_profile_with_wallets( name=WORKING_ACCOUNT_DATA.account.name, password=WORKING_ACCOUNT_PASSWORD, working_account=WorkingAccount(name=WORKING_ACCOUNT_DATA.account.name), -- GitLab From e49cec4d3f7d2ab7740af37eac5303e6c6c9ab67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 15:44:25 +0100 Subject: [PATCH 035/192] Improve naming --- clive/__private/core/commands/commands.py | 4 ++-- .../ui/forms/create_profile/create_profile_form_screen.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 84bbe43ee4..b8c28d0117 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -114,11 +114,11 @@ class Commands(Generic[WorldT_co]): permanent_unlock: bool = True, ) -> CommandWithResultWrapper[CreateProfileWalletsResult]: """ - Create a beekeeper wallet. + Create a profile-related beekeeper wallets. Args: ---- - profile_name: Name of the new wallets. If None, the world profile_name will be used. + profile_name: Names of the new wallets will be based on that. If None, the world profile_name will be used. password: Password later used to unlock the wallet. unlock_time: The time after which the wallet will be automatically locked. Do not need to pass when unlocking permanently. diff --git a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py index a454e16503..3fa6968691 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py @@ -71,13 +71,13 @@ class CreateProfileFormScreen(BaseScreen, FormScreen[CreateProfileContext]): profile = self.context.profile profile.name = profile_name - async def create_wallet() -> None: + async def create_wallets() -> None: await self.world.commands.create_profile_wallets(profile_name=profile_name, password=password) async def sync_data() -> None: await self.world.commands.sync_data_with_beekeeper(profile=profile) - self._owner.add_post_action(create_wallet, sync_data) + self._owner.add_post_action(create_wallets, sync_data) def _revalidate_repeat_password_input_when_password_changed(self) -> None: if not self._repeat_password_input.is_empty: -- GitLab From 186245b555f5601ce05db974b1b8ae97f50e5730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 16:31:21 +0100 Subject: [PATCH 036/192] Improve cleanup on World.close --- clive/__private/core/world.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index e13a2e3a59..e685d8adee 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -167,6 +167,11 @@ class World: if self._beekeeper is not None: self._beekeeper.teardown() + self.app_state.lock() + self._beekeeper_settings = self._setup_beekeepy_settings() + + self._profile = None + self._node = None self._beekeeper = None self._session = None self._wallets = None -- GitLab From 9f88afdbf8b7eaee7223841eb6af67781d807e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 16:32:02 +0100 Subject: [PATCH 037/192] No need for set_wallets in create_new_profile_with_wallets as they are already set via CreateProfileWallets core command --- clive/__private/core/world.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index e685d8adee..f9502e0574 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -201,10 +201,8 @@ class World: await self.create_new_profile(name, working_account, watched_accounts) create_wallet_wrapper = await self.commands.create_profile_wallets(password=password) - result = create_wallet_wrapper.result_or_raise if create_wallet_wrapper.error_occurred: self.profile.delete() - await self.wallets.set_wallets(WalletContainer(result.unlocked_user_wallet, result.unlocked_encryption_wallet)) await self.commands.save_profile() async def load_profile_based_on_beekepeer(self) -> None: -- GitLab From e328e1b9a2d06e4542ff7afa8f05edbfaee3a06b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 15:46:10 +0100 Subject: [PATCH 038/192] Adjust test for non existing wallet --- tests/functional/commands/test_locking.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/functional/commands/test_locking.py b/tests/functional/commands/test_locking.py index 610f272009..798c551f63 100644 --- a/tests/functional/commands/test_locking.py +++ b/tests/functional/commands/test_locking.py @@ -5,10 +5,9 @@ from datetime import timedelta from typing import TYPE_CHECKING, Final import pytest -from beekeepy.exceptions import NoWalletWithSuchNameError from clive.__private.core.commands.is_wallet_unlocked import IsWalletUnlocked -from clive.__private.core.commands.unlock import Unlock +from clive.__private.core.commands.unlock import CannotRecoverWalletsDuringUnlockError, Unlock if TYPE_CHECKING: import clive @@ -30,9 +29,9 @@ async def test_unlock( assert world.app_state.is_unlocked -async def test_unlock_non_existing_wallet(world: clive.World, prepare_profile_with_wallet: Profile) -> None: # noqa: ARG001 +async def test_unlock_non_existing_wallets(world: clive.World, prepare_profile_with_wallet: Profile) -> None: # noqa: ARG001 # ACT & ASSERT - with pytest.raises(NoWalletWithSuchNameError): + with pytest.raises(CannotRecoverWalletsDuringUnlockError): await Unlock( app_state=world.app_state, session=world._session_ensure, -- GitLab From b7fe6ff0f91a04e2e405162acf67149fa6fdeabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 16:30:19 +0100 Subject: [PATCH 039/192] Add test for user_wallet and encryption_wallet recovery during unlock --- tests/functional/commands/test_locking.py | 56 ++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/functional/commands/test_locking.py b/tests/functional/commands/test_locking.py index 798c551f63..ddad2ae078 100644 --- a/tests/functional/commands/test_locking.py +++ b/tests/functional/commands/test_locking.py @@ -2,12 +2,13 @@ from __future__ import annotations import asyncio from datetime import timedelta -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, Literal import pytest from clive.__private.core.commands.is_wallet_unlocked import IsWalletUnlocked from clive.__private.core.commands.unlock import CannotRecoverWalletsDuringUnlockError, Unlock +from clive.__private.core.encryption import EncryptionService if TYPE_CHECKING: import clive @@ -40,6 +41,59 @@ async def test_unlock_non_existing_wallets(world: clive.World, prepare_profile_w ).execute() +@pytest.mark.parametrize("wallet_type", ["user_wallet", "encryption_wallet"]) +async def test_unlock_recovers_missing_wallet( + world: clive.World, + prepare_profile_with_wallet: Profile, + wallet_password: str, + wallet_type: Literal["user_wallet", "encryption_wallet"], +) -> None: + # ARRANGE + profile = prepare_profile_with_wallet + + encryption_wallet = (await world.commands.get_unlocked_encryption_wallet()).result_or_raise + encryption_keys_before = await encryption_wallet.public_keys + + beekeeper_working_directory = world.beekeeper.settings.working_directory + assert beekeeper_working_directory is not None, "Beekeeper working directory should be set" + + wallet_filenames = { + "user_wallet": f"{profile.name}.wallet", + "encryption_wallet": f"{EncryptionService.get_encryption_wallet_name(profile.name)}.wallet", + } + + wallet_filename = wallet_filenames[wallet_type] + + wallet_filepath = beekeeper_working_directory / wallet_filename + assert wallet_filepath.is_file(), "Wallet file should exist" + + # remove wallet + wallet_filepath.unlink() + assert not wallet_filepath.exists(), "Wallet file should not exist" + + # restart beekeeper so wallets are loaded again because beekeeper is caching them + # and recovery process is not triggered + await world.close() + await world.setup() + + # ACT + # unlock takes place during load_profile + await world.load_profile(profile.name, wallet_password) + + # ASSERT + assert world.app_state.is_unlocked, "Wallet should be unlocked" + assert wallet_filepath.is_file(), "Wallet file should be recovered" + + if wallet_type == "user_wallet": + user_wallet = (await world.commands.get_unlocked_user_wallet()).result_or_raise + public_keys = await user_wallet.public_keys + assert public_keys == [], "User wallet should be recovered with no keys" + else: + encryption_wallet = (await world.commands.get_unlocked_encryption_wallet()).result_or_raise + public_keys = await encryption_wallet.public_keys + assert public_keys == encryption_keys_before, "Encryption wallet should be recovered with the same keys" + + async def test_lock(world: clive.World, prepare_profile_with_wallet: Profile) -> None: # noqa: ARG001 # ARRANGE & ACT assert world.app_state.is_unlocked -- GitLab From b11df219de8918430bb34ee072c8523bcdeaf008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 16:57:12 +0100 Subject: [PATCH 040/192] Fix issue with switching TUI mode when app is closing --- clive/__private/core/world.py | 40 ++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index f9502e0574..0f3e29ee14 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -60,6 +60,7 @@ class World: self._node: Node | None = None self._is_during_setup = False + self._is_during_closure = False async def __aenter__(self) -> Self: return await self.setup() @@ -152,6 +153,14 @@ class World: finally: self._is_during_setup = False + @asynccontextmanager + async def during_closure(self) -> AsyncGenerator[None]: + self._is_during_closure = True + try: + yield + finally: + self._is_during_closure = False + async def setup(self) -> Self: async with self.during_setup(): self._beekeeper = await self._setup_beekeeper() @@ -160,21 +169,22 @@ class World: return self async def close(self) -> None: - if self._should_save_profile_on_close: - await self.commands.save_profile() - if self._node is not None: - self._node.teardown() - if self._beekeeper is not None: - self._beekeeper.teardown() + async with self.during_closure(): + if self._should_save_profile_on_close: + await self.commands.save_profile() + if self._node is not None: + self._node.teardown() + if self._beekeeper is not None: + self._beekeeper.teardown() - self.app_state.lock() - self._beekeeper_settings = self._setup_beekeepy_settings() + self.app_state.lock() + self._beekeeper_settings = self._setup_beekeepy_settings() - self._profile = None - self._node = None - self._beekeeper = None - self._session = None - self._wallets = None + self._profile = None + self._node = None + self._beekeeper = None + self._session = None + self._wallets = None async def create_new_profile( self, @@ -227,13 +237,13 @@ class World: def on_going_into_locked_mode(self, source: LockSource) -> None: """Triggered when the application is going into the locked mode.""" - if self._is_during_setup: + if self._is_during_setup or self._is_during_closure: return self._on_going_into_locked_mode(source) def on_going_into_unlocked_mode(self) -> None: """Triggered when the application is going into the unlocked mode.""" - if self._is_during_setup: + if self._is_during_setup or self._is_during_closure: return self._on_going_into_unlocked_mode() -- GitLab From fc3a06ee7eb8b70b2156deec2f077b52c34d1d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Feb 2025 17:04:08 +0100 Subject: [PATCH 041/192] Make during_setup during_closure internal and move --- clive/__private/core/world.py | 42 +++++++++++++++++------------------ 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 0f3e29ee14..36919d8957 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -142,34 +142,15 @@ class World: def _should_save_profile_on_close(self) -> bool: return self._profile is not None - @asynccontextmanager - async def during_setup(self) -> AsyncGenerator[None]: - self._is_during_setup = True - try: - yield - except Exception: - await self.close() - raise - finally: - self._is_during_setup = False - - @asynccontextmanager - async def during_closure(self) -> AsyncGenerator[None]: - self._is_during_closure = True - try: - yield - finally: - self._is_during_closure = False - async def setup(self) -> Self: - async with self.during_setup(): + async with self._during_setup(): self._beekeeper = await self._setup_beekeeper() self._session = await self.beekeeper.session self._wallets = WalletManager(self._session) return self async def close(self) -> None: - async with self.during_closure(): + async with self._during_closure(): if self._should_save_profile_on_close: await self.commands.save_profile() if self._node is not None: @@ -253,6 +234,25 @@ class World: def _on_going_into_unlocked_mode(self) -> None: """Override this method to hook when clive goes into the unlocked mode.""" + @asynccontextmanager + async def _during_setup(self) -> AsyncGenerator[None]: + self._is_during_setup = True + try: + yield + except Exception: + await self.close() + raise + finally: + self._is_during_setup = False + + @asynccontextmanager + async def _during_closure(self) -> AsyncGenerator[None]: + self._is_during_closure = True + try: + yield + finally: + self._is_during_closure = False + def _setup_commands(self) -> Commands[World]: return Commands(self) -- GitLab From 34ba5e2f9c1c94c0de61d26e4841a6e195dbdf02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 25 Feb 2025 12:00:44 +0100 Subject: [PATCH 042/192] Display notification in TUI/CLI when user wallet was recovered because it wont have keys --- clive/__private/cli/commands/unlock.py | 15 ++++++-- clive/__private/cli/notify.py | 14 ++++++++ clive/__private/core/commands/commands.py | 35 +++++++++++++++++-- clive/__private/core/commands/unlock.py | 11 ++++-- .../core/constants/wallet_recovery.py | 11 ++++++ clive/__private/core/types.py | 5 +++ 6 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 clive/__private/cli/notify.py create mode 100644 clive/__private/core/constants/wallet_recovery.py create mode 100644 clive/__private/core/types.py diff --git a/clive/__private/cli/commands/unlock.py b/clive/__private/cli/commands/unlock.py index 169aba3fb5..4f3ce012ed 100644 --- a/clive/__private/cli/commands/unlock.py +++ b/clive/__private/cli/commands/unlock.py @@ -14,8 +14,14 @@ from clive.__private.cli.exceptions import ( CLIPrettyError, CLIProfileDoesNotExistsError, ) +from clive.__private.cli.notify import notify from clive.__private.core.commands.unlock import Unlock as CoreUnlockCommand +from clive.__private.core.commands.unlock import WalletRecoveryStatus from clive.__private.core.constants.cli import UNLOCK_CREATE_PROFILE_HELP, UNLOCK_CREATE_PROFILE_SELECT +from clive.__private.core.constants.wallet_recovery import ( + USER_WALLET_RECOVERED_MESSAGE, + USER_WALLET_RECOVERED_NOTIFICATION_LEVEL, +) from clive.__private.core.profile import Profile PASSWORD_SELECTION_ATTEMPTS: Final[int] = 3 @@ -56,15 +62,16 @@ class Unlock(BeekeeperBasedCommand): async def _unlock_profile(self, profile_name: str, password: str) -> None: try: - await CoreUnlockCommand( + result = await CoreUnlockCommand( profile_name=profile_name, password=password, session=await self.beekeeper.session, - ).execute() + ).execute_with_result() except InvalidPasswordError as error: raise CLIInvalidPasswordError(profile_name) from error except NoWalletWithSuchNameError as error: raise CLIPrettyError("Wallet with this name no longer exist on the beekeeper.", errno.ENOENT) from error + self._display_wallet_recovery_status(result) def _prompt_for_profile_name(self) -> str | None: options = self._generate_profile_options() @@ -137,3 +144,7 @@ class Unlock(BeekeeperBasedCommand): def _display_create_profile_help_info(self) -> None: typer.echo(UNLOCK_CREATE_PROFILE_HELP) + + def _display_wallet_recovery_status(self, status: WalletRecoveryStatus) -> None: + if status == "user_wallet_recovered": + notify(USER_WALLET_RECOVERED_MESSAGE, level=USER_WALLET_RECOVERED_NOTIFICATION_LEVEL) diff --git a/clive/__private/cli/notify.py b/clive/__private/cli/notify.py new file mode 100644 index 0000000000..526a260234 --- /dev/null +++ b/clive/__private/cli/notify.py @@ -0,0 +1,14 @@ +from rich.console import Console + +from clive.__private.cli.styling import colorize_error, colorize_warning +from clive.__private.core.types import NotifyLevel + + +def notify(message: str, *, level: NotifyLevel = "info") -> None: + if level == "warning": + message = colorize_warning(message) + elif level == "error": + message = colorize_error(message) + + console = Console() + console.print(message) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index b8c28d0117..7cdaa62d42 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -50,11 +50,15 @@ from clive.__private.core.commands.set_timeout import SetTimeout from clive.__private.core.commands.sign import ALREADY_SIGNED_MODE_DEFAULT, AlreadySignedMode, Sign from clive.__private.core.commands.sync_data_with_beekeeper import SyncDataWithBeekeeper from clive.__private.core.commands.sync_state_with_beekeeper import SyncStateWithBeekeeper -from clive.__private.core.commands.unlock import Unlock +from clive.__private.core.commands.unlock import Unlock, WalletRecoveryStatus from clive.__private.core.commands.unsign import UnSign from clive.__private.core.commands.update_transaction_metadata import ( UpdateTransactionMetadata, ) +from clive.__private.core.constants.wallet_recovery import ( + USER_WALLET_RECOVERED_MESSAGE, + USER_WALLET_RECOVERED_NOTIFICATION_LEVEL, +) from clive.__private.core.error_handlers.abc.error_handler_context_manager import ( ResultNotAvailable, ) @@ -68,6 +72,7 @@ if TYPE_CHECKING: from pathlib import Path from beekeepy import AsyncUnlockedWallet, AsyncWallet + from textual.notifications import SeverityLevel from clive.__private.core.accounts.accounts import TrackedAccount from clive.__private.core.app_state import LockSource @@ -78,6 +83,7 @@ if TYPE_CHECKING: ) from clive.__private.core.keys import PrivateKeyAliased, PublicKey, PublicKeyAliased from clive.__private.core.profile import Profile + from clive.__private.core.types import NotifyLevel from clive.__private.core.world import CLIWorld, TUIWorld, World from clive.__private.models import Transaction from clive.__private.models.schemas import ( @@ -105,6 +111,9 @@ class Commands(Generic[WorldT_co]): self._world = world self.__exception_handlers = [*(exception_handlers or [])] + def _notify(self, message: str, *, level: NotifyLevel = "info") -> None: + """Send a notification message to the user.""" + async def create_profile_wallets( self, *, @@ -160,7 +169,7 @@ class Commands(Generic[WorldT_co]): async def unlock( self, *, profile_name: str | None = None, password: str, time: timedelta | None = None, permanent: bool = True - ) -> CommandWrapper: + ) -> CommandWithResultWrapper[WalletRecoveryStatus]: """ Return a CommandWrapper instance to unlock the profile-related wallets (user keys and encryption key). @@ -172,7 +181,7 @@ class Commands(Generic[WorldT_co]): permanently. permanent: Whether to unlock the wallet permanently. Will take precedence when `time` is also set. """ - return await self.__surround_with_exception_handlers( + wrapper = await self.__surround_with_exception_handlers( Unlock( password=password, app_state=self._world.app_state, @@ -183,6 +192,12 @@ class Commands(Generic[WorldT_co]): ) ) + if wrapper.success: + result = wrapper.result_or_raise + if result == "user_wallet_recovered": + self._notify(USER_WALLET_RECOVERED_MESSAGE, level=USER_WALLET_RECOVERED_NOTIFICATION_LEVEL) + return wrapper + async def lock(self) -> CommandWrapper: """Lock all the wallets in the given beekeeper session.""" return await self.__surround_with_exception_handlers( @@ -589,9 +604,23 @@ class CLICommands(Commands["CLIWorld"]): def __init__(self, world: CLIWorld) -> None: super().__init__(world, exception_handlers=[CommunicationFailureNotificator, GeneralErrorNotificator]) + def _notify(self, message: str, *, level: NotifyLevel = "info") -> None: + from clive.__private.cli.notify import notify + + notify(message, level=level) + class TUICommands(Commands["TUIWorld"], CliveDOMNode): def __init__(self, world: TUIWorld) -> None: super().__init__( world, exception_handlers=[TUIErrorHandler, CommunicationFailureNotificator, GeneralErrorNotificator] ) + + def _notify(self, message: str, *, level: NotifyLevel = "info") -> None: + clive_to_textual_notification_level: dict[NotifyLevel, SeverityLevel] = { + "info": "information", + "warning": "warning", + "error": "warning", + } + assert level in clive_to_textual_notification_level, f"Unknown level: {level}" + self.app.notify(message, severity=clive_to_textual_notification_level[level]) diff --git a/clive/__private/core/commands/unlock.py b/clive/__private/core/commands/unlock.py index c9cd9d9fe8..d26070b48c 100644 --- a/clive/__private/core/commands/unlock.py +++ b/clive/__private/core/commands/unlock.py @@ -1,12 +1,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Final, Literal from beekeepy.exceptions import NoWalletWithSuchNameError from clive.__private.core.commands.abc.command import Command, CommandError from clive.__private.core.commands.abc.command_secured import CommandPasswordSecured +from clive.__private.core.commands.abc.command_with_result import CommandWithResult from clive.__private.core.commands.create_encryption_wallet import CreateEncryptionWallet from clive.__private.core.commands.create_user_wallet import CreateUserWallet from clive.__private.core.commands.set_timeout import SetTimeout @@ -20,6 +21,8 @@ if TYPE_CHECKING: from clive.__private.core.app_state import AppState +WalletRecoveryStatus = Literal["nothing_to_recover", "encryption_wallet_recovered", "user_wallet_recovered"] + class CannotRecoverWalletsDuringUnlockError(CommandError): MESSAGE: Final[str] = "Looks like beekeeper wallets no longer exist and we cannot do the recovery process." @@ -29,7 +32,7 @@ class CannotRecoverWalletsDuringUnlockError(CommandError): @dataclass(kw_only=True) -class Unlock(CommandPasswordSecured): +class Unlock(CommandPasswordSecured, CommandWithResult[WalletRecoveryStatus]): """Unlock the profile-related wallets (user keys and encryption key) managed by the beekeeper.""" profile_name: str @@ -51,15 +54,19 @@ class Unlock(CommandPasswordSecured): # so this could lead to a situation profile password will be changed when wallets are deleted raise CannotRecoverWalletsDuringUnlockError(self) + self._result = "nothing_to_recover" + if not encryption_wallet: encryption_wallet = await CreateEncryptionWallet( session=self.session, profile_name=self.profile_name, password=self.password ).execute_with_result() + self._result = "encryption_wallet_recovered" if not user_wallet: user_wallet = await CreateUserWallet( session=self.session, profile_name=self.profile_name, password=self.password ).execute_with_result() + self._result = "user_wallet_recovered" assert user_wallet is not None, "User wallet should be created at this point" assert encryption_wallet is not None, "Encryption wallet should be created at this point" diff --git a/clive/__private/core/constants/wallet_recovery.py b/clive/__private/core/constants/wallet_recovery.py new file mode 100644 index 0000000000..695d3dc1b1 --- /dev/null +++ b/clive/__private/core/constants/wallet_recovery.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from clive.__private.core.types import NotifyLevel + +USER_WALLET_RECOVERED_NOTIFICATION_LEVEL: Final[NotifyLevel] = "warning" +USER_WALLET_RECOVERED_MESSAGE: Final[str] = ( + "Detected missing wallet. It was recovered, but keys can't be recovered. Please reimport them." +) diff --git a/clive/__private/core/types.py b/clive/__private/core/types.py new file mode 100644 index 0000000000..bd777121fe --- /dev/null +++ b/clive/__private/core/types.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Literal + +NotifyLevel = Literal["info", "warning", "error"] -- GitLab From 78b2e84d512b91afd07e8c864edb357c0eff9324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 25 Feb 2025 12:24:38 +0100 Subject: [PATCH 043/192] Create a dedicated RecoverWallets core command --- clive/__private/cli/commands/unlock.py | 4 +- clive/__private/core/commands/commands.py | 5 +- .../core/commands/recover_wallets.py | 80 +++++++++++++++++++ clive/__private/core/commands/unlock.py | 54 +++++-------- .../general_error_notificator.py | 4 +- tests/functional/commands/test_locking.py | 5 +- 6 files changed, 110 insertions(+), 42 deletions(-) create mode 100644 clive/__private/core/commands/recover_wallets.py diff --git a/clive/__private/cli/commands/unlock.py b/clive/__private/cli/commands/unlock.py index 4f3ce012ed..1e0256f8ff 100644 --- a/clive/__private/cli/commands/unlock.py +++ b/clive/__private/cli/commands/unlock.py @@ -15,8 +15,8 @@ from clive.__private.cli.exceptions import ( CLIProfileDoesNotExistsError, ) from clive.__private.cli.notify import notify +from clive.__private.core.commands.recover_wallets import RecoverWalletsStatus from clive.__private.core.commands.unlock import Unlock as CoreUnlockCommand -from clive.__private.core.commands.unlock import WalletRecoveryStatus from clive.__private.core.constants.cli import UNLOCK_CREATE_PROFILE_HELP, UNLOCK_CREATE_PROFILE_SELECT from clive.__private.core.constants.wallet_recovery import ( USER_WALLET_RECOVERED_MESSAGE, @@ -145,6 +145,6 @@ class Unlock(BeekeeperBasedCommand): def _display_create_profile_help_info(self) -> None: typer.echo(UNLOCK_CREATE_PROFILE_HELP) - def _display_wallet_recovery_status(self, status: WalletRecoveryStatus) -> None: + def _display_wallet_recovery_status(self, status: RecoverWalletsStatus) -> None: if status == "user_wallet_recovered": notify(USER_WALLET_RECOVERED_MESSAGE, level=USER_WALLET_RECOVERED_NOTIFICATION_LEVEL) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 7cdaa62d42..9597d46cbd 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -50,7 +50,7 @@ from clive.__private.core.commands.set_timeout import SetTimeout from clive.__private.core.commands.sign import ALREADY_SIGNED_MODE_DEFAULT, AlreadySignedMode, Sign from clive.__private.core.commands.sync_data_with_beekeeper import SyncDataWithBeekeeper from clive.__private.core.commands.sync_state_with_beekeeper import SyncStateWithBeekeeper -from clive.__private.core.commands.unlock import Unlock, WalletRecoveryStatus +from clive.__private.core.commands.unlock import Unlock from clive.__private.core.commands.unsign import UnSign from clive.__private.core.commands.update_transaction_metadata import ( UpdateTransactionMetadata, @@ -77,6 +77,7 @@ if TYPE_CHECKING: from clive.__private.core.accounts.accounts import TrackedAccount from clive.__private.core.app_state import LockSource from clive.__private.core.commands.abc.command import Command + from clive.__private.core.commands.recover_wallets import RecoverWalletsStatus from clive.__private.core.ensure_transaction import TransactionConvertibleType from clive.__private.core.error_handlers.abc.error_handler_context_manager import ( AnyErrorHandlerContextManager, @@ -169,7 +170,7 @@ class Commands(Generic[WorldT_co]): async def unlock( self, *, profile_name: str | None = None, password: str, time: timedelta | None = None, permanent: bool = True - ) -> CommandWithResultWrapper[WalletRecoveryStatus]: + ) -> CommandWithResultWrapper[RecoverWalletsStatus]: """ Return a CommandWrapper instance to unlock the profile-related wallets (user keys and encryption key). diff --git a/clive/__private/core/commands/recover_wallets.py b/clive/__private/core/commands/recover_wallets.py new file mode 100644 index 0000000000..74de7c82d2 --- /dev/null +++ b/clive/__private/core/commands/recover_wallets.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, Literal + +from clive.__private.core.commands.abc.command import Command, CommandError +from clive.__private.core.commands.abc.command_secured import CommandPasswordSecured +from clive.__private.core.commands.abc.command_with_result import CommandWithResult +from clive.__private.core.commands.create_encryption_wallet import CreateEncryptionWallet +from clive.__private.core.commands.create_user_wallet import CreateUserWallet + +if TYPE_CHECKING: + from beekeepy import AsyncSession, AsyncUnlockedWallet + +RecoverWalletsStatus = Literal["nothing_recovered", "encryption_wallet_recovered", "user_wallet_recovered"] + + +class CannotRecoverWalletsError(CommandError): + MESSAGE: Final[str] = "Looks like beekeeper wallets no longer exist and we cannot do the recovery process." + + def __init__(self, command: Command) -> None: + super().__init__(command, self.MESSAGE) + + +@dataclass +class RecoverWalletsResult: + status: RecoverWalletsStatus = "nothing_recovered" + recovered_wallet: AsyncUnlockedWallet | None = None + + +@dataclass(kw_only=True) +class RecoverWallets(CommandPasswordSecured, CommandWithResult[RecoverWalletsResult]): + """ + Recover (if possible) the profile-related wallets (user wallet and encryption wallet) managed by the beekeeper. + + Note that only one wallet can be recovered. If both wallets are missing, the recovery process will fail. + """ + + profile_name: str + session: AsyncSession + should_recover_encryption_wallet: bool = False + should_recover_user_wallet: bool = False + + async def _execute(self) -> None: + self._validate_recovery_attempt() + status, wallet = await self._recover_wallet() + self._result = RecoverWalletsResult(status, wallet) + + def _validate_recovery_attempt(self) -> None: + """ + Ensure that both wallets are not recovered simultaneously. + + We should not recreate both wallets during the unlock process because when both wallets are deleted, + we don't know what the previous password was so this could lead to a situation profile password will be changed + when wallets are deleted. + """ + if self.should_recover_encryption_wallet and self.should_recover_user_wallet: + raise CannotRecoverWalletsError(self) + + async def _recover_wallet(self) -> tuple[RecoverWalletsStatus, AsyncUnlockedWallet | None]: + """Attempt to recover a wallet.""" + if self.should_recover_encryption_wallet: + return await self._recover_encryption_wallet() + if self.should_recover_user_wallet: + return await self._recover_user_wallet() + return "nothing_recovered", None + + async def _recover_encryption_wallet(self) -> tuple[RecoverWalletsStatus, AsyncUnlockedWallet]: + """Handle encryption wallet recovery.""" + wallet = await CreateEncryptionWallet( + session=self.session, profile_name=self.profile_name, password=self.password + ).execute_with_result() + return "encryption_wallet_recovered", wallet + + async def _recover_user_wallet(self) -> tuple[RecoverWalletsStatus, AsyncUnlockedWallet]: + """Handle user wallet recovery.""" + wallet = await CreateUserWallet( + session=self.session, profile_name=self.profile_name, password=self.password + ).execute_with_result() + return "user_wallet_recovered", wallet diff --git a/clive/__private/core/commands/unlock.py b/clive/__private/core/commands/unlock.py index d26070b48c..443108b0aa 100644 --- a/clive/__private/core/commands/unlock.py +++ b/clive/__private/core/commands/unlock.py @@ -1,15 +1,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Final, Literal +from typing import TYPE_CHECKING from beekeepy.exceptions import NoWalletWithSuchNameError -from clive.__private.core.commands.abc.command import Command, CommandError from clive.__private.core.commands.abc.command_secured import CommandPasswordSecured from clive.__private.core.commands.abc.command_with_result import CommandWithResult -from clive.__private.core.commands.create_encryption_wallet import CreateEncryptionWallet -from clive.__private.core.commands.create_user_wallet import CreateUserWallet +from clive.__private.core.commands.recover_wallets import RecoverWallets, RecoverWalletsStatus from clive.__private.core.commands.set_timeout import SetTimeout from clive.__private.core.encryption import EncryptionService from clive.__private.core.wallet_container import WalletContainer @@ -21,18 +19,9 @@ if TYPE_CHECKING: from clive.__private.core.app_state import AppState -WalletRecoveryStatus = Literal["nothing_to_recover", "encryption_wallet_recovered", "user_wallet_recovered"] - - -class CannotRecoverWalletsDuringUnlockError(CommandError): - MESSAGE: Final[str] = "Looks like beekeeper wallets no longer exist and we cannot do the recovery process." - - def __init__(self, command: Command) -> None: - super().__init__(command, self.MESSAGE) - @dataclass(kw_only=True) -class Unlock(CommandPasswordSecured, CommandWithResult[WalletRecoveryStatus]): +class Unlock(CommandPasswordSecured, CommandWithResult[RecoverWalletsStatus]): """Unlock the profile-related wallets (user keys and encryption key) managed by the beekeeper.""" profile_name: str @@ -47,26 +36,23 @@ class Unlock(CommandPasswordSecured, CommandWithResult[WalletRecoveryStatus]): encryption_wallet = await self._unlock_wallet(EncryptionService.get_encryption_wallet_name(self.profile_name)) user_wallet = await self._unlock_wallet(self.profile_name) - - if not encryption_wallet and not user_wallet: - # we should not recreate both wallets during the unlock process - # because when both wallets are deleted, we don't know what the previous password was - # so this could lead to a situation profile password will be changed when wallets are deleted - raise CannotRecoverWalletsDuringUnlockError(self) - - self._result = "nothing_to_recover" - - if not encryption_wallet: - encryption_wallet = await CreateEncryptionWallet( - session=self.session, profile_name=self.profile_name, password=self.password - ).execute_with_result() - self._result = "encryption_wallet_recovered" - - if not user_wallet: - user_wallet = await CreateUserWallet( - session=self.session, profile_name=self.profile_name, password=self.password - ).execute_with_result() - self._result = "user_wallet_recovered" + is_encryption_wallet_missing = encryption_wallet is None + is_user_wallet_missing = user_wallet is None + + recover_wallets_result = await RecoverWallets( + password=self.password, + profile_name=self.profile_name, + session=self.session, + should_recover_encryption_wallet=is_encryption_wallet_missing, + should_recover_user_wallet=is_user_wallet_missing, + ).execute_with_result() + + self._result = recover_wallets_result.status + + if self._result == "encryption_wallet_recovered": + encryption_wallet = recover_wallets_result.recovered_wallet + elif self._result == "user_wallet_recovered": + user_wallet = recover_wallets_result.recovered_wallet assert user_wallet is not None, "User wallet should be created at this point" assert encryption_wallet is not None, "Encryption wallet should be created at this point" diff --git a/clive/__private/core/error_handlers/general_error_notificator.py b/clive/__private/core/error_handlers/general_error_notificator.py index 871a62f218..f94f07cca4 100644 --- a/clive/__private/core/error_handlers/general_error_notificator.py +++ b/clive/__private/core/error_handlers/general_error_notificator.py @@ -4,7 +4,7 @@ from typing import Final, TypeGuard from beekeepy.exceptions import InvalidPasswordError, NoWalletWithSuchNameError -from clive.__private.core.commands.unlock import CannotRecoverWalletsDuringUnlockError +from clive.__private.core.commands.recover_wallets import CannotRecoverWalletsError from clive.__private.core.error_handlers.abc.error_notificator import ErrorNotificator from clive.__private.storage.service import ProfileEncryptionError @@ -16,7 +16,7 @@ class GeneralErrorNotificator(ErrorNotificator[Exception]): InvalidPasswordError: "The password you entered is incorrect. Please try again.", NoWalletWithSuchNameError: "Wallet with this name was not found on the beekeeper. Please try again.", ProfileEncryptionError: "Profile encryption failed which means profile cannot be saved or loaded.", - CannotRecoverWalletsDuringUnlockError: CannotRecoverWalletsDuringUnlockError.MESSAGE, + CannotRecoverWalletsError: CannotRecoverWalletsError.MESSAGE, } def __init__(self) -> None: diff --git a/tests/functional/commands/test_locking.py b/tests/functional/commands/test_locking.py index ddad2ae078..0af569c215 100644 --- a/tests/functional/commands/test_locking.py +++ b/tests/functional/commands/test_locking.py @@ -7,7 +7,8 @@ from typing import TYPE_CHECKING, Final, Literal import pytest from clive.__private.core.commands.is_wallet_unlocked import IsWalletUnlocked -from clive.__private.core.commands.unlock import CannotRecoverWalletsDuringUnlockError, Unlock +from clive.__private.core.commands.recover_wallets import CannotRecoverWalletsError +from clive.__private.core.commands.unlock import Unlock from clive.__private.core.encryption import EncryptionService if TYPE_CHECKING: @@ -32,7 +33,7 @@ async def test_unlock( async def test_unlock_non_existing_wallets(world: clive.World, prepare_profile_with_wallet: Profile) -> None: # noqa: ARG001 # ACT & ASSERT - with pytest.raises(CannotRecoverWalletsDuringUnlockError): + with pytest.raises(CannotRecoverWalletsError): await Unlock( app_state=world.app_state, session=world._session_ensure, -- GitLab From 10f2321be8f21baa37cc88e50fd23b0da16f7b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 25 Feb 2025 14:33:05 +0100 Subject: [PATCH 044/192] Do not place the entire raw error in notification messages --- .../hive_power_data_provider.py | 2 +- .../data_providers/proposals_data_provider.py | 2 +- .../data_providers/savings_data_provider.py | 2 +- .../data_providers/witnesses_data_provider.py | 2 +- .../ui/dialogs/witness_details_dialog.py | 2 +- .../governance_operations/witness/witness.py | 2 +- .../transaction_summary.py | 42 ++++++++----------- 7 files changed, 24 insertions(+), 30 deletions(-) diff --git a/clive/__private/ui/data_providers/hive_power_data_provider.py b/clive/__private/ui/data_providers/hive_power_data_provider.py index 5f49e3f230..eb555c3eb4 100644 --- a/clive/__private/ui/data_providers/hive_power_data_provider.py +++ b/clive/__private/ui/data_providers/hive_power_data_provider.py @@ -20,7 +20,7 @@ class HivePowerDataProvider(DataProvider[HivePowerData]): wrapper = await self.commands.retrieve_hp_data(account_name=account_name) if wrapper.error_occurred: - self.notify(f"Failed to retrieve hive power data: {wrapper.error}", severity="error") + self.notify("Failed to retrieve hive power data.", severity="error") return result = wrapper.result_or_raise diff --git a/clive/__private/ui/data_providers/proposals_data_provider.py b/clive/__private/ui/data_providers/proposals_data_provider.py index 38f53f1960..4e5ac307d7 100644 --- a/clive/__private/ui/data_providers/proposals_data_provider.py +++ b/clive/__private/ui/data_providers/proposals_data_provider.py @@ -31,7 +31,7 @@ class ProposalsDataProvider(DataProvider[ProposalsData]): ) if wrapper.error_occurred: - self.notify(f"Failed to retrieve proposals data: {wrapper.error}", severity="error") + self.notify("Failed to retrieve proposals data.", severity="error") return result = wrapper.result_or_raise diff --git a/clive/__private/ui/data_providers/savings_data_provider.py b/clive/__private/ui/data_providers/savings_data_provider.py index a98c3dc4a6..a8d8fbfdd4 100644 --- a/clive/__private/ui/data_providers/savings_data_provider.py +++ b/clive/__private/ui/data_providers/savings_data_provider.py @@ -19,7 +19,7 @@ class SavingsDataProvider(DataProvider[SavingsData]): wrapper = await self.commands.retrieve_savings_data(account_name=account_name) if wrapper.error_occurred: - self.notify(f"Failed to retrieve savings data: {wrapper.error}", severity="error") + self.notify("Failed to retrieve savings data.", severity="error") return result = wrapper.result_or_raise diff --git a/clive/__private/ui/data_providers/witnesses_data_provider.py b/clive/__private/ui/data_providers/witnesses_data_provider.py index d8391c5110..c3777f37af 100644 --- a/clive/__private/ui/data_providers/witnesses_data_provider.py +++ b/clive/__private/ui/data_providers/witnesses_data_provider.py @@ -39,7 +39,7 @@ class WitnessesDataProvider(DataProvider[WitnessesData]): ) if wrapper.error_occurred: - self.notify(f"Failed to retrieve witnesses data: {wrapper.error}", severity="error") + self.notify("Failed to retrieve witnesses data.", severity="error") return result = wrapper.result_or_raise diff --git a/clive/__private/ui/dialogs/witness_details_dialog.py b/clive/__private/ui/dialogs/witness_details_dialog.py index 1cf125c806..a7fb5cbdeb 100644 --- a/clive/__private/ui/dialogs/witness_details_dialog.py +++ b/clive/__private/ui/dialogs/witness_details_dialog.py @@ -45,7 +45,7 @@ class WitnessDetailsDialog(CliveInfoDialog, CliveWidget): wrapper = await self.commands.find_witness(witness_name=self._witness_name) if wrapper.error_occurred: - new_witness_data = f"Unable to retrieve witness information:\n{wrapper.error}" + new_witness_data = "Failed to retrieve witness information." else: witness = wrapper.result_or_raise url = witness.url diff --git a/clive/__private/ui/screens/operations/governance_operations/witness/witness.py b/clive/__private/ui/screens/operations/governance_operations/witness/witness.py index a04f659bff..4234dcae11 100644 --- a/clive/__private/ui/screens/operations/governance_operations/witness/witness.py +++ b/clive/__private/ui/screens/operations/governance_operations/witness/witness.py @@ -83,7 +83,7 @@ class WitnessNameLabel(Label, CliveWidget): wrapper = await self.commands.find_witness(witness_name=self.__witness_name) if wrapper.error_occurred: - new_tooltip_text = f"Unable to retrieve witness information:\n{wrapper.error}" + new_tooltip_text = "Failed to retrieve witness information." else: witness = wrapper.result_or_raise created = humanize_datetime(witness.created) diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.py b/clive/__private/ui/screens/transaction_summary/transaction_summary.py index a5764bf67d..0d13e6d1ad 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.py +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.py @@ -238,21 +238,18 @@ class TransactionSummary(BaseScreen): save_as_binary = result.save_as_binary should_be_signed = result.should_be_signed transaction = self.profile.transaction.copy() - try: - transaction = ( - await self.commands.perform_actions_on_transaction( - content=transaction, - sign_key=self._get_key_to_sign() - if should_be_signed and not self.profile.transaction.is_signed - else None, - force_unsign=not should_be_signed, - save_file_path=file_path, - force_save_format="bin" if save_as_binary else "json", - ) - ).result_or_raise - except Exception as error: # noqa: BLE001 - self.notify(f"Transaction save failed. Reason: {error}", severity="error") + wrapper = await self.commands.perform_actions_on_transaction( + content=transaction, + sign_key=self._get_key_to_sign() if should_be_signed and not self.profile.transaction.is_signed else None, + force_unsign=not should_be_signed, + save_file_path=file_path, + force_save_format="bin" if save_as_binary else "json", + ) + + if wrapper.error_occurred: + self.notify("Transaction save failed. Please try again.", severity="error") return + self.profile.transaction.reset() self.profile.transaction_file_path = None self.app.trigger_profile_watchers() @@ -287,16 +284,13 @@ class TransactionSummary(BaseScreen): from clive.__private.ui.screens.dashboard import Dashboard transaction = self.profile.transaction - try: - ( - await self.commands.perform_actions_on_transaction( - content=transaction, - sign_key=self._get_key_to_sign() if not transaction.is_signed else None, - broadcast=True, - ) - ).raise_if_error_occurred() - except Exception as error: # noqa: BLE001 - self.notify(f"Transaction broadcast failed! Reason: {error}", severity="error") + wrapper = await self.commands.perform_actions_on_transaction( + content=transaction, + sign_key=self._get_key_to_sign() if not transaction.is_signed else None, + broadcast=True, + ) + if wrapper.error_occurred: + self.notify("Transaction broadcast failed. Please try again.", severity="error") return self.notify(f"Transaction with ID '{transaction.calculate_transaction_id()}' successfully broadcasted!") -- GitLab From e54da3449a8befd43b4dc5fbbadfffc099840585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 25 Feb 2025 14:34:24 +0100 Subject: [PATCH 045/192] Handle NotExistingKeyError --- .../__private/core/error_handlers/general_error_notificator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/clive/__private/core/error_handlers/general_error_notificator.py b/clive/__private/core/error_handlers/general_error_notificator.py index f94f07cca4..8bce4a4999 100644 --- a/clive/__private/core/error_handlers/general_error_notificator.py +++ b/clive/__private/core/error_handlers/general_error_notificator.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Final, TypeGuard -from beekeepy.exceptions import InvalidPasswordError, NoWalletWithSuchNameError +from beekeepy.exceptions import InvalidPasswordError, NotExistingKeyError, NoWalletWithSuchNameError from clive.__private.core.commands.recover_wallets import CannotRecoverWalletsError from clive.__private.core.error_handlers.abc.error_notificator import ErrorNotificator @@ -15,6 +15,7 @@ class GeneralErrorNotificator(ErrorNotificator[Exception]): SEARCHED_AND_PRINTED_MESSAGES: Final[dict[type[Exception], str]] = { InvalidPasswordError: "The password you entered is incorrect. Please try again.", NoWalletWithSuchNameError: "Wallet with this name was not found on the beekeeper. Please try again.", + NotExistingKeyError: "Key does not exist in the wallet.", ProfileEncryptionError: "Profile encryption failed which means profile cannot be saved or loaded.", CannotRecoverWalletsError: CannotRecoverWalletsError.MESSAGE, } -- GitLab From 29ba6a7b9d35de9ce1cb7468e5a60e40f7575a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 25 Feb 2025 15:20:13 +0100 Subject: [PATCH 046/192] Use correct log level for errors happening during background jobs --- clive/__private/ui/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 698922561f..eafc4e4ca5 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -270,7 +270,7 @@ class Clive(App[int]): accounts = self.world.profile.accounts.tracked wrapper = await self.world.commands.update_alarms_data(accounts=accounts) if wrapper.error_occurred: - logger.warning(f"Update alarms data failed: {wrapper.error}") + logger.error(f"Update alarms data failed: {wrapper.error}") return self.trigger_profile_watchers() @@ -286,7 +286,7 @@ class Clive(App[int]): # notify watchers when node goes offline self.trigger_node_watchers() - logger.warning(f"Update node data failed: {wrapper.error}") + logger.error(f"Update node data failed: {wrapper.error}") return self.trigger_profile_watchers() -- GitLab From 8e432cd251cdd343cf690bfe7f36b15ac106cd3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 25 Feb 2025 15:38:43 +0100 Subject: [PATCH 047/192] Log errors occured on command execution --- clive/__private/core/commands/abc/command.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/clive/__private/core/commands/abc/command.py b/clive/__private/core/commands/abc/command.py index 2ecbb5ab19..08d3d3f669 100644 --- a/clive/__private/core/commands/abc/command.py +++ b/clive/__private/core/commands/abc/command.py @@ -38,7 +38,11 @@ class Command(ABC): self._log_execution_skipped() return self._log_execution_info() - await self._execute() + try: + await self._execute() + except Exception as error: + self._log_execution_error(error) + raise @staticmethod async def execute_multiple(*commands: Command) -> None: @@ -49,3 +53,6 @@ class Command(ABC): def _log_execution_skipped(self) -> None: logger.debug(f"Skipping execution of command: {self.__class__.__name__}") + + def _log_execution_error(self, error: Exception) -> None: + logger.debug(f"Error occurred during {self.__class__.__name__} command execution. Error:\n{error!s}") -- GitLab From 99a59c8d5633eedf80110da8b6b735be61d0e739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 25 Feb 2025 15:43:37 +0100 Subject: [PATCH 048/192] Log TUI notifications --- clive/__private/ui/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index eafc4e4ca5..804692049d 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -109,6 +109,7 @@ class Clive(App[int]): ) -> None: title = title if title else severity.capitalize() timeout = math.inf if timeout is None and severity == "error" else timeout + logger.info(f"Sending notification: {severity=}, {title=}, {message=}") return super().notify(message, title=title, severity=severity, timeout=timeout) def is_notification_present(self, message: str) -> bool: -- GitLab From 9057a9ee89fe7674c6686368f1ffb6708728ccaa Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Wed, 26 Feb 2025 12:52:19 +0100 Subject: [PATCH 049/192] Fix positioning inside container that holds broadcast/save/load buttons --- .../ui/screens/transaction_summary/transaction_summary.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.scss b/clive/__private/ui/screens/transaction_summary/transaction_summary.scss index ebfed5ed99..41e47fbb59 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.scss +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.scss @@ -59,6 +59,7 @@ TransactionSummary { } #actions-container { + align-horizontal: right; margin: 1 0; height: auto; -- GitLab From 7c77e3547656125aeba11539d2f199f403087204 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 27 Feb 2025 08:54:46 +0100 Subject: [PATCH 050/192] Don't propagate sigint to beekeeper on ctrl+c We are already properly handling signals on close --- clive/__private/settings/_safe_settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/clive/__private/settings/_safe_settings.py b/clive/__private/settings/_safe_settings.py index af2751ba34..68fc0308a3 100644 --- a/clive/__private/settings/_safe_settings.py +++ b/clive/__private/settings/_safe_settings.py @@ -242,6 +242,7 @@ class SafeSettings: beekeepy_settings.period_between_retries = timedelta(seconds=self.communication_retries_delay_secs) beekeepy_settings.initialization_timeout = timedelta(seconds=self.initialization_timeout) beekeepy_settings.close_timeout = timedelta(seconds=self.close_timeout) + beekeepy_settings.propagate_sigint = False return beekeepy_settings -- GitLab From 82bdacd8393e88ed123077b71be03c8bf3e25c46 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 27 Feb 2025 10:11:41 +0100 Subject: [PATCH 051/192] Prevent ctrl-c killing prepared testnet node if script is not in foreground --- docker/entrypoint.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1d80d37828..c77acf3a9c 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -122,7 +122,8 @@ run_testnet() { exec python3 testnet_node.py -p -t else echo "Launching clive in CLI mode on testnet" - python3 testnet_node.py -p >${TESTNET_NODE_LOG_FILE} 2>&1 & + # We use setsid to detach from the process group because ctrl-c will kill the testnet_node.py + setsid python3 testnet_node.py -p >${TESTNET_NODE_LOG_FILE} 2>&1 & wait_for_testnet launch_cli fi -- GitLab From e5903d5a8a54ea098d92b4b665f54c7a7a48dcda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 27 Feb 2025 12:46:40 +0100 Subject: [PATCH 052/192] Fix testnet_node.py SIGINT handling --- testnet_node.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/testnet_node.py b/testnet_node.py index 226851c6f8..1ce597b2a7 100644 --- a/testnet_node.py +++ b/testnet_node.py @@ -129,11 +129,15 @@ def launch_tui() -> None: def serve_forever() -> None: tt.logger.info("Serving forever... press Ctrl+C to exit") - while True: - time.sleep(1) + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + tt.logger.info("Exiting...") + return -async def prepare(*, recreate_profiles: bool) -> None: +async def prepare(*, recreate_profiles: bool) -> tt.RawNode: prepare_before_launch(enable_textual_logger=False, enable_stream_handlers=True) node = prepare_node() print_working_account_keys() @@ -143,19 +147,23 @@ async def prepare(*, recreate_profiles: bool) -> None: prepare_before_launch(enable_textual_logger=False, enable_stream_handlers=True) await prepare_profiles(node) + return node + def main() -> None: args = init_argparse(sys.argv[1:]) should_prepare_profiles = args.prepare_profiles should_launch_tui = args.tui - asyncio.run(prepare(recreate_profiles=should_prepare_profiles)) + node = asyncio.run(prepare(recreate_profiles=should_prepare_profiles)) if should_launch_tui: launch_tui() else: serve_forever() + node.close() + if __name__ == "__main__": main() -- GitLab From f0974af4a376e5651eb35b9905da6ad65b4dded6 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 6 Feb 2025 16:22:47 +0100 Subject: [PATCH 053/192] Allow to set custom unlock time in script starting docker image --- docker/entrypoint.sh | 9 ++++++++- scripts/activate_beekeeper.sh | 6 +++++- scripts/templates/start_clive_cli.sh.template | 17 ++++++++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c77acf3a9c..079888c1f5 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -5,6 +5,7 @@ set -euo pipefail SCRIPTPATH="$(cd -- "$(dirname "$0")" >/dev/null 2>&1 && pwd -P)" SELECTED_PROFILE="" +UNLOCK_TIME_MINS="" TESTNET_NODE_LOG_FILE="testnet_node.log" INTERACTIVE_CLI_MODE=0 @@ -15,7 +16,8 @@ print_help() { echo "An entrypoint script for the Clive Docker container." echo "OPTIONS:" echo " --cli Launch Clive in the interactive CLI mode (default is TUI)" - echo " --profile-name NAME Name of profile that will be used." + echo " --profile-name NAME Name of profile that will be used, default is profile selection." + echo " --unlock-time MINUTES Unlock time in minutes, default is no timeout for unlock." echo " --exec FILE Path to bash script to be executed." echo " -h, --help Display this help screen and exit" echo @@ -76,6 +78,11 @@ parse_arguments() { SELECTED_PROFILE="$1" export SELECTED_PROFILE ;; + --unlock-time) + shift + UNLOCK_TIME_MINS="$1" + export UNLOCK_TIME_MINS + ;; -h|--help) print_help exit 0 diff --git a/scripts/activate_beekeeper.sh b/scripts/activate_beekeeper.sh index aa57006bfc..1a0b97ac02 100755 --- a/scripts/activate_beekeeper.sh +++ b/scripts/activate_beekeeper.sh @@ -25,11 +25,15 @@ start_beekeeper_with_prepared_session_token() { # Unlock wallet for selected profile unlock_wallet() { local profile_name_arg="" + local unlock_time_mins_arg="" if [[ -n "$SELECTED_PROFILE" ]]; then profile_name_arg="--profile-name=${SELECTED_PROFILE}" fi + if [[ -n "$UNLOCK_TIME_MINS" ]]; then + unlock_time_mins_arg="--unlock-time=${UNLOCK_TIME_MINS}" + fi # shellcheck disable=SC2086 - clive unlock $profile_name_arg --include-create-new-profile + clive unlock $profile_name_arg $unlock_time_mins_arg --include-create-new-profile return $? } diff --git a/scripts/templates/start_clive_cli.sh.template b/scripts/templates/start_clive_cli.sh.template index 0a1041cd35..39139d3918 100644 --- a/scripts/templates/start_clive_cli.sh.template +++ b/scripts/templates/start_clive_cli.sh.template @@ -15,7 +15,8 @@ print_help() { echo " --data-dir=DIRECTORY_PATH Points to a Clive data directory to store profile data. Defaults to ${HOME}/.clive directory." echo " --docker-option=OPTION Allows specifying additional Docker options to pass to underlying Docker run." echo " --exec=PATH_TO_FILE Path to bash script to be executed." - echo " --profile-name=PROFILE_NAME Name of profile that will be used." + echo " --profile-name=PROFILE_NAME Name of profile that will be used, default is profile selection." + echo " --unlock-time=MINUTES Unlock time in minutes, default is no timeout for unlock." echo " --help Display this help screen and exit." echo } @@ -51,6 +52,13 @@ handle_profile_name_option() { add_clive_arg "${profile_name}" } +# Helper function for --unlock-time argument +handle_unlock_time_option() { + local unlock_time_mins="$1" + add_clive_arg "--unlock-time" + add_clive_arg "${unlock_time_mins}" +} + # Override parse_args to handle --exec flag specifically for CLI mode parse_args() { while [ $# -gt 0 ]; do @@ -69,6 +77,13 @@ parse_args() { --profile-name=*) handle_profile_name_option "${1#*=}" ;; + --unlock-time) + shift + handle_unlock_time_option "${1}" + ;; + --unlock-time=*) + handle_unlock_time_option "${1#*=}" + ;; *) new_args+=("$1") ;; -- GitLab From c37264e9915d8af706a89ce7a50d5a0430de2d7a Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 6 Feb 2025 16:47:16 +0100 Subject: [PATCH 054/192] Allow to set custom unlock time for cli command unlock --- clive/__private/cli/commands/unlock.py | 15 +++++++++++++++ clive/__private/cli/main.py | 10 +++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/clive/__private/cli/commands/unlock.py b/clive/__private/cli/commands/unlock.py index 1e0256f8ff..fed00c77fe 100644 --- a/clive/__private/cli/commands/unlock.py +++ b/clive/__private/cli/commands/unlock.py @@ -1,6 +1,7 @@ import errno import sys from dataclasses import dataclass +from datetime import timedelta from getpass import getpass from typing import Final @@ -32,8 +33,20 @@ ProfileSelectionOptions = dict[int, str] @dataclass(kw_only=True) class Unlock(BeekeeperBasedCommand): profile_name: str | None + unlock_time_mins: int | None = None + """None means permanent unlock.""" include_create_new_profile: bool + @property + def _duration(self) -> timedelta | None: + if self.unlock_time_mins is None: + return None + return timedelta(minutes=self.unlock_time_mins) + + @property + def _is_unlock_permanent(self) -> bool: + return self.unlock_time_mins is None + async def validate(self) -> None: self._validate_profile_exists() await super().validate() @@ -66,6 +79,8 @@ class Unlock(BeekeeperBasedCommand): profile_name=profile_name, password=password, session=await self.beekeeper.session, + time=self._duration, + permanent=self._is_unlock_permanent, ).execute_with_result() except InvalidPasswordError as error: raise CLIInvalidPasswordError(profile_name) from error diff --git a/clive/__private/cli/main.py b/clive/__private/cli/main.py index 2db6ad73e6..35a7d75fc6 100644 --- a/clive/__private/cli/main.py +++ b/clive/__private/cli/main.py @@ -56,18 +56,26 @@ async def unlock( ctx: typer.Context, # noqa: ARG001 profile_name: Optional[str] = _profile_name_unlock_argument, profile_name_option: Optional[str] = argument_related_options.profile_name, + unlock_time_mins: Optional[int] = typer.Option( + None, "--unlock-time", help="Time to unlock the profile in minutes, default is no timeout.", show_default=False + ), include_create_new_profile: bool = typer.Option( # noqa: FBT001 default=False, hidden=True, ), ) -> None: - """Unlocks the selected profile.""" + """ + Unlocks the selected profile. + + By default unlock is permanent and has no timeout. + """ from clive.__private.cli.commands.unlock import Unlock common = BeekeeperOptionsGroup.get_instance() await Unlock( **common.as_dict(), profile_name=EnsureSingleProfileNameValue().of(profile_name, profile_name_option, allow_none=True), + unlock_time_mins=unlock_time_mins, include_create_new_profile=include_create_new_profile, ).run() -- GitLab From 60a4a0312c2d2305f1afb1523ea4ed6eb50ccbed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Wed, 26 Feb 2025 12:11:34 +0100 Subject: [PATCH 055/192] Improve CLITestCommandError message by including traceback --- .../clive_local_tools/cli/exceptions.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/clive-local-tools/clive_local_tools/cli/exceptions.py b/tests/clive-local-tools/clive_local_tools/cli/exceptions.py index 334a1d6b1a..19f634b159 100644 --- a/tests/clive-local-tools/clive_local_tools/cli/exceptions.py +++ b/tests/clive-local-tools/clive_local_tools/cli/exceptions.py @@ -1,16 +1,22 @@ from __future__ import annotations +import traceback from typing import TYPE_CHECKING, TypeAlias, get_args if TYPE_CHECKING: + from types import TracebackType + from click.testing import Result class CLITestCommandError(AssertionError): def __init__(self, command: list[str], exit_code: int, stdout: str, result: Result) -> None: - message = f"command {command} failed because of {exit_code=}. Output:\n{stdout}" + message = f"Command {command} failed because of {exit_code=}.\n\nOutput:\n{stdout}" if result.exception: - message += f"\nException occurred:\n{result.exception!r}" + assert result.exc_info is not None, "exc_info should be set when exception is set." + tb: TracebackType = result.exc_info[2] + tb_formatted = "".join(traceback.format_tb(tb)) + message += f"\nException:\n{result.exception!r}\n\nTraceback:\n{tb_formatted}" super().__init__(message) self.command = command self.exit_code = exit_code -- GitLab From 8b3ced95e106ae8e88006f7de74d776c0eb0c0f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Wed, 26 Feb 2025 12:15:53 +0100 Subject: [PATCH 056/192] Log CLITestCommandError --- tests/clive-local-tools/clive_local_tools/cli/exceptions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/clive-local-tools/clive_local_tools/cli/exceptions.py b/tests/clive-local-tools/clive_local_tools/cli/exceptions.py index 19f634b159..078c74d4af 100644 --- a/tests/clive-local-tools/clive_local_tools/cli/exceptions.py +++ b/tests/clive-local-tools/clive_local_tools/cli/exceptions.py @@ -3,6 +3,8 @@ from __future__ import annotations import traceback from typing import TYPE_CHECKING, TypeAlias, get_args +import test_tools as tt + if TYPE_CHECKING: from types import TracebackType @@ -17,7 +19,10 @@ class CLITestCommandError(AssertionError): tb: TracebackType = result.exc_info[2] tb_formatted = "".join(traceback.format_tb(tb)) message += f"\nException:\n{result.exception!r}\n\nTraceback:\n{tb_formatted}" + + tt.logger.error(message) super().__init__(message) + self.command = command self.exit_code = exit_code self.stdout = stdout -- GitLab From b91adf8109d1a7f124dea1f02b07abbd907732f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 09:04:23 +0100 Subject: [PATCH 057/192] Fix crash NoItemSelectedError during transaction broadcast --- .../transaction_summary/transaction_summary.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.py b/clive/__private/ui/screens/transaction_summary/transaction_summary.py index 0d13e6d1ad..860c5e522c 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.py +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.py @@ -238,9 +238,16 @@ class TransactionSummary(BaseScreen): save_as_binary = result.save_as_binary should_be_signed = result.should_be_signed transaction = self.profile.transaction.copy() + + try: + sign_key = self._get_key_to_sign() if should_be_signed and not transaction.is_signed else None + except NoItemSelectedError: + self.notify("Transaction can't be saved because no key was selected.", severity="error") + return + wrapper = await self.commands.perform_actions_on_transaction( content=transaction, - sign_key=self._get_key_to_sign() if should_be_signed and not self.profile.transaction.is_signed else None, + sign_key=sign_key, force_unsign=not should_be_signed, save_file_path=file_path, force_save_format="bin" if save_as_binary else "json", @@ -284,9 +291,16 @@ class TransactionSummary(BaseScreen): from clive.__private.ui.screens.dashboard import Dashboard transaction = self.profile.transaction + + try: + sign_key = self._get_key_to_sign() if not transaction.is_signed else None + except NoItemSelectedError: + self.notify("Transaction can't be broadcasted because no key was selected.", severity="error") + return + wrapper = await self.commands.perform_actions_on_transaction( content=transaction, - sign_key=self._get_key_to_sign() if not transaction.is_signed else None, + sign_key=sign_key, broadcast=True, ) if wrapper.error_occurred: -- GitLab From 39107673cefd4605997c430dae11a8e437546a9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:26:41 +0100 Subject: [PATCH 058/192] Bump textual to 0.84.0 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index c5c6ac980c..30da24c4c7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.83.0" +version = "0.84.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.83.0-py3-none-any.whl", hash = "sha256:d6efc1e5c54086fd0a4fe274f18b5638ca24a69325c07e1b4400a7d0a1a14c55"}, - {file = "textual-0.83.0.tar.gz", hash = "sha256:fc3b97796092d9c7e685e5392f38f3eb2007ffe1b3b1384dee6d3f10d256babd"}, + {file = "textual-0.84.0-py3-none-any.whl", hash = "sha256:1457d2cb66ba4ea46812355f31adbb4b693424a94e69d052e4affe1dc410ec96"}, + {file = "textual-0.84.0.tar.gz", hash = "sha256:fb89717960fea7a539823fa264252f7be1c84844e4b8d27360e6d4edb36846a8"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "d9c20dc5ee1c331ca99670d23eaa85193ef77c09ea2ed35081be489970910074" +content-hash = "df19d213826653607851bb217349cd3dd23b660c9f0de879ced744b4be0998ff" diff --git a/pyproject.toml b/pyproject.toml index beddbd7edc..a2bb8db9e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.83.0" +textual = "0.84.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 656d224602a77d1788409a4c998235911e10c333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:02 +0100 Subject: [PATCH 059/192] Bump textual to 0.85.0 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 30da24c4c7..2b74f1f14e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.84.0" +version = "0.85.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.84.0-py3-none-any.whl", hash = "sha256:1457d2cb66ba4ea46812355f31adbb4b693424a94e69d052e4affe1dc410ec96"}, - {file = "textual-0.84.0.tar.gz", hash = "sha256:fb89717960fea7a539823fa264252f7be1c84844e4b8d27360e6d4edb36846a8"}, + {file = "textual-0.85.0-py3-none-any.whl", hash = "sha256:8e75d023f06b242fb88233926dfb7801792f867643493096dd45dd216dc950f3"}, + {file = "textual-0.85.0.tar.gz", hash = "sha256:645c0fd0b4f61cd19383df78a1acd4f3b555e2c514cfa2f454e20692dffc10a0"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "df19d213826653607851bb217349cd3dd23b660c9f0de879ced744b4be0998ff" +content-hash = "764297f3633b6ec7b6c1c4e9a9ec82850a45be593829597838e7f7a66091995c" diff --git a/pyproject.toml b/pyproject.toml index a2bb8db9e9..02882b04d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.84.0" +textual = "0.85.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From ec795bd8d42438145d0aa4b797bbfe33c4e4ebdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:06 +0100 Subject: [PATCH 060/192] Bump textual to 0.85.1 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2b74f1f14e..59656f467f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.85.0" +version = "0.85.1" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.85.0-py3-none-any.whl", hash = "sha256:8e75d023f06b242fb88233926dfb7801792f867643493096dd45dd216dc950f3"}, - {file = "textual-0.85.0.tar.gz", hash = "sha256:645c0fd0b4f61cd19383df78a1acd4f3b555e2c514cfa2f454e20692dffc10a0"}, + {file = "textual-0.85.1-py3-none-any.whl", hash = "sha256:a1a064c67b9b81cfa0c1b14298aa52221855aa4a56ad17a9b89a5594c73657a8"}, + {file = "textual-0.85.1.tar.gz", hash = "sha256:9966214390fad9a84c3f69d49398487897577f5fa788838106dd77bd7babc9cd"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "764297f3633b6ec7b6c1c4e9a9ec82850a45be593829597838e7f7a66091995c" +content-hash = "6a6d30ab71779bfdea1a2539d23fad160e1039374b15dcd52e90dd493fa24c0b" diff --git a/pyproject.toml b/pyproject.toml index 02882b04d9..d5445abe40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.85.0" +textual = "0.85.1" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From d812961d4e1272d9edcefd1a3cdfe0ec56c00172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:08 +0100 Subject: [PATCH 061/192] Bump textual to 0.85.2 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 59656f467f..ed9ea60600 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.85.1" +version = "0.85.2" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.85.1-py3-none-any.whl", hash = "sha256:a1a064c67b9b81cfa0c1b14298aa52221855aa4a56ad17a9b89a5594c73657a8"}, - {file = "textual-0.85.1.tar.gz", hash = "sha256:9966214390fad9a84c3f69d49398487897577f5fa788838106dd77bd7babc9cd"}, + {file = "textual-0.85.2-py3-none-any.whl", hash = "sha256:9ccdeb6b8a6a0ff72d497f714934f2e524f2eb67783b459fb08b1339ee537dc0"}, + {file = "textual-0.85.2.tar.gz", hash = "sha256:2a416995c49d5381a81d0a6fd23925cb0e3f14b4f239ed05f35fa3c981bb1df2"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "6a6d30ab71779bfdea1a2539d23fad160e1039374b15dcd52e90dd493fa24c0b" +content-hash = "2f930d7ea8214841a2558a1f96f7218c8cbff1b2be91d5ec8ecce2bc0a587025" diff --git a/pyproject.toml b/pyproject.toml index d5445abe40..e6247d9e2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.85.1" +textual = "0.85.2" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 4c63e8f7740939c9c21568e36d208b7ff2146844 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:09 +0100 Subject: [PATCH 062/192] Bump textual to 0.86.0 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index ed9ea60600..9a924e135d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.85.2" +version = "0.86.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.85.2-py3-none-any.whl", hash = "sha256:9ccdeb6b8a6a0ff72d497f714934f2e524f2eb67783b459fb08b1339ee537dc0"}, - {file = "textual-0.85.2.tar.gz", hash = "sha256:2a416995c49d5381a81d0a6fd23925cb0e3f14b4f239ed05f35fa3c981bb1df2"}, + {file = "textual-0.86.0-py3-none-any.whl", hash = "sha256:ae661f28e32aae96e7f0e9301e35f5ad2260b7e7259b2abac96306c956c66a09"}, + {file = "textual-0.86.0.tar.gz", hash = "sha256:db90ed4d41015722f0f49796761b314ca85dbd32871a3078275df21bb74a76ce"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "2f930d7ea8214841a2558a1f96f7218c8cbff1b2be91d5ec8ecce2bc0a587025" +content-hash = "9a85ca34a0c3639f8496f2482dccc8fc6c927b83ef0f795944e74b20a0ef9a22" diff --git a/pyproject.toml b/pyproject.toml index e6247d9e2f..57257991d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.85.2" +textual = "0.86.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 9f368d7cb432ab5ebe96f99debc1b5ff3e7ba87b Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Mon, 13 Jan 2025 11:43:36 +0100 Subject: [PATCH 063/192] Pass the title in the section title as a positional argument --- clive/__private/ui/widgets/section_title.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clive/__private/ui/widgets/section_title.py b/clive/__private/ui/widgets/section_title.py index d32b9adba5..c0f2155ea9 100644 --- a/clive/__private/ui/widgets/section_title.py +++ b/clive/__private/ui/widgets/section_title.py @@ -31,7 +31,7 @@ class SectionTitle(Static): def __init__( self, title: str, variant: SectionTitleVariant = "default", id_: str | None = None, classes: str | None = None ) -> None: - super().__init__(renderable=title, id=id_, classes=classes) + super().__init__(title, id=id_, classes=classes) self.variant = variant def watch_variant(self, old_variant: str, variant: str) -> None: -- GitLab From b11108f5f77b5c780f287351551f1e5eecc5f540 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:17:53 +0100 Subject: [PATCH 064/192] Set clive theme to the textual-dark --- clive/__private/ui/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 804692049d..490e04ace5 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -181,6 +181,8 @@ class Clive(App[int]): else: self.switch_mode("create_profile") + self.theme = "textual-dark" + async def on_unmount(self) -> None: if self._world is not None: # There might be an exception during world setup and therefore world might not be available. -- GitLab From 8b8ae639d057f47435cf2d8989630420e5fdc58e Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:18:45 +0100 Subject: [PATCH 065/192] Enable command palette --- clive/__private/ui/app.py | 2 +- clive/__private/ui/widgets/clive_basic/clive_header.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 490e04ace5..d29da2942b 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -50,7 +50,7 @@ class Clive(App[int]): AUTO_FOCUS = "*" - ENABLE_COMMAND_PALETTE = False + ENABLE_COMMAND_PALETTE = True BINDINGS = [ Binding("ctrl+s", "app.screenshot()", "Screenshot", show=False), diff --git a/clive/__private/ui/widgets/clive_basic/clive_header.py b/clive/__private/ui/widgets/clive_basic/clive_header.py index 9d5bcb0702..13a8fd518c 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_header.py +++ b/clive/__private/ui/widgets/clive_basic/clive_header.py @@ -24,7 +24,7 @@ from clive.__private.ui.widgets.titled_label import TitledLabel if TYPE_CHECKING: from textual.app import ComposeResult - from textual.events import Mount + from textual.events import Click, Mount from clive.__private.core.app_state import AppState from clive.__private.core.node import Node @@ -47,7 +47,8 @@ class HeaderIcon(TextualHeaderIcon, CliveWidget): def header_expanded_changed(self, expanded: bool) -> None: # noqa: FBT001 self.icon = "-" if expanded else "+" - def on_click(self) -> None: # type: ignore[override] + def on_click(self, event: Click) -> None: # type: ignore[override] + event.prevent_default() self.app.header_expanded = not self.app.header_expanded -- GitLab From ccf60180be6eaaee786ab214f12b4fa387ca635c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:11 +0100 Subject: [PATCH 066/192] Change command palette key binding so it does not collide with "previous screen" --- clive/__private/ui/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index d29da2942b..161b8239ea 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -51,6 +51,7 @@ class Clive(App[int]): AUTO_FOCUS = "*" ENABLE_COMMAND_PALETTE = True + COMMAND_PALETTE_BINDING = "f12" BINDINGS = [ Binding("ctrl+s", "app.screenshot()", "Screenshot", show=False), -- GitLab From 9a93c7d35726ec4b5c5c12d861c27e044c97d2f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:13 +0100 Subject: [PATCH 067/192] Use @on with Click event --- clive/__private/ui/dialogs/clive_base_dialogs.py | 7 ++++--- .../__private/ui/widgets/clive_basic/clive_header.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/clive/__private/ui/dialogs/clive_base_dialogs.py b/clive/__private/ui/dialogs/clive_base_dialogs.py index a784f5d65a..fa5b5444ca 100644 --- a/clive/__private/ui/dialogs/clive_base_dialogs.py +++ b/clive/__private/ui/dialogs/clive_base_dialogs.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal from textual import on from textual.binding import Binding from textual.containers import Center, Horizontal, Vertical +from textual.events import Click from textual.message import Message from textual.reactive import reactive from textual.screen import ModalScreen @@ -18,7 +19,6 @@ from clive.__private.ui.widgets.inputs.clive_input import CliveInput if TYPE_CHECKING: from textual.app import ComposeResult - from textual.events import Click CliveDialogVariant = Literal["default", "error"] @@ -87,9 +87,10 @@ class CliveBaseDialog(ModalScreen[ScreenResultT], CliveWidget, AbstractClassMess self.content.remove_class(f"-{old_variant}") self.content.add_class(f"-{variant}") - def on_click(self, event: Click) -> None: + @on(Click) + def close_dialog(self, event: Click) -> None: + """Close the Dialog if the user clicks outside the modal content.""" if self.get_widget_at(event.screen_x, event.screen_y)[0] is self: - # Close the screen if the user clicks outside the modal content self.dismiss() @abstractmethod diff --git a/clive/__private/ui/widgets/clive_basic/clive_header.py b/clive/__private/ui/widgets/clive_basic/clive_header.py index 13a8fd518c..2d824b28a2 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_header.py +++ b/clive/__private/ui/widgets/clive_basic/clive_header.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final from textual import events, on from textual.containers import Horizontal from textual.css.query import NoMatches +from textual.events import Click from textual.message import Message from textual.widgets import Header, Static from textual.widgets._header import HeaderIcon as TextualHeaderIcon @@ -24,7 +25,7 @@ from clive.__private.ui.widgets.titled_label import TitledLabel if TYPE_CHECKING: from textual.app import ComposeResult - from textual.events import Click, Mount + from textual.events import Mount from clive.__private.core.app_state import AppState from clive.__private.core.node import Node @@ -47,8 +48,9 @@ class HeaderIcon(TextualHeaderIcon, CliveWidget): def header_expanded_changed(self, expanded: bool) -> None: # noqa: FBT001 self.icon = "-" if expanded else "+" - def on_click(self, event: Click) -> None: # type: ignore[override] - event.prevent_default() + @on(Click) + def expand_header(self, event: Click) -> None: + event.prevent_default() # textual default is to show the command palette, we don't want that self.app.header_expanded = not self.app.header_expanded @@ -269,12 +271,14 @@ class CliveRawHeader(Header, CliveWidget): with Horizontal(): yield HeaderTitle() - def _on_click(self, event: events.Click) -> None: # type: ignore[override] + @on(Click) + def prevent_header_expanding(self, event: events.Click) -> None: """ Override this method to prevent expanding header on click. Default behavior of the textual header is to expand on click. We do not want behavior like that, so we had to override the `_on_click` method. + We only allow for expanding the header by clicking on the HeaderIcon, not on the entire Header. """ event.prevent_default() -- GitLab From ca9b71e4994066a5b536ba3100494b14d0c5a67b Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:20:26 +0100 Subject: [PATCH 068/192] Add new properties to the global.scss --- clive/__private/ui/global.scss | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/clive/__private/ui/global.scss b/clive/__private/ui/global.scss index b86ec8e3f3..5a0915c21d 100644 --- a/clive/__private/ui/global.scss +++ b/clive/__private/ui/global.scss @@ -15,6 +15,39 @@ Tabs { Tab.-active { text-style: bold reverse; + background: white; + } + } +} + +RadioSet { + background: $panel-darken-1 !important; + + & > RadioButton { + &.-selected { + background: transparent; + } + } + + &:focus { + & > RadioButton.-selected { + background: white; + } + } +} + +Checkbox { + &:focus { + & > .toggle--label { + background: white; + } + } +} + +Select { + OptionList { + & > .option-list--option-highlighted { + background: white; } } } -- GitLab From 5e0b576bacb8ad7755b4f1e65cad33c7ebd04961 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:30:29 +0100 Subject: [PATCH 069/192] Add darken-primary variant of the clive button --- clive/__private/ui/widgets/buttons/clive_button.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/clive/__private/ui/widgets/buttons/clive_button.py b/clive/__private/ui/widgets/buttons/clive_button.py index d54d134f0b..54e448839a 100644 --- a/clive/__private/ui/widgets/buttons/clive_button.py +++ b/clive/__private/ui/widgets/buttons/clive_button.py @@ -20,6 +20,7 @@ CliveButtonVariant = Literal[ "transparent", "grey-darken", "grey-lighten", + "darken-primary", ButtonVariant, ] @@ -89,6 +90,14 @@ class CliveButton(Button, CliveWidget): background: $panel-lighten-1; } } + + &.-darken-primary { + background: $primary-darken-1; + + &:hover { + background: $primary-darken-3; + } + } } """ -- GitLab From 3038f2e001a021bd205a93040f36b181bda51271 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:35:23 +0100 Subject: [PATCH 070/192] The renderable property can now also be of type SupportsVisual --- clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py | 3 ++- clive/__private/ui/widgets/titled_label.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py b/clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py index edb7a636be..db3d92a024 100644 --- a/clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py +++ b/clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py @@ -13,6 +13,7 @@ from clive.__private.ui.widgets.dynamic_widgets.dynamic_widget import ( if TYPE_CHECKING: from rich.console import RenderableType from textual.reactive import Reactable + from textual.visual import SupportsVisual DynamicLabelCallbackType = WatchLikeCallbackType[str] @@ -44,7 +45,7 @@ class DynamicLabel(DynamicWidget[Label, str]): ) @property - def renderable(self) -> RenderableType: + def renderable(self) -> RenderableType | SupportsVisual: return self._widget.renderable def _create_widget(self) -> Label: diff --git a/clive/__private/ui/widgets/titled_label.py b/clive/__private/ui/widgets/titled_label.py index 4b98583aa9..82783eaaf5 100644 --- a/clive/__private/ui/widgets/titled_label.py +++ b/clive/__private/ui/widgets/titled_label.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: from rich.console import RenderableType from textual.app import ComposeResult from textual.reactive import Reactable + from textual.visual import SupportsVisual from clive.__private.ui.widgets.dynamic_widgets.dynamic_label import ( DynamicLabelCallbackType, @@ -66,7 +67,7 @@ class TitledLabel(CliveWidget): ) @property - def value(self) -> RenderableType: + def value(self) -> RenderableType | SupportsVisual: return self._value_label.renderable def compose(self) -> ComposeResult: -- GitLab From b1408293fb0f3de2974a750fa8b9aa84b92271ac Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:42:38 +0100 Subject: [PATCH 071/192] Change available variants of the section title --- clive/__private/ui/widgets/section_title.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/clive/__private/ui/widgets/section_title.py b/clive/__private/ui/widgets/section_title.py index c0f2155ea9..536b2708d7 100644 --- a/clive/__private/ui/widgets/section_title.py +++ b/clive/__private/ui/widgets/section_title.py @@ -4,7 +4,7 @@ from textual.reactive import reactive from textual.widgets import Static from typing_extensions import Literal -SectionTitleVariant = Literal["default", "dark", "red"] +SectionTitleVariant = Literal["default", "light", "red"] """The names of the valid section title variants.""" @@ -12,13 +12,13 @@ class SectionTitle(Static): DEFAULT_CSS = """ SectionTitle { text-style: bold; - background: $primary; + background: $primary-darken-3; width: 1fr; height: 1; text-align: center; - &.-dark { - background: $primary-background; + &.-light { + background: $primary-lighten-3; } &.-red { -- GitLab From cf96176be3568d8073c98d064d65f281f8cf8c4f Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:43:14 +0100 Subject: [PATCH 072/192] Change background of the no content available and apr widgets --- clive/__private/ui/widgets/apr.py | 2 +- clive/__private/ui/widgets/no_content_available.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/widgets/apr.py b/clive/__private/ui/widgets/apr.py index f373791c65..94683cd355 100644 --- a/clive/__private/ui/widgets/apr.py +++ b/clive/__private/ui/widgets/apr.py @@ -15,7 +15,7 @@ class APR(DynamicLabel, AbstractClassMessagePump): APR { height: 1; margin-top: 1; - background: $primary-background; + background: $primary-darken-3; text-style: bold; align: center middle; width: 1fr; diff --git a/clive/__private/ui/widgets/no_content_available.py b/clive/__private/ui/widgets/no_content_available.py index 2b1d18cc72..09051ae050 100644 --- a/clive/__private/ui/widgets/no_content_available.py +++ b/clive/__private/ui/widgets/no_content_available.py @@ -7,7 +7,7 @@ class NoContentAvailable(Static): DEFAULT_CSS = """ NoContentAvailable { text-style: bold; - background: $primary; + background: $secondary-lighten-1; width: 1fr; height: 1; text-align: center; -- GitLab From 0bcd64319eea26c238347518c5dda7297b7d1572 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:51:42 +0100 Subject: [PATCH 073/192] Modify look of the checkerboard-table --- clive/__private/core/constants/tui/class_names.py | 2 ++ .../account_management/common/header_of_tables.py | 10 ++++------ .../config/manage_key_aliases/manage_key_aliases.py | 10 +++++----- .../ui/screens/transaction_summary/cart_table.py | 10 +++++----- .../widgets/clive_basic/clive_checkerboard_table.py | 12 ++++++++++-- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/clive/__private/core/constants/tui/class_names.py b/clive/__private/core/constants/tui/class_names.py index edfa75ab96..e08540ea19 100644 --- a/clive/__private/core/constants/tui/class_names.py +++ b/clive/__private/core/constants/tui/class_names.py @@ -4,3 +4,5 @@ from typing import Final CLIVE_ODD_COLUMN_CLASS_NAME: Final[str] = "-odd-column" CLIVE_EVEN_COLUMN_CLASS_NAME: Final[str] = "-even-column" +CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME: Final[str] = "checkerboard-header-cell" +CLIVE_CHECKERBOARD_TITLE_CLASS_NAME: Final[str] = "checkerboard-table-title" diff --git a/clive/__private/ui/screens/config/account_management/common/header_of_tables.py b/clive/__private/ui/screens/config/account_management/common/header_of_tables.py index 2c086770b1..4c41d5abb3 100644 --- a/clive/__private/ui/screens/config/account_management/common/header_of_tables.py +++ b/clive/__private/ui/screens/config/account_management/common/header_of_tables.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING from textual.containers import Horizontal from textual.widgets import Label -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME if TYPE_CHECKING: from textual.app import ComposeResult @@ -29,9 +29,7 @@ class AccountsTableHeader(Horizontal): self.show_type_column = show_type_column def compose(self) -> ComposeResult: - yield Label(self._account_column_name, classes=CLIVE_ODD_COLUMN_CLASS_NAME) + yield Label(self._account_column_name, classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) if self.show_type_column: - yield Label("Account type", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) - yield Label( - "Action", classes=CLIVE_ODD_COLUMN_CLASS_NAME if self.show_type_column else CLIVE_EVEN_COLUMN_CLASS_NAME - ) + yield Label("Account type", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Label("Action", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) diff --git a/clive/__private/ui/screens/config/manage_key_aliases/manage_key_aliases.py b/clive/__private/ui/screens/config/manage_key_aliases/manage_key_aliases.py index 24d9b23c98..857d1612bb 100644 --- a/clive/__private/ui/screens/config/manage_key_aliases/manage_key_aliases.py +++ b/clive/__private/ui/screens/config/manage_key_aliases/manage_key_aliases.py @@ -7,7 +7,7 @@ from textual.binding import Binding from textual.containers import Horizontal from textual.widgets import Static -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.dialogs import RemoveKeyAliasDialog from clive.__private.ui.get_css import get_relative_css_path @@ -63,10 +63,10 @@ class KeyAliasRow(CliveCheckerboardTableRow, CliveWidget): class KeyAliasesHeader(Horizontal): def compose(self) -> ComposeResult: - yield Static("No.", classes=CLIVE_ODD_COLUMN_CLASS_NAME) - yield Static("Alias", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) - yield Static("Public key", classes=f"{CLIVE_ODD_COLUMN_CLASS_NAME} public-key") - yield Static("Actions", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) + yield Static("No.", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Alias", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Public key", classes=f"{CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME} public-key") + yield Static("Actions", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) class ManageKeyAliasesTable(CliveCheckerboardTable): diff --git a/clive/__private/ui/screens/transaction_summary/cart_table.py b/clive/__private/ui/screens/transaction_summary/cart_table.py index 17fa914147..90c00a5c42 100644 --- a/clive/__private/ui/screens/transaction_summary/cart_table.py +++ b/clive/__private/ui/screens/transaction_summary/cart_table.py @@ -12,7 +12,7 @@ from textual.reactive import reactive from textual.widgets import Static from typing_extensions import Self -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.core.formatters.humanize import humanize_operation_details, humanize_operation_name from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.dialogs.confirm_invalidate_signatures_dialog import ConfirmInvalidateSignaturesDialog @@ -274,10 +274,10 @@ class CartItem(CliveCheckerboardTableRow, CliveWidget): class CartHeader(Horizontal): def compose(self) -> ComposeResult: - yield Static("No.", classes=CLIVE_ODD_COLUMN_CLASS_NAME) - yield Static("Operation type", classes=f"{CLIVE_EVEN_COLUMN_CLASS_NAME} operation-name") - yield Static("Operation details", classes=f"{CLIVE_ODD_COLUMN_CLASS_NAME} operation-details") - yield Static("Actions", classes=f"{CLIVE_EVEN_COLUMN_CLASS_NAME} actions") + yield Static("No.", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Operation type", classes=f"{CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME} operation-name") + yield Static("Operation details", classes=f"{CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME} operation-details") + yield Static("Actions", classes=f"{CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME} actions") class CartTable(CliveCheckerboardTable): diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index 54416ff9cf..ac62d6196e 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -136,7 +136,7 @@ class CliveCheckerboardTable(CliveWidget): height: auto; .-odd-column { - background: $primary-background-darken-2; + background: $panel-lighten-2; OneLineButton { opacity: 93%; @@ -144,13 +144,21 @@ class CliveCheckerboardTable(CliveWidget): } .-even-column { - background: $primary-background-darken-1; + background: $panel-lighten-1; } #loading-static { text-align: center; text-style: bold; } + + .checkerboard-header-cell { + background: $primary; + } + + .checkerboard-table-title { + background: $primary-darken-3; + } } """ -- GitLab From 751643bf45b8118a6d12029221d071b21671a8ed Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:52:12 +0100 Subject: [PATCH 074/192] Improve styling of the savings screen --- .../savings_operations/savings_operations.py | 18 +++++++++--------- .../savings_operations/savings_operations.scss | 12 ++++++++---- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/clive/__private/ui/screens/operations/savings_operations/savings_operations.py b/clive/__private/ui/screens/operations/savings_operations/savings_operations.py index cc70f626c6..74ae5ee741 100644 --- a/clive/__private/ui/screens/operations/savings_operations/savings_operations.py +++ b/clive/__private/ui/screens/operations/savings_operations/savings_operations.py @@ -6,7 +6,7 @@ from textual import on from textual.containers import Grid, Horizontal from textual.widgets import Label, RadioSet, Static, TabPane -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.core.formatters.humanize import humanize_datetime, humanize_hbd_savings_apr from clive.__private.core.percent_conversions import hive_percent_to_percent from clive.__private.models import Asset @@ -24,7 +24,7 @@ from clive.__private.ui.screens.operations.operation_summary.cancel_transfer_fro CancelTransferFromSavings, ) from clive.__private.ui.widgets.apr import APR -from clive.__private.ui.widgets.buttons import CancelButton +from clive.__private.ui.widgets.buttons import CancelOneLineButton from clive.__private.ui.widgets.clive_basic import ( CliveCheckerboardTable, CliveCheckerBoardTableCell, @@ -104,7 +104,7 @@ class SavingsInterestInfo(TrackedAccountReferencingWidget): return self.screen.query_exactly_one(SavingsDataProvider) def compose(self) -> ComposeResult: - yield SectionTitle("Interest data", variant="dark") + yield SectionTitle("Interest data") yield DynamicLabel( self.provider, "_content", @@ -130,10 +130,10 @@ class SavingsInterestInfo(TrackedAccountReferencingWidget): class PendingTransfersHeader(Horizontal): def compose(self) -> ComposeResult: - yield Label("To", classes=CLIVE_ODD_COLUMN_CLASS_NAME) - yield Label("Amount", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) - yield Label("Realized on (UTC)", classes=CLIVE_ODD_COLUMN_CLASS_NAME) - yield Label("Memo", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) + yield Label("To", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Label("Amount", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Label("Realized on (UTC)", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Label("Memo", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) yield Label() @@ -144,11 +144,11 @@ class PendingTransfer(CliveCheckerboardTableRow): CliveCheckerBoardTableCell(aligned_amount), CliveCheckerBoardTableCell(humanize_datetime(pending_transfer.complete)), CliveCheckerBoardTableCell(pending_transfer.memo), - CliveCheckerBoardTableCell(CancelButton()), + CliveCheckerBoardTableCell(CancelOneLineButton()), ) self._pending_transfer = pending_transfer - @on(CancelButton.Pressed) + @on(CancelOneLineButton.Pressed) def push_operation_summary_screen(self) -> None: self.app.push_screen(CancelTransferFromSavings(self._pending_transfer)) diff --git a/clive/__private/ui/screens/operations/savings_operations/savings_operations.scss b/clive/__private/ui/screens/operations/savings_operations/savings_operations.scss index 2eab403246..0741d9f347 100644 --- a/clive/__private/ui/screens/operations/savings_operations/savings_operations.scss +++ b/clive/__private/ui/screens/operations/savings_operations/savings_operations.scss @@ -3,6 +3,10 @@ Savings { width: 1fr; } + Tabs { + margin-top: 1; + } + #savings-info-container { max-height: 3; @@ -12,7 +16,7 @@ Savings { SavingsBalancesHeader { #savings-title { - background: $primary-background; + background: $primary-darken-3; } } @@ -50,11 +54,11 @@ Savings { } .interest-info-row-odd { - background: $accent; + background: $panel-lighten-2; } .interest-info-row-even { - background: $accent-lighten-1; + background: $panel-lighten-3; } } } @@ -79,7 +83,7 @@ Savings { } CliveCheckerBoardTableCell { - height: 3; + height: 1; } CliveButton { -- GitLab From aabf8319067b1bc11a7d70e8564f863aceb0d431 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:53:53 +0100 Subject: [PATCH 075/192] Improve styling of the governance screen --- .../common_governance/governance_actions.scss | 4 +- .../proposals/proposals.scss | 10 +-- .../governance_operations/proxy/proxy.py | 14 ++-- .../governance_operations/witness/witness.py | 18 ++--- .../witness/witness.scss | 65 +++++-------------- 5 files changed, 42 insertions(+), 69 deletions(-) diff --git a/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_actions.scss b/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_actions.scss index 073b6bf056..88d219e2e6 100644 --- a/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_actions.scss +++ b/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_actions.scss @@ -1,6 +1,6 @@ $governance-green-color: $success-darken-3; $governance-red-color: $error; -$table-header-contrast-column: $warning-darken-2; +$table-header-color: $primary-lighten-3; GovernanceActions { margin-left: 1; @@ -15,7 +15,7 @@ GovernanceActions { } #action-row { - background: $table-header-contrast-column; + background: $table-header-color; width: 1fr; } } diff --git a/clive/__private/ui/screens/operations/governance_operations/proposals/proposals.scss b/clive/__private/ui/screens/operations/governance_operations/proposals/proposals.scss index add933ee8d..5812b0c6da 100644 --- a/clive/__private/ui/screens/operations/governance_operations/proposals/proposals.scss +++ b/clive/__private/ui/screens/operations/governance_operations/proposals/proposals.scss @@ -1,8 +1,8 @@ -$table-column-1-odd: $accent; -$table-column-2-odd: $accent-lighten-1; -$table-column-1-even: $primary-background; -$table-column-2-even: $primary-background-lighten-1; -$table-header-column: $primary-darken-2; +$table-column-1-odd: $panel-lighten-1; +$table-column-2-odd: $panel-darken-1; +$table-column-1-even: $secondary-lighten-1; +$table-column-2-even: $secondary-darken-1; +$table-header-column: $primary; Proposals { Label { diff --git a/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py b/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py index 09cea0cf12..2ec1c5c1a3 100644 --- a/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py +++ b/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py @@ -4,12 +4,12 @@ from typing import TYPE_CHECKING from textual import on from textual.containers import Container, Horizontal -from textual.widgets import Button, TabPane +from textual.widgets import TabPane from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.get_css import get_css_from_relative_path from clive.__private.ui.screens.operations.operation_summary.account_witness_proxy import AccountWitnessProxy -from clive.__private.ui.widgets.buttons import CliveButton +from clive.__private.ui.widgets.buttons import OneLineButton from clive.__private.ui.widgets.inputs.labelized_input import LabelizedInput from clive.__private.ui.widgets.inputs.proxy_input import ProxyInput from clive.__private.ui.widgets.notice import Notice @@ -48,7 +48,7 @@ class ProxyNotSet(ProxyBaseContainer): yield CurrentProxy("Proxy not set") yield NewProxyInput(required=True) with Container(id="set-button-container"): - yield CliveButton("Set proxy", id_="set-proxy-button", variant="success") + yield OneLineButton("Set proxy", id_="set-proxy-button", variant="success") class ProxySet(ProxyBaseContainer): @@ -68,8 +68,8 @@ class ProxySet(ProxyBaseContainer): yield CurrentProxy(self._current_proxy) yield NewProxyInput(required=False) with Horizontal(id="modify-proxy-buttons"): - yield CliveButton("Change proxy", variant="success", id_="set-proxy-button") - yield CliveButton("Remove proxy", variant="error", id_="remove-proxy-button") + yield OneLineButton("Change proxy", variant="success", id_="set-proxy-button") + yield OneLineButton("Remove proxy", variant="error", id_="remove-proxy-button") class Proxy(TabPane, CliveWidget): @@ -93,7 +93,7 @@ class Proxy(TabPane, CliveWidget): with ScrollablePart(id="scrollable-for-proxy"): yield content - @on(Button.Pressed, "#set-proxy-button") + @on(OneLineButton.Pressed, "#set-proxy-button") def set_new_proxy(self) -> None: if not self.new_proxy_input.validate_passed(): # we need to exclusively validate the input when set button is pressed, because the input is not validated @@ -103,7 +103,7 @@ class Proxy(TabPane, CliveWidget): new_proxy = self.new_proxy_input.value_or_error # already validated self.app.push_screen(AccountWitnessProxy(new_proxy=new_proxy)) - @on(Button.Pressed, "#remove-proxy-button") + @on(OneLineButton.Pressed, "#remove-proxy-button") def remove_proxy(self) -> None: self.app.push_screen(AccountWitnessProxy(new_proxy=None)) diff --git a/clive/__private/ui/screens/operations/governance_operations/witness/witness.py b/clive/__private/ui/screens/operations/governance_operations/witness/witness.py index 4234dcae11..10c42e257a 100644 --- a/clive/__private/ui/screens/operations/governance_operations/witness/witness.py +++ b/clive/__private/ui/screens/operations/governance_operations/witness/witness.py @@ -59,8 +59,9 @@ class WitnessDetailsLabel(Label): class Clicked(Message): """Message send when DetailsLabel is clicked.""" - def __init__(self, *, classes: str | None = None) -> None: + def __init__(self, witness_name: str, *, classes: str | None = None) -> None: super().__init__(renderable="details", classes=classes) + self.tooltip = f"Click to see more details about {witness_name} witness." @on(Click) def clicked(self) -> None: @@ -108,16 +109,17 @@ class Witness(GovernanceTableRow[WitnessData]): BINDINGS = [Binding("f3", "show_details", "Details")] def create_row_content(self) -> ComposeResult: + class_name = f"witness-row-{self.evenness}" yield Label( str(self.row_data.rank) if self.row_data.rank is not None else f">{MAX_NUMBER_OF_WITNESSES_IN_TABLE}", - classes=f"witness-rank-{self.evenness}", + classes=class_name, ) yield WitnessNameLabel( self.row_data.name, - classes=f"witness-name-{self.evenness}", + classes=class_name, ) - yield Label(str(self.row_data.votes), classes=f"witness-votes-{self.evenness}") - yield WitnessDetailsLabel(classes=f"witness-details-{self.evenness}") + yield Label(str(self.row_data.votes), classes=class_name) + yield WitnessDetailsLabel(self.row_data.name, classes=class_name) @on(WitnessDetailsLabel.Clicked) async def action_show_details(self) -> None: @@ -246,9 +248,9 @@ class WitnessesList(GovernanceListWidget[WitnessData]): class WitnessesListHeader(GovernanceListHeader): def create_custom_columns(self) -> ComposeResult: - yield Static("rank", id="rank-column") - yield Static("witness", id="name-column") - yield Static("votes", id="votes-column") + yield Static("Rank", classes="witnesses-table-header") + yield Static("Witness", classes="witnesses-table-header witness-name-header") + yield Static("Votes", classes="witnesses-table-header") def create_additional_headlines(self) -> ComposeResult: yield SectionTitle( diff --git a/clive/__private/ui/screens/operations/governance_operations/witness/witness.scss b/clive/__private/ui/screens/operations/governance_operations/witness/witness.scss index 08dbf4b5fa..26894ed426 100644 --- a/clive/__private/ui/screens/operations/governance_operations/witness/witness.scss +++ b/clive/__private/ui/screens/operations/governance_operations/witness/witness.scss @@ -1,28 +1,20 @@ $governance-green-color: $success-darken-3; $governance-red-color: $error; -$table-column-1-odd: $accent; -$table-column-2-odd: $accent-lighten-1; -$table-column-3-odd: $accent-lighten-2; -$table-column-4-odd: $accent-darken-2; -$table-column-1-even: $primary-background; -$table-column-2-even: $primary-background-lighten-1; -$table-column-3-even: $primary-background-lighten-2; -$table-column-4-even: $primary-background-lighten-3; -$table-header-contrast-column: $warning-darken-2; -$table-header-column: $primary-darken-2; +$table-row-odd-color: $secondary-darken-1; +$table-row-even-color: $panel-darken-1; +$table-header-color: $primary; +$table-header-color-2: $primary-lighten-3; Witnesses { layout: vertical; height: auto; Label { - text-style: bold; width: 1fr; text-align: center; } Static { - text-style: bold; text-align: center; } @@ -35,36 +27,19 @@ Witnesses { grid-columns: 2fr 1fr 3fr 2fr 2fr; height: auto; - .witness-name-even { - background: $table-column-1-even; + WitnessNameLabel, + WitnessDetailsLabel { + &:hover { + background: $panel; + } } - .witness-rank-even { - background: $table-column-2-even; + .witness-row-even { + background: $table-row-even-color; } - .witness-votes-even { - background: $table-column-3-even; - } - - .witness-details-even { - background: $table-column-4-even; - } - - .witness-name-odd { - background: $table-column-1-odd; - } - - .witness-rank-odd { - background: $table-column-2-odd; - } - - .witness-votes-odd { - background: $table-column-3-odd; - } - - .witness-details-odd { - background: $table-column-4-odd; + .witness-row-odd { + background: $table-row-odd-color; } } @@ -73,16 +48,12 @@ Witnesses { grid-columns: 2fr 1fr 3fr 2fr 2fr; height: 2; - #rank-column { - background: $table-header-contrast-column; - } - - #name-column { - background: $table-header-column; + .witnesses-table-header { + background: $table-header-color; } - #votes-column { - background: $table-header-column; + .witness-name-header { + background: $table-header-color-2; } } @@ -97,7 +68,7 @@ Witnesses { WitnessesActions { #action-name-row { - background: $table-header-column; + background: $table-header-color; width: 2fr; } } -- GitLab From de727f7991ac8d7878014743ad3285d1782bf989 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 13:57:03 +0100 Subject: [PATCH 076/192] Improve styling of the HP management screen --- .../common_hive_power/additional_info_widgets.py | 12 ++++++------ .../delegate_hive_power/delegate_hive_power.py | 8 ++++---- .../hive_power_management.scss | 13 ++++++++++--- .../hive_power_management/power_down/power_down.py | 8 ++++---- .../power_down/power_down.scss | 2 +- .../withdraw_routes/withdraw_routes.py | 8 ++++---- 6 files changed, 29 insertions(+), 22 deletions(-) diff --git a/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/additional_info_widgets.py b/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/additional_info_widgets.py index 9bfec539d5..4d0727cad4 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/additional_info_widgets.py +++ b/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/additional_info_widgets.py @@ -40,12 +40,12 @@ class WithdrawalInfo(Vertical, CliveWidget): margin-top: 1; } - #withdrawal-info-date, #withdrawal-info-vests-amount { - background: $accent; + #withdrawal-info-date, #withdrawal-info-hp-amount { + background: $panel-lighten-2; } - #withdrawal-info-hp-amount { - background: $accent-lighten-1; + #withdrawal-info-vests-amount { + background: $panel-lighten-3; } """ @@ -54,7 +54,7 @@ class WithdrawalInfo(Vertical, CliveWidget): self._provider = provider def compose(self) -> ComposeResult: - yield SectionTitle("Next withdrawal", variant="dark") + yield SectionTitle("Next withdrawal") yield DynamicLabel( self._provider, "_content", @@ -62,7 +62,7 @@ class WithdrawalInfo(Vertical, CliveWidget): id_="withdrawal-info-date", first_try_callback=lambda content: content is not None, ) - yield SectionTitle("To withdraw", variant="dark", id_="to-withdraw-header") + yield SectionTitle("To withdraw", id_="to-withdraw-header") yield DynamicLabel( self._provider, "_content", diff --git a/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py b/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py index c1ee1b8237..74d1ff89cd 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py +++ b/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py @@ -6,7 +6,7 @@ from textual import on from textual.containers import Horizontal from textual.widgets import Static, TabPane -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.core.ensure_vests import ensure_vests from clive.__private.models.schemas import DelegateVestingSharesOperation from clive.__private.ui.data_providers.hive_power_data_provider import HivePowerDataProvider @@ -39,9 +39,9 @@ if TYPE_CHECKING: class DelegationsTableHeader(Horizontal): def compose(self) -> ComposeResult: - yield Static("Delegate", classes=CLIVE_ODD_COLUMN_CLASS_NAME) - yield Static("Shares [HP]", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) - yield Static("Shares [VESTS]", classes=CLIVE_ODD_COLUMN_CLASS_NAME) + yield Static("Delegate", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Shares [HP]", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Shares [VESTS]", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) yield PlaceTaker() diff --git a/clive/__private/ui/screens/operations/hive_power_management/hive_power_management.scss b/clive/__private/ui/screens/operations/hive_power_management/hive_power_management.scss index 8bd1134a14..3b80b5a150 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/hive_power_management.scss +++ b/clive/__private/ui/screens/operations/hive_power_management/hive_power_management.scss @@ -1,4 +1,4 @@ -$table-header-color: $primary-background; +$table-header-color: $primary-darken-3; HivePowerManagement { HpVestsFactor { @@ -9,6 +9,10 @@ HivePowerManagement { width: 1fr; } + Tabs { + margin-top: 1; + } + #hive-power-info { height: 6; } @@ -36,14 +40,17 @@ HivePowerManagement { } #shares-name-header { - background: $table-header-color; width: 2fr; } .shares-balance-header { - background: $table-header-color; width: 3fr; } + + #shares-name-header, + .shares-balance-header { + background: $table-header-color; + } } RowTitle { diff --git a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py index 74d350dfd6..33b63eaebf 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py +++ b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py @@ -6,7 +6,7 @@ from textual import on from textual.containers import Horizontal from textual.widgets import Pretty, Static, TabPane -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.core.ensure_vests import ensure_vests from clive.__private.core.formatters.humanize import humanize_datetime, humanize_percent from clive.__private.core.percent_conversions import hive_percent_to_percent @@ -81,9 +81,9 @@ class WithdrawRoutesDisplay(CliveWidget): class PendingPowerDownHeader(Horizontal): def compose(self) -> ComposeResult: - yield Static("Next power down", classes=CLIVE_ODD_COLUMN_CLASS_NAME) - yield Static("Power down [HP]", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) - yield Static("Power down [VESTS]", classes=CLIVE_ODD_COLUMN_CLASS_NAME) + yield Static("Next power down", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Power down [HP]", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Power down [VESTS]", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) yield PlaceTaker() diff --git a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.scss b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.scss index 386c45b3e4..be91a992bf 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.scss +++ b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.scss @@ -24,7 +24,7 @@ PowerDown { #withdraw-routes-header { text-style: bold; margin-top: 1; - background: $primary; + background: $secondary-lighten-1; width: 1fr; height: 1; text-align: center; diff --git a/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py b/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py index e7d84a1f3c..3b76e6c13a 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py +++ b/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py @@ -7,7 +7,7 @@ from textual.containers import Horizontal from textual.widgets import Checkbox, Static, TabPane from clive.__private.core.constants.precision import HIVE_PERCENT_PRECISION -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.core.formatters.humanize import align_to_dot, humanize_bool from clive.__private.core.percent_conversions import percent_to_hive_percent from clive.__private.models.schemas import SetWithdrawVestingRouteOperation @@ -42,9 +42,9 @@ if TYPE_CHECKING: class WithdrawRoutesHeader(Horizontal): def compose(self) -> ComposeResult: - yield Static("To", classes=CLIVE_ODD_COLUMN_CLASS_NAME) - yield Static("Percent", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) - yield Static("Auto vest", classes=CLIVE_ODD_COLUMN_CLASS_NAME) + yield Static("To", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Percent", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) + yield Static("Auto vest", classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) yield PlaceTaker() -- GitLab From 57f6227e0d370c4d10e41ec0dcaed70f17cb96e6 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 14:00:29 +0100 Subject: [PATCH 077/192] Improve styling of the dashboard --- .../ui/screens/dashboard/dashboard.py | 16 +++++------ .../ui/screens/dashboard/dashboard.scss | 27 +++++++++---------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/clive/__private/ui/screens/dashboard/dashboard.py b/clive/__private/ui/screens/dashboard/dashboard.py index bc1010556f..05f21a4ba9 100644 --- a/clive/__private/ui/screens/dashboard/dashboard.py +++ b/clive/__private/ui/screens/dashboard/dashboard.py @@ -132,7 +132,7 @@ class BalanceStatsButton(DynamicOneLineButtonUnfocusable): balance_type: Literal["liquid", "savings"], asset_type: type[Asset.LiquidT], classes: str | None = None, - variant: CliveButtonVariant = "primary", + variant: CliveButtonVariant = "grey-lighten", ) -> None: super().__init__( obj_to_watch=self.world, @@ -202,13 +202,13 @@ class BalanceStats(TrackedAccountReferencingWidget): def compose(self) -> ComposeResult: yield Static("", classes="empty") yield EllipsedStatic("LIQUID", classes="title") - yield EllipsedStatic("SAVINGS", classes="title title-variant") - yield Static("HIVE", classes="token") - yield BalanceStatsButton(self._account, "liquid", Asset.Hive, variant="grey-darken") - yield BalanceStatsButton(self._account, "savings", Asset.Hive, variant="grey-lighten") - yield Static("HBD", classes="token token-variant") - yield BalanceStatsButton(self._account, "liquid", Asset.Hbd, variant="grey-lighten") - yield BalanceStatsButton(self._account, "savings", Asset.Hbd, variant="grey-darken") + yield EllipsedStatic("SAVINGS", classes="title") + yield Static("HIVE", classes="token-hive") + yield BalanceStatsButton(self._account, "liquid", Asset.Hive) + yield BalanceStatsButton(self._account, "savings", Asset.Hive, variant="grey-darken") + yield Static("HBD", classes="token-hbd") + yield BalanceStatsButton(self._account, "liquid", Asset.Hbd, variant="grey-darken") + yield BalanceStatsButton(self._account, "savings", Asset.Hbd) class TrackedAccountInfo(Container, TrackedAccountReferencingWidget): diff --git a/clive/__private/ui/screens/dashboard/dashboard.scss b/clive/__private/ui/screens/dashboard/dashboard.scss index 9a4d753255..05cf0d8c17 100644 --- a/clive/__private/ui/screens/dashboard/dashboard.scss +++ b/clive/__private/ui/screens/dashboard/dashboard.scss @@ -1,5 +1,5 @@ -$working-color: $secondary-darken-2; -$watched-color: $primary-darken-1; +$working-color: $primary-darken-2; +$watched-color: $secondary-darken-2; Dashboard { AccountsContainer { @@ -79,11 +79,11 @@ Dashboard { height: 2; .even-manabar { - background: $primary-lighten-1; + background: $panel-lighten-1; } .odd-manabar { - background: $primary-lighten-2; + background: $panel-lighten-2; } } @@ -104,12 +104,12 @@ Dashboard { .time { column-span: 6; - background: $panel-lighten-2; + background: $panel; } .hivepower-value { column-span: 4; - background: $panel-lighten-1; + background: $panel-darken-1; } } @@ -132,21 +132,18 @@ Dashboard { } .title { - background: $accent-lighten-3; + background: $primary-darken-3; color: $text; } - .title-variant { - background: $accent-lighten-2; - } - - .token { + .token-hive { margin-left: 2; - background: $accent-lighten-1; + background: $primary-darken-1; } - .token-variant { - background: $accent-lighten-2; + .token-hbd { + margin-left: 2; + background: $primary-lighten-2; } } } -- GitLab From a4cf22727ec078798357df063cb28948e45de7fe Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 14:05:14 +0100 Subject: [PATCH 078/192] Improve styling of alarm screens --- clive/__private/ui/dialogs/alarm_info_dialog.py | 6 +++--- clive/__private/ui/screens/account_details/alarms/alarms.py | 6 +++--- .../__private/ui/screens/account_details/alarms/alarms.scss | 6 +++++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/clive/__private/ui/dialogs/alarm_info_dialog.py b/clive/__private/ui/dialogs/alarm_info_dialog.py index a791718774..e3ec0b78d4 100644 --- a/clive/__private/ui/dialogs/alarm_info_dialog.py +++ b/clive/__private/ui/dialogs/alarm_info_dialog.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Iterable from textual.containers import Horizontal from textual.widgets import Static -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.ui.dialogs.clive_base_dialogs import CliveInfoDialog from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.account_details.alarms.fix_alarm_info_widget import FixAlarmInfoWidget @@ -30,8 +30,8 @@ class AlarmDataHeader(Horizontal): self._columns = columns def compose(self) -> ComposeResult: - for evenness, column in enumerate(self._columns): - yield Static(column, classes=CLIVE_EVEN_COLUMN_CLASS_NAME if evenness % 2 else CLIVE_ODD_COLUMN_CLASS_NAME) + for column in self._columns: + yield Static(column, classes=CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME) class AlarmDataRow(CliveCheckerboardTableRow): diff --git a/clive/__private/ui/screens/account_details/alarms/alarms.py b/clive/__private/ui/screens/account_details/alarms/alarms.py index ecfb60232b..b6c2fb2657 100644 --- a/clive/__private/ui/screens/account_details/alarms/alarms.py +++ b/clive/__private/ui/screens/account_details/alarms/alarms.py @@ -6,7 +6,7 @@ from textual import on from textual.containers import Horizontal from textual.widgets import Static, TabPane -from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME +from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.dialogs import AlarmInfoDialog from clive.__private.ui.get_css import get_css_from_relative_path @@ -32,8 +32,8 @@ if TYPE_CHECKING: class AlarmsTableHeader(Horizontal): def compose(self) -> ComposeResult: - yield Static("Alarm", classes=CLIVE_ODD_COLUMN_CLASS_NAME) - yield Static("Action", classes=CLIVE_EVEN_COLUMN_CLASS_NAME) + yield Static("Alarm", classes=f"{CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME} basic-info-cell-header") + yield Static("Action", classes="action-header") class AlarmsTableRow(CliveCheckerboardTableRow): diff --git a/clive/__private/ui/screens/account_details/alarms/alarms.scss b/clive/__private/ui/screens/account_details/alarms/alarms.scss index 227fda64ad..5872cc33c2 100644 --- a/clive/__private/ui/screens/account_details/alarms/alarms.scss +++ b/clive/__private/ui/screens/account_details/alarms/alarms.scss @@ -26,9 +26,13 @@ Alarms { text-align: center; } - .-odd-column { + .basic-info-cell-header { width: 3fr; } + + .action-header { + background: $primary-darken-2; + } } } } -- GitLab From 5e49e3bc58d9a1ace258514cc48df31782d48ca3 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 14:06:59 +0100 Subject: [PATCH 079/192] Improve styling of the car based screen --- .../ui/screens/cart_based_screen/cart_based_screen.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/screens/cart_based_screen/cart_based_screen.scss b/clive/__private/ui/screens/cart_based_screen/cart_based_screen.scss index b7d771eb3c..69e1945bf2 100644 --- a/clive/__private/ui/screens/cart_based_screen/cart_based_screen.scss +++ b/clive/__private/ui/screens/cart_based_screen/cart_based_screen.scss @@ -12,7 +12,7 @@ CartBasedScreen { Resources { grid-size: 2; grid-rows: 1; - border: $primary; + border: $primary-darken-2; height: auto; #rc-container { @@ -37,7 +37,7 @@ CartBasedScreen { } CartInfoContainer { - border: $secondary; + border: $primary-darken-2; height: 1fr; overflow: hidden; -- GitLab From 9faebd0b399f8bf3b931f2d130e29da242e2a710 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 14:08:26 +0100 Subject: [PATCH 080/192] Improve styling of the clive data table --- clive/__private/ui/widgets/clive_basic/clive_data_table.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/clive/__private/ui/widgets/clive_basic/clive_data_table.py b/clive/__private/ui/widgets/clive_basic/clive_data_table.py index 271730eff4..e20237ad66 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_data_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_data_table.py @@ -18,9 +18,9 @@ class CliveDataTableRow(Horizontal, CliveWidget): """Class that represent the one line of the clive data table.""" DEFAULT_CSS = """ - $row-color-odd: $accent; - $row-color-even: $accent-lighten-1; - $row-title-color: $background-lighten-2; + $row-color-odd: $panel-lighten-2; + $row-color-even: $panel-lighten-3; + $row-title-color: $primary-lighten-2; CliveDataTableRow { layout: horizontal; -- GitLab From a224d6d7639672bb6d824431d9400a480ae5bd72 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 14:36:21 +0100 Subject: [PATCH 081/192] Do not use accent as profile name display in the header --- clive/__private/ui/widgets/clive_basic/clive_header.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clive/__private/ui/widgets/clive_basic/clive_header.scss b/clive/__private/ui/widgets/clive_basic/clive_header.scss index fadf47fffa..23e9a4bf0f 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_header.scss +++ b/clive/__private/ui/widgets/clive_basic/clive_header.scss @@ -22,7 +22,7 @@ CliveHeader { LeftPart { #profile-name { - color: $accent; + color: $primary-darken-1; } #separator { -- GitLab From 8b7022affb66c9cf706e92abfe77035be6109b40 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 14:38:00 +0100 Subject: [PATCH 082/192] Unify border colors --- .../ui/screens/transaction_summary/transaction_summary.scss | 2 +- clive/__private/ui/widgets/inputs/clive_input.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.scss b/clive/__private/ui/screens/transaction_summary/transaction_summary.scss index 41e47fbb59..f267f2bc31 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.scss +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.scss @@ -1,4 +1,4 @@ -$metadata-color: $primary-darken-1; +$metadata-color: $primary; TransactionSummary { Subtitle { diff --git a/clive/__private/ui/widgets/inputs/clive_input.py b/clive/__private/ui/widgets/inputs/clive_input.py index 80aadc873c..73de740eb3 100644 --- a/clive/__private/ui/widgets/inputs/clive_input.py +++ b/clive/__private/ui/widgets/inputs/clive_input.py @@ -22,7 +22,7 @@ class CliveInput(Input): """A custom input that shows a title on the border top-left corner.""" DEFAULT_CSS = """ - $regular-color: $accent + $regular-color: $primary; $invalid-color: $error; $valid-color: $success-darken-3; -- GitLab From 67dcdcc4aa06cdb0004ffdd8dc1f9946aa7f285d Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 21 Jan 2025 14:38:23 +0100 Subject: [PATCH 083/192] Add to cart and finalize transaction buttons are one line buttons now --- clive/__private/ui/widgets/buttons/__init__.py | 3 +-- .../__private/ui/widgets/buttons/add_to_cart_button.py | 6 +++--- .../ui/widgets/buttons/finalize_transaction_button.py | 10 ++-------- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/clive/__private/ui/widgets/buttons/__init__.py b/clive/__private/ui/widgets/buttons/__init__.py index 5590383002..8313624625 100644 --- a/clive/__private/ui/widgets/buttons/__init__.py +++ b/clive/__private/ui/widgets/buttons/__init__.py @@ -6,7 +6,7 @@ from .cancel_button import CancelButton, CancelOneLineButton from .clive_button import CliveButton from .close_button import CloseButton, CloseOneLineButton from .confirm_button import ConfirmButton, ConfirmOneLineButton -from .finalize_transaction_button import FinalizeTransactionButton, FinalizeTransactionOneLineButton +from .finalize_transaction_button import FinalizeTransactionButton from .generous_button import GenerousButton from .one_line_button import OneLineButton, OneLineButtonUnfocusable from .page_switch_buttons import PageDownButton, PageDownOneLineButton, PageUpButton, PageUpOneLineButton @@ -27,7 +27,6 @@ __all__ = [ "ConfirmButton", "ConfirmOneLineButton", "FinalizeTransactionButton", - "FinalizeTransactionOneLineButton", "GenerousButton", "OneLineButton", "OneLineButtonUnfocusable", diff --git a/clive/__private/ui/widgets/buttons/add_to_cart_button.py b/clive/__private/ui/widgets/buttons/add_to_cart_button.py index 7292666bab..e05abdd062 100644 --- a/clive/__private/ui/widgets/buttons/add_to_cart_button.py +++ b/clive/__private/ui/widgets/buttons/add_to_cart_button.py @@ -1,16 +1,16 @@ from __future__ import annotations -from clive.__private.ui.widgets.buttons.clive_button import CliveButton +from clive.__private.ui.widgets.buttons.one_line_button import OneLineButton -class AddToCartButton(CliveButton): +class AddToCartButton(OneLineButton): DEFAULT_CSS = """ AddToCartButton { width: 25; } """ - class Pressed(CliveButton.Pressed): + class Pressed(OneLineButton.Pressed): """Message send when AddToCartButton is pressed.""" def __init__(self) -> None: diff --git a/clive/__private/ui/widgets/buttons/finalize_transaction_button.py b/clive/__private/ui/widgets/buttons/finalize_transaction_button.py index eb5d7adcd4..cdfd1c984d 100644 --- a/clive/__private/ui/widgets/buttons/finalize_transaction_button.py +++ b/clive/__private/ui/widgets/buttons/finalize_transaction_button.py @@ -1,18 +1,17 @@ from __future__ import annotations from clive.__private.core.constants.tui.bindings import FINALIZE_TRANSACTION_BINDING_KEY -from clive.__private.ui.widgets.buttons.clive_button import CliveButton from clive.__private.ui.widgets.buttons.one_line_button import OneLineButton -class FinalizeTransactionButton(CliveButton): +class FinalizeTransactionButton(OneLineButton): DEFAULT_CSS = """ FinalizeTransactionButton { width: 29; } """ - class Pressed(CliveButton.Pressed): + class Pressed(OneLineButton.Pressed): """Used to identify exactly that FinalizeTransactionButton was pressed.""" def __init__(self) -> None: @@ -21,8 +20,3 @@ class FinalizeTransactionButton(CliveButton): variant="success", id_="finalize-button", ) - - -class FinalizeTransactionOneLineButton(OneLineButton, FinalizeTransactionButton): - class Pressed(FinalizeTransactionButton.Pressed): - """Message sent when FinalizeTransactionOneLineButton is pressed.""" -- GitLab From 468ae172ba5974eb8d1b24450727d5e4464dad6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:22 +0100 Subject: [PATCH 084/192] Do not set default theme via reactive Rationale: Doing it that way disables the feature of TEXTUAL_THEME env variable. If we want to override textual default we should probably do it via our settings.toml so thing like CLIVE_THEME would work. --- clive/__private/ui/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 161b8239ea..9a9b156526 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -182,8 +182,6 @@ class Clive(App[int]): else: self.switch_mode("create_profile") - self.theme = "textual-dark" - async def on_unmount(self) -> None: if self._world is not None: # There might be an exception during world setup and therefore world might not be available. -- GitLab From 68ba4de14348493a8138c6768d7ee80dc059a794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:23 +0100 Subject: [PATCH 085/192] Bump textual to 0.86.1 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 9a924e135d..7402777038 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.86.0" +version = "0.86.1" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.86.0-py3-none-any.whl", hash = "sha256:ae661f28e32aae96e7f0e9301e35f5ad2260b7e7259b2abac96306c956c66a09"}, - {file = "textual-0.86.0.tar.gz", hash = "sha256:db90ed4d41015722f0f49796761b314ca85dbd32871a3078275df21bb74a76ce"}, + {file = "textual-0.86.1-py3-none-any.whl", hash = "sha256:ebc2bfd92c2f1a451c12dcbcfda002598a2fc9793d1684be83514b5fba75f915"}, + {file = "textual-0.86.1.tar.gz", hash = "sha256:a6e68de5383415f222f26b4049c2a92ed204071fdfebe3d729f8dd373ca5f519"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "9a85ca34a0c3639f8496f2482dccc8fc6c927b83ef0f795944e74b20a0ef9a22" +content-hash = "984361fb7994d48630926de43915f94699660f81b3b12d22f5ae72ddc94038c2" diff --git a/pyproject.toml b/pyproject.toml index 57257991d5..bc84c9aeb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.86.0" +textual = "0.86.1" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 55283bee8623a9fc183d9f84eac161cdb34aae0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:25 +0100 Subject: [PATCH 086/192] Bump textual to 0.86.2 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7402777038..b8658c3960 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.86.1" +version = "0.86.2" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.86.1-py3-none-any.whl", hash = "sha256:ebc2bfd92c2f1a451c12dcbcfda002598a2fc9793d1684be83514b5fba75f915"}, - {file = "textual-0.86.1.tar.gz", hash = "sha256:a6e68de5383415f222f26b4049c2a92ed204071fdfebe3d729f8dd373ca5f519"}, + {file = "textual-0.86.2-py3-none-any.whl", hash = "sha256:2c0744feda959554aff1f169341e6e1fba6e51cdc45fb0a878675307d5719ca6"}, + {file = "textual-0.86.2.tar.gz", hash = "sha256:43073f9eb52d0f38a5af2f124ba68b378f91d260df4b1c92f384a3b5cbbbcb70"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "984361fb7994d48630926de43915f94699660f81b3b12d22f5ae72ddc94038c2" +content-hash = "a9d2cd43ea11c717405a8c54e90be36cbdb294425ddb749d7ec6907cd81a9b54" diff --git a/pyproject.toml b/pyproject.toml index bc84c9aeb5..271ac32a18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.86.1" +textual = "0.86.2" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From e2937c4acf7ca3a5b7f37c222f69f89dab9a1627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:26 +0100 Subject: [PATCH 087/192] Bump textual to 0.86.3 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index b8658c3960..4665784e91 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.86.2" +version = "0.86.3" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.86.2-py3-none-any.whl", hash = "sha256:2c0744feda959554aff1f169341e6e1fba6e51cdc45fb0a878675307d5719ca6"}, - {file = "textual-0.86.2.tar.gz", hash = "sha256:43073f9eb52d0f38a5af2f124ba68b378f91d260df4b1c92f384a3b5cbbbcb70"}, + {file = "textual-0.86.3-py3-none-any.whl", hash = "sha256:ffe85bc749de7d71e0e048af301b6027abfca8942263ffb680620261cd1baa6f"}, + {file = "textual-0.86.3.tar.gz", hash = "sha256:3c4d68612243af351e8b2d3dabe44d3cf87624624d7ea657f4d718853206188f"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "a9d2cd43ea11c717405a8c54e90be36cbdb294425ddb749d7ec6907cd81a9b54" +content-hash = "f9f371f9264602a430b869671570c2332fef579c8d80cd73dba51c152de7677e" diff --git a/pyproject.toml b/pyproject.toml index 271ac32a18..23af966961 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.86.2" +textual = "0.86.3" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 2cb32e192edc500a654e67cbbb3e1f110add767e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:28 +0100 Subject: [PATCH 088/192] Bump textual to 0.87.0 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4665784e91..97a54ad9f9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.86.3" +version = "0.87.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.86.3-py3-none-any.whl", hash = "sha256:ffe85bc749de7d71e0e048af301b6027abfca8942263ffb680620261cd1baa6f"}, - {file = "textual-0.86.3.tar.gz", hash = "sha256:3c4d68612243af351e8b2d3dabe44d3cf87624624d7ea657f4d718853206188f"}, + {file = "textual-0.87.0-py3-none-any.whl", hash = "sha256:e0f3e51e9568b7639011e4eb07082e8d0179576ea35df1a2181daabc7ba4c108"}, + {file = "textual-0.87.0.tar.gz", hash = "sha256:15638863197dcc08f906523ef60341be754269b29fd656625a716aef7503d45e"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "f9f371f9264602a430b869671570c2332fef579c8d80cd73dba51c152de7677e" +content-hash = "b78f714296c326ba1d4ddd09bfd6e11f3068a5ea4b4fa61d1b705e80f8feab87" diff --git a/pyproject.toml b/pyproject.toml index 23af966961..dfac2dbbe8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.86.3" +textual = "0.87.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 4a885a40e23bf4fe1f5fa60c9de3b40587d4c2b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:31 +0100 Subject: [PATCH 089/192] Bump textual to 0.87.1 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 97a54ad9f9..adb0e85505 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.87.0" +version = "0.87.1" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.87.0-py3-none-any.whl", hash = "sha256:e0f3e51e9568b7639011e4eb07082e8d0179576ea35df1a2181daabc7ba4c108"}, - {file = "textual-0.87.0.tar.gz", hash = "sha256:15638863197dcc08f906523ef60341be754269b29fd656625a716aef7503d45e"}, + {file = "textual-0.87.1-py3-none-any.whl", hash = "sha256:026d1368cd10610a72a9d3de7a56692a17e7e8dffa0468147eb8e186ba0ff0c0"}, + {file = "textual-0.87.1.tar.gz", hash = "sha256:daf4e248ba3d890831ff2617099535eb835863a2e3609c8ce00af0f6d55ed123"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "b78f714296c326ba1d4ddd09bfd6e11f3068a5ea4b4fa61d1b705e80f8feab87" +content-hash = "c6930df28848f153fd34501427f32e24c3737fb749eaf5bd3ddfca1f15360f1d" diff --git a/pyproject.toml b/pyproject.toml index dfac2dbbe8..0c4184714c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.87.0" +textual = "0.87.1" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 6e10298f81ad6b2249c8bc2dd289239be0c41f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:33 +0100 Subject: [PATCH 090/192] Bump textual to 0.88.0 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index adb0e85505..d586d285b9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.87.1" +version = "0.88.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.87.1-py3-none-any.whl", hash = "sha256:026d1368cd10610a72a9d3de7a56692a17e7e8dffa0468147eb8e186ba0ff0c0"}, - {file = "textual-0.87.1.tar.gz", hash = "sha256:daf4e248ba3d890831ff2617099535eb835863a2e3609c8ce00af0f6d55ed123"}, + {file = "textual-0.88.0-py3-none-any.whl", hash = "sha256:87a1085a403e3a95aa4b954c530d46947d830e9ad4b8c15490104c0b4a452b6a"}, + {file = "textual-0.88.0.tar.gz", hash = "sha256:bf9cc3ec9d34957c361eabf739e59272295323478cc822633fb0a7b7cc2a0ac3"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c6930df28848f153fd34501427f32e24c3737fb749eaf5bd3ddfca1f15360f1d" +content-hash = "4e3678907f8ba9c5aa2a66335c303bee3cb4356850dcbd05580f8b9fb936658c" diff --git a/pyproject.toml b/pyproject.toml index 0c4184714c..79b5f4b34e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.87.1" +textual = "0.88.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From ff010b7e4fc6530ed58ce6de39af44079bc974a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:34 +0100 Subject: [PATCH 091/192] Bump textual to 0.88.1 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index d586d285b9..8bb0b269c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.88.0" +version = "0.88.1" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.88.0-py3-none-any.whl", hash = "sha256:87a1085a403e3a95aa4b954c530d46947d830e9ad4b8c15490104c0b4a452b6a"}, - {file = "textual-0.88.0.tar.gz", hash = "sha256:bf9cc3ec9d34957c361eabf739e59272295323478cc822633fb0a7b7cc2a0ac3"}, + {file = "textual-0.88.1-py3-none-any.whl", hash = "sha256:f2db8ce892007f724dab57a2b791e55f9d7ce04d333c50fb4b6fb7f3990d4cec"}, + {file = "textual-0.88.1.tar.gz", hash = "sha256:9c56d953dc7d1a8ddf06acc910d9224027e02416551f92920e70f435bd28e062"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "4e3678907f8ba9c5aa2a66335c303bee3cb4356850dcbd05580f8b9fb936658c" +content-hash = "584d7bdc67e35378cd6c4d24901d7a7bed80aac4a73190390b2e2c28d771e17e" diff --git a/pyproject.toml b/pyproject.toml index 79b5f4b34e..5d55598e46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.88.0" +textual = "0.88.1" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 59f3a5972aba44927d283a5e631fcedfb9688656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:35 +0100 Subject: [PATCH 092/192] Bump textual to 0.89.0 --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8bb0b269c3..4f966e3200 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.88.1" +version = "0.89.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.88.1-py3-none-any.whl", hash = "sha256:f2db8ce892007f724dab57a2b791e55f9d7ce04d333c50fb4b6fb7f3990d4cec"}, - {file = "textual-0.88.1.tar.gz", hash = "sha256:9c56d953dc7d1a8ddf06acc910d9224027e02416551f92920e70f435bd28e062"}, + {file = "textual-0.89.0-py3-none-any.whl", hash = "sha256:2968094ad6d0caee3f26700861cfd505dcb7ed17070041e1c42b6066010f3906"}, + {file = "textual-0.89.0.tar.gz", hash = "sha256:b3282598ded248410623f0c5ee83cbfc8f5424aa0f92222bc44fdc422700a03f"}, ] [package.dependencies] @@ -1395,7 +1395,7 @@ rich = ">=13.3.3" typing-extensions = ">=4.4.0,<5.0.0" [package.extras] -syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree-sitter-languages (==1.10.2)"] +syntax = ["tree-sitter (>=0.23.0)", "tree-sitter-bash (>=0.23.0)", "tree-sitter-css (>=0.23.0)", "tree-sitter-go (>=0.23.0)", "tree-sitter-html (>=0.23.0)", "tree-sitter-java (>=0.23.0)", "tree-sitter-javascript (>=0.23.0)", "tree-sitter-json (>=0.24.0)", "tree-sitter-markdown (>=0.3.0)", "tree-sitter-python (>=0.23.0)", "tree-sitter-regex (>=0.24.0)", "tree-sitter-rust (>=0.23.0)", "tree-sitter-sql (>=0.3.0)", "tree-sitter-toml (>=0.6.0)", "tree-sitter-xml (>=0.7.0)", "tree-sitter-yaml (>=0.6.0)"] [[package]] name = "textual-dev" @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "584d7bdc67e35378cd6c4d24901d7a7bed80aac4a73190390b2e2c28d771e17e" +content-hash = "64a300ecd5c5489a52a70f728483d949be2d6b3f04eacaa21013aa5ec0f18b5a" diff --git a/pyproject.toml b/pyproject.toml index 5d55598e46..ce6599c704 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.88.1" +textual = "0.89.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From ffc7df92ddf11675becb923a2f5f7478d9869a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:38 +0100 Subject: [PATCH 093/192] Bump textual to 0.89.1 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4f966e3200..f3e6892404 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.89.0" +version = "0.89.1" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.89.0-py3-none-any.whl", hash = "sha256:2968094ad6d0caee3f26700861cfd505dcb7ed17070041e1c42b6066010f3906"}, - {file = "textual-0.89.0.tar.gz", hash = "sha256:b3282598ded248410623f0c5ee83cbfc8f5424aa0f92222bc44fdc422700a03f"}, + {file = "textual-0.89.1-py3-none-any.whl", hash = "sha256:0a5d214df6e951b4a2c421e13d0b608482882471c1e34ea74a3631adede8054f"}, + {file = "textual-0.89.1.tar.gz", hash = "sha256:66befe80e2bca5a8c876cd8ceeaf01752267b6b1dc1d0f73071f1f1e15d90cc8"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "64a300ecd5c5489a52a70f728483d949be2d6b3f04eacaa21013aa5ec0f18b5a" +content-hash = "71bd02fb7682f69ae8eb6c1ecea3534879ae337683fb3887ea86e2e27112ed61" diff --git a/pyproject.toml b/pyproject.toml index ce6599c704..2b70bb2a81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.89.0" +textual = "0.89.1" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From b6ee0b48cf6a2d4ea9acb44d4a5404d7803a4253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:40 +0100 Subject: [PATCH 094/192] Bump textual to 1.0.0 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index f3e6892404..26d7c92803 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "0.89.1" +version = "1.0.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-0.89.1-py3-none-any.whl", hash = "sha256:0a5d214df6e951b4a2c421e13d0b608482882471c1e34ea74a3631adede8054f"}, - {file = "textual-0.89.1.tar.gz", hash = "sha256:66befe80e2bca5a8c876cd8ceeaf01752267b6b1dc1d0f73071f1f1e15d90cc8"}, + {file = "textual-1.0.0-py3-none-any.whl", hash = "sha256:2d4a701781c05104925e463ae370c630567c70c2880e92ab838052e3e23c986f"}, + {file = "textual-1.0.0.tar.gz", hash = "sha256:bec9fe63547c1c552569d1b75d309038b7d456c03f86dfa3706ddb099b151399"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "71bd02fb7682f69ae8eb6c1ecea3534879ae337683fb3887ea86e2e27112ed61" +content-hash = "9381bcd7a47c23a850bee6809db12096c3b9d763daa9f767513623be6ae31622" diff --git a/pyproject.toml b/pyproject.toml index 2b70bb2a81..17d2b81366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "0.89.1" +textual = "1.0.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 896ad7e74b24616891f72745b05f57e4ca5494e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:41 +0100 Subject: [PATCH 095/192] Change quit binding from Ctrl+X to Ctrl+Q See: https://github.com/Textualize/textual/releases/tag/v1.0.0 Ctrl+C is used for copy now (previously SIGINT) Ctrl+X is used for cut (previously quit) --- clive/__private/core/constants/tui/bindings.py | 1 + clive/__private/ui/app.py | 3 ++- clive/__private/ui/global_help.md | 2 +- clive/__private/ui/screens/quit/quit.py | 5 +++-- tests/clive-local-tools/clive_local_tools/tui/clive_quit.py | 5 +++-- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/clive/__private/core/constants/tui/bindings.py b/clive/__private/core/constants/tui/bindings.py index c48bb01005..0e09c4eacd 100644 --- a/clive/__private/core/constants/tui/bindings.py +++ b/clive/__private/core/constants/tui/bindings.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Final +APP_QUIT_KEY_BINDING: Final[str] = "ctrl+q" NEXT_SCREEN_BINDING_KEY: Final[str] = "ctrl+n" PREVIOUS_SCREEN_BINDING_KEY: Final[str] = "ctrl+p" FINALIZE_TRANSACTION_BINDING_KEY: Final[str] = "f6" diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 9a9b156526..fbfd0477ce 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -15,6 +15,7 @@ from textual.notifications import Notification, Notify, SeverityLevel from textual.reactive import var from clive.__private.core.constants.terminal import TERMINAL_HEIGHT, TERMINAL_WIDTH +from clive.__private.core.constants.tui.bindings import APP_QUIT_KEY_BINDING from clive.__private.core.profile import Profile from clive.__private.core.world import TUIWorld from clive.__private.logger import logger @@ -55,7 +56,7 @@ class Clive(App[int]): BINDINGS = [ Binding("ctrl+s", "app.screenshot()", "Screenshot", show=False), - Binding("ctrl+x", "push_screen('quit')", "Quit", show=False), + Binding(APP_QUIT_KEY_BINDING, "push_screen('quit')", "Quit", show=False), Binding("c", "clear_notifications", "Clear notifications", show=False), Binding("f1", "help", "Help", show=False), Binding("f7", "go_to_transaction_summary", "Transaction summary", show=False), diff --git a/clive/__private/ui/global_help.md b/clive/__private/ui/global_help.md index b7c7721056..e2e513b8f6 100644 --- a/clive/__private/ui/global_help.md +++ b/clive/__private/ui/global_help.md @@ -9,7 +9,7 @@ | `F1` | Show help | | `F7` | Go to transaction summary | | `F8` | Go to dashboard | -| `Ctrl+X` | Quit | +| `Ctrl+Q` | Quit | | `Ctrl+S` | Screenshot | | `C` | Clear notifications | diff --git a/clive/__private/ui/screens/quit/quit.py b/clive/__private/ui/screens/quit/quit.py index 07617ea86c..a7416e61c8 100644 --- a/clive/__private/ui/screens/quit/quit.py +++ b/clive/__private/ui/screens/quit/quit.py @@ -7,6 +7,7 @@ from textual.binding import Binding from textual.containers import Horizontal from textual.widgets import Static +from clive.__private.core.constants.tui.bindings import APP_QUIT_KEY_BINDING from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen from clive.__private.ui.widgets.buttons import CliveButton @@ -20,7 +21,7 @@ class Quit(BaseScreen): CSS_PATH = [get_relative_css_path(__file__)] BINDINGS = [ - Binding("ctrl+x", "exit_cleanly", "Quit"), + Binding(APP_QUIT_KEY_BINDING, "exit_cleanly", "Quit"), Binding("escape", "cancel", "Back"), ] @@ -29,7 +30,7 @@ class Quit(BaseScreen): def create_main_panel(self) -> ComposeResult: with DialogContainer(): yield Static("Are you sure you want to quit?", id="question") - yield Static("(You can also confirm by pressing Ctrl+X again)", id="hint") + yield Static(f"(You can also confirm by pressing {APP_QUIT_KEY_BINDING} again)", id="hint") with Horizontal(id="buttons"): yield CliveButton("Quit", variant="error", id_="quit") yield CliveButton("Cancel", id_="cancel") diff --git a/tests/clive-local-tools/clive_local_tools/tui/clive_quit.py b/tests/clive-local-tools/clive_local_tools/tui/clive_quit.py index f023238669..c0a0a90470 100644 --- a/tests/clive-local-tools/clive_local_tools/tui/clive_quit.py +++ b/tests/clive-local-tools/clive_local_tools/tui/clive_quit.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from clive.__private.core.constants.tui.bindings import APP_QUIT_KEY_BINDING from clive.__private.ui.screens.quit import Quit from clive_local_tools.tui.textual_helpers import press_and_wait_for_screen, press_binding @@ -12,5 +13,5 @@ if TYPE_CHECKING: async def clive_quit(pilot: ClivePilot) -> None: """Clean exit Clive from any screen.""" quit_binding_desc = "Quit" - await press_and_wait_for_screen(pilot, "ctrl+x", Quit, key_description=quit_binding_desc) - await press_binding(pilot, "ctrl+x", quit_binding_desc) + await press_and_wait_for_screen(pilot, APP_QUIT_KEY_BINDING, Quit, key_description=quit_binding_desc) + await press_binding(pilot, APP_QUIT_KEY_BINDING, quit_binding_desc) -- GitLab From 038717c23ad5dbfcc0b3feabac0f606315b0406d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:43 +0100 Subject: [PATCH 096/192] Do not include special rules of bnding order for ctrl+x --- clive/__private/ui/clive_screen.py | 4 ++-- tests/unit/test_sort_bindings.py | 11 ----------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/clive/__private/ui/clive_screen.py b/clive/__private/ui/clive_screen.py index 7f8460d7fc..2cda80864b 100644 --- a/clive/__private/ui/clive_screen.py +++ b/clive/__private/ui/clive_screen.py @@ -115,7 +115,7 @@ class CliveScreen(Screen[ScreenResultT], CliveWidget): """ Sort bindings in a Clive-way. - By placing the CTRL+X key first, then the ESC, then non-fn keys and fn keys at the end of the dictionary. + By placing the ESC key first, then non-fn keys and fn keys at the end of the dictionary. This is done so that the bindings in the footer are displayed in a correct, uniform way. Args: @@ -131,7 +131,7 @@ class CliveScreen(Screen[ScreenResultT], CliveWidget): # place keys stored in container at the beginning of the list container = [] - for key in ("ctrl+x", "escape"): + for key in ("escape",): if key in non_fn_keys: non_fn_keys.remove(key) container.append(key) diff --git a/tests/unit/test_sort_bindings.py b/tests/unit/test_sort_bindings.py index a2e60aa99d..8064ed368e 100644 --- a/tests/unit/test_sort_bindings.py +++ b/tests/unit/test_sort_bindings.py @@ -22,8 +22,6 @@ ADDITIONAL_CHARACTER_BINDINGS: Final[dict[str, str]] = create_binding_dict("a", ESC_BINDING: Final[dict[str, str]] = create_binding_dict("escape") -CTRLX_BINDING: Final[dict[str, str]] = create_binding_dict("ctrl+x") - def dicts_equal_with_order(dict_a: dict[Any, Any], dict_b: dict[Any, Any]) -> bool: """ @@ -55,12 +53,3 @@ def test_sorting_with_esc_binding() -> None: # ACT & ASSERT assert dicts_equal_with_order(METHOD_TO_TEST(dict_to_sort), dict_sorted) - - -def test_sorting_with_ctrlx_binding() -> None: - # ARRANGE - dict_to_sort = FN_BINDINGS_UNSORTED | ADDITIONAL_CHARACTER_BINDINGS | ESC_BINDING | CTRLX_BINDING - dict_sorted = CTRLX_BINDING | ESC_BINDING | ADDITIONAL_CHARACTER_BINDINGS | FN_BINDINGS_SORTED - - # ACT & ASSERT - assert dicts_equal_with_order(METHOD_TO_TEST(dict_to_sort), dict_sorted) -- GitLab From 5ac4c63ab5d4db1d8c3693daed989573ce053395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:44 +0100 Subject: [PATCH 097/192] Refactor sort_bindings --- clive/__private/ui/clive_screen.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/clive/__private/ui/clive_screen.py b/clive/__private/ui/clive_screen.py index 2cda80864b..25541b7a89 100644 --- a/clive/__private/ui/clive_screen.py +++ b/clive/__private/ui/clive_screen.py @@ -129,12 +129,15 @@ class CliveScreen(Screen[ScreenResultT], CliveWidget): fn_keys = sorted([key for key in data if key.startswith("f")], key=lambda x: int(x[1:])) non_fn_keys = [key for key in data if key not in fn_keys] - # place keys stored in container at the beginning of the list - container = [] - for key in ("escape",): + prioritized = ("escape",) + prioritized_matches = [] + for key in prioritized: + if key in fn_keys: + fn_keys.remove(key) + prioritized_matches.append(key) if key in non_fn_keys: non_fn_keys.remove(key) - container.append(key) + prioritized_matches.append(key) - sorted_keys = container + non_fn_keys + fn_keys - return {key: data[key] for key in sorted_keys} + ordered_keys = prioritized_matches + non_fn_keys + fn_keys + return {key: data[key] for key in ordered_keys} -- GitLab From 0915c3e6c4fd94965fe181bd2fa8fcc54ae39bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:46 +0100 Subject: [PATCH 098/192] Bump textual to 2.0.0 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 26d7c92803..03f6181053 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "1.0.0" +version = "2.0.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-1.0.0-py3-none-any.whl", hash = "sha256:2d4a701781c05104925e463ae370c630567c70c2880e92ab838052e3e23c986f"}, - {file = "textual-1.0.0.tar.gz", hash = "sha256:bec9fe63547c1c552569d1b75d309038b7d456c03f86dfa3706ddb099b151399"}, + {file = "textual-2.0.0-py3-none-any.whl", hash = "sha256:8de36e5f77e97e0e2b54fd895d474416f9c814b6fff542e6a5664e3a952fe1d6"}, + {file = "textual-2.0.0.tar.gz", hash = "sha256:5b32816fb3f2ba6aabd589be295b68d78dd2d0f41279e549f499d9d8418e67cf"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "9381bcd7a47c23a850bee6809db12096c3b9d763daa9f767513623be6ae31622" +content-hash = "ea05aefe29af9d8440b7e5e50e1259aa335dfd1131ed593c13ff8331b5f08911" diff --git a/pyproject.toml b/pyproject.toml index 17d2b81366..dd8041f0c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "1.0.0" +textual = "2.0.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 9ed94bd708d113b5a70af29ca6fe64c6c9b90178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:47 +0100 Subject: [PATCH 099/192] Remove unused title init param of Clive tab panes --- .../governance_operations.py | 6 +++--- .../governance_operations/proposals/proposals.py | 5 ++--- .../governance_operations/proxy/proxy.py | 5 ++--- .../governance_operations/witness/witness.py | 5 ++--- .../delegate_hive_power/delegate_hive_power.py | 12 ++---------- .../hive_power_management.py | 15 +++++---------- .../power_down/power_down.py | 12 ++---------- .../hive_power_management/power_up/power_up.py | 12 ++---------- .../withdraw_routes/withdraw_routes.py | 12 ++---------- 9 files changed, 22 insertions(+), 62 deletions(-) diff --git a/clive/__private/ui/screens/operations/governance_operations/governance_operations.py b/clive/__private/ui/screens/operations/governance_operations/governance_operations.py index 566365ca47..80edd41fb4 100644 --- a/clive/__private/ui/screens/operations/governance_operations/governance_operations.py +++ b/clive/__private/ui/screens/operations/governance_operations/governance_operations.py @@ -34,9 +34,9 @@ class Governance(OperationBaseScreen): with WitnessesDataProvider(paused=True), ProposalsDataProvider(paused=True), CliveTabbedContent( initial=self._initial_tab ): - yield Proxy("Proxy") - yield Witnesses("Witnesses") - yield Proposals("Proposals") + yield Proxy() + yield Witnesses() + yield Proposals() @on(CliveTabbedContent.TabActivated) def change_provider_status(self, event: CliveTabbedContent.TabActivated) -> None: diff --git a/clive/__private/ui/screens/operations/governance_operations/proposals/proposals.py b/clive/__private/ui/screens/operations/governance_operations/proposals/proposals.py index 4a49a8d605..3cdc0e5eda 100644 --- a/clive/__private/ui/screens/operations/governance_operations/proposals/proposals.py +++ b/clive/__private/ui/screens/operations/governance_operations/proposals/proposals.py @@ -36,7 +36,6 @@ from clive.__private.ui.widgets.section_title import SectionTitle if TYPE_CHECKING: from typing import Final - from rich.text import TextType from textual.app import ComposeResult from typing_extensions import TypeIs @@ -255,8 +254,8 @@ class Proposals(GovernanceTabPane): DEFAULT_CSS = get_css_from_relative_path(__file__) - def __init__(self, title: TextType) -> None: - super().__init__(title=title, id="proposals") + def __init__(self) -> None: + super().__init__(title="Proposals", id="proposals") def compose(self) -> ComposeResult: self.__proposals_table = ProposalsTable() diff --git a/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py b/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py index 2ec1c5c1a3..675c080e39 100644 --- a/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py +++ b/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py @@ -17,7 +17,6 @@ from clive.__private.ui.widgets.scrolling import ScrollablePart from clive.__private.ui.widgets.section import SectionScrollable if TYPE_CHECKING: - from rich.text import TextType from textual.app import ComposeResult @@ -77,8 +76,8 @@ class Proxy(TabPane, CliveWidget): DEFAULT_CSS = get_css_from_relative_path(__file__) - def __init__(self, title: TextType) -> None: - super().__init__(title=title, id="proxy") + def __init__(self) -> None: + super().__init__(title="Proxy", id="proxy") self._current_proxy = self.profile.accounts.working.data.proxy def on_mount(self) -> None: diff --git a/clive/__private/ui/screens/operations/governance_operations/witness/witness.py b/clive/__private/ui/screens/operations/governance_operations/witness/witness.py index 10c42e257a..9ce2f93366 100644 --- a/clive/__private/ui/screens/operations/governance_operations/witness/witness.py +++ b/clive/__private/ui/screens/operations/governance_operations/witness/witness.py @@ -42,7 +42,6 @@ from clive.__private.ui.widgets.section_title import SectionTitle if TYPE_CHECKING: from typing import Final - from rich.text import TextType from textual.app import ComposeResult from typing_extensions import TypeIs @@ -291,8 +290,8 @@ class Witnesses(GovernanceTabPane): DEFAULT_CSS = get_css_from_relative_path(__file__) - def __init__(self, title: TextType) -> None: - super().__init__(title=title, id="witnesses") + def __init__(self) -> None: + super().__init__(title="Witnesses", id="witnesses") def compose(self) -> ComposeResult: self.__witness_table = WitnessesTable() diff --git a/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py b/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py index 74d1ff89cd..03f1d438aa 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py +++ b/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py @@ -29,7 +29,6 @@ from clive.__private.ui.widgets.section import Section from clive.__private.ui.widgets.transaction_buttons import TransactionButtons if TYPE_CHECKING: - from rich.text import TextType from textual.app import ComposeResult from clive.__private.core.commands.data_retrieval.hive_power_data import HivePowerData @@ -112,15 +111,8 @@ class DelegateHivePower(TabPane, OperationActionBindings): DEFAULT_CSS = get_css_from_relative_path(__file__) - def __init__(self, title: TextType) -> None: - """ - Initialize a TabPane. - - Args: - ---- - title: Title of the TabPane (will be displayed in a tab label). - """ - super().__init__(title=title) + def __init__(self) -> None: + super().__init__(title="Delegate") self._delegate_input = ReceiverInput("Delegate") self._shares_input = HPVestsAmountInput() diff --git a/clive/__private/ui/screens/operations/hive_power_management/hive_power_management.py b/clive/__private/ui/screens/operations/hive_power_management/hive_power_management.py index a02fb59133..cb4bc9797f 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/hive_power_management.py +++ b/clive/__private/ui/screens/operations/hive_power_management/hive_power_management.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING from textual.containers import Horizontal @@ -27,11 +27,6 @@ from clive.__private.ui.widgets.location_indicator import LocationIndicator if TYPE_CHECKING: from textual.app import ComposeResult -POWER_UP_TAB_LABEL: Final[str] = "Power up" -POWER_DOWN_TAB_LABEL: Final[str] = "Power down" -WITHDRAW_ROUTES_TAB_LABEL: Final[str] = "Withdraw routes" -DELEGATE_HIVE_POWER_LABEL: Final[str] = "Delegate" - class HivePowerManagement(OperationBaseScreen): CSS_PATH = [ @@ -48,7 +43,7 @@ class HivePowerManagement(OperationBaseScreen): yield WithdrawalInfo(provider) yield HivePowerAPR(provider) with CliveTabbedContent(): - yield PowerUp(POWER_UP_TAB_LABEL) - yield PowerDown(POWER_DOWN_TAB_LABEL) - yield WithdrawRoutes(WITHDRAW_ROUTES_TAB_LABEL) - yield DelegateHivePower(DELEGATE_HIVE_POWER_LABEL) + yield PowerUp() + yield PowerDown() + yield WithdrawRoutes() + yield DelegateHivePower() diff --git a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py index 33b63eaebf..730f0df659 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py +++ b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py @@ -35,7 +35,6 @@ from clive.__private.ui.widgets.transaction_buttons import TransactionButtons if TYPE_CHECKING: from datetime import datetime - from rich.text import TextType from textual.app import ComposeResult from clive.__private.core.commands.data_retrieval.hive_power_data import HivePowerData @@ -132,15 +131,8 @@ class PowerDown(TabPane, OperationActionBindings): DEFAULT_CSS = get_css_from_relative_path(__file__) - def __init__(self, title: TextType) -> None: - """ - Initialize the PowerDown tab-pane. - - Args: - ---- - title: Title of the TabPane (will be displayed in a tab label). - """ - super().__init__(title=title) + def __init__(self) -> None: + super().__init__(title="Power down") self._shares_input = HPVestsAmountInput() self._one_withdrawal_display = Notice( obj_to_watch=self._shares_input.input, diff --git a/clive/__private/ui/screens/operations/hive_power_management/power_up/power_up.py b/clive/__private/ui/screens/operations/hive_power_management/power_up/power_up.py index cab38b2938..39d4d8ef1e 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/power_up/power_up.py +++ b/clive/__private/ui/screens/operations/hive_power_management/power_up/power_up.py @@ -18,7 +18,6 @@ from clive.__private.ui.widgets.section import Section from clive.__private.ui.widgets.transaction_buttons import TransactionButtons if TYPE_CHECKING: - from rich.text import TextType from textual.app import ComposeResult from clive.__private.models import Asset @@ -29,15 +28,8 @@ class PowerUp(TabPane, OperationActionBindings): DEFAULT_CSS = get_css_from_relative_path(__file__) - def __init__(self, title: TextType) -> None: - """ - Initialize a TabPane. - - Args: - ---- - title: Title of the TabPane (will be displayed in a tab label). - """ - super().__init__(title=title) + def __init__(self) -> None: + super().__init__(title="Power up") self._receiver_input = ReceiverInput("Receiver", value=self.working_account_name) self._asset_input = HiveAssetAmountInput() diff --git a/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py b/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py index 3b76e6c13a..6499dcd93a 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py +++ b/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py @@ -33,7 +33,6 @@ from clive.__private.ui.widgets.section import Section from clive.__private.ui.widgets.transaction_buttons import TransactionButtons if TYPE_CHECKING: - from rich.text import TextType from textual.app import ComposeResult from clive.__private.core.commands.data_retrieval.hive_power_data import HivePowerData @@ -106,15 +105,8 @@ class WithdrawRoutes(TabPane, OperationActionBindings): DEFAULT_CSS = get_css_from_relative_path(__file__) DEFAULT_AUTO_VEST: Final[bool] = False - def __init__(self, title: TextType) -> None: - """ - Initialize a TabPane. - - Args: - ---- - title: Title of the TabPane (will be displayed in a tab label). - """ - super().__init__(title=title) + def __init__(self) -> None: + super().__init__(title="Withdraw routes") self._account_input = ReceiverInput() self._percent_input = PercentInput() self._auto_vest_checkbox = Checkbox("Auto vest", value=self.DEFAULT_AUTO_VEST) -- GitLab From 3d17fecaf526243a04c0b9bc15eef401436c247a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:50 +0100 Subject: [PATCH 100/192] Fix crash on alarm info by escaping markup in RecoveryAccountWarningListed.FIX_ALARM_INFO In textual 2.0.0 every string rendered from e.g. Static can be interpreted as markup by default. This can lead to error like: `MarkupError: Expected markup style value (found "'alice', 'bob']").` while trying to render: `Static("accounts: ['alice', 'bob']")` This can be disabled with `markup=False` given to Widget initializer or by adding the `\` escape character before markup opening tag like `\[`. - `Static("accounts: ['alice', 'bob']", markup=False)` - `Static("accounts: \['alice', 'bob']")` Using the second solution still gives the possibility to have some part of the text styled instead of disabling the entire markup for it. --- .../alarms/specific_alarms/recovery_account_warning_listed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clive/__private/core/alarms/specific_alarms/recovery_account_warning_listed.py b/clive/__private/core/alarms/specific_alarms/recovery_account_warning_listed.py index 92441439c5..5ca7010036 100644 --- a/clive/__private/core/alarms/specific_alarms/recovery_account_warning_listed.py +++ b/clive/__private/core/alarms/specific_alarms/recovery_account_warning_listed.py @@ -32,7 +32,7 @@ class RecoveryAccountWarningListed( WARNING_RECOVERY_ACCOUNTS: Final[set[str]] = {"steem"} ALARM_DESCRIPTION = RECOVERY_ACCOUNT_WARNING_LISTED_ALARM_DESCRIPTION - FIX_ALARM_INFO = f"You should change it to account other than {list(WARNING_RECOVERY_ACCOUNTS)}" + FIX_ALARM_INFO = f"You should change it to account other than \\{list(WARNING_RECOVERY_ACCOUNTS)}" def update_alarm_status(self, data: AccountAlarmsData) -> None: if data.recovery_account not in self.WARNING_RECOVERY_ACCOUNTS: -- GitLab From cff7f3d62e09da909bd38a8e0499d4d9a0314736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:52 +0100 Subject: [PATCH 101/192] Fix crash in cart by disabling markup for operation details table cell --- clive/__private/ui/screens/transaction_summary/cart_table.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/clive/__private/ui/screens/transaction_summary/cart_table.py b/clive/__private/ui/screens/transaction_summary/cart_table.py index 90c00a5c42..fddafbc3a5 100644 --- a/clive/__private/ui/screens/transaction_summary/cart_table.py +++ b/clive/__private/ui/screens/transaction_summary/cart_table.py @@ -237,7 +237,9 @@ class CartItem(CliveCheckerboardTableRow, CliveWidget): return [ CliveCheckerBoardTableCell(self.humanize_operation_number()), CliveCheckerBoardTableCell(self.humanize_operation_name(), classes="operation-name"), - CliveCheckerBoardTableCell(self.humanize_operation_details(), classes="operation-details"), + CliveCheckerBoardTableCell( + Static(self.humanize_operation_details(), markup=False), classes="operation-details" + ), CliveCheckerBoardTableCell(self._create_buttons_container(), classes="actions"), ] -- GitLab From 2d232a3d5c5d64b1da8088b5d7d67f73b2ba685e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:53 +0100 Subject: [PATCH 102/192] Bump textual to 2.0.1 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 03f6181053..21f74f7be0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "2.0.0" +version = "2.0.1" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-2.0.0-py3-none-any.whl", hash = "sha256:8de36e5f77e97e0e2b54fd895d474416f9c814b6fff542e6a5664e3a952fe1d6"}, - {file = "textual-2.0.0.tar.gz", hash = "sha256:5b32816fb3f2ba6aabd589be295b68d78dd2d0f41279e549f499d9d8418e67cf"}, + {file = "textual-2.0.1-py3-none-any.whl", hash = "sha256:6ca475c24c14fcf9ff285c66ab53280f02950a959fa7aa2b99b5111fc08c5688"}, + {file = "textual-2.0.1.tar.gz", hash = "sha256:bbb0968234d94eaf8e75270aa1d84b422affb03b367ad5170a51f1de12ac1d74"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "ea05aefe29af9d8440b7e5e50e1259aa335dfd1131ed593c13ff8331b5f08911" +content-hash = "8127276ddfc57bc343cd0d374f41cd3c9a793b23553a7b459ebed217a77b806b" diff --git a/pyproject.toml b/pyproject.toml index dd8041f0c4..fad46f3ecf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "2.0.0" +textual = "2.0.1" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From cb9013ab3a9c1d48d7d3cf2bfde23892722ce014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:55 +0100 Subject: [PATCH 103/192] Bump textual to 2.0.2 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 21f74f7be0..91355101fe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "2.0.1" +version = "2.0.2" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-2.0.1-py3-none-any.whl", hash = "sha256:6ca475c24c14fcf9ff285c66ab53280f02950a959fa7aa2b99b5111fc08c5688"}, - {file = "textual-2.0.1.tar.gz", hash = "sha256:bbb0968234d94eaf8e75270aa1d84b422affb03b367ad5170a51f1de12ac1d74"}, + {file = "textual-2.0.2-py3-none-any.whl", hash = "sha256:777e0d802872292caae63aee6b4f9b7a2e0a164a9b6c939723863bc6551b210e"}, + {file = "textual-2.0.2.tar.gz", hash = "sha256:48277b45f52b826b60cb0fc75e1e01d7bf8f4d429642fb44e1f9034d6219c031"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "8127276ddfc57bc343cd0d374f41cd3c9a793b23553a7b459ebed217a77b806b" +content-hash = "ce08e86bda9bde95d1e2e518515a34993a51e2038e7c5c1ec2ea3cd87b167217" diff --git a/pyproject.toml b/pyproject.toml index fad46f3ecf..cd385d643e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "2.0.1" +textual = "2.0.2" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From df4a4e248342e6611ae3bf0a8c6ef9d167c44106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:56 +0100 Subject: [PATCH 104/192] Bump textual to 2.0.3 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 91355101fe..5470ec0c10 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "2.0.2" +version = "2.0.3" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-2.0.2-py3-none-any.whl", hash = "sha256:777e0d802872292caae63aee6b4f9b7a2e0a164a9b6c939723863bc6551b210e"}, - {file = "textual-2.0.2.tar.gz", hash = "sha256:48277b45f52b826b60cb0fc75e1e01d7bf8f4d429642fb44e1f9034d6219c031"}, + {file = "textual-2.0.3-py3-none-any.whl", hash = "sha256:32e4bc2f065bfa5f3aeb58e28fbfe95424d25d6d079f0802327b85dcea5bbe4b"}, + {file = "textual-2.0.3.tar.gz", hash = "sha256:b7239077407ed9764b83faa1feff64c88c338e68a9b67beb8de35571757fbf16"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "ce08e86bda9bde95d1e2e518515a34993a51e2038e7c5c1ec2ea3cd87b167217" +content-hash = "f42fe81254994e7ee20d5390c7a3ee8852cf920d7a2a3efd340a1d9f40b8accb" diff --git a/pyproject.toml b/pyproject.toml index cd385d643e..58c4c2b771 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "2.0.2" +textual = "2.0.3" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From ad1af86c66c63d30bc78067e97ce33f9e2db90db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:57 +0100 Subject: [PATCH 105/192] Bump textual to 2.0.4 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5470ec0c10..3c3dac2544 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "2.0.3" +version = "2.0.4" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-2.0.3-py3-none-any.whl", hash = "sha256:32e4bc2f065bfa5f3aeb58e28fbfe95424d25d6d079f0802327b85dcea5bbe4b"}, - {file = "textual-2.0.3.tar.gz", hash = "sha256:b7239077407ed9764b83faa1feff64c88c338e68a9b67beb8de35571757fbf16"}, + {file = "textual-2.0.4-py3-none-any.whl", hash = "sha256:0a151c060586eb4fb091584fab58f0706daa51cf27330efef548bc14f3c6aa66"}, + {file = "textual-2.0.4.tar.gz", hash = "sha256:128177fc6d63d0f3236e374e28d68f4343bd2e996825da2a6f122d9fbf017d31"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "f42fe81254994e7ee20d5390c7a3ee8852cf920d7a2a3efd340a1d9f40b8accb" +content-hash = "fa90479f4f6e2dc417c95790adbf2ce8cf4ba6ee38aad161a18e5d25ee81b9d4" diff --git a/pyproject.toml b/pyproject.toml index 58c4c2b771..1aa470c735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "2.0.3" +textual = "2.0.4" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 06b4bc4ab61dd3f969706e1821144b779d196fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:27:59 +0100 Subject: [PATCH 106/192] Bump textual to 2.1.0 --- poetry.lock | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3c3dac2544..e0d5210115 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "2.0.4" +version = "2.1.0" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-2.0.4-py3-none-any.whl", hash = "sha256:0a151c060586eb4fb091584fab58f0706daa51cf27330efef548bc14f3c6aa66"}, - {file = "textual-2.0.4.tar.gz", hash = "sha256:128177fc6d63d0f3236e374e28d68f4343bd2e996825da2a6f122d9fbf017d31"}, + {file = "textual-2.1.0-py3-none-any.whl", hash = "sha256:c061604f4ca639ea660671213ea2b6db6139e04528b2e9a6210b1063ba467114"}, + {file = "textual-2.1.0.tar.gz", hash = "sha256:0b1d45cbe351ccd68bfeefd22defa33a59811436089de048bab9fb8b4657bd87"}, ] [package.dependencies] @@ -1395,7 +1395,7 @@ rich = ">=13.3.3" typing-extensions = ">=4.4.0,<5.0.0" [package.extras] -syntax = ["tree-sitter (>=0.23.0)", "tree-sitter-bash (>=0.23.0)", "tree-sitter-css (>=0.23.0)", "tree-sitter-go (>=0.23.0)", "tree-sitter-html (>=0.23.0)", "tree-sitter-java (>=0.23.0)", "tree-sitter-javascript (>=0.23.0)", "tree-sitter-json (>=0.24.0)", "tree-sitter-markdown (>=0.3.0)", "tree-sitter-python (>=0.23.0)", "tree-sitter-regex (>=0.24.0)", "tree-sitter-rust (>=0.23.0)", "tree-sitter-sql (>=0.3.0)", "tree-sitter-toml (>=0.6.0)", "tree-sitter-xml (>=0.7.0)", "tree-sitter-yaml (>=0.6.0)"] +syntax = ["tree-sitter (>=0.23.0)", "tree-sitter-bash (>=0.23.0)", "tree-sitter-css (>=0.23.0)", "tree-sitter-go (>=0.23.0)", "tree-sitter-html (>=0.23.0)", "tree-sitter-java (>=0.23.0)", "tree-sitter-javascript (>=0.23.0)", "tree-sitter-json (>=0.24.0)", "tree-sitter-markdown (>=0.3.0)", "tree-sitter-python (>=0.23.0)", "tree-sitter-regex (>=0.24.0)", "tree-sitter-rust (>=0.23.0)", "tree-sitter-sql (>=0.3.0,<0.3.8)", "tree-sitter-toml (>=0.6.0)", "tree-sitter-xml (>=0.7.0)", "tree-sitter-yaml (>=0.6.0)"] [[package]] name = "textual-dev" @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "fa90479f4f6e2dc417c95790adbf2ce8cf4ba6ee38aad161a18e5d25ee81b9d4" +content-hash = "06bb88a80e6283cc58412b47928637294e5f76aa7f7aac8b422f693d0e8e7717" diff --git a/pyproject.toml b/pyproject.toml index 1aa470c735..b496e1e080 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "2.0.4" +textual = "2.1.0" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From beb2f5940fe6abbbb013df386c14ff53d568d04e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:28:00 +0100 Subject: [PATCH 107/192] Bump textual to 2.1.1 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index e0d5210115..cfc0f41065 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "2.1.0" +version = "2.1.1" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-2.1.0-py3-none-any.whl", hash = "sha256:c061604f4ca639ea660671213ea2b6db6139e04528b2e9a6210b1063ba467114"}, - {file = "textual-2.1.0.tar.gz", hash = "sha256:0b1d45cbe351ccd68bfeefd22defa33a59811436089de048bab9fb8b4657bd87"}, + {file = "textual-2.1.1-py3-none-any.whl", hash = "sha256:789c9ba1b2f6b78224ea0fe396e5188feb6882ca43894fc15f6ebbd237525263"}, + {file = "textual-2.1.1.tar.gz", hash = "sha256:c1dd54fce53c3abe87a021735efbbfd8af5313191f0729a02ecdb3083367cf62"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "06bb88a80e6283cc58412b47928637294e5f76aa7f7aac8b422f693d0e8e7717" +content-hash = "d3ed72b19d234e16ef102f1a493231f5e302a3e36c8922330607d85988db2a8b" diff --git a/pyproject.toml b/pyproject.toml index b496e1e080..58bd29e127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "2.1.0" +textual = "2.1.1" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From ad9ec807e68464daddcc41c29ab6aec7110383ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:28:01 +0100 Subject: [PATCH 108/192] Bump textual to 2.1.2 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index cfc0f41065..13e513b750 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1379,13 +1379,13 @@ url = "hive/tests/python/hive-local-tools/test-tools" [[package]] name = "textual" -version = "2.1.1" +version = "2.1.2" description = "Modern Text User Interface framework" optional = false python-versions = "<4.0.0,>=3.8.1" files = [ - {file = "textual-2.1.1-py3-none-any.whl", hash = "sha256:789c9ba1b2f6b78224ea0fe396e5188feb6882ca43894fc15f6ebbd237525263"}, - {file = "textual-2.1.1.tar.gz", hash = "sha256:c1dd54fce53c3abe87a021735efbbfd8af5313191f0729a02ecdb3083367cf62"}, + {file = "textual-2.1.2-py3-none-any.whl", hash = "sha256:95f37f49e930838e721bba8612f62114d410a3019665b6142adabc14c2fb9611"}, + {file = "textual-2.1.2.tar.gz", hash = "sha256:aae3f9fde00c7440be00e3c3ac189e02d014f5298afdc32132f93480f9e09146"}, ] [package.dependencies] @@ -1660,4 +1660,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "d3ed72b19d234e16ef102f1a493231f5e302a3e36c8922330607d85988db2a8b" +content-hash = "f9371bfb7b8fadcf50a1c6ed7dd6e31472a6289dc9e85452d8e326b6bce92b54" diff --git a/pyproject.toml b/pyproject.toml index 58bd29e127..eb61f69fde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "2.1.1" +textual = "2.1.2" aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 4dd31544b77e0dd6143795eb2bc940c8c873facf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 6 Mar 2025 15:28:04 +0100 Subject: [PATCH 109/192] Fix CanFocusWithScrollbarsOnly It stopped working behaving correctly after 2.0.0: https://github.com/Textualize/textual/issues/5605 Still there is a know issue: https://github.com/Textualize/textual/issues/5609 --- .../common_governance/governance_actions.py | 2 +- clive/__private/ui/widgets/scrolling.py | 14 +++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_actions.py b/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_actions.py index 4c9e8883eb..1235208cc4 100644 --- a/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_actions.py +++ b/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_actions.py @@ -86,7 +86,7 @@ class GovernanceActions(ScrollablePartFocusable, Generic[OperationActionT]): yield Static("Action", id="action-row") yield Static(self.NAME_OF_ACTION, id="action-name-row") - async def on_mount(self) -> None: # type: ignore[override] + async def on_mount(self) -> None: await self.mount_operations_from_cart() async def add_row(self, identifier: str, *, vote: bool = False, pending: bool = False) -> None: diff --git a/clive/__private/ui/widgets/scrolling.py b/clive/__private/ui/widgets/scrolling.py index ec2bdfa3d8..498adba65e 100644 --- a/clive/__private/ui/widgets/scrolling.py +++ b/clive/__private/ui/widgets/scrolling.py @@ -13,17 +13,9 @@ class CanFocusWithScrollbarsOnly(CliveWidget, AbstractClassMessagePump): Inherit from this class to make a widget focusable only when any scrollbar is active. """ - def on_mount(self) -> None: - self.__enable_focus_only_when_scrollbar_is_active() - - def watch_show_vertical_scrollbar(self) -> None: - self.__enable_focus_only_when_scrollbar_is_active() - - def watch_show_horizontal_scrollbar(self) -> None: - self.__enable_focus_only_when_scrollbar_is_active() - - def __enable_focus_only_when_scrollbar_is_active(self) -> None: - self.can_focus = any(self.scrollbars_enabled) + def allow_focus(self) -> bool: + # Known issue: https://github.com/Textualize/textual/issues/5609 + return any(self.scrollbars_enabled) class ScrollablePartFocusable(VerticalScroll, CanFocusWithScrollbarsOnly): -- GitLab From 1b5c63eac15d24698fa78825934647ef7008b99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 10:39:41 +0100 Subject: [PATCH 110/192] Fix ctrl+c help quit not being displayed This was because Textual checks if `quit` or `app.quit` is registered in BINDINGS. --- clive/__private/ui/app.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index fbfd0477ce..a97d5e084e 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -56,7 +56,7 @@ class Clive(App[int]): BINDINGS = [ Binding("ctrl+s", "app.screenshot()", "Screenshot", show=False), - Binding(APP_QUIT_KEY_BINDING, "push_screen('quit')", "Quit", show=False), + Binding(APP_QUIT_KEY_BINDING, "quit", "Quit", show=False), Binding("c", "clear_notifications", "Clear notifications", show=False), Binding("f1", "help", "Help", show=False), Binding("f7", "go_to_transaction_summary", "Transaction summary", show=False), @@ -195,6 +195,9 @@ class Clive(App[int]): return current_screen raise ScreenNotFoundError(f"Screen {screen} not found in stack") + async def action_quit(self) -> None: + self.push_screen(Quit()) + def action_help(self) -> None: if isinstance(self.screen, Help): return -- GitLab From 8f83835ec88ceb881f848fceff9da3dc615f4291 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 10 Mar 2025 08:00:28 +0100 Subject: [PATCH 111/192] Rename Content -> ContentT --- .../clive_basic/clive_checkerboard_table.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index ac62d6196e..d3e14b9b6e 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -15,7 +15,7 @@ from clive.exceptions import CliveDeveloperError if TYPE_CHECKING: from textual.app import ComposeResult -Content = TypeVar("Content", bound=Any) +ContentT = TypeVar("ContentT", bound=Any) class CliveCheckerboardTableError(CliveDeveloperError): @@ -180,7 +180,7 @@ class CliveCheckerboardTable(CliveWidget): def on_mount(self) -> None: if self.should_be_dynamic: - def delegate_work(content: Content) -> None: + def delegate_work(content: ContentT) -> None: self.run_worker(self._mount_dynamic_rows(content)) self.watch(self.object_to_watch, self.ATTRIBUTE_TO_WATCH, delegate_work) @@ -189,7 +189,7 @@ class CliveCheckerboardTable(CliveWidget): """Mount rows created in static mode.""" self.mount_all(self._create_table_content()) - async def _mount_dynamic_rows(self, content: Content) -> None: + async def _mount_dynamic_rows(self, content: ContentT) -> None: """Mount new rows when the ATTRIBUTE_TO_WATCH has been changed.""" if not self.should_be_dynamic: raise InvalidDynamicDefinedError @@ -203,13 +203,13 @@ class CliveCheckerboardTable(CliveWidget): self.update_previous_state(content) await self.rebuild(content) - async def rebuild(self, content: Content | None = None) -> None: + async def rebuild(self, content: ContentT | None = None) -> None: """Rebuilds whole table - explicit use available for static and dynamic version.""" with self.app.batch_update(): await self.query("*").remove() await self.mount_all(self._create_table_content(content)) - async def rebuild_rows(self, content: Content | None = None) -> None: + async def rebuild_rows(self, content: ContentT | None = None) -> None: """Rebuilds table rows - explicit use available for static and dynamic version.""" with self.app.batch_update(): await self.query(CliveCheckerboardTableRow).remove() @@ -217,7 +217,7 @@ class CliveCheckerboardTable(CliveWidget): new_rows = self._create_table_rows(content) await self.mount_all(new_rows) - def _create_table_rows(self, content: Content | None = None) -> Sequence[CliveCheckerboardTableRow]: + def _create_table_rows(self, content: ContentT | None = None) -> Sequence[CliveCheckerboardTableRow]: if content is not None and not self.is_anything_to_display(content): # if content is given, we can check if there is anything to display and return earlier return [] @@ -236,7 +236,7 @@ class CliveCheckerboardTable(CliveWidget): self._set_evenness_styles(rows) return rows - def _create_table_content(self, content: Content | None = None) -> list[Widget]: + def _create_table_content(self, content: ContentT | None = None) -> list[Widget]: rows = self._create_table_rows(content) if not rows: @@ -248,7 +248,7 @@ class CliveCheckerboardTable(CliveWidget): title = self._title if isinstance(self._title, Widget) else SectionTitle(self._title) return [title, self._header, *rows] - def create_dynamic_rows(self, content: Content) -> Sequence[CliveCheckerboardTableRow]: # noqa: ARG002 + def create_dynamic_rows(self, content: ContentT) -> Sequence[CliveCheckerboardTableRow]: # noqa: ARG002 """ Override this method when using dynamic table (ATTRIBUTE_TO_WATCH is set). @@ -279,7 +279,7 @@ class CliveCheckerboardTable(CliveWidget): def should_be_dynamic(self) -> bool: return bool(self.ATTRIBUTE_TO_WATCH) - def check_if_should_be_updated(self, content: Content) -> bool: # noqa: ARG002 + def check_if_should_be_updated(self, content: ContentT) -> bool: # noqa: ARG002 """ Must be overridden by the child class when using dynamic table. @@ -323,11 +323,11 @@ class CliveCheckerboardTable(CliveWidget): if self.should_be_dynamic: raise InvalidDynamicDefinedError - def is_anything_to_display(self, content: Content) -> bool: # noqa: ARG002 + def is_anything_to_display(self, content: ContentT) -> bool: # noqa: ARG002 """Check whether there are elements to display. Should be overridden to create a custom condition.""" return True - def update_previous_state(self, content: Content) -> None: # noqa: ARG002 + def update_previous_state(self, content: ContentT) -> None: # noqa: ARG002 """ Must be overridden if the `ATTRIBUTE_TO_WATCH` class-var is set. -- GitLab From bad6a1969b217f42fc5376e5f2325a8bed4eec59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 10 Mar 2025 08:04:12 +0100 Subject: [PATCH 112/192] Introduce CellContent --- .../ui/widgets/clive_basic/clive_checkerboard_table.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index d3e14b9b6e..b2496c156b 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: ContentT = TypeVar("ContentT", bound=Any) +CellContent = str | Widget + class CliveCheckerboardTableError(CliveDeveloperError): pass @@ -64,7 +66,7 @@ class CliveCheckerBoardTableCell(Container): } """ - def __init__(self, content: str | Widget, id_: str | None = None, classes: str | None = None) -> None: + def __init__(self, content: CellContent, id_: str | None = None, classes: str | None = None) -> None: """ Initialise the checkerboard table cell. @@ -78,7 +80,7 @@ class CliveCheckerBoardTableCell(Container): self._content = content @property - def content(self) -> str | Widget: + def content(self) -> CellContent: return self._content def compose(self) -> ComposeResult: @@ -87,7 +89,7 @@ class CliveCheckerBoardTableCell(Container): else: yield Static(self._content) - def update_content(self, content: str) -> None: + def update_content(self, content: CellContent) -> None: if isinstance(content, Widget): raise InvalidContentUpdateError self._content = content -- GitLab From 1af12a9e0536ae8e70c267a8a787600fd3f49032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 10 Mar 2025 08:14:33 +0100 Subject: [PATCH 113/192] Remove assertion for Cell content type and InvalidContentUpdateError --- .../screens/transaction_summary/cart_table.py | 39 ++++++++++--------- .../clive_basic/clive_checkerboard_table.py | 13 +------ 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/clive/__private/ui/screens/transaction_summary/cart_table.py b/clive/__private/ui/screens/transaction_summary/cart_table.py index fddafbc3a5..f7c45513b5 100644 --- a/clive/__private/ui/screens/transaction_summary/cart_table.py +++ b/clive/__private/ui/screens/transaction_summary/cart_table.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: from textual.app import ComposeResult from clive.__private.models.schemas import OperationUnion + from clive.__private.ui.widgets.clive_basic.clive_checkerboard_table import CellContent class ButtonMoveUp(CliveButton): @@ -147,10 +148,10 @@ class CartItem(CliveCheckerboardTableRow, CliveWidget): elif self.is_last: self.unbind("ctrl+down") - def watch_operation_index(self, value: int) -> None: + async def watch_operation_index(self, value: int) -> None: assert self._is_operation_index_valid(value), "Operation index is invalid when trying to update." self._operation_index = value - self.operation_number_cell.update_content(self.humanize_operation_number()) + await self.operation_number_cell.update_content(self.humanize_operation_number()) def humanize_operation_number(self, *, before_removal: bool = False) -> str: cart_items = self.operations_amount - 1 if before_removal else self.operations_amount @@ -313,7 +314,7 @@ class CartTable(CliveCheckerboardTable): self._focus_appropriate_item_on_deletion(item_to_remove) await item_to_remove.remove() if self._has_cart_items: - self._update_cart_items_on_deletion(removed_item=item_to_remove) + await self._update_cart_items_on_deletion(removed_item=item_to_remove) self._disable_appropriate_button_on_deletion(removed_item=item_to_remove) else: await self.query_exactly_one(CartHeader).remove() @@ -333,7 +334,7 @@ class CartTable(CliveCheckerboardTable): assert to_index < len(self.profile.transaction), "Item cannot be moved to id greater than cart length." with self.app.batch_update(): - self._update_values_of_swapped_rows(from_index=from_index, to_index=to_index) + await self._update_values_of_swapped_rows(from_index=from_index, to_index=to_index) self._focus_item_on_move(to_index) self.profile.transaction.swap_operations(from_index, to_index) @@ -347,14 +348,16 @@ class CartTable(CliveCheckerboardTable): if event.target_index == cart_item.operation_index: cart_item.focus() - def _update_cart_items_on_deletion(self, removed_item: CartItem) -> None: + async def _update_cart_items_on_deletion(self, removed_item: CartItem) -> None: def update_indexes() -> None: for cart_item in cart_items_to_update_index: cart_item.operation_index = cart_item.operation_index - 1 - def update_operation_number() -> None: + async def update_operation_number() -> None: for cart_item in all_cart_items: - cart_item.operation_number_cell.update_content(cart_item.humanize_operation_number(before_removal=True)) + await cart_item.operation_number_cell.update_content( + cart_item.humanize_operation_number(before_removal=True) + ) def update_evenness() -> None: self._set_evenness_styles(cart_items_to_update_index, starting_index=start_index) @@ -364,7 +367,7 @@ class CartTable(CliveCheckerboardTable): cart_items_to_update_index = all_cart_items[start_index:] update_indexes() update_evenness() - update_operation_number() + await update_operation_number() def _disable_appropriate_button_on_deletion(self, removed_item: CartItem) -> None: if removed_item.is_first: @@ -387,26 +390,24 @@ class CartTable(CliveCheckerboardTable): cart_item_to_focus.focus() - def _update_values_of_swapped_rows(self, from_index: int, to_index: int) -> None: - def extract_cells_and_data(row_index: int) -> tuple[list[CliveCheckerBoardTableCell], list[str]]: + async def _update_values_of_swapped_rows(self, from_index: int, to_index: int) -> None: + def extract_cells_and_data(row_index: int) -> tuple[list[CliveCheckerBoardTableCell], list[CellContent]]: row = self._cart_items[row_index] cells = row.query(CliveCheckerBoardTableCell)[1:-1] # Skip "operation number" and "buttons container" cells - data: list[str] = [] - for cell in cells: - content = cell.content - assert isinstance(content, str), f"Cell content is not a string: {content}" - data.append(content) + data = [cell.content for cell in cells] return list(cells), data - def swap_cell_data(source_cells: list[CliveCheckerBoardTableCell], target_data: list[str]) -> None: + async def swap_cell_data( + source_cells: list[CliveCheckerBoardTableCell], target_data: list[CellContent] + ) -> None: for cell, value in zip(source_cells, target_data): - cell.update_content(value) + await cell.update_content(value) from_cells, from_data = extract_cells_and_data(from_index) to_cells, to_data = extract_cells_and_data(to_index) - swap_cell_data(to_cells, from_data) - swap_cell_data(from_cells, to_data) + await swap_cell_data(to_cells, from_data) + await swap_cell_data(from_cells, to_data) def _focus_item_on_move(self, target_index: int) -> None: for cart_item in self._cart_items: diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index b2496c156b..323eb3d8db 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -24,13 +24,6 @@ class CliveCheckerboardTableError(CliveDeveloperError): pass -class InvalidContentUpdateError(CliveCheckerboardTableError): - _MESSAGE = "Cannot update cell with widget type. Use string only." - - def __init__(self) -> None: - super().__init__(self._MESSAGE) - - class InvalidDynamicDefinedError(CliveCheckerboardTableError): _MESSAGE = """ You are trying to create a dynamic checkerboard table without overriding one of the mandatory properties or methods. @@ -89,11 +82,9 @@ class CliveCheckerBoardTableCell(Container): else: yield Static(self._content) - def update_content(self, content: CellContent) -> None: - if isinstance(content, Widget): - raise InvalidContentUpdateError + async def update_content(self, content: CellContent) -> None: self._content = content - self.query_exactly_one(Static).update(self._content) + await self.recompose() class CliveCheckerboardTableRow(CliveWidget): -- GitLab From 115c89e5b2b9906e006602946aad15b3602eeee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 10 Mar 2025 08:22:37 +0100 Subject: [PATCH 114/192] Use Content instead of Static with markup=False to disable markup --- .../__private/ui/screens/transaction_summary/cart_table.py | 5 ++--- .../ui/widgets/clive_basic/clive_checkerboard_table.py | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/clive/__private/ui/screens/transaction_summary/cart_table.py b/clive/__private/ui/screens/transaction_summary/cart_table.py index f7c45513b5..9bd65fd5a5 100644 --- a/clive/__private/ui/screens/transaction_summary/cart_table.py +++ b/clive/__private/ui/screens/transaction_summary/cart_table.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal from textual import on from textual.binding import Binding from textual.containers import Horizontal +from textual.content import Content from textual.css.query import DOMQuery, NoMatches from textual.message import Message from textual.reactive import reactive @@ -238,9 +239,7 @@ class CartItem(CliveCheckerboardTableRow, CliveWidget): return [ CliveCheckerBoardTableCell(self.humanize_operation_number()), CliveCheckerBoardTableCell(self.humanize_operation_name(), classes="operation-name"), - CliveCheckerBoardTableCell( - Static(self.humanize_operation_details(), markup=False), classes="operation-details" - ), + CliveCheckerBoardTableCell(Content(self.humanize_operation_details()), classes="operation-details"), CliveCheckerBoardTableCell(self._create_buttons_container(), classes="actions"), ] diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index 323eb3d8db..44f8b5b888 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar, Sequence, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Sequence, TypeAlias, TypeVar from textual.containers import Container from textual.widget import Widget @@ -14,10 +14,11 @@ from clive.exceptions import CliveDeveloperError if TYPE_CHECKING: from textual.app import ComposeResult + from textual.visual import VisualType ContentT = TypeVar("ContentT", bound=Any) -CellContent = str | Widget +CellContent: TypeAlias = "VisualType | Widget" class CliveCheckerboardTableError(CliveDeveloperError): @@ -80,7 +81,7 @@ class CliveCheckerBoardTableCell(Container): if isinstance(self._content, Widget): yield self._content else: - yield Static(self._content) + yield Static(self._content) # type: ignore[arg-type] # See: https://github.com/Textualize/textual/pull/5618 async def update_content(self, content: CellContent) -> None: self._content = content -- GitLab From f86c4a7018a46e51e72c63bb963a87b215dca7af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Wed, 12 Mar 2025 08:23:24 +0100 Subject: [PATCH 115/192] Fix TUI Unlock not possible when lock countdown is invalid but hidden --- clive/__private/ui/screens/unlock/unlock.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index 43b0d15288..0c5fa45424 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -52,13 +52,21 @@ class LockAfterTime(Horizontal): id="unlock-time-input", ) + @property + def should_stay_unlocked(self) -> bool: + return self._checkbox.value + @property def is_valid(self) -> bool: + if self.should_stay_unlocked: + return True + return self._lock_after_time_input.validate_passed() @property def lock_duration(self) -> timedelta | None: - if not self.is_valid or self._checkbox.value: + """Return lock duration. None means should stay unlocked (permanent unlock).""" + if self.should_stay_unlocked: return None return timedelta(minutes=self._lock_after_time_input.value_or_error) @@ -106,7 +114,7 @@ class Unlock(BaseScreen): await self.commands.unlock( profile_name=select_profile.value_ensure, password=password_input.value_or_error, - permanent=lock_after_time.lock_duration is None, + permanent=lock_after_time.should_stay_unlocked, time=lock_after_time.lock_duration, ) ).success: -- GitLab From 1dfd1d04687a6ced58d9eca8953327f9b4b266b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 13 Mar 2025 15:11:22 +0100 Subject: [PATCH 116/192] Bump textual to d9f7ff This fixes the following crashes: - `NoScreen: node has no screen` - `KeyError: "No 'toast--title' key in COMPONENT_CLASSES"` --- poetry.lock | 18 +++++++++++------- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/poetry.lock b/poetry.lock index 13e513b750..7505c996ee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1382,21 +1382,25 @@ name = "textual" version = "2.1.2" description = "Modern Text User Interface framework" optional = false -python-versions = "<4.0.0,>=3.8.1" -files = [ - {file = "textual-2.1.2-py3-none-any.whl", hash = "sha256:95f37f49e930838e721bba8612f62114d410a3019665b6142adabc14c2fb9611"}, - {file = "textual-2.1.2.tar.gz", hash = "sha256:aae3f9fde00c7440be00e3c3ac189e02d014f5298afdc32132f93480f9e09146"}, -] +python-versions = "^3.8.1" +files = [] +develop = false [package.dependencies] markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} platformdirs = ">=3.6.0,<5" rich = ">=13.3.3" -typing-extensions = ">=4.4.0,<5.0.0" +typing-extensions = "^4.4.0" [package.extras] syntax = ["tree-sitter (>=0.23.0)", "tree-sitter-bash (>=0.23.0)", "tree-sitter-css (>=0.23.0)", "tree-sitter-go (>=0.23.0)", "tree-sitter-html (>=0.23.0)", "tree-sitter-java (>=0.23.0)", "tree-sitter-javascript (>=0.23.0)", "tree-sitter-json (>=0.24.0)", "tree-sitter-markdown (>=0.3.0)", "tree-sitter-python (>=0.23.0)", "tree-sitter-regex (>=0.24.0)", "tree-sitter-rust (>=0.23.0)", "tree-sitter-sql (>=0.3.0,<0.3.8)", "tree-sitter-toml (>=0.6.0)", "tree-sitter-xml (>=0.7.0)", "tree-sitter-yaml (>=0.6.0)"] +[package.source] +type = "git" +url = "https://github.com/Textualize/textual" +reference = "d9f7ffdad56bda27048a8bb7b70627dd90cc4c8e" +resolved_reference = "d9f7ffdad56bda27048a8bb7b70627dd90cc4c8e" + [[package]] name = "textual-dev" version = "1.5.1" @@ -1660,4 +1664,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "f9371bfb7b8fadcf50a1c6ed7dd6e31472a6289dc9e85452d8e326b6bce92b54" +content-hash = "cb67ea36cc60cca5764a88b968993939295ff5aba0864592fcc445ad57c950d4" diff --git a/pyproject.toml b/pyproject.toml index eb61f69fde..05134ba312 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ source = [ python = "^3.10" dynaconf = "3.1.11" loguru = "0.7.2" -textual = "2.1.2" +textual = { git = "https://github.com/Textualize/textual", rev = "d9f7ffdad56bda27048a8bb7b70627dd90cc4c8e" } aiohttp = "3.9.1" typer = { extras = ["all"], version = "0.9.0" } inflection = "0.5.1" -- GitLab From 48460016ed4d077e729cc4d357b24c9920265f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 13 Mar 2025 15:22:16 +0100 Subject: [PATCH 117/192] Update typing to the Textual change --- .../ui/widgets/clive_basic/clive_checkerboard_table.py | 2 +- clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py | 5 ++--- clive/__private/ui/widgets/titled_label.py | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index 44f8b5b888..df5ee05ea8 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -81,7 +81,7 @@ class CliveCheckerBoardTableCell(Container): if isinstance(self._content, Widget): yield self._content else: - yield Static(self._content) # type: ignore[arg-type] # See: https://github.com/Textualize/textual/pull/5618 + yield Static(self._content) async def update_content(self, content: CellContent) -> None: self._content = content diff --git a/clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py b/clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py index db3d92a024..b4bf7bb419 100644 --- a/clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py +++ b/clive/__private/ui/widgets/dynamic_widgets/dynamic_label.py @@ -11,9 +11,8 @@ from clive.__private.ui.widgets.dynamic_widgets.dynamic_widget import ( ) if TYPE_CHECKING: - from rich.console import RenderableType from textual.reactive import Reactable - from textual.visual import SupportsVisual + from textual.visual import VisualType DynamicLabelCallbackType = WatchLikeCallbackType[str] @@ -45,7 +44,7 @@ class DynamicLabel(DynamicWidget[Label, str]): ) @property - def renderable(self) -> RenderableType | SupportsVisual: + def renderable(self) -> VisualType: return self._widget.renderable def _create_widget(self) -> Label: diff --git a/clive/__private/ui/widgets/titled_label.py b/clive/__private/ui/widgets/titled_label.py index 82783eaaf5..77abc04a36 100644 --- a/clive/__private/ui/widgets/titled_label.py +++ b/clive/__private/ui/widgets/titled_label.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: from rich.console import RenderableType from textual.app import ComposeResult from textual.reactive import Reactable - from textual.visual import SupportsVisual + from textual.visual import VisualType from clive.__private.ui.widgets.dynamic_widgets.dynamic_label import ( DynamicLabelCallbackType, @@ -67,7 +67,7 @@ class TitledLabel(CliveWidget): ) @property - def value(self) -> RenderableType | SupportsVisual: + def value(self) -> VisualType: return self._value_label.renderable def compose(self) -> ComposeResult: -- GitLab From 1f942ea7e6871833d62120e0ce0c23c404562461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 14 Mar 2025 09:12:18 +0100 Subject: [PATCH 118/192] Add git to dockerfile python_installer Because we install Textual version from git and not from pip --- docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e9e708b168..9a4ead6dce 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -60,7 +60,7 @@ FROM preconfigured_base_image AS python_installer USER root RUN --mount=type=cache,mode=0777,sharing=locked,target=${APT_CACHE_DIR} \ apt-get update && \ - apt-get install -y python3-venv && \ + apt-get install -y python3-venv git && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -- GitLab From 8d3851e38c2c81ed05e5db0e58ff41fb2ab43fe4 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Tue, 11 Mar 2025 10:40:23 +0100 Subject: [PATCH 119/192] Unify periodic and asap versions of update node/alarms data --- clive/__private/ui/app.py | 37 +++++-------------- .../create_profile/set_account_form_screen.py | 3 +- .../ui/widgets/add_account_container.py | 2 +- .../switch_working_account_container.py | 2 +- 4 files changed, 13 insertions(+), 31 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index a97d5e084e..f8c15da749 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -242,37 +242,18 @@ class Clive(App[int]): def trigger_app_state_watchers(self) -> None: self.world.mutate_reactive(TUIWorld.app_state) # type: ignore[arg-type] - def update_alarms_data_asap(self) -> Worker[None]: - """Update alarms as soon as possible after node data becomes available.""" - - async def _update_alarms_data_asap() -> None: - self.pause_refresh_alarms_data_interval() - while not self.world.profile.accounts.is_tracked_accounts_node_data_available: # noqa: ASYNC110 - await asyncio.sleep(0.1) - await self.update_alarms_data().wait() - self.resume_refresh_alarms_data_interval() - - return self.run_worker(_update_alarms_data_asap()) - - def update_data_from_node_asap(self) -> Worker[None]: - async def _update_data_from_node_asap() -> None: - self.pause_refresh_node_data_interval() - await self.update_data_from_node().wait() - self.resume_refresh_node_data_interval() - - return self.run_worker(_update_data_from_node_asap()) - def update_alarms_data_asap_on_newest_node_data(self) -> Worker[None]: """Update alarms on the newest possible node data.""" + self.update_data_from_node() + worker = self.update_alarms_data() + self.resume_refresh_node_data_interval() + self.resume_refresh_alarms_data_interval() + return worker - async def _update_alarms_data_asap_on_newest_node_data() -> None: - await self.update_data_from_node_asap().wait() - await self.update_alarms_data_asap().wait() - - return self.run_worker(_update_alarms_data_asap_on_newest_node_data()) - - @work(name="alarms data update worker", group="node_data") + @work(name="alarms data update worker", group="alarms_data", exclusive=True) async def update_alarms_data(self) -> None: + while not self.world.profile.accounts.is_tracked_accounts_node_data_available: # noqa: ASYNC110 + await asyncio.sleep(0.1) accounts = self.world.profile.accounts.tracked wrapper = await self.world.commands.update_alarms_data(accounts=accounts) if wrapper.error_occurred: @@ -281,7 +262,7 @@ class Clive(App[int]): self.trigger_profile_watchers() - @work(name="node data update worker", group="alarms_data") + @work(name="node data update worker", group="node_data", exclusive=True) async def update_data_from_node(self) -> None: accounts = self.world.profile.accounts.tracked # accounts list gonna be empty, but dgpo will be refreshed diff --git a/clive/__private/ui/forms/create_profile/set_account_form_screen.py b/clive/__private/ui/forms/create_profile/set_account_form_screen.py index 9db4c71811..b55a8bc003 100644 --- a/clive/__private/ui/forms/create_profile/set_account_form_screen.py +++ b/clive/__private/ui/forms/create_profile/set_account_form_screen.py @@ -49,7 +49,8 @@ class SetAccountFormScreen(BaseScreen, FormScreen[CreateProfileContext], FinishP return self.app.query_exactly_one(WorkingAccountCheckbox) def on_mount(self) -> None: - self.app.update_data_from_node_asap() + self.app.update_data_from_node() + self.app.resume_refresh_node_data_interval() def create_main_panel(self) -> ComposeResult: with SectionScrollable("Set account name"): diff --git a/clive/__private/ui/widgets/add_account_container.py b/clive/__private/ui/widgets/add_account_container.py index 50ff34f5f9..d022891adb 100644 --- a/clive/__private/ui/widgets/add_account_container.py +++ b/clive/__private/ui/widgets/add_account_container.py @@ -79,5 +79,5 @@ class AddAccountContainer(Horizontal, CliveWidget): self.app.trigger_profile_watchers() self._account_input.input.clear() - self.app.update_alarms_data_asap() + self.app.update_alarms_data() return True diff --git a/clive/__private/ui/widgets/switch_working_account_container.py b/clive/__private/ui/widgets/switch_working_account_container.py index 3440611f18..2b3dcc7a59 100644 --- a/clive/__private/ui/widgets/switch_working_account_container.py +++ b/clive/__private/ui/widgets/switch_working_account_container.py @@ -165,7 +165,7 @@ class SwitchWorkingAccountContainer(Container, CliveWidget): def _perform_actions_after_accounts_modification(self) -> None: self.app.trigger_profile_watchers() - self.app.update_alarms_data_asap() + self.app.update_alarms_data() def _handle_selected_account_changed(self, profile: Profile) -> None: """Due to the dynamic nature, we must take into account that tracked accounts may be modified elsewhere.""" -- GitLab From ff13a394bab4d797a300455950df77c12bce5f74 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 13 Mar 2025 12:35:41 +0100 Subject: [PATCH 120/192] Run periodic works update node/alarms data only if its not ongoing --- clive/__private/ui/app.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index f8c15da749..c4975138ab 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -163,10 +163,10 @@ class Clive(App[int]): def on_mount(self) -> None: self._refresh_node_data_interval = self.set_interval( - safe_settings.node.refresh_rate_secs, lambda: self.update_data_from_node(), pause=True + safe_settings.node.refresh_rate_secs, self._retrigger_update_data_from_node, pause=True ) self._refresh_alarms_data_interval = self.set_interval( - safe_settings.node.refresh_alarms_rate_secs, lambda: self.update_alarms_data(), pause=True + safe_settings.node.refresh_alarms_rate_secs, self._retrigger_update_alarms_data, pause=True ) self._refresh_beekeeper_wallet_lock_status_interval = self.set_interval( @@ -309,3 +309,14 @@ class Clive(App[int]): loop = asyncio.get_running_loop() # can't use self._loop since it's not set yet for signal_number in [signal.SIGHUP, signal.SIGINT, signal.SIGQUIT, signal.SIGTERM]: loop.add_signal_handler(signal_number, callback) + + def _is_worker_group_empty(self, group: str) -> bool: + return not bool([worker for worker in self.workers if worker.group == group]) + + def _retrigger_update_data_from_node(self) -> None: + if self._is_worker_group_empty("node_data"): + self.update_data_from_node() + + def _retrigger_update_alarms_data(self) -> None: + if self._is_worker_group_empty("alarms_data"): + self.update_alarms_data() -- GitLab From 8f2b93f57fb280760b6a1182a3b144f8a16f2b07 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Fri, 7 Mar 2025 14:49:06 +0100 Subject: [PATCH 121/192] Pause refreshing node data when going to previous screen in create profile --- .../ui/forms/create_profile/set_account_form_screen.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/clive/__private/ui/forms/create_profile/set_account_form_screen.py b/clive/__private/ui/forms/create_profile/set_account_form_screen.py index b55a8bc003..4dc41d60ae 100644 --- a/clive/__private/ui/forms/create_profile/set_account_form_screen.py +++ b/clive/__private/ui/forms/create_profile/set_account_form_screen.py @@ -10,7 +10,7 @@ from clive.__private.core.constants.tui.placeholders import ACCOUNT_NAME_CREATE_ from clive.__private.ui.forms.create_profile.context import CreateProfileContext from clive.__private.ui.forms.create_profile.finish_profile_creation_mixin import FinishProfileCreationMixin from clive.__private.ui.forms.form_screen import FormScreen -from clive.__private.ui.forms.navigation_buttons import NavigationButtons +from clive.__private.ui.forms.navigation_buttons import NavigationButtons, PreviousScreenButton from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen from clive.__private.ui.widgets.inputs.account_name_input import AccountNameInput @@ -52,6 +52,12 @@ class SetAccountFormScreen(BaseScreen, FormScreen[CreateProfileContext], FinishP self.app.update_data_from_node() self.app.resume_refresh_node_data_interval() + @on(PreviousScreenButton.Pressed) + async def action_previous_screen(self, event: PreviousScreenButton.Pressed | None = None) -> None: + self.app.pause_refresh_node_data_interval() + if event is None: + await super().action_previous_screen() + def create_main_panel(self) -> ComposeResult: with SectionScrollable("Set account name"): yield AccountNameInput( -- GitLab From 7c252c852b0e732355db8ca40b397be1deb4b877 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 13 Mar 2025 13:09:53 +0100 Subject: [PATCH 122/192] Start periodic update alarms/node data on unlock or create new profile --- clive/__private/ui/app.py | 5 +---- .../ui/forms/create_profile/finish_profile_creation_mixin.py | 4 +++- clive/__private/ui/screens/unlock/unlock.py | 2 ++ tests/tui/conftest.py | 4 +++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index c4975138ab..a3ad343c0d 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -245,10 +245,7 @@ class Clive(App[int]): def update_alarms_data_asap_on_newest_node_data(self) -> Worker[None]: """Update alarms on the newest possible node data.""" self.update_data_from_node() - worker = self.update_alarms_data() - self.resume_refresh_node_data_interval() - self.resume_refresh_alarms_data_interval() - return worker + return self.update_alarms_data() @work(name="alarms data update worker", group="alarms_data", exclusive=True) async def update_alarms_data(self) -> None: diff --git a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py index 96fd80205d..ad69c5f655 100644 --- a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py +++ b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py @@ -13,7 +13,9 @@ class FinishProfileCreationMixin(FormScreenBase[CreateProfileContext]): self.app.run_worker(self._finish()) async def _finish(self) -> None: - self._owner.add_post_action(self.app.update_alarms_data_asap_on_newest_node_data) + self._owner.add_post_action( + self.app.update_alarms_data_asap_on_newest_node_data, self.app.resume_refresh_alarms_data_interval + ) profile = self.context.profile profile.enable_saving() diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index 0c5fa45424..194b55e9ca 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -124,6 +124,8 @@ class Unlock(BaseScreen): await self.app.switch_mode("dashboard") self._remove_welcome_modes() self.app.update_alarms_data_asap_on_newest_node_data() + self.app.resume_refresh_node_data_interval() + self.app.resume_refresh_alarms_data_interval() @on(Button.Pressed, "#new-profile-button") async def create_new_profile(self) -> None: diff --git a/tests/tui/conftest.py b/tests/tui/conftest.py index 483b394429..ba93a4058a 100644 --- a/tests/tui/conftest.py +++ b/tests/tui/conftest.py @@ -97,8 +97,10 @@ async def prepared_tui_on_dashboard(prepared_env: PreparedTuiEnv) -> PreparedTui node, wallet, pilot = prepared_env await pilot.app.world.load_profile(WORKING_ACCOUNT_DATA.account.name, WORKING_ACCOUNT_PASSWORD) - # update the data (pilot skips onboarding/unlocking via TUI - updating is handled there) + # update the data and resume timers (pilot skips onboarding/unlocking via TUI - updating is handled there) await pilot.app.update_alarms_data_asap_on_newest_node_data().wait() + pilot.app.resume_refresh_node_data_interval() + pilot.app.resume_refresh_alarms_data_interval() await pilot.app.push_screen(Dashboard()) await wait_for_screen(pilot, Dashboard) -- GitLab From 17958066f053b17df9e3d91ab9ace9c65b746914 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 13 Mar 2025 13:25:35 +0100 Subject: [PATCH 123/192] Ensure update_alarms_data_asap_on_newest_node does not work on cached node data --- clive/__private/ui/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index a3ad343c0d..9a25ab2a5d 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -244,8 +244,10 @@ class Clive(App[int]): def update_alarms_data_asap_on_newest_node_data(self) -> Worker[None]: """Update alarms on the newest possible node data.""" - self.update_data_from_node() - return self.update_alarms_data() + async def update_alarms_data_on_newest_node_data() -> None: + await self.update_data_from_node().wait() + await self.update_alarms_data().wait() + return self.run_worker(update_alarms_data_on_newest_node_data()) @work(name="alarms data update worker", group="alarms_data", exclusive=True) async def update_alarms_data(self) -> None: -- GitLab From eae066efb1e94d6c5f09c55dc63d2d886dbcea6a Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Thu, 13 Mar 2025 13:45:55 +0100 Subject: [PATCH 124/192] Suppress WorkerCancelled exception of update node/alarms data when entering dashboard --- clive/__private/ui/app.py | 18 ++++++++++++++---- .../finish_profile_creation_mixin.py | 3 ++- clive/__private/ui/screens/unlock/unlock.py | 2 +- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 9a25ab2a5d..eac92904ba 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -13,6 +13,7 @@ from textual.app import App from textual.binding import Binding from textual.notifications import Notification, Notify, SeverityLevel from textual.reactive import var +from textual.worker import WorkerCancelled from clive.__private.core.constants.terminal import TERMINAL_HEIGHT, TERMINAL_WIDTH from clive.__private.core.constants.tui.bindings import APP_QUIT_KEY_BINDING @@ -242,12 +243,21 @@ class Clive(App[int]): def trigger_app_state_watchers(self) -> None: self.world.mutate_reactive(TUIWorld.app_state) # type: ignore[arg-type] - def update_alarms_data_asap_on_newest_node_data(self) -> Worker[None]: + def update_alarms_data_asap_on_newest_node_data(self, *, suppress_cancelled_error: bool = False) -> Worker[None]: """Update alarms on the newest possible node data.""" + async def update_alarms_data_on_newest_node_data() -> None: - await self.update_data_from_node().wait() - await self.update_alarms_data().wait() - return self.run_worker(update_alarms_data_on_newest_node_data()) + try: + await self.update_data_from_node().wait() + await self.update_alarms_data().wait() + except WorkerCancelled as error: + logger.warning("Update alarms data on newest node data cancelled.") + if not suppress_cancelled_error: + logger.warning(f"Re-raising exception: {error}") + raise + logger.warning(f"Ignoring exception: {error}") + + return self.run_worker(update_alarms_data_on_newest_node_data(), exclusive=True) @work(name="alarms data update worker", group="alarms_data", exclusive=True) async def update_alarms_data(self) -> None: diff --git a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py index ad69c5f655..52fd14c8a5 100644 --- a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py +++ b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py @@ -14,7 +14,8 @@ class FinishProfileCreationMixin(FormScreenBase[CreateProfileContext]): async def _finish(self) -> None: self._owner.add_post_action( - self.app.update_alarms_data_asap_on_newest_node_data, self.app.resume_refresh_alarms_data_interval + lambda: self.app.update_alarms_data_asap_on_newest_node_data(suppress_cancelled_error=True), + self.app.resume_refresh_alarms_data_interval, ) profile = self.context.profile diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index 194b55e9ca..44f1eff6eb 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -123,7 +123,7 @@ class Unlock(BaseScreen): await self.world.load_profile_based_on_beekepeer() await self.app.switch_mode("dashboard") self._remove_welcome_modes() - self.app.update_alarms_data_asap_on_newest_node_data() + self.app.update_alarms_data_asap_on_newest_node_data(suppress_cancelled_error=True) self.app.resume_refresh_node_data_interval() self.app.resume_refresh_alarms_data_interval() -- GitLab From 8cd2c908a2548b14bb0fdbb5d91b53aa68772313 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Fri, 14 Mar 2025 11:19:43 +0100 Subject: [PATCH 125/192] Clean node cache when going back from creating profile to unlock screen --- .../__private/ui/forms/create_profile/set_account_form_screen.py | 1 + 1 file changed, 1 insertion(+) diff --git a/clive/__private/ui/forms/create_profile/set_account_form_screen.py b/clive/__private/ui/forms/create_profile/set_account_form_screen.py index 4dc41d60ae..80e5da8169 100644 --- a/clive/__private/ui/forms/create_profile/set_account_form_screen.py +++ b/clive/__private/ui/forms/create_profile/set_account_form_screen.py @@ -55,6 +55,7 @@ class SetAccountFormScreen(BaseScreen, FormScreen[CreateProfileContext], FinishP @on(PreviousScreenButton.Pressed) async def action_previous_screen(self, event: PreviousScreenButton.Pressed | None = None) -> None: self.app.pause_refresh_node_data_interval() + self.node.cached.clear() if event is None: await super().action_previous_screen() -- GitLab From 33fe65637cc065178d067f91d0b21804a8ad335a Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Fri, 14 Mar 2025 12:03:52 +0100 Subject: [PATCH 126/192] Rename update_alarms_data_asap_on_newest_node_data --- clive/__private/ui/app.py | 8 ++++---- .../forms/create_profile/finish_profile_creation_mixin.py | 2 +- clive/__private/ui/screens/unlock/unlock.py | 2 +- tests/tui/conftest.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index eac92904ba..31460c2d02 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -243,10 +243,10 @@ class Clive(App[int]): def trigger_app_state_watchers(self) -> None: self.world.mutate_reactive(TUIWorld.app_state) # type: ignore[arg-type] - def update_alarms_data_asap_on_newest_node_data(self, *, suppress_cancelled_error: bool = False) -> Worker[None]: - """Update alarms on the newest possible node data.""" + def update_alarms_data_on_newest_node_data(self, *, suppress_cancelled_error: bool = False) -> Worker[None]: + """There is periodic work refreshing alarms data and node data, this method triggers immediate update.""" - async def update_alarms_data_on_newest_node_data() -> None: + async def _update_alarms_data_on_newest_node_data() -> None: try: await self.update_data_from_node().wait() await self.update_alarms_data().wait() @@ -257,7 +257,7 @@ class Clive(App[int]): raise logger.warning(f"Ignoring exception: {error}") - return self.run_worker(update_alarms_data_on_newest_node_data(), exclusive=True) + return self.run_worker(_update_alarms_data_on_newest_node_data(), exclusive=True) @work(name="alarms data update worker", group="alarms_data", exclusive=True) async def update_alarms_data(self) -> None: diff --git a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py index 52fd14c8a5..6ada739231 100644 --- a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py +++ b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py @@ -14,7 +14,7 @@ class FinishProfileCreationMixin(FormScreenBase[CreateProfileContext]): async def _finish(self) -> None: self._owner.add_post_action( - lambda: self.app.update_alarms_data_asap_on_newest_node_data(suppress_cancelled_error=True), + lambda: self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True), self.app.resume_refresh_alarms_data_interval, ) diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index 44f1eff6eb..7a831c2c27 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -123,7 +123,7 @@ class Unlock(BaseScreen): await self.world.load_profile_based_on_beekepeer() await self.app.switch_mode("dashboard") self._remove_welcome_modes() - self.app.update_alarms_data_asap_on_newest_node_data(suppress_cancelled_error=True) + self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) self.app.resume_refresh_node_data_interval() self.app.resume_refresh_alarms_data_interval() diff --git a/tests/tui/conftest.py b/tests/tui/conftest.py index ba93a4058a..b267e310c0 100644 --- a/tests/tui/conftest.py +++ b/tests/tui/conftest.py @@ -98,7 +98,7 @@ async def prepared_tui_on_dashboard(prepared_env: PreparedTuiEnv) -> PreparedTui await pilot.app.world.load_profile(WORKING_ACCOUNT_DATA.account.name, WORKING_ACCOUNT_PASSWORD) # update the data and resume timers (pilot skips onboarding/unlocking via TUI - updating is handled there) - await pilot.app.update_alarms_data_asap_on_newest_node_data().wait() + await pilot.app.update_alarms_data_on_newest_node_data().wait() pilot.app.resume_refresh_node_data_interval() pilot.app.resume_refresh_alarms_data_interval() -- GitLab From 8fcba07f929553efae83c37d757b4e99b3205313 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Fri, 14 Mar 2025 12:40:17 +0100 Subject: [PATCH 127/192] Make data_provider update works exclusive --- clive/__private/ui/app.py | 6 ++--- .../ui/data_providers/abc/data_provider.py | 24 +++++++++++++++---- .../hive_power_data_provider.py | 4 +--- .../data_providers/proposals_data_provider.py | 4 +--- .../data_providers/savings_data_provider.py | 4 +--- .../data_providers/witnesses_data_provider.py | 4 +--- 6 files changed, 26 insertions(+), 20 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 31460c2d02..4046cd661f 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -319,13 +319,13 @@ class Clive(App[int]): for signal_number in [signal.SIGHUP, signal.SIGINT, signal.SIGQUIT, signal.SIGTERM]: loop.add_signal_handler(signal_number, callback) - def _is_worker_group_empty(self, group: str) -> bool: + def is_worker_group_empty(self, group: str) -> bool: return not bool([worker for worker in self.workers if worker.group == group]) def _retrigger_update_data_from_node(self) -> None: - if self._is_worker_group_empty("node_data"): + if self.is_worker_group_empty("node_data"): self.update_data_from_node() def _retrigger_update_alarms_data(self) -> None: - if self._is_worker_group_empty("alarms_data"): + if self.is_worker_group_empty("alarms_data"): self.update_alarms_data() diff --git a/clive/__private/ui/data_providers/abc/data_provider.py b/clive/__private/ui/data_providers/abc/data_provider.py index b327d580a3..b5ba6bf05b 100644 --- a/clive/__private/ui/data_providers/abc/data_provider.py +++ b/clive/__private/ui/data_providers/abc/data_provider.py @@ -3,7 +3,8 @@ from __future__ import annotations from abc import abstractmethod from typing import Final, Generic, TypeVar -from textual import on, work +from inflection import underscore +from textual import on from textual.containers import Container from textual.reactive import var from textual.worker import Worker, WorkerState @@ -63,15 +64,20 @@ class DataProvider(Container, CliveWidget, Generic[ProviderContentT], AbstractCl if not paused and init_update: self.update() - self.interval = self.set_interval(safe_settings.node.refresh_rate_secs, self.update, pause=paused) + self.interval = self.set_interval( + safe_settings.node.refresh_rate_secs, self._update_if_not_ongoing, pause=paused + ) + + def update(self) -> Worker[None]: + name = self.get_worker_name() + return self.run_worker(self._update(), name=name, group=name, exclusive=True) @abstractmethod - @work - async def update(self) -> None: + async def _update(self) -> None: """ Define the logic to update the provider data. - The name of the worker can be included by overriding the work decorator, e.g.: @work("my work"). + The name and group of the worker is inferred from the class name. """ @on(Worker.StateChanged) @@ -106,3 +112,11 @@ class DataProvider(Container, CliveWidget, Generic[ProviderContentT], AbstractCl self.interval.resume() return worker + + def get_worker_name(self) -> str: + return f"{underscore(self.__class__.__name__)} update worker" + + def _update_if_not_ongoing(self) -> None: + name = self.get_worker_name() + if self.app.is_worker_group_empty(name): + self.update() diff --git a/clive/__private/ui/data_providers/hive_power_data_provider.py b/clive/__private/ui/data_providers/hive_power_data_provider.py index eb555c3eb4..f0e6aabac9 100644 --- a/clive/__private/ui/data_providers/hive_power_data_provider.py +++ b/clive/__private/ui/data_providers/hive_power_data_provider.py @@ -1,6 +1,5 @@ from __future__ import annotations -from textual import work from textual.reactive import var from clive.__private.core.commands.data_retrieval.hive_power_data import HivePowerData @@ -13,8 +12,7 @@ class HivePowerDataProvider(DataProvider[HivePowerData]): def __init__(self, *, paused: bool = False, init_update: bool = True) -> None: super().__init__(paused=paused, init_update=init_update) - @work(name="hive power data update worker") - async def update(self) -> None: + async def _update(self) -> None: account_name = self.profile.accounts.working.name wrapper = await self.commands.retrieve_hp_data(account_name=account_name) diff --git a/clive/__private/ui/data_providers/proposals_data_provider.py b/clive/__private/ui/data_providers/proposals_data_provider.py index 4e5ac307d7..933df8b4ad 100644 --- a/clive/__private/ui/data_providers/proposals_data_provider.py +++ b/clive/__private/ui/data_providers/proposals_data_provider.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from textual import work from textual.reactive import var from clive.__private.core.commands.data_retrieval.proposals_data import ProposalsData, ProposalsDataRetrieval @@ -21,8 +20,7 @@ class ProposalsDataProvider(DataProvider[ProposalsData]): self.__order_direction: ProposalsDataRetrieval.OrderDirections = ProposalsDataRetrieval.DEFAULT_ORDER_DIRECTION self.__status: ProposalsDataRetrieval.Statuses = ProposalsDataRetrieval.DEFAULT_STATUS - @work(name="proposals data update worker") - async def update(self) -> None: + async def _update(self) -> None: proxy = self.profile.accounts.working.data.proxy account_name = proxy if proxy else self.profile.accounts.working.name diff --git a/clive/__private/ui/data_providers/savings_data_provider.py b/clive/__private/ui/data_providers/savings_data_provider.py index a8d8fbfdd4..5ae1646450 100644 --- a/clive/__private/ui/data_providers/savings_data_provider.py +++ b/clive/__private/ui/data_providers/savings_data_provider.py @@ -1,6 +1,5 @@ from __future__ import annotations -from textual import work from textual.reactive import var from clive.__private.core.commands.data_retrieval.savings_data import SavingsData @@ -13,8 +12,7 @@ class SavingsDataProvider(DataProvider[SavingsData]): _content: SavingsData | None = var(None, init=False) # type: ignore[assignment] """It is used to check whether savings data has been refreshed and to store savings data.""" - @work(name="savings data update worker") - async def update(self) -> None: + async def _update(self) -> None: account_name = self.profile.accounts.working.name wrapper = await self.commands.retrieve_savings_data(account_name=account_name) diff --git a/clive/__private/ui/data_providers/witnesses_data_provider.py b/clive/__private/ui/data_providers/witnesses_data_provider.py index c3777f37af..aa6d8e9f33 100644 --- a/clive/__private/ui/data_providers/witnesses_data_provider.py +++ b/clive/__private/ui/data_providers/witnesses_data_provider.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from textual import work from textual.reactive import var from clive.__private.core.commands.data_retrieval.witnesses_data import WitnessesData, WitnessesDataRetrieval @@ -26,8 +25,7 @@ class WitnessesDataProvider(DataProvider[WitnessesData]): self.__mode: WitnessesDataRetrieval.Modes = WitnessesDataRetrieval.DEFAULT_MODE self.__witness_name_pattern: str | None = None - @work(name="witnesses data update worker") - async def update(self) -> None: + async def _update(self) -> None: proxy = self.profile.accounts.working.data.proxy account_name = proxy if proxy else self.profile.accounts.working.name -- GitLab From 24e0b28a4fc0859f7ef901ecd576b717f1746016 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Wed, 26 Feb 2025 09:28:41 +0100 Subject: [PATCH 128/192] Bump hive and related dependencies --- .gitlab-ci.yml | 2 +- hive | 2 +- poetry.lock | 60 +++++++++++++++++++++----------------------------- pyproject.toml | 8 +++---- 4 files changed, 30 insertions(+), 42 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fb860daf46..47f8a506c8 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -39,7 +39,7 @@ variables: include: - project: 'hive/hive' - ref: ed702f86d3a1ae567b2d2e1d0d7240a187223964 + ref: 3a471e21c852f662c5017636eba178be08d11bb1 file: '/scripts/ci-helpers/prepare_data_image_job.yml' - project: 'hive/common-ci-configuration' ref: 689c9ac584a4ec4ebd8b623d1ed34c58ea76f1bd diff --git a/hive b/hive index ed702f86d3..3a471e21c8 160000 --- a/hive +++ b/hive @@ -1 +1 @@ -Subproject commit ed702f86d3a1ae567b2d2e1d0d7240a187223964 +Subproject commit 3a471e21c852f662c5017636eba178be08d11bb1 diff --git a/poetry.lock b/poetry.lock index 7505c996ee..e44508dcb1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -174,21 +174,27 @@ tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pyte [[package]] name = "beekeepy" -version = "0.0.1.dev327+6dbc448" +version = "0.0.1.dev338+58e0bf7" description = "All in one package for beekeeper interaction via Python interface." optional = false python-versions = ">=3.10,<4.0" files = [ - {file = "beekeepy-0.0.1.dev327+6dbc448-py3-none-any.whl", hash = "sha256:84420db8aa9858a8522e48b5ef491c4aa973658e0ef34fee9fdbd2a3a73fa0e8"}, + {file = "beekeepy-0.0.1.dev338+58e0bf7-py3-none-any.whl", hash = "sha256:380dce868a3c6a326f8fc53890f6135eeeceb9f79639b41896dd406b43ed3f89"}, ] [package.dependencies] -helpy = "0.0.1.dev327+6dbc448" +aiohttp = "3.9.1" +httpx = {version = "0.23.3", extras = ["http2"]} +loguru = "0.7.2" +pydantic = "1.10.18" +python-dateutil = "2.8.2" +requests = "2.27.1" +schemas = "0.0.1.dev323+e5a1ba1" [package.source] type = "legacy" url = "https://gitlab.syncad.com/api/v4/projects/434/packages/pypi/simple" -reference = "gitlab-helpy" +reference = "gitlab-beekeepy" [[package]] name = "certifi" @@ -436,30 +442,6 @@ files = [ hpack = ">=4.0,<5" hyperframe = ">=6.0,<7" -[[package]] -name = "helpy" -version = "0.0.1.dev327+6dbc448" -description = "Easily interact with the Hive blockchain using Python." -optional = false -python-versions = ">=3.10,<4.0" -files = [ - {file = "helpy-0.0.1.dev327+6dbc448-py3-none-any.whl", hash = "sha256:cd22b4c98a9e16065db8e889d8488050ee305d6b2577e5b0256ff04f9a2950f3"}, -] - -[package.dependencies] -aiohttp = "3.9.1" -httpx = {version = "0.23.3", extras = ["http2"]} -loguru = "0.7.2" -python-dateutil = "2.8.2" -requests = "2.27.1" -schemas = "0.0.1.dev323+e5a1ba1" -wax = "0.3.10.dev321+1384595" - -[package.source] -type = "legacy" -url = "https://gitlab.syncad.com/api/v4/projects/434/packages/pypi/simple" -reference = "gitlab-helpy" - [[package]] name = "hpack" version = "4.0.0" @@ -1368,10 +1350,12 @@ develop = false [package.dependencies] abstractcp = "0.9.9" -beekeepy = "0.0.1.dev327+6dbc448" +beekeepy = "0.0.1.dev338+58e0bf7" loguru = "0.7.2" python-dateutil = "2.8.2" requests = "2.27.1" +typing-extensions = "4.12.2" +wax = "0.3.10.dev558+3a636d1" [package.source] type = "directory" @@ -1467,13 +1451,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.8.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, - {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -1528,16 +1512,22 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "wax" -version = "0.3.10.dev321+1384595" +version = "0.3.10.dev558+3a636d1" description = "" optional = false python-versions = ">=3.10,<4.0" files = [ - {file = "wax-0.3.10.dev321+1384595-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:747429dfa159864212acf5f4ad57684f551b33152fa6b2dc0a6d62b7cd50d1d0"}, + {file = "wax-0.3.10.dev558+3a636d1-cp310-cp310-manylinux_2_35_x86_64.whl", hash = "sha256:ca430196868d2be71c442993192e214c81e2f0d4d86b322a0fc53628cf9003cc"}, ] [package.dependencies] +aiohttp = "3.9.1" +beekeepy = "0.0.1.dev338+58e0bf7" +httpx = {version = "0.23.3", extras = ["http2"]} +loguru = "0.7.2" protobuf = "4.24.4" +python-dateutil = "2.8.2" +requests = "2.27.1" [package.source] type = "legacy" @@ -1664,4 +1654,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "cb67ea36cc60cca5764a88b968993939295ff5aba0864592fcc445ad57c950d4" +content-hash = "1a08148da8f2092e32b1b48bb27824bc67c52245238e28cb6d95a23405746455" diff --git a/pyproject.toml b/pyproject.toml index 05134ba312..4840737150 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ source = [ { name = "PyPI", priority = "primary" }, { name = "gitlab-schemas", url = "https://gitlab.syncad.com/api/v4/projects/362/packages/pypi/simple", priority = "supplemental" }, { name = "gitlab-wax", url = "https://gitlab.syncad.com/api/v4/projects/419/packages/pypi/simple", priority = "supplemental" }, - { name = "gitlab-helpy", url = "https://gitlab.syncad.com/api/v4/projects/434/packages/pypi/simple", priority = "supplemental" }, + { name = "gitlab-beekeepy", url = "https://gitlab.syncad.com/api/v4/projects/434/packages/pypi/simple", priority = "supplemental" }, ] @@ -42,10 +42,8 @@ pydantic = "1.10.18" # remember to keep these versions adequate to one specified in test-tools schemas = "0.0.1.dev323+e5a1ba1" -wax = "0.3.10.dev321+1384595" -helpy = "0.0.1.dev327+6dbc448" -beekeepy = "0.0.1.dev327+6dbc448" - +beekeepy = "0.0.1.dev338+58e0bf7" +wax = "0.3.10.dev558+3a636d1" [tool.poetry.group.embeddedtestnet.dependencies] clive-local-tools = { path = "tests/clive-local-tools", develop = true } -- GitLab From 45626796bfdaa92c5106cb2f13618e8ba437b448 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Wed, 26 Feb 2025 09:40:07 +0100 Subject: [PATCH 129/192] Replace the import of settings from helpy by those from beekeepy --- clive/__private/settings/_safe_settings.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/clive/__private/settings/_safe_settings.py b/clive/__private/settings/_safe_settings.py index 68fc0308a3..fb57a95df9 100644 --- a/clive/__private/settings/_safe_settings.py +++ b/clive/__private/settings/_safe_settings.py @@ -7,9 +7,9 @@ from datetime import timedelta from pathlib import Path from typing import Literal, TypeVar, cast, get_args, overload +from beekeepy import RemoteHandleSettings from beekeepy import Settings as BeekeepySettings -from helpy import HttpUrl -from helpy import Settings as HelpySettings +from beekeepy.interfaces import HttpUrl from inflection import underscore from clive.__private.core.constants.setting_identifiers import ( @@ -311,14 +311,14 @@ class SafeSettings: def communication_retries_delay_secs(self) -> float: return self._get_node_communication_retries_delay_secs() - def settings_factory(self, http_endpoint: HttpUrl) -> HelpySettings: - helpy_settings = HelpySettings(http_endpoint=http_endpoint) + def settings_factory(self, http_endpoint: HttpUrl) -> RemoteHandleSettings: + remote_handle_settings = RemoteHandleSettings(http_endpoint=http_endpoint) - helpy_settings.timeout = timedelta(seconds=self.communication_timeout_total_secs) - helpy_settings.max_retries = self.communication_attempts_amount - helpy_settings.period_between_retries = timedelta(seconds=self.communication_retries_delay_secs) + remote_handle_settings.timeout = timedelta(seconds=self.communication_timeout_total_secs) + remote_handle_settings.max_retries = self.communication_attempts_amount + remote_handle_settings.period_between_retries = timedelta(seconds=self.communication_retries_delay_secs) - return helpy_settings + return remote_handle_settings def _get_node_chain_id(self) -> str | None: from clive.__private.core.validate_schema_field import is_schema_field_valid -- GitLab From 62246952953a6b30d64f5db0ae5e72ee764c246e Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Wed, 26 Feb 2025 10:05:53 +0100 Subject: [PATCH 130/192] Replacement of HttpUrl import from helpy to beekeepy --- clive/__private/cli/commands/abc/beekeeper_based_command.py | 2 +- clive/__private/cli/commands/configure/node.py | 2 +- clive/__private/cli/exceptions.py | 2 +- clive/__private/core/profile.py | 2 +- clive/__private/core/url_utils.py | 2 +- clive/__private/ui/widgets/node_widgets.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/clive/__private/cli/commands/abc/beekeeper_based_command.py b/clive/__private/cli/commands/abc/beekeeper_based_command.py index f2e64861bc..9402cd204e 100644 --- a/clive/__private/cli/commands/abc/beekeeper_based_command.py +++ b/clive/__private/cli/commands/abc/beekeeper_based_command.py @@ -3,7 +3,7 @@ from dataclasses import dataclass import typer from beekeepy import AsyncBeekeeper -from helpy import HttpUrl +from beekeepy.interfaces import HttpUrl from clive.__private.cli.commands.abc.contextual_cli_command import ContextualCLICommand from clive.__private.cli.exceptions import ( diff --git a/clive/__private/cli/commands/configure/node.py b/clive/__private/cli/commands/configure/node.py index f94aad7657..a2403787fb 100644 --- a/clive/__private/cli/commands/configure/node.py +++ b/clive/__private/cli/commands/configure/node.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from helpy import HttpUrl +from beekeepy.interfaces import HttpUrl from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand diff --git a/clive/__private/cli/exceptions.py b/clive/__private/cli/exceptions.py index d283b814cb..43b3ac83fc 100644 --- a/clive/__private/cli/exceptions.py +++ b/clive/__private/cli/exceptions.py @@ -19,7 +19,7 @@ from clive.__private.settings import clive_prefixed_envvar if TYPE_CHECKING: from datetime import timedelta - from helpy import HttpUrl + from beekeepy.interfaces import HttpUrl from clive.__private.core.profile import Profile diff --git a/clive/__private/core/profile.py b/clive/__private/core/profile.py index c59dbe2682..1a9bbe0005 100644 --- a/clive/__private/core/profile.py +++ b/clive/__private/core/profile.py @@ -3,7 +3,7 @@ from __future__ import annotations from copy import deepcopy from typing import TYPE_CHECKING, Final -from helpy import HttpUrl +from beekeepy.interfaces import HttpUrl from clive.__private.core.accounts.account_manager import AccountManager from clive.__private.core.contextual import Context diff --git a/clive/__private/core/url_utils.py b/clive/__private/core/url_utils.py index e85ac56f55..ff2201d821 100644 --- a/clive/__private/core/url_utils.py +++ b/clive/__private/core/url_utils.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING import aiohttp if TYPE_CHECKING: - from helpy import HttpUrl + from beekeepy.interfaces import HttpUrl async def is_url_reachable(url: HttpUrl) -> bool: diff --git a/clive/__private/ui/widgets/node_widgets.py b/clive/__private/ui/widgets/node_widgets.py index a56d85539e..193859404a 100644 --- a/clive/__private/ui/widgets/node_widgets.py +++ b/clive/__private/ui/widgets/node_widgets.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from helpy import HttpUrl +from beekeepy.interfaces import HttpUrl from textual.containers import Container from textual.reactive import reactive from textual.widgets import Static -- GitLab From 4332a2af35e2f37b6533f5fccb7a6abdbb8cf1ca Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Wed, 26 Feb 2025 10:07:35 +0100 Subject: [PATCH 131/192] Replace import from helpy in the node.py module --- clive/__private/core/node.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/clive/__private/core/node.py b/clive/__private/core/node.py index f57c15e067..fbff72a9ed 100644 --- a/clive/__private/core/node.py +++ b/clive/__private/core/node.py @@ -6,15 +6,17 @@ from dataclasses import dataclass, field from datetime import timedelta from typing import TYPE_CHECKING -from helpy import AsyncHived, HttpUrl -from helpy.exceptions import CommunicationError +from beekeepy.exceptions import CommunicationError from clive.__private.core.commands.data_retrieval.get_node_basic_info import GetNodeBasicInfo, NodeBasicInfoData from clive.__private.settings import safe_settings +from wax.helpy import AsyncHived if TYPE_CHECKING: from collections.abc import Iterator + from beekeepy.interfaces import HttpUrl + from clive.__private.core.profile import Profile from clive.__private.models.schemas import Config, DynamicGlobalProperties, Version -- GitLab From 07a152837e5eced844de87111034cf631e3d8f10 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Wed, 26 Feb 2025 10:44:24 +0100 Subject: [PATCH 132/192] Import exceptions from beekeepy instead of helpy --- clive/__private/cli/commands/configure/profile.py | 2 +- clive/__private/cli/error_handlers.py | 2 +- .../core/commands/data_retrieval/update_node_data/command.py | 2 +- clive/__private/core/commands/decrypt.py | 2 +- clive/__private/core/commands/encrypt.py | 2 +- .../core/error_handlers/communication_failure_notificator.py | 2 +- clive/__private/ui/app.py | 2 +- .../clive_local_tools/checkers/blockchain_checkers.py | 2 +- tests/functional/commands/test_transaction_status.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/clive/__private/cli/commands/configure/profile.py b/clive/__private/cli/commands/configure/profile.py index 043eaa26ca..8196e096be 100644 --- a/clive/__private/cli/commands/configure/profile.py +++ b/clive/__private/cli/commands/configure/profile.py @@ -3,7 +3,7 @@ import sys from dataclasses import dataclass from getpass import getpass -from helpy.exceptions import CommunicationError +from beekeepy.exceptions import CommunicationError from clive.__private.cli.commands.abc.beekeeper_based_command import BeekeeperBasedCommand from clive.__private.cli.commands.abc.external_cli_command import ExternalCLICommand diff --git a/clive/__private/cli/error_handlers.py b/clive/__private/cli/error_handlers.py index 2969bb8a42..18c380a855 100644 --- a/clive/__private/cli/error_handlers.py +++ b/clive/__private/cli/error_handlers.py @@ -1,6 +1,6 @@ import errno -from helpy.exceptions import CommunicationError +from beekeepy.exceptions import CommunicationError from clive.__private.cli.clive_typer import CliveTyper from clive.__private.cli.exceptions import CLIPrettyError, CLIProfileAlreadyExistsError, CLIProfileDoesNotExistsError diff --git a/clive/__private/core/commands/data_retrieval/update_node_data/command.py b/clive/__private/core/commands/data_retrieval/update_node_data/command.py index 49e3b3a446..0fcd6e6329 100644 --- a/clive/__private/core/commands/data_retrieval/update_node_data/command.py +++ b/clive/__private/core/commands/data_retrieval/update_node_data/command.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import TYPE_CHECKING, Final -from helpy import SuppressApiNotFound +from beekeepy.interfaces import SuppressApiNotFound from clive.__private.core import iwax from clive.__private.core.commands.abc.command_data_retrieval import CommandDataRetrieval diff --git a/clive/__private/core/commands/decrypt.py b/clive/__private/core/commands/decrypt.py index f530ca9ccb..b4cb00d964 100644 --- a/clive/__private/core/commands/decrypt.py +++ b/clive/__private/core/commands/decrypt.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from helpy.exceptions import CommunicationError +from beekeepy.exceptions import CommunicationError from clive.__private.core.commands.abc.command import Command, CommandError from clive.__private.core.commands.abc.command_encryption import CommandEncryption diff --git a/clive/__private/core/commands/encrypt.py b/clive/__private/core/commands/encrypt.py index a00a1e8601..ead9879379 100644 --- a/clive/__private/core/commands/encrypt.py +++ b/clive/__private/core/commands/encrypt.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from helpy.exceptions import CommunicationError +from beekeepy.exceptions import CommunicationError from clive.__private.core.commands.abc.command import Command, CommandError from clive.__private.core.commands.abc.command_encryption import CommandEncryption diff --git a/clive/__private/core/error_handlers/communication_failure_notificator.py b/clive/__private/core/error_handlers/communication_failure_notificator.py index 2eb3d392c4..6b62ade903 100644 --- a/clive/__private/core/error_handlers/communication_failure_notificator.py +++ b/clive/__private/core/error_handlers/communication_failure_notificator.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Final, TypeGuard -from helpy.exceptions import CommunicationError, TimeoutExceededError +from beekeepy.exceptions import CommunicationError, TimeoutExceededError from clive.__private.core.clive_import import get_clive from clive.__private.core.error_handlers.abc.error_notificator import ErrorNotificator diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 4046cd661f..454f68544b 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -6,7 +6,7 @@ import traceback from contextlib import asynccontextmanager, contextmanager from typing import TYPE_CHECKING, Any, TypeVar, cast -from helpy.exceptions import CommunicationError +from beekeepy.exceptions import CommunicationError from textual import on, work from textual._context import active_app from textual.app import App diff --git a/tests/clive-local-tools/clive_local_tools/checkers/blockchain_checkers.py b/tests/clive-local-tools/clive_local_tools/checkers/blockchain_checkers.py index d7d268a4c6..fc85e01507 100644 --- a/tests/clive-local-tools/clive_local_tools/checkers/blockchain_checkers.py +++ b/tests/clive-local-tools/clive_local_tools/checkers/blockchain_checkers.py @@ -9,8 +9,8 @@ if TYPE_CHECKING: from clive.__private.models.schemas import OperationUnion, RepresentationBase +from beekeepy.exceptions import ErrorInResponseError from click.testing import Result -from helpy.exceptions import ErrorInResponseError from clive_local_tools.helpers import get_transaction_id_from_output diff --git a/tests/functional/commands/test_transaction_status.py b/tests/functional/commands/test_transaction_status.py index 6191874d33..3767ec8e11 100644 --- a/tests/functional/commands/test_transaction_status.py +++ b/tests/functional/commands/test_transaction_status.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING import pytest -from helpy.exceptions import ApiNotFoundError +from beekeepy.exceptions import ApiNotFoundError from clive.__private.core.keys import PrivateKeyAliased from clive.__private.logger import logger -- GitLab From 49951d5c5733e3eb409924468068d09214f921f0 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Wed, 26 Feb 2025 10:45:17 +0100 Subject: [PATCH 133/192] Import database api common from wax.helpy instead of helpy --- clive/__private/core/commands/data_retrieval/proposals_data.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/clive/__private/core/commands/data_retrieval/proposals_data.py b/clive/__private/core/commands/data_retrieval/proposals_data.py index e7b7c12183..7c18a70893 100644 --- a/clive/__private/core/commands/data_retrieval/proposals_data.py +++ b/clive/__private/core/commands/data_retrieval/proposals_data.py @@ -13,11 +13,10 @@ from clive.__private.models import Asset if TYPE_CHECKING: import datetime - from helpy._handles.hived.api.database_api.common import DatabaseApiCommons - from clive.__private.core.node import Node from clive.__private.models.schemas import DynamicGlobalProperties, ListProposals, ListProposalVotes from clive.__private.models.schemas import Proposal as SchemasProposal + from wax.helpy import DatabaseApiCommons @dataclass -- GitLab From e6cacb46f7f58acdd8b8fcd7420186fb36087862 Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Fri, 28 Feb 2025 11:44:31 +0100 Subject: [PATCH 134/192] Update expected error in the `test_withdrawal_cancel_invalid` See: https://gitlab.syncad.com/hive/hive/-/commit/a80969d0bbbe0279ffcc70cf3e866118ae47b075 --- .../cli/process/test_process_savings_withdrawal_cancel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/functional/cli/process/test_process_savings_withdrawal_cancel.py b/tests/functional/cli/process/test_process_savings_withdrawal_cancel.py index 0b0925339a..4e50875827 100644 --- a/tests/functional/cli/process/test_process_savings_withdrawal_cancel.py +++ b/tests/functional/cli/process/test_process_savings_withdrawal_cancel.py @@ -8,7 +8,7 @@ import test_tools as tt from clive_local_tools.cli import checkers from clive_local_tools.cli.exceptions import CLITestCommandError from clive_local_tools.data.constants import WORKING_ACCOUNT_KEY_ALIAS -from clive_local_tools.testnet_block_log import WORKING_ACCOUNT_DATA +from clive_local_tools.testnet_block_log import WORKING_ACCOUNT_DATA, WORKING_ACCOUNT_NAME if TYPE_CHECKING: from clive_local_tools.cli.cli_tester import CLITester @@ -46,7 +46,9 @@ async def test_withdrawal_cancel_invalid(cli_tester: CLITester) -> None: cli_tester.process_savings_withdrawal( amount=AMOUNT_TO_DEPOSIT, sign=WORKING_ACCOUNT_KEY_ALIAS, request_id=actual_request_id ) - expected_error = rf'unknown key: \["alice",{invalid_request_id}\] of type' + expected_error = ( + rf"Savings withdraw for `owner` {WORKING_ACCOUNT_NAME} and 'request_id' {invalid_request_id} doesn't exist." + ) # ACT with pytest.raises(CLITestCommandError, match=expected_error) as withdrawal_cancel_exception_info: -- GitLab From 49f402a35aca9488b440b2f19458c28485be3f5c Mon Sep 17 00:00:00 2001 From: Jakub Ziebinski <ziebinskijakub@gmail.com> Date: Tue, 11 Mar 2025 11:55:33 +0100 Subject: [PATCH 135/192] Remove helpy from known first party packages --- clive/__private/core/constants/env.py | 1 - 1 file changed, 1 deletion(-) diff --git a/clive/__private/core/constants/env.py b/clive/__private/core/constants/env.py index ac06f510a3..76dbfcb914 100644 --- a/clive/__private/core/constants/env.py +++ b/clive/__private/core/constants/env.py @@ -11,7 +11,6 @@ ENVVAR_PREFIX: Final[str] = "CLIVE" KNOWN_FIRST_PARTY_PACKAGES: Final[list[str]] = [ "beekeepy", "clive_local_tools", - "helpy", "schemas", "test_tools", "wax", -- GitLab From ea465607e2c714bbbdf98b4048d2bb9ee336cfad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Wed, 12 Mar 2025 16:28:22 +0100 Subject: [PATCH 136/192] Remove run_worker --- clive/__private/ui/dialogs/switch_node_address_dialog.py | 4 ++-- .../common_governance/governance_table.py | 5 +---- .../ui/widgets/clive_basic/clive_checkerboard_table.py | 6 +----- .../ui/widgets/switch_working_account_container.py | 5 +---- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/clive/__private/ui/dialogs/switch_node_address_dialog.py b/clive/__private/ui/dialogs/switch_node_address_dialog.py index ccfadf1ad9..5cf5477d2c 100644 --- a/clive/__private/ui/dialogs/switch_node_address_dialog.py +++ b/clive/__private/ui/dialogs/switch_node_address_dialog.py @@ -30,9 +30,9 @@ class SwitchNodeAddressDialog(CliveActionDialog): @on(ConfirmButton.Pressed) async def switch_node_address(self) -> None: - self.app.run_worker(self._switch_node_address()) + await self._switch_node_address() async def _switch_node_address(self) -> None: change_node_succeeded = await self.query_exactly_one(NodesList).save_selected_node_address() if change_node_succeeded: - await self.app.pop_screen() + self.dismiss() diff --git a/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_table.py b/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_table.py index d5d179cd80..7381de9272 100644 --- a/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_table.py +++ b/clive/__private/ui/screens/operations/governance_operations/common_governance/governance_table.py @@ -226,10 +226,7 @@ class GovernanceTable( yield self.header def on_mount(self) -> None: - def delegate_work() -> None: - self.run_worker(self.sync_list()) - - self.watch(self.provider, "_content", callback=delegate_work) + self.watch(self.provider, "_content", callback=lambda: self.sync_list()) async def sync_list(self, *, focus_first_element: bool = False) -> None: await self.loading_set() diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index df5ee05ea8..e015508640 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -173,11 +173,7 @@ class CliveCheckerboardTable(CliveWidget): def on_mount(self) -> None: if self.should_be_dynamic: - - def delegate_work(content: ContentT) -> None: - self.run_worker(self._mount_dynamic_rows(content)) - - self.watch(self.object_to_watch, self.ATTRIBUTE_TO_WATCH, delegate_work) + self.watch(self.object_to_watch, self.ATTRIBUTE_TO_WATCH, self._mount_dynamic_rows) def _mount_static_rows(self) -> None: """Mount rows created in static mode.""" diff --git a/clive/__private/ui/widgets/switch_working_account_container.py b/clive/__private/ui/widgets/switch_working_account_container.py index 2b3dcc7a59..875a8aa0a0 100644 --- a/clive/__private/ui/widgets/switch_working_account_container.py +++ b/clive/__private/ui/widgets/switch_working_account_container.py @@ -89,11 +89,8 @@ class SwitchWorkingAccountContainer(Container, CliveWidget): ) def on_mount(self) -> None: - def delegate_work_rebuild_tracked_accounts(profile: Profile) -> None: - self.run_worker(self._rebuild_tracked_accounts(profile)) - self.watch(self.world, "profile_reactive", self._update_local_profile) - self.watch(self, "local_profile", delegate_work_rebuild_tracked_accounts) + self.watch(self, "local_profile", self._rebuild_tracked_accounts) def _update_local_profile(self, profile: Profile) -> None: if self.local_profile.accounts.tracked == profile.accounts.tracked: -- GitLab From 0acd59eb12db9a22535f72dfd18f89b415f34127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 17 Mar 2025 10:30:59 +0100 Subject: [PATCH 137/192] Use NotUpdatedYet rather than None in DataProvider --- .../ui/data_providers/abc/data_provider.py | 11 +++++++---- .../data_providers/hive_power_data_provider.py | 3 ++- .../data_providers/proposals_data_provider.py | 3 ++- .../ui/data_providers/savings_data_provider.py | 3 ++- .../data_providers/witnesses_data_provider.py | 3 ++- clive/__private/ui/not_updated_yet.py | 17 +++++++++++++++++ .../additional_info_widgets.py | 7 ++++--- .../common_hive_power/hp_vests_factor.py | 3 ++- .../power_down/power_down.py | 11 ++++++----- .../savings_operations/savings_operations.py | 6 +++--- clive/__private/ui/widgets/apr.py | 3 ++- .../clive_basic/clive_checkerboard_table.py | 15 +++++++++------ .../ui/widgets/clive_basic/clive_data_table.py | 6 ++---- 13 files changed, 60 insertions(+), 31 deletions(-) diff --git a/clive/__private/ui/data_providers/abc/data_provider.py b/clive/__private/ui/data_providers/abc/data_provider.py index b5ba6bf05b..1e57094558 100644 --- a/clive/__private/ui/data_providers/abc/data_provider.py +++ b/clive/__private/ui/data_providers/abc/data_provider.py @@ -13,6 +13,7 @@ from clive.__private.abstract_class import AbstractClassMessagePump from clive.__private.settings import safe_settings from clive.__private.ui.clive_screen import CliveScreen from clive.__private.ui.clive_widget import CliveWidget +from clive.__private.ui.not_updated_yet import NotUpdatedYet, is_not_updated_yet from clive.exceptions import CliveError ProviderContentT = TypeVar("ProviderContentT") @@ -26,7 +27,8 @@ class ProviderNotSetYetError(ProviderError): _MESSAGE: Final[str] = """ Provider content was referenced before the update actually occurred. You're probably using it too early. -If you are sure, you can use the `updated` property to check if content is ready or `_content` which may be None.""" +If you are sure, you can use the `updated` property to check if content is ready + or `_content` which may be NotUpdatedYet.""" def __init__(self) -> None: super().__init__(self._MESSAGE) @@ -41,7 +43,7 @@ class DataProvider(Container, CliveWidget, Generic[ProviderContentT], AbstractCl method, but could be also done by using the provider methods. """ - _content: ProviderContentT | None = var(None, init=False) # type: ignore[assignment] + _content: ProviderContentT | NotUpdatedYet = var(NotUpdatedYet(), init=False) # type: ignore[assignment] """Should be overridden by subclasses to store the data retrieved by the provider.""" updated: bool = var(default=False) # type: ignore[assignment] @@ -87,13 +89,14 @@ class DataProvider(Container, CliveWidget, Generic[ProviderContentT], AbstractCl @property def content(self) -> ProviderContentT: - if self._content is None: + if not self.is_content_set: raise ProviderNotSetYetError + assert not isinstance(self._content, NotUpdatedYet), "Already checked." return self._content @property def is_content_set(self) -> bool: - return self._content is not None + return not is_not_updated_yet(self._content) def stop(self) -> None: self.interval.stop() diff --git a/clive/__private/ui/data_providers/hive_power_data_provider.py b/clive/__private/ui/data_providers/hive_power_data_provider.py index f0e6aabac9..789953dc2b 100644 --- a/clive/__private/ui/data_providers/hive_power_data_provider.py +++ b/clive/__private/ui/data_providers/hive_power_data_provider.py @@ -4,10 +4,11 @@ from textual.reactive import var from clive.__private.core.commands.data_retrieval.hive_power_data import HivePowerData from clive.__private.ui.data_providers.abc.data_provider import DataProvider +from clive.__private.ui.not_updated_yet import NotUpdatedYet class HivePowerDataProvider(DataProvider[HivePowerData]): - _content: HivePowerData | None = var(None, init=False) # type: ignore[assignment] + _content: HivePowerData | NotUpdatedYet = var(NotUpdatedYet(), init=False) # type: ignore[assignment] def __init__(self, *, paused: bool = False, init_update: bool = True) -> None: super().__init__(paused=paused, init_update=init_update) diff --git a/clive/__private/ui/data_providers/proposals_data_provider.py b/clive/__private/ui/data_providers/proposals_data_provider.py index 933df8b4ad..2717cc3a01 100644 --- a/clive/__private/ui/data_providers/proposals_data_provider.py +++ b/clive/__private/ui/data_providers/proposals_data_provider.py @@ -6,13 +6,14 @@ from textual.reactive import var from clive.__private.core.commands.data_retrieval.proposals_data import ProposalsData, ProposalsDataRetrieval from clive.__private.ui.data_providers.abc.data_provider import DataProvider +from clive.__private.ui.not_updated_yet import NotUpdatedYet if TYPE_CHECKING: from textual.worker import Worker class ProposalsDataProvider(DataProvider[ProposalsData]): - _content: ProposalsData | None = var(None, init=False) # type: ignore[assignment] + _content: ProposalsData | NotUpdatedYet = var(NotUpdatedYet(), init=False) # type: ignore[assignment] def __init__(self, *, paused: bool = False, init_update: bool = True) -> None: super().__init__(paused=paused, init_update=init_update) diff --git a/clive/__private/ui/data_providers/savings_data_provider.py b/clive/__private/ui/data_providers/savings_data_provider.py index 5ae1646450..105f7eb54e 100644 --- a/clive/__private/ui/data_providers/savings_data_provider.py +++ b/clive/__private/ui/data_providers/savings_data_provider.py @@ -4,12 +4,13 @@ from textual.reactive import var from clive.__private.core.commands.data_retrieval.savings_data import SavingsData from clive.__private.ui.data_providers.abc.data_provider import DataProvider +from clive.__private.ui.not_updated_yet import NotUpdatedYet class SavingsDataProvider(DataProvider[SavingsData]): """A class for retrieving information about savings stored in a SavingsData dataclass.""" - _content: SavingsData | None = var(None, init=False) # type: ignore[assignment] + _content: SavingsData | NotUpdatedYet = var(NotUpdatedYet(), init=False) # type: ignore[assignment] """It is used to check whether savings data has been refreshed and to store savings data.""" async def _update(self) -> None: diff --git a/clive/__private/ui/data_providers/witnesses_data_provider.py b/clive/__private/ui/data_providers/witnesses_data_provider.py index aa6d8e9f33..6b330ae01d 100644 --- a/clive/__private/ui/data_providers/witnesses_data_provider.py +++ b/clive/__private/ui/data_providers/witnesses_data_provider.py @@ -6,6 +6,7 @@ from textual.reactive import var from clive.__private.core.commands.data_retrieval.witnesses_data import WitnessesData, WitnessesDataRetrieval from clive.__private.ui.data_providers.abc.data_provider import DataProvider +from clive.__private.ui.not_updated_yet import NotUpdatedYet if TYPE_CHECKING: from textual.worker import Worker @@ -14,7 +15,7 @@ if TYPE_CHECKING: class WitnessesDataProvider(DataProvider[WitnessesData]): """A class for retrieving information about witnesses stored in a WitnessesData dataclass.""" - _content: WitnessesData | None = var(None, init=False) # type: ignore[assignment] + _content: WitnessesData | NotUpdatedYet = var(NotUpdatedYet(), init=False) # type: ignore[assignment] """It is used to check whether witnesses data has been refreshed and to store witnesses data.""" def __init__(self, *, paused: bool = False, init_update: bool = True) -> None: diff --git a/clive/__private/ui/not_updated_yet.py b/clive/__private/ui/not_updated_yet.py index fa129ca146..dabe9efe67 100644 --- a/clive/__private/ui/not_updated_yet.py +++ b/clive/__private/ui/not_updated_yet.py @@ -1,5 +1,22 @@ from __future__ import annotations +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from typing_extensions import TypeIs + +ValueT = TypeVar("ValueT") + + +def is_not_updated_yet(value: ValueT | NotUpdatedYet) -> TypeIs[NotUpdatedYet]: + """Check whether the value is an instance of NotUpdatedYet.""" + return isinstance(value, NotUpdatedYet) + + +def is_updated(value: ValueT | NotUpdatedYet) -> TypeIs[ValueT]: + """Check whether the value is not an instance of NotUpdatedYet.""" + return not is_not_updated_yet(value) + class NotUpdatedYet: """Used to check whether data is not updated yet or it is not present (created to avoid initialization via None).""" diff --git a/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/additional_info_widgets.py b/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/additional_info_widgets.py index 4d0727cad4..3bca78a241 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/additional_info_widgets.py +++ b/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/additional_info_widgets.py @@ -11,6 +11,7 @@ from clive.__private.core.formatters.humanize import ( humanize_hp_vests_apr, ) from clive.__private.ui.clive_widget import CliveWidget +from clive.__private.ui.not_updated_yet import is_updated from clive.__private.ui.widgets.apr import APR from clive.__private.ui.widgets.dynamic_widgets.dynamic_label import DynamicLabel from clive.__private.ui.widgets.section_title import SectionTitle @@ -60,7 +61,7 @@ class WithdrawalInfo(Vertical, CliveWidget): "_content", self._get_next_withdrawal_date, id_="withdrawal-info-date", - first_try_callback=lambda content: content is not None, + first_try_callback=is_updated, ) yield SectionTitle("To withdraw", id_="to-withdraw-header") yield DynamicLabel( @@ -68,14 +69,14 @@ class WithdrawalInfo(Vertical, CliveWidget): "_content", self._get_to_withdraw_hp, id_="withdrawal-info-vests-amount", - first_try_callback=lambda content: content is not None, + first_try_callback=is_updated, ) yield DynamicLabel( self._provider, "_content", self._get_to_withdraw_vests, id_="withdrawal-info-hp-amount", - first_try_callback=lambda content: content is not None, + first_try_callback=is_updated, ) def _get_next_withdrawal_date(self, content: HivePowerData) -> str: diff --git a/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/hp_vests_factor.py b/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/hp_vests_factor.py index 83da8e2d72..a46c2458ea 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/hp_vests_factor.py +++ b/clive/__private/ui/screens/operations/hive_power_management/common_hive_power/hp_vests_factor.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from clive.__private.core.formatters.humanize import humanize_vest_to_hive_ratio +from clive.__private.ui.not_updated_yet import is_updated from clive.__private.ui.widgets.notice import Notice if TYPE_CHECKING: @@ -16,7 +17,7 @@ class HpVestsFactor(Notice): obj_to_watch=provider, attribute_name="_content", callback=self._get_hp_vests_factor, - first_try_callback=lambda content: content is not None, + first_try_callback=is_updated, ) def _get_hp_vests_factor(self, content: HivePowerData) -> str: diff --git a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py index 730f0df659..e06555467a 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py +++ b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py @@ -6,6 +6,7 @@ from textual import on from textual.containers import Horizontal from textual.widgets import Pretty, Static, TabPane +from clive.__private.core.commands.data_retrieval.hive_power_data import HivePowerData from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HEADER_CELL_CLASS_NAME from clive.__private.core.ensure_vests import ensure_vests from clive.__private.core.formatters.humanize import humanize_datetime, humanize_percent @@ -15,7 +16,7 @@ from clive.__private.models.schemas import WithdrawVestingOperation from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.data_providers.hive_power_data_provider import HivePowerDataProvider from clive.__private.ui.get_css import get_css_from_relative_path -from clive.__private.ui.not_updated_yet import NotUpdatedYet +from clive.__private.ui.not_updated_yet import NotUpdatedYet, is_not_updated_yet from clive.__private.ui.screens.operations.bindings.operation_action_bindings import OperationActionBindings from clive.__private.ui.screens.operations.operation_summary.cancel_power_down import CancelPowerDown from clive.__private.ui.widgets.buttons import CancelOneLineButton, GenerousButton @@ -37,8 +38,6 @@ if TYPE_CHECKING: from textual.app import ComposeResult - from clive.__private.core.commands.data_retrieval.hive_power_data import HivePowerData - class WithdrawRoutesDisplay(CliveWidget): """Widget used just to inform user to which account has withdrawal route and how much % it is.""" @@ -55,11 +54,13 @@ class WithdrawRoutesDisplay(CliveWidget): def on_mount(self) -> None: self.watch(self.provider, "_content", self._update_withdraw_routes) - def _update_withdraw_routes(self, content: HivePowerData | None) -> None: + def _update_withdraw_routes(self, content: HivePowerData | NotUpdatedYet) -> None: """Update withdraw routes pretty widget.""" - if content is None: # data not received yet + if is_not_updated_yet(content): return + assert isinstance(content, HivePowerData), "Content should be HivePowerData." + if not content.withdraw_routes: self.query_exactly_one("#withdraw-routes-header", Static).update("You have no withdraw routes") self._pretty.display = False diff --git a/clive/__private/ui/screens/operations/savings_operations/savings_operations.py b/clive/__private/ui/screens/operations/savings_operations/savings_operations.py index 74ae5ee741..0ed27ce7bf 100644 --- a/clive/__private/ui/screens/operations/savings_operations/savings_operations.py +++ b/clive/__private/ui/screens/operations/savings_operations/savings_operations.py @@ -17,7 +17,7 @@ from clive.__private.models.schemas import ( from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.data_providers.savings_data_provider import SavingsDataProvider from clive.__private.ui.get_css import get_relative_css_path -from clive.__private.ui.not_updated_yet import NotUpdatedYet +from clive.__private.ui.not_updated_yet import NotUpdatedYet, is_updated from clive.__private.ui.screens.operation_base_screen import OperationBaseScreen from clive.__private.ui.screens.operations.bindings import OperationActionBindings from clive.__private.ui.screens.operations.operation_summary.cancel_transfer_from_savings import ( @@ -110,14 +110,14 @@ class SavingsInterestInfo(TrackedAccountReferencingWidget): "_content", callback=self._get_unclaimed_hbd, classes="interest-info-row-even", - first_try_callback=lambda content: content is not None, + first_try_callback=is_updated, ) yield DynamicLabel( self.provider, "_content", callback=self._get_last_payment_date, classes="interest-info-row-odd", - first_try_callback=lambda content: content is not None, + first_try_callback=is_updated, ) def _get_last_payment_date(self, content: SavingsData) -> str: diff --git a/clive/__private/ui/widgets/apr.py b/clive/__private/ui/widgets/apr.py index 94683cd355..a9b3c87413 100644 --- a/clive/__private/ui/widgets/apr.py +++ b/clive/__private/ui/widgets/apr.py @@ -4,6 +4,7 @@ from abc import abstractmethod from typing import TYPE_CHECKING, Any from clive.__private.abstract_class import AbstractClassMessagePump +from clive.__private.ui.not_updated_yet import is_updated from clive.__private.ui.widgets.dynamic_widgets.dynamic_label import DynamicLabel if TYPE_CHECKING: @@ -27,7 +28,7 @@ class APR(DynamicLabel, AbstractClassMessagePump): obj_to_watch=provider, attribute_name="_content", callback=self._get_apr, - first_try_callback=lambda content: content is not None, + first_try_callback=is_updated, ) self._provider = provider diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index e015508640..e49a2b47dc 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -8,6 +8,7 @@ from textual.widgets import Static from clive.__private.core.constants.tui.class_names import CLIVE_EVEN_COLUMN_CLASS_NAME, CLIVE_ODD_COLUMN_CLASS_NAME from clive.__private.ui.clive_widget import CliveWidget +from clive.__private.ui.not_updated_yet import NotUpdatedYet, is_not_updated_yet, is_updated from clive.__private.ui.widgets.no_content_available import NoContentAvailable from clive.__private.ui.widgets.section_title import SectionTitle from clive.exceptions import CliveDeveloperError @@ -184,7 +185,7 @@ class CliveCheckerboardTable(CliveWidget): if not self.should_be_dynamic: raise InvalidDynamicDefinedError - if content is None: # data not received yet + if is_not_updated_yet(content): return if not self.check_if_should_be_updated(content): @@ -193,13 +194,13 @@ class CliveCheckerboardTable(CliveWidget): self.update_previous_state(content) await self.rebuild(content) - async def rebuild(self, content: ContentT | None = None) -> None: + async def rebuild(self, content: ContentT | NotUpdatedYet | None = None) -> None: """Rebuilds whole table - explicit use available for static and dynamic version.""" with self.app.batch_update(): await self.query("*").remove() await self.mount_all(self._create_table_content(content)) - async def rebuild_rows(self, content: ContentT | None = None) -> None: + async def rebuild_rows(self, content: ContentT | NotUpdatedYet | None = None) -> None: """Rebuilds table rows - explicit use available for static and dynamic version.""" with self.app.batch_update(): await self.query(CliveCheckerboardTableRow).remove() @@ -207,8 +208,10 @@ class CliveCheckerboardTable(CliveWidget): new_rows = self._create_table_rows(content) await self.mount_all(new_rows) - def _create_table_rows(self, content: ContentT | None = None) -> Sequence[CliveCheckerboardTableRow]: - if content is not None and not self.is_anything_to_display(content): + def _create_table_rows( + self, content: ContentT | NotUpdatedYet | None = None + ) -> Sequence[CliveCheckerboardTableRow]: + if content is not None and is_updated(content) and not self.is_anything_to_display(content): # if content is given, we can check if there is anything to display and return earlier return [] @@ -226,7 +229,7 @@ class CliveCheckerboardTable(CliveWidget): self._set_evenness_styles(rows) return rows - def _create_table_content(self, content: ContentT | None = None) -> list[Widget]: + def _create_table_content(self, content: ContentT | NotUpdatedYet | None = None) -> list[Widget]: rows = self._create_table_rows(content) if not rows: diff --git a/clive/__private/ui/widgets/clive_basic/clive_data_table.py b/clive/__private/ui/widgets/clive_basic/clive_data_table.py index e20237ad66..d036927ac4 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_data_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_data_table.py @@ -7,6 +7,7 @@ from textual.widgets import Static from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.data_providers.abc.data_provider import DataProvider +from clive.__private.ui.not_updated_yet import is_not_updated_yet from clive.exceptions import CliveError if TYPE_CHECKING: @@ -77,9 +78,6 @@ class CliveDataTableRow(Horizontal, CliveWidget): def refresh_row(self, content: Any) -> None: # noqa: ANN401 """Iterate through the cells and update each of them.""" - if content is None: # data not received yet - return - for cell, value in zip(self.cells, self.get_new_values(content), strict=True): cell.update(value) @@ -133,7 +131,7 @@ class CliveDataTable(CliveWidget): self.watch(self.provider, "_content", self.refresh_rows) def refresh_rows(self, content: Any) -> None: # noqa: ANN401 - if content is None: # data not received yet + if is_not_updated_yet(content): return with self.app.batch_update(): -- GitLab From c95b22180e0a9d2d3bce8acc1db8e70ce09f888b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 17 Mar 2025 10:23:30 +0100 Subject: [PATCH 138/192] Add init_dynamic to CliveCheckerboardTable so dynamic table can be created right away when data is available --- .../clive_basic/clive_checkerboard_table.py | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py index e49a2b47dc..233d2df573 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py +++ b/clive/__private/ui/widgets/clive_basic/clive_checkerboard_table.py @@ -161,27 +161,43 @@ class CliveCheckerboardTable(CliveWidget): """attribute name to trigger an update of the table and to download new data""" NO_CONTENT_TEXT: ClassVar[str] = "No content available" - def __init__(self, *, header: Widget, title: Widget | str | None = None) -> None: + def __init__(self, *, header: Widget, title: str | Widget | None = None, init_dynamic: bool = True) -> None: + """ + Initialise the checkerboard table. + + Args: + ---- + header: Header of the table. + title: Title of the table. + init_dynamic: Whether the table should be created right away because data is already available. + If not set will display "Loading..." until the data is received. + """ super().__init__() self._title = title self._header = header + self._init_dynamic = init_dynamic def compose(self) -> ComposeResult: - if self.should_be_dynamic: + if not self.should_be_dynamic: + yield from self._create_table_content() + return + + if self.should_be_dynamic and not self._init_dynamic: yield Static("Loading...", id="loading-static") - else: - self._mount_static_rows() + return + + content = self._get_dynamic_initial_content() + if is_updated(content): + self.update_previous_state(content) + yield from self._create_table_content(content) def on_mount(self) -> None: if self.should_be_dynamic: - self.watch(self.object_to_watch, self.ATTRIBUTE_TO_WATCH, self._mount_dynamic_rows) + self.watch( + self.object_to_watch, self.ATTRIBUTE_TO_WATCH, self._update_dynamic_table, init=not self._init_dynamic + ) - def _mount_static_rows(self) -> None: - """Mount rows created in static mode.""" - self.mount_all(self._create_table_content()) - - async def _mount_dynamic_rows(self, content: ContentT) -> None: - """Mount new rows when the ATTRIBUTE_TO_WATCH has been changed.""" + async def _update_dynamic_table(self, content: ContentT) -> None: if not self.should_be_dynamic: raise InvalidDynamicDefinedError @@ -208,6 +224,9 @@ class CliveCheckerboardTable(CliveWidget): new_rows = self._create_table_rows(content) await self.mount_all(new_rows) + def _get_dynamic_initial_content(self) -> object: + return getattr(self.object_to_watch, self.ATTRIBUTE_TO_WATCH) + def _create_table_rows( self, content: ContentT | NotUpdatedYet | None = None ) -> Sequence[CliveCheckerboardTableRow]: @@ -219,6 +238,10 @@ class CliveCheckerboardTable(CliveWidget): assert ( content is not None ), "Content must be provided when creating dynamic rows. Maybe you should use static table?" + + if is_not_updated_yet(content): + return [] + rows = self.create_dynamic_rows(content) else: assert ( -- GitLab From c75c12e13b25fb816728520ffb8f4b710398dc56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 17 Mar 2025 11:39:34 +0100 Subject: [PATCH 139/192] Disable init_dynamic in places where data can be not received yet. --- .../delegate_hive_power/delegate_hive_power.py | 2 +- .../operations/hive_power_management/power_down/power_down.py | 2 +- .../hive_power_management/withdraw_routes/withdraw_routes.py | 2 +- .../screens/operations/savings_operations/savings_operations.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py b/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py index 03f1d438aa..a3b47b7ddf 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py +++ b/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py @@ -81,7 +81,7 @@ class DelegationsTable(CliveCheckerboardTable): NO_CONTENT_TEXT = "You have no delegations" def __init__(self) -> None: - super().__init__(header=DelegationsTableHeader(), title="Current delegations") + super().__init__(header=DelegationsTableHeader(), title="Current delegations", init_dynamic=False) self._previous_delegations: list[VestingDelegation[Asset.Vests]] | NotUpdatedYet = NotUpdatedYet() def create_dynamic_rows(self, content: HivePowerData) -> list[Delegation]: diff --git a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py index e06555467a..19fcb50775 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py +++ b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py @@ -92,7 +92,7 @@ class PendingPowerDown(CliveCheckerboardTable): NO_CONTENT_TEXT = "You have no pending power down" def __init__(self) -> None: - super().__init__(header=PendingPowerDownHeader(), title="Current power down") + super().__init__(header=PendingPowerDownHeader(), title="Current power down", init_dynamic=False) self._previous_next_vesting_withdrawal: datetime | NotUpdatedYet = NotUpdatedYet() def create_dynamic_rows(self, content: HivePowerData) -> list[CliveCheckerboardTableRow]: diff --git a/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py b/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py index 6499dcd93a..acc36f6d29 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py +++ b/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py @@ -71,7 +71,7 @@ class WithdrawRoutesTable(CliveCheckerboardTable): NO_CONTENT_TEXT = "You have no withdraw routes" def __init__(self) -> None: - super().__init__(header=WithdrawRoutesHeader(), title="Current withdraw routes") + super().__init__(header=WithdrawRoutesHeader(), title="Current withdraw routes", init_dynamic=False) self._previous_withdraw_routes: list[SchemasWithdrawRoute] | NotUpdatedYet = NotUpdatedYet() def create_dynamic_rows(self, content: HivePowerData) -> list[WithdrawRoute]: diff --git a/clive/__private/ui/screens/operations/savings_operations/savings_operations.py b/clive/__private/ui/screens/operations/savings_operations/savings_operations.py index 0ed27ce7bf..fa1fd246d4 100644 --- a/clive/__private/ui/screens/operations/savings_operations/savings_operations.py +++ b/clive/__private/ui/screens/operations/savings_operations/savings_operations.py @@ -158,7 +158,7 @@ class PendingTransfers(CliveCheckerboardTable): NO_CONTENT_TEXT = "You have no pending transfers" def __init__(self) -> None: - super().__init__(header=PendingTransfersHeader(), title=SectionTitle("")) + super().__init__(header=PendingTransfersHeader(), title=SectionTitle(""), init_dynamic=False) self._previous_pending_transfers: list[SavingsWithdrawal] | NotUpdatedYet = NotUpdatedYet() def create_dynamic_rows(self, content: SavingsData) -> list[PendingTransfer]: -- GitLab From 2e1218d1eb0e44567e1beb873ded3385a915f1d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 140/192] Make World.node property raise ProfileNotLoadedError instead of AssertionError --- clive/__private/core/world.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 36919d8957..d154210efe 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -83,8 +83,8 @@ class World: @property def node(self) -> Node: """Node shouldn't be used for direct API calls in CLI/TUI. Instead, use commands which also handle errors.""" - message = "Node is not available. It requires profile to be loaded. Is the profile available?" - assert self._node is not None, message + if self._node is None: + raise ProfileNotLoadedError return self._node @property -- GitLab From e98f22ab057a0f2c9ba559648b5af66fb7f8cfec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 141/192] Remove property _should_sync_with_beekeper Looks like theres no reason to skip sync with beekeeper while loading profile based on beekeeper --- clive/__private/core/world.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index d154210efe..05ca7e481d 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -134,10 +134,6 @@ class World: assert self._session is not None, message return self._session - @property - def _should_sync_with_beekeeper(self) -> bool: - return safe_settings.beekeeper.is_session_token_set - @property def _should_save_profile_on_close(self) -> bool: return self._profile is not None @@ -204,8 +200,7 @@ class World: profile_name = self.wallets.name profile = (await self.commands.load_profile(profile_name=profile_name)).result_or_raise await self.switch_profile(profile) - if self._should_sync_with_beekeeper: - await self.commands.sync_state_with_beekeeper() + await self.commands.sync_state_with_beekeeper() async def load_profile(self, profile_name: str, password: str) -> None: assert not self.app_state.is_unlocked, "Application is already unlocked" @@ -297,10 +292,6 @@ class TUIWorld(World, CliveDOMNode): def is_in_create_profile_mode(self) -> bool: return self.profile.name == WELCOME_PROFILE_NAME - @property - def _should_sync_with_beekeeper(self) -> bool: - return super()._should_sync_with_beekeeper and not self.is_in_create_profile_mode - @property def _should_save_profile_on_close(self) -> bool: """In TUI, it's not possible to save profile on some screens like Unlock/CreateProfile.""" -- GitLab From 8eccf77d74d2473cafab0e9c9fcc9cf81d01f6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 142/192] Make header displaying logic be based on locked/unlocked status instead of World profile name --- clive/__private/core/world.py | 4 ---- .../config/manage_key_aliases/widgets/key_alias_form.py | 2 +- clive/__private/ui/widgets/clive_basic/clive_header.py | 6 +++--- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 05ca7e481d..510c98e914 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -288,10 +288,6 @@ class TUIWorld(World, CliveDOMNode): def commands(self) -> TUICommands: return cast(TUICommands, super().commands) - @property - def is_in_create_profile_mode(self) -> bool: - return self.profile.name == WELCOME_PROFILE_NAME - @property def _should_save_profile_on_close(self) -> bool: """In TUI, it's not possible to save profile on some screens like Unlock/CreateProfile.""" diff --git a/clive/__private/ui/screens/config/manage_key_aliases/widgets/key_alias_form.py b/clive/__private/ui/screens/config/manage_key_aliases/widgets/key_alias_form.py index 743c00199a..f89384b58f 100644 --- a/clive/__private/ui/screens/config/manage_key_aliases/widgets/key_alias_form.py +++ b/clive/__private/ui/screens/config/manage_key_aliases/widgets/key_alias_form.py @@ -53,7 +53,7 @@ class KeyAliasForm(BaseScreen, Contextual[KeyAliasFormContextT], ABC): yield from self._content_after_alias_input() yield self._public_key_input yield self._key_alias_input - if self.world.is_in_create_profile_mode: + if not self.app_state.is_unlocked: yield NavigationButtons(is_finish=True) yield SelectCopyPasteHint() diff --git a/clive/__private/ui/widgets/clive_basic/clive_header.py b/clive/__private/ui/widgets/clive_basic/clive_header.py index 2d824b28a2..a223159639 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_header.py +++ b/clive/__private/ui/widgets/clive_basic/clive_header.py @@ -321,7 +321,7 @@ class CliveHeader(CliveRawHeader): def compose(self) -> ComposeResult: yield HeaderIcon() with Bar(id="bar"): - if not self.world.is_in_create_profile_mode: + if self.app_state.is_unlocked: with LeftPart(): yield from self._create_left_part_bar() yield LockStatus() @@ -336,7 +336,7 @@ class CliveHeader(CliveRawHeader): yield from self._create_right_part_expandable() def on_mount(self, _: Mount) -> None: - if not self.world.is_in_create_profile_mode: + if self.app_state.is_unlocked: self.watch(self.world, "profile_reactive", self._update_alarm_display_showing) @on(LockStatus.WalletUnlocked) @@ -359,7 +359,7 @@ class CliveHeader(CliveRawHeader): yield AlarmDisplay() def _create_right_part_bar(self) -> ComposeResult: - if not self.world.is_in_create_profile_mode: + if self.app_state.is_unlocked: yield DashboardButton() yield CartStatus() yield NodeStatus() -- GitLab From 5a06718d17c5a99f3959115dc418f7dfb9339cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 143/192] Remove misleading action prefix in Form --- clive/__private/ui/forms/form.py | 8 ++++---- clive/__private/ui/forms/form_screen.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index eee9882a90..b45811776a 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -43,7 +43,7 @@ class Form(Contextual[ContextT], CliveScreen[None]): def _skip_during_push_screen(self) -> list[ScreenBuilder[ContextT]]: return [] - def action_next_screen(self) -> None: + def next_screen(self) -> None: if not self.__check_valid_range(self.__current_screen_index + 1): return @@ -51,12 +51,12 @@ class Form(Contextual[ContextT], CliveScreen[None]): if self.__is_current_screen_to_skip(): self.__skipped_screens.add(self.current_screen) - self.action_next_screen() + self.next_screen() return self.__push_current_screen() - def action_previous_screen(self) -> None: + def previous_screen(self) -> None: if not self.__check_valid_range(self.__current_screen_index - 1): return @@ -64,7 +64,7 @@ class Form(Contextual[ContextT], CliveScreen[None]): if self.__is_current_screen_skipped(): self.__skipped_screens.discard(self.current_screen) - self.action_previous_screen() + self.previous_screen() return # self.dismiss() won't work here because self is Form and not FormScreen diff --git a/clive/__private/ui/forms/form_screen.py b/clive/__private/ui/forms/form_screen.py index 3daad5b29b..10f0bc7c7f 100644 --- a/clive/__private/ui/forms/form_screen.py +++ b/clive/__private/ui/forms/form_screen.py @@ -64,7 +64,7 @@ class FormScreen(FormScreenBase[ContextT], ABC): await self._back_to_unlock_screen() return - self._owner.action_previous_screen() + self._owner.previous_screen() @on(NextScreenButton.Pressed) @on(CliveInput.Submitted) @@ -83,7 +83,7 @@ class FormScreen(FormScreenBase[ContextT], ABC): self.post_message(self.Finish()) return - self._owner.action_next_screen() + self._owner.next_screen() @abstractmethod async def validate(self) -> ValidationFail | ValidationSuccess | None: -- GitLab From f75968cf943fd9953509ae8bf0417168f44fa560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 144/192] Make Form properties/methods "not intended for usage" --- clive/__private/ui/forms/form.py | 46 ++++++++++++++++---------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index b45811776a..7a6941907e 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -19,10 +19,10 @@ class Form(Contextual[ContextT], CliveScreen[None]): MINIMUM_SCREEN_COUNT = 2 # Rationale: it makes no sense to have only one screen in the form def __init__(self) -> None: - self.__current_screen_index = 0 - self.__screens: list[ScreenBuilder[ContextT]] = [*list(self.register_screen_builders())] - self.__skipped_screens: set[ScreenBuilder[ContextT]] = set() - assert len(self.__screens) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" + self._current_screen_index = 0 + self._screens: list[ScreenBuilder[ContextT]] = [*list(self.register_screen_builders())] + self._skipped_screens: set[ScreenBuilder[ContextT]] = set() + assert len(self._screens) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" self._rebuild_context() self._post_actions = Queue[PostAction]() @@ -30,57 +30,57 @@ class Form(Contextual[ContextT], CliveScreen[None]): @property def screens(self) -> list[ScreenBuilder[ContextT]]: - return self.__screens + return self._screens @property def current_screen(self) -> ScreenBuilder[ContextT]: - return self.__screens[self.__current_screen_index] + return self._screens[self._current_screen_index] def on_mount(self) -> None: - assert self.__current_screen_index == 0 - self.__push_current_screen() + assert self._current_screen_index == 0 + self._push_current_screen() def _skip_during_push_screen(self) -> list[ScreenBuilder[ContextT]]: return [] def next_screen(self) -> None: - if not self.__check_valid_range(self.__current_screen_index + 1): + if not self._check_valid_range(self._current_screen_index + 1): return - self.__current_screen_index += 1 + self._current_screen_index += 1 - if self.__is_current_screen_to_skip(): - self.__skipped_screens.add(self.current_screen) + if self._is_current_screen_to_skip(): + self._skipped_screens.add(self.current_screen) self.next_screen() return - self.__push_current_screen() + self._push_current_screen() def previous_screen(self) -> None: - if not self.__check_valid_range(self.__current_screen_index - 1): + if not self._check_valid_range(self._current_screen_index - 1): return - self.__current_screen_index -= 1 + self._current_screen_index -= 1 - if self.__is_current_screen_skipped(): - self.__skipped_screens.discard(self.current_screen) + if self._is_current_screen_skipped(): + self._skipped_screens.discard(self.current_screen) self.previous_screen() return # self.dismiss() won't work here because self is Form and not FormScreen self.app.pop_screen() - def __is_current_screen_to_skip(self) -> bool: + def _is_current_screen_to_skip(self) -> bool: return self.current_screen in self._skip_during_push_screen() - def __is_current_screen_skipped(self) -> bool: - return self.current_screen in self.__skipped_screens + def _is_current_screen_skipped(self) -> bool: + return self.current_screen in self._skipped_screens - def __push_current_screen(self) -> None: + def _push_current_screen(self) -> None: self.app.push_screen(self.current_screen(self)) - def __check_valid_range(self, proposed_idx: int) -> bool: - return (proposed_idx >= 0) and (proposed_idx < len(self.__screens)) + def _check_valid_range(self, proposed_idx: int) -> bool: + return (proposed_idx >= 0) and (proposed_idx < len(self._screens)) @abstractmethod def _rebuild_context(self) -> None: -- GitLab From f59f142dd3e87b83710372fa2ced44b3cec37ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 145/192] Remove dead code related to skipping screens --- .../create_profile/create_profile_form.py | 9 --------- clive/__private/ui/forms/form.py | 20 ------------------- 2 files changed, 29 deletions(-) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 3034457ec5..15a5f82317 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -31,12 +31,3 @@ class CreateProfileForm(Form[CreateProfileContext]): def _rebuild_context(self) -> None: profile = Profile.create(WELCOME_PROFILE_NAME) self.__context = CreateProfileContext(profile, Node(profile)) - - def _skip_during_push_screen(self) -> list[ScreenBuilder[CreateProfileContext]]: - screens_to_skip: list[ScreenBuilder[CreateProfileContext]] = [] - - # skip NewKeyAliasForm if there is no working account set - if not self.context.profile.accounts.has_working_account: - screens_to_skip.append(NewKeyAliasFormScreen) - - return screens_to_skip diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index 7a6941907e..f26f9419ae 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -21,7 +21,6 @@ class Form(Contextual[ContextT], CliveScreen[None]): def __init__(self) -> None: self._current_screen_index = 0 self._screens: list[ScreenBuilder[ContextT]] = [*list(self.register_screen_builders())] - self._skipped_screens: set[ScreenBuilder[ContextT]] = set() assert len(self._screens) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" self._rebuild_context() self._post_actions = Queue[PostAction]() @@ -40,20 +39,12 @@ class Form(Contextual[ContextT], CliveScreen[None]): assert self._current_screen_index == 0 self._push_current_screen() - def _skip_during_push_screen(self) -> list[ScreenBuilder[ContextT]]: - return [] - def next_screen(self) -> None: if not self._check_valid_range(self._current_screen_index + 1): return self._current_screen_index += 1 - if self._is_current_screen_to_skip(): - self._skipped_screens.add(self.current_screen) - self.next_screen() - return - self._push_current_screen() def previous_screen(self) -> None: @@ -62,20 +53,9 @@ class Form(Contextual[ContextT], CliveScreen[None]): self._current_screen_index -= 1 - if self._is_current_screen_skipped(): - self._skipped_screens.discard(self.current_screen) - self.previous_screen() - return - # self.dismiss() won't work here because self is Form and not FormScreen self.app.pop_screen() - def _is_current_screen_to_skip(self) -> bool: - return self.current_screen in self._skip_during_push_screen() - - def _is_current_screen_skipped(self) -> bool: - return self.current_screen in self._skipped_screens - def _push_current_screen(self) -> None: self.app.push_screen(self.current_screen(self)) -- GitLab From 9b6fd322ce41c639bbd81f88b5778e96da41d53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 146/192] Get rid of unused ScreenBuilder type alias --- .../forms/create_profile/create_profile_form.py | 6 ++++-- clive/__private/ui/forms/form.py | 15 ++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 15a5f82317..3c046489a2 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -10,18 +10,20 @@ from clive.__private.ui.forms.create_profile.create_profile_form_screen import C from clive.__private.ui.forms.create_profile.new_key_alias_form_screen import NewKeyAliasFormScreen from clive.__private.ui.forms.create_profile.set_account_form_screen import SetAccountFormScreen from clive.__private.ui.forms.create_profile.welcome_form_screen import CreateProfileWelcomeFormScreen -from clive.__private.ui.forms.form import Form, ScreenBuilder +from clive.__private.ui.forms.form import Form if TYPE_CHECKING: from collections.abc import Iterator + from clive.__private.ui.forms.form_screen import FormScreenBase + class CreateProfileForm(Form[CreateProfileContext]): @property def context(self) -> CreateProfileContext: return self.__context - def register_screen_builders(self) -> Iterator[ScreenBuilder[CreateProfileContext]]: + def register_screen_builders(self) -> Iterator[type[FormScreenBase[CreateProfileContext]]]: if not Profile.is_any_profile_saved(): yield CreateProfileWelcomeFormScreen yield CreateProfileFormScreen diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index f26f9419ae..3bd3d8700d 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -4,14 +4,15 @@ import inspect from abc import abstractmethod from collections.abc import Callable, Iterator from queue import Queue -from typing import Any +from typing import TYPE_CHECKING, Any from clive.__private.core.commands.abc.command import Command from clive.__private.core.contextual import ContextT, Contextual from clive.__private.ui.clive_screen import CliveScreen -from clive.__private.ui.forms.form_screen import FormScreenBase -ScreenBuilder = Callable[["Form[ContextT]"], FormScreenBase[ContextT] | FormScreenBase[None]] +if TYPE_CHECKING: + from clive.__private.ui.forms.form_screen import FormScreenBase + PostAction = Command | Callable[[], Any] @@ -20,7 +21,7 @@ class Form(Contextual[ContextT], CliveScreen[None]): def __init__(self) -> None: self._current_screen_index = 0 - self._screens: list[ScreenBuilder[ContextT]] = [*list(self.register_screen_builders())] + self._screens: list[type[FormScreenBase[ContextT]]] = [*list(self.register_screen_builders())] assert len(self._screens) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" self._rebuild_context() self._post_actions = Queue[PostAction]() @@ -28,11 +29,11 @@ class Form(Contextual[ContextT], CliveScreen[None]): super().__init__() @property - def screens(self) -> list[ScreenBuilder[ContextT]]: + def screens(self) -> list[type[FormScreenBase[ContextT]]]: return self._screens @property - def current_screen(self) -> ScreenBuilder[ContextT]: + def current_screen(self) -> type[FormScreenBase[ContextT]]: return self._screens[self._current_screen_index] def on_mount(self) -> None: @@ -67,7 +68,7 @@ class Form(Contextual[ContextT], CliveScreen[None]): """Create brand new fresh context.""" @abstractmethod - def register_screen_builders(self) -> Iterator[ScreenBuilder[ContextT]]: + def register_screen_builders(self) -> Iterator[type[FormScreenBase[ContextT]]]: """Return screens to display.""" def add_post_action(self, *actions: PostAction) -> None: -- GitLab From fce6c7b6251809a84803cb31956eb8cea1c90700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 147/192] Rename misleading screens -> screen_types in Form --- clive/__private/ui/forms/form.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index 3bd3d8700d..96c7d20661 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -21,20 +21,20 @@ class Form(Contextual[ContextT], CliveScreen[None]): def __init__(self) -> None: self._current_screen_index = 0 - self._screens: list[type[FormScreenBase[ContextT]]] = [*list(self.register_screen_builders())] - assert len(self._screens) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" + self._screen_types: list[type[FormScreenBase[ContextT]]] = [*list(self.register_screen_builders())] + assert len(self._screen_types) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" self._rebuild_context() self._post_actions = Queue[PostAction]() super().__init__() @property - def screens(self) -> list[type[FormScreenBase[ContextT]]]: - return self._screens + def screens_types(self) -> list[type[FormScreenBase[ContextT]]]: + return self._screen_types @property - def current_screen(self) -> type[FormScreenBase[ContextT]]: - return self._screens[self._current_screen_index] + def current_screen_type(self) -> type[FormScreenBase[ContextT]]: + return self._screen_types[self._current_screen_index] def on_mount(self) -> None: assert self._current_screen_index == 0 @@ -58,10 +58,10 @@ class Form(Contextual[ContextT], CliveScreen[None]): self.app.pop_screen() def _push_current_screen(self) -> None: - self.app.push_screen(self.current_screen(self)) + self.app.push_screen(self.current_screen_type(self)) def _check_valid_range(self, proposed_idx: int) -> bool: - return (proposed_idx >= 0) and (proposed_idx < len(self._screens)) + return (proposed_idx >= 0) and (proposed_idx < len(self._screen_types)) @abstractmethod def _rebuild_context(self) -> None: -- GitLab From d08d0b51a5afcf7ecf97258de7ea4f597568b007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 148/192] Rename register_screen_builders -> compose_form --- .../ui/forms/create_profile/create_profile_form.py | 2 +- clive/__private/ui/forms/form.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 3c046489a2..8510b1ef10 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -23,7 +23,7 @@ class CreateProfileForm(Form[CreateProfileContext]): def context(self) -> CreateProfileContext: return self.__context - def register_screen_builders(self) -> Iterator[type[FormScreenBase[CreateProfileContext]]]: + def compose_form(self) -> Iterator[type[FormScreenBase[CreateProfileContext]]]: if not Profile.is_any_profile_saved(): yield CreateProfileWelcomeFormScreen yield CreateProfileFormScreen diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index 96c7d20661..e5ba20d7c8 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -21,7 +21,7 @@ class Form(Contextual[ContextT], CliveScreen[None]): def __init__(self) -> None: self._current_screen_index = 0 - self._screen_types: list[type[FormScreenBase[ContextT]]] = [*list(self.register_screen_builders())] + self._screen_types: list[type[FormScreenBase[ContextT]]] = [*list(self.compose_form())] assert len(self._screen_types) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" self._rebuild_context() self._post_actions = Queue[PostAction]() @@ -68,8 +68,8 @@ class Form(Contextual[ContextT], CliveScreen[None]): """Create brand new fresh context.""" @abstractmethod - def register_screen_builders(self) -> Iterator[type[FormScreenBase[ContextT]]]: - """Return screens to display.""" + def compose_form(self) -> Iterator[type[FormScreenBase[ContextT]]]: + """Yield screens types in the order they should be displayed.""" def add_post_action(self, *actions: PostAction) -> None: for action in actions: -- GitLab From c742a3446fa0915f1b42f41c4ea3ce09a2c63319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 149/192] Reorder methods in Form --- clive/__private/ui/forms/form.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index e5ba20d7c8..a1ee9ec61c 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -28,6 +28,14 @@ class Form(Contextual[ContextT], CliveScreen[None]): super().__init__() + @abstractmethod + def compose_form(self) -> Iterator[type[FormScreenBase[ContextT]]]: + """Yield screens types in the order they should be displayed.""" + + @abstractmethod + def _rebuild_context(self) -> None: + """Create brand new fresh context.""" + @property def screens_types(self) -> list[type[FormScreenBase[ContextT]]]: return self._screen_types @@ -57,20 +65,6 @@ class Form(Contextual[ContextT], CliveScreen[None]): # self.dismiss() won't work here because self is Form and not FormScreen self.app.pop_screen() - def _push_current_screen(self) -> None: - self.app.push_screen(self.current_screen_type(self)) - - def _check_valid_range(self, proposed_idx: int) -> bool: - return (proposed_idx >= 0) and (proposed_idx < len(self._screen_types)) - - @abstractmethod - def _rebuild_context(self) -> None: - """Create brand new fresh context.""" - - @abstractmethod - def compose_form(self) -> Iterator[type[FormScreenBase[ContextT]]]: - """Yield screens types in the order they should be displayed.""" - def add_post_action(self, *actions: PostAction) -> None: for action in actions: self._post_actions.put_nowait(action) @@ -88,3 +82,9 @@ class Form(Contextual[ContextT], CliveScreen[None]): await action() else: action() + + def _push_current_screen(self) -> None: + self.app.push_screen(self.current_screen_type(self)) + + def _check_valid_range(self, proposed_idx: int) -> bool: + return (proposed_idx >= 0) and (proposed_idx < len(self._screen_types)) -- GitLab From 0d5a52980a338306db0d3b69e12d9bc16a6bfe5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 150/192] Remove WELCOME_PROFILE from TUI, set Profile to None when locked --- clive/__private/core/constants/tui/profile.py | 5 ---- clive/__private/core/world.py | 30 ++++++++++--------- .../create_profile/create_profile_form.py | 3 +- tests/tui/test_create_profile.py | 5 ++-- 4 files changed, 19 insertions(+), 24 deletions(-) delete mode 100644 clive/__private/core/constants/tui/profile.py diff --git a/clive/__private/core/constants/tui/profile.py b/clive/__private/core/constants/tui/profile.py deleted file mode 100644 index 145b15ab06..0000000000 --- a/clive/__private/core/constants/tui/profile.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from typing import Final - -WELCOME_PROFILE_NAME: Final[str] = "welcome" diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 510c98e914..eaedd5a99c 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -12,7 +12,6 @@ from clive.__private.cli.exceptions import CLINoProfileUnlockedError from clive.__private.core.app_state import AppState, LockSource from clive.__private.core.commands.commands import CLICommands, Commands, TUICommands from clive.__private.core.commands.get_unlocked_user_wallet import NoProfileUnlockedError -from clive.__private.core.constants.tui.profile import WELCOME_PROFILE_NAME from clive.__private.core.known_exchanges import KnownExchanges from clive.__private.core.node import Node from clive.__private.core.profile import Profile @@ -207,7 +206,7 @@ class World: await self.commands.unlock(profile_name=profile_name, password=password, permanent=True) await self.load_profile_based_on_beekepeer() - async def switch_profile(self, new_profile: Profile) -> None: + async def switch_profile(self, new_profile: Profile | None) -> None: self._profile = new_profile await self._update_node() @@ -276,7 +275,11 @@ class World: class TUIWorld(World, CliveDOMNode): profile_reactive: Profile = var(None, init=False) # type: ignore[assignment] + """Should be used only after unlocking the profile so it will be available then.""" + node_reactive: Node = var(None, init=False) # type: ignore[assignment] + """Should be used only after unlocking the profile so it will be available then.""" + app_state: AppState = var(None, init=False) # type: ignore[assignment] @override @@ -304,18 +307,13 @@ class TUIWorld(World, CliveDOMNode): try: await self.load_profile_based_on_beekepeer() except NoProfileUnlockedError: - await self._switch_to_welcome_profile() + await self.switch_profile(None) return self - async def switch_profile(self, new_profile: Profile) -> None: + async def switch_profile(self, new_profile: Profile | None) -> None: await super().switch_profile(new_profile) self._update_profile_related_reactive_attributes() - async def _switch_to_welcome_profile(self) -> None: - """Set the profile to default (welcome).""" - await self.create_new_profile(WELCOME_PROFILE_NAME) - self.profile.skip_saving() - def _watch_profile(self, profile: Profile) -> None: self.node.change_related_profile(profile) @@ -330,7 +328,7 @@ class TUIWorld(World, CliveDOMNode): self._add_welcome_modes() await self.app.switch_mode("unlock") await self._restart_dashboard_mode() - await self._switch_to_welcome_profile() + await self.switch_profile(None) self.app.run_worker(lock()) @@ -349,10 +347,14 @@ class TUIWorld(World, CliveDOMNode): self.app.add_mode("dashboard", Dashboard) def _update_profile_related_reactive_attributes(self) -> None: - if self._node is not None: - self.node_reactive = self._node - if self._profile is not None: - self.profile_reactive = self._profile + # There's no proper way to add some proxy reactive property on textual reactives that could raise error if + # not set yet, and still can be watched. See: https://github.com/Textualize/textual/discussions/4007 + + if self._node is None or self._profile is None: + assert not self.app_state.is_unlocked, "Profile and node should never be None when unlocked" + + self.node_reactive = self._node # type: ignore[assignment] # ignore that, node_reactive shouldn't be accessed before unlocking + self.profile_reactive = self._profile # type: ignore[assignment] # ignore that, profile_reactive shouldn't be accessed before unlocking class CLIWorld(World): diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 8510b1ef10..7c428161b0 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from clive.__private.core.constants.tui.profile import WELCOME_PROFILE_NAME from clive.__private.core.node import Node from clive.__private.core.profile import Profile from clive.__private.ui.forms.create_profile.context import CreateProfileContext @@ -31,5 +30,5 @@ class CreateProfileForm(Form[CreateProfileContext]): yield NewKeyAliasFormScreen def _rebuild_context(self) -> None: - profile = Profile.create(WELCOME_PROFILE_NAME) + profile = Profile.create("temp") self.__context = CreateProfileContext(profile, Node(profile)) diff --git a/tests/tui/test_create_profile.py b/tests/tui/test_create_profile.py index 76a523fbbf..ab60ab546c 100644 --- a/tests/tui/test_create_profile.py +++ b/tests/tui/test_create_profile.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Final import pytest -from clive.__private.core.constants.tui.profile import WELCOME_PROFILE_NAME from clive.__private.ui.app import Clive from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.forms.create_profile.new_key_alias_form_screen import NewKeyAliasFormScreen @@ -115,8 +114,8 @@ async def assert_tui_key_alias_exists(pilot: ClivePilot) -> None: def assert_is_new_profile(pilot: ClivePilot) -> None: - assert pilot.app.world.profile, "Expected profile is not None" - assert pilot.app.world.profile.name == WELCOME_PROFILE_NAME, f"Expected profile name to be {WELCOME_PROFILE_NAME}" + assert pilot.app.world.profile, "Expected profile to be set" + assert pilot.app.world.profile.name == "temp_name", "Expected different profile name" def assert_working_account(pilot: ClivePilot, name: str) -> None: -- GitLab From 356488034b9ab2d707b9440fb421968d7f531621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 151/192] Get rid of Context, Contextual during CreateProfileForm --- clive/__private/core/profile.py | 3 +-- .../ui/dialogs/switch_node_address_dialog.py | 9 +++------ .../ui/forms/create_profile/context.py | 16 ---------------- .../forms/create_profile/create_profile_form.py | 16 +++++----------- .../create_profile_form_screen.py | 10 ++++------ .../finish_profile_creation_mixin.py | 9 ++------- .../create_profile/new_key_alias_form_screen.py | 17 ++++------------- .../create_profile/set_account_form_screen.py | 17 ++++++++--------- .../forms/create_profile/welcome_form_screen.py | 5 ++--- clive/__private/ui/screens/base_screen.py | 8 +------- .../config/manage_key_aliases/edit_key_alias.py | 7 +------ .../config/manage_key_aliases/new_key_alias.py | 17 ++++------------- .../widgets/key_alias_form.py | 16 +++------------- .../switch_node_address/switch_node_address.py | 10 +++------- clive/__private/ui/widgets/node_widgets.py | 4 ---- 15 files changed, 41 insertions(+), 123 deletions(-) delete mode 100644 clive/__private/ui/forms/create_profile/context.py diff --git a/clive/__private/core/profile.py b/clive/__private/core/profile.py index 1a9bbe0005..3e9a3000f5 100644 --- a/clive/__private/core/profile.py +++ b/clive/__private/core/profile.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Final from beekeepy.interfaces import HttpUrl from clive.__private.core.accounts.account_manager import AccountManager -from clive.__private.core.contextual import Context from clive.__private.core.formatters.humanize import humanize_validation_result from clive.__private.core.keys import KeyManager, PublicKeyAliased from clive.__private.core.validate_schema_field import is_schema_field_valid @@ -48,7 +47,7 @@ class ProfileInvalidNameError(ProfileError): super().__init__(message) -class Profile(Context): +class Profile: _INIT_KEY: Final[object] = object() """Used to prevent direct initialization of the class. Instead factory methods should be used.""" diff --git a/clive/__private/ui/dialogs/switch_node_address_dialog.py b/clive/__private/ui/dialogs/switch_node_address_dialog.py index 5cf5477d2c..eb892dae6a 100644 --- a/clive/__private/ui/dialogs/switch_node_address_dialog.py +++ b/clive/__private/ui/dialogs/switch_node_address_dialog.py @@ -13,20 +13,17 @@ from clive.__private.ui.widgets.section import Section if TYPE_CHECKING: from textual.app import ComposeResult - from clive.__private.core.node import Node - class SwitchNodeAddressDialog(CliveActionDialog): CSS_PATH = [get_relative_css_path(__file__)] - def __init__(self, node: Node) -> None: + def __init__(self) -> None: super().__init__(border_title="Switch node address") - self._node = node def create_dialog_content(self) -> ComposeResult: with Section(): - yield SelectedNodeAddress(self._node.http_endpoint) - yield NodesList(self._node) + yield SelectedNodeAddress(self.node.http_endpoint) + yield NodesList() @on(ConfirmButton.Pressed) async def switch_node_address(self) -> None: diff --git a/clive/__private/ui/forms/create_profile/context.py b/clive/__private/ui/forms/create_profile/context.py deleted file mode 100644 index 86aa79fa48..0000000000 --- a/clive/__private/ui/forms/create_profile/context.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from clive.__private.core.contextual import Context - -if TYPE_CHECKING: - from clive.__private.core.node import Node - from clive.__private.core.profile import Profile - - -@dataclass -class CreateProfileContext(Context): - profile: Profile - node: Node diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 7c428161b0..1dc236ee33 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -2,9 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from clive.__private.core.node import Node from clive.__private.core.profile import Profile -from clive.__private.ui.forms.create_profile.context import CreateProfileContext from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.forms.create_profile.new_key_alias_form_screen import NewKeyAliasFormScreen from clive.__private.ui.forms.create_profile.set_account_form_screen import SetAccountFormScreen @@ -17,18 +15,14 @@ if TYPE_CHECKING: from clive.__private.ui.forms.form_screen import FormScreenBase -class CreateProfileForm(Form[CreateProfileContext]): - @property - def context(self) -> CreateProfileContext: - return self.__context +class CreateProfileForm(Form): + async def initialize(self) -> None: + await self.world.create_new_profile("temp_name") + self.profile.skip_saving() - def compose_form(self) -> Iterator[type[FormScreenBase[CreateProfileContext]]]: + def compose_form(self) -> Iterator[type[FormScreenBase]]: if not Profile.is_any_profile_saved(): yield CreateProfileWelcomeFormScreen yield CreateProfileFormScreen yield SetAccountFormScreen yield NewKeyAliasFormScreen - - def _rebuild_context(self) -> None: - profile = Profile.create("temp") - self.__context = CreateProfileContext(profile, Node(profile)) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py index 3fa6968691..0bdc923a94 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING from textual.binding import Binding from clive.__private.core.profile import Profile -from clive.__private.ui.forms.create_profile.context import CreateProfileContext from clive.__private.ui.forms.form_screen import FormScreen from clive.__private.ui.forms.navigation_buttons import NavigationButtons from clive.__private.ui.get_css import get_relative_css_path @@ -23,13 +22,13 @@ if TYPE_CHECKING: from clive.__private.ui.forms.form import Form -class CreateProfileFormScreen(BaseScreen, FormScreen[CreateProfileContext]): +class CreateProfileFormScreen(BaseScreen, FormScreen): BINDINGS = [Binding("f1", "help", "Help")] CSS_PATH = [get_relative_css_path(__file__)] BIG_TITLE = "create profile" SHOW_RAW_HEADER = True - def __init__(self, owner: Form[CreateProfileContext]) -> None: + def __init__(self, owner: Form) -> None: self._profile_name_input = SetProfileNameInput() self._password_input = SetPasswordInput() self._repeat_password_input = RepeatPasswordInput(self._password_input) @@ -68,14 +67,13 @@ class CreateProfileFormScreen(BaseScreen, FormScreen[CreateProfileContext]): profile_name = self._profile_name_input.value_or_error password = self._password_input.value_or_error - profile = self.context.profile - profile.name = profile_name + self.profile.name = profile_name async def create_wallets() -> None: await self.world.commands.create_profile_wallets(profile_name=profile_name, password=password) async def sync_data() -> None: - await self.world.commands.sync_data_with_beekeeper(profile=profile) + await self.world.commands.sync_data_with_beekeeper(profile=self.profile) self._owner.add_post_action(create_wallets, sync_data) diff --git a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py index 6ada739231..ed1c7965ba 100644 --- a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py +++ b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py @@ -2,11 +2,10 @@ from __future__ import annotations from textual import on -from clive.__private.ui.forms.create_profile.context import CreateProfileContext from clive.__private.ui.forms.form_screen import FormScreen, FormScreenBase -class FinishProfileCreationMixin(FormScreenBase[CreateProfileContext]): +class FinishProfileCreationMixin(FormScreenBase): @on(FormScreen.Finish) async def finish(self) -> None: # Has to be done in a separate task to avoid deadlock. More: https://github.com/Textualize/textual/issues/5008 @@ -17,13 +16,9 @@ class FinishProfileCreationMixin(FormScreenBase[CreateProfileContext]): lambda: self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True), self.app.resume_refresh_alarms_data_interval, ) - - profile = self.context.profile - profile.enable_saving() - await self.world.switch_profile(profile) - await self._owner.execute_post_actions() await self._handle_modes_on_finish() + self.profile.enable_saving() await self.commands.save_profile() async def _handle_modes_on_finish(self) -> None: diff --git a/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py b/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py index cdb797c142..86662d8466 100644 --- a/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py +++ b/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py @@ -1,25 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any, ClassVar from textual import on from textual.binding import Binding from clive.__private.logger import logger -from clive.__private.ui.forms.create_profile.context import CreateProfileContext from clive.__private.ui.forms.create_profile.finish_profile_creation_mixin import FinishProfileCreationMixin from clive.__private.ui.forms.form_screen import FormScreen from clive.__private.ui.forms.navigation_buttons import PreviousScreenButton from clive.__private.ui.screens.config.manage_key_aliases.new_key_alias import NewKeyAliasBase from clive.__private.ui.widgets.inputs.clive_validated_input import FailedManyValidationError -if TYPE_CHECKING: - from clive.__private.core.node import Node - -class NewKeyAliasFormScreen( - NewKeyAliasBase[CreateProfileContext], FormScreen[CreateProfileContext], FinishProfileCreationMixin -): +class NewKeyAliasFormScreen(NewKeyAliasBase, FormScreen, FinishProfileCreationMixin): BINDINGS = [Binding("f1", "help", "Help")] BIG_TITLE = "create profile" SUBTITLE = "Optional step, could be done later" @@ -32,7 +26,7 @@ class NewKeyAliasFormScreen( @on(PreviousScreenButton.Pressed) async def action_previous_screen(self) -> None: # We allow just for adding one key during create_profile. Clear old ones because validation could fail. - self.context.profile.keys.clear_to_import() + self.profile.keys.clear_to_import() await super().action_previous_screen() async def validate(self) -> NewKeyAliasFormScreen.ValidationFail | None: @@ -43,11 +37,8 @@ class NewKeyAliasFormScreen( return None async def apply(self) -> None: - self.context.profile.keys.set_to_import([self._private_key_aliased]) + self.profile.keys.set_to_import([self._private_key_aliased]) logger.debug("New private key is waiting to be imported...") def is_step_optional(self) -> bool: return self._key_input.is_empty - - def get_node(self) -> Node: - return self.context.node diff --git a/clive/__private/ui/forms/create_profile/set_account_form_screen.py b/clive/__private/ui/forms/create_profile/set_account_form_screen.py index 80e5da8169..c87c23950f 100644 --- a/clive/__private/ui/forms/create_profile/set_account_form_screen.py +++ b/clive/__private/ui/forms/create_profile/set_account_form_screen.py @@ -7,7 +7,6 @@ from textual.binding import Binding from textual.widgets import Checkbox from clive.__private.core.constants.tui.placeholders import ACCOUNT_NAME_CREATE_PROFILE_PLACEHOLDER -from clive.__private.ui.forms.create_profile.context import CreateProfileContext from clive.__private.ui.forms.create_profile.finish_profile_creation_mixin import FinishProfileCreationMixin from clive.__private.ui.forms.form_screen import FormScreen from clive.__private.ui.forms.navigation_buttons import NavigationButtons, PreviousScreenButton @@ -29,7 +28,7 @@ class WorkingAccountCheckbox(Checkbox): super().__init__("Working account?", value=True) -class SetAccountFormScreen(BaseScreen, FormScreen[CreateProfileContext], FinishProfileCreationMixin): +class SetAccountFormScreen(BaseScreen, FormScreen, FinishProfileCreationMixin): BINDINGS = [Binding("f1", "help", "Help")] CSS_PATH = [get_relative_css_path(__file__)] BIG_TITLE = "create profile" @@ -86,20 +85,20 @@ class SetAccountFormScreen(BaseScreen, FormScreen[CreateProfileContext], FinishP async def apply(self) -> None: # allow only for adding one account - self.context.profile.accounts.unset_working_account() - self.context.profile.accounts.watched.clear() - self.context.profile.accounts.known.clear() + self.profile.accounts.unset_working_account() + self.profile.accounts.watched.clear() + self.profile.accounts.known.clear() account_name = self.account_name - self.context.profile.accounts.known.add(account_name) + self.profile.accounts.known.add(account_name) if self.working_account_checkbox.value: - self.context.profile.accounts.set_working_account(account_name) + self.profile.accounts.set_working_account(account_name) else: - self.context.profile.accounts.watched.add(account_name) + self.profile.accounts.watched.add(account_name) def get_node(self) -> Node: - return self.context.node + return self.node @on(WorkingAccountCheckbox.Changed) def _change_finish_screen_status(self, event: WorkingAccountCheckbox.Changed) -> None: diff --git a/clive/__private/ui/forms/create_profile/welcome_form_screen.py b/clive/__private/ui/forms/create_profile/welcome_form_screen.py index 4d1a8b1478..a8e59a817f 100644 --- a/clive/__private/ui/forms/create_profile/welcome_form_screen.py +++ b/clive/__private/ui/forms/create_profile/welcome_form_screen.py @@ -7,7 +7,6 @@ from textual.binding import Binding from textual.widgets import Static from clive.__private.core.constants.tui.messages import PRESS_HELP_MESSAGE -from clive.__private.ui.forms.create_profile.context import CreateProfileContext from clive.__private.ui.forms.form_screen import FormScreen from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen @@ -24,12 +23,12 @@ class Description(Static): """Description of the welcome screen.""" -class CreateProfileWelcomeFormScreen(BaseScreen, FormScreen[CreateProfileContext]): +class CreateProfileWelcomeFormScreen(BaseScreen, FormScreen): BINDINGS = [Binding("f1", "help", "Help")] CSS_PATH = [get_relative_css_path(__file__)] SHOW_RAW_HEADER = True - def __init__(self, owner: Form[CreateProfileContext]) -> None: + def __init__(self, owner: Form) -> None: super().__init__(owner) self._description = "Let's create profile!\n" + PRESS_HELP_MESSAGE self.back_screen_mode = "nothing_to_back" diff --git a/clive/__private/ui/screens/base_screen.py b/clive/__private/ui/screens/base_screen.py index b37e6efb47..271caee32f 100644 --- a/clive/__private/ui/screens/base_screen.py +++ b/clive/__private/ui/screens/base_screen.py @@ -16,8 +16,6 @@ from clive.__private.ui.widgets.location_indicator import LocationIndicator if TYPE_CHECKING: from textual.app import ComposeResult - from clive.__private.core.node import Node - class BaseScreen(CliveScreen[ScreenResultT], AbstractClassMessagePump): BIG_TITLE: ClassVar[str] = "" @@ -41,12 +39,8 @@ class BaseScreen(CliveScreen[ScreenResultT], AbstractClassMessagePump): def push_switch_node_address_dialog(self) -> None: from clive.__private.ui.dialogs.switch_node_address_dialog import SwitchNodeAddressDialog - self.app.push_screen(SwitchNodeAddressDialog(self.get_node())) + self.app.push_screen(SwitchNodeAddressDialog()) @abstractmethod def create_main_panel(self) -> ComposeResult: """Yield the main panel widgets.""" - - def get_node(self) -> Node: - """Override this method to return the node other than the one in the world.""" - return self.world.node diff --git a/clive/__private/ui/screens/config/manage_key_aliases/edit_key_alias.py b/clive/__private/ui/screens/config/manage_key_aliases/edit_key_alias.py index 91894ef0b7..c6a1c5704f 100644 --- a/clive/__private/ui/screens/config/manage_key_aliases/edit_key_alias.py +++ b/clive/__private/ui/screens/config/manage_key_aliases/edit_key_alias.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, ClassVar from textual import on from textual.binding import Binding -from clive.__private.core.profile import Profile from clive.__private.ui.screens.config.manage_key_aliases.widgets.key_alias_form import KeyAliasForm from clive.__private.ui.widgets.inputs.clive_input import CliveInput from clive.__private.ui.widgets.inputs.clive_validated_input import ( @@ -17,7 +16,7 @@ if TYPE_CHECKING: from clive.__private.core.keys import PublicKeyAliased -class EditKeyAlias(KeyAliasForm[Profile]): +class EditKeyAlias(KeyAliasForm): BINDINGS = [ Binding("escape", "app.pop_screen", "Back"), Binding("f6", "save", "Save"), @@ -31,10 +30,6 @@ class EditKeyAlias(KeyAliasForm[Profile]): self._public_key = public_key super().__init__() - @property - def context(self) -> Profile: - return self.profile - @on(CliveInput.Submitted) def action_save(self) -> None: try: diff --git a/clive/__private/ui/screens/config/manage_key_aliases/new_key_alias.py b/clive/__private/ui/screens/config/manage_key_aliases/new_key_alias.py index 817cdba9e6..f0300e9233 100644 --- a/clive/__private/ui/screens/config/manage_key_aliases/new_key_alias.py +++ b/clive/__private/ui/screens/config/manage_key_aliases/new_key_alias.py @@ -9,12 +9,8 @@ from textual.widgets import Input from clive.__private.core.constants.tui.placeholders import KEY_FILE_PATH_PLACEHOLDER from clive.__private.core.keys import PrivateKey, PrivateKeyAliased -from clive.__private.core.profile import Profile from clive.__private.settings import safe_settings -from clive.__private.ui.screens.config.manage_key_aliases.widgets.key_alias_form import ( - KeyAliasForm, - KeyAliasFormContextT, -) +from clive.__private.ui.screens.config.manage_key_aliases.widgets.key_alias_form import KeyAliasForm from clive.__private.ui.widgets.inputs.clive_input import CliveInput from clive.__private.ui.widgets.inputs.clive_validated_input import ( CliveValidatedInput, @@ -32,7 +28,7 @@ if TYPE_CHECKING: from clive.__private.ui.widgets.inputs.public_key_alias_input import PublicKeyAliasInput -class NewKeyAliasBase(KeyAliasForm[KeyAliasFormContextT], ABC): +class NewKeyAliasBase(KeyAliasForm, ABC): BINDINGS = [ Binding("f2", "load_from_file", "Load from file"), ] @@ -134,7 +130,7 @@ class NewKeyAliasBase(KeyAliasForm[KeyAliasFormContextT], ABC): return self._key_alias_input.value_or_error -class NewKeyAlias(NewKeyAliasBase[Profile]): +class NewKeyAlias(NewKeyAliasBase): BIG_TITLE = "Configuration" SUBTITLE = "Manage key aliases" BINDINGS = [ @@ -142,10 +138,6 @@ class NewKeyAlias(NewKeyAliasBase[Profile]): Binding("f6", "save", "Save"), ] - @property - def context(self) -> Profile: - return self.profile - @on(CliveInput.Submitted) async def action_save(self) -> None: try: @@ -154,11 +146,10 @@ class NewKeyAlias(NewKeyAliasBase[Profile]): return def set_key_alias_to_import() -> None: - self.context.keys.set_to_import([self._private_key_aliased]) + self.profile.keys.set_to_import([self._private_key_aliased]) if not self._handle_key_alias_change(set_key_alias_to_import): return - await self._import_new_key() self.dismiss() diff --git a/clive/__private/ui/screens/config/manage_key_aliases/widgets/key_alias_form.py b/clive/__private/ui/screens/config/manage_key_aliases/widgets/key_alias_form.py index f89384b58f..002da85cc3 100644 --- a/clive/__private/ui/screens/config/manage_key_aliases/widgets/key_alias_form.py +++ b/clive/__private/ui/screens/config/manage_key_aliases/widgets/key_alias_form.py @@ -1,14 +1,11 @@ from __future__ import annotations from abc import ABC -from typing import TYPE_CHECKING, Any, Callable, ClassVar, TypeVar +from typing import TYPE_CHECKING, Any, Callable, ClassVar from textual.widgets import Static -from clive.__private.core.contextual import Contextual from clive.__private.core.keys import KeyAliasAlreadyInUseError -from clive.__private.core.profile import Profile -from clive.__private.ui.forms.create_profile.context import CreateProfileContext from clive.__private.ui.forms.navigation_buttons import NavigationButtons from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen @@ -20,14 +17,12 @@ from clive.__private.ui.widgets.select_copy_paste_hint import SelectCopyPasteHin if TYPE_CHECKING: from textual.app import ComposeResult -KeyAliasFormContextT = TypeVar("KeyAliasFormContextT", Profile, CreateProfileContext) - class SubTitle(Static): pass -class KeyAliasForm(BaseScreen, Contextual[KeyAliasFormContextT], ABC): +class KeyAliasForm(BaseScreen, ABC): CSS_PATH = [get_relative_css_path(__file__)] SECTION_TITLE: ClassVar[str] = "Change me in subclass" @@ -39,7 +34,7 @@ class KeyAliasForm(BaseScreen, Contextual[KeyAliasFormContextT], ABC): self._key_alias_input = PublicKeyAliasInput( value=self._default_key_alias_name(), setting_key_alias=True, - key_manager=self._get_context_profile().keys, + key_manager=self.profile.keys, required=False, ) self._key_alias_input.clear_validation(clear_value=False) @@ -57,11 +52,6 @@ class KeyAliasForm(BaseScreen, Contextual[KeyAliasFormContextT], ABC): yield NavigationButtons(is_finish=True) yield SelectCopyPasteHint() - def _get_context_profile(self) -> Profile: - if isinstance(self.context, Profile): - return self.context - return self.context.profile - def _content_after_big_title(self) -> ComposeResult: return [] diff --git a/clive/__private/ui/screens/config/switch_node_address/switch_node_address.py b/clive/__private/ui/screens/config/switch_node_address/switch_node_address.py index 3ac8bb63ad..9bb521f371 100644 --- a/clive/__private/ui/screens/config/switch_node_address/switch_node_address.py +++ b/clive/__private/ui/screens/config/switch_node_address/switch_node_address.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from textual import on from textual.binding import Binding @@ -19,15 +19,11 @@ class SwitchNodeAddress(BaseScreen): BIG_TITLE = "configuration" BINDINGS = [Binding("escape", "app.pop_screen", "Back")] - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self._node = self.get_node() - def create_main_panel(self) -> ComposeResult: # Section is focusable bcs it's not possible to use scrolling via keyboard when Select is focused with SectionScrollable("Set node address", focusable=True): - yield SelectedNodeAddress(self._node.http_endpoint) - yield NodesList(self._node) + yield SelectedNodeAddress(self.node.http_endpoint) + yield NodesList() @on(NodeSelector.Changed) async def save_selected_node_address(self) -> None: diff --git a/clive/__private/ui/widgets/node_widgets.py b/clive/__private/ui/widgets/node_widgets.py index 193859404a..a99c2aa1ad 100644 --- a/clive/__private/ui/widgets/node_widgets.py +++ b/clive/__private/ui/widgets/node_widgets.py @@ -50,10 +50,6 @@ class NodesList(Container, CliveWidget): } """ - def __init__(self, node: Node) -> None: - super().__init__() - self._node = node - def compose(self) -> ComposeResult: yield Static("Please select the node you want to connect to from the predefined list below.") with self.prevent(NodeSelector.Changed): -- GitLab From c8f2197667d4361a054972adb60b63b21184b77c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 152/192] Update core contextual --- clive/__private/core/contextual.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/clive/__private/core/contextual.py b/clive/__private/core/contextual.py index 9257966440..31eec1451b 100644 --- a/clive/__private/core/contextual.py +++ b/clive/__private/core/contextual.py @@ -1,24 +1,16 @@ from __future__ import annotations from abc import abstractmethod -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import Generic, TypeVar from clive.__private.abstract_class import AbstractClassMessagePump -if TYPE_CHECKING: - from typing_extensions import Self - class Context: """A class that could be used as a context.""" - def update_from_context(self, context: Self) -> None: - """Update self from other context.""" - for attribute in self.__dict__: - setattr(self, attribute, getattr(context, attribute)) - -ContextT = TypeVar("ContextT", bound=Context | None) +ContextT = TypeVar("ContextT", bound=Context) class Contextual(Generic[ContextT], AbstractClassMessagePump): -- GitLab From 3e2b9e40be0d14465b4374e0a0a5cfc7e224d0fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 153/192] Allow for no context in Form/FormScreen --- clive/__private/ui/forms/form.py | 29 +++++++++++++----------- clive/__private/ui/forms/form_context.py | 12 ++++++++++ clive/__private/ui/forms/form_screen.py | 11 +++++---- 3 files changed, 34 insertions(+), 18 deletions(-) create mode 100644 clive/__private/ui/forms/form_context.py diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index a1ee9ec61c..954e87107f 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -7,8 +7,9 @@ from queue import Queue from typing import TYPE_CHECKING, Any from clive.__private.core.commands.abc.command import Command -from clive.__private.core.contextual import ContextT, Contextual +from clive.__private.core.contextual import ContextualHolder from clive.__private.ui.clive_screen import CliveScreen +from clive.__private.ui.forms.form_context import FormContextT, NoContext if TYPE_CHECKING: from clive.__private.ui.forms.form_screen import FormScreenBase @@ -16,38 +17,37 @@ if TYPE_CHECKING: PostAction = Command | Callable[[], Any] -class Form(Contextual[ContextT], CliveScreen[None]): +class Form(ContextualHolder[FormContextT], CliveScreen[None]): MINIMUM_SCREEN_COUNT = 2 # Rationale: it makes no sense to have only one screen in the form def __init__(self) -> None: self._current_screen_index = 0 - self._screen_types: list[type[FormScreenBase[ContextT]]] = [*list(self.compose_form())] + self._screen_types: list[type[FormScreenBase[FormContextT]]] = [*list(self.compose_form())] assert len(self._screen_types) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" - self._rebuild_context() self._post_actions = Queue[PostAction]() - super().__init__() + super().__init__(self._build_context()) @abstractmethod - def compose_form(self) -> Iterator[type[FormScreenBase[ContextT]]]: + def compose_form(self) -> Iterator[type[FormScreenBase[FormContextT]]]: """Yield screens types in the order they should be displayed.""" - @abstractmethod - def _rebuild_context(self) -> None: - """Create brand new fresh context.""" - @property - def screens_types(self) -> list[type[FormScreenBase[ContextT]]]: + def screens_types(self) -> list[type[FormScreenBase[FormContextT]]]: return self._screen_types @property - def current_screen_type(self) -> type[FormScreenBase[ContextT]]: + def current_screen_type(self) -> type[FormScreenBase[FormContextT]]: return self._screen_types[self._current_screen_index] - def on_mount(self) -> None: + async def on_mount(self) -> None: assert self._current_screen_index == 0 + await self.initialize() self._push_current_screen() + async def initialize(self) -> None: + """Do actions that should be executed before the first form is displayed.""" + def next_screen(self) -> None: if not self._check_valid_range(self._current_screen_index + 1): return @@ -83,6 +83,9 @@ class Form(Contextual[ContextT], CliveScreen[None]): else: action() + def _build_context(self) -> FormContextT: + return NoContext() # type: ignore[return-value] + def _push_current_screen(self) -> None: self.app.push_screen(self.current_screen_type(self)) diff --git a/clive/__private/ui/forms/form_context.py b/clive/__private/ui/forms/form_context.py new file mode 100644 index 0000000000..1fe6e80e7f --- /dev/null +++ b/clive/__private/ui/forms/form_context.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing_extensions import TypeVar + +from clive.__private.core.contextual import Context + + +class NoContext(Context): + """A class that signals that there is no context.""" + + +FormContextT = TypeVar("FormContextT", bound=Context, default=NoContext) diff --git a/clive/__private/ui/forms/form_screen.py b/clive/__private/ui/forms/form_screen.py index 10f0bc7c7f..8dea9780c0 100644 --- a/clive/__private/ui/forms/form_screen.py +++ b/clive/__private/ui/forms/form_screen.py @@ -10,8 +10,9 @@ from textual.message import Message from textual.reactive import var from clive.__private.core.constants.tui.bindings import NEXT_SCREEN_BINDING_KEY, PREVIOUS_SCREEN_BINDING_KEY -from clive.__private.core.contextual import ContextT, Contextual +from clive.__private.core.contextual import Contextual from clive.__private.ui.clive_screen import CliveScreen +from clive.__private.ui.forms.form_context import FormContextT from clive.__private.ui.forms.navigation_buttons import NextScreenButton, PreviousScreenButton from clive.__private.ui.widgets.inputs.clive_input import CliveInput @@ -22,17 +23,17 @@ if TYPE_CHECKING: BackScreenModes = Literal["back_to_unlock", "nothing_to_back", "back_to_previous"] -class FormScreenBase(CliveScreen, Contextual[ContextT]): - def __init__(self, owner: Form[ContextT]) -> None: +class FormScreenBase(CliveScreen, Contextual[FormContextT]): + def __init__(self, owner: Form[FormContextT]) -> None: self._owner = owner super().__init__() @property - def context(self) -> ContextT: + def context(self) -> FormContextT: return self._owner.context -class FormScreen(FormScreenBase[ContextT], ABC): +class FormScreen(FormScreenBase[FormContextT], ABC): BINDINGS = [ Binding("escape", "previous_screen", "Previous screen", show=False), Binding(NEXT_SCREEN_BINDING_KEY, "next_screen", "Next screen"), -- GitLab From 0e929af7d8f08139be67433383f28ebb2e05ad49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 154/192] Ensure form cleanup is done when exiting form --- .../ui/forms/create_profile/create_profile_form.py | 4 ++++ clive/__private/ui/forms/form.py | 3 +++ clive/__private/ui/forms/form_screen.py | 1 + clive/__private/ui/screens/unlock/unlock.py | 7 +++++++ 4 files changed, 15 insertions(+) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 1dc236ee33..949bc43275 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -20,6 +20,10 @@ class CreateProfileForm(Form): await self.world.create_new_profile("temp_name") self.profile.skip_saving() + async def cleanup(self) -> None: + await self.world.switch_profile(None) + self.app.call_later(lambda: self.app.remove_mode("create_profile")) + def compose_form(self) -> Iterator[type[FormScreenBase]]: if not Profile.is_any_profile_saved(): yield CreateProfileWelcomeFormScreen diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index 954e87107f..9f7ab27bd3 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -48,6 +48,9 @@ class Form(ContextualHolder[FormContextT], CliveScreen[None]): async def initialize(self) -> None: """Do actions that should be executed before the first form is displayed.""" + async def cleanup(self) -> None: + """Do actions that should be executed when exiting the form.""" + def next_screen(self) -> None: if not self._check_valid_range(self._current_screen_index + 1): return diff --git a/clive/__private/ui/forms/form_screen.py b/clive/__private/ui/forms/form_screen.py index 8dea9780c0..4860ef764d 100644 --- a/clive/__private/ui/forms/form_screen.py +++ b/clive/__private/ui/forms/form_screen.py @@ -62,6 +62,7 @@ class FormScreen(FormScreenBase[FormContextT], ABC): return if self.back_screen_mode == "back_to_unlock": + await self._owner.cleanup() await self._back_to_unlock_screen() return diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index 7a831c2c27..df00784f98 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -1,9 +1,11 @@ from __future__ import annotations +import contextlib from datetime import timedelta from typing import TYPE_CHECKING from textual import on +from textual.app import InvalidModeError from textual.containers import Horizontal from textual.validation import Integer from textual.widgets import Button, Checkbox, Static @@ -11,6 +13,7 @@ from textual.widgets import Button, Checkbox, Static from clive.__private.core.constants.tui.messages import PRESS_HELP_MESSAGE from clive.__private.core.profile import Profile from clive.__private.ui.clive_widget import CliveWidget +from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen from clive.__private.ui.widgets.buttons import CliveButton @@ -129,6 +132,10 @@ class Unlock(BaseScreen): @on(Button.Pressed, "#new-profile-button") async def create_new_profile(self) -> None: + with contextlib.suppress(InvalidModeError): + # If the mode is already added, we don't want to add it again + self.app.add_mode("create_profile", CreateProfileForm) + await self.app.switch_mode("create_profile") @on(SelectProfile.Changed) -- GitLab From 25ee5e9190b8c4bd4d9ae64b2976f61cc5b9d5b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 155/192] Rewrite Form so CreateProfileForm-specific code is not there --- .../create_profile/create_profile_form.py | 22 +++++++- .../create_profile_form_screen.py | 3 -- .../finish_profile_creation_mixin.py | 27 ---------- .../new_key_alias_form_screen.py | 8 +-- .../create_profile/set_account_form_screen.py | 5 +- .../create_profile/welcome_form_screen.py | 7 ++- clive/__private/ui/forms/form.py | 44 ++++++++++----- clive/__private/ui/forms/form_screen.py | 54 +++++++------------ 8 files changed, 82 insertions(+), 88 deletions(-) delete mode 100644 clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 949bc43275..5b6ba8b687 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -22,7 +22,6 @@ class CreateProfileForm(Form): async def cleanup(self) -> None: await self.world.switch_profile(None) - self.app.call_later(lambda: self.app.remove_mode("create_profile")) def compose_form(self) -> Iterator[type[FormScreenBase]]: if not Profile.is_any_profile_saved(): @@ -30,3 +29,24 @@ class CreateProfileForm(Form): yield CreateProfileFormScreen yield SetAccountFormScreen yield NewKeyAliasFormScreen + + async def exit_form(self) -> None: + # when this form is displayed during onboarding, there is no previous screen to go back to + # so this method won't be called + await self.app.switch_mode("unlock") + self.app.remove_mode("create_profile") + + async def finish_form(self) -> None: + async def handle_modes() -> None: + await self.app.switch_mode("dashboard") + self.app.remove_mode("create_profile") + self.app.remove_mode("unlock") + + self.add_post_action( + lambda: self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True), + self.app.resume_refresh_alarms_data_interval, + ) + await self.execute_post_actions() + await handle_modes() + self.profile.enable_saving() + await self.commands.save_profile() diff --git a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py index 0bdc923a94..4e19c8bec3 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING from textual.binding import Binding -from clive.__private.core.profile import Profile from clive.__private.ui.forms.form_screen import FormScreen from clive.__private.ui.forms.navigation_buttons import NavigationButtons from clive.__private.ui.get_css import get_relative_css_path @@ -33,8 +32,6 @@ class CreateProfileFormScreen(BaseScreen, FormScreen): self._password_input = SetPasswordInput() self._repeat_password_input = RepeatPasswordInput(self._password_input) super().__init__(owner=owner) - if Profile.is_any_profile_saved(): - self.back_screen_mode = "back_to_unlock" def create_main_panel(self) -> ComposeResult: with SectionScrollable("Enter profile data"): diff --git a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py b/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py deleted file mode 100644 index ed1c7965ba..0000000000 --- a/clive/__private/ui/forms/create_profile/finish_profile_creation_mixin.py +++ /dev/null @@ -1,27 +0,0 @@ -from __future__ import annotations - -from textual import on - -from clive.__private.ui.forms.form_screen import FormScreen, FormScreenBase - - -class FinishProfileCreationMixin(FormScreenBase): - @on(FormScreen.Finish) - async def finish(self) -> None: - # Has to be done in a separate task to avoid deadlock. More: https://github.com/Textualize/textual/issues/5008 - self.app.run_worker(self._finish()) - - async def _finish(self) -> None: - self._owner.add_post_action( - lambda: self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True), - self.app.resume_refresh_alarms_data_interval, - ) - await self._owner.execute_post_actions() - await self._handle_modes_on_finish() - self.profile.enable_saving() - await self.commands.save_profile() - - async def _handle_modes_on_finish(self) -> None: - await self.app.switch_mode("dashboard") - await self.app.remove_mode("create_profile") - await self.app.remove_mode("unlock") diff --git a/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py b/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py index 86662d8466..c7027fabe7 100644 --- a/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py +++ b/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py @@ -6,14 +6,13 @@ from textual import on from textual.binding import Binding from clive.__private.logger import logger -from clive.__private.ui.forms.create_profile.finish_profile_creation_mixin import FinishProfileCreationMixin from clive.__private.ui.forms.form_screen import FormScreen from clive.__private.ui.forms.navigation_buttons import PreviousScreenButton from clive.__private.ui.screens.config.manage_key_aliases.new_key_alias import NewKeyAliasBase from clive.__private.ui.widgets.inputs.clive_validated_input import FailedManyValidationError -class NewKeyAliasFormScreen(NewKeyAliasBase, FormScreen, FinishProfileCreationMixin): +class NewKeyAliasFormScreen(NewKeyAliasBase, FormScreen): BINDINGS = [Binding("f1", "help", "Help")] BIG_TITLE = "create profile" SUBTITLE = "Optional step, could be done later" @@ -21,7 +20,10 @@ class NewKeyAliasFormScreen(NewKeyAliasBase, FormScreen, FinishProfileCreationMi def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - self.should_finish = True + + @property + def should_finish(self) -> bool: + return True @on(PreviousScreenButton.Pressed) async def action_previous_screen(self) -> None: diff --git a/clive/__private/ui/forms/create_profile/set_account_form_screen.py b/clive/__private/ui/forms/create_profile/set_account_form_screen.py index c87c23950f..5e2a150849 100644 --- a/clive/__private/ui/forms/create_profile/set_account_form_screen.py +++ b/clive/__private/ui/forms/create_profile/set_account_form_screen.py @@ -7,7 +7,6 @@ from textual.binding import Binding from textual.widgets import Checkbox from clive.__private.core.constants.tui.placeholders import ACCOUNT_NAME_CREATE_PROFILE_PLACEHOLDER -from clive.__private.ui.forms.create_profile.finish_profile_creation_mixin import FinishProfileCreationMixin from clive.__private.ui.forms.form_screen import FormScreen from clive.__private.ui.forms.navigation_buttons import NavigationButtons, PreviousScreenButton from clive.__private.ui.get_css import get_relative_css_path @@ -28,7 +27,7 @@ class WorkingAccountCheckbox(Checkbox): super().__init__("Working account?", value=True) -class SetAccountFormScreen(BaseScreen, FormScreen, FinishProfileCreationMixin): +class SetAccountFormScreen(BaseScreen, FormScreen): BINDINGS = [Binding("f1", "help", "Help")] CSS_PATH = [get_relative_css_path(__file__)] BIG_TITLE = "create profile" @@ -102,5 +101,5 @@ class SetAccountFormScreen(BaseScreen, FormScreen, FinishProfileCreationMixin): @on(WorkingAccountCheckbox.Changed) def _change_finish_screen_status(self, event: WorkingAccountCheckbox.Changed) -> None: - self.should_finish = not event.value + self._should_finish = not event.value self.query_exactly_one(NavigationButtons).is_finish = self.should_finish diff --git a/clive/__private/ui/forms/create_profile/welcome_form_screen.py b/clive/__private/ui/forms/create_profile/welcome_form_screen.py index a8e59a817f..db87acb647 100644 --- a/clive/__private/ui/forms/create_profile/welcome_form_screen.py +++ b/clive/__private/ui/forms/create_profile/welcome_form_screen.py @@ -6,6 +6,7 @@ from textual import on from textual.binding import Binding from textual.widgets import Static +from clive.__private.core.constants.tui.bindings import PREVIOUS_SCREEN_BINDING_KEY from clive.__private.core.constants.tui.messages import PRESS_HELP_MESSAGE from clive.__private.ui.forms.form_screen import FormScreen from clive.__private.ui.get_css import get_relative_css_path @@ -24,14 +25,16 @@ class Description(Static): class CreateProfileWelcomeFormScreen(BaseScreen, FormScreen): - BINDINGS = [Binding("f1", "help", "Help")] + BINDINGS = [ + Binding("f1", "help", "Help"), + Binding(f"{PREVIOUS_SCREEN_BINDING_KEY},escape", "_there_is_no_back", "Nothing", show=False), + ] CSS_PATH = [get_relative_css_path(__file__)] SHOW_RAW_HEADER = True def __init__(self, owner: Form) -> None: super().__init__(owner) self._description = "Let's create profile!\n" + PRESS_HELP_MESSAGE - self.back_screen_mode = "nothing_to_back" def create_main_panel(self) -> ComposeResult: with DialogContainer("welcome"): diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index 9f7ab27bd3..c3dbb7a481 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -4,7 +4,7 @@ import inspect from abc import abstractmethod from collections.abc import Callable, Iterator from queue import Queue -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from clive.__private.core.commands.abc.command import Command from clive.__private.core.contextual import ContextualHolder @@ -40,27 +40,46 @@ class Form(ContextualHolder[FormContextT], CliveScreen[None]): def current_screen_type(self) -> type[FormScreenBase[FormContextT]]: return self._screen_types[self._current_screen_index] - async def on_mount(self) -> None: - assert self._current_screen_index == 0 - await self.initialize() - self._push_current_screen() + @property + def is_on_the_last_screen(self) -> bool: + return self._current_screen_index == len(self._screen_types) - 1 + + @property + def is_should_finish_set_on_current_screen(self) -> bool: + return cast("FormScreenBase", self.app.screen).should_finish async def initialize(self) -> None: """Do actions that should be executed before the first form is displayed.""" async def cleanup(self) -> None: - """Do actions that should be executed when exiting the form.""" + """Do actions that should be executed before exiting the form.""" + + async def exit_form(self) -> None: + self.app.pop_screen() - def next_screen(self) -> None: - if not self._check_valid_range(self._current_screen_index + 1): + async def finish_form(self) -> None: + """Execute actions when the form is finished.""" + + async def on_mount(self) -> None: + assert self._current_screen_index == 0 + await self.initialize() + self._push_current_screen() + + async def next_screen(self) -> None: + if self.is_on_the_last_screen or self.is_should_finish_set_on_current_screen: + await self.finish_form() return self._current_screen_index += 1 self._push_current_screen() - def previous_screen(self) -> None: - if not self._check_valid_range(self._current_screen_index - 1): + async def previous_screen(self) -> None: + is_leaving_form = self._current_screen_index == 0 + + if is_leaving_form: + await self.cleanup() + await self.exit_form() return self._current_screen_index -= 1 @@ -90,7 +109,6 @@ class Form(ContextualHolder[FormContextT], CliveScreen[None]): return NoContext() # type: ignore[return-value] def _push_current_screen(self) -> None: + assert self._current_screen_index < len(self._screen_types), "Current screen index is out of bounds" + assert self._current_screen_index >= 0, "Current screen index is out of bounds" self.app.push_screen(self.current_screen_type(self)) - - def _check_valid_range(self, proposed_idx: int) -> bool: - return (proposed_idx >= 0) and (proposed_idx < len(self._screen_types)) diff --git a/clive/__private/ui/forms/form_screen.py b/clive/__private/ui/forms/form_screen.py index 4860ef764d..e8345f8d62 100644 --- a/clive/__private/ui/forms/form_screen.py +++ b/clive/__private/ui/forms/form_screen.py @@ -2,11 +2,10 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING from textual import on from textual.binding import Binding -from textual.message import Message from textual.reactive import var from clive.__private.core.constants.tui.bindings import NEXT_SCREEN_BINDING_KEY, PREVIOUS_SCREEN_BINDING_KEY @@ -20,9 +19,6 @@ if TYPE_CHECKING: from clive.__private.ui.forms.form import Form -BackScreenModes = Literal["back_to_unlock", "nothing_to_back", "back_to_previous"] - - class FormScreenBase(CliveScreen, Contextual[FormContextT]): def __init__(self, owner: Form[FormContextT]) -> None: self._owner = owner @@ -32,18 +28,23 @@ class FormScreenBase(CliveScreen, Contextual[FormContextT]): def context(self) -> FormContextT: return self._owner.context + @property + def should_finish(self) -> bool: + return False + class FormScreen(FormScreenBase[FormContextT], ABC): BINDINGS = [ - Binding("escape", "previous_screen", "Previous screen", show=False), + Binding( + f"{PREVIOUS_SCREEN_BINDING_KEY},escape", + "previous_screen", + "Previous screen", + key_display=PREVIOUS_SCREEN_BINDING_KEY, + ), Binding(NEXT_SCREEN_BINDING_KEY, "next_screen", "Next screen"), ] - should_finish: bool = var(default=False) # type: ignore[assignment] - back_screen_mode: BackScreenModes = var("back_to_previous") # type: ignore[assignment] - - class Finish(Message): - """Used to determine that the form is finished.""" + _should_finish: bool = var(default=False, init=False) # type: ignore[assignment] @dataclass class ValidationSuccess: @@ -56,17 +57,13 @@ class FormScreen(FormScreenBase[FormContextT], ABC): notification_message: str | None = None """Message to be displayed in the notification.""" + @property + def should_finish(self) -> bool: + return self._should_finish + @on(PreviousScreenButton.Pressed) async def action_previous_screen(self) -> None: - if self.back_screen_mode == "nothing_to_back": - return - - if self.back_screen_mode == "back_to_unlock": - await self._owner.cleanup() - await self._back_to_unlock_screen() - return - - self._owner.previous_screen() + await self._owner.previous_screen() @on(NextScreenButton.Pressed) @on(CliveInput.Submitted) @@ -81,11 +78,7 @@ class FormScreen(FormScreenBase[FormContextT], ABC): await self.apply() - if self.should_finish: - self.post_message(self.Finish()) - return - - self._owner.next_screen() + await self._owner.next_screen() @abstractmethod async def validate(self) -> ValidationFail | ValidationSuccess | None: @@ -98,14 +91,3 @@ class FormScreen(FormScreenBase[FormContextT], ABC): def is_step_optional(self) -> bool: """Override to skip form validation.""" return False - - def watch_back_screen_mode(self, value: BackScreenModes) -> None: - """Responding to a change of the back screen mode.""" - if value == "nothing_to_back": - self.unbind(PREVIOUS_SCREEN_BINDING_KEY) - return - - self.bind(Binding(PREVIOUS_SCREEN_BINDING_KEY, "previous_screen", "Previous screen")) - - async def _back_to_unlock_screen(self) -> None: - await self.app.switch_mode("unlock") -- GitLab From bffce6183aa56f1c140979598bcb6dbfcfbea75c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 156/192] Get rid of FormScreenBase --- .../create_profile/create_profile_form.py | 4 ++-- clive/__private/ui/forms/form.py | 12 +++++----- clive/__private/ui/forms/form_screen.py | 24 +++++++------------ 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 5b6ba8b687..d0fe0c7d41 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -12,7 +12,7 @@ from clive.__private.ui.forms.form import Form if TYPE_CHECKING: from collections.abc import Iterator - from clive.__private.ui.forms.form_screen import FormScreenBase + from clive.__private.ui.forms.form_screen import FormScreen class CreateProfileForm(Form): @@ -23,7 +23,7 @@ class CreateProfileForm(Form): async def cleanup(self) -> None: await self.world.switch_profile(None) - def compose_form(self) -> Iterator[type[FormScreenBase]]: + def compose_form(self) -> Iterator[type[FormScreen]]: if not Profile.is_any_profile_saved(): yield CreateProfileWelcomeFormScreen yield CreateProfileFormScreen diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index c3dbb7a481..0d1d14f1fe 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -12,7 +12,7 @@ from clive.__private.ui.clive_screen import CliveScreen from clive.__private.ui.forms.form_context import FormContextT, NoContext if TYPE_CHECKING: - from clive.__private.ui.forms.form_screen import FormScreenBase + from clive.__private.ui.forms.form_screen import FormScreen PostAction = Command | Callable[[], Any] @@ -22,22 +22,22 @@ class Form(ContextualHolder[FormContextT], CliveScreen[None]): def __init__(self) -> None: self._current_screen_index = 0 - self._screen_types: list[type[FormScreenBase[FormContextT]]] = [*list(self.compose_form())] + self._screen_types: list[type[FormScreen[FormContextT]]] = [*list(self.compose_form())] assert len(self._screen_types) >= self.MINIMUM_SCREEN_COUNT, "Form must have at least 2 screens" self._post_actions = Queue[PostAction]() super().__init__(self._build_context()) @abstractmethod - def compose_form(self) -> Iterator[type[FormScreenBase[FormContextT]]]: + def compose_form(self) -> Iterator[type[FormScreen[FormContextT]]]: """Yield screens types in the order they should be displayed.""" @property - def screens_types(self) -> list[type[FormScreenBase[FormContextT]]]: + def screens_types(self) -> list[type[FormScreen[FormContextT]]]: return self._screen_types @property - def current_screen_type(self) -> type[FormScreenBase[FormContextT]]: + def current_screen_type(self) -> type[FormScreen[FormContextT]]: return self._screen_types[self._current_screen_index] @property @@ -46,7 +46,7 @@ class Form(ContextualHolder[FormContextT], CliveScreen[None]): @property def is_should_finish_set_on_current_screen(self) -> bool: - return cast("FormScreenBase", self.app.screen).should_finish + return cast("FormScreen", self.app.screen).should_finish async def initialize(self) -> None: """Do actions that should be executed before the first form is displayed.""" diff --git a/clive/__private/ui/forms/form_screen.py b/clive/__private/ui/forms/form_screen.py index e8345f8d62..9f56443c66 100644 --- a/clive/__private/ui/forms/form_screen.py +++ b/clive/__private/ui/forms/form_screen.py @@ -19,21 +19,7 @@ if TYPE_CHECKING: from clive.__private.ui.forms.form import Form -class FormScreenBase(CliveScreen, Contextual[FormContextT]): - def __init__(self, owner: Form[FormContextT]) -> None: - self._owner = owner - super().__init__() - - @property - def context(self) -> FormContextT: - return self._owner.context - - @property - def should_finish(self) -> bool: - return False - - -class FormScreen(FormScreenBase[FormContextT], ABC): +class FormScreen(CliveScreen, Contextual[FormContextT], ABC): BINDINGS = [ Binding( f"{PREVIOUS_SCREEN_BINDING_KEY},escape", @@ -57,6 +43,14 @@ class FormScreen(FormScreenBase[FormContextT], ABC): notification_message: str | None = None """Message to be displayed in the notification.""" + def __init__(self, owner: Form[FormContextT]) -> None: + self._owner = owner + super().__init__() + + @property + def context(self) -> FormContextT: + return self._owner.context + @property def should_finish(self) -> bool: return self._should_finish -- GitLab From a4047b2e687f830ace903fb0c94c41e56329578e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:47:50 +0100 Subject: [PATCH 157/192] Create ComposeFormResult typealias --- .../ui/forms/create_profile/create_profile_form.py | 11 ++--------- clive/__private/ui/forms/form.py | 12 ++++++------ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index d0fe0c7d41..05e89b379e 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -1,18 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from clive.__private.core.profile import Profile from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.forms.create_profile.new_key_alias_form_screen import NewKeyAliasFormScreen from clive.__private.ui.forms.create_profile.set_account_form_screen import SetAccountFormScreen from clive.__private.ui.forms.create_profile.welcome_form_screen import CreateProfileWelcomeFormScreen -from clive.__private.ui.forms.form import Form - -if TYPE_CHECKING: - from collections.abc import Iterator - - from clive.__private.ui.forms.form_screen import FormScreen +from clive.__private.ui.forms.form import ComposeFormResult, Form class CreateProfileForm(Form): @@ -23,7 +16,7 @@ class CreateProfileForm(Form): async def cleanup(self) -> None: await self.world.switch_profile(None) - def compose_form(self) -> Iterator[type[FormScreen]]: + def compose_form(self) -> ComposeFormResult: if not Profile.is_any_profile_saved(): yield CreateProfileWelcomeFormScreen yield CreateProfileFormScreen diff --git a/clive/__private/ui/forms/form.py b/clive/__private/ui/forms/form.py index 0d1d14f1fe..be5391ba66 100644 --- a/clive/__private/ui/forms/form.py +++ b/clive/__private/ui/forms/form.py @@ -2,20 +2,20 @@ from __future__ import annotations import inspect from abc import abstractmethod -from collections.abc import Callable, Iterator +from collections.abc import Callable from queue import Queue -from typing import TYPE_CHECKING, Any, cast +from typing import Any, Iterable, cast from clive.__private.core.commands.abc.command import Command from clive.__private.core.contextual import ContextualHolder from clive.__private.ui.clive_screen import CliveScreen from clive.__private.ui.forms.form_context import FormContextT, NoContext - -if TYPE_CHECKING: - from clive.__private.ui.forms.form_screen import FormScreen +from clive.__private.ui.forms.form_screen import FormScreen PostAction = Command | Callable[[], Any] +ComposeFormResult = Iterable[type[FormScreen[FormContextT]]] + class Form(ContextualHolder[FormContextT], CliveScreen[None]): MINIMUM_SCREEN_COUNT = 2 # Rationale: it makes no sense to have only one screen in the form @@ -29,7 +29,7 @@ class Form(ContextualHolder[FormContextT], CliveScreen[None]): super().__init__(self._build_context()) @abstractmethod - def compose_form(self) -> Iterator[type[FormScreen[FormContextT]]]: + def compose_form(self) -> ComposeFormResult[FormContextT]: """Yield screens types in the order they should be displayed.""" @property -- GitLab From 193bac055ae73c6af09e551f25dd200f4786a831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 7 Mar 2025 14:51:54 +0100 Subject: [PATCH 158/192] Fix typo --- tests/tui/test_create_profile.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/tui/test_create_profile.py b/tests/tui/test_create_profile.py index ab60ab546c..c84b47c482 100644 --- a/tests/tui/test_create_profile.py +++ b/tests/tui/test_create_profile.py @@ -56,7 +56,7 @@ async def prepared_tui_on_create_profile( await clive_quit(pilot) -async def crate_profile_until_set_account( +async def create_profile_until_set_account( pilot: ClivePilot, profile_name: str, profile_password: str, account_name: str ) -> None: assert_is_screen_active(pilot, CreateProfileWelcomeFormScreen) @@ -141,7 +141,7 @@ async def test_create_profile_watched_account_creation(prepared_tui_on_create_pr pilot = prepared_tui_on_create_profile # ACT - await crate_profile_until_set_account(pilot, PROFILE_NAME, PROFILE_PASSWORD, ACCOUNT_NAME) + await create_profile_until_set_account(pilot, PROFILE_NAME, PROFILE_PASSWORD, ACCOUNT_NAME) await create_profile_mark_account_as_watched(pilot) await create_profile_finish(pilot) @@ -155,7 +155,7 @@ async def test_create_profile_working_account_creation(prepared_tui_on_create_pr pilot = prepared_tui_on_create_profile # ACT - await crate_profile_until_set_account(pilot, PROFILE_NAME, PROFILE_PASSWORD, ACCOUNT_NAME) + await create_profile_until_set_account(pilot, PROFILE_NAME, PROFILE_PASSWORD, ACCOUNT_NAME) await press_and_wait_for_screen(pilot, "enter", NewKeyAliasFormScreen) await create_profile_set_key_and_alias_name(pilot, KEY_ALIAS_NAME, PRIVATE_KEY) await create_profile_finish(pilot) @@ -171,7 +171,7 @@ async def test_create_profile_working_account_creation_no_key(prepared_tui_on_cr pilot = prepared_tui_on_create_profile # ACT - await crate_profile_until_set_account(pilot, PROFILE_NAME, PROFILE_PASSWORD, ACCOUNT_NAME) + await create_profile_until_set_account(pilot, PROFILE_NAME, PROFILE_PASSWORD, ACCOUNT_NAME) await press_and_wait_for_screen(pilot, "enter", NewKeyAliasFormScreen) await create_profile_finish(pilot) -- GitLab From 981f97249c284014caa007b1b85e3f1814a2a0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 10 Mar 2025 11:41:03 +0100 Subject: [PATCH 159/192] Rename CreateProfileFormScreen -> ProfileCredentialsFormScreen --- .../__private/ui/forms/create_profile/create_profile_form.py | 4 ++-- ...file_form_screen.py => profile_credentials_form_screen.py} | 4 ++-- ..._form_screen.scss => profile_credentials_form_screen.scss} | 2 +- tests/tui/test_create_profile.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) rename clive/__private/ui/forms/create_profile/{create_profile_form_screen.py => profile_credentials_form_screen.py} (95%) rename clive/__private/ui/forms/create_profile/{create_profile_form_screen.scss => profile_credentials_form_screen.scss} (81%) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 05e89b379e..434e39dfd7 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -1,8 +1,8 @@ from __future__ import annotations from clive.__private.core.profile import Profile -from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.forms.create_profile.new_key_alias_form_screen import NewKeyAliasFormScreen +from clive.__private.ui.forms.create_profile.profile_credentials_form_screen import ProfileCredentialsFormScreen from clive.__private.ui.forms.create_profile.set_account_form_screen import SetAccountFormScreen from clive.__private.ui.forms.create_profile.welcome_form_screen import CreateProfileWelcomeFormScreen from clive.__private.ui.forms.form import ComposeFormResult, Form @@ -19,7 +19,7 @@ class CreateProfileForm(Form): def compose_form(self) -> ComposeFormResult: if not Profile.is_any_profile_saved(): yield CreateProfileWelcomeFormScreen - yield CreateProfileFormScreen + yield ProfileCredentialsFormScreen yield SetAccountFormScreen yield NewKeyAliasFormScreen diff --git a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py b/clive/__private/ui/forms/create_profile/profile_credentials_form_screen.py similarity index 95% rename from clive/__private/ui/forms/create_profile/create_profile_form_screen.py rename to clive/__private/ui/forms/create_profile/profile_credentials_form_screen.py index 4e19c8bec3..f693f72758 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py +++ b/clive/__private/ui/forms/create_profile/profile_credentials_form_screen.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: from clive.__private.ui.forms.form import Form -class CreateProfileFormScreen(BaseScreen, FormScreen): +class ProfileCredentialsFormScreen(BaseScreen, FormScreen): BINDINGS = [Binding("f1", "help", "Help")] CSS_PATH = [get_relative_css_path(__file__)] BIG_TITLE = "create profile" @@ -45,7 +45,7 @@ class CreateProfileFormScreen(BaseScreen, FormScreen): # Validate the repeat password input again when password is changed and repeat was already touched. self.watch(self._password_input.input, "value", self._revalidate_repeat_password_input_when_password_changed) - async def validate(self) -> CreateProfileFormScreen.ValidationFail | None: + async def validate(self) -> ProfileCredentialsFormScreen.ValidationFail | None: try: CliveValidatedInput.validate_many_with_error( self._profile_name_input, self._password_input, self._repeat_password_input diff --git a/clive/__private/ui/forms/create_profile/create_profile_form_screen.scss b/clive/__private/ui/forms/create_profile/profile_credentials_form_screen.scss similarity index 81% rename from clive/__private/ui/forms/create_profile/create_profile_form_screen.scss rename to clive/__private/ui/forms/create_profile/profile_credentials_form_screen.scss index e2e093c968..c7746d65b2 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form_screen.scss +++ b/clive/__private/ui/forms/create_profile/profile_credentials_form_screen.scss @@ -1,4 +1,4 @@ -CreateProfileFormScreen { +ProfileCredentialsFormScreen { SectionScrollable { margin: 1 4 0 4; diff --git a/tests/tui/test_create_profile.py b/tests/tui/test_create_profile.py index c84b47c482..1697946cdc 100644 --- a/tests/tui/test_create_profile.py +++ b/tests/tui/test_create_profile.py @@ -5,8 +5,8 @@ from typing import TYPE_CHECKING, Final import pytest from clive.__private.ui.app import Clive -from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.forms.create_profile.new_key_alias_form_screen import NewKeyAliasFormScreen +from clive.__private.ui.forms.create_profile.profile_credentials_form_screen import ProfileCredentialsFormScreen from clive.__private.ui.forms.create_profile.set_account_form_screen import SetAccountFormScreen, WorkingAccountCheckbox from clive.__private.ui.forms.create_profile.welcome_form_screen import CreateProfileWelcomeFormScreen from clive.__private.ui.screens.config import Config @@ -61,7 +61,7 @@ async def create_profile_until_set_account( ) -> None: assert_is_screen_active(pilot, CreateProfileWelcomeFormScreen) assert_is_new_profile(pilot) - await press_and_wait_for_screen(pilot, "enter", CreateProfileFormScreen) + await press_and_wait_for_screen(pilot, "enter", ProfileCredentialsFormScreen) assert_is_clive_composed_input_focused( pilot, SetProfileNameInput, context="CreateProfileForm should have initial focus" ) -- GitLab From b267e10cc010354005cd08637603d68250050d59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 10 Mar 2025 11:56:15 +0100 Subject: [PATCH 160/192] Create base ABC CreateProfileFormScreen --- .../create_profile_form_screen.py | 18 ++++++++++++++++++ .../new_key_alias_form_screen.py | 9 +++------ .../profile_credentials_form_screen.py | 8 ++++---- .../create_profile/set_account_form_screen.py | 4 ++-- .../create_profile/welcome_form_screen.py | 8 ++++---- clive/__private/ui/forms/form_screen.py | 10 +++++++--- 6 files changed, 38 insertions(+), 19 deletions(-) create mode 100644 clive/__private/ui/forms/create_profile/create_profile_form_screen.py diff --git a/clive/__private/ui/forms/create_profile/create_profile_form_screen.py b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py new file mode 100644 index 0000000000..e646f50403 --- /dev/null +++ b/clive/__private/ui/forms/create_profile/create_profile_form_screen.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from abc import ABC +from typing import TYPE_CHECKING, cast + +from clive.__private.ui.forms.form_screen import FormScreen + +if TYPE_CHECKING: + from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm + + +class CreateProfileFormScreen(FormScreen, ABC): + def __init__(self, owner: CreateProfileForm) -> None: + super().__init__(owner) + + @property + def owner(self) -> CreateProfileForm: + return cast("CreateProfileForm", super().owner) diff --git a/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py b/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py index c7027fabe7..db9b197594 100644 --- a/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py +++ b/clive/__private/ui/forms/create_profile/new_key_alias_form_screen.py @@ -1,26 +1,23 @@ from __future__ import annotations -from typing import Any, ClassVar +from typing import ClassVar from textual import on from textual.binding import Binding from clive.__private.logger import logger -from clive.__private.ui.forms.form_screen import FormScreen +from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.forms.navigation_buttons import PreviousScreenButton from clive.__private.ui.screens.config.manage_key_aliases.new_key_alias import NewKeyAliasBase from clive.__private.ui.widgets.inputs.clive_validated_input import FailedManyValidationError -class NewKeyAliasFormScreen(NewKeyAliasBase, FormScreen): +class NewKeyAliasFormScreen(NewKeyAliasBase, CreateProfileFormScreen): BINDINGS = [Binding("f1", "help", "Help")] BIG_TITLE = "create profile" SUBTITLE = "Optional step, could be done later" IS_PRIVATE_KEY_REQUIRED: ClassVar[bool] = False - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - @property def should_finish(self) -> bool: return True diff --git a/clive/__private/ui/forms/create_profile/profile_credentials_form_screen.py b/clive/__private/ui/forms/create_profile/profile_credentials_form_screen.py index f693f72758..e94c239a74 100644 --- a/clive/__private/ui/forms/create_profile/profile_credentials_form_screen.py +++ b/clive/__private/ui/forms/create_profile/profile_credentials_form_screen.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING from textual.binding import Binding -from clive.__private.ui.forms.form_screen import FormScreen +from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.forms.navigation_buttons import NavigationButtons from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen @@ -18,16 +18,16 @@ from clive.__private.ui.widgets.select_copy_paste_hint import SelectCopyPasteHin if TYPE_CHECKING: from textual.app import ComposeResult - from clive.__private.ui.forms.form import Form + from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm -class ProfileCredentialsFormScreen(BaseScreen, FormScreen): +class ProfileCredentialsFormScreen(BaseScreen, CreateProfileFormScreen): BINDINGS = [Binding("f1", "help", "Help")] CSS_PATH = [get_relative_css_path(__file__)] BIG_TITLE = "create profile" SHOW_RAW_HEADER = True - def __init__(self, owner: Form) -> None: + def __init__(self, owner: CreateProfileForm) -> None: self._profile_name_input = SetProfileNameInput() self._password_input = SetPasswordInput() self._repeat_password_input = RepeatPasswordInput(self._password_input) diff --git a/clive/__private/ui/forms/create_profile/set_account_form_screen.py b/clive/__private/ui/forms/create_profile/set_account_form_screen.py index 5e2a150849..5877af4900 100644 --- a/clive/__private/ui/forms/create_profile/set_account_form_screen.py +++ b/clive/__private/ui/forms/create_profile/set_account_form_screen.py @@ -7,7 +7,7 @@ from textual.binding import Binding from textual.widgets import Checkbox from clive.__private.core.constants.tui.placeholders import ACCOUNT_NAME_CREATE_PROFILE_PLACEHOLDER -from clive.__private.ui.forms.form_screen import FormScreen +from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.forms.navigation_buttons import NavigationButtons, PreviousScreenButton from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen @@ -27,7 +27,7 @@ class WorkingAccountCheckbox(Checkbox): super().__init__("Working account?", value=True) -class SetAccountFormScreen(BaseScreen, FormScreen): +class SetAccountFormScreen(BaseScreen, CreateProfileFormScreen): BINDINGS = [Binding("f1", "help", "Help")] CSS_PATH = [get_relative_css_path(__file__)] BIG_TITLE = "create profile" diff --git a/clive/__private/ui/forms/create_profile/welcome_form_screen.py b/clive/__private/ui/forms/create_profile/welcome_form_screen.py index db87acb647..da40880d19 100644 --- a/clive/__private/ui/forms/create_profile/welcome_form_screen.py +++ b/clive/__private/ui/forms/create_profile/welcome_form_screen.py @@ -8,7 +8,7 @@ from textual.widgets import Static from clive.__private.core.constants.tui.bindings import PREVIOUS_SCREEN_BINDING_KEY from clive.__private.core.constants.tui.messages import PRESS_HELP_MESSAGE -from clive.__private.ui.forms.form_screen import FormScreen +from clive.__private.ui.forms.create_profile.create_profile_form_screen import CreateProfileFormScreen from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen from clive.__private.ui.widgets.buttons import CliveButton @@ -17,14 +17,14 @@ from clive.__private.ui.widgets.dialog_container import DialogContainer if TYPE_CHECKING: from textual.app import ComposeResult - from clive.__private.ui.forms.form import Form + from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm class Description(Static): """Description of the welcome screen.""" -class CreateProfileWelcomeFormScreen(BaseScreen, FormScreen): +class CreateProfileWelcomeFormScreen(BaseScreen, CreateProfileFormScreen): BINDINGS = [ Binding("f1", "help", "Help"), Binding(f"{PREVIOUS_SCREEN_BINDING_KEY},escape", "_there_is_no_back", "Nothing", show=False), @@ -32,7 +32,7 @@ class CreateProfileWelcomeFormScreen(BaseScreen, FormScreen): CSS_PATH = [get_relative_css_path(__file__)] SHOW_RAW_HEADER = True - def __init__(self, owner: Form) -> None: + def __init__(self, owner: CreateProfileForm) -> None: super().__init__(owner) self._description = "Let's create profile!\n" + PRESS_HELP_MESSAGE diff --git a/clive/__private/ui/forms/form_screen.py b/clive/__private/ui/forms/form_screen.py index 9f56443c66..df1f045bd9 100644 --- a/clive/__private/ui/forms/form_screen.py +++ b/clive/__private/ui/forms/form_screen.py @@ -47,9 +47,13 @@ class FormScreen(CliveScreen, Contextual[FormContextT], ABC): self._owner = owner super().__init__() + @property + def owner(self) -> Form[FormContextT]: + return self._owner + @property def context(self) -> FormContextT: - return self._owner.context + return self.owner.context @property def should_finish(self) -> bool: @@ -57,7 +61,7 @@ class FormScreen(CliveScreen, Contextual[FormContextT], ABC): @on(PreviousScreenButton.Pressed) async def action_previous_screen(self) -> None: - await self._owner.previous_screen() + await self.owner.previous_screen() @on(NextScreenButton.Pressed) @on(CliveInput.Submitted) @@ -72,7 +76,7 @@ class FormScreen(CliveScreen, Contextual[FormContextT], ABC): await self.apply() - await self._owner.next_screen() + await self.owner.next_screen() @abstractmethod async def validate(self) -> ValidationFail | ValidationSuccess | None: -- GitLab From 5419e86a2c142fc979f2a6f43bffdbc62210c7af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 10 Mar 2025 11:57:39 +0100 Subject: [PATCH 161/192] Rename WelcomeFormScreen --- .../__private/ui/forms/create_profile/create_profile_form.py | 4 ++-- .../__private/ui/forms/create_profile/welcome_form_screen.py | 2 +- .../ui/forms/create_profile/welcome_form_screen.scss | 2 +- tests/tui/test_create_profile.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 434e39dfd7..a09cff3951 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -4,7 +4,7 @@ from clive.__private.core.profile import Profile from clive.__private.ui.forms.create_profile.new_key_alias_form_screen import NewKeyAliasFormScreen from clive.__private.ui.forms.create_profile.profile_credentials_form_screen import ProfileCredentialsFormScreen from clive.__private.ui.forms.create_profile.set_account_form_screen import SetAccountFormScreen -from clive.__private.ui.forms.create_profile.welcome_form_screen import CreateProfileWelcomeFormScreen +from clive.__private.ui.forms.create_profile.welcome_form_screen import WelcomeFormScreen from clive.__private.ui.forms.form import ComposeFormResult, Form @@ -18,7 +18,7 @@ class CreateProfileForm(Form): def compose_form(self) -> ComposeFormResult: if not Profile.is_any_profile_saved(): - yield CreateProfileWelcomeFormScreen + yield WelcomeFormScreen yield ProfileCredentialsFormScreen yield SetAccountFormScreen yield NewKeyAliasFormScreen diff --git a/clive/__private/ui/forms/create_profile/welcome_form_screen.py b/clive/__private/ui/forms/create_profile/welcome_form_screen.py index da40880d19..eb174c0451 100644 --- a/clive/__private/ui/forms/create_profile/welcome_form_screen.py +++ b/clive/__private/ui/forms/create_profile/welcome_form_screen.py @@ -24,7 +24,7 @@ class Description(Static): """Description of the welcome screen.""" -class CreateProfileWelcomeFormScreen(BaseScreen, CreateProfileFormScreen): +class WelcomeFormScreen(BaseScreen, CreateProfileFormScreen): BINDINGS = [ Binding("f1", "help", "Help"), Binding(f"{PREVIOUS_SCREEN_BINDING_KEY},escape", "_there_is_no_back", "Nothing", show=False), diff --git a/clive/__private/ui/forms/create_profile/welcome_form_screen.scss b/clive/__private/ui/forms/create_profile/welcome_form_screen.scss index bd548c6053..1e91789ae9 100644 --- a/clive/__private/ui/forms/create_profile/welcome_form_screen.scss +++ b/clive/__private/ui/forms/create_profile/welcome_form_screen.scss @@ -1,4 +1,4 @@ -CreateProfileWelcomeFormScreen { +WelcomeFormScreen { DialogBody { max-height: 26; diff --git a/tests/tui/test_create_profile.py b/tests/tui/test_create_profile.py index 1697946cdc..3251ba2272 100644 --- a/tests/tui/test_create_profile.py +++ b/tests/tui/test_create_profile.py @@ -8,7 +8,7 @@ from clive.__private.ui.app import Clive from clive.__private.ui.forms.create_profile.new_key_alias_form_screen import NewKeyAliasFormScreen from clive.__private.ui.forms.create_profile.profile_credentials_form_screen import ProfileCredentialsFormScreen from clive.__private.ui.forms.create_profile.set_account_form_screen import SetAccountFormScreen, WorkingAccountCheckbox -from clive.__private.ui.forms.create_profile.welcome_form_screen import CreateProfileWelcomeFormScreen +from clive.__private.ui.forms.create_profile.welcome_form_screen import WelcomeFormScreen from clive.__private.ui.screens.config import Config from clive.__private.ui.screens.config.manage_key_aliases.manage_key_aliases import KeyAliasRow, ManageKeyAliases from clive.__private.ui.screens.dashboard import Dashboard @@ -59,7 +59,7 @@ async def prepared_tui_on_create_profile( async def create_profile_until_set_account( pilot: ClivePilot, profile_name: str, profile_password: str, account_name: str ) -> None: - assert_is_screen_active(pilot, CreateProfileWelcomeFormScreen) + assert_is_screen_active(pilot, WelcomeFormScreen) assert_is_new_profile(pilot) await press_and_wait_for_screen(pilot, "enter", ProfileCredentialsFormScreen) assert_is_clive_composed_input_focused( -- GitLab From 9d2f1f314ed4cec40faa2980652d662854505198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 18 Mar 2025 11:42:39 +0100 Subject: [PATCH 162/192] Provide additional explaination for ProfileNotLoadedError raised from World.profile and World.node properties --- clive/__private/core/world.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index eaedd5a99c..6a6d2c1096 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -72,7 +72,7 @@ class World: @property def profile(self) -> Profile: if self._profile is None: - raise ProfileNotLoadedError + raise ProfileNotLoadedError("World profile cannot be accessed before it is loaded.") return self._profile @property @@ -83,7 +83,9 @@ class World: def node(self) -> Node: """Node shouldn't be used for direct API calls in CLI/TUI. Instead, use commands which also handle errors.""" if self._node is None: - raise ProfileNotLoadedError + raise ProfileNotLoadedError( + "World node cannot be accessed before profile is loaded as it is profile dependent." + ) return self._node @property -- GitLab From 7ca8b1d16beffaf4a8185a771abba2163926c5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 18 Mar 2025 11:51:45 +0100 Subject: [PATCH 163/192] Update debug log so it won't crash in locked mode or when node is offline We now display cached head block number. --- clive/__private/ui/app.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 454f68544b..080b93c98b 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -298,8 +298,14 @@ class Clive(App[int]): logger.debug(f"Currently focused: {self.focused}") logger.debug(f"Screen stack: {self.screen_stack}") - response = await self.world.node.api.database.get_dynamic_global_properties() - logger.debug(f"Current block: {response.head_block_number}") + if self.world.is_profile_available: + cached_dgpo = self.world.node.cached.dynamic_global_properties_or_none + message = ( + f"Currently cached head block number: {cached_dgpo.head_block_number}" + if cached_dgpo + else "Node cache seems to be empty, no head block number available." + ) + logger.debug(message) logger.debug("=================================================") -- GitLab From 072edd091e75c0d0b3b36e5171454e73cb038c15 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Wed, 26 Feb 2025 16:46:45 +0100 Subject: [PATCH 164/192] Use world load_profile helper on unlock in tui --- clive/__private/core/world.py | 9 +++++++-- clive/__private/ui/screens/unlock/unlock.py | 13 +++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 6a6d2c1096..698e078c7f 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -26,6 +26,7 @@ from clive.exceptions import ProfileNotLoadedError if TYPE_CHECKING: from collections.abc import Iterable + from datetime import timedelta from types import TracebackType from typing_extensions import Self @@ -203,9 +204,13 @@ class World: await self.switch_profile(profile) await self.commands.sync_state_with_beekeeper() - async def load_profile(self, profile_name: str, password: str) -> None: + async def load_profile( + self, profile_name: str, password: str, *, time: timedelta | None = None, permanent: bool = True + ) -> None: assert not self.app_state.is_unlocked, "Application is already unlocked" - await self.commands.unlock(profile_name=profile_name, password=password, permanent=True) + ( + await self.commands.unlock(profile_name=profile_name, password=password, time=time, permanent=permanent) + ).raise_if_error_occurred() await self.load_profile_based_on_beekepeer() async def switch_profile(self, new_profile: Profile | None) -> None: diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index df00784f98..09886023e1 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -4,6 +4,7 @@ import contextlib from datetime import timedelta from typing import TYPE_CHECKING +from beekeepy.exceptions import InvalidPasswordError from textual import on from textual.app import InvalidModeError from textual.containers import Horizontal @@ -12,6 +13,7 @@ from textual.widgets import Button, Checkbox, Static from clive.__private.core.constants.tui.messages import PRESS_HELP_MESSAGE from clive.__private.core.profile import Profile +from clive.__private.logger import logger from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm from clive.__private.ui.get_css import get_relative_css_path @@ -113,17 +115,20 @@ class Unlock(BaseScreen): if not password_input.validate_passed() or not lock_after_time.is_valid: return - if not ( - await self.commands.unlock( + try: + await self.world.load_profile( profile_name=select_profile.value_ensure, password=password_input.value_or_error, permanent=lock_after_time.should_stay_unlocked, time=lock_after_time.lock_duration, ) - ).success: + except InvalidPasswordError: + logger.error( + f"Profile `{select_profile.value_ensure}` was not unlocked " + "because entered password is invalid, skipping switching modes" + ) return - await self.world.load_profile_based_on_beekepeer() await self.app.switch_mode("dashboard") self._remove_welcome_modes() self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) -- GitLab From 64474959ebd9a737d9f3e39dbc8e235b170f8d59 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Wed, 26 Feb 2025 17:05:36 +0100 Subject: [PATCH 165/192] Store beekeeper, session and wallets in WalletManager --- clive/__private/core/app_state.py | 4 +- clive/__private/core/beekeeper_manager.py | 108 ++++++++++++++++++ clive/__private/core/commands/commands.py | 46 ++++---- clive/__private/core/wallet_manager.py | 58 ---------- clive/__private/core/world.py | 64 ++--------- clive/__private/ui/app.py | 2 +- tests/conftest.py | 4 +- .../cli/configure/test_configure_key.py | 4 +- .../configure/test_configure_known_account.py | 4 +- .../test_configure_tracked_account.py | 6 +- .../test_configure_working_account.py | 2 +- tests/functional/cli/conftest.py | 4 +- tests/functional/commands/test_locking.py | 8 +- tests/unit/profile/test_profile_loading.py | 4 +- 14 files changed, 164 insertions(+), 154 deletions(-) create mode 100644 clive/__private/core/beekeeper_manager.py delete mode 100644 clive/__private/core/wallet_manager.py diff --git a/clive/__private/core/app_state.py b/clive/__private/core/app_state.py index 931768c6af..a18dda2df1 100644 --- a/clive/__private/core/app_state.py +++ b/clive/__private/core/app_state.py @@ -33,7 +33,7 @@ class AppState: self._is_unlocked = True if wallets: - await self.world.wallets.set_wallets(wallets) + await self.world.beekeeper_manager.set_wallets(wallets) self.world.on_going_into_unlocked_mode() logger.info("Mode switched to UNLOCKED.") @@ -42,7 +42,7 @@ class AppState: return self._is_unlocked = False - self.world.wallets.clear_wallets() + self.world.beekeeper_manager.clear_wallets() self.world.on_going_into_locked_mode(source) logger.info("Mode switched to LOCKED.") diff --git a/clive/__private/core/beekeeper_manager.py b/clive/__private/core/beekeeper_manager.py new file mode 100644 index 0000000000..22d16e9c68 --- /dev/null +++ b/clive/__private/core/beekeeper_manager.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from beekeepy import AsyncBeekeeper, AsyncSession +from beekeepy import Settings as BeekeepySettings + +from clive.__private.settings import safe_settings +from clive.exceptions import CliveError + +if TYPE_CHECKING: + from beekeepy import AsyncSession, AsyncUnlockedWallet + + from clive.__private.core.wallet_container import WalletContainer + + +class WalletsNotAvailableError(CliveError): + MESSAGE: Final[str] = "Wallets are not available. They should be available when application is unlocked." + + def __init__(self) -> None: + super().__init__(self.MESSAGE) + + +class BeekeeperManager: + def __init__(self) -> None: + self._settings = self._setup_beekeepy_settings() + self._beekeeper: AsyncBeekeeper | None = None + self._session: AsyncSession | None = None + self._wallets: WalletContainer | None = None + + async def setup(self) -> None: + self._beekeeper = await self._setup() + self._session = await self.beekeeper.session + + def teardown(self) -> None: + if self._beekeeper is not None: + self._beekeeper.teardown() + self._beekeeper = None + self._session = None + self.clear_wallets() + + def __bool__(self) -> bool: + return bool(self._wallets) + + @property + def settings(self) -> BeekeepySettings: + """Should be used only for modifying beekeeper settings before setup is done.""" + use_instead_for_modify = "beekeeper_manager.beekeeper.update_settings" + use_instead_for_read = "beekeeper_manager.beekeeper.settings" + message = ( + f"Usage impossible after setup, use `{use_instead_for_modify}` to modify " + f"or `{use_instead_for_read}` for read instead." + ) + assert self._beekeeper is None, message + return self._settings + + @property + def beekeeper(self) -> AsyncBeekeeper: + message = "Beekeeper is not available. Did you forget to use as a context manager or call `setup`?" + assert self._beekeeper is not None, message + return self._beekeeper + + @property + def session(self) -> AsyncSession: + message = "Session is not available. Did you forget to use as a context manager or call `setup`?" + assert self._session is not None, message + return self._session + + @property + def user_wallet(self) -> AsyncUnlockedWallet: + return self._content.user_wallet + + @property + def encryption_wallet(self) -> AsyncUnlockedWallet: + return self._content.encryption_wallet + + @property + def name(self) -> str: + return self._content.name + + @property + def _content(self) -> WalletContainer: + if not self._wallets: + raise WalletsNotAvailableError + return self._wallets + + async def set_wallets(self, wallets: WalletContainer) -> None: + existing_wallet_names = [wallet.name for wallet in (await self.session.wallets)] + + def assert_wallet_exists(name: str) -> None: + assert name in existing_wallet_names, f"Wallet {name} does not exists within this session" + + assert_wallet_exists(wallets.user_wallet.name) + assert_wallet_exists(wallets.encryption_wallet.name) + + self._wallets = wallets + + def clear_wallets(self) -> None: + self._wallets = None + + def _setup_beekeepy_settings(self) -> BeekeepySettings: + return safe_settings.beekeeper.settings_factory() + + async def _setup(self) -> AsyncBeekeeper: + if self.settings.http_endpoint is not None: + return await AsyncBeekeeper.remote_factory(url_or_settings=self.settings) + + return await AsyncBeekeeper.factory(settings=self.settings) diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 9597d46cbd..3f62a68996 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -137,7 +137,7 @@ class Commands(Generic[WorldT_co]): return await self.__surround_with_exception_handlers( CreateProfileWallets( app_state=self._world.app_state, - session=self._world._session_ensure, + session=self._world.beekeeper_manager.session, profile_name=profile_name if profile_name is not None else self._world.profile.name, password=password, unlock_time=unlock_time, @@ -148,8 +148,8 @@ class Commands(Generic[WorldT_co]): async def decrypt(self, *, encrypted_content: str) -> CommandWithResultWrapper[str]: return await self.__surround_with_exception_handlers( Decrypt( - unlocked_wallet=self._world.wallets.user_wallet, - unlocked_encryption_wallet=self._world.wallets.encryption_wallet, + unlocked_wallet=self._world.beekeeper_manager.user_wallet, + unlocked_encryption_wallet=self._world.beekeeper_manager.encryption_wallet, encrypted_content=encrypted_content, ) ) @@ -162,8 +162,8 @@ class Commands(Generic[WorldT_co]): async def encrypt(self, *, content: str) -> CommandWithResultWrapper[str]: return await self.__surround_with_exception_handlers( Encrypt( - unlocked_wallet=self._world.wallets.user_wallet, - unlocked_encryption_wallet=self._world.wallets.encryption_wallet, + unlocked_wallet=self._world.beekeeper_manager.user_wallet, + unlocked_encryption_wallet=self._world.beekeeper_manager.encryption_wallet, content=content, ) ) @@ -186,7 +186,7 @@ class Commands(Generic[WorldT_co]): Unlock( password=password, app_state=self._world.app_state, - session=self._world._session_ensure, + session=self._world.beekeeper_manager.session, profile_name=profile_name or self._world.profile.name, time=time, permanent=permanent, @@ -204,21 +204,23 @@ class Commands(Generic[WorldT_co]): return await self.__surround_with_exception_handlers( Lock( app_state=self._world.app_state, - session=self._world._session_ensure, + session=self._world.beekeeper_manager.session, ) ) async def get_unlocked_encryption_wallet(self) -> CommandWithResultWrapper[AsyncUnlockedWallet]: return await self.__surround_with_exception_handlers( - GetUnlockedEncryptionWallet(session=self._world._session_ensure) + GetUnlockedEncryptionWallet(session=self._world.beekeeper_manager.session) ) async def get_unlocked_user_wallet(self) -> CommandWithResultWrapper[AsyncUnlockedWallet]: - return await self.__surround_with_exception_handlers(GetUnlockedUserWallet(session=self._world._session_ensure)) + return await self.__surround_with_exception_handlers( + GetUnlockedUserWallet(session=self._world.beekeeper_manager.session) + ) async def get_wallet_names(self, filter_by_status: WalletStatus = "all") -> CommandWithResultWrapper[list[str]]: return await self.__surround_with_exception_handlers( - GetWalletNames(session=self._world._session_ensure, filter_by_status=filter_by_status) + GetWalletNames(session=self._world.beekeeper_manager.session, filter_by_status=filter_by_status) ) async def is_password_valid(self, *, password: str) -> CommandWithResultWrapper[bool]: @@ -240,7 +242,7 @@ class Commands(Generic[WorldT_co]): """ return await self.__surround_with_exception_handlers( IsWalletUnlocked( - wallet=wallet if wallet is not None else self._world.wallets.user_wallet, + wallet=wallet if wallet is not None else self._world.beekeeper_manager.user_wallet, ) ) @@ -254,7 +256,7 @@ class Commands(Generic[WorldT_co]): permanent: Whether to keep the wallets unlocked permanently. Will take precedence when `time` is also set. """ return await self.__surround_with_exception_handlers( - SetTimeout(session=self._world._session_ensure, time=time, permanent=permanent) + SetTimeout(session=self._world.beekeeper_manager.session, time=time, permanent=permanent) ) async def perform_actions_on_transaction( # noqa: PLR0913 @@ -274,7 +276,7 @@ class Commands(Generic[WorldT_co]): content=content, app_state=self._world.app_state, node=self._world.node, - unlocked_wallet=self._world.wallets.user_wallet if sign_key else None, + unlocked_wallet=self._world.beekeeper_manager.user_wallet if sign_key else None, sign_key=sign_key, already_signed_mode=already_signed_mode, force_unsign=force_unsign, @@ -323,7 +325,7 @@ class Commands(Generic[WorldT_co]): ) -> CommandWithResultWrapper[Transaction]: return await self.__surround_with_exception_handlers( Sign( - unlocked_wallet=self._world.wallets.user_wallet, + unlocked_wallet=self._world.beekeeper_manager.user_wallet, transaction=transaction, key=sign_with, chain_id=chain_id or await self._world.node.chain_id, @@ -354,7 +356,7 @@ class Commands(Generic[WorldT_co]): async def import_key(self, *, key_to_import: PrivateKeyAliased) -> CommandWithResultWrapper[PublicKeyAliased]: return await self.__surround_with_exception_handlers( ImportKey( - unlocked_wallet=self._world.wallets.user_wallet, + unlocked_wallet=self._world.beekeeper_manager.user_wallet, key_to_import=key_to_import, ) ) @@ -362,7 +364,7 @@ class Commands(Generic[WorldT_co]): async def remove_key(self, *, key_to_remove: PublicKey) -> CommandWrapper: return await self.__surround_with_exception_handlers( RemoveKey( - unlocked_wallet=self._world.wallets.user_wallet, + unlocked_wallet=self._world.beekeeper_manager.user_wallet, key_to_remove=key_to_remove, ) ) @@ -377,7 +379,7 @@ class Commands(Generic[WorldT_co]): """ return await self.__surround_with_exception_handlers( SyncDataWithBeekeeper( - unlocked_wallet=self._world.wallets.user_wallet, + unlocked_wallet=self._world.beekeeper_manager.user_wallet, profile=profile if profile is not None else self._world.profile, ) ) @@ -385,7 +387,7 @@ class Commands(Generic[WorldT_co]): async def sync_state_with_beekeeper(self, source: LockSource = "unknown") -> CommandWrapper: return await self.__surround_with_exception_handlers( SyncStateWithBeekeeper( - session=self._world._session_ensure, + session=self._world.beekeeper_manager.session, app_state=self._world.app_state, source=source, ) @@ -505,8 +507,8 @@ class Commands(Generic[WorldT_co]): return await self.__surround_with_exception_handlers( SaveProfile( profile=profile, - unlocked_wallet=self._world.wallets.user_wallet, - unlocked_encryption_wallet=self._world.wallets.encryption_wallet, + unlocked_wallet=self._world.beekeeper_manager.user_wallet, + unlocked_encryption_wallet=self._world.beekeeper_manager.encryption_wallet, ) ) @@ -514,8 +516,8 @@ class Commands(Generic[WorldT_co]): return await self.__surround_with_exception_handlers( LoadProfile( profile_name=profile_name, - unlocked_wallet=self._world.wallets.user_wallet, - unlocked_encryption_wallet=self._world.wallets.encryption_wallet, + unlocked_wallet=self._world.beekeeper_manager.user_wallet, + unlocked_encryption_wallet=self._world.beekeeper_manager.encryption_wallet, ) ) diff --git a/clive/__private/core/wallet_manager.py b/clive/__private/core/wallet_manager.py deleted file mode 100644 index a998d5aa6b..0000000000 --- a/clive/__private/core/wallet_manager.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Final - -from clive.exceptions import CliveError - -if TYPE_CHECKING: - from beekeepy import AsyncSession, AsyncUnlockedWallet - - from clive.__private.core.wallet_container import WalletContainer - - -class WalletsNotAvailableError(CliveError): - MESSAGE: Final[str] = "Wallets are not available. They should be available when application is unlocked." - - def __init__(self) -> None: - super().__init__(self.MESSAGE) - - -class WalletManager: - def __init__(self, session: AsyncSession) -> None: - self._session = session - self._wallets: WalletContainer | None = None - - def __bool__(self) -> bool: - return bool(self._wallets) - - @property - def user_wallet(self) -> AsyncUnlockedWallet: - return self._content.user_wallet - - @property - def encryption_wallet(self) -> AsyncUnlockedWallet: - return self._content.encryption_wallet - - @property - def name(self) -> str: - return self._content.name - - @property - def _content(self) -> WalletContainer: - if not self._wallets: - raise WalletsNotAvailableError - return self._wallets - - async def set_wallets(self, wallets: WalletContainer) -> None: - existing_wallet_names = [wallet.name for wallet in (await self._session.wallets)] - - def assert_wallet_exists(name: str) -> None: - assert name in existing_wallet_names, f"Wallet {name} does not exists within this session" - - assert_wallet_exists(wallets.user_wallet.name) - assert_wallet_exists(wallets.encryption_wallet.name) - - self._wallets = wallets - - def clear_wallets(self) -> None: - self._wallets = None diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 698e078c7f..33e59a387d 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -3,21 +3,18 @@ from __future__ import annotations from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any, AsyncGenerator, cast -from beekeepy import AsyncBeekeeper, AsyncSession -from beekeepy import Settings as BeekeepySettings from textual.reactive import var from typing_extensions import override from clive.__private.cli.exceptions import CLINoProfileUnlockedError from clive.__private.core.app_state import AppState, LockSource +from clive.__private.core.beekeeper_manager import BeekeeperManager from clive.__private.core.commands.commands import CLICommands, Commands, TUICommands from clive.__private.core.commands.get_unlocked_user_wallet import NoProfileUnlockedError from clive.__private.core.known_exchanges import KnownExchanges from clive.__private.core.node import Node from clive.__private.core.profile import Profile from clive.__private.core.wallet_container import WalletContainer -from clive.__private.core.wallet_manager import WalletManager -from clive.__private.settings import safe_settings from clive.__private.ui.clive_dom_node import CliveDOMNode from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm from clive.__private.ui.screens.dashboard import Dashboard @@ -29,6 +26,7 @@ if TYPE_CHECKING: from datetime import timedelta from types import TracebackType + from beekeepy import AsyncBeekeeper from typing_extensions import Self from clive.__private.core.accounts.accounts import WatchedAccount, WorkingAccount @@ -53,10 +51,7 @@ class World: self._known_exchanges = KnownExchanges() self._app_state = AppState(self) self._commands = self._setup_commands() - self._beekeeper_settings = self._setup_beekeepy_settings() - self._beekeeper: AsyncBeekeeper | None = None - self._session: AsyncSession | None = None - self._wallets: WalletManager | None = None + self._beekeeper_manager = BeekeeperManager() self._node: Node | None = None self._is_during_setup = False @@ -102,10 +97,8 @@ class World: return self._known_exchanges @property - def wallets(self) -> WalletManager: - message = "Wallets are not available. Did you forget to use as a context manager or call `setup`?" - assert self._wallets is not None, message - return self._wallets + def beekeeper_manager(self) -> BeekeeperManager: + return self._beekeeper_manager @property def beekeeper(self) -> AsyncBeekeeper: @@ -114,27 +107,7 @@ class World: Same applies for other beekeepy objects like session or wallet. """ - message = "Beekeeper is not available. Did you forget to use as a context manager or call `setup`?" - assert self._beekeeper is not None, message - return self._beekeeper - - @property - def beekeeper_settings(self) -> BeekeepySettings: - """Should be used only for modifying beekeeper settings before setup is done.""" - use_instead_for_modify = "world.beekeeper.update_settings" - use_instead_for_read = "world.beekeeper.settings" - message = ( - f"Usage impossible after setup, use `{use_instead_for_modify}` to modify " - f"or `{use_instead_for_read}` for read instead." - ) - assert self._beekeeper is None, message - return self._beekeeper_settings - - @property - def _session_ensure(self) -> AsyncSession: - message = "Session is not available. Did you forget to use as a context manager or call `setup`?" - assert self._session is not None, message - return self._session + return self._beekeeper_manager.beekeeper @property def _should_save_profile_on_close(self) -> bool: @@ -142,9 +115,7 @@ class World: async def setup(self) -> Self: async with self._during_setup(): - self._beekeeper = await self._setup_beekeeper() - self._session = await self.beekeeper.session - self._wallets = WalletManager(self._session) + await self._beekeeper_manager.setup() return self async def close(self) -> None: @@ -153,17 +124,12 @@ class World: await self.commands.save_profile() if self._node is not None: self._node.teardown() - if self._beekeeper is not None: - self._beekeeper.teardown() + self._beekeeper_manager.teardown() self.app_state.lock() - self._beekeeper_settings = self._setup_beekeepy_settings() self._profile = None self._node = None - self._beekeeper = None - self._session = None - self._wallets = None async def create_new_profile( self, @@ -197,9 +163,9 @@ class World: async def load_profile_based_on_beekepeer(self) -> None: unlocked_user_wallet = (await self.commands.get_unlocked_user_wallet()).result_or_raise unlocked_encryption_wallet = (await self.commands.get_unlocked_encryption_wallet()).result_or_raise - await self.wallets.set_wallets(WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet)) + await self.beekeeper_manager.set_wallets(WalletContainer(unlocked_user_wallet, unlocked_encryption_wallet)) - profile_name = self.wallets.name + profile_name = self.beekeeper_manager.name profile = (await self.commands.load_profile(profile_name=profile_name)).result_or_raise await self.switch_profile(profile) await self.commands.sync_state_with_beekeeper() @@ -257,12 +223,6 @@ class World: def _setup_commands(self) -> Commands[World]: return Commands(self) - async def _setup_beekeeper(self) -> AsyncBeekeeper: - if self.beekeeper_settings.http_endpoint is not None: - return await AsyncBeekeeper.remote_factory(url_or_settings=self.beekeeper_settings) - - return await AsyncBeekeeper.factory(settings=self.beekeeper_settings) - async def _update_node(self) -> None: if self._profile is None: if self._node is not None: @@ -275,10 +235,6 @@ class World: else: self._node.change_related_profile(self._profile) - @classmethod - def _setup_beekeepy_settings(cls) -> BeekeepySettings: - return safe_settings.beekeeper.settings_factory() - class TUIWorld(World, CliveDOMNode): profile_reactive: Profile = var(None, init=False) # type: ignore[assignment] diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 080b93c98b..df68a9728c 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -290,7 +290,7 @@ class Clive(App[int]): @work(name="beekeeper wallet lock status update worker") async def update_wallet_lock_status_from_beekeeper(self) -> None: - if self.world.wallets: + if self.world._beekeeper_manager: await self.world.commands.sync_state_with_beekeeper("beekeeper_monitoring_thread") async def __debug_log(self) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 5fb0c04c4a..4ac8c06f81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -177,7 +177,7 @@ def setup_wallets(world: World) -> SetupWalletsFactory: for wallet in wallets: await CreateProfileWallets( app_state=world.app_state, - session=world._session_ensure, + session=world.beekeeper_manager.session, profile_name=wallet.name, password=wallet.password, ).execute() @@ -185,7 +185,7 @@ def setup_wallets(world: World) -> SetupWalletsFactory: if import_keys: for pairs in wallet.keys.pairs: await ImportKey( - unlocked_wallet=world.wallets.user_wallet, + unlocked_wallet=world.beekeeper_manager.user_wallet, key_to_import=pairs.private_key, ).execute() return wallets diff --git a/tests/functional/cli/configure/test_configure_key.py b/tests/functional/cli/configure/test_configure_key.py index 607f83374c..e3da5b5eb5 100644 --- a/tests/functional/cli/configure/test_configure_key.py +++ b/tests/functional/cli/configure/test_configure_key.py @@ -27,7 +27,7 @@ async def test_configure_key_add(cli_tester: CLITester) -> None: """Check clive configure key add command.""" # ARRANGE pk = PrivateKey.create() - unlocked_wallet = cli_tester.world.wallets.user_wallet + unlocked_wallet = cli_tester.world.beekeeper_manager.user_wallet await assert_key_exists(unlocked_wallet, pk, should_exists=False) # ACT @@ -55,7 +55,7 @@ async def test_configure_key_remove(cli_tester: CLITester, *, from_beekeeper: bo """Check clive configure key remove command.""" # ARRANGE pk = PrivateKey.create() - unlocked_wallet = cli_tester.world.wallets.user_wallet + unlocked_wallet = cli_tester.world.beekeeper_manager.user_wallet await assert_key_exists(unlocked_wallet, pk, should_exists=False) cli_tester.configure_key_add(key=pk.value, alias="key") await assert_key_exists(unlocked_wallet, pk, should_exists=True) diff --git a/tests/functional/cli/configure/test_configure_known_account.py b/tests/functional/cli/configure/test_configure_known_account.py index ca25439bc9..cd0397009c 100644 --- a/tests/functional/cli/configure/test_configure_known_account.py +++ b/tests/functional/cli/configure/test_configure_known_account.py @@ -21,7 +21,7 @@ async def test_configure_known_account_add(cli_tester: CLITester) -> None: """Check clive configure known-account add command.""" # ARRANGE account_to_add = ALT_WORKING_ACCOUNT1_NAME - profile_checker = ProfileAccountsChecker(cli_tester.world.profile.name, cli_tester.world.wallets._content) + profile_checker = ProfileAccountsChecker(cli_tester.world.profile.name, cli_tester.world.beekeeper_manager._content) # ACT cli_tester.configure_known_account_add(account_name=account_to_add) @@ -45,7 +45,7 @@ async def test_configure_known_account_add_already_known_account(cli_tester: CLI async def test_configure_known_account_remove(cli_tester: CLITester) -> None: """Check clive configure known-account remove command.""" # ARRANGE - profile_checker = ProfileAccountsChecker(cli_tester.world.profile.name, cli_tester.world.wallets._content) + profile_checker = ProfileAccountsChecker(cli_tester.world.profile.name, cli_tester.world.beekeeper_manager._content) cli_tester.configure_known_account_add(account_name=ACCOUNT_TO_REMOVE) await profile_checker.assert_in_known_accounts(account_names=[ACCOUNT_TO_REMOVE]) diff --git a/tests/functional/cli/configure/test_configure_tracked_account.py b/tests/functional/cli/configure/test_configure_tracked_account.py index 207dae1df6..4d133e2101 100644 --- a/tests/functional/cli/configure/test_configure_tracked_account.py +++ b/tests/functional/cli/configure/test_configure_tracked_account.py @@ -24,7 +24,7 @@ async def test_configure_tracked_account_add(cli_tester: CLITester) -> None: # ARRANGE account_to_add = ALT_WORKING_ACCOUNT1_NAME profile_name = cli_tester.world.profile.name - profile_checker = ProfileAccountsChecker(profile_name, cli_tester.world.wallets._content) + profile_checker = ProfileAccountsChecker(profile_name, cli_tester.world.beekeeper_manager._content) # ACT cli_tester.configure_tracked_account_add(account_name=account_to_add) @@ -52,7 +52,7 @@ async def test_configure_tracked_account_remove(cli_tester: CLITester) -> None: """Check clive configure tracked-account remove command.""" # ARRANGE profile_name = cli_tester.world.profile.name - profile_checker = ProfileAccountsChecker(profile_name, cli_tester.world.wallets._content) + profile_checker = ProfileAccountsChecker(profile_name, cli_tester.world.beekeeper_manager._content) # ACT await profile_checker.assert_in_tracked_accounts(account_names=[ACCOUNT_TO_REMOVE]) @@ -67,7 +67,7 @@ async def test_configure_tracked_account_remove_with_already_removed_account(cli # ARRANGE message = f"Account {ACCOUNT_TO_REMOVE} not found." profile_name = cli_tester.world.profile.name - profile_checker = ProfileAccountsChecker(profile_name, cli_tester.world.wallets._content) + profile_checker = ProfileAccountsChecker(profile_name, cli_tester.world.beekeeper_manager._content) # ACT await profile_checker.assert_in_tracked_accounts(account_names=[ACCOUNT_TO_REMOVE]) diff --git a/tests/functional/cli/configure/test_configure_working_account.py b/tests/functional/cli/configure/test_configure_working_account.py index 7393a62000..c594393a92 100644 --- a/tests/functional/cli/configure/test_configure_working_account.py +++ b/tests/functional/cli/configure/test_configure_working_account.py @@ -16,7 +16,7 @@ async def test_configure_working_account_switch(cli_tester: CLITester) -> None: """Check clive configure working-account switch command.""" # ARRANGE profile_name = cli_tester.world.profile.name - profile_account_checker = ProfileAccountsChecker(profile_name, cli_tester.world.wallets._content) + profile_account_checker = ProfileAccountsChecker(profile_name, cli_tester.world.beekeeper_manager._content) account_to_switch = WATCHED_ACCOUNTS_NAMES[0] # ACT diff --git a/tests/functional/cli/conftest.py b/tests/functional/cli/conftest.py index b2142bceff..537fd6fc3a 100644 --- a/tests/functional/cli/conftest.py +++ b/tests/functional/cli/conftest.py @@ -49,8 +49,8 @@ async def world_cli(beekeeper_local: AsyncBeekeeper) -> AsyncGenerator[World]: token = await (await beekeeper_local.session).token world = World() - world.beekeeper_settings.http_endpoint = beekeeper_local.http_endpoint - world.beekeeper_settings.use_existing_session = token + world.beekeeper_manager.settings.http_endpoint = beekeeper_local.http_endpoint + world.beekeeper_manager.settings.use_existing_session = token async with world as world_cm: yield world_cm diff --git a/tests/functional/commands/test_locking.py b/tests/functional/commands/test_locking.py index 0af569c215..cc087ecca6 100644 --- a/tests/functional/commands/test_locking.py +++ b/tests/functional/commands/test_locking.py @@ -22,7 +22,7 @@ async def test_unlock( wallet_password: str, ) -> None: # ARRANGE - await world._session_ensure.lock_all() + await world.beekeeper_manager.session.lock_all() # ACT await world.commands.unlock(password=wallet_password) @@ -36,7 +36,7 @@ async def test_unlock_non_existing_wallets(world: clive.World, prepare_profile_w with pytest.raises(CannotRecoverWalletsError): await Unlock( app_state=world.app_state, - session=world._session_ensure, + session=world.beekeeper_manager.session, profile_name="blabla", password="blabla", ).execute() @@ -135,7 +135,9 @@ async def test_lock_after_given_time( await asyncio.sleep(time_to_sleep.total_seconds() + 1) # extra second for notification # ASSERT - is_wallet_unlocked_in_beekeeper = await IsWalletUnlocked(wallet=world.wallets.user_wallet).execute_with_result() + is_wallet_unlocked_in_beekeeper = await IsWalletUnlocked( + wallet=world.beekeeper_manager.user_wallet + ).execute_with_result() assert not is_wallet_unlocked_in_beekeeper, "Wallet should be locked in beekeeper" await world.commands.sync_state_with_beekeeper() diff --git a/tests/unit/profile/test_profile_loading.py b/tests/unit/profile/test_profile_loading.py index 3a9028aa6c..bd2dcd94b3 100644 --- a/tests/unit/profile/test_profile_loading.py +++ b/tests/unit/profile/test_profile_loading.py @@ -59,8 +59,8 @@ async def test_if_unlocked_profile_is_loaded_other_was_saved(beekeeper: AsyncBee token = await (await beekeeper.session).token cli_world = CLIWorld() - cli_world.beekeeper_settings.http_endpoint = beekeeper.http_endpoint - cli_world.beekeeper_settings.use_existing_session = token + cli_world.beekeeper_manager.settings.http_endpoint = beekeeper.http_endpoint + cli_world.beekeeper_manager.settings.use_existing_session = token async with cli_world as world_cm: loaded_profile_name = world_cm.profile.name -- GitLab From 543eca54d95168fcc7d5c9dc813249c1c3251430 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Tue, 18 Mar 2025 14:46:33 +0100 Subject: [PATCH 166/192] Remove property World.beepeeper as it is owned by BeekeeperManager --- clive/__private/core/beekeeper_manager.py | 5 +++++ clive/__private/core/commands/commands.py | 2 +- clive/__private/core/world.py | 10 ---------- tests/conftest.py | 2 +- .../functional/cli/process/test_process_transaction.py | 2 +- tests/functional/cli/test_locking.py | 4 ++-- tests/functional/commands/test_locking.py | 5 ++++- 7 files changed, 14 insertions(+), 16 deletions(-) diff --git a/clive/__private/core/beekeeper_manager.py b/clive/__private/core/beekeeper_manager.py index 22d16e9c68..6c1f3a05e6 100644 --- a/clive/__private/core/beekeeper_manager.py +++ b/clive/__private/core/beekeeper_manager.py @@ -56,6 +56,11 @@ class BeekeeperManager: @property def beekeeper(self) -> AsyncBeekeeper: + """ + Beekeeper shouldn't be used for API calls in CLI/TUI. Instead, use commands which also handle errors. + + Same applies for other beekeepy objects like session or wallet. + """ message = "Beekeeper is not available. Did you forget to use as a context manager or call `setup`?" assert self._beekeeper is not None, message return self._beekeeper diff --git a/clive/__private/core/commands/commands.py b/clive/__private/core/commands/commands.py index 3f62a68996..9364209bb4 100644 --- a/clive/__private/core/commands/commands.py +++ b/clive/__private/core/commands/commands.py @@ -226,7 +226,7 @@ class Commands(Generic[WorldT_co]): async def is_password_valid(self, *, password: str) -> CommandWithResultWrapper[bool]: return await self.__surround_with_exception_handlers( IsPasswordValid( - beekeeper=self._world.beekeeper, + beekeeper=self._world.beekeeper_manager.beekeeper, wallet_name=self._world.profile.name, password=password, ) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 33e59a387d..3a05ad97d1 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -26,7 +26,6 @@ if TYPE_CHECKING: from datetime import timedelta from types import TracebackType - from beekeepy import AsyncBeekeeper from typing_extensions import Self from clive.__private.core.accounts.accounts import WatchedAccount, WorkingAccount @@ -100,15 +99,6 @@ class World: def beekeeper_manager(self) -> BeekeeperManager: return self._beekeeper_manager - @property - def beekeeper(self) -> AsyncBeekeeper: - """ - Beekeeper shouldn't be used for API calls in CLI/TUI. Instead, use commands which also handle errors. - - Same applies for other beekeepy objects like session or wallet. - """ - return self._beekeeper_manager.beekeeper - @property def _should_save_profile_on_close(self) -> bool: return self._profile is not None diff --git a/tests/conftest.py b/tests/conftest.py index 4ac8c06f81..10b905e83b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -163,7 +163,7 @@ async def init_node_extra_apis( @pytest.fixture def beekeeper(world: World) -> AsyncBeekeeper: - return world.beekeeper + return world.beekeeper_manager.beekeeper @pytest.fixture diff --git a/tests/functional/cli/process/test_process_transaction.py b/tests/functional/cli/process/test_process_transaction.py index e86641fef6..e90730c2db 100644 --- a/tests/functional/cli/process/test_process_transaction.py +++ b/tests/functional/cli/process/test_process_transaction.py @@ -126,7 +126,7 @@ async def test_negative_process_transaction_in_locked( broadcast=False, save_file=trx_file(tmp_path), ) - beekeeper = cli_tester.world.beekeeper + beekeeper = cli_tester.world.beekeeper_manager.beekeeper await (await beekeeper.session).lock_all() cli_tester.world.profile.skip_saving() # cannot save profile when it is locked because encryption is not possible diff --git a/tests/functional/cli/test_locking.py b/tests/functional/cli/test_locking.py index cbec6023e5..249382c545 100644 --- a/tests/functional/cli/test_locking.py +++ b/tests/functional/cli/test_locking.py @@ -79,7 +79,7 @@ async def test_lock(cli_tester: CLITester) -> None: cli_tester.world.profile.skip_saving() # cannot save profile when it is locked because encryption is not possible # ASSERT - await assert_wallets_locked(cli_tester.world.beekeeper) + await assert_wallets_locked(cli_tester.world.beekeeper_manager.beekeeper) async def test_unlock_one_profile(cli_tester_locked: CLITester) -> None: @@ -87,7 +87,7 @@ async def test_unlock_one_profile(cli_tester_locked: CLITester) -> None: cli_tester_locked.unlock(profile_name=WORKING_ACCOUNT_NAME, password_stdin=WORKING_ACCOUNT_PASSWORD) # ASSERT - await assert_wallet_unlocked(cli_tester_locked.world.beekeeper, WORKING_ACCOUNT_NAME) + await assert_wallet_unlocked(cli_tester_locked.world.beekeeper_manager.beekeeper, WORKING_ACCOUNT_NAME) async def test_second_profile(cli_tester_locked_with_second_profile: CLITester) -> None: diff --git a/tests/functional/commands/test_locking.py b/tests/functional/commands/test_locking.py index cc087ecca6..352c638f95 100644 --- a/tests/functional/commands/test_locking.py +++ b/tests/functional/commands/test_locking.py @@ -12,6 +12,8 @@ from clive.__private.core.commands.unlock import Unlock from clive.__private.core.encryption import EncryptionService if TYPE_CHECKING: + from beekeepy import AsyncBeekeeper + import clive from clive.__private.core.profile import Profile @@ -46,6 +48,7 @@ async def test_unlock_non_existing_wallets(world: clive.World, prepare_profile_w async def test_unlock_recovers_missing_wallet( world: clive.World, prepare_profile_with_wallet: Profile, + beekeeper: AsyncBeekeeper, wallet_password: str, wallet_type: Literal["user_wallet", "encryption_wallet"], ) -> None: @@ -55,7 +58,7 @@ async def test_unlock_recovers_missing_wallet( encryption_wallet = (await world.commands.get_unlocked_encryption_wallet()).result_or_raise encryption_keys_before = await encryption_wallet.public_keys - beekeeper_working_directory = world.beekeeper.settings.working_directory + beekeeper_working_directory = beekeeper.settings.working_directory assert beekeeper_working_directory is not None, "Beekeeper working directory should be set" wallet_filenames = { -- GitLab From ac9f633011f0d79cc59fff04c14f985478a4c5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 28 Feb 2025 09:22:25 +0100 Subject: [PATCH 167/192] CliveActionDialog and CliveInfoDialog should be abstract --- clive/__private/ui/dialogs/clive_base_dialogs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/clive/__private/ui/dialogs/clive_base_dialogs.py b/clive/__private/ui/dialogs/clive_base_dialogs.py index fa5b5444ca..c8040ffddc 100644 --- a/clive/__private/ui/dialogs/clive_base_dialogs.py +++ b/clive/__private/ui/dialogs/clive_base_dialogs.py @@ -1,6 +1,6 @@ from __future__ import annotations -from abc import abstractmethod +from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Literal from textual import on @@ -102,7 +102,7 @@ class CliveBaseDialog(ModalScreen[ScreenResultT], CliveWidget, AbstractClassMess """Yield all the content with buttons.""" -class CliveActionDialog(CliveBaseDialog[ScreenResultT]): +class CliveActionDialog(CliveBaseDialog[ScreenResultT], ABC): BINDINGS = [Binding("escape", "cancel", "Quit")] class Confirmed(Message): @@ -133,7 +133,7 @@ class CliveActionDialog(CliveBaseDialog[ScreenResultT]): self.app.pop_screen() -class CliveInfoDialog(CliveBaseDialog[ScreenResultT]): +class CliveInfoDialog(CliveBaseDialog[ScreenResultT], ABC): BINDINGS = [Binding("escape", "close", "Quit")] def create_buttons_content(self) -> ComposeResult: -- GitLab From 2f5344abe86f191073aa467bfc146af5a663cd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 28 Feb 2025 09:18:45 +0100 Subject: [PATCH 168/192] Fix binding descriptions in dialogs --- clive/__private/ui/dialogs/add_tracked_account_dialog.py | 2 +- clive/__private/ui/dialogs/clive_base_dialogs.py | 4 ++-- clive/__private/ui/dialogs/switch_working_account_dialog.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/clive/__private/ui/dialogs/add_tracked_account_dialog.py b/clive/__private/ui/dialogs/add_tracked_account_dialog.py index 6594182ed8..9602ce2c86 100644 --- a/clive/__private/ui/dialogs/add_tracked_account_dialog.py +++ b/clive/__private/ui/dialogs/add_tracked_account_dialog.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: class AddTrackedAccountDialog(CliveActionDialog): CSS_PATH = [get_relative_css_path(__file__)] - BINDINGS = [Binding("escape,f4", "cancel", "Quit")] + BINDINGS = [Binding("escape,f4", "cancel", "Cancel")] AUTO_FOCUS = "Input" def __init__(self) -> None: diff --git a/clive/__private/ui/dialogs/clive_base_dialogs.py b/clive/__private/ui/dialogs/clive_base_dialogs.py index c8040ffddc..7fc92c67a7 100644 --- a/clive/__private/ui/dialogs/clive_base_dialogs.py +++ b/clive/__private/ui/dialogs/clive_base_dialogs.py @@ -103,7 +103,7 @@ class CliveBaseDialog(ModalScreen[ScreenResultT], CliveWidget, AbstractClassMess class CliveActionDialog(CliveBaseDialog[ScreenResultT], ABC): - BINDINGS = [Binding("escape", "cancel", "Quit")] + BINDINGS = [Binding("escape", "cancel", "Cancel")] class Confirmed(Message): """Inform the dialog that it should be confirmed.""" @@ -134,7 +134,7 @@ class CliveActionDialog(CliveBaseDialog[ScreenResultT], ABC): class CliveInfoDialog(CliveBaseDialog[ScreenResultT], ABC): - BINDINGS = [Binding("escape", "close", "Quit")] + BINDINGS = [Binding("escape", "close", "Close")] def create_buttons_content(self) -> ComposeResult: yield CloseOneLineButton() diff --git a/clive/__private/ui/dialogs/switch_working_account_dialog.py b/clive/__private/ui/dialogs/switch_working_account_dialog.py index 2f5c6ea6fd..ad828ae7b0 100644 --- a/clive/__private/ui/dialogs/switch_working_account_dialog.py +++ b/clive/__private/ui/dialogs/switch_working_account_dialog.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: class SwitchWorkingAccountDialog(CliveActionDialog): CSS_PATH = [get_relative_css_path(__file__)] - BINDINGS = [Binding("escape,f3", "cancel", "Quit")] + BINDINGS = [Binding("escape,f3", "cancel", "Cancel")] AUTO_FOCUS = "RadioSet" def __init__(self) -> None: -- GitLab From a4a96a43deb787aa6bc0e3cbc6d70df5b566ac21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 28 Feb 2025 09:19:47 +0100 Subject: [PATCH 169/192] CliveInfoDialog should explicitly have no return --- clive/__private/ui/dialogs/clive_base_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clive/__private/ui/dialogs/clive_base_dialogs.py b/clive/__private/ui/dialogs/clive_base_dialogs.py index 7fc92c67a7..c4e638cd6e 100644 --- a/clive/__private/ui/dialogs/clive_base_dialogs.py +++ b/clive/__private/ui/dialogs/clive_base_dialogs.py @@ -133,7 +133,7 @@ class CliveActionDialog(CliveBaseDialog[ScreenResultT], ABC): self.app.pop_screen() -class CliveInfoDialog(CliveBaseDialog[ScreenResultT], ABC): +class CliveInfoDialog(CliveBaseDialog[None], ABC): BINDINGS = [Binding("escape", "close", "Close")] def create_buttons_content(self) -> ComposeResult: -- GitLab From 576148bc043a3c8c9d60657c1a9d0c4dbb5664bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 28 Feb 2025 09:26:57 +0100 Subject: [PATCH 170/192] Dialogs should dismiss instead of pop_screen --- clive/__private/ui/dialogs/add_tracked_account_dialog.py | 2 +- clive/__private/ui/dialogs/clive_base_dialogs.py | 4 ++-- clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py | 2 +- clive/__private/ui/dialogs/switch_working_account_dialog.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/clive/__private/ui/dialogs/add_tracked_account_dialog.py b/clive/__private/ui/dialogs/add_tracked_account_dialog.py index 9602ce2c86..5069b64782 100644 --- a/clive/__private/ui/dialogs/add_tracked_account_dialog.py +++ b/clive/__private/ui/dialogs/add_tracked_account_dialog.py @@ -31,4 +31,4 @@ class AddTrackedAccountDialog(CliveActionDialog): async def save_account(self) -> None: is_account_saved = await self._add_account_container.save_account() if is_account_saved: - self.app.pop_screen() + self.dismiss() diff --git a/clive/__private/ui/dialogs/clive_base_dialogs.py b/clive/__private/ui/dialogs/clive_base_dialogs.py index c4e638cd6e..e8a44517e2 100644 --- a/clive/__private/ui/dialogs/clive_base_dialogs.py +++ b/clive/__private/ui/dialogs/clive_base_dialogs.py @@ -130,7 +130,7 @@ class CliveActionDialog(CliveBaseDialog[ScreenResultT], ABC): @on(CancelOneLineButton.Pressed) def action_cancel(self) -> None: - self.app.pop_screen() + self.dismiss() class CliveInfoDialog(CliveBaseDialog[None], ABC): @@ -141,4 +141,4 @@ class CliveInfoDialog(CliveBaseDialog[None], ABC): @on(CloseOneLineButton.Pressed) def action_close(self) -> None: - self.app.pop_screen() + self.dismiss() diff --git a/clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py b/clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py index 74303a56a7..d6b1e2735c 100644 --- a/clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py +++ b/clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py @@ -31,4 +31,4 @@ class MarkAlarmAsHarmlessDialog(ConfirmActionDialog): self._alarm.is_harmless = True self.notify(f"Alarm `{self.alarm_info}` was marked as harmless.") self.app.trigger_profile_watchers() - self.app.pop_screen() + self.dismiss() diff --git a/clive/__private/ui/dialogs/switch_working_account_dialog.py b/clive/__private/ui/dialogs/switch_working_account_dialog.py index ad828ae7b0..28442324b1 100644 --- a/clive/__private/ui/dialogs/switch_working_account_dialog.py +++ b/clive/__private/ui/dialogs/switch_working_account_dialog.py @@ -32,4 +32,4 @@ class SwitchWorkingAccountDialog(CliveActionDialog): @on(CliveActionDialog.Confirmed) def confirm_selected_working_account(self) -> None: self._switch_working_account_container.confirm_selected_working_account() - self.app.pop_screen() + self.dismiss() -- GitLab From 4cd457aff7216e5068df0268baa6db651973698e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 28 Feb 2025 10:35:02 +0100 Subject: [PATCH 171/192] Refactor/fix dialogs - dismiss should be called in CliveActionDialog, Confirmed message should be sent only when conditions are met and actions are done (eg. SwitchNodeAddressDialog, RemoveKeyAliasDialog) --- .../ui/dialogs/add_tracked_account_dialog.py | 7 +-- .../ui/dialogs/clive_base_dialogs.py | 49 ++++++++++++++++--- .../ui/dialogs/confirm_action_dialog.py | 10 +--- .../dialogs/mark_alarm_as_harmless_dialog.py | 8 ++- .../ui/dialogs/remove_key_alias_dialog.py | 6 +-- .../ui/dialogs/switch_node_address_dialog.py | 12 +---- .../dialogs/switch_working_account_dialog.py | 6 +-- 7 files changed, 55 insertions(+), 43 deletions(-) diff --git a/clive/__private/ui/dialogs/add_tracked_account_dialog.py b/clive/__private/ui/dialogs/add_tracked_account_dialog.py index 5069b64782..d9add8f215 100644 --- a/clive/__private/ui/dialogs/add_tracked_account_dialog.py +++ b/clive/__private/ui/dialogs/add_tracked_account_dialog.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from textual import on from textual.binding import Binding from clive.__private.ui.dialogs.clive_base_dialogs import CliveActionDialog @@ -27,8 +26,6 @@ class AddTrackedAccountDialog(CliveActionDialog): yield AccountManagementReference() yield self._add_account_container - @on(CliveActionDialog.Confirmed) - async def save_account(self) -> None: + async def _perform_confirmation(self) -> bool: is_account_saved = await self._add_account_container.save_account() - if is_account_saved: - self.dismiss() + return is_account_saved # noqa: RET504 diff --git a/clive/__private/ui/dialogs/clive_base_dialogs.py b/clive/__private/ui/dialogs/clive_base_dialogs.py index e8a44517e2..907915ef49 100644 --- a/clive/__private/ui/dialogs/clive_base_dialogs.py +++ b/clive/__private/ui/dialogs/clive_base_dialogs.py @@ -20,7 +20,6 @@ from clive.__private.ui.widgets.inputs.clive_input import CliveInput if TYPE_CHECKING: from textual.app import ComposeResult - CliveDialogVariant = Literal["default", "error"] @@ -123,14 +122,41 @@ class CliveActionDialog(CliveBaseDialog[ScreenResultT], ABC): yield ConfirmOneLineButton(self._confirm_button_text) yield CancelOneLineButton() + async def confirm_dialog(self) -> None: + """Confirm the dialog which means try to perform the action and close the dialog if successful.""" + is_confirmed = await self._perform_confirmation() + if is_confirmed: + self.post_message(self.Confirmed()) + self._close_when_confirmed() + + async def cancel_dialog(self) -> None: + """Cancel the dialog which means close the dialog without performing any action.""" + self._close_when_cancelled() + + async def _perform_confirmation(self) -> bool: + """Perform the action of the dialog and return True if it was successful.""" + return True + + def _close_when_confirmed(self) -> None: + self.dismiss() + + def _close_when_cancelled(self) -> None: + self.dismiss() + @on(CliveInput.Submitted) @on(ConfirmOneLineButton.Pressed) - async def confirm_dialog(self) -> None: - self.post_message(self.Confirmed()) + async def _confirm_with_event(self) -> None: + """By default, pressing the confirm button or submitting the input will confirm the dialog.""" + await self.confirm_dialog() @on(CancelOneLineButton.Pressed) - def action_cancel(self) -> None: - self.dismiss() + async def _cancel_with_button(self) -> None: + """By default, pressing the cancel button will cancel the dialog.""" + await self.cancel_dialog() + + async def _action_cancel(self) -> None: + """By default, pressing the cancel key binding will cancel the dialog.""" + await self.cancel_dialog() class CliveInfoDialog(CliveBaseDialog[None], ABC): @@ -139,6 +165,15 @@ class CliveInfoDialog(CliveBaseDialog[None], ABC): def create_buttons_content(self) -> ComposeResult: yield CloseOneLineButton() - @on(CloseOneLineButton.Pressed) - def action_close(self) -> None: + async def _close(self) -> None: + """Close the dialog without performing any action.""" self.dismiss() + + @on(CloseOneLineButton.Pressed) + async def _close_with_button(self) -> None: + """By default, pressing the close button will close the dialog.""" + await self._close() + + async def _action_close(self) -> None: + """By default, pressing the close key binding will close the dialog.""" + await self._close() diff --git a/clive/__private/ui/dialogs/confirm_action_dialog.py b/clive/__private/ui/dialogs/confirm_action_dialog.py index e3d426faa7..f743ccc905 100644 --- a/clive/__private/ui/dialogs/confirm_action_dialog.py +++ b/clive/__private/ui/dialogs/confirm_action_dialog.py @@ -2,12 +2,10 @@ from __future__ import annotations from typing import TYPE_CHECKING -from textual import on from textual.widgets import Static from clive.__private.ui.dialogs.clive_base_dialogs import CliveActionDialog, CliveDialogVariant from clive.__private.ui.get_css import get_relative_css_path -from clive.__private.ui.widgets.buttons import CancelOneLineButton, ConfirmOneLineButton from clive.__private.ui.widgets.section import Section if TYPE_CHECKING: @@ -39,12 +37,8 @@ class ConfirmActionDialog(CliveActionDialog[bool]): with Section(): yield Static(self._confirm_question, id="confirm-question") - @on(ConfirmOneLineButton.Pressed) - async def confirm_dialog(self) -> None: + def _close_when_confirmed(self) -> None: self.dismiss(result=True) - @on(CancelOneLineButton.Pressed) - def action_cancel(self, event: CancelOneLineButton.Pressed | None = None) -> None: - if event is not None: - event.prevent_default() + def _close_when_cancelled(self) -> None: self.dismiss(result=False) diff --git a/clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py b/clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py index d6b1e2735c..3dcbb70f73 100644 --- a/clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py +++ b/clive/__private/ui/dialogs/mark_alarm_as_harmless_dialog.py @@ -2,8 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from textual import on - from clive.__private.ui.dialogs.confirm_action_dialog import ConfirmActionDialog if TYPE_CHECKING: @@ -26,9 +24,9 @@ class MarkAlarmAsHarmlessDialog(ConfirmActionDialog): def alarm_info(self) -> str: return self._alarm.get_alarm_basic_info() - @on(ConfirmActionDialog.Confirmed) - def mark_alarm_as_harmless(self) -> None: + async def _perform_confirmation(self) -> bool: self._alarm.is_harmless = True self.notify(f"Alarm `{self.alarm_info}` was marked as harmless.") self.app.trigger_profile_watchers() - self.dismiss() + self.call_later(self.app.pop_screen) # pop the underlying AlarmInfoDialog + return True diff --git a/clive/__private/ui/dialogs/remove_key_alias_dialog.py b/clive/__private/ui/dialogs/remove_key_alias_dialog.py index b8dd45731e..cf78871331 100644 --- a/clive/__private/ui/dialogs/remove_key_alias_dialog.py +++ b/clive/__private/ui/dialogs/remove_key_alias_dialog.py @@ -2,8 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from textual import on - from clive.__private.ui.dialogs.confirm_action_dialog import ConfirmActionDialog if TYPE_CHECKING: @@ -27,8 +25,8 @@ class RemoveKeyAliasDialog(ConfirmActionDialog): def key_alias(self) -> str: return self._public_key.alias - @on(ConfirmActionDialog.Confirmed) - def remove_key_alias(self) -> None: + async def _perform_confirmation(self) -> bool: self.profile.keys.remove(self._public_key) self.notify(f"Key alias `{self.key_alias}` was removed.") self.app.trigger_profile_watchers() + return True diff --git a/clive/__private/ui/dialogs/switch_node_address_dialog.py b/clive/__private/ui/dialogs/switch_node_address_dialog.py index eb892dae6a..4fd5d4c876 100644 --- a/clive/__private/ui/dialogs/switch_node_address_dialog.py +++ b/clive/__private/ui/dialogs/switch_node_address_dialog.py @@ -2,11 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING -from textual import on - from clive.__private.ui.dialogs.clive_base_dialogs import CliveActionDialog from clive.__private.ui.get_css import get_relative_css_path -from clive.__private.ui.widgets.buttons import ConfirmButton from clive.__private.ui.widgets.node_widgets import NodesList, SelectedNodeAddress from clive.__private.ui.widgets.section import Section @@ -25,11 +22,6 @@ class SwitchNodeAddressDialog(CliveActionDialog): yield SelectedNodeAddress(self.node.http_endpoint) yield NodesList() - @on(ConfirmButton.Pressed) - async def switch_node_address(self) -> None: - await self._switch_node_address() - - async def _switch_node_address(self) -> None: + async def _perform_confirmation(self) -> bool: change_node_succeeded = await self.query_exactly_one(NodesList).save_selected_node_address() - if change_node_succeeded: - self.dismiss() + return change_node_succeeded # noqa: RET504 diff --git a/clive/__private/ui/dialogs/switch_working_account_dialog.py b/clive/__private/ui/dialogs/switch_working_account_dialog.py index 28442324b1..f383631bb7 100644 --- a/clive/__private/ui/dialogs/switch_working_account_dialog.py +++ b/clive/__private/ui/dialogs/switch_working_account_dialog.py @@ -2,7 +2,6 @@ from __future__ import annotations from typing import TYPE_CHECKING -from textual import on from textual.binding import Binding from clive.__private.ui.dialogs.clive_base_dialogs import CliveActionDialog @@ -29,7 +28,6 @@ class SwitchWorkingAccountDialog(CliveActionDialog): yield AccountManagementReference() yield self._switch_working_account_container - @on(CliveActionDialog.Confirmed) - def confirm_selected_working_account(self) -> None: + async def _perform_confirmation(self) -> bool: self._switch_working_account_container.confirm_selected_working_account() - self.dismiss() + return True -- GitLab From a8ba88224cd52b1ae02c279d1e17e32d3a9ef2e7 Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Mon, 3 Feb 2025 13:50:26 +0100 Subject: [PATCH 172/192] Introduce ADD_OPERATION_TO_CART_BINDING_KEY --- clive/__private/core/constants/tui/bindings.py | 1 + .../operations/bindings/operation_action_bindings.py | 7 +++++-- clive/__private/ui/widgets/buttons/add_to_cart_button.py | 5 ++++- .../clive_local_tools/tui/process_operation.py | 3 ++- tests/tui/test_savings.py | 3 ++- tests/tui/test_transfer.py | 3 ++- 6 files changed, 16 insertions(+), 6 deletions(-) diff --git a/clive/__private/core/constants/tui/bindings.py b/clive/__private/core/constants/tui/bindings.py index 0e09c4eacd..4d33e7081d 100644 --- a/clive/__private/core/constants/tui/bindings.py +++ b/clive/__private/core/constants/tui/bindings.py @@ -7,6 +7,7 @@ NEXT_SCREEN_BINDING_KEY: Final[str] = "ctrl+n" PREVIOUS_SCREEN_BINDING_KEY: Final[str] = "ctrl+p" FINALIZE_TRANSACTION_BINDING_KEY: Final[str] = "f6" SAVE_TRANSACTION_TO_FILE_BINDING_KEY: Final[str] = "f2" +ADD_OPERATION_TO_CART_BINDING_KEY: Final[str] = "f2" BROADCAST_TRANSACTION_BINDING_KEY: Final[str] = "f6" LOAD_TRANSACTION_FROM_FILE_BINDING_KEY: Final[str] = "f3" REFRESH_TRANSACTION_METADATA_BINDING_KEY: Final[str] = "f5" diff --git a/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py b/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py index 9f85a225e7..86b5be470f 100644 --- a/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py +++ b/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py @@ -10,7 +10,10 @@ from textual.css.query import NoMatches from clive.__private.abstract_class import AbstractClassMessagePump from clive.__private.core import iwax -from clive.__private.core.constants.tui.bindings import FINALIZE_TRANSACTION_BINDING_KEY +from clive.__private.core.constants.tui.bindings import ( + ADD_OPERATION_TO_CART_BINDING_KEY, + FINALIZE_TRANSACTION_BINDING_KEY, +) from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.dialogs.confirm_action_dialog_with_known_exchange import ConfirmActionDialogWithKnownExchange from clive.__private.ui.screens.transaction_summary import TransactionSummary @@ -40,7 +43,7 @@ class OperationActionBindings(CliveWidget, AbstractClassMessagePump): """Class to provide access to methods related with operations to not just screens.""" BINDINGS = [ - Binding("f2", "add_to_cart", "Add to cart"), + Binding(ADD_OPERATION_TO_CART_BINDING_KEY, "add_to_cart", "Add to cart"), Binding(FINALIZE_TRANSACTION_BINDING_KEY, "finalize_transaction", "Finalize transaction"), ] ALLOW_THE_SAME_OPERATION_IN_CART_MULTIPLE_TIMES: ClassVar[bool] = True diff --git a/clive/__private/ui/widgets/buttons/add_to_cart_button.py b/clive/__private/ui/widgets/buttons/add_to_cart_button.py index e05abdd062..69f0505404 100644 --- a/clive/__private/ui/widgets/buttons/add_to_cart_button.py +++ b/clive/__private/ui/widgets/buttons/add_to_cart_button.py @@ -1,5 +1,6 @@ from __future__ import annotations +from clive.__private.core.constants.tui.bindings import ADD_OPERATION_TO_CART_BINDING_KEY from clive.__private.ui.widgets.buttons.one_line_button import OneLineButton @@ -14,4 +15,6 @@ class AddToCartButton(OneLineButton): """Message send when AddToCartButton is pressed.""" def __init__(self) -> None: - super().__init__("Add to cart (F2)", id_="add-to-cart-button", variant="success") + super().__init__( + f"Add to cart ({ADD_OPERATION_TO_CART_BINDING_KEY.upper()})", id_="add-to-cart-button", variant="success" + ) diff --git a/tests/clive-local-tools/clive_local_tools/tui/process_operation.py b/tests/clive-local-tools/clive_local_tools/tui/process_operation.py index 008b1a9514..e9756ffd79 100644 --- a/tests/clive-local-tools/clive_local_tools/tui/process_operation.py +++ b/tests/clive-local-tools/clive_local_tools/tui/process_operation.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from clive.__private.core.constants.tui.bindings import ADD_OPERATION_TO_CART_BINDING_KEY from clive.__private.ui.screens.operations import Operations from clive.__private.ui.screens.transaction_summary import TransactionSummary from clive_local_tools.tui.broadcast_transaction import broadcast_transaction @@ -13,7 +14,7 @@ if TYPE_CHECKING: async def process_operation(pilot: ClivePilot, operation_processing: OperationProcessing) -> None: if operation_processing == "ADD_TO_CART": - await press_binding(pilot, "f2", "Add to cart") + await press_binding(pilot, ADD_OPERATION_TO_CART_BINDING_KEY, "Add to cart") await press_and_wait_for_screen(pilot, "escape", Operations) await press_and_wait_for_screen(pilot, "f2", TransactionSummary) else: diff --git a/tests/tui/test_savings.py b/tests/tui/test_savings.py index 11bef85035..1b89b8f31a 100644 --- a/tests/tui/test_savings.py +++ b/tests/tui/test_savings.py @@ -6,6 +6,7 @@ import pytest import test_tools as tt from textual.widgets import RadioSet +from clive.__private.core.constants.tui.bindings import ADD_OPERATION_TO_CART_BINDING_KEY from clive.__private.models.schemas import ( CancelTransferFromSavingsOperation, TransferFromSavingsOperation, @@ -247,7 +248,7 @@ async def test_savings_finalize_cart( log_current_view(pilot.app, nodes=True, source=f"after fill_savings_data({i})") await focus_next(pilot) # focus add to cart button - await press_binding(pilot, "f2", "Add to cart") + await press_binding(pilot, ADD_OPERATION_TO_CART_BINDING_KEY, "Add to cart") await focus_next(pilot) # focus finalize transaction button await focus_next(pilot) # focus transfer tab pane log_current_view(pilot.app) diff --git a/tests/tui/test_transfer.py b/tests/tui/test_transfer.py index 7c16397e09..0118a72df2 100644 --- a/tests/tui/test_transfer.py +++ b/tests/tui/test_transfer.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Final import pytest import test_tools as tt +from clive.__private.core.constants.tui.bindings import ADD_OPERATION_TO_CART_BINDING_KEY from clive.__private.models.schemas import TransferOperation from clive.__private.ui.screens.operations import Operations, TransferToAccount from clive.__private.ui.screens.transaction_summary import TransactionSummary @@ -149,7 +150,7 @@ async def test_transfers_finalize_cart(prepared_tui_on_dashboard: tuple[tt.RawNo await focus_next(pilot) # focus on add to cart button await focus_next(pilot) # focus on finalize transaction button await focus_next(pilot) # focus on "to" input - await press_binding(pilot, "f2", "Add to cart") + await press_binding(pilot, ADD_OPERATION_TO_CART_BINDING_KEY, "Add to cart") log_current_view(pilot.app) await press_and_wait_for_screen(pilot, "escape", Operations) -- GitLab From abb2c051c92a1266304e0fc511ed997cf6a018aa Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Mon, 3 Feb 2025 13:14:13 +0100 Subject: [PATCH 173/192] Show dialogs instead of operation summary screens Rename files, its content, move whole directory to dialogs --- .../operation_summary/__init__.py | 0 .../account_witness_proxy_dialog.py} | 18 ++++--- .../cancel_power_down_dialog.py} | 12 ++--- .../cancel_transfer_from_savings_dialog.py} | 13 +++-- .../operation_summary_base_dialog.py | 49 +++++++++++++++++++ .../operation_summary_base_dialog.scss | 13 +++++ .../remove_delegation_dialog.py} | 14 +++--- .../remove_withdraw_vesting_route_dialog.py} | 14 +++--- .../bindings/operation_action_bindings.py | 2 +- .../governance_operations/proxy/proxy.py | 13 +++-- .../delegate_hive_power.py | 4 +- .../power_down/power_down.py | 4 +- .../withdraw_routes/withdraw_routes.py | 8 +-- .../operation_summary/operation_summary.py | 39 --------------- .../operation_summary/operation_summary.scss | 11 ----- .../savings_operations/savings_operations.py | 8 +-- tests/tui/test_savings.py | 8 +-- 17 files changed, 123 insertions(+), 107 deletions(-) rename clive/__private/ui/{screens/operations => dialogs}/operation_summary/__init__.py (100%) rename clive/__private/ui/{screens/operations/operation_summary/account_witness_proxy.py => dialogs/operation_summary/account_witness_proxy_dialog.py} (70%) rename clive/__private/ui/{screens/operations/operation_summary/cancel_power_down.py => dialogs/operation_summary/cancel_power_down_dialog.py} (80%) rename clive/__private/ui/{screens/operations/operation_summary/cancel_transfer_from_savings.py => dialogs/operation_summary/cancel_transfer_from_savings_dialog.py} (75%) create mode 100644 clive/__private/ui/dialogs/operation_summary/operation_summary_base_dialog.py create mode 100644 clive/__private/ui/dialogs/operation_summary/operation_summary_base_dialog.scss rename clive/__private/ui/{screens/operations/operation_summary/remove_delegation.py => dialogs/operation_summary/remove_delegation_dialog.py} (80%) rename clive/__private/ui/{screens/operations/operation_summary/remove_withdraw_vesting_route.py => dialogs/operation_summary/remove_withdraw_vesting_route_dialog.py} (79%) delete mode 100644 clive/__private/ui/screens/operations/operation_summary/operation_summary.py delete mode 100644 clive/__private/ui/screens/operations/operation_summary/operation_summary.scss diff --git a/clive/__private/ui/screens/operations/operation_summary/__init__.py b/clive/__private/ui/dialogs/operation_summary/__init__.py similarity index 100% rename from clive/__private/ui/screens/operations/operation_summary/__init__.py rename to clive/__private/ui/dialogs/operation_summary/__init__.py diff --git a/clive/__private/ui/screens/operations/operation_summary/account_witness_proxy.py b/clive/__private/ui/dialogs/operation_summary/account_witness_proxy_dialog.py similarity index 70% rename from clive/__private/ui/screens/operations/operation_summary/account_witness_proxy.py rename to clive/__private/ui/dialogs/operation_summary/account_witness_proxy_dialog.py index f2407531ed..88c356876b 100644 --- a/clive/__private/ui/screens/operations/operation_summary/account_witness_proxy.py +++ b/clive/__private/ui/dialogs/operation_summary/account_witness_proxy_dialog.py @@ -1,9 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING from clive.__private.models.schemas import AccountWitnessProxyOperation -from clive.__private.ui.screens.operations.operation_summary.operation_summary import OperationSummary +from clive.__private.ui.dialogs.operation_summary.operation_summary_base_dialog import OperationSummaryBaseDialog from clive.__private.ui.widgets.inputs.labelized_input import LabelizedInput if TYPE_CHECKING: @@ -12,11 +12,9 @@ if TYPE_CHECKING: from clive.__private.core.accounts.accounts import Account -class AccountWitnessProxy(OperationSummary): - SECTION_TITLE: ClassVar[str] = "Account witness proxy" - +class AccountWitnessProxyDialog(OperationSummaryBaseDialog[bool]): def __init__(self, *, new_proxy: str | None) -> None: - super().__init__() + super().__init__("Account witness proxy") self._new_proxy = new_proxy @property @@ -29,12 +27,18 @@ class AccountWitnessProxy(OperationSummary): return "" return self._new_proxy - def content(self) -> ComposeResult: + def create_dialog_content(self) -> ComposeResult: yield LabelizedInput("Account name", self.working_account_name) yield LabelizedInput("New proxy", self._new_proxy if self._new_proxy is not None else "Proxy will be removed") def get_account_to_be_marked_as_known(self) -> str | Account | None: return self._new_proxy + def _close_when_cancelled(self) -> None: + self.dismiss(result=False) + + def _close_when_confirmed(self) -> None: + self.dismiss(result=True) + def _create_operation(self) -> AccountWitnessProxyOperation: return AccountWitnessProxyOperation(account=self.working_account_name, proxy=self.proxy_to_be_set) diff --git a/clive/__private/ui/screens/operations/operation_summary/cancel_power_down.py b/clive/__private/ui/dialogs/operation_summary/cancel_power_down_dialog.py similarity index 80% rename from clive/__private/ui/screens/operations/operation_summary/cancel_power_down.py rename to clive/__private/ui/dialogs/operation_summary/cancel_power_down_dialog.py index d62d6bb8c1..e703f9f70d 100644 --- a/clive/__private/ui/screens/operations/operation_summary/cancel_power_down.py +++ b/clive/__private/ui/dialogs/operation_summary/cancel_power_down_dialog.py @@ -1,12 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING from clive.__private.core.constants.node import VESTS_TO_REMOVE_POWER_DOWN from clive.__private.core.formatters.humanize import humanize_datetime from clive.__private.models import Asset from clive.__private.models.schemas import WithdrawVestingOperation -from clive.__private.ui.screens.operations.operation_summary.operation_summary import OperationSummary +from clive.__private.ui.dialogs.operation_summary.operation_summary_base_dialog import OperationSummaryBaseDialog from clive.__private.ui.widgets.inputs.labelized_input import LabelizedInput if TYPE_CHECKING: @@ -17,15 +17,13 @@ if TYPE_CHECKING: from clive.__private.models import HpVestsBalance -class CancelPowerDown(OperationSummary): - SECTION_TITLE: ClassVar[str] = "Cancel power down" - +class CancelPowerDownDialog(OperationSummaryBaseDialog): def __init__(self, next_power_down_date: datetime, next_power_down: HpVestsBalance) -> None: - super().__init__() + super().__init__("Cancel power down") self._next_power_down_date = next_power_down_date self._next_power_down = next_power_down - def content(self) -> ComposeResult: + def create_dialog_content(self) -> ComposeResult: yield LabelizedInput("Next power down", humanize_datetime(self._next_power_down_date)) yield LabelizedInput("Power down [HP]", Asset.pretty_amount(self._next_power_down.hp_balance)) yield LabelizedInput("Power down [VESTS]", Asset.pretty_amount(self._next_power_down.vests_balance)) diff --git a/clive/__private/ui/screens/operations/operation_summary/cancel_transfer_from_savings.py b/clive/__private/ui/dialogs/operation_summary/cancel_transfer_from_savings_dialog.py similarity index 75% rename from clive/__private/ui/screens/operations/operation_summary/cancel_transfer_from_savings.py rename to clive/__private/ui/dialogs/operation_summary/cancel_transfer_from_savings_dialog.py index 45bdce6321..f0338b15c4 100644 --- a/clive/__private/ui/screens/operations/operation_summary/cancel_transfer_from_savings.py +++ b/clive/__private/ui/dialogs/operation_summary/cancel_transfer_from_savings_dialog.py @@ -1,10 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING from clive.__private.core.formatters import humanize from clive.__private.models.schemas import CancelTransferFromSavingsOperation -from clive.__private.ui.screens.operations.operation_summary.operation_summary import OperationSummary +from clive.__private.ui.dialogs.operation_summary.operation_summary_base_dialog import OperationSummaryBaseDialog +from clive.__private.ui.screens.operations.bindings import OperationActionBindings from clive.__private.ui.widgets.inputs.labelized_input import LabelizedInput if TYPE_CHECKING: @@ -13,11 +14,9 @@ if TYPE_CHECKING: from clive.__private.models.schemas import SavingsWithdrawal -class CancelTransferFromSavings(OperationSummary): - SECTION_TITLE: ClassVar[str] = "Cancel transfer from savings" - +class CancelTransferFromSavingsDialog(OperationSummaryBaseDialog, OperationActionBindings): def __init__(self, transfer: SavingsWithdrawal) -> None: - super().__init__() + super().__init__("Cancel transfer from savings") self._transfer = transfer @property @@ -28,7 +27,7 @@ class CancelTransferFromSavings(OperationSummary): def realized_on(self) -> str: return humanize.humanize_datetime(self._transfer.complete) - def content(self) -> ComposeResult: + def create_dialog_content(self) -> ComposeResult: yield LabelizedInput("Request id", str(self._transfer.request_id)) yield LabelizedInput("Realized on", self.realized_on) yield LabelizedInput("From", self.from_account) diff --git a/clive/__private/ui/dialogs/operation_summary/operation_summary_base_dialog.py b/clive/__private/ui/dialogs/operation_summary/operation_summary_base_dialog.py new file mode 100644 index 0000000000..d7a5f05476 --- /dev/null +++ b/clive/__private/ui/dialogs/operation_summary/operation_summary_base_dialog.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from abc import ABC +from typing import TYPE_CHECKING + +from textual import on + +from clive.__private.ui.clive_screen import ScreenResultT +from clive.__private.ui.dialogs.clive_base_dialogs import CliveActionDialog +from clive.__private.ui.get_css import get_relative_css_path +from clive.__private.ui.screens.operations.bindings import OperationActionBindings +from clive.__private.ui.widgets.buttons import ( + AddToCartButton, + CancelOneLineButton, + FinalizeTransactionButton, +) + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +class OperationSummaryBaseDialog(CliveActionDialog[ScreenResultT], OperationActionBindings, ABC): + """Base class for operation summary dialogs. Confirmation means that operation was added to cart or finalized.""" + + CSS_PATH = [get_relative_css_path(__file__)] + ALLOW_THE_SAME_OPERATION_IN_CART_MULTIPLE_TIMES = False + + def create_buttons_content(self) -> ComposeResult: + yield AddToCartButton() + yield FinalizeTransactionButton() + yield CancelOneLineButton() + + async def action_add_to_cart(self) -> None: + await super().action_add_to_cart() + await self.confirm_dialog() + + async def action_finalize_transaction(self) -> None: + await super().action_finalize_transaction() + await self.confirm_dialog() + + @on(AddToCartButton.Pressed) + async def _add_to_cart_with_button(self, event: AddToCartButton.Pressed) -> None: + event.prevent_default() + await self.action_add_to_cart() + + @on(FinalizeTransactionButton.Pressed) + async def _finalize_transaction_with_button(self, event: FinalizeTransactionButton.Pressed) -> None: + event.prevent_default() + await self.action_finalize_transaction() diff --git a/clive/__private/ui/dialogs/operation_summary/operation_summary_base_dialog.scss b/clive/__private/ui/dialogs/operation_summary/operation_summary_base_dialog.scss new file mode 100644 index 0000000000..45ee5df7ea --- /dev/null +++ b/clive/__private/ui/dialogs/operation_summary/operation_summary_base_dialog.scss @@ -0,0 +1,13 @@ +OperationSummaryBaseDialog { + CliveDialogContent { + width: 70%; + } + + LabelizedInput { + margin-bottom: 1; + } + + #buttons-container { + margin-top: 0; + } +} diff --git a/clive/__private/ui/screens/operations/operation_summary/remove_delegation.py b/clive/__private/ui/dialogs/operation_summary/remove_delegation_dialog.py similarity index 80% rename from clive/__private/ui/screens/operations/operation_summary/remove_delegation.py rename to clive/__private/ui/dialogs/operation_summary/remove_delegation_dialog.py index 2c1301f9b6..110b38572e 100644 --- a/clive/__private/ui/screens/operations/operation_summary/remove_delegation.py +++ b/clive/__private/ui/dialogs/operation_summary/remove_delegation_dialog.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING from clive.__private.core.constants.node import VESTS_TO_REMOVE_DELEGATION from clive.__private.models import Asset from clive.__private.models.schemas import DelegateVestingSharesOperation -from clive.__private.ui.screens.operations.operation_summary.operation_summary import OperationSummary +from clive.__private.ui.dialogs.operation_summary.operation_summary_base_dialog import OperationSummaryBaseDialog from clive.__private.ui.widgets.inputs.labelized_input import LabelizedInput if TYPE_CHECKING: @@ -14,13 +14,11 @@ if TYPE_CHECKING: from clive.__private.models.schemas import VestingDelegation -class RemoveDelegation(OperationSummary): - """Screen to remove delegation.""" - - SECTION_TITLE: ClassVar[str] = "Remove delegation" +class RemoveDelegationDialog(OperationSummaryBaseDialog): + """Dialog to remove delegation.""" def __init__(self, delegation: VestingDelegation[Asset.Vests], pretty_hp_amount: str) -> None: - super().__init__() + super().__init__("Remove delegation") self._delegation = delegation self._pretty_hp_amount = pretty_hp_amount @@ -28,7 +26,7 @@ class RemoveDelegation(OperationSummary): def working_account_name(self) -> str: return self.profile.accounts.working.name - def content(self) -> ComposeResult: + def create_dialog_content(self) -> ComposeResult: yield LabelizedInput("Delegator", self.working_account_name) yield LabelizedInput("Delegate", self._delegation.delegatee) yield LabelizedInput( diff --git a/clive/__private/ui/screens/operations/operation_summary/remove_withdraw_vesting_route.py b/clive/__private/ui/dialogs/operation_summary/remove_withdraw_vesting_route_dialog.py similarity index 79% rename from clive/__private/ui/screens/operations/operation_summary/remove_withdraw_vesting_route.py rename to clive/__private/ui/dialogs/operation_summary/remove_withdraw_vesting_route_dialog.py index 51de727c2b..282e75bae8 100644 --- a/clive/__private/ui/screens/operations/operation_summary/remove_withdraw_vesting_route.py +++ b/clive/__private/ui/dialogs/operation_summary/remove_withdraw_vesting_route_dialog.py @@ -1,12 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING from clive.__private.core.constants.node import PERCENT_TO_REMOVE_WITHDRAW_ROUTE from clive.__private.core.constants.precision import HIVE_PERCENT_PRECISION from clive.__private.core.formatters.humanize import humanize_bool from clive.__private.models.schemas import SetWithdrawVestingRouteOperation -from clive.__private.ui.screens.operations.operation_summary.operation_summary import OperationSummary +from clive.__private.ui.dialogs.operation_summary.operation_summary_base_dialog import OperationSummaryBaseDialog from clive.__private.ui.widgets.inputs.labelized_input import LabelizedInput if TYPE_CHECKING: @@ -15,20 +15,18 @@ if TYPE_CHECKING: from clive.__private.models.schemas import WithdrawRoute -class RemoveWithdrawVestingRoute(OperationSummary): - """Screen to remove withdraw vesting route.""" - - SECTION_TITLE: ClassVar[str] = "Remove withdraw vesting route" +class RemoveWithdrawVestingRouteDialog(OperationSummaryBaseDialog): + """Dialog to remove withdraw vesting route.""" def __init__(self, withdraw_route: WithdrawRoute) -> None: - super().__init__() + super().__init__("Remove withdraw vesting route") self._withdraw_route = withdraw_route @property def working_account_name(self) -> str: return self.profile.accounts.working.name - def content(self) -> ComposeResult: + def create_dialog_content(self) -> ComposeResult: yield LabelizedInput("From account", self.working_account_name) yield LabelizedInput("To account", self._withdraw_route.to_account) yield LabelizedInput("Percent", f"{self._withdraw_route.percent / HIVE_PERCENT_PRECISION :.2f} %") diff --git a/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py b/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py index 86b5be470f..3ba79c255b 100644 --- a/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py +++ b/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py @@ -141,7 +141,7 @@ class OperationActionBindings(CliveWidget, AbstractClassMessagePump): @on(CliveInput.Submitted) @on(AddToCartButton.Pressed) - def action_add_to_cart(self) -> None: + async def action_add_to_cart(self) -> None: def add_to_cart() -> None: if self._add_to_cart(): if self.profile.transaction.is_signed: diff --git a/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py b/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py index 675c080e39..2dcc69d0f7 100644 --- a/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py +++ b/clive/__private/ui/screens/operations/governance_operations/proxy/proxy.py @@ -7,8 +7,8 @@ from textual.containers import Container, Horizontal from textual.widgets import TabPane from clive.__private.ui.clive_widget import CliveWidget +from clive.__private.ui.dialogs.operation_summary.account_witness_proxy_dialog import AccountWitnessProxyDialog from clive.__private.ui.get_css import get_css_from_relative_path -from clive.__private.ui.screens.operations.operation_summary.account_witness_proxy import AccountWitnessProxy from clive.__private.ui.widgets.buttons import OneLineButton from clive.__private.ui.widgets.inputs.labelized_input import LabelizedInput from clive.__private.ui.widgets.inputs.proxy_input import ProxyInput @@ -100,11 +100,11 @@ class Proxy(TabPane, CliveWidget): return new_proxy = self.new_proxy_input.value_or_error # already validated - self.app.push_screen(AccountWitnessProxy(new_proxy=new_proxy)) + self.app.push_screen(AccountWitnessProxyDialog(new_proxy=new_proxy), self._proxy_dialog_cb) @on(OneLineButton.Pressed, "#remove-proxy-button") def remove_proxy(self) -> None: - self.app.push_screen(AccountWitnessProxy(new_proxy=None)) + self.app.push_screen(AccountWitnessProxyDialog(new_proxy=None), self._proxy_dialog_cb) def sync_when_proxy_changed(self) -> None: proxy_profile = self.profile.accounts.working.data.proxy @@ -116,3 +116,10 @@ class Proxy(TabPane, CliveWidget): with self.app.batch_update(): self.query_exactly_one(ProxyBaseContainer).remove() self.query_exactly_one("#scrollable-for-proxy").mount(content) + + def _clear_proxy_input(self) -> None: + self.new_proxy_input.clear_validation() + + def _proxy_dialog_cb(self, confirm: bool | None) -> None: + if confirm: + self._clear_proxy_input() diff --git a/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py b/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py index a3b47b7ddf..ae204b9ba5 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py +++ b/clive/__private/ui/screens/operations/hive_power_management/delegate_hive_power/delegate_hive_power.py @@ -10,10 +10,10 @@ from clive.__private.core.constants.tui.class_names import CLIVE_CHECKERBOARD_HE from clive.__private.core.ensure_vests import ensure_vests from clive.__private.models.schemas import DelegateVestingSharesOperation from clive.__private.ui.data_providers.hive_power_data_provider import HivePowerDataProvider +from clive.__private.ui.dialogs.operation_summary.remove_delegation_dialog import RemoveDelegationDialog from clive.__private.ui.get_css import get_css_from_relative_path from clive.__private.ui.not_updated_yet import NotUpdatedYet from clive.__private.ui.screens.operations.bindings import OperationActionBindings -from clive.__private.ui.screens.operations.operation_summary.remove_delegation import RemoveDelegation from clive.__private.ui.widgets.buttons import CliveButton, OneLineButton from clive.__private.ui.widgets.clive_basic import ( CliveCheckerboardTable, @@ -71,7 +71,7 @@ class Delegation(CliveCheckerboardTableRow): @on(CliveButton.Pressed, "#remove-delegation-button") def push_operation_summary_screen(self) -> None: - self.app.push_screen(RemoveDelegation(self._delegation, self._aligned_hp_amount)) + self.app.push_screen(RemoveDelegationDialog(self._delegation, self._aligned_hp_amount)) class DelegationsTable(CliveCheckerboardTable): diff --git a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py index 19fcb50775..787c2aa38b 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py +++ b/clive/__private/ui/screens/operations/hive_power_management/power_down/power_down.py @@ -15,10 +15,10 @@ from clive.__private.models import Asset from clive.__private.models.schemas import WithdrawVestingOperation from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.data_providers.hive_power_data_provider import HivePowerDataProvider +from clive.__private.ui.dialogs.operation_summary.cancel_power_down_dialog import CancelPowerDownDialog from clive.__private.ui.get_css import get_css_from_relative_path from clive.__private.ui.not_updated_yet import NotUpdatedYet, is_not_updated_yet from clive.__private.ui.screens.operations.bindings.operation_action_bindings import OperationActionBindings -from clive.__private.ui.screens.operations.operation_summary.cancel_power_down import CancelPowerDown from clive.__private.ui.widgets.buttons import CancelOneLineButton, GenerousButton from clive.__private.ui.widgets.clive_basic import ( CliveCheckerboardTable, @@ -108,7 +108,7 @@ class PendingPowerDown(CliveCheckerboardTable): @on(CancelOneLineButton.Pressed) def push_operation_summary_screen(self) -> None: self.app.push_screen( - CancelPowerDown( + CancelPowerDownDialog( self.object_to_watch.content.next_vesting_withdrawal, self.object_to_watch.content.next_power_down ) ) diff --git a/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py b/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py index acc36f6d29..46a53904fd 100644 --- a/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py +++ b/clive/__private/ui/screens/operations/hive_power_management/withdraw_routes/withdraw_routes.py @@ -12,12 +12,12 @@ from clive.__private.core.formatters.humanize import align_to_dot, humanize_bool from clive.__private.core.percent_conversions import percent_to_hive_percent from clive.__private.models.schemas import SetWithdrawVestingRouteOperation from clive.__private.ui.data_providers.hive_power_data_provider import HivePowerDataProvider +from clive.__private.ui.dialogs.operation_summary.remove_withdraw_vesting_route_dialog import ( + RemoveWithdrawVestingRouteDialog, +) from clive.__private.ui.get_css import get_css_from_relative_path from clive.__private.ui.not_updated_yet import NotUpdatedYet from clive.__private.ui.screens.operations.bindings import OperationActionBindings -from clive.__private.ui.screens.operations.operation_summary.remove_withdraw_vesting_route import ( - RemoveWithdrawVestingRoute, -) from clive.__private.ui.widgets.buttons import CliveButton, OneLineButton from clive.__private.ui.widgets.clive_basic import ( CliveCheckerboardTable, @@ -61,7 +61,7 @@ class WithdrawRoute(CliveCheckerboardTableRow): @on(CliveButton.Pressed, "#remove-withdraw-route-button") def push_operation_summary_screen(self) -> None: - self.app.push_screen(RemoveWithdrawVestingRoute(self._withdraw_route)) + self.app.push_screen(RemoveWithdrawVestingRouteDialog(self._withdraw_route)) class WithdrawRoutesTable(CliveCheckerboardTable): diff --git a/clive/__private/ui/screens/operations/operation_summary/operation_summary.py b/clive/__private/ui/screens/operations/operation_summary/operation_summary.py deleted file mode 100644 index 416f904453..0000000000 --- a/clive/__private/ui/screens/operations/operation_summary/operation_summary.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from abc import abstractmethod -from typing import TYPE_CHECKING, ClassVar - -from textual.containers import Grid - -from clive.__private.abstract_class import AbstractClassMessagePump -from clive.__private.ui.get_css import get_relative_css_path -from clive.__private.ui.screens.operation_base_screen import OperationBaseScreen -from clive.__private.ui.screens.operations.bindings import OperationActionBindings -from clive.__private.ui.widgets.section import SectionScrollable - -if TYPE_CHECKING: - from textual.app import ComposeResult - - -class Body(Grid): - """All the content of the screen, excluding the title.""" - - -class OperationSummary(OperationBaseScreen, OperationActionBindings, AbstractClassMessagePump): - """Base class for operation summary screens.""" - - CSS_PATH = [get_relative_css_path(__file__)] - - POP_SCREEN_AFTER_ADDING_TO_CART = True - - SECTION_TITLE: ClassVar[str] = "Operation summary" - - ALLOW_THE_SAME_OPERATION_IN_CART_MULTIPLE_TIMES = False - - def create_left_panel(self) -> ComposeResult: - with SectionScrollable(self.SECTION_TITLE, focusable=True), Body(): - yield from self.content() - - @abstractmethod - def content(self) -> ComposeResult: - """Create the content of the screen.""" diff --git a/clive/__private/ui/screens/operations/operation_summary/operation_summary.scss b/clive/__private/ui/screens/operations/operation_summary/operation_summary.scss deleted file mode 100644 index cbc27b2b51..0000000000 --- a/clive/__private/ui/screens/operations/operation_summary/operation_summary.scss +++ /dev/null @@ -1,11 +0,0 @@ -OperationSummary { - SectionScrollable { - margin: 0 4; - } - - Body { - grid-rows: auto; - grid-gutter: 1; - height: auto; - } -} diff --git a/clive/__private/ui/screens/operations/savings_operations/savings_operations.py b/clive/__private/ui/screens/operations/savings_operations/savings_operations.py index fa1fd246d4..879b72e9df 100644 --- a/clive/__private/ui/screens/operations/savings_operations/savings_operations.py +++ b/clive/__private/ui/screens/operations/savings_operations/savings_operations.py @@ -16,13 +16,13 @@ from clive.__private.models.schemas import ( ) from clive.__private.ui.clive_widget import CliveWidget from clive.__private.ui.data_providers.savings_data_provider import SavingsDataProvider +from clive.__private.ui.dialogs.operation_summary.cancel_transfer_from_savings_dialog import ( + CancelTransferFromSavingsDialog, +) from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.not_updated_yet import NotUpdatedYet, is_updated from clive.__private.ui.screens.operation_base_screen import OperationBaseScreen from clive.__private.ui.screens.operations.bindings import OperationActionBindings -from clive.__private.ui.screens.operations.operation_summary.cancel_transfer_from_savings import ( - CancelTransferFromSavings, -) from clive.__private.ui.widgets.apr import APR from clive.__private.ui.widgets.buttons import CancelOneLineButton from clive.__private.ui.widgets.clive_basic import ( @@ -150,7 +150,7 @@ class PendingTransfer(CliveCheckerboardTableRow): @on(CancelOneLineButton.Pressed) def push_operation_summary_screen(self) -> None: - self.app.push_screen(CancelTransferFromSavings(self._pending_transfer)) + self.app.push_screen(CancelTransferFromSavingsDialog(self._pending_transfer)) class PendingTransfers(CliveCheckerboardTable): diff --git a/tests/tui/test_savings.py b/tests/tui/test_savings.py index 1b89b8f31a..749e160f90 100644 --- a/tests/tui/test_savings.py +++ b/tests/tui/test_savings.py @@ -12,11 +12,11 @@ from clive.__private.models.schemas import ( TransferFromSavingsOperation, TransferToSavingsOperation, ) +from clive.__private.ui.dialogs.operation_summary.cancel_transfer_from_savings_dialog import ( + CancelTransferFromSavingsDialog, +) from clive.__private.ui.screens.dashboard import Dashboard from clive.__private.ui.screens.operations import Operations -from clive.__private.ui.screens.operations.operation_summary.cancel_transfer_from_savings import ( - CancelTransferFromSavings, -) from clive.__private.ui.screens.operations.savings_operations.savings_operations import ( PendingTransfer, Savings, @@ -306,7 +306,7 @@ async def test_canceling_transfer_from_savings( await go_to_savings(pilot) await pilot.press("right") # switch tab to pending transfers await focus_next(pilot) - await press_and_wait_for_screen(pilot, "enter", CancelTransferFromSavings) # Cancel transfer + await press_and_wait_for_screen(pilot, "enter", CancelTransferFromSavingsDialog) # Cancel transfer await press_and_wait_for_screen(pilot, "f6", TransactionSummary) # Finalize transaction await broadcast_transaction(pilot) -- GitLab From d56ed2b148f2b9aade86805ea0367d3e322992da Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Mon, 3 Mar 2025 15:22:13 +0100 Subject: [PATCH 174/192] Refactor OperationActionBindings class --- .../bindings/operation_action_bindings.py | 235 ++++++++++-------- 1 file changed, 127 insertions(+), 108 deletions(-) diff --git a/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py b/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py index 3ba79c255b..05fdc9e95a 100644 --- a/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py +++ b/clive/__private/ui/screens/operations/bindings/operation_action_bindings.py @@ -53,7 +53,7 @@ class OperationActionBindings(CliveWidget, AbstractClassMessagePump): # Multiple inheritance friendly, passes arguments to next object in MRO. super().__init__(*args, **kwargs) - self.__check_if_correctly_implemented() + self._check_if_correctly_implemented() def _create_operation(self) -> OperationUnion | None | _NotImplemented: """Return a new operation based on the data from screen or None.""" @@ -114,78 +114,53 @@ class OperationActionBindings(CliveWidget, AbstractClassMessagePump): def create_operations(self) -> list[OperationUnion] | None: return self._validate_and_notify(self._create_operations) - @on(FinalizeTransactionButton.Pressed) - async def action_finalize_transaction(self) -> None: - async def finalize() -> None: - if self._add_to_cart(): - self._add_account_to_known_after_action() - self.profile.transaction_file_path = None - if self.profile.transaction.is_signed: - self._send_cleared_signatures_notification() - await self.commands.update_transaction_metadata(transaction=self.profile.transaction) - self._clear_inputs() - self._actions_after_clearing_inputs() - await self.app.push_screen(TransactionSummary()) - - async def finalize_cb(confirm: bool | None) -> None: - if confirm: - await finalize() - - if not self._can_proceed_operation(): # For faster validation feedback to the user - return + async def action_add_to_cart(self) -> None: + await self._handle_add_to_cart_request() - if self._check_is_known_exchange_in_input(): - await self.app.push_screen(ConfirmActionDialogWithKnownExchange(), finalize_cb) - else: - await finalize() + @on(AddToCartButton.Pressed) + async def add_to_cart_by_button(self) -> None: + await self._handle_add_to_cart_request() @on(CliveInput.Submitted) - @on(AddToCartButton.Pressed) - async def action_add_to_cart(self) -> None: - def add_to_cart() -> None: - if self._add_to_cart(): - if self.profile.transaction.is_signed: - self.profile.transaction.unsign() - self._send_cleared_signatures_notification() - self.profile.transaction_file_path = None - self._add_account_to_known_after_action() - if self.POP_SCREEN_AFTER_ADDING_TO_CART: - self.app.pop_screen() - self._clear_inputs() - self._actions_after_clearing_inputs() - self.notify("The operation was added to the cart.") - - def add_to_cart_cb(confirm: bool | None) -> None: - if confirm: - add_to_cart() + async def add_to_cart_with_event(self) -> None: + await self._handle_add_to_cart_request() - if not self._can_proceed_operation(): # For faster validation feedback to the user - return + async def action_finalize_transaction(self) -> None: + await self._handle_finalize_transaction_request() - if self._check_is_known_exchange_in_input(): - self.app.push_screen(ConfirmActionDialogWithKnownExchange(), add_to_cart_cb) - else: - add_to_cart() + @on(FinalizeTransactionButton.Pressed) + async def finalize_transaction_by_button(self) -> None: + await self._handle_finalize_transaction_request() - def get_account_to_be_marked_as_known(self) -> str | Account | None: + def check_is_known_exchange_in_input(self) -> bool | None: """ - Return the account (if overwritten) that should have been marked as known after action like add to cart. + Check if the account name input (action receiver) is a known exchange account (if overwritten). Notice: _______ If this method is not overridden, the account from the account name input (action receiver), - will be marked as known. + will be checked for being a known exchange. """ return None - def check_is_known_exchange_in_input(self) -> bool | None: + def ensure_operations_list(self) -> list[OperationUnion]: + operation = self.create_operation() + if operation is not None: + return [operation] + + operations = self.create_operations() + if operations is not None: + return operations + return [] + + def get_account_to_be_marked_as_known(self) -> str | Account | None: """ - Check if the account name input (action receiver) is a known exchange account (if overwritten). + Return the account (if overwritten) that should have been marked as known after action like add to cart. Notice: _______ If this method is not overridden, the account from the account name input (action receiver), - will be checked for being a known exchange. + will be marked as known. """ return None @@ -199,32 +174,55 @@ class OperationActionBindings(CliveWidget, AbstractClassMessagePump): self._additional_actions_after_clearing_inputs() + def _add_account_to_known_after_action(self) -> None: + """Add account that is given via parameter. If not given - add all accounts from the account name inputs.""" + account = self.get_account_to_be_marked_as_known() + + if account is not None: + if not self.profile.accounts.is_account_known(account): + self.profile.accounts.known.add(account) + return + + with contextlib.suppress(NoMatches): + self.query_exactly_one(AccountNameInput).add_account_to_known() + + def _actions_after_adding_to_cart(self) -> None: + """It's performing all actions needed after adding operation to cart.""" + if self.profile.transaction.is_signed: + self.profile.transaction.unsign() + self._send_cleared_signatures_notification() + self.profile.transaction_file_path = None + self._add_account_to_known_after_action() + self._clear_inputs() + self._actions_after_clearing_inputs() + + def _add_to_cart(self, operations: list[OperationUnion], *, notify: bool = True) -> None: + """Just adds given operations to cart.""" + self.profile.add_operation(*operations) + self.app.trigger_profile_watchers() + if notify: + self.notify("The operation was added to the cart.") + def _can_proceed_operation(self) -> bool: if not self.create_operation() and not self.create_operations(): self.notify(INVALID_OPERATION_WARNING, severity="warning") return False return True - def _add_to_cart(self) -> bool: - """ - Create a new operation and adds it to the cart. - - Returns - ------- - True if the operation was added to the cart successfully, False otherwise. - """ - operations = self.ensure_operations_list() - assert operations, "when calling '_add_to_cart', operations must not be empty" + def _check_if_correctly_implemented(self) -> None: + with self.app.suppressed_notifications(): + try: + create_operation_missing = isinstance(self._create_operation(), _NotImplemented) + except Exception: # noqa: BLE001 + create_operation_missing = False - if not self.ALLOW_THE_SAME_OPERATION_IN_CART_MULTIPLE_TIMES: - for operation in operations: - if operation in self.profile.transaction: - self.notify("Operation already in the cart", severity="error") - return False + try: + create_operations_missing = isinstance(self._create_operations(), _NotImplemented) + except Exception: # noqa: BLE001 + create_operations_missing = False - self.profile.add_operation(*operations) - self.app.trigger_profile_watchers() - return True + if sum([create_operation_missing, create_operations_missing]) != 1: + raise RuntimeError("One and only one of `_create_operation` or `_create_operations` should be implemented.") def _check_is_known_exchange_in_input(self) -> bool: is_known_exchange_in_input = self.check_is_known_exchange_in_input() @@ -238,50 +236,71 @@ class OperationActionBindings(CliveWidget, AbstractClassMessagePump): return False - def _add_account_to_known_after_action(self) -> None: - """Add account that is given via parameter. If not given - add all accounts from the account name inputs.""" - account = self.get_account_to_be_marked_as_known() - - if account is not None: - if not self.profile.accounts.is_account_known(account): - self.profile.accounts.known.add(account) - return - - with contextlib.suppress(NoMatches): - self.query_exactly_one(AccountNameInput).add_account_to_known() - def _clear_inputs(self) -> None: inputs = self.query(CliveValidatedInput) # type: ignore[type-abstract] for widget in inputs: widget.clear_validation() + async def _handle_add_to_cart_request(self) -> None: + def add_operation_to_cart_and_perform_post_actions() -> None: + self._add_to_cart(operations_to_add) + self._actions_after_adding_to_cart() + if self.POP_SCREEN_AFTER_ADDING_TO_CART: + self.app.pop_screen() + + def cb(confirm: bool | None) -> None: + if confirm: + add_operation_to_cart_and_perform_post_actions() + + if not self._can_proceed_operation(): # For faster validation feedback to the user + return + + operations_to_add = self.ensure_operations_list() + assert operations_to_add, "when calling '_add_to_cart', operations must not be empty" + + if self._validate_operations_already_in_the_cart(operations_to_add): + return + + if self._check_is_known_exchange_in_input(): + self.app.push_screen(ConfirmActionDialogWithKnownExchange(), cb) + else: + add_operation_to_cart_and_perform_post_actions() + + async def _handle_finalize_transaction_request(self) -> None: + async def finalize_and_perform_post_actions() -> None: + self._add_to_cart(operations_to_add, notify=False) + self._actions_after_adding_to_cart() + await self.commands.update_transaction_metadata(transaction=self.profile.transaction) + await self.app.push_screen(TransactionSummary()) + + async def cb(confirm: bool | None) -> None: + if confirm: + await finalize_and_perform_post_actions() + + if not self._can_proceed_operation(): # For faster validation feedback to the user + return + + operations_to_add = self.ensure_operations_list() + assert operations_to_add, "when calling '_add_to_cart', operations must not be empty" + + if self._validate_operations_already_in_the_cart(operations_to_add): + return + + if self._check_is_known_exchange_in_input(): + await self.app.push_screen(ConfirmActionDialogWithKnownExchange(), cb) + else: + await finalize_and_perform_post_actions() + def _send_cleared_signatures_notification(self) -> None: self.notify( "Transaction signatures were removed since you changed the transaction content.", severity="warning", ) - def ensure_operations_list(self) -> list[OperationUnion]: - operation = self.create_operation() - if operation is not None: - return [operation] - - operations = self.create_operations() - if operations is not None: - return operations - return [] - - def __check_if_correctly_implemented(self) -> None: - with self.app.suppressed_notifications(): - try: - create_operation_missing = isinstance(self._create_operation(), _NotImplemented) - except Exception: # noqa: BLE001 - create_operation_missing = False - - try: - create_operations_missing = isinstance(self._create_operations(), _NotImplemented) - except Exception: # noqa: BLE001 - create_operations_missing = False - - if sum([create_operation_missing, create_operations_missing]) != 1: - raise RuntimeError("One and only one of `_create_operation` or `_create_operations` should be implemented.") + def _validate_operations_already_in_the_cart(self, operations: list[OperationUnion]) -> bool: + if not self.ALLOW_THE_SAME_OPERATION_IN_CART_MULTIPLE_TIMES and any( + operation in self.profile.transaction for operation in operations + ): + self.notify("Operation already in the cart", severity="error") + return True + return False -- GitLab From 4dc14207ce37ef716a6f348657566c047ebaebad Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Wed, 19 Mar 2025 09:24:23 +0100 Subject: [PATCH 175/192] Clear transaction file path after successfully broadcasting transaction loaded from file --- .../ui/screens/transaction_summary/transaction_summary.py | 1 + 1 file changed, 1 insertion(+) diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.py b/clive/__private/ui/screens/transaction_summary/transaction_summary.py index 860c5e522c..b5f9804122 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.py +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.py @@ -309,6 +309,7 @@ class TransactionSummary(BaseScreen): self.notify(f"Transaction with ID '{transaction.calculate_transaction_id()}' successfully broadcasted!") self.profile.transaction.reset() + self.profile.transaction_file_path = None self.app.trigger_profile_watchers() self.app.get_screen_from_current_stack(Dashboard).pop_until_active() -- GitLab From a2800f8339d29c78563aa97aab89fe2fbe3f79c4 Mon Sep 17 00:00:00 2001 From: Mateusz Kudela <mkudela@syncad.com> Date: Wed, 19 Mar 2025 09:36:52 +0100 Subject: [PATCH 176/192] Recompose key container if broadcast transaction fails To let user know that transaction is already signed --- .../ui/screens/transaction_summary/transaction_summary.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/clive/__private/ui/screens/transaction_summary/transaction_summary.py b/clive/__private/ui/screens/transaction_summary/transaction_summary.py index b5f9804122..052e59a7d8 100644 --- a/clive/__private/ui/screens/transaction_summary/transaction_summary.py +++ b/clive/__private/ui/screens/transaction_summary/transaction_summary.py @@ -304,6 +304,9 @@ class TransactionSummary(BaseScreen): broadcast=True, ) if wrapper.error_occurred: + # recompose key container in case fail of broadcast when transaction was already signed + if transaction.is_signed: + await self.key_container.recompose() self.notify("Transaction broadcast failed. Please try again.", severity="error") return -- GitLab From 3034dc37f3580e12ade48e213ab3dcb7a078faba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 20 Mar 2025 15:05:08 +0100 Subject: [PATCH 177/192] Remove dead code --- clive/__private/core/world.py | 3 --- clive/__private/ui/screens/dashboard/dashboard.py | 5 ----- 2 files changed, 8 deletions(-) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index 3a05ad97d1..a4f09b2086 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -285,9 +285,6 @@ class TUIWorld(World, CliveDOMNode): self.app.run_worker(lock()) - def _on_going_into_unlocked_mode(self) -> None: - self.app.trigger_app_state_watchers() - def _setup_commands(self) -> TUICommands: return TUICommands(self) diff --git a/clive/__private/ui/screens/dashboard/dashboard.py b/clive/__private/ui/screens/dashboard/dashboard.py index 05f21a4ba9..8769f5468a 100644 --- a/clive/__private/ui/screens/dashboard/dashboard.py +++ b/clive/__private/ui/screens/dashboard/dashboard.py @@ -38,7 +38,6 @@ if TYPE_CHECKING: from textual.app import ComposeResult from textual.widget import Widget - from clive.__private.core.app_state import AppState from clive.__private.core.commands.data_retrieval.update_node_data import Manabar from clive.__private.core.profile import Profile from clive.__private.ui.widgets.buttons.clive_button import CliveButtonVariant @@ -291,7 +290,6 @@ class Dashboard(BaseScreen): def on_mount(self) -> None: self.watch(self.world, "profile_reactive", self._update_account_containers) - self.watch(self.world, "app_state", self._update_mode) async def _update_account_containers(self, profile: Profile) -> None: if self.tracked_accounts == self._previous_tracked_accounts: @@ -315,9 +313,6 @@ class Dashboard(BaseScreen): await accounts_container.query("*").remove() await accounts_container.mount_all(widgets_to_mount) - def _update_mode(self, app_state: AppState) -> None: - self.is_unlocked = app_state.is_unlocked - @CliveScreen.prevent_action_when_no_working_account() @CliveScreen.prevent_action_when_no_accounts_node_data() def action_operations(self) -> None: -- GitLab From 847bb16e942c77faab81e1f900ac59489645649c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 18 Mar 2025 08:35:15 +0100 Subject: [PATCH 178/192] Make LockSource literal consistent with worker name --- clive/__private/core/app_state.py | 2 +- clive/__private/core/world.py | 2 +- clive/__private/ui/app.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clive/__private/core/app_state.py b/clive/__private/core/app_state.py index a18dda2df1..ace862eb20 100644 --- a/clive/__private/core/app_state.py +++ b/clive/__private/core/app_state.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from clive.__private.core.wallet_container import WalletContainer from clive.__private.core.world import World -LockSource = Literal["beekeeper_monitoring_thread", "unknown"] +LockSource = Literal["beekeeper_wallet_lock_status_update_worker", "unknown"] @dataclass diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index a4f09b2086..d2d9d1acc9 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -271,7 +271,7 @@ class TUIWorld(World, CliveDOMNode): self.node.change_related_profile(profile) def _on_going_into_locked_mode(self, source: LockSource) -> None: - if source == "beekeeper_monitoring_thread": + if source == "beekeeper_wallet_lock_status_update_worker": self.app.notify("Switched to the LOCKED mode due to timeout.", timeout=10) self.app.pause_refresh_node_data_interval() self.app.pause_refresh_alarms_data_interval() diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index df68a9728c..04dd639616 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -291,7 +291,7 @@ class Clive(App[int]): @work(name="beekeeper wallet lock status update worker") async def update_wallet_lock_status_from_beekeeper(self) -> None: if self.world._beekeeper_manager: - await self.world.commands.sync_state_with_beekeeper("beekeeper_monitoring_thread") + await self.world.commands.sync_state_with_beekeeper("beekeeper_wallet_lock_status_update_worker") async def __debug_log(self) -> None: logger.debug("===================== DEBUG =====================") -- GitLab From 262f7f9a9e094c58aed103df5c04f6b441c1e6c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 17 Mar 2025 15:09:07 +0100 Subject: [PATCH 179/192] Add AsyncGuard --- clive/__private/core/async_guard.py | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 clive/__private/core/async_guard.py diff --git a/clive/__private/core/async_guard.py b/clive/__private/core/async_guard.py new file mode 100644 index 0000000000..e9af3707f8 --- /dev/null +++ b/clive/__private/core/async_guard.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import asyncio +import contextlib +from typing import Final, Generator + +from clive.__private.logger import logger +from clive.exceptions import CliveError + + +class AsyncGuardNotAvailableError(CliveError): + """Raised when trying to acquire a guard that is already acquired.""" + + MESSAGE: Final[str] = "Guard is already acquired." + + def __init__(self) -> None: + super().__init__(self.MESSAGE) + + +class AsyncGuard: + """ + A helper class to manage an asynchronous event-like lock, ensuring exclusive execution. + + Use this for scenarios where you want to prevent concurrent execution of an async task. + When the guard is acquired by some other task, the guarded block could not execute, error will be raised instead. + Can be used together with `suppress`. Look into its documentation for more details. + """ + + def __init__(self) -> None: + self._event = asyncio.Event() + + @property + def is_available(self) -> bool: + """Return True if the event lock is currently available (not acquired).""" + return not self._event.is_set() + + def acquire(self) -> None: + if not self.is_available: + raise AsyncGuardNotAvailableError + + self._event.set() + + def release(self) -> None: + self._event.clear() + + @contextlib.contextmanager + def guard(self) -> Generator[None]: + self.acquire() + try: + yield + finally: + self.release() + + @staticmethod + @contextlib.contextmanager + def suppress() -> Generator[None]: + """ + Suppresses the AsyncGuardNotAvailable error raised by the guard. + + Use this together with `acquire` or `guard` to skip code execution when the guard is acquired. + """ + try: + yield + except AsyncGuardNotAvailableError: + logger.debug("Suppressing AsyncGuardNotAvailableError.") -- GitLab From e20ade7c42b03433ed9a889633faf5b6b3701551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 17 Mar 2025 15:18:00 +0100 Subject: [PATCH 180/192] Use AsyncGuard to protect from race condition and crash when screen is removed --- clive/__private/core/app_state.py | 6 +- clive/__private/core/commands/lock.py | 2 +- .../commands/sync_state_with_beekeeper.py | 2 +- .../core/error_handlers/tui_error_handler.py | 5 +- clive/__private/core/world.py | 41 +++-------- clive/__private/ui/app.py | 71 ++++++++++++++++++- .../create_profile/create_profile_form.py | 32 +++++---- .../ui/screens/dashboard/dashboard.py | 4 +- clive/__private/ui/screens/unlock/unlock.py | 57 +++++++-------- .../ui/widgets/clive_basic/clive_header.py | 5 +- 10 files changed, 136 insertions(+), 89 deletions(-) diff --git a/clive/__private/core/app_state.py b/clive/__private/core/app_state.py index ace862eb20..ecb2f02859 100644 --- a/clive/__private/core/app_state.py +++ b/clive/__private/core/app_state.py @@ -34,16 +34,16 @@ class AppState: self._is_unlocked = True if wallets: await self.world.beekeeper_manager.set_wallets(wallets) - self.world.on_going_into_unlocked_mode() + await self.world.on_going_into_unlocked_mode() logger.info("Mode switched to UNLOCKED.") - def lock(self, source: LockSource = "unknown") -> None: + async def lock(self, source: LockSource = "unknown") -> None: if not self._is_unlocked: return self._is_unlocked = False self.world.beekeeper_manager.clear_wallets() - self.world.on_going_into_locked_mode(source) + await self.world.on_going_into_locked_mode(source) logger.info("Mode switched to LOCKED.") def __hash__(self) -> int: diff --git a/clive/__private/core/commands/lock.py b/clive/__private/core/commands/lock.py index 894b1fd92e..18df59c4b9 100644 --- a/clive/__private/core/commands/lock.py +++ b/clive/__private/core/commands/lock.py @@ -21,4 +21,4 @@ class Lock(Command): async def _execute(self) -> None: await self.session.lock_all() if self.app_state: - self.app_state.lock() + await self.app_state.lock() diff --git a/clive/__private/core/commands/sync_state_with_beekeeper.py b/clive/__private/core/commands/sync_state_with_beekeeper.py index d98c766acc..57113323df 100644 --- a/clive/__private/core/commands/sync_state_with_beekeeper.py +++ b/clive/__private/core/commands/sync_state_with_beekeeper.py @@ -59,6 +59,6 @@ class SyncStateWithBeekeeper(Command): if user_wallet and encryption_wallet: await self.app_state.unlock(WalletContainer(user_wallet, encryption_wallet)) elif not user_wallet and not encryption_wallet: - self.app_state.lock(self.source) + await self.app_state.lock(self.source) else: raise InvalidWalletStateError(self) diff --git a/clive/__private/core/error_handlers/tui_error_handler.py b/clive/__private/core/error_handlers/tui_error_handler.py index b146b6361b..f20acb1887 100644 --- a/clive/__private/core/error_handlers/tui_error_handler.py +++ b/clive/__private/core/error_handlers/tui_error_handler.py @@ -36,4 +36,7 @@ class TUIErrorHandler(ErrorHandlerContextManager[Exception]): return ResultNotAvailable(error) def _switch_to_locked_mode(self) -> None: - self._app.world.app_state.lock() + async def impl() -> None: + await self._app.switch_mode_into_locked(save_profile=False) + + self._app.run_worker_with_screen_remove_guard(impl()) diff --git a/clive/__private/core/world.py b/clive/__private/core/world.py index d2d9d1acc9..c02536ad07 100644 --- a/clive/__private/core/world.py +++ b/clive/__private/core/world.py @@ -16,9 +16,6 @@ from clive.__private.core.node import Node from clive.__private.core.profile import Profile from clive.__private.core.wallet_container import WalletContainer from clive.__private.ui.clive_dom_node import CliveDOMNode -from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm -from clive.__private.ui.screens.dashboard import Dashboard -from clive.__private.ui.screens.unlock import Unlock from clive.exceptions import ProfileNotLoadedError if TYPE_CHECKING: @@ -116,7 +113,7 @@ class World: self._node.teardown() self._beekeeper_manager.teardown() - self.app_state.lock() + await self.app_state.lock() self._profile = None self._node = None @@ -173,22 +170,22 @@ class World: self._profile = new_profile await self._update_node() - def on_going_into_locked_mode(self, source: LockSource) -> None: + async def on_going_into_locked_mode(self, source: LockSource) -> None: """Triggered when the application is going into the locked mode.""" if self._is_during_setup or self._is_during_closure: return - self._on_going_into_locked_mode(source) + await self._on_going_into_locked_mode(source) - def on_going_into_unlocked_mode(self) -> None: + async def on_going_into_unlocked_mode(self) -> None: """Triggered when the application is going into the unlocked mode.""" if self._is_during_setup or self._is_during_closure: return - self._on_going_into_unlocked_mode() + await self._on_going_into_unlocked_mode() - def _on_going_into_locked_mode(self, _: LockSource) -> None: + async def _on_going_into_locked_mode(self, _: LockSource) -> None: """Override this method to hook when clive goes into the locked mode.""" - def _on_going_into_unlocked_mode(self) -> None: + async def _on_going_into_unlocked_mode(self) -> None: """Override this method to hook when clive goes into the unlocked mode.""" @asynccontextmanager @@ -270,32 +267,12 @@ class TUIWorld(World, CliveDOMNode): def _watch_profile(self, profile: Profile) -> None: self.node.change_related_profile(profile) - def _on_going_into_locked_mode(self, source: LockSource) -> None: - if source == "beekeeper_wallet_lock_status_update_worker": - self.app.notify("Switched to the LOCKED mode due to timeout.", timeout=10) - self.app.pause_refresh_node_data_interval() - self.app.pause_refresh_alarms_data_interval() - self.node.cached.clear() - - async def lock() -> None: - self._add_welcome_modes() - await self.app.switch_mode("unlock") - await self._restart_dashboard_mode() - await self.switch_profile(None) - - self.app.run_worker(lock()) + async def _on_going_into_locked_mode(self, source: LockSource) -> None: + await self.app._switch_mode_into_locked(source) def _setup_commands(self) -> TUICommands: return TUICommands(self) - def _add_welcome_modes(self) -> None: - self.app.add_mode("create_profile", CreateProfileForm) - self.app.add_mode("unlock", Unlock) - - async def _restart_dashboard_mode(self) -> None: - await self.app.remove_mode("dashboard") - self.app.add_mode("dashboard", Dashboard) - def _update_profile_related_reactive_attributes(self) -> None: # There's no proper way to add some proxy reactive property on textual reactives that could raise error if # not set yet, and still can be watched. See: https://github.com/Textualize/textual/discussions/4007 diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 04dd639616..6e24ece10a 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -1,20 +1,22 @@ from __future__ import annotations import asyncio +import contextlib import math import traceback from contextlib import asynccontextmanager, contextmanager -from typing import TYPE_CHECKING, Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, Awaitable, TypeVar, cast from beekeepy.exceptions import CommunicationError from textual import on, work from textual._context import active_app -from textual.app import App +from textual.app import App, UnknownModeError from textual.binding import Binding from textual.notifications import Notification, Notify, SeverityLevel from textual.reactive import var from textual.worker import WorkerCancelled +from clive.__private.core.async_guard import AsyncGuard from clive.__private.core.constants.terminal import TERMINAL_HEIGHT, TERMINAL_WIDTH from clive.__private.core.constants.tui.bindings import APP_QUIT_KEY_BINDING from clive.__private.core.profile import Profile @@ -37,6 +39,7 @@ if TYPE_CHECKING: from textual.screen import Screen, ScreenResultType from textual.worker import Worker + from clive.__private.core.app_state import LockSource UpdateScreenResultT = TypeVar("UpdateScreenResultT") @@ -85,6 +88,16 @@ class Clive(App[int]): super().__init__(*args, **kwargs) self._world: TUIWorld | None = None + self._screen_remove_guard = AsyncGuard() + """ + Due to https://github.com/Textualize/textual/issues/5008. + + Any action that involves removing a screen like remove_mode/switch_screen/pop_screen + cannot be awaited in the @on handler like Button.Pressed because it will deadlock the app. + Workaround is to not await mentioned action or run it in a separate task if something later needs to await it. + This workaround can create race conditions, so we need to guard against it. + """ + @property def world(self) -> TUIWorld: assert self._world is not None, "World is not set yet." @@ -293,6 +306,27 @@ class Clive(App[int]): if self.world._beekeeper_manager: await self.world.commands.sync_state_with_beekeeper("beekeeper_wallet_lock_status_update_worker") + async def switch_mode_into_locked(self, *, save_profile: bool = True) -> None: + if save_profile: + await self.world.commands.save_profile() + await self.world.commands.lock() + + def run_worker_with_guard(self, awaitable: Awaitable[None], guard: AsyncGuard) -> None: + """Run work in a worker with a guard. It means that the work will be executed only if the guard is available.""" + + async def work_with_release() -> None: + try: + await awaitable + finally: + guard.release() + + with guard.suppress(): + guard.acquire() + self.run_worker(work_with_release()) + + def run_worker_with_screen_remove_guard(self, awaitable: Awaitable[None]) -> None: + self.run_worker_with_guard(awaitable, self._screen_remove_guard) + async def __debug_log(self) -> None: logger.debug("===================== DEBUG =====================") logger.debug(f"Currently focused: {self.focused}") @@ -335,3 +369,36 @@ class Clive(App[int]): def _retrigger_update_alarms_data(self) -> None: if self.is_worker_group_empty("alarms_data"): self.update_alarms_data() + + async def _switch_mode_into_locked(self, source: LockSource) -> None: + async def restart_dashboard_mode() -> None: + await self.remove_mode("dashboard") + self.add_mode("dashboard", Dashboard) + + def add_welcome_modes() -> None: + self.add_mode("create_profile", CreateProfileForm) + self.add_mode("unlock", Unlock) + + if source == "beekeeper_wallet_lock_status_update_worker": + self.notify("Switched to the LOCKED mode due to timeout.", timeout=10) + + self.pause_refresh_node_data_interval() + self.pause_refresh_alarms_data_interval() + self.world.node.cached.clear() + add_welcome_modes() + await self.switch_mode("unlock") + await restart_dashboard_mode() + await self.world.switch_profile(None) + + async def _switch_mode_into_unlocked(self) -> None: + async def remove_welcome_modes() -> None: + with contextlib.suppress(UnknownModeError): + await self.remove_mode("create_profile") + with contextlib.suppress(UnknownModeError): + await self.remove_mode("unlock") + + await self.switch_mode("dashboard") + await remove_welcome_modes() + self.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) + self.resume_refresh_node_data_interval() + self.resume_refresh_alarms_data_interval() diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index a09cff3951..029b937f6e 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -26,20 +26,22 @@ class CreateProfileForm(Form): async def exit_form(self) -> None: # when this form is displayed during onboarding, there is no previous screen to go back to # so this method won't be called - await self.app.switch_mode("unlock") - self.app.remove_mode("create_profile") + + async def impl() -> None: + await self.app.switch_mode("unlock") + await self.app.remove_mode("create_profile") + + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(impl()) async def finish_form(self) -> None: - async def handle_modes() -> None: - await self.app.switch_mode("dashboard") - self.app.remove_mode("create_profile") - self.app.remove_mode("unlock") - - self.add_post_action( - lambda: self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True), - self.app.resume_refresh_alarms_data_interval, - ) - await self.execute_post_actions() - await handle_modes() - self.profile.enable_saving() - await self.commands.save_profile() + async def impl() -> None: + await self.execute_post_actions() + self.profile.enable_saving() + await self.commands.save_profile() + await self.app._switch_mode_into_unlocked() + + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(impl()) diff --git a/clive/__private/ui/screens/dashboard/dashboard.py b/clive/__private/ui/screens/dashboard/dashboard.py index 8769f5468a..3c7285021d 100644 --- a/clive/__private/ui/screens/dashboard/dashboard.py +++ b/clive/__private/ui/screens/dashboard/dashboard.py @@ -329,8 +329,8 @@ class Dashboard(BaseScreen): self.app.push_screen(AddTrackedAccountDialog()) async def action_switch_mode_into_locked(self) -> None: - await self.app.world.commands.save_profile() - await self.app.world.commands.lock() + with self.app._screen_remove_guard.suppress(), self.app._screen_remove_guard.guard(): + await self.app.switch_mode_into_locked() @property def has_working_account(self) -> bool: diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index 09886023e1..82350ad2d5 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -108,32 +108,33 @@ class Unlock(BaseScreen): @on(Button.Pressed, "#unlock-button") @on(CliveInput.Submitted) async def unlock(self) -> None: - password_input = self.query_exactly_one(PasswordInput) - select_profile = self.query_exactly_one(SelectProfile) - lock_after_time = self.query_exactly_one(LockAfterTime) - - if not password_input.validate_passed() or not lock_after_time.is_valid: - return - - try: - await self.world.load_profile( - profile_name=select_profile.value_ensure, - password=password_input.value_or_error, - permanent=lock_after_time.should_stay_unlocked, - time=lock_after_time.lock_duration, - ) - except InvalidPasswordError: - logger.error( - f"Profile `{select_profile.value_ensure}` was not unlocked " - "because entered password is invalid, skipping switching modes" - ) - return - - await self.app.switch_mode("dashboard") - self._remove_welcome_modes() - self.app.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) - self.app.resume_refresh_node_data_interval() - self.app.resume_refresh_alarms_data_interval() + async def impl() -> None: + password_input = self.query_exactly_one(PasswordInput) + select_profile = self.query_exactly_one(SelectProfile) + lock_after_time = self.query_exactly_one(LockAfterTime) + + if not password_input.validate_passed() or not lock_after_time.is_valid: + return + + try: + await self.world.load_profile( + profile_name=select_profile.value_ensure, + password=password_input.value_or_error, + permanent=lock_after_time.should_stay_unlocked, + time=lock_after_time.lock_duration, + ) + except InvalidPasswordError: + logger.error( + f"Profile `{select_profile.value_ensure}` was not unlocked " + "because entered password is invalid, skipping switching modes" + ) + return + + await self.app._switch_mode_into_unlocked() + + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(impl()) @on(Button.Pressed, "#new-profile-button") async def create_new_profile(self) -> None: @@ -146,7 +147,3 @@ class Unlock(BaseScreen): @on(SelectProfile.Changed) def clear_password_input(self) -> None: self.query_exactly_one(PasswordInput).clear_validation() - - def _remove_welcome_modes(self) -> None: - self.app.remove_mode("unlock") - self.app.remove_mode("create_profile") diff --git a/clive/__private/ui/widgets/clive_basic/clive_header.py b/clive/__private/ui/widgets/clive_basic/clive_header.py index a223159639..8734569bf3 100644 --- a/clive/__private/ui/widgets/clive_basic/clive_header.py +++ b/clive/__private/ui/widgets/clive_basic/clive_header.py @@ -203,8 +203,9 @@ class LockStatus(DynamicOneLineButtonUnfocusable): @on(OneLineButton.Pressed) async def lock_wallet(self) -> None: - await self.commands.save_profile() - await self.commands.lock() + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(self.app.switch_mode_into_locked()) def _wallet_to_locked_changed(self) -> None: self.post_message(self.WalletLocked()) -- GitLab From 8ce35b143c32299001125503da5561fbe3fca7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 21 Mar 2025 13:23:14 +0100 Subject: [PATCH 181/192] Create switch_mode_with_reset and reset_mode --- clive/__private/ui/app.py | 65 +++++++++++++------ .../create_profile/create_profile_form.py | 3 +- clive/__private/ui/screens/unlock/unlock.py | 12 ++-- clive/__private/ui/types.py | 4 +- 4 files changed, 53 insertions(+), 31 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 6e24ece10a..0a8fad67b3 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import contextlib import math import traceback from contextlib import asynccontextmanager, contextmanager @@ -10,7 +9,8 @@ from typing import TYPE_CHECKING, Any, Awaitable, TypeVar, cast from beekeepy.exceptions import CommunicationError from textual import on, work from textual._context import active_app -from textual.app import App, UnknownModeError +from textual.app import App +from textual.await_complete import AwaitComplete from textual.binding import Binding from textual.notifications import Notification, Notify, SeverityLevel from textual.reactive import var @@ -40,6 +40,7 @@ if TYPE_CHECKING: from textual.worker import Worker from clive.__private.core.app_state import LockSource + from clive.__private.ui.types import CliveModes UpdateScreenResultT = TypeVar("UpdateScreenResultT") @@ -103,6 +104,12 @@ class Clive(App[int]): assert self._world is not None, "World is not set yet." return self._world + @property + def current_mode(self) -> CliveModes: + mode = super().current_mode + assert mode in self.MODES, f"Mode {mode} is not in the list of modes" + return cast("CliveModes", mode) + @staticmethod def app_instance() -> Clive: return cast(Clive, active_app.get()) @@ -306,6 +313,39 @@ class Clive(App[int]): if self.world._beekeeper_manager: await self.world.commands.sync_state_with_beekeeper("beekeeper_wallet_lock_status_update_worker") + def switch_mode_with_reset(self, new_mode: CliveModes) -> AwaitComplete: + """ + Switch mode and reset all other active modes. + + The `App.switch_mode` method from Textual keeps the previous mode in the stack. + This method allows to switch to a new mode and have only a new mode in the stack without keeping + any other screen stacks. + + Args: + ---- + new_mode: The mode to switch to. + """ + + async def impl() -> None: + logger.debug(f"Switching mode from: `{self.current_mode}` to: `{new_mode}`") + await self.switch_mode(new_mode) + + modes_to_keep = (new_mode, "_default") + modes_to_reset = [mode for mode in self._screen_stacks if mode not in modes_to_keep] + assert all(mode in self.MODES for mode in modes_to_reset), "Unexpected mode in modes_to_reset" + await self.reset_mode(*cast("list[CliveModes]", modes_to_reset)) + + return AwaitComplete(impl()).call_next(self) + + def reset_mode(self, *modes: CliveModes) -> AwaitComplete: + async def impl() -> None: + logger.debug(f"Resetting modes: {modes}") + for mode in modes: + await self.remove_mode(mode) + self.add_mode(mode, self.MODES[mode]) + + return AwaitComplete(impl()).call_next(self) + async def switch_mode_into_locked(self, *, save_profile: bool = True) -> None: if save_profile: await self.world.commands.save_profile() @@ -371,34 +411,17 @@ class Clive(App[int]): self.update_alarms_data() async def _switch_mode_into_locked(self, source: LockSource) -> None: - async def restart_dashboard_mode() -> None: - await self.remove_mode("dashboard") - self.add_mode("dashboard", Dashboard) - - def add_welcome_modes() -> None: - self.add_mode("create_profile", CreateProfileForm) - self.add_mode("unlock", Unlock) - if source == "beekeeper_wallet_lock_status_update_worker": self.notify("Switched to the LOCKED mode due to timeout.", timeout=10) self.pause_refresh_node_data_interval() self.pause_refresh_alarms_data_interval() self.world.node.cached.clear() - add_welcome_modes() - await self.switch_mode("unlock") - await restart_dashboard_mode() + await self.switch_mode_with_reset("unlock") await self.world.switch_profile(None) async def _switch_mode_into_unlocked(self) -> None: - async def remove_welcome_modes() -> None: - with contextlib.suppress(UnknownModeError): - await self.remove_mode("create_profile") - with contextlib.suppress(UnknownModeError): - await self.remove_mode("unlock") - - await self.switch_mode("dashboard") - await remove_welcome_modes() + await self.switch_mode_with_reset("dashboard") self.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) self.resume_refresh_node_data_interval() self.resume_refresh_alarms_data_interval() diff --git a/clive/__private/ui/forms/create_profile/create_profile_form.py b/clive/__private/ui/forms/create_profile/create_profile_form.py index 029b937f6e..6b5ae016ac 100644 --- a/clive/__private/ui/forms/create_profile/create_profile_form.py +++ b/clive/__private/ui/forms/create_profile/create_profile_form.py @@ -28,8 +28,7 @@ class CreateProfileForm(Form): # so this method won't be called async def impl() -> None: - await self.app.switch_mode("unlock") - await self.app.remove_mode("create_profile") + await self.app.switch_mode_with_reset("unlock") # Has to be done in a separate task to avoid deadlock. # More: https://github.com/Textualize/textual/issues/5008 diff --git a/clive/__private/ui/screens/unlock/unlock.py b/clive/__private/ui/screens/unlock/unlock.py index 82350ad2d5..effd8022c6 100644 --- a/clive/__private/ui/screens/unlock/unlock.py +++ b/clive/__private/ui/screens/unlock/unlock.py @@ -1,12 +1,10 @@ from __future__ import annotations -import contextlib from datetime import timedelta from typing import TYPE_CHECKING from beekeepy.exceptions import InvalidPasswordError from textual import on -from textual.app import InvalidModeError from textual.containers import Horizontal from textual.validation import Integer from textual.widgets import Button, Checkbox, Static @@ -15,7 +13,6 @@ from clive.__private.core.constants.tui.messages import PRESS_HELP_MESSAGE from clive.__private.core.profile import Profile from clive.__private.logger import logger from clive.__private.ui.clive_widget import CliveWidget -from clive.__private.ui.forms.create_profile.create_profile_form import CreateProfileForm from clive.__private.ui.get_css import get_relative_css_path from clive.__private.ui.screens.base_screen import BaseScreen from clive.__private.ui.widgets.buttons import CliveButton @@ -138,11 +135,12 @@ class Unlock(BaseScreen): @on(Button.Pressed, "#new-profile-button") async def create_new_profile(self) -> None: - with contextlib.suppress(InvalidModeError): - # If the mode is already added, we don't want to add it again - self.app.add_mode("create_profile", CreateProfileForm) + async def impl() -> None: + await self.app.switch_mode_with_reset("create_profile") - await self.app.switch_mode("create_profile") + # Has to be done in a separate task to avoid deadlock. + # More: https://github.com/Textualize/textual/issues/5008 + self.app.run_worker_with_screen_remove_guard(impl()) @on(SelectProfile.Changed) def clear_password_input(self) -> None: diff --git a/clive/__private/ui/types.py b/clive/__private/ui/types.py index 4b8247ebb3..996bef40e9 100644 --- a/clive/__private/ui/types.py +++ b/clive/__private/ui/types.py @@ -1,8 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias if TYPE_CHECKING: from textual.binding import ActiveBinding ActiveBindingsMap: TypeAlias = dict[str, ActiveBinding] + + CliveModes = Literal["unlock", "create_profile", "dashboard"] -- GitLab From f8e586c3a01da2c24057b2800725eb23ff161f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 21 Mar 2025 13:25:17 +0100 Subject: [PATCH 182/192] Log current mode and screen stacks in debug loop --- clive/__private/ui/app.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 0a8fad67b3..61a4731567 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -197,7 +197,7 @@ class Clive(App[int]): should_enable_debug_loop = safe_settings.dev.should_enable_debug_loop if should_enable_debug_loop: debug_loop_period_secs = safe_settings.dev.debug_loop_period_secs - self.set_interval(debug_loop_period_secs, self.__debug_log) + self.set_interval(debug_loop_period_secs, self._debug_log) if Profile.is_any_profile_saved(): self.switch_mode("unlock") @@ -367,10 +367,12 @@ class Clive(App[int]): def run_worker_with_screen_remove_guard(self, awaitable: Awaitable[None]) -> None: self.run_worker_with_guard(awaitable, self._screen_remove_guard) - async def __debug_log(self) -> None: + async def _debug_log(self) -> None: logger.debug("===================== DEBUG =====================") logger.debug(f"Currently focused: {self.focused}") + logger.debug(f"Current mode: {self.current_mode}") logger.debug(f"Screen stack: {self.screen_stack}") + logger.debug(f"Screen stacks: {self._screen_stacks}") if self.world.is_profile_available: cached_dgpo = self.world.node.cached.dynamic_global_properties_or_none -- GitLab From 54446e1feb4727da5845326630f7d78ab2d2d9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Mar 2025 08:33:25 +0100 Subject: [PATCH 183/192] Fix lock/sync race condition - wallet lock status worker is paused and resumed in right places #394 Also there could be observed: ``` clive.__private.core.commands.sync_state_with_beekeeper.InvalidWalletAmountError: Command SyncStateWithBeekeeper failed. Reason: The amount of wallets is invalid. Profile can have either 0 (if not created yet) or 2 wallets. ``` sometimes before this commit. Easier reproduction when setting `CLIVE_BEEKEEPER__REFRESH_TIMEOUT_SECS=0.01` --- clive/__private/ui/app.py | 26 ++++++++++++++++++++++---- tests/tui/conftest.py | 1 + 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 61a4731567..b4c9dc4158 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -191,7 +191,9 @@ class Clive(App[int]): ) self._refresh_beekeeper_wallet_lock_status_interval = self.set_interval( - safe_settings.beekeeper.refresh_timeout_secs, self.update_wallet_lock_status_from_beekeeper + safe_settings.beekeeper.refresh_timeout_secs, + self._retrigger_update_wallet_lock_status_from_beekeeper, + pause=True, ) should_enable_debug_loop = safe_settings.dev.should_enable_debug_loop @@ -254,6 +256,13 @@ class Clive(App[int]): def resume_refresh_node_data_interval(self) -> None: self._refresh_node_data_interval.resume() + def pause_refresh_beekeeper_wallet_lock_status_interval(self) -> None: + self._refresh_beekeeper_wallet_lock_status_interval.pause() + self.workers.cancel_group(self, "wallet_lock_status") + + def resume_refresh_beekeeper_wallet_lock_status_interval(self) -> None: + self._refresh_beekeeper_wallet_lock_status_interval.resume() + def trigger_profile_watchers(self) -> None: self.world.mutate_reactive(TUIWorld.profile_reactive) # type: ignore[arg-type] @@ -308,10 +317,9 @@ class Clive(App[int]): self.trigger_profile_watchers() self.trigger_node_watchers() - @work(name="beekeeper wallet lock status update worker") + @work(name="beekeeper wallet lock status update worker", group="wallet_lock_status") async def update_wallet_lock_status_from_beekeeper(self) -> None: - if self.world._beekeeper_manager: - await self.world.commands.sync_state_with_beekeeper("beekeeper_wallet_lock_status_update_worker") + await self.world.commands.sync_state_with_beekeeper("beekeeper_wallet_lock_status_update_worker") def switch_mode_with_reset(self, new_mode: CliveModes) -> AwaitComplete: """ @@ -349,6 +357,10 @@ class Clive(App[int]): async def switch_mode_into_locked(self, *, save_profile: bool = True) -> None: if save_profile: await self.world.commands.save_profile() + + # needs to be done before beekeeper API call to avoid race condition between manual lock and timeout lock + self.pause_refresh_beekeeper_wallet_lock_status_interval() + await self.world.commands.lock() def run_worker_with_guard(self, awaitable: Awaitable[None], guard: AsyncGuard) -> None: @@ -412,12 +424,17 @@ class Clive(App[int]): if self.is_worker_group_empty("alarms_data"): self.update_alarms_data() + def _retrigger_update_wallet_lock_status_from_beekeeper(self) -> None: + if self.is_worker_group_empty("wallet_lock_status"): + self.update_wallet_lock_status_from_beekeeper() + async def _switch_mode_into_locked(self, source: LockSource) -> None: if source == "beekeeper_wallet_lock_status_update_worker": self.notify("Switched to the LOCKED mode due to timeout.", timeout=10) self.pause_refresh_node_data_interval() self.pause_refresh_alarms_data_interval() + self.pause_refresh_beekeeper_wallet_lock_status_interval() self.world.node.cached.clear() await self.switch_mode_with_reset("unlock") await self.world.switch_profile(None) @@ -427,3 +444,4 @@ class Clive(App[int]): self.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) self.resume_refresh_node_data_interval() self.resume_refresh_alarms_data_interval() + self.resume_refresh_beekeeper_wallet_lock_status_interval() diff --git a/tests/tui/conftest.py b/tests/tui/conftest.py index b267e310c0..0d60de09de 100644 --- a/tests/tui/conftest.py +++ b/tests/tui/conftest.py @@ -101,6 +101,7 @@ async def prepared_tui_on_dashboard(prepared_env: PreparedTuiEnv) -> PreparedTui await pilot.app.update_alarms_data_on_newest_node_data().wait() pilot.app.resume_refresh_node_data_interval() pilot.app.resume_refresh_alarms_data_interval() + pilot.app.resume_refresh_beekeeper_wallet_lock_status_interval() await pilot.app.push_screen(Dashboard()) await wait_for_screen(pilot, Dashboard) -- GitLab From 60d70f141e4da69beea812b9b653f9edd91d03c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Mon, 24 Mar 2025 12:20:31 +0100 Subject: [PATCH 184/192] Fix random crash when locking #405 --- clive/__private/ui/app.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index b4c9dc4158..7c067ca069 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -14,7 +14,7 @@ from textual.await_complete import AwaitComplete from textual.binding import Binding from textual.notifications import Notification, Notify, SeverityLevel from textual.reactive import var -from textual.worker import WorkerCancelled +from textual.worker import NoActiveWorker, WorkerCancelled, get_current_worker from clive.__private.core.async_guard import AsyncGuard from clive.__private.core.constants.terminal import TERMINAL_HEIGHT, TERMINAL_WIDTH @@ -435,6 +435,10 @@ class Clive(App[int]): self.pause_refresh_node_data_interval() self.pause_refresh_alarms_data_interval() self.pause_refresh_beekeeper_wallet_lock_status_interval() + + # There might be ongoing workers that should be cancelled (e.g. DynamicWidget update) + self._cancel_workers_except_current() + self.world.node.cached.clear() await self.switch_mode_with_reset("unlock") await self.world.switch_profile(None) @@ -445,3 +449,13 @@ class Clive(App[int]): self.resume_refresh_node_data_interval() self.resume_refresh_alarms_data_interval() self.resume_refresh_beekeeper_wallet_lock_status_interval() + + def _cancel_workers_except_current(self) -> None: + try: + current_worker = get_current_worker() + except NoActiveWorker: + current_worker = None + + for worker in self.workers: + if worker != current_worker: + worker.cancel() -- GitLab From 3ba929a10742f733ebcb5835a39428428caae96d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Tue, 25 Mar 2025 13:42:08 +0100 Subject: [PATCH 185/192] Create methods for pausing/resuming all app timers --- clive/__private/ui/app.py | 18 ++++++++++++------ tests/tui/conftest.py | 4 +--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 7c067ca069..31ee7a602c 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -263,6 +263,16 @@ class Clive(App[int]): def resume_refresh_beekeeper_wallet_lock_status_interval(self) -> None: self._refresh_beekeeper_wallet_lock_status_interval.resume() + def pause_periodic_intervals(self) -> None: + self.pause_refresh_node_data_interval() + self.pause_refresh_alarms_data_interval() + self.pause_refresh_beekeeper_wallet_lock_status_interval() + + def resume_periodic_intervals(self) -> None: + self.resume_refresh_node_data_interval() + self.resume_refresh_alarms_data_interval() + self.resume_refresh_beekeeper_wallet_lock_status_interval() + def trigger_profile_watchers(self) -> None: self.world.mutate_reactive(TUIWorld.profile_reactive) # type: ignore[arg-type] @@ -432,9 +442,7 @@ class Clive(App[int]): if source == "beekeeper_wallet_lock_status_update_worker": self.notify("Switched to the LOCKED mode due to timeout.", timeout=10) - self.pause_refresh_node_data_interval() - self.pause_refresh_alarms_data_interval() - self.pause_refresh_beekeeper_wallet_lock_status_interval() + self.pause_periodic_intervals() # There might be ongoing workers that should be cancelled (e.g. DynamicWidget update) self._cancel_workers_except_current() @@ -446,9 +454,7 @@ class Clive(App[int]): async def _switch_mode_into_unlocked(self) -> None: await self.switch_mode_with_reset("dashboard") self.update_alarms_data_on_newest_node_data(suppress_cancelled_error=True) - self.resume_refresh_node_data_interval() - self.resume_refresh_alarms_data_interval() - self.resume_refresh_beekeeper_wallet_lock_status_interval() + self.resume_periodic_intervals() def _cancel_workers_except_current(self) -> None: try: diff --git a/tests/tui/conftest.py b/tests/tui/conftest.py index 0d60de09de..86ef1c9dd1 100644 --- a/tests/tui/conftest.py +++ b/tests/tui/conftest.py @@ -99,9 +99,7 @@ async def prepared_tui_on_dashboard(prepared_env: PreparedTuiEnv) -> PreparedTui # update the data and resume timers (pilot skips onboarding/unlocking via TUI - updating is handled there) await pilot.app.update_alarms_data_on_newest_node_data().wait() - pilot.app.resume_refresh_node_data_interval() - pilot.app.resume_refresh_alarms_data_interval() - pilot.app.resume_refresh_beekeeper_wallet_lock_status_interval() + pilot.app.resume_periodic_intervals() await pilot.app.push_screen(Dashboard()) await wait_for_screen(pilot, Dashboard) -- GitLab From afdc12a1290d8c51ce79f1cce08f3e2406e6c1da Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Wed, 12 Mar 2025 16:16:16 +0100 Subject: [PATCH 186/192] Display meaningful notofication when save profile fails --- clive/__private/core/commands/save_profile.py | 19 ++++++++++++++++--- .../general_error_notificator.py | 2 ++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/clive/__private/core/commands/save_profile.py b/clive/__private/core/commands/save_profile.py index 31f7066181..dbe4d0a29e 100644 --- a/clive/__private/core/commands/save_profile.py +++ b/clive/__private/core/commands/save_profile.py @@ -1,9 +1,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final -from clive.__private.core.commands.abc.command import Command +from beekeepy.exceptions import CommunicationError + +from clive.__private.core.beekeeper_manager import WalletsNotAvailableError +from clive.__private.core.commands.abc.command import Command, CommandError from clive.__private.core.commands.abc.command_encryption import CommandEncryption from clive.__private.core.encryption import EncryptionService from clive.__private.core.wallet_container import WalletContainer @@ -12,6 +15,13 @@ if TYPE_CHECKING: from clive.__private.core.profile import Profile +class ProfileSavingFailedError(CommandError): + MESSAGE: Final[str] = "Profile saving failed because beekeeper is not available." + + def __init__(self, command: Command) -> None: + super().__init__(command, self.MESSAGE) + + @dataclass(kw_only=True) class SaveProfile(CommandEncryption, Command): profile: Profile @@ -22,4 +32,7 @@ class SaveProfile(CommandEncryption, Command): async def _execute(self) -> None: encryption_service = EncryptionService(WalletContainer(self.unlocked_wallet, self.unlocked_encryption_wallet)) - await self.profile.save(encryption_service) + try: + await self.profile.save(encryption_service) + except (CommunicationError, WalletsNotAvailableError) as error: + raise ProfileSavingFailedError(self) from error diff --git a/clive/__private/core/error_handlers/general_error_notificator.py b/clive/__private/core/error_handlers/general_error_notificator.py index 8bce4a4999..c10a940f9b 100644 --- a/clive/__private/core/error_handlers/general_error_notificator.py +++ b/clive/__private/core/error_handlers/general_error_notificator.py @@ -5,6 +5,7 @@ from typing import Final, TypeGuard from beekeepy.exceptions import InvalidPasswordError, NotExistingKeyError, NoWalletWithSuchNameError from clive.__private.core.commands.recover_wallets import CannotRecoverWalletsError +from clive.__private.core.commands.save_profile import ProfileSavingFailedError from clive.__private.core.error_handlers.abc.error_notificator import ErrorNotificator from clive.__private.storage.service import ProfileEncryptionError @@ -18,6 +19,7 @@ class GeneralErrorNotificator(ErrorNotificator[Exception]): NotExistingKeyError: "Key does not exist in the wallet.", ProfileEncryptionError: "Profile encryption failed which means profile cannot be saved or loaded.", CannotRecoverWalletsError: CannotRecoverWalletsError.MESSAGE, + ProfileSavingFailedError: ProfileSavingFailedError.MESSAGE, } def __init__(self) -> None: -- GitLab From 515a76ca3bfae8d9e57b84025ec05eb6f8ee40a3 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Tue, 25 Mar 2025 14:13:21 +0100 Subject: [PATCH 187/192] Store hash of last saved profile in runtime profile --- clive/__private/core/commands/save_profile.py | 2 +- clive/__private/core/profile.py | 22 +++++++++++++++++++ clive/__private/storage/model.py | 3 +++ clive/__private/storage/service.py | 11 ++++++++-- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/clive/__private/core/commands/save_profile.py b/clive/__private/core/commands/save_profile.py index dbe4d0a29e..c22cebf0a1 100644 --- a/clive/__private/core/commands/save_profile.py +++ b/clive/__private/core/commands/save_profile.py @@ -28,7 +28,7 @@ class SaveProfile(CommandEncryption, Command): @property def _should_skip_execution(self) -> bool: - return self.profile.is_skip_save_set + return not self.profile.should_be_saved async def _execute(self) -> None: encryption_service = EncryptionService(WalletContainer(self.unlocked_wallet, self.unlocked_encryption_wallet)) diff --git a/clive/__private/core/profile.py b/clive/__private/core/profile.py index 3e9a3000f5..d8607c28fa 100644 --- a/clive/__private/core/profile.py +++ b/clive/__private/core/profile.py @@ -13,6 +13,7 @@ from clive.__private.logger import logger from clive.__private.models import Transaction from clive.__private.models.schemas import ChainId, OperationRepresentationUnion, OperationUnion from clive.__private.settings import safe_settings +from clive.__private.storage.runtime_to_storage_converter import RuntimeToStorageConverter from clive.__private.storage.service import PersistentStorageService from clive.__private.validators.profile_name_validator import ProfileNameValidator from clive.exceptions import CliveError @@ -85,12 +86,21 @@ class Profile: self._skip_save = False self._is_newly_created = is_newly_created + self._hash_of_stored_profile: int | None = None + + def __hash__(self) -> int: + return hash(RuntimeToStorageConverter(self).create_storage_model()) @property def is_newly_created(self) -> bool: """Determine if the profile is newly created (has not been saved yet).""" return self._is_newly_created + @property + def hash_of_stored_profile(self) -> int | None: + """Return hash of stored profile, None if profile is not stored.""" + return self._hash_of_stored_profile + @classmethod def is_any_profile_saved(cls) -> bool: return bool(cls.list_profiles()) @@ -154,6 +164,10 @@ class Profile: def is_skip_save_set(self) -> bool: return self._skip_save + @property + def should_be_saved(self) -> bool: + return not self.is_skip_save_set and self.hash_of_stored_profile != hash(self) + def skip_saving(self) -> None: logger.debug(f"Skipping saving of profile: {self.name} with id {id(self)}") self._skip_save = True @@ -192,6 +206,7 @@ class Profile: ------ ProfileDoesNotExistsError: If this profile is not stored, it could not be removed. """ + self._unset_hash_of_stored_profile() self.delete_by_name(self.name) @staticmethod @@ -297,6 +312,13 @@ class Profile: is_newly_created=is_newly_created, ) + def _update_hash_of_stored_profile(self, new_hash: int | None = None) -> None: + """Update the hash of stored profile to a given value. None means recalculate.""" + self._hash_of_stored_profile = new_hash if new_hash is not None else hash(self) + + def _unset_hash_of_stored_profile(self) -> None: + self._hash_of_stored_profile = None + def _get_initial_node_address(self, given_node_address: str | HttpUrl | None = None) -> HttpUrl: secret_node_address = self._get_secret_node_address() if secret_node_address: diff --git a/clive/__private/storage/model.py b/clive/__private/storage/model.py index 88f6a45b7c..42d0f94aa4 100644 --- a/clive/__private/storage/model.py +++ b/clive/__private/storage/model.py @@ -65,6 +65,9 @@ class ProfileStorageModel(CliveBaseModel): chain_id: str | None = None node_address: str + def __hash__(self) -> int: + return hash(self.json(indent=4)) + class AlarmStorageModelSchema(AlarmStorageModel): identifier: Any diff --git a/clive/__private/storage/service.py b/clive/__private/storage/service.py index dabe93a49f..f188cd0361 100644 --- a/clive/__private/storage/service.py +++ b/clive/__private/storage/service.py @@ -73,12 +73,17 @@ class PersistentStorageService: or communication with beekeeper failed. """ self._raise_if_profile_with_name_already_exists_on_first_save(profile) - profile.unset_is_newly_created() + profile_hash = hash(profile) + if profile.hash_of_stored_profile == profile_hash: + logger.debug("Invoked save_profile but profile didn't change since last load/save.") + return + profile._update_hash_of_stored_profile(profile_hash) profile_name = profile.name profile_model = RuntimeToStorageConverter(profile).create_storage_model() await self._save_profile_model(profile_name, profile_model) + profile.unset_is_newly_created() async def load_profile(self, profile_name: str) -> Profile: """ @@ -97,7 +102,9 @@ class PersistentStorageService: self._raise_if_profile_not_stored(profile_name) profile_storage_model = await self._find_profile_storage_model_by_name(profile_name) - return StorageToRuntimeConverter(profile_storage_model).create_profile() + profile = StorageToRuntimeConverter(profile_storage_model).create_profile() + profile._update_hash_of_stored_profile() + return profile @classmethod def delete_profile(cls, profile_name: str) -> None: -- GitLab From 3fa5dbe2e9703e994c5fcb905147bcc38211de78 Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Tue, 25 Mar 2025 14:15:47 +0100 Subject: [PATCH 188/192] Watch reactive attribute reactive_profile and save on change --- clive/__private/ui/app.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/clive/__private/ui/app.py b/clive/__private/ui/app.py index 31ee7a602c..51c680b963 100644 --- a/clive/__private/ui/app.py +++ b/clive/__private/ui/app.py @@ -195,6 +195,7 @@ class Clive(App[int]): self._retrigger_update_wallet_lock_status_from_beekeeper, pause=True, ) + self.watch(self.world, "profile_reactive", self.save_profile_in_worker) should_enable_debug_loop = safe_settings.dev.should_enable_debug_loop if should_enable_debug_loop: @@ -389,6 +390,13 @@ class Clive(App[int]): def run_worker_with_screen_remove_guard(self, awaitable: Awaitable[None]) -> None: self.run_worker_with_guard(awaitable, self._screen_remove_guard) + def save_profile_in_worker(self, profile: Profile | None) -> None: + async def impl() -> None: + if profile is not None: + await self.world.commands.save_profile() + + self.app.run_worker(impl(), name="save profile worker", group="save_profile", exclusive=True) + async def _debug_log(self) -> None: logger.debug("===================== DEBUG =====================") logger.debug(f"Currently focused: {self.focused}") -- GitLab From 9ebad6bbd4b242475f679244d43f01f8bc62570f Mon Sep 17 00:00:00 2001 From: Marcin Sobczyk <msobczyk@syncad.com> Date: Mon, 24 Mar 2025 15:23:01 +0100 Subject: [PATCH 189/192] Remove redundant property is_newly_created of profile --- clive/__private/core/profile.py | 20 +++++-------------- clive/__private/storage/service.py | 4 +--- .../storage/storage_to_runtime_converter.py | 1 - 3 files changed, 6 insertions(+), 19 deletions(-) diff --git a/clive/__private/core/profile.py b/clive/__private/core/profile.py index d8607c28fa..5bef4184d2 100644 --- a/clive/__private/core/profile.py +++ b/clive/__private/core/profile.py @@ -64,8 +64,6 @@ class Profile: transaction_file_path: Path | None = None, chain_id: str | None = None, node_address: str | HttpUrl | None = None, - *, - is_newly_created: bool = True, ) -> None: self._assert_no_direct_initialization(init_key) @@ -85,22 +83,21 @@ class Profile: self.set_chain_id(chain_id or self._default_chain_id()) self._skip_save = False - self._is_newly_created = is_newly_created self._hash_of_stored_profile: int | None = None def __hash__(self) -> int: return hash(RuntimeToStorageConverter(self).create_storage_model()) - @property - def is_newly_created(self) -> bool: - """Determine if the profile is newly created (has not been saved yet).""" - return self._is_newly_created - @property def hash_of_stored_profile(self) -> int | None: """Return hash of stored profile, None if profile is not stored.""" return self._hash_of_stored_profile + @property + def is_newly_created(self) -> bool: + """Determine if the profile is newly created (has not been saved yet).""" + return self._hash_of_stored_profile is None + @classmethod def is_any_profile_saved(cls) -> bool: return bool(cls.list_profiles()) @@ -109,9 +106,6 @@ class Profile: def is_only_one_profile_saved(cls) -> bool: return len(cls.list_profiles()) == 1 - def unset_is_newly_created(self) -> None: - self._is_newly_created = False - @property def accounts(self) -> AccountManager: return self._accounts @@ -241,7 +235,6 @@ class Profile: transaction_file_path, chain_id, node_address, - is_newly_created=True, ) @classmethod @@ -294,8 +287,6 @@ class Profile: transaction_file_path: Path | None = None, chain_id: str | None = None, node_address: str | HttpUrl | None = None, - *, - is_newly_created: bool = True, ) -> Self: """Create new instance bypassing blocked direct initializer call.""" return cls( @@ -309,7 +300,6 @@ class Profile: transaction_file_path, chain_id, node_address, - is_newly_created=is_newly_created, ) def _update_hash_of_stored_profile(self, new_hash: int | None = None) -> None: diff --git a/clive/__private/storage/service.py b/clive/__private/storage/service.py index f188cd0361..af02ae5d50 100644 --- a/clive/__private/storage/service.py +++ b/clive/__private/storage/service.py @@ -83,7 +83,6 @@ class PersistentStorageService: profile_model = RuntimeToStorageConverter(profile).create_storage_model() await self._save_profile_model(profile_name, profile_model) - profile.unset_is_newly_created() async def load_profile(self, profile_name: str) -> Profile: """ @@ -238,8 +237,7 @@ class PersistentStorageService: def _raise_if_profile_with_name_already_exists_on_first_save(self, profile: Profile) -> None: profile_name = profile.name - is_newly_created = profile.is_newly_created existing_profile_names = self.list_stored_profile_names() - if is_newly_created and profile_name in existing_profile_names: + if profile.is_newly_created and profile_name in existing_profile_names: raise ProfileAlreadyExistsError(profile_name, existing_profile_names) diff --git a/clive/__private/storage/storage_to_runtime_converter.py b/clive/__private/storage/storage_to_runtime_converter.py index 0f0e097ef8..8ac59d6f60 100644 --- a/clive/__private/storage/storage_to_runtime_converter.py +++ b/clive/__private/storage/storage_to_runtime_converter.py @@ -39,7 +39,6 @@ class StorageToRuntimeConverter: transaction_file_path=self._transaction_file_path_from_storage_model(), chain_id=self._model.chain_id, node_address=self._model.node_address, - is_newly_created=False, ) def _working_account_from_profile_storage_model(self) -> WorkingAccount | None: -- GitLab From 1c12ac4c9d26a3bdfc5041fd4e52d57839a746e8 Mon Sep 17 00:00:00 2001 From: Dan Notestein <dan@syncad.com> Date: Wed, 26 Mar 2025 22:20:59 +0000 Subject: [PATCH 190/192] Edit README.md to fix typo in docker run command (problematic --) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6b1c430f3e..eefdb7d9bb 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The launch command (depending on the branch and chain version you want to use, e like: ```bash -docker pull registry.gitlab.syncad.com/hive/clive/testnet-instance-develop:testnet-instance-latest && docker run -ti --detach-keys 'ctrl-@,ctrl-q' --registry.gitlab.syncad.com/hive/clive/testnet-instance-develop:testnet-instance-latest +docker pull registry.gitlab.syncad.com/hive/clive/testnet-instance-develop:testnet-instance-latest && docker run -ti --detach-keys 'ctrl-@,ctrl-q' registry.gitlab.syncad.com/hive/clive/testnet-instance-develop:testnet-instance-latest ``` If you want to run clive in the interactive CLI mode, you should include the `--cli` flag in the command: -- GitLab From e36de1428a6b9fb2624daf019721edd11f35007b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Thu, 27 Mar 2025 15:33:49 +0100 Subject: [PATCH 191/192] Fix ctrl+p in footer instad of ^p --- clive/__private/ui/forms/form_screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clive/__private/ui/forms/form_screen.py b/clive/__private/ui/forms/form_screen.py index df1f045bd9..a0f10b9160 100644 --- a/clive/__private/ui/forms/form_screen.py +++ b/clive/__private/ui/forms/form_screen.py @@ -25,7 +25,7 @@ class FormScreen(CliveScreen, Contextual[FormContextT], ABC): f"{PREVIOUS_SCREEN_BINDING_KEY},escape", "previous_screen", "Previous screen", - key_display=PREVIOUS_SCREEN_BINDING_KEY, + key_display="^p", ), Binding(NEXT_SCREEN_BINDING_KEY, "next_screen", "Next screen"), ] -- GitLab From 4969fdcd109832258136092d6bc2cd99757153ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= <mzebrak@syncad.com> Date: Fri, 28 Mar 2025 14:26:23 +0100 Subject: [PATCH 192/192] Fix updating hash during save - should be done after actual save Otherwise it is possible saving profile worker is cancelled and hash is updated but the sata is not saved so next update will skip saving --- clive/__private/storage/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clive/__private/storage/service.py b/clive/__private/storage/service.py index af02ae5d50..e7e2341915 100644 --- a/clive/__private/storage/service.py +++ b/clive/__private/storage/service.py @@ -78,11 +78,11 @@ class PersistentStorageService: logger.debug("Invoked save_profile but profile didn't change since last load/save.") return - profile._update_hash_of_stored_profile(profile_hash) profile_name = profile.name profile_model = RuntimeToStorageConverter(profile).create_storage_model() await self._save_profile_model(profile_name, profile_model) + profile._update_hash_of_stored_profile(profile_hash) async def load_profile(self, profile_name: str) -> Profile: """ -- GitLab