Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • hive/clive
1 result
Show changes
Commits on Source (7)
......@@ -88,7 +88,7 @@ class Beekeeper:
if not (Beekeeper.get_remote_address_from_settings() or Beekeeper.get_path_from_settings()):
raise BeekeeperNotConfiguredError
self.__communication = Communication(timeout_secs=self.DEFAULT_TIMEOUT_TOTAL_SECONDS)
self.__communication = Communication(timeout_total_secs=self.DEFAULT_TIMEOUT_TOTAL_SECONDS)
self.__run_in_background = run_in_background
self.is_running = False
self.is_starting = False
......@@ -123,6 +123,7 @@ class Beekeeper:
webserver_thread_pool_size: int = BeekeeperDefaults.DEFAULT_WEBSERVER_THREAD_POOL_SIZE,
webserver_http_endpoint: Url | None = BeekeeperDefaults.DEFAULT_WEBSERVER_HTTP_ENDPOINT,
) -> Self:
await self.__communication.setup()
arguments: BeekeeperCLIArguments = BeekeeperCLIArguments(
help=BeekeeperDefaults.DEFAULT_HELP,
version=BeekeeperDefaults.DEFAULT_VERSION,
......@@ -190,11 +191,11 @@ class Beekeeper:
def modified_connection_details(
self,
max_attempts: int = Communication.DEFAULT_ATTEMPTS,
timeout_secs: float = DEFAULT_TIMEOUT_TOTAL_SECONDS,
timeout_total_secs: float = DEFAULT_TIMEOUT_TOTAL_SECONDS,
pool_time_secs: float = Communication.DEFAULT_POOL_TIME_SECONDS,
) -> Iterator[None]:
"""Allow to temporarily change connection details."""
with self.__communication.modified_connection_details(max_attempts, timeout_secs, pool_time_secs):
with self.__communication.modified_connection_details(max_attempts, timeout_total_secs, pool_time_secs):
yield
@staticmethod
......@@ -268,6 +269,7 @@ class Beekeeper:
self.__token = None
await self.__close_beekeeper()
self.is_running = False
await self.__communication.teardown()
logger.info("Beekeeper closed.")
def attach_wallet_closing_listener(self, listener: WalletClosingListener) -> None:
......
......@@ -50,7 +50,6 @@ from clive.__private.core.commands.update_transaction_metadata import (
from clive.__private.core.error_handlers.abc.error_handler_context_manager import (
ResultNotAvailable,
)
from clive.__private.core.error_handlers.async_closed import AsyncClosedErrorHandler
from clive.__private.core.error_handlers.communication_failure_notificator import CommunicationFailureNotificator
from clive.__private.core.error_handlers.general_error_notificator import GeneralErrorNotificator
from clive.__private.ui.clive_dom_node import CliveDOMNode
......@@ -91,7 +90,7 @@ class Commands(Generic[WorldT_co]):
super().__init__(*args, **kwargs)
self._world = world
self.__exception_handlers = [AsyncClosedErrorHandler, *(exception_handlers or [])]
self.__exception_handlers = [*(exception_handlers or [])]
async def create_wallet(self, *, password: str | None) -> CommandWithResultWrapper[str]:
return await self.__surround_with_exception_handlers(
......@@ -406,7 +405,7 @@ class Commands(Generic[WorldT_co]):
return self.__create_command_wrapper(command)
return await self.__surround_with_exception_handler(command, self.__exception_handlers) # type: ignore[arg-type]
return await self.__surround_with_exception_handler(command, self.__exception_handlers)
@overload
async def __surround_with_exception_handler( # type: ignore[overload-overlap]
......
......@@ -9,6 +9,7 @@ from json import JSONDecodeError
from typing import TYPE_CHECKING, Any, Final
import aiohttp
from typing_extensions import Self
from clive.__private.core._async import asyncio_run
from clive.__private.core.constants.date import TIME_FORMAT_WITH_MILLIS
......@@ -17,6 +18,7 @@ from clive.exceptions import CliveError, CommunicationError, CommunicationTimeou
if TYPE_CHECKING:
from collections.abc import Iterator
from types import TracebackType
from clive.__private.core.beekeeper.notification_http_server import JsonT
from clive.exceptions import CommunicationResponseT
......@@ -47,38 +49,62 @@ class Communication:
self,
*,
max_attempts: int = DEFAULT_ATTEMPTS,
timeout_secs: float = DEFAULT_TIMEOUT_TOTAL_SECONDS,
timeout_total_secs: float = DEFAULT_TIMEOUT_TOTAL_SECONDS,
pool_time_secs: float = DEFAULT_POOL_TIME_SECONDS,
) -> None:
self.__max_attempts = max_attempts
self.__timeout_secs = timeout_secs
self.__timeout_total_secs = timeout_total_secs
self.__pool_time_secs = pool_time_secs
self.__session: aiohttp.ClientSession | None = None
assert self.__max_attempts > 0, "Max attempts must be greater than 0."
assert self.__timeout_secs > 0, "Timeout must be greater than 0."
assert self.__timeout_total_secs > 0, "Timeout must be greater than 0."
assert self.__pool_time_secs >= 0, "Pool time must be greater or equal to 0."
async def __aenter__(self) -> Self:
await self.setup()
return self
async def __aexit__(
self, _: type[BaseException] | None, __: BaseException | None, ___: TracebackType | None
) -> None:
await self.teardown()
async def setup(self) -> None:
if self.__session is None:
self.__session = aiohttp.ClientSession()
async def teardown(self) -> None:
if self.__session is not None:
await self.__session.close()
self.__session = None
@property
def _session(self) -> aiohttp.ClientSession:
assert self.__session is not None, "Session is not started."
return self.__session
@contextmanager
def modified_connection_details(
self,
max_attempts: int = DEFAULT_ATTEMPTS,
timeout_secs: float = DEFAULT_TIMEOUT_TOTAL_SECONDS,
timeout_total_secs: float = DEFAULT_TIMEOUT_TOTAL_SECONDS,
pool_time_secs: float = DEFAULT_POOL_TIME_SECONDS,
) -> Iterator[None]:
"""Temporarily change connection details."""
before = {
"max_attempts": self.__max_attempts,
"timeout_secs": self.__timeout_secs,
"timeout_total_secs": self.__timeout_total_secs,
"pool_time_secs": self.__pool_time_secs,
}
self.__max_attempts = max_attempts
self.__timeout_secs = timeout_secs
self.__timeout_total_secs = timeout_total_secs
self.__pool_time_secs = pool_time_secs
try:
yield
finally:
self.__max_attempts = before["max_attempts"] # type: ignore[assignment]
self.__timeout_secs = before["timeout_secs"]
self.__timeout_total_secs = before["timeout_total_secs"]
self.__pool_time_secs = before["pool_time_secs"]
def request( # noqa: PLR0913
......@@ -87,7 +113,7 @@ class Communication:
*,
data: Any, # noqa: ANN401
max_attempts: int | None = None,
timeout_secs: float | None = None,
timeout_total_secs: float | None = None,
pool_time_secs: float | None = None,
) -> aiohttp.ClientResponse:
"""
......@@ -99,12 +125,16 @@ class Communication:
url: url to send request to.
data: data to send.
max_attempts: max attempts to send request (will override the value given during Communication creation)
timeout_secs: timeout in seconds (will override the value given during Communication creation)
timeout_total_secs: timeout in seconds (will override the value given during Communication creation)
pool_time_secs: time to wait between attempts (will override the value given during Communication creation)
"""
return asyncio_run(
self.__request(
url, data=data, max_attempts=max_attempts, timeout_secs=timeout_secs, pool_time_secs=pool_time_secs
url,
data=data,
max_attempts=max_attempts,
timeout_total_secs=timeout_total_secs,
pool_time_secs=pool_time_secs,
)
)
......@@ -114,7 +144,7 @@ class Communication:
*,
data: Any, # noqa: ANN401
max_attempts: int | None = None,
timeout_secs: float | None = None,
timeout_total_secs: float | None = None,
pool_time_secs: float | None = None,
) -> aiohttp.ClientResponse:
"""
......@@ -126,11 +156,15 @@ class Communication:
url: url to send request to.
data: data to send.
max_attempts: max attempts to send request (will override the value given during Communication creation)
timeout_secs: timeout in seconds (will override the value given during Communication creation)
timeout_total_secs: timeout in seconds (will override the value given during Communication creation)
pool_time_secs: time to wait between attempts (will override the value given during Communication creation)
"""
return await self.__request(
url, data=data, max_attempts=max_attempts, timeout_secs=timeout_secs, pool_time_secs=pool_time_secs
url,
data=data,
max_attempts=max_attempts,
timeout_total_secs=timeout_total_secs,
pool_time_secs=pool_time_secs,
)
async def __request( # noqa: PLR0913, C901, PLR0915
......@@ -139,15 +173,15 @@ class Communication:
*,
data: Any, # noqa: ANN401
max_attempts: int | None = None,
timeout_secs: float | None = None,
timeout_total_secs: float | None = None,
pool_time_secs: float | None = None,
) -> aiohttp.ClientResponse:
_max_attempts = max_attempts or self.__max_attempts
_timeout_secs = timeout_secs or self.__timeout_secs
_timeout_total_secs = timeout_total_secs or self.__timeout_total_secs
_pool_time_secs = pool_time_secs or self.__pool_time_secs
assert _max_attempts > 0, "Max attempts must be greater than 0."
assert _timeout_secs > 0, "Timeout must be greater than 0."
assert _timeout_total_secs > 0, "Timeout must be greater than 0."
assert _pool_time_secs >= 0, "Pool time must be greater or equal to 0."
result: CommunicationResponseT | None = None
......@@ -164,48 +198,48 @@ class Communication:
await asyncio.sleep(_pool_time_secs)
def raise_timeout_error(context: str, error_: asyncio.TimeoutError) -> None:
raise CommunicationTimeoutError(url, data_serialized, _timeout_secs, context) from error_
raise CommunicationTimeoutError(url, data_serialized, _timeout_total_secs, context) from error_
while attempt < _max_attempts:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=_timeout_secs)) as session:
try:
response = await self._session.post(
url,
data=data_serialized,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=_timeout_total_secs),
)
except aiohttp.ClientError as error:
logger.error(f"ClientError occurred: {error} from {url=}, request={data_serialized}")
await next_try(error)
continue
except asyncio.TimeoutError as error:
raise_timeout_error("performing request", error)
except Exception as error: # noqa: BLE001
logger.error(f"Unexpected error occurred: {error} from {url=}, request={data_serialized}")
await next_try(error)
continue
if response.ok:
try:
response = await session.post(
url,
data=data_serialized,
headers={"Content-Type": "application/json"},
)
except aiohttp.ClientError as error:
logger.error(f"ClientError occurred: {error} from {url=}, request={data_serialized}")
await next_try(error)
continue
except asyncio.TimeoutError as error:
raise_timeout_error("performing request", error)
except Exception as error: # noqa: BLE001
logger.error(f"Unexpected error occurred: {error} from {url=}, request={data_serialized}")
await next_try(error)
continue
if response.ok:
result = await response.json()
except (aiohttp.ContentTypeError, JSONDecodeError) as error:
try:
result = await response.json()
except (aiohttp.ContentTypeError, JSONDecodeError) as error:
try:
result = await response.text()
except asyncio.TimeoutError as timeout_error:
raise_timeout_error("reading text response", timeout_error)
else:
await next_try(error)
continue
except asyncio.TimeoutError as error:
raise_timeout_error("reading json response", error)
with contextlib.suppress(ErrorInResponseJsonError, NullResultInResponseJsonError):
self.__check_response(url=url, request=data_serialized, result=result)
await response.read() # Ensure response is available outside of the context manager.
return response
else:
logger.error(f"Received bad status code: {response.status} from {url=}, request={data_serialized}")
result = await response.text()
result = await response.text()
except asyncio.TimeoutError as timeout_error:
raise_timeout_error("reading text response", timeout_error)
else:
await next_try(error)
continue
except asyncio.TimeoutError as error:
raise_timeout_error("reading json response", error)
with contextlib.suppress(ErrorInResponseJsonError, NullResultInResponseJsonError):
self.__check_response(url=url, request=data_serialized, result=result)
await response.read() # Ensure response is available outside of the context manager.
return response
else:
logger.error(f"Received bad status code: {response.status} from {url=}, request={data_serialized}")
result = await response.text()
await next_try()
......
from __future__ import annotations
from typing import TypeGuard
from clive.__private.core.error_handlers.abc.error_handler_context_manager import (
ErrorHandlerContextManager,
ResultNotAvailable,
)
from clive.__private.logger import logger
class AsyncClosedErrorHandler(ErrorHandlerContextManager[AssertionError]):
"""A context manager that notifies about errors."""
def _is_exception_to_catch(self, error: Exception) -> TypeGuard[AssertionError]:
return isinstance(error, AssertionError) and "Session is closed" in str(error)
def _handle_error(self, error: AssertionError) -> ResultNotAvailable:
logger.warning("Suppressed `Session is closed` exception, application is closing?")
return ResultNotAvailable(error)
......@@ -376,11 +376,26 @@ class Node(BaseNode):
def __init__(self, profile: Profile) -> None:
self.__profile = profile
self.__communication = Communication(timeout_secs=self.DEFAULT_TIMEOUT_TOTAL_SECONDS)
self.__communication = Communication(timeout_total_secs=self.DEFAULT_TIMEOUT_TOTAL_SECONDS)
self.api = Apis(self)
self.cached = self.CachedData(self)
self.__network_type = ""
async def __aenter__(self) -> Self:
await self.setup()
return self
async def __aexit__(
self, _: type[BaseException] | None, __: BaseException | None, ___: TracebackType | None
) -> None:
await self.teardown()
async def setup(self) -> None:
await self.__communication.setup()
async def teardown(self) -> None:
await self.__communication.teardown()
def batch(self, *, delay_error_on_data_access: bool = False) -> _BatchNode:
"""
In this mode all requests will be send as one request.
......@@ -401,11 +416,11 @@ class Node(BaseNode):
def modified_connection_details(
self,
max_attempts: int = Communication.DEFAULT_ATTEMPTS,
timeout_secs: float = DEFAULT_TIMEOUT_TOTAL_SECONDS,
timeout_total_secs: float = DEFAULT_TIMEOUT_TOTAL_SECONDS,
pool_time_secs: float = Communication.DEFAULT_POOL_TIME_SECONDS,
) -> Iterator[None]:
"""Temporarily change connection details."""
with self.__communication.modified_connection_details(max_attempts, timeout_secs, pool_time_secs):
with self.__communication.modified_connection_details(max_attempts, timeout_total_secs, pool_time_secs):
yield
@property
......
......@@ -85,7 +85,7 @@ class World:
def modified_connection_details(
self,
max_attempts: int = Communication.DEFAULT_ATTEMPTS,
timeout_secs: float = Communication.DEFAULT_TIMEOUT_TOTAL_SECONDS,
timeout_total_secs: float = Communication.DEFAULT_TIMEOUT_TOTAL_SECONDS,
pool_time_secs: float = Communication.DEFAULT_POOL_TIME_SECONDS,
target: Literal["beekeeper", "node", "all"] = "all",
) -> Iterator[None]:
......@@ -93,10 +93,12 @@ class World:
contexts_to_enter = []
if target in ("beekeeper", "all"):
contexts_to_enter.append(
self.beekeeper.modified_connection_details(max_attempts, timeout_secs, pool_time_secs)
self.beekeeper.modified_connection_details(max_attempts, timeout_total_secs, pool_time_secs)
)
if target in ("node", "all"):
contexts_to_enter.append(self.node.modified_connection_details(max_attempts, timeout_secs, pool_time_secs))
contexts_to_enter.append(
self.node.modified_connection_details(max_attempts, timeout_total_secs, pool_time_secs)
)
with contextlib.ExitStack() as stack:
for context in contexts_to_enter:
......@@ -104,12 +106,14 @@ class World:
yield
async def setup(self) -> Self:
await self._node.setup()
if self._use_beekeeper:
self._beekeeper = await self.__setup_beekeeper(remote_endpoint=self._beekeeper_remote_endpoint)
return self
async def close(self) -> None:
self.profile.save()
await self._node.teardown()
if self._beekeeper is not None:
await self._beekeeper.close()
......
......@@ -53,6 +53,23 @@ class ButtonDelete(CliveButton):
super().__init__("Remove", id_="delete-button", variant="error")
@dataclass
class CartItemsActionManager:
"""Object used for disabling actions like move/delete on cart items while another action is in progress."""
_is_action_disabled: bool = False
@property
def is_action_disabled(self) -> bool:
return self._is_action_disabled
def enable_action(self) -> None:
self._is_action_disabled = False
def disable_action(self) -> None:
self._is_action_disabled = True
class CartItem(CliveCheckerboardTableRow, CliveWidget):
"""Row of CartTable."""
......@@ -78,19 +95,11 @@ class CartItem(CliveCheckerboardTableRow, CliveWidget):
target_index: int
def __init__(self, operation_index: int) -> None:
def __init__(self, operation_index: int, action_manager: CartItemsActionManager) -> None:
assert self._is_operation_index_valid(operation_index), "During construction, operation index has to be valid"
self._operation_index = operation_index
self._is_already_deleted = False
"""This could be a situation where the user is trying to delete an operation that is already deleted
(textual has not yet deleted the widget visually). This situation is possible if the user clicks so quickly
to remove an operation and there are a large number of operations in the shopping basket."""
self._is_already_moving = False
"""Used to prevent user from moving item when the previous move is not finished yet."""
super().__init__(*self._create_cells())
self._action_manager = action_manager
def __repr__(self) -> str:
return f"{self.__class__.__name__}(operation_index={self._operation_index})"
......@@ -182,9 +191,6 @@ class CartItem(CliveCheckerboardTableRow, CliveWidget):
focus_first_focusable_button()
return self
def unset_moving_flag(self) -> None:
self._is_already_moving = False
@on(CliveButton.Pressed, "#move-up-button")
def move_up(self) -> None:
self._move("up")
......@@ -195,10 +201,10 @@ class CartItem(CliveCheckerboardTableRow, CliveWidget):
@on(CliveButton.Pressed, "#delete-button")
def delete(self) -> None:
if self._is_already_deleted:
if self._action_manager.is_action_disabled:
return
self._is_already_deleted = True
self._action_manager.disable_action()
self.post_message(self.Delete(self))
def _create_cells(self) -> list[CliveCheckerBoardTableCell]:
......@@ -219,9 +225,10 @@ class CartItem(CliveCheckerboardTableRow, CliveWidget):
return value < self.operations_amount
def _move(self, direction: Literal["up", "down"]) -> None:
if self._is_already_moving:
if self._action_manager.is_action_disabled:
return
self._is_already_moving = True
self._action_manager.disable_action()
index_change = -1 if direction == "up" else 1
self.post_message(self.Move(from_index=self._operation_index, to_index=self._operation_index + index_change))
......@@ -239,6 +246,7 @@ class CartTable(CliveCheckerboardTable):
def __init__(self) -> None:
super().__init__(header=CartHeader())
self._cart_items_action_manager = CartItemsActionManager()
@property
def _cart_items(self) -> DOMQuery[CartItem]:
......@@ -249,18 +257,21 @@ class CartTable(CliveCheckerboardTable):
return bool(self._cart_items)
def create_static_rows(self) -> list[CartItem]:
return [CartItem(index) for index in range(len(self.profile.cart))]
return [CartItem(index, self._cart_items_action_manager) for index in range(len(self.profile.cart))]
@on(CartItem.Delete)
async def remove_item(self, event: CartItem.Delete) -> None:
item_to_remove = event.widget
with self.app.batch_update():
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)
self._disable_appropriate_button_on_deletion(removed_item=item_to_remove)
self.profile.cart.remove(item_to_remove.operation)
self._cart_items_action_manager.enable_action()
self.app.trigger_profile_watchers()
@on(CartItem.Move)
......@@ -274,9 +285,9 @@ class CartTable(CliveCheckerboardTable):
with self.app.batch_update():
self._update_values_of_swapped_rows(from_index=from_index, to_index=to_index)
self._focus_item_on_move(to_index)
self._unset_moving_flags()
self.profile.cart.swap(from_index, to_index)
self._cart_items_action_manager.enable_action()
self.app.trigger_profile_watchers()
@on(CartItem.Focus)
......@@ -343,10 +354,6 @@ class CartTable(CliveCheckerboardTable):
cart_item.focus()
break
def _unset_moving_flags(self) -> None:
for cart_item in self._cart_items:
cart_item.unset_moving_flag()
class Cart(BaseScreen):
CSS_PATH = [get_relative_css_path(__file__)]
......
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING, Any, Final
from typing import TYPE_CHECKING, Any
from rich.highlighter import Highlighter
from textual import on
from textual.binding import Binding
from textual.containers import Container
from textual.widgets import Select, Static
from textual.widgets._select import NoSelection
from clive.__private.core.communication import Communication
from clive.__private.core.date_utils import utc_now
from clive.__private.core.url import Url
from clive.__private.models.schemas import JSONRPCRequest
from clive.__private.ui.clive_widget import CliveWidget
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.clive_button import CliveButton
from clive.__private.ui.widgets.section import SectionScrollable
from clive.exceptions import CommunicationError, NodeAddressError
from clive.exceptions import NodeAddressError
if TYPE_CHECKING:
from rich.console import RenderableType
from rich.text import Text
from textual.app import ComposeResult
......@@ -52,36 +47,6 @@ class NodesList(Container, CliveWidget):
yield NodeSelector()
class NodeUrlHighlighter(Highlighter):
def __init__(self) -> None:
self.__last_highlight_time = utc_now()
self.__last_style = "white"
super().__init__()
def __check_and_update_highlight_period(self) -> bool:
highlight_period: Final[int] = 1
now = utc_now()
if (now - self.__last_highlight_time).seconds >= highlight_period:
self.__last_highlight_time = now
return True
return False
def is_valid_url(self, url: str) -> bool:
try:
Communication().request(url, data=JSONRPCRequest(method="database_api.get_config"))
except CommunicationError:
return False
return True
def highlight(self, text: Text) -> None:
if self.__check_and_update_highlight_period():
if self.is_valid_url(str(text)):
self.__last_style = "green"
else:
self.__last_style = "red"
text.stylize(self.__last_style)
class SetNodeAddressBase(BaseScreen, ABC):
CSS_PATH = [get_relative_css_path(__file__)]
......
......@@ -84,6 +84,12 @@ class AlarmDisplay(DynamicOneLineButtonUnfocusable):
def push_account_details_screen(self) -> None:
from clive.__private.ui.screens.account_details.account_details import AccountDetails
def is_current_screen_account_details() -> bool:
return isinstance(self.app.screen, AccountDetails)
if is_current_screen_account_details():
return
if self._is_in_auto_working_account_mode() and not self.profile.accounts.has_working_account:
return
......
......@@ -27,6 +27,14 @@ class CliveButton(Button, CliveWidget):
DEFAULT_CSS = """
CliveButton {
&.-success {
background: $success-darken-1;
&:hover {
background: $success-darken-3;
}
}
&.-loading-variant {
background: $panel-lighten-2;
......