diff --git a/Dockerfile b/Dockerfile index 6818f4f2..6b02782a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,19 +61,12 @@ RUN . ~/.bash_profile \ && poetry config virtualenvs.create false # install python packages -USER apl -COPY pyproject.toml /app/pyproject.toml -COPY poetry.lock /app/poetry.lock -RUN . ~/.bash_profile \ - && poetry install --directory /app --only main --no-root \ - && rm -f /app/pyproject.toml \ - && rm -f /app/poetry.lock - -# app deploy USER root COPY --chown=apl:apl LICENSE /app/ibet-Prime/ RUN mkdir -p /app/ibet-Prime/bin/ COPY --chown=apl:apl bin/ /app/ibet-Prime/bin/ +RUN mkdir -p /app/ibet-Prime/cmd/ +COPY --chown=apl:apl cmd/ /app/ibet-Prime/cmd/ RUN mkdir -p /app/ibet-Prime/contracts/ COPY --chown=apl:apl contracts/ /app/ibet-Prime/contracts/ RUN mkdir -p /app/ibet-Prime/conf/ @@ -88,6 +81,15 @@ COPY --chown=apl:apl app/ /app/ibet-Prime/app/ RUN find /app/ibet-Prime/ -type d -name __pycache__ | xargs rm -fr \ && chmod -R 755 /app/ibet-Prime/ +USER apl +COPY pyproject.toml /app/ibet-Prime/pyproject.toml +COPY poetry.lock /app/ibet-Prime/poetry.lock +RUN . ~/.bash_profile \ + && cd /app/ibet-Prime \ + && poetry install --only main --no-root -E ibet-explorer \ + && rm -f /app/ibet-Prime/pyproject.toml \ + && rm -f /app/ibet-Prime/poetry.lock + # command deploy USER apl COPY run.sh healthcheck.sh /app/ diff --git a/README.md b/README.md index bf44471b..ea8ae59e 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ English | [日本語](./README_JA.md) Install python packages with: ```bash -$ pip install -r requirements.txt +$ poetry install --no-root --only main -E explorer ``` ### Setting environment variables @@ -68,13 +68,13 @@ The main environment variables are as follows. DATABASE_URL False Database URL - postgresql://issuerapi:issuerapipass@localhost:5432/issuerapidb + postgresql+psycopg://issuerapi:issuerapipass@localhost:5432/issuerapidb TEST_DATABASE_URL False Test database URL - postgresql://issuerapi:issuerapipass@localhost:5432/issuerapidb + postgresql+psycopg://issuerapi:issuerapipass@localhost:5432/issuerapidb DATABASE_SCHEMA diff --git a/README_JA.md b/README_JA.md index 792b4200..6cbd3e76 100644 --- a/README_JA.md +++ b/README_JA.md @@ -51,7 +51,7 @@ 以下のコマンドで Python パッケージをインストールします。 ```bash -$ pip install -r requirements.txt +$ poetry install --no-root --only main -E explorer ``` ### 環境変数の設定 @@ -69,13 +69,13 @@ $ pip install -r requirements.txt DATABASE_URL False データベース URL - postgresql://issuerapi:issuerapipass@localhost:5432/issuerapidb + postgresql+psycopg://issuerapi:issuerapipass@localhost:5432/issuerapidb TEST_DATABASE_URL False テスト用データベース URL - postgresql://issuerapi:issuerapipass@localhost:5432/issuerapidb + postgresql+psycopg://issuerapi:issuerapipass@localhost:5432/issuerapidb DATABASE_SCHEMA diff --git a/app/model/schema/__init__.py b/app/model/schema/__init__.py index c1ccbd5e..c8530f01 100644 --- a/app/model/schema/__init__.py +++ b/app/model/schema/__init__.py @@ -41,8 +41,10 @@ # Response BlockDataResponse, BlockDataListResponse, + BlockDataDetail, TxDataResponse, - TxDataListResponse + TxDataListResponse, + TxDataDetail ) from .bulk_transfer import ( # Response diff --git a/app/model/schema/bc_explorer.py b/app/model/schema/bc_explorer.py index 322fbd4e..3233ebfa 100644 --- a/app/model/schema/bc_explorer.py +++ b/app/model/schema/bc_explorer.py @@ -28,7 +28,10 @@ from pydantic.dataclasses import dataclass from web3 import Web3 -from .types import ResultSet +from .types import ( + ResultSet, + SortOrder +) ############################ @@ -44,6 +47,7 @@ class BlockData(BaseModel): gas_used: int size: NonNegativeInt + class BlockDataDetail(BaseModel): number: NonNegativeInt = Field(description="Block number") parent_hash: str @@ -64,6 +68,7 @@ class BlockDataDetail(BaseModel): size: NonNegativeInt transactions: list[str] = Field(description="Transaction list") + class TxData(BaseModel): hash: str = Field(description="Transaction hash") block_hash: str @@ -72,6 +77,7 @@ class TxData(BaseModel): from_address: str to_address: Optional[str] + class TxDataDetail(BaseModel): hash: str = Field(description="Transaction hash") block_hash: str @@ -87,6 +93,7 @@ class TxDataDetail(BaseModel): value: NonNegativeInt nonce: NonNegativeInt + ############################ # REQUEST ############################ @@ -97,6 +104,8 @@ class ListBlockDataQuery: limit: Optional[NonNegativeInt] = Query(default=None, description="number of set") from_block_number: Optional[NonNegativeInt] = Query(default=None) to_block_number: Optional[NonNegativeInt] = Query(default=None) + sort_order: Optional[SortOrder] = Query(default=SortOrder.ASC, description="sort order(0: ASC, 1: DESC)") + @dataclass class ListTxDataQuery: @@ -120,6 +129,7 @@ def to_address_is_valid_address(cls, v): raise ValueError("to_address is not a valid address") return v + ############################ # RESPONSE ############################ @@ -127,13 +137,16 @@ def to_address_is_valid_address(cls, v): class BlockDataResponse(BaseModel): __root__: BlockDataDetail + class BlockDataListResponse(BaseModel): result_set: ResultSet block_data: list[BlockData] + class TxDataResponse(BaseModel): __root__: TxDataDetail + class TxDataListResponse(BaseModel): result_set: ResultSet tx_data: list[TxData] diff --git a/app/routers/bc_explorer.py b/app/routers/bc_explorer.py index 43f0e649..83d21e7e 100644 --- a/app/routers/bc_explorer.py +++ b/app/routers/bc_explorer.py @@ -16,18 +16,20 @@ SPDX-License-Identifier: Apache-2.0 """ -from pathlib import Path from typing import Tuple, Dict, Any, Type from eth_utils import to_checksum_address from fastapi import ( APIRouter, - Depends, HTTPException + Depends, + HTTPException, + Path ) from pydantic import NonNegativeInt from sqlalchemy import desc from sqlalchemy.orm import Session from web3.contract import ContractFunction +import config from app import log from app.database import db_session @@ -36,7 +38,9 @@ ) from app.model.db import ( IDXBlockData, - IDXTxData, Token + IDXTxData, + Token, + IDXBlockDataBlockNumber ) from app.model.schema import ( ListBlockDataQuery, @@ -90,9 +94,27 @@ def list_block_data( limit = request_query.limit from_block_number = request_query.from_block_number to_block_number = request_query.to_block_number + sort_order = request_query.sort_order # default: asc + + # NOTE: The more data, the slower the SELECT COUNT(1) query becomes. + # To get total number of block data, latest block number where block data synced is used here. + idx_block_data_block_number = ( + session.query(IDXBlockDataBlockNumber).filter(IDXBlockDataBlockNumber.chain_id == str(config.CHAIN_ID)).first() + ) + if idx_block_data_block_number is None: + return json_response({ + "result_set": { + "count": 0, + "offset": offset, + "limit": limit, + "total": 0 + }, + "block_data": [] + }) + + total = idx_block_data_block_number.latest_block_number + 1 query = session.query(IDXBlockData) - total = query.count() # Search Filter if from_block_number is not None and to_block_number is not None: @@ -105,7 +127,10 @@ def list_block_data( count = query.count() # Sort - query = query.order_by(IDXBlockData.number) + if sort_order == 0: + query = query.order_by(IDXBlockData.number) + else: + query = query.order_by(desc(IDXBlockData.number)) # Pagination if limit is not None: diff --git a/cmd/explorer/Makefile b/cmd/explorer/Makefile new file mode 100644 index 00000000..7fe492ed --- /dev/null +++ b/cmd/explorer/Makefile @@ -0,0 +1,21 @@ +.PHONY: isort black test run + +format: isort black + +isort: + isort src/. + +black: + poetry run black src + +test: + pytest . + +console: + textual console + +dev: + TEXTUAL=devtools poetry run python src/main.py + +run: + poetry run python src/main.py \ No newline at end of file diff --git a/cmd/explorer/README.md b/cmd/explorer/README.md new file mode 100644 index 00000000..188d29f2 --- /dev/null +++ b/cmd/explorer/README.md @@ -0,0 +1,44 @@ +# ibet-Prime BC-Explorer + +## Run + +### with container + +```bash +> docker exec -it -e "TERM=xterm-256color" ibet-prime-app bash --login +> apl@2e5a80e06fcb:/$ ibet-explorer --help + + Usage: ibet-explorer [OPTIONS] + +╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ --url TEXT ibet-Prime server URL to connect [default: http://localhost:5000] │ +│ --lot-size INTEGER Lot size to fetch Block Data list [default: 100] │ +│ --install-completion [bash|zsh|fish|powershell|pwsh] Install completion for the specified shell. [default: None] │ +│ --show-completion [bash|zsh|fish|powershell|pwsh] Show completion for the specified shell, to copy it or customize the installation. [default: None] │ +│ --help Show this message and exit. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +``` + +- **URL**: ibet-Prime URL. +- You can run this on pythonic way in local. + +### Poetry +```bash +> poetry install +> poetry run python src/main.py --url http://localhost:5000 +``` + +### Pip +```bash +> pip install -e ./ +> python src/main.py --url http://localhost:5000 +``` + +## Screenshots 👀 + +![query-setting](https://user-images.githubusercontent.com/15183665/222354993-0c11eedc-fb22-472a-8c9f-f9bc8be4d173.png) + +![block](https://user-images.githubusercontent.com/15183665/222355008-0c893524-2a80-4975-9c44-537649b11fc7.png) + +![transaction](https://user-images.githubusercontent.com/15183665/222355025-24b72685-8d27-48e5-9ea1-b265c4365629.png) + diff --git a/cmd/explorer/poetry.lock b/cmd/explorer/poetry.lock new file mode 100644 index 00000000..4e734b46 --- /dev/null +++ b/cmd/explorer/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.0" +python-versions = "3.10.4" +content-hash = "27d3391f1c2b128fc1ae428a33310a6bd3ed1b1f8ee7a44bd6f546eb287b334e" diff --git a/cmd/explorer/pyproject.toml b/cmd/explorer/pyproject.toml new file mode 100644 index 00000000..cf27734e --- /dev/null +++ b/cmd/explorer/pyproject.toml @@ -0,0 +1,30 @@ +[tool.poetry] +name = "ibet-prime-explorer" +version = "0.1.0" +description = "ibet-Prime Terminal UI for Block Chain Explorer" +authors = ["BOOSTRY Co., Ltd. "] +readme = "README.md" +packages = [ + { include = "src" }, +] + +[tool.poetry.dependencies] +python = "3.10.4" + +[tool.poetry.scripts] +ibet-explorer = "src.main:app" + +[tool.mypy] +python_version = "3.10" +no_strict_optional = true +ignore_missing_imports = true +check_untyped_defs = true + +[tool.black] +includes = "src" +target-version = ['py310'] +line-length = 120 + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/cmd/explorer/src/__init__.py b/cmd/explorer/src/__init__.py new file mode 100644 index 00000000..5ebbd941 --- /dev/null +++ b/cmd/explorer/src/__init__.py @@ -0,0 +1,18 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" diff --git a/cmd/explorer/src/connector/__init__.py b/cmd/explorer/src/connector/__init__.py new file mode 100644 index 00000000..532829c4 --- /dev/null +++ b/cmd/explorer/src/connector/__init__.py @@ -0,0 +1,88 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +import os +import sys +from dataclasses import asdict +from typing import Any + +from aiohttp import ClientSession +from cache import AsyncTTL + +path = os.path.join(os.path.dirname(__file__), "../../../../") +sys.path.append(path) + +from app.model.schema import ( + BlockDataDetail, + BlockDataListResponse, + BlockNumberResponse, + ListBlockDataQuery, + ListTxDataQuery, + TxDataDetail, + TxDataListResponse, +) + + +class ApiNotEnabledException(Exception): + pass + + +async def health_check(url: str, session: ClientSession) -> None: + async with session.get(url=f"{url}/") as resp: + await resp.json() + + +@AsyncTTL(time_to_live=10, skip_args=1) +async def get_block_number(session: ClientSession, url: str) -> BlockNumberResponse: + async with session.get(url=f"{url}/block_number") as resp: + data = await resp.json() + return BlockNumberResponse.parse_obj(data) + + +def dict_factory(x: list[tuple[str, Any]]): + return {k: v for (k, v) in x if v is not None} + + +@AsyncTTL(time_to_live=3600, skip_args=1) +async def list_block_data(session: ClientSession, url: str, query: ListBlockDataQuery) -> BlockDataListResponse: + async with session.get(url=f"{url}/blockchain_explorer/block_data", params=asdict(query, dict_factory=dict_factory)) as resp: + data = await resp.json() + if resp.status == 404: + raise ApiNotEnabledException(data) + return BlockDataListResponse.parse_obj(data) + + +@AsyncTTL(time_to_live=3600, skip_args=1) +async def get_block_data(session: ClientSession, url: str, block_number: int) -> BlockDataDetail: + async with session.get(url=f"{url}/blockchain_explorer/block_data/{block_number}") as resp: + data = await resp.json() + return BlockDataDetail.parse_obj(data) + + +@AsyncTTL(time_to_live=3600, skip_args=1) +async def list_tx_data(session: ClientSession, url: str, query: ListTxDataQuery) -> TxDataListResponse: + async with session.get(url=f"{url}/blockchain_explorer/tx_data", params=asdict(query, dict_factory=dict_factory)) as resp: + data = await resp.json() + return TxDataListResponse.parse_obj(data) + + +@AsyncTTL(time_to_live=3600, skip_args=1) +async def get_tx_data(session: ClientSession, url: str, tx_hash: str) -> TxDataDetail: + async with session.get(url=f"{url}/blockchain_explorer/tx_data/{tx_hash}") as resp: + data = await resp.json() + return TxDataDetail.parse_obj(data) diff --git a/cmd/explorer/src/gui/__init__.py b/cmd/explorer/src/gui/__init__.py new file mode 100644 index 00000000..5ebbd941 --- /dev/null +++ b/cmd/explorer/src/gui/__init__.py @@ -0,0 +1,18 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" diff --git a/cmd/explorer/src/gui/consts.py b/cmd/explorer/src/gui/consts.py new file mode 100644 index 00000000..f423941f --- /dev/null +++ b/cmd/explorer/src/gui/consts.py @@ -0,0 +1,57 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from enum import Enum + +UP = "\u2191" +DOWN = "\u2193" +LEFT = "\u2190" +RIGHT = "\u2192" +RIGHT_TRIANGLE = "\u25B6" +BIG_RIGHT_TRIANGLE = "\uE0B0" +DOWN_TRIANGLE = "\u25BC" + +THINKING_FACE = ":thinking_face:" +FIRE = ":fire:" +INFO = "[blue]:information:[/]" + + +class ID(str, Enum): + BLOCK_CONNECTED = "block_connected" + BLOCK_CURRENT_BLOCK_NUMBER = "block_current_block_number" + BLOCK_IS_SYNCED = "block_is_synced" + BLOCK_NOTION = "block_notion" + BLOCK_SCREEN_HEADER = "block_screen_header" + + BLOCK_LIST_FILTER = "block_list_filter" + BLOCK_LIST_LOADED_TIME = "block_list_loaded_time" + BLOCK_LIST_LOADING = "block_list_loading" + BLOCK_LIST_DESCRIPTION = "block_list_description" + BLOCK_LIST_TABLE = "block_list_table" + + TX_SELECTED_BLOCK_NUMBER = "tx_selected_block_number" + + MENU = "menu" + MENU_CANCEL = "menu_cancel" + MENU_SHOW_TX = "menu_show_tx" + + QUERY_PANEL = "query_panel" + QUERY_PANEL_FROM_BLOCK_INPUT = "query_panel_from_block_input" + QUERY_PANEL_TO_BLOCK_INPUT = "query_panel_to_block_input" + QUERY_PANEL_SORT_ORDER_CHOICE = "query_panel_sort_order_choice" + QUERY_PANEL_ENTER = "query_panel_enter" diff --git a/cmd/explorer/src/gui/error.py b/cmd/explorer/src/gui/error.py new file mode 100644 index 00000000..4c3473d3 --- /dev/null +++ b/cmd/explorer/src/gui/error.py @@ -0,0 +1,34 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from typing import Any + +from textual.message import Message + + +class Error(Message): + """ + A message sent when there was an error encoding the content. + """ + + def __init__(self, error: Exception, *args: Any, **kwargs: Any) -> None: + """ + Initialise the error message. + """ + super().__init__(*args, **kwargs) + self.error = error diff --git a/cmd/explorer/src/gui/explorer.css b/cmd/explorer/src/gui/explorer.css new file mode 100644 index 00000000..87151635 --- /dev/null +++ b/cmd/explorer/src/gui/explorer.css @@ -0,0 +1,108 @@ +/***************************** + Screens + *****************************/ +BlockScreen { + layer: 0; + height: 100%; + layers: one two three; + align: center middle; +} +BlockScreen.menu { + align: left top; +} +TransactionScreen { + layer: 0; +} +TracebackScreen { + layer: 0; +} + +/***************************** + Widgets + *****************************/ +Menu { + visibility: hidden; + height: auto; + width: auto; + layer: two; + opacity: 0; +} + +DataTable { + width: 100%; + padding: 0; + border: $surface +} + +DataTable:focus { + border: heavy $accent-darken-3; +} + +QuerySetting { + layer: three; + visibility: hidden; + margin-top: 3; + width: auto; + height: auto; + align: center middle; + content-align: center middle; + text-align: center; + border: heavy $accent-darken-3; +} + +QuerySetting.visible { + align: center middle; +} + +/***************************** + Class&Id + *****************************/ + +.column { + width: 1fr; +} +.visible { + visibility: visible; +} +.column_auto { + width: auto; +} +#block_screen_header { + height: 1; + background: $accent-darken-3; +} +#block_list_description { + dock: top; + height: auto; +} +#block_list_table { + +} +#query_panel_from_block_input { + width: 50; + color: grey; +} +#query_panel_to_block_input { + width: 50; +} +#query_panel_sort_order_choice { + align: center middle; + width: 50; + height: 3; +} +#query_panel_enter { + width: 50; + height: 3; +} +#tx_list_header { + height: 1; + background: $accent-darken-3; +} +.menubutton { + color: $text; + width: 30; + height: 3; + margin: 0; + padding: 0; + border: heavy $accent-darken-3; +} diff --git a/cmd/explorer/src/gui/explorer.py b/cmd/explorer/src/gui/explorer.py new file mode 100644 index 00000000..bd67ecc5 --- /dev/null +++ b/cmd/explorer/src/gui/explorer.py @@ -0,0 +1,96 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +import os + +from pydantic import ValidationError +from textual.app import App, ReturnType +from textual.binding import Binding + +from src.connector import ApiNotEnabledException +from src.gui.screen.block import BlockScreen +from src.gui.screen.traceback import TracebackScreen +from src.gui.screen.transaction import TransactionScreen +from src.gui.error import Error + +from app.model.schema import ListBlockDataQuery, ListTxDataQuery + + +class AppState: + tx_list_query: ListTxDataQuery | None = None + block_list_query: ListBlockDataQuery | None = None + current_block_number: int | None = None + error: Exception | None = None + + +class ExplorerApp(App): + """A Textual app to explorer ibet-Network.""" + + # Base App Setting + BINDINGS = [Binding("ctrl+c", "quit", "Quit")] + CSS_PATH = f"{os.path.dirname(os.path.abspath(__file__))}/explorer.css" + SCREENS = {"transaction_screen": TransactionScreen, "traceback_screen": TracebackScreen} + + # Injectable App Setting + url: str + lot_size: int + + # App State + state: AppState = AppState() + + # Run App + async def run_async( + self, + *, + url: str = "http://localhost:5000", + lot_size: int = 30, + headless: bool = False, + size: tuple[int, int] | None = None, + auto_pilot: None = None, + ) -> ReturnType | None: + self.url = url + self.lot_size = lot_size + return await super().run_async(headless=headless, size=size, auto_pilot=auto_pilot) + + ################################################## + # Event + ################################################## + + def on_mount(self): + """ + Occurs when Self is mounted + """ + self.push_screen(BlockScreen(name="block_screen")) + + def on_error(self, event: Error) -> None: + if isinstance(event.error, ApiNotEnabledException): + raise event.error from None + if isinstance(event.error, ValidationError): + raise ValueError(event.error.json()) from None + self.state.error = event.error + self.push_screen("traceback_screen") + + ################################################## + # Key binding + ################################################## + + async def action_quit(self) -> None: + """ + Occurs when keybind related to `quit` is called. + """ + self.exit() diff --git a/cmd/explorer/src/gui/rendarable/__init__.py b/cmd/explorer/src/gui/rendarable/__init__.py new file mode 100644 index 00000000..5ebbd941 --- /dev/null +++ b/cmd/explorer/src/gui/rendarable/__init__.py @@ -0,0 +1,18 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" diff --git a/cmd/explorer/src/gui/rendarable/block_detail_info.py b/cmd/explorer/src/gui/rendarable/block_detail_info.py new file mode 100644 index 00000000..bd8343fb --- /dev/null +++ b/cmd/explorer/src/gui/rendarable/block_detail_info.py @@ -0,0 +1,76 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +import time + +from app.model.schema import BlockDataDetail +from rich.console import Group +from rich.panel import Panel +from rich.progress_bar import ProgressBar +from rich.table import Table +from rich.text import Text + +from src.utils.time import human_time, unix_to_iso + + +class BlockDetailInfo: + def __init__(self, block_detail: BlockDataDetail) -> None: + self.block_detail = block_detail + + def __rich__(self) -> Group: + current = int(round(time.time())) + + basic_table = Table(box=None, expand=False, show_header=False, show_edge=False) + basic_table.add_column(style="deep_pink2 bold") + basic_table.add_column() + + basic_table.add_row(Text.from_markup("Block Height:"), str(self.block_detail.number)) + basic_table.add_row( + Text.from_markup("Timestamp:"), + f"{human_time(current - self.block_detail.timestamp)} ({unix_to_iso(self.block_detail.timestamp)})", + ) + basic_table.add_row( + Text.from_markup("Transactions:"), f"{len(self.block_detail.transactions)} transactions in this block" + ) + + content_table = Table(box=None, expand=False, show_header=False, show_edge=False) + content_table.add_column(style="deep_pink2 bold") + content_table.add_column() + content_table.add_column() + content_table.add_row(Text.from_markup("Total Difficulty:"), str(self.block_detail.difficulty), "") + content_table.add_row( + Text.from_markup("Gas Used:"), + f"{self.block_detail.gas_used} ({(self.block_detail.gas_used/self.block_detail.gas_limit)*100:.4f} %)", + ProgressBar(completed=(self.block_detail.gas_used / self.block_detail.gas_limit) * 100, width=10), + ) + content_table.add_row(Text.from_markup("Gas Limit:"), f"{self.block_detail.gas_limit}" "") + content_table.add_row(Text.from_markup("Size:"), f"{self.block_detail.size} Bytes" "") + + hash_table = Table(box=None, expand=False, show_header=False, show_edge=False) + hash_table.add_column(style="deep_pink2 bold") + hash_table.add_column() + hash_table.add_row(Text.from_markup("Hash:"), self.block_detail.hash) + hash_table.add_row(Text.from_markup("Parent Hash:"), self.block_detail.parent_hash) + hash_table.add_row("StateRoot: ", self.block_detail.state_root) + hash_table.add_row("Nonce: ", self.block_detail.nonce) + + return Group( + Panel(basic_table, expand=True, title="Common", title_align="left"), + Panel(content_table, expand=True, title="Content", title_align="left"), + Panel(hash_table, expand=True, title="Hash", title_align="left"), + ) diff --git a/cmd/explorer/src/gui/rendarable/tx_detail_info.py b/cmd/explorer/src/gui/rendarable/tx_detail_info.py new file mode 100644 index 00000000..f0e6e039 --- /dev/null +++ b/cmd/explorer/src/gui/rendarable/tx_detail_info.py @@ -0,0 +1,61 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from rich.console import Group +from rich.panel import Panel +from rich.table import Table + +from app.model.schema import TxDataDetail + + +class TxDetailInfo: + def __init__(self, tx_detail: TxDataDetail) -> None: + self.tx_detail = tx_detail + + def __str__(self) -> str: + return str(self.tx_detail) + + def __rich__(self) -> Group: + common_table = Table(box=None, expand=False, show_header=False, show_edge=False) + common_table.add_column(style="deep_pink2 bold") + common_table.add_column() + + common_table.add_row("Transaction Hash:", self.tx_detail.hash) + common_table.add_row("Block:", str(self.tx_detail.block_number)) + common_table.add_row("From:", self.tx_detail.from_address) + common_table.add_row("Nonce:", str(self.tx_detail.nonce)) + common_table.add_row("To:", self.tx_detail.to_address) + + common_table.add_row("Value:", str(self.tx_detail.value)) + common_table.add_row("Gas Price:", str(self.tx_detail.gas_price)) + common_table.add_row("Gas:", str(self.tx_detail.gas)) + + contract_table = Table(box=None, expand=False, show_header=False, show_edge=False) + contract_table.add_column(style="deep_pink2 bold") + contract_table.add_row("Contract Name:", self.tx_detail.contract_name) + contract_table.add_row("Contract Function:", self.tx_detail.contract_function) + if self.tx_detail.contract_parameters is not None: + function_arguments_table = Table(box=None, expand=False, show_header=False, show_edge=False) + for k, v in self.tx_detail.contract_parameters.items(): + function_arguments_table.add_row(f"{k}: ", str(v)) + contract_table.add_row("Contract Function Arguments:", Panel(function_arguments_table)) + + return Group( + Panel(common_table, expand=True, title="Common", title_align="left"), + Panel(contract_table, expand=True, title="Contract", title_align="left"), + ) diff --git a/cmd/explorer/src/gui/screen/__init__.py b/cmd/explorer/src/gui/screen/__init__.py new file mode 100644 index 00000000..5ebbd941 --- /dev/null +++ b/cmd/explorer/src/gui/screen/__init__.py @@ -0,0 +1,18 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" diff --git a/cmd/explorer/src/gui/screen/base.py b/cmd/explorer/src/gui/screen/base.py new file mode 100644 index 00000000..9c718c60 --- /dev/null +++ b/cmd/explorer/src/gui/screen/base.py @@ -0,0 +1,30 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from typing import TYPE_CHECKING, cast + +from textual.screen import Screen + +if TYPE_CHECKING: + from src.gui.explorer import ExplorerApp + + +class TuiScreen(Screen): + @property + def tui(self) -> "ExplorerApp": + return cast("ExplorerApp", self.app) diff --git a/cmd/explorer/src/gui/screen/block.py b/cmd/explorer/src/gui/screen/block.py new file mode 100644 index 00000000..f35b012e --- /dev/null +++ b/cmd/explorer/src/gui/screen/block.py @@ -0,0 +1,266 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +import asyncio +from asyncio import Event, Lock +from datetime import datetime +from typing import Optional + +from aiohttp import ( + ClientSession, + ClientTimeout, + TCPConnector +) +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import ( + Horizontal, + Vertical +) +from textual.reactive import Reactive +from textual.widgets import ( + Button, + DataTable, + Footer, + Label, + Static +) + +from src import connector +from src.gui.consts import ID +from src.gui.error import Error +from src.gui.screen.base import TuiScreen +from src.gui.widget.block_detail_view import BlockDetailView +from src.gui.widget.block_list_table import BlockListTable +from src.gui.widget.block_list_view import ( + BlockListQueryPanel, + BlockListSummaryPanel, + BlockListView +) +from src.gui.widget.menu import ( + Menu, + MenuInstruction +) +from src.gui.widget.query_panel import QuerySetting + +from app.model.schema import ( + BlockDataDetail, + BlockDataListResponse, + BlockNumberResponse, + ListBlockDataQuery, + ListTxDataQuery, +) +from app.model.schema.types import SortOrder + + +class BlockScreen(TuiScreen): + BINDINGS = [ + Binding("e", "edit_query", "Edit list block data query"), + ] + dark = Reactive(True) + mutex_reload_block = Reactive(Lock()) + background_lock: Optional[Event] = None + + def __init__(self, name: str | None = None, id: str | None = None, classes: str | None = None): + super().__init__(name=name, id=id, classes=classes) + self.base_url = self.tui.url + self.refresh_rate = 5.0 + self.block_detail_header_widget = BlockDetailView(classes="column") + + def compose(self) -> ComposeResult: + yield Horizontal( + Vertical( + Horizontal( + Label(Text.from_markup(" [bold]ibet-Wallet-API BC Explorer[/bold]")), + Label(" | "), + Label("Fetching current block...", id=ID.BLOCK_CURRENT_BLOCK_NUMBER), + Label(" | "), + Label("Fetching current status...", id=ID.BLOCK_IS_SYNCED), + Label(" | "), + Label("Loading...", id=ID.BLOCK_NOTION), + id=ID.BLOCK_SCREEN_HEADER, + ), + Horizontal(BlockListView(classes="column"), self.block_detail_header_widget), + classes="column", + ) + ) + yield Footer() + yield Menu(id=ID.MENU) + yield QuerySetting(id=ID.QUERY_PANEL) + + ################################################## + # Event + ################################################## + + async def on_mount(self) -> None: + """ + Occurs when Self is mounted + """ + self.query_one(Menu).hide() + self.remove_class("menu") + self.query_one(QuerySetting).hide() + self.query(BlockListTable)[0].focus() + self.set_interval(self.refresh_rate, self.fetch_sync_status) + + async def on_button_pressed(self, event: Button.Pressed) -> None: + """ + Occurs when Button is pressed + """ + event.stop() + event.prevent_default() + match event.button.id: + case ID.MENU_CANCEL: + self.query_one(Menu).hide() + self.remove_class("menu") + self.query(BlockListTable)[0].can_focus = True + self.query_one(BlockListTable).focus() + case ID.MENU_SHOW_TX: + ix = self.query_one(Menu).hide() + self.remove_class("menu") + get_query = ListTxDataQuery() + get_query.block_number = ix.block_number + self.tui.state.tx_list_query = get_query + await self.app.push_screen("transaction_screen") + case ID.QUERY_PANEL_ENTER: + self.reload_block() + + async def on_query_setting_enter(self, event: QuerySetting.Enter): + """ + Occurs when QuerySetting.Enter is emitted + """ + event.stop() + event.prevent_default() + self.reload_block() + + async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """ + Occurs when DataTable row is selected + """ + event.stop() + event.prevent_default() + selected_row = self.query_one(BlockListTable).data.get(event.cursor_row) + if selected_row is None: + return + await self.fetch_block_detail(selected_row[0]) + + if int(selected_row[2]) == 0: + # If the number of transaction is 0, menu is not pop up. + return + + self.query_one(Menu).show( + MenuInstruction( + block_number=selected_row[0], + block_hash=selected_row[3], + selected_row=event.cursor_row + ) + ) + self.add_class("menu") + self.query(BlockListTable)[0].can_focus = False + + def reload_block(self) -> None: + if self.tui.state.current_block_number is None or self.tui.state.block_list_query is None: + return + + self.query_one(BlockListQueryPanel).block_list_query = self.tui.state.block_list_query + asyncio.create_task(self.fetch_block_list()) + + ################################################## + # Key binding + ################################################## + + def action_edit_query(self) -> None: + """ + Occurs when keybind related to `edit_query` is called. + """ + if self.tui.state.current_block_number is None or self.tui.state.block_list_query is None: + return + + self.query_one(QuerySetting).show() + self.query(BlockListTable)[0].can_focus = False + + ################################################## + # Fetch data + ################################################## + + async def fetch_sync_status(self): + async with TCPConnector(limit=2, keepalive_timeout=0) as tcp_connector: + async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session: + try: + block_number_response: BlockNumberResponse = await connector.get_block_number(session, self.base_url) + block_number = block_number_response.block_number + except Exception as e: + if hasattr(self, "emit_no_wait"): + self.emit_no_wait(Error(e, self)) + return + self.update_current_block(block_number) + self.update_is_synced(True) + if self.tui.state.current_block_number is None and self.tui.state.block_list_query is None: + # initialize block list query + query = ListBlockDataQuery() + query.to_block_number = block_number + query.from_block_number = max(block_number - self.tui.lot_size - 1, 0) + query.sort_order = SortOrder.DESC + self.tui.state.block_list_query = query + self.query_one(BlockListQueryPanel).block_list_query = query + + self.tui.state.current_block_number = block_number + + async def fetch_block_list(self) -> None: + if self.tui.state.current_block_number == 0: + return + try: + self.query_one(BlockListSummaryPanel).loading = True + await asyncio.sleep(5) + async with TCPConnector(limit=1, keepalive_timeout=0) as tcp_connector: + async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session: + try: + block_data_list: BlockDataListResponse = await connector.list_block_data( + session, self.base_url, self.tui.state.block_list_query + ) + except Exception as e: + if hasattr(self, "emit_no_wait"): + self.emit_no_wait(Error(e, self)) + return + self.query_one(BlockListTable).update_rows(block_data_list.block_data) + self.query_one(BlockListSummaryPanel).loaded_time = datetime.now() + + finally: + self.query_one(BlockListSummaryPanel).loading = False + + async def fetch_block_detail(self, block_number: int): + async with TCPConnector(limit=1, keepalive_timeout=0) as tcp_connector: + async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session: + try: + block_detail: BlockDataDetail = await connector.get_block_data( + session, + self.base_url, + block_number, + ) + except Exception as e: + if hasattr(self, "emit_no_wait"): + self.emit_no_wait(Error(e, self)) + return + self.query_one(BlockDetailView).block_detail = block_detail + + def update_current_block(self, latest_block_number: int): + self.query_one(f"#{ID.BLOCK_CURRENT_BLOCK_NUMBER}", Static).update(f"Current Block: {latest_block_number}") + + def update_is_synced(self, is_synced: bool): + self.query_one(f"#{ID.BLOCK_IS_SYNCED}", Static).update(f"Is Synced: {is_synced}") + self.query_one(f"#{ID.BLOCK_NOTION}", Static).update(f"Press [E] To Load Block List") diff --git a/cmd/explorer/src/gui/screen/traceback.py b/cmd/explorer/src/gui/screen/traceback.py new file mode 100644 index 00000000..c23e6b85 --- /dev/null +++ b/cmd/explorer/src/gui/screen/traceback.py @@ -0,0 +1,54 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widgets import Footer + +from src.gui.screen.base import TuiScreen +from src.gui.widget.block_list_table import BlockListTable +from src.gui.widget.traceback import TracebackWidget + + +class TracebackScreen(TuiScreen): + BINDINGS = [Binding("q,enter,space", "quit", "Close", priority=True)] + + def compose(self) -> ComposeResult: + yield TracebackWidget() + yield Footer() + + ################################################## + # Event + ################################################## + + async def on_mount(self) -> None: + """ + Occurs when Self is mounted + """ + self.query(TracebackWidget)[0].focus() + + ################################################## + # Key binding + ################################################## + + def action_quit(self): + """ + Occurs when keybind related to `quit` is called. + """ + self.tui.pop_screen() + self.tui.query_one(BlockListTable).focus() diff --git a/cmd/explorer/src/gui/screen/transaction.py b/cmd/explorer/src/gui/screen/transaction.py new file mode 100644 index 00000000..659e79ca --- /dev/null +++ b/cmd/explorer/src/gui/screen/transaction.py @@ -0,0 +1,116 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from aiohttp import ClientSession, ClientTimeout, TCPConnector +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.widgets import DataTable, Footer, Label + +from src import connector +from src.gui.consts import ID +from src.gui.screen.base import TuiScreen +from src.gui.widget.block_list_table import BlockListTable +from src.gui.widget.tx_detail_view import TxDetailView +from src.gui.widget.tx_list_table import TxListTable +from src.gui.widget.tx_list_view import TxListView + +from app.model.schema.bc_explorer import TxDataDetail + + +class TransactionScreen(TuiScreen): + BINDINGS = [Binding("q", "quit", "Close", priority=True)] + + def compose(self) -> ComposeResult: + yield Horizontal( + Vertical( + Horizontal( + Label(Text.from_markup(" [bold]ibet-Wallet-API BC Explorer[/bold]")), + Label(" | "), + Label(f"Selected block: -", id=ID.TX_SELECTED_BLOCK_NUMBER), + id="tx_list_header", + ), + Horizontal(TxListView(classes="column"), TxDetailView(classes="column")), + classes="column", + ) + ) + yield Footer() + + ################################################## + # Event + ################################################## + + async def on_mount(self) -> None: + """ + Occurs when Self is mounted + """ + self.query(TxListTable)[0].focus() + + async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """ + Occurs when DataTable row is selected + """ + event.stop() + event.prevent_default() + selected_row = self.query_one(TxListTable).data.get(event.cursor_row) + if selected_row is None: + return + + tx_hash = selected_row[0] + async with TCPConnector(limit=1, keepalive_timeout=0) as tcp_connector: + async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session: + tx_detail: TxDataDetail = await connector.get_tx_data( + session, + self.tui.url, + tx_hash, + ) + self.query_one(TxDetailView).tx_detail = tx_detail + + async def on_screen_suspend(self): + """ + Occurs when Self is suspended + """ + self.query_one(TxListTable).update_rows([]) + + async def on_screen_resume(self): + """ + Occurs when Self is resumed + """ + if self.tui.state.tx_list_query is not None: + async with TCPConnector(limit=1, keepalive_timeout=0) as tcp_connector: + async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session: + tx_list = await connector.list_tx_data( + session=session, url=self.tui.url, query=self.tui.state.tx_list_query + ) + self.query_one(TxListTable).update_rows(tx_list.tx_data) + self.query_one(f"#{ID.TX_SELECTED_BLOCK_NUMBER}", Label).update( + f"Selected block: {self.tui.state.tx_list_query.block_number}" + ) + + ################################################## + # Key binding + ################################################## + + def action_quit(self): + """ + Occurs when keybind related to `quit` is called. + """ + self.tui.pop_screen() + self.tui.query(BlockListTable)[0].can_focus = True + self.tui.query(BlockListTable)[0].focus() diff --git a/cmd/explorer/src/gui/styles.py b/cmd/explorer/src/gui/styles.py new file mode 100644 index 00000000..26b0e2c0 --- /dev/null +++ b/cmd/explorer/src/gui/styles.py @@ -0,0 +1,27 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from rich import box + +BORDER_FOCUSED = "green" +BORDER_ERROR = "red" +BORDER_MOUSE_OVER = "grey100" +BORDER = "grey82" +BOX = box.SQUARE + +TABLE_BOX = box.SIMPLE_HEAD diff --git a/cmd/explorer/src/gui/widget/__init__.py b/cmd/explorer/src/gui/widget/__init__.py new file mode 100644 index 00000000..5ebbd941 --- /dev/null +++ b/cmd/explorer/src/gui/widget/__init__.py @@ -0,0 +1,18 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" diff --git a/cmd/explorer/src/gui/widget/base.py b/cmd/explorer/src/gui/widget/base.py new file mode 100644 index 00000000..a7fb0820 --- /dev/null +++ b/cmd/explorer/src/gui/widget/base.py @@ -0,0 +1,37 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from typing import TYPE_CHECKING, cast + +from textual.widget import Widget +from textual.widgets import Static + +if TYPE_CHECKING: + from src.gui.explorer import ExplorerApp + + +class TuiWidget(Widget): + @property + def tui(self) -> "ExplorerApp": + return cast("ExplorerApp", self.app) + + +class TuiStatic(Static): + @property + def tui(self) -> "ExplorerApp": + return cast("ExplorerApp", self.app) diff --git a/cmd/explorer/src/gui/widget/block_detail_view.py b/cmd/explorer/src/gui/widget/block_detail_view.py new file mode 100644 index 00000000..ae12cc7d --- /dev/null +++ b/cmd/explorer/src/gui/widget/block_detail_view.py @@ -0,0 +1,58 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from typing import Literal, Union + +from rich.align import Align +from rich.panel import Panel +from rich.style import Style +from textual.reactive import Reactive, reactive + +from src.gui import styles +from src.gui.rendarable.block_detail_info import BlockDetailInfo +from src.gui.widget.base import TuiWidget +from app.model.schema import BlockDataDetail + + +class BlockDetailView(TuiWidget): + block_detail: Reactive[BlockDataDetail | None] = reactive(None) + + def watch_block_detail(self, old: BlockDetailInfo, new: BlockDetailInfo): + """ + Occurs when `block_detail` is changed + """ + self.render() + + def render(self) -> Panel: + block_detail: Union[Align, BlockDetailInfo] = Align.center("Press [E] to set query", vertical="middle") + style: Style | Literal["none"] = Style(bgcolor="#004578") + + if self.block_detail is not None: + block_detail = BlockDetailInfo(self.block_detail) + style = "none" + + panel = Panel( + block_detail, + title="[bold]Block[/]", + title_align="left", + style=style, + border_style=styles.BORDER, + box=styles.BOX, + ) + + return panel diff --git a/cmd/explorer/src/gui/widget/block_list_table.py b/cmd/explorer/src/gui/widget/block_list_table.py new file mode 100644 index 00000000..84110636 --- /dev/null +++ b/cmd/explorer/src/gui/widget/block_list_table.py @@ -0,0 +1,108 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +import time +from typing import Iterable + +from rich.progress_bar import ProgressBar +from textual.binding import Binding +from textual.coordinate import Coordinate +from textual.reactive import reactive +from textual.widgets import DataTable + +from src.utils.time import human_time +from app.model.schema.bc_explorer import BlockData + + +class BlockListTable(DataTable): + BINDINGS = [ + Binding("ctrl+n", "cursor_down", "Down", show=False), + Binding("ctrl+p", "cursor_up", "Up", show=False), + ] + only_include_tx = reactive(False) + raw_data: Iterable[BlockData] = [] + + def __init__(self, name: str, complete_refresh: bool, id: str): + super().__init__(name=name, id=id) + self.table_name = name + self.cursor_type = "row" + self.complete_refresh = complete_refresh + + def on_mount(self) -> None: + """ + Occurs when Self is mounted + """ + self.add_column("Block", width=10) + self.add_column("Age", width=24) + self.add_column("Txn", width=4) + self.add_column("Hash", width=70) + self.add_column("Gas Used") + + def toggle_filter(self) -> bool: + self.only_include_tx = not self.only_include_tx + self.update_rows(self.raw_data) + return self.only_include_tx + + def update_rows(self, data: Iterable[BlockData]): + self.raw_data = data + selected_row = self.cursor_row + if len(self.data) > 0: + selected_block_number = self.data[selected_row][0] + else: + selected_block_number = None + + if self.complete_refresh: + self.clear() + + current = int(round(time.time())) + rows = [ + [ + str(d.number), + human_time(current - d.timestamp) + " ago", + str(len(d.transactions)), + d.hash, + ProgressBar(completed=(d.gas_used / d.gas_limit) * 100, width=10), + ] + for d in data + ] + if self.only_include_tx: + rows = list(filter(lambda r: r[2] != "0", rows)) + self.add_rows(rows) + + # Keep current selected position + if selected_block_number is not None: + row_to_be_selected = next( + (i for i, row in enumerate(rows) if row[0] == selected_block_number), + len(rows) - 1 if len(rows) > 0 else 0, + ) + self.cursor_cell = Coordinate(row_to_be_selected, 0) + self.hover_cell = Coordinate(row_to_be_selected, 0) + else: + self.cursor_cell = Coordinate(0, 0) + self.hover_cell = Coordinate(0, 0) + + self._scroll_cursor_into_view(animate=False) + self.refresh() + + def action_select_cursor(self) -> None: + """ + Occurs when keybind related to `select_cursor` is called. + """ + self._set_hover_cursor(False) + if self.show_cursor and self.cursor_type != "none" and self.has_focus: + self._emit_selected_message() diff --git a/cmd/explorer/src/gui/widget/block_list_view.py b/cmd/explorer/src/gui/widget/block_list_view.py new file mode 100644 index 00000000..d7625d61 --- /dev/null +++ b/cmd/explorer/src/gui/widget/block_list_view.py @@ -0,0 +1,160 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from datetime import datetime + +from rich.panel import Panel +from rich.spinner import Spinner +from rich.table import Table +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal +from textual.reactive import Reactive, reactive +from textual.timer import Timer + +from src.gui import styles +from src.gui.consts import ID +from src.gui.widget.base import TuiStatic, TuiWidget +from src.gui.widget.block_list_table import BlockListTable +from app.model.schema import ListBlockDataQuery + + +class BlockListQueryPanel(TuiStatic): + block_list_query: Reactive[ListBlockDataQuery | None] = reactive(None) + + def watch_block_list_query(self, old: ListBlockDataQuery, new: ListBlockDataQuery): + """ + Occurs when `block_list_query` is changed + """ + self.render() + + def render(self) -> Panel: + content = Table(show_header=True, header_style="bold", show_edge=False, show_lines=False) + content.add_column("From", justify="center", width=10) + content.add_column("To", justify="center", width=10) + content.add_column("Sort", justify="center", width=7) + + if self.block_list_query is not None: + content.add_row( + *[ + str(self.block_list_query.from_block_number), + str(self.block_list_query.to_block_number), + "Asc" if self.block_list_query.sort_order == 0 else "Desc", + ] + ) + else: + content.add_row(*["", "", ""]) + + style = "none" + panel = Panel( + content, + title="[bold]Query[/]", + title_align="left", + style=style, + border_style=styles.BORDER, + box=styles.BOX, + ) + + return panel + + +class BlockListSummaryPanel(TuiStatic): + loading: reactive[bool | None] = reactive(False) + loaded_time: reactive[datetime | None] = reactive(None) + only_block_filter: reactive[bool | None] = reactive(False) + + update_render: Timer | None = None + + def __init__(self, classes: str | None = None): + super().__init__(classes=classes) + self._spinner = Spinner("dots") + + def watch_loading(self, new: bool): + """ + Occurs when `loading` is changed + """ + if new: + if self.update_render is None: + self.update_render = self.set_interval(1 / 60, self.update_spinner) + else: + self.update_render.resume() + else: + if self.update_render is not None: + self.update_render.pause() + + def watch_loaded_time(self, new: datetime): + """ + Occurs when `loaded_time` is changed + """ + self.render() + + def watch_only_block_filter(self, new: bool): + """ + Occurs when `only_block_filter` is changed + """ + self.render() + + def update_spinner(self) -> None: + self.render() + self.refresh() + + def render(self) -> Panel: + content = Table(show_header=True, header_style="bold", show_edge=False, show_lines=False) + content.add_column("Loading", justify="center") + content.add_column("Only Blocks Including Tx", style="dim", justify="center") + content.add_column("Loaded Time", style="dim", justify="center") + + content.add_row( + self._spinner if self.loading else "", + f"{self.only_block_filter}", + f"{self.loaded_time.strftime('%Y/%m/%d %H:%M:%S')}" if self.loaded_time is not None else "", + ) + + style = "none" + panel = Panel( + content, + title="[bold]Result[/]", + title_align="left", + style=style, + border_style=styles.BORDER, + box=styles.BOX, + ) + + return panel + + +class BlockListView(TuiWidget): + BINDINGS = [ + Binding("t", "filter", "Toggle Only Blocks Including Tx"), + ] + + def compose(self) -> ComposeResult: + yield Horizontal( + BlockListQueryPanel(classes="column_auto"), + BlockListSummaryPanel(classes="column"), + id=ID.BLOCK_LIST_DESCRIPTION, + ) + yield BlockListTable(name="blocks", complete_refresh=True, id=ID.BLOCK_LIST_TABLE) + + def action_filter(self): + """ + Occurs when keybind related to `filter` is called. + """ + if self.query_one(BlockListTable).can_focus: + toggle = self.query_one(BlockListTable).toggle_filter() + self.query_one(BlockListSummaryPanel).only_block_filter = toggle diff --git a/cmd/explorer/src/gui/widget/choice.py b/cmd/explorer/src/gui/widget/choice.py new file mode 100644 index 00000000..8b0351f7 --- /dev/null +++ b/cmd/explorer/src/gui/widget/choice.py @@ -0,0 +1,55 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widgets import Label, ListItem, ListView + + +class Choices(ListView): + BINDINGS = [ + Binding("enter", "select_cursor", "Select", show=False), + Binding("up", "cursor_up", "Cursor Up", show=False, priority=True), + Binding("ctrl+p", "cursor_up", "Cursor Up", show=False, priority=True), + Binding("down", "cursor_down", "Cursor Down", show=False, priority=True), + Binding("ctrl+n", "cursor_down", "Cursor Down", show=False, priority=True), + ] + + def __init__(self, choices: list[str], *, id: str | None = None) -> None: + super().__init__(id=id) + self.choices = choices + + def compose(self) -> ComposeResult: + for choice in self.choices: + yield ListItem(Label(choice)) + + @property + def value(self) -> ListItem | None: + return self.highlighted_child + + @value.setter + def value(self, v: str): + idx = self.choices.index(v) + if idx is None: + return + else: + self.index = idx + self.render() + + def current_value(self): + return self.choices[self.index] diff --git a/cmd/explorer/src/gui/widget/menu.py b/cmd/explorer/src/gui/widget/menu.py new file mode 100644 index 00000000..48a06cf8 --- /dev/null +++ b/cmd/explorer/src/gui/widget/menu.py @@ -0,0 +1,62 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from pydantic import BaseModel +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.widgets import Button + +from src.gui.consts import ID +from src.gui.widget.base import TuiWidget + + +class MenuInstruction(BaseModel): + block_number: int + block_hash: str + selected_row: int + + +class Menu(TuiWidget): + BINDINGS = [ + Binding("tab,down,ctrl+n", "focus_next", "Focus Next", show=False), + Binding("shift+tab,up,ctrl+p", "focus_previous", "Focus Previous", show=False), + Binding("ctrl+r", "", "", show=False), + Binding("t", "click('menu_show_tx')", "Show Transactions"), + Binding("c,q", "click('menu_cancel')", "Cancel", key_display="Q, C"), + ] + ix: MenuInstruction | None = None + + def compose(self) -> ComposeResult: + yield Button(Text.from_markup("\[t] Show Transactions :package:"), id=ID.MENU_SHOW_TX, classes="menubutton") + yield Button("\[c] Cancel", id=ID.MENU_CANCEL, classes="menubutton") + + def show(self, ix: MenuInstruction): + self.ix = ix + self.add_class("visible") + self.query_one(f"#{ID.MENU_SHOW_TX}", Button).focus() + + def hide(self) -> MenuInstruction | None: + self.remove_class("visible") + return self.ix + + def action_click(self, _id: str): + """ + Occurs when keybind related to `click` is called. + """ + self.query_one(f"#{_id}", Button).press() diff --git a/cmd/explorer/src/gui/widget/query_panel.py b/cmd/explorer/src/gui/widget/query_panel.py new file mode 100644 index 00000000..36f83852 --- /dev/null +++ b/cmd/explorer/src/gui/widget/query_panel.py @@ -0,0 +1,269 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from typing import TYPE_CHECKING, cast + +from app.model.schema import ListBlockDataQuery +from app.model.schema.types import SortOrder +from rich.markdown import Markdown +from textual import events +from textual.app import ComposeResult +from textual.binding import Binding +from textual.message import Message +from textual.widgets import Button, Input, Label + +from src.gui.consts import ID +from src.gui.widget.base import TuiWidget +from src.gui.widget.block_list_table import BlockListTable +from src.gui.widget.block_list_view import BlockListQueryPanel +from src.gui.widget.choice import Choices + +if TYPE_CHECKING: + from src.gui.explorer import ExplorerApp + + +class ToBlockInput(Input): + @property + def tui(self) -> "ExplorerApp": + return cast("ExplorerApp", self.app) + + def increment_value(self, increment: int) -> None: + """ + Increments the value by the increment + """ + if self.value is None: + self.value = str(0) + + if ( + self.tui.state.current_block_number is not None + and int(self.value) + increment > self.tui.state.current_block_number + ): + self.value = f"{self.tui.state.current_block_number}" + elif int(self.value) + increment < 0: + self.value = str(0) + else: + self.value = str(int(self.value) + increment) + + def insert_text_at_cursor(self, text: str) -> None: + """ + Insert new text at the cursor, move the cursor to the end of the new text. + """ + if self.value == "0": + if text == "0": + return + self.value = str(int(self.value) + int(text)) + self.cursor_position = 1 + return + + if self.cursor_position > len(self.value): + if ( + self.tui.state.current_block_number is not None + and int(self.value + text) > self.tui.state.current_block_number + ): + self.value = f"{self.tui.state.current_block_number}" + else: + self.value += text + self.cursor_position = len(self.value) + else: + value = self.value + before = value[: self.cursor_position] + after = value[self.cursor_position :] + if ( + self.tui.state.current_block_number is not None + and int(before + text + after) > self.tui.state.current_block_number + ): + self.value = f"{self.tui.state.current_block_number}" + else: + self.value = f"{before}{text}{after}" + self.cursor_position += len(text) + + async def on_key(self, event: events.Key) -> None: + """ + Occurs when `Key` is emitted. + """ + self._cursor_visible = True + if self.cursor_blink: + self.blink_timer.reset() + + event.prevent_default() + event.stop() + if await self.handle_key(event): + return + + if event.character is not None and event.character.isdigit(): + if event.character == "0" and self.cursor_position == 0: + self.cursor_position += 1 + return + self.insert_text_at_cursor(event.character) + elif event.key in ["left", "ctrl+b"]: + if self.cursor_position != 0: + self.cursor_position -= 1 + elif event.key in ["right", "ctrl+f"]: + if self.cursor_position != len(self.value): + self.cursor_position += 1 + elif event.key in ["up", "ctrl+p"]: + self.increment_value(10) + self.cursor_position = len(self.value) + elif event.key in ["down", "ctrl+n"]: + self.increment_value(-10) + self.cursor_position = len(self.value) + elif event.key in ["home", "ctrl+a"]: + self.cursor_position = 0 + elif event.key in ["end", "ctrl+e"]: + self.cursor_position = len(self.value) + elif event.key == "backspace": + if self.cursor_position == 0: + return + elif len(self.value) == 1: + self.value = str(0) + self.cursor_position = 0 + elif len(self.value) == 2: + if self.cursor_position == 1: + self.value = self.value[1] + self.cursor_position = 0 + else: + self.value = self.value[0] + self.cursor_position = 1 + else: + if self.cursor_position == 1: + self.value = self.value[1:] + self.cursor_position = 0 + elif self.cursor_position == len(self.value): + self.value = self.value[:-1] + self.cursor_position -= 1 + else: + new_value = self.value[: self.cursor_position - 1] + self.value[self.cursor_position :] + if new_value != "": + self.value = new_value + self.cursor_position -= 1 + + +class QuerySetting(TuiWidget): + BINDINGS = [ + Binding("c,q,escape", "cancel()", "Cancel", key_display="Q, C", priority=True), + Binding("tab", "focus_next", "Focus Next", show=True, priority=True), + Binding("shift+tab", "focus_previous", "Focus Previous", show=True, priority=True), + Binding("enter", "enter()", "Enter", priority=True), + Binding("e", "", "", show=False), + Binding("ctrl+c", "", "", show=False), + Binding("ctrl+r", "", "", show=False), + Binding("ctrl+n", "cursor_down", "Down", show=False), + Binding("ctrl+p", "cursor_up", "Up", show=False), + ] + + def compose(self) -> ComposeResult: + yield Label(Markdown("# Query Setting")) + yield Label(Markdown("#### To Block")) + yield ToBlockInput(placeholder="100", name="to_block", id=ID.QUERY_PANEL_TO_BLOCK_INPUT) + yield Label(Markdown("#### From Block(Auto set)")) + yield Input(placeholder="0", name="from_block", id=ID.QUERY_PANEL_FROM_BLOCK_INPUT) + yield Label(Markdown("#### Sort Order")) + yield Choices(["DESC", "ASC"], id=ID.QUERY_PANEL_SORT_ORDER_CHOICE) + yield Button("Enter", id=ID.QUERY_PANEL_ENTER) + + def show(self): + self.add_class("visible") + self.query_one(f"#{ID.QUERY_PANEL_TO_BLOCK_INPUT}", Input).focus() + self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).can_focus = False + + if self.tui.state.block_list_query is not None: + query = self.tui.state.block_list_query + self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).value = str(query.from_block_number) + self.query_one(f"#{ID.QUERY_PANEL_TO_BLOCK_INPUT}", Input).value = str(query.to_block_number) + + item = "ASC" if query.sort_order.value == 0 else "DESC" + self.query_one(f"#{ID.QUERY_PANEL_SORT_ORDER_CHOICE}", Choices).index = self.query_one( + f"#{ID.QUERY_PANEL_SORT_ORDER_CHOICE}", Choices + ).choices.index(item) + + def hide(self) -> None: + self.remove_class("visible") + self.tui.query(BlockListTable)[0].can_focus = True + self.tui.query(BlockListTable)[0].focus() + + def on_key(self, event: events.Key): + """ + Occurs when `Key` is emitted. + """ + if event.key == "Enter": + self.action_enter() + from_block = self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).value + to_block = self.query_one(f"#{ID.QUERY_PANEL_TO_BLOCK_INPUT}", Input).value + sort_order = self.query_one(f"#{ID.QUERY_PANEL_SORT_ORDER_CHOICE}", Choices).current_value() + + query = ListBlockDataQuery( + from_block_number=int(from_block), + to_block_number=int(to_block), + sort_order=SortOrder.DESC if sort_order == "DESC" else SortOrder.ASC, + ) + self.tui.state.block_list_query = query + self.tui.query_one(BlockListQueryPanel).block_list_query = query + + self.hide() + else: + event.stop() + event.prevent_default() + + async def on_input_changed(self, event: ToBlockInput.Changed): + """ + Occurs when `Input.Changed` is emitted. + """ + event.prevent_default() + event.stop() + if event.input.id == ID.QUERY_PANEL_TO_BLOCK_INPUT: + self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).value = str( + max(int(event.input.value) - self.tui.lot_size - 1, 0) + ) + + async def on_button_pressed(self, event: Button.Pressed) -> None: + """ + Occurs when `Button.Pressed` is emitted. + """ + if event.button.id == ID.QUERY_PANEL_ENTER: + self.action_enter() + else: + event.stop() + event.prevent_default() + + class Enter(Message): + pass + + def action_enter(self): + """ + Occurs when keybind related to `enter` is called. + """ + from_block = self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).value + to_block = self.query_one(f"#{ID.QUERY_PANEL_TO_BLOCK_INPUT}", Input).value + sort_order = self.query_one(f"#{ID.QUERY_PANEL_SORT_ORDER_CHOICE}", Choices).current_value() + + query = ListBlockDataQuery( + from_block_number=int(from_block), + to_block_number=int(to_block), + sort_order=SortOrder.DESC if sort_order == "DESC" else SortOrder.ASC, + ) + self.tui.state.block_list_query = query + self.tui.query_one(BlockListQueryPanel).block_list_query = query + + self.emit_no_wait(self.Enter(sender=self)) + self.hide() + + def action_cancel(self): + """ + Occurs when keybind related to `cancel` is called. + """ + self.hide() diff --git a/cmd/explorer/src/gui/widget/traceback.py b/cmd/explorer/src/gui/widget/traceback.py new file mode 100644 index 00000000..b9bd2b05 --- /dev/null +++ b/cmd/explorer/src/gui/widget/traceback.py @@ -0,0 +1,54 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from typing import Literal + +from rich.align import Align +from rich.panel import Panel +from rich.style import Style +from rich.traceback import Traceback + +from src.gui import styles +from src.gui.widget.base import TuiWidget + + +class TracebackWidget(TuiWidget): + def render(self) -> Panel: + style: Style | Literal["none"] = Style() + if self.tui.state.error is not None: + trace_back = Traceback.from_exception( + exc_type=type(self.tui.state.error), + exc_value=self.tui.state.error, + traceback=self.tui.state.error.__traceback__, + ) + content: Align = Align.center(trace_back, vertical="middle") + else: + content = Align.center("", vertical="middle") + + panel = Panel( + content, + title="[bold]Exception[/]", + style=style, + border_style=styles.BORDER, + box=styles.BOX, + title_align="left", + padding=0, + highlight=True, + ) + + return panel diff --git a/cmd/explorer/src/gui/widget/tx_detail_view.py b/cmd/explorer/src/gui/widget/tx_detail_view.py new file mode 100644 index 00000000..c6ba6ccf --- /dev/null +++ b/cmd/explorer/src/gui/widget/tx_detail_view.py @@ -0,0 +1,65 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from typing import Literal, Union + +from app.model.schema import TxDataDetail +from rich.align import Align +from rich.panel import Panel +from rich.style import Style +from textual.reactive import Reactive, reactive + +from src.gui import styles +from src.gui.rendarable.tx_detail_info import TxDetailInfo +from src.gui.widget.base import TuiWidget + + +class TxDetailView(TuiWidget): + tx_detail: Reactive[TxDataDetail | None] = reactive(None) + + def on_mount(self) -> None: + """ + Occurs when Self is mounted + """ + pass + + def watch_tx_detail(self, old: TxDetailInfo, new: TxDetailInfo): + """ + Occurs when `tx_detail` is changed + """ + self.render() + + def render(self) -> Panel: + tx_detail: Union[Align, TxDetailInfo] = Align.center("Not selected", vertical="middle") + style: Style | Literal["none"] = Style(bgcolor="#004578") + + if self.tx_detail is not None: + tx_detail = TxDetailInfo(self.tx_detail) + style = "none" + + panel = Panel( + tx_detail, + title="[bold]Transaction[/]", + style=style, + border_style=styles.BORDER, + box=styles.BOX, + title_align="left", + padding=0, + ) + + return panel diff --git a/cmd/explorer/src/gui/widget/tx_list_table.py b/cmd/explorer/src/gui/widget/tx_list_table.py new file mode 100644 index 00000000..e2ebe11a --- /dev/null +++ b/cmd/explorer/src/gui/widget/tx_list_table.py @@ -0,0 +1,61 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from typing import Iterable + +from app.model.schema.bc_explorer import TxData +from textual.binding import Binding +from textual.reactive import reactive +from textual.widgets import DataTable + + +class TxListTable(DataTable): + BINDINGS = [ + Binding("ctrl+n", "cursor_down", "Down", show=False), + Binding("ctrl+p", "cursor_up", "Up", show=False), + ] + only_include_tx = reactive(False) + raw_data: Iterable[TxData] = [] + + def __init__(self, name: str, complete_refresh: bool): + super().__init__() + self.table_name = name + self.column_labels = ["Txn Hash", "Block"] + self.cursor_type = "row" + self.complete_refresh = complete_refresh + + def on_mount(self) -> None: + """ + Occurs when Self is mounted + """ + self.add_columns(*self.column_labels) + + def update_rows(self, data: Iterable[TxData]): + if self.complete_refresh: + self.clear() + rows = [[d.hash, str(d.block_number)] for d in data] + self.add_rows(rows) + self.refresh() + + def action_select_cursor(self) -> None: + """ + Occurs when keybind related to `select_cursor` is called. + """ + self._set_hover_cursor(False) + if self.show_cursor and self.cursor_type != "none" and self.has_focus: + self._emit_selected_message() diff --git a/cmd/explorer/src/gui/widget/tx_list_view.py b/cmd/explorer/src/gui/widget/tx_list_view.py new file mode 100644 index 00000000..acf0ba15 --- /dev/null +++ b/cmd/explorer/src/gui/widget/tx_list_view.py @@ -0,0 +1,39 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.widget import Widget +from textual.widgets import Static + +from src.gui.widget.base import TuiWidget +from src.gui.widget.tx_list_table import TxListTable + + +class TxListView(TuiWidget): + BINDINGS = [] + + def __init__(self, *children: Widget, name: str | None = None, id: str | None = None, classes: str | None = None): + super().__init__(*children, name=name, id=id, classes=classes) + + def compose(self) -> ComposeResult: + yield TxListTable(name="transactions", complete_refresh=True) + yield Horizontal( + Static("", classes="column"), + id="tx_list_description", + ) diff --git a/cmd/explorer/src/main.py b/cmd/explorer/src/main.py new file mode 100644 index 00000000..c86d3570 --- /dev/null +++ b/cmd/explorer/src/main.py @@ -0,0 +1,41 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +import logging +import asyncio +logging.getLogger("asyncio").setLevel(logging.WARNING) +logging.getLogger("psycopg").setLevel(logging.WARNING) + +import typer + +from src.gui.explorer import ExplorerApp + +app = typer.Typer(pretty_exceptions_show_locals=False) + + +@app.command() +def run( + url: str = typer.Option("http://localhost:5000", help="ibet-Prime server URL to connect"), + lot_size: int = typer.Option(100, help="Lot size to fetch Block Data list"), +): + explorer = ExplorerApp() + asyncio.run(explorer.run_async(url=url, lot_size=lot_size)) + + +if __name__ == "__main__": + app() diff --git a/cmd/explorer/src/utils/time.py b/cmd/explorer/src/utils/time.py new file mode 100644 index 00000000..c66a5a17 --- /dev/null +++ b/cmd/explorer/src/utils/time.py @@ -0,0 +1,77 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +from collections import OrderedDict +from datetime import datetime + +INTERVALS = OrderedDict( + [ + ("year", 31536000), # 60 * 60 * 24 * 365 + ("month", 2627424), # 60 * 60 * 24 * 30.41 (assuming 30.41 days in a month) + ("week", 604800), # 60 * 60 * 24 * 7 + ("day", 86400), # 60 * 60 * 24 + ("hr", 3600), # 60 * 60 + ("minute", 60), + ("sec", 1), + ] +) + + +def human_time(secs: int): + """Human-readable time from secs (ie. 5 days and 2 hrs). + Examples: + >>> human_time(15) + "less than minutes" + >>> human_time(3600) + "1 hr" + >>> human_time(3720) + "1 hr" + >>> human_time(266400) + "3 days" + >>> human_time(0) + "0 secs" + >>> human_time(1) + "less than minutes" + Args: + secs (int): Duration in secs. + Returns: + str: Human-readable time. + """ + if secs < 0: + return "0 secs" + elif secs == 0: + return "0 secs" + elif 1 < secs < INTERVALS["minute"]: + return "less than minutes" + + res = [] + for interval, count in INTERVALS.items(): + quotient, remainder = divmod(secs, count) + if quotient >= 1: + secs = remainder + if quotient > 1: + interval += "s" + res.append("%i %s" % (int(quotient), interval)) + if remainder == 0: + break + + return res[0] + + +def unix_to_iso(unix_time: int): + return datetime.fromtimestamp(unix_time).isoformat() diff --git a/poetry.lock b/poetry.lock index e8fe55c6..de5b5a70 100644 --- a/poetry.lock +++ b/poetry.lock @@ -176,6 +176,17 @@ files = [ {file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"}, ] +[[package]] +name = "async-cache" +version = "1.1.1" +description = "An asyncio Cache" +category = "main" +optional = true +python-versions = ">=3.3" +files = [ + {file = "async-cache-1.1.1.tar.gz", hash = "sha256:81aa9ccd19fb06784aaf30bd5f2043dc0a23fc3e998b93d0c2c17d1af9803393"}, +] + [[package]] name = "async-timeout" version = "4.0.2" @@ -321,18 +332,18 @@ files = [ [[package]] name = "boto3" -version = "1.26.82" +version = "1.26.83" description = "The AWS SDK for Python" category = "main" optional = false python-versions = ">= 3.7" files = [ - {file = "boto3-1.26.82-py3-none-any.whl", hash = "sha256:f618f2e8a4b9d5cb9f05d3a5628ca2afa4bcd3e8b4b8413d11dea0f4c4480b3c"}, - {file = "boto3-1.26.82.tar.gz", hash = "sha256:f50c048b4f3223dd57afb6eee1189ef9a472bf0ed9b26ff9db65ec13275c4414"}, + {file = "boto3-1.26.83-py3-none-any.whl", hash = "sha256:c07ed643aa72940f92b2c807591e1ba803c1b79d20ca129a6f8cccf846c40ff8"}, + {file = "boto3-1.26.83.tar.gz", hash = "sha256:df81bfa4f8dfb3a645c43b7fd317f48df3e7fa402115ef21fc2f6430d73a1821"}, ] [package.dependencies] -botocore = ">=1.29.82,<1.30.0" +botocore = ">=1.29.83,<1.30.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.6.0,<0.7.0" @@ -341,14 +352,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.29.82" +version = "1.29.83" description = "Low-level, data-driven core of boto 3." category = "main" optional = false python-versions = ">= 3.7" files = [ - {file = "botocore-1.29.82-py3-none-any.whl", hash = "sha256:b31e86f9b51eced5506a0f6038370fe8c1355000379610def1bb9e91cae9e77e"}, - {file = "botocore-1.29.82.tar.gz", hash = "sha256:913ca09a184cf4428f05838afc631c4fb93f3b173871b69d66230fd14656a3a1"}, + {file = "botocore-1.29.83-py3-none-any.whl", hash = "sha256:b7df2a1413f32992f350dd66bc5c8747b7211b776cf6500f64aebba7eddbe4f0"}, + {file = "botocore-1.29.83.tar.gz", hash = "sha256:2e3d215b94df4e4c0ce3da250db43fb39ef7834f8f8fd19e5b223b35edb4d073"}, ] [package.dependencies] @@ -1298,6 +1309,20 @@ cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (>=1.0.0,<2.0.0)"] +[[package]] +name = "ibet-prime-explorer" +version = "0.1.0" +description = "ibet-Prime Terminal UI for Block Chain Explorer" +category = "main" +optional = true +python-versions = "3.10.4" +files = [] +develop = true + +[package.source] +type = "directory" +url = "cmd/explorer" + [[package]] name = "idna" version = "3.4" @@ -1310,6 +1335,26 @@ files = [ {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +[[package]] +name = "importlib-metadata" +version = "4.13.0" +description = "Read metadata from Python packages" +category = "main" +optional = true +python-versions = ">=3.7" +files = [ + {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"}, + {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +perf = ["ipython"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] + [[package]] name = "iniconfig" version = "2.0.0" @@ -1451,6 +1496,31 @@ babel = ["Babel"] lingua = ["lingua"] testing = ["pytest"] +[[package]] +name = "markdown-it-py" +version = "2.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +category = "main" +optional = true +python-versions = ">=3.7" +files = [ + {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, + {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "markupsafe" version = "2.1.2" @@ -1511,6 +1581,18 @@ files = [ {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +category = "main" +optional = true +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + [[package]] name = "multiaddr" version = "0.0.9" @@ -1613,6 +1695,18 @@ files = [ {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, ] +[[package]] +name = "nanoid" +version = "2.0.0" +description = "A tiny, secure, URL-friendly, unique string ID generator for Python" +category = "main" +optional = true +python-versions = "*" +files = [ + {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"}, + {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"}, +] + [[package]] name = "netaddr" version = "0.8.0" @@ -1953,6 +2047,21 @@ typing-extensions = ">=4.2.0" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] +[[package]] +name = "pygments" +version = "2.14.0" +description = "Pygments is a syntax highlighting package written in Python." +category = "main" +optional = true +python-versions = ">=3.6" +files = [ + {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, + {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + [[package]] name = "pyrsistent" version = "0.19.3" @@ -2190,6 +2299,25 @@ idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} [package.extras] idna2008 = ["idna"] +[[package]] +name = "rich" +version = "13.3.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = true +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.3.1-py3-none-any.whl", hash = "sha256:8aa57747f3fc3e977684f0176a88e789be314a99f99b43b75d1e9cb5dc6db9e9"}, + {file = "rich-13.3.1.tar.gz", hash = "sha256:125d96d20c92b946b983d0d392b84ff945461e5a06d3867e9f9e575f8697b67f"}, +] + +[package.dependencies] +markdown-it-py = ">=2.1.0,<3.0.0" +pygments = ">=2.14.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rlp" version = "2.0.1" @@ -2384,6 +2512,26 @@ anyio = ">=3.4.0,<5" [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] +[[package]] +name = "textual" +version = "0.10.1" +description = "Modern Text User Interface framework" +category = "main" +optional = true +python-versions = ">=3.7,<4.0" +files = [ + {file = "textual-0.10.1-py3-none-any.whl", hash = "sha256:dd9a5b38a74cf42364a0f247e8f57e3ded7d69d44a63ee664af333f986c48e81"}, + {file = "textual-0.10.1.tar.gz", hash = "sha256:928cfeec37c60b212963f484e806b25380afdddb5a2aecd888ce8c9b46f93553"}, +] + +[package.dependencies] +importlib-metadata = ">=4.11.3,<5.0.0" +nanoid = ">=2.0.0" +rich = ">12.6.0" + +[package.extras] +dev = ["aiohttp (>=3.8.1)", "click (>=8.1.2)", "msgpack (>=1.0.3)"] + [[package]] name = "tomli" version = "2.0.1" @@ -2408,6 +2556,27 @@ files = [ {file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"}, ] +[[package]] +name = "typer" +version = "0.7.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +category = "main" +optional = true +python-versions = ">=3.6" +files = [ + {file = "typer-0.7.0-py3-none-any.whl", hash = "sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d"}, + {file = "typer-0.7.0.tar.gz", hash = "sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165"}, +] + +[package.dependencies] +click = ">=7.1.1,<9.0.0" + +[package.extras] +all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] +test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] + [[package]] name = "typing-extensions" version = "4.5.0" @@ -2645,7 +2814,26 @@ files = [ idna = ">=2.0" multidict = ">=4.0" +[[package]] +name = "zipp" +version = "3.15.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = true +python-versions = ">=3.7" +files = [ + {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, + {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] + +[extras] +ibet-explorer = ["aiohttp", "async-cache", "ibet-prime-explorer", "textual", "typer"] + [metadata] lock-version = "2.0" python-versions = "3.10.4" -content-hash = "f574750ca893eeb073fbd87cf216dac7158e22b9cffe3a0ca684e9c51e30521c" +content-hash = "d30919603cf774a6bbd285bccf971369efaa9d52177e2740a93ddfedac49005d" diff --git a/pyproject.toml b/pyproject.toml index d59fc9ea..b587d73a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,12 @@ uvicorn = "~0.20.0" web3 = "~5.31.3" sqlalchemy = "~2.0.4" +ibet-prime-explorer = {path = "cmd/explorer", optional = true, develop = true} +textual = {version = "~0.10.1", optional = true} +async-cache = {version = "~1.1.1", optional = true} +typer = {version = "~0.7.0", optional = true} +aiohttp = {version = "~3.8.4", optional = true} + [tool.poetry.group.dev.dependencies] httpx = "^0.23.3" pytest = "^7.2.1" @@ -33,6 +39,15 @@ pytest-cov = "^4.0.0" pyyaml = "^6.0" pytest-freezegun = "^0.4.2" +[tool.poetry.extras] +ibet-explorer = [ + "ibet-prime-explorer", + "textual", + "async-cache", + "typer", + "aiohttp" +] + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/Dockerfile b/tests/Dockerfile index ad4a4475..0487f42f 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -61,19 +61,12 @@ RUN . ~/.bash_profile \ && poetry config virtualenvs.create false # install python packages -USER apl -COPY pyproject.toml /app/pyproject.toml -COPY poetry.lock /app/poetry.lock -RUN . ~/.bash_profile \ - && poetry install --directory /app --no-root \ - && rm -f /app/pyproject.toml \ - && rm -f /app/poetry.lock - -# app deploy USER root COPY --chown=apl:apl LICENSE /app/ibet-Prime/ RUN mkdir -p /app/ibet-Prime/bin/ COPY --chown=apl:apl bin/ /app/ibet-Prime/bin/ +RUN mkdir -p /app/ibet-Prime/cmd/ +COPY --chown=apl:apl cmd/ /app/ibet-Prime/cmd/ RUN mkdir -p /app/ibet-Prime/contracts/ COPY --chown=apl:apl contracts/ /app/ibet-Prime/contracts/ RUN mkdir -p /app/ibet-Prime/conf/ @@ -88,6 +81,15 @@ COPY --chown=apl:apl app/ /app/ibet-Prime/app/ RUN find /app/ibet-Prime/ -type d -name __pycache__ | xargs rm -fr \ && chmod -R 755 /app/ibet-Prime/ +USER apl +COPY pyproject.toml /app/ibet-Prime/pyproject.toml +COPY poetry.lock /app/ibet-Prime/poetry.lock +RUN . ~/.bash_profile \ + && cd /app/ibet-Prime \ + && poetry install --no-root \ + && rm -f /app/ibet-Prime/pyproject.toml \ + && rm -f /app/ibet-Prime/poetry.lock + # test build layer USER root RUN mkdir -p /app/ibet-Prime/tests/ diff --git a/tests/test_app_routers_blockchain_explorer_block_data_GET.py b/tests/test_app_routers_blockchain_explorer_block_data_GET.py index e407fa9d..5be848e3 100644 --- a/tests/test_app_routers_blockchain_explorer_block_data_GET.py +++ b/tests/test_app_routers_blockchain_explorer_block_data_GET.py @@ -21,7 +21,11 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from app.model.db import IDXBlockData +from app.model.db import ( + IDXBlockData, + IDXBlockDataBlockNumber +) +from config import CHAIN_ID class TestListBlockData: @@ -126,16 +130,45 @@ def insert_block_data(db, block_data): db.add(block_model) db.commit() + @staticmethod + def insert_block_data_block_number(session: Session, latest_block_number: int): + idx_block_data_block_number = IDXBlockDataBlockNumber() + idx_block_data_block_number.chain_id = CHAIN_ID + idx_block_data_block_number.latest_block_number = latest_block_number + session.add(idx_block_data_block_number) + session.commit() + ########################################################################### # Normal ########################################################################### - # Normal_1 - def test_normal_1(self, client: TestClient, db: Session): + # Normal_1_1 + # IDXBlockDataBlockNumber is None + def test_normal_1_1(self, client: TestClient, db: Session): + # Request target API + with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True): + resp = client.get(self.apiurl) + + # Assertion + assert resp.status_code == 200 + + response_data = resp.json() + assert response_data["result_set"] == { + "count": 0, + "offset": None, + "limit": None, + "total": 0 + } + assert response_data["block_data"] == [] + + # Normal_1_2 + def test_normal_1_2(self, client: TestClient, db: Session): self.insert_block_data(db, self.block_0) self.insert_block_data(db, self.block_1) self.insert_block_data(db, self.block_2) + self.insert_block_data_block_number(db, latest_block_number=2) + # Request target API with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True): resp = client.get(self.apiurl) @@ -163,6 +196,8 @@ def test_normal_2_1(self, client: TestClient, db: Session): self.insert_block_data(db, self.block_1) self.insert_block_data(db, self.block_2) + self.insert_block_data_block_number(db, latest_block_number=2) + # Request target API with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True): params = {"from_block_number": 1} @@ -190,6 +225,8 @@ def test_normal_2_2(self, client: TestClient, db: Session): self.insert_block_data(db, self.block_1) self.insert_block_data(db, self.block_2) + self.insert_block_data_block_number(db, latest_block_number=2) + # Request target API with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True): params = {"to_block_number": 1} @@ -217,6 +254,8 @@ def test_normal_3_1(self, client: TestClient, db: Session): self.insert_block_data(db, self.block_1) self.insert_block_data(db, self.block_2) + self.insert_block_data_block_number(db, latest_block_number=2) + # Request target API with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True): params = {"offset": 1} @@ -244,6 +283,8 @@ def test_normal_3_2(self, client: TestClient, db: Session): self.insert_block_data(db, self.block_1) self.insert_block_data(db, self.block_2) + self.insert_block_data_block_number(db, latest_block_number=2) + # Request target API with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True): params = {"limit": 1} @@ -263,6 +304,36 @@ def test_normal_3_2(self, client: TestClient, db: Session): self.filter_response_item(self.block_0) ] + # Normal_4 + # sort_order + def test_normal_4(self, client: TestClient, db: Session): + self.insert_block_data(db, self.block_0) + self.insert_block_data(db, self.block_1) + self.insert_block_data(db, self.block_2) + + self.insert_block_data_block_number(db, latest_block_number=2) + + # Request target API + with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True): + params = {"sort_order": 1} + resp = client.get(self.apiurl, params=params) + + # Assertion + assert resp.status_code == 200 + + response_data = resp.json() + assert response_data["result_set"] == { + "count": 3, + "offset": None, + "limit": None, + "total": 3 + } + assert response_data["block_data"] == [ + self.filter_response_item(self.block_2), + self.filter_response_item(self.block_1), + self.filter_response_item(self.block_0) + ] + ########################################################################### # Error ########################################################################### @@ -339,6 +410,8 @@ def test_error_3(self, client: TestClient, db: Session): self.insert_block_data(db, self.block_1) self.insert_block_data(db, self.block_2) + self.insert_block_data_block_number(db, latest_block_number=2) + # Request target API with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True),\ mock.patch("app.routers.bc_explorer.BLOCK_RESPONSE_LIMIT", 2):