From 1af07f96ba9f90e234467e5639440653c9df8d04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Mon, 15 Dec 2025 10:28:07 +0100 Subject: [PATCH 01/15] Adjust docstring --- clive/__private/settings/_settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clive/__private/settings/_settings.py b/clive/__private/settings/_settings.py index 553652c6a8..63a4239ce2 100644 --- a/clive/__private/settings/_settings.py +++ b/clive/__private/settings/_settings.py @@ -50,7 +50,7 @@ class Settings: The environment variable ```bash - CLIVE__FIRST_GROUP__NESTED_GROUP__SOME_KEY=124 + CLIVE_FIRST_GROUP__NESTED_GROUP__SOME_KEY=124 ``` would override the setting in the file. -- GitLab From 69919a0f578ee0174f3a05d3c5d3765b54214cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Thu, 11 Dec 2025 09:16:32 +0000 Subject: [PATCH 02/15] Add CLAUDE.md with project documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive project overview and architecture documentation - Document CLI and TUI structure with reference to docs/cli_commands_structure.md - Include testing patterns, fixtures, and CI configuration - Document code style, conventions, and development guidelines - Add known accounts, tracked accounts, and profile system details - Reference configuration sources instead of hardcoded values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 295 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..b59dfa3eb0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,295 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Clive** is a CLI and TUI (Terminal User Interface) application for interacting with the Hive blockchain. It's written +in Python and designed to replace the original Hive CLI for power users and testing. The TUI features mouse-based +navigation inspired by midnight commander. + +- **Main entry point**: `clive` - automatically launches TUI when run without arguments, or CLI mode when arguments + are provided like `clive` for TUI and `clive --help` for CLI +- **Development entry point**: `clive-dev` - includes extra debugging information +- **Python version**: (see `requires-python` in `pyproject.toml`, restricted due to wax dependency) +- **Build system**: Poetry +- **Main branch**: `develop` (use this for PRs, not `main`) + +## GitLab Instance + +This project uses **gitlab.syncad.com**, NOT gitlab.com. + +- Repository: https://gitlab.syncad.com/hive/clive +- Use `glab api "projects/hive%2Fclive/..."` for API calls + +## Essential Commands + +### Installation and Setup + +```bash +# Install dependencies (must be run from repository root) +poetry install + +# When updating dependencies and hive submodule is updated also, test-tools should be forced to uninstall first +pip uninstall -y test-tools && poetry install +``` + +### Running Clive + +```bash +# Launch TUI (default) +clive + +# Use CLI mode +clive --help +clive show profile +clive configure profile create + +# Development mode with debug info (useful for presenting full stack-trace instead of pretty errors in CLI) +clive-dev +``` + +### Linting and Formatting + +```bash +# Run all pre-commit hooks (include tools like ruff, mypy and additional hooks) +pre-commit run --all-files + +# Lint with Ruff +ruff check clive/ tests/ + +# Format code +ruff format clive/ tests/ + +# Type checking +mypy clive/ tests/ +``` + +### Testing + +```bash +# Run smoke test +pytest -n 2 tests/functional/cli/show/test_show_account.py::test_show_account tests/functional/cli/process/test_process_transfer.py::test_process_transfer + +# Run all tests in parallel (default process count set in .gitlab-ci.yml) +pytest -n 16 + +# Run unit tests only +pytest tests/unit/ + +# Run functional tests (CLI or TUI) +pytest tests/functional/cli/ +pytest tests/functional/tui/ + +# Run a single test file (example) +pytest tests/unit/test_date_utils.py + +# Run a specific test (example) +pytest tests/unit/test_date_utils.py::test_specific_function -v + +# Run with timeout (important for tests that may hang) +pytest --timeout=600 + +# Run without parallelization (for debugging) +pytest -n 0 +``` + +**Note**: Tests require the embedded testnet dependencies. Some tests spawn local Hive nodes using `test-tools`. + +## Architecture + +### Configuration + +**Global settings** can be configured via: + +1. **Settings files** (in order of precedence): + + - `~/.clive/settings.toml` (user settings, higher priority) + - `{project_root}/settings.toml` (project defaults) + +2. **Environment variables** override settings files using the format: + + ```bash + CLIVE_{GROUP}__{KEY}=value + ``` + + Example: `CLIVE_NODE__CHAIN_ID=abc123` overrides `[NODE] CHAIN_ID` in settings.toml + +3. **Special environment variable**: `CLIVE_DATA_PATH` controls the Clive data directory (default: `~/.clive`). This + also determines where user settings are loaded from (`$CLIVE_DATA_PATH/settings.toml`). + +**Per-profile settings** are stored separately for each profile and configured via `clive configure`: + +- Tracked accounts (working account + watched accounts) +- Known accounts (and enable/disable feature) +- Key aliases +- Node address +- Chain ID + +See `clive configure --help` for all available options. + +### Core Architecture Pattern: Command Pattern + +Clive uses a **Command Pattern** for all operations that interact with the blockchain or beekeeper: + +- **Commands location**: `clive/__private/core/commands/` +- **Base classes**: All commands inherit from `Command` (in `abc/command.py`) +- **Execution**: Commands are async and executed via `await command.execute()` +- **Command hierarchy**: + - `Command` - Base class with `_execute()` method + - `CommandWithResult` - Commands that return a value + - `CommandRestricted` - Base for commands with execution preconditions + - `CommandInUnlocked` - Commands requiring unlocked user wallet + - `CommandEncryption` - Commands requiring both unlocked user wallet and encryption wallet + - `CommandPasswordSecured` - Commands requiring a password + - `CommandDataRetrieval` - Commands that fetch data from the node + - `CommandCachedDataRetrieval` - Data retrieval with caching support + +### World Object - Application Container + +`World` (`clive/__private/core/world.py`) is the top-level container and single source of truth: + +- `world.profile` - Current user profile (settings, accounts, keys) +- `world.node` - Hive node connection for API calls +- `world.commands` - Access to all command instances +- `world.beekeeper_manager` - Manages beekeeper (key storage) lifecycle +- `world.app_state` - Application state (locked/unlocked, etc.) + +**Important**: Direct `world.node` API calls should be avoided in CLI/TUI. Use `world.commands` instead, which handles +errors properly. + +### Profile System + +Profiles (`clive/__private/core/profile.py`) store user configuration: + +- **Working account**: The currently active Hive account +- **Watched accounts**: Accounts being monitored +- **Known accounts**: Accounts approved for transactions. CLI requires explicit addition before broadcasting + operations (configurable via `enable`/`disable`). TUI automatically adds accounts when operations are added to cart + (also configurable). Managed via `clive configure known-account` +- **Key aliases**: Named public keys +- **Transaction**: Pending transaction operations +- **Node address**: Hive node endpoint +- **Chain ID**: Blockchain identifier (like a mainnet/mirrornet/testnet) + +Profiles are persisted to disk via `PersistentStorageService` with encryption support. + +**Note**: Tracked accounts is a combination of working account and watched accounts. + +### Dual Interface Architecture + +**CLI Mode** (`clive/__private/cli/`): + +- Built with **Typer** for command-line interface +- Main command groups: `configure`, `show`, `process`, `beekeeper`, `generate`, `unlock`, `lock` +- For complete CLI command structure, see `docs/cli_commands_structure.md` +- CLI implementation in `clive/__private/cli/` +- Most of the commands —especially those that interact with profile— require Beekeeper (via the + `CLIVE_BEEKEEPER__REMOTE_ADDRESS` and `CLIVE_BEEKEEPER__SESSION_TOKEN` environment variables) for profile encryption + and decryption. +- Commands `clive beekeeper spawn` and `clive beekeeper create-session` can be used for preparing the CLI environment. + +**TUI Mode** (`clive/__private/ui/`): + +- Built with **Textual** (Python TUI framework) +- Main app: `clive/__private/ui/app.py` (Clive class) +- Screens in `clive/__private/ui/screens/` +- Widgets in `clive/__private/ui/widgets/` +- Styling: TCSS (a Textual variation of CSS) files stored as .scss due to better syntax highlighting +- TUI can be used in environment where Beekeeper is already running (`CLIVE_BEEKEEPER__REMOTE_ADDRESS` and + `CLIVE_BEEKEEPER__SESSION_TOKEN` env vars are set), but without them, beekeeper will be automatically spawned and + session will be created when starting TUI. + +### Beekeeper Integration + +Clive uses **beekeepy** (async Python wrapper) to communicate with Hive's beekeeper for key management: + +- **BeekeeperManager**: `clive/__private/core/beekeeper_manager.py` +- Beekeeper stores keys in encrypted wallets +- Beekeeper wallets are stored in the `~/.clive/beekeeper` directory (or `$CLIVE_DATA_PATH/beekeeper` if customized) +- Two wallet types: user wallets (for signing) and encryption wallets (for encrypting profile data) +- Wallets must be unlocked before use +- Beekeeper address and session token can be pointed with respective setting in the `settings.toml` file or via env + var that would have higher precedence + +### Blockchain Communication + +- **Node interaction**: `clive/__private/core/node/node.py` +- **API wrapper**: `clive/__private/core/node/async_hived/` - async wrapper around Hive node APIs +- **Wax integration**: Uses `hiveio-wax` for transaction building and signing +- **Operation models**: `clive/__private/models/schemas.py` - Pydantic models for Hive operations + +### Storage and Migrations + +- **Storage service**: `clive/__private/storage/service/service.py` +- **Converters**: Runtime models ↔ Storage models +- **Migrations**: `clive/__private/storage/migrations/` - versioned profile schema migrations +- Profiles are stored as encrypted files (location can controlled by `CLIVE_DATA_PATH` environment variable, default: + `~/.clive/data/`) + +## Test Organization + +Tests are organized into two main categories: + +- **`tests/unit/`** - Unit tests for individual components (keys, storage, commands, etc.) +- **`tests/functional/`** - Functional tests split by interface: + - `functional/cli/` - CLI command tests + - `functional/tui/` - TUI interaction tests + +**Test fixtures and patterns**: + +Common fixtures (`tests/conftest.py`): + +- `world` - Async World instance +- `beekeeper` - Async beekeeper instance from beekeepy (spawned automatically) +- `node` - Local testnet node (spawned automatically via test-tools) + +CLI test patterns (`tests/functional/cli/`): + +- Uses `CLITester` from `clive-local-tools` package +- `cli_tester` fixture - Provides typed CLI testing interface with command invocation and output checking + +TUI test patterns (`tests/functional/tui/`): + +- Uses `ClivePilot` (Textual's async test driver) +- `prepared_env` fixture - Returns `(node, wallet, pilot)` tuple with TUI ready on Unlock screen +- `prepared_tui_on_dashboard` fixture - TUI already authenticated and on Dashboard screen +- `node_with_wallet` fixture - Test node with initialized wallet +- Tests interact with TUI via pilot (e.g., `pilot.click()`, `pilot.press()`) + +## Development Guidelines + +### Code Style + +- **Strict mypy**: Type hints are required and strictly enforced +- **Ruff**: Comprehensive linting with "ALL" rules (see `pyproject.toml` for ignored rules) +- **Future imports**: All files must have `from __future__ import annotations` (enforced by ruff) +- **Docstrings**: Google style, checked by pydoclint (no redundant type hints in docstrings) +- **Line length**: See `line-length` in `pyproject.toml` (currently 120 characters) + +### Important Conventions + +1. **Private modules**: Implementation details are in `__private/` directories +2. **No direct initialization**: Some classes (like `Profile`) use factory methods instead of `__init__` +3. **Command pattern**: Always use commands for blockchain operations, not direct API calls +4. **Async context managers**: Many resources (World, Node) require async context manager usage +5. **Settings**: Use `safe_settings` from `clive/__private/settings` for reading configuration + +### When Working With Tests + +- Pytest tests use `test-tools` from the Hive submodule for spawning local nodes +- For manual tests, local testnet node can be started manually via `testnet_node.py` +- Both `testnet_node.py` and `test-tools` based tests require executables from the `hive` submodule pointed by the + `HIVE_BUILD_ROOT_PATH` environment variable +- Beekeeper is spawned automatically by test fixtures when needed +- Tests modify settings to use test directories, not user's actual Clive data +- The `clive-local-tools` package provides test helpers and checkers + +### CI Environment + +Tests run in CI with: + +- Parallel processes configurable via `PYTEST_NUMBER_OF_PROCESSES` (see `.gitlab-ci.yml`, default: 16) +- Timeout configurable via `PYTEST_TIMEOUT_MINUTES` (typically 10 minutes for most test suites) +- Separate jobs for unit tests, CLI tests, and TUI tests +- Tests run against installed wheel (not editable install) -- GitLab From 2edd1a70207917bc3c84899d380420e10ce43b8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Mon, 15 Dec 2025 10:53:51 +0000 Subject: [PATCH 03/15] Add Claude Code slash commands for common workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /smoke, /lint, /test, and /reflection commands to streamline development workflows like running smoke tests, linting, and pytest. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/lint.md | 13 +++++++++ .claude/commands/reflection.md | 48 ++++++++++++++++++++++++++++++++++ .claude/commands/smoke.md | 9 +++++++ .claude/commands/test.md | 28 ++++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 .claude/commands/lint.md create mode 100644 .claude/commands/reflection.md create mode 100644 .claude/commands/smoke.md create mode 100644 .claude/commands/test.md diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md new file mode 100644 index 0000000000..dc7ec82026 --- /dev/null +++ b/.claude/commands/lint.md @@ -0,0 +1,13 @@ +Run all linting and formatting checks on the codebase. + +Execute this command: + +```bash +pre-commit run --all-files +``` + +Report the results: + +- If all checks pass, confirm success +- If checks fail, summarize which hooks failed and the key issues found +- For auto-fixable issues (like formatting), mention if files were modified diff --git a/.claude/commands/reflection.md b/.claude/commands/reflection.md new file mode 100644 index 0000000000..5368d98e52 --- /dev/null +++ b/.claude/commands/reflection.md @@ -0,0 +1,48 @@ +You are an expert in prompt engineering, specializing in optimizing AI code assistant instructions. Your task is to +analyze and improve the instructions for Claude Code. Follow these steps carefully: + +1. Analysis Phase: Review the chat history in your context window. + +Then, examine the current Claude instructions, commands and config /CLAUDE.md /.claude/commands/\* +\*\*/CLAUDE.md .claude/settings.json .claude/settings.local.json + +Analyze the chat history, instructions, commands and config to identify areas that could be improved. Look for: + +- Inconsistencies in Claude's responses +- Misunderstandings of user requests +- Areas where Claude could provide more detailed or accurate information +- Opportunities to enhance Claude's ability to handle specific types of queries or tasks +- New commands or improvements to a commands name, function or response +- MCPs we've approved locally that we should add to the config, especially if we've added new tools or require them + for the command to work + +2. Interaction Phase: Present your findings and improvement ideas to the human. For each suggestion: a) Explain the + current issue you've identified b) Propose a specific change or addition to the instructions c) Describe how this + change would improve Claude's performance + +Wait for feedback from the human on each suggestion before proceeding. If the human approves a change, move it to the +implementation phase. If not, refine your suggestion or move on to the next idea. + +3. Implementation Phase: For each approved change: a) Clearly state the section of the instructions you're modifying b) + Present the new or modified text for that section c) Explain how this change addresses the issue identified in the + analysis phase + +4. Output Format: Present your final output in the following structure: + + +[List the issues identified and potential improvements] + + + +[For each approved improvement: +1. Section being modified +2. New or modified instruction text +3. Explanation of how this addresses the identified issue] + + + [Present the complete, updated set of instructions for Claude, incorporating all approved changes] + + +Remember, your goal is to enhance Claude's performance and consistency while maintaining the core functionality and +purpose of the AI assistant. Be thorough in your analysis, clear in your explanations, and precise in your +implementations. diff --git a/.claude/commands/smoke.md b/.claude/commands/smoke.md new file mode 100644 index 0000000000..10230b31e8 --- /dev/null +++ b/.claude/commands/smoke.md @@ -0,0 +1,9 @@ +Run the smoke test to quickly verify basic CLI functionality. + +Execute this command: + +```bash +pytest -n 2 tests/functional/cli/show/test_show_account.py::test_show_account tests/functional/cli/process/test_process_transfer.py::test_process_transfer +``` + +Report the results concisely - whether tests passed or failed, and any errors encountered. diff --git a/.claude/commands/test.md b/.claude/commands/test.md new file mode 100644 index 0000000000..0ec397fc32 --- /dev/null +++ b/.claude/commands/test.md @@ -0,0 +1,28 @@ +Run pytest with the provided arguments. + +Arguments: $ARGUMENTS + +First, expand any shortcuts in the arguments: + +- `unit` → `tests/unit/` +- `cli` → `tests/functional/cli/` +- `tui` → `tests/functional/tui/` +- `functional` → `tests/functional/` + +Then execute pytest with the expanded path. Examples: + +- `/test unit` runs `pytest tests/unit/` +- `/test cli` runs `pytest tests/functional/cli/` +- `/test tests/unit/test_date_utils.py -v` runs as-is (full path provided) + +If no arguments provided, run all tests with parallel execution: + +```bash +pytest -n 16 +``` + +Report results concisely: + +- Number of tests passed/failed/skipped +- For failures, show the test name and a brief summary of the error +- Suggest next steps if tests fail -- GitLab From f28af530361d66db6ecccb16864a2420b65add6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Mon, 15 Dec 2025 11:23:23 +0000 Subject: [PATCH 04/15] Document Claude Code slash commands and simplify Testing section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add new "Claude Code Commands" section documenting /smoke, /lint, /test, /reflection - Document test shortcuts (unit, cli, tui, functional) - Simplify Testing section by referencing slash commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b59dfa3eb0..9c71840cdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,17 @@ This project uses **gitlab.syncad.com**, NOT gitlab.com. - Repository: https://gitlab.syncad.com/hive/clive - Use `glab api "projects/hive%2Fclive/..."` for API calls +## Claude Code Commands + +Available slash commands for development workflows: + +| Command | Description | +| -------------- | ------------------------------------------------------------- | +| `/smoke` | Run smoke test | +| `/lint` | Run all pre-commit hooks | +| `/test ` | Run pytest with shortcuts: `unit`, `cli`, `tui`, `functional` | +| `/reflection` | Analyze and improve Claude Code configuration | + ## Essential Commands ### Installation and Setup @@ -67,23 +78,10 @@ mypy clive/ tests/ ### Testing -```bash -# Run smoke test -pytest -n 2 tests/functional/cli/show/test_show_account.py::test_show_account tests/functional/cli/process/test_process_transfer.py::test_process_transfer - -# Run all tests in parallel (default process count set in .gitlab-ci.yml) -pytest -n 16 - -# Run unit tests only -pytest tests/unit/ - -# Run functional tests (CLI or TUI) -pytest tests/functional/cli/ -pytest tests/functional/tui/ - -# Run a single test file (example) -pytest tests/unit/test_date_utils.py +Use `/smoke`, `/lint`, and `/test` slash commands for common workflows (see +[Claude Code Commands](#claude-code-commands)). +```bash # Run a specific test (example) pytest tests/unit/test_date_utils.py::test_specific_function -v -- GitLab From 35484a0434c18e15b458d666c9e3e3854f2303a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=BBebrak?= Date: Mon, 15 Dec 2025 13:03:47 +0000 Subject: [PATCH 05/15] Add useful GitLab CLI commands to CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added glab commands for common MR and pipeline operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 9c71840cdf..707610c792 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -283,6 +283,24 @@ TUI test patterns (`tests/functional/tui/`): - Tests modify settings to use test directories, not user's actual Clive data - The `clive-local-tools` package provides test helpers and checkers +### Useful Commands + +#### GitLab CLI (glab) + +```bash +# Find MR for a branch +glab mr list --source-branch= + +# Add comment to MR +glab mr note --message "..." + +# Get pipeline job details +glab api "projects/hive%2Fclive/pipelines//jobs" + +# Get job logs +glab api "projects/hive%2Fclive/jobs//trace" +``` + ### CI Environment Tests run in CI with: -- GitLab From f9ab52ed08dda64c8a5952b6ed8fbcb584404323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Tue, 16 Dec 2025 10:36:31 +0000 Subject: [PATCH 06/15] Add PY (Python Interface) foundation layer Introduces foundational components for Python Interface (PY): - Module structure (clive/__private/py/) - Exceptions: PyContextManagerNotUsedError, validation errors - Input validators: AccountName, PageSize, PageNumber, KeyPairs - Data classes: Balances, Accounts, Authority, AuthorityInfo, Witness, WitnessesResult, KeyPair - CommandBase for PY commands with validation support - Foundation commands: ShowProfiles, ShowBalances, ShowAccounts, ShowWitnesses, ShowAuthority, GenerateRandomKey The WitnessesResult dataclass provides proper encapsulation for witness queries, returning witnesses list along with total_count and proxy metadata. --- clive/__private/py/__init__.py | 8 ++ clive/__private/py/data_classes.py | 88 ++++++++++++++ clive/__private/py/exceptions.py | 55 +++++++++ clive/__private/py/foundation/__init__.py | 0 clive/__private/py/foundation/base.py | 19 +++ clive/__private/py/foundation/generate.py | 24 ++++ clive/__private/py/foundation/show.py | 136 ++++++++++++++++++++++ clive/__private/py/validators.py | 92 +++++++++++++++ pydoclint-errors-baseline.txt | 39 +++++++ 9 files changed, 461 insertions(+) create mode 100644 clive/__private/py/__init__.py create mode 100644 clive/__private/py/data_classes.py create mode 100644 clive/__private/py/exceptions.py create mode 100644 clive/__private/py/foundation/__init__.py create mode 100644 clive/__private/py/foundation/base.py create mode 100644 clive/__private/py/foundation/generate.py create mode 100644 clive/__private/py/foundation/show.py create mode 100644 clive/__private/py/validators.py diff --git a/clive/__private/py/__init__.py b/clive/__private/py/__init__.py new file mode 100644 index 0000000000..3352df7fb5 --- /dev/null +++ b/clive/__private/py/__init__.py @@ -0,0 +1,8 @@ +""" +Clive Python Interface (PY) - Internal implementation. + +This module contains the internal implementation of the Python Interface. +For public API, use: from clive.py import ClivePy, UnlockedClivePy, clive_use_unlocked_profile +""" + +from __future__ import annotations diff --git a/clive/__private/py/data_classes.py b/clive/__private/py/data_classes.py new file mode 100644 index 0000000000..84fec50832 --- /dev/null +++ b/clive/__private/py/data_classes.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime + + from clive.__private.models.asset import Asset + + +@dataclass +class Balances: + """Account balances for HBD and HIVE.""" + + hbd_liquid: Asset.Hbd + hbd_savings: Asset.Hbd + hbd_unclaimed: Asset.Hbd + hive_liquid: Asset.Hive + hive_savings: Asset.Hive + hive_unclaimed: Asset.Hive + + def __str__(self) -> str: + return ( + f"HBD: {self.hbd_liquid.pretty_amount()} (liquid), " + f"{self.hbd_savings.pretty_amount()} (savings), " + f"{self.hbd_unclaimed.pretty_amount()} (unclaimed) | " + f"HIVE: {self.hive_liquid.pretty_amount()} (liquid), " + f"{self.hive_savings.pretty_amount()} (savings), " + f"{self.hive_unclaimed.pretty_amount()} (unclaimed)" + ) + + +@dataclass +class Accounts: + """Account tracking information.""" + + working_account: str | None + tracked_accounts: list[str] + known_accounts: list[str] + + +@dataclass +class Authority: + """Authority structure for an account or key.""" + + account_or_public_key: str + weight: int + + +@dataclass +class AuthorityInfo: + """Detailed authority information for an account.""" + + authority_owner_account_name: str + authority_type: str + weight_threshold: int + authorities: list[Authority] + + +@dataclass +class KeyPair: + """Key pair (private/public).""" + + private_key: str | None + public_key: str | None + + +@dataclass +class Witness: + voted: bool + rank: int | None + witness_name: str + votes: str + created: datetime + missed_blocks: int + last_block: int + price_feed: str + version: str + + +@dataclass +class WitnessesResult: + """Result of witnesses query including pagination metadata.""" + + witnesses: list[Witness] + total_count: int + proxy: str | None diff --git a/clive/__private/py/exceptions.py b/clive/__private/py/exceptions.py new file mode 100644 index 0000000000..ef4e33833d --- /dev/null +++ b/clive/__private/py/exceptions.py @@ -0,0 +1,55 @@ +"""Exceptions for the PY module.""" + +from __future__ import annotations + + +class PyContextManagerNotUsedError(Exception): + """Raised when UnlockedClivePy is used without async context manager.""" + + def __init__(self, message: str) -> None: + super().__init__(message) + + +class PasswordRequirementsNotMetError(Exception): + """Raised when the provided password does not meet the requirements.""" + + def __init__(self, description: str) -> None: + super().__init__(f"Password requirements not met: {description}") + + +class InvalidAccountNameError(Exception): + """Raised when the provided account name is invalid.""" + + def __init__(self, account_name: str) -> None: + super().__init__(f"Invalid account name: '{account_name}'.") + + +class InvalidProfileNameError(Exception): + """Raised when the provided profile name is invalid.""" + + def __init__(self, profile_name: str, description: str) -> None: + super().__init__(f"Invalid profile name: '{profile_name}'. {description}") + + +class InvalidPageNumberError(Exception): + """Raised when the provided page number is invalid.""" + + def __init__(self, page_number: int, min_number: int = 0) -> None: + super().__init__(f"Invalid page number: {page_number}. Page number must be greater or equal to {min_number}.") + + +class InvalidPageSizeError(Exception): + """Raised when the provided page size is invalid.""" + + def __init__(self, page_size: int, min_size: int) -> None: + super().__init__(f"Invalid page size: {page_size}. Page size must be greater than or equal to {min_size}.") + + +class InvalidNumberOfKeyPairsError(Exception): + """Raised when the provided number of key pairs is invalid.""" + + def __init__(self, number_of_key_pairs: int, min_size: int) -> None: + super().__init__( + f"Invalid number of key pairs: {number_of_key_pairs}. " + f"Number of key pairs must be greater than or equal to {min_size}." + ) diff --git a/clive/__private/py/foundation/__init__.py b/clive/__private/py/foundation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/clive/__private/py/foundation/base.py b/clive/__private/py/foundation/base.py new file mode 100644 index 0000000000..4cde323fa4 --- /dev/null +++ b/clive/__private/py/foundation/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TypeVar + +T = TypeVar("T") + + +class CommandBase[T](ABC): + async def validate(self) -> None: # noqa: B027 + pass + + async def run(self) -> T: + await self.validate() + return await self._run() + + @abstractmethod + async def _run(self) -> T: + """Run the command logic.""" diff --git a/clive/__private/py/foundation/generate.py b/clive/__private/py/foundation/generate.py new file mode 100644 index 0000000000..5d4f51fe14 --- /dev/null +++ b/clive/__private/py/foundation/generate.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from clive.__private.core.keys.keys import PrivateKey +from clive.__private.py.data_classes import KeyPair +from clive.__private.py.foundation.base import CommandBase +from clive.__private.py.validators import ( + KeyPairsNumberValidator, +) + + +class GenerateRandomKey(CommandBase[list[KeyPair]]): + def __init__(self, key_pairs: int) -> None: + self.key_pairs = key_pairs + + async def validate(self) -> None: + KeyPairsNumberValidator().validate(self.key_pairs) + + async def _run(self) -> list[KeyPair]: + key_pairs_list = [] + for _ in range(self.key_pairs): + private_key = PrivateKey.generate() + public_key = private_key.calculate_public_key() + key_pairs_list.append(KeyPair(private_key=private_key.value, public_key=public_key.value)) + return key_pairs_list diff --git a/clive/__private/py/foundation/show.py b/clive/__private/py/foundation/show.py new file mode 100644 index 0000000000..9c910ea281 --- /dev/null +++ b/clive/__private/py/foundation/show.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.core.accounts.accounts import TrackedAccount +from clive.__private.core.commands.data_retrieval.witnesses_data import ( + WitnessData, + WitnessesData, + WitnessesDataRetrieval, +) +from clive.__private.core.profile import Profile +from clive.__private.py.data_classes import Accounts, Authority, AuthorityInfo, Balances, Witness, WitnessesResult +from clive.__private.py.foundation.base import CommandBase +from clive.__private.py.validators import AccountNameValidator, PageNumberValidator, PageSizeValidator + +if TYPE_CHECKING: + from clive.__private.core.types import AuthorityLevelRegular + from clive.__private.core.world import World + + +class ShowProfiles(CommandBase[list[str]]): + def __init__(self) -> None: + pass + + async def _run(self) -> list[str]: + return Profile.list_profiles() + + +class ShowBalances(CommandBase[Balances]): + def __init__(self, world: World, account_name: str) -> None: + self.world = world + self.account_name = account_name + + async def validate(self) -> None: + AccountNameValidator().validate(self.account_name) + + async def _run(self) -> Balances: + account = TrackedAccount(name=self.account_name) + await self.world.commands.update_node_data(accounts=[account]) + + return Balances( + hbd_liquid=account.data.hbd_balance, + hbd_savings=account.data.hbd_savings, + hbd_unclaimed=account.data.hbd_unclaimed, + hive_liquid=account.data.hive_balance, + hive_savings=account.data.hive_savings, + hive_unclaimed=account.data.hive_unclaimed, + ) + + +class ShowAccounts(CommandBase[Accounts]): + def __init__(self, world: World) -> None: + self.world = world + + async def _run(self) -> Accounts: + profile = self.world.profile + return Accounts( + working_account=profile.accounts.working.name if profile.accounts.has_working_account else None, + tracked_accounts=[account.name for account in profile.accounts.tracked], + known_accounts=[account.name for account in profile.accounts.known], + ) + + +class ShowWitnesses(CommandBase[WitnessesResult]): + def __init__(self, world: World, account_name: str, page_size: int, page_no: int) -> None: + self.world = world + self.account_name = account_name + self.page_size = page_size + self.page_no = page_no + + async def validate(self) -> None: + AccountNameValidator().validate(self.account_name) + PageSizeValidator().validate(self.page_size) + PageNumberValidator().validate(self.page_no) + + async def _run(self) -> WitnessesResult: + accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise + proxy = accounts[0].proxy + + wrapper = await self.world.commands.retrieve_witnesses_data( + account_name=proxy if proxy else self.account_name, + mode=WitnessesDataRetrieval.DEFAULT_MODE, + witness_name_pattern=None, + search_by_pattern_limit=WitnessesDataRetrieval.DEFAULT_SEARCH_BY_PATTERN_LIMIT, + ) + witnesses_data: WitnessesData = wrapper.result_or_raise + start_index: int = self.page_no * self.page_size + end_index: int = start_index + self.page_size + witnesses_list: list[WitnessData] = list(witnesses_data.witnesses.values()) + witnesses_chunk: list[WitnessData] = witnesses_list[start_index:end_index] + + witnesses = [ + Witness( + voted=witness.voted, + rank=witness.rank, + witness_name=witness.name, + votes=witness.votes, + created=witness.created, + missed_blocks=witness.missed_blocks, + last_block=witness.last_block, + price_feed=witness.price_feed, + version=witness.version, + ) + for witness in witnesses_chunk + ] + + return WitnessesResult( + witnesses=witnesses, + total_count=len(witnesses_list), + proxy=proxy, + ) + + +class ShowAuthority(CommandBase[AuthorityInfo]): + def __init__(self, world: World, account_name: str, authority: AuthorityLevelRegular) -> None: + self.world = world + self.account_name = account_name + self.authority = authority + + async def validate(self) -> None: + AccountNameValidator().validate(self.account_name) + + async def _run(self) -> AuthorityInfo: + accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise + account = accounts[0] + + authorities = [] + for auth, weight in [*account[self.authority].key_auths, *account[self.authority].account_auths]: + authorities.append(Authority(account_or_public_key=auth, weight=weight)) + + return AuthorityInfo( + authority_owner_account_name=account.name, + authority_type=self.authority, + weight_threshold=account[self.authority].weight_threshold, + authorities=authorities, + ) diff --git a/clive/__private/py/validators.py b/clive/__private/py/validators.py new file mode 100644 index 0000000000..7bbcaf0722 --- /dev/null +++ b/clive/__private/py/validators.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Final + +from clive.__private.core.formatters.humanize import humanize_validation_result +from clive.__private.py.exceptions import ( + InvalidAccountNameError, + InvalidNumberOfKeyPairsError, + InvalidPageNumberError, + InvalidPageSizeError, + InvalidProfileNameError, + PasswordRequirementsNotMetError, +) +from clive.__private.validators.account_name_validator import AccountNameValidator as AccountNameValidatorImpl +from clive.__private.validators.profile_name_validator import ProfileNameValidator as ProfileNameValidatorImpl +from clive.__private.validators.set_password_validator import SetPasswordValidator as SetPasswordValidatorImpl + + +class Validator(ABC): + """ + Abstract base class for all PY validators. + + All validators must implement the validate() method which should raise + an appropriate exception from clive.__private.py.exceptions if validation fails. + + Configuration should be passed via __init__, and validate() should accept only the value to validate. + """ + + @abstractmethod + def validate(self, value: object) -> None: + """ + Validate input data. + + Args: + value: The value to validate. + + Raises: + An appropriate exception from clive.__private.py.exceptions if validation fails. + """ + ... + + +class ProfileNameValidator(Validator): + def validate(self, value: object) -> None: + assert isinstance(value, str) + validation_result = ProfileNameValidatorImpl().validate(value) + if not validation_result.is_valid: + raise InvalidProfileNameError(value, humanize_validation_result(validation_result)) + + +class SetPasswordValidator(Validator): + def validate(self, value: object) -> None: + assert isinstance(value, str) + validation_result = SetPasswordValidatorImpl().validate(value) + if not validation_result.is_valid: + raise PasswordRequirementsNotMetError(humanize_validation_result(validation_result)) + + +class AccountNameValidator(Validator): + def validate(self, value: object) -> None: + assert isinstance(value, str) + validation_result = AccountNameValidatorImpl().validate(value) + if not validation_result.is_valid: + raise InvalidAccountNameError(value) + + +class PageNumberValidator(Validator): + MIN_NUMBER: Final[int] = 0 + + def validate(self, value: object) -> None: + assert isinstance(value, int) + if value < self.MIN_NUMBER: + raise InvalidPageNumberError(value, self.MIN_NUMBER) + + +class PageSizeValidator(Validator): + MIN_SIZE: Final[int] = 1 + + def validate(self, value: object) -> None: + assert isinstance(value, int) + if value < self.MIN_SIZE: + raise InvalidPageSizeError(value, self.MIN_SIZE) + + +class KeyPairsNumberValidator(Validator): + MIN_NUMBER: Final[int] = 1 + + def validate(self, value: object) -> None: + assert isinstance(value, int) + if value < self.MIN_NUMBER: + raise InvalidNumberOfKeyPairsError(value, self.MIN_NUMBER) diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index cde6ffd639..30a0dd9031 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -340,6 +340,45 @@ clive/__private/models/asset.py clive/__private/models/transaction.py DOC201: Method `Transaction.__bool__` does not have a return section in docstring -------------------- +clive/__private/py/data_classes.py + DOC601: Class `Balances`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `Balances`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [hbd_liquid: Asset.Hbd, hbd_savings: Asset.Hbd, hbd_unclaimed: Asset.Hbd, hive_liquid: Asset.Hive, hive_savings: Asset.Hive, hive_unclaimed: Asset.Hive]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `Accounts`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `Accounts`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [known_accounts: list[str], tracked_accounts: list[str], working_account: str | None]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `Authority`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `Authority`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [account_or_public_key: str, weight: int]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `AuthorityInfo`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `AuthorityInfo`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [authorities: list[Authority], authority_owner_account_name: str, authority_type: str, weight_threshold: int]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `KeyPair`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `KeyPair`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [private_key: str | None, public_key: str | None]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC601: Class `WitnessesResult`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) + DOC603: Class `WitnessesResult`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [proxy: str | None, total_count: int, witnesses: list[Witness]]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) +-------------------- +clive/__private/py/exceptions.py + DOC101: Method `PyContextManagerNotUsedError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `PyContextManagerNotUsedError.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [message: str]. + DOC101: Method `PasswordRequirementsNotMetError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `PasswordRequirementsNotMetError.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [description: str]. + DOC101: Method `InvalidAccountNameError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidAccountNameError.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [account_name: str]. + DOC101: Method `InvalidProfileNameError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidProfileNameError.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [description: str, profile_name: str]. + DOC101: Method `InvalidPageNumberError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidPageNumberError.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [min_number: int, page_number: int]. + DOC101: Method `InvalidPageSizeError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidPageSizeError.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [min_size: int, page_size: int]. + DOC101: Method `InvalidNumberOfKeyPairsError.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `InvalidNumberOfKeyPairsError.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [min_size: int, number_of_key_pairs: int]. +-------------------- +clive/__private/py/foundation/base.py + DOC201: Method `CommandBase._run` does not have a return section in docstring +-------------------- +clive/__private/py/validators.py + DOC001: Function/method `validate`: Potential formatting errors in docstring. Error message: Expected a colon in 'An appropriate exception from clive.__private.py.exceptions if validation fails.'. (Note: DOC001 could trigger other unrelated violations under this function/method too. Please fix the docstring formatting first.) + DOC003: Function/method `validate`: Docstring style mismatch. (Please read more at https://jsh9.github.io/pydoclint/style_mismatch.html ). You specified "google" style, but the docstring is likely not written in this style. + DOC101: Method `Validator.validate`: Docstring contains fewer arguments than in function signature. + DOC103: Method `Validator.validate`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [value: object]. +-------------------- clive/__private/storage/service/service.py DOC502: Method `PersistentStorageService.save_profile` has a "Raises" section in the docstring, but there are not "raise" statements in the body DOC502: Method `PersistentStorageService.load_profile` has a "Raises" section in the docstring, but there are not "raise" statements in the body -- GitLab From af5c3390b3d8bbbd2ae24ac17fd75fe6cadf3dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Tue, 16 Dec 2025 10:37:53 +0000 Subject: [PATCH 07/15] Add PY public API and base classes Introduces main Python Interface entry points: - ClivePy: For read-only operations without profile (show.profiles()) - UnlockedClivePy: For operations requiring unlocked profile - clive_use_unlocked_profile(): Factory function for UnlockedClivePy - PyWorld: World specialized for PY usage with automatic profile loading High-level interfaces: - ShowInterface: balances, accounts, witnesses, authority methods - ShowInterfaceNoProfile: profiles listing - GenerateInterface: random key generation - ConfigureInterface: profile configuration Public API available via: from clive.py import ClivePy, UnlockedClivePy Note: Process operations (transfer, update_authority) are stubbed with NotImplementedError - will be implemented in part2. --- clive/__private/py/base.py | 159 ++++++++++++++++++++++++++++++++ clive/__private/py/configure.py | 22 +++++ clive/__private/py/generate.py | 19 ++++ clive/__private/py/show.py | 71 ++++++++++++++ clive/py/__init__.py | 54 +++++++++++ pydoclint-errors-baseline.txt | 37 ++++++++ 6 files changed, 362 insertions(+) create mode 100644 clive/__private/py/base.py create mode 100644 clive/__private/py/configure.py create mode 100644 clive/__private/py/generate.py create mode 100644 clive/__private/py/show.py create mode 100644 clive/py/__init__.py diff --git a/clive/__private/py/base.py b/clive/__private/py/base.py new file mode 100644 index 0000000000..f30db4fece --- /dev/null +++ b/clive/__private/py/base.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, NoReturn, Self, override + +from clive.__private.before_launch import prepare_before_launch +from clive.__private.core.world import World +from clive.__private.py.configure import ConfigureInterface +from clive.__private.py.exceptions import PyContextManagerNotUsedError +from clive.__private.py.generate import GenerateInterface +from clive.__private.py.show import ShowInterface, ShowInterfaceNoProfile + +if TYPE_CHECKING: + from types import TracebackType + + +class PyWorld(World): + """ + World specialized for Python Interface (PY) usage. + + Automatically loads unlocked profile during setup, similar to CLIWorld and TUIWorld. + This ensures that PY operations always have access to a loaded profile. + """ + + @override + async def _setup(self) -> None: + await super()._setup() + await self.load_profile_based_on_beekepeer() + + +def clive_use_unlocked_profile() -> UnlockedClivePy: + """ + Factory function to create a Clive PY instance with an unlocked profile. + + IMPORTANT: UnlockedClivePy MUST be used as an async context manager (with 'async with' statement). + This ensures proper initialization (profile loading) and cleanup (profile saving). + + Returns: + UnlockedClivePy instance configured to use an already unlocked profile. + + Example: + async with clive_use_unlocked_profile() as clive: + balances = await clive.show.balances("alice") + """ + return UnlockedClivePy() + + +class ClivePy: + """ + Main entry point for Clive Python Interface without profile context. + + Provides access to read-only operations that don't require a profile. + For operations requiring a profile, use UnlockedClivePy instead. + + Example: + async with ClivePy() as clive: + profiles = await clive.show.profiles() + """ + + def __init__(self) -> None: + self.show = ShowInterfaceNoProfile() + self.generate = GenerateInterface() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, _: type[BaseException] | None, ex: BaseException | None, ___: TracebackType | None + ) -> None: + pass + + +class UnlockedClivePy: + """ + Clive Python Interface with profile context. + + This class provides full access to Clive functionality including operations + that require an unlocked profile (show balances, accounts, witnesses, etc.). + + IMPORTANT: This class MUST be used as an async context manager (with 'async with' statement). + The context manager ensures: + - Profile is automatically loaded from the unlocked beekeeper wallet on entry (__aenter__) + - Profile is automatically saved to storage on exit (__aexit__) + - Proper cleanup of resources (node, wax interface, beekeeper) + + Use the factory function or context manager: + async with clive_use_unlocked_profile() as clive: + balances = await clive.show.balances("alice") + + Or directly: + async with UnlockedClivePy() as clive: + accounts = await clive.show.accounts() + + Note: Process operations (transfer, update_authority, etc.) will be available in a future release. + """ + + def __init__(self) -> None: + self._world = PyWorld() + self._is_setup_called = False + self.__show = ShowInterface(self) + self.__configure = ConfigureInterface(self) + self.generate = GenerateInterface() + self.__prepare_before_launch() + + async def __aenter__(self) -> Self: + await self.setup() + return self + + async def __aexit__( + self, _: type[BaseException] | None, ex: BaseException | None, ___: TracebackType | None + ) -> None: + await self.close() + + @property + def show(self) -> ShowInterface: + """Access to show operations. Requires context manager to be entered.""" + self._ensure_setup_called() + return self.__show + + @property + def process(self) -> NoReturn: + """ + Access to process operations (transfer, update_authority, etc.). + + NOTE: Process operations are not yet implemented in this release. + They will be available in a future release. + + Raises: + NotImplementedError: Always, as process operations are not yet available. + """ + raise NotImplementedError( + "Process operations (transfer, update_authority, transaction, etc.) are not yet available. " + "They will be added in a future release." + ) + + @property + def configure(self) -> ConfigureInterface: + """Access to configure operations. Requires context manager to be entered.""" + self._ensure_setup_called() + return self.__configure + + async def setup(self) -> None: + """Initialize the PY world and load profile. Called automatically by context manager.""" + await self._world.setup() + self._is_setup_called = True + + async def close(self) -> None: + """Cleanup resources and save profile. Called automatically by context manager.""" + await self._world.close() + + def __prepare_before_launch(self) -> None: + prepare_before_launch() + + def _ensure_setup_called(self) -> None: + """Ensure setup was called by checking if context manager was used.""" + if not self._is_setup_called: + raise PyContextManagerNotUsedError( + "UnlockedClivePy must be used as an async context manager. " + "Use 'async with UnlockedClivePy() as clive:' or 'async with clive_use_unlocked_profile() as clive:'" + ) diff --git a/clive/__private/py/configure.py b/clive/__private/py/configure.py new file mode 100644 index 0000000000..90c47ea44c --- /dev/null +++ b/clive/__private/py/configure.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from clive.__private.py.base import UnlockedClivePy + + +class ConfigureInterface: + """Interface for profile configuration actions (create/load).""" + + def __init__(self, clive_instance: UnlockedClivePy) -> None: + self.clive = clive_instance + + async def profile_load(self) -> None: + """ + Reload the currently unlocked profile from storage. + + This method is rarely needed as PyWorld automatically loads the unlocked profile during setup. + Use this only if you need to refresh the profile state after external changes. + """ + await self.clive._world.load_profile_based_on_beekepeer() diff --git a/clive/__private/py/generate.py b/clive/__private/py/generate.py new file mode 100644 index 0000000000..dc97df920b --- /dev/null +++ b/clive/__private/py/generate.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.py.foundation.generate import GenerateRandomKey + +if TYPE_CHECKING: + from clive.__private.py.data_classes import KeyPair + + +class GenerateInterface: + """Interface for generating keys and secret phrases.""" + + def __init__(self) -> None: + pass + + async def random_key(self, key_pairs: int = 1) -> list[KeyPair]: + """Generate one or more random key pairs.""" + return await GenerateRandomKey(key_pairs=key_pairs).run() diff --git a/clive/__private/py/show.py b/clive/__private/py/show.py new file mode 100644 index 0000000000..b963767f41 --- /dev/null +++ b/clive/__private/py/show.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from clive.__private.py.foundation.show import ShowAccounts, ShowAuthority, ShowBalances, ShowProfiles, ShowWitnesses + +if TYPE_CHECKING: + from clive.__private.core.types import AuthorityLevelRegular + from clive.__private.py.base import UnlockedClivePy + from clive.__private.py.data_classes import Accounts, AuthorityInfo, Balances, WitnessesResult + + +class ShowInterfaceNoProfile: + """Interface for show operations that do not require a profile.""" + + def __init__(self) -> None: + pass + + async def profiles(self) -> list[str]: + """List all available profiles.""" + return await ShowProfiles().run() + + +class ShowInterface(ShowInterfaceNoProfile): + """ + Main interface for show operations that require a profile. + + Keeps client usage unchanged (async, argument names, defaults). + """ + + def __init__(self, clive_instance: UnlockedClivePy) -> None: + self.clive = clive_instance + + async def balances(self, account_name: str) -> Balances: + """Show balances for an account.""" + return await ShowBalances( + world=self.clive._world, + account_name=account_name, + ).run() + + async def accounts(self) -> Accounts: + """Show accounts information.""" + return await ShowAccounts( + world=self.clive._world, + ).run() + + async def witnesses(self, account_name: str, page_size: int = 30, page_no: int = 0) -> WitnessesResult: + """Show witnesses for an account with pagination metadata.""" + return await ShowWitnesses( + world=self.clive._world, account_name=account_name, page_size=page_size, page_no=page_no + ).run() + + async def _get_authority(self, account_name: str, authority: AuthorityLevelRegular) -> AuthorityInfo: + """Helper method to get authority information for a specific authority type.""" + return await ShowAuthority( + world=self.clive._world, + account_name=account_name, + authority=authority, + ).run() + + async def owner_authority(self, account_name: str) -> AuthorityInfo: + """Show owner authority for an account.""" + return await self._get_authority(account_name, "owner") + + async def active_authority(self, account_name: str) -> AuthorityInfo: + """Show active authority for an account.""" + return await self._get_authority(account_name, "active") + + async def posting_authority(self, account_name: str) -> AuthorityInfo: + """Show posting authority for an account.""" + return await self._get_authority(account_name, "posting") diff --git a/clive/py/__init__.py b/clive/py/__init__.py new file mode 100644 index 0000000000..4b6bd22642 --- /dev/null +++ b/clive/py/__init__.py @@ -0,0 +1,54 @@ +""" +Clive Python Interface (PY). + +This module provides a programmatic interface for interacting with Clive functionality. +It allows developers to write Python scripts that perform blockchain operations. + +Main classes: + ClivePy - Interface for read-only operations (no profile needed) + UnlockedClivePy - Full interface with unlocked profile for all operations + +Factory function: + clive_use_unlocked_profile() - Convenient way to get UnlockedClivePy instance + +IMPORTANT: UnlockedClivePy and clive_use_unlocked_profile() MUST be used as async context managers. +The context manager handles: +- Profile loading from the unlocked beekeeper wallet on entry +- Profile saving to storage on exit +- Proper cleanup of all resources + +Example usage: + ```python + from clive.py import ClivePy, UnlockedClivePy, clive_use_unlocked_profile + + # Query profiles (no profile needed) + async with ClivePy() as clive: + profiles = await clive.show.profiles() + + # Operations with profile - ALWAYS use async with + async with clive_use_unlocked_profile() as clive: + # Show operations + balances = await clive.show.balances("alice") + accounts = await clive.show.accounts() + witnesses = await clive.show.witnesses("alice", page_size=30, page_no=0) + owner_auth = await clive.show.owner_authority("alice") + active_auth = await clive.show.active_authority("alice") + posting_auth = await clive.show.posting_authority("alice") + + # Or directly - ALWAYS use async with + async with UnlockedClivePy() as clive: + balances = await clive.show.balances("bob") + ``` + +Note: Process operations (transfer, update_authority, etc.) will be available in a future release. +""" + +from __future__ import annotations + +from clive.__private.py.base import ClivePy, UnlockedClivePy, clive_use_unlocked_profile + +__all__ = [ + "ClivePy", + "UnlockedClivePy", + "clive_use_unlocked_profile", +] diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt index 30a0dd9031..ed90725943 100644 --- a/pydoclint-errors-baseline.txt +++ b/pydoclint-errors-baseline.txt @@ -340,6 +340,14 @@ clive/__private/models/asset.py clive/__private/models/transaction.py DOC201: Method `Transaction.__bool__` does not have a return section in docstring -------------------- +clive/__private/py/base.py + DOC501: Method `UnlockedClivePy._ensure_setup_called` has raise statements, but the docstring does not have a "Raises" section + DOC503: Method `UnlockedClivePy._ensure_setup_called` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['PyContextManagerNotUsedError']. +-------------------- +clive/__private/py/configure.py + DOC101: Method `ConfigureInterface.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ConfigureInterface.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [clive_instance: UnlockedClivePy]. +-------------------- clive/__private/py/data_classes.py DOC601: Class `Balances`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) DOC603: Class `Balances`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [hbd_liquid: Asset.Hbd, hbd_savings: Asset.Hbd, hbd_unclaimed: Asset.Hbd, hive_liquid: Asset.Hive, hive_savings: Asset.Hive, hive_unclaimed: Asset.Hive]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.) @@ -373,6 +381,35 @@ clive/__private/py/exceptions.py clive/__private/py/foundation/base.py DOC201: Method `CommandBase._run` does not have a return section in docstring -------------------- +clive/__private/py/generate.py + DOC101: Method `GenerateInterface.random_key`: Docstring contains fewer arguments than in function signature. + DOC103: Method `GenerateInterface.random_key`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [key_pairs: int]. + DOC201: Method `GenerateInterface.random_key` does not have a return section in docstring +-------------------- +clive/__private/py/show.py + DOC201: Method `ShowInterfaceNoProfile.profiles` does not have a return section in docstring + DOC101: Method `ShowInterface.__init__`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [clive_instance: UnlockedClivePy]. + DOC101: Method `ShowInterface.balances`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.balances`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [account_name: str]. + DOC201: Method `ShowInterface.balances` does not have a return section in docstring + DOC201: Method `ShowInterface.accounts` does not have a return section in docstring + DOC101: Method `ShowInterface.witnesses`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.witnesses`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [account_name: str, page_no: int, page_size: int]. + DOC201: Method `ShowInterface.witnesses` does not have a return section in docstring + DOC101: Method `ShowInterface._get_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface._get_authority`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [account_name: str, authority: AuthorityLevelRegular]. + DOC201: Method `ShowInterface._get_authority` does not have a return section in docstring + DOC101: Method `ShowInterface.owner_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.owner_authority`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [account_name: str]. + DOC201: Method `ShowInterface.owner_authority` does not have a return section in docstring + DOC101: Method `ShowInterface.active_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.active_authority`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [account_name: str]. + DOC201: Method `ShowInterface.active_authority` does not have a return section in docstring + DOC101: Method `ShowInterface.posting_authority`: Docstring contains fewer arguments than in function signature. + DOC103: Method `ShowInterface.posting_authority`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [account_name: str]. + DOC201: Method `ShowInterface.posting_authority` does not have a return section in docstring +-------------------- clive/__private/py/validators.py DOC001: Function/method `validate`: Potential formatting errors in docstring. Error message: Expected a colon in 'An appropriate exception from clive.__private.py.exceptions if validation fails.'. (Note: DOC001 could trigger other unrelated violations under this function/method too. Please fix the docstring formatting first.) DOC003: Function/method `validate`: Docstring style mismatch. (Please read more at https://jsh9.github.io/pydoclint/style_mismatch.html ). You specified "google" style, but the docstring is likely not written in this style. -- GitLab From 942d2d107eff5d3c2427b645ef35e0999c040df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Tue, 16 Dec 2025 10:38:17 +0000 Subject: [PATCH 08/15] Integrate PY layer with CLI show commands Updates CLI show commands to use the new PY layer: - show_accounts: Uses ShowAccountsPy for account listing - show_authority: Uses ShowAuthorityPy for authority display - show_balances: Uses ShowBalancesPy for balance information - show_witnesses: Uses ShowWitnessesPy with WitnessesResult for witness listing - show_profile: Await async _show_accounts_info method This ensures CLI and PY share the same business logic, reducing code duplication and maintaining consistency. --- .../cli/commands/show/show_accounts.py | 15 ++++---- .../cli/commands/show/show_authority.py | 12 +++---- .../cli/commands/show/show_balances.py | 20 +++++------ .../cli/commands/show/show_profile.py | 2 +- .../cli/commands/show/show_witnesses.py | 35 +++++++------------ 5 files changed, 35 insertions(+), 49 deletions(-) diff --git a/clive/__private/cli/commands/show/show_accounts.py b/clive/__private/cli/commands/show/show_accounts.py index 2b5b857957..a436976a51 100644 --- a/clive/__private/cli/commands/show/show_accounts.py +++ b/clive/__private/cli/commands/show/show_accounts.py @@ -4,18 +4,19 @@ from dataclasses import dataclass from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.print_cli import print_cli +from clive.__private.py.foundation.show import ShowAccounts as ShowAccountsPy @dataclass(kw_only=True) class ShowAccounts(WorldBasedCommand): async def _run(self) -> None: - self._show_accounts_info() + await self._show_accounts_info() - def _show_accounts_info(self) -> None: - profile = self.profile - if profile.accounts.has_working_account: - print_cli(f"Working account: {profile.accounts.working.name}") + async def _show_accounts_info(self) -> None: + accounts = await ShowAccountsPy(world=self.world).run() + if accounts.working_account is not None: + print_cli(f"Working account: {accounts.working_account}") else: print_cli("Working account is not set.") - print_cli(f"Tracked accounts: {[account.name for account in profile.accounts.tracked]}") - print_cli(f"Known accounts: {[account.name for account in profile.accounts.known]}") + print_cli(f"Tracked accounts: {accounts.tracked_accounts}") + print_cli(f"Known accounts: {accounts.known_accounts}") diff --git a/clive/__private/cli/commands/show/show_authority.py b/clive/__private/cli/commands/show/show_authority.py index 8f036715da..90a28374d8 100644 --- a/clive/__private/cli/commands/show/show_authority.py +++ b/clive/__private/cli/commands/show/show_authority.py @@ -7,6 +7,7 @@ from rich.table import Table from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.print_cli import print_cli +from clive.__private.py.foundation.show import ShowAuthority as ShowAuthorityPy if TYPE_CHECKING: from clive.__private.core.types import AuthorityLevelRegular @@ -18,18 +19,17 @@ class ShowAuthority(WorldBasedCommand): authority: AuthorityLevelRegular async def _run(self) -> None: - accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise - account = accounts[0] + authority_info = await ShowAuthorityPy(self.world, self.account_name, self.authority).run() title = ( - f"{self.authority} authority of `{account.name}` account," - f"\nweight threshold is {account[self.authority].weight_threshold}:" + f"{authority_info.authority_type} authority of `{authority_info.authority_owner_account_name}` account," + f"\nweight threshold is {authority_info.weight_threshold}:" ) table = Table(title=title) table.add_column("account or public key", min_width=53) table.add_column("weight", justify="right") - for auth, weight in [*account[self.authority].key_auths, *account[self.authority].account_auths]: - table.add_row(f"{auth}", f"{weight}") + for authority in authority_info.authorities: + table.add_row(f"{authority.account_or_public_key}", f"{authority.weight}") print_cli(table) diff --git a/clive/__private/cli/commands/show/show_balances.py b/clive/__private/cli/commands/show/show_balances.py index 713ead2659..12839f2bd8 100644 --- a/clive/__private/cli/commands/show/show_balances.py +++ b/clive/__private/cli/commands/show/show_balances.py @@ -6,8 +6,8 @@ from rich.table import Table from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.print_cli import print_cli -from clive.__private.core.accounts.accounts import TrackedAccount from clive.__private.models.asset import Asset +from clive.__private.py.foundation.show import ShowBalances as ShowBalancesPy @dataclass(kw_only=True) @@ -15,20 +15,16 @@ class ShowBalances(WorldBasedCommand): account_name: str async def _run(self) -> None: - account = TrackedAccount(name=self.account_name) - - await self.world.commands.update_node_data(accounts=[account]) - + balances = await ShowBalancesPy(world=self.world, account_name=self.account_name).run() table = Table(title=f"Balances of `{self.account_name}` account") table.add_column("Type", justify="left", style="cyan", no_wrap=True) table.add_column("Amount", justify="right", style="green", no_wrap=True) - data = account.data - table.add_row("HBD Liquid", f"{Asset.pretty_amount(data.hbd_balance)}") - table.add_row("HBD Savings", f"{Asset.pretty_amount(data.hbd_savings)}") - table.add_row("HBD Unclaimed", f"{Asset.pretty_amount(data.hbd_unclaimed)}") - table.add_row("HIVE Liquid", f"{Asset.pretty_amount(data.hive_balance)}") - table.add_row("HIVE Savings", f"{Asset.pretty_amount(data.hive_savings)}") - table.add_row("HIVE Unclaimed", f"{Asset.pretty_amount(data.hive_unclaimed)}") + table.add_row("HBD Liquid", f"{Asset.pretty_amount(balances.hbd_liquid)}") + table.add_row("HBD Savings", f"{Asset.pretty_amount(balances.hbd_savings)}") + table.add_row("HBD Unclaimed", f"{Asset.pretty_amount(balances.hbd_unclaimed)}") + table.add_row("HIVE Liquid", f"{Asset.pretty_amount(balances.hive_liquid)}") + table.add_row("HIVE Savings", f"{Asset.pretty_amount(balances.hive_savings)}") + table.add_row("HIVE Unclaimed", f"{Asset.pretty_amount(balances.hive_unclaimed)}") print_cli(table) diff --git a/clive/__private/cli/commands/show/show_profile.py b/clive/__private/cli/commands/show/show_profile.py index c811736da3..426e3af5f1 100644 --- a/clive/__private/cli/commands/show/show_profile.py +++ b/clive/__private/cli/commands/show/show_profile.py @@ -11,7 +11,7 @@ from clive.__private.core.formatters.humanize import humanize_bool class ShowProfile(ShowAccounts): async def _run(self) -> None: self._show_profile_info() - self._show_accounts_info() + await self._show_accounts_info() def _show_profile_info(self) -> None: profile = self.profile diff --git a/clive/__private/cli/commands/show/show_witnesses.py b/clive/__private/cli/commands/show/show_witnesses.py index c39783fb45..33edd8d6fe 100644 --- a/clive/__private/cli/commands/show/show_witnesses.py +++ b/clive/__private/cli/commands/show/show_witnesses.py @@ -8,11 +8,11 @@ from rich.table import Table from clive.__private.cli.commands.abc.world_based_command import WorldBasedCommand from clive.__private.cli.print_cli import print_cli from clive.__private.cli.table_pagination_info import add_pagination_info_to_table_if_needed -from clive.__private.core.commands.data_retrieval.witnesses_data import WitnessesDataRetrieval from clive.__private.core.formatters.humanize import humanize_bool +from clive.__private.py.foundation.show import ShowWitnesses as ShowWitnessesPy if TYPE_CHECKING: - from clive.__private.core.commands.data_retrieval.witnesses_data import WitnessData, WitnessesData + from clive.__private.py.data_classes import Witness @dataclass(kw_only=True) @@ -22,24 +22,13 @@ class ShowWitnesses(WorldBasedCommand): page_no: int async def _run(self) -> None: - accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise - proxy = accounts[0].proxy - - wrapper = await self.world.commands.retrieve_witnesses_data( - account_name=proxy if proxy else self.account_name, - mode=WitnessesDataRetrieval.DEFAULT_MODE, - witness_name_pattern=None, - search_by_pattern_limit=WitnessesDataRetrieval.DEFAULT_SEARCH_BY_PATTERN_LIMIT, - ) - witnesses_data: WitnessesData = wrapper.result_or_raise - start_index: int = self.page_no * self.page_size - end_index: int = start_index + self.page_size - witnesses_list: list[WitnessData] = list(witnesses_data.witnesses.values()) - witnesses_chunk: list[WitnessData] = witnesses_list[start_index:end_index] + result = await ShowWitnessesPy( + world=self.world, account_name=self.account_name, page_size=self.page_size, page_no=self.page_no + ).run() proxy_name_message = f"`{self.account_name}`" - if proxy: - proxy_name_message += f" (proxy set to `{proxy}`)" + if result.proxy: + proxy_name_message += f" (proxy set to `{result.proxy}`)" table = Table(title=f"Witnesses and votes of {proxy_name_message} account") table.add_column("voted", justify="left", style="cyan") @@ -52,14 +41,14 @@ class ShowWitnesses(WorldBasedCommand): table.add_column("price\nfeed", justify="right", style="green") table.add_column("version", justify="right", style="green") - witness: WitnessData - for witness in witnesses_chunk: + witness: Witness + for witness in result.witnesses: table.add_row( humanize_bool(witness.voted), f"{witness.rank}", - f"{witness.name}", + f"{witness.witness_name}", f"{witness.votes}", - f"{witness.pretty_created}", + f"{witness.created}", f"{witness.missed_blocks}", f"{witness.last_block}", f"{witness.price_feed}", @@ -67,7 +56,7 @@ class ShowWitnesses(WorldBasedCommand): ) add_pagination_info_to_table_if_needed( - table=table, page_no=self.page_no, page_size=self.page_size, all_entries=len(witnesses_list) + table=table, page_no=self.page_no, page_size=self.page_size, all_entries=result.total_count ) print_cli(table) -- GitLab From 29459f6999d341ba58b98c3489b8e7c7a31e4b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Tue, 16 Dec 2025 10:38:58 +0000 Subject: [PATCH 09/15] Add functional tests for PY show layer Tests for Python Interface show operations: - TestShowProfiles: Verifies profiles listing - TestShowBalances: Verifies balance retrieval with Balances dataclass - TestShowAccounts: Verifies accounts information with Accounts dataclass - TestShowWitnesses: Verifies witness listing with WitnessesResult - TestShowAuthority: Verifies owner/active/posting authority with AuthorityInfo - TestProcessStubbed: Verifies process raises NotImplementedError All tests use the PyWorld fixture that connects to test beekeeper. --- tests/functional/py/__init__.py | 0 tests/functional/py/show/__init__.py | 0 tests/functional/py/show/test_show.py | 169 ++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 tests/functional/py/__init__.py create mode 100644 tests/functional/py/show/__init__.py create mode 100644 tests/functional/py/show/test_show.py diff --git a/tests/functional/py/__init__.py b/tests/functional/py/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/functional/py/show/__init__.py b/tests/functional/py/show/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/functional/py/show/test_show.py b/tests/functional/py/show/test_show.py new file mode 100644 index 0000000000..4c5d2d620a --- /dev/null +++ b/tests/functional/py/show/test_show.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from clive.__private.py.base import PyWorld, UnlockedClivePy +from clive.__private.py.data_classes import Accounts, AuthorityInfo, Balances, Witness, WitnessesResult +from clive.py import ClivePy + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + + from clive.__private.core.world import World + + +@pytest.fixture +async def py_world( + world_with_remote_beekeeper: World, _prepare_profile_and_setup_wallet: None +) -> AsyncGenerator[PyWorld]: + """Create PyWorld that uses the test beekeeper. + + Note: We must read settings from world_with_remote_beekeeper using the settings property + (not direct attribute access) because the fixture yields an already-setup world. + """ + py_world = PyWorld() + # Set beekeeper settings before entering context manager (before setup) + # Read from the already-setup world using settings property + py_world.beekeeper_manager.settings.http_endpoint = ( + world_with_remote_beekeeper.beekeeper_manager.beekeeper.settings.http_endpoint + ) + py_world.beekeeper_manager.settings.use_existing_session = ( + world_with_remote_beekeeper.beekeeper_manager.beekeeper.settings.use_existing_session + ) + async with py_world: + yield py_world + + +class TestShowProfiles: + async def test_show_profiles_returns_list(self) -> None: + """Test that show.profiles() returns a list of profile names.""" + async with ClivePy() as clive: + profiles = await clive.show.profiles() + + assert isinstance(profiles, list) + + +class TestShowBalances: + async def test_show_balances_returns_balances_object( + self, + py_world: PyWorld, + node: None, # noqa: ARG002 + ) -> None: + """Test that show.balances() returns Balances dataclass.""" + py = UnlockedClivePy() + py._world = py_world + py._is_setup_called = True + + balances = await py.show.balances("alice") + + assert isinstance(balances, Balances) + assert hasattr(balances, "hbd_liquid") + assert hasattr(balances, "hive_liquid") + assert hasattr(balances, "hbd_savings") + assert hasattr(balances, "hive_savings") + + +class TestShowAccounts: + async def test_show_accounts_returns_accounts_object(self, py_world: PyWorld) -> None: + """Test that show.accounts() returns Accounts dataclass.""" + py = UnlockedClivePy() + py._world = py_world + py._is_setup_called = True + + accounts = await py.show.accounts() + + assert isinstance(accounts, Accounts) + assert hasattr(accounts, "working_account") + assert hasattr(accounts, "tracked_accounts") + assert hasattr(accounts, "known_accounts") + assert isinstance(accounts.tracked_accounts, list) + assert isinstance(accounts.known_accounts, list) + + +class TestShowWitnesses: + async def test_show_witnesses_returns_witnesses_result( + self, + py_world: PyWorld, + node: None, # noqa: ARG002 + ) -> None: + """Test that show.witnesses() returns WitnessesResult with metadata.""" + py = UnlockedClivePy() + py._world = py_world + py._is_setup_called = True + + result = await py.show.witnesses("alice", page_size=10, page_no=0) + + assert isinstance(result, WitnessesResult) + assert hasattr(result, "witnesses") + assert hasattr(result, "total_count") + assert hasattr(result, "proxy") + assert isinstance(result.witnesses, list) + assert isinstance(result.total_count, int) + if len(result.witnesses) > 0: + assert isinstance(result.witnesses[0], Witness) + assert hasattr(result.witnesses[0], "witness_name") + assert hasattr(result.witnesses[0], "votes") + assert hasattr(result.witnesses[0], "voted") + + +class TestShowAuthority: + async def test_show_owner_authority_returns_authority_info( + self, + py_world: PyWorld, + node: None, # noqa: ARG002 + ) -> None: + """Test that show.owner_authority() returns AuthorityInfo dataclass.""" + py = UnlockedClivePy() + py._world = py_world + py._is_setup_called = True + + authority = await py.show.owner_authority("alice") + + assert isinstance(authority, AuthorityInfo) + assert authority.authority_type == "owner" + assert hasattr(authority, "weight_threshold") + assert hasattr(authority, "authorities") + + async def test_show_active_authority_returns_authority_info( + self, + py_world: PyWorld, + node: None, # noqa: ARG002 + ) -> None: + """Test that show.active_authority() returns AuthorityInfo dataclass.""" + py = UnlockedClivePy() + py._world = py_world + py._is_setup_called = True + + authority = await py.show.active_authority("alice") + + assert isinstance(authority, AuthorityInfo) + assert authority.authority_type == "active" + + async def test_show_posting_authority_returns_authority_info( + self, + py_world: PyWorld, + node: None, # noqa: ARG002 + ) -> None: + """Test that show.posting_authority() returns AuthorityInfo dataclass.""" + py = UnlockedClivePy() + py._world = py_world + py._is_setup_called = True + + authority = await py.show.posting_authority("alice") + + assert isinstance(authority, AuthorityInfo) + assert authority.authority_type == "posting" + + +class TestProcessStubbed: + async def test_process_raises_not_implemented(self) -> None: + """Test that process property raises NotImplementedError.""" + py = UnlockedClivePy() + py._is_setup_called = True + + with pytest.raises(NotImplementedError) as exc_info: + _ = py.process + + assert "not yet available" in str(exc_info.value) -- GitLab From 27aecfa1288aef28f2df60844d53f8c7e146be3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 19 Dec 2025 10:15:51 +0000 Subject: [PATCH 10/15] MR !803 Thread #1: Replace assert with proper TypeError exceptions in validators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace assert isinstance(...) statements with proper TypeError exceptions for type validation. This provides clear error messages instead of AssertionError which could be confusing for API users. Addresses: CRIT-3 from code review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- clive/__private/py/validators.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/clive/__private/py/validators.py b/clive/__private/py/validators.py index 7bbcaf0722..be2d834358 100644 --- a/clive/__private/py/validators.py +++ b/clive/__private/py/validators.py @@ -43,7 +43,8 @@ class Validator(ABC): class ProfileNameValidator(Validator): def validate(self, value: object) -> None: - assert isinstance(value, str) + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}: {value!r}") validation_result = ProfileNameValidatorImpl().validate(value) if not validation_result.is_valid: raise InvalidProfileNameError(value, humanize_validation_result(validation_result)) @@ -51,7 +52,8 @@ class ProfileNameValidator(Validator): class SetPasswordValidator(Validator): def validate(self, value: object) -> None: - assert isinstance(value, str) + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}: {value!r}") validation_result = SetPasswordValidatorImpl().validate(value) if not validation_result.is_valid: raise PasswordRequirementsNotMetError(humanize_validation_result(validation_result)) @@ -59,7 +61,8 @@ class SetPasswordValidator(Validator): class AccountNameValidator(Validator): def validate(self, value: object) -> None: - assert isinstance(value, str) + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}: {value!r}") validation_result = AccountNameValidatorImpl().validate(value) if not validation_result.is_valid: raise InvalidAccountNameError(value) @@ -69,7 +72,8 @@ class PageNumberValidator(Validator): MIN_NUMBER: Final[int] = 0 def validate(self, value: object) -> None: - assert isinstance(value, int) + if not isinstance(value, int): + raise TypeError(f"Expected int, got {type(value).__name__}: {value!r}") if value < self.MIN_NUMBER: raise InvalidPageNumberError(value, self.MIN_NUMBER) @@ -78,7 +82,8 @@ class PageSizeValidator(Validator): MIN_SIZE: Final[int] = 1 def validate(self, value: object) -> None: - assert isinstance(value, int) + if not isinstance(value, int): + raise TypeError(f"Expected int, got {type(value).__name__}: {value!r}") if value < self.MIN_SIZE: raise InvalidPageSizeError(value, self.MIN_SIZE) @@ -87,6 +92,7 @@ class KeyPairsNumberValidator(Validator): MIN_NUMBER: Final[int] = 1 def validate(self, value: object) -> None: - assert isinstance(value, int) + if not isinstance(value, int): + raise TypeError(f"Expected int, got {type(value).__name__}: {value!r}") if value < self.MIN_NUMBER: raise InvalidNumberOfKeyPairsError(value, self.MIN_NUMBER) -- GitLab From 0ef2347b9990f65f3d9ef6d177c4b86f1f948649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 19 Dec 2025 10:16:56 +0000 Subject: [PATCH 11/15] MR !803 Thread #2: Add PyError exception hierarchy for better error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PyError as the base exception for all PY module errors and PyValidationError as the base for validation-related errors. This allows users to catch all PY errors or just validation errors with a single except clause. Addresses: IMP-4 from code review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- clive/__private/py/exceptions.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/clive/__private/py/exceptions.py b/clive/__private/py/exceptions.py index ef4e33833d..7652b650ff 100644 --- a/clive/__private/py/exceptions.py +++ b/clive/__private/py/exceptions.py @@ -3,49 +3,57 @@ from __future__ import annotations -class PyContextManagerNotUsedError(Exception): +class PyError(Exception): + """Base exception for all PY module errors.""" + + +class PyValidationError(PyError): + """Base exception for validation errors.""" + + +class PyContextManagerNotUsedError(PyError): """Raised when UnlockedClivePy is used without async context manager.""" def __init__(self, message: str) -> None: super().__init__(message) -class PasswordRequirementsNotMetError(Exception): +class PasswordRequirementsNotMetError(PyValidationError): """Raised when the provided password does not meet the requirements.""" def __init__(self, description: str) -> None: super().__init__(f"Password requirements not met: {description}") -class InvalidAccountNameError(Exception): +class InvalidAccountNameError(PyValidationError): """Raised when the provided account name is invalid.""" def __init__(self, account_name: str) -> None: super().__init__(f"Invalid account name: '{account_name}'.") -class InvalidProfileNameError(Exception): +class InvalidProfileNameError(PyValidationError): """Raised when the provided profile name is invalid.""" def __init__(self, profile_name: str, description: str) -> None: super().__init__(f"Invalid profile name: '{profile_name}'. {description}") -class InvalidPageNumberError(Exception): +class InvalidPageNumberError(PyValidationError): """Raised when the provided page number is invalid.""" def __init__(self, page_number: int, min_number: int = 0) -> None: super().__init__(f"Invalid page number: {page_number}. Page number must be greater or equal to {min_number}.") -class InvalidPageSizeError(Exception): +class InvalidPageSizeError(PyValidationError): """Raised when the provided page size is invalid.""" def __init__(self, page_size: int, min_size: int) -> None: super().__init__(f"Invalid page size: {page_size}. Page size must be greater than or equal to {min_size}.") -class InvalidNumberOfKeyPairsError(Exception): +class InvalidNumberOfKeyPairsError(PyValidationError): """Raised when the provided number of key pairs is invalid.""" def __init__(self, number_of_key_pairs: int, min_size: int) -> None: -- GitLab From b1138c54a66956ca23b4fed6b585fe764ba06fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 19 Dec 2025 13:40:44 +0000 Subject: [PATCH 12/15] MR !803 Thread #10/#14: Add AccountNotFoundError handling in ShowWitnesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap result_or_raise in try-except to catch CoreAccountNotFoundError and raise AccountNotFoundError. Also check if accounts list is empty before accessing accounts[0]. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- clive/__private/py/foundation/show.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/clive/__private/py/foundation/show.py b/clive/__private/py/foundation/show.py index 9c910ea281..65018c7aa8 100644 --- a/clive/__private/py/foundation/show.py +++ b/clive/__private/py/foundation/show.py @@ -8,8 +8,10 @@ from clive.__private.core.commands.data_retrieval.witnesses_data import ( WitnessesData, WitnessesDataRetrieval, ) +from clive.__private.core.commands.find_accounts import AccountNotFoundError as CoreAccountNotFoundError from clive.__private.core.profile import Profile from clive.__private.py.data_classes import Accounts, Authority, AuthorityInfo, Balances, Witness, WitnessesResult +from clive.__private.py.exceptions import AccountNotFoundError from clive.__private.py.foundation.base import CommandBase from clive.__private.py.validators import AccountNameValidator, PageNumberValidator, PageSizeValidator @@ -74,7 +76,13 @@ class ShowWitnesses(CommandBase[WitnessesResult]): PageNumberValidator().validate(self.page_no) async def _run(self) -> WitnessesResult: - accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise + wrapper = await self.world.commands.find_accounts(accounts=[self.account_name]) + try: + accounts = wrapper.result_or_raise + except CoreAccountNotFoundError as err: + raise AccountNotFoundError(self.account_name) from err + if not accounts: + raise AccountNotFoundError(self.account_name) proxy = accounts[0].proxy wrapper = await self.world.commands.retrieve_witnesses_data( -- GitLab From 05fb1ab2f18d71ef354d401b69e26db93121f668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 19 Dec 2025 13:41:04 +0000 Subject: [PATCH 13/15] MR !803 Thread #17: Export data classes and exceptions in public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add imports and exports for: - Data classes: Accounts, Authority, AuthorityInfo, Balances, KeyPair, Witness, WitnessesResult - Exceptions: AccountNotFoundError, InvalidAccountNameError, InvalidNumberOfKeyPairsError, InvalidPageNumberError, InvalidPageSizeError, InvalidProfileNameError, PasswordRequirementsNotMetError, PyContextManagerNotUsedError, PyError, PyValidationError 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- clive/py/__init__.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/clive/py/__init__.py b/clive/py/__init__.py index 4b6bd22642..f492ad9703 100644 --- a/clive/py/__init__.py +++ b/clive/py/__init__.py @@ -46,9 +46,47 @@ Note: Process operations (transfer, update_authority, etc.) will be available in from __future__ import annotations from clive.__private.py.base import ClivePy, UnlockedClivePy, clive_use_unlocked_profile +from clive.__private.py.data_classes import ( + Accounts, + Authority, + AuthorityInfo, + Balances, + KeyPair, + Witness, + WitnessesResult, +) +from clive.__private.py.exceptions import ( + AccountNotFoundError, + InvalidAccountNameError, + InvalidNumberOfKeyPairsError, + InvalidPageNumberError, + InvalidPageSizeError, + InvalidProfileNameError, + PasswordRequirementsNotMetError, + PyContextManagerNotUsedError, + PyError, + PyValidationError, +) __all__ = [ + "AccountNotFoundError", + "Accounts", + "Authority", + "AuthorityInfo", + "Balances", "ClivePy", + "InvalidAccountNameError", + "InvalidNumberOfKeyPairsError", + "InvalidPageNumberError", + "InvalidPageSizeError", + "InvalidProfileNameError", + "KeyPair", + "PasswordRequirementsNotMetError", + "PyContextManagerNotUsedError", + "PyError", + "PyValidationError", "UnlockedClivePy", + "Witness", + "WitnessesResult", "clive_use_unlocked_profile", ] -- GitLab From f94f9a7491bde63105b22a799f381b8425c279fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 19 Dec 2025 13:41:42 +0000 Subject: [PATCH 14/15] MR !803 Thread #18: Remove unnecessary empty __init__ methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove empty __init__(self) -> None: pass from: - ShowProfiles in foundation/show.py - ShowInterfaceNoProfile in show.py Python provides a default __init__ when none is defined, making these empty implementations redundant. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- clive/__private/py/foundation/show.py | 3 --- clive/__private/py/generate.py | 3 --- clive/__private/py/show.py | 3 --- 3 files changed, 9 deletions(-) diff --git a/clive/__private/py/foundation/show.py b/clive/__private/py/foundation/show.py index 65018c7aa8..0aed54c4a9 100644 --- a/clive/__private/py/foundation/show.py +++ b/clive/__private/py/foundation/show.py @@ -21,9 +21,6 @@ if TYPE_CHECKING: class ShowProfiles(CommandBase[list[str]]): - def __init__(self) -> None: - pass - async def _run(self) -> list[str]: return Profile.list_profiles() diff --git a/clive/__private/py/generate.py b/clive/__private/py/generate.py index dc97df920b..eebc1d3da7 100644 --- a/clive/__private/py/generate.py +++ b/clive/__private/py/generate.py @@ -11,9 +11,6 @@ if TYPE_CHECKING: class GenerateInterface: """Interface for generating keys and secret phrases.""" - def __init__(self) -> None: - pass - async def random_key(self, key_pairs: int = 1) -> list[KeyPair]: """Generate one or more random key pairs.""" return await GenerateRandomKey(key_pairs=key_pairs).run() diff --git a/clive/__private/py/show.py b/clive/__private/py/show.py index b963767f41..abd7a9d321 100644 --- a/clive/__private/py/show.py +++ b/clive/__private/py/show.py @@ -13,9 +13,6 @@ if TYPE_CHECKING: class ShowInterfaceNoProfile: """Interface for show operations that do not require a profile.""" - def __init__(self) -> None: - pass - async def profiles(self) -> list[str]: """List all available profiles.""" return await ShowProfiles().run() -- GitLab From 6690acf78af31793e2229f38a321335abe46949d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radek=20Mas=C5=82owski?= Date: Fri, 19 Dec 2025 13:44:38 +0000 Subject: [PATCH 15/15] Apply remaining PY layer code review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit includes various improvements from code review: - ShowBalances: Add account existence check before fetching data - ShowAccounts: Use tuple instead of list for accounts - ShowWitnesses: Add docstring, use tuple, update votes mapping - ShowAuthority: Add VALID_AUTHORITIES constant, add validation, use tuple - ShowInterface: Use composition instead of inheritance (LSP fix), access world via property instead of _world - Exceptions: Improve InvalidAccountNameError with description parameter, add max bounds for page number and page size validators - Validators: Add MAX bounds, fix bool check for int validation - WitnessData: Add votes_raw attribute for raw vote count - Add functional tests for exceptions and validators 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commands/data_retrieval/witnesses_data.py | 2 + clive/__private/py/data_classes.py | 129 +++++++++++++++--- clive/__private/py/exceptions.py | 47 ++++++- clive/__private/py/foundation/show.py | 43 ++++-- clive/__private/py/show.py | 28 +++- clive/__private/py/validators.py | 16 ++- tests/functional/py/test_exceptions.py | 79 +++++++++++ tests/functional/py/test_validators.py | 117 ++++++++++++++++ 8 files changed, 413 insertions(+), 48 deletions(-) create mode 100644 tests/functional/py/test_exceptions.py create mode 100644 tests/functional/py/test_validators.py diff --git a/clive/__private/core/commands/data_retrieval/witnesses_data.py b/clive/__private/core/commands/data_retrieval/witnesses_data.py index c69d332a06..0590e63d27 100644 --- a/clive/__private/core/commands/data_retrieval/witnesses_data.py +++ b/clive/__private/core/commands/data_retrieval/witnesses_data.py @@ -34,6 +34,7 @@ class WitnessData: created: datetime = field(default_factory=utc_epoch) voted: bool = False votes: str = "?" + votes_raw: int = 0 rank: int | None = None missed_blocks: int = 0 last_block: int = 0 @@ -207,6 +208,7 @@ class WitnessesDataRetrieval(CommandDataRetrieval[HarvestedDataRaw, SanitizedDat created=witness.created, rank=rank, votes=humanize_votes_with_suffix(witness.votes, data.gdpo), + votes_raw=witness.votes, missed_blocks=witness.total_missed, voted=witness.owner in data.witnesses_votes, last_block=witness.last_confirmed_block_num, diff --git a/clive/__private/py/data_classes.py b/clive/__private/py/data_classes.py index 84fec50832..6e2f6489dd 100644 --- a/clive/__private/py/data_classes.py +++ b/clive/__private/py/data_classes.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: from datetime import datetime @@ -9,7 +9,10 @@ if TYPE_CHECKING: from clive.__private.models.asset import Asset -@dataclass +AuthorityType = Literal["owner", "active", "posting"] + + +@dataclass(frozen=True) class Balances: """Account balances for HBD and HIVE.""" @@ -30,17 +33,38 @@ class Balances: f"{self.hive_unclaimed.pretty_amount()} (unclaimed)" ) + def to_dict(self) -> dict[str, Any]: + """Convert balances to dictionary with legacy asset format.""" + from clive.__private.models.asset import Asset # noqa: PLC0415 + + return { + "hbd_liquid": Asset.to_legacy(self.hbd_liquid), + "hbd_savings": Asset.to_legacy(self.hbd_savings), + "hbd_unclaimed": Asset.to_legacy(self.hbd_unclaimed), + "hive_liquid": Asset.to_legacy(self.hive_liquid), + "hive_savings": Asset.to_legacy(self.hive_savings), + "hive_unclaimed": Asset.to_legacy(self.hive_unclaimed), + } + -@dataclass +@dataclass(frozen=True) class Accounts: """Account tracking information.""" working_account: str | None - tracked_accounts: list[str] - known_accounts: list[str] + tracked_accounts: tuple[str, ...] + known_accounts: tuple[str, ...] + def to_dict(self) -> dict[str, Any]: + """Convert accounts to dictionary.""" + return { + "working_account": self.working_account, + "tracked_accounts": list(self.tracked_accounts), + "known_accounts": list(self.known_accounts), + } -@dataclass + +@dataclass(frozen=True) class Authority: """Authority structure for an account or key.""" @@ -48,41 +72,108 @@ class Authority: weight: int -@dataclass +@dataclass(frozen=True) class AuthorityInfo: """Detailed authority information for an account.""" authority_owner_account_name: str - authority_type: str + authority_type: AuthorityType weight_threshold: int - authorities: list[Authority] - - -@dataclass + authorities: tuple[Authority, ...] + + def to_dict(self) -> dict[str, Any]: + """Convert authority info to dictionary.""" + return { + "authority_owner_account_name": self.authority_owner_account_name, + "authority_type": self.authority_type, + "weight_threshold": self.weight_threshold, + "authorities": [ + {"account_or_public_key": auth.account_or_public_key, "weight": auth.weight} + for auth in self.authorities + ], + } + + +@dataclass(frozen=True) class KeyPair: """Key pair (private/public).""" - private_key: str | None - public_key: str | None + private_key: str + public_key: str + def __repr__(self) -> str: + return f"KeyPair(private_key=, public_key={self.public_key!r})" -@dataclass + def __str__(self) -> str: + return f"KeyPair(public={self.public_key[:20]}...)" + + def to_dict(self) -> dict[str, Any]: + """Convert key pair to dictionary (private key is redacted).""" + return { + "private_key": "", + "public_key": self.public_key, + } + + +@dataclass(frozen=True) class Witness: + """ + Witness data from the blockchain. + + Attributes: + voted: Whether the account has voted for this witness. + rank: Witness rank (None if not in top witnesses). + witness_name: The witness account name. + votes: Raw vote count (vests). + votes_display: Human-readable vote count (e.g., "1.23M HP"). + url: Witness URL/website. + created: When the witness was created. + missed_blocks: Number of blocks missed by this witness. + last_block: Last block produced by this witness. + price_feed: Current price feed from this witness. + version: Witness node version. + """ + voted: bool rank: int | None witness_name: str - votes: str + votes: int + votes_display: str + url: str created: datetime missed_blocks: int last_block: int price_feed: str version: str - -@dataclass + def to_dict(self) -> dict[str, Any]: + """Convert witness to dictionary.""" + return { + "voted": self.voted, + "rank": self.rank, + "witness_name": self.witness_name, + "votes": self.votes, + "votes_display": self.votes_display, + "created": self.created.isoformat(), + "missed_blocks": self.missed_blocks, + "last_block": self.last_block, + "price_feed": self.price_feed, + "version": self.version, + } + + +@dataclass(frozen=True) class WitnessesResult: """Result of witnesses query including pagination metadata.""" - witnesses: list[Witness] + witnesses: tuple[Witness, ...] total_count: int proxy: str | None + + def to_dict(self) -> dict[str, Any]: + """Convert witnesses result to dictionary.""" + return { + "witnesses": [w.to_dict() for w in self.witnesses], + "total_count": self.total_count, + "proxy": self.proxy, + } diff --git a/clive/__private/py/exceptions.py b/clive/__private/py/exceptions.py index 7652b650ff..e3e0b11306 100644 --- a/clive/__private/py/exceptions.py +++ b/clive/__private/py/exceptions.py @@ -28,8 +28,13 @@ class PasswordRequirementsNotMetError(PyValidationError): class InvalidAccountNameError(PyValidationError): """Raised when the provided account name is invalid.""" - def __init__(self, account_name: str) -> None: - super().__init__(f"Invalid account name: '{account_name}'.") + def __init__(self, account_name: str, description: str | None = None) -> None: + message = f"Invalid account name: '{account_name}'." + if description: + message = f"{message} {description}" + super().__init__(message) + self.account_name = account_name + self.description = description class InvalidProfileNameError(PyValidationError): @@ -42,15 +47,29 @@ class InvalidProfileNameError(PyValidationError): class InvalidPageNumberError(PyValidationError): """Raised when the provided page number is invalid.""" - def __init__(self, page_number: int, min_number: int = 0) -> None: - super().__init__(f"Invalid page number: {page_number}. Page number must be greater or equal to {min_number}.") + def __init__(self, page_number: int, min_number: int = 0, max_number: int | None = None) -> None: + if max_number is not None: + message = ( + f"Invalid page number: {page_number}. " + f"Page number must be between {min_number} and {max_number}." + ) + else: + message = f"Invalid page number: {page_number}. Page number must be greater or equal to {min_number}." + super().__init__(message) class InvalidPageSizeError(PyValidationError): """Raised when the provided page size is invalid.""" - def __init__(self, page_size: int, min_size: int) -> None: - super().__init__(f"Invalid page size: {page_size}. Page size must be greater than or equal to {min_size}.") + def __init__(self, page_size: int, min_size: int, max_size: int | None = None) -> None: + if max_size is not None: + message = ( + f"Invalid page size: {page_size}. " + f"Page size must be between {min_size} and {max_size}." + ) + else: + message = f"Invalid page size: {page_size}. Page size must be greater than or equal to {min_size}." + super().__init__(message) class InvalidNumberOfKeyPairsError(PyValidationError): @@ -61,3 +80,19 @@ class InvalidNumberOfKeyPairsError(PyValidationError): f"Invalid number of key pairs: {number_of_key_pairs}. " f"Number of key pairs must be greater than or equal to {min_size}." ) + + +class InvalidAuthorityTypeError(PyValidationError): + """Raised when the provided authority type is invalid.""" + + def __init__(self, authority: str, valid_authorities: frozenset[str]) -> None: + valid_str = ", ".join(sorted(valid_authorities)) + super().__init__(f"Invalid authority type: '{authority}'. Valid authorities are: {valid_str}.") + + +class AccountNotFoundError(PyError): + """Raised when the specified account does not exist on the blockchain.""" + + def __init__(self, account_name: str) -> None: + super().__init__(f"Account '{account_name}' not found on the blockchain.") + self.account_name = account_name diff --git a/clive/__private/py/foundation/show.py b/clive/__private/py/foundation/show.py index 0aed54c4a9..f5c854a093 100644 --- a/clive/__private/py/foundation/show.py +++ b/clive/__private/py/foundation/show.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from clive.__private.core.accounts.accounts import TrackedAccount from clive.__private.core.commands.data_retrieval.witnesses_data import ( @@ -11,7 +11,7 @@ from clive.__private.core.commands.data_retrieval.witnesses_data import ( from clive.__private.core.commands.find_accounts import AccountNotFoundError as CoreAccountNotFoundError from clive.__private.core.profile import Profile from clive.__private.py.data_classes import Accounts, Authority, AuthorityInfo, Balances, Witness, WitnessesResult -from clive.__private.py.exceptions import AccountNotFoundError +from clive.__private.py.exceptions import AccountNotFoundError, InvalidAuthorityTypeError from clive.__private.py.foundation.base import CommandBase from clive.__private.py.validators import AccountNameValidator, PageNumberValidator, PageSizeValidator @@ -34,6 +34,7 @@ class ShowBalances(CommandBase[Balances]): AccountNameValidator().validate(self.account_name) async def _run(self) -> Balances: + await self._ensure_account_exists() account = TrackedAccount(name=self.account_name) await self.world.commands.update_node_data(accounts=[account]) @@ -46,6 +47,13 @@ class ShowBalances(CommandBase[Balances]): hive_unclaimed=account.data.hive_unclaimed, ) + async def _ensure_account_exists(self) -> None: + """Check if the account exists on the blockchain.""" + try: + await self.world.commands.find_accounts(accounts=[self.account_name]) + except CoreAccountNotFoundError as err: + raise AccountNotFoundError(self.account_name) from err + class ShowAccounts(CommandBase[Accounts]): def __init__(self, world: World) -> None: @@ -55,12 +63,21 @@ class ShowAccounts(CommandBase[Accounts]): profile = self.world.profile return Accounts( working_account=profile.accounts.working.name if profile.accounts.has_working_account else None, - tracked_accounts=[account.name for account in profile.accounts.tracked], - known_accounts=[account.name for account in profile.accounts.known], + tracked_accounts=tuple(account.name for account in profile.accounts.tracked), + known_accounts=tuple(account.name for account in profile.accounts.known), ) class ShowWitnesses(CommandBase[WitnessesResult]): + """ + Command to retrieve witnesses with pagination support. + + Note: + If page_no exceeds available pages, returns empty witnesses list + without raising an error. The total_count field in the result + can be used to calculate the maximum valid page number. + """ + def __init__(self, world: World, account_name: str, page_size: int, page_no: int) -> None: self.world = world self.account_name = account_name @@ -94,12 +111,13 @@ class ShowWitnesses(CommandBase[WitnessesResult]): witnesses_list: list[WitnessData] = list(witnesses_data.witnesses.values()) witnesses_chunk: list[WitnessData] = witnesses_list[start_index:end_index] - witnesses = [ + witnesses = tuple( Witness( voted=witness.voted, rank=witness.rank, witness_name=witness.name, - votes=witness.votes, + votes=witness.votes_raw, + votes_display=witness.votes, created=witness.created, missed_blocks=witness.missed_blocks, last_block=witness.last_block, @@ -107,7 +125,7 @@ class ShowWitnesses(CommandBase[WitnessesResult]): version=witness.version, ) for witness in witnesses_chunk - ] + ) return WitnessesResult( witnesses=witnesses, @@ -117,6 +135,8 @@ class ShowWitnesses(CommandBase[WitnessesResult]): class ShowAuthority(CommandBase[AuthorityInfo]): + VALID_AUTHORITIES: Final[frozenset[str]] = frozenset({"owner", "active", "posting"}) + def __init__(self, world: World, account_name: str, authority: AuthorityLevelRegular) -> None: self.world = world self.account_name = account_name @@ -124,14 +144,17 @@ class ShowAuthority(CommandBase[AuthorityInfo]): async def validate(self) -> None: AccountNameValidator().validate(self.account_name) + if self.authority not in self.VALID_AUTHORITIES: + raise InvalidAuthorityTypeError(self.authority, self.VALID_AUTHORITIES) async def _run(self) -> AuthorityInfo: accounts = (await self.world.commands.find_accounts(accounts=[self.account_name])).result_or_raise account = accounts[0] - authorities = [] - for auth, weight in [*account[self.authority].key_auths, *account[self.authority].account_auths]: - authorities.append(Authority(account_or_public_key=auth, weight=weight)) + authorities = tuple( + Authority(account_or_public_key=auth, weight=weight) + for auth, weight in [*account[self.authority].key_auths, *account[self.authority].account_auths] + ) return AuthorityInfo( authority_owner_account_name=account.name, diff --git a/clive/__private/py/show.py b/clive/__private/py/show.py index abd7a9d321..b0df1899bc 100644 --- a/clive/__private/py/show.py +++ b/clive/__private/py/show.py @@ -18,39 +18,55 @@ class ShowInterfaceNoProfile: return await ShowProfiles().run() -class ShowInterface(ShowInterfaceNoProfile): +class ShowInterface: """ Main interface for show operations that require a profile. + Uses composition instead of inheritance to avoid LSP violation. Keeps client usage unchanged (async, argument names, defaults). """ def __init__(self, clive_instance: UnlockedClivePy) -> None: self.clive = clive_instance + self._no_profile = ShowInterfaceNoProfile() + + async def profiles(self) -> list[str]: + """ + List all available profiles. + + Delegates to ShowInterfaceNoProfile implementation. + """ + return await self._no_profile.profiles() async def balances(self, account_name: str) -> Balances: """Show balances for an account.""" return await ShowBalances( - world=self.clive._world, + world=self.clive.world, account_name=account_name, ).run() async def accounts(self) -> Accounts: """Show accounts information.""" return await ShowAccounts( - world=self.clive._world, + world=self.clive.world, ).run() async def witnesses(self, account_name: str, page_size: int = 30, page_no: int = 0) -> WitnessesResult: - """Show witnesses for an account with pagination metadata.""" + """ + Show witnesses for an account with pagination metadata. + + Note: + If page_no exceeds available pages, returns empty witnesses list + without raising an error. + """ return await ShowWitnesses( - world=self.clive._world, account_name=account_name, page_size=page_size, page_no=page_no + world=self.clive.world, account_name=account_name, page_size=page_size, page_no=page_no ).run() async def _get_authority(self, account_name: str, authority: AuthorityLevelRegular) -> AuthorityInfo: """Helper method to get authority information for a specific authority type.""" return await ShowAuthority( - world=self.clive._world, + world=self.clive.world, account_name=account_name, authority=authority, ).run() diff --git a/clive/__private/py/validators.py b/clive/__private/py/validators.py index be2d834358..c5c29430b3 100644 --- a/clive/__private/py/validators.py +++ b/clive/__private/py/validators.py @@ -70,29 +70,31 @@ class AccountNameValidator(Validator): class PageNumberValidator(Validator): MIN_NUMBER: Final[int] = 0 + MAX_NUMBER: Final[int] = 100000 def validate(self, value: object) -> None: - if not isinstance(value, int): + if isinstance(value, bool) or not isinstance(value, int): raise TypeError(f"Expected int, got {type(value).__name__}: {value!r}") - if value < self.MIN_NUMBER: - raise InvalidPageNumberError(value, self.MIN_NUMBER) + if value < self.MIN_NUMBER or value > self.MAX_NUMBER: + raise InvalidPageNumberError(value, self.MIN_NUMBER, self.MAX_NUMBER) class PageSizeValidator(Validator): MIN_SIZE: Final[int] = 1 + MAX_SIZE: Final[int] = 1000 def validate(self, value: object) -> None: - if not isinstance(value, int): + if isinstance(value, bool) or not isinstance(value, int): raise TypeError(f"Expected int, got {type(value).__name__}: {value!r}") - if value < self.MIN_SIZE: - raise InvalidPageSizeError(value, self.MIN_SIZE) + if value < self.MIN_SIZE or value > self.MAX_SIZE: + raise InvalidPageSizeError(value, self.MIN_SIZE, self.MAX_SIZE) class KeyPairsNumberValidator(Validator): MIN_NUMBER: Final[int] = 1 def validate(self, value: object) -> None: - if not isinstance(value, int): + if isinstance(value, bool) or not isinstance(value, int): raise TypeError(f"Expected int, got {type(value).__name__}: {value!r}") if value < self.MIN_NUMBER: raise InvalidNumberOfKeyPairsError(value, self.MIN_NUMBER) diff --git a/tests/functional/py/test_exceptions.py b/tests/functional/py/test_exceptions.py new file mode 100644 index 0000000000..f00651dbb5 --- /dev/null +++ b/tests/functional/py/test_exceptions.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from clive.py import ( + InvalidAccountNameError, + InvalidPageNumberError, + InvalidPageSizeError, + PyError, + PyValidationError, +) + + +class TestExceptionHierarchy: + def test_py_validation_error_inherits_from_py_error(self) -> None: + """Test that PyValidationError is a subclass of PyError.""" + assert issubclass(PyValidationError, PyError) + + def test_invalid_account_name_inherits_from_validation_error(self) -> None: + """Test that InvalidAccountNameError is a subclass of PyValidationError.""" + assert issubclass(InvalidAccountNameError, PyValidationError) + + def test_invalid_page_number_inherits_from_validation_error(self) -> None: + """Test that InvalidPageNumberError is a subclass of PyValidationError.""" + assert issubclass(InvalidPageNumberError, PyValidationError) + + def test_invalid_page_size_inherits_from_validation_error(self) -> None: + """Test that InvalidPageSizeError is a subclass of PyValidationError.""" + assert issubclass(InvalidPageSizeError, PyValidationError) + + def test_can_catch_all_validation_errors(self) -> None: + """Test that we can catch all validation errors with PyValidationError.""" + try: + raise InvalidAccountNameError("bad") + except PyValidationError: + pass # Expected + + def test_can_catch_all_py_errors(self) -> None: + """Test that we can catch all PY errors with PyError.""" + try: + raise InvalidAccountNameError("bad") + except PyError: + pass # Expected + + +class TestInvalidAccountNameError: + def test_basic_message(self) -> None: + """Test that exception message contains account name.""" + exc = InvalidAccountNameError("bad_name") + assert "bad_name" in str(exc) + + def test_with_description(self) -> None: + """Test that exception message contains description when provided.""" + exc = InvalidAccountNameError("bad_name", "Name is too long") + assert "bad_name" in str(exc) + assert "Name is too long" in str(exc) + + def test_attributes_accessible(self) -> None: + """Test that account_name and description attributes are accessible.""" + exc = InvalidAccountNameError("test_account", "some reason") + assert exc.account_name == "test_account" + assert exc.description == "some reason" + + def test_description_none_by_default(self) -> None: + """Test that description is None when not provided.""" + exc = InvalidAccountNameError("test_account") + assert exc.description is None + + +class TestInvalidPageNumberError: + def test_message_contains_page_number(self) -> None: + """Test that exception message contains page number.""" + exc = InvalidPageNumberError(-1, 0, 100000) + assert "-1" in str(exc) + + +class TestInvalidPageSizeError: + def test_message_contains_page_size(self) -> None: + """Test that exception message contains page size.""" + exc = InvalidPageSizeError(0, 1, 1000) + assert "0" in str(exc) diff --git a/tests/functional/py/test_validators.py b/tests/functional/py/test_validators.py new file mode 100644 index 0000000000..05341ebef3 --- /dev/null +++ b/tests/functional/py/test_validators.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import pytest + +from clive.__private.py.exceptions import ( + InvalidNumberOfKeyPairsError, + InvalidPageNumberError, + InvalidPageSizeError, +) +from clive.__private.py.validators import ( + AccountNameValidator, + KeyPairsNumberValidator, + PageNumberValidator, + PageSizeValidator, +) + + +class TestAccountNameValidator: + def test_valid_account_name(self) -> None: + """Test that valid account names pass validation.""" + validator = AccountNameValidator() + validator.validate("alice") # Should not raise + + def test_invalid_type_raises_type_error(self) -> None: + """Test that non-string input raises TypeError.""" + validator = AccountNameValidator() + with pytest.raises(TypeError, match="Expected str"): + validator.validate(123) + + def test_none_raises_type_error(self) -> None: + """Test that None input raises TypeError.""" + validator = AccountNameValidator() + with pytest.raises(TypeError, match="Expected str"): + validator.validate(None) + + +class TestPageNumberValidator: + def test_valid_page_number(self) -> None: + """Test that valid page numbers pass validation.""" + validator = PageNumberValidator() + validator.validate(0) + validator.validate(100) + validator.validate(100000) + + def test_bool_rejected(self) -> None: + """Test that bool is rejected even though isinstance(True, int) is True.""" + validator = PageNumberValidator() + with pytest.raises(TypeError, match="Expected int"): + validator.validate(True) # noqa: FBT003 + with pytest.raises(TypeError, match="Expected int"): + validator.validate(False) # noqa: FBT003 + + def test_negative_raises_error(self) -> None: + """Test that negative page numbers raise InvalidPageNumberError.""" + validator = PageNumberValidator() + with pytest.raises(InvalidPageNumberError): + validator.validate(-1) + + def test_max_exceeded_raises_error(self) -> None: + """Test that exceeding max page number raises InvalidPageNumberError.""" + validator = PageNumberValidator() + with pytest.raises(InvalidPageNumberError): + validator.validate(100001) + + def test_string_raises_type_error(self) -> None: + """Test that string input raises TypeError.""" + validator = PageNumberValidator() + with pytest.raises(TypeError, match="Expected int"): + validator.validate("1") + + +class TestPageSizeValidator: + def test_valid_page_size(self) -> None: + """Test that valid page sizes pass validation.""" + validator = PageSizeValidator() + validator.validate(1) + validator.validate(100) + validator.validate(1000) + + def test_bool_rejected(self) -> None: + """Test that bool is rejected.""" + validator = PageSizeValidator() + with pytest.raises(TypeError, match="Expected int"): + validator.validate(True) # noqa: FBT003 + + def test_zero_raises_error(self) -> None: + """Test that zero page size raises InvalidPageSizeError.""" + validator = PageSizeValidator() + with pytest.raises(InvalidPageSizeError): + validator.validate(0) + + def test_max_exceeded_raises_error(self) -> None: + """Test that exceeding max page size raises InvalidPageSizeError.""" + validator = PageSizeValidator() + with pytest.raises(InvalidPageSizeError): + validator.validate(1001) + + +class TestKeyPairsNumberValidator: + def test_valid_number(self) -> None: + """Test that valid numbers pass validation.""" + validator = KeyPairsNumberValidator() + validator.validate(1) + validator.validate(10) + validator.validate(100) + + def test_bool_rejected(self) -> None: + """Test that bool is rejected.""" + validator = KeyPairsNumberValidator() + with pytest.raises(TypeError, match="Expected int"): + validator.validate(True) # noqa: FBT003 + + def test_zero_raises_error(self) -> None: + """Test that zero raises error.""" + validator = KeyPairsNumberValidator() + with pytest.raises(InvalidNumberOfKeyPairsError): + validator.validate(0) -- GitLab