diff --git a/.claude/commands/lint.md b/.claude/commands/lint.md
new file mode 100644
index 0000000000000000000000000000000000000000..dc7ec8202625c688b1813bd01c405bee41495b7c
--- /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 0000000000000000000000000000000000000000..5368d98e52c039fa6a601cfecedf49e2df1f613d
--- /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 0000000000000000000000000000000000000000..10230b31e84ca9917a4fb5fe629e9fe5b025ab97
--- /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 0000000000000000000000000000000000000000..0ec397fc322fe41ef2ed883c773e5f06b8b88889
--- /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
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000000000000000000000000000000000..707610c7924d480a86aacc6ad7b400868c28500d
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,311 @@
+# 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
+
+## 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
+
+```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
+
+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
+
+# 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
+
+### 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:
+
+- 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)
diff --git a/clive/__private/cli/commands/show/show_accounts.py b/clive/__private/cli/commands/show/show_accounts.py
index 2b5b857957229357d3587d1cd8430ecb80577575..a436976a519be738f84c53b8c38a01f21dea0242 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 8f036715daee32c12c9238043e5dbfd961a85ed6..90a28374d873bb7657315a8fe79131a25bc5fa8c 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 713ead2659ad306848dab9d3d503568e33d2b633..12839f2bd888ff038449ee85ae328c9a4b746ba3 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 c811736da34650dfd520c215f8fda52a441ddf05..426e3af5f135b804b19f41e9743ee75abe7f6695 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 c39783fb45ce70c700954b222f142bc49151437b..33edd8d6fea9f0e0fd6dddd52440e06eb2fbb67e 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)
diff --git a/clive/__private/core/commands/data_retrieval/witnesses_data.py b/clive/__private/core/commands/data_retrieval/witnesses_data.py
index c69d332a06472ddb1cb516a0ab16bd0132730345..0590e63d278448b6edc1682a1d2b46bf73bc0c95 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/__init__.py b/clive/__private/py/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..3352df7fb5f84c0707880acd5f36ba8968d6a299
--- /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/base.py b/clive/__private/py/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..f30db4feceb02dac8a7663339e1771a7751d6cbf
--- /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 0000000000000000000000000000000000000000..90c47ea44c624c7502cbab50a00c27509f8843a5
--- /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/data_classes.py b/clive/__private/py/data_classes.py
new file mode 100644
index 0000000000000000000000000000000000000000..6e2f6489ddd87845a67223d74874078d930e71c4
--- /dev/null
+++ b/clive/__private/py/data_classes.py
@@ -0,0 +1,179 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Literal
+
+if TYPE_CHECKING:
+ from datetime import datetime
+
+ from clive.__private.models.asset import Asset
+
+
+AuthorityType = Literal["owner", "active", "posting"]
+
+
+@dataclass(frozen=True)
+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)"
+ )
+
+ 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(frozen=True)
+class Accounts:
+ """Account tracking information."""
+
+ working_account: str | None
+ 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(frozen=True)
+class Authority:
+ """Authority structure for an account or key."""
+
+ account_or_public_key: str
+ weight: int
+
+
+@dataclass(frozen=True)
+class AuthorityInfo:
+ """Detailed authority information for an account."""
+
+ authority_owner_account_name: str
+ authority_type: AuthorityType
+ weight_threshold: int
+ 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
+ public_key: str
+
+ def __repr__(self) -> str:
+ return f"KeyPair(private_key=, public_key={self.public_key!r})"
+
+ 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: int
+ votes_display: str
+ url: str
+ created: datetime
+ missed_blocks: int
+ last_block: int
+ price_feed: str
+ version: str
+
+ 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: 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
new file mode 100644
index 0000000000000000000000000000000000000000..e3e0b11306645fe2e9279a4db01c29ccc0097493
--- /dev/null
+++ b/clive/__private/py/exceptions.py
@@ -0,0 +1,98 @@
+"""Exceptions for the PY module."""
+
+from __future__ import annotations
+
+
+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(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(PyValidationError):
+ """Raised when the provided account name is invalid."""
+
+ 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):
+ """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(PyValidationError):
+ """Raised when the provided page number is invalid."""
+
+ 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, 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):
+ """Raised when the provided number of key pairs is invalid."""
+
+ def __init__(self, number_of_key_pairs: int, min_size: int) -> None:
+ super().__init__(
+ f"Invalid number of key pairs: {number_of_key_pairs}. "
+ f"Number of key pairs must be greater than or equal to {min_size}."
+ )
+
+
+class 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/__init__.py b/clive/__private/py/foundation/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/clive/__private/py/foundation/base.py b/clive/__private/py/foundation/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..4cde323fa475244b3bd7d924b5b7a9d7b1981af4
--- /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 0000000000000000000000000000000000000000..5d4f51fe1441dadae2bff9774a612bff5871f242
--- /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 0000000000000000000000000000000000000000..f5c854a093ab1c57bb2f3586f5289ddd6cff6c23
--- /dev/null
+++ b/clive/__private/py/foundation/show.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Final
+
+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.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, InvalidAuthorityTypeError
+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]]):
+ 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:
+ await self._ensure_account_exists()
+ 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,
+ )
+
+ 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:
+ 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=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
+ 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:
+ 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(
+ 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 = tuple(
+ Witness(
+ voted=witness.voted,
+ rank=witness.rank,
+ witness_name=witness.name,
+ votes=witness.votes_raw,
+ votes_display=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]):
+ 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
+ self.authority = authority
+
+ 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 = 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,
+ authority_type=self.authority,
+ weight_threshold=account[self.authority].weight_threshold,
+ authorities=authorities,
+ )
diff --git a/clive/__private/py/generate.py b/clive/__private/py/generate.py
new file mode 100644
index 0000000000000000000000000000000000000000..eebc1d3da7517e22200b244aaead4ce5a7fc5b80
--- /dev/null
+++ b/clive/__private/py/generate.py
@@ -0,0 +1,16 @@
+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."""
+
+ 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 0000000000000000000000000000000000000000..b0df1899bcd1c9f6c000109cd6787ddd58c8634f
--- /dev/null
+++ b/clive/__private/py/show.py
@@ -0,0 +1,84 @@
+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."""
+
+ async def profiles(self) -> list[str]:
+ """List all available profiles."""
+ return await ShowProfiles().run()
+
+
+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,
+ 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.
+
+ 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
+ ).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/__private/py/validators.py b/clive/__private/py/validators.py
new file mode 100644
index 0000000000000000000000000000000000000000..c5c29430b3914be64bafd3b97933e57cc822bdcf
--- /dev/null
+++ b/clive/__private/py/validators.py
@@ -0,0 +1,100 @@
+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:
+ 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))
+
+
+class SetPasswordValidator(Validator):
+ def validate(self, value: object) -> None:
+ 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))
+
+
+class AccountNameValidator(Validator):
+ def validate(self, value: object) -> None:
+ 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)
+
+
+class PageNumberValidator(Validator):
+ MIN_NUMBER: Final[int] = 0
+ MAX_NUMBER: Final[int] = 100000
+
+ def validate(self, value: object) -> None:
+ 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 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 isinstance(value, bool) or not isinstance(value, int):
+ raise TypeError(f"Expected int, got {type(value).__name__}: {value!r}")
+ 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 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/clive/__private/settings/_settings.py b/clive/__private/settings/_settings.py
index 553652c6a817c3213f434d453514e06339178274..63a4239ce2781fb657e26548d6eb6d5194e9f49b 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.
diff --git a/clive/py/__init__.py b/clive/py/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f492ad97033e0a09c7742f120fa498cf207ea034
--- /dev/null
+++ b/clive/py/__init__.py
@@ -0,0 +1,92 @@
+"""
+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
+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",
+]
diff --git a/pydoclint-errors-baseline.txt b/pydoclint-errors-baseline.txt
index cde6ffd639fff6aed4180dd9ac642532eece73c2..ed90725943d45cff2c5a76b784b16a7cd4791ef2 100644
--- a/pydoclint-errors-baseline.txt
+++ b/pydoclint-errors-baseline.txt
@@ -340,6 +340,82 @@ 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.)
+ 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/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.
+ 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
diff --git a/tests/functional/py/__init__.py b/tests/functional/py/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/functional/py/show/__init__.py b/tests/functional/py/show/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/functional/py/show/test_show.py b/tests/functional/py/show/test_show.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c5d2d620a4d113c6cd204c7b07e48ba3a1c21ab
--- /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)
diff --git a/tests/functional/py/test_exceptions.py b/tests/functional/py/test_exceptions.py
new file mode 100644
index 0000000000000000000000000000000000000000..f00651dbb5a9dbf18cef33eddc5d064431cd2648
--- /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 0000000000000000000000000000000000000000..05341ebef3bbaa4958025b526110b10e24edc015
--- /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)