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)